diff --git a/CHANGELOG.md b/CHANGELOG.md index cf39163..aea7670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +### Fixed + +- **`cf_ui_head(theme="daisy")` shipped half of daisyUI's own documented CDN + recipe, and that half silently drops every layout utility (#56).** DaisyUI + is a Tailwind *plugin* — its CDN stylesheet is the component layer only + (`.btn{`, `.card{`), never the utility layer (`.flex{`, `.w-full{`, + `.gap-4{`) that the shipped daisy templates depend on for layout. A + consumer following the quickstart with `CF_UI_THEME = "daisy"` got buttons + and cards that looked right sitting in a layout that did not work, with no + error to point at the cause. daisyUI's own CDN docs + () pair the stylesheet with Tailwind's + Play CDN script for exactly this reason; cf-ui was shipping only the first + tag. `cf_ui_head` / the `cf_ui_head` Jinja macro now emit both, in the + vendor's order, gated by a new `CF_UI_DAISY_CDN` setting (`"play"` default, + `"off"` for a consumer with a real Tailwind build supplying both layers + itself). An invalid value now fails at Django startup, matching + `CF_UI_THEME` and `CF_UI_COMPOSITION`. The other four themes are + unaffected — this only ever touched the daisy branch of `cf_ui_head`. See + [DaisyUI](docs/daisyui.md) for the full recipe and why `"play"` is the + default rather than `"off"`. + ### Added — a primitives layer: button, badge, heading, label, icon (#52) - **Five new components, on all five themes, in both template sets.** diff --git a/docs/daisyui.md b/docs/daisyui.md index c9eb5d7..9aad081 100644 --- a/docs/daisyui.md +++ b/docs/daisyui.md @@ -6,6 +6,72 @@ instead of linking a finished stylesheet. That changes two things — what Tailwind has to scan, and what Tailwind's preflight does to whatever styling you already had. +## The CDN path needs two tags, not one + +DaisyUI is a Tailwind *plugin* — its CDN bundle +(`daisyui@{version}/dist/full.min.css`) is the component layer only. It has +`.btn{` and `.card{`, but no `.flex{`, `.w-full{`, `.gap-4{`, or any other +Tailwind utility, because utilities are the host framework's job and a plugin +bundle does not carry them. The shipped daisy templates lean on exactly those +utilities for layout, so the stylesheet alone renders styled buttons and cards +sitting in a broken layout — no error, no console warning, just a page that +looks wrong in a way that does not point at the cause. + +DaisyUI's own CDN documentation () +prescribes two tags, in this order: + +```html + + +``` + +The second tag is Tailwind's **Play CDN** — a real Tailwind build that +compiles utility classes in the browser, at request time. Upstream is explicit +that this is "for development purposes only, and not intended for +production." + +`{% cf_ui_head %}` / `cf_ui_head()` now emits that same pair for you, gated by +one switch. + +### `CF_UI_DAISY_CDN` + +```python +# settings.py (Django) +CF_UI_DAISY_CDN = "play" # default — or "off" +``` + +```jinja +{# Jinja2 / JinjaX #} +{{ cf_ui_head(theme="daisy", daisy_cdn="play") }} +``` + +| Value | What `cf_ui_head` emits | When to use it | +|---|---|---| +| `"play"` (default) | An explanatory HTML comment, then the daisyUI stylesheet ``, then the Tailwind Play CDN ` {%- endif %} diff --git a/src/cf_ui/templatetags/cf_ui.py b/src/cf_ui/templatetags/cf_ui.py index 1931b7f..34e2f27 100644 --- a/src/cf_ui/templatetags/cf_ui.py +++ b/src/cf_ui/templatetags/cf_ui.py @@ -6,7 +6,7 @@ from cf_ui.axes import root_attrs, style_element from cf_ui.django import axis_value_sets from cf_ui.primitives import validate as validate_primitive -from cf_ui.themes import cotton_partial +from cf_ui.themes import cotton_partial, resolve_daisy_cdn register = template.Library() @@ -18,6 +18,18 @@ "daisy": "https://cdn.jsdelivr.net/npm/daisyui@{v}/dist/full.min.css", } _ALPINE_CDN = "https://cdn.jsdelivr.net/npm/alpinejs@{v}/dist/cdn.min.js" + +# daisyUI's own CDN recipe (https://v4.daisyui.com/docs/cdn/) is this +# stylesheet paired with Tailwind's Play CDN script, in this order — daisyUI +# is a Tailwind plugin, so the stylesheet alone has no utility layer (#56). +_TAILWIND_PLAY_CDN = '' +_DAISY_PLAY_COMMENT = ( + '' +) _DEFAULTS = { "bulma": "1.0.2", "bootstrap": "5.3.3", @@ -52,7 +64,16 @@ def cf_ui_head() -> str: v = _versions() parts = [] - if theme in _CDN_CSS: + if theme == "daisy": + daisy_cdn = resolve_daisy_cdn(getattr(settings, "CF_UI_DAISY_CDN", None)) + if daisy_cdn == "play": + url = _CDN_CSS["daisy"].format(v=v.get("daisy", "")) + parts.append(_DAISY_PLAY_COMMENT) + parts.append(f'') + parts.append(_TAILWIND_PLAY_CDN) + # "off": a real Tailwind build supplies both the stylesheet and the + # utility layer, so cf-ui emits neither tag. + elif theme in _CDN_CSS: url = _CDN_CSS[theme].format(v=v.get(theme, "")) parts.append(f'') diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py index 22ebec9..971485e 100644 --- a/src/cf_ui/themes.py +++ b/src/cf_ui/themes.py @@ -30,6 +30,17 @@ DEFAULT_THEME = "bulma" +#: daisyUI is a Tailwind *plugin* — its CDN bundle carries only the component +#: layer (``.btn``, ``.card``), never the utility layer (``.flex``, ``.gap-4``, +#: ``.w-full``) that the shipped daisy templates lean on for layout. daisyUI's +#: own CDN docs (https://v4.daisyui.com/docs/cdn/) pair the stylesheet with +#: Tailwind's Play CDN script for exactly this reason. ``"play"`` completes +#: that documented pair; ``"off"`` is for a consumer with a real Tailwind +#: build, who supplies both layers themselves (see docs/daisyui.md). +DAISY_CDN_MODES = ("play", "off") + +DEFAULT_DAISY_CDN = "play" + #: django-cotton file stems, as used by ````. #: #: A name here must have a partial under every theme in :data:`THEMES` — @@ -87,6 +98,22 @@ def resolve_theme(theme: str | None = None) -> str: return theme +def resolve_daisy_cdn(mode: str | None = None) -> str: + """Validate a daisy CDN mode, defaulting to ``"play"``. + + Fails loudly here rather than silently — see :data:`DAISY_CDN_MODES` for + why the two values exist. A bad value is a configuration mistake, so it + is rejected at startup (:mod:`cf_ui.django`) the same way an unknown + ``CF_UI_THEME`` is, not left to surface as a half-styled page. + """ + if not mode: + return DEFAULT_DAISY_CDN + if mode not in DAISY_CDN_MODES: + available = ", ".join(DAISY_CDN_MODES) + raise ThemeError(f"unknown daisy CDN mode {mode!r} — valid values are: {available}") + return mode + + def cotton_partial(component: str, theme: str | None = None) -> str: """Template path of a component's partial for ``theme``.""" if component not in COMPONENTS: diff --git a/tests/integration/jinja_app/main.py b/tests/integration/jinja_app/main.py index 1684cef..003f597 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -1,41 +1,28 @@ from fastapi import FastAPI from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles +from jinja2 import Environment, FileSystemLoader, select_autoescape from jinjax import Catalog from markupsafe import Markup from cf_ui import JINJA_TEMPLATES_DIR from cf_ui.fastapi import install_cf_ui -_CF_UI_STATIC_DIR = JINJA_TEMPLATES_DIR.parent.parent / "static" / "cf_ui" - -_THEME_CSS = { - "bulma": "https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css", - "daisy": "https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.min.css", - "bootstrap": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", - "foundation": ( - "https://cdn.jsdelivr.net/npm/foundation-sites@6.7.5/dist/css/foundation.min.css" - ), - "fomantic": "https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.3/dist/semantic.min.css", -} - -# DaisyUI ships component classes but no Tailwind utilities. The components use -# utilities for layout and responsive behavior (`hidden`, `lg:flex`), so without -# a Tailwind build those classes resolve to nothing and the E2E tier cannot see -# whether a toggle actually changes anything. The play CDN is a real in-browser -# Tailwind JIT, which makes the gallery representative of a consuming app. -_THEME_EXTRA_HEAD = { - "bulma": "", - "daisy": '', - # Bootstrap, Foundation and Fomantic all ship prebuilt CSS, and cf-ui - # deliberately loads none of their JavaScript — Alpine owns modal, tab and - # panel state in every theme, so no bootstrap.bundle.js, no foundation.js, - # and none of Fomantic's jQuery Modal/Tab/Accordion/Dropdown modules. The - # point of each theme is that the pages work without them. - "bootstrap": "", - "foundation": "", - "fomantic": "", -} +_TEMPLATES_ROOT = JINJA_TEMPLATES_DIR.parent # .../cf_ui/templates +_CF_UI_STATIC_DIR = _TEMPLATES_ROOT.parent / "static" / "cf_ui" + +# The gallery's `` is built through the real `cf_ui_head` macro rather +# than a hand-maintained CDN URL table — see #56. It used to hand-roll its +# own `_THEME_CSS` dict plus a `_THEME_EXTRA_HEAD["daisy"]` patch that +# injected the Tailwind Play CDN script cf_ui_head itself failed to emit, +# which meant this E2E tier was never exercising the shipped tag, only a +# workaround for it. Routing through the actual macro is what makes deleting +# that patch a real regression guard instead of a hope. +_assets_env = Environment( + loader=FileSystemLoader(str(_TEMPLATES_ROOT)), + autoescape=select_autoescape(["html", "jinja"]), +) +_assets_module = _assets_env.get_template("cf_ui/assets.jinja").make_module() def make_app(theme: str = "bulma") -> FastAPI: @@ -139,12 +126,13 @@ async def gallery(): _content="Initial content", extra_class="", ) + head_html = _assets_module.cf_ui_head( + theme=theme, cf_axes_url="/static/cf_ui/cf_ui_axes.css" + ) return f""" - {_THEME_EXTRA_HEAD[theme]} - - + {head_html}
diff --git a/tests/unit/test_asset_tags.py b/tests/unit/test_asset_tags.py index 67f8d88..cb61542 100644 --- a/tests/unit/test_asset_tags.py +++ b/tests/unit/test_asset_tags.py @@ -75,13 +75,13 @@ def test_cf_ui_body_cf_alpine_loads_before_alpine(settings): # the agreement is executed here rather than eyeballed when a theme lands. -def _assets_head(theme: str) -> str: +def _assets_head(theme: str, daisy_cdn: str = "play") -> str: env = Environment( loader=FileSystemLoader(TEMPLATES_DIR), autoescape=select_autoescape(["html", "jinja"]), ) module = env.get_template("cf_ui/assets.jinja").make_module() - return str(module.cf_ui_head(theme=theme)) + return str(module.cf_ui_head(theme=theme, daisy_cdn=daisy_cdn)) @pytest.mark.parametrize("theme", ["bulma", "daisy", "bootstrap", "foundation", "fomantic"]) @@ -123,3 +123,140 @@ def test_bootstrap_head_links_the_stylesheet_and_no_bundle(settings): assert "bootstrap.bundle" not in head assert "bootstrap.bundle" not in cf_ui_body() assert "bootstrap.bundle" not in _assets_head("bootstrap") + + +# --- daisyUI CDN utilities (#56) -------------------------------------------- +# +# daisyUI is a Tailwind *plugin*: its CDN bundle is the component layer only +# (`.btn{`, `.card{`), never the utility layer (`.flex{`, `.w-full{`, +# `.gap-4{`) the shipped daisy templates lean on for layout. daisyUI's own CDN +# docs (https://v4.daisyui.com/docs/cdn/) pair the stylesheet with Tailwind's +# Play CDN script for exactly that reason — cf-ui shipped the first tag and +# silently dropped the second. `CF_UI_DAISY_CDN` ("play"/"off") controls it; +# "play" (the default) completes the documented pair, "off" is for a consumer +# with a real Tailwind build supplying both layers itself. + +_SCRIPT_TAG_RE = re.compile(r"]*>") +_TAILWIND_PLAY_SCRIPT = '' + + +def test_daisy_head_in_play_mode_emits_the_tailwind_play_cdn_script(settings): + """daisyUI's own CDN recipe is a two-tag pair; "play" ships both.""" + from cf_ui.templatetags.cf_ui import cf_ui_head + + settings.CF_UI_THEME = "daisy" + settings.CF_UI_DAISY_CDN = "play" + settings.CF_UI_CDN_VERSIONS = {} + + result = cf_ui_head() + assert _TAILWIND_PLAY_SCRIPT in result + + +@pytest.mark.parametrize("theme", ["bulma", "bootstrap", "foundation", "fomantic"]) +def test_non_daisy_themes_emit_no_script_tag_from_cf_ui_head(settings, theme): + """The Play CDN script is a daisy-only concern — the other four themes + ship self-contained CSS and must render byte-identically to today.""" + from cf_ui.templatetags.cf_ui import cf_ui_head + + settings.CF_UI_THEME = theme + settings.CF_UI_CDN_VERSIONS = {} + + assert not _SCRIPT_TAG_RE.search(cf_ui_head()) + assert not _SCRIPT_TAG_RE.search(_assets_head(theme)) + + +def test_daisy_head_in_off_mode_emits_neither_stylesheet_nor_script(settings): + """ "off" is for a consumer with a real Tailwind build — cf-ui must not + hand it a stylesheet or script it did not ask for.""" + from cf_ui.templatetags.cf_ui import cf_ui_head + + settings.CF_UI_THEME = "daisy" + settings.CF_UI_DAISY_CDN = "off" + settings.CF_UI_CDN_VERSIONS = {} + + result = cf_ui_head() + assert "daisyui@" not in result + assert "full.min.css" not in result + assert _TAILWIND_PLAY_SCRIPT not in result + assert not _SCRIPT_TAG_RE.search(result) + + +def test_daisy_head_in_off_mode_still_emits_axes_css_and_xcloak_style(settings): + """Turning the CDN off must not turn off cf-ui's own assets.""" + from cf_ui.templatetags.cf_ui import cf_ui_head + + settings.CF_UI_THEME = "daisy" + settings.CF_UI_DAISY_CDN = "off" + settings.CF_UI_CDN_VERSIONS = {} + + result = cf_ui_head() + assert "cf_ui_axes.css" in result + assert "[x-cloak]" in result + assert "display: none" in result + + +def test_daisy_play_mode_carries_the_explanatory_comment(settings): + """The comment is the unmissable signal that this is a dev-only CDN.""" + from cf_ui.templatetags.cf_ui import cf_ui_head + + settings.CF_UI_THEME = "daisy" + settings.CF_UI_DAISY_CDN = "play" + settings.CF_UI_CDN_VERSIONS = {} + + result = cf_ui_head() + assert "