diff --git a/docs/api-reference/chart-factories.md b/docs/api-reference/chart-factories.md index 7682b63f..c0c4f5bd 100644 --- a/docs/api-reference/chart-factories.md +++ b/docs/api-reference/chart-factories.md @@ -24,11 +24,11 @@ from xy_docs.api_reference import chart_containers_api, chart_factories_api chart_factories_api() ~~~ -Annotation-only compositions use the neutral `chart()` container, so that -factory appears under Annotations. It also appears beside `facet_chart()` in -Facets and Layers because layered marks use the same neutral container. +Annotation-only and layered compositions use the neutral `chart()` container, +so that factory appears once under Annotations and Layers. `facet_chart()` has +its own Facets group. -## Shared Chart Props +## Chart Container Props ~~~python eval chart_containers_api() diff --git a/docs/app/scripts/check_duplicate_ids.py b/docs/app/scripts/check_duplicate_ids.py new file mode 100644 index 00000000..d3afb304 --- /dev/null +++ b/docs/app/scripts/check_duplicate_ids.py @@ -0,0 +1,59 @@ +"""Fast source-level validation for Markdown-rendered documentation IDs. + +The post-build route validator checks complete HTML, including shared layout, +raw HTML/SVG, and redirects. +""" + +from collections import Counter +from collections.abc import Iterator, Sequence +from typing import Any + +from reflex_site_shared.docs.content import discover_docs +from xy_docs.config import DOCS_CONFIG +from xy_docs.markdown import render_xy_markdown_page + + +def _static_string(value: Any) -> str | None: + """Return a Python string from a literal Reflex value.""" + if isinstance(value, str): + return value + literal = getattr(value, "_var_value", None) + return literal if isinstance(literal, str) else None + + +def _walk_component_tree(component: object) -> Iterator[object]: + """Yield a rendered Reflex component tree in document order.""" + yield component + for child in getattr(component, "children", ()): + yield from _walk_component_tree(child) + + +def duplicate_ids(components: Sequence[object]) -> tuple[str, ...]: + """Return duplicate static IDs from a rendered component sequence.""" + static_ids = tuple( + component_id + for component in components + if (component_id := _static_string(getattr(component, "id", None))) + ) + return tuple(component_id for component_id, count in Counter(static_ids).items() if count > 1) + + +def validate_public_page_ids() -> None: + """Raise when Markdown-rendered page content has duplicate static IDs.""" + failures: dict[str, tuple[str, ...]] = {} + for page in discover_docs(DOCS_CONFIG): + components = tuple(_walk_component_tree(render_xy_markdown_page(page))) + duplicates = duplicate_ids(components) + if duplicates: + failures[page.route] = duplicates + if failures: + raise RuntimeError(f"duplicate static IDs: {failures}") + + +def main() -> None: + """Validate literal IDs produced from all Markdown-backed pages.""" + validate_public_page_ids() + + +if __name__ == "__main__": + main() diff --git a/docs/app/scripts/check_html_routes.py b/docs/app/scripts/check_html_routes.py index 720097d2..2de95c33 100644 --- a/docs/app/scripts/check_html_routes.py +++ b/docs/app/scripts/check_html_routes.py @@ -1,6 +1,8 @@ """Validate human-facing documentation routes.""" import re +from collections import Counter +from html.parser import HTMLParser from pathlib import Path from reflex_site_shared.docs import discover_docs @@ -18,6 +20,52 @@ LLMS_DIRECTIVE = "For AI agents: the complete XY documentation index is at" +class _ElementIdParser(HTMLParser): + """Collect element IDs from prerendered HTML, including inline SVG.""" + + def __init__(self) -> None: + super().__init__() + self.ids: list[str] = [] + + def _collect_ids(self, attrs: list[tuple[str, str | None]]) -> None: + """Record non-empty IDs from one start tag.""" + self.ids.extend(value for name, value in attrs if name == "id" and value) + + def handle_starttag( + self, + _tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + """Collect IDs from a normal start tag.""" + self._collect_ids(attrs) + + def handle_startendtag( + self, + _tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + """Collect IDs from a self-closing start tag.""" + self._collect_ids(attrs) + + +def duplicate_html_ids(source: str) -> tuple[str, ...]: + """Return duplicate element IDs from a complete prerendered document.""" + parser = _ElementIdParser() + parser.feed(source) + parser.close() + return tuple(element_id for element_id, count in Counter(parser.ids).items() if count > 1) + + +def validate_unique_html_ids(page_route: str, html_path: Path, source: str) -> None: + """Reject repeated IDs anywhere in a complete prerendered route.""" + duplicates = duplicate_html_ids(source) + if duplicates: + msg = ( + f"Prerendered route has duplicate element IDs {duplicates}: {page_route} ({html_path})" + ) + raise RuntimeError(msg) + + def route_html_paths(route: str) -> tuple[Path, ...]: """Return the canonical trailing-slash HTML path for a route. @@ -115,7 +163,11 @@ def main() -> None: msg = f"Missing prerendered documentation route: {html_paths!r}" raise RuntimeError(msg) for html_path in html_paths: - if html_path.is_file() and LLMS_DIRECTIVE not in html_path.read_text(encoding="utf-8"): + if not html_path.is_file(): + continue + source = html_path.read_text(encoding="utf-8") + validate_unique_html_ids(page.route, html_path, source) + if LLMS_DIRECTIVE not in source: msg = f"Prerendered route omits the llms.txt directive: {html_path}" raise RuntimeError(msg) @@ -128,6 +180,13 @@ def main() -> None: if not any(path.is_file() for path in html_paths): msg = f"Missing prerendered redirect route: {html_paths!r}" raise RuntimeError(msg) + for html_path in html_paths: + if html_path.is_file(): + validate_unique_html_ids( + route, + html_path, + html_path.read_text(encoding="utf-8"), + ) if __name__ == "__main__": diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index d8b1c25a..d826a938 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -10,6 +10,7 @@ import xml.etree.ElementTree as ET from collections.abc import Iterator from pathlib import Path +from types import SimpleNamespace from urllib.parse import urlparse import pytest @@ -1795,6 +1796,75 @@ def test_inline_svg_gallery_validator_requires_every_styled_preview(tmp_path: Pa check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) +def test_prerendered_route_validator_rejects_ids_from_layout_and_raw_svg( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Inspect layout and raw SVG IDs through the final content-route check.""" + build_root = tmp_path / "build" + routes_root = tmp_path / "routes" + route = "/guide/" + html_path = build_root / "guide" / "index.html" + module_path = routes_root / "[guide]._index.jsx" + html_path.parent.mkdir(parents=True) + module_path.parent.mkdir(parents=True) + (build_root / "__spa-fallback.html").write_text("", encoding="utf-8") + module_path.write_text("export default function Guide() {}", encoding="utf-8") + source = f"""\ + + +
+ + + + +
+{check_html_routes.LLMS_DIRECTIVE} +""" + html_path.write_text(source, encoding="utf-8") + monkeypatch.setattr(check_html_routes, "BUILD_ROOT", build_root) + monkeypatch.setattr(check_html_routes, "ROUTES_ROOT", routes_root) + monkeypatch.setattr(check_html_routes, "DOCS_REDIRECTS", {}) + monkeypatch.setattr( + check_html_routes, + "discover_docs", + lambda _config: (SimpleNamespace(route=route, content=""),), + ) + assert check_html_routes.duplicate_html_ids(source) == ("shared-id", "raw-svg-id") + with pytest.raises( + RuntimeError, + match=r"duplicate element IDs .*shared-id.*raw-svg-id", + ): + check_html_routes.main() + + +def test_redirect_route_validator_rejects_duplicate_ids( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Run the final-ID check for redirects as well as content pages.""" + build_root = tmp_path / "build" + routes_root = tmp_path / "routes" + redirect = "/legacy/" + redirect_html = build_root / "legacy" / "index.html" + redirect_module = routes_root / "[legacy]._index.jsx" + redirect_html.parent.mkdir(parents=True) + redirect_module.parent.mkdir(parents=True) + (build_root / "__spa-fallback.html").write_text("", encoding="utf-8") + redirect_html.write_text( + '
', + encoding="utf-8", + ) + redirect_module.write_text("export default function Redirect() {}", encoding="utf-8") + monkeypatch.setattr(check_html_routes, "BUILD_ROOT", build_root) + monkeypatch.setattr(check_html_routes, "ROUTES_ROOT", routes_root) + monkeypatch.setattr(check_html_routes, "DOCS_REDIRECTS", {redirect: "/"}) + monkeypatch.setattr(check_html_routes, "discover_docs", lambda _config: ()) + + with pytest.raises(RuntimeError, match=r"duplicate element IDs .*redirect-id"): + check_html_routes.main() + + @pytest.mark.xfail( not EXPORTED_SITEMAP.is_file(), reason="Build the XY docs frontend before validating Markdown links.", diff --git a/docs/app/tests/test_unique_ids.py b/docs/app/tests/test_unique_ids.py new file mode 100644 index 00000000..bbb2ad55 --- /dev/null +++ b/docs/app/tests/test_unique_ids.py @@ -0,0 +1,42 @@ +"""Regression coverage for public documentation element IDs.""" + +import importlib.util +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +DOCS_APP_ROOT = Path(__file__).resolve().parent.parent +CHECK_DUPLICATE_IDS_PATH = DOCS_APP_ROOT / "scripts" / "check_duplicate_ids.py" +SPEC = importlib.util.spec_from_file_location( + "xy_docs_check_duplicate_ids", + CHECK_DUPLICATE_IDS_PATH, +) +if SPEC is None or SPEC.loader is None: + msg = f"Unable to load duplicate-ID validator: {CHECK_DUPLICATE_IDS_PATH}" + raise RuntimeError(msg) +check_duplicate_ids = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check_duplicate_ids) + + +def test_markdown_page_bodies_have_unique_literal_ids() -> None: + """Keep Markdown-rendered page content free of duplicate literal IDs.""" + result = subprocess.run( + [sys.executable, str(CHECK_DUPLICATE_IDS_PATH)], + cwd=DOCS_APP_ROOT, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + + +def test_duplicate_id_validator_rejects_repeated_literal_ids() -> None: + """Reject two rendered components with the same literal ID.""" + duplicate = SimpleNamespace(_var_value="duplicate") + first = SimpleNamespace(id=duplicate) + second = SimpleNamespace(id=duplicate) + + assert check_duplicate_ids.duplicate_ids((first, second)) == ("duplicate",) diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index 74f35665..ed2046fb 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -73,8 +73,8 @@ xy.triangle_mesh_chart, ), ), - ("Annotations", (xy.chart,)), - ("Facets and Layers", (xy.chart, xy.facet_chart)), + ("Annotations and Layers", (xy.chart,)), + ("Facets", (xy.facet_chart,)), ) _CHART_FACTORY_COMPONENTS = frozenset( factory for _group_name, factories in CHART_FACTORY_GROUPS for factory in factories diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 4aef9f3b..4f604eaa 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -34,7 +34,7 @@ built — one renderer never silently ignores what another draws. | `wedge-gap` | xy | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | | `marker-shape` | xy | `scatter` | full | full | full | shipped | -### Notes +### Mark style notes - **`opacity`** — Multiplies the mark's own alpha in every renderer. - **`fill`** — A plain color compiles to the mark's paint; a `linear-gradient(...)` compiles to a gradient and is accepted only by area and rect kinds, which are the ones with a gradient program. @@ -86,7 +86,7 @@ token bag or in mark and axis `style=`, which every renderer reads. | `axis_title` | full | partial | partial | | `annotation_label` | full | none | none | -### Notes +### Chrome slot notes - **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export. - **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py index f7ce5f12..b135f096 100644 --- a/scripts/gen_capability_matrix.py +++ b/scripts/gen_capability_matrix.py @@ -79,7 +79,7 @@ def render() -> str: "", ] lines += caps.markdown_mark_property_table() - lines += ["", "### Notes", ""] + lines += ["", "### Mark style notes", ""] for prop in caps.MARK_STYLE_PROPERTIES: if prop.notes: lines.append(f"- **`{prop.id}`** — {prop.notes}") @@ -98,7 +98,7 @@ def render() -> str: "", ] lines += caps.markdown_slot_table() - lines += ["", "### Notes", ""] + lines += ["", "### Chrome slot notes", ""] for slot in caps.CHART_SLOTS: if slot.notes: lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}") @@ -110,7 +110,7 @@ def render() -> str: "", ] lines += caps.markdown_extension_table() - lines += ["", "### Notes", ""] + lines += ["", "### Extension point notes", ""] for point in caps.EXTENSION_POINTS: lines.append(f"- **{point.id}** — {point.notes}") lines += [ @@ -179,7 +179,7 @@ def render_public() -> str: "", ] lines += caps.markdown_mark_property_table() - lines += ["", "### Notes", ""] + lines += ["", "### Mark style notes", ""] for prop in caps.MARK_STYLE_PROPERTIES: if prop.notes: lines.append(f"- **`{prop.id}`** — {prop.notes}") @@ -194,7 +194,7 @@ def render_public() -> str: "", ] lines += caps.markdown_slot_table() - lines += ["", "### Notes", ""] + lines += ["", "### Chrome slot notes", ""] for slot in caps.CHART_SLOTS: if slot.notes: lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}") diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index a77247cf..a9088853 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -38,7 +38,7 @@ one honors. | `wedge-gap` | xy | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | | `marker-shape` | xy | `scatter` | full | full | full | shipped | -### Notes +### Mark style notes - **`opacity`** — Multiplies the mark's own alpha in every renderer. - **`fill`** — A plain color compiles to the mark's paint; a `linear-gradient(...)` compiles to a gradient and is accepted only by area and rect kinds, which are the ones with a gradient program. @@ -94,7 +94,7 @@ contracted in [export.md](export.md) §9 and pinned by | `axis_title` | full | partial | partial | | `annotation_label` | full | none | none | -### Notes +### Chrome slot notes - **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export. - **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. @@ -117,7 +117,7 @@ Ways to add behavior the core does not ship, without forking it. | mark_plugin_shader | planned | `—` | — | | custom_renderer | planned | `—` | — | -### Notes +### Extension point notes - **mark_plugin_composition** — A calc over declared columns plus a build that returns built-in marks. Its output is ordinary traces, so it reuses the built-in rendering, picking, and export paths rather than reimplementing them. - **mark_plugin_shader** — §24's WGSL/GLSL snippet pair. Deferred: a plugin with its own shader reuses none of the built-in rendering, picking, or export paths and would have to reimplement them.