@vectoriox/iox-ui 5.26.0 → 5.28.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 (30) hide show
  1. package/design-core/index.d.ts +144 -4
  2. package/fesm2022/vectoriox-iox-ui-design-core.mjs +153 -5
  3. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  4. package/fesm2022/vectoriox-iox-ui-forms-core.mjs +40 -5
  5. package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +7018 -0
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -0
  8. package/fesm2022/vectoriox-iox-ui.mjs +72 -14
  9. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  10. package/forms-core/index.d.ts +18 -2
  11. package/generation-core/index.d.ts +1397 -0
  12. package/generation-core/package.json +3 -0
  13. package/index.d.ts +13 -2
  14. package/package.json +5 -1
  15. package/src/design-core/flavor-spec.generated.ts +153 -5
  16. package/src/forms-core/collect.ts +3 -1
  17. package/src/forms-core/options.ts +29 -0
  18. package/src/forms-core/public-api.ts +1 -0
  19. package/src/forms-core/transport.ts +6 -2
  20. package/src/generation-core/compose-site.ts +474 -0
  21. package/src/generation-core/node-baseline.model.ts +10 -0
  22. package/src/generation-core/node-baselines.generated.ts +4841 -0
  23. package/src/generation-core/patterns.ts +125 -0
  24. package/src/generation-core/public-api.ts +20 -0
  25. package/src/generation-core/serialized-node.ts +91 -0
  26. package/src/generation-core/site-plan.model.ts +136 -0
  27. package/src/generation-core/site-spec.generated.ts +1111 -0
  28. package/src/generation-core/templates.ts +60 -0
  29. package/src/generation-core/validate-site-plan.ts +413 -0
  30. package/styles/iox-forms.css +136 -8
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ /** What an untouched node of one registered type saves as (`NODE_BASELINES`, generated from the live
2
+ * iox-builder configs) — the builder serializer's flat `props` / `styleProps`, the trait names it has a
3
+ * slot for, and whether it holds children. */
4
+ export interface NodeBaseline {
5
+ readonly props: Readonly<Record<string, unknown>>;
6
+ readonly styleProps: Readonly<Record<string, unknown>>;
7
+ readonly traits: ReadonlyArray<string>;
8
+ readonly styleTraits: ReadonlyArray<string>;
9
+ readonly container: boolean;
10
+ }