@vectoriox/iox-ui 5.25.0 → 5.27.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 (32) hide show
  1. package/design-core/index.d.ts +1153 -191
  2. package/fesm2022/vectoriox-iox-ui-design-core.mjs +1680 -241
  3. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  4. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +7018 -0
  5. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -0
  6. package/generation-core/index.d.ts +1397 -0
  7. package/generation-core/package.json +3 -0
  8. package/package.json +5 -1
  9. package/src/design-core/brand-neutrals.ts +236 -0
  10. package/src/design-core/brand.ts +24 -14
  11. package/src/design-core/colour-eval.ts +117 -0
  12. package/src/design-core/contrast-audit.ts +214 -0
  13. package/src/design-core/design-apply.ts +34 -14
  14. package/src/design-core/design-settings.ts +12 -3
  15. package/src/design-core/design-token.model.ts +26 -0
  16. package/src/design-core/flavor-fixtures.generated.ts +150 -0
  17. package/src/design-core/flavor-spec.generated.ts +1061 -189
  18. package/src/design-core/flavor-tokens.ts +36 -8
  19. package/src/design-core/public-api.ts +3 -0
  20. package/src/design-core/token-css.ts +48 -15
  21. package/src/generation-core/compose-site.ts +474 -0
  22. package/src/generation-core/node-baseline.model.ts +10 -0
  23. package/src/generation-core/node-baselines.generated.ts +4841 -0
  24. package/src/generation-core/patterns.ts +125 -0
  25. package/src/generation-core/public-api.ts +20 -0
  26. package/src/generation-core/serialized-node.ts +91 -0
  27. package/src/generation-core/site-plan.model.ts +136 -0
  28. package/src/generation-core/site-spec.generated.ts +1111 -0
  29. package/src/generation-core/templates.ts +60 -0
  30. package/src/generation-core/validate-site-plan.ts +413 -0
  31. package/styles/iox-design.css +16 -0
  32. package/styles/iox-forms.css +15 -9
@@ -1,4 +1,5 @@
1
1
  import { deriveAccentTokens } from './brand';
2
+ import { flavorColours, FlavorColours, preTintAccentGrounds } from './brand-neutrals';
2
3
  import { fontStackWithout, formatFontFamily } from './fonts';
3
4
  import { SPEC_AXES } from './flavor-spec.generated';
4
5
  import {
@@ -11,7 +12,8 @@ import {
11
12
  *
12
13
  * Layering, each step over the last (the boards' `tokenMap`, with the product's colour math):
13
14
  * pack tokens → chosen font pair → shape · density · motion axes → pill-button rule →
14
- * the author's own font roles → a seed ≠ the pack's own re-derives the accent quartet.
15
+ * the author's own font roles → every COLOUR token from the brand seed and the background tint
16
+ * (`flavorColours`, DS9: tinted neutrals, the accent quartet, the held contrast pairs).
15
17
  *
16
18
  * `mode` does not change rows — every row keeps its light value and its dark override; mode is
17
19
  * how the stylesheet is compiled.
@@ -91,13 +93,40 @@ export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): D
91
93
  if (typeof family === 'string' && family.trim() !== '') set(role, userFontStack(family, byName.get(role)?.value), USER_TOKEN_SOURCE);
92
94
  }
93
95
 
96
+ setColours(byName, flavorColours(pack, { seed: choices.seed, tint: choices.tint ?? pack.defaults.tint }));
97
+
98
+ return rows;
99
+ }
100
+
101
+ /** Colour rows take their light value and the dark one only where it differs (the contract's own rule). */
102
+ function setColours(byName: Map<string, DesignTokenRow>, colours: FlavorColours): void {
103
+ for (const [name, [light, dark]] of Object.entries(colours)) {
104
+ const row = byName.get(name);
105
+ if (!row) continue;
106
+ row.value = light;
107
+ if (dark !== undefined && dark !== light) row.values = { dark };
108
+ else delete row.values;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * What the contract wrote for `choices` BEFORE DS9 — the baseline a studio that applied its look before
114
+ * the tint existed is judged against (`designBaseline`, `designTokenRepairs`): the look's own palette, the
115
+ * rows DS9 changed at their old values (`pack.legacy`), and a seed ≠ the pack's own re-deriving the accent
116
+ * set against `bg` alone, as `deriveAccentTokens` was called then. No held pairs, no tint.
117
+ */
118
+ export function legacyFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): DesignTokenRow[] {
119
+ const rows = buildFlavorTokens(pack, { ...choices, seed: pack.seed, tint: 'neutral' });
120
+ const byName = new Map(rows.map(r => [r.name, r]));
121
+ for (const [name, old] of Object.entries(pack.legacy ?? {})) {
122
+ const row = byName.get(name);
123
+ if (!row) continue;
124
+ row.value = old.value;
125
+ if (old.dark !== undefined) row.values = { dark: old.dark };
126
+ else delete row.values;
127
+ }
94
128
  if (choices.seed && choices.seed.toLowerCase() !== pack.seed.toLowerCase()) {
95
- const bg = byName.get('bg');
96
- const inverseBg = byName.get('inverse-bg');
97
- for (const accent of deriveAccentTokens(choices.seed, {
98
- bg: bg?.value ?? '', bgDark: bg?.values?.dark,
99
- inverseBg: inverseBg?.value, inverseBgDark: inverseBg?.values?.dark,
100
- })) {
129
+ for (const accent of deriveAccentTokens(choices.seed, preTintAccentGrounds(pack.palette))) {
101
130
  const row = byName.get(accent.name);
102
131
  if (!row) continue;
103
132
  row.value = accent.value;
@@ -105,6 +134,5 @@ export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): D
105
134
  else delete row.values;
106
135
  }
107
136
  }
108
-
109
137
  return rows;
110
138
  }
@@ -18,6 +18,9 @@ export * from './adopt';
18
18
  export * from './node-classes';
19
19
  export * from './empty-image';
20
20
  export * from './brand';
21
+ export * from './colour-eval';
22
+ export * from './brand-neutrals';
23
+ export * from './contrast-audit';
21
24
  export * from './flavor-tokens';
22
25
  export * from './flavor-seed';
23
26
  export * from './spring';
@@ -121,7 +121,7 @@ export function tokensToDarkVarMap(tokens: ReadonlyArray<DesignTokenLike> | null
121
121
  }
122
122
 
123
123
  const TOKEN_REF = /var\(\s*--iox-([\w-]+)\s*(?:,([^()]*))?\)/g;
124
- const PLACEHOLDER = /\{\{\s*([\w-]+)\s*\}\}/g;
124
+ const PLACEHOLDER = /\{\{\s*(~?)([\w-]+)\s*\}\}/g;
125
125
 
126
126
  /** A compiled colour mode — the contract's modes minus `auto`, which is a compile CHOICE, not values. */
127
127
  export type ResolvedMode = Exclude<DesignMode, 'auto'>;
@@ -163,6 +163,26 @@ export function resolveTokenValues(
163
163
  return out;
164
164
  }
165
165
 
166
+ /**
167
+ * Every token's value INSIDE a section wearing `schemeId`, in one mode — what CSS computes there: the
168
+ * scheme's overrides (`{{token}}` / `{{~token}}` placeholders substituted from the root), every other
169
+ * row as declared, and `var()` references resolved within the section (the §3c re-declaration rule
170
+ * makes that the section's own resolution). `default` (or an unknown id) is the root.
171
+ */
172
+ export function resolveSchemeTokenValues(
173
+ tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
174
+ mode: ResolvedMode,
175
+ schemeId: string,
176
+ ): Record<string, string> {
177
+ const rows = usableTokens(tokens);
178
+ const set = schemeId === DEFAULT_SCHEME ? undefined : (SPEC_SCHEMES as Readonly<Record<string, { set: Readonly<Record<string, string>> }>>)[schemeId]?.set;
179
+ if (!set) return resolveTokenValues(rows, mode);
180
+ const root = resolveTokenValues(rows, mode);
181
+ const other = resolveTokenValues(rows, mode === DARK ? LIGHT : DARK);
182
+ const overrides = new Map(schemeEntries(set, root, other));
183
+ return resolveTokenValues(rows.map(t => ({ name: t.name, value: overrides.get(t.name) ?? modeValue(t, mode) })), LIGHT);
184
+ }
185
+
166
186
  /**
167
187
  * `value` with every `var(--iox-…)` replaced by that token's resolved root value in `mode` — what
168
188
  * the Style panel's Detach writes, so the node keeps exactly what it showed as a literal. An unknown
@@ -178,20 +198,25 @@ export function resolveTokenReferences(
178
198
  resolved[name] ?? (fallback !== undefined ? fallback.trim() : ref));
179
199
  }
180
200
 
181
- /** A scheme value with its `{{token}}` placeholders replaced by resolved root values — or null
182
- * when a placeholder names a token the set does not have (the declaration is then omitted). */
183
- function substitute(template: string, resolved: Readonly<Record<string, string>>): string | null {
201
+ type ResolvedValues = Readonly<Record<string, string>>;
202
+
203
+ /** A scheme value with its placeholders replaced — `{{token}}` by the root's resolved value in this
204
+ * mode, `{{~token}}` by its value in the OTHER mode (an inverse section is the other mode's ground,
205
+ * DS9) — or null when a placeholder names a token the set does not have (the declaration is then
206
+ * omitted). */
207
+ function substitute(template: string, resolved: ResolvedValues, opposite: ResolvedValues): string | null {
184
208
  let missing = false;
185
- const value = template.replace(PLACEHOLDER, (_, name: string) => {
186
- if (!(name in resolved)) missing = true;
187
- return resolved[name] ?? '';
209
+ const value = template.replace(PLACEHOLDER, (_, tilde: string, name: string) => {
210
+ const from = tilde ? opposite : resolved;
211
+ if (!(name in from)) missing = true;
212
+ return from[name] ?? '';
188
213
  });
189
214
  return missing ? null : value;
190
215
  }
191
216
 
192
- const schemeEntries = (set: Readonly<Record<string, string>>, resolved: Readonly<Record<string, string>>) =>
217
+ const schemeEntries = (set: Readonly<Record<string, string>>, resolved: ResolvedValues, opposite: ResolvedValues) =>
193
218
  Object.entries(set)
194
- .map(([name, template]): [string, string | null] => [name, substitute(template, resolved)])
219
+ .map(([name, template]): [string, string | null] => [name, substitute(template, resolved, opposite)])
195
220
  .filter((e): e is [string, string] => e[1] !== null);
196
221
 
197
222
  const hasPlaceholder = (value: string) => value.includes('{{');
@@ -209,12 +234,17 @@ const hasPlaceholder = (value: string) => value.includes('{{');
209
234
  * and CSS makes both invalid. The contract writes `{{accent}}` instead — the token's ROOT value,
210
235
  * resolved here at compile time (and again from dark values for the dark twin).
211
236
  */
212
- export function compileSchemeCss(tokens: ReadonlyArray<DesignTokenLike> | null | undefined, scope?: string): string {
237
+ export function compileSchemeCss(
238
+ tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
239
+ scope?: string,
240
+ opposite?: ResolvedValues,
241
+ ): string {
213
242
  const rows = usableTokens(tokens);
214
243
  if (!rows.length) return '';
215
244
  const resolved = resolveTokenValues(rows, LIGHT);
245
+ const other = opposite ?? resolveTokenValues(rows, DARK);
216
246
  return nonDefaultSchemes().map(([id, set]) => rule(schemeSelector(id, scope), [
217
- ...schemeEntries(set, resolved),
247
+ ...schemeEntries(set, resolved, other),
218
248
  ...rows.filter(t => !(t.name in set) && t.value.includes(REFERENCE))
219
249
  .map((t): [string, string] => [t.name, t.value]),
220
250
  ])).filter(Boolean).join('\n');
@@ -231,9 +261,9 @@ function darkSchemeRules(rows: ReadonlyArray<DesignTokenLike>, scope: string | u
231
261
  });
232
262
  return nonDefaultSchemes().map(([id, set]) => {
233
263
  const swapped = Object.fromEntries(Object.entries(set).filter(([, v]) => hasPlaceholder(v)));
234
- const lightSwap = new Map(schemeEntries(swapped, light));
264
+ const lightSwap = new Map(schemeEntries(swapped, light, dark));
235
265
  return rule(schemeSelector(id, scope), [
236
- ...schemeEntries(swapped, dark).filter(([name, value]) => lightSwap.get(name) !== value),
266
+ ...schemeEntries(swapped, dark, light).filter(([name, value]) => lightSwap.get(name) !== value),
237
267
  ...redeclared.filter(t => !(t.name in set)).map((t): [string, string] => [t.name, darkOf(t) as string]),
238
268
  ], ' ');
239
269
  });
@@ -249,15 +279,18 @@ export function compileTokenCssBlocks(
249
279
  tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
250
280
  opts: TokenCssOptions = {},
251
281
  ): TokenCssBlocks {
252
- const rows = rowsForMode(usableTokens(tokens), opts.mode);
282
+ const all = usableTokens(tokens);
283
+ const rows = rowsForMode(all, opts.mode);
253
284
  const root = opts.scope ?? ROOT;
285
+ // A forced mode flattens the rows to ONE mode; a `{{~token}}` still reads the other mode's value.
286
+ const opposite = resolveTokenValues(all, opts.mode === DARK ? LIGHT : DARK);
254
287
  const present = new Set(rows.map(t => t.name));
255
288
  const unset: Array<[string, string]> = opts.isolate
256
289
  ? [...specTokenNames()].filter(name => !present.has(name)).map((name): [string, string] => [name, 'initial'])
257
290
  : [];
258
291
  const base = [
259
292
  rule(root, [...unset, ...rows.map((t): [string, string] => [t.name, t.value])]),
260
- compileSchemeCss(rows, opts.scope),
293
+ compileSchemeCss(rows, opts.scope, opposite),
261
294
  ...ACCESSIBILITY_MEDIA.map(({ query, overrides }) =>
262
295
  media(query, [rule(root, presentOverrides(rows, overrides, false), ' ')])),
263
296
  ].filter(Boolean).join('\n');
@@ -0,0 +1,474 @@
1
+ import { DesignChoices, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { NODE_BASELINES } from './node-baselines.generated';
4
+ import { BindScope, PatternDirective, PatternNode, PatternPlaceholder, SitePattern, patternById } from './patterns';
5
+ import { assertStyleSlots, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps, ComposeError, walkSerialized } from './serialized-node';
6
+ import {
7
+ ContentTypePlan, DataSourcePlan, FactKey, FACT_KEYS, GlobalSlot, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
8
+ RelativeOp, SitePlan, SitePlanSource, SITE_FACT_LABELS, SiteBusiness,
9
+ } from './site-plan.model';
10
+ import { assertValidSitePlan, NAV_PAGES_ALIAS, nextSourceOf, routeParams } from './validate-site-plan';
11
+
12
+ /**
13
+ * THE composer — a Site Plan in, ordinary IOX data out (design-system plan §6b, W1). Deterministic and
14
+ * pure: the same plan composes to byte-identical output, with no clock, no randomness and no transport.
15
+ * The wizard and the AI agent both end here; W2's site-build service writes what it returns.
16
+ *
17
+ * Every node is created the way the builder creates one (`createSerializedNode`): the type's full
18
+ * constructor defaults, then ONE element style, then the pattern's layout. So a composed page is what
19
+ * an author would have built by hand from the palette — the builder reloads it and saves it unchanged,
20
+ * and the engine renders it with nothing to resolve. Never a colour, size or font a pattern did not
21
+ * name; never a fact the customer did not give (blank → a placeholder carrying the fact's label).
22
+ */
23
+
24
+ export enum PlaceholderKind {
25
+ /** A FACT the customer did not give (email, phone, city): publish-blocking. */
26
+ Fact = 'fact',
27
+ /** The pattern's own words, for the author to rewrite. */
28
+ Copy = 'copy',
29
+ /** An image slot with no image: the empty-image placeholder (DS3). */
30
+ Image = 'image',
31
+ }
32
+
33
+ export interface ComposedPlaceholder {
34
+ nodeId: string;
35
+ /** The page key, or the global slot (`header` / `footer`), the node lives in. */
36
+ owner: string;
37
+ kind: PlaceholderKind;
38
+ fact?: FactKey;
39
+ slot?: string;
40
+ /** What the node shows meanwhile: the fact's label, the pattern's copy, or '' for an image. */
41
+ text: string;
42
+ }
43
+
44
+ export enum ComposeIssueCode {
45
+ /** A slot the plan and the business left empty and the pattern gives no words for — the node keeps
46
+ * its type's default text. */
47
+ UnfilledSlot = 'unfilled-slot',
48
+ /** A pattern links to a route the plan has no page for (an optional page was left out). */
49
+ DanglingLink = 'dangling-link',
50
+ }
51
+
52
+ export interface ComposeIssue {
53
+ code: ComposeIssueCode;
54
+ nodeId: string;
55
+ owner: string;
56
+ detail: string;
57
+ }
58
+
59
+ export interface ComposedPage {
60
+ key: string;
61
+ name: string;
62
+ route: { path: string };
63
+ isHome: boolean;
64
+ status: 'draft';
65
+ /** DS3: every page the wizard creates follows the look (page-bg / text / font-body). */
66
+ pageSettings: { designSystem: true };
67
+ layout: SerializedNode[];
68
+ }
69
+
70
+ export interface ComposedDataSource {
71
+ id: string;
72
+ alias: string;
73
+ type: string;
74
+ mode: string;
75
+ request: Record<string, unknown>;
76
+ }
77
+
78
+ export type ComposedItem = Record<string, unknown> & { isPublished: boolean; isSample: boolean };
79
+
80
+ export interface ComposedRoute {
81
+ key: string;
82
+ path: string;
83
+ isHome: boolean;
84
+ }
85
+
86
+ export interface ComposedSite {
87
+ source: SitePlanSource;
88
+ business: SiteBusiness;
89
+ /** The look, completed against its pack (`resolveDesignChoices`) — what the build applies. */
90
+ design: DesignChoices;
91
+ /** The content types to create, as the plan states them. */
92
+ schemas: ContentTypePlan[];
93
+ /** Sample items per content type. `{sampleImage:k}` values name a bundled sample image the build resolves. */
94
+ items: Record<string, ComposedItem[]>;
95
+ /** Merged by alias into the org data-source library. */
96
+ dataSources: ComposedDataSource[];
97
+ pages: ComposedPage[];
98
+ /** Header + footer: root nodes carry `isGlobal` and `globalPosition`. */
99
+ globals: SerializedNode[];
100
+ routes: ComposedRoute[];
101
+ placeholders: ComposedPlaceholder[];
102
+ issues: ComposeIssue[];
103
+ }
104
+
105
+ /** `{sampleImage:k}` — a bundled abstract sample image, never a stock photo (plan §6b). */
106
+ export const SAMPLE_IMAGE_REF = /^\{sampleImage:(\d+)\}$/;
107
+ const SAMPLE_IMAGE_TEMPLATE = /\{sampleImage:n(?:\+(\d+))?\}/g;
108
+ const ITEM_NUMBER = /\{n\}/g;
109
+
110
+ export function sampleImageRef(index: number): string {
111
+ return `{sampleImage:${index}}`;
112
+ }
113
+
114
+ /** The 1-based index a `{sampleImage:k}` value names, or null. */
115
+ export function parseSampleImageRef(value: unknown): number | null {
116
+ const m = typeof value === 'string' ? SAMPLE_IMAGE_REF.exec(value) : null;
117
+ return m ? Number(m[1]) : null;
118
+ }
119
+
120
+ /** The prop a pattern's `text` / slot / fact fills, per type. */
121
+ const TEXT_PROP: Readonly<Record<string, string>> = {
122
+ Heading: 'content',
123
+ Text: 'content',
124
+ Link: 'label',
125
+ SubmitButton: 'label',
126
+ };
127
+ /** The prop a pattern's `bind` binds, per type. */
128
+ const BIND_PROP: Readonly<Record<string, string>> = {
129
+ Heading: 'content',
130
+ Text: 'content',
131
+ Link: 'label',
132
+ Image: 'src',
133
+ };
134
+ /** Types whose `href` is a website page (`linkType: page` + `pageRoute`). */
135
+ const PAGE_LINK_TYPES: ReadonlySet<string> = new Set(['Link', 'LinkedContainer']);
136
+ const BUTTON_BLOCK = 'ButtonBlock';
137
+ const FORM = 'Form';
138
+ const FORM_STATE_BLOCKS = ['FormSuccess', 'FormError'];
139
+ const LINK_TYPE_PAGE = 'page';
140
+ const ANCHOR_TAG = 'a';
141
+ const LABEL_TAG = 'span';
142
+ const TEXT = 'Text';
143
+ /** A button's label reads the button's type and ink (the consent preset's labels, `consent-preset.ts`). */
144
+ const BUTTON_LABEL_INHERITS = { fontSize: 'inherit', fontWeight: 'inherit', color: 'inherit' };
145
+ const ITEM_VALUE_PATH = '$';
146
+ const STATUS_DRAFT = 'draft';
147
+
148
+ const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
149
+
150
+ /** 32-bit FNV-1a, base 36 — a short stable id fragment. */
151
+ function stableHash(text: string): string {
152
+ let h = 0x811c9dc5;
153
+ for (let i = 0; i < text.length; i++) {
154
+ h ^= text.charCodeAt(i);
155
+ h = Math.imul(h, 0x01000193) >>> 0;
156
+ }
157
+ return h.toString(36).padStart(7, '0').slice(-6);
158
+ }
159
+
160
+ /** A page route as a base-relative href — the engine's `resolvePageHref` form (`/contact` → `contact`,
161
+ * `/` → `./`), so a link stays inside the org's `<base href>`. */
162
+ export function baseRelativeHref(route: string): string {
163
+ const path = route.replace(/\/{2,}/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
164
+ return path || './';
165
+ }
166
+
167
+ interface RepeaterScope {
168
+ /** The alias a row's children bind (`repeaterItemAlias`: the iterate-path's last segment, else the source). */
169
+ itemAlias: string;
170
+ }
171
+
172
+ interface Ctx {
173
+ owner: string;
174
+ page: PagePlan | null;
175
+ section: PageSectionPlan | null;
176
+ plan: SitePlan;
177
+ /** `next.*` → this alias. */
178
+ nextAlias: string | null;
179
+ placeholders: ComposedPlaceholder[];
180
+ issues: ComposeIssue[];
181
+ routes: ReadonlySet<string>;
182
+ }
183
+
184
+ function nonEmpty(v: unknown): v is string {
185
+ return typeof v === 'string' && v.trim() !== '';
186
+ }
187
+
188
+ interface TextChoice {
189
+ value?: string;
190
+ placeholder?: { kind: PlaceholderKind; fact?: FactKey };
191
+ }
192
+
193
+ /** Where a node's words come from: the plan's slot → the business → the customer's fact → the pattern's
194
+ * own text. A missing fact shows its label and is a placeholder; nothing is ever made up. */
195
+ function chooseText(pn: PatternNode, ctx: Ctx): TextChoice {
196
+ const fact = pn.ph && (FACT_KEYS as ReadonlyArray<string>).includes(pn.ph) ? pn.ph as FactKey : null;
197
+ const slotValue = pn.slot ? ctx.section?.slots?.[pn.slot] : undefined;
198
+ if (nonEmpty(slotValue)) return { value: slotValue };
199
+ if (pn.fromBusiness) {
200
+ const v = (ctx.plan.business as unknown as Record<string, unknown>)[pn.fromBusiness];
201
+ if (nonEmpty(v)) return { value: v };
202
+ }
203
+ if (fact) {
204
+ const given = ctx.plan.business.facts?.[fact];
205
+ return nonEmpty(given) ? { value: given } : { value: SITE_FACT_LABELS[fact], placeholder: { kind: PlaceholderKind.Fact, fact } };
206
+ }
207
+ if (pn.ph === PatternPlaceholder.Copy) return { value: pn.text, placeholder: { kind: PlaceholderKind.Copy } };
208
+ return { value: pn.text };
209
+ }
210
+
211
+ /** A `bind` / `param` / `src` path → the binding source + path, in this node's scope. */
212
+ function resolveScoped(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { source: string; path: string } {
213
+ const [scope, ...rest] = path.split('.');
214
+ const sub = rest.join('.');
215
+ switch (scope) {
216
+ case BindScope.Item:
217
+ case BindScope.Page:
218
+ if (!repeater) throw new ComposeError(`${nodeId}: "${path}" outside a Repeater`);
219
+ return { source: repeater.itemAlias, path: sub || ITEM_VALUE_PATH };
220
+ case BindScope.Route:
221
+ if (!ctx.page?.routeSource) throw new ComposeError(`${nodeId}: "${path}" on a page with no routeSource`);
222
+ return { source: ctx.page.routeSource, path: sub || ITEM_VALUE_PATH };
223
+ case BindScope.Next:
224
+ if (!ctx.nextAlias) throw new ComposeError(`${nodeId}: "${path}" with no next-sibling source`);
225
+ return { source: ctx.nextAlias, path: sub || ITEM_VALUE_PATH };
226
+ default:
227
+ throw new ComposeError(`${nodeId}: unknown binding scope "${scope}"`);
228
+ }
229
+ }
230
+
231
+ function addBinding(node: SerializedNode, binding: SerializedBinding): void {
232
+ const declared = NODE_BASELINES[node.type]?.traits ?? [];
233
+ if (!declared.includes(binding.prop) && !binding.prop.startsWith('routeParam:')) {
234
+ throw new ComposeError(`${node.type} has no trait "${binding.prop}" to bind`);
235
+ }
236
+ node.bindings.push(binding);
237
+ }
238
+
239
+ function assertTraits(type: string, names: ReadonlyArray<string>): void {
240
+ const declared = NODE_BASELINES[type]?.traits ?? [];
241
+ for (const name of names) if (!declared.includes(name)) throw new ComposeError(`${type} has no trait "${name}"`);
242
+ }
243
+
244
+ function setProp(node: SerializedNode, name: string, value: unknown): void {
245
+ assertTraits(node.type, [name]);
246
+ node.props[name] = value;
247
+ }
248
+
249
+ /** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out. */
250
+ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode | null {
251
+ if (pn.optional && ctx.section?.without?.includes(pn.optional)) return null;
252
+
253
+ const props: Record<string, unknown> = {};
254
+ if (pn.level) props['level'] = pn.level;
255
+ if (pn.tag) props['htmlTag'] = pn.tag;
256
+ if (pn.scheme) props['colorScheme'] = pn.scheme;
257
+ if (pn.control) props['control'] = pn.control;
258
+ if (pn.label) props['label'] = pn.label;
259
+ if (pn.required !== undefined) props['required'] = pn.required;
260
+ assertTraits(pn.t, Object.keys(props));
261
+ // Props first: a Heading with no `es` wears its LEVEL's default style.
262
+ const node = createSerializedNode(id, pn.t, props, pn.es ?? null);
263
+
264
+ // Words.
265
+ const words = chooseText(pn, ctx);
266
+ if (words.placeholder) {
267
+ const shown = words.value ?? String(NODE_BASELINES[pn.t]?.props[TEXT_PROP[pn.t]] ?? '');
268
+ ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: words.placeholder.kind, ...(words.placeholder.fact ? { fact: words.placeholder.fact } : {}), ...(pn.slot ? { slot: pn.slot } : {}), text: shown });
269
+ } else if (words.value === undefined && (pn.slot || pn.fromBusiness)) {
270
+ ctx.issues.push({ code: ComposeIssueCode.UnfilledSlot, nodeId: id, owner: ctx.owner, detail: `no value for ${pn.slot ? `slot "${pn.slot}"` : `business "${pn.fromBusiness}"`} — keeps the ${pn.t} default` });
271
+ }
272
+ if (words.value !== undefined) {
273
+ if (pn.t === BUTTON_BLOCK) {
274
+ const label = createSerializedNode(`${id}-label`, TEXT, { content: words.value, tag: LABEL_TAG });
275
+ setStyleProps(label, BUTTON_LABEL_INHERITS);
276
+ node.children = [label];
277
+ } else {
278
+ const prop = TEXT_PROP[pn.t];
279
+ if (!prop) throw new ComposeError(`${id}: ${pn.t} cannot hold text`);
280
+ setProp(node, prop, words.value);
281
+ }
282
+ }
283
+
284
+ // Images.
285
+ if (pn.ph === PatternPlaceholder.Image) {
286
+ setProp(node, 'src', '');
287
+ ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: PlaceholderKind.Image, text: '' });
288
+ }
289
+ if (pn.aspect) setStyleProps(node, { aspectRatio: pn.aspect });
290
+
291
+ // Links.
292
+ if (pn.href) {
293
+ if (pn.href === PatternDirective.PageRoute) {
294
+ setProp(node, 'linkType', LINK_TYPE_PAGE);
295
+ addBinding(node, { prop: 'pageRoute', ...resolveScoped(`${BindScope.Page}.route`, repeater, ctx, id) });
296
+ } else {
297
+ if (!ctx.routes.has(pn.href)) ctx.issues.push({ code: ComposeIssueCode.DanglingLink, nodeId: id, owner: ctx.owner, detail: `links to ${pn.href}, which the plan has no page for` });
298
+ if (PAGE_LINK_TYPES.has(pn.t)) {
299
+ setProp(node, 'linkType', LINK_TYPE_PAGE);
300
+ setProp(node, 'pageRoute', pn.href);
301
+ } else if (pn.t === BUTTON_BLOCK) {
302
+ setProp(node, 'tag', ANCHOR_TAG);
303
+ setProp(node, 'href', baseRelativeHref(pn.href));
304
+ } else {
305
+ throw new ComposeError(`${id}: ${pn.t} cannot link`);
306
+ }
307
+ }
308
+ }
309
+ if (pn.param) {
310
+ const names = pn.href ? routeParams(pn.href) : [];
311
+ if (names.length !== 1) throw new ComposeError(`${id}: "param" needs an href with one :param`);
312
+ addBinding(node, { prop: `routeParam:${names[0]}`, ...resolveScoped(pn.param, repeater, ctx, id) });
313
+ }
314
+ if (pn.bind) {
315
+ const prop = BIND_PROP[pn.t];
316
+ if (!prop) throw new ComposeError(`${id}: ${pn.t} has no bindable value`);
317
+ addBinding(node, { prop, ...resolveScoped(pn.bind, repeater, ctx, id) });
318
+ }
319
+
320
+ // Layout.
321
+ if (pn.lay) setStyleProps(node, pn.lay);
322
+ if (pn.resp) {
323
+ for (const layer of Object.values(pn.resp)) assertStyleSlots(node.type, Object.keys(layer));
324
+ node.responsive = Object.fromEntries(Object.entries(pn.resp).map(([bp, styleProps]) => [bp, { styleProps: { ...styleProps } }]));
325
+ }
326
+
327
+ // A Repeater's rows.
328
+ let rowScope = repeater;
329
+ if (pn.src) {
330
+ let binding: SerializedBinding;
331
+ if (pn.src === PatternDirective.Binding) {
332
+ if (!ctx.section?.binding) throw new ComposeError(`${id}: "$binding" with no section binding`);
333
+ binding = { prop: 'source', source: ctx.section.binding, path: '' };
334
+ } else if (pn.src === PatternDirective.NavPages) {
335
+ binding = { prop: 'source', source: NAV_PAGES_ALIAS, path: '' };
336
+ } else {
337
+ const scoped = resolveScoped(pn.src, repeater, ctx, id);
338
+ binding = { prop: 'source', source: scoped.source, path: scoped.path === ITEM_VALUE_PATH ? '' : scoped.path };
339
+ }
340
+ addBinding(node, binding);
341
+ const lastSegment = binding.path.split('.').filter(Boolean).pop();
342
+ rowScope = { itemAlias: lastSegment || binding.source };
343
+ }
344
+
345
+ // Children.
346
+ const children = (pn.c ?? [])
347
+ .map((child, i) => composeNode(child, `${id}-${i}`, rowScope, ctx))
348
+ .filter((c): c is SerializedNode => !!c);
349
+ if (pn.c?.length) {
350
+ if (!node.children) throw new ComposeError(`${id}: ${pn.t} holds no children`);
351
+ node.children = [...node.children, ...children];
352
+ }
353
+
354
+ // A Form: its stable id, and both state blocks — never optional (forms plan D7).
355
+ if (pn.t === FORM) {
356
+ if (pn.preset) setProp(node, 'formId', `${pn.preset}-${stableHash(`${ctx.owner}/${id}`)}`);
357
+ node.children = [
358
+ ...(node.children ?? []),
359
+ ...FORM_STATE_BLOCKS.map((type, i) => createSerializedNode(`${id}-${(pn.c?.length ?? 0) + i}`, type)),
360
+ ];
361
+ }
362
+ return node;
363
+ }
364
+
365
+ function composePattern(pattern: SitePattern, id: string, ctx: Ctx): SerializedNode {
366
+ const node = composeNode(pattern.node, id, null, ctx);
367
+ if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional`);
368
+ node.label = pattern.label;
369
+ return node;
370
+ }
371
+
372
+ function composeDataSource(ds: DataSourcePlan): ComposedDataSource {
373
+ const id = `ds-${ds.alias}`;
374
+ if (ds.type === PlanDataSourceType.Cms) {
375
+ const cms: Record<string, unknown> = { contentType: ds.contentType };
376
+ if (ds.limit !== undefined) cms['limit'] = ds.limit;
377
+ if (ds.filterByRouteParam) cms['filterByRouteParam'] = { ...ds.filterByRouteParam };
378
+ return { id, alias: ds.alias, type: PlanDataSourceType.Cms, mode: ds.mode, request: { cms } };
379
+ }
380
+ const pick = ds.op === RelativeOp.SiblingsNext ? 'next' : 'prev';
381
+ return {
382
+ id, alias: ds.alias, type: PlanDataSourceType.Relative, mode: PlanDataSourceMode.Single,
383
+ request: { relative: { kind: 'siblings', from: ds.within, subjectFrom: ds.of, pick } },
384
+ };
385
+ }
386
+
387
+ function navPagesSource(plan: SitePlan): ComposedDataSource {
388
+ return {
389
+ id: `ds-${NAV_PAGES_ALIAS}`, alias: NAV_PAGES_ALIAS, type: 'static', mode: PlanDataSourceMode.Query,
390
+ request: { data: plan.pages.filter(p => p.inNav).map(p => ({ name: p.name, route: p.route })) },
391
+ };
392
+ }
393
+
394
+ function expandSeedValue(value: unknown, n: number): unknown {
395
+ if (Array.isArray(value)) return value.map(v => expandSeedValue(v, n));
396
+ if (typeof value !== 'string') return value;
397
+ return value
398
+ .replace(ITEM_NUMBER, String(n))
399
+ .replace(SAMPLE_IMAGE_TEMPLATE, (_m, plus?: string) => sampleImageRef(n + (plus ? Number(plus) : 0)));
400
+ }
401
+
402
+ function composeItems(type: ContentTypePlan): ComposedItem[] {
403
+ const seed = type.seed;
404
+ if (!seed) return [];
405
+ const items: ComposedItem[] = [];
406
+ for (let n = 1; n <= seed.count; n++) {
407
+ const item: Record<string, unknown> = {};
408
+ for (const field of type.fields) if (field.name in seed) item[field.name] = expandSeedValue(seed[field.name], n);
409
+ items.push({ ...item, isPublished: seed.isPublished, isSample: seed.sample });
410
+ }
411
+ return items;
412
+ }
413
+
414
+ /** True when a composed tree iterates the navigation's pages. */
415
+ function bindsAlias(trees: ReadonlyArray<ReadonlyArray<SerializedNode>>, alias: string): boolean {
416
+ let found = false;
417
+ for (const tree of trees) walkSerialized(tree, node => { if (node.bindings.some(b => b.source === alias)) found = true; });
418
+ return found;
419
+ }
420
+
421
+ /**
422
+ * Compose a Site Plan. Throws `SitePlanError` (every issue) when the plan does not validate — the
423
+ * composer never repairs a plan.
424
+ */
425
+ export function composeSite(plan: SitePlan): ComposedSite {
426
+ assertValidSitePlan(plan);
427
+ const placeholders: ComposedPlaceholder[] = [];
428
+ const issues: ComposeIssue[] = [];
429
+ const routes = new Set(plan.pages.map(p => p.route));
430
+ const relatives = new Map(plan.dataSources.map(d => [d.alias, d.type === PlanDataSourceType.Relative ? { type: d.type, of: d.of, op: d.op } : { type: d.type }]));
431
+ const base = { plan, placeholders, issues, routes };
432
+
433
+ const pages: ComposedPage[] = plan.pages.map(page => {
434
+ const nextAlias = page.routeSource ? nextSourceOf(relatives, page.routeSource) : null;
435
+ const layout = page.sections.map((section, i) => {
436
+ const pattern = patternById(section.pattern) as SitePattern;
437
+ return composePattern(pattern, `${page.key}-${i}`, { ...base, owner: page.key, page, section, nextAlias });
438
+ });
439
+ return {
440
+ key: page.key,
441
+ name: page.name,
442
+ route: { path: page.route },
443
+ isHome: page.isHome === true,
444
+ status: STATUS_DRAFT,
445
+ pageSettings: { designSystem: true },
446
+ layout,
447
+ };
448
+ });
449
+
450
+ const globals = Object.values(GlobalSlot).map(slot => {
451
+ const pattern = patternById(plan.globals[slot]) as SitePattern;
452
+ const node = composePattern(pattern, slot, { ...base, owner: slot, page: null, section: null, nextAlias: null });
453
+ node.isGlobal = true;
454
+ node.globalPosition = pattern.global;
455
+ return node;
456
+ });
457
+
458
+ const dataSources = plan.dataSources.map(composeDataSource);
459
+ if (bindsAlias([...pages.map(p => p.layout), globals], NAV_PAGES_ALIAS)) dataSources.push(navPagesSource(plan));
460
+
461
+ return {
462
+ source: clone(plan.source),
463
+ business: clone(plan.business),
464
+ design: resolveDesignChoices(clone(plan.design)),
465
+ schemas: clone(plan.contentTypes) as ContentTypePlan[],
466
+ items: Object.fromEntries(plan.contentTypes.map(t => [t.name, composeItems(t)])),
467
+ dataSources,
468
+ pages,
469
+ globals,
470
+ routes: plan.pages.map(p => ({ key: p.key, path: p.route, isHome: p.isHome === true })),
471
+ placeholders,
472
+ issues,
473
+ };
474
+ }