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..07cfe9b8c97 100644
--- a/packages/reflex-docgen/src/reflex_docgen/markdown/__init__.py
+++ b/packages/reflex-docgen/src/reflex_docgen/markdown/__init__.py
@@ -1,5 +1,7 @@
"""Markdown parsing and types for Reflex documentation."""
+from __future__ import annotations
+
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
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..b7ae9d29b06 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,18 @@ 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, hash=False, compare=False
+ )
@dataclass(frozen=True, slots=True, kw_only=True)
@@ -174,11 +181,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 +322,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..9e16aa4516e 100644
--- a/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/__init__.py
+++ b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/__init__.py
@@ -1,4 +1,12 @@
-"""Document transformers for converting parsed markdown into other formats."""
+"""Document transformers for converting parsed markdown into other formats.
+
+The reflex-dependent :class:`~reflex_docgen.markdown.transformer.reflex.ReflexComponentTransformer`
+lives in the :mod:`reflex_docgen.markdown.transformer.reflex` submodule so that
+importing this package (and the markdown-string transformer) stays free of a
+hard ``reflex`` dependency until the component transformer is actually used.
+"""
+
+from __future__ import annotations
from reflex_docgen.markdown.transformer._base import (
DocumentTransformer as DocumentTransformer,
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..1391e97010d
--- /dev/null
+++ b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/reflex.py
@@ -0,0 +1,414 @@
+"""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
+
+__all__ = ["ReflexComponentTransformer"]
+
+
+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))
+ elif isinstance(span, LineBreakSpan):
+ parts.append(" ")
+ 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:
+ """Render frontmatter as nothing (an empty fragment).
+
+ Args:
+ block: The frontmatter block.
+
+ Returns:
+ An empty fragment.
+ """
+ return rx.fragment()
+
+ def heading(self, block: HeadingBlock) -> rx.Component:
+ """Render a heading as an ``
``-```` element.
+
+ Args:
+ block: The heading block.
+
+ Returns:
+ The heading 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:
+ """Render a paragraph as a ``
`` element.
+
+ Args:
+ block: The text block.
+
+ Returns:
+ The paragraph 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:
+ """Render a list as a ``
`` or ```` element.
+
+ Args:
+ block: The list block.
+
+ Returns:
+ The list 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:
+ """Render a single list item as an ``- `` element.
+
+ Args:
+ item: The list item.
+
+ Returns:
+ The list-item component.
+ """
+ return rx.el.li(*self.transform_blocks(item.children))
+
+ def quote(self, block: QuoteBlock) -> rx.Component:
+ """Render a block quote as a ``
`` element.
+
+ Args:
+ block: The quote block.
+
+ Returns:
+ The blockquote 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:
+ """Render a fenced code block as a ```` element.
+
+ Args:
+ block: The code block.
+
+ Returns:
+ The preformatted code 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:
+ """Render a directive, falling back to a plain ````.
+
+ Args:
+ block: The directive block.
+
+ Returns:
+ The component produced by the matching override, or a ``
``
+ wrapping the directive's children.
+ """
+ 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:
+ """Render a table as a ``
`` element.
+
+ Args:
+ block: The table block.
+
+ Returns:
+ The table 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:
+ """Render a single table row as a ```` element.
+
+ Args:
+ row: The table row.
+ header: Whether the row belongs to the table header.
+
+ Returns:
+ The table-row 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:
+ """Render a single table cell as a ``| `` or `` | `` element.
+
+ Args:
+ cell: The table cell.
+ header: Whether the cell belongs to the table header.
+
+ Returns:
+ The table-cell 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:
+ """Render a thematic break as an `` `` element.
+
+ Args:
+ block: The thematic-break block.
+
+ Returns:
+ The horizontal-rule component.
+ """
+ if builder := self._overrides.get("hr"):
+ return builder()
+ return rx.el.hr()
+
+ def text_span(self, span: TextSpan) -> rx.Component:
+ """Render plain text as a ```` element.
+
+ Args:
+ span: The text span.
+
+ Returns:
+ The span component.
+ """
+ if builder := self._overrides.get("span"):
+ return builder(span.text)
+ return rx.el.span(span.text)
+
+ def bold(self, span: BoldSpan) -> rx.Component:
+ """Render bold text as a ```` element.
+
+ Args:
+ span: The bold span.
+
+ Returns:
+ The strong 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:
+ """Render italic text as an ```` element.
+
+ Args:
+ span: The italic span.
+
+ Returns:
+ The emphasis 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:
+ """Render struck-through text as an ```` element.
+
+ Args:
+ span: The strikethrough span.
+
+ Returns:
+ The strikethrough 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:
+ """Render inline code as a ```` element.
+
+ Args:
+ span: The code span.
+
+ Returns:
+ The inline-code component.
+ """
+ if builder := self._overrides.get("code"):
+ return builder(span.code)
+ return rx.el.code(span.code)
+
+ def link(self, span: LinkSpan) -> rx.Component:
+ """Render a link as an ```` element.
+
+ Args:
+ span: The link span.
+
+ Returns:
+ The anchor 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:
+ """Render an image as an `` `` element.
+
+ Args:
+ span: The image span.
+
+ Returns:
+ The image 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:
+ """Render a line break as a `` `` element.
+
+ Args:
+ span: The line-break span.
+
+ Returns:
+ The line-break 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..75d4d16a56e 100644
--- a/tests/units/docgen/test_markdown.py
+++ b/tests/units/docgen/test_markdown.py
@@ -149,6 +149,73 @@ 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_frontmatter_is_hashable_with_metadata():
+ """FrontMatter / Document stay hashable even when metadata holds a dict."""
+ doc = parse_document("---\nauthor: Jane\norder: 3\n---\n# Hi\n")
+ hash(doc.frontmatter)
+ hash(doc)
+
+
+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 +364,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..94b0bb149c7
--- /dev/null
+++ b/tests/units/docgen/test_reflex_transformer.py
@@ -0,0 +1,233 @@
+"""Tests for the reflex-docgen ReflexComponentTransformer."""
+
+from reflex_docgen.markdown import HeadingBlock, TextSpan, parse_document
+from reflex_docgen.markdown.transformer.reflex 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)  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 with - items."""
+ ul = _rx.transform(parse_document("- one\n- two\n")).children[0]
+ assert getattr(ul, "tag", None) == "ul"
+ assert _tags(ul) == ["li", "li"]
+
+
+def test_ordered_list():
+ """An ordered list renders as
with - items."""
+ ol = _rx.transform(parse_document("1. a\n2. b\n")).children[0]
+ assert getattr(ol, "tag", None) == "ol"
+ assert _tags(ol) == ["li", "li"]
+
+
+def test_nested_list():
+ """A nested list is rendered inside its parent list item."""
+ ul = _rx.transform(parse_document("- outer\n - inner\n")).children[0]
+ li = ul.children[0]
+ assert getattr(li, "tag", None) == "li"
+ assert "ul" in _tags(li)
+
+
+def test_blockquote():
+ """A blockquote renders as
."""
+ bq = _rx.transform(parse_document("> wise words\n")).children[0]
+ assert getattr(bq, "tag", None) == "blockquote"
+
+
+def test_code_block():
+ """A fenced code block renders as ."""
+ pre = _rx.transform(parse_document("```python\nx = 1\n```\n")).children[0]
+ assert getattr(pre, "tag", None) == "pre"
+ assert _tags(pre) == ["code"]
+
+
+def test_thematic_break():
+ """A thematic break renders as ."""
+ assert "hr" in _tags(_rx.transform(parse_document("text\n\n---\n")))
+
+
+def test_table():
+ """A table renders as with /, th headers, td cells."""
+ doc = parse_document("| Name | Val |\n| :--- | ---: |\n| a | 1 |\n")
+ table = _rx.transform(doc).children[0]
+ assert getattr(table, "tag", None) == "table"
+ assert _tags(table) == ["thead", "tbody"]
+ header_row = table.children[0].children[0]
+ assert _tags(header_row) == ["th", "th"]
+ body_row = table.children[1].children[0]
+ assert _tags(body_row) == ["td", "td"]
+
+
+def test_image_renders_img():
+ """An image renders as ; its alt is flattened to plain text."""
+ img = (
+ _rx.transform(parse_document("\n")).children[0].children[0]
+ )
+ assert getattr(img, "tag", None) == "img"
+
+
+def test_image_alt_line_break_becomes_space():
+ """A line break inside spans flattens to a single space, not nothing."""
+ from reflex_docgen.markdown._types import LineBreakSpan
+ from reflex_docgen.markdown.transformer.reflex import _plain_text
+
+ spans = (TextSpan(text="two"), LineBreakSpan(soft=True), TextSpan(text="words"))
+ assert _plain_text(spans) == "two words"
+
+
+def test_image_alt_flattened():
+ """Formatted alt text is flattened to a plain string for the alt attribute."""
+ from reflex_docgen.markdown import BoldSpan, CodeSpan, LinkSpan
+ from reflex_docgen.markdown.transformer.reflex import _plain_text
+
+ spans = (
+ TextSpan(text="a "),
+ BoldSpan(children=(TextSpan(text="b"),)),
+ CodeSpan(code="c"),
+ LinkSpan(children=(TextSpan(text="d"),), target="http://x.com"),
+ )
+ assert _plain_text(spans) == "a bcd"
+
+
+def test_hard_line_break_renders_br():
+ """A hard line break inside a paragraph renders as ."""
+ para = _rx.transform(parse_document("a \nb\n")).children[0]
+ assert "br" in [getattr(c, "tag", None) for c in para.children]
+
+
+def test_default_directive_renders_div():
+ """A directive without an override renders its children inside a ."""
+ div = _rx.transform(parse_document("```md alert\nBody.\n```\n")).children[0]
+ assert getattr(div, "tag", None) == "div"
+
+
+def test_quote_directive_falls_through_to_div():
+ """`md quote` has no special default; it renders as a generic .
+
+ Consumers that want styled quotes override the ``quote`` directive (by name)
+ and read the raw line-oriented body from ``DirectiveBlock.content``.
+ """
+ src = "```md quote\n- name: Jane\nReflex is great.\n```\n"
+ div = _rx.transform(parse_document(src)).children[0]
+ assert getattr(div, "tag", None) == "div"
+
+
+def test_frontmatter_renders_nothing():
+ """Frontmatter is not part of the rendered blocks."""
+ doc = parse_document("---\ntitle: T\n---\n# Hi\n")
+ assert _tags(_rx.transform(doc)) == ["h1"]
+
+
+def test_overrides_heading():
+ """An overrides entry for a heading is honored."""
+ sentinel = rx.el.div("custom")
+
+ def my_h1(level: int, children: tuple[rx.Component, ...]) -> rx.Component:
+ return sentinel
+
+ transformer = ReflexComponentTransformer(overrides={"h1": my_h1})
+ assert transformer.transform(parse_document("# Title\n")).children[0] is sentinel
+
+
+def test_overrides_link():
+ """An overrides entry for a link receives children and href."""
+ captured: dict[str, object] = {}
+
+ def my_link(children: tuple[rx.Component, ...], href: str) -> rx.Component:
+ captured["href"] = href
+ return rx.el.span(*children)
+
+ transformer = ReflexComponentTransformer(overrides={"a": my_link})
+ transformer.transform(parse_document("[click](http://x.com)\n"))
+ assert captured["href"] == "http://x.com"
+
+
+def test_overrides_code_block():
+ """An overrides entry for a code block receives content and language."""
+ captured: dict[str, object] = {}
+
+ def my_pre(content: str, language: str | None) -> rx.Component:
+ captured["content"] = content
+ captured["language"] = language
+ return rx.el.div(content)
+
+ transformer = ReflexComponentTransformer(overrides={"pre": my_pre})
+ transformer.transform(parse_document("```python\nx = 1\n```\n"))
+ assert captured["content"] == "x = 1"
+ assert captured["language"] == "python"
+
+
+def test_overrides_directive_by_name():
+ """A directive override keyed by name receives the DirectiveBlock itself."""
+ from reflex_docgen.markdown._types import DirectiveBlock
+
+ captured: dict[str, object] = {}
+
+ def my_alert(block: DirectiveBlock) -> rx.Component:
+ captured["args"] = block.args
+ captured["content"] = block.content
+ return rx.el.div()
+
+ transformer = ReflexComponentTransformer(overrides={"alert": my_alert})
+ transformer.transform(parse_document("```md alert info\nBe careful.\n```\n"))
+ assert captured["args"] == ("info",)
+ assert captured["content"] == "Be careful."
+
+
+def test_overrides_directive_reads_raw_content():
+ """A line-oriented directive override can read the unreflowed raw body."""
+ from reflex_docgen.markdown._types import DirectiveBlock
+
+ captured: dict[str, object] = {}
+
+ def my_quote(block: DirectiveBlock) -> rx.Component:
+ captured["content"] = block.content
+ return rx.el.div()
+
+ transformer = ReflexComponentTransformer(overrides={"quote": my_quote})
+ src = "```md quote\n- name: Jane\n- role: CEO\nGreat product.\n```\n"
+ transformer.transform(parse_document(src))
+ assert captured["content"] == "- name: Jane\n- role: CEO\nGreat product."
|