From 69830b0637486cf26ff8f73d55d7565f910e7720 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 10:45:56 -0700 Subject: [PATCH 01/13] ci(docs): run docs/app tests in CI and guard Upload from being evaluated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a docs_tests workflow that runs `pytest tests/` in docs/app on changes to docs/**, plus a new test that loads `reflex_docs.pages` in a subprocess and asserts `Upload.is_used` stayed False — catches regressions where an `rx.upload(...)` call sneaks into an executed markdown block or a frontmatter preview lambda. Fixes the one regression the new check surfaced: the `Upload:` preview lambda in `library/forms/upload.md` frontmatter was being eval'd at route-build time, instantiating an upload component and forcing the docs site to mount the upload endpoint. Removing the lambda falls through to the non-interactive fragment fallback in `component.py:540-541`, so no Upload is constructed. Side cleanup along the way: - `test_agent_files.py` monkeypatched `reflex_base.config.get_config`, but `agent_files._plugin` does `from reflex_base.config import get_config` at module load. Patch `agent_files._plugin.get_config` instead — 3 tests had silently rotted because docs/app tests never ran in CI. - Delete unused docs/app/tests/conftest.py (Playwright fixtures + video hook used by no test), utils.py (helper imported by no test), and the orphan tests/assets/chakra_color_mode_provider.js asset. --- .github/workflows/docs_tests.yml | 40 ++++++ .../assets/chakra_color_mode_provider.js | 36 ----- docs/app/tests/conftest.py | 130 ------------------ docs/app/tests/test_agent_files.py | 10 +- docs/app/tests/test_upload_not_evaluated.py | 44 ++++++ docs/app/tests/utils.py | 25 ---- docs/library/forms/upload.md | 5 +- 7 files changed, 90 insertions(+), 200 deletions(-) create mode 100644 .github/workflows/docs_tests.yml delete mode 100644 docs/app/tests/assets/chakra_color_mode_provider.js delete mode 100644 docs/app/tests/conftest.py create mode 100644 docs/app/tests/test_upload_not_evaluated.py delete mode 100644 docs/app/tests/utils.py diff --git a/.github/workflows/docs_tests.yml b/.github/workflows/docs_tests.yml new file mode 100644 index 00000000000..72d8961fd5c --- /dev/null +++ b/.github/workflows/docs_tests.yml @@ -0,0 +1,40 @@ +name: docs tests + +on: + pull_request: + branches: ["main"] + paths: + - 'docs/**' + - 'packages/reflex-components-core/src/reflex_components_core/core/upload.py' + - '.github/workflows/docs_tests.yml' + push: + branches: ["main"] + paths: + - 'docs/**' + - 'packages/reflex-components-core/src/reflex_components_core/core/upload.py' + - '.github/workflows/docs_tests.yml' + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + docs-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + - uses: ./.github/actions/setup_build_env + with: + python-version: "3.12" + run-uv-sync: true + - name: Run docs app tests + working-directory: ./docs/app + run: uv run --no-sync pytest tests/ -v diff --git a/docs/app/tests/assets/chakra_color_mode_provider.js b/docs/app/tests/assets/chakra_color_mode_provider.js deleted file mode 100644 index c950d4e29db..00000000000 --- a/docs/app/tests/assets/chakra_color_mode_provider.js +++ /dev/null @@ -1,36 +0,0 @@ -import { useColorMode as chakraUseColorMode } from "@chakra-ui/react"; -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; -import { ColorModeContext, defaultColorMode } from "$/utils/context.js"; - -export default function ChakraColorModeProvider({ children }) { - const { theme, resolvedTheme, setTheme } = useTheme(); - const { colorMode, toggleColorMode } = chakraUseColorMode(); - const [resolvedColorMode, setResolvedColorMode] = useState(colorMode); - - useEffect(() => { - if (colorMode != resolvedTheme) { - toggleColorMode(); - } - setResolvedColorMode(resolvedTheme); - }, [theme, resolvedTheme]); - - const rawColorMode = colorMode; - const setColorMode = (mode) => { - const allowedModes = ["light", "dark", "system"]; - if (!allowedModes.includes(mode)) { - console.error( - `Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".`, - ); - mode = defaultColorMode; - } - setTheme(mode); - }; - return ( - - {children} - - ); -} diff --git a/docs/app/tests/conftest.py b/docs/app/tests/conftest.py deleted file mode 100644 index d1b9a7e77ef..00000000000 --- a/docs/app/tests/conftest.py +++ /dev/null @@ -1,130 +0,0 @@ -import sys -from pathlib import Path - -import pytest -from reflex.testing import AppHarness - -# Add tests directory to Python path for absolute imports -sys.path.insert(0, str(Path(__file__).parent)) - - -@pytest.fixture(scope="session") -def reflex_web_app(): - app_root = Path(__file__).parent.parent - from reflex_docs.whitelist import WHITELISTED_PAGES - - WHITELISTED_PAGES.extend([ - "/events", - "/vars", - "/getting-started", - "/library/graphing", - "/api-reference/special-events", - ]) - - with AppHarness.create(root=app_root) as harness: - yield harness - - -@pytest.fixture -def browser_context_args(): - """Configure browser context with video recording.""" - return { - "record_video_dir": "test-videos/", - "record_video_size": {"width": 1280, "height": 720}, - } - - -@pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item, call): - """Create metadata mapping for video files on test failure and clean up videos for passed tests.""" - outcome = yield - report = outcome.get_result() - - # Handle test completion (both pass and fail) - if report.when == "call": - page = None - if hasattr(item, "funcargs"): - if "page" in item.funcargs: - page = item.funcargs["page"] - else: - # Look for page object in other fixtures - for fixture_value in item.funcargs.values(): - if hasattr(fixture_value, "page") and hasattr( - fixture_value.page, "video" - ): - page = fixture_value.page - break - - if page and hasattr(page, "video") and page.video: - try: - import time - - video_path = None - for _ in range(3): - try: - video_path = page.video.path() - if video_path and Path(video_path).exists(): - break - except Exception: - time.sleep(0.5) - - if not video_path: - print(f"Failed to get video path for test: {item.name}") - return - - video_file = Path(video_path) - - if report.failed: - # Test failed - keep video and create metadata - test_name = item.name - - import fcntl - import json - import os - - split_index = os.environ.get("PYTEST_SPLIT_INDEX", "1") - metadata_file = ( - Path("test-videos") / f"video_metadata_{split_index}.json" - ) - metadata_file.parent.mkdir(exist_ok=True) - - with metadata_file.open("a+") as f: - fcntl.flock(f.fileno(), fcntl.LOCK_EX) - f.seek(0) - try: - content = f.read() - metadata = json.loads(content) if content.strip() else {} - except (json.JSONDecodeError, ValueError): - metadata = {} - - video_filename = video_file.name - metadata[video_filename] = test_name - - f.seek(0) - f.truncate() - json.dump(metadata, f, indent=2) - else: - # Test passed - remove video file - if video_file.exists(): - video_file.unlink() - - except Exception as e: - print(f"Failed to process video for test {item.name}: {e}") - import traceback - - traceback.print_exc() - else: - if report.failed: - print(f"No video available for failed test: {item.name}") - video_dir = Path("test-videos") - if video_dir.exists(): - import time - - recent_videos = [ - f - for f in video_dir.glob("*.webm") - if f.stat().st_mtime > (time.time() - 60) - ] - print( - f"Recent video files found: {[f.name for f in recent_videos]}" - ) diff --git a/docs/app/tests/test_agent_files.py b/docs/app/tests/test_agent_files.py index 96fc6f2d831..382cd2111bc 100644 --- a/docs/app/tests/test_agent_files.py +++ b/docs/app/tests/test_agent_files.py @@ -19,7 +19,7 @@ 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( - "reflex_base.config.get_config", + "agent_files._plugin.get_config", lambda: SimpleNamespace( deploy_url="https://reflex.dev", frontend_path="/docs", @@ -128,7 +128,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( - "reflex_base.config.get_config", + "agent_files._plugin.get_config", lambda: SimpleNamespace( deploy_url="http://localhost:3000", frontend_path="/docs", @@ -162,7 +162,7 @@ def test_generate_markdown_file_content_appends_component_props_table( ): """Component docs markdown includes generated API reference props tables.""" monkeypatch.setattr( - "reflex_base.config.get_config", + "agent_files._plugin.get_config", lambda: SimpleNamespace( deploy_url="https://reflex.dev", frontend_path="/docs", @@ -218,7 +218,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( - "reflex_base.config.get_config", + "agent_files._plugin.get_config", lambda: SimpleNamespace( deploy_url="https://reflex.dev", frontend_path="/docs", @@ -274,7 +274,7 @@ def test_generate_dynamic_api_reference_files(monkeypatch): 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( - "reflex_base.config.get_config", + "agent_files._plugin.get_config", lambda: SimpleNamespace( deploy_url="https://reflex.dev", frontend_path="/docs", diff --git a/docs/app/tests/test_upload_not_evaluated.py b/docs/app/tests/test_upload_not_evaluated.py new file mode 100644 index 00000000000..7d8c8ddca20 --- /dev/null +++ b/docs/app/tests/test_upload_not_evaluated.py @@ -0,0 +1,44 @@ +"""Enforce that compiling the docs app does not evaluate `rx.upload`. + +`Upload.is_used` is a class-level flag flipped to ``True`` whenever +``rx.upload(...)`` / ``rx.upload.root(...)`` is constructed. When the docs site +is compiled, that flag (and the on-disk marker it writes) causes the upload +HTTP endpoint to be registered. The docs site never needs upload functionality, +so any `python exec` / `python demo` / `python demo exec` / `python eval` block +that constructs an upload component is a regression — code samples for +``rx.upload`` must stay in plain ```python``` fenced blocks. The same goes for +the ``components:`` frontmatter preview lambdas rendered by the docgen pipeline. +""" + +import subprocess +import sys + + +def test_compiling_docs_does_not_evaluate_upload(): + """A fresh import of the docs app must not flip ``Upload.is_used``. + + Runs in a subprocess: the flag is sticky once flipped and reflex_docs + modules get cached in ``sys.modules`` after the first import, so checking + in-process would only catch regressions when this test happens to run + before any other docs test imports ``reflex_docs.pages``. + """ + code = ( + "from reflex_components_core.core.upload import Upload\n" + "import reflex_docs.pages # builds all routes; renders every doc\n" + "import sys\n" + "sys.exit(1 if Upload.is_used else 0)\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, ( + "Compiling the docs flipped Upload.is_used = True. " + "An rx.upload(...) call leaked into an executed block " + "(python exec / python demo / python demo exec / python eval) " + "or into a ``components:`` frontmatter preview lambda. " + "Move the upload sample into a plain ```python fenced block.\n\n" + f"subprocess stderr:\n{result.stderr}" + ) diff --git a/docs/app/tests/utils.py b/docs/app/tests/utils.py deleted file mode 100644 index e7682841b8b..00000000000 --- a/docs/app/tests/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Test utilities for reflex-web tests.""" - -from urllib.parse import urljoin - -from reflex.testing import AppHarness - - -def get_full_url(app_harness: AppHarness, path: str) -> str: - """Properly join the app's frontend URL with a path. - - This ensures proper URL construction without double slashes, - which is important since React Router is stricter than Next.js - about URL formatting. - - Args: - app_harness: The AppHarness instance - path: The path to join (should start with /) - - Returns: - The properly joined full URL - """ - if not app_harness.frontend_url: - raise ValueError("App harness frontend_url is None") - - return urljoin(app_harness.frontend_url, path) diff --git a/docs/library/forms/upload.md b/docs/library/forms/upload.md index 38e93a1553f..f41615b5116 100644 --- a/docs/library/forms/upload.md +++ b/docs/library/forms/upload.md @@ -2,9 +2,6 @@ components: - rx.upload - rx.upload.root - -Upload: | - lambda **props: rx.center(rx.upload(id="my_upload", **props)) --- ```python exec @@ -370,7 +367,7 @@ def index(): To use a completely unstyled upload component and apply your own customization, use `rx.upload.root` instead: -```python demo +```python rx.upload.root( rx.box( rx.icon( From 60364731f6a102ea8256769e96c847ed4451c533 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:01:57 -0700 Subject: [PATCH 02/13] test: drop subprocess, use a plain `routes_fixture` like marketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match marketing/tests/test_routes.py: import the routes module via a pytest fixture, then assert `Upload.is_used` stayed False. No subprocess plumbing — the assertion sees the current flag, which reflects whether *any* code path constructed an Upload component during compilation, so the check works regardless of test order. --- docs/app/tests/test_upload_not_evaluated.py | 43 +++++++++------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/docs/app/tests/test_upload_not_evaluated.py b/docs/app/tests/test_upload_not_evaluated.py index 7d8c8ddca20..0922dd01510 100644 --- a/docs/app/tests/test_upload_not_evaluated.py +++ b/docs/app/tests/test_upload_not_evaluated.py @@ -1,4 +1,4 @@ -"""Enforce that compiling the docs app does not evaluate `rx.upload`. +"""Enforce that compiling the docs does not evaluate `rx.upload`. `Upload.is_used` is a class-level flag flipped to ``True`` whenever ``rx.upload(...)`` / ``rx.upload.root(...)`` is constructed. When the docs site @@ -10,35 +10,30 @@ the ``components:`` frontmatter preview lambdas rendered by the docgen pipeline. """ -import subprocess import sys +from pathlib import Path +import pytest -def test_compiling_docs_does_not_evaluate_upload(): - """A fresh import of the docs app must not flip ``Upload.is_used``. +sys.path.append(str(Path(__file__).resolve().parent.parent)) - Runs in a subprocess: the flag is sticky once flipped and reflex_docs - modules get cached in ``sys.modules`` after the first import, so checking - in-process would only catch regressions when this test happens to run - before any other docs test imports ``reflex_docs.pages``. - """ - code = ( - "from reflex_components_core.core.upload import Upload\n" - "import reflex_docs.pages # builds all routes; renders every doc\n" - "import sys\n" - "sys.exit(1 if Upload.is_used else 0)\n" - ) - result = subprocess.run( - [sys.executable, "-c", code], - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, ( + +@pytest.fixture +def routes_fixture(): + from reflex_docs.pages import routes + + yield routes + + +def test_compiling_docs_does_not_evaluate_upload(routes_fixture): + """Loading the docs route tree must not flip ``Upload.is_used``.""" + from reflex_components_core.core.upload import Upload + + assert routes_fixture is not None + assert not Upload.is_used, ( "Compiling the docs flipped Upload.is_used = True. " "An rx.upload(...) call leaked into an executed block " "(python exec / python demo / python demo exec / python eval) " "or into a ``components:`` frontmatter preview lambda. " - "Move the upload sample into a plain ```python fenced block.\n\n" - f"subprocess stderr:\n{result.stderr}" + "Move the upload sample into a plain ```python fenced block." ) From bb76b52b4adcaaa01ff808f8a1e4be943e43c68d Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:03:03 -0700 Subject: [PATCH 03/13] =?UTF-8?q?test:=20drop=20sys.path=20hack=20?= =?UTF-8?q?=E2=80=94=20reflex=5Fdocs=20is=20installed=20in=20the=20workspa?= =?UTF-8?q?ce=20venv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/app/tests/test_upload_not_evaluated.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/app/tests/test_upload_not_evaluated.py b/docs/app/tests/test_upload_not_evaluated.py index 0922dd01510..6b6ebce789c 100644 --- a/docs/app/tests/test_upload_not_evaluated.py +++ b/docs/app/tests/test_upload_not_evaluated.py @@ -10,13 +10,8 @@ the ``components:`` frontmatter preview lambdas rendered by the docgen pipeline. """ -import sys -from pathlib import Path - import pytest -sys.path.append(str(Path(__file__).resolve().parent.parent)) - @pytest.fixture def routes_fixture(): From 0ec04b63b2dbeebcd6903d6651ee26c280615d40 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:06:19 -0700 Subject: [PATCH 04/13] test(docs): drop sys.path manipulation from docs/app tests - test_breadcrumbs, test_docgen_double_eval, test_routes: imports resolve through the workspace venv (`reflex_docs` is installed as a workspace member), so `sys.path.append('docs/app')` was redundant. - test_doc_links: `check_doc_links` is a bare script at docs/app/scripts/, not a package. Load it via `importlib.util` from its file path instead of poking sys.path. --- docs/app/tests/test_breadcrumbs.py | 4 ---- docs/app/tests/test_doc_links.py | 14 ++++++++++---- docs/app/tests/test_docgen_double_eval.py | 3 --- docs/app/tests/test_routes.py | 4 ---- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/docs/app/tests/test_breadcrumbs.py b/docs/app/tests/test_breadcrumbs.py index 30f122c80ad..0034767b6cb 100644 --- a/docs/app/tests/test_breadcrumbs.py +++ b/docs/app/tests/test_breadcrumbs.py @@ -1,13 +1,9 @@ """Tests for docs breadcrumbs.""" import importlib -import sys -from pathlib import Path import reflex as rx -sys.path.append(str(Path(__file__).resolve().parent.parent)) - def test_enterprise_parent_breadcrumb_uses_overview_route(monkeypatch): """Parent breadcrumbs should link to a real overview route when needed.""" diff --git a/docs/app/tests/test_doc_links.py b/docs/app/tests/test_doc_links.py index d6ac011f6ab..c887ee96193 100644 --- a/docs/app/tests/test_doc_links.py +++ b/docs/app/tests/test_doc_links.py @@ -1,13 +1,19 @@ """Unit tests for scripts/check_doc_links.py.""" -import sys +import importlib.util from pathlib import Path import pytest -sys.path.append(str(Path(__file__).resolve().parent.parent / "scripts")) - -from check_doc_links import _normalize, check +_check_doc_links_path = ( + Path(__file__).resolve().parent.parent / "scripts" / "check_doc_links.py" +) +_spec = importlib.util.spec_from_file_location("check_doc_links", _check_doc_links_path) +assert _spec is not None and _spec.loader is not None +_check_doc_links = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_check_doc_links) +_normalize = _check_doc_links._normalize +check = _check_doc_links.check SITEMAP_XML = """ diff --git a/docs/app/tests/test_docgen_double_eval.py b/docs/app/tests/test_docgen_double_eval.py index eb3882d6a1d..0667f064827 100644 --- a/docs/app/tests/test_docgen_double_eval.py +++ b/docs/app/tests/test_docgen_double_eval.py @@ -7,13 +7,10 @@ _exec_and_get_last_callable finds no "new" keys and raises RuntimeError. """ -import sys from pathlib import Path import pytest -sys.path.insert(0, str(Path(__file__).parent.parent)) - MD_WITH_TWO_EXEC_BLOCKS = """\ ```python exec diff --git a/docs/app/tests/test_routes.py b/docs/app/tests/test_routes.py index a75eff34751..f764d3adc0c 100644 --- a/docs/app/tests/test_routes.py +++ b/docs/app/tests/test_routes.py @@ -1,13 +1,9 @@ """Integration tests for all routes in Reflex.""" -import sys from collections import Counter -from pathlib import Path import pytest -sys.path.append(str(Path(__file__).resolve().parent.parent)) - @pytest.fixture def routes_fixture(): From 3d36a6be82ed372cd1ecce49eac94fb4167344c8 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:13:00 -0700 Subject: [PATCH 05/13] refactor(docs): move check_doc_links into reflex_docs.scripts package `docs/app/scripts/check_doc_links.py` was a bare script that test_doc_links.py could only import via sys.path or importlib.util gymnastics. Move it into the installed `reflex_docs` package so the test (and anyone else) can `from reflex_docs.scripts.check_doc_links import ...` directly. - Move the file to `docs/app/reflex_docs/scripts/check_doc_links.py` with an `__init__.py` so it's a proper package. - Update the default `--md-root` / `--sitemap` paths since the script is two directories deeper now. - Switch the integration workflow to `python -m reflex_docs.scripts.check_doc_links`. - Drop the importlib spec/loader block from test_doc_links.py in favour of a normal import. --- docs/app/.github/workflows/integration_tests.yml | 2 +- docs/app/reflex_docs/scripts/__init__.py | 0 .../{ => reflex_docs}/scripts/check_doc_links.py | 13 +++++++------ docs/app/tests/test_doc_links.py | 13 ++----------- 4 files changed, 10 insertions(+), 18 deletions(-) create mode 100644 docs/app/reflex_docs/scripts/__init__.py rename docs/app/{ => reflex_docs}/scripts/check_doc_links.py (94%) diff --git a/docs/app/.github/workflows/integration_tests.yml b/docs/app/.github/workflows/integration_tests.yml index f721446c837..53c3b0d5a21 100644 --- a/docs/app/.github/workflows/integration_tests.yml +++ b/docs/app/.github/workflows/integration_tests.yml @@ -65,4 +65,4 @@ jobs: run: reflex export - name: Validate /docs links against generated sitemap - run: uv run python scripts/check_doc_links.py + run: uv run python -m reflex_docs.scripts.check_doc_links diff --git a/docs/app/reflex_docs/scripts/__init__.py b/docs/app/reflex_docs/scripts/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/app/scripts/check_doc_links.py b/docs/app/reflex_docs/scripts/check_doc_links.py similarity index 94% rename from docs/app/scripts/check_doc_links.py rename to docs/app/reflex_docs/scripts/check_doc_links.py index 238ec4bbdbd..41d466ec5df 100644 --- a/docs/app/scripts/check_doc_links.py +++ b/docs/app/reflex_docs/scripts/check_doc_links.py @@ -14,7 +14,7 @@ cd docs/app uv run reflex export --frontend-only --no-zip - uv run python scripts/check_doc_links.py + uv run python -m reflex_docs.scripts.check_doc_links """ from __future__ import annotations @@ -192,18 +192,19 @@ def check(md_root: Path, sitemap_path: Path) -> list[str]: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - here = Path(__file__).resolve().parent + # docs/app/reflex_docs/scripts/check_doc_links.py — climb out to docs/. + docs_app = Path(__file__).resolve().parents[2] parser.add_argument( "--md-root", type=Path, - default=here.parent.parent, - help="Root directory containing .md docs (default: ../..).", + default=docs_app.parent, + help="Root directory containing .md docs (default: /docs).", ) parser.add_argument( "--sitemap", type=Path, - default=here.parent / ".web" / "public" / "sitemap.xml", - help="Path to sitemap.xml (default: ../.web/public/sitemap.xml).", + default=docs_app / ".web" / "public" / "sitemap.xml", + help="Path to sitemap.xml (default: /docs/app/.web/public/sitemap.xml).", ) args = parser.parse_args() diff --git a/docs/app/tests/test_doc_links.py b/docs/app/tests/test_doc_links.py index c887ee96193..768a5bc127c 100644 --- a/docs/app/tests/test_doc_links.py +++ b/docs/app/tests/test_doc_links.py @@ -1,19 +1,10 @@ -"""Unit tests for scripts/check_doc_links.py.""" +"""Unit tests for reflex_docs.scripts.check_doc_links.""" -import importlib.util from pathlib import Path import pytest -_check_doc_links_path = ( - Path(__file__).resolve().parent.parent / "scripts" / "check_doc_links.py" -) -_spec = importlib.util.spec_from_file_location("check_doc_links", _check_doc_links_path) -assert _spec is not None and _spec.loader is not None -_check_doc_links = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_check_doc_links) -_normalize = _check_doc_links._normalize -check = _check_doc_links.check +from reflex_docs.scripts.check_doc_links import _normalize, check SITEMAP_XML = """ From 8a3154c38c448e5a87a32b10cf0b93ac271a8f2c Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:17:23 -0700 Subject: [PATCH 06/13] refactor(docs): drop check_doc_links CLI; drive the whole-docs check from pytest The script's only consumer was a workflow step; nothing ran it interactively. Strip `main()` + the argparse/sys imports and keep the module as a library only. Add `test_docs_links_against_exported_sitemap` to test_doc_links.py: it calls `check()` against the real docs root and the exported sitemap, and `pytest.skip`s when `.web/public/sitemap.xml` is absent so `pytest tests/` still passes without a build. Switch the integration workflow step to `uv run pytest tests/test_doc_links.py`. --- .../.github/workflows/integration_tests.yml | 2 +- .../reflex_docs/scripts/check_doc_links.py | 41 ++----------------- docs/app/tests/test_doc_links.py | 22 +++++++++- 3 files changed, 26 insertions(+), 39 deletions(-) diff --git a/docs/app/.github/workflows/integration_tests.yml b/docs/app/.github/workflows/integration_tests.yml index 53c3b0d5a21..4039e38f0d2 100644 --- a/docs/app/.github/workflows/integration_tests.yml +++ b/docs/app/.github/workflows/integration_tests.yml @@ -65,4 +65,4 @@ jobs: run: reflex export - name: Validate /docs links against generated sitemap - run: uv run python -m reflex_docs.scripts.check_doc_links + run: uv run pytest tests/test_doc_links.py -v diff --git a/docs/app/reflex_docs/scripts/check_doc_links.py b/docs/app/reflex_docs/scripts/check_doc_links.py index 41d466ec5df..28a98b293e2 100644 --- a/docs/app/reflex_docs/scripts/check_doc_links.py +++ b/docs/app/reflex_docs/scripts/check_doc_links.py @@ -10,17 +10,14 @@ correctly ignored, reference-style and multi-line links are caught, and escapes/edge cases are handled the same way the docs site renders them. -Run after building the frontend so .web/public/sitemap.xml is present: - - cd docs/app - uv run reflex export --frontend-only --no-zip - uv run python -m reflex_docs.scripts.check_doc_links +The whole-docs run is driven by +``tests/test_doc_links.py::test_docs_links_against_exported_sitemap``, +which requires ``reflex export`` to have populated +``.web/public/sitemap.xml``. """ from __future__ import annotations -import argparse -import sys import xml.etree.ElementTree as ET from collections.abc import Iterator from pathlib import Path @@ -190,33 +187,3 @@ def check(md_root: Path, sitemap_path: Path) -> list[str]: return errors -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - # docs/app/reflex_docs/scripts/check_doc_links.py — climb out to docs/. - docs_app = Path(__file__).resolve().parents[2] - parser.add_argument( - "--md-root", - type=Path, - default=docs_app.parent, - help="Root directory containing .md docs (default: /docs).", - ) - parser.add_argument( - "--sitemap", - type=Path, - default=docs_app / ".web" / "public" / "sitemap.xml", - help="Path to sitemap.xml (default: /docs/app/.web/public/sitemap.xml).", - ) - args = parser.parse_args() - - errors = check(args.md_root.resolve(), args.sitemap.resolve()) - if errors: - print(f"Found {len(errors)} broken /docs link(s):", file=sys.stderr) - for err in errors: - print(f" {err}", file=sys.stderr) - return 1 - print("All /docs links resolve against sitemap.xml.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docs/app/tests/test_doc_links.py b/docs/app/tests/test_doc_links.py index 768a5bc127c..a67e2b0f1c3 100644 --- a/docs/app/tests/test_doc_links.py +++ b/docs/app/tests/test_doc_links.py @@ -1,4 +1,4 @@ -"""Unit tests for reflex_docs.scripts.check_doc_links.""" +"""Tests for reflex_docs.scripts.check_doc_links.""" from pathlib import Path @@ -6,6 +6,26 @@ from reflex_docs.scripts.check_doc_links import _normalize, check +_DOCS_APP = Path(__file__).resolve().parent.parent # docs/app/ +_MD_ROOT = _DOCS_APP.parent # docs/ +_SITEMAP = _DOCS_APP / ".web" / "public" / "sitemap.xml" + + +def test_docs_links_against_exported_sitemap(): + """End-to-end check: every /docs link in real markdown resolves in the sitemap. + + Requires `reflex export` to have populated .web/public/sitemap.xml. Skips + otherwise so `pytest tests/` still passes without a build. + """ + if not _SITEMAP.is_file(): + pytest.skip( + f"Sitemap not found at {_SITEMAP}; run " + "`uv run reflex export --frontend-only --no-zip` first." + ) + + errors = check(_MD_ROOT, _SITEMAP) + assert not errors, "\n".join(errors) + SITEMAP_XML = """ http://localhost:3000/getting-started/basics/ From 685e18cccc5c9a736c048a7892ee422a0b60c049 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:21:36 -0700 Subject: [PATCH 07/13] ci(docs): run the /docs link check on the real exported sitemap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the link-check pytest into the `reflex-docs` job in the root integration_tests workflow: the existing prod-mode integration.sh already builds the site (populating .web/public/sitemap.xml), so a follow-up `pytest tests/test_doc_links.py` runs against a real sitemap instead of skipping. Also delete docs/app/.github/workflows/integration_tests.yml. That file was a leftover from when docs/app shipped as its own repository — GitHub Actions only reads .github/workflows/ at the repo root, so the file (and its sibling codespell.yml/pre-commit.yml/unit_tests.yml) has never run in this monorepo. The /docs link validation it described is now hosted by the active workflow above. --- .github/workflows/integration_tests.yml | 4 ++ .../.github/workflows/integration_tests.yml | 68 ------------------- 2 files changed, 4 insertions(+), 68 deletions(-) delete mode 100644 docs/app/.github/workflows/integration_tests.yml diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index b93db4b9016..bdb91e8c1a0 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -161,6 +161,10 @@ jobs: which npm && npm -v uv run --active --no-sync bash scripts/integration.sh ./docs/app prod + - name: Validate /docs links against generated sitemap + working-directory: ./docs/app + run: uv run --active --no-sync pytest tests/test_doc_links.py -v + - name: Upload Socket.dev Firewall report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/app/.github/workflows/integration_tests.yml b/docs/app/.github/workflows/integration_tests.yml deleted file mode 100644 index 4039e38f0d2..00000000000 --- a/docs/app/.github/workflows/integration_tests.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: integration-tests - -env: - TELEMETRY_ENABLED: false - NODE_OPTIONS: "--max_old_space_size=8192" - REFLEX_DEP: "git+https://github.com/reflex-dev/reflex@main" -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - workflow_dispatch: - inputs: - reflex_dep: - description: "Reflex dependency (full specifier)" - -permissions: - contents: read - -defaults: - run: - shell: bash - -jobs: - reflex-web: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - - - name: Install the latest version of uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.11" - activate-environment: true - - - name: Install the project with latest reflex from main - run: | - uv sync --all-extras --dev \ - --upgrade-package reflex \ - --upgrade-package reflex-core \ - --upgrade-package reflex-docgen \ - --upgrade-package reflex-components-code \ - --upgrade-package reflex-components-core \ - --upgrade-package reflex-components-dataeditor \ - --upgrade-package reflex-components-gridjs \ - --upgrade-package reflex-components-lucide \ - --upgrade-package reflex-components-markdown \ - --upgrade-package reflex-components-moment \ - --upgrade-package reflex-components-plotly \ - --upgrade-package reflex-components-radix \ - --upgrade-package reflex-components-react-player \ - --upgrade-package reflex-components-recharts \ - --upgrade-package reflex-components-sonner - - - name: Install custom reflex version (if specified) - if: github.event.inputs.reflex_dep - run: uv pip install '${{ github.event.inputs.reflex_dep }}' - - - name: Init Website for reflex-web - run: reflex init - - - name: Export the website - run: reflex export - - - name: Validate /docs links against generated sitemap - run: uv run pytest tests/test_doc_links.py -v From 7c991830ba7a7587dc51c2550f69d75381fc75fa Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:23:30 -0700 Subject: [PATCH 08/13] test(docs): turn the sitemap skip into a hard failure under CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipping the link test is fine for `pytest tests/` on a dev machine that hasn't run an export, but in CI a missing sitemap means the workflow forgot to build the docs — silently skipping there hides a broken pipeline. Promote the skip to `pytest.fail` when the `CI` env var is set (GitHub Actions, GitLab, CircleCI, etc. all set it). --- docs/app/tests/test_doc_links.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/app/tests/test_doc_links.py b/docs/app/tests/test_doc_links.py index a67e2b0f1c3..bc5f7c80672 100644 --- a/docs/app/tests/test_doc_links.py +++ b/docs/app/tests/test_doc_links.py @@ -1,5 +1,6 @@ """Tests for reflex_docs.scripts.check_doc_links.""" +import os from pathlib import Path import pytest @@ -14,14 +15,19 @@ def test_docs_links_against_exported_sitemap(): """End-to-end check: every /docs link in real markdown resolves in the sitemap. - Requires `reflex export` to have populated .web/public/sitemap.xml. Skips - otherwise so `pytest tests/` still passes without a build. + Requires `reflex export` to have populated .web/public/sitemap.xml. + Skipped locally when the sitemap is absent so `pytest tests/` works + without a build; in CI (``CI`` env var set) a missing sitemap is a hard + failure — that would mean the workflow forgot to build the site. """ if not _SITEMAP.is_file(): - pytest.skip( - f"Sitemap not found at {_SITEMAP}; run " - "`uv run reflex export --frontend-only --no-zip` first." + message = ( + f"Sitemap not found at {_SITEMAP}. Build the docs first " + "(e.g. `uv run reflex export --frontend-only --no-zip`)." ) + if os.environ.get("CI"): + pytest.fail(message) + pytest.skip(message) errors = check(_MD_ROOT, _SITEMAP) assert not errors, "\n".join(errors) From 6ba7cccd6f22e6146836594e1a0fdab0266122e0 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:33:38 -0700 Subject: [PATCH 09/13] test(docs): switch sitemap precondition to xfail + workflow --runxfail Use a pytest primitive instead of the env-var branching: mark the end-to-end link test xfail (run=False) when sitemap.xml is absent, and pass --runxfail in the CI step. Locally `pytest tests/` still no-ops the test when the docs haven't been built; in CI --runxfail neuters the marker so a missing sitemap surfaces as a real failure instead of a silent skip. --- .github/workflows/integration_tests.yml | 2 +- docs/app/tests/test_doc_links.py | 28 ++++++++++--------------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index bdb91e8c1a0..51a8469ed24 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -163,7 +163,7 @@ jobs: - name: Validate /docs links against generated sitemap working-directory: ./docs/app - run: uv run --active --no-sync pytest tests/test_doc_links.py -v + run: uv run --active --no-sync pytest --runxfail tests/test_doc_links.py -v - name: Upload Socket.dev Firewall report if: always() diff --git a/docs/app/tests/test_doc_links.py b/docs/app/tests/test_doc_links.py index bc5f7c80672..e30a2d66edf 100644 --- a/docs/app/tests/test_doc_links.py +++ b/docs/app/tests/test_doc_links.py @@ -1,6 +1,5 @@ """Tests for reflex_docs.scripts.check_doc_links.""" -import os from pathlib import Path import pytest @@ -12,23 +11,18 @@ _SITEMAP = _DOCS_APP / ".web" / "public" / "sitemap.xml" +@pytest.mark.xfail( + not _SITEMAP.is_file(), + reason=( + "Sitemap not generated; build the docs first " + "(e.g. `uv run reflex export --frontend-only --no-zip`). " + "CI passes `--runxfail` so the missing sitemap surfaces as a " + "real failure instead of a silent xfail." + ), + run=False, +) def test_docs_links_against_exported_sitemap(): - """End-to-end check: every /docs link in real markdown resolves in the sitemap. - - Requires `reflex export` to have populated .web/public/sitemap.xml. - Skipped locally when the sitemap is absent so `pytest tests/` works - without a build; in CI (``CI`` env var set) a missing sitemap is a hard - failure — that would mean the workflow forgot to build the site. - """ - if not _SITEMAP.is_file(): - message = ( - f"Sitemap not found at {_SITEMAP}. Build the docs first " - "(e.g. `uv run reflex export --frontend-only --no-zip`)." - ) - if os.environ.get("CI"): - pytest.fail(message) - pytest.skip(message) - + """End-to-end check: every /docs link in real markdown resolves in the sitemap.""" errors = check(_MD_ROOT, _SITEMAP) assert not errors, "\n".join(errors) From 649ab58c3cafe46bf1a1a84446d6acd115204c1c Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:36:52 -0700 Subject: [PATCH 10/13] refactor(docs): fold check_doc_links logic into test_doc_links.py The link checker's only consumer is its own test file; a dedicated module under reflex_docs.scripts was extra indirection for no benefit. Inline the helpers (`_normalize`, `_walk_blocks`, `check`, etc.) at the top of tests/test_doc_links.py and delete docs/app/reflex_docs/scripts/ entirely. --- docs/app/reflex_docs/scripts/__init__.py | 0 .../reflex_docs/scripts/check_doc_links.py | 189 ------------------ docs/app/tests/test_doc_links.py | 182 ++++++++++++++++- 3 files changed, 180 insertions(+), 191 deletions(-) delete mode 100644 docs/app/reflex_docs/scripts/__init__.py delete mode 100644 docs/app/reflex_docs/scripts/check_doc_links.py diff --git a/docs/app/reflex_docs/scripts/__init__.py b/docs/app/reflex_docs/scripts/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docs/app/reflex_docs/scripts/check_doc_links.py b/docs/app/reflex_docs/scripts/check_doc_links.py deleted file mode 100644 index 28a98b293e2..00000000000 --- a/docs/app/reflex_docs/scripts/check_doc_links.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Validate /docs/* markdown links against the generated sitemap.xml. - -For every .md file under the docs tree, parse it with reflex-docgen's -markdown parser and verify every `[text](/docs/...)` link: - -1. The URL path contains no underscores (URLs use hyphens). -2. After stripping the `/docs` prefix, the path exists in sitemap.xml. - -Using the real markdown AST means links inside fenced code blocks are -correctly ignored, reference-style and multi-line links are caught, and -escapes/edge cases are handled the same way the docs site renders them. - -The whole-docs run is driven by -``tests/test_doc_links.py::test_docs_links_against_exported_sitemap``, -which requires ``reflex export`` to have populated -``.web/public/sitemap.xml``. -""" - -from __future__ import annotations - -import xml.etree.ElementTree as ET -from collections.abc import Iterator -from pathlib import Path -from urllib.parse import urlparse - -from reflex_docgen.markdown import ( - Block, - BoldSpan, - DirectiveBlock, - HeadingBlock, - ImageSpan, - ItalicSpan, - LinkSpan, - ListBlock, - QuoteBlock, - Span, - StrikethroughSpan, - TableBlock, - TextBlock, - parse_document, -) - -SITEMAP_NS = {"sm": "https://www.sitemaps.org/schemas/sitemap/0.9"} -SKIP_DIRS = {".web", "node_modules", "__pycache__", ".git", ".venv", "dist", "build"} - - -def _normalize(path: str) -> str: - path = path.split("#", 1)[0].split("?", 1)[0] - if not path.startswith("/"): - path = "/" + path - return path.rstrip("/") or "/" - - -def _strip_docs_prefix(path: str) -> str: - """Drop a leading `/docs` segment so both deployment styles compare equal.""" - if path == "/docs": - return "/" - if path.startswith("/docs/"): - return path[len("/docs") :] - return path - - -def load_sitemap_paths(sitemap_path: Path) -> set[str]: - """Return the set of normalized URL paths declared in sitemap.xml.""" - tree = ET.parse(sitemap_path) - paths: set[str] = set() - for loc in tree.getroot().findall("sm:url/sm:loc", SITEMAP_NS): - if loc.text is None: - continue - path = urlparse(loc.text.strip()).path - paths.add(_strip_docs_prefix(_normalize(path))) - return paths - - -def iter_md_files(md_root: Path) -> Iterator[Path]: - """Yield .md files under md_root, skipping build/vendor directories.""" - for path in md_root.rglob("*.md"): - if any(part in SKIP_DIRS for part in path.relative_to(md_root).parts): - continue - yield path - - -def _walk_spans(spans: tuple[Span, ...]) -> Iterator[LinkSpan]: - """Recursively yield every LinkSpan inside a span tree.""" - for span in spans: - if isinstance(span, LinkSpan): - yield span - yield from _walk_spans(span.children) - elif isinstance(span, (BoldSpan, ItalicSpan, StrikethroughSpan, ImageSpan)): - yield from _walk_spans(span.children) - - -def _walk_blocks(blocks: tuple[Block, ...]) -> Iterator[LinkSpan]: - """Recursively yield every LinkSpan in a block tree, skipping CodeBlock.""" - for block in blocks: - if isinstance(block, (HeadingBlock, TextBlock)): - yield from _walk_spans(block.children) - elif isinstance(block, ListBlock): - for item in block.items: - yield from _walk_blocks(item.children) - elif isinstance(block, (QuoteBlock, DirectiveBlock)): - yield from _walk_blocks(block.children) - elif isinstance(block, TableBlock): - for row in (block.header, *block.rows): - for cell in row.cells: - yield from _walk_spans(cell.children) - - -def _line_for(text: str, target: str, cursor: int) -> tuple[int, int]: - """Locate the next occurrence of `](target)` after cursor. - - Returns ``(line_number, new_cursor)``. If the link is reference-style - (no `](target)` in source), falls back to scanning for `]: target`. - Returns ``line_number == 0`` if the target can't be located. - """ - needle = "](" + target - pos = text.find(needle, cursor) - if pos == -1: - # Reference-style links resolve to the same target but live in - # a `[label]: target` definition further down the file. - pos = text.find("]: " + target, cursor) - if pos == -1: - return 0, cursor - return text.count("\n", 0, pos) + 1, pos + len(needle) - - -def check(md_root: Path, sitemap_path: Path) -> list[str]: - """Return a list of human-readable error strings. - - Prints a per-link trail and a summary so CI logs make it obvious which - files were scanned and which links were validated. - """ - if not sitemap_path.is_file(): - return [ - f"sitemap.xml not found at {sitemap_path}. " - "Build the frontend first (e.g. `uv run reflex export --frontend-only --no-zip`)." - ] - - valid_paths = load_sitemap_paths(sitemap_path) - print(f"Loaded {len(valid_paths)} URLs from sitemap {sitemap_path}") - - md_files = list(iter_md_files(md_root)) - if not md_files: - return [f"No .md files found under {md_root}. Check --md-root."] - print(f"Scanning {len(md_files)} markdown file(s) under {md_root}") - - errors: list[str] = [] - links_checked = 0 - for md_file in md_files: - try: - text = md_file.read_text(encoding="utf-8") - except OSError: - continue - try: - doc = parse_document(text) - except Exception as exc: - errors.append(f"{md_file}: failed to parse markdown ({exc})") - continue - - cursor = 0 - for link in _walk_blocks(doc.blocks): - target = link.target - if not (target == "/docs" or target.startswith("/docs/")): - continue - - line_no, cursor = _line_for(text, target, cursor) - location = f"{md_file}:{line_no}" if line_no else str(md_file) - links_checked += 1 - - path_only = _normalize(target) - sitemap_key = _strip_docs_prefix(path_only) - has_underscore = "_" in path_only - in_sitemap = sitemap_key in valid_paths - status = "OK" if (in_sitemap and not has_underscore) else "FAIL" - print(f" [{status:<4}] {location} -> {target}") - - if has_underscore: - errors.append( - f"{location}: link contains an underscore (use hyphens): {target!r}" - ) - if not in_sitemap: - errors.append( - f"{location}: {target!r} -> {sitemap_key!r} not found in sitemap" - ) - - print(f"Checked {links_checked} /docs link(s) across {len(md_files)} file(s).") - return errors - - diff --git a/docs/app/tests/test_doc_links.py b/docs/app/tests/test_doc_links.py index e30a2d66edf..b45df97293b 100644 --- a/docs/app/tests/test_doc_links.py +++ b/docs/app/tests/test_doc_links.py @@ -1,10 +1,187 @@ -"""Tests for reflex_docs.scripts.check_doc_links.""" +"""Validate /docs/* markdown links against the generated sitemap.xml. +For every .md file under the docs tree, parse it with reflex-docgen's +markdown parser and verify every ``[text](/docs/...)`` link: + +1. The URL path contains no underscores (URLs use hyphens). +2. After stripping the ``/docs`` prefix, the path exists in sitemap.xml. + +Using the real markdown AST means links inside fenced code blocks are +correctly ignored, reference-style and multi-line links are caught, and +escapes/edge cases are handled the same way the docs site renders them. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from collections.abc import Iterator from pathlib import Path +from urllib.parse import urlparse import pytest +from reflex_docgen.markdown import ( + Block, + BoldSpan, + DirectiveBlock, + HeadingBlock, + ImageSpan, + ItalicSpan, + LinkSpan, + ListBlock, + QuoteBlock, + Span, + StrikethroughSpan, + TableBlock, + TextBlock, + parse_document, +) + +SITEMAP_NS = {"sm": "https://www.sitemaps.org/schemas/sitemap/0.9"} +SKIP_DIRS = {".web", "node_modules", "__pycache__", ".git", ".venv", "dist", "build"} + + +def _normalize(path: str) -> str: + path = path.split("#", 1)[0].split("?", 1)[0] + if not path.startswith("/"): + path = "/" + path + return path.rstrip("/") or "/" + + +def _strip_docs_prefix(path: str) -> str: + """Drop a leading `/docs` segment so both deployment styles compare equal.""" + if path == "/docs": + return "/" + if path.startswith("/docs/"): + return path[len("/docs") :] + return path + + +def _load_sitemap_paths(sitemap_path: Path) -> set[str]: + """Return the set of normalized URL paths declared in sitemap.xml.""" + tree = ET.parse(sitemap_path) + paths: set[str] = set() + for loc in tree.getroot().findall("sm:url/sm:loc", SITEMAP_NS): + if loc.text is None: + continue + path = urlparse(loc.text.strip()).path + paths.add(_strip_docs_prefix(_normalize(path))) + return paths + + +def _iter_md_files(md_root: Path) -> Iterator[Path]: + """Yield .md files under md_root, skipping build/vendor directories.""" + for path in md_root.rglob("*.md"): + if any(part in SKIP_DIRS for part in path.relative_to(md_root).parts): + continue + yield path + + +def _walk_spans(spans: tuple[Span, ...]) -> Iterator[LinkSpan]: + """Recursively yield every LinkSpan inside a span tree.""" + for span in spans: + if isinstance(span, LinkSpan): + yield span + yield from _walk_spans(span.children) + elif isinstance(span, (BoldSpan, ItalicSpan, StrikethroughSpan, ImageSpan)): + yield from _walk_spans(span.children) + + +def _walk_blocks(blocks: tuple[Block, ...]) -> Iterator[LinkSpan]: + """Recursively yield every LinkSpan in a block tree, skipping CodeBlock.""" + for block in blocks: + if isinstance(block, (HeadingBlock, TextBlock)): + yield from _walk_spans(block.children) + elif isinstance(block, ListBlock): + for item in block.items: + yield from _walk_blocks(item.children) + elif isinstance(block, (QuoteBlock, DirectiveBlock)): + yield from _walk_blocks(block.children) + elif isinstance(block, TableBlock): + for row in (block.header, *block.rows): + for cell in row.cells: + yield from _walk_spans(cell.children) + + +def _line_for(text: str, target: str, cursor: int) -> tuple[int, int]: + """Locate the next occurrence of `](target)` after cursor. + + Returns ``(line_number, new_cursor)``. If the link is reference-style + (no `](target)` in source), falls back to scanning for `]: target`. + Returns ``line_number == 0`` if the target can't be located. + """ + needle = "](" + target + pos = text.find(needle, cursor) + if pos == -1: + # Reference-style links resolve to the same target but live in + # a `[label]: target` definition further down the file. + pos = text.find("]: " + target, cursor) + if pos == -1: + return 0, cursor + return text.count("\n", 0, pos) + 1, pos + len(needle) + + +def check(md_root: Path, sitemap_path: Path) -> list[str]: + """Return a list of human-readable error strings. + + Prints a per-link trail and a summary so CI logs make it obvious which + files were scanned and which links were validated. + """ + if not sitemap_path.is_file(): + return [ + f"sitemap.xml not found at {sitemap_path}. " + "Build the frontend first (e.g. `uv run reflex export --frontend-only --no-zip`)." + ] + + valid_paths = _load_sitemap_paths(sitemap_path) + print(f"Loaded {len(valid_paths)} URLs from sitemap {sitemap_path}") + + md_files = list(_iter_md_files(md_root)) + if not md_files: + return [f"No .md files found under {md_root}."] + print(f"Scanning {len(md_files)} markdown file(s) under {md_root}") + + errors: list[str] = [] + links_checked = 0 + for md_file in md_files: + try: + text = md_file.read_text(encoding="utf-8") + except OSError: + continue + try: + doc = parse_document(text) + except Exception as exc: + errors.append(f"{md_file}: failed to parse markdown ({exc})") + continue + + cursor = 0 + for link in _walk_blocks(doc.blocks): + target = link.target + if not (target == "/docs" or target.startswith("/docs/")): + continue + + line_no, cursor = _line_for(text, target, cursor) + location = f"{md_file}:{line_no}" if line_no else str(md_file) + links_checked += 1 + + path_only = _normalize(target) + sitemap_key = _strip_docs_prefix(path_only) + has_underscore = "_" in path_only + in_sitemap = sitemap_key in valid_paths + status = "OK" if (in_sitemap and not has_underscore) else "FAIL" + print(f" [{status:<4}] {location} -> {target}") + + if has_underscore: + errors.append( + f"{location}: link contains an underscore (use hyphens): {target!r}" + ) + if not in_sitemap: + errors.append( + f"{location}: {target!r} -> {sitemap_key!r} not found in sitemap" + ) + + print(f"Checked {links_checked} /docs link(s) across {len(md_files)} file(s).") + return errors -from reflex_docs.scripts.check_doc_links import _normalize, check _DOCS_APP = Path(__file__).resolve().parent.parent # docs/app/ _MD_ROOT = _DOCS_APP.parent # docs/ @@ -26,6 +203,7 @@ def test_docs_links_against_exported_sitemap(): errors = check(_MD_ROOT, _SITEMAP) assert not errors, "\n".join(errors) + SITEMAP_XML = """ http://localhost:3000/getting-started/basics/ From b16d739b6d653ac412a9ac01f1a09890394ad204 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:38:17 -0700 Subject: [PATCH 11/13] pre commit --- docs/app/tests/test_docgen_double_eval.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/app/tests/test_docgen_double_eval.py b/docs/app/tests/test_docgen_double_eval.py index 0667f064827..a61ec3baaee 100644 --- a/docs/app/tests/test_docgen_double_eval.py +++ b/docs/app/tests/test_docgen_double_eval.py @@ -11,7 +11,6 @@ import pytest - MD_WITH_TWO_EXEC_BLOCKS = """\ ```python exec import reflex as rx From 9ead19e3dd2d2e2fa8124282622ade2f7bdf04fa Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:45:07 -0700 Subject: [PATCH 12/13] ci: thread a working-directory input through setup_build_env `uv sync` was running from the repo root, which doesn't install the docs/app workspace member (it isn't listed in the root `[tool.uv.sources]`), leaving `agent_files` off sys.path and breaking test collection in docs_tests.yml. Add a `working-directory` input to the action so callers can point uv at the right workspace project, and set it to `./docs/app` in docs_tests.yml. --- .github/actions/setup_build_env/action.yml | 6 ++++++ .github/workflows/docs_tests.yml | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/actions/setup_build_env/action.yml b/.github/actions/setup_build_env/action.yml index 73bf0f0a8b9..8d2cad1748b 100644 --- a/.github/actions/setup_build_env/action.yml +++ b/.github/actions/setup_build_env/action.yml @@ -18,6 +18,10 @@ inputs: description: "Whether to run uv sync on current dir" required: false default: false + working-directory: + description: "Directory to run uv sync from. Defaults to the repo root." + required: false + default: "." create-venv-at-path: description: "Path to venv (if uv sync is enabled)" required: false @@ -34,6 +38,7 @@ runs: prune-cache: false activate-environment: true cache-dependency-glob: "uv.lock" + working-directory: ${{ inputs.working-directory }} - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -42,3 +47,4 @@ runs: if: inputs.run-uv-sync == 'true' run: uv sync shell: bash + working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/docs_tests.yml b/.github/workflows/docs_tests.yml index 72d8961fd5c..d2c1b1a68e7 100644 --- a/.github/workflows/docs_tests.yml +++ b/.github/workflows/docs_tests.yml @@ -35,6 +35,7 @@ jobs: with: python-version: "3.12" run-uv-sync: true + working-directory: ./docs/app - name: Run docs app tests working-directory: ./docs/app run: uv run --no-sync pytest tests/ -v From a2069be1a0d1ea845f4a2f564274d4c2040cd3e5 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Thu, 21 May 2026 11:55:16 -0700 Subject: [PATCH 13/13] docs: fix two underscore /docs links flagged by the link check router_attributes.md linked to `/docs/pages/dynamic_routing` in two places. The source file is `docs/pages/dynamic_routing.md` but the served URL is `/docs/pages/dynamic-routing/` (docgen rewrites underscores to hyphens). Switch the links to the hyphenated form so they resolve. --- docs/utility_methods/router_attributes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index fb78fc6047a..a9dbd5c3323 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -107,7 +107,7 @@ about the current page, session, or state. The `self.router` attribute has several sub-attributes that provide various information: - `router.url`: the URL of the current page, parsed into its components (see [URL Attributes](#url-attributes) below). -- `router.route_id`: the route pattern that matched the current request (e.g. `/posts/[id]`). For [dynamic pages](/docs/pages/dynamic_routing) this contains the slug rather than the actual value used to load the page. +- `router.route_id`: the route pattern that matched the current request (e.g. `/posts/[id]`). For [dynamic pages](/docs/pages/dynamic-routing) this contains the slug rather than the actual value used to load the page. - `router.session`: data about the current session - `client_token`: UUID associated with the current tab's token. Each tab has a unique token. @@ -173,7 +173,7 @@ class State(rx.State): # ... load the appropriate data for that page ... ``` -For dynamic path segments such as `[id]` or `[[...splat]]`, see [Dynamic Routes](/docs/pages/dynamic_routing) — those values are exposed as state vars on the root state (e.g. `rx.State.id`, `rx.State.splat`), not through `router.url`. +For dynamic path segments such as `[id]` or `[[...splat]]`, see [Dynamic Routes](/docs/pages/dynamic-routing) — those values are exposed as state vars on the root state (e.g. `rx.State.id`, `rx.State.splat`), not through `router.url`. ## Migrating from `router.page`