Skip to content
Merged
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
58 changes: 58 additions & 0 deletions 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, ...]:
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
"""Return duplicate element IDs from a complete prerendered document."""
parser = _ElementIdParser()
parser.feed(source)
parser.close()
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
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)
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved


def route_html_paths(route: str) -> tuple[Path, ...]:
"""Return supported prerendered HTML paths for a route.

Expand Down Expand Up @@ -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)
Expand All @@ -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__":
Expand Down
49 changes: 49 additions & 0 deletions docs/app/tests/test_check_html_routes.py
Original file line number Diff line number Diff line change
@@ -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 = '<main id="content"><svg id="chart"/><div id=""></div></main>'
assert validator.duplicate_html_ids(unique_source) == ()

duplicate_source = '<main id="content"><svg><g id="shared"/></svg><section id="shared"/></main>'
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,
)
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 @@ -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
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 @@ -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.
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 @@ -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.
Expand All @@ -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.
Expand Down
Loading