@getvitops/generator 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +797 -20
  2. package/assets/css/patterns/banner.css +20 -0
  3. package/assets/css/patterns/copy.css +5 -0
  4. package/assets/css/patterns/forms.css +5 -0
  5. package/assets/css/patterns/marquee.css +16 -0
  6. package/assets/css/patterns/splitter.css +11 -0
  7. package/assets/css/patterns/table.css +10 -0
  8. package/assets/js/consent.js +1 -1
  9. package/assets/js/deferred.js +1 -1
  10. package/assets/js/editor.js +2 -2
  11. package/assets/js/elements.js +5 -5
  12. package/assets/js/polyfills/adoptedStyleSheets.js +1 -1
  13. package/assets/js/polyfills/css-anchor-positioning-fn.js +4 -4
  14. package/assets/js/polyfills/global.esm.js +1 -1
  15. package/assets/js/polyfills/invokers-polyfill.js +1 -1
  16. package/assets/js/polyfills/popover.js +2 -2
  17. package/assets/js/polyfills/webcomponents-bundle.js +9 -9
  18. package/config.schema.json +464 -228
  19. package/dist/config-BfcWKH-5.mjs +1743 -0
  20. package/dist/config-BfcWKH-5.mjs.map +1 -0
  21. package/dist/config-Bhg3Xv3D.mjs +1774 -0
  22. package/dist/config-Bhg3Xv3D.mjs.map +1 -0
  23. package/dist/config.d.mts +2431 -0
  24. package/dist/config.d.mts.map +1 -0
  25. package/dist/config.mjs +3 -0
  26. package/dist/icons-sprite-CQ5Zj4go.d.mts +265 -0
  27. package/dist/icons-sprite-CQ5Zj4go.d.mts.map +1 -0
  28. package/dist/icons-sprite.d.mts +2 -0
  29. package/dist/icons-sprite.mjs +128 -0
  30. package/dist/icons-sprite.mjs.map +1 -0
  31. package/dist/index.d.mts +73 -2885
  32. package/dist/index.d.mts.map +1 -1
  33. package/dist/index.mjs +144 -2422
  34. package/dist/index.mjs.map +1 -1
  35. package/dist/schema-BV2n2phj.d.mts +291 -0
  36. package/dist/schema-BV2n2phj.d.mts.map +1 -0
  37. package/dist/tokens-BzBbTeTi.d.mts +225 -0
  38. package/dist/tokens-BzBbTeTi.d.mts.map +1 -0
  39. package/dist/tokens-C1RJwFeG.mjs +695 -0
  40. package/dist/tokens-C1RJwFeG.mjs.map +1 -0
  41. package/dist/tokens.d.mts +2 -0
  42. package/dist/tokens.mjs +3 -0
  43. package/package.json +19 -6
  44. package/schema.json +12 -28
package/dist/index.mjs CHANGED
@@ -1,2254 +1,16 @@
1
- import { createRequire } from "node:module";
1
+ import { _ as SYSTEM_DARK_SEL, a as expandPalette, b as roleHue, c as monotonicityErrors, d as tokenVar, f as validateColorContract, g as ROLE_TOKEN_KEYS, h as REQUIRED_ROLES, i as checkContrast, l as patchToOverrideCss, m as DARK_SEL, n as NUMERIC_STEPS, o as functionalRole, p as BASE_HOOK, r as buildReverseIndex, s as ladderWarnings, t as CONTRAST, u as tokenClass, v as TW_CLASH, x as roleKind, y as TYPO_KEYMAP } from "./tokens-C1RJwFeG.mjs";
2
+ import { A as jsonSchema, C as resolveTheme, D as DesignSystemSchema, E as validateConfig, M as movedTokens, O as SCHEMA_LOCAL_PATH, S as resolvePrivacyContact, T as stripNulls, _ as getAt, a as CONFIG_VERSION, b as resolveConfig, c as JURISDICTIONS, d as SiteFontSchema, f as configJsonSchema, g as formatConfig, h as experimentDimensionWarnings, i as CONFIG_SCHEMA_URL, j as validate, k as SCHEMA_URL, l as JURISDICTION_COUNTRIES, m as designSystemPath, n as BIGIN_DATA_CENTERS, o as CRM_PROVIDERS, p as deepMerge, r as CONFIG_SCHEMA_LOCAL_PATH, s as ConfigSchema, t as AD_PROVIDERS, u as PLAN_CAPABILITIES, v as isConfig, w as setAt, x as resolveInput, y as mergePatch } from "./config-BfcWKH-5.mjs";
3
+ import { buildIconSprite, loadIconSvg, spriteId } from "./icons-sprite.mjs";
2
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
5
  import { dirname, join } from "node:path";
4
- import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { fileURLToPath } from "node:url";
5
7
  import { createHash } from "node:crypto";
6
8
  import { transform } from "lightningcss";
7
- import * as z from "zod/mini";
8
- import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchStringToHex, pickOn, tintedEndpoints } from "@getvitops/utils/color";
9
9
  import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
10
10
  import { generateIconInclude, nodesToHtml, resolveIcon, t } from "@getvitops/utils";
11
- import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
12
11
  import { resolveSitemapUrl } from "@getvitops/utils/indexing";
13
12
 
14
- //#region src/shared.ts
15
- /**
16
- * Data shared between the generator (`generate.ts`) and the docs bundle
17
- * (`docs.ts`). Lives in its own module because `generate.ts` imports `docs.ts` —
18
- * importing back the other way would be a cycle.
19
- */
20
- /**
21
- * Selector the dark functional-token flip hangs off.
22
- *
23
- * `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
24
- * WordPress target needs. Nothing sets it anywhere else, which meant the dark
25
- * flip was unreachable outside Bricks: the shipped `<wc-color-scheme-toggle>` web
26
- * component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
27
- * clicking "Dark" changed an attribute no rule matched. Matching both makes the
28
- * component work on every target without changing what Bricks already does.
29
- *
30
- * This covers the EXPLICIT choice. The OS preference is a second, opt-in block —
31
- * see `SYSTEM_DARK_SEL` — because turning it on flips a site dark for dark-OS
32
- * users, which is the site's decision rather than the design system's.
33
- *
34
- * Shared so the docs quote the selector the generator actually emits.
35
- */
36
- const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
37
- /**
38
- * Selector for the OS-preference dark block, emitted inside
39
- * `@media (prefers-color-scheme: dark)` when a site opts in.
40
- *
41
- * Derived from `DARK_SEL`'s attributes rather than written out a third time, and
42
- * inverted: this must apply when NO explicit choice has been made. That is
43
- * exactly what `<wc-color-scheme-toggle>`'s "System" position produces — it removes
44
- * `data-theme` entirely — so with this block present System resolves to the OS
45
- * instead of silently falling through to light, and needs no JS to do it.
46
- *
47
- * The `:not()`s are what keep an explicit *light* choice winning over a dark OS.
48
- */
49
- const SYSTEM_DARK_SEL = `:root${DARK_SEL.split(", ").map((s) => s.replace(/^:root\[([\w-]+)="dark"\]$/, "[$1=\"light\"]")).map((attr) => `:not(${attr})`).join("")}`;
50
- /**
51
- * Roles the shipped CSS partials reference with NO fallback, so a config that
52
- * omits one leaves those rules resolving to nothing — an invalid declaration,
53
- * not a graceful degrade.
54
- *
55
- * `colors.roles` is an open record: any name works and generates a full token
56
- * set, utilities and dark flip. But the framework's own component CSS is
57
- * written against these six, so "roles are arbitrary" is only half true — they
58
- * are *extensible*, over a required core. `validate()` warns when one is
59
- * missing; `required-roles.test.ts` re-derives this list from the partials so
60
- * it cannot drift as components are added.
61
- *
62
- * Kept as a constant rather than scanned at call time because `validate()` is
63
- * a pure function — the CLI, the Vite plugin and `toJSONSchema` all import it,
64
- * and none of them should touch the filesystem to answer "is this config ok?".
65
- */
66
- const REQUIRED_ROLES = [
67
- "brand-primary",
68
- "danger",
69
- "neutral",
70
- "surface",
71
- "ui-primary",
72
- "warning"
73
- ];
74
- /**
75
- * Every token key a role can resolve to, across both role kinds. Shared so the
76
- * required-roles drift test greps for exactly what the generator emits.
77
- *
78
- * These are *keys*, not var names: the role sits in the middle of the property
79
- * (`--color-bg-danger-muted`), so compose them with `tokenVar()` from `tokens.ts`
80
- * rather than concatenating by hand.
81
- */
82
- const ROLE_TOKEN_KEYS = [
83
- "bg",
84
- "bg-muted",
85
- "bg-x-muted",
86
- "bg-bold",
87
- "bg-x-bold",
88
- "bg-solid",
89
- "bg-solid-bold",
90
- "bg-solid-x-bold",
91
- "text",
92
- "text-bold",
93
- "text-muted",
94
- "text-x-muted",
95
- "text-xx-muted",
96
- "text-on",
97
- "text-on-bold",
98
- "icon",
99
- "icon-muted",
100
- "border",
101
- "border-muted",
102
- "border-bold"
103
- ];
104
- /** The palette hue a role points at, through either authoring form. */
105
- const roleHue = (spec) => typeof spec === "string" ? spec : spec.hue;
106
- /**
107
- * A role's kind. The bare-string shorthand means `chromatic`, because signal
108
- * colours are the common case and surfaces are the two or three you name once.
109
- */
110
- const roleKind = (spec) => typeof spec === "string" ? "chromatic" : spec.kind ?? "chromatic";
111
- /**
112
- * Pattern base geometry props → per-pattern override-hook shorthand. Each hook
113
- * wraps the authored value (`padding: var(--p-btn, 0.4em 0.8em)`) so a
114
- * consumer restyles one pattern by setting one variable. The hook is named after
115
- * the pattern's key, not its class.
116
- */
117
- const BASE_HOOK = {
118
- padding: "p",
119
- "border-radius": "br",
120
- border: "b",
121
- "box-shadow": "ds",
122
- "font-size": "fs",
123
- background: "bg",
124
- "background-color": "bg"
125
- };
126
- /**
127
- * Framework utility classes whose names collide with Tailwind's own defaults.
128
- * The `tailwind` format strips any rule targeting these — Tailwind provides
129
- * them, so consumers use Tailwind's utilities for these names instead.
130
- */
131
- const TW_CLASH = new Set([
132
- "block",
133
- "inline",
134
- "inline-block",
135
- "flow-root",
136
- "flex",
137
- "inline-flex",
138
- "grid",
139
- "inline-grid",
140
- "table",
141
- "inline-table",
142
- "table-caption",
143
- "table-cell",
144
- "table-row",
145
- "contents",
146
- "hidden",
147
- "list-item",
148
- "static",
149
- "fixed",
150
- "absolute",
151
- "relative",
152
- "sticky",
153
- "isolate",
154
- "visible",
155
- "invisible",
156
- "collapse",
157
- "sr-only",
158
- "not-sr-only",
159
- "flex-row",
160
- "flex-row-reverse",
161
- "flex-col",
162
- "flex-col-reverse",
163
- "flex-wrap",
164
- "flex-nowrap",
165
- "items-start",
166
- "items-end",
167
- "items-center",
168
- "items-baseline",
169
- "items-stretch",
170
- "justify-start",
171
- "justify-end",
172
- "justify-center",
173
- "justify-between",
174
- "justify-around",
175
- "justify-evenly",
176
- "content-start",
177
- "content-end",
178
- "content-center",
179
- "content-between",
180
- "content-around",
181
- "content-evenly",
182
- "text-left",
183
- "text-center",
184
- "text-right",
185
- "text-justify",
186
- "text-start",
187
- "text-end",
188
- "text-wrap",
189
- "text-nowrap",
190
- "text-balance",
191
- "text-pretty",
192
- "rounded-none",
193
- "rounded-full"
194
- ]);
195
-
196
- //#endregion
197
- //#region src/tokens.ts
198
- /**
199
- * Palette expansion + semantic colour tokens.
200
- *
201
- * A palette hue is authored one of two ways:
202
- * • `{ seed, anchors? }` — an 11-step numeric scale (50…950) generated in
203
- * OKLCH via @getvitops/utils/color. Lightness comes from the shared
204
- * `LIGHTNESS_LADDER`; the seed (and any anchors) are reproduced verbatim at
205
- * their steps.
206
- * • `{ tones }` — a fixed brand kit: authored tones placed verbatim at their
207
- * nearest steps + tinted endpoints; nothing else is invented (strict kits).
208
- *
209
- * **The grammar.** Every role gets tokens named `--color-<target>-<role>[-<variant>]`,
210
- * where target ∈ bg | text | icon | border. Putting the target *inside* the name is
211
- * the point: `--color-bg-danger-muted` and `--color-text-danger-muted` are distinct
212
- * tokens, so the two axes that used to collide over one `<family>-<role>-<modifier>
213
- * ` namespace cannot any more. The utility class is the token name minus `--color-`.
214
- *
215
- * Variants are ordinal — xx-muted < x-muted < muted < (bare) < bold < x-bold —
216
- * and the tables are **sparse**: a target only emits the cells that are actually
217
- * viable. `bold` means "more emphatic than the role's default in the current
218
- * appearance", not "darker"; the appearance decides polarity.
219
- *
220
- * Roles come in two kinds (declared in `colors.roles`):
221
- * • `surface` — a page/panel colour. Has a bare `bg-<role>` plus the full
222
- * emphasis range and the complete text scale.
223
- * • `chromatic` (default) — a signal colour. Backgrounds split into *tints*
224
- * (`bg-<role>-x-muted` / `-muted`) and *solids* (`bg-<role>-solid[-bold|-x-bold]`);
225
- * there is deliberately **no bare `bg-<role>`**, because "how loud?" is a
226
- * question the author has to answer.
227
- *
228
- * Dark mode re-points which step each token reads; the solid family and the
229
- * `text-on-<role>` foreground computed against it stay mode-stable, so a filled
230
- * button keeps its identity across appearances.
231
- */
232
- const NUMERIC_STEPS = [
233
- "50",
234
- "100",
235
- "200",
236
- "300",
237
- "400",
238
- "500",
239
- "600",
240
- "700",
241
- "800",
242
- "900",
243
- "950"
244
- ];
245
- /**
246
- * Lightness of each numeric step. Re-exported from the engine's ladder rather
247
- * than restated — there used to be three copies of this curve (here, the engine,
248
- * and the linter) and they were free to drift.
249
- */
250
- const STEP_L = Object.fromEntries(NUMERIC_STEPS.map((s) => [s, LIGHTNESS_LADDER[Number(s)]]));
251
- const isHex = (v) => v.startsWith("#");
252
- const toOklchStr = (v) => {
253
- if (!isHex(v)) return v;
254
- const { l, c, h } = hexToOklch(v);
255
- return `oklch(${(l * 100).toFixed(2)}% ${c.toFixed(4)} ${h.toFixed(2)})`;
256
- };
257
- const nearestStep = (l) => NUMERIC_STEPS.reduce((best, s) => Math.abs(l - STEP_L[s]) < Math.abs(l - STEP_L[best]) ? s : best);
258
- /**
259
- * Claim `step` for `value`, refusing to overwrite a step another colour already
260
- * took.
261
- *
262
- * Silently dropping one of two colliding tones is the worst outcome: the palette
263
- * still builds, and the tone the author cared about is simply gone. The record
264
- * form (`tones: { "600": "#…", "700": "#…" }`) is the escape hatch, so the error
265
- * points at it.
266
- */
267
- function claim(into, step, value, hueLabel, what) {
268
- const taken = into[step];
269
- if (taken != null && taken !== value) throw new Error(`palette hue "${hueLabel}": ${what} ${value} and ${taken} both map to step ${step}. Key them explicitly, e.g. { "${step}": "${taken}", "${Number(step) + 100}": "${value}" }.`);
270
- into[step] = value;
271
- }
272
- function expandSeeded(input, hueLabel) {
273
- const anchors = {};
274
- for (const [key, value] of Object.entries(input.anchors ?? {})) claim(anchors, NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(value).l), toOklchStr(value), hueLabel, "anchors");
275
- const seedStep = nearestStep(hexToOklch(input.seed).l);
276
- anchors[seedStep] ??= toOklchStr(input.seed);
277
- const scale = generateOklchPalette(anchors);
278
- const numeric = {};
279
- for (const s of NUMERIC_STEPS) if (scale[s] != null) numeric[s] = oklchStringToHex(scale[s]);
280
- return {
281
- numeric,
282
- deviations: deviationsFor(anchors)
283
- };
284
- }
285
- function expandTones(input, hueLabel) {
286
- const entries = Array.isArray(input.tones) ? input.tones.map((hex) => [null, hex]) : Object.entries(input.tones).map(([k, hex]) => [k, hex]);
287
- const placed = {};
288
- for (const [key, hex] of entries) claim(placed, key != null && NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(hex).l), hex, hueLabel, "tones");
289
- const numeric = {};
290
- for (const [step, hex] of Object.entries(placed)) numeric[step] = hex;
291
- const ends = tintedEndpoints(hexToOklch(entries[0]?.[1] ?? "#808080").h);
292
- numeric["50"] ??= ends.light;
293
- numeric["950"] ??= ends.dark;
294
- return {
295
- numeric,
296
- sparse: true,
297
- deviations: deviationsFor(placed)
298
- };
299
- }
300
- /** How far each pinned colour sits from its step's ladder lightness. */
301
- function deviationsFor(pinned) {
302
- const out = {};
303
- for (const [step, value] of Object.entries(pinned)) out[step] = (isHex(value) ? hexToOklch(value).l : hexToOklch(oklchStringToHex(value)).l) - STEP_L[step];
304
- return out;
305
- }
306
- /** Snap to the nearest AVAILABLE numeric step (sparse kits). */
307
- function snap(numeric, want) {
308
- if (numeric[want] != null) return want;
309
- return NUMERIC_STEPS.filter((s) => numeric[s] != null).reduce((best, s) => Math.abs(STEP_L[s] - STEP_L[want]) < Math.abs(STEP_L[best] - STEP_L[want]) ? s : best);
310
- }
311
- /** Expand every palette hue into its numeric scale. */
312
- function expandPalette(raw) {
313
- const out = {};
314
- for (const [name, entry] of Object.entries(raw)) {
315
- const e = entry;
316
- if (typeof e?.seed === "string") out[name] = expandSeeded(e, name);
317
- else if (e?.tones != null) out[name] = expandTones(e, name);
318
- else throw new Error(`palette hue "${name}" must have a "seed" or "tones"`);
319
- }
320
- return out;
321
- }
322
- /**
323
- * Warn about pinned colours that sit noticeably off the ladder. Not an error:
324
- * the author asked for that exact colour and gets it. But at ~0.03 L the hue
325
- * starts reading visibly heavier or lighter than its siblings at that step, and
326
- * that is the class of drift the fixed ladder exists to prevent.
327
- */
328
- const LADDER_TOLERANCE = .03;
329
- function ladderWarnings(palette) {
330
- const warnings = [];
331
- for (const [hue, expanded] of Object.entries(palette)) for (const [step, deviation] of Object.entries(expanded.deviations ?? {})) {
332
- if (Math.abs(deviation) <= LADDER_TOLERANCE) continue;
333
- const dir = deviation > 0 ? "lighter" : "darker";
334
- warnings.push(`colors.palette.${hue}: the colour pinned at step ${step} is ${Math.abs(deviation).toFixed(3)} ${dir} than the shared lightness ladder — this hue will read ${dir} than others at ${step}. Legal, but move it to a neighbouring step if that wasn't intended.`);
335
- }
336
- return warnings;
337
- }
338
- /**
339
- * Gamut mapping nudges lightness by a hair, so an exact `<` comparison would
340
- * report rounding as an inversion. Well below any deviation a person could see.
341
- */
342
- const MONOTONIC_EPSILON = .001;
343
- /**
344
- * Ramps whose lightness does not fall monotonically from 50 to 950.
345
- *
346
- * A ramp built from the ladder alone cannot invert — only pinned colours (a seed,
347
- * an `anchor`, a `tones` entry) can, because they are the one thing allowed off
348
- * it. `ladderWarnings` will not catch it: it compares each pinned step against
349
- * *its own* ladder rung, never against its neighbours, so it reports the wrong
350
- * defect ("reads darker than others at 600") while the real problem is that 600
351
- * is darker than 700. And since `LADDER_TOLERANCE` is 0.03 — the same size as the
352
- * ladder's own 50→100 gap — two anchors can each sit within tolerance and still
353
- * invert, with no warning at all.
354
- *
355
- * This is an error rather than a warning because the rest of the system assumes
356
- * the order: `snap` picks the nearest step by ladder lightness, the mode-stable
357
- * solid family hard-codes 600/700/800 as increasingly dark, and both dark tables
358
- * re-point steps on the same assumption. An inverted ramp therefore runs hover
359
- * states backwards — the failure the fixed ladder was introduced to eliminate.
360
- */
361
- function monotonicityErrors(palette) {
362
- const errors = [];
363
- for (const [hue, expanded] of Object.entries(palette)) {
364
- const numeric = expanded.numeric ?? {};
365
- const steps = NUMERIC_STEPS.filter((s) => numeric[s] != null);
366
- for (let i = 1; i < steps.length; i++) {
367
- const [prev, step] = [steps[i - 1], steps[i]];
368
- const lPrev = hexToOklch(numeric[prev]).l;
369
- const lStep = hexToOklch(numeric[step]).l;
370
- if (lStep < lPrev + MONOTONIC_EPSILON) continue;
371
- const pinned = expanded.deviations ?? {};
372
- const culprits = [prev, step].filter((s) => pinned[s] != null);
373
- const fix = culprits.length ? culprits.map((s) => {
374
- const l = hexToOklch(numeric[s]).l;
375
- return `${numeric[s]} (pinned at ${s}) sits at L ${l.toFixed(3)}, nearest step ${nearestStep(l)}`;
376
- }).join("; ") : `no step here is pinned, so this is a bug in the generator rather than in your config`;
377
- errors.push(`colors.palette.${hue}: step ${step} (${numeric[step]}, L ${lStep.toFixed(3)}) is LIGHTER than step ${prev} (${numeric[prev]}, L ${lPrev.toFixed(3)}). A ramp must darken from 50 to 950 — inverted, a hover from \`bg-<role>-solid\` to \`-solid-bold\` gets lighter, and the dark-mode tables re-point steps assuming the order. ${fix}.`);
378
- }
379
- }
380
- return errors;
381
- }
382
- /**
383
- * Surface roles: a bare `bg` (cards, panels, inputs), `bg-muted` for the page
384
- * behind them, `bg-x-muted` for wells, and `bg-bold`/`-x-bold` for the inverse
385
- * surface a tooltip sits on. Elevation is expressed by *which* token you reach
386
- * for — page `bg-muted`, card `bg` — rather than by a raised/sunken pair. That
387
- * is what lets a future `data-surfaces` axis flatten it without touching markup.
388
- */
389
- const SURFACE_LIGHT = {
390
- bg: "50",
391
- "bg-muted": "100",
392
- "bg-x-muted": "200",
393
- "bg-bold": "800",
394
- "bg-x-bold": "950",
395
- text: "900",
396
- "text-bold": "950",
397
- "text-muted": "700",
398
- "text-x-muted": "500",
399
- "text-xx-muted": "400",
400
- "text-on-bold": "50",
401
- icon: "600",
402
- "icon-muted": "500",
403
- "border-muted": "200",
404
- border: "300",
405
- "border-bold": "600"
406
- };
407
- const SURFACE_DARK = {
408
- bg: "900",
409
- "bg-muted": "950",
410
- "bg-x-muted": "950",
411
- "bg-bold": "200",
412
- "bg-x-bold": "50",
413
- text: "100",
414
- "text-bold": "50",
415
- "text-muted": "300",
416
- "text-x-muted": "500",
417
- "text-xx-muted": "600",
418
- "text-on-bold": "950",
419
- icon: "400",
420
- "icon-muted": "500",
421
- "border-muted": "800",
422
- border: "700",
423
- "border-bold": "400"
424
- };
425
- /**
426
- * Chromatic roles: tints for alert/badge backgrounds, solids for fills. No bare
427
- * `bg` — see the module docblock. No `text-muted` either: a de-emphasised
428
- * coloured text could not hold its contrast target off a white surface, so the
429
- * way to soften coloured text is weight/size, not another colour.
430
- */
431
- const CHROMATIC_LIGHT = {
432
- "bg-x-muted": "50",
433
- "bg-muted": "100",
434
- text: "700",
435
- "text-bold": "900",
436
- icon: "600",
437
- border: "200",
438
- "border-bold": "300"
439
- };
440
- const CHROMATIC_DARK = {
441
- "bg-x-muted": "950",
442
- "bg-muted": "900",
443
- text: "300",
444
- "text-bold": "200",
445
- icon: "400",
446
- border: "800",
447
- "border-bold": "700"
448
- };
449
- /**
450
- * The solid family — mode-stable, so a filled button keeps its identity when the
451
- * appearance flips. Fixed steps rather than the old scan-and-clamp: with a shared
452
- * lightness ladder every hue's 600 already sits at the same lightness, so there
453
- * is nothing left to search for. `text-on-<role>` is computed against `bg-solid`.
454
- */
455
- const SOLID = {
456
- "bg-solid": "600",
457
- "bg-solid-bold": "700",
458
- "bg-solid-x-bold": "800"
459
- };
460
- /**
461
- * Map a token key to its CSS custom property for a given role.
462
- *
463
- * `on` is the one irregular case: the foreground for a filled surface reads
464
- * `--color-text-on-<role>`, with the role *after* `on`, because it names what it
465
- * sits on rather than what it is.
466
- *
467
- * The utility class name is this minus the `--color-` prefix, which is why the
468
- * emitter and the linter can both derive their vocabulary from one function.
469
- */
470
- function tokenVar(role, key) {
471
- const dash = key.indexOf("-");
472
- const target = dash === -1 ? key : key.slice(0, dash);
473
- const variant = dash === -1 ? "" : key.slice(dash + 1);
474
- if (variant === "on") return `--color-${target}-on-${role}`;
475
- if (variant.startsWith("on-")) return `--color-${target}-on-${role}-${variant.slice(3)}`;
476
- return variant ? `--color-${target}-${role}-${variant}` : `--color-${target}-${role}`;
477
- }
478
- /** The class name a token key produces for a role (`bg-danger-solid`, …). */
479
- const tokenClass = (role, key) => tokenVar(role, key).slice(8);
480
- /** Resolve the token set for one role over its hue. */
481
- function functionalRole(role, hueName, hue, kind = "chromatic") {
482
- const numeric = hue.numeric;
483
- const v = (n) => `var(--color-${hueName}-${snap(numeric, n)})`;
484
- const hex = (n) => numeric[snap(numeric, n)];
485
- const map = (table) => {
486
- const out = {};
487
- for (const [token, step] of Object.entries(table)) out[token] = v(step);
488
- return out;
489
- };
490
- const solids = map(SOLID);
491
- const onSolid = pickOn(hex(SOLID["bg-solid"]));
492
- return {
493
- role,
494
- hue: hueName,
495
- kind,
496
- light: {
497
- ...map(kind === "surface" ? SURFACE_LIGHT : CHROMATIC_LIGHT),
498
- ...solids,
499
- "text-on": onSolid
500
- },
501
- dark: {
502
- ...map(kind === "surface" ? SURFACE_DARK : CHROMATIC_DARK),
503
- ...solids,
504
- "text-on": onSolid
505
- }
506
- };
507
- }
508
- /**
509
- * APCA targets. `TEXT` is the body-text bar; `SECONDARY` covers text on a
510
- * non-primary surface and de-emphasised text. `NON_TEXT` is the icon/boundary
511
- * tier — the APCA analogue of WCAG's 3:1, where a glyph may run more vivid than
512
- * text because shape carries meaning alongside contrast.
513
- */
514
- const CONTRAST = {
515
- TEXT: 75,
516
- SECONDARY: 60,
517
- NON_TEXT: 45
518
- };
519
- /**
520
- * Text tokens deliberately exempt from the contract. Placeholders and disabled
521
- * text are required to look unavailable; holding them to the body-text bar would
522
- * defeat the affordance. Nothing else may be exempt.
523
- */
524
- const EXEMPT_TEXT = new Set(["text-x-muted", "text-xx-muted"]);
525
- /**
526
- * Verify a role's contrast targets. Returns human-readable failures.
527
- *
528
- * Chromatic roles are checked against the surface planes they actually sit on
529
- * (`surfaceBg`) as well as their own tints — coloured text almost never appears
530
- * over its own solid, it appears over the page. Checking only within the role
531
- * would have missed the pairing that matters most.
532
- */
533
- function checkContrast(fr, numericOf, surfaceBg) {
534
- const failures = [];
535
- const resolve = (val) => {
536
- const m = /^var\(--color-([a-z0-9-]+)-(\d+)\)$/.exec(val);
537
- if (!m) return val;
538
- return numericOf(m[1])[m[2]] ?? val;
539
- };
540
- const fail = (appearance, msg) => failures.push(`${fr.role}/${appearance}: ${msg}`);
541
- for (const [appearance, tokens] of [["light", fr.light], ["dark", fr.dark]]) {
542
- const ownBg = Object.keys(tokens).filter((k) => k === "bg" || k.startsWith("bg-")).filter((k) => !k.startsWith("bg-solid")).filter((k) => !k.startsWith("bg-bold") && !k.startsWith("bg-x-bold"));
543
- const planes = fr.kind === "surface" ? ownBg.map((k) => [k, resolve(tokens[k])]) : [...(surfaceBg?.[appearance] ?? []).map((h, i) => [`surface#${i}`, h]), ...ownBg.map((k) => [k, resolve(tokens[k])])];
544
- for (const [planeName, bg] of planes) {
545
- const textMin = planeName === "bg" ? CONTRAST.TEXT : CONTRAST.SECONDARY;
546
- for (const key of [
547
- "text",
548
- "text-bold",
549
- "text-muted"
550
- ]) {
551
- if (tokens[key] == null || EXEMPT_TEXT.has(key)) continue;
552
- const min = key === "text-muted" ? CONTRAST.SECONDARY : textMin;
553
- const lc = contrastLc(resolve(tokens[key]), bg);
554
- if (lc < min) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${min} on ${planeName} ${bg}`);
555
- }
556
- const nonText = fr.kind === "surface" ? ["icon", "border-bold"] : ["icon"];
557
- for (const key of nonText) {
558
- if (tokens[key] == null) continue;
559
- const lc = contrastLc(resolve(tokens[key]), bg);
560
- if (lc < CONTRAST.NON_TEXT) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${CONTRAST.NON_TEXT} on ${planeName} ${bg}`);
561
- }
562
- }
563
- const solid = resolve(tokens["bg-solid"]);
564
- const onLc = contrastLc(tokens["text-on"], solid);
565
- if (onLc < CONTRAST.SECONDARY) fail(appearance, `text-on Lc ${onLc.toFixed(1)} < ${CONTRAST.SECONDARY} on bg-solid`);
566
- if (tokens["bg-bold"] != null && tokens["text-on-bold"] != null) {
567
- const lc = contrastLc(resolve(tokens["text-on-bold"]), resolve(tokens["bg-bold"]));
568
- if (lc < CONTRAST.TEXT) fail(appearance, `text-on-bold Lc ${lc.toFixed(1)} < ${CONTRAST.TEXT} on bg-bold`);
569
- }
570
- }
571
- return failures;
572
- }
573
-
574
- //#endregion
575
- //#region src/token-refs.ts
576
- /**
577
- * Cross-layer check: does every `var(--…)` a config authors resolve to a token
578
- * the generator actually emits?
579
- *
580
- * A `patterns` value is a string the schema deliberately does not interpret
581
- * (see `CssDecls` in `schema.ts`), so a reference to a token that does not exist
582
- * parses, generates, minifies and ships. The declaration then resolves to
583
- * `inherit`/`unset` in the browser and nothing anywhere says so. Downstream this
584
- * shipped a `.cta` whose `color` fell back to `inherit` on a brand fill —
585
- * unreadable text on every filled button — and two of the four dead references
586
- * in that config had been dead for several versions.
587
- *
588
- * **The check is anchored to namespaces the generator owns**, exactly as
589
- * `vitops lint` is anchored to classes derived from the consumer's own config.
590
- * A pattern may legitimately reference a framework token defined in
591
- * `@getvitops/core`'s hand-written CSS (`--icon-size`, `--animation-duration`,
592
- * a consumer's own hook), and `validate()` is a pure function that must not read
593
- * the filesystem to find out. Enumerating "every valid token" would therefore
594
- * turn every such reference into a false positive. Enumerating only what the
595
- * generator emits — the colour layer, shadows, z-tiers — is a set it can compute
596
- * from the config alone, and it is where the dangling references actually occur.
597
- *
598
- * `FRAMEWORK_OWNED` is the one leak in that reasoning: a token that sits inside
599
- * an owned prefix but is defined by core's CSS instead. `token-refs.test.ts`
600
- * greps the shipped partials and fails if a new one appears, so the allowlist
601
- * cannot silently go stale.
602
- *
603
- * A reference carrying a fallback (`var(--x, 0.25rem)`) is **never** flagged: the
604
- * fallback is the author stating what happens when the token is absent, which is
605
- * the whole difference between a considered default and an accident.
606
- */
607
- /**
608
- * Prefixes whose entire contents the generator emits. A `var()` inside one of
609
- * these either resolves to something the colour/shadow/z layer defines, or it
610
- * resolves to nothing — there is no third possibility, which is what makes an
611
- * error (rather than a warning) the right severity.
612
- */
613
- const OWNED_PREFIXES = [
614
- "--color-",
615
- "--shadow-",
616
- "--z-tier-"
617
- ];
618
- /** Singleton tokens the colour layer emits, checked by exact name. */
619
- const OWNED_EXACT = new Set(["--surface-glass", "--overlay"]);
620
- /**
621
- * Tokens inside an owned prefix that `@getvitops/core`'s hand-written CSS
622
- * defines rather than the generator. Drift-guarded by `token-refs.test.ts`.
623
- */
624
- const FRAMEWORK_OWNED = new Set(["--color-swatch-size"]);
625
- /**
626
- * Roles whose name says "this is a surface" while the bare-string shorthand
627
- * says `chromatic`. The two kinds emit *different token sets* — a chromatic role
628
- * has no bare `bg`, so `--color-bg-surface` and every `.bg-surface` utility
629
- * simply cease to exist, taking 36 references in the framework's own CSS with
630
- * them. Naming one of these and getting the chromatic set is almost always a
631
- * mistake, and it is the exact one a downstream config made.
632
- */
633
- const SURFACE_SHAPED = new Set([
634
- "surface",
635
- "neutral",
636
- "background",
637
- "page",
638
- "canvas"
639
- ]);
640
- /**
641
- * The 1.0 colour-grammar rename, as data.
642
- *
643
- * Old suffix (on `--<role>-<suffix>`) → new token key (fed through `tokenVar`).
644
- * Transcribed from the migration table in the 1.0 changelog; `surface` rotates
645
- * separately below because its rename is value-preserving rather than
646
- * mechanical. `--color-<role>-<variant>` "stops" are deliberately absent — the
647
- * changelog says to judge those by use, and they already land in the owned
648
- * `--color-` namespace, so the generic check catches them with a near-miss hint.
649
- */
650
- const MOVED_SUFFIXES = {
651
- bg: "bg-x-muted",
652
- "bg-muted": "bg-muted",
653
- solid: "bg-solid",
654
- "solid-bold": "bg-solid-bold",
655
- "solid-x-bold": "bg-solid-x-bold",
656
- "on-solid": "text-on",
657
- "on-solid-bold": "text-on-bold",
658
- text: "text",
659
- "text-muted": "text-muted",
660
- "text-x-muted": "text-x-muted",
661
- "text-xx-muted": "text-xx-muted",
662
- "text-bold": "text-bold",
663
- icon: "icon",
664
- "icon-muted": "icon-muted",
665
- border: "border",
666
- "border-muted": "border-muted",
667
- "border-bold": "border-bold"
668
- };
669
- /**
670
- * `surface`'s background names rotate rather than map: what was `--surface-bg`
671
- * (the page) is now `--color-bg-surface-muted`, and what was `--surface-bg-bold`
672
- * (raised) is now `--color-bg-surface`. Applying the chromatic rule to it would
673
- * silently move the page background two rungs.
674
- */
675
- const SURFACE_BG_ROTATION = {
676
- bg: "bg-muted",
677
- "bg-bold": "bg",
678
- "bg-muted": "bg-x-muted"
679
- };
680
- /**
681
- * Old-grammar token name → its 1.0 replacement, for the roles this config
682
- * declares.
683
- *
684
- * Built per-config rather than as a static table because the old names embed
685
- * the *consumer's own* role names — there is no fixed list to hard-code, which
686
- * is why nothing detected these before.
687
- */
688
- function movedTokens(roles) {
689
- const out = {};
690
- for (const [role, spec] of Object.entries(roles)) {
691
- const rotate = roleKind(spec) === "surface";
692
- for (const [old, key] of Object.entries(MOVED_SUFFIXES)) out[`--${role}-${old}`] = tokenVar(role, rotate && SURFACE_BG_ROTATION[old] || key);
693
- if (rotate) for (const [old, key] of Object.entries(SURFACE_BG_ROTATION)) out[`--${role}-${old}`] = tokenVar(role, key);
694
- }
695
- return out;
696
- }
697
- /**
698
- * Extract every `var(--name[, fallback])` from a CSS value.
699
- *
700
- * Hand-rolled rather than a regex because the fallback has to be detected at the
701
- * reference's *own* paren depth: in `var(--a, var(--b))` the outer reference has
702
- * a fallback and the inner one does not, and a regex that stops at the first `)`
703
- * gets both wrong.
704
- */
705
- function extractVarRefs(value, path) {
706
- const out = [];
707
- for (let i = value.indexOf("var("); i !== -1; i = value.indexOf("var(", i + 1)) {
708
- let j = i + 4;
709
- while (j < value.length && /\s/.test(value[j])) j++;
710
- const start = j;
711
- while (j < value.length && /[\w-]/.test(value[j])) j++;
712
- const name = value.slice(start, j);
713
- if (!name.startsWith("--")) continue;
714
- let depth = 0;
715
- let fallback = false;
716
- for (let k = j; k < value.length; k++) {
717
- const c = value[k];
718
- if (c === "(") depth++;
719
- else if (c === ")") {
720
- if (depth === 0) break;
721
- depth--;
722
- } else if (c === "," && depth === 0) fallback = true;
723
- }
724
- out.push({
725
- path,
726
- name,
727
- fallback
728
- });
729
- }
730
- return out;
731
- }
732
- /** Recursively collect refs from a value that may be a string or a nested map. */
733
- const walkValue = (value, path, out) => {
734
- if (typeof value === "string") out.push(...extractVarRefs(value, path));
735
- else if (value != null && typeof value === "object" && !Array.isArray(value)) for (const [k, v] of Object.entries(value)) walkValue(v, `${path}.${k}`, out);
736
- };
737
- /**
738
- * Every authored declaration that can hold a `var()`.
739
- *
740
- * `patterns` is where the reported failures were, but `typography.roles.*.color`
741
- * and a `shadows` entry are authored the same way and fail the same way, so the
742
- * same walk covers them. `patterns.items.*.states` is walked recursively — a
743
- * state's `css` shortcut is a nested declaration map.
744
- */
745
- function collectVarRefs(ds) {
746
- const out = [];
747
- const p = ds.patterns ?? {};
748
- walkValue(p["defaults"], "patterns.defaults", out);
749
- walkValue(p["radii"], "patterns.radii", out);
750
- walkValue(p["groups"], "patterns.groups", out);
751
- for (const [name, item] of Object.entries(p["items"] ?? {})) {
752
- const it = item ?? {};
753
- walkValue(it["base"], `patterns.items.${name}.base`, out);
754
- walkValue(it["overrides"], `patterns.items.${name}.overrides`, out);
755
- walkValue(it["states"], `patterns.items.${name}.states`, out);
756
- }
757
- walkValue(ds.typography?.["roles"], "typography.roles", out);
758
- walkValue(ds.shadows, "shadows", out);
759
- return out;
760
- }
761
- /**
762
- * The custom properties the generator emits, as names.
763
- *
764
- * Derived from the same functions the emitter uses — `expandPalette` for the
765
- * ramps and `functionalRole` + `tokenVar` for the role set — so the check cannot
766
- * disagree with the output about what exists. Both appearances are unioned: a
767
- * token that only appears in the dark block is still defined.
768
- */
769
- function emittedTokens(ds) {
770
- const defined = /* @__PURE__ */ new Set();
771
- const palette = expandPalette(ds.colors.palette);
772
- for (const [hueName, hue] of Object.entries(palette)) for (const n of Object.keys(hue.numeric)) defined.add(`--color-${hueName}-${n}`);
773
- let hasSurface = false;
774
- for (const [role, spec] of Object.entries(ds.colors.roles)) {
775
- const hueName = roleHue(spec);
776
- const hue = palette[hueName];
777
- if (hue == null) continue;
778
- const kind = roleKind(spec);
779
- if (role === "surface" && kind === "surface") hasSurface = true;
780
- const fr = functionalRole(role, hueName, hue, kind);
781
- for (const key of new Set([...Object.keys(fr.light), ...Object.keys(fr.dark)])) defined.add(tokenVar(role, key));
782
- }
783
- if (Object.keys(ds.colors.roles).length) defined.add("--color-border-focus");
784
- if (hasSurface) {
785
- defined.add("--surface-glass");
786
- defined.add("--overlay");
787
- }
788
- for (const name of Object.keys(ds.shadows ?? {})) defined.add(`--shadow-${name}`);
789
- for (const name of Object.keys(ds.patterns?.z ?? {})) defined.add(`--z-tier-${name}`);
790
- return defined;
791
- }
792
- /** Whether this check has an opinion about a given custom property. */
793
- const isOwned = (name) => !FRAMEWORK_OWNED.has(name) && (OWNED_EXACT.has(name) || OWNED_PREFIXES.some((p) => name.startsWith(p)));
794
- /**
795
- * Suggest a near-miss from the emitted set, so the message names the fix rather
796
- * than only the failure. Cheap edit-distance-ish heuristic: same prefix, or a
797
- * one-segment difference.
798
- */
799
- const nearest = (name, defined) => {
800
- const segs = name.split("-").filter(Boolean);
801
- let best;
802
- let bestScore = 0;
803
- for (const cand of defined) {
804
- const cs = cand.split("-").filter(Boolean);
805
- const score = cs.filter((s) => segs.includes(s)).length - Math.abs(cs.length - segs.length) * .5;
806
- if (score > bestScore) {
807
- bestScore = score;
808
- best = cand;
809
- }
810
- }
811
- return bestScore >= Math.max(2, segs.length - 1) ? best : void 0;
812
- };
813
- /**
814
- * Check a parsed design system's authored `var()` references, plus the
815
- * role-kind heuristic that produces most of them.
816
- *
817
- * Returns messages rather than issues so `validate()` owns the `$ZodIssue`
818
- * shaping, and so the same check can be reported by `generate()` without
819
- * inventing a second vocabulary.
820
- */
821
- function tokenRefIssues(ds) {
822
- const errors = [];
823
- const warnings = [];
824
- for (const [role, spec] of Object.entries(ds.colors.roles)) if (SURFACE_SHAPED.has(role) && roleKind(spec) === "chromatic") warnings.push(`colors.roles.${role} is declared chromatic (a bare hue string is shorthand for \`kind: "chromatic"\`), but the name reads as a surface. A chromatic role emits no bare \`--color-bg-${role}\` and no \`.bg-${role}\` utility` + (REQUIRED_ROLES.includes(role) ? `, and the framework's own component CSS references those with no fallback` : "") + `. Write \`"${role}": { "hue": "${roleHue(spec)}", "kind": "surface" }\` if you meant a surface.`);
825
- let defined;
826
- try {
827
- defined = emittedTokens(ds);
828
- } catch {
829
- return {
830
- errors,
831
- warnings
832
- };
833
- }
834
- const seen = /* @__PURE__ */ new Set();
835
- const renames = movedTokens(ds.colors.roles);
836
- const moved = /* @__PURE__ */ new Map();
837
- const excused = /* @__PURE__ */ new Set();
838
- for (const role of REQUIRED_ROLES) if (ds.colors.roles[role] == null) for (const key of ROLE_TOKEN_KEYS) excused.add(tokenVar(role, key));
839
- for (const ref of collectVarRefs(ds)) {
840
- if (ref.fallback || defined.has(ref.name) || excused.has(ref.name)) continue;
841
- const moveTo = renames[ref.name];
842
- if (moveTo != null) {
843
- moved.set(ref.name, moveTo);
844
- continue;
845
- }
846
- if (!isOwned(ref.name)) continue;
847
- const key = `${ref.name} ${ref.path}`;
848
- if (seen.has(key)) continue;
849
- seen.add(key);
850
- const hint = nearest(ref.name, defined);
851
- errors.push({
852
- path: ref.path.split("."),
853
- message: `${ref.path} references \`var(${ref.name})\`, which this config emits no token for — the declaration will resolve to nothing at runtime.` + (hint ? ` Did you mean \`var(${hint})\`?` : "") + ` (Give it a fallback — \`var(${ref.name}, <value>)\` — if the absence is intentional.)`
854
- });
855
- }
856
- if (moved.size) errors.unshift({
857
- path: ["colors"],
858
- message: `${moved.size} token reference${moved.size === 1 ? " uses" : "s use"} the pre-1.0 colour grammar. The role moved into the middle of the property name, after its target (\`--color-<target>-<role>[-<variant>]\`):\n` + [...moved].map(([old, next]) => ` ${old} → ${next}`).join("\n") + "\n\nApply these simultaneously, not one at a time — several renames are cyclic (a surface role's backgrounds rotate), so sequential replacement compounds them. `vitops lint <src> --fix` does this for markup and CSS."
859
- });
860
- return {
861
- errors,
862
- warnings
863
- };
864
- }
865
-
866
- //#endregion
867
- //#region src/schema.ts
868
- /**
869
- * Canonical `design-system.json` schema — the single source of truth.
870
- *
871
- * Authored with `zod/mini` (tree-shakeable Zod v4). Everything else derives from it:
872
- * • the `DesignSystem` TS type (`z.infer`), used throughout the generator,
873
- * • the published JSON Schema (`toJSONSchema`), emitted to `schema.json` for `$schema` refs,
874
- * • runtime validation (`validate`) for the CLI and to gate `generate`,
875
- * • the generated `authoring.md` reference doc (walks `jsonSchema`'s descriptions).
876
- */
877
- /**
878
- * Attach a JSON-Schema `description`. zod/mini has no `.describe()` method; the
879
- * mini-API equivalent is `.check(z.describe(…))`, and `.check()` clones — so a
880
- * shared sub-schema (`Scale`, `CssDecls`, …) can carry a different description
881
- * per use-site without mutating the original.
882
- */
883
- const desc = (schema, text) => schema.check(z.describe(text));
884
- const CssDecls = desc(z.record(z.string(), z.string()), "A CSS declaration block: property → value. Values stay strings (they can be hex, var(), clamp(), keywords, …); the generator, not the schema, interprets them.");
885
- const Scalar = z.union([z.string(), z.number()]);
886
- const VarMap = z.record(z.string(), Scalar);
887
- const SeededRamp = desc(z.strictObject({
888
- seed: desc(z.string(), "Seed colour (hex or oklch()). An 11-step numeric scale (50…950, tinted near-white → tinted near-black) is generated in OKLCH from it; the seed is preserved at its natural step."),
889
- anchors: z.optional(desc(z.record(z.string(), z.string()), "Step → colour overrides (hex or oklch()) pinned VERBATIM at those steps. Every other step takes its lightness from the shared ladder, with chroma and hue interpolated between the anchors — so an anchor is reproduced exactly and is the only step allowed off the ladder. An explicit anchor overrides the seed at that step; two anchors that resolve to the same step are an error."))
890
- }), "Seeded hue: the 11-step scale is GENERATED in OKLCH from `seed` (anchors pin specific steps).");
891
- const FixedRamp = desc(z.strictObject({ tones: desc(z.union([z.array(z.string()), z.record(z.string(), z.string())]), "Fixed brand kit: authored tones placed verbatim at their nearest steps plus tinted off-white/off-black endpoints; no interpolation. Either an ordered light → dark array or a step → colour map.") }), "Fixed hue: authored brand tones used verbatim; no generation.");
892
- const Ramp = desc(z.union([SeededRamp, FixedRamp]), "A palette hue, authored one of two ways: `{ seed, anchors? }` generates an 11-step numeric OKLCH scale (50…950) from the seed, or `{ tones }` supplies a fixed brand kit used verbatim.");
893
- /**
894
- * A role points at a hue, and optionally declares its kind. The bare-string form
895
- * is the shorthand for a chromatic role — signal colours are the common case,
896
- * and the two or three surfaces are worth naming explicitly.
897
- */
898
- const RoleSpecSchema = z.union([desc(z.string(), "A palette hue name. Shorthand for `{ hue, kind: \"chromatic\" }`."), z.strictObject({
899
- hue: desc(z.string(), "The palette hue this role resolves to."),
900
- kind: z.optional(desc(z.enum(["surface", "chromatic"]), "`surface` — a page/panel colour: gets a bare `bg-<role>` plus the full emphasis range and text scale. `chromatic` (default) — a signal colour: tints and solids only, no bare `bg-<role>`."))
901
- })]);
902
- const UtilityName = z.enum([
903
- "bg",
904
- "text",
905
- "icon",
906
- "border",
907
- "outline",
908
- "fill",
909
- "stroke"
910
- ]);
911
- const Colors = desc(z.strictObject({
912
- palette: desc(z.record(z.string(), Ramp), "Palette hues by name. Each becomes an 11-step numeric OKLCH scale (`--color-<hue>-50…950`). Every ramp shares one fixed lightness ladder, so a step means the same lightness in every hue; only chroma and hue vary. Beyond the outermost authored colour the chroma decays towards a small endpoint value (0.008 light / 0.015 dark) so the near-white and near-black ends keep a whisper of the hue — but that is a **ceiling**, not a target, so a low-chroma seed stays low and **a chroma-0 seed gives a true neutral** rather than a tinted one. The ladder is also enforced: if pinned colours leave a ramp non-monotonic (some step darker than the one below it), the build fails rather than shipping a scale whose hover states run backwards."),
913
- roles: desc(z.record(z.string(), RoleSpecSchema), `Maps semantic role names onto palette hues. Role names are ARBITRARY — add a key and the generator emits that role's token set (\`--color-<target>-<role>[-<variant>]\` for target bg/text/icon/border), its dark-mode flip and its utility classes.\n\nA value is either a hue name (\`"danger": "rust"\`) or \`{ "hue": …, "kind": "surface" | "chromatic" }\`. **The kind decides the shape of the token set.** \`chromatic\` (the default, and what the bare-string form means) is a signal colour: its backgrounds split into tints (\`bg-<role>-x-muted\`/\`-muted\`) and solids (\`bg-<role>-solid[-bold|-x-bold]\`), with deliberately **no bare \`bg-<role>\`** — "how loud?" is a question the author answers. \`surface\` is a page/panel colour: it has a bare \`bg-<role>\` plus the full emphasis range and text scale.\n\nDark mode flips automatically; there is no per-appearance scheme grammar. The solid family and its computed \`text-on-<role>\` foreground stay mode-stable so a filled button keeps its identity. Six roles are a required core, because the shipped framework CSS references them with no fallback: ${REQUIRED_ROLES.join(", ")}. Conventional additions are ui-secondary/accent, brand-secondary, info and success.`),
914
- utilities: z.optional(desc(z.array(UtilityName), "Which colour utility-class families to emit (`bg-*`, `text-*`, `icon-*`, `border-*`, `outline-*`, `fill-*`, `stroke-*`). Defaults to bg, text, icon, border. `icon` is a separate non-text tier (a glyph may run more vivid than text); `outline`/`fill`/`stroke` have no tokens of their own and alias the border and icon tiers."))
915
- }), "The colour system (the only required section): `palette` hues become generated OKLCH scales; `roles` map semantic roles onto those hues, from which all role tokens and dark mode derive.");
916
- const Scale = z.strictObject({
917
- base: desc(z.string(), "Anchor size (a CSS length, e.g. \"1rem\") — the value at `baseStep`."),
918
- ratio: desc(z.number(), "Modular ratio between adjacent steps at large viewports."),
919
- steps: desc(z.optional(z.number()), "Token count when `names` is absent (steps are then named 1..steps)."),
920
- names: desc(z.optional(z.array(z.string())), "Step names, smallest → largest (e.g. [\"xs\",\"sm\",\"md\",…]); each becomes a token suffix."),
921
- baseStep: desc(z.optional(z.number()), "1-based index of the step whose value is `base`."),
922
- baseline: desc(z.optional(z.string()), "Named step used as the fluid pivot / GUI scale centre (defaults to `baseStep`)."),
923
- fluid: desc(z.optional(z.strictObject({
924
- minVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling bottoms out."),
925
- maxVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling tops out."),
926
- minRatio: desc(z.number(), "Modular ratio at/below `minVw` (usually < `ratio`).")
927
- })), "Makes the scale fluid: each step compiles to a clamp() that interpolates from `minRatio` at `minVw` to `ratio` at `maxVw`.")
928
- });
929
- const Pattern = desc(z.strictObject({
930
- group: desc(z.optional(z.string()), "Token-cascade group this pattern belongs to (e.g. tag / control / panel); base declarations resolve through `--<prop>-<group>` before `--<prop>-default`."),
931
- overrides: z.optional(desc(CssDecls, "Per-pattern token overrides, emitted as `--<prop>-<name>-group` values.")),
932
- element: desc(z.optional(z.string()), "Style at element level via zero-specificity `:where(<element>)` (instead of, or alongside, a class)."),
933
- class: desc(z.optional(z.string()), "Class name to emit (defaults to the pattern's key when no `element` is set). Combined with `element`, the pattern emits one zero-specificity `:where(<element>, .<class>)` rule so the class works on any tag and any explicit class overrides it."),
934
- fill: desc(z.optional(z.boolean()), "Whether this pattern is colour-filled (states/roles drive `background-color` + `on-solid` text) or text-coloured (they drive `color`). Defaults to true when `base` declares a background."),
935
- default_role: desc(z.optional(z.string()), "Semantic colour role applied to the bare/default variant."),
936
- base: z.optional(desc(CssDecls, "Base CSS declarations. Geometry properties (padding, border-radius, border, box-shadow, font-size) are wrapped in per-pattern override hooks (`--p-<name>`, `--br-<name>`, `--b-<name>`, `--ds-<name>`, `--fs-<name>`) so consumers can restyle one pattern by setting one variable.")),
937
- states: desc(z.optional(z.record(z.string(), z.record(z.string(), z.unknown()))), "Interaction states (hover / active / focus-visible), each a map of shortcuts: `step` (intensify the fill or text by n rungs — `bg-<role>-solid` → `-solid-bold`, `text-<role>` → `-bold`), `scale` (transform scale), `lift` (translateY + shadow), `shadow` (a shadow name → drop-shadow(var(--shadow-<name>)), or true → lift shadow), `ring` (focus ring), or raw `css` declarations. Hover rules are wrapped in `@media (hover: hover)`."),
938
- roles: desc(z.optional(z.array(z.string())), "Semantic colour role variants to emit as `<pattern>-<role>` classes (fills use the role solid / on-solid tokens).")
939
- }), "One component pattern (button, link, badge, card, …): base declarations + interaction states + semantic role variants, resolved through the pattern token cascade.");
940
- const Patterns = desc(z.strictObject({
941
- defaults: z.optional(desc(CssDecls, "Cascade-wide fallback tokens, emitted as `--<prop>-default`.")),
942
- radii: z.optional(desc(CssDecls, "Shape primitives, emitted as `--br-<name>` (referenced by pattern bases).")),
943
- groups: desc(z.optional(z.record(z.string(), CssDecls)), "Group-level tokens, emitted as `--<prop>-<group>`; patterns opt in via their `group` key."),
944
- z: desc(z.optional(z.record(z.string(), z.number())), "Z-index tiers → `--z-tier-<name>`."),
945
- items: desc(z.optional(z.record(z.string(), Pattern)), "The component patterns to emit, keyed by name.")
946
- }), "Component patterns and their token cascade: `defaults` → `groups` → per-pattern `overrides`, plus shape (`radii`) and z-index primitives.");
947
- const TypographyRole = desc(z.record(z.string(), Scalar), "A bag of CSS-ish keys, each mapped to a declaration plus a `--<role>-<sfx>` override hook. The recognised set is **closed**: `family`, `size`, `weight`, `style`, `line-height`, `tracking` (→ `letter-spacing`), `text-transform`, `text-decoration`, `text-wrap`, `color`. Note the last four are spelled with their full CSS property names — `transform` and `decoration` are NOT accepted. Anything unrecognised is **ignored**, not passed through, so the generator warns rather than emitting it: a silently-dropped `transform: uppercase` is how title-case navigation reaches production. Note also that `style`, `text-transform`, `text-decoration` and `text-wrap` are emitted on **every** role at their identity value (`normal`/`none`/`none`/`wrap`) whether declared or not, so applying one role class over another fully resets it — which means **omitting `text-wrap` is not \"inherit\"**: it emits `text-wrap: wrap` and cancels the `pretty` the role would otherwise inherit from a `pretty` ancestor such as a `body`-mapped role. Declare it on every role — `balance` for heading-like roles, `pretty` for copy, `wrap` for short single-line labels.");
948
- const Typography = desc(z.strictObject({
949
- families: desc(z.optional(z.record(z.string(), z.string())), "Role-facing family aliases → CSS font values, usually referencing the top-level `fonts` tokens (e.g. \"var(--font-display)\")."),
950
- roles: desc(z.optional(z.record(z.string(), TypographyRole)), "Semantic type roles (display, title, heading, body, quote, caption, eyebrow, code, lead, footnote, tag, …), each emitted as a `font-<role>` class."),
951
- headings: desc(z.optional(z.record(z.string(), z.string())), "Maps bare elements to type roles so unclassed markup picks up role styling — `{ \"h1\": \"display\", \"h2\": \"heading\" }`. The key is used verbatim as a selector, so it is not limited to h1…h6: **map `\"body\"` to your prose role** to bind base page typography to the role rather than hand-writing it. That binding is what makes the role editable — a stylesheet that re-states `font-family`/`line-height` as literals on `body` shadows `--<role>-ff`/`--<role>-lh`, and the live theme editor then appears to do nothing.")
952
- }), "Typography: family aliases, semantic type roles (→ `font-<role>` classes), and the bare-element → role mapping.");
953
- const AnimEffect = desc(z.strictObject({
954
- kf: desc(z.string(), "Keyframe family driving the effect: composite (transform/opacity), paint, or layout."),
955
- css: z.optional(desc(VarMap, "Extra literal declarations merged into the effect class as-is.")),
956
- vars: z.optional(desc(VarMap, "Effect endpoint variables (`--<key>: <value>`, e.g. opacity-from, translate-y-to) that override the keyframe defaults."))
957
- }), "A named animation effect class — a pure value layer (`--_anim` + `--<prop>-from/-to`) over the static keyframe engine.");
958
- const Animations = desc(z.strictObject({
959
- effects: desc(z.optional(z.record(z.string(), AnimEffect)), "Effect classes to emit (`.fade-in`, `.reveal-left`, …), keyed by class name."),
960
- journeys: desc(z.optional(z.strictObject({
961
- base: desc(z.optional(z.record(z.string(), VarMap)), "Named journey building blocks: part name → var map."),
962
- compose: desc(z.optional(z.array(z.array(z.string()))), "Combinations of base parts, each emitted as a `.<parts>-journey` class.")
963
- })), "Multi-part journey classes composed from `base` building blocks.")
964
- }), "Animation effect + journey classes (pure value layers). The animation engine itself — keyframes, drivers, floats, utilities — is static framework CSS, not configured here.");
965
- const DesignSystemSchema = z.strictObject({
966
- $schema: desc(z.optional(z.string()), "URL of the published JSON Schema (stamped by `vitops init`) so editors provide autocomplete + validation."),
967
- meta: desc(z.optional(z.strictObject({
968
- name: desc(z.optional(z.string()), "Brand/system name. Used as the `name` field and `<h1>` of the `design` format's `DESIGN.md`. Defaults to \"Design System\"."),
969
- description: desc(z.optional(z.string()), "One or two sentences on the brand personality and the feeling the UI should evoke — what an agent needs when no token answers the question. Becomes the DESIGN.md `description` field and opens its Overview section; if omitted, a generic description of the system's mechanics is used instead.")
970
- })), "Brand identity for agent-facing output. Consumed only by the `design` format (`DESIGN.md`); it emits no CSS and no tokens."),
971
- colors: Colors,
972
- shadows: desc(z.optional(z.record(z.string(), z.string())), "Named shadows → `--shadow-<name>` tokens and `.drop-shadow-<name>` utilities. Values are shadow parameter lists (offset/blur/colour). Each token feeds two consumers with different grammars — `box-shadow` (pattern geometry, via the `--ds-*` group aliases) and `filter: drop-shadow(…)` (the utilities and the `shadow:` state shortcut) — so values must stay in the intersection: **one layer, no spread radius, no `inset`**. `drop-shadow()` rejects all three, and rejecting them invalidates the whole filter, so the shadow vanishes rather than degrading."),
973
- fonts: desc(z.optional(z.record(z.string(), z.string())), "Raw font stacks by name, emitted as `--font-<name>` tokens (referenced by `typography.families`). **Stacks only — vitops does not load webfonts.** A value here is a `font-family` list and nothing more: it emits no `@font-face`, no preload, and no metrics-matched fallback. If a family needs loading, declare it in Astro's `fonts:` config (`astro.config`, or the site config's `fonts` array) and point the token at the family's `cssVariable` — `\"display\": \"var(--font-league-spartan), sans-serif\"`. Installing a `@fontsource*` package and importing its CSS also works but gives up subsetting, preload and `size-adjust`/`ascent-override` fallbacks, so it regresses CLS."),
974
- typeScale: desc(z.optional(Scale), "Fluid modular TYPE scale → `--text-<name>` tokens, consumed by typography roles and text-size utilities."),
975
- spaceScale: desc(z.optional(Scale), "Fluid modular SPACE scale → `--space-<name>` tokens, consumed by spacing/gap utilities and vertical rhythm."),
976
- patterns: z.optional(Patterns),
977
- typography: z.optional(Typography),
978
- animations: z.optional(Animations)
979
- });
980
- const ColorsPatch = z.strictObject({
981
- palette: z.optional(z.record(z.string(), Ramp)),
982
- roles: z.optional(z.record(z.string(), RoleSpecSchema)),
983
- utilities: z.optional(z.array(UtilityName))
984
- });
985
- const DesignSystemPatchSchema = z.extend(z.partial(DesignSystemSchema), { colors: z.optional(ColorsPatch) });
986
- /**
987
- * Stable URL of the published JSON Schema. Resolves once `@getvitops/generator` is on
988
- * npm (unpkg serves the packaged `schema.json`); `vitops init` stamps this into a
989
- * scaffolded config's `$schema` so editors light up autocomplete + validation.
990
- */
991
- const SCHEMA_URL = "https://unpkg.com/@getvitops/generator/schema.json";
992
- /**
993
- * What `vitops init` actually stamps into a scaffolded config's `$schema`.
994
- *
995
- * A **local** path, following wrangler's `node_modules/wrangler/config-schema.json`.
996
- * `SCHEMA_URL` has no version in it, so unpkg resolves it to whatever is newest
997
- * — an editor validating a project pinned to generator 2.x silently checks it
998
- * against the 3.x schema, reporting errors for valid config and accepting
999
- * fields that do not exist yet. The installed copy is by definition the one
1000
- * that matches the toolchain doing the building.
1001
- *
1002
- * `SCHEMA_URL` remains the schema's `$id`, which is what it is for.
1003
- */
1004
- const SCHEMA_LOCAL_PATH = "./node_modules/@getvitops/generator/schema.json";
1005
- /** The published JSON Schema (draft-2020-12), derived from the zod schema. */
1006
- const jsonSchema = {
1007
- $id: SCHEMA_URL,
1008
- ...z.toJSONSchema(DesignSystemSchema, { target: "draft-2020-12" })
1009
- };
1010
- /** Split a CSS value on top-level commas, ignoring commas nested in `rgba(…)` etc. */
1011
- const splitLayers = (value) => {
1012
- const out = [];
1013
- let depth = 0;
1014
- let start = 0;
1015
- for (let i = 0; i < value.length; i++) {
1016
- const c = value[i];
1017
- if (c === "(") depth++;
1018
- else if (c === ")") depth--;
1019
- else if (c === "," && depth === 0) {
1020
- out.push(value.slice(start, i));
1021
- start = i + 1;
1022
- }
1023
- }
1024
- out.push(value.slice(start));
1025
- return out.map((s) => s.trim()).filter(Boolean);
1026
- };
1027
- const LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px|rem|em|ch|ex|vh|vw|vmin|vmax|cm|mm|in|pt|pc|q)?$/i;
1028
- /**
1029
- * Why a shadow can be *valid CSS* and still render nothing: a `--shadow-<name>`
1030
- * token is consumed both as a `box-shadow` (pattern geometry) and inside
1031
- * `filter: drop-shadow(…)` (the `.drop-shadow-<name>` utilities, the `shadow:`
1032
- * state shortcut). `drop-shadow()` accepts a *single* layer of at most three
1033
- * lengths and no `inset` — a spread radius, a second comma-separated layer or an
1034
- * inset keyword makes the function invalid, which drops the entire `filter`
1035
- * declaration. `box-shadow` accepts all three happily, so the value looks fine
1036
- * everywhere it is authored and only the utility silently goes blank.
1037
- */
1038
- const dropShadowIssue = (value) => {
1039
- const layers = splitLayers(value);
1040
- if (layers.length > 1) return `${layers.length} comma-separated layers`;
1041
- const layer = layers[0] ?? "";
1042
- if (/(^|\s)inset(\s|$)/i.test(layer)) return "an `inset` keyword";
1043
- const lengths = layer.replaceAll(/[\w-]*\([^()]*\)/g, " ").split(/\s+/).filter((t) => LENGTH.test(t));
1044
- if (lengths.length > 3) return `a spread radius (${lengths.length} lengths)`;
1045
- };
1046
- const RAMP_KEYS = new Set([
1047
- "seed",
1048
- "anchors",
1049
- "tones"
1050
- ]);
1051
- /**
1052
- * Replace the bare "Invalid input" a failed union produces with the reason.
1053
- *
1054
- * `Ramp` is `SeededRamp | FixedRamp`, and both branches are closed — so `{ seed,
1055
- * tones }` or a stray key fails *both* and zod reports only that the union didn't
1056
- * match. That message sends you reading the schema rather than looking at the two
1057
- * keys in front of you, and the config that produced it is one whose `tones` were
1058
- * being silently discarded at generate time.
1059
- */
1060
- const explainRamp = (input) => (issue) => {
1061
- if (issue.code === "unrecognized_keys") {
1062
- const keys = issue.keys ?? [];
1063
- if (keys.length) return {
1064
- ...issue,
1065
- message: `unknown key${keys.length > 1 ? "s" : ""} ${keys.map((k) => `"${k}"`).join(", ")} — not part of the schema, so ignored at generate time.`
1066
- };
1067
- }
1068
- if (issue.path.length !== 3 || issue.path[0] !== "colors" || issue.path[1] !== "palette") return issue;
1069
- const hue = input?.colors?.palette?.[String(issue.path[2])];
1070
- if (hue == null || typeof hue !== "object") return issue;
1071
- const keys = Object.keys(hue);
1072
- const unknown = keys.filter((k) => !RAMP_KEYS.has(k));
1073
- const message = "seed" in hue && "tones" in hue ? "a hue is either seeded (`seed`, with optional `anchors`) or fixed (`tones`) — not both. Drop one: with both present the `tones` are ignored at generate time." : unknown.length ? `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}. A hue takes \`seed\` + optional \`anchors\`, or \`tones\`.` : keys.length === 0 ? "a hue needs `seed` (+ optional `anchors`) or `tones`." : null;
1074
- return message ? {
1075
- ...issue,
1076
- message
1077
- } : issue;
1078
- };
1079
- /**
1080
- * Validate an unknown value against the design-system schema.
1081
- *
1082
- * `warnings` are configurations that parse and generate, but produce output that
1083
- * won't behave as authored — currently only the token-namespace collisions the
1084
- * flat `--<prop>-<name>` grammar makes possible.
1085
- */
1086
- function validate(input) {
1087
- const result = z.safeParse(DesignSystemSchema, input);
1088
- if (!result.success) return {
1089
- ok: false,
1090
- data: void 0,
1091
- errors: result.error.issues.map(explainRamp(input)),
1092
- warnings: []
1093
- };
1094
- const { palette, roles } = result.data.colors;
1095
- for (const [role, spec] of Object.entries(roles)) {
1096
- const hue = roleHue(spec);
1097
- if (palette[hue] == null) return {
1098
- ok: false,
1099
- data: void 0,
1100
- errors: [{
1101
- code: "custom",
1102
- path: [
1103
- "colors",
1104
- "roles",
1105
- role
1106
- ],
1107
- message: `role "${role}" references unknown palette hue "${hue}"`
1108
- }],
1109
- warnings: []
1110
- };
1111
- }
1112
- const warnings = [];
1113
- const missing = REQUIRED_ROLES.filter((r) => roles[r] == null);
1114
- if (missing.length) warnings.push(`colors.roles is missing ${missing.map((r) => `"${r}"`).join(", ")} — the framework's own component CSS references ${missing.length === 1 ? "it" : "them"} with no fallback, so those components will render uncoloured. Add any role you like, but keep these defined.`);
1115
- const radii = Object.keys(result.data.patterns?.radii ?? {});
1116
- const items = new Set(Object.keys(result.data.patterns?.items ?? {}));
1117
- for (const name of radii) if (items.has(name)) warnings.push(`patterns.radii.${name} collides with the "${name}" pattern on --br-${name}; the pattern's override hook wins — rename the radius`);
1118
- for (const [name, value] of Object.entries(result.data.shadows ?? {})) {
1119
- const issue = dropShadowIssue(value);
1120
- if (issue) warnings.push(`shadows.${name} carries ${issue}, which \`filter: drop-shadow()\` rejects — \`.drop-shadow-${name}\` will render no shadow at all (the token still works as a \`box-shadow\`). Use one layer of at most three lengths.`);
1121
- }
1122
- const refs = tokenRefIssues(result.data);
1123
- warnings.push(...refs.warnings);
1124
- if (refs.errors.length) return {
1125
- ok: false,
1126
- data: void 0,
1127
- errors: refs.errors.map((e) => ({
1128
- code: "custom",
1129
- ...e
1130
- })),
1131
- warnings
1132
- };
1133
- return {
1134
- ok: true,
1135
- data: result.data,
1136
- errors: [],
1137
- warnings
1138
- };
1139
- }
1140
-
1141
- //#endregion
1142
- //#region src/config.ts
1143
- /**
1144
- * The top-level configuration schema — the umbrella around a
1145
- * `design-system.json`-compliant `designSystem`.
1146
- *
1147
- * **Three sections, one document.** A config is `{ designSystem, organization,
1148
- * site }`:
1149
- *
1150
- * - `designSystem` — the token set (named themes + which theme/appearance).
1151
- * - `organization` — the company: who it is, where it is, how to reach it, what
1152
- * it sells. Facts that stay true if the site is rebuilt from scratch.
1153
- * - `site` — this published site: locales, domains, environments, SEO,
1154
- * analytics, legal, icons, deployment. Facts about *a* presentation of the
1155
- * organization.
1156
- *
1157
- * The split is not cosmetic. The flat predecessor (`SiteConfig`) held the company
1158
- * and the deployment as peers, so no single noun described it — and the
1159
- * multi-site case was inexpressible. With three sections, several sites can share
1160
- * one `organization` and override only `site`.
1161
- *
1162
- * Authored with `zod/mini` (same instance the design-system schema uses), so the
1163
- * two compose: `DesignSystemSchema` is embedded as `designSystem.themes.<theme>` and the
1164
- * whole thing serialises to one JSON Schema via `z.toJSONSchema`. Everything a
1165
- * generator needs derives from here: the `Config` type (`z.infer`), the
1166
- * published JSON Schema (`configJsonSchema`), and runtime validation (`validateConfig`).
1167
- *
1168
- * Pure: no YAML/env/fs. `resolveConfig` takes an already-parsed object; a
1169
- * thin loader (reading YAML, injecting `SITE_ENV`) lives outside this module.
1170
- */
1171
- const LocalizableSchema = desc(z.union([z.string(), z.record(z.string(), z.string())]), "A string, or a per-locale map of strings (`{ en: \"…\", fr: \"…\" }`).");
1172
- const ImageRefSchema = z.union([z.string(), z.object({
1173
- src: z.string(),
1174
- alt: z.string(),
1175
- width: z.optional(z.number()),
1176
- height: z.optional(z.number())
1177
- })]);
1178
- const AttrValue = z.union([
1179
- z.string(),
1180
- z.number(),
1181
- z.boolean()
1182
- ]);
1183
- const AttrsSchema = z.optional(z.record(z.string(), AttrValue));
1184
- const IsoDate = z.string().check(z.regex(/^\d{4}-\d{2}-\d{2}$/));
1185
- const Iso4217 = z.string().check(z.regex(/^[A-Z]{3}$/));
1186
- const DayOfWeek = z.enum([
1187
- "Monday",
1188
- "Tuesday",
1189
- "Wednesday",
1190
- "Thursday",
1191
- "Friday",
1192
- "Saturday",
1193
- "Sunday"
1194
- ]);
1195
- const DesignSystemEntry = z.extend(DesignSystemPatchSchema, {
1196
- displayName: desc(z.optional(LocalizableSchema), "Human-readable theme name (localisable). The label a theme picker would show — nothing reads it yet, because nothing builds more than one theme (see `themes`)."),
1197
- extends: desc(z.optional(z.string()), "Another `themes` key to inherit from; this entry then only supplies what it overrides.")
1198
- });
1199
- /**
1200
- * The design system as a whole: the named themes plus the facts that describe the
1201
- * system rather than any one theme.
1202
- *
1203
- * `themes` is nested rather than being the block itself because the block used to
1204
- * BE the map — every key was a theme name, so there was nowhere to put
1205
- * system-wide metadata without it colliding with a theme of that name.
1206
- *
1207
- * Note the two axes here are independent, and deliberately so. `defaultTheme`
1208
- * picks a *theme* (an authored design system); `defaultColorScheme` picks an
1209
- * *appearance* (light/dark), which every theme already has because the flip is
1210
- * derived from one ramp rather than authored. "Theme `elegant`, appearance
1211
- * follows the OS" has to be sayable, and a single union could not express it.
1212
- */
1213
- const DesignSystemBlock = desc(z.strictObject({
1214
- themes: desc(z.record(z.string(), DesignSystemEntry), "Named themes. `default` is the base; others may `extends` another and supply a partial patch. Light/dark is NOT a theme — the functional tokens flip per appearance within each one, so `default` already has both. Only the default theme is built today; multi-theme output and a picker are not wired yet."),
1215
- defaultTheme: desc(z.optional(z.string()), "Which `themes` entry to use (convention: \"default\"). Validated against the map; selecting a non-default theme is not wired yet."),
1216
- defaultColorScheme: desc(z.optional(z.enum([
1217
- "light",
1218
- "dark",
1219
- "system"
1220
- ])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<wc-color-scheme-toggle>`'s \"System\" position resolve to anything — it removes the theme attribute, so without this the page falls through to light. It also gives a no-JS page the OS appearance. Defaults to `\"light\"`, because switching an existing site to `\"system\"` visibly flips it dark for dark-OS visitors.")
1221
- }), "The design system: named themes plus the system-wide facts (which theme, which appearance). A bare design system, or a bare theme map, is accepted as shorthand for `{ themes: … }`.");
1222
- const LocaleSchema = z.object({
1223
- name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
1224
- tagline: desc(z.optional(z.string()), "Site tagline in this locale."),
1225
- basePath: desc(z.optional(z.string()), "URL prefix for this locale (e.g. \"/fr\").")
1226
- });
1227
- const HttpRedirect = z.union([
1228
- z.literal(301),
1229
- z.literal(302),
1230
- z.literal(307),
1231
- z.literal(308)
1232
- ]);
1233
- const DomainAliasSchema = z.object({
1234
- domain: z.string(),
1235
- redirectType: HttpRedirect,
1236
- redirectTo: z.string(),
1237
- environment: z.optional(z.string())
1238
- });
1239
- const PostalAddressSchema = desc(z.object({
1240
- streetAddress: z.string(),
1241
- addressLocality: desc(z.string(), "City."),
1242
- addressRegion: desc(z.optional(z.string()), "State / province."),
1243
- postalCode: z.optional(z.string()),
1244
- addressCountry: desc(z.string(), "Country (ISO 3166-1 alpha-2 preferred).")
1245
- }), "schema.org `PostalAddress` field names, so JSON-LD generation is lossless.");
1246
- const ContactObjectSchema = z.object({
1247
- name: z.optional(z.string()),
1248
- email: z.optional(z.email()),
1249
- phone: z.optional(z.string()),
1250
- address: z.optional(PostalAddressSchema)
1251
- });
1252
- const ContactConfigSchema = z.union([z.string(), ContactObjectSchema]);
1253
- const GeoSchema = z.object({
1254
- latitude: z.number().check(z.gte(-90), z.lte(90)),
1255
- longitude: z.number().check(z.gte(-180), z.lte(180))
1256
- });
1257
- /** schema.org `OpeningHoursSpecification` — per-day open/close (24h `HH:MM`). */
1258
- const OpeningHoursSchema = z.object({
1259
- dayOfWeek: z.array(DayOfWeek),
1260
- opens: z.string().check(z.regex(/^\d{2}:\d{2}$/)),
1261
- closes: z.string().check(z.regex(/^\d{2}:\d{2}$/))
1262
- });
1263
- /**
1264
- * A dated deviation from the recurring `hours` — holiday hours, a one-off
1265
- * closure, a temporary schedule. Same schema.org shape
1266
- * (`OpeningHoursSpecification`), distinguished by carrying `validFrom`. A
1267
- * full closure omits `opens`/`closes` rather than encoding an open interval.
1268
- */
1269
- const SpecialHoursSchema = z.object({
1270
- validFrom: IsoDate,
1271
- validThrough: z.optional(IsoDate),
1272
- opens: z.optional(z.string().check(z.regex(/^\d{2}:\d{2}$/))),
1273
- closes: z.optional(z.string().check(z.regex(/^\d{2}:\d{2}$/)))
1274
- });
1275
- const LocalBusinessType = z.enum([
1276
- "LocalBusiness",
1277
- "Store",
1278
- "Restaurant",
1279
- "CafeOrCoffeeShop",
1280
- "ProfessionalService",
1281
- "MedicalBusiness",
1282
- "HealthAndBeautyBusiness",
1283
- "HomeAndConstructionBusiness",
1284
- "AutomotiveBusiness",
1285
- "FinancialService",
1286
- "LegalService",
1287
- "RealEstateAgent",
1288
- "Organization"
1289
- ]);
1290
- const ListingPlatform = z.enum([
1291
- "google",
1292
- "bing",
1293
- "apple"
1294
- ]);
1295
- const LocationSchema = z.object({
1296
- slug: z.optional(LocalizableSchema),
1297
- name: LocalizableSchema,
1298
- email: z.optional(z.email()),
1299
- phone: z.optional(z.string()),
1300
- address: z.optional(PostalAddressSchema),
1301
- geo: z.optional(GeoSchema),
1302
- hours: z.optional(z.array(OpeningHoursSchema)),
1303
- hoursSpecial: desc(z.optional(z.array(SpecialHoursSchema)), "Holiday/temporary hours, as dated deviations from the recurring `hours` — distinct from it because these carry a date range rather than a weekday."),
1304
- type: z.optional(LocalBusinessType),
1305
- description: z.optional(LocalizableSchema),
1306
- areaServed: z.optional(z.array(z.string())),
1307
- priceRange: z.optional(z.string()),
1308
- paymentAccepted: z.optional(z.array(z.string())),
1309
- currenciesAccepted: z.optional(Iso4217),
1310
- knowsLanguage: z.optional(z.array(z.string())),
1311
- photos: desc(z.optional(z.array(ImageRefSchema)), "Location photos (logo, storefront, interior, …) → JSON-LD `image`. Every listing platform (GBP, Bing Places, Apple Business Connect) wants these; none can derive them."),
1312
- sameAs: desc(z.optional(z.array(z.url())), "This location's own profile URLs (its Google Business Profile, Bing Places listing, Apple Business Connect location, Facebook page, …) → JSON-LD `sameAs`. Distinct from `organization.sameAs`, which is the company overall — a multi-location org's locations do not share one GBP listing."),
1313
- listings: desc(z.optional(z.partialRecord(ListingPlatform, z.string())), "External listing IDs for this location, keyed by platform — the Google Business Profile location id, the Bing Places business id, the Apple Business Connect location id. Not consumed by the generator today; recorded here so a future sync has a stable id to match against instead of risking a duplicate listing.")
1314
- });
1315
- const OrganizationSchema = z.object({
1316
- name: desc(z.optional(LocalizableSchema), "Trading name, as a reader would recognise it (e.g. \"Acme\"). Localisable."),
1317
- legalName: desc(z.optional(z.string()), "Registered legal name (e.g. \"Acme Widgets Inc.\"). The generated legal documents prefer this over `name` — a policy is a statement by the legal entity."),
1318
- foundingDate: desc(z.optional(IsoDate), "Date the organization was founded (YYYY-MM-DD)."),
1319
- logo: desc(z.optional(ImageRefSchema), "Organization logo → JSON-LD `logo`."),
1320
- email: desc(z.optional(z.email()), "General contact address. Also the last fallback for privacy requests — see `contact`."),
1321
- phone: desc(z.optional(z.string()), "General contact telephone number."),
1322
- address: desc(z.optional(PostalAddressSchema), "Registered/mailing address. Its `addressRegion` and `addressCountry` are what the governing-law clause of the generated terms of service defaults to."),
1323
- taxID: desc(z.optional(z.string()), "Tax identification number."),
1324
- vatID: desc(z.optional(z.string()), "VAT identification number."),
1325
- sameAs: desc(z.optional(z.array(z.url())), "Profile URLs → JSON-LD `sameAs` (mirrors `links`).")
1326
- });
1327
- const DnsRecordType = z.enum([
1328
- "A",
1329
- "AAAA",
1330
- "CNAME",
1331
- "TXT",
1332
- "MX",
1333
- "NS",
1334
- "SRV",
1335
- "CAA",
1336
- "PTR",
1337
- "SOA"
1338
- ]);
1339
- const DnsRecordSchema = z.object({
1340
- type: DnsRecordType,
1341
- name: z.optional(z.string()),
1342
- value: z.string(),
1343
- ttl: z.optional(z.number().check(z.int(), z.positive())),
1344
- proxied: z.optional(z.boolean())
1345
- });
1346
- const DnsDomainSchema = z.object({
1347
- provider: z.optional(z.string()),
1348
- nameservers: z.optional(z.array(z.string())),
1349
- records: z.optional(z.array(DnsRecordSchema))
1350
- });
1351
- /**
1352
- * One domain to onboard into Google Search Console via `vitops search setup`.
1353
- *
1354
- * The verification token is fetched live from the Site Verification API at run
1355
- * time and written to DNS — it is never stored here. What the config records is
1356
- * the *intent*: which domains to onboard, and who should co-own them.
1357
- */
1358
- const SearchConsoleSetupSchema = z.object({
1359
- delegatedOwners: desc(z.optional(z.array(z.email())), "Emails added as verification owners on the Site Verification web resource. Automated via the API — additive and idempotent, never removes an existing owner."),
1360
- fullUserGroup: desc(z.optional(z.email()), "A Google Group to grant Full-User access in Search Console. SURFACED AS A REMINDER only: Search Console exposes no user/permission API, so this step stays manual by design.")
1361
- });
1362
- const RobotsSchema = z.string();
1363
- const EnvironmentSchema = z.object({
1364
- url: desc(z.url(), "Public origin of this environment."),
1365
- api: desc(z.optional(z.url()), "API origin, when different from `url`."),
1366
- analytics: desc(z.optional(z.boolean()), "Whether analytics fire in this environment."),
1367
- ads: desc(z.optional(z.boolean()), "Whether `site.ads` pixels fire in this environment. Defaults to `analytics`, then true — a preview deployment sending pageviews is survivable, one firing conversion pixels is not, so this can be turned off on its own."),
1368
- robots: desc(z.optional(RobotsSchema), "Robots policy (e.g. \"noindex,nofollow\" for dev)."),
1369
- variant: desc(z.optional(z.string()), "Active `abTesting.variants` key for this environment.")
1370
- });
1371
- const AbVariantSchema = z.object({
1372
- environment: z.string(),
1373
- description: z.optional(z.string()),
1374
- overrides: z.optional(z.record(z.string(), z.unknown()))
1375
- });
1376
- const FontProvider = z.enum([
1377
- "google",
1378
- "fontsource",
1379
- "adobe",
1380
- "bunny",
1381
- "fontshare",
1382
- "googleicons",
1383
- "npm",
1384
- "local"
1385
- ]);
1386
- const FontStyle = z.enum(["normal", "italic"]);
1387
- const FontDisplay = z.enum([
1388
- "auto",
1389
- "block",
1390
- "swap",
1391
- "fallback",
1392
- "optional"
1393
- ]);
1394
- const FontWeight = z.union([z.number(), z.string()]);
1395
- /** A local-font face (`provider: 'local'`). */
1396
- const FontVariantSchema = z.object({
1397
- src: z.array(z.string()),
1398
- weight: z.optional(FontWeight),
1399
- style: z.optional(FontStyle),
1400
- unicodeRange: z.optional(z.array(z.string())),
1401
- display: z.optional(FontDisplay)
1402
- });
1403
- const SiteFontSchema = z.object({
1404
- name: z.string(),
1405
- provider: FontProvider,
1406
- cssVariable: z.string().check(z.regex(/^--/)),
1407
- weights: z.optional(z.array(FontWeight)),
1408
- styles: z.optional(z.array(FontStyle)),
1409
- subsets: z.optional(z.array(z.string())),
1410
- fallbacks: z.optional(z.array(z.string())),
1411
- optimizedFallbacks: z.optional(z.boolean()),
1412
- display: z.optional(FontDisplay),
1413
- formats: z.optional(z.array(z.string())),
1414
- unicodeRange: z.optional(z.array(z.string())),
1415
- variants: z.optional(z.array(FontVariantSchema)),
1416
- preload: desc(z.optional(z.union([z.boolean(), z.array(z.object({
1417
- weight: FontWeight,
1418
- style: z.optional(FontStyle)
1419
- }))])), "Emit `<Font preload />` for this family (true, or a list of weight/style faces).")
1420
- });
1421
- const PresentSchema = z.optional(z.record(z.string(), z.unknown()));
1422
- const NavItemSchema = z.object({
1423
- label: z.optional(LocalizableSchema),
1424
- image: z.optional(ImageRefSchema),
1425
- href: z.optional(z.string()),
1426
- icon: z.optional(z.string()),
1427
- attrs: AttrsSchema,
1428
- itemAttrs: AttrsSchema,
1429
- submenu: z.optional(z.object({
1430
- present: PresentSchema,
1431
- get items() {
1432
- return z.optional(z.array(NavItemSchema));
1433
- }
1434
- }))
1435
- });
1436
- const FormFieldType = z.enum([
1437
- "text",
1438
- "email",
1439
- "tel",
1440
- "url",
1441
- "number",
1442
- "password",
1443
- "textarea",
1444
- "select",
1445
- "checkbox",
1446
- "radio",
1447
- "date",
1448
- "file",
1449
- "hidden"
1450
- ]);
1451
- const FormFieldSchema = z.object({
1452
- name: z.string(),
1453
- type: FormFieldType,
1454
- label: z.optional(LocalizableSchema),
1455
- required: z.optional(z.boolean()),
1456
- validation: z.optional(z.string()),
1457
- options: z.optional(z.union([z.string(), z.array(z.object({
1458
- label: z.string(),
1459
- value: z.string()
1460
- }))])),
1461
- attrs: AttrsSchema
1462
- });
1463
- const ContentNodeSchema = z.object({
1464
- tag: z.string(),
1465
- attrs: AttrsSchema,
1466
- text: z.optional(z.string()),
1467
- get children() {
1468
- return z.optional(z.array(ContentNodeSchema));
1469
- }
1470
- });
1471
- const TemplateSchema = z.union([
1472
- z.object({
1473
- type: z.literal("nav"),
1474
- tags: z.optional(z.array(z.string())),
1475
- present: PresentSchema,
1476
- items: z.optional(z.array(NavItemSchema))
1477
- }),
1478
- z.object({
1479
- type: z.literal("form"),
1480
- tags: z.optional(z.array(z.string())),
1481
- present: PresentSchema,
1482
- action: z.optional(z.string()),
1483
- method: z.optional(z.enum(["get", "post"])),
1484
- fields: z.optional(z.array(FormFieldSchema)),
1485
- submit: z.optional(z.record(z.string(), AttrValue)),
1486
- honeypot: z.optional(z.boolean())
1487
- }),
1488
- z.object({
1489
- type: z.literal("nodes"),
1490
- tags: z.optional(z.array(z.string())),
1491
- nodes: z.optional(z.array(ContentNodeSchema))
1492
- })
1493
- ]);
1494
- const GalleryImageSchema = z.object({
1495
- src: z.string(),
1496
- alt: LocalizableSchema,
1497
- caption: z.optional(LocalizableSchema)
1498
- });
1499
- const GallerySchema = z.object({
1500
- title: z.optional(LocalizableSchema),
1501
- tags: z.optional(z.array(z.string())),
1502
- images: z.array(GalleryImageSchema)
1503
- });
1504
- const TestimonialItemSchema = z.object({
1505
- quote: LocalizableSchema,
1506
- name: z.string(),
1507
- role: z.optional(z.string()),
1508
- rating: z.optional(z.number().check(z.gte(0), z.lte(5))),
1509
- date: z.optional(IsoDate)
1510
- });
1511
- const TestimonialGroupSchema = z.object({
1512
- title: z.optional(LocalizableSchema),
1513
- items: z.array(TestimonialItemSchema)
1514
- });
1515
- const OfferSchema = z.object({
1516
- name: z.optional(LocalizableSchema),
1517
- price: z.optional(z.union([z.string(), z.number()])),
1518
- priceCurrency: z.optional(Iso4217),
1519
- availability: z.optional(z.string()),
1520
- url: z.optional(z.url()),
1521
- validFrom: z.optional(IsoDate),
1522
- priceValidUntil: z.optional(IsoDate)
1523
- });
1524
- const ServiceSchema = z.object({
1525
- name: LocalizableSchema,
1526
- description: z.optional(LocalizableSchema),
1527
- slug: z.optional(LocalizableSchema),
1528
- offers: z.optional(z.array(OfferSchema))
1529
- });
1530
- const OpenGraphSchema = z.object({
1531
- type: z.optional(z.string()),
1532
- siteName: z.optional(z.string()),
1533
- title: z.optional(LocalizableSchema),
1534
- description: z.optional(LocalizableSchema),
1535
- image: z.optional(ImageRefSchema),
1536
- locale: z.optional(z.string())
1537
- });
1538
- const TwitterSchema = z.object({
1539
- card: z.optional(z.enum([
1540
- "summary",
1541
- "summary_large_image",
1542
- "app",
1543
- "player"
1544
- ])),
1545
- site: z.optional(z.string()),
1546
- creator: z.optional(z.string()),
1547
- title: z.optional(LocalizableSchema),
1548
- description: z.optional(LocalizableSchema),
1549
- image: z.optional(ImageRefSchema)
1550
- });
1551
- /**
1552
- * What `vitops search notify` needs to tell search engines a deploy happened.
1553
- *
1554
- * Scoped by what search engines actually accept, which is narrower than it looks:
1555
- * Google exposes **no** "request indexing" API (the Search Console button is not in
1556
- * its API, and URL Inspection is read-only), and it removed the sitemap ping
1557
- * endpoint in 2023. So the sanctioned set is: an accurate sitemap, a Search Console
1558
- * sitemap resubmit, an IndexNow ping — which Google does *not* honour but Bing,
1559
- * Yandex, Naver, Seznam and Yep do — and a read-only inspection pass to find out
1560
- * what Google actually did.
1561
- *
1562
- * Facts only, the same rule the legal block follows: this records what the site has
1563
- * (a key, a property, which pages matter), never what to say about it.
1564
- */
1565
- const IndexNowSchema = z.object({
1566
- key: desc(z.string().check(z.regex(/^[a-zA-Z0-9-]{8,128}$/)), "IndexNow key (8–128 chars, hex is conventional). NOT a secret — it is served publicly at `keyLocation` so the engine can verify you own the host. Generate one with `vitops search notify --new-key`."),
1567
- keyLocation: desc(z.optional(z.url()), "Absolute URL of the key file. Defaults to `<canonical>/<key>.txt`; set it only when the file lives elsewhere."),
1568
- endpoint: desc(z.optional(z.url()), "IndexNow endpoint (default `https://api.indexnow.org/indexnow`). Any participating engine shares submissions with the rest, so one is normally enough.")
1569
- });
1570
- const SearchConsoleSchema = z.object({
1571
- siteUrl: desc(z.string(), "The property exactly as Search Console identifies it — `sc-domain:acme.ca` for a domain property, or the URL-prefix form `https://acme.ca/`. A mismatch here is a 403, not a \"not found\"."),
1572
- resubmitSitemap: desc(z.optional(z.boolean()), "Re-submit the sitemap through the Search Console API on each notify (default true when `searchConsole` is set). This is the automated equivalent of the manual resubmit in the UI.")
1573
- });
1574
- const IndexingSchema = z.object({
1575
- sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
1576
- indexNow: desc(z.optional(IndexNowSchema), "IndexNow submission (Bing, Yandex, Naver, Seznam, Yep — not Google). Omit to skip the channel."),
1577
- searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a credential at run time — either a service account in `VITOPS_GSC_SERVICE_ACCOUNT` / `GOOGLE_APPLICATION_CREDENTIALS`, or the user OAuth credential `vitops search setup` uses (`VITOPS_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` / `_REFRESH_TOKEN`). The service account wins when both are set. Never put a key in this file."),
1578
- priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops search notify --check` inspects these and exits non-zero if Google has not indexed one. Kept explicit because URL Inspection is quota-bound (2000/day), so checking every page is neither affordable nor informative.")
1579
- });
1580
- /**
1581
- * The ad platforms `vitops ads` can link a site to.
1582
- *
1583
- * A closed enum, mirroring `AD_PROVIDER_KEYS` in `@getvitops/utils/ads` — that
1584
- * module owns what each platform *can do* (whether it verifies a domain, what its
1585
- * tag is, which click ID it stamps), this owns what a config may *say*. Mirrored
1586
- * rather than derived because a `z.enum` needs literals to reach the published JSON
1587
- * Schema; `config.test.ts` asserts the two lists are the same set, so a provider
1588
- * added to one and not the other fails the build rather than silently getting no
1589
- * verification step and no tag.
1590
- */
1591
- const AD_PROVIDERS = [
1592
- "google",
1593
- "meta",
1594
- "linkedin",
1595
- "reddit",
1596
- "tiktok",
1597
- "microsoft",
1598
- "pinterest",
1599
- "snapchat"
1600
- ];
1601
- const AdProviderSchema = z.enum(AD_PROVIDERS);
1602
- /**
1603
- * One ad property this site is linked to.
1604
- *
1605
- * Facts, not prose — the same rule the legal block follows. This records *which*
1606
- * account and *which* tag id, and the platform capability table decides what can be
1607
- * done about them. It is also what finally lets the rest of the toolchain see an ad
1608
- * pixel: the generated cookie notice discloses its cookies, `vitops ads lint`
1609
- * checks its click ID is captured, and the consent gate is handed the cookie names
1610
- * to clear on revoke. A pixel pasted into a template is invisible to all three.
1611
- */
1612
- const AdPropertySchema = z.object({
1613
- accountId: desc(z.optional(z.string()), "The advertising account as the platform shows it — Google Ads customer ID, Meta ad account, LinkedIn partner ID, Reddit advertiser ID. For LinkedIn this is also the Insight Tag id; everywhere else the tag id is `pixelId`."),
1614
- pixelId: desc(z.optional(z.string()), "The tag/pixel ID the browser snippet initialises — Meta pixel, TikTok pixel, Reddit pixel, Microsoft UET tag ID, Pinterest tag ID, Snap pixel, or the Google Ads conversion ID (`AW-…`, which is NOT the customer ID)."),
1615
- conversionLabel: desc(z.optional(z.string()), "Google Ads only: the conversion action label paired with the `AW-…` conversion ID."),
1616
- domain: desc(z.optional(z.string()), "Bare hostname to verify with this platform. Defaults to the host of `domains.canonical` — set it only when the ad account is verified against a different domain."),
1617
- domainVerification: desc(z.optional(z.string()), "The verification token from the platform UI, for the platforms that verify by DNS TXT (Meta, TikTok, Pinterest, Snapchat). `vitops ads setup` prompts for it and writes it here on first run. NOT a secret — it is published in DNS, and the platform fetching it back is the ownership proof, exactly like the IndexNow key. A value containing `=` is used as the whole record, which is the escape hatch if a platform changes its prefix."),
1618
- category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the tag waits on (default `marketing`). An ad pixel is advertising by default; `analytics` is for a site using one purely for its own measurement."),
1619
- enabled: desc(z.optional(z.boolean()), "Set false to keep the property on record without emitting its tag.")
1620
- });
1621
- /**
1622
- * How one location's Google reviews are selected and ordered for display.
1623
- *
1624
- * Keyed by `organization.locations` key, mirroring `site.ads`/`site.searchConsole`
1625
- * — `vitops reviews` needs to know which physical location a fetched review set
1626
- * belongs to, and that record already exists.
1627
- */
1628
- const ReviewLocationSchema = z.object({
1629
- accountId: desc(z.optional(z.string()), "GBP account resource id (`accounts/…`), overriding `site.reviews.accountId` for this location."),
1630
- locationId: desc(z.string(), "GBP location resource id (`locations/…`) to fetch reviews for."),
1631
- featured: desc(z.optional(z.array(z.string())), "Review ids to always include, in this order, ahead of everything else automatically selected."),
1632
- minRating: desc(z.optional(z.number()), "Drop reviews below this star rating (default 4) from the automatic (non-featured) selection."),
1633
- limit: desc(z.optional(z.number()), "Keep at most this many reviews after filtering."),
1634
- sort: desc(z.optional(z.enum(["recent", "rating"])), "Ordering for the non-featured remainder (default `recent`).")
1635
- });
1636
- const ReviewsSchema = z.object({
1637
- accountId: desc(z.optional(z.string()), "Default GBP account resource id (`accounts/…`) for locations that do not override it."),
1638
- locations: desc(z.record(z.string(), ReviewLocationSchema), "Which `organization.locations` to fetch Google reviews for, and how each one’s reviews are filtered/ordered. `vitops reviews` writes the result to `.vitops/reviews.json`."),
1639
- schema: desc(z.optional(z.boolean()), "Emit `Review` JSON-LD for these reviews (default false). Off by default: Google’s structured-data policy treats a business marking up reviews of itself as ineligible for review rich results, and risks a manual action."),
1640
- avatars: desc(z.optional(z.boolean()), "Render the reviewer’s Google-hosted avatar photo (default false). Off by default: every visitor would otherwise issue a third-party request to googleusercontent.com, and the URLs are not guaranteed to stay live.")
1641
- });
1642
- /**
1643
- * Which template set the generated legal documents are written against. This is
1644
- * a closed enum rather than a free string because an unrecognised value would
1645
- * otherwise silently fall back to the wrong body of law; `validateConfig` rejects
1646
- * anything without a registered template. Add a member here and a matching entry
1647
- * in `legal/templates/index.ts` — the two are checked against each other.
1648
- */
1649
- const JURISDICTIONS = ["ca"];
1650
- const JurisdictionSchema = z.enum(JURISDICTIONS);
1651
- /**
1652
- * The country each template set frames cross-border transfers as *leaving*.
1653
- *
1654
- * A jurisdiction's own country is not a transfer, so this is what filters the
1655
- * disclosure lists — without it, `country: "Canada"` renders the incoherent
1656
- * "outside of Canada, including Canada".
1657
- *
1658
- * It belongs to the **jurisdiction**, not to `organization.address`: a `ca` policy
1659
- * says "outside of Canada" even for a business incorporated elsewhere. (The
1660
- * address drives a different sentence — the terms' governing-law clause, via
1661
- * `governingCountry`.) `satisfies` makes a new jurisdiction unable to forget it,
1662
- * the same guard style as `TEMPLATES`.
1663
- */
1664
- const JURISDICTION_COUNTRIES = { ca: "Canada" };
1665
- /**
1666
- * A third party that receives personal information. The generator derives the
1667
- * ones it can see (analytics IDs, Turnstile, the deploy platform); this is for
1668
- * the rest, which no other part of the config implies.
1669
- *
1670
- * Deliberately facts, not prose: the config records *what is true*, the template
1671
- * owns *how it is said*. That keeps a policy correct when the wording changes.
1672
- */
1673
- const ProcessorStorageSchema = z.object({
1674
- country: desc(z.string(), "A country or bloc where this provider stores or processes the information, as it should read in a sentence (e.g. \"Canada\", \"the United States\", \"the European Union\"). Naming the jurisdiction the policy is written for is meaningful: it says this information does not leave it."),
1675
- scope: desc(z.optional(z.string()), "Which information is held there, as a noun phrase that reads after \"in the case of\" (e.g. \"account and sign-in information\", \"mailbox contents\"). Omit when this location holds everything the provider receives — a scoped entry claims LESS than an unscoped one, so omitting it is the safe default.")
1676
- });
1677
- const ProcessorSchema = z.object({
1678
- name: desc(z.string(), "The provider, as a reader would recognise it (e.g. \"Stripe\")."),
1679
- purpose: desc(z.string(), "Why they receive it, as a noun phrase that reads after \"for\" (e.g. \"payment processing\")."),
1680
- country: desc(z.optional(z.string()), "Shorthand for the common case where one country is both where they store it and whose laws reach it: asserts BOTH `storage: [{ country }]` AND `operatorCountry`. Reads inside a sentence (e.g. \"the United States\"). When the two differ — a Canadian region operated by a US company — state `storage` and `operatorCountry` instead; setting this alongside either is rejected."),
1681
- storage: desc(z.optional(z.array(ProcessorStorageSchema)), "Where the information actually rests. Feeds the \"stored or processed outside of <jurisdiction>\" disclosure and nothing else. Several entries are allowed, each optionally scoped to a category of information — which is what makes a Canadian-region tenant holding identity data in the US expressible."),
1682
- operatorCountry: desc(z.optional(z.string()), "The jurisdiction that can compel this provider to hand the information over — where it is established, or from which it is controlled. Reads after \"the laws of\" (e.g. \"the United States\"); a bloc is acceptable. A SEPARATE fact from `storage`, because privacy law cares about foreign *access*, not only foreign storage: a Canadian-region service run by a US company is `storage: [{ country: \"Canada\" }]` with `operatorCountry: \"the United States\"`."),
1683
- privacyUrl: z.optional(z.url())
1684
- });
1685
- const SiteSectionSchema = z.object({
1686
- defaultLocale: desc(z.string(), "The locale used when none is specified; must be a `locales` key."),
1687
- locales: desc(z.record(z.string(), LocaleSchema), "Locales the site is published in, keyed by BCP 47 tag (e.g. \"en\", \"fr\")."),
1688
- domains: desc(z.optional(z.object({
1689
- canonical: desc(z.url(), "The canonical origin used for absolute URLs and SEO."),
1690
- aliases: desc(z.optional(z.array(DomainAliasSchema)), "Alias domains and how they redirect to the canonical one.")
1691
- })), "Canonical domain + redirecting aliases."),
1692
- dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
1693
- cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
1694
- google: desc(z.optional(z.object({ project: desc(z.string(), "Google Cloud project id that API usage is attributed to (quota and billing). One project per site keeps usage separate — Search Console today, Maps and anything else later. Sent as `x-goog-user-project`, which is required when the credential is a *user* one (an ADC login from `gcloud auth application-default login`, or `VITOPS_GOOGLE_*`): those use a shared OAuth client that carries no project of its own. A service-account credential belongs to a project already, so this is not needed for it. The identity must hold `serviceusage.services.use` on the project, and the APIs being called must be enabled there.") })), "This site's Google Cloud project. Not a secret — the credentials stay in the environment."),
1695
- searchConsole: desc(z.optional(z.record(z.string(), SearchConsoleSetupSchema)), "Domains to onboard as Google Search Console *domain properties* via `vitops search setup`, keyed by bare hostname (mirrors `dns`). Credentials come from the environment (CLOUDFLARE_API_TOKEN + the Google OAuth vars) — never in this file. Set `google.project` when using a user credential."),
1696
- environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
1697
- abTesting: desc(z.optional(z.object({
1698
- enabled: z.boolean(),
1699
- cookieName: z.optional(z.string()),
1700
- cookieMaxAge: desc(z.optional(z.number().check(z.int(), z.positive())), "Seconds."),
1701
- splitRatio: desc(z.optional(z.number().check(z.gte(0), z.lte(1))), "Fraction of traffic sent to the variant (0–1)."),
1702
- variants: desc(z.optional(z.record(z.string(), AbVariantSchema)), "Named variants; each targets an environment and may deep-merge config `overrides`.")
1703
- })), "A/B testing: cookie-based split plus named variants whose `overrides` patch the config per environment."),
1704
- fonts: desc(z.optional(z.array(SiteFontSchema)), "Font LOADING (a serialisable projection of the Astro Fonts API: provider, weights, subsets, preload). Font TOKENS live in `designSystem.themes.<theme>.fonts` and reference the same `cssVariable`."),
1705
- tags: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-tag taxonomy (site-specific; not interpreted by the generator)."),
1706
- postTypes: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-type definitions (site-specific; not interpreted by the generator)."),
1707
- galleries: desc(z.optional(z.record(z.string(), GallerySchema)), "Named image galleries (title, tags, images with localisable alt/caption)."),
1708
- testimonials: desc(z.optional(z.record(z.string(), TestimonialGroupSchema)), "Named testimonial groups (quote, name, role, rating, date)."),
1709
- templates: desc(z.optional(z.record(z.string(), TemplateSchema)), "Named content templates rendered at SSR time: `nav` (menu trees), `form` (field lists + submit), or `nodes` (raw element trees)."),
1710
- navigation: desc(z.optional(z.object({ activeTemplate: desc(z.optional(z.object({
1711
- default: desc(z.optional(z.string()), "A `templates` key."),
1712
- breakpoints: desc(z.optional(z.record(z.string(), z.string())), "Breakpoint name → `templates` key overrides.")
1713
- })), "Which nav template is active, optionally per breakpoint.") })), "Site navigation settings (which nav template renders where)."),
1714
- seo: desc(z.optional(z.object({
1715
- titleTemplate: desc(z.optional(z.string()), "Page-title pattern (e.g. \"%s · Acme\")."),
1716
- descriptionTemplate: z.optional(z.string()),
1717
- robots: desc(z.optional(RobotsSchema), "e.g. \"index,follow\" / \"noindex,nofollow\"."),
1718
- googleSiteVerification: z.optional(z.string()),
1719
- bingSiteVerification: z.optional(z.string()),
1720
- openGraph: z.optional(OpenGraphSchema),
1721
- twitter: z.optional(TwitterSchema),
1722
- indexing: desc(z.optional(IndexingSchema), "How `vitops search notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
1723
- })), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
1724
- analytics: desc(z.optional(z.object({
1725
- googleAnalyticsId: z.optional(z.string()),
1726
- plausibleDomain: z.optional(z.string()),
1727
- googleTagManagerId: z.optional(z.string()),
1728
- clarityId: desc(z.optional(z.string()), "Microsoft Clarity project ID."),
1729
- matomo: desc(z.optional(z.object({
1730
- url: desc(z.string(), "Instance base URL (self-hosted, or `*.matomo.cloud`)."),
1731
- siteId: desc(z.string(), "Site ID within that instance."),
1732
- cookies: desc(z.optional(z.boolean()), "Default false — Matomo runs cookieless (`disableCookies`), which is what lets the cookie notice state positively that it sets none.")
1733
- })), "Matomo instance.")
1734
- })), "Analytics provider IDs (gated per environment via `environments.<env>.analytics`). Which provider is set is also what the generated privacy policy and cookie notice disclose — see `legal`."),
1735
- notifications: desc(z.optional(z.object({ email: desc(z.optional(z.union([z.email(), z.object({
1736
- provider: desc(z.literal("cloudflare"), "Delivery provider. Only Cloudflare Email Sending is implemented; the field is stated so adding another is additive."),
1737
- to: desc(z.optional(z.email()), "Recipient. Defaults to the primary location’s `email`, then the first location that has one."),
1738
- from: desc(z.optional(z.email()), "Sender. Defaults to `noreply@<domains.canonical>`. Its domain must be onboarded (`wrangler email sending enable <domain>`) or every send fails."),
1739
- fromName: desc(z.optional(z.string()), "Display name on the From header. Defaults to `organization.name`."),
1740
- replyTo: desc(z.optional(z.email()), "Reply-To header."),
1741
- binding: desc(z.optional(z.string()), "Workers `send_email` binding name (default `EMAIL`). Set it when using a restricted binding.")
1742
- })])), "Where conversion notifications are e-mailed. A bare address is shorthand for `{ provider: \"cloudflare\", to }`.") })), "Where site notifications (form submissions, tracked calls) are sent. TODO: `sms` and `persist` channels — only `email` is implemented."),
1743
- tracking: desc(z.optional(z.object({
1744
- enabled: desc(z.optional(z.boolean()), "Capture ad click IDs and UTMs from the landing URL into the `_ac` cookie, for attributing conversions."),
1745
- category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the `_ac` cookie waits on (default `marketing`). It is a 90-day identifier tying a visitor to the ad that brought them, so it is only written once this category is granted — and asking for it is what raises the banner."),
1746
- platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
1747
- })), "Ad-click attribution for conversion tracking."),
1748
- ads: desc(z.optional(z.partialRecord(AdProviderSchema, AdPropertySchema)), "Ad properties this site is linked to, keyed by platform. Read by `vitops ads setup` (ensure the DNS verification record), `vitops ads tags` (emit the consent-gated pixel), and the generated cookie notice (disclose what each pixel stores). Credentials never live here — the DNS write uses CLOUDFLARE_API_TOKEN from the environment."),
1749
- reviews: desc(z.optional(ReviewsSchema), "Google Business Profile reviews to cache and display, keyed by `organization.locations`. Read by `vitops reviews`. Credentials never live here — a Google OAuth credential comes from the environment, same as `site.searchConsole`."),
1750
- security: desc(z.optional(z.object({ turnstile: z.optional(z.object({ siteKey: desc(z.optional(z.string()), "Cloudflare Turnstile site key (public).") })) })), "Security integrations (bot protection)."),
1751
- legal: desc(z.optional(z.object({
1752
- jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
1753
- privacyPolicy: z.optional(z.object({
1754
- enabled: z.boolean(),
1755
- url: z.optional(z.url()),
1756
- lastUpdated: z.optional(IsoDate),
1757
- privacyOfficer: desc(z.optional(ContactConfigSchema), "Who privacy requests go to. Falls back to `contact`, then `primaryLocation`."),
1758
- retention: desc(z.optional(z.string()), "How long personal information is kept, in prose (e.g. \"24 months after last contact\")."),
1759
- processors: desc(z.optional(z.array(ProcessorSchema)), "Third parties that receive personal information and cannot be inferred from the rest of the config (payment processors, CRMs, mail senders). Known providers implied by `analytics`, `security` and `deployment` are added automatically — list only the rest.")
1760
- })),
1761
- termsOfService: z.optional(z.object({
1762
- enabled: z.boolean(),
1763
- url: z.optional(z.url()),
1764
- lastUpdated: z.optional(z.nullable(IsoDate)),
1765
- governingLaw: desc(z.optional(z.string()), "Governing-law clause, in prose (e.g. \"the Province of Ontario\"). Defaults from the contact address.")
1766
- })),
1767
- cookieConsent: z.optional(z.object({
1768
- enabled: z.boolean(),
1769
- type: desc(z.optional(z.enum(["opt-in", "opt-out"])), "Consent model."),
1770
- position: z.optional(z.enum([
1771
- "top",
1772
- "bottom",
1773
- "center"
1774
- ])),
1775
- categories: z.optional(z.array(z.string()))
1776
- }))
1777
- })), "Legal pages: which documents exist, where they live, and the facts the generated prose asserts."),
1778
- icons: desc(z.optional(z.looseObject({
1779
- ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\", \"ph\")."),
1780
- brand: desc(z.optional(z.string()), "Icon set used for brand marks."),
1781
- weight: desc(z.optional(z.string()), "Weight for suffix-weighted sets like Phosphor (\"regular\" | \"bold\" | \"duotone\" | \"fill\" | \"light\" | \"thin\"). Ignored by sets that split weights across collections, e.g. Font Awesome."),
1782
- sprite: desc(z.optional(z.boolean()), "Emit an SVG sprite (icons.svg) alongside the stylesheet, for consumers that cannot run an icon integration (Bricks/WordPress, EmDash renderers)."),
1783
- semantic: desc(z.optional(z.array(z.string())), "Named semantic icons to include."),
1784
- "fa7-solid": z.optional(z.array(z.string())),
1785
- "fa7-regular": z.optional(z.array(z.string())),
1786
- "fa7-light": z.optional(z.array(z.string())),
1787
- "fa7-thin": z.optional(z.array(z.string())),
1788
- "fa7-brands": z.optional(z.array(z.string())),
1789
- "simple-icons": z.optional(z.array(z.string())),
1790
- "material-symbols": z.optional(z.array(z.string())),
1791
- lucide: z.optional(z.array(z.string())),
1792
- ph: z.optional(z.array(z.string()))
1793
- })), "Icon sets and the specific icons to bundle from each (keys are iconify collection names)."),
1794
- favicon: desc(z.optional(z.object({
1795
- source: desc(z.string(), "Source image (SVG/PNG) the favicon set is generated from."),
1796
- lowResSource: desc(z.optional(z.string()), "Alternate source for small raster sizes (16/32px) when the main source scales down poorly."),
1797
- name: desc(z.optional(z.string()), "App name for the generated web manifest. Setting this AND `themeColor` is what makes the site installable: together they emit `site.webmanifest` (with `display: \"standalone\"`) and link it. Neither alone does anything. A marketing site that does not want a browser offering to install it should leave one of them unset."),
1798
- themeColor: desc(z.optional(z.string()), "Browser UI colour, emitted as `<meta name=\"theme-color\">` on every page — so it applies with or without a manifest. Note it is also half the manifest switch: see `name`."),
1799
- backgroundColor: desc(z.optional(z.string()), "Background composited under the OPAQUE generated icons — `apple-touch-icon.png` and the maskable `icon-mask.png` — which are produced whether or not there is a manifest. Defaults to white, so a dark logo on a transparent source needs this set. It is also the manifest `background_color` when one is emitted, but it is not manifest-only.")
1800
- })), "Favicon/PWA asset generation (consumed by `@getvitops/utils` favicon tooling)."),
1801
- deployment: desc(z.optional(z.object({
1802
- platform: z.optional(z.string()),
1803
- buildCommand: z.optional(z.string()),
1804
- deployCommand: z.optional(z.string()),
1805
- outputDirectory: z.optional(z.string()),
1806
- type: z.optional(z.string())
1807
- })), "How the site is built and deployed (platform, commands, output directory) — informational for tooling.")
1808
- });
1809
- const OrganizationSectionSchema = z.extend(OrganizationSchema, {
1810
- contact: desc(z.optional(ContactConfigSchema), "Primary contact: either a `locations` key (string reference) or an inline name/email/phone/address object."),
1811
- primaryLocation: desc(z.optional(z.string()), "The main `locations` key (for JSON-LD and defaults)."),
1812
- locations: desc(z.optional(z.record(z.string(), LocationSchema)), "Physical locations (schema.org LocalBusiness): address, geo, opening hours, service area."),
1813
- services: desc(z.optional(z.record(z.string(), ServiceSchema)), "Services offered (schema.org Service/Offer): name, description, slug, price offers."),
1814
- links: desc(z.optional(z.object({
1815
- googleMaps: z.optional(z.url()),
1816
- instagram: z.optional(z.url()),
1817
- facebook: z.optional(z.url()),
1818
- x: z.optional(z.url()),
1819
- linkedin: z.optional(z.url()),
1820
- youtube: z.optional(z.url()),
1821
- github: z.optional(z.url())
1822
- })), "Public profile URLs (also feed JSON-LD `sameAs` via `organization.sameAs`).")
1823
- });
1824
- const ConfigSchema = z.object({
1825
- $schema: desc(z.optional(z.string()), "URL of the published config JSON Schema (stamped by `vitops init --site`) so editors provide autocomplete + validation."),
1826
- designSystem: DesignSystemBlock,
1827
- organization: desc(z.optional(OrganizationSectionSchema), "The company: schema.org Organization details for JSON-LD (name, legalName, logo, tax IDs, sameAs), plus its contact, physical locations, services and public profiles. These stay true across sites — several sites can share one `organization` and differ only in `site`."),
1828
- site: desc(SiteSectionSchema, "This published site: locales, domains, environments, content templates, SEO, analytics, legal documents, icons, favicon and deployment. Facts about a *presentation* of the organization rather than the organization itself.")
1829
- });
1830
- const CONFIG_SCHEMA_URL = "https://unpkg.com/@getvitops/generator/config.schema.json";
1831
- /** The installed copy, not the unpinned unpkg URL — see `SCHEMA_LOCAL_PATH`'s twin. */
1832
- const CONFIG_SCHEMA_LOCAL_PATH = "./node_modules/@getvitops/generator/config.schema.json";
1833
- /** The published JSON Schema (draft-2020-12), derived from the zod schema. */
1834
- const configJsonSchema = {
1835
- $id: CONFIG_SCHEMA_URL,
1836
- ...z.toJSONSchema(ConfigSchema, { target: "draft-2020-12" })
1837
- };
1838
- const issue = (path, message) => ({
1839
- code: "custom",
1840
- path,
1841
- message
1842
- });
1843
- /**
1844
- * Where each key of the old flat config now lives.
1845
- *
1846
- * This exists so the restructure fails with a sentence a reader can act on. Zod
1847
- * reports a flat config as a dozen separate `unrecognized_keys` issues plus a
1848
- * missing required `site` — an accurate description of the document that teaches
1849
- * nobody where anything went. Checked first, and it short-circuits: once we know
1850
- * the document is the old shape, every other error is downstream noise.
1851
- */
1852
- const MOVED_KEYS = {
1853
- defaultLocale: "site",
1854
- locales: "site",
1855
- domains: "site",
1856
- dns: "site",
1857
- cloudflare: "site",
1858
- environments: "site",
1859
- abTesting: "site",
1860
- fonts: "site",
1861
- tags: "site",
1862
- postTypes: "site",
1863
- galleries: "site",
1864
- testimonials: "site",
1865
- templates: "site",
1866
- navigation: "site",
1867
- seo: "site",
1868
- analytics: "site",
1869
- notifications: "site",
1870
- tracking: "site",
1871
- security: "site",
1872
- legal: "site",
1873
- icons: "site",
1874
- favicon: "site",
1875
- deployment: "site",
1876
- contact: "organization",
1877
- primaryLocation: "organization",
1878
- locations: "organization",
1879
- services: "organization",
1880
- links: "organization"
1881
- };
1882
- /**
1883
- * Detect the pre-3.0 flat shape and name the moves.
1884
- *
1885
- * Keyed on a moved key sitting at the ROOT, not on the absence of `site` — a
1886
- * config that simply forgot `site` should get the ordinary "required" error, and
1887
- * a partially-migrated one (some keys moved, some not) is exactly the case worth
1888
- * catching loudly.
1889
- */
1890
- function migrationIssues(raw) {
1891
- if (typeof raw !== "object" || raw == null || Array.isArray(raw)) return [];
1892
- const found = Object.keys(raw).filter((k) => k in MOVED_KEYS);
1893
- if (found.length === 0) return [];
1894
- return [issue([], `this is the pre-3.0 flat site config. The top level is now three sections — \`designSystem\`, \`organization\`, \`site\` — and these keys moved:
1895
- ${found.map((k) => ` ${k} → ${MOVED_KEYS[k]}.${k}`).join("\n")}\n\`designSystem\` and the fields already under \`organization\` stay where they are.`)];
1896
- }
1897
- /**
1898
- * Validate a raw (already parsed) config: shape via zod, then the cross-field
1899
- * integrity that JSON Schema can't express (referential keys, extends acyclicity).
1900
- * These live only here — the published JSON Schema enforces shape, not integrity.
1901
- */
1902
- function validateConfig(input) {
1903
- const migration = migrationIssues(input);
1904
- if (migration.length) return {
1905
- ok: false,
1906
- data: void 0,
1907
- errors: migration
1908
- };
1909
- const parsed = z.safeParse(ConfigSchema, input);
1910
- if (!parsed.success) return {
1911
- ok: false,
1912
- data: void 0,
1913
- errors: parsed.error.issues
1914
- };
1915
- const cfg = parsed.data;
1916
- const site = cfg.site;
1917
- const org = cfg.organization;
1918
- const errors = [];
1919
- const has = (m, k) => !!m && k in m;
1920
- if (!has(site.locales, site.defaultLocale)) errors.push(issue(["site", "defaultLocale"], `defaultLocale "${site.defaultLocale}" is not in locales`));
1921
- const themes = cfg.designSystem?.themes ?? {};
1922
- const defaultTheme = cfg.designSystem?.defaultTheme;
1923
- if (!has(themes, "default") && defaultTheme == null) errors.push(issue(["designSystem", "themes"], "themes must include a \"default\" entry"));
1924
- if (defaultTheme != null && !has(themes, defaultTheme)) errors.push(issue(["designSystem", "defaultTheme"], `defaultTheme "${defaultTheme}" is not in designSystem.themes`));
1925
- for (const [key, entry] of Object.entries(themes)) if (entry.extends != null && !has(themes, entry.extends)) errors.push(issue([
1926
- "designSystem",
1927
- "themes",
1928
- key,
1929
- "extends"
1930
- ], `extends "${entry.extends}" is not a themes key`));
1931
- for (const key of Object.keys(themes)) try {
1932
- resolveTheme(themes, key);
1933
- } catch (e) {
1934
- errors.push(issue([
1935
- "designSystem",
1936
- "themes",
1937
- key
1938
- ], e.message));
1939
- }
1940
- if (org?.primaryLocation != null && !has(org.locations, org.primaryLocation)) errors.push(issue(["organization", "primaryLocation"], `primaryLocation "${org.primaryLocation}" is not in organization.locations`));
1941
- if (typeof org?.contact === "string" && !has(org.locations, org.contact)) errors.push(issue(["organization", "contact"], `contact "${org.contact}" is not an organization.locations key`));
1942
- for (const [name, v] of Object.entries(site.abTesting?.variants ?? {})) if (!has(site.environments, v.environment)) errors.push(issue([
1943
- "site",
1944
- "abTesting",
1945
- "variants",
1946
- name,
1947
- "environment"
1948
- ], `environment "${v.environment}" is not in site.environments`));
1949
- for (const [i, a] of (site.domains?.aliases ?? []).entries()) if (a.environment != null && !has(site.environments, a.environment)) errors.push(issue([
1950
- "site",
1951
- "domains",
1952
- "aliases",
1953
- i,
1954
- "environment"
1955
- ], `environment "${a.environment}" is not in site.environments`));
1956
- const templates = site.templates;
1957
- const at = site.navigation?.activeTemplate;
1958
- if (at?.default != null && !has(templates, at.default)) errors.push(issue([
1959
- "site",
1960
- "navigation",
1961
- "activeTemplate",
1962
- "default"
1963
- ], `template "${at.default}" is not in site.templates`));
1964
- for (const [bp, tpl] of Object.entries(at?.breakpoints ?? {})) if (!has(templates, tpl)) errors.push(issue([
1965
- "site",
1966
- "navigation",
1967
- "activeTemplate",
1968
- "breakpoints",
1969
- bp
1970
- ], `template "${tpl}" is not in site.templates`));
1971
- const privacy = site.legal?.privacyPolicy;
1972
- /**
1973
- * `country` is shorthand for BOTH `storage` and `operatorCountry`, so combining it
1974
- * with either is genuinely ambiguous — is the shorthand narrowing the explicit
1975
- * fact, or adding to it? Both readings are plausible and they make *contradictory
1976
- * legal claims*, so this is rejected rather than resolved by a silent rule.
1977
- *
1978
- * Checked unconditionally, not gated on `privacy.enabled`: the fact is wrong
1979
- * either way, and a document turned on later shouldn't surface it for the first
1980
- * time. Here rather than as a `z.refine` on the schema because parse-time
1981
- * rejection would make `resolveConfig` throw on the `vitops legal` path, which
1982
- * has no formatting for a zod issue — `validateConfig` is where every other
1983
- * cross-field legal requirement already lives, and it prints `path: message`.
1984
- */
1985
- for (const [i, p] of (privacy?.processors ?? []).entries()) if (p.country != null && (p.storage != null || p.operatorCountry != null)) errors.push(issue([
1986
- "site",
1987
- "legal",
1988
- "privacyPolicy",
1989
- "processors",
1990
- i,
1991
- "country"
1992
- ], `processor "${p.name}": \`country\` is shorthand for BOTH \`storage\` and \`operatorCountry\`, so combining it with either makes the narrower claim ambiguous — drop \`country\` and state the two facts, or drop the two and keep the shorthand`));
1993
- if (typeof privacy?.privacyOfficer === "string" && !has(org?.locations, privacy.privacyOfficer)) errors.push(issue([
1994
- "site",
1995
- "legal",
1996
- "privacyPolicy",
1997
- "privacyOfficer"
1998
- ], `privacyOfficer "${privacy.privacyOfficer}" is not an organization.locations key`));
1999
- if (privacy?.enabled) {
2000
- if (resolvePrivacyContact(cfg) == null) errors.push(issue([
2001
- "site",
2002
- "legal",
2003
- "privacyPolicy"
2004
- ], "privacyPolicy.enabled requires a contact for privacy requests — set site.legal.privacyPolicy.privacyOfficer, organization.contact, or organization.email/address"));
2005
- if (site.domains?.canonical == null) errors.push(issue([
2006
- "site",
2007
- "legal",
2008
- "privacyPolicy"
2009
- ], "privacyPolicy.enabled requires site.domains.canonical — the policy states where information is held"));
2010
- }
2011
- const indexing = site.seo?.indexing;
2012
- if (indexing != null && site.domains?.canonical == null) {
2013
- if (indexing.sitemapUrl == null) errors.push(issue([
2014
- "site",
2015
- "seo",
2016
- "indexing",
2017
- "sitemapUrl"
2018
- ], "site.seo.indexing needs site.domains.canonical to derive the sitemap URL — set one, or state sitemapUrl explicitly"));
2019
- if (indexing.indexNow != null && indexing.indexNow.keyLocation == null) errors.push(issue([
2020
- "site",
2021
- "seo",
2022
- "indexing",
2023
- "indexNow",
2024
- "keyLocation"
2025
- ], "site.seo.indexing.indexNow needs site.domains.canonical to derive the key location — set one, or state keyLocation explicitly"));
2026
- }
2027
- for (const [provider, entry] of Object.entries(site.ads ?? {})) {
2028
- if (entry == null) continue;
2029
- const platform = AD_PLATFORMS[provider];
2030
- const at = [
2031
- "site",
2032
- "ads",
2033
- provider
2034
- ];
2035
- if (entry.domainVerification != null && platform.verification.method === "none") errors.push(issue([...at, "domainVerification"], `${platform.name} has no domain verification — ${platform.verification.reason ?? "nothing verifies a domain there"}. Remove the token; linking is the tag and the account id.`));
2036
- if (entry.domainVerification != null && entry.domain == null && site.domains?.canonical == null) errors.push(issue([...at, "domain"], `site.ads.${provider}.domainVerification needs a domain to put the record on — set site.domains.canonical, or state site.ads.${provider}.domain explicitly`));
2037
- if (entry.accountId == null && entry.pixelId == null) errors.push(issue(at, `site.ads.${provider} identifies nothing — set accountId (the advertising account) and/or pixelId (the tag: ${platform.tag.needs} is what its snippet needs)`));
2038
- }
2039
- for (const [key, entry] of Object.entries(site.reviews?.locations ?? {})) {
2040
- const at = [
2041
- "site",
2042
- "reviews",
2043
- "locations",
2044
- key
2045
- ];
2046
- if (!has(org?.locations, key)) errors.push(issue(at, `"${key}" is not an organization.locations key`));
2047
- else if (org?.locations?.[key]?.listings?.google == null) errors.push(issue(at, `organization.locations.${key} has no listings.google id — record the GBP location id there first`));
2048
- if (entry.accountId == null && site.reviews?.accountId == null) errors.push(issue([...at, "accountId"], `no accountId — set site.reviews.accountId or site.reviews.locations.${key}.accountId`));
2049
- }
2050
- if (errors.length) return {
2051
- ok: false,
2052
- data: void 0,
2053
- errors
2054
- };
2055
- return {
2056
- ok: true,
2057
- data: cfg,
2058
- errors: []
2059
- };
2060
- }
2061
- /**
2062
- * Resolve who privacy requests go to: the explicit privacy officer, else the
2063
- * organization's contact, else its primary location, else the organization itself.
2064
- *
2065
- * Exported because `validateConfig` and the legal-document derivation must agree
2066
- * on it exactly — a config that validates must be one the policy can render, and
2067
- * two copies of this precedence would drift into a policy with a blank address.
2068
- *
2069
- * Note every candidate but the first now lives in `organization`, and the first
2070
- * (`site.legal.privacyPolicy.privacyOfficer`) still dereferences against
2071
- * `organization.locations` — the officer is a person at the company, named by the
2072
- * site because a company may publish more than one policy.
2073
- */
2074
- function resolvePrivacyContact(cfg) {
2075
- const org = cfg.organization;
2076
- const deref = (c) => {
2077
- if (c == null) return void 0;
2078
- if (typeof c !== "string") return c;
2079
- const loc = org?.locations?.[c];
2080
- return loc && {
2081
- email: loc.email,
2082
- phone: loc.phone,
2083
- address: loc.address
2084
- };
2085
- };
2086
- return [
2087
- deref(cfg.site.legal?.privacyPolicy?.privacyOfficer),
2088
- deref(org?.contact),
2089
- deref(org?.primaryLocation),
2090
- org && {
2091
- email: org.email,
2092
- phone: org.phone,
2093
- address: org.address
2094
- }
2095
- ].find((c) => c != null && (c.email != null || c.address != null));
2096
- }
2097
- /**
2098
- * Resolve a design-system theme by following its `extends` chain and deep-merging
2099
- * base → child (child wins; arrays replace wholesale, correct for token overrides).
2100
- * Throws on unknown keys or an `extends` cycle. The result is a plain
2101
- * `DesignSystem` (validate it against `DesignSystemSchema` for completeness).
2102
- */
2103
- function resolveTheme(designSystem, key) {
2104
- const chain = [];
2105
- const seen = /* @__PURE__ */ new Set();
2106
- let cur = key;
2107
- while (cur != null) {
2108
- if (seen.has(cur)) throw new Error(`extends cycle in designSystem at "${cur}"`);
2109
- seen.add(cur);
2110
- const entry = designSystem[cur];
2111
- if (entry == null) throw new Error(`designSystem theme "${cur}" not found`);
2112
- chain.unshift(entry);
2113
- cur = entry.extends;
2114
- }
2115
- const out = {};
2116
- for (const entry of chain) {
2117
- const { extends: _e, displayName: _d, ...ds } = entry;
2118
- deepMerge(out, ds);
2119
- }
2120
- return out;
2121
- }
2122
- /**
2123
- * Normalise the two `designSystem` shorthands to the canonical
2124
- * `{ themes: { … } }` shape.
2125
- *
2126
- * Both are keyed off what CANNOT be a theme name in the other reading:
2127
- * - `colors` → a bare `DesignSystem` was written inline.
2128
- * - `themes` → already canonical.
2129
- * - neither → the legacy bare theme map, from before the block gained
2130
- * system-wide fields. Every config authored against the old schema takes this
2131
- * branch, which is what keeps the change forgiving at runtime even though the
2132
- * published JSON Schema moved.
2133
- */
2134
- function normaliseDesignSystem(raw) {
2135
- const ds = raw.designSystem;
2136
- if (ds == null || typeof ds !== "object") return;
2137
- if ("themes" in ds) return;
2138
- raw.designSystem = "colors" in ds ? { themes: { default: ds } } : { themes: ds };
2139
- }
2140
- /**
2141
- * Resolve a raw (already-parsed) config into a validated `Config`:
2142
- * 1. strip YAML nulls,
2143
- * 2. normalise the `designSystem` shorthands to `{ themes: … }`,
2144
- * 3. apply the active A/B variant's `overrides` (by `siteEnv`),
2145
- * 4. validate (shape + cross-field).
2146
- * Pure — no YAML/env access. A loader supplies `siteEnv`.
2147
- *
2148
- * Steps 2 and 3 are in this order deliberately. Normalising first means an
2149
- * `abTesting` override always addresses the canonical shape
2150
- * (`designSystem.themes.<name>`), whatever shorthand the base config used. With
2151
- * the merge first, an override's key path depended on how the base happened to be
2152
- * written — so the same patch hit a different place in two configs that were
2153
- * otherwise equivalent.
2154
- *
2155
- * The A/B lookup reads `site.environments` / `site.abTesting`, and the overrides
2156
- * still deep-merge at the **root** — a variant patches whichever section it names,
2157
- * which is what lets one target `designSystem` and another `site.seo`.
2158
- */
2159
- function resolveConfig(input, siteEnv = "production") {
2160
- const raw = stripNulls(input);
2161
- normaliseDesignSystem(raw);
2162
- const site = raw.site;
2163
- const envConfig = (site?.environments)?.[siteEnv];
2164
- if (envConfig?.variant) {
2165
- const overrides = (site?.abTesting)?.variants?.[envConfig.variant]?.overrides;
2166
- if (overrides) {
2167
- normaliseDesignSystem(overrides);
2168
- deepMerge(raw, overrides);
2169
- }
2170
- }
2171
- const result = validateConfig(raw);
2172
- if (!result.ok) {
2173
- const msg = result.errors.map((e) => ` ${e.path.join(".") || "<root>"}: ${e.message}`).join("\n");
2174
- throw new Error(`Invalid config:\n${msg}`);
2175
- }
2176
- return result.data;
2177
- }
2178
- /**
2179
- * Is this raw config a full `Config` rather than a bare `design-system.json`?
2180
- *
2181
- * The discriminator is **total**, not a heuristic, and both halves are load-
2182
- * bearing: `ConfigSchema` cannot validate without a `designSystem` (it is a
2183
- * required key), and `DesignSystemSchema` is strict, so a `designSystem` key in a
2184
- * design system is an `unrecognized_keys` error. No document can be read as both,
2185
- * and no document that is neither reaches here without failing validation anyway.
2186
- *
2187
- * The restructure did not weaken this: `designSystem` stayed at the root
2188
- * precisely so the discriminator kept working unchanged.
2189
- *
2190
- * Deliberately shape-based rather than filename-based: consumers name this file
2191
- * whatever suits them (`company.json`, `site.json`, `vitops.config.json`), and a
2192
- * rule keyed to a name would silently stop working the moment someone renamed it.
2193
- */
2194
- function isConfig(raw) {
2195
- return typeof raw === "object" && raw != null && !Array.isArray(raw) && "designSystem" in raw;
2196
- }
2197
- /**
2198
- * Resolve a raw config of either kind into the design system to build from.
2199
- *
2200
- * Every entry point that used to take a `design-system.json` goes through this,
2201
- * so a consumer who keeps their tokens inside the larger config points the
2202
- * same option at that file instead of maintaining a second one. The config
2203
- * is handed back too, because the parts of generation that depend on site-level
2204
- * facts (`designSystem.defaultColorScheme`, the legal documents, the icon
2205
- * sprite) would otherwise need the same path declared a second time.
2206
- *
2207
- * The returned `designSystem` is **not** validated here — the caller does that,
2208
- * so it can say which file and which theme the errors belong to. Validation of
2209
- * the config itself has already happened (`resolveConfig` throws).
2210
- */
2211
- function resolveInput(raw, opts = {}) {
2212
- if (!isConfig(raw)) {
2213
- if (opts.theme != null) throw new Error(`theme "${opts.theme}" was requested, but this is a design-system.json — it holds one design system and no \`themes\` map. Point at a full config to select a theme.`);
2214
- return { designSystem: raw };
2215
- }
2216
- const config = resolveConfig(raw, opts.siteEnv);
2217
- const themes = config.designSystem?.themes ?? {};
2218
- const theme = opts.theme ?? config.designSystem?.defaultTheme ?? "default";
2219
- if (!(theme in themes)) throw new Error(`designSystem.themes has no "${theme}" entry (found: ${Object.keys(themes).join(", ") || "none"})`);
2220
- return {
2221
- designSystem: resolveTheme(themes, theme),
2222
- config,
2223
- theme
2224
- };
2225
- }
2226
- /** Strip nulls (YAML `~`/`null`) → undefined so optional fields validate. */
2227
- function stripNulls(obj) {
2228
- if (obj === null) return void 0;
2229
- if (Array.isArray(obj)) return obj.map(stripNulls);
2230
- if (obj && typeof obj === "object") {
2231
- const result = {};
2232
- for (const [key, value] of Object.entries(obj)) {
2233
- const stripped = stripNulls(value);
2234
- if (stripped !== void 0) result[key] = stripped;
2235
- }
2236
- return result;
2237
- }
2238
- return obj;
2239
- }
2240
- /** Deep-merge `source` into `target` (in place). Arrays replace wholesale. */
2241
- function deepMerge(target, source) {
2242
- for (const key of Object.keys(source)) {
2243
- const s = source[key];
2244
- const t = target[key];
2245
- if (s && typeof s === "object" && !Array.isArray(s) && t && typeof t === "object" && !Array.isArray(t)) deepMerge(t, s);
2246
- else target[key] = s;
2247
- }
2248
- }
2249
-
2250
- //#endregion
2251
- //#region src/tiers.ts
13
+ // src/tiers.ts
2252
14
  const c = (classes, partial, generated = false) => ({
2253
15
  ...partial ? { partial } : {},
2254
16
  classes,
@@ -2286,6 +48,7 @@ const TIERS = {
2286
48
  use: "`<Tree items={…} />` — it emits `<wc-tree>` itself, so do NOT add your own wrapper."
2287
49
  },
2288
50
  carousel: {
51
+ emdash: ["vitops.carousel"],
2289
52
  css: c([
2290
53
  "carousel",
2291
54
  "carousel__track",
@@ -2303,6 +66,7 @@ const TIERS = {
2303
66
  use: "`<Carousel slides={…} label=\"…\" />` — it emits `<wc-carousel>` itself, so do NOT add your own wrapper. `.carousel` is the shell and `.carousel__track` the scroller; add `loop` for the cloned infinite strip (`autoplay` implies it)."
2304
67
  },
2305
68
  gallery: {
69
+ emdash: ["vitops.gallery"],
2306
70
  css: c([
2307
71
  "gallery",
2308
72
  "gallery__item",
@@ -2332,6 +96,7 @@ const TIERS = {
2332
96
  use: "`<wc-entries>` around `<h3>` + `<dl>` pairs. The tier-2 exemplar: semantic pairs with no JS, a table with it."
2333
97
  },
2334
98
  copy: {
99
+ emdash: ["vitops.copyButton"],
2335
100
  css: c([
2336
101
  "copy",
2337
102
  "copy__button",
@@ -2371,6 +136,7 @@ const TIERS = {
2371
136
  use: "`<wc-split-panel>` with two children. Unenhanced they simply stack."
2372
137
  },
2373
138
  "image-compare": {
139
+ emdash: ["vitops.imageCompare"],
2374
140
  css: c([
2375
141
  "image-compare",
2376
142
  "image-compare__before",
@@ -2385,6 +151,7 @@ const TIERS = {
2385
151
  use: "`<wc-image-compare>` with two images. Both are visible without JS."
2386
152
  },
2387
153
  marquee: {
154
+ emdash: ["vitops.marquee"],
2388
155
  css: c([
2389
156
  "marquee",
2390
157
  "marquee__content",
@@ -2510,6 +277,7 @@ const TIERS = {
2510
277
  use: "Not shipped. See `color-wheel`."
2511
278
  },
2512
279
  details: {
280
+ emdash: ["vitops.details", "vitops.accordion"],
2513
281
  css: c([
2514
282
  "details",
2515
283
  "details-trigger",
@@ -2556,6 +324,7 @@ const TIERS = {
2556
324
  use: "`<Subgrid>` — emits `<ul class=\"subgrid\" role=\"list\">`; author `<li class=\"card subgrid-card\">` items yourself, so tranches align across the set."
2557
325
  },
2558
326
  card: {
327
+ emdash: ["vitops.cards"],
2559
328
  css: c(["card"], "patterns/card.css", true),
2560
329
  wc: {
2561
330
  tag: "wc-cards",
@@ -2576,6 +345,7 @@ const TIERS = {
2576
345
  use: "`<Icon name=\"menu\" />`. Names are semantic and resolve per configured set; a name containing `:` passes through."
2577
346
  },
2578
347
  split: {
348
+ emdash: ["vitops.hero"],
2579
349
  css: c([
2580
350
  "split",
2581
351
  "split-1-2",
@@ -2613,14 +383,17 @@ const TIERS = {
2613
383
  use: "`.split-link` — a link plus a disclosure toggle. No JS."
2614
384
  },
2615
385
  btn: {
386
+ emdash: ["vitops.actionLink"],
2616
387
  css: c(["btn"], void 0, true),
2617
388
  use: "`<button>` gets it with no class; `.btn` carries it to other tags. `fill: false`, so states drive `color`."
2618
389
  },
2619
390
  cta: {
391
+ emdash: ["vitops.actionLink"],
2620
392
  css: c(["cta", "cta-danger"], void 0, true),
2621
393
  use: "`.cta` on any element — usually `<a>`, since a CTA navigates. Filled, with role variants."
2622
394
  },
2623
395
  link: {
396
+ emdash: ["vitops.actionLink"],
2624
397
  css: c([
2625
398
  "link",
2626
399
  "skip-link",
@@ -2719,6 +492,7 @@ const TIERS = {
2719
492
  use: "`.navbar` with `--start/--center/--end`."
2720
493
  },
2721
494
  banner: {
495
+ emdash: ["vitops.banner"],
2722
496
  css: c([
2723
497
  "banner",
2724
498
  "banner__content",
@@ -2886,6 +660,7 @@ function tierPatterns(tier) {
2886
660
  case "wc": return !!e.wc;
2887
661
  case "astro": return !!e.astro?.length;
2888
662
  case "bricks": return !!e.bricks;
663
+ case "emdash": return !!e.emdash?.length;
2889
664
  }
2890
665
  };
2891
666
  return TIER_NAMES.filter((name) => has(TIERS[name])).map((name) => ({
@@ -2894,8 +669,7 @@ function tierPatterns(tier) {
2894
669
  }));
2895
670
  }
2896
671
 
2897
- //#endregion
2898
- //#region src/docs.ts
672
+ // src/docs.ts
2899
673
  /**
2900
674
  * OKF documentation bundle generator (ported from the repo's `generate-docs.ts`).
2901
675
  *
@@ -3351,7 +1125,8 @@ function renderElementsDoc(elementsDir) {
3351
1125
  if (ib >= 0) return 1;
3352
1126
  return a.name.localeCompare(b.name);
3353
1127
  });
3354
- return `${ELEMENTS_PREAMBLE}\n${elements.map(renderElement).join("\n---\n\n")}`;
1128
+ const body = elements.map(renderElement).join("\n---\n\n");
1129
+ return `${ELEMENTS_PREAMBLE}\n${body}`;
3355
1130
  }
3356
1131
  const code = (xs) => xs.map((x) => `\`${x}\``).join(", ");
3357
1132
  function renderCssClasses(ds) {
@@ -3831,10 +1606,11 @@ const CODE_SLOT = "\0";
3831
1606
  */
3832
1607
  const renderInlineMarkdown = (s) => {
3833
1608
  const spans = [];
3834
- return escapeHtml(s.replace(/`([^`]+)`/g, (_, body) => {
1609
+ const lifted = s.replace(/`([^`]+)`/g, (_, body) => {
3835
1610
  spans.push(body);
3836
1611
  return `${CODE_SLOT}${spans.length - 1}${CODE_SLOT}`;
3837
- })).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*\n]+)\*/g, "<em>$1</em>").replace(new RegExp(`${CODE_SLOT}(\\d+)${CODE_SLOT}`, "g"), (_, i) => `<code>${escapeHtml(spans[Number(i)] ?? "")}</code>`);
1612
+ });
1613
+ return escapeHtml(lifted).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*\n]+)\*/g, "<em>$1</em>").replace(new RegExp(`${CODE_SLOT}(\\d+)${CODE_SLOT}`, "g"), (_, i) => `<code>${escapeHtml(spans[Number(i)] ?? "")}</code>`);
3838
1614
  };
3839
1615
  /**
3840
1616
  * Walk a JSON Schema into tree DATA.
@@ -3891,7 +1667,7 @@ function schemaTreeNodes(schema, opts = {}) {
3891
1667
  };
3892
1668
  };
3893
1669
  const required = schema.required ?? [];
3894
- return Object.entries(schema.properties ?? {}).filter(([key]) => key !== "$schema").map(([key, child]) => build(key, child, required.includes(key), join(idPrefix, key), 0));
1670
+ return Object.entries(schema.properties ?? {}).filter(([key]) => key !== "$schema" && key !== "configVersion").map(([key, child]) => build(key, child, required.includes(key), join(idPrefix, key), 0));
3895
1671
  }
3896
1672
  function renderAuthoring() {
3897
1673
  const sections = schemaSections(jsonSchema);
@@ -4503,7 +2279,8 @@ function renderComponentsConcept(ds) {
4503
2279
  "css",
4504
2280
  "wc",
4505
2281
  "astro",
4506
- "bricks"
2282
+ "bricks",
2283
+ "emdash"
4507
2284
  ].filter((t) => tierPatterns(t).some((p) => p.name === name));
4508
2285
  const overview = TIER_NAMES.map((name) => {
4509
2286
  const e = TIERS[name];
@@ -4516,6 +2293,7 @@ function renderComponentsConcept(ds) {
4516
2293
  }).join("\n");
4517
2294
  const astro = tierPatterns("astro").flatMap(({ name, entry }) => entry.astro.map((a) => `| ${code(a.component)} | ${code(name)} | ${a.wraps === "wc" ? `the ${code(`<${entry.wc.tag}>`)} tag, fallback inside` : "tier-1 markup — no web component"} |`)).join("\n");
4518
2295
  const bricks = tierPatterns("bricks").map(({ name, entry }) => `| ${code(entry.bricks)} | ${code(name)} |`).join("\n");
2296
+ const emdash = tierPatterns("emdash").flatMap(({ name, entry }) => entry.emdash.map((type) => `| ${code(type)} | ${code(name)} | ${entry.astro?.length ? list(entry.astro.map((a) => `<${a.component.split("/").pop().replace(".astro", "")} />`)) : "tier-1 markup"} |`)).join("\n");
4519
2297
  const css = tierPatterns("css").map(({ name, entry }) => {
4520
2298
  const c = entry.css;
4521
2299
  const config = !c.generated ? "—" : declared.has(name) ? "declared" : "**absent from this config — no CSS emitted**";
@@ -4526,13 +2304,14 @@ function renderComponentsConcept(ds) {
4526
2304
  return `${frontmatter({
4527
2305
  type: "Design Concept",
4528
2306
  title: "Vitops components — which tier provides a pattern, and what to write",
4529
- description: "Every UI pattern across the three tiers (CSS classes, wc-* web components, and the platform wrappers: Astro components and Bricks elements): which tiers provide it, which call to make, and how the tiers compose.",
2307
+ description: "Every UI pattern across the three tiers (CSS classes, wc-* web components, and the platform wrappers: Astro components, Bricks elements and EmDash blocks): which tiers provide it, which call to make, and how the tiers compose.",
4530
2308
  resource: DS_PATH,
4531
2309
  tags: [
4532
2310
  "components",
4533
2311
  "web-components",
4534
2312
  "astro",
4535
2313
  "bricks",
2314
+ "emdash",
4536
2315
  "tiers",
4537
2316
  "design-system"
4538
2317
  ]
@@ -4549,13 +2328,17 @@ A pattern is provided by up to **three tiers**, and they **compose** rather than
4549
2328
  3. **Platform wrappers** — authoring conveniences that generate the correct markup at build
4550
2329
  time, using the classes and elements of tiers 1 and 2. They must not require runtime JS.
4551
2330
  Where a pattern has a web component, the wrapper emits that tag **with the accessible
4552
- fallback inside it**. Two platforms:
2331
+ fallback inside it**. Three platforms:
4553
2332
  - **Astro components** (${code("@getvitops/astro")})
4554
2333
  - **Bricks elements** (WordPress / Bricks Builder)
2334
+ - **EmDash blocks** (${code("@getvitops/emdash")}) — Portable Text blocks an editor inserts;
2335
+ each is an adapter over the pattern's Astro component
4555
2336
 
4556
- **Astro and Bricks are the same tier**, not tiers 3 and 4. They are siblings chosen by which
4557
- platform you are on — no project uses both, and neither outranks the other. The tables below
4558
- give them separate columns because they are separate packages, not separate levels.
2337
+ **Astro, Bricks and EmDash are the same tier**, not tiers 3, 4 and 5. They are siblings chosen
2338
+ by which platform you are on — neither outranks the others. (An EmDash site is an Astro site,
2339
+ so it uses the Astro components directly in templates and the blocks inside editor content.)
2340
+ The tables below give them separate columns because they are separate packages, not separate
2341
+ levels.
4559
2342
 
4560
2343
  ## Choosing
4561
2344
 
@@ -4602,6 +2385,16 @@ Controls, defaults and seeded children for each are in [the elements reference](
4602
2385
  | --- | --- |
4603
2386
  ${bricks}
4604
2387
 
2388
+ ## EmDash blocks
2389
+
2390
+ Portable Text block ${code("_type")}s from ${code("@getvitops/emdash")}. Repeating patterns are
2391
+ repeater fields. A block cannot contain other blocks — a section holding a card and a button is a
2392
+ ${code("blocks")} field in the EmDash schema, not a Portable Text block.
2393
+
2394
+ | Block | Pattern | Renders via |
2395
+ | --- | --- | --- |
2396
+ ${emdash}
2397
+
4605
2398
  ## CSS
4606
2399
 
4607
2400
  ${code("patterns.items")} patterns get the full token cascade — ${code("base")} declarations,
@@ -5349,8 +3142,7 @@ function generateDocs(ds, assetsDir) {
5349
3142
  };
5350
3143
  }
5351
3144
 
5352
- //#endregion
5353
- //#region src/design-md.ts
3145
+ // src/design-md.ts
5354
3146
  const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
5355
3147
  /** Keys are quoted only when they would not survive as a plain scalar. */
5356
3148
  const key = (k) => /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : q(k);
@@ -5391,7 +3183,7 @@ function emitDesignMd(ctx) {
5391
3183
  };
5392
3184
  const roleTokens = [];
5393
3185
  for (const fr of scaleRoles) for (const [token, value] of Object.entries(fr.light)) roleTokens.push([tokenClass(fr.role, token), rampRef(value)]);
5394
- const colorNames = new Set([...rampNames, ...roleTokens.map(([k]) => k)]);
3186
+ const colorNames = /* @__PURE__ */ new Set([...rampNames, ...roleTokens.map(([k]) => k)]);
5395
3187
  /** Recommended-name alias, emitted only when the role behind it exists. */
5396
3188
  const aliasAs = (as, token) => colorNames.has(token) ? [[as, `{colors.${token}}`]] : [];
5397
3189
  const interop = [
@@ -5777,8 +3569,7 @@ across every pattern. Focus rings are never removed, only restyled.
5777
3569
  `;
5778
3570
  }
5779
3571
 
5780
- //#endregion
5781
- //#region src/legal/providers.ts
3572
+ // src/legal/providers.ts
5782
3573
  /**
5783
3574
  * What the config already tells us about third parties that receive personal
5784
3575
  * information.
@@ -5794,10 +3585,14 @@ across every pattern. Focus rings are never removed, only restyled.
5794
3585
  * (provider identity is implied by which ID is set, not by an enum).
5795
3586
  *
5796
3587
  * This table is deliberately small and covers only what the schema can imply.
5797
- * Everything else — payment processors, CRMs, mail senders — is declared by the
5798
- * consumer in `legal.privacyPolicy.processors`, which flows through the same
5799
- * pipeline. This is not a directory of the web; it's the set of providers the
5800
- * toolchain itself can vouch for.
3588
+ * Everything else — payment processors, mail senders, and any CRM outside the
3589
+ * three `site.crm` knows about — is declared by the consumer in
3590
+ * `legal.privacyPolicy.processors`, which flows through the same pipeline.
3591
+ * This is not a directory of the web; it's the set of providers the toolchain
3592
+ * itself can vouch for. HubSpot, Zoho Bigin and Jobber moved into that set
3593
+ * once `site.crm` existed to make the toolchain the thing deciding whether a
3594
+ * submission reaches any of them — the same reasoning `site.ads` already
3595
+ * established for ad platforms.
5801
3596
  */
5802
3597
  /**
5803
3598
  * The providers the config can imply. Keyed for testability — a test can assert
@@ -5877,6 +3672,27 @@ const KNOWN_PROCESSORS = {
5877
3672
  operatorCountry: "the United States",
5878
3673
  privacyUrl: "https://www.netlify.com/privacy/",
5879
3674
  cookies: []
3675
+ },
3676
+ hubspot: {
3677
+ name: "HubSpot",
3678
+ purpose: "CRM and marketing automation",
3679
+ operatorCountry: "the United States",
3680
+ privacyUrl: "https://legal.hubspot.com/privacy-policy",
3681
+ cookies: ["hubspotutk"]
3682
+ },
3683
+ zohoBigin: {
3684
+ name: "Zoho Bigin",
3685
+ purpose: "CRM",
3686
+ privacyUrl: "https://www.zoho.com/privacy.html",
3687
+ cookies: []
3688
+ },
3689
+ jobber: {
3690
+ name: "Jobber",
3691
+ purpose: "CRM and field-service management",
3692
+ operatorCountry: "Canada",
3693
+ storage: [{ country: "the United States" }, { country: "Canada" }],
3694
+ privacyUrl: "https://www.getjobber.com/privacy-policy/",
3695
+ cookies: []
5880
3696
  }
5881
3697
  };
5882
3698
  /** Substring → hosting provider. `deployment.platform` is a free-form string. */
@@ -5902,6 +3718,9 @@ function detectProcessorKeys(cfg) {
5902
3718
  if (analytics?.clarityId) keys.push("clarity");
5903
3719
  if (analytics?.matomo) keys.push(analytics.matomo.cookies ? "matomo" : "matomoCookieless");
5904
3720
  if (site.security?.turnstile?.siteKey) keys.push("turnstile");
3721
+ if (site.crm?.provider === "hubspot") keys.push("hubspot");
3722
+ if (site.crm?.provider === "bigin") keys.push("zohoBigin");
3723
+ if (site.crm?.provider === "jobber") keys.push("jobber");
5905
3724
  const platform = site.deployment?.platform;
5906
3725
  const hosting = platform && HOSTING_PATTERNS.find(([re]) => re.test(platform))?.[1];
5907
3726
  if (hosting && !keys.includes(hosting)) keys.push(hosting);
@@ -5914,8 +3733,16 @@ function detectProcessorKeys(cfg) {
5914
3733
  * Consumer entries come last and are never deduplicated against the built-ins —
5915
3734
  * an explicit declaration is a statement of fact we have no grounds to drop.
5916
3735
  */
3736
+ /**
3737
+ * Refiners that turn a config field into a processor's actual location,
3738
+ * chained by `reduce` rather than hand-nested — adding one is an array entry,
3739
+ * not another nesting level. Each refiner checks the processor's own name and
3740
+ * passes every other processor through unchanged, so order between them
3741
+ * never matters.
3742
+ */
3743
+ const LOCATION_REFINERS = [matomoLocation, biginLocation];
5917
3744
  function resolveProcessors(cfg) {
5918
- const detected = detectProcessorKeys(cfg).map((k) => matomoLocation(KNOWN_PROCESSORS[k], cfg.site));
3745
+ const detected = detectProcessorKeys(cfg).map((k) => LOCATION_REFINERS.reduce((p, refine) => refine(p, cfg.site), KNOWN_PROCESSORS[k]));
5919
3746
  const declared = cfg.site.legal?.privacyPolicy?.processors ?? [];
5920
3747
  return [
5921
3748
  ...detected,
@@ -6012,9 +3839,34 @@ function matomoLocation(processor, site) {
6012
3839
  storage: [{ country: "the European Union" }]
6013
3840
  } : processor;
6014
3841
  }
3842
+ /**
3843
+ * Where the self-client's Bigin data actually rests — Zoho's own published
3844
+ * data-centre-to-country mapping, derived exactly as `matomoLocation` derives
3845
+ * Matomo Cloud's: from a config field, not a guess. `derive.ts`'s `foreign()`
3846
+ * then correctly omits a cross-border-transfer sentence when `dataCenter` is
3847
+ * `ca`, which is a real win for an Ontario client running Bigin's Canadian
3848
+ * data centre. This sets `storage`, never `operatorCountry` — see the note on
3849
+ * `zohoBigin` above for why the operator's own jurisdiction stays unset.
3850
+ */
3851
+ const BIGIN_DATA_CENTER_COUNTRY = {
3852
+ com: "the United States",
3853
+ ca: "Canada",
3854
+ eu: "the European Union",
3855
+ in: "India",
3856
+ "com.au": "Australia",
3857
+ jp: "Japan"
3858
+ };
3859
+ function biginLocation(processor, site) {
3860
+ if (processor.name !== "Zoho Bigin") return processor;
3861
+ const dataCenter = site.crm?.bigin?.dataCenter;
3862
+ const country = dataCenter && BIGIN_DATA_CENTER_COUNTRY[dataCenter];
3863
+ return country ? {
3864
+ ...processor,
3865
+ storage: [{ country }]
3866
+ } : processor;
3867
+ }
6015
3868
 
6016
- //#endregion
6017
- //#region src/legal/derive.ts
3869
+ // src/legal/derive.ts
6018
3870
  /**
6019
3871
  * Site config → the facts a legal document asserts.
6020
3872
  *
@@ -6078,7 +3930,6 @@ function fieldPhrase(name, type) {
6078
3930
  case "select":
6079
3931
  case "radio":
6080
3932
  case "checkbox": return "your answers to the questions we ask";
6081
- default: break;
6082
3933
  }
6083
3934
  if (/name/i.test(name)) return "your name";
6084
3935
  if (/compan|organi|business/i.test(name)) return "your company or organization name";
@@ -6280,8 +4131,7 @@ function derivePolicyVars(cfg) {
6280
4131
  };
6281
4132
  }
6282
4133
 
6283
- //#endregion
6284
- //#region src/legal/render.ts
4134
+ // src/legal/render.ts
6285
4135
  /**
6286
4136
  * The legal documents are authored in markdown; consumers need markdown, HTML
6287
4137
  * and EmDash Portable Text. This parses the markdown **once** into a small block
@@ -6491,8 +4341,7 @@ function toPortableText(blocks, prefix) {
6491
4341
  return out;
6492
4342
  }
6493
4343
 
6494
- //#endregion
6495
- //#region src/legal/templates/shared.ts
4344
+ // src/legal/templates/shared.ts
6496
4345
  /**
6497
4346
  * The banner every generated document opens with.
6498
4347
  *
@@ -6515,8 +4364,7 @@ function updatedLine(v) {
6515
4364
  return v.lastUpdated ? `\nLast updated: ${v.lastUpdated}\n` : "";
6516
4365
  }
6517
4366
 
6518
- //#endregion
6519
- //#region src/legal/templates/cookies.ca.ts
4367
+ // src/legal/templates/cookies.ca.ts
6520
4368
  /**
6521
4369
  * Cookie notice — Canada.
6522
4370
  *
@@ -6599,8 +4447,7 @@ ${bullets(v.cookieCategories)}
6599
4447
  `;
6600
4448
  }
6601
4449
 
6602
- //#endregion
6603
- //#region src/legal/templates/privacy.ca.ts
4450
+ // src/legal/templates/privacy.ca.ts
6604
4451
  /**
6605
4452
  * Privacy policy — Canada (PIPEDA).
6606
4453
  *
@@ -6760,8 +4607,7 @@ function operatorClause(v) {
6760
4607
  return `Some of the service providers we use are established in, or controlled from, jurisdictions outside of Canada, including ${v.operatorCountries}. Personal information those providers handle on our behalf may be subject to the laws of those jurisdictions even when it is stored in Canada.`;
6761
4608
  }
6762
4609
 
6763
- //#endregion
6764
- //#region src/legal/templates/terms.ca.ts
4610
+ // src/legal/templates/terms.ca.ts
6765
4611
  function termsCa(v) {
6766
4612
  return `# Terms of Service
6767
4613
  ${updatedLine(v)}
@@ -6830,8 +4676,7 @@ function governingLaw(v) {
6830
4676
  return v.governingProvince ? `${v.governingProvince} and the federal laws of ${v.governingCountry} applicable therein` : v.governingCountry;
6831
4677
  }
6832
4678
 
6833
- //#endregion
6834
- //#region src/legal/templates/index.ts
4679
+ // src/legal/templates/index.ts
6835
4680
  const TEMPLATES = { ca: {
6836
4681
  privacy: privacyCa,
6837
4682
  terms: termsCa,
@@ -6850,8 +4695,7 @@ const DOC_SLUGS = {
6850
4695
  cookies: "cookie-notice"
6851
4696
  };
6852
4697
 
6853
- //#endregion
6854
- //#region src/legal/index.ts
4698
+ // src/legal/index.ts
6855
4699
  /**
6856
4700
  * Generate a site's legal documents from its config.
6857
4701
  *
@@ -6915,120 +4759,7 @@ function generateLegal(cfg, opts = {}) {
6915
4759
  return out;
6916
4760
  }
6917
4761
 
6918
- //#endregion
6919
- //#region src/icons-sprite.ts
6920
- /**
6921
- * Build an SVG sprite from an icon `include` map.
6922
- *
6923
- * This is the delivery path for consumers that cannot run an icon integration:
6924
- * Bricks/WordPress, EmDash's renderers, anything rendering HTML outside an Astro
6925
- * build. Markup is `<svg><use href="…/icons.svg#ph--caret-down"/></svg>` — no
6926
- * runtime JS and no network call, which is what keeps it inside the tier rules
6927
- * (a runtime-fetching element would be behaviour JS with no fallback to enhance).
6928
- *
6929
- * The icon DATA comes from the `@iconify-json/*` collections, which stay optional
6930
- * peers loaded dynamically: `@iconify-json/ph` alone is ~4.5 MB and the semantic
6931
- * map spans five sets, so depending on them directly would be indefensible for
6932
- * every consumer who never asks for a sprite.
6933
- */
6934
- /**
6935
- * `ph:caret-down` → `ph--caret-down`.
6936
- *
6937
- * The one place this grammar lives. `Icon.astro` mirrors it and `icon.test.ts`
6938
- * guards the pair, because a drift here resolves `<use>` to nothing and renders
6939
- * as an empty box rather than an error.
6940
- */
6941
- function spriteId(qualified) {
6942
- return qualified.replace(":", "--");
6943
- }
6944
- /**
6945
- * Default loader: the collection JSON shipped by `@iconify-json/<prefix>`.
6946
- *
6947
- * Resolved through a `createRequire` rooted at the consumer's directory, then
6948
- * imported by absolute path — a bare specifier would resolve against this
6949
- * package instead, where the collections deliberately aren't installed.
6950
- */
6951
- async function loadCollection(prefix, from) {
6952
- try {
6953
- const mod = await import(pathToFileURL(createRequire(pathToFileURL(join(from, "noop.js"))).resolve(`@iconify-json/${prefix}/icons.json`)).href, { with: { type: "json" } });
6954
- return mod.default ?? mod;
6955
- } catch {
6956
- return null;
6957
- }
6958
- }
6959
- async function buildIconSprite(o) {
6960
- const from = o.resolveFrom ?? process.cwd();
6961
- const load = o.loadSet ?? ((prefix) => loadCollection(prefix, from));
6962
- const symbols = [];
6963
- const ids = [];
6964
- const missing = [];
6965
- /** qualified name → its rendered body+viewBox, so an alias can reuse it. */
6966
- const rendered = /* @__PURE__ */ new Map();
6967
- for (const [prefix, names] of Object.entries(o.include)) {
6968
- if (!names?.length) continue;
6969
- const set = await load(prefix);
6970
- if (!set) throw new Error(`[vitops icons] Cannot build the sprite: the "${prefix}" icon set is not installed. Run \`pnpm add -D @iconify-json/${prefix}\`, or drop it from your icons config.`);
6971
- for (const name of names) {
6972
- const data = getIconData(set, name);
6973
- if (!data) {
6974
- missing.push(`${prefix}:${name}`);
6975
- continue;
6976
- }
6977
- const built = iconToSVG(data, { height: "none" });
6978
- const body = replaceIDs(built.body);
6979
- const id = spriteId(`${prefix}:${name}`);
6980
- rendered.set(`${prefix}:${name}`, {
6981
- viewBox: built.attributes.viewBox,
6982
- body
6983
- });
6984
- ids.push(id);
6985
- symbols.push(`<symbol id="${id}" viewBox="${built.attributes.viewBox}">${body}</symbol>`);
6986
- }
6987
- }
6988
- for (const [alias, target] of Object.entries(o.aliases ?? {})) {
6989
- const hit = rendered.get(target);
6990
- if (!hit) {
6991
- missing.push(`${target} (aliased as ${alias})`);
6992
- continue;
6993
- }
6994
- ids.push(alias);
6995
- symbols.push(`<symbol id="${alias}" viewBox="${hit.viewBox}">${hit.body}</symbol>`);
6996
- }
6997
- return {
6998
- svg: `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">` + symbols.join("") + `</svg>\n`,
6999
- ids,
7000
- missing
7001
- };
7002
- }
7003
- /**
7004
- * One icon's renderable parts, for inlining into markup.
7005
- *
7006
- * Exists because `<Icon />` cannot go through astro-icon: its `./components`
7007
- * entry is a TypeScript file that Vite must transform, so it can only be
7008
- * reached by a statically-analysable import — and a static import is exactly
7009
- * what made the optional peer mandatory for anyone rendering Popover, Details
7010
- * or Drawer. Reading the collection directly sidesteps the peer entirely and
7011
- * shares this file's loader with the sprite, so both paths draw the same glyph.
7012
- */
7013
- async function loadIconSvg(qualified, opts) {
7014
- const i = qualified.indexOf(":");
7015
- if (i <= 0) return null;
7016
- const prefix = qualified.slice(0, i);
7017
- const name = qualified.slice(i + 1);
7018
- const from = opts?.resolveFrom ?? process.cwd();
7019
- const set = await (opts?.loadSet ?? ((p) => loadCollection(p, from)))(prefix);
7020
- if (!set) return null;
7021
- const data = getIconData(set, name);
7022
- if (!data) return null;
7023
- const built = iconToSVG(data, { height: "none" });
7024
- return {
7025
- viewBox: built.attributes.viewBox,
7026
- body: replaceIDs(built.body)
7027
- };
7028
- }
7029
-
7030
- //#endregion
7031
- //#region src/generate.ts
4762
+ // src/generate.ts
7032
4763
  /**
7033
4764
  * Pure, parameterized port of the Vitops design-system generator.
7034
4765
  *
@@ -7042,7 +4773,8 @@ async function loadIconSvg(qualified, opts) {
7042
4773
  * and the pre-built JS bundles) live under `assets/` in this package and are read /
7043
4774
  * copied from there — they are the same for every consumer.
7044
4775
  */
7045
- const DEFAULT_ASSETS = join(dirname(fileURLToPath(import.meta.url)), "..", "assets");
4776
+ const HERE = dirname(fileURLToPath(import.meta.url));
4777
+ const DEFAULT_ASSETS = join(HERE, "..", "assets");
7046
4778
  const UTILITY_PROPS = {
7047
4779
  bg: "background-color",
7048
4780
  text: "color",
@@ -7180,18 +4912,6 @@ const buildScale = (s) => {
7180
4912
  };
7181
4913
  });
7182
4914
  };
7183
- const TYPO_KEYMAP = {
7184
- family: ["font-family", "ff"],
7185
- size: ["font-size", "fs"],
7186
- weight: ["font-weight", "fw"],
7187
- style: ["font-style", "fst"],
7188
- "line-height": ["line-height", "lh"],
7189
- tracking: ["letter-spacing", "ls"],
7190
- "text-decoration": ["text-decoration", "td"],
7191
- "text-transform": ["text-transform", "tt"],
7192
- "text-wrap": ["text-wrap", "tw"],
7193
- color: ["color", "color"]
7194
- };
7195
4915
  /**
7196
4916
  * Wrong spellings worth naming explicitly, because each is a plausible reading of
7197
4917
  * the key it isn't. `transform`/`decoration` were documented in the schema for a
@@ -7292,7 +5012,7 @@ function build(ds, format, assetsDir, buildOpts = {}) {
7292
5012
  * `TW_CLASH` names.
7293
5013
  */
7294
5014
  const NON_PALETTE_BG = "/* Undo a pattern's fill (Tailwind provides these natively in that format). */\n.bg-transparent { background-color: transparent; }\n.bg-inherit { background-color: inherit; }\n";
7295
- if (BRICKS) generated["color.css"] = "/* Colours provided by Bricks (palette import generates tokens + utilities). */\n/* Undo a pattern's fill (Tailwind provides these natively in that format). */\n.bg-transparent { background-color: transparent; }\n.bg-inherit { background-color: inherit; }\n";
5015
+ if (BRICKS) generated["color.css"] = "/* Colours provided by Bricks (palette import generates tokens + utilities). */\n" + NON_PALETTE_BG;
7296
5016
  else {
7297
5017
  let css = `/* GENERATED from design-system.json — do not edit by hand. */\n:root {\n`;
7298
5018
  css += ` color-scheme: light;\n`;
@@ -8076,7 +5796,8 @@ body {
8076
5796
  return kept.join("\n\n");
8077
5797
  };
8078
5798
  const componentCss = componentPartialOrder(cssDir).map((rel) => {
8079
- return `/* ── ${rel} ── */\n${stripForTailwind(readFileSync(join(cssDir, rel), "utf8"))}`;
5799
+ const css = readFileSync(join(cssDir, rel), "utf8");
5800
+ return `/* ── ${rel} ── */\n${stripForTailwind(css)}`;
8080
5801
  }).join("\n\n");
8081
5802
  if (componentCss) parts.push(`/* ── Component + structural partials (Tailwind-clashing utilities dropped) ── */\n/* Dropped ${droppedVariantBlocks} pre-expanded breakpoint block(s) — write @sm:/@md:/@lg:/@xl: instead.\n Dropped ${droppedClashes.size} clashing utilit(ies) Tailwind provides itself:\n ${[...droppedClashes].sort().join(" ") || "(none)"} */\n@layer components {\n${componentCss}\n}`);
8082
5803
  return nl(parts) + "\n";
@@ -8114,8 +5835,8 @@ body {
8114
5835
  * prelude is `table,\n.table`, which doesn't start with `.`, so the regex missed
8115
5836
  * it by luck. Listing it makes that explicit rather than accidental.
8116
5837
  */
8117
- const CLASH_KEEP = new Set(["sticky", "table"]);
8118
- const TAILWIND_SKIP = new Set([
5838
+ const CLASH_KEEP = /* @__PURE__ */ new Set(["sticky", "table"]);
5839
+ const TAILWIND_SKIP = /* @__PURE__ */ new Set([
8119
5840
  "global.css",
8120
5841
  "animation.css",
8121
5842
  "layout.css",
@@ -8221,7 +5942,8 @@ const layerForPartial = (rel) => CHUNK_LAYER[rel] ?? "vitops.components";
8221
5942
  */
8222
5943
  function bundleCss(generated, assetsDir, minify) {
8223
5944
  const cssDir = join(assetsDir, "css");
8224
- const index = readFileSync(join(cssDir, "index.css"), "utf8");
5945
+ const indexPath = join(cssDir, "index.css");
5946
+ const index = readFileSync(indexPath, "utf8");
8225
5947
  const chunks = [];
8226
5948
  for (const line of index.split("\n")) {
8227
5949
  const m = /@import\s+['"]\.\/([^'"]+)['"]/.exec(line);
@@ -8255,7 +5977,8 @@ function bundleCss(generated, assetsDir, minify) {
8255
5977
  * is wrong *here*" when the file is a few hundred lines of company facts.
8256
5978
  */
8257
5979
  function loadInput(input, opts) {
8258
- const resolved = resolveInput(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, opts);
5980
+ const raw = typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input;
5981
+ const resolved = resolveInput(raw, opts);
8259
5982
  const result = validate(resolved.designSystem);
8260
5983
  if (!result.ok) {
8261
5984
  const where = resolved.theme != null ? `design system at designSystem.themes.${resolved.theme}` : "design-system.json";
@@ -8348,11 +6071,11 @@ async function generate(options) {
8348
6071
  };
8349
6072
  }
8350
6073
  function loadConfigFile(input, siteEnv) {
8351
- return resolveConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, siteEnv);
6074
+ const raw = typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input;
6075
+ return resolveConfig(raw, siteEnv);
8352
6076
  }
8353
6077
 
8354
- //#endregion
8355
- //#region src/robots.ts
6078
+ // src/robots.ts
8356
6079
  /**
8357
6080
  * `robots.txt`, generated from the same fact the indexing gate and the
8358
6081
  * `<meta name="robots">` tag already read.
@@ -8413,8 +6136,7 @@ function renderRobotsTxt(cfg, options = {}) {
8413
6136
  return lines.join("\n") + "\n";
8414
6137
  }
8415
6138
 
8416
- //#endregion
8417
- //#region src/index.ts
6139
+ // src/index.ts
8418
6140
  /**
8419
6141
  * A minimal but complete starter design system, used by `vitops init`. It
8420
6142
  * validates against the schema and generates real output in every format, so a
@@ -8702,6 +6424,7 @@ function defaultSiteConfig(opts) {
8702
6424
  const url = opts?.url ?? "https://example.com";
8703
6425
  return {
8704
6426
  $schema: CONFIG_SCHEMA_LOCAL_PATH,
6427
+ configVersion: 1,
8705
6428
  designSystem: {
8706
6429
  themes: { default: defaultConfig() },
8707
6430
  defaultColorScheme: "light"
@@ -8721,6 +6444,5 @@ function defaultSiteConfig(opts) {
8721
6444
  };
8722
6445
  }
8723
6446
 
8724
- //#endregion
8725
- export { AD_PROVIDERS, BASE_HOOK, CONFIG_SCHEMA_LOCAL_PATH, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, JURISDICTION_COUNTRIES, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_LOCAL_PATH, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, defaultSiteConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, movedTokens, parseMarkdown, processorsMissingLocation, renderInlineMarkdown, renderMarkdown, renderNodes, renderRobotsTxt, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
6447
+ export { AD_PROVIDERS, BASE_HOOK, BIGIN_DATA_CENTERS, CONFIG_SCHEMA_LOCAL_PATH, CONFIG_SCHEMA_URL, CONFIG_VERSION, CONTRAST, CRM_PROVIDERS, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, JURISDICTION_COUNTRIES, KNOWN_PROCESSORS, NUMERIC_STEPS, PLAN_CAPABILITIES, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_LOCAL_PATH, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, TYPO_KEYMAP, buildIconSprite, buildReverseIndex, checkContrast, configJsonSchema, deepMerge, defaultConfig, defaultSiteConfig, derivePolicyVars, designSystemPath, detectProcessorKeys, enabledDocs, expandPalette, experimentDimensionWarnings, formatConfig, functionalRole, generate, generateDocs, generateLegal, getAt, isConfig, jsonSchema, loadIconSvg, mergePatch, movedTokens, parseMarkdown, patchToOverrideCss, processorsMissingLocation, renderInlineMarkdown, renderMarkdown, renderNodes, renderRobotsTxt, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, setAt, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateColorContract, validateConfig };
8726
6448
  //# sourceMappingURL=index.mjs.map