From 277a36680462c1e8123a52126ff06937e142dd5b Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 16 Jul 2026 14:45:58 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20NEW:=20card-header/card-footer?= =?UTF-8?q?=20directives=20(retire=20^^^/+++=20pre-parse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parser-portable `card-header` / `card-footer` sub-directives (plus inline `:header:` / `:footer:` options for one-liners) that are hoisted into the card's header/footer slots, replacing the `^^^` / `+++` separators that regex-scanned the card's raw source lines. The card body is now parsed in a single pass, so source lines are attributed correctly (kills the old `TODO set proper lines`): an error inside a card-footer now reports the footer's line, not the card's. `grid-item-card` inherits the same behaviour via `create_card`. The legacy separators remain on by default behind the new `sd_card_legacy_separators` config field (flat `sd_card_legacy_separators`, bool, default True, rebuild=env). They emit a single, suppressible `design.card_legacy` deprecation warning per document. Note the raw-line scan still runs on every card while enabled, so a stray `^^^` / `+++` (e.g. inside a code block) keeps mis-splitting until the separators are disabled with `sd_card_legacy_separators=False`; the single-parse robustness is only fully realised then (default flip planned at 1.0). Both syntaxes produce identical doctrees (asserted by dedicated tests and by the unchanged snippet fixtures). Mixing separators and directives warns (`design.card`, wording points at the flag for incidental separator lines), directives win and merge with no doubled slot wrappers even when nested in a separator chunk; multiple headers/footers merge with a warning; a card-header/card-footer outside a card warns (mirrors tab-item). An empty card-header/card-footer directive now errors ("Content block expected") where an empty legacy chunk produced an empty slot. The private `CardDirective._create_component` helper is renamed to `_create_slot`, with a back-compatible alias kept for one release. Docs switch every card/grid example to the new syntax and gain a "Legacy separator syntax" migration section (rst + myst) with a migrate-then-flip note. --- CHANGELOG.md | 18 + docs/cards.md | 192 ++++++++- docs/conf.py | 4 +- docs/snippets/myst/card-head-foot.txt | 11 +- docs/snippets/myst/card-images.txt | 16 +- docs/snippets/rst/card-head-foot.txt | 12 +- docs/snippets/rst/card-images.txt | 24 +- sphinx_design/cards.py | 509 +++++++++++++++++++--- sphinx_design/config.py | 10 + sphinx_design/grids.py | 5 + tests/test_cards.py | 579 ++++++++++++++++++++++++++ tests/test_misc.py | 3 + 12 files changed, 1298 insertions(+), 85 deletions(-) create mode 100644 tests/test_cards.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f31abd0..43822b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## Unreleased +- ✨ NEW: Card headers and footers now have dedicated, parser-portable + `card-header` / `card-footer` directives (plus inline `:header:` / `:footer:` + options for one-liners), which are hoisted into their slots. The whole card + body is parsed in a single pass, so error messages point at the right line. + The legacy `^^^` / `+++` separators — which scan the card's raw source lines + — remain on by default behind the new `sd_card_legacy_separators` option, + emitting a suppressible `design.card_legacy` deprecation warning once per + document; both syntaxes produce identical doctrees. Note that the single-parse + robustness — correct line numbers, and a stray `^^^` / `+++` (e.g. inside a + code block) no longer mis-splitting the card — is only fully realized once the + legacy separators are disabled with `sd_card_legacy_separators = False` + (planned to become the default at 1.0). One behaviour change: an *empty* + `card-header` / `card-footer` directive now errors (`Content block expected`), + where an empty legacy chunk produced an empty slot. See the cards + documentation for the mechanical migration ({pr}`PRNUM`) +- ♻️ REFACTOR: The private `CardDirective._create_component` helper is renamed + to `_create_slot`; a back-compatible alias is kept for one release for + downstream extensions that call it ({pr}`PRNUM`) - ♻️ IMPROVE: Replace the Sass/Node build with a dependency-free Python CSS generator (`tools/generate_css.py` driven by `style/design.toml` and hand-authored `style/*.css`); `package.json` is gone. The compiled diff --git a/docs/cards.md b/docs/cards.md index 723390c6..69c1df1b 100644 --- a/docs/cards.md +++ b/docs/cards.md @@ -25,16 +25,25 @@ See the [Material Design](https://material.io/components/cards) and [Bootstrap c ```` ````` -All content before the first occurrence of three or more `^^^` is considered as a header, -and all content after the final occurrence of three or more `+++` is considered as a footer: +## Card headers and footers -:::{card} Card Title +Add a header and/or footer to a card with the `card-header` and `card-footer` directives. +They always render in their slots — the header at the top and the footer at the bottom of +the card — regardless of where they appear within the card body (the recommended order is +header, then body, then footer): + +::::{card} Card Title + +:::{card-header} Header -^^^ +::: + Card content -+++ + +:::{card-footer} Footer ::: +:::: `````{dropdown-syntax} @@ -48,26 +57,46 @@ Footer ```` ````` +For a single line of content, the `header` and `footer` options are a convenient +short-hand (they accept inline markup, and compose with the card body): + +:::{card} Card Title +:header: A **header** +:footer: A footer + +Card content +::: + When using cards in grids (see [`grid-item-card`](./grids.md)) footers can be aligned. -::::{grid} 2 -:::{grid-item-card} Card Title +::::::{grid} 2 +:::::{grid-item-card} Card Title + +::::{card-header} Header -^^^ +:::: + Card content -+++ + +::::{card-footer} Footer -::: -:::{grid-item-card} Card Title +:::: +::::: +:::::{grid-item-card} Card Title + +::::{card-header} Header -^^^ +:::: + Longer Card content -+++ + +::::{card-footer} Footer -::: :::: +::::: +:::::: ## Card images @@ -91,22 +120,30 @@ Text :img-top: images/particle_background.jpg :img-alt: your desired alt text +:::{card-header} Header -^^^ +::: + Content -+++ + +:::{card-footer} Footer +::: :::: ::::{grid-item-card} Title :img-bottom: images/particle_background.jpg :img-alt: your desired alt text +:::{card-header} Header -^^^ +::: + Content -+++ + +:::{card-footer} Footer +::: :::: ::::: @@ -288,6 +325,14 @@ link-alt shadow : The size of the shadow below the card: `none`, `sm` (default), `md`, `lg`. +header +: Inline-markup short-hand for a single-line header + (equivalent to a `card-header` directive; the two are mutually exclusive). + +footer +: Inline-markup short-hand for a single-line footer + (equivalent to a `card-footer` directive; the two are mutually exclusive). + class-card : Additional CSS classes for the card container element. @@ -308,3 +353,114 @@ class-img-top class-img-bottom : Additional CSS classes for the bottom image (if present). + +(legacy-separator-syntax)= + +## Legacy separator syntax + +:::{deprecated} 0.8 +Prefer the `card-header` / `card-footer` directives (above). +The `^^^` / `+++` separators are **deprecated**: they scan the card's raw source +lines, so a `^^^` or `+++` line inside nested content (for example a code block) +is mistaken for a separator, and they have no meaning to non-Python MyST tools. +::: + +Historically, a card header and footer were delimited within the body itself: +all content before the first line of three-or-more `^^^` became the header, +and all content after the final line of three-or-more `+++` became the footer. + +::::{card} Card Title +Header +^^^ +Card content ++++ +Footer +:::: + +This syntax is still recognised by default, but emits a deprecation warning +(once per document). The rewrite is mechanical: + +| Legacy separators | Header / footer directives | +| --- | --- | +| `Header` above a `^^^` line | a `card-header` directive | +| `Footer` below a `+++` line | a `card-footer` directive | + +`````{tab-set} +````{tab-item} MyST +```markdown +:::{card} Card Title +Header +^^^ +Body ++++ +Footer +::: +``` +becomes +```markdown +::::{card} Card Title + +:::{card-header} +Header +::: + +Body + +:::{card-footer} +Footer +::: +:::: +``` +```` +````{tab-item} reStructuredText +```rst +.. card:: Card Title + + Header + ^^^ + Body + +++ + Footer +``` +becomes +```rst +.. card:: Card Title + + .. card-header:: + + Header + + Body + + .. card-footer:: + + Footer +``` +```` +````` + +:::{important} Migrate, then flip the flag +While the legacy separators are enabled (the default), the raw-line scan runs +on **every** card, so a stray column-0 `^^^` or `+++` — even inside a code +block, and even in a card that has already migrated to the `card-header` / +`card-footer` directives — is still treated as a separator (mis-splitting the +card and emitting a mixed-syntax warning). The single-parse robustness is only +realized once the separators are turned off. + +The recommended order is therefore **migrate first, then flip the flag**: +convert every card to the directives, then set +`sd_card_legacy_separators = False` in your `conf.py`. +::: + +With `sd_card_legacy_separators = False`, `^^^` / `+++` lines are no longer +scanned — but they do **not** reliably render as literal text, so migrate +before flipping: + +- in MyST, a bare `+++` line is a block break and silently disappears; +- in reStructuredText, a `^^^` / `++++` line can raise a docutils `CRITICAL` + "Unexpected section title or transition" error. + +To keep the legacy syntax on but silence the deprecation warning, add +`"design.card_legacy"` to the Sphinx `suppress_warnings` list. + +**Timeline**: deprecated now (default on) → default off at `1.0` → removed at `2.0`. diff --git a/docs/conf.py b/docs/conf.py index e715cf48..5da580d7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,9 @@ ] # suppresses the warning for builders with no fontawesome support (e.g. man) -suppress_warnings = ["design.fa-build"] +# "design.card_legacy" is suppressed because the cards page intentionally +# demonstrates the deprecated ^^^/+++ separator syntax in its migration section +suppress_warnings = ["design.fa-build", "design.card_legacy"] sd_fontawesome_latex = "fontawesome5" # pdflatex errors on emoji (e.g. in the changelog); xelatex only warns for # missing glyphs. makeindex replaces xindy, which CI TeX images lack. diff --git a/docs/snippets/myst/card-head-foot.txt b/docs/snippets/myst/card-head-foot.txt index f28d7c3e..c166d5e5 100644 --- a/docs/snippets/myst/card-head-foot.txt +++ b/docs/snippets/myst/card-head-foot.txt @@ -1,7 +1,12 @@ -:::{card} Card Title +::::{card} Card Title + +:::{card-header} Header -^^^ +::: + Card content -+++ + +:::{card-footer} Footer ::: +:::: diff --git a/docs/snippets/myst/card-images.txt b/docs/snippets/myst/card-images.txt index b9991c1e..ecf616b3 100644 --- a/docs/snippets/myst/card-images.txt +++ b/docs/snippets/myst/card-images.txt @@ -16,21 +16,29 @@ Text :img-top: images/particle_background.jpg :img-alt: +:::{card-header} Header -^^^ +::: + Content -+++ + +:::{card-footer} Footer +::: :::: ::::{grid-item-card} Title :img-bottom: images/particle_background.jpg +:::{card-header} Header -^^^ +::: + Content -+++ + +:::{card-footer} Footer +::: :::: ::::: diff --git a/docs/snippets/rst/card-head-foot.txt b/docs/snippets/rst/card-head-foot.txt index c9842bcc..cc5dba3e 100644 --- a/docs/snippets/rst/card-head-foot.txt +++ b/docs/snippets/rst/card-head-foot.txt @@ -1,7 +1,11 @@ .. card:: Card Title - Header - ^^^ + .. card-header:: + + Header + Card content - +++ - Footer + + .. card-footer:: + + Footer diff --git a/docs/snippets/rst/card-images.txt b/docs/snippets/rst/card-images.txt index d5292731..baa8a05d 100644 --- a/docs/snippets/rst/card-images.txt +++ b/docs/snippets/rst/card-images.txt @@ -13,17 +13,25 @@ :img-top: images/particle_background.jpg :img-alt: - Header - ^^^ + .. card-header:: + + Header + Content - +++ - Footer + + .. card-footer:: + + Footer .. grid-item-card:: Title :img-bottom: images/particle_background.jpg - Header - ^^^ + .. card-header:: + + Header + Content - +++ - Footer + + .. card-footer:: + + Footer diff --git a/sphinx_design/cards.py b/sphinx_design/cards.py index ec51cfb8..c2988a53 100644 --- a/sphinx_design/cards.py +++ b/sphinx_design/cards.py @@ -1,15 +1,16 @@ +from __future__ import annotations + import re -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple from docutils import nodes from docutils.parsers.rst import directives -from docutils.statemachine import StringList from sphinx import addnodes from sphinx.application import Sphinx from sphinx.util import ws_re -from sphinx.util.docutils import SphinxDirective from sphinx.util.logging import getLogger +from .config import get_sd_config from .shared import ( WARNING_TYPE, PassthroughTextElement, @@ -22,29 +23,48 @@ text_align, ) +if TYPE_CHECKING: + from docutils.statemachine import StringList + from sphinx.util.docutils import SphinxDirective + LOGGER = getLogger(__name__) DIRECTIVE_NAME_CARD = "card" +DIRECTIVE_NAME_CARD_HEADER = "card-header" +DIRECTIVE_NAME_CARD_FOOTER = "card-footer" DIRECTIVE_NAME_CAROUSEL = "card-carousel" -REGEX_HEADER = re.compile(r"^\^{3,}\s*$") -REGEX_FOOTER = re.compile(r"^\+{3,}\s*$") + +#: Node attribute marking a container as a "card content" parse context, +#: so that the ``card-header``/``card-footer`` sub-directives can detect +#: that they are being parsed inside a card (mirrors the ``tab-item`` parent +#: check). It is only ever set on transient/parse containers, and removed +#: before those containers reach the output, so it never leaks into a doctree. +CARD_CONTEXT_KEY = "sd_card_context" + +#: Documentation anchor for the legacy-separator deprecation notice. +LEGACY_MIGRATION_URL = ( + "https://sphinx-design.readthedocs.io/en/latest/cards.html#legacy-separator-syntax" +) def setup_cards(app: Sphinx) -> None: """Setup the card components.""" app.add_directive(DIRECTIVE_NAME_CARD, CardDirective) + app.add_directive(DIRECTIVE_NAME_CARD_HEADER, CardHeaderDirective) + app.add_directive(DIRECTIVE_NAME_CARD_FOOTER, CardFooterDirective) app.add_directive(DIRECTIVE_NAME_CAROUSEL, CardCarouselDirective) -class CardContent(NamedTuple): - """Split card into header (optional), body, footer (optional). +def is_card_context(node: nodes.Node) -> bool: + """Check if a node marks a "card content" parse context. - (offset, content) + :param node: The (parse-parent) node to check. + :return: True if the node is a card content container. """ - - body: tuple[int, StringList] - header: tuple[int, StringList] | None = None - footer: tuple[int, StringList] | None = None + try: + return bool(node.get(CARD_CONTEXT_KEY)) + except AttributeError: + return False class CardDirective(SdDirective): @@ -66,6 +86,10 @@ class CardDirective(SdDirective): "link-type": make_choice(["url", "any", "ref", "doc"]), "link-alt": directives.unchanged, "shadow": make_choice(["none", "sm", "md", "lg"]), + # inline one-liner slots (compose with the body content); + # mutually exclusive with the card-header/card-footer sub-directives + "header": directives.unchanged, + "footer": directives.unchanged, "class-card": directives.class_option, "class-header": directives.class_option, "class-body": directives.class_option, @@ -124,18 +148,24 @@ def create_card( # noqa: PLR0915 ) container.append(image_top) - components = cls.split_content(inst.content, inst.content_offset) + # parse the content into the header/body/footer slots + # (using the card-header/card-footer directives, or -- if enabled and + # present -- the legacy ^^^/+++ separators) + header, body_children, footer = cls.parse_content(inst, options) - if components.header: - container.append( - cls._create_component( - inst, "header", options, components.header[0], components.header[1] - ) - ) + # inline one-liner :header:/:footer: options + header = cls._slot_from_option(inst, options, "header", header) + footer = cls._slot_from_option(inst, options, "footer", footer) - body = cls._create_component( - inst, "body", options, components.body[0], components.body[1] + if header is not None: + container.append(header) + + body = create_component( + "card-body", ["sd-card-body", *options.get("class-body", [])] ) + inst.set_source_info(body) + body.extend(body_children) + cls.add_card_child_classes(body) if arguments: title = create_component( "card-title", @@ -153,12 +183,8 @@ def create_card( # noqa: PLR0915 body.insert(0, title) container.append(body) - if components.footer: - container.append( - cls._create_component( - inst, "footer", options, components.footer[0], components.footer[1] - ) - ) + if footer is not None: + container.append(footer) if "img-bottom" in options: image_bottom = nodes.image( @@ -237,9 +263,299 @@ def get_link_target(target: str, link_type: str) -> str: return target.lower() return target + @classmethod + def parse_content( + cls, inst: SphinxDirective, options: dict + ) -> tuple[nodes.container | None, list[nodes.Node], nodes.container | None]: + """Parse the card content into ``(header, body_children, footer)``. + + The primary (parser-portable) syntax uses the ``card-header`` and + ``card-footer`` sub-directives, which are hoisted out of the parsed + content into their slots. If the legacy ``^^^``/``+++`` separators are + enabled (``sd_card_legacy_separators``) *and* present in the raw + content, the deprecated separator splitter is used instead. + + :param inst: The (card or grid-item-card) directive instance. + :param options: The directive options. + :return: The header slot (or None), the ordered body children, and the + footer slot (or None). + """ + config = get_sd_config(inst.env) + if config.card_legacy_separators and _has_legacy_separators(inst.content): + return cls._parse_legacy(inst, options) + return cls._parse_slots(inst, options) + + @classmethod + def _parse_slots( + cls, inst: SphinxDirective, options: dict + ) -> tuple[nodes.container | None, list[nodes.Node], nodes.container | None]: + """Parse the content once, hoisting card-header/card-footer directives. + + The whole content is parsed into a transient container marked as a card + context; its children are then partitioned into the header slot, the + footer slot, and the (ordered) body children. Because docutils tracks + source lines throughout this single parse, error attribution is correct + for free. + """ + temp = create_component("card-content") + temp[CARD_CONTEXT_KEY] = True + inst.set_source_info(temp) + inst.state.nested_parse(inst.content, inst.content_offset, temp) + header: nodes.container | None = None + footer: nodes.container | None = None + body_children: list[nodes.Node] = [] + for child in temp.children: + if is_component(child, "card-header"): + header = cls._merge_slot(header, child, "header") + elif is_component(child, "card-footer"): + footer = cls._merge_slot(footer, child, "footer") + else: + body_children.append(child) + # compose the card-level class-header/class-footer options onto the slots + _apply_slot_classes(header, options.get("class-header", [])) + _apply_slot_classes(footer, options.get("class-footer", [])) + return header, body_children, footer + + @classmethod + def _merge_slot( + cls, + existing: nodes.container | None, + new: nodes.container, + name: str, + ) -> nodes.container: + """Return the slot to use, warning and merging if one already exists. + + Multiple ``card-header``/``card-footer`` directives in a single card is + a user error; rather than dropping content we merge the later slot's + children into the first, and warn. + """ + if existing is None: + return new + LOGGER.warning( + f"Card has multiple 'card-{name}' directives; " + f"merging them into one [{WARNING_TYPE}.card]", + location=new, + type=WARNING_TYPE, + subtype="card", + ) + existing.extend(new.children) + return existing + + @classmethod + def _slot_from_option( + cls, + inst: SphinxDirective, + options: dict, + name: str, + slot: nodes.container | None, + ) -> nodes.container | None: + """Build a header/footer slot from the inline ``:header:``/``:footer:`` + option, or warn (slot wins) if a slot was already supplied. + + :param inst: The directive instance. + :param options: The directive options. + :param name: The slot name (``header`` or ``footer``). + :param slot: The slot already produced from the content (or None). + :return: The slot to use. + """ + raw = options.get(name) + if not raw: + return slot + if slot is not None: + LOGGER.warning( + f"Card ':{name}:' option ignored: a 'card-{name}' directive " + f"(or '^^^'/'+++' separator) is also present, which takes " + f"precedence [{WARNING_TYPE}.card]", + location=(inst.env.docname, inst.lineno), + type=WARNING_TYPE, + subtype="card", + ) + return slot + option_slot = create_component( + f"card-{name}", [f"sd-card-{name}", *options.get(f"class-{name}", [])] + ) + inst.set_source_info(option_slot) + textnodes, _ = inst.state.inline_text(raw, inst.lineno) + para = nodes.paragraph("", "", *textnodes, classes=["sd-card-text"]) + inst.set_source_info(para) + option_slot.append(para) + return option_slot + + @staticmethod + def add_card_child_classes(node: nodes.Element) -> None: + """Add classes to specific child nodes.""" + # only stamp direct child paragraphs of the component (see #40), + # not paragraphs nested inside admonitions, lists, nested cards, etc. + for para in node.children: + if isinstance(para, nodes.paragraph): + para["classes"] = [*para.get("classes", []), "sd-card-text"] + # for title in node.findall(nodes.title): + # title["classes"] = ([] if "classes" not in title else title["classes"]) + [ + # "sd-card-title" + # ] + + # ------------------------------------------------------------------ + # Legacy ^^^/+++ separator handling. + # + # This whole section supports the deprecated separator syntax, kept behind + # the ``sd_card_legacy_separators`` config flag (default on). It is + # scheduled to become opt-in at v1.0 and be removed at v2.0; the block can + # then be deleted wholesale, leaving the directive-based ``_parse_slots`` + # path to stand alone. + # ------------------------------------------------------------------ + + @classmethod + def _parse_legacy( + cls, inst: SphinxDirective, options: dict + ) -> tuple[nodes.container | None, list[nodes.Node], nodes.container | None]: + """Split the content on the legacy ``^^^``/``+++`` separators. + + A single deprecation notice is emitted per document. If card-header/ + card-footer directives are *also* present (mixed syntax), they win: a + ``design.card`` warning is emitted and they are hoisted into the slots, + overriding the separator-derived ones. + """ + # once-per-document deprecation notice (``temp_data`` is cleared before + # each document is read, giving us document-scoped de-duplication) + if not inst.env.temp_data.get("sd_card_legacy_warned"): + inst.env.temp_data["sd_card_legacy_warned"] = True + LOGGER.warning( + "The '^^^'/'+++' card header/footer separators are deprecated; " + "use the 'card-header'/'card-footer' directives instead " + f"(see {LEGACY_MIGRATION_URL}; silence with " + 'suppress_warnings=["design.card_legacy"] or disable with ' + f"sd_card_legacy_separators=False) [{WARNING_TYPE}.card_legacy]", + location=(inst.env.docname, inst.lineno), + type=WARNING_TYPE, + subtype="card_legacy", + ) + + components = cls.split_content(inst.content, inst.content_offset) + header = ( + cls._create_slot(inst, "header", options, *components.header) + if components.header + else None + ) + body_children = cls._parse_body_chunk(inst, *components.body) + footer = ( + cls._create_slot(inst, "footer", options, *components.footer) + if components.footer + else None + ) + # sub-directives take precedence over separators when both are used + return cls._reconcile_mixed(inst, options, header, body_children, footer) + + @classmethod + def _reconcile_mixed( + cls, + inst: SphinxDirective, + options: dict, + header: nodes.container | None, + body_children: list[nodes.Node], + footer: nodes.container | None, + ) -> tuple[nodes.container | None, list[nodes.Node], nodes.container | None]: + """Hoist any card-header/card-footer directives nested in the legacy + chunks (mixed syntax), so the directives take precedence. + + The directives may be nested in *any* legacy chunk -- the body, or even + inside a separator-derived header/footer slot (which would otherwise + double the ``sd-card-*`` wrapper). A single ``design.card`` warning is + emitted, the directives are removed from wherever they were parsed, and + their children are merged into the matching slot (creating the slot if + the separators did not). + """ + # extract nested directive slots from every parsed location + dir_headers: list[nodes.container] = [] + dir_footers: list[nodes.container] = [] + header = cls._extract_nested_slots(header, dir_headers, dir_footers) + body_children = cls._extract_nested_slots_list( + body_children, dir_headers, dir_footers + ) + footer = cls._extract_nested_slots(footer, dir_headers, dir_footers) + + if not (dir_headers or dir_footers): + return header, body_children, footer + + LOGGER.warning( + "Card mixes '^^^'/'+++' separators with card-header/card-footer " + "directives; the directives take precedence. If a '^^^'/'+++' line " + "is incidental body content (e.g. inside a code block), disable the " + f"legacy separators with sd_card_legacy_separators=False " + f"[{WARNING_TYPE}.card]", + location=(dir_headers or dir_footers)[0], + type=WARNING_TYPE, + subtype="card", + ) + for node in dir_headers: + header = cls._merge_directive_slot(inst, options, "header", header, node) + for node in dir_footers: + footer = cls._merge_directive_slot(inst, options, "footer", footer, node) + return header, body_children, footer + + @staticmethod + def _extract_nested_slots( + slot: nodes.container | None, + dir_headers: list[nodes.container], + dir_footers: list[nodes.container], + ) -> nodes.container | None: + """Pull any nested card-header/card-footer directive components out of a + separator-derived slot, collecting them for later merging. + """ + if slot is None: + return None + slot.children = CardDirective._extract_nested_slots_list( + list(slot.children), dir_headers, dir_footers + ) + return slot + + @staticmethod + def _extract_nested_slots_list( + children: list[nodes.Node], + dir_headers: list[nodes.container], + dir_footers: list[nodes.container], + ) -> list[nodes.Node]: + """Partition a child list, collecting card-header/card-footer directive + components and returning the remaining (non-slot) children. + """ + kept: list[nodes.Node] = [] + for node in children: + if is_component(node, "card-header"): + dir_headers.append(node) + elif is_component(node, "card-footer"): + dir_footers.append(node) + else: + kept.append(node) + return kept + + @classmethod + def _merge_directive_slot( + cls, + inst: SphinxDirective, + options: dict, + name: str, + slot: nodes.container | None, + directive_slot: nodes.container, + ) -> nodes.container: + """Merge a mixed-syntax directive slot into the separator slot. + + If the separators produced no slot of this type, the directive's own + container becomes the slot (with the card-level ``class-*`` option + composed on); otherwise the directive's children are merged into the + existing slot, dropping the directive's wrapper (no doubled wrappers). + """ + if slot is None: + _apply_slot_classes(directive_slot, options.get(f"class-{name}", [])) + return directive_slot + slot.extend(directive_slot.children) + return slot + @staticmethod def split_content(content: StringList, offset: int) -> CardContent: - """Split the content into header, body and footer.""" + """Split the content into header, body and footer. + + .. deprecated:: + Part of the legacy ``^^^``/``+++`` separator syntax. + """ header_index, footer_index, header, footer = None, None, None, None body_offset = offset for index, line in enumerate(content): @@ -263,7 +579,7 @@ def split_content(content: StringList, offset: int) -> CardContent: return CardContent(body, header, footer) @classmethod - def _create_component( + def _create_slot( cls, inst: SphinxDirective, name: str, @@ -271,27 +587,126 @@ def _create_component( offset: int, content: StringList, ) -> nodes.container: - """Create the header, body, or footer.""" - component = create_component( + """Create a header or footer slot from a legacy content chunk.""" + slot = create_component( f"card-{name}", [f"sd-card-{name}", *options.get(f"class-{name}", [])] ) - inst.set_source_info(component) # TODO set proper lines - inst.state.nested_parse(content, offset, component) - cls.add_card_child_classes(component) - return component + inst.set_source_info(slot) + # parse into a discarded, marked temp container (so any nested + # card-header/card-footer directives detect the card context), then + # move the children onto the slot -- the marker never touches `slot`, + # and this is exception-safe (no marker to clean up on `slot` itself) + slot.extend(cls._parse_body_chunk(inst, offset, content)) + cls.add_card_child_classes(slot) + return slot + + #: Back-compatible alias for the pre-``card-header``/``card-footer`` name. + #: Retained for one release for downstream extensions (e.g. + #: sphinx-design-elements) that call it; prefer :meth:`_create_slot`. + # TODO(2.0) remove this alias + _create_component = _create_slot - @staticmethod - def add_card_child_classes(node): - """Add classes to specific child nodes.""" - # only stamp direct child paragraphs of the component (see #40), - # not paragraphs nested inside admonitions, lists, nested cards, etc. - for para in node.children: - if isinstance(para, nodes.paragraph): - para["classes"] = [*para.get("classes", []), "sd-card-text"] - # for title in node.findall(nodes.title): - # title["classes"] = ([] if "classes" not in title else title["classes"]) + [ - # "sd-card-title" - # ] + @classmethod + def _parse_body_chunk( + cls, inst: SphinxDirective, offset: int, content: StringList + ) -> list[nodes.Node]: + """Parse a legacy content chunk into its (unstamped) child nodes. + + Parsing happens inside a transient container marked as a card context + (so nested card-header/card-footer directives detect the card), which + is then discarded -- the marker never reaches the output. + """ + temp = create_component("card-content") + temp[CARD_CONTEXT_KEY] = True + inst.set_source_info(temp) + inst.state.nested_parse(content, offset, temp) + return list(temp.children) + + +class CardContent(NamedTuple): + """Split card into header (optional), body, footer (optional). + + (offset, content) + + .. deprecated:: + Part of the legacy ``^^^``/``+++`` separator syntax. + """ + + body: tuple[int, StringList] + header: tuple[int, StringList] | None = None + footer: tuple[int, StringList] | None = None + + +#: Legacy card header separator (``^^^``); part of the deprecated syntax. +REGEX_HEADER = re.compile(r"^\^{3,}\s*$") +#: Legacy card footer separator (``+++``); part of the deprecated syntax. +REGEX_FOOTER = re.compile(r"^\+{3,}\s*$") + + +def _has_legacy_separators(content: StringList) -> bool: + """Check the raw content for any legacy ``^^^``/``+++`` separator line.""" + return any(REGEX_HEADER.match(line) or REGEX_FOOTER.match(line) for line in content) + + +def _apply_slot_classes(slot: nodes.container | None, extra: list[str]) -> None: + """Compose the card-level ``class-header``/``class-footer`` options onto a + directive-produced slot, keeping the base ``sd-card-*`` class first and + dropping any duplicates (order-preserving). + """ + if slot is None or not extra: + return + seen = set(slot["classes"]) + additions: list[str] = [] + for cls_name in extra: + if cls_name not in seen: + seen.add(cls_name) + additions.append(cls_name) + slot["classes"][1:1] = additions + + +class _CardSlotDirective(SdDirective): + """Base class for the ``card-header`` and ``card-footer`` sub-directives.""" + + has_content = True + option_spec = {"class": directives.class_option} + + #: The slot name (``header`` or ``footer``), set by subclasses. + slot_name: str = "" + + def run_with_defaults(self) -> list[nodes.Node]: + self.assert_has_content() + if not is_card_context(self.state_machine.node): + LOGGER.warning( + f"The parent of a 'card-{self.slot_name}' should be a 'card' " + f"or 'grid-item-card' [{WARNING_TYPE}.card]", + location=(self.env.docname, self.lineno), + type=WARNING_TYPE, + subtype="card", + ) + slot = create_component( + f"card-{self.slot_name}", + [f"sd-card-{self.slot_name}", *self.options.get("class", [])], + ) + self.set_source_info(slot) + self.state.nested_parse(self.content, self.content_offset, slot) + CardDirective.add_card_child_classes(slot) + return [slot] + + +class CardHeaderDirective(_CardSlotDirective): + """The header of a card (rendered in the header slot regardless of + position within the card body). + """ + + slot_name = "header" + + +class CardFooterDirective(_CardSlotDirective): + """The footer of a card (rendered in the footer slot regardless of + position within the card body). + """ + + slot_name = "footer" class CardCarouselDirective(SdDirective): diff --git a/sphinx_design/config.py b/sphinx_design/config.py index 7522104c..7d9276d0 100644 --- a/sphinx_design/config.py +++ b/sphinx_design/config.py @@ -262,6 +262,16 @@ class SdConfig: ), }, ) + card_legacy_separators: bool = dc.field( + default=True, + metadata={ + "validator": instance_of(bool), + "help": ( + "Honour the legacy '^^^'/'+++' card header/footer separators " + "(deprecated; prefer the 'card-header'/'card-footer' directives)" + ), + }, + ) def __post_init__(self) -> None: validate_fields(self) diff --git a/sphinx_design/grids.py b/sphinx_design/grids.py index c0325c0c..5bd9e723 100644 --- a/sphinx_design/grids.py +++ b/sphinx_design/grids.py @@ -229,6 +229,9 @@ class GridItemCardDirective(SdDirective): "link-type": make_choice(["url", "any", "ref", "doc"]), "link-alt": directives.unchanged, "shadow": make_choice(["none", "sm", "md", "lg"]), + # inline one-liner slots (see CardDirective) + "header": directives.unchanged, + "footer": directives.unchanged, "class-card": directives.class_option, "class-body": directives.class_option, "class-title": directives.class_option, @@ -272,6 +275,8 @@ def run_with_defaults(self) -> list[nodes.Node]: "link-type", "link-alt", "shadow", + "header", + "footer", "class-card", "class-body", "class-title", diff --git a/tests/test_cards.py b/tests/test_cards.py new file mode 100644 index 00000000..9d3be3e0 --- /dev/null +++ b/tests/test_cards.py @@ -0,0 +1,579 @@ +"""Tests for the card header/footer sub-directives and the legacy separators. + +These exercise the parser-portable ``card-header``/``card-footer`` directive +syntax, the deprecated ``^^^``/``+++`` separator fallback (gated on +``sd_card_legacy_separators``), and prove the two produce identical doctrees. +""" + +from docutils import nodes +import pytest + +from sphinx_design.shared import is_component + +try: + import myst_parser # noqa: F401 + + MYST_INSTALLED = True +except ImportError: + MYST_INSTALLED = False + +MYST_PARAM = pytest.param( + "myst", + marks=pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed"), +) + + +def _rst_builder(sphinx_builder, **conf): + builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"], **conf}) + return builder + + +def _cards(doctree): + return list(doctree.findall(lambda n: is_component(n, "card"))) + + +# --------------------------------------------------------------------------- +# Doctree identity: legacy separators vs. header/footer directives +# --------------------------------------------------------------------------- + +IDENTITY_RST = """ +Heading +======= + +.. card:: Card Title + + .. card-header:: + + Header *emph* + + Card content + + .. card-footer:: + + Footer + +.. card:: Card Title + + Header *emph* + ^^^ + Card content + +++ + Footer +""" + +IDENTITY_MYST = """ +# Heading + +::::{card} Card Title + +:::{card-header} +Header *emph* +::: + +Card content + +:::{card-footer} +Footer +::: +:::: + +:::{card} Card Title +Header *emph* +^^^ +Card content ++++ +Footer +::: +""" + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_card_new_and_legacy_identical(fmt, sphinx_builder): + """The directive syntax and the legacy separators must produce an + identical card doctree for equivalent input. + """ + if fmt == "rst": + builder = _rst_builder(sphinx_builder, suppress_warnings=["design.card_legacy"]) + builder.src_path.joinpath("index.rst").write_text(IDENTITY_RST, encoding="utf8") + else: + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["myst_parser", "sphinx_design"], + "myst_enable_extensions": ["colon_fence"], + "suppress_warnings": ["design.card_legacy"], + } + ) + builder.src_path.joinpath("index.md").write_text(IDENTITY_MYST, encoding="utf8") + builder.build() # asserts no (unsuppressed) warnings + cards = _cards(builder.get_doctree("index")) + assert len(cards) == 2 + new, legacy = cards + assert new.pformat() == legacy.pformat() + + +def test_grid_item_card_new_and_legacy_identical(sphinx_builder): + """``grid-item-card`` gets the same treatment as ``card``.""" + builder = _rst_builder(sphinx_builder, suppress_warnings=["design.card_legacy"]) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. grid:: 2 + + .. grid-item-card:: Title + + .. card-header:: + + Header + + Body + + .. card-footer:: + + Footer + + .. grid-item-card:: Title + + Header + ^^^ + Body + +++ + Footer +""", + encoding="utf8", + ) + builder.build() + cards = _cards(builder.get_doctree("index")) + assert len(cards) == 2 + assert cards[0].pformat() == cards[1].pformat() + + +# --------------------------------------------------------------------------- +# Legacy deprecation warning +# --------------------------------------------------------------------------- + + +def test_card_legacy_warns_once_per_document(sphinx_builder): + """The legacy separators emit exactly one ``design.card_legacy`` warning + per document, regardless of how many cards use them. + """ + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: + + A + ^^^ + B + +.. card:: + + C + +++ + D +""", + encoding="utf8", + ) + builder.build(assert_pass=False) + # exactly one deprecation notice, however many cards use the separators + # (count the unique message body, since Sphinx may also append the subtype) + assert builder.warnings.count("card header/footer separators are deprecated") == 1 + assert "[design.card_legacy]" in builder.warnings + + +def test_card_legacy_warning_suppressible(sphinx_builder): + """The deprecation warning is suppressible via ``suppress_warnings``.""" + builder = _rst_builder(sphinx_builder, suppress_warnings=["design.card_legacy"]) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: + + A + ^^^ + B + +++ + C +""", + encoding="utf8", + ) + builder.build() # asserts no warnings + doctree = builder.get_doctree("index") + assert list(doctree.findall(lambda n: is_component(n, "card-header"))) + assert list(doctree.findall(lambda n: is_component(n, "card-footer"))) + + +def test_card_legacy_disabled(sphinx_builder): + """With the flag off, separators are ordinary rST and no slots are made.""" + builder = _rst_builder(sphinx_builder, sd_card_legacy_separators=False) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: + + .. code-block:: text + + first + +++ + second +""", + encoding="utf8", + ) + builder.build() # no deprecation warning, no mis-split + doctree = builder.get_doctree("index") + # the killer regression: the ``+++`` inside the code-block survives intact + literals = list(doctree.findall(nodes.literal_block)) + assert len(literals) == 1 + assert "+++" in literals[0].astext() + assert not list(doctree.findall(lambda n: is_component(n, "card-footer"))) + assert "[design.card_legacy]" not in builder.warnings + + +# --------------------------------------------------------------------------- +# Directive-syntax behaviours +# --------------------------------------------------------------------------- + + +def test_card_header_footer_out_of_order(sphinx_builder): + """A footer directive before the body still renders in the footer slot.""" + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + + .. card-footer:: + + Footer + + Body + + .. card-header:: + + Header +""", + encoding="utf8", + ) + builder.build() # no warnings + card = _cards(builder.get_doctree("index"))[0] + # slots are assembled in header, body, footer order regardless of source + kinds = [child.get("design_component") for child in card.children] + assert kinds == ["card-header", "card-body", "card-footer"] + + +def test_card_header_only(sphinx_builder): + """A card with only a header directive (no footer).""" + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + + .. card-header:: + + Just a header + + Body +""", + encoding="utf8", + ) + builder.build() # no warnings + card = _cards(builder.get_doctree("index"))[0] + kinds = [child.get("design_component") for child in card.children] + assert kinds == ["card-header", "card-body"] + + +def test_card_footer_only(sphinx_builder): + """A card with only a footer directive (no header).""" + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + + Body + + .. card-footer:: + + Just a footer +""", + encoding="utf8", + ) + builder.build() # no warnings + card = _cards(builder.get_doctree("index"))[0] + kinds = [child.get("design_component") for child in card.children] + assert kinds == ["card-body", "card-footer"] + + +def test_card_slot_class_option(sphinx_builder): + """The sub-directives' ``:class:`` option, and the card's ``class-header`` / + ``class-footer`` options, both land on the slot. + """ + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + :class-header: from-card + + .. card-header:: + :class: from-directive + + Header + + Body +""", + encoding="utf8", + ) + builder.build() # no warnings + card = _cards(builder.get_doctree("index"))[0] + header = next(card.findall(lambda n: is_component(n, "card-header"))) + assert header["classes"] == ["sd-card-header", "from-card", "from-directive"] + + +def test_card_multiple_headers_warn_and_merge(sphinx_builder): + """Multiple header directives warn and are merged into one slot.""" + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + + .. card-header:: + + First + + .. card-header:: + + Second + + Body +""", + encoding="utf8", + ) + builder.build(assert_pass=False) + assert "multiple 'card-header'" in builder.warnings + assert "[design.card]" in builder.warnings + card = _cards(builder.get_doctree("index"))[0] + headers = list(card.findall(lambda n: is_component(n, "card-header"))) + assert len(headers) == 1 + assert "First" in headers[0].astext() + assert "Second" in headers[0].astext() + + +def test_card_header_outside_card_warns(sphinx_builder): + """A ``card-header`` outside a card warns (mirrors the tab-item check).""" + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card-header:: + + orphan +""", + encoding="utf8", + ) + builder.build(assert_pass=False) + assert "parent of a 'card-header' should be a 'card'" in builder.warnings + assert "[design.card]" in builder.warnings + + +def test_card_mixed_syntax_warns(sphinx_builder): + """Using both separators and directives warns; directives win.""" + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + + .. card-header:: + + Directive header + + Body + +++ + Separator footer +""", + encoding="utf8", + ) + builder.build(assert_pass=False) + assert ( + "mixes '^^^'/'+++' separators with card-header/card-footer" in builder.warnings + ) + assert "sd_card_legacy_separators=False" in builder.warnings + card = _cards(builder.get_doctree("index"))[0] + headers = list(card.findall(lambda n: is_component(n, "card-header"))) + assert len(headers) == 1 + assert "Directive header" in headers[0].astext() + + +def test_card_directive_nested_in_legacy_chunk(sphinx_builder): + """A card-header/card-footer directive nested inside a separator chunk is + hoisted (warned + merged), leaving no doubled ``sd-card-*`` wrapper. + """ + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: footer directive in footer chunk + + Body + +++ + .. card-footer:: + + real footer + +.. card:: header directive in header chunk + + .. card-header:: + + real header + + ^^^ + Body two +""", + encoding="utf8", + ) + builder.build(assert_pass=False) + assert "mixes '^^^'/'+++' separators" in builder.warnings + cards = _cards(builder.get_doctree("index")) + assert len(cards) == 2 + # exactly one footer/header slot per card -- no slot-in-slot doubling + footers = list(cards[0].findall(lambda n: is_component(n, "card-footer"))) + assert len(footers) == 1 + assert "real footer" in footers[0].astext() + headers = list(cards[1].findall(lambda n: is_component(n, "card-header"))) + assert len(headers) == 1 + assert "real header" in headers[0].astext() + + +def test_card_empty_slot_directive_errors(sphinx_builder): + """An empty card-header/card-footer directive errors (behaviour change vs + an empty legacy chunk, which produced an empty slot). + """ + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + + .. card-header:: + + Body +""", + encoding="utf8", + ) + builder.build(assert_pass=False) + assert "Content block expected for the" in builder.warnings + + +def test_card_header_footer_options(sphinx_builder): + """The inline ``:header:``/``:footer:`` options build the slots.""" + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + :header: Inline **header** + :footer: Inline footer + + Body +""", + encoding="utf8", + ) + builder.build() # no warnings + card = _cards(builder.get_doctree("index"))[0] + header = next(card.findall(lambda n: is_component(n, "card-header"))) + footer = next(card.findall(lambda n: is_component(n, "card-footer"))) + assert "header" in header.astext() + # inline markup is parsed (the emphasis produces a strong node) + assert list(header.findall(nodes.strong)) + assert "Inline footer" in footer.astext() + + +def test_card_option_and_directive_conflict_warns(sphinx_builder): + """A ``:header:`` option and a ``card-header`` directive conflict warns; + the directive wins. + """ + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text( + """ +Heading +======= + +.. card:: Title + :header: Option header + + .. card-header:: + + Directive header + + Body +""", + encoding="utf8", + ) + builder.build(assert_pass=False) + assert "':header:' option ignored" in builder.warnings + card = _cards(builder.get_doctree("index"))[0] + header = next(card.findall(lambda n: is_component(n, "card-header"))) + assert "Directive header" in header.astext() + assert "Option header" not in header.astext() + + +def test_card_footer_line_attribution(sphinx_builder): + """An error inside a card-footer reports the footer's line, not the card's. + + This is the payoff of the single-parse rework (killing the old + ``TODO set proper lines``). + """ + lines = [ + "Heading", + "=======", + "", + ".. card:: Title", # card starts on this line (index 3 -> line 4) + "", + " Body", + "", + " .. card-footer::", + "", + " .. nonexistent-directive::", # the error line + "", + " boom", + "", + ] + card_line = lines.index(".. card:: Title") + 1 + error_line = lines.index(" .. nonexistent-directive::") + 1 + builder = _rst_builder(sphinx_builder) + builder.src_path.joinpath("index.rst").write_text("\n".join(lines), encoding="utf8") + builder.build(assert_pass=False) + assert "Unknown directive type" in builder.warnings + assert f"index.rst:{error_line}:" in builder.warnings + assert f"index.rst:{card_line}:" not in builder.warnings diff --git a/tests/test_misc.py b/tests/test_misc.py index dc651e50..bc58f784 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -511,6 +511,7 @@ def test_button_i18n_translated(sphinx_builder): "fontawesome_version": ("7", "must be one of"), "fontawesome_latex": ("bad-mode", "must be a bool or one of"), "tabs_storage_prefix": (123, "must be of type"), + "card_legacy_separators": (123, "must be of type"), } """An invalidly typed value (and expected warning) for every ``SdConfig`` field.""" @@ -621,6 +622,7 @@ def test_config_toml_round_trip(): fontawesome_version = "6" fontawesome_latex = true tabs_storage_prefix = "sphinx-design-tab-id-" + card_legacy_separators = false [custom_directives.dropdown-syntax] inherit = "dropdown" @@ -640,6 +642,7 @@ def test_config_toml_round_trip(): assert config.fontawesome_version == "6" assert config.fontawesome_latex is True assert config.tabs_storage_prefix == "sphinx-design-tab-id-" + assert config.card_legacy_separators is False assert config.fontawesome_latex_mode == "fontawesome" assert config.custom_directives == { "dropdown-syntax": { From 754acef08a927a6ff1987cba24b2efa74167c52e Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 16 Jul 2026 15:22:11 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9A=20Fill=20changelog=20PR=20numb?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43822b39..9f999100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,10 @@ (planned to become the default at 1.0). One behaviour change: an *empty* `card-header` / `card-footer` directive now errors (`Content block expected`), where an empty legacy chunk produced an empty slot. See the cards - documentation for the mechanical migration ({pr}`PRNUM`) + documentation for the mechanical migration ({pr}`294`) - ♻️ REFACTOR: The private `CardDirective._create_component` helper is renamed to `_create_slot`; a back-compatible alias is kept for one release for - downstream extensions that call it ({pr}`PRNUM`) + downstream extensions that call it ({pr}`294`) - ♻️ IMPROVE: Replace the Sass/Node build with a dependency-free Python CSS generator (`tools/generate_css.py` driven by `style/design.toml` and hand-authored `style/*.css`); `package.json` is gone. The compiled From 1ca2fec6e0ec4283a0e410bf150e1ff29d543f65 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 16 Jul 2026 22:00:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9A=20Truthful=20mixed-syntax=20wa?= =?UTF-8?q?rning:=20content=20is=20merged,=20not=20replaced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sphinx_design/cards.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sphinx_design/cards.py b/sphinx_design/cards.py index c2988a53..3d6f06fc 100644 --- a/sphinx_design/cards.py +++ b/sphinx_design/cards.py @@ -455,7 +455,8 @@ def _reconcile_mixed( footer: nodes.container | None, ) -> tuple[nodes.container | None, list[nodes.Node], nodes.container | None]: """Hoist any card-header/card-footer directives nested in the legacy - chunks (mixed syntax), so the directives take precedence. + chunks (mixed syntax); directive slot content is merged into the + corresponding slot, after any separator-produced content. The directives may be nested in *any* legacy chunk -- the body, or even inside a separator-derived header/footer slot (which would otherwise @@ -478,7 +479,8 @@ def _reconcile_mixed( LOGGER.warning( "Card mixes '^^^'/'+++' separators with card-header/card-footer " - "directives; the directives take precedence. If a '^^^'/'+++' line " + "directives; the directive content is merged into the slots (after " + "any separator content). If a '^^^'/'+++' line " "is incidental body content (e.g. inside a code block), disable the " f"legacy separators with sd_card_legacy_separators=False " f"[{WARNING_TYPE}.card]",