use crate::diag_catalog::{DIAGNOSTIC_CODES, DIAGNOSTIC_VERBS, DiagnosticCodeInfo};
use crate::parse::transform::PAGE_KNOWN_PROPS;
use crate::parse::transform::{ASSET_KNOWN_PROPS, DOCUMENT_KNOWN_PROPS, known_props_for_kind};
pub fn node_kinds() -> &'static [&'static str] {
&[
"code",
"connector",
"ellipse",
"field",
"footnote",
"frame",
"group",
"image",
"chart",
"instance",
"line",
"light",
"mesh",
"pattern",
"polygon",
"polyline",
"rect",
"shape",
"table",
"text",
"toc",
]
}
pub fn node_summary(kind: &str) -> Option<&'static str> {
match kind {
"rect" => Some("Rectangle with optional fill, stroke, and rounded corners."),
"ellipse" => Some("Ellipse or circle with optional fill and stroke."),
"line" => Some("Straight line segment between two endpoints."),
"text" => Some("Multi-span text block with typography and layout properties."),
"code" => Some("Monospace code block with syntax-theme highlighting."),
"frame" => Some("Container that clips and positions its children within a fixed box."),
"group" => Some("Transparent grouping container for related nodes."),
"image" => Some("Raster or SVG image positioned within a bounding box."),
"polygon" => Some("Closed polygon defined by an ordered vertex list."),
"polyline" => Some("Open polyline defined by an ordered vertex list."),
"instance" => Some("Reference to a master component, optionally with overrides."),
"field" => Some("Editable variable-data text field bound to a named slot."),
"footnote" => Some("Page-level footnote referenced by text span markers."),
"toc" => Some("Table-of-contents placeholder resolved to text by the scene compiler."),
"table" => Some("Structured data table with columns, rows, and cells."),
"shape" => Some("Preset geometric shape with an optional text label."),
"connector" => Some("Directed connector line between two anchor points on nodes."),
"pattern" => Some("Procedural grid or scatter tiling of one motif node."),
"chart" => Some(
"Data-visualization chart (bar, line, area, sparkline, pie, donut) with inline series data.",
),
"light" => Some("Effect node that emits a soft radial ambient light."),
"mesh" => {
Some("Effect node that emits a procedural orthographic or perspective grid mesh.")
}
_ => None,
}
}
pub fn node_example(kind: &str) -> Option<&'static str> {
match kind {
"light" => Some(
"light id=\"bg.glow\" kind=\"ambient\" x=(%)85 y=(%)12 \
radius=(token)\"size.glow\" color=(token)\"color.glow\" opacity=0.35",
),
"mesh" => Some(
"mesh id=\"bg.mesh\" kind=\"perspective\" x=(px)0 y=(px)0 w=(px)1920 h=(px)1080 \
rows=7 columns=8 vanishing-x=(px)1260 vanishing-y=(px)-420 extend=(px)160 \
stroke=(token)\"color.grid\" stroke-width=(token)\"stroke.hairline\" opacity=0.34",
),
_ => None,
}
}
pub struct NodeContentDescriptor {
pub description: &'static str,
pub example: &'static str,
}
pub fn node_content(kind: &str) -> Option<NodeContentDescriptor> {
match kind {
"text" => Some(NodeContentDescriptor {
description: "One or more `span` children carry the text runs. \
Each span takes a string argument and optional inline style props: \
fill, font-weight, italic, underline, strikethrough, highlight, code, link, \
vertical-align, footnote-ref. \
`highlight` is a per-span background color (token ref or raw color string) \
rendered behind the glyph run like a marker-pen highlight. \
`code=#true` renders the span in the bundled monospace family with a subtle \
background, suitable for inline code. \
`link=\"url\"` renders the span underlined in the default link color (unless \
`fill` is set); in PDF output the URL becomes a clickable `/Link` annotation \
over the span. \
`selectable` (node attribute, default `#true`) controls PDF text extraction: \
by default the text is emitted as real, selectable / searchable / indexable \
text (with a ToUnicode map, so copy and search work and links are clickable); \
`selectable=#false` renders the glyphs as filled outlines instead — visually \
identical but not selectable, searchable, or extractable. The PNG backend is \
unaffected. \
The `format` node attribute (values: `markdown` | `plain`) opts into \
markdown rendering of the concatenated span text. \
When `format=\"markdown\"`, the scene compile pass re-parses the span content \
AFTER data-binding substitution and renders both inline marks and block structure. \
Inline marks: `**bold**`, `*italic*`, `~~strike~~`, `==highlight==`, \
`++underline++`, `` `code` ``, `[label](url)`. \
Block structure (one construct per line/paragraph): \
`# H1` through `###### H6` (ATX headings), blank line separates paragraphs, \
`> text` blockquote, `- item` / `* item` / `+ item` unordered list, \
`1. item` ordered list, ` ``` ` fenced code block (optional lang after opening \
fence; ends at closing ` ``` `), `---` / `***` / `___` horizontal rule. \
The block roles produced (h1..h6, p, blockquote, li, code-block, hr) are the \
same names styled by `block role=\"…\"` declarations (see `zenith schema block`). \
v1 limitation: in a `chain` flow, code-block backgrounds and `---` rules are \
not drawn and blockquote/list indent is not applied — these render fully only \
in a single non-chained text box. \
Pairs well with a single `data-ref` span to parse external content as markdown \
without encoding marks in the document. `format=\"plain\"` or absent = literal \
(byte-identical to today's behavior). \
The `src` node attribute (`src=\"path/to/file.md\"`) loads the file at the \
given path (resolved relative to the document's project directory) and uses its \
UTF-8 contents as the node's text content, replacing any inline `span` children \
at render time. This keeps the `.zen` file lean for long-form prose. When paired \
with `format=\"markdown\"`, the loaded text is parsed as markdown by the \
scene compile pass. A missing or unreadable file emits a `text.src_missing` \
Error diagnostic (same gate as `asset.missing`). The `src` field is retained \
on the node so a future editor can write edits back to the original file. \
Threaded text flow (`chain` attribute): all `text` nodes that share the same \
`chain=\"id\"` value form one ordered chain (document source order, across pages). \
The FIRST member that carries spans or `src` content is the content source; \
subsequent members must have EMPTY spans (no `src`, no inline spans) and serve \
as overflow boxes. Each member needs explicit `x`/`y`/`w`/`h` geometry. Text \
fills box 1, the remainder flows into box 2, etc., across page boundaries. \
This is how you resolve a `text.overflow` warning for long-form copy: add \
chained continuation boxes (on the same or new pages) until nothing overflows. \
Only the first member's font/style drives the whole chain; per-span overrides \
on the source are honored. \
A `block role=\"…\"` declaration may appear BEFORE span children to set per-role \
markdown block style at this text node's scope (highest cascade precedence: \
text > page > document). Block decls affect only nodes with `format=\"markdown\"` \
and have no effect on plain-text nodes (see `zenith schema block`).",
example: concat!(
"block role=\"h1\" font-size=(token)\"size.h1\" font-weight=(token)\"weight.bold\"\n",
"span \"Hello \"\n",
"span \"world\" font-weight=(token)\"weight.bold\" italic=#true",
),
}),
"shape" => Some(NodeContentDescriptor {
description: "Optional `span` children form a text label rendered centered inside the \
shape. Use h-align/v-align on the shape node to adjust alignment. \
Omit the block entirely for an unlabelled shape.",
example: "span \"Approve\"",
}),
"footnote" => Some(NodeContentDescriptor {
description: "One or more `span` children carry the footnote body text, \
using the same span model as `text`.",
example: "span \"See also Chapter 3.\"",
}),
"polygon" => Some(NodeContentDescriptor {
description: "Two or more `point` children define the closed vertex list in order. \
Each point carries `x` and `y` as px-literal dimensions.",
example: concat!(
"point x=(px)0 y=(px)0\n",
"point x=(px)100 y=(px)0\n",
"point x=(px)50 y=(px)86",
),
}),
"polyline" => Some(NodeContentDescriptor {
description: "Two or more `point` children define the open vertex list in order. \
Each point carries `x` and `y` as px-literal dimensions.",
example: concat!(
"point x=(px)0 y=(px)0\n",
"point x=(px)100 y=(px)50\n",
"point x=(px)200 y=(px)0",
),
}),
"table" => Some(NodeContentDescriptor {
description: "Optional `column` children (each with `width=(px)N`) declare column \
widths; then `row` children each containing `cell` children. \
Each cell accepts colspan, rowspan, fill, border, h-align, v-align, \
and arbitrary renderable child nodes for cell content. \
Cell text auto-places: the cell sizes and positions its text into the content box \
(padding-inset), wraps to the column width, and aligns via the cell/table \
`h-align` (start|center|end) and `v-align` (top|middle|bottom). \
Omit `x`/`y`/`w`/`h` on cell text; set them only to override the auto layout. \
The table itself requires its own `x`/`y`/`w`/`h`.",
example: concat!(
"column width=(px)120\n",
"column width=(px)80\n",
"row {\n",
" cell { text { span \"Name\" } }\n",
" cell { text { span \"Score\" } }\n",
"}",
),
}),
"frame" => Some(NodeContentDescriptor {
description: "Arbitrary renderable child nodes (any node kind). \
The frame clips its children to its bounding box. \
Use layout=\"grid\" with columns/rows attrs for grid layout.",
example: "rect id=\"bg\" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)\"color.bg\"",
}),
"group" => Some(NodeContentDescriptor {
description: "Arbitrary renderable child nodes (any node kind). \
May also include `protected-region id=... x=... y=... w=... h=...` \
and `editable-param id=...` metadata children.",
example: "rect id=\"box\" x=(px)0 y=(px)0 w=(px)100 h=(px)100",
}),
"chart" => Some(NodeContentDescriptor {
description: "Optional `categories` child carries the X-axis category labels as \
positional string arguments (one per slot; absent = derive index labels at render). \
Optional `label-colors` child carries per-slice value-label colors as positional \
PropertyValue arguments (e.g. `(token)\"color.x\"`; one per category in order; \
absent = use the chart value-color or the white on-fill default). \
Optional `slice-colors` child carries per-slice FILL colors for pie/donut as \
positional PropertyValue arguments (e.g. `(token)\"color.x\"`; one per category \
in order; absent = use the palette). \
Zero or more `series` children carry the numeric data. \
Each series node takes its f64 data values as positional arguments \
and optional named props: label, color (token ref), label-color (token ref), data-ref. \
A `data-ref=\"field\"` binds the whole series to a numeric ARRAY field supplied at \
render via `--data` — JSON `{\"field\":[120,185,143]}` or a CSV column named `field` \
(one number per category). This is render-time binding, distinct from `zenith merge`, \
which substitutes per-row scalar text/image via `role=\"data.<column>\"` and does not \
vary chart series per row. \
Emit `categories` then `label-colors` then `slice-colors` before any `series` children.",
example: concat!(
"categories \"Q1\" \"Q2\" \"Q3\" \"Q4\"\n",
"label-colors (token)\"color.c1\" (token)\"color.c2\" (token)\"color.c3\" (token)\"color.c4\"\n",
"slice-colors (token)\"color.s1\" (token)\"color.s2\" (token)\"color.s3\" (token)\"color.s4\"\n",
"series label=\"Revenue\" color=(token)\"color.primary\" label-color=(token)\"color.lbl\" 120.0 200.0 150.0 310.0\n",
"series label=\"Costs\" color=(token)\"color.secondary\" 80.0 90.0 100.0 120.0",
),
}),
"light" | "mesh" => None,
"pattern" => Some(NodeContentDescriptor {
description: "Exactly one required child node — the motif — which is the template \
node that gets tiled. Any authorable node kind is valid as the motif.",
example: "rect id=\"dot\" x=(px)0 y=(px)0 w=(px)8 h=(px)8 fill=(token)\"color.accent\"",
}),
"instance" => Some(NodeContentDescriptor {
description: "Zero or more `override` children apply per-node property overrides \
to descendants of the referenced component. Each override targets a node by \
`ref=\"id\"` and accepts fill, visible, and optional `span` children to \
replace text content.",
example: concat!(
"override ref=\"headline\" fill=(token)\"color.alt\" {\n",
" span \"New headline text\"\n",
"}",
),
}),
"code" => Some(NodeContentDescriptor {
description: "A single `content` child carries the verbatim source string as its \
first positional argument. Newlines and tabs are expressed as \\n and \\t \
escape sequences in the string literal.",
example: "content \"fn main() {\\n println!(\\\"hello\\\");\\n}\"",
}),
"connector" => Some(NodeContentDescriptor {
description: "Optional `span` children form a text label rendered at the \
connector's geometric midpoint (the mid-point of the routed polyline). \
Use `text-style` on the connector node to apply a style ref to the label. \
Omit the block entirely (or author no `span` children) for an unlabelled \
connector — the render output is byte-identical when no spans are present.",
example: "span \"Yes\"",
}),
"rect" | "ellipse" | "line" | "image" | "field" | "toc" => None,
_ => None,
}
}
pub fn node_attributes(kind: &str) -> Vec<&'static str> {
dedupe_to_kebab(known_props_for_kind(kind))
}
pub fn page_summary() -> &'static str {
"Page declaration — geometry (w/h), margins, bleed, baseline grid, and workflow metadata."
}
pub fn asset_summary() -> &'static str {
"Asset declaration (image/svg/font) — provenance including sha256 and AI-generation fields."
}
pub fn document_summary() -> &'static str {
"Document root — colorspace, pagination, spread gutter, and document-level default margins."
}
pub fn page_attributes() -> Vec<&'static str> {
dedupe_to_kebab(PAGE_KNOWN_PROPS)
}
pub fn asset_attributes() -> Vec<&'static str> {
dedupe_to_kebab(ASSET_KNOWN_PROPS)
}
pub fn document_attributes() -> Vec<&'static str> {
dedupe_to_kebab(DOCUMENT_KNOWN_PROPS)
}
pub fn attribute_type_for_kind(kind: &str, name: &str) -> &'static str {
attribute_type_for_kind_inner(kind, name, "string")
}
pub fn attribute_type(name: &str) -> &'static str {
attribute_type_for_kind_inner("", name, "string")
}
fn attribute_type_for_kind_inner(kind: &str, name: &str, fallback: &'static str) -> &'static str {
match (kind, name) {
("rect" | "ellipse" | "polygon" | "polyline" | "pattern" | "chart", "fill") => {
"token ref: color/gradient"
}
("text" | "shape" | "code" | "table", "fill") => "token ref: color",
(_, "stroke") => "token ref: color",
(_, "shadow") => "token ref: shadow",
(_, "filter") => "token ref: filter",
(_, "mask") => "token ref: mask",
(_, "background") => "token ref: color/gradient",
(_, "border-top" | "border-bottom" | "border-left" | "border-right" | "stroke-outer") => {
"token ref: color"
}
(_, "border") => "token ref: color",
(_, "contrast-bg" | "header-fill") => "token ref: color",
("shape", "kind") => "enum: process|decision|terminator|ellipse",
("pattern", "kind") => "enum: grid|scatter",
("chart", "kind") => "enum: bar|line|area|sparkline|pie|donut",
("light", "kind") => "enum: ambient|glow|key|rim",
("light", "color") => "token ref: color",
("light", "angle") => "dimension: deg",
("mesh", "kind") => "enum: orthographic|perspective",
("mesh", "stroke-width") | ("mesh", "stroke-dash") | ("mesh", "stroke-gap") => {
"dimension literal or token ref: dimension"
}
("mesh", "x")
| ("mesh", "y")
| ("mesh", "w")
| ("mesh", "h")
| ("mesh", "vanishing-x")
| ("mesh", "vanishing-y")
| ("mesh", "extend") => "dimension literal or token ref: dimension",
("mesh", "rows") | ("mesh", "columns") => "u32 (>0)",
("mesh", "stroke-linecap") => "enum: butt|round|square",
("chart", "legend") => "bool",
("chart", "caption") => "string",
("chart", "axis-min" | "axis-max") => "f64",
("chart", "axis-style") => "string",
("chart", "legend-position") => "enum: right|left|top|bottom",
("chart", "legend-layout") => "enum: wrapped|list",
("chart", "legend-align") => "enum: center|left|right",
("chart", "bar-mode") => "enum: grouped|stacked",
("chart", "orientation") => "enum: vertical|horizontal",
("chart", "point-placement") => "enum: edge|center",
("chart", "value-labels") => "enum: auto|none|top|center",
("chart", "value-color") => "token ref: color",
("chart", "label-color") => "token ref: color",
("", "kind") => "enum: image|svg|font",
("connector", "route") => "enum: straight|orthogonal|avoid",
("frame", "layout") => "enum: absolute|flow|grid",
_ => attribute_type_generic(name, fallback),
}
}
fn attribute_type_generic(name: &str, fallback: &'static str) -> &'static str {
match name {
"id" => "string",
"name" => "string",
"role" => "string",
"style" => "string",
"x" | "y" | "w" | "h" => "px literal or token ref: dimension",
"x1" | "y1" | "x2" | "y2" => "px literal",
"rx" | "ry" => "px literal",
"rotate" => "px literal",
"spacing" => "px literal",
"padding-left" | "text-indent" => "px literal",
"bullet-gap" => "px literal",
"anchor-gap" => "px literal",
"blur" => "px literal",
"bleed" => "px literal",
"spread-gutter" => "px literal",
"margin-inner" | "margin-outer" | "margin-top" | "margin-bottom" => "px literal",
"radius" | "radius-tl" | "radius-tr" | "radius-br" | "radius-bl" => "token ref: dimension",
"stroke-width" | "stroke-dash" | "stroke-gap" | "stroke-outer-width" => {
"token ref: dimension"
}
"border-width" => "token ref: dimension",
"font-size" | "font-size-min" => "token ref: dimension",
"baseline-grid" => "token ref: dimension",
"gap" | "cell-padding" | "padding" => "token ref: dimension",
"src-x" | "src-y" | "src-w" | "src-h" => "px literal",
"object-position-x" | "object-position-y" => "f64 (0.0–1.0)",
"clip-radius" => "token ref: dimension",
"font-family" => "token ref: fontFamily",
"font-weight" => "token ref: fontWeight",
"opacity" | "jitter" | "intensity" => "f64 (0.0–1.0)",
"seed" | "count" => "i64",
"drop-cap-lines" | "widow-orphan" | "tab-width" | "line-numbers" => "i64",
"colspan" | "rowspan" | "header-rows" | "columns" | "rows" => "i64",
"layer-priority" => "i64",
"visible" | "locked" | "anchor-parent" | "selectable" => "bool",
"hyphenate" | "suppress-first" | "border-collapse" => "bool",
"mirror-margins" | "facing-pages" => "bool",
"line-jumps" => "bool",
"anchor" => {
"enum: top-left|top-center|top-right|center-left|center|center-right|bottom-left|bottom-center|bottom-right"
}
"anchor-edge" => "enum: above|below|before|after",
"align" => "enum: left|center|right|justify",
"overflow" => "enum: clip|visible|scroll",
"blend-mode" => "string (enum)",
"stroke-alignment" => "enum: inside|center|outside",
"stroke-linecap" => "enum: butt|round|square",
"fill-rule" => "enum: nonzero|evenodd",
"fit" => "enum: fill|contain|cover|none",
"clip" => "bool",
"parity" => "enum: left|right",
"page-parity-start" => "enum: left|right",
"page-progression" => "enum: ltr|rtl",
"colorspace" => "enum: srgb|display-p3|rec2020",
"direction" => "enum: ltr|rtl",
"overflow-wrap" => "enum: normal|break-word",
"h-align" => "enum: left|center|right",
"v-align" => "enum: top|middle|bottom",
"from" | "to" => "node id",
"from-anchor" | "to-anchor" => "string",
"marker-start" | "marker-end" => "string",
"chain" => "string (chain id)",
"tab-leader" | "text-exclusion" | "bullet" => "string",
"language" => "string",
"syntax-theme" => "string",
"data-ref" => "string",
"format" => "enum: currency|percent|number",
"precision" => "i64",
"locale" => "string",
"type" => "string",
"recto" | "verso" | "target" => "string",
"folio-style" | "header-style" | "text-style" => "string",
"marker" => "string",
"match-role" | "match-style" | "leader" => "string",
"flows" => "string",
"asset" => "asset id",
"component" => "string",
"semantic-role" => "string",
"master" => "string",
"version" => "string",
"doc-id" => "string",
"title" => "string",
"src" => "string",
"sha256" => "string",
"ai-prompt" => "string",
"ai-model" => "string",
"ai-provider" => "string",
"ai-seed" => "i64",
"ai-generation-date" => "string",
"ai-license" => "string",
"ai-source-rights" => "string",
"ai-safety-status" => "string",
"ai-reuse-policy" => "string",
"anchor-zone" | "anchor-sibling" => "string",
_ => fallback,
}
}
pub fn token_types() -> &'static [&'static str] {
&[
"color",
"dimension",
"filter",
"fontFamily",
"fontWeight",
"gradient",
"mask",
"number",
"shadow",
]
}
pub fn token_type_summary(ty: &str) -> Option<&'static str> {
match ty {
"color" => Some("sRGB hex, alpha-hex, or CMYK color constant."),
"dimension" => Some("Typed measurement with unit: px, pt, pct, or deg."),
"filter" => Some("Ordered stack of image filter ops (grayscale, duotone, noise, …)."),
"fontFamily" => Some("Named font-family string used for typography."),
"fontWeight" => Some("Integer font weight in 100–900 (e.g. 400 = regular, 700 = bold)."),
"gradient" => Some("Linear or radial gradient built from ≥2 color-stop child nodes."),
"mask" => Some("Spatial coverage mask: a single rect, ellipse, or rounded-rect shape."),
"number" => Some("Unitless finite number (e.g. opacity ratio, scale factor)."),
"shadow" => Some("Ordered stack of drop-shadow layers, each referencing a color token."),
_ => None,
}
}
pub struct TokenTypeDescriptor {
pub type_name: &'static str,
pub summary: &'static str,
pub value_form: &'static str,
pub child_nodes: &'static str,
pub example: &'static str,
}
pub fn token_type_descriptor(ty: &str) -> Option<TokenTypeDescriptor> {
match ty {
"color" => Some(TokenTypeDescriptor {
type_name: "color",
summary: token_type_summary("color").unwrap_or(""),
value_form: r##"String literal: "#rrggbb" (6-digit lowercase hex), "#rrggbbaa" (8-digit), or "cmyk(c,m,y,k)" with each channel 0–100."##,
child_nodes: "",
example: r##"token id="color.brand.primary" type="color" value="#1a73e8""##,
}),
"dimension" => Some(TokenTypeDescriptor {
type_name: "dimension",
summary: token_type_summary("dimension").unwrap_or(""),
value_form: "Dimension literal: (px)N, (pt)N, (pct)N, or (deg)N — annotation then bare number, no space. E.g. (px)16, (pt)12, (pct)100, (deg)45.",
child_nodes: "",
example: r#"token id="dim.radius.card" type="dimension" value=(px)8"#,
}),
"filter" => Some(TokenTypeDescriptor {
type_name: "filter",
summary: token_type_summary("filter").unwrap_or(""),
value_form: "No inline value. Defined entirely by op child nodes.",
child_nodes: "≥1 op child node. Valid op names: grayscale, invert, sepia, saturate, brightness, contrast, hue-rotate (each accept optional amount=N); duotone (requires shadow=(token)\"id\" highlight=(token)\"id\", optional amount=N); noise (accepts seed=N scale=N, optional amount=N).",
example: "token id=\"filter.mono\" type=\"filter\" {\n grayscale amount=1.0\n}",
}),
"fontFamily" => Some(TokenTypeDescriptor {
type_name: "fontFamily",
summary: token_type_summary("fontFamily").unwrap_or(""),
value_form: r#"Non-empty string literal: the font-family name as it appears in the asset block, e.g. "Inter" or "Source Serif 4"."#,
child_nodes: "",
example: r#"token id="font.body" type="fontFamily" value="Inter""#,
}),
"fontWeight" => Some(TokenTypeDescriptor {
type_name: "fontWeight",
summary: token_type_summary("fontWeight").unwrap_or(""),
value_form: "Bare integer (NOT a string, NOT a dimension): an integer in 100–900 with no unit annotation. E.g. 400, 700.",
child_nodes: "",
example: r#"token id="weight.bold" type="fontWeight" value=700"#,
}),
"gradient" => Some(TokenTypeDescriptor {
type_name: "gradient",
summary: token_type_summary("gradient").unwrap_or(""),
value_form: "No inline value. Defined entirely by stop child nodes plus optional angle/radial props on the token node itself.",
child_nodes: "≥2 stop child nodes. Each stop: stop offset=0.0 color=(token)\"color-token-id\". Optional props on the token node: angle=(deg)N (linear, default 90), radial=#true, center-x=0.5 center-y=0.5 radius=1.0.",
example: "token id=\"gradient.brand\" type=\"gradient\" angle=(deg)90 {\n stop offset=0.0 color=(token)\"color.brand.primary\"\n stop offset=1.0 color=(token)\"color.brand.secondary\"\n}",
}),
"mask" => Some(TokenTypeDescriptor {
type_name: "mask",
summary: token_type_summary("mask").unwrap_or(""),
value_form: "No inline value. Defined by exactly one shape child node.",
child_nodes: "Exactly 1 shape child: rect, ellipse, or rounded. Each accepts feather=N (Gaussian sigma px, default 0) and invert=#true/#false. rounded also accepts radius=N (corner radius px).",
example: "token id=\"mask.card\" type=\"mask\" {\n rounded radius=8 feather=2\n}",
}),
"number" => Some(TokenTypeDescriptor {
type_name: "number",
summary: token_type_summary("number").unwrap_or(""),
value_form: "Bare finite number with no unit annotation. E.g. 1.0, 0.5, 1.05. NaN and ±inf are invalid.",
child_nodes: "",
example: r#"token id="number.line-height" type="number" value=1.4"#,
}),
"shadow" => Some(TokenTypeDescriptor {
type_name: "shadow",
summary: token_type_summary("shadow").unwrap_or(""),
value_form: "No inline value. Defined entirely by layer child nodes.",
child_nodes: "≥1 layer child node. Each layer: layer color=(token)\"color-token-id\" dx=(px)N dy=(px)N blur=(px)N. dx/dy can be negative (offsets); blur is clamped to ≥0.",
example: "token id=\"shadow.card\" type=\"shadow\" {\n layer color=(token)\"color.shadow\" dx=(px)0 dy=(px)2 blur=(px)8\n}",
}),
_ => None,
}
}
pub struct VariantDescriptor {
pub summary: &'static str,
pub block_structure: &'static str,
pub variant_node: &'static str,
pub override_entry: &'static str,
pub override_props: &'static [(&'static str, &'static str, bool)],
pub example: &'static str,
}
pub fn variant_descriptor() -> VariantDescriptor {
VariantDescriptor {
summary: "Variant system — named page-level derivatives with per-node property overrides.",
block_structure: "A `variants { … }` block sits at the document root, as a sibling of \
`document` (canonical order: after `provenance`, before `document`) — NOT inside a \
page. It contains one or more `variant` entries, each with its own child block of \
`override` entries that apply to that variant.",
variant_node: "variant id=<id> source=<page-id> w=(px)N h=(px)N { … }\n\
\n\
• id — unique identifier for this variant (string, required)\n\
• source — the page id to base this variant on (page id string, required)\n\
• w — override canvas width in pixels, e.g. (px)1920 (dimension, required)\n\
• h — override canvas height in pixels, e.g. (px)1080 (dimension, required)\n\
\n\
The child block of `variant { … }` contains `override` entries (see below).",
override_entry: "override node=\"<id>\" …\n\
\n\
Targets the node whose id equals the `node=` value, and applies one or more \
property overrides. The `node` key is the only required field; all visual/geometry \
keys are optional and independent (omitted keys retain the source page value).\n\
\n\
IMPORTANT: the selector key is `node` (the target node's id string), NOT `id`.\n\
Wrong: override id=\"hero\" visible=#false\n\
Correct: override node=\"hero\" visible=#false",
override_props: &[
("node", "string — target node id selector (required)", true),
("visible", "#true or #false", false),
("text", "string — replacement text content", false),
("fill", "token ref or color string", false),
("x", "typed dimension, e.g. (px)100", false),
("y", "typed dimension, e.g. (px)50", false),
("w", "typed dimension, e.g. (px)800", false),
("h", "typed dimension, e.g. (px)600", false),
],
example: concat!(
"variants {\n",
" variant id=\"mobile\" source=\"page.main\" w=(px)390 h=(px)844 {\n",
" // hide the desktop-only sidebar\n",
" override node=\"sidebar\" visible=#false\n",
" // shrink the hero to fit the narrower canvas\n",
" override node=\"hero\" x=(px)0 y=(px)0 w=(px)390 h=(px)260\n",
" // swap the headline copy\n",
" override node=\"headline\" text=\"Mobile headline\"\n",
" }\n",
"}",
),
}
}
pub fn diagnostics_summary() -> &'static str {
"In-file diagnostic policy — allow/deny/warn specific diagnostic codes \
(integrity Errors cannot be suppressed)."
}
pub fn diagnostics_verbs() -> &'static [&'static str] {
DIAGNOSTIC_VERBS
}
pub fn diagnostic_codes() -> &'static [DiagnosticCodeInfo] {
DIAGNOSTIC_CODES
}
fn dedupe_to_kebab(raw: &'static [&'static str]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = raw
.iter()
.map(|&name| {
let kebab = name.replace('_', "-");
raw.iter().copied().find(|n| *n == kebab).unwrap_or(name)
})
.collect();
out.sort_unstable();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Node;
use crate::ast::token::TokenType;
use crate::parse::KdlSource;
use crate::parse::kdl_adapter::KdlAdapter;
fn node_variant_count_exhaustive(node: &Node) -> usize {
match node {
Node::Rect(_) => 1,
Node::Ellipse(_) => 1,
Node::Line(_) => 1,
Node::Text(_) => 1,
Node::Code(_) => 1,
Node::Frame(_) => 1,
Node::Group(_) => 1,
Node::Image(_) => 1,
Node::Polygon(_) => 1,
Node::Polyline(_) => 1,
Node::Instance(_) => 1,
Node::Field(_) => 1,
Node::Footnote(_) => 1,
Node::Toc(_) => 1,
Node::Table(_) => 1,
Node::Shape(_) => 1,
Node::Connector(_) => 1,
Node::Pattern(_) => 1,
Node::Chart(_) => 1,
Node::Light(_) => 1,
Node::Mesh(_) => 1,
Node::Unknown(_) => 1,
}
}
const TOTAL_NODE_VARIANTS: usize = 22;
#[test]
fn node_summary_covers_every_node_kind() {
let expected_authorable = TOTAL_NODE_VARIANTS - 1; assert_eq!(
node_kinds().len(),
expected_authorable,
"node_kinds() has {} entries but the exhaustive Node match covers {} authorable \
variants (plus Unknown). Update node_kinds() and node_summary() when adding a variant.",
node_kinds().len(),
expected_authorable,
);
let _guard: fn(&Node) -> usize = node_variant_count_exhaustive;
for kind in node_kinds() {
assert!(
node_summary(kind).is_some(),
"node_summary(\"{kind}\") returned None — add a one-liner to node_summary()",
);
}
}
#[test]
fn node_content_returns_some_for_content_bearing_kinds() {
let content_kinds = &[
"text",
"shape",
"footnote",
"polygon",
"polyline",
"table",
"frame",
"group",
"pattern",
"chart",
"instance",
"code",
"connector",
];
for &kind in content_kinds {
let desc = node_content(kind);
assert!(
desc.is_some(),
"node_content(\"{kind}\") returned None — expected Some for a content-bearing kind",
);
let d = desc.unwrap();
assert!(
!d.description.is_empty(),
"node_content(\"{kind}\").description is empty",
);
assert!(
!d.example.is_empty(),
"node_content(\"{kind}\").example is empty",
);
}
}
#[test]
fn node_content_returns_none_for_no_content_kinds() {
let no_content_kinds = &["rect", "ellipse", "line", "image", "field", "toc"];
for &kind in no_content_kinds {
assert!(
node_content(kind).is_none(),
"node_content(\"{kind}\") returned Some — expected None for a leaf-only kind",
);
}
}
#[test]
fn node_attributes_nonempty_for_geometry_kinds() {
let rect_attrs = node_attributes("rect");
assert!(!rect_attrs.is_empty(), "rect attributes must not be empty");
assert!(
rect_attrs.contains(&"fill"),
"rect attributes must contain \"fill\"; got: {:?}",
rect_attrs
);
assert!(
rect_attrs.contains(&"x"),
"rect attributes must contain \"x\"; got: {:?}",
rect_attrs
);
assert!(
rect_attrs.contains(&"w"),
"rect attributes must contain \"w\"; got: {:?}",
rect_attrs
);
let text_attrs = node_attributes("text");
assert!(!text_attrs.is_empty(), "text attributes must not be empty");
assert!(
text_attrs.contains(&"x"),
"text attributes must contain \"x\"; got: {:?}",
text_attrs
);
let pattern_attrs = node_attributes("pattern");
assert!(
!pattern_attrs.is_empty(),
"pattern attributes must not be empty"
);
assert!(
pattern_attrs.contains(&"kind"),
"pattern attributes must contain \"kind\"; got: {:?}",
pattern_attrs
);
assert!(
pattern_attrs.contains(&"spacing"),
"pattern attributes must contain \"spacing\"; got: {:?}",
pattern_attrs
);
let frame_attrs = node_attributes("frame");
assert!(
!frame_attrs.is_empty(),
"frame attributes must not be empty"
);
assert!(
frame_attrs.contains(&"x"),
"frame attributes must contain \"x\"; got: {:?}",
frame_attrs
);
assert!(
frame_attrs.contains(&"w"),
"frame attributes must contain \"w\"; got: {:?}",
frame_attrs
);
}
#[test]
fn node_attributes_empty_for_unknown_kind() {
assert!(
node_attributes("not-a-real-kind").is_empty(),
"unrecognised kinds must return an empty slice"
);
}
#[test]
fn page_attributes_anchor_check() {
let attrs = page_attributes();
assert!(!attrs.is_empty(), "page_attributes() must not be empty");
for anchor in &["w", "h", "line-jumps"] {
assert!(
attrs.contains(anchor),
"page_attributes() must contain \"{anchor}\"; got: {attrs:?}",
);
}
assert!(
!attrs.contains(&"line_jumps"),
"underscore alias \"line_jumps\" must be collapsed; got: {attrs:?}",
);
}
#[test]
fn asset_attributes_anchor_check() {
let attrs = asset_attributes();
assert!(!attrs.is_empty(), "asset_attributes() must not be empty");
for anchor in &["sha256", "ai-prompt", "ai-model", "src", "kind"] {
assert!(
attrs.contains(anchor),
"asset_attributes() must contain \"{anchor}\"; got: {attrs:?}",
);
}
}
#[test]
fn document_attributes_anchor_check() {
let attrs = document_attributes();
assert!(!attrs.is_empty(), "document_attributes() must not be empty");
for anchor in &["title", "colorspace", "doc-id", "spread-gutter"] {
assert!(
attrs.contains(anchor),
"document_attributes() must contain \"{anchor}\"; got: {attrs:?}",
);
}
assert!(
!attrs.contains(&"doc_id"),
"underscore alias \"doc_id\" must be collapsed; got: {attrs:?}",
);
}
#[test]
fn attribute_type_covers_all_known_attrs() {
use std::collections::BTreeMap;
use std::collections::BTreeSet;
let mut attr_to_kind: BTreeMap<&'static str, &'static str> = BTreeMap::new();
for &kind in node_kinds() {
for attr in node_attributes(kind) {
attr_to_kind.entry(attr).or_insert(kind);
}
}
let mut surface_attrs: BTreeSet<&'static str> = BTreeSet::new();
for attr in page_attributes() {
surface_attrs.insert(attr);
}
for attr in asset_attributes() {
surface_attrs.insert(attr);
}
for attr in document_attributes() {
surface_attrs.insert(attr);
}
let mut unmapped: Vec<String> = Vec::new();
for (attr, kind) in &attr_to_kind {
if attribute_type_for_kind_inner(kind, attr, "<unmapped>") == "<unmapped>" {
unmapped.push(format!("{attr} (on {kind})"));
}
}
for attr in &surface_attrs {
if !attr_to_kind.contains_key(attr)
&& attribute_type_for_kind_inner("", attr, "<unmapped>") == "<unmapped>"
{
unmapped.push(format!("{attr} (surface)"));
}
}
assert!(
unmapped.is_empty(),
"attribute_type_for_kind_inner() has no entry for {} attribute(s): {:?}\n\
Add an arm to `attribute_type_for_kind_inner` or `attribute_type_generic` \
in zenith-core/src/schema.rs.",
unmapped.len(),
unmapped,
);
}
#[test]
fn fill_type_hint_is_kind_accurate() {
for kind in &["rect", "ellipse", "polygon", "polyline", "pattern"] {
assert_eq!(
attribute_type_for_kind(kind, "fill"),
"token ref: color/gradient",
"fill on {kind} should accept color/gradient (validator uses ColorOrGradient)",
);
}
for kind in &["text", "shape", "code", "table"] {
assert_eq!(
attribute_type_for_kind(kind, "fill"),
"token ref: color",
"fill on {kind} should be color-only (validator uses VisualExpect::Color)",
);
}
}
#[test]
fn stroke_type_hint_is_color_only() {
for kind in &[
"rect",
"ellipse",
"line",
"polygon",
"polyline",
"pattern",
"text",
"shape",
"connector",
] {
assert_eq!(
attribute_type_for_kind(kind, "stroke"),
"token ref: color",
"stroke on {kind} must be color-only (validator uses VisualExpect::Color)",
);
}
}
#[test]
fn shadow_filter_mask_report_own_token_types() {
for kind in &["rect", "pattern"] {
assert_eq!(
attribute_type_for_kind(kind, "shadow"),
"token ref: shadow",
"shadow on {kind} must reference a shadow token",
);
assert_eq!(
attribute_type_for_kind(kind, "filter"),
"token ref: filter",
"filter on {kind} must reference a filter token",
);
assert_eq!(
attribute_type_for_kind(kind, "mask"),
"token ref: mask",
"mask on {kind} must reference a mask token",
);
}
assert_eq!(attribute_type("shadow"), "token ref: shadow");
assert_eq!(attribute_type("filter"), "token ref: filter");
assert_eq!(attribute_type("mask"), "token ref: mask");
}
#[test]
fn container_effect_attributes_are_discoverable() {
for kind in &["group", "frame"] {
let attrs = node_attributes(kind);
for attr in &["shadow", "filter", "mask", "blur", "blend-mode"] {
assert!(
attrs.contains(attr),
"{kind} schema must include {attr}; attrs: {attrs:?}"
);
}
assert_eq!(attribute_type_for_kind(kind, "shadow"), "token ref: shadow");
assert_eq!(attribute_type_for_kind(kind, "filter"), "token ref: filter");
assert_eq!(attribute_type_for_kind(kind, "mask"), "token ref: mask");
}
}
#[test]
fn background_type_hint_is_color_or_gradient() {
assert_eq!(attribute_type("background"), "token ref: color/gradient");
assert_eq!(
attribute_type_for_kind("", "background"),
"token ref: color/gradient"
);
}
#[test]
fn border_and_stroke_outer_are_color_only() {
for attr in &[
"border-top",
"border-bottom",
"border-left",
"border-right",
"stroke-outer",
] {
assert_eq!(
attribute_type_for_kind("rect", attr),
"token ref: color",
"{attr} on rect must be color-only (validator uses VisualExpect::Color)",
);
}
}
#[test]
fn kind_type_hint_is_node_aware() {
assert_eq!(
attribute_type_for_kind("shape", "kind"),
"enum: process|decision|terminator|ellipse",
"shape.kind must enumerate the flowchart shape variants",
);
assert_eq!(
attribute_type_for_kind("pattern", "kind"),
"enum: grid|scatter",
"pattern.kind must enumerate the tiling mode variants",
);
assert_ne!(
attribute_type_for_kind("shape", "kind"),
attribute_type_for_kind("pattern", "kind"),
"shape.kind and pattern.kind must NOT return the same hint (they are different enums)",
);
}
#[test]
fn route_type_hint_is_connector_specific() {
assert_eq!(
attribute_type_for_kind("connector", "route"),
"enum: straight|orthogonal|avoid",
"connector.route must enumerate all three routing modes",
);
}
#[test]
fn layout_type_hint_is_frame_specific() {
assert_eq!(
attribute_type_for_kind("frame", "layout"),
"enum: absolute|flow|grid",
"frame.layout must enumerate the three layout modes",
);
}
fn token_type_variant_count_exhaustive(ty: &TokenType) -> usize {
match ty {
TokenType::Color => 1,
TokenType::Dimension => 1,
TokenType::Number => 1,
TokenType::FontFamily => 1,
TokenType::FontWeight => 1,
TokenType::Gradient => 1,
TokenType::Shadow => 1,
TokenType::Filter => 1,
TokenType::Mask => 1,
TokenType::Unknown(_) => 1,
}
}
const TOTAL_TOKEN_TYPE_VARIANTS: usize = 10;
#[test]
fn token_type_summary_covers_every_token_type() {
let expected_authorable = TOTAL_TOKEN_TYPE_VARIANTS - 1;
assert_eq!(
token_types().len(),
expected_authorable,
"token_types() has {} entries but the exhaustive TokenType match covers {} authorable \
variants (plus Unknown). Update token_types(), token_type_summary(), and \
token_type_descriptor() when adding a variant.",
token_types().len(),
expected_authorable,
);
let _guard: fn(&TokenType) -> usize = token_type_variant_count_exhaustive;
for ty in token_types() {
assert!(
token_type_summary(ty).is_some(),
"token_type_summary(\"{ty}\") returned None — add a one-liner to token_type_summary()",
);
}
}
#[test]
fn token_type_descriptor_covers_every_token_type() {
for ty in token_types() {
assert!(
token_type_descriptor(ty).is_some(),
"token_type_descriptor(\"{ty}\") returned None — add a descriptor to token_type_descriptor()",
);
}
for ty in token_types() {
let desc = token_type_descriptor(ty).unwrap();
assert_eq!(
desc.type_name, *ty,
"token_type_descriptor(\"{ty}\").type_name is \"{}\", expected \"{ty}\"",
desc.type_name,
);
assert!(
!desc.summary.is_empty(),
"token_type_descriptor(\"{ty}\").summary is empty",
);
assert!(
!desc.value_form.is_empty() || !desc.child_nodes.is_empty(),
"token_type_descriptor(\"{ty}\") has both empty value_form and child_nodes",
);
assert!(
!desc.example.is_empty(),
"token_type_descriptor(\"{ty}\").example is empty",
);
}
}
#[test]
fn token_type_examples_parse_without_syntax_errors() {
for ty in token_types() {
let desc = token_type_descriptor(ty).unwrap();
let doc_src = format!(
"zenith version=1 {{\n\
\x20 tokens format=\"zenith-token-v1\" {{\n\
\x20 {}\n\
\x20 }}\n\
\x20 document id=\"doc\" {{\n\
\x20 page id=\"pg\" w=(px)1 h=(px)1 {{}}\n\
\x20 }}\n\
}}\n",
desc.example,
);
let result = KdlAdapter.parse(doc_src.as_bytes());
assert!(
result.is_ok(),
"token_type_descriptor(\"{ty}\").example failed to parse:\n\
example:\n {}\n\
wrapped doc:\n{doc_src}\n\
parse error: {:?}",
desc.example,
result.err(),
);
}
}
}