From c84885388e11577be91aad291405e37e78048b50 Mon Sep 17 00:00:00 2001 From: carlosabadia Date: Wed, 6 May 2026 12:48:40 +0200 Subject: [PATCH 1/4] Add meta tags for docpages --- docs/app/agent_files/_plugin.py | 29 ++----- docs/app/reflex_docs/pages/docs/__init__.py | 72 +++++++++++++--- docs/app/reflex_docs/pages/docs/component.py | 11 ++- docs/app/reflex_docs/reflex_docs.py | 85 +++++++++++++------ .../reflex_docs/templates/docpage/docpage.py | 17 ++-- .../src/reflex_docgen/markdown/_parser.py | 18 +++- .../src/reflex_docgen/markdown/_types.py | 4 + .../markdown/transformer/_markdown.py | 4 + .../src/reflex_site_shared/utils/url.py | 36 ++++++++ tests/units/docgen/test_markdown.py | 83 ++++++++++++++++++ 10 files changed, 288 insertions(+), 71 deletions(-) create mode 100644 packages/reflex-site-shared/src/reflex_site_shared/utils/url.py diff --git a/docs/app/agent_files/_plugin.py b/docs/app/agent_files/_plugin.py index f3d56ab827a..c3385af3c48 100644 --- a/docs/app/agent_files/_plugin.py +++ b/docs/app/agent_files/_plugin.py @@ -112,33 +112,16 @@ def _extract_markdown_title(source: str) -> str | None: def _llms_url_for_path(url_path: Path) -> str: """Return the public URL for a generated markdown asset.""" - from reflex_base.config import get_config - - config = get_config() - deploy_url = config.deploy_url.removesuffix("/") if config.deploy_url else "" - frontend_path = (config.frontend_path or "").strip("/") - base_url = deploy_url - if frontend_path: - base_url = f"{base_url}/{frontend_path}" if base_url else f"/{frontend_path}" - return ( - f"{base_url}/{url_path.as_posix()}" if base_url else f"/{url_path.as_posix()}" - ) + from reflex_site_shared.utils.url import public_url + + return public_url(f"/{url_path.as_posix()}") def _docs_home_url() -> str: """Return the public URL for the docs home.""" - from reflex_base.config import get_config - - config = get_config() - deploy_url = config.deploy_url.removesuffix("/") if config.deploy_url else "" - frontend_path = (config.frontend_path or "").strip("/") - if deploy_url and frontend_path: - return f"{deploy_url}/{frontend_path}/" - if deploy_url: - return f"{deploy_url}/" - if frontend_path: - return f"/{frontend_path}/" - return "/" + from reflex_site_shared.utils.url import public_url + + return public_url("/") def _strip_first_heading(source: str) -> str: diff --git a/docs/app/reflex_docs/pages/docs/__init__.py b/docs/app/reflex_docs/pages/docs/__init__.py index 6ac16a0b29d..776a741a09d 100644 --- a/docs/app/reflex_docs/pages/docs/__init__.py +++ b/docs/app/reflex_docs/pages/docs/__init__.py @@ -1,11 +1,12 @@ import os from collections import defaultdict, namedtuple +from functools import lru_cache from pathlib import Path from types import SimpleNamespace import reflex as rx from reflex_components_core.core.cond import Cond -from reflex_docgen.markdown import parse_document +from reflex_docgen.markdown import FrontMatter, parse_document # External Components from reflex_pyplot import pyplot as pyplot @@ -24,6 +25,8 @@ from .library import library from .recipes_overview import overview +DEFAULT_DOC_DESCRIPTION_TEMPLATE = "Reflex Documentation page for {title}" + SPECIAL_COMPONENT_DOCS = { "rx.cond": Cond, } @@ -62,14 +65,20 @@ def build_nested_namespace( return parent_namespace +@lru_cache(maxsize=None) +def _frontmatter_for(filepath: str) -> FrontMatter | None: + """Parse a doc's frontmatter once per file (cached for the process lifetime).""" + source = Path(filepath).read_text(encoding="utf-8") + return parse_document(source).frontmatter + + def get_components_from_frontmatter(filepath: str) -> list: """Extract component tuples from a doc's frontmatter.""" - source = Path(filepath).read_text(encoding="utf-8") - doc = parse_document(source) - if doc.frontmatter is None: + fm = _frontmatter_for(filepath) + if fm is None: return [] components = [] - for comp_str in doc.frontmatter.components: + for comp_str in fm.components: if component := SPECIAL_COMPONENT_DOCS.get(comp_str): components.append((component, comp_str)) continue @@ -87,11 +96,28 @@ def get_components_from_frontmatter(filepath: str) -> list: def get_previews_from_frontmatter(filepath: str) -> dict[str, str]: """Extract component preview sources from a doc's frontmatter.""" - source = Path(filepath).read_text(encoding="utf-8") - doc = parse_document(source) - if doc.frontmatter is None: + fm = _frontmatter_for(filepath) + if fm is None: return {} - return {p.name: p.source for p in doc.frontmatter.component_previews} + return {p.name: p.source for p in fm.component_previews} + + +def get_description_from_frontmatter(filepath: str, title: str) -> str: + """Resolve a per-page meta description for a doc. + + Uses the ``description`` frontmatter field when set; otherwise falls back + to ``DEFAULT_DOC_DESCRIPTION_TEMPLATE``. + """ + fm = _frontmatter_for(filepath) + if fm is not None and fm.description: + return fm.description + return DEFAULT_DOC_DESCRIPTION_TEMPLATE.format(title=title) + + +def get_image_from_frontmatter(filepath: str) -> str | None: + """Resolve a per-page social preview image from frontmatter, if any.""" + fm = _frontmatter_for(filepath) + return fm.image if fm is not None else None # --------------------------------------------------------------------------- @@ -193,13 +219,22 @@ def resolve_doc_route(doc: str, title: str) -> ResolvedDoc | None: return ResolvedDoc(route=route, display_title=display_title, category=category) -def make_docpage(route: str, title: str, doc_virtual: str, render_fn): +def make_docpage( + route: str, + title: str, + doc_virtual: str, + render_fn, + description: str | None = None, + image: str | None = None, +): """Wrap a render function as a docpage, setting module metadata.""" doc_path = Path(doc_virtual) render_fn.__module__ = ".".join(doc_path.parts[:-1]) render_fn.__name__ = doc_path.stem render_fn.__qualname__ = doc_path.stem - return docpage(set_path=route, t=title)(render_fn) + return docpage(set_path=route, t=title, description=description, image=image)( + render_fn + ) def handle_library_doc( @@ -211,6 +246,8 @@ def handle_library_doc( """Handle docs/library/** docs — component API reference via multi_docs.""" clist = [title, *get_components_from_frontmatter(actual_path)] previews = get_previews_from_frontmatter(actual_path) + description = get_description_from_frontmatter(actual_path, resolved.display_title) + image = get_image_from_frontmatter(actual_path) if doc.startswith("docs/library/graphing"): graphing_components[resolved.category].append(clist) else: @@ -222,6 +259,8 @@ def handle_library_doc( previews=previews, component_list=clist, title=resolved.display_title, + description=description, + image=image, ) @@ -242,7 +281,16 @@ def comp(_actual=actual_path, _virtual=virtual_doc): ) return ((toc, doc_content), rendered) - return make_docpage(resolved.route, resolved.display_title, virtual_doc, comp) + description = get_description_from_frontmatter(actual_path, resolved.display_title) + image = get_image_from_frontmatter(actual_path) + return make_docpage( + resolved.route, + resolved.display_title, + virtual_doc, + comp, + description=description, + image=image, + ) # Build doc_markdown_sources mapping diff --git a/docs/app/reflex_docs/pages/docs/component.py b/docs/app/reflex_docs/pages/docs/component.py index ac2dc85cc1a..c600445637f 100644 --- a/docs/app/reflex_docs/pages/docs/component.py +++ b/docs/app/reflex_docs/pages/docs/component.py @@ -930,6 +930,8 @@ def multi_docs( previews: dict[str, str], component_list: list, title: str, + description: str | None = None, + image: str | None = None, ): components = [ component_docs(component_tuple, previews) @@ -986,7 +988,7 @@ def links(current_page, ll_doc_exists, path): ) return rx.fragment() - @docpage(set_path=path, t=title) + @docpage(set_path=path, t=title, description=description, image=image) def out(): toc = get_docgen_toc(actual_path) doc_content = Path(actual_path).read_text(encoding="utf-8") @@ -1012,7 +1014,12 @@ def out(): class_name="flex flex-col w-full", ) - @docpage(set_path=path + "low", t=title + " (Low Level)") + @docpage( + set_path=path + "low", + t=title + " (Low Level)", + description=description, + image=image, + ) def ll(): ll_virtual = virtual_path.replace(".md", "-ll.md") toc = get_docgen_toc(ll_actual_path) diff --git a/docs/app/reflex_docs/reflex_docs.py b/docs/app/reflex_docs/reflex_docs.py index d47d672b7bc..2e919784c17 100644 --- a/docs/app/reflex_docs/reflex_docs.py +++ b/docs/app/reflex_docs/reflex_docs.py @@ -8,12 +8,43 @@ from reflex_site_shared import styles from reflex_site_shared.backend.status import monitor_checkly_status from reflex_site_shared.constants import REFLEX_ASSETS_CDN -from reflex_site_shared.meta.meta import favicons_links, to_cdn_image_url +from reflex_site_shared.meta.meta import ( + ONE_LINE_DESCRIPTION, + create_meta_tags, + favicons_links, + to_cdn_image_url, +) from reflex_site_shared.telemetry import get_pixel_website_trackers +from reflex_site_shared.utils.url import public_url from reflex_docs.pages import page404, routes +from reflex_docs.templates.docpage.docpage import DOCS_PROD_BASE from reflex_docs.whitelist import _check_whitelisted_path + +def _seo_meta_tags( + title: str, description: str, image: str, canonical_url: str +) -> list: + """Build the per-page SEO meta tag set, deduped against framework output. + + Reflex's ``add_page`` already emits ```` (from + its ``description=`` arg) and ```` (from its + ``image=`` arg). We strip those entries from ``create_meta_tags`` to + avoid duplicate tags in the rendered ````. + """ + tags = create_meta_tags( + title=title, description=description, image=image, url=canonical_url + ) + return [ + t + for t in tags + if not ( + isinstance(t, dict) + and (t.get("name") == "description" or t.get("property") == "og:image") + ) + ] + + # This number discovered by trial and error on Windows 11 w/ Node 18, any # higher and the prod build fails with EMFILE error. WINDOWS_MAX_ROUTES = int(os.environ.get("REFLEX_WEB_WINDOWS_MAX_ROUTES", "100")) @@ -76,37 +107,39 @@ def _llms_txt_directive() -> rx.Component: routes = routes[:WINDOWS_MAX_ROUTES] # Add the pages to the app. +_DEFAULT_PREVIEW = f"{REFLEX_ASSETS_CDN}previews/index_preview.webp" for route in routes: # print(f"Adding route: {route}") if _check_whitelisted_path(route.path): - # Normalize image to CDN URL when it's a relative path image_url = ( - f"{REFLEX_ASSETS_CDN}previews/index_preview.webp" - if route.image is None - else to_cdn_image_url(route.image) - or f"{REFLEX_ASSETS_CDN}previews/index_preview.webp" - ) - - page_args = { - "component": route.component, - "route": route.path, - "title": route.title, - "image": image_url, - "meta": [ - {"name": "theme-color", "content": route.background_color}, - ], - "on_load": route.on_load, - } - - # Add the description only if it is not None - if route.description is not None: - page_args["description"] = route.description - # Add the extra meta data only if it is not None + to_cdn_image_url(route.image) if route.image else None + ) or _DEFAULT_PREVIEW + page_description = route.description or ONE_LINE_DESCRIPTION + + meta_tags: list = [ + {"name": "theme-color", "content": route.background_color}, + ] + if isinstance(route.title, str): + meta_tags.extend( + _seo_meta_tags( + title=route.title, + description=page_description, + image=image_url, + canonical_url=public_url(route.path, fallback_base=DOCS_PROD_BASE), + ) + ) if route.meta is not None: - page_args["meta"].extend(route.meta) + meta_tags.extend(route.meta) - # Call add_page with the dynamically constructed arguments - app.add_page(**page_args) + app.add_page( + component=route.component, + route=route.path, + title=route.title, + description=page_description, + image=image_url, + meta=meta_tags, + on_load=route.on_load, + ) # Add redirects. redirects = [ diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index 96dfe4b5d6b..48363892ac1 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -687,6 +687,8 @@ def docpage( right_sidebar: bool = True, page_title: str | None = None, pseudo_right_bar: bool = False, + description: str | None = None, + image: str | None = None, ): """A template that most pages on the reflex.dev site should use. @@ -698,6 +700,8 @@ def docpage( right_sidebar: Whether to show the right sidebar. page_title: The full title to set for the page. If None, defaults to `{title} · Reflex Docs`. pseudo_right_bar: Whether to show a pseudo right sidebar (empty space). + description: SEO meta description for the page. + image: Social-preview image (relative path or absolute URL). Returns: A wrapper function that returns the full webpage. @@ -941,15 +945,14 @@ def wrapper(*args, **kwargs) -> rx.Component: if len(components) > 2 else None ) - if page_title: - return Route( - path=path, - title=page_title, - component=wrapper, - ) + resolved_title = page_title or ( + f"{title} · Reflex Docs" if category is None else title + ) return Route( path=path, - title=f"{title} · Reflex Docs" if category is None else title, + title=resolved_title, + description=description, + image=image, component=wrapper, ) diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py b/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py index d9f1a782ced..a7dd4753472 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/_parser.py @@ -40,7 +40,13 @@ #: Known frontmatter keys that are not component preview lambdas. -_KNOWN_KEYS = frozenset({"components", "only_low_level", "title"}) +_KNOWN_KEYS = frozenset({ + "components", + "only_low_level", + "title", + "description", + "image", +}) def _extract_frontmatter(source: str) -> tuple[FrontMatter | None, str]: @@ -79,6 +85,14 @@ def _extract_frontmatter(source: str) -> tuple[FrontMatter | None, str]: raw_title = data.get("title") title = str(raw_title) if raw_title is not None else None + # SEO description + raw_description = data.get("description") + description = str(raw_description) if raw_description is not None else None + + # social preview image + raw_image = data.get("image") + image = str(raw_image) if raw_image is not None else None + # component previews — any key not in _KNOWN_KEYS with a string value previews: list[ComponentPreview] = [] for key, value in data.items(): @@ -90,6 +104,8 @@ def _extract_frontmatter(source: str) -> tuple[FrontMatter | None, str]: components=components, only_low_level=only_low_level, title=title, + description=description, + image=image, component_previews=tuple(previews), ), source[m.end() :], diff --git a/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py b/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py index d54b786a2f2..2d7230a130a 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/_types.py @@ -140,6 +140,8 @@ class FrontMatter: components: Component paths to document (e.g. ``["rx.button"]``). only_low_level: Whether to show only low-level component variants. title: An optional page title. + description: An optional SEO meta description for the page. + image: An optional social-preview image path/URL for the page. component_previews: Preview lambdas keyed by component class name. """ @@ -147,6 +149,8 @@ class FrontMatter: only_low_level: bool title: str | None component_previews: tuple[ComponentPreview, ...] + description: str | None = None + image: str | None = None @dataclass(frozen=True, slots=True, kw_only=True) 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..4cc09da28b8 100644 --- a/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_markdown.py +++ b/packages/reflex-docgen/src/reflex_docgen/markdown/transformer/_markdown.py @@ -120,6 +120,10 @@ def frontmatter(self, block: FrontMatter) -> str: data["only_low_level"] = [True] if block.title is not None: data["title"] = block.title + if block.description is not None: + data["description"] = block.description + if block.image is not None: + data["image"] = block.image 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---" diff --git a/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py b/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py new file mode 100644 index 00000000000..a85d1eb5306 --- /dev/null +++ b/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py @@ -0,0 +1,36 @@ +"""URL helpers shared across the Reflex site packages.""" + +from __future__ import annotations + + +def public_url(path: str = "", fallback_base: str | None = None) -> str: + """Build a public URL from ``deploy_url`` + ``frontend_path`` in rxconfig. + + The same combination is used for canonical URLs, ``llms.txt`` asset + references, and the docs home URL. Centralizing here keeps the rules + consistent across call sites. + + Args: + path: Path segment to append. Should start with ``/`` to form a + clean URL, or be empty to return the base alone. + fallback_base: Base URL (already including any frontend_path prefix, + e.g. ``https://reflex.dev/docs``) to use when ``deploy_url`` is + unset or points at localhost. When ``None``, the returned URL + may be relative (no scheme/host). + + Returns: + The combined URL. If neither a deploy URL, frontend path, nor a + fallback base is configured, returns ``path`` unchanged. + """ + from reflex_base.config import get_config + + config = get_config() + deploy_url = (config.deploy_url or "").removesuffix("/") + if fallback_base is not None and (not deploy_url or "localhost" in deploy_url): + return fallback_base.rstrip("/") + path + frontend_path = (config.frontend_path or "").strip("/") + if frontend_path: + base = f"{deploy_url}/{frontend_path}" if deploy_url else f"/{frontend_path}" + else: + base = deploy_url + return f"{base}{path}" if base else path diff --git a/tests/units/docgen/test_markdown.py b/tests/units/docgen/test_markdown.py index e083592c8f5..ae46de3f8bd 100644 --- a/tests/units/docgen/test_markdown.py +++ b/tests/units/docgen/test_markdown.py @@ -118,6 +118,49 @@ def test_multiple_previews(): assert "InputSlot" in names +def test_frontmatter_description(): + """Frontmatter ``description`` is extracted as a string.""" + source = "---\ndescription: A short SEO description.\n---\n# Hello\n" + fm = parse_document(source).frontmatter + assert fm is not None + assert fm.description == "A short SEO description." + + +def test_frontmatter_image(): + """Frontmatter ``image`` is extracted as a string.""" + source = "---\nimage: /previews/foo.webp\n---\n# Hello\n" + fm = parse_document(source).frontmatter + assert fm is not None + assert fm.image == "/previews/foo.webp" + + +def test_frontmatter_description_and_image_default_none(): + """``description`` / ``image`` default to None when absent.""" + source = "---\ntitle: Test\n---\n# Hello\n" + fm = parse_document(source).frontmatter + assert fm is not None + assert fm.description is None + assert fm.image is None + + +def test_frontmatter_description_image_not_treated_as_preview(): + """``description`` / ``image`` are reserved keys, not component previews.""" + source = ( + "---\n" + "components:\n - rx.button\n" + "description: SEO desc\n" + "image: /img.webp\n\n" + "Button: |\n lambda **props: rx.button(**props)\n" + "---\n# Button\n" + ) + fm = parse_document(source).frontmatter + assert fm is not None + assert fm.description == "SEO desc" + assert fm.image == "/img.webp" + assert len(fm.component_previews) == 1 + assert fm.component_previews[0].name == "Button" + + def test_transform_frontmatter_with_previews(): """FrontMatter with component previews renders correctly.""" fm = FrontMatter( @@ -149,6 +192,46 @@ def test_transform_frontmatter_with_only_low_level(): assert "only_low_level" in _md.frontmatter(fm) +def test_transform_frontmatter_with_description_and_image(): + """FrontMatter ``description`` / ``image`` round-trip through the writer.""" + fm = FrontMatter( + components=(), + only_low_level=False, + title=None, + description="A SEO description.", + image="/previews/foo.webp", + component_previews=(), + ) + md = _md.frontmatter(fm) + assert "description: A SEO description." in md + assert "image: /previews/foo.webp" in md + + +def test_transform_frontmatter_omits_unset_description_and_image(): + """Unset ``description`` / ``image`` are not emitted by the writer.""" + fm = FrontMatter( + components=(), + only_low_level=False, + title=None, + component_previews=(), + ) + md = _md.frontmatter(fm) + assert "description:" not in md + assert "image:" not in md + + +def test_frontmatter_description_image_round_trip(): + """Parser → transformer → parser preserves ``description`` and ``image``.""" + original = "---\ndescription: Round trip\nimage: /round/trip.webp\n---\n# Hi\n" + fm = parse_document(original).frontmatter + assert fm is not None + rendered = _md.frontmatter(fm) + "\n# Hi\n" + fm2 = parse_document(rendered).frontmatter + assert fm2 is not None + assert fm2.description == "Round trip" + assert fm2.image == "/round/trip.webp" + + def test_h1(): """A level-1 heading is parsed correctly.""" doc = parse_document("# Title\n") From dc2a6c5cae0eff0580916f9f668f0ac2e937a87a Mon Sep 17 00:00:00 2001 From: Carlos Date: Fri, 22 May 2026 11:26:28 +0200 Subject: [PATCH 2/4] updates --- docs/app/agent_files/_plugin.py | 5 +---- .../src/reflex_site_shared/utils/url.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/app/agent_files/_plugin.py b/docs/app/agent_files/_plugin.py index 1af9306a3e2..2da28d56630 100644 --- a/docs/app/agent_files/_plugin.py +++ b/docs/app/agent_files/_plugin.py @@ -9,6 +9,7 @@ from reflex.constants import Dirs from reflex_base.config import get_config from reflex_base.plugins import CommonContext, Plugin +from reflex_site_shared.utils.url import public_url from typing_extensions import Unpack MCP_DOC_PATHS = { @@ -113,15 +114,11 @@ def _extract_markdown_title(source: str) -> str | None: def _llms_url_for_path(url_path: Path) -> str: """Return the public URL for a generated markdown asset.""" - from reflex_site_shared.utils.url import public_url - return public_url(f"/{url_path.as_posix()}") def _docs_home_url() -> str: """Return the public URL for the docs home.""" - from reflex_site_shared.utils.url import public_url - return public_url("/") diff --git a/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py b/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py index a85d1eb5306..4f306b54775 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py @@ -2,6 +2,10 @@ from __future__ import annotations +from reflex_base.config import get_config + +from reflex.utils.exec import is_prod_mode + def public_url(path: str = "", fallback_base: str | None = None) -> str: """Build a public URL from ``deploy_url`` + ``frontend_path`` in rxconfig. @@ -14,20 +18,18 @@ def public_url(path: str = "", fallback_base: str | None = None) -> str: path: Path segment to append. Should start with ``/`` to form a clean URL, or be empty to return the base alone. fallback_base: Base URL (already including any frontend_path prefix, - e.g. ``https://reflex.dev/docs``) to use when ``deploy_url`` is - unset or points at localhost. When ``None``, the returned URL - may be relative (no scheme/host). + e.g. ``https://reflex.dev/docs``) to use outside of prod mode. + When ``None``, the returned URL may be relative (no scheme/host). Returns: The combined URL. If neither a deploy URL, frontend path, nor a fallback base is configured, returns ``path`` unchanged. """ - from reflex_base.config import get_config + if fallback_base is not None and not is_prod_mode(): + return fallback_base.rstrip("/") + path config = get_config() deploy_url = (config.deploy_url or "").removesuffix("/") - if fallback_base is not None and (not deploy_url or "localhost" in deploy_url): - return fallback_base.rstrip("/") + path frontend_path = (config.frontend_path or "").strip("/") if frontend_path: base = f"{deploy_url}/{frontend_path}" if deploy_url else f"/{frontend_path}" From 71d0a7aaebe1198773c32b936d2785c019d5d62a Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 1 Aug 2026 02:40:53 +0500 Subject: [PATCH 3/4] Fix docs-tests config patching and add docgen news fragment The agent_files tests patched get_config only in agent_files._plugin, but public URLs are now built via reflex_site_shared.utils.url.public_url which reads the config through its own import; patch both read sites. Add the towncrier news fragment for reflex-docgen's new frontmatter fields. --- docs/app/tests/test_agent_files.py | 52 +++++++-------------- packages/reflex-docgen/news/6464.feature.md | 1 + 2 files changed, 18 insertions(+), 35 deletions(-) create mode 100644 packages/reflex-docgen/news/6464.feature.md diff --git a/docs/app/tests/test_agent_files.py b/docs/app/tests/test_agent_files.py index 255b2d295e0..c223060eeba 100644 --- a/docs/app/tests/test_agent_files.py +++ b/docs/app/tests/test_agent_files.py @@ -16,15 +16,21 @@ ) +def _patch_config(monkeypatch, deploy_url: str, frontend_path: str = "/docs"): + """Patch the site config everywhere it is read. + + Public URLs are built via ``reflex_site_shared.utils.url.public_url``, which + reads the config through its own module-level ``get_config`` import, so + patching only ``agent_files._plugin.get_config`` is not enough. + """ + config = SimpleNamespace(deploy_url=deploy_url, frontend_path=frontend_path) + monkeypatch.setattr("agent_files._plugin.get_config", lambda: config) + monkeypatch.setattr("reflex_site_shared.utils.url.get_config", lambda: config) + + def test_generate_llms_txt_groups_docs_at_public_root(monkeypatch): """The docs mount exposes public-root llms.txt as /docs/llms.txt.""" - monkeypatch.setattr( - "agent_files._plugin.get_config", - lambda: SimpleNamespace( - deploy_url="https://reflex.dev", - frontend_path="/docs", - ), - ) + _patch_config(monkeypatch, deploy_url="https://reflex.dev") path, content = generate_llms_txt([ MarkdownIndexEntry( @@ -127,13 +133,7 @@ def test_generate_llms_txt_groups_docs_at_public_root(monkeypatch): def test_generate_markdown_file_content_adds_agent_directive(monkeypatch, tmp_path): """Generated markdown pages advertise the docs index and markdown access.""" - monkeypatch.setattr( - "agent_files._plugin.get_config", - lambda: SimpleNamespace( - deploy_url="http://localhost:3000", - frontend_path="/docs", - ), - ) + _patch_config(monkeypatch, deploy_url="http://localhost:3000") source = tmp_path / "overview.md" source.write_text( "# Overview\n\nBuild full-stack apps in Python.\n", @@ -161,13 +161,7 @@ def test_generate_markdown_file_content_appends_component_props_table( monkeypatch, tmp_path ): """Component docs markdown includes generated API reference props tables.""" - monkeypatch.setattr( - "agent_files._plugin.get_config", - lambda: SimpleNamespace( - deploy_url="https://reflex.dev", - frontend_path="/docs", - ), - ) + _patch_config(monkeypatch, deploy_url="https://reflex.dev") source = tmp_path / "button.md" source.write_text( "---\n" @@ -217,13 +211,7 @@ def test_generate_markdown_file_content_appends_component_props_table( def test_generate_dynamic_api_reference_files(monkeypatch): """Dynamic API reference pages have generated markdown assets.""" - monkeypatch.setattr( - "agent_files._plugin.get_config", - lambda: SimpleNamespace( - deploy_url="https://reflex.dev", - frontend_path="/docs", - ), - ) + _patch_config(monkeypatch, deploy_url="https://reflex.dev") raw_files = generate_dynamic_api_reference_files() files = dict(raw_files) @@ -281,13 +269,7 @@ def test_section_for_root_level_markdown_strips_extension(): def test_generate_llms_full_txt_stitches_markdown_docs(monkeypatch, tmp_path): """llms-full.txt contains full Markdown page bodies with source URLs.""" - monkeypatch.setattr( - "agent_files._plugin.get_config", - lambda: SimpleNamespace( - deploy_url="https://reflex.dev", - frontend_path="/docs", - ), - ) + _patch_config(monkeypatch, deploy_url="https://reflex.dev") introduction = tmp_path / "introduction.md" introduction.write_text( "# Introduction\n\nBuild full-stack apps in Python.\n", diff --git a/packages/reflex-docgen/news/6464.feature.md b/packages/reflex-docgen/news/6464.feature.md new file mode 100644 index 00000000000..eb3b07dde3a --- /dev/null +++ b/packages/reflex-docgen/news/6464.feature.md @@ -0,0 +1 @@ +Added typed `description` and `image` frontmatter fields to `FrontMatter`, parsed from docs and preserved by the markdown writer, for per-page SEO metadata. From 11a7bc869ee546d544280f7db6d93dea2bd7f5c8 Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 1 Aug 2026 03:01:11 +0500 Subject: [PATCH 4/4] Address review findings: guard frontmatter reads, normalize deploy_url - _frontmatter_for now tolerates read failures (returning None like docs without frontmatter) so an unreadable file can't abort route registration, and parses only the frontmatter block instead of the full document, keeping the per-doc startup cost minimal. - public_url strips all trailing slashes from deploy_url instead of one. - Regression tests for both. --- docs/app/reflex_docs/pages/docs/__init__.py | 22 +++++++-- docs/app/tests/test_frontmatter_meta.py | 40 +++++++++++++++ .../src/reflex_site_shared/utils/url.py | 2 +- .../reflex_site_shared/utils/test_url.py | 49 +++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 docs/app/tests/test_frontmatter_meta.py create mode 100644 tests/units/reflex_site_shared/utils/test_url.py diff --git a/docs/app/reflex_docs/pages/docs/__init__.py b/docs/app/reflex_docs/pages/docs/__init__.py index b96d96b7ae6..178fb537a12 100644 --- a/docs/app/reflex_docs/pages/docs/__init__.py +++ b/docs/app/reflex_docs/pages/docs/__init__.py @@ -74,11 +74,27 @@ def build_nested_namespace( return parent_namespace +# Leading YAML frontmatter block, mirroring reflex_docgen's parser. +_FRONTMATTER_BLOCK_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL) + + @lru_cache(maxsize=None) def _frontmatter_for(filepath: str) -> FrontMatter | None: - """Parse a doc's frontmatter once per file (cached for the process lifetime).""" - source = Path(filepath).read_text(encoding="utf-8") - return parse_document(source).frontmatter + """Parse a doc's frontmatter once per file (cached for the process lifetime). + + Only the frontmatter block is fed to the parser so this stays cheap even + when called for every doc at startup; the body is parsed separately by the + rendering pipeline. Read failures yield None (like docs without + frontmatter) so a missing/unreadable file can't abort route registration. + """ + try: + source = Path(filepath).read_text(encoding="utf-8") + except OSError: + return None + block = _FRONTMATTER_BLOCK_RE.match(source) + if block is None: + return None + return parse_document(block.group(0)).frontmatter def get_components_from_frontmatter(filepath: str) -> list: diff --git a/docs/app/tests/test_frontmatter_meta.py b/docs/app/tests/test_frontmatter_meta.py new file mode 100644 index 00000000000..fe0c5254afe --- /dev/null +++ b/docs/app/tests/test_frontmatter_meta.py @@ -0,0 +1,40 @@ +"""Unit tests for the frontmatter metadata helpers in reflex_docs.pages.docs.""" + +from reflex_docs.pages.docs import _frontmatter_for, get_image_from_frontmatter + + +def test_frontmatter_for_extracts_fields(tmp_path): + """Frontmatter fields are parsed from a doc with a body.""" + doc = tmp_path / "page.md" + doc.write_text( + "---\ntitle: Page\nimage: /previews/page.webp\n---\n\n# Page\n\nBody prose.\n", + encoding="utf-8", + ) + fm = _frontmatter_for(str(doc)) + assert fm is not None + assert fm.title == "Page" + assert fm.image == "/previews/page.webp" + + +def test_frontmatter_for_none_without_frontmatter(tmp_path): + """Docs without a frontmatter block yield None.""" + doc = tmp_path / "plain.md" + doc.write_text("# Plain\n\nNo frontmatter here.\n", encoding="utf-8") + assert _frontmatter_for(str(doc)) is None + + +def test_frontmatter_for_unreadable_file_returns_none(tmp_path): + """A failed read yields None instead of aborting route registration.""" + assert _frontmatter_for(str(tmp_path / "missing.md")) is None + + +def test_get_image_from_frontmatter(tmp_path): + """The image helper returns the frontmatter image or None.""" + with_image = tmp_path / "with_image.md" + with_image.write_text( + "---\nimage: /previews/foo.webp\n---\n\n# T\n", encoding="utf-8" + ) + without_image = tmp_path / "without_image.md" + without_image.write_text("---\ntitle: T\n---\n\n# T\n", encoding="utf-8") + assert get_image_from_frontmatter(str(with_image)) == "/previews/foo.webp" + assert get_image_from_frontmatter(str(without_image)) is None diff --git a/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py b/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py index 4f306b54775..3568a437dfb 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/utils/url.py @@ -29,7 +29,7 @@ def public_url(path: str = "", fallback_base: str | None = None) -> str: return fallback_base.rstrip("/") + path config = get_config() - deploy_url = (config.deploy_url or "").removesuffix("/") + deploy_url = (config.deploy_url or "").rstrip("/") frontend_path = (config.frontend_path or "").strip("/") if frontend_path: base = f"{deploy_url}/{frontend_path}" if deploy_url else f"/{frontend_path}" diff --git a/tests/units/reflex_site_shared/utils/test_url.py b/tests/units/reflex_site_shared/utils/test_url.py new file mode 100644 index 00000000000..8aa46e0c84d --- /dev/null +++ b/tests/units/reflex_site_shared/utils/test_url.py @@ -0,0 +1,49 @@ +"""Unit tests for reflex_site_shared.utils.url.""" + +from types import SimpleNamespace + +import pytest +from reflex_site_shared.utils.url import public_url + + +@pytest.fixture +def patch_config(monkeypatch): + """Patch the config read by public_url with the given values. + + Returns: + A function taking (deploy_url, frontend_path) that installs the patch. + """ + + def _patch(deploy_url: str | None, frontend_path: str | None): + config = SimpleNamespace(deploy_url=deploy_url, frontend_path=frontend_path) + monkeypatch.setattr("reflex_site_shared.utils.url.get_config", lambda: config) + + return _patch + + +def test_public_url_joins_deploy_url_and_frontend_path(patch_config): + patch_config("https://reflex.dev", "/docs") + assert public_url("/llms.txt") == "https://reflex.dev/docs/llms.txt" + + +def test_public_url_strips_all_trailing_slashes_from_deploy_url(patch_config): + patch_config("https://reflex.dev//", "/docs") + assert public_url("/llms.txt") == "https://reflex.dev/docs/llms.txt" + + +def test_public_url_without_frontend_path(patch_config): + patch_config("https://reflex.dev/", None) + assert public_url("/llms.txt") == "https://reflex.dev/llms.txt" + + +def test_public_url_relative_when_unconfigured(patch_config): + patch_config(None, None) + assert public_url("/llms.txt") == "/llms.txt" + + +def test_public_url_fallback_base_outside_prod(monkeypatch): + monkeypatch.setattr("reflex_site_shared.utils.url.is_prod_mode", lambda: False) + assert ( + public_url("/state/", fallback_base="https://reflex.dev/docs/") + == "https://reflex.dev/docs/state/" + )