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`