1use crate::diag_catalog::{DIAGNOSTIC_CODES, DIAGNOSTIC_VERBS, DiagnosticCodeInfo};
18use crate::parse::transform::PAGE_KNOWN_PROPS;
19use crate::parse::transform::{ASSET_KNOWN_PROPS, DOCUMENT_KNOWN_PROPS, known_props_for_kind};
20
21pub fn node_kinds() -> &'static [&'static str] {
28 &[
32 "code",
33 "connector",
34 "ellipse",
35 "field",
36 "footnote",
37 "frame",
38 "group",
39 "image",
40 "chart",
41 "instance",
42 "line",
43 "pattern",
44 "polygon",
45 "polyline",
46 "rect",
47 "shape",
48 "table",
49 "text",
50 "toc",
51 ]
52}
53
54pub fn node_summary(kind: &str) -> Option<&'static str> {
62 match kind {
63 "rect" => Some("Rectangle with optional fill, stroke, and rounded corners."),
64 "ellipse" => Some("Ellipse or circle with optional fill and stroke."),
65 "line" => Some("Straight line segment between two endpoints."),
66 "text" => Some("Multi-span text block with typography and layout properties."),
67 "code" => Some("Monospace code block with syntax-theme highlighting."),
68 "frame" => Some("Container that clips and positions its children within a fixed box."),
69 "group" => Some("Transparent grouping container for related nodes."),
70 "image" => Some("Raster or SVG image positioned within a bounding box."),
71 "polygon" => Some("Closed polygon defined by an ordered vertex list."),
72 "polyline" => Some("Open polyline defined by an ordered vertex list."),
73 "instance" => Some("Reference to a master component, optionally with overrides."),
74 "field" => Some("Editable variable-data text field bound to a named slot."),
75 "footnote" => Some("Page-level footnote referenced by text span markers."),
76 "toc" => Some("Table-of-contents placeholder resolved to text by the scene compiler."),
77 "table" => Some("Structured data table with columns, rows, and cells."),
78 "shape" => Some("Preset geometric shape with an optional text label."),
79 "connector" => Some("Directed connector line between two anchor points on nodes."),
80 "pattern" => Some("Procedural grid or scatter tiling of one motif node."),
81 "chart" => Some(
82 "Data-visualization chart (bar, line, area, sparkline, pie, donut) with inline series data.",
83 ),
84 _ => None,
85 }
86}
87
88pub struct NodeContentDescriptor {
94 pub description: &'static str,
96 pub example: &'static str,
99}
100
101pub fn node_content(kind: &str) -> Option<NodeContentDescriptor> {
108 match kind {
109 "text" => Some(NodeContentDescriptor {
111 description: "One or more `span` children carry the text runs. \
112 Each span takes a string argument and optional inline style props: \
113 fill, font-weight, italic, underline, strikethrough, highlight, code, link, \
114 vertical-align, footnote-ref. \
115 `highlight` is a per-span background color (token ref or raw color string) \
116 rendered behind the glyph run like a marker-pen highlight. \
117 `code=#true` renders the span in the bundled monospace family with a subtle \
118 background, suitable for inline code. \
119 `link=\"url\"` renders the span underlined in the default link color (unless \
120 `fill` is set); in PDF output the URL becomes a clickable `/Link` annotation \
121 over the span. \
122 `selectable` (node attribute, default `#true`) controls PDF text extraction: \
123 by default the text is emitted as real, selectable / searchable / indexable \
124 text (with a ToUnicode map, so copy and search work and links are clickable); \
125 `selectable=#false` renders the glyphs as filled outlines instead — visually \
126 identical but not selectable, searchable, or extractable. The PNG backend is \
127 unaffected. \
128 The `format` node attribute (values: `markdown` | `plain`) opts into \
129 markdown rendering of the concatenated span text. \
130 When `format=\"markdown\"`, the scene compile pass re-parses the span content \
131 AFTER data-binding substitution and renders both inline marks and block structure. \
132 Inline marks: `**bold**`, `*italic*`, `~~strike~~`, `==highlight==`, \
133 `++underline++`, `` `code` ``, `[label](url)`. \
134 Block structure (one construct per line/paragraph): \
135 `# H1` through `###### H6` (ATX headings), blank line separates paragraphs, \
136 `> text` blockquote, `- item` / `* item` / `+ item` unordered list, \
137 `1. item` ordered list, ` ``` ` fenced code block (optional lang after opening \
138 fence; ends at closing ` ``` `), `---` / `***` / `___` horizontal rule. \
139 The block roles produced (h1..h6, p, blockquote, li, code-block, hr) are the \
140 same names styled by `block role=\"…\"` declarations (see `zenith schema block`). \
141 v1 limitation: in a `chain` flow, code-block backgrounds and `---` rules are \
142 not drawn and blockquote/list indent is not applied — these render fully only \
143 in a single non-chained text box. \
144 Pairs well with a single `data-ref` span to parse external content as markdown \
145 without encoding marks in the document. `format=\"plain\"` or absent = literal \
146 (byte-identical to today's behavior). \
147 The `src` node attribute (`src=\"path/to/file.md\"`) loads the file at the \
148 given path (resolved relative to the document's project directory) and uses its \
149 UTF-8 contents as the node's text content, replacing any inline `span` children \
150 at render time. This keeps the `.zen` file lean for long-form prose. When paired \
151 with `format=\"markdown\"`, the loaded text is parsed as markdown by the \
152 scene compile pass. A missing or unreadable file emits a `text.src_missing` \
153 Error diagnostic (same gate as `asset.missing`). The `src` field is retained \
154 on the node so a future editor can write edits back to the original file. \
155 Threaded text flow (`chain` attribute): all `text` nodes that share the same \
156 `chain=\"id\"` value form one ordered chain (document source order, across pages). \
157 The FIRST member that carries spans or `src` content is the content source; \
158 subsequent members must have EMPTY spans (no `src`, no inline spans) and serve \
159 as overflow boxes. Each member needs explicit `x`/`y`/`w`/`h` geometry. Text \
160 fills box 1, the remainder flows into box 2, etc., across page boundaries. \
161 This is how you resolve a `text.overflow` warning for long-form copy: add \
162 chained continuation boxes (on the same or new pages) until nothing overflows. \
163 Only the first member's font/style drives the whole chain; per-span overrides \
164 on the source are honored. \
165 A `block role=\"…\"` declaration may appear BEFORE span children to set per-role \
166 markdown block style at this text node's scope (highest cascade precedence: \
167 text > page > document). Block decls affect only nodes with `format=\"markdown\"` \
168 and have no effect on plain-text nodes (see `zenith schema block`).",
169 example: concat!(
170 "block role=\"h1\" font-size=(token)\"size.h1\" font-weight=(token)\"weight.bold\"\n",
171 "span \"Hello \"\n",
172 "span \"world\" font-weight=(token)\"weight.bold\" italic=#true",
173 ),
174 }),
175 "shape" => Some(NodeContentDescriptor {
176 description: "Optional `span` children form a text label rendered centered inside the \
177 shape. Use h-align/v-align on the shape node to adjust alignment. \
178 Omit the block entirely for an unlabelled shape.",
179 example: "span \"Approve\"",
180 }),
181 "footnote" => Some(NodeContentDescriptor {
182 description: "One or more `span` children carry the footnote body text, \
183 using the same span model as `text`.",
184 example: "span \"See also Chapter 3.\"",
185 }),
186
187 "polygon" => Some(NodeContentDescriptor {
189 description: "Two or more `point` children define the closed vertex list in order. \
190 Each point carries `x` and `y` as px-literal dimensions.",
191 example: concat!(
192 "point x=(px)0 y=(px)0\n",
193 "point x=(px)100 y=(px)0\n",
194 "point x=(px)50 y=(px)86",
195 ),
196 }),
197 "polyline" => Some(NodeContentDescriptor {
198 description: "Two or more `point` children define the open vertex list in order. \
199 Each point carries `x` and `y` as px-literal dimensions.",
200 example: concat!(
201 "point x=(px)0 y=(px)0\n",
202 "point x=(px)100 y=(px)50\n",
203 "point x=(px)200 y=(px)0",
204 ),
205 }),
206
207 "table" => Some(NodeContentDescriptor {
209 description: "Optional `column` children (each with `width=(px)N`) declare column \
210 widths; then `row` children each containing `cell` children. \
211 Each cell accepts colspan, rowspan, fill, border, h-align, v-align, \
212 and arbitrary renderable child nodes for cell content. \
213 Cell text auto-places: the cell sizes and positions its text into the content box \
214 (padding-inset), wraps to the column width, and aligns via the cell/table \
215 `h-align` (start|center|end) and `v-align` (top|middle|bottom). \
216 Omit `x`/`y`/`w`/`h` on cell text; set them only to override the auto layout. \
217 The table itself requires its own `x`/`y`/`w`/`h`.",
218 example: concat!(
219 "column width=(px)120\n",
220 "column width=(px)80\n",
221 "row {\n",
222 " cell { text { span \"Name\" } }\n",
223 " cell { text { span \"Score\" } }\n",
224 "}",
225 ),
226 }),
227
228 "frame" => Some(NodeContentDescriptor {
230 description: "Arbitrary renderable child nodes (any node kind). \
231 The frame clips its children to its bounding box. \
232 Use layout=\"grid\" with columns/rows attrs for grid layout.",
233 example: "rect id=\"bg\" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)\"color.bg\"",
234 }),
235 "group" => Some(NodeContentDescriptor {
236 description: "Arbitrary renderable child nodes (any node kind). \
237 May also include `protected-region id=... x=... y=... w=... h=...` \
238 and `editable-param id=...` metadata children.",
239 example: "rect id=\"box\" x=(px)0 y=(px)0 w=(px)100 h=(px)100",
240 }),
241
242 "chart" => Some(NodeContentDescriptor {
244 description: "Optional `categories` child carries the X-axis category labels as \
245 positional string arguments (one per slot; absent = derive index labels at render). \
246 Optional `label-colors` child carries per-slice value-label colors as positional \
247 PropertyValue arguments (e.g. `(token)\"color.x\"`; one per category in order; \
248 absent = use the chart value-color or the white on-fill default). \
249 Optional `slice-colors` child carries per-slice FILL colors for pie/donut as \
250 positional PropertyValue arguments (e.g. `(token)\"color.x\"`; one per category \
251 in order; absent = use the palette). \
252 Zero or more `series` children carry the numeric data. \
253 Each series node takes its f64 data values as positional arguments \
254 and optional named props: label, color (token ref), label-color (token ref), data-ref. \
255 A `data-ref=\"field\"` binds the whole series to a numeric ARRAY field supplied at \
256 render via `--data` — JSON `{\"field\":[120,185,143]}` or a CSV column named `field` \
257 (one number per category). This is render-time binding, distinct from `zenith merge`, \
258 which substitutes per-row scalar text/image via `role=\"data.<column>\"` and does not \
259 vary chart series per row. \
260 Emit `categories` then `label-colors` then `slice-colors` before any `series` children.",
261 example: concat!(
262 "categories \"Q1\" \"Q2\" \"Q3\" \"Q4\"\n",
263 "label-colors (token)\"color.c1\" (token)\"color.c2\" (token)\"color.c3\" (token)\"color.c4\"\n",
264 "slice-colors (token)\"color.s1\" (token)\"color.s2\" (token)\"color.s3\" (token)\"color.s4\"\n",
265 "series label=\"Revenue\" color=(token)\"color.primary\" label-color=(token)\"color.lbl\" 120.0 200.0 150.0 310.0\n",
266 "series label=\"Costs\" color=(token)\"color.secondary\" 80.0 90.0 100.0 120.0",
267 ),
268 }),
269
270 "pattern" => Some(NodeContentDescriptor {
272 description: "Exactly one required child node — the motif — which is the template \
273 node that gets tiled. Any authorable node kind is valid as the motif.",
274 example: "rect id=\"dot\" x=(px)0 y=(px)0 w=(px)8 h=(px)8 fill=(token)\"color.accent\"",
275 }),
276
277 "instance" => Some(NodeContentDescriptor {
279 description: "Zero or more `override` children apply per-node property overrides \
280 to descendants of the referenced component. Each override targets a node by \
281 `ref=\"id\"` and accepts fill, visible, and optional `span` children to \
282 replace text content.",
283 example: concat!(
284 "override ref=\"headline\" fill=(token)\"color.alt\" {\n",
285 " span \"New headline text\"\n",
286 "}",
287 ),
288 }),
289
290 "code" => Some(NodeContentDescriptor {
292 description: "A single `content` child carries the verbatim source string as its \
293 first positional argument. Newlines and tabs are expressed as \\n and \\t \
294 escape sequences in the string literal.",
295 example: "content \"fn main() {\\n println!(\\\"hello\\\");\\n}\"",
296 }),
297
298 "connector" => Some(NodeContentDescriptor {
300 description: "Optional `span` children form a text label rendered at the \
301 connector's geometric midpoint (the mid-point of the routed polyline). \
302 Use `text-style` on the connector node to apply a style ref to the label. \
303 Omit the block entirely (or author no `span` children) for an unlabelled \
304 connector — the render output is byte-identical when no spans are present.",
305 example: "span \"Yes\"",
306 }),
307
308 "rect" | "ellipse" | "line" | "image" | "field" | "toc" => None,
310
311 _ => None,
313 }
314}
315
316pub fn node_attributes(kind: &str) -> Vec<&'static str> {
328 dedupe_to_kebab(known_props_for_kind(kind))
333}
334
335pub fn page_summary() -> &'static str {
339 "Page declaration — geometry (w/h), margins, bleed, baseline grid, and workflow metadata."
340}
341
342pub fn asset_summary() -> &'static str {
344 "Asset declaration (image/svg/font) — provenance including sha256 and AI-generation fields."
345}
346
347pub fn document_summary() -> &'static str {
349 "Document root — colorspace, pagination, spread gutter, and document-level default margins."
350}
351
352pub fn page_attributes() -> Vec<&'static str> {
360 dedupe_to_kebab(PAGE_KNOWN_PROPS)
361}
362
363pub fn asset_attributes() -> Vec<&'static str> {
368 dedupe_to_kebab(ASSET_KNOWN_PROPS)
369}
370
371pub fn document_attributes() -> Vec<&'static str> {
377 dedupe_to_kebab(DOCUMENT_KNOWN_PROPS)
378}
379
380pub fn attribute_type_for_kind(kind: &str, name: &str) -> &'static str {
408 attribute_type_for_kind_inner(kind, name, "string")
409}
410
411pub fn attribute_type(name: &str) -> &'static str {
421 attribute_type_for_kind_inner("", name, "string")
426}
427
428fn attribute_type_for_kind_inner(kind: &str, name: &str, fallback: &'static str) -> &'static str {
434 match (kind, name) {
443 ("rect" | "ellipse" | "polygon" | "polyline" | "pattern" | "chart", "fill") => {
447 "token ref: color/gradient"
448 }
449 ("text" | "shape" | "code" | "table", "fill") => "token ref: color",
452 (_, "stroke") => "token ref: color",
458 (_, "shadow") => "token ref: shadow",
463 (_, "filter") => "token ref: filter",
464 (_, "mask") => "token ref: mask",
465 (_, "background") => "token ref: color/gradient",
468 (_, "border-top" | "border-bottom" | "border-left" | "border-right" | "stroke-outer") => {
470 "token ref: color"
471 }
472 (_, "border") => "token ref: color",
474 (_, "contrast-bg" | "header-fill") => "token ref: color",
477 ("shape", "kind") => "enum: process|decision|terminator|ellipse",
483 ("pattern", "kind") => "enum: grid|scatter",
484 ("chart", "kind") => "enum: bar|line|area|sparkline|pie|donut",
485 ("chart", "legend") => "bool",
487 ("chart", "caption") => "string",
488 ("chart", "axis-min" | "axis-max") => "f64",
489 ("chart", "axis-style") => "string",
490 ("chart", "legend-position") => "enum: right|left|top|bottom",
491 ("chart", "legend-layout") => "enum: wrapped|list",
492 ("chart", "legend-align") => "enum: center|left|right",
493 ("chart", "bar-mode") => "enum: grouped|stacked",
494 ("chart", "orientation") => "enum: vertical|horizontal",
495 ("chart", "point-placement") => "enum: edge|center",
496 ("chart", "value-labels") => "enum: auto|none|top|center",
497 ("chart", "value-color") => "token ref: color",
498 ("chart", "label-color") => "token ref: color",
500 ("", "kind") => "enum: image|svg|font",
503 ("connector", "route") => "enum: straight|orthogonal|avoid",
506 ("frame", "layout") => "enum: absolute|flow|grid",
510 _ => attribute_type_generic(name, fallback),
512 }
513}
514
515fn attribute_type_generic(name: &str, fallback: &'static str) -> &'static str {
520 match name {
521 "id" => "string",
523 "name" => "string",
524 "role" => "string",
525 "style" => "string",
526
527 "x" | "y" | "w" | "h" => "px literal or token ref: dimension",
529 "x1" | "y1" | "x2" | "y2" => "px literal",
530 "rx" | "ry" => "px literal",
531 "rotate" => "px literal",
532 "spacing" => "px literal",
533 "padding-left" | "text-indent" => "px literal",
534 "bullet-gap" => "px literal",
535 "anchor-gap" => "px literal",
536 "blur" => "px literal",
537 "bleed" => "px literal",
538 "spread-gutter" => "px literal",
539 "margin-inner" | "margin-outer" | "margin-top" | "margin-bottom" => "px literal",
540
541 "radius" | "radius-tl" | "radius-tr" | "radius-br" | "radius-bl" => "token ref: dimension",
543 "stroke-width" | "stroke-dash" | "stroke-gap" | "stroke-outer-width" => {
544 "token ref: dimension"
545 }
546 "border-width" => "token ref: dimension",
547 "font-size" | "font-size-min" => "token ref: dimension",
548 "baseline-grid" => "token ref: dimension",
549 "gap" | "cell-padding" | "padding" => "token ref: dimension",
550 "src-x" | "src-y" | "src-w" | "src-h" => "px literal",
552 "object-position-x" | "object-position-y" => "f64 (0.0–1.0)",
554 "clip-radius" => "token ref: dimension",
556
557 "font-family" => "token ref: fontFamily",
559 "font-weight" => "token ref: fontWeight",
560
561 "opacity" | "jitter" | "intensity" => "f64 (0.0–1.0)",
563
564 "seed" | "count" => "i64",
566 "drop-cap-lines" | "widow-orphan" | "tab-width" | "line-numbers" => "i64",
567 "colspan" | "rowspan" | "header-rows" | "columns" | "rows" => "i64",
568 "layer-priority" => "i64",
569
570 "visible" | "locked" | "anchor-parent" | "selectable" => "bool",
572 "hyphenate" | "suppress-first" | "border-collapse" => "bool",
573 "mirror-margins" | "facing-pages" => "bool",
574 "line-jumps" => "bool",
575
576 "anchor" => {
578 "enum: top-left|top-center|top-right|center-left|center|center-right|bottom-left|bottom-center|bottom-right"
579 }
580 "anchor-edge" => "enum: above|below|before|after",
581 "align" => "enum: left|center|right|justify",
582 "overflow" => "enum: clip|visible|scroll",
583 "blend-mode" => "string (enum)",
584 "stroke-alignment" => "enum: inside|center|outside",
585 "stroke-linecap" => "enum: butt|round|square",
586 "fill-rule" => "enum: nonzero|evenodd",
587 "fit" => "enum: fill|contain|cover|none",
588 "clip" => "bool",
589 "parity" => "enum: left|right",
590 "page-parity-start" => "enum: left|right",
591 "page-progression" => "enum: ltr|rtl",
592 "colorspace" => "enum: srgb|display-p3|rec2020",
593 "direction" => "enum: ltr|rtl",
594 "overflow-wrap" => "enum: normal|break-word",
595 "h-align" => "enum: left|center|right",
596 "v-align" => "enum: top|middle|bottom",
597
598 "from" | "to" => "node id",
600 "from-anchor" | "to-anchor" => "string",
601 "marker-start" | "marker-end" => "string",
602
603 "chain" => "string (chain id)",
609 "tab-leader" | "text-exclusion" | "bullet" => "string",
610 "language" => "string",
611 "syntax-theme" => "string",
612
613 "data-ref" => "string",
615 "format" => "enum: currency|percent|number",
616 "precision" => "i64",
617 "locale" => "string",
618
619 "type" => "string",
621 "recto" | "verso" | "target" => "string",
622 "folio-style" | "header-style" | "text-style" => "string",
623 "marker" => "string",
624 "match-role" | "match-style" | "leader" => "string",
625 "flows" => "string",
626
627 "asset" => "asset id",
629
630 "component" => "string",
632
633 "semantic-role" => "string",
635
636 "master" => "string",
638
639 "version" => "string",
641 "doc-id" => "string",
642 "title" => "string",
643
644 "src" => "string",
646 "sha256" => "string",
647 "ai-prompt" => "string",
648 "ai-model" => "string",
649 "ai-provider" => "string",
650 "ai-seed" => "i64",
651 "ai-generation-date" => "string",
652 "ai-license" => "string",
653 "ai-source-rights" => "string",
654 "ai-safety-status" => "string",
655 "ai-reuse-policy" => "string",
656
657 "anchor-zone" | "anchor-sibling" => "string",
659
660 _ => fallback,
661 }
662}
663
664pub fn token_types() -> &'static [&'static str] {
675 &[
676 "color",
677 "dimension",
678 "filter",
679 "fontFamily",
680 "fontWeight",
681 "gradient",
682 "mask",
683 "number",
684 "shadow",
685 ]
686}
687
688pub fn token_type_summary(ty: &str) -> Option<&'static str> {
696 match ty {
697 "color" => Some("sRGB hex, alpha-hex, or CMYK color constant."),
698 "dimension" => Some("Typed measurement with unit: px, pt, pct, or deg."),
699 "filter" => Some("Ordered stack of image filter ops (grayscale, duotone, noise, …)."),
700 "fontFamily" => Some("Named font-family string used for typography."),
701 "fontWeight" => Some("Integer font weight in 100–900 (e.g. 400 = regular, 700 = bold)."),
702 "gradient" => Some("Linear or radial gradient built from ≥2 color-stop child nodes."),
703 "mask" => Some("Spatial coverage mask: a single rect, ellipse, or rounded-rect shape."),
704 "number" => Some("Unitless finite number (e.g. opacity ratio, scale factor)."),
705 "shadow" => Some("Ordered stack of drop-shadow layers, each referencing a color token."),
706 _ => None,
707 }
708}
709
710pub struct TokenTypeDescriptor {
716 pub type_name: &'static str,
718 pub summary: &'static str,
720 pub value_form: &'static str,
723 pub child_nodes: &'static str,
726 pub example: &'static str,
729}
730
731pub fn token_type_descriptor(ty: &str) -> Option<TokenTypeDescriptor> {
737 match ty {
738 "color" => Some(TokenTypeDescriptor {
739 type_name: "color",
740 summary: token_type_summary("color").unwrap_or(""),
741 value_form: r##"String literal: "#rrggbb" (6-digit lowercase hex), "#rrggbbaa" (8-digit), or "cmyk(c,m,y,k)" with each channel 0–100."##,
742 child_nodes: "",
743 example: r##"token id="color.brand.primary" type="color" value="#1a73e8""##,
744 }),
745 "dimension" => Some(TokenTypeDescriptor {
746 type_name: "dimension",
747 summary: token_type_summary("dimension").unwrap_or(""),
748 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.",
749 child_nodes: "",
750 example: r#"token id="dim.radius.card" type="dimension" value=(px)8"#,
751 }),
752 "filter" => Some(TokenTypeDescriptor {
753 type_name: "filter",
754 summary: token_type_summary("filter").unwrap_or(""),
755 value_form: "No inline value. Defined entirely by op child nodes.",
756 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).",
757 example: "token id=\"filter.mono\" type=\"filter\" {\n grayscale amount=1.0\n}",
758 }),
759 "fontFamily" => Some(TokenTypeDescriptor {
760 type_name: "fontFamily",
761 summary: token_type_summary("fontFamily").unwrap_or(""),
762 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"."#,
763 child_nodes: "",
764 example: r#"token id="font.body" type="fontFamily" value="Inter""#,
765 }),
766 "fontWeight" => Some(TokenTypeDescriptor {
767 type_name: "fontWeight",
768 summary: token_type_summary("fontWeight").unwrap_or(""),
769 value_form: "Bare integer (NOT a string, NOT a dimension): an integer in 100–900 with no unit annotation. E.g. 400, 700.",
770 child_nodes: "",
771 example: r#"token id="weight.bold" type="fontWeight" value=700"#,
772 }),
773 "gradient" => Some(TokenTypeDescriptor {
774 type_name: "gradient",
775 summary: token_type_summary("gradient").unwrap_or(""),
776 value_form: "No inline value. Defined entirely by stop child nodes plus optional angle/radial props on the token node itself.",
777 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.",
778 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}",
779 }),
780 "mask" => Some(TokenTypeDescriptor {
781 type_name: "mask",
782 summary: token_type_summary("mask").unwrap_or(""),
783 value_form: "No inline value. Defined by exactly one shape child node.",
784 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).",
785 example: "token id=\"mask.card\" type=\"mask\" {\n rounded radius=8 feather=2\n}",
786 }),
787 "number" => Some(TokenTypeDescriptor {
788 type_name: "number",
789 summary: token_type_summary("number").unwrap_or(""),
790 value_form: "Bare finite number with no unit annotation. E.g. 1.0, 0.5, 1.05. NaN and ±inf are invalid.",
791 child_nodes: "",
792 example: r#"token id="number.line-height" type="number" value=1.4"#,
793 }),
794 "shadow" => Some(TokenTypeDescriptor {
795 type_name: "shadow",
796 summary: token_type_summary("shadow").unwrap_or(""),
797 value_form: "No inline value. Defined entirely by layer child nodes.",
798 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.",
799 example: "token id=\"shadow.card\" type=\"shadow\" {\n layer color=(token)\"color.shadow\" dx=(px)0 dy=(px)2 blur=(px)8\n}",
800 }),
801 _ => None,
802 }
803}
804
805pub struct VariantDescriptor {
809 pub summary: &'static str,
811 pub block_structure: &'static str,
813 pub variant_node: &'static str,
815 pub override_entry: &'static str,
817 pub override_props: &'static [(&'static str, &'static str, bool)],
819 pub example: &'static str,
821}
822
823pub fn variant_descriptor() -> VariantDescriptor {
829 VariantDescriptor {
830 summary: "Variant system — named page-level derivatives with per-node property overrides.",
831 block_structure: "A `variants { … }` block sits at the document root, as a sibling of \
832 `document` (canonical order: after `provenance`, before `document`) — NOT inside a \
833 page. It contains one or more `variant` entries, each with its own child block of \
834 `override` entries that apply to that variant.",
835 variant_node: "variant id=<id> source=<page-id> w=(px)N h=(px)N { … }\n\
836 \n\
837 • id — unique identifier for this variant (string, required)\n\
838 • source — the page id to base this variant on (page id string, required)\n\
839 • w — override canvas width in pixels, e.g. (px)1920 (dimension, required)\n\
840 • h — override canvas height in pixels, e.g. (px)1080 (dimension, required)\n\
841 \n\
842 The child block of `variant { … }` contains `override` entries (see below).",
843 override_entry: "override node=\"<id>\" …\n\
844 \n\
845 Targets the node whose id equals the `node=` value, and applies one or more \
846 property overrides. The `node` key is the only required field; all visual/geometry \
847 keys are optional and independent (omitted keys retain the source page value).\n\
848 \n\
849 IMPORTANT: the selector key is `node` (the target node's id string), NOT `id`.\n\
850 Wrong: override id=\"hero\" visible=#false\n\
851 Correct: override node=\"hero\" visible=#false",
852 override_props: &[
853 ("node", "string — target node id selector (required)", true),
854 ("visible", "#true or #false", false),
855 ("text", "string — replacement text content", false),
856 ("fill", "token ref or color string", false),
857 ("x", "typed dimension, e.g. (px)100", false),
858 ("y", "typed dimension, e.g. (px)50", false),
859 ("w", "typed dimension, e.g. (px)800", false),
860 ("h", "typed dimension, e.g. (px)600", false),
861 ],
862 example: concat!(
863 "variants {\n",
864 " variant id=\"mobile\" source=\"page.main\" w=(px)390 h=(px)844 {\n",
865 " // hide the desktop-only sidebar\n",
866 " override node=\"sidebar\" visible=#false\n",
867 " // shrink the hero to fit the narrower canvas\n",
868 " override node=\"hero\" x=(px)0 y=(px)0 w=(px)390 h=(px)260\n",
869 " // swap the headline copy\n",
870 " override node=\"headline\" text=\"Mobile headline\"\n",
871 " }\n",
872 "}",
873 ),
874 }
875}
876
877pub fn diagnostics_summary() -> &'static str {
882 "In-file diagnostic policy — allow/deny/warn specific diagnostic codes \
883 (integrity Errors cannot be suppressed)."
884}
885
886pub fn diagnostics_verbs() -> &'static [&'static str] {
891 DIAGNOSTIC_VERBS
892}
893
894pub fn diagnostic_codes() -> &'static [DiagnosticCodeInfo] {
901 DIAGNOSTIC_CODES
902}
903
904fn dedupe_to_kebab(raw: &'static [&'static str]) -> Vec<&'static str> {
914 let mut out: Vec<&'static str> = raw
915 .iter()
916 .map(|&name| {
917 let kebab = name.replace('_', "-");
918 raw.iter().copied().find(|n| *n == kebab).unwrap_or(name)
919 })
920 .collect();
921 out.sort_unstable();
922 out.dedup();
923 out
924}
925
926#[cfg(test)]
929mod tests {
930 use super::*;
931 use crate::ast::Node;
932 use crate::ast::token::TokenType;
933 use crate::parse::KdlSource;
934 use crate::parse::kdl_adapter::KdlAdapter;
935
936 fn node_variant_count_exhaustive(node: &Node) -> usize {
949 match node {
950 Node::Rect(_) => 1,
951 Node::Ellipse(_) => 1,
952 Node::Line(_) => 1,
953 Node::Text(_) => 1,
954 Node::Code(_) => 1,
955 Node::Frame(_) => 1,
956 Node::Group(_) => 1,
957 Node::Image(_) => 1,
958 Node::Polygon(_) => 1,
959 Node::Polyline(_) => 1,
960 Node::Instance(_) => 1,
961 Node::Field(_) => 1,
962 Node::Footnote(_) => 1,
963 Node::Toc(_) => 1,
964 Node::Table(_) => 1,
965 Node::Shape(_) => 1,
966 Node::Connector(_) => 1,
967 Node::Pattern(_) => 1,
968 Node::Chart(_) => 1,
969 Node::Unknown(_) => 1,
973 }
974 }
975
976 const TOTAL_NODE_VARIANTS: usize = 20; #[test]
984 fn node_summary_covers_every_node_kind() {
985 let expected_authorable = TOTAL_NODE_VARIANTS - 1; assert_eq!(
989 node_kinds().len(),
990 expected_authorable,
991 "node_kinds() has {} entries but the exhaustive Node match covers {} authorable \
992 variants (plus Unknown). Update node_kinds() and node_summary() when adding a variant.",
993 node_kinds().len(),
994 expected_authorable,
995 );
996
997 let _guard: fn(&Node) -> usize = node_variant_count_exhaustive;
1001
1002 for kind in node_kinds() {
1004 assert!(
1005 node_summary(kind).is_some(),
1006 "node_summary(\"{kind}\") returned None — add a one-liner to node_summary()",
1007 );
1008 }
1009 }
1010
1011 #[test]
1020 fn node_content_returns_some_for_content_bearing_kinds() {
1021 let content_kinds = &[
1022 "text",
1023 "shape",
1024 "footnote",
1025 "polygon",
1026 "polyline",
1027 "table",
1028 "frame",
1029 "group",
1030 "pattern",
1031 "chart",
1032 "instance",
1033 "code",
1034 "connector",
1035 ];
1036 for &kind in content_kinds {
1037 let desc = node_content(kind);
1038 assert!(
1039 desc.is_some(),
1040 "node_content(\"{kind}\") returned None — expected Some for a content-bearing kind",
1041 );
1042 let d = desc.unwrap();
1043 assert!(
1044 !d.description.is_empty(),
1045 "node_content(\"{kind}\").description is empty",
1046 );
1047 assert!(
1048 !d.example.is_empty(),
1049 "node_content(\"{kind}\").example is empty",
1050 );
1051 }
1052 }
1053
1054 #[test]
1056 fn node_content_returns_none_for_no_content_kinds() {
1057 let no_content_kinds = &["rect", "ellipse", "line", "image", "field", "toc"];
1058 for &kind in no_content_kinds {
1059 assert!(
1060 node_content(kind).is_none(),
1061 "node_content(\"{kind}\") returned Some — expected None for a leaf-only kind",
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn node_attributes_nonempty_for_geometry_kinds() {
1068 let rect_attrs = node_attributes("rect");
1070 assert!(!rect_attrs.is_empty(), "rect attributes must not be empty");
1071 assert!(
1072 rect_attrs.contains(&"fill"),
1073 "rect attributes must contain \"fill\"; got: {:?}",
1074 rect_attrs
1075 );
1076 assert!(
1077 rect_attrs.contains(&"x"),
1078 "rect attributes must contain \"x\"; got: {:?}",
1079 rect_attrs
1080 );
1081 assert!(
1082 rect_attrs.contains(&"w"),
1083 "rect attributes must contain \"w\"; got: {:?}",
1084 rect_attrs
1085 );
1086
1087 let text_attrs = node_attributes("text");
1089 assert!(!text_attrs.is_empty(), "text attributes must not be empty");
1090 assert!(
1091 text_attrs.contains(&"x"),
1092 "text attributes must contain \"x\"; got: {:?}",
1093 text_attrs
1094 );
1095
1096 let pattern_attrs = node_attributes("pattern");
1098 assert!(
1099 !pattern_attrs.is_empty(),
1100 "pattern attributes must not be empty"
1101 );
1102 assert!(
1103 pattern_attrs.contains(&"kind"),
1104 "pattern attributes must contain \"kind\"; got: {:?}",
1105 pattern_attrs
1106 );
1107 assert!(
1108 pattern_attrs.contains(&"spacing"),
1109 "pattern attributes must contain \"spacing\"; got: {:?}",
1110 pattern_attrs
1111 );
1112
1113 let frame_attrs = node_attributes("frame");
1115 assert!(
1116 !frame_attrs.is_empty(),
1117 "frame attributes must not be empty"
1118 );
1119 assert!(
1120 frame_attrs.contains(&"x"),
1121 "frame attributes must contain \"x\"; got: {:?}",
1122 frame_attrs
1123 );
1124 assert!(
1125 frame_attrs.contains(&"w"),
1126 "frame attributes must contain \"w\"; got: {:?}",
1127 frame_attrs
1128 );
1129 }
1130
1131 #[test]
1132 fn node_attributes_empty_for_unknown_kind() {
1133 assert!(
1134 node_attributes("not-a-real-kind").is_empty(),
1135 "unrecognised kinds must return an empty slice"
1136 );
1137 }
1138
1139 #[test]
1145 fn page_attributes_anchor_check() {
1146 let attrs = page_attributes();
1147 assert!(!attrs.is_empty(), "page_attributes() must not be empty");
1148 for anchor in &["w", "h", "line-jumps"] {
1149 assert!(
1150 attrs.contains(anchor),
1151 "page_attributes() must contain \"{anchor}\"; got: {attrs:?}",
1152 );
1153 }
1154 assert!(
1156 !attrs.contains(&"line_jumps"),
1157 "underscore alias \"line_jumps\" must be collapsed; got: {attrs:?}",
1158 );
1159 }
1160
1161 #[test]
1164 fn asset_attributes_anchor_check() {
1165 let attrs = asset_attributes();
1166 assert!(!attrs.is_empty(), "asset_attributes() must not be empty");
1167 for anchor in &["sha256", "ai-prompt", "ai-model", "src", "kind"] {
1168 assert!(
1169 attrs.contains(anchor),
1170 "asset_attributes() must contain \"{anchor}\"; got: {attrs:?}",
1171 );
1172 }
1173 }
1174
1175 #[test]
1178 fn document_attributes_anchor_check() {
1179 let attrs = document_attributes();
1180 assert!(!attrs.is_empty(), "document_attributes() must not be empty");
1181 for anchor in &["title", "colorspace", "doc-id", "spread-gutter"] {
1182 assert!(
1183 attrs.contains(anchor),
1184 "document_attributes() must contain \"{anchor}\"; got: {attrs:?}",
1185 );
1186 }
1187 assert!(
1189 !attrs.contains(&"doc_id"),
1190 "underscore alias \"doc_id\" must be collapsed; got: {attrs:?}",
1191 );
1192 }
1193
1194 #[test]
1212 fn attribute_type_covers_all_known_attrs() {
1213 use std::collections::BTreeMap;
1214 use std::collections::BTreeSet;
1215
1216 let mut attr_to_kind: BTreeMap<&'static str, &'static str> = BTreeMap::new();
1219 for &kind in node_kinds() {
1220 for attr in node_attributes(kind) {
1221 attr_to_kind.entry(attr).or_insert(kind);
1222 }
1223 }
1224
1225 let mut surface_attrs: BTreeSet<&'static str> = BTreeSet::new();
1227 for attr in page_attributes() {
1228 surface_attrs.insert(attr);
1229 }
1230 for attr in asset_attributes() {
1231 surface_attrs.insert(attr);
1232 }
1233 for attr in document_attributes() {
1234 surface_attrs.insert(attr);
1235 }
1236
1237 let mut unmapped: Vec<String> = Vec::new();
1239
1240 for (attr, kind) in &attr_to_kind {
1241 if attribute_type_for_kind_inner(kind, attr, "<unmapped>") == "<unmapped>" {
1242 unmapped.push(format!("{attr} (on {kind})"));
1243 }
1244 }
1245 for attr in &surface_attrs {
1246 if !attr_to_kind.contains_key(attr)
1248 && attribute_type_for_kind_inner("", attr, "<unmapped>") == "<unmapped>"
1249 {
1250 unmapped.push(format!("{attr} (surface)"));
1251 }
1252 }
1253
1254 assert!(
1255 unmapped.is_empty(),
1256 "attribute_type_for_kind_inner() has no entry for {} attribute(s): {:?}\n\
1257 Add an arm to `attribute_type_for_kind_inner` or `attribute_type_generic` \
1258 in zenith-core/src/schema.rs.",
1259 unmapped.len(),
1260 unmapped,
1261 );
1262 }
1263
1264 #[test]
1270 fn fill_type_hint_is_kind_accurate() {
1271 for kind in &["rect", "ellipse", "polygon", "polyline", "pattern"] {
1274 assert_eq!(
1275 attribute_type_for_kind(kind, "fill"),
1276 "token ref: color/gradient",
1277 "fill on {kind} should accept color/gradient (validator uses ColorOrGradient)",
1278 );
1279 }
1280 for kind in &["text", "shape", "code", "table"] {
1283 assert_eq!(
1284 attribute_type_for_kind(kind, "fill"),
1285 "token ref: color",
1286 "fill on {kind} should be color-only (validator uses VisualExpect::Color)",
1287 );
1288 }
1289 }
1290
1291 #[test]
1295 fn stroke_type_hint_is_color_only() {
1296 for kind in &[
1297 "rect",
1298 "ellipse",
1299 "line",
1300 "polygon",
1301 "polyline",
1302 "pattern",
1303 "text",
1304 "shape",
1305 "connector",
1306 ] {
1307 assert_eq!(
1308 attribute_type_for_kind(kind, "stroke"),
1309 "token ref: color",
1310 "stroke on {kind} must be color-only (validator uses VisualExpect::Color)",
1311 );
1312 }
1313 }
1314
1315 #[test]
1319 fn shadow_filter_mask_report_own_token_types() {
1320 for kind in &["rect", "pattern"] {
1321 assert_eq!(
1322 attribute_type_for_kind(kind, "shadow"),
1323 "token ref: shadow",
1324 "shadow on {kind} must reference a shadow token",
1325 );
1326 assert_eq!(
1327 attribute_type_for_kind(kind, "filter"),
1328 "token ref: filter",
1329 "filter on {kind} must reference a filter token",
1330 );
1331 assert_eq!(
1332 attribute_type_for_kind(kind, "mask"),
1333 "token ref: mask",
1334 "mask on {kind} must reference a mask token",
1335 );
1336 }
1337 assert_eq!(attribute_type("shadow"), "token ref: shadow");
1339 assert_eq!(attribute_type("filter"), "token ref: filter");
1340 assert_eq!(attribute_type("mask"), "token ref: mask");
1341 }
1342
1343 #[test]
1346 fn background_type_hint_is_color_or_gradient() {
1347 assert_eq!(attribute_type("background"), "token ref: color/gradient");
1348 assert_eq!(
1349 attribute_type_for_kind("", "background"),
1350 "token ref: color/gradient"
1351 );
1352 }
1353
1354 #[test]
1357 fn border_and_stroke_outer_are_color_only() {
1358 for attr in &[
1359 "border-top",
1360 "border-bottom",
1361 "border-left",
1362 "border-right",
1363 "stroke-outer",
1364 ] {
1365 assert_eq!(
1366 attribute_type_for_kind("rect", attr),
1367 "token ref: color",
1368 "{attr} on rect must be color-only (validator uses VisualExpect::Color)",
1369 );
1370 }
1371 }
1372
1373 #[test]
1381 fn kind_type_hint_is_node_aware() {
1382 assert_eq!(
1383 attribute_type_for_kind("shape", "kind"),
1384 "enum: process|decision|terminator|ellipse",
1385 "shape.kind must enumerate the flowchart shape variants",
1386 );
1387 assert_eq!(
1388 attribute_type_for_kind("pattern", "kind"),
1389 "enum: grid|scatter",
1390 "pattern.kind must enumerate the tiling mode variants",
1391 );
1392 assert_ne!(
1394 attribute_type_for_kind("shape", "kind"),
1395 attribute_type_for_kind("pattern", "kind"),
1396 "shape.kind and pattern.kind must NOT return the same hint (they are different enums)",
1397 );
1398 }
1399
1400 #[test]
1404 fn route_type_hint_is_connector_specific() {
1405 assert_eq!(
1406 attribute_type_for_kind("connector", "route"),
1407 "enum: straight|orthogonal|avoid",
1408 "connector.route must enumerate all three routing modes",
1409 );
1410 }
1411
1412 #[test]
1417 fn layout_type_hint_is_frame_specific() {
1418 assert_eq!(
1419 attribute_type_for_kind("frame", "layout"),
1420 "enum: absolute|flow|grid",
1421 "frame.layout must enumerate the three layout modes",
1422 );
1423 }
1424
1425 fn token_type_variant_count_exhaustive(ty: &TokenType) -> usize {
1441 match ty {
1442 TokenType::Color => 1,
1443 TokenType::Dimension => 1,
1444 TokenType::Number => 1,
1445 TokenType::FontFamily => 1,
1446 TokenType::FontWeight => 1,
1447 TokenType::Gradient => 1,
1448 TokenType::Shadow => 1,
1449 TokenType::Filter => 1,
1450 TokenType::Mask => 1,
1451 TokenType::Unknown(_) => 1,
1454 }
1455 }
1456
1457 const TOTAL_TOKEN_TYPE_VARIANTS: usize = 10; #[test]
1462 fn token_type_summary_covers_every_token_type() {
1463 let expected_authorable = TOTAL_TOKEN_TYPE_VARIANTS - 1;
1466 assert_eq!(
1467 token_types().len(),
1468 expected_authorable,
1469 "token_types() has {} entries but the exhaustive TokenType match covers {} authorable \
1470 variants (plus Unknown). Update token_types(), token_type_summary(), and \
1471 token_type_descriptor() when adding a variant.",
1472 token_types().len(),
1473 expected_authorable,
1474 );
1475
1476 let _guard: fn(&TokenType) -> usize = token_type_variant_count_exhaustive;
1480
1481 for ty in token_types() {
1483 assert!(
1484 token_type_summary(ty).is_some(),
1485 "token_type_summary(\"{ty}\") returned None — add a one-liner to token_type_summary()",
1486 );
1487 }
1488 }
1489
1490 #[test]
1491 fn token_type_descriptor_covers_every_token_type() {
1492 for ty in token_types() {
1494 assert!(
1495 token_type_descriptor(ty).is_some(),
1496 "token_type_descriptor(\"{ty}\") returned None — add a descriptor to token_type_descriptor()",
1497 );
1498 }
1499
1500 for ty in token_types() {
1502 let desc = token_type_descriptor(ty).unwrap();
1503 assert_eq!(
1504 desc.type_name, *ty,
1505 "token_type_descriptor(\"{ty}\").type_name is \"{}\", expected \"{ty}\"",
1506 desc.type_name,
1507 );
1508 assert!(
1510 !desc.summary.is_empty(),
1511 "token_type_descriptor(\"{ty}\").summary is empty",
1512 );
1513 assert!(
1515 !desc.value_form.is_empty() || !desc.child_nodes.is_empty(),
1516 "token_type_descriptor(\"{ty}\") has both empty value_form and child_nodes",
1517 );
1518 assert!(
1520 !desc.example.is_empty(),
1521 "token_type_descriptor(\"{ty}\").example is empty",
1522 );
1523 }
1524 }
1525
1526 #[test]
1534 fn token_type_examples_parse_without_syntax_errors() {
1535 for ty in token_types() {
1536 let desc = token_type_descriptor(ty).unwrap();
1537 let doc_src = format!(
1541 "zenith version=1 {{\n\
1542 \x20 tokens format=\"zenith-token-v1\" {{\n\
1543 \x20 {}\n\
1544 \x20 }}\n\
1545 \x20 document id=\"doc\" {{\n\
1546 \x20 page id=\"pg\" w=(px)1 h=(px)1 {{}}\n\
1547 \x20 }}\n\
1548 }}\n",
1549 desc.example,
1550 );
1551 let result = KdlAdapter.parse(doc_src.as_bytes());
1552 assert!(
1553 result.is_ok(),
1554 "token_type_descriptor(\"{ty}\").example failed to parse:\n\
1555 example:\n {}\n\
1556 wrapped doc:\n{doc_src}\n\
1557 parse error: {:?}",
1558 desc.example,
1559 result.err(),
1560 );
1561 }
1562 }
1563}