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
new file mode 100644
index 00000000000..d2c1b1a68e7
--- /dev/null
+++ b/.github/workflows/docs_tests.yml
@@ -0,0 +1,41 @@
+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
+ working-directory: ./docs/app
+ - name: Run docs app tests
+ working-directory: ./docs/app
+ run: uv run --no-sync pytest tests/ -v
diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml
index b93db4b9016..51a8469ed24 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 --runxfail 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 f721446c837..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 python scripts/check_doc_links.py
diff --git a/docs/app/scripts/check_doc_links.py b/docs/app/scripts/check_doc_links.py
deleted file mode 100644
index 238ec4bbdbd..00000000000
--- a/docs/app/scripts/check_doc_links.py
+++ /dev/null
@@ -1,221 +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.
-
-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 scripts/check_doc_links.py
-"""
-
-from __future__ import annotations
-
-import argparse
-import sys
-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
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- here = Path(__file__).resolve().parent
- parser.add_argument(
- "--md-root",
- type=Path,
- default=here.parent.parent,
- help="Root directory containing .md docs (default: ../..).",
- )
- parser.add_argument(
- "--sitemap",
- type=Path,
- default=here.parent / ".web" / "public" / "sitemap.xml",
- help="Path to sitemap.xml (default: ../.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/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_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..b45df97293b 100644
--- a/docs/app/tests/test_doc_links.py
+++ b/docs/app/tests/test_doc_links.py
@@ -1,13 +1,208 @@
-"""Unit tests for scripts/check_doc_links.py."""
+"""Validate /docs/* markdown links against the generated sitemap.xml.
-import sys
+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
+
+
+_DOCS_APP = Path(__file__).resolve().parent.parent # docs/app/
+_MD_ROOT = _DOCS_APP.parent # docs/
+_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."""
+ errors = check(_MD_ROOT, _SITEMAP)
+ assert not errors, "\n".join(errors)
-sys.path.append(str(Path(__file__).resolve().parent.parent / "scripts"))
-
-from check_doc_links import _normalize, 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..a61ec3baaee 100644
--- a/docs/app/tests/test_docgen_double_eval.py
+++ b/docs/app/tests/test_docgen_double_eval.py
@@ -7,14 +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
import reflex as rx
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():
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..6b6ebce789c
--- /dev/null
+++ b/docs/app/tests/test_upload_not_evaluated.py
@@ -0,0 +1,34 @@
+"""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
+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 pytest
+
+
+@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."
+ )
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(
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`