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_html_routes.py b/docs/app/scripts/check_html_routes.py index a76ab38e..95e1e002 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 supported prerendered HTML paths for a route. @@ -139,6 +187,11 @@ def main() -> None: if LLMS_DIRECTIVE not in html_path.read_text(encoding="utf-8"): msg = f"Prerendered route omits the llms.txt directive: {html_path}" raise RuntimeError(msg) + validate_unique_html_ids( + page.route, + html_path, + html_path.read_text(encoding="utf-8"), + ) for route in DOCS_REDIRECTS: module_path = route_module_path(route) @@ -149,6 +202,11 @@ def main() -> None: if not html_path.is_file(): msg = f"Missing prerendered redirect route: {html_path}" raise RuntimeError(msg) + validate_unique_html_ids( + route, + html_path, + html_path.read_text(encoding="utf-8"), + ) if __name__ == "__main__": diff --git a/docs/app/tests/test_check_html_routes.py b/docs/app/tests/test_check_html_routes.py new file mode 100644 index 00000000..20db330b --- /dev/null +++ b/docs/app/tests/test_check_html_routes.py @@ -0,0 +1,49 @@ +"""Focused tests for the post-build HTML route validator.""" + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + + +def test_duplicate_id_validator_covers_html_svg_and_diagnostics( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Accept unique IDs but reject an HTML/SVG collision with route context.""" + reflex_site_shared = ModuleType("reflex_site_shared") + reflex_docs = ModuleType("reflex_site_shared.docs") + reflex_docs.discover_docs = lambda _config: [] + xy_docs = ModuleType("xy_docs") + xy_config = ModuleType("xy_docs.config") + xy_config.DOCS_CONFIG = None + xy_config.DOCS_REDIRECTS = {} + monkeypatch.setitem(sys.modules, "reflex_site_shared", reflex_site_shared) + monkeypatch.setitem(sys.modules, "reflex_site_shared.docs", reflex_docs) + monkeypatch.setitem(sys.modules, "xy_docs", xy_docs) + monkeypatch.setitem(sys.modules, "xy_docs.config", xy_config) + + validator_path = Path(__file__).resolve().parents[1] / "scripts" / "check_html_routes.py" + spec = importlib.util.spec_from_file_location("focused_check_html_routes", validator_path) + assert spec is not None and spec.loader is not None + validator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(validator) + + unique_source = '
' + assert validator.duplicate_html_ids(unique_source) == () + + duplicate_source = '
' + html_path = tmp_path / "charts" / "scatter.html" + assert validator.duplicate_html_ids(duplicate_source) == ("shared",) + + with pytest.raises( + RuntimeError, + match=r"duplicate element IDs \('shared',\).*?/charts/scatter/.*scatter\.html", + ): + validator.validate_unique_html_ids( + "/charts/scatter/", + html_path, + duplicate_source, + ) diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index 92d3dd08..9e8523b8 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -74,8 +74,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 88ad0b0d..2c66d053 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. @@ -105,7 +105,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 069fc218..15bbd430 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. @@ -113,7 +113,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. @@ -136,7 +136,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.