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")