From 337117b753632e2d2159a2c080d1fb2bdececb3b Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 28 May 2026 17:06:24 +0200 Subject: [PATCH 1/3] Extend reflex docgen --- docs/app/reflex.lock/bun.lock | 7 - docs/app/reflex.lock/package.json | 1 - .../src/reflex_docgen/markdown/__init__.py | 30 +++ .../src/reflex_docgen/markdown/_parser.py | 2 + .../src/reflex_docgen/markdown/_types.py | 17 +- .../markdown/transformer/__init__.py | 36 +++ .../markdown/transformer/_markdown.py | 27 +- .../markdown/transformer/_reflex.py | 247 ++++++++++++++++++ tests/units/docgen/test_markdown.py | 67 +++++ tests/units/docgen/test_reflex_transformer.py | 224 ++++++++++++++++ 10 files changed, 640 insertions(+), 18 deletions(-) create mode 100644 packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_reflex.py create mode 100644 tests/units/docgen/test_reflex_transformer.py diff --git a/docs/app/reflex.lock/bun.lock b/docs/app/reflex.lock/bun.lock index 0606ca10649..c5c50b701f5 100644 --- a/docs/app/reflex.lock/bun.lock +++ b/docs/app/reflex.lock/bun.lock @@ -43,7 +43,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "19.2.6", - "react-dropzone": "15.0.0", "react-fast-marquee": "1.6.5", "react-helmet": "6.1.0", "react-leaflet": "5.0.0", @@ -864,8 +863,6 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], - "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -1180,8 +1177,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], - "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -1746,8 +1741,6 @@ "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], - "react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="], - "react-easy-swipe": ["react-easy-swipe@0.0.21", "", { "dependencies": { "prop-types": "^15.5.8" } }, "sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg=="], "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], diff --git a/docs/app/reflex.lock/package.json b/docs/app/reflex.lock/package.json index 4988a1c737b..c510989b352 100644 --- a/docs/app/reflex.lock/package.json +++ b/docs/app/reflex.lock/package.json @@ -44,7 +44,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "19.2.6", - "react-dropzone": "15.0.0", "react-fast-marquee": "1.6.5", "react-helmet": "6.1.0", "react-leaflet": "5.0.0", diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/__init__.py b/packages/reflex-docgen/src/reflex_docgen/markdown/__init__.py index f113841b683..903714f52bd 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/__init__.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/__init__.py @@ -1,5 +1,9 @@ """Markdown parsing and types for Reflex documentation.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from reflex_docgen.markdown import transformer as transformer from reflex_docgen.markdown._parser import parse_document as parse_document from reflex_docgen.markdown._types import Block as Block @@ -27,6 +31,11 @@ from reflex_docgen.markdown._types import TextSpan as TextSpan from reflex_docgen.markdown._types import ThematicBreakBlock as ThematicBreakBlock +if TYPE_CHECKING: + from reflex_docgen.markdown.transformer import ( + ReflexComponentTransformer as ReflexComponentTransformer, + ) + __all__ = [ "Block", "BoldSpan", @@ -44,6 +53,7 @@ "ListBlock", "ListItem", "QuoteBlock", + "ReflexComponentTransformer", "Span", "StrikethroughSpan", "TableBlock", @@ -55,3 +65,23 @@ "parse_document", "transformer", ] + + +def __getattr__(name: str) -> object: + """Lazily re-export the reflex-dependent transformer on first access. + + Args: + name: The attribute being accessed. + + Returns: + The resolved attribute. + + Raises: + AttributeError: If the attribute does not exist. + """ + if name == "ReflexComponentTransformer": + from reflex_docgen.markdown.transformer import ReflexComponentTransformer + + return ReflexComponentTransformer + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py b/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py index d9f1a782ced..0cfc6a836e7 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py @@ -91,6 +91,7 @@ def _extract_frontmatter(source: str) -> tuple[FrontMatter | None, str]: only_low_level=only_low_level, title=title, component_previews=tuple(previews), + metadata=data, ), source[m.end() :], ) @@ -254,6 +255,7 @@ def _convert_block(token: BlockToken) -> Block | None: name=flags[0], args=flags[1:], children=_parse_blocks(content), + content=content, ) return CodeBlock(language=language, flags=flags, content=content) diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py b/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py index d54b786a2f2..8cccec0e6a9 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py @@ -2,7 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Mapping +from dataclasses import dataclass, field # --------------------------------------------------------------------------- # Span types — inline content without exposing mistletoe @@ -141,12 +142,16 @@ class FrontMatter: only_low_level: Whether to show only low-level component variants. title: An optional page title. component_previews: Preview lambdas keyed by component class name. + metadata: The full raw frontmatter mapping, including the keys modeled + by the fields above plus any arbitrary keys a site defines (e.g. + ``author``, ``tags``, ``order``). """ components: tuple[str, ...] only_low_level: bool title: str | None component_previews: tuple[ComponentPreview, ...] + metadata: Mapping[str, object] = field(default_factory=dict) @dataclass(frozen=True, slots=True, kw_only=True) @@ -174,11 +179,16 @@ class DirectiveBlock: name: The directive name (e.g. "alert", "video", "definition", "section"). args: Additional arguments after the name (e.g. ("info",) or ("https://...",)). children: The parsed block-level content inside the directive. + content: The raw (unparsed) inner text. Directives whose body is + line-oriented rather than markdown (e.g. a ``quote`` block's + ``- name:``/``- role:`` lines) should read this instead of + ``children`` to avoid CommonMark reflowing the lines. """ name: str args: tuple[str, ...] children: tuple[Block, ...] + content: str = "" @dataclass(frozen=True, slots=True, kw_only=True) @@ -310,6 +320,11 @@ class Document: frontmatter: FrontMatter | None blocks: tuple[Block, ...] + @property + def metadata(self) -> Mapping[str, object]: + """Return the raw frontmatter mapping, or an empty mapping if absent.""" + return self.frontmatter.metadata if self.frontmatter is not None else {} + @property def headings(self) -> tuple[HeadingBlock, ...]: """Return all headings in the document.""" diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/__init__.py b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/__init__.py index b4681621e8b..6d5fa4eb680 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/__init__.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/__init__.py @@ -1,5 +1,9 @@ """Document transformers for converting parsed markdown into other formats.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from reflex_docgen.markdown.transformer._base import ( DocumentTransformer as DocumentTransformer, ) @@ -7,7 +11,39 @@ MarkdownTransformer as MarkdownTransformer, ) +if TYPE_CHECKING: + from reflex_docgen.markdown.transformer._reflex import ( + ReflexComponentTransformer as ReflexComponentTransformer, + ) + __all__ = [ "DocumentTransformer", "MarkdownTransformer", + "ReflexComponentTransformer", ] + + +def __getattr__(name: str) -> object: + """Lazily import the reflex-dependent transformer on first access. + + Keeps ``import reflex_docgen.markdown`` (and the markdown-string + transformer) free of a hard ``reflex`` import until the component + transformer is actually used. + + Args: + name: The attribute being accessed. + + Returns: + The resolved attribute. + + Raises: + AttributeError: If the attribute does not exist. + """ + if name == "ReflexComponentTransformer": + from reflex_docgen.markdown.transformer._reflex import ( + ReflexComponentTransformer, + ) + + return ReflexComponentTransformer + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_markdown.py b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_markdown.py index 2660d14a0b2..7912170b4c9 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_markdown.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_markdown.py @@ -113,15 +113,24 @@ def line_break(self, span: LineBreakSpan) -> str: def frontmatter(self, block: FrontMatter) -> str: import yaml - data: dict[str, object] = {} - if block.components: - data["components"] = list(block.components) - if block.only_low_level: - data["only_low_level"] = [True] - if block.title is not None: - data["title"] = block.title - for preview in block.component_previews: - data[preview.name] = preview.source + if block.metadata: + # The raw mapping is the faithful source of truth for a parsed + # document: render it verbatim so a parse -> render round-trip + # preserves key order, falsy values (e.g. ``only_low_level: false``), + # and preview source text exactly. + data: dict[str, object] = dict(block.metadata) + else: + # Programmatically constructed frontmatter carries no raw mapping; + # rebuild one from the typed fields and previews. + data = {} + if block.components: + data["components"] = list(block.components) + if block.only_low_level: + data["only_low_level"] = [True] + if block.title is not None: + data["title"] = block.title + for preview in block.component_previews: + data[preview.name] = preview.source return f"---\n{yaml.dump(data, default_flow_style=False, sort_keys=False).rstrip()}\n---" def code_block(self, block: CodeBlock) -> str: diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_reflex.py b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_reflex.py new file mode 100644 index 00000000000..bec89f6ac35 --- /dev/null +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_reflex.py @@ -0,0 +1,247 @@ +"""Reflex-component transformer — renders a Document to ``rx.Component`` trees. + +This is the default way to turn parsed markdown into Reflex components. It +covers the lean element set the content sites actually use and exposes two +customization layers that together replace the old flexdown ``component_map``: + +* **Subclassing** — override any block/span handler (``heading``, ``link``, …) + to fully control how a node renders. This is the primary mechanism. +* **Overrides dict** — pass ``overrides={element_name: builder}`` to swap a + single element's builder without subclassing, giving near drop-in parity with + the old ``component_map``. + +Builder signatures (the value side of ``overrides``) receive the node's +already-transformed children plus any scalar attributes, so they only need to +assemble the container: + +============ =========================================================== +element key builder signature +============ =========================================================== +``h1``-``h6`` ``(level: int, children: tuple[rx.Component, ...])`` +``p`` ``(children: tuple[rx.Component, ...])`` +``ul``/``ol`` ``(items: list[rx.Component])`` +``blockquote````(children: tuple[rx.Component, ...])`` +``pre`` ``(content: str, language: str | None)`` +``table`` ``(thead: rx.Component, tbody: rx.Component)`` +``hr`` ``()`` +``span`` ``(text: str)`` +``strong`` ``(children: tuple[rx.Component, ...])`` +``em`` ``(children: tuple[rx.Component, ...])`` +``s`` ``(children: tuple[rx.Component, ...])`` +``code`` ``(code: str)`` +``a`` ``(children: tuple[rx.Component, ...], href: str)`` +``img`` ``(src: str, alt: str)`` +``br`` ``()`` +============ =========================================================== + +Directive blocks are keyed by their directive name (e.g. ``"alert"``) and the +builder receives the :class:`DirectiveBlock` itself — read ``block.content`` +for line-oriented (non-markdown) bodies (e.g. ``md quote``'s ``- name:``/ +``- role:`` lines) to avoid CommonMark reflowing them, or call +``self.transform_blocks(block.children)`` to render the parsed children. +Without an override a directive falls back to a plain ``
``. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import reflex as rx +from reflex_docgen.markdown._types import ( + BoldSpan, + CodeBlock, + CodeSpan, + DirectiveBlock, + Document, + FrontMatter, + HeadingBlock, + ImageSpan, + ItalicSpan, + LineBreakSpan, + LinkSpan, + ListBlock, + ListItem, + QuoteBlock, + Span, + StrikethroughSpan, + TableBlock, + TableCell, + TableRow, + TextBlock, + TextSpan, + ThematicBreakBlock, +) +from reflex_docgen.markdown.transformer._base import DocumentTransformer + + +def _plain_text(spans: tuple[Span, ...]) -> str: + """Flatten inline spans into their plain text content. + + Used for attributes that must be strings (e.g. an image's ``alt``). + + Args: + spans: The inline spans to flatten. + + Returns: + The concatenated text of the spans, ignoring formatting. + """ + parts: list[str] = [] + for span in spans: + if isinstance(span, TextSpan): + parts.append(span.text) + elif isinstance(span, CodeSpan): + parts.append(span.code) + elif isinstance( + span, BoldSpan | ItalicSpan | StrikethroughSpan | LinkSpan | ImageSpan + ): + parts.append(_plain_text(span.children)) + return "".join(parts) + + +class ReflexComponentTransformer(DocumentTransformer["rx.Component"]): + """Transforms a :class:`Document` into a tree of Reflex components. + + Every handler renders with plain ``rx.el.*`` elements by default. Pass + ``overrides`` to replace individual element builders, or subclass and + override handlers for full control. See the module docstring for the + builder signatures keyed by element name. + """ + + def __init__(self, overrides: dict[str, Callable[..., rx.Component]] | None = None): + """Initialize the transformer. + + Args: + overrides: Element-name -> builder map (the ``component_map`` + analog). Each builder is consulted at the top of the matching + handler; absent keys fall through to the default ``rx.el.*`` + rendering. + """ + self._overrides = overrides or {} + + def transform(self, document: Document) -> rx.Component: + """Render a full document to a fragment of its blocks. + + Frontmatter is not part of ``document.blocks`` and renders nothing. + + Args: + document: The parsed document. + + Returns: + A fragment wrapping the transformed blocks. + """ + return rx.fragment(*self.transform_blocks(document.blocks)) + + def frontmatter(self, block: FrontMatter) -> rx.Component: + return rx.fragment() + + def heading(self, block: HeadingBlock) -> rx.Component: + level = min(max(block.level, 1), 6) + children = self.transform_spans(block.children) + if builder := self._overrides.get(f"h{level}"): + return builder(level, children) + return getattr(rx.el, f"h{level}")(*children) + + def text_block(self, block: TextBlock) -> rx.Component: + children = self.transform_spans(block.children) + if builder := self._overrides.get("p"): + return builder(children) + return rx.el.p(*children) + + def list_block(self, block: ListBlock) -> rx.Component: + items = [self.transform_list_item(item) for item in block.items] + if builder := self._overrides.get("ol" if block.ordered else "ul"): + return builder(items) + tag = rx.el.ol if block.ordered else rx.el.ul + return tag(*items) + + def transform_list_item(self, item: ListItem) -> rx.Component: + return rx.el.li(*self.transform_blocks(item.children)) + + def quote(self, block: QuoteBlock) -> rx.Component: + children = self.transform_blocks(block.children) + if builder := self._overrides.get("blockquote"): + return builder(children) + return rx.el.blockquote(*children) + + def code_block(self, block: CodeBlock) -> rx.Component: + if builder := self._overrides.get("pre"): + return builder(block.content, block.language) + return rx.el.pre(rx.el.code(block.content)) + + def directive(self, block: DirectiveBlock) -> rx.Component: + if builder := self._overrides.get(block.name): + return builder(block) + return rx.el.div(*self.transform_blocks(block.children)) + + def table(self, block: TableBlock) -> rx.Component: + thead = rx.el.thead(self.transform_table_row(block.header, header=True)) + tbody = rx.el.tbody(*(self.transform_table_row(row) for row in block.rows)) + if builder := self._overrides.get("table"): + return builder(thead, tbody) + return rx.el.table(thead, tbody) + + def transform_table_row( + self, row: TableRow, *, header: bool = False + ) -> rx.Component: + return rx.el.tr( + *(self.transform_table_cell(cell, header=header) for cell in row.cells) + ) + + def transform_table_cell( + self, cell: TableCell, *, header: bool = False + ) -> rx.Component: + children = self.transform_spans(cell.children) + tag = rx.el.th if header else rx.el.td + if cell.align is not None: + return tag(*children, text_align=cell.align) + return tag(*children) + + def thematic_break(self, block: ThematicBreakBlock) -> rx.Component: + if builder := self._overrides.get("hr"): + return builder() + return rx.el.hr() + + def text_span(self, span: TextSpan) -> rx.Component: + if builder := self._overrides.get("span"): + return builder(span.text) + return rx.el.span(span.text) + + def bold(self, span: BoldSpan) -> rx.Component: + children = self.transform_spans(span.children) + if builder := self._overrides.get("strong"): + return builder(children) + return rx.el.strong(*children) + + def italic(self, span: ItalicSpan) -> rx.Component: + children = self.transform_spans(span.children) + if builder := self._overrides.get("em"): + return builder(children) + return rx.el.em(*children) + + def strikethrough(self, span: StrikethroughSpan) -> rx.Component: + children = self.transform_spans(span.children) + if builder := self._overrides.get("s"): + return builder(children) + return rx.el.s(*children) + + def code_span(self, span: CodeSpan) -> rx.Component: + if builder := self._overrides.get("code"): + return builder(span.code) + return rx.el.code(span.code) + + def link(self, span: LinkSpan) -> rx.Component: + children = self.transform_spans(span.children) + if builder := self._overrides.get("a"): + return builder(children, span.target) + return rx.el.a(*children, href=span.target) + + def image(self, span: ImageSpan) -> rx.Component: + alt = _plain_text(span.children) + if builder := self._overrides.get("img"): + return builder(span.src, alt) + return rx.el.img(src=span.src, alt=alt) + + def line_break(self, span: LineBreakSpan) -> rx.Component: + if builder := self._overrides.get("br"): + return builder() + return rx.el.br() diff --git a/tests/units/docgen/test_markdown.py b/tests/units/docgen/test_markdown.py index e083592c8f5..29d1435bc10 100644 --- a/tests/units/docgen/test_markdown.py +++ b/tests/units/docgen/test_markdown.py @@ -149,6 +149,66 @@ def test_transform_frontmatter_with_only_low_level(): assert "only_low_level" in _md.frontmatter(fm) +def test_metadata_exposes_raw_frontmatter(): + """document.metadata exposes the full raw frontmatter mapping.""" + source = "---\ntitle: T\nauthor: Jane\norder: 3\ntags:\n - a\n - b\n---\n# Hi\n" + doc = parse_document(source) + assert doc.metadata["title"] == "T" + assert doc.metadata["author"] == "Jane" + assert doc.metadata["order"] == 3 + assert doc.metadata["tags"] == ["a", "b"] + + +def test_metadata_empty_without_frontmatter(): + """document.metadata is an empty mapping when there is no frontmatter.""" + assert dict(parse_document("# Hi\n").metadata) == {} + + +def test_metadata_on_frontmatter_block(): + """The raw mapping is also available on the FrontMatter block itself.""" + fm = parse_document("---\nauthor: Jane\n---\n# Hi\n").frontmatter + assert fm is not None + assert fm.metadata["author"] == "Jane" + + +def test_frontmatter_metadata_defaults_to_empty(): + """A FrontMatter constructed without metadata defaults to an empty mapping.""" + fm = FrontMatter( + components=(), only_low_level=False, title=None, component_previews=() + ) + assert dict(fm.metadata) == {} + + +def test_transform_frontmatter_preserves_extra_metadata(): + """Round-tripping preserves arbitrary metadata keys (lists, ints, scalars).""" + source = "---\ntitle: T\ntags:\n - a\n - b\norder: 3\n---\n# Hi\n" + doc = parse_document(source) + rendered = _md.transform(doc) + assert "tags:" in rendered + assert "order: 3" in rendered + # The round-trip is stable and the keys survive a re-parse. + doc2 = parse_document(rendered) + assert doc2.metadata["tags"] == ["a", "b"] + assert doc2.metadata["order"] == 3 + assert _md.transform(doc2) == rendered + + +def test_transform_frontmatter_preserves_falsy_modeled_keys(): + """Explicit falsy values for modeled keys survive a round-trip.""" + source = "---\ncomponents: []\nonly_low_level: false\n---\n# Hi\n" + rendered = _md.transform(parse_document(source)) + assert "components:" in rendered + assert "only_low_level:" in rendered + + +def test_transform_frontmatter_preserves_string_metadata_verbatim(): + """String-valued site metadata round-trips verbatim, not stripped.""" + source = "---\nauthor: ' Jane '\n---\n# Hi\n" + doc = parse_document(source) + rendered = _md.transform(doc) + assert parse_document(rendered).metadata["author"] == " Jane " + + def test_h1(): """A level-1 heading is parsed correctly.""" doc = parse_document("# Title\n") @@ -297,6 +357,13 @@ def test_directive_section(): assert d.args == () +def test_directive_preserves_raw_content(): + """DirectiveBlock.content holds the raw, unparsed inner text.""" + source = "```md quote\n- name: Jane\n- role: CEO\nGreat product.\n```\n" + d = parse_document(source).directives[0] + assert d.content == "- name: Jane\n- role: CEO\nGreat product." + + def test_directive_not_in_code_blocks(): """Directive blocks should not appear in the code_blocks list.""" source = "```md alert\nBody\n```\n" diff --git a/tests/units/docgen/test_reflex_transformer.py b/tests/units/docgen/test_reflex_transformer.py new file mode 100644 index 00000000000..2d00217a96f --- /dev/null +++ b/tests/units/docgen/test_reflex_transformer.py @@ -0,0 +1,224 @@ +"""Tests for the reflex-docgen ReflexComponentTransformer.""" + +from reflex_docgen.markdown import HeadingBlock, TextSpan, parse_document +from reflex_docgen.markdown.transformer import ReflexComponentTransformer + +import reflex as rx +from reflex.components.component import BaseComponent + +_rx = ReflexComponentTransformer() + + +def _tags(component: BaseComponent) -> list[str | None]: + """Return the html tag of each direct child of a component.""" + return [getattr(c, "tag", None) for c in component.children] + + +def test_transform_returns_fragment(): + """The document transforms into a fragment of its blocks.""" + doc = parse_document("# Hi\n\nWorld\n") + tree = _rx.transform(doc) + assert isinstance(tree, rx.Fragment) + assert _tags(tree) == ["h1", "p"] + + +def test_heading_levels(): + """Heading levels map to h1-h6.""" + doc = parse_document("# A\n\n## B\n\n### C\n") + assert _tags(_rx.transform(doc)) == ["h1", "h2", "h3"] + + +def test_heading_level_clamped(): + """Heading levels above 6 clamp to h6.""" + h = _rx.heading(HeadingBlock(level=7, children=(TextSpan(text="x"),))) + assert h.tag == "h6" + + +def test_paragraph(): + """A paragraph renders as a

.""" + doc = parse_document("Hello world.\n") + assert _tags(_rx.transform(doc)) == ["p"] + + +def test_inline_spans(): + """Inline formatting maps to the matching inline elements.""" + doc = parse_document("A **b** *i* ~~s~~ `c` [l](http://x.com) ![a](i.png) end.\n") + para = _rx.transform(doc).children[0] + tags = [getattr(c, "tag", None) for c in para.children] + # The anchor (rx.el.a) renders as reflex's routing Link element. + assert {"span", "strong", "em", "s", "code", "img", "Link"} <= set(tags) + + +def test_unordered_list(): + """An unordered list renders as