Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/api-reference/chart-factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
59 changes: 59 additions & 0 deletions docs/app/scripts/check_duplicate_ids.py
Original file line number Diff line number Diff line change
@@ -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)))
Comment thread
Alek99 marked this conversation as resolved.
)
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):
Comment thread
Alek99 marked this conversation as resolved.
components = tuple(_walk_component_tree(render_xy_markdown_page(page)))
Comment thread
Alek99 marked this conversation as resolved.
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()
61 changes: 60 additions & 1 deletion docs/app/scripts/check_html_routes.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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)

Expand All @@ -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__":
Expand Down
70 changes: 70 additions & 0 deletions docs/app/tests/test_docs_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("<html></html>", encoding="utf-8")
module_path.write_text("export default function Guide() {}", encoding="utf-8")
source = f"""\
<!doctype html>
<nav id="shared-id"></nav>
<main id="shared-id">
<svg>
<filter id="raw-svg-id"></filter>
<clipPath id="raw-svg-id" />
</svg>
</main>
{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("<html></html>", encoding="utf-8")
redirect_html.write_text(
'<html><meta id="redirect-id"><main id="redirect-id"></main></html>',
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.",
Expand Down
42 changes: 42 additions & 0 deletions docs/app/tests/test_unique_ids.py
Original file line number Diff line number Diff line change
@@ -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",)
4 changes: 2 additions & 2 deletions docs/app/xy_docs/api_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/styling/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions scripts/gen_capability_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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}")
Expand All @@ -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 += [
Expand Down Expand Up @@ -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}")
Expand All @@ -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}")
Expand Down
6 changes: 3 additions & 3 deletions spec/api/capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading