diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f31abd..9f99910 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}`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}`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 diff --git a/docs/cards.md b/docs/cards.md index 723390c..69c1df1 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 e715cf4..5da580d 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 f28d7c3..c166d5e 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 b9991c1..ecf616b 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 c9842bc..cc5dba3 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 d529273..baa8a05 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 ec51cfb..3d6f06f 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,301 @@ 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); 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 + 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 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]", + 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 +581,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 +589,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 7522104..7d9276d 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 c0325c0..5bd9e72 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 0000000..9d3be3e --- /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 dc651e5..bc58f78 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": {