@vectoriox/iox-ui 5.60.0 → 5.62.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.
@@ -0,0 +1,544 @@
1
+ import { ANIMATE_IDS, DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+ import { FORM_PRESETS } from '@vectoriox/iox-ui/forms-core';
3
+
4
+ import { describeShapeKey, jsonAnyOf, jsonBoolean, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText } from './json-schema';
5
+ import { patternLinkSlots, patternOptionalParts, patternSlots, PatternGlobalPosition, SITE_PATTERNS } from './patterns';
6
+ import {
7
+ ContentFieldPlan, ContentTypePlan, DataSourcePlan, FactKey, GlobalSlot, PAGE_ARCHETYPES, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
8
+ RelativeOp, SITE_NEED_IDS, SITE_PLAN_VERSION, SitePlan, SitePlanSource, SiteLocale,
9
+ } from './site-plan.model';
10
+ import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
11
+ import { isPlainObject, PlainObject, ShapeEntry, shapeKeys } from './shape-check';
12
+
13
+ /**
14
+ * The plan WIRE (AI plan M1-3, vectoriox/iox-cms#45, #47): what a model writes when it plans a site — a compact format
15
+ * inside the Claude API's structured-output limits (≤ 24 optional parameters and ≤ 16 unions per request, and a compiled
16
+ * grammar under the size Claude refuses — measured as ≤ 60 properties; `strict-schema.ts`). The Site Plan itself
17
+ * (`SPEC_SITE_PLAN_SHAPE`) has 213 optional keys once every pattern is a branch, so the model never writes it: it writes
18
+ * this, and ONE pure `expandPlanWire` turns it into the Site Plan that `validateSitePlan` and the stage's gate judge
19
+ * exactly as before.
20
+ *
21
+ * - **Written in two PARTS, one request each** (#47 — the whole plan in one format was 64 properties): the **structure** (the business,
22
+ * the content types, the data sources) and then the **pages** (the pages, the header and footer), written knowing the
23
+ * structure. Every top-level key belongs to exactly one part (`part` below); `expandPlanWire` puts the parts together.
24
+ *
25
+ * - **Every key required.** A plan key that may be absent is written with its stated "none" — `''`, `0`, `false`, `[]`.
26
+ * - **Closed lists as enums** (patterns, page types, needs, slots, parts, forms, entrances, header/footer), from the
27
+ * contract — never per-pattern branches: the gate refuses a slot or a part the chosen pattern does not have, with its path.
28
+ * - **Maps as lists:** slots `[{ slot, text }]`, links `[{ slot, route }]`, seed values `[{ field, value }]`, roles `[{ role, field }]`.
29
+ * - **One data source object** for both kinds; `type` says which keys are read (the other kind's hold their none).
30
+ * - **What the model does not decide is not on the wire** (`PLAN_WIRE_DERIVED`): the look (the owner's pick), the run,
31
+ * the language (the reading's), the facts (the owner's own, verbatim), the version, the home page (the page at `/`).
32
+ * So the format is the same for every studio and every run — one compiled grammar, cached by the provider.
33
+ *
34
+ * ONE source: every wire key names the plan keys it carries (`refs`) and is described in the plan table's own words, and
35
+ * every plan key is either carried or derived (`planWireGaps()`); a key the plan shape gains breaks the builder until it
36
+ * is placed. The expanders translate, never repair: a value the model got wrong stays wrong, for the gate to refuse.
37
+ */
38
+
39
+ /** The plan's parts, in the order they are written: the pages bind to the structure's sources and types. */
40
+ export enum PlanPart {
41
+ Structure = 'structure',
42
+ Pages = 'pages',
43
+ }
44
+
45
+ export const PLAN_PARTS: ReadonlyArray<PlanPart> = [PlanPart.Structure, PlanPart.Pages];
46
+
47
+ /** Each part's format name (a request names the format it asks for). */
48
+ export const PLAN_WIRE_PART_SCHEMA_NAMES: Readonly<Record<PlanPart, string>> = {
49
+ [PlanPart.Structure]: 'site_plan_structure',
50
+ [PlanPart.Pages]: 'site_plan_pages',
51
+ };
52
+
53
+ export enum PlanWireEntry {
54
+ Plan = 'plan',
55
+ Business = 'business',
56
+ ContentType = 'contentType',
57
+ RoleField = 'roleField',
58
+ Field = 'field',
59
+ Seed = 'seed',
60
+ SeedValue = 'seedValue',
61
+ DataSource = 'dataSource',
62
+ Page = 'page',
63
+ PageSeo = 'pageSeo',
64
+ Section = 'section',
65
+ SlotText = 'slotText',
66
+ SlotLink = 'slotLink',
67
+ Globals = 'globals',
68
+ }
69
+
70
+ type PlanShapeName = keyof typeof SPEC_SITE_PLAN_SHAPE;
71
+
72
+ interface WireKey {
73
+ /** The plan keys it carries (`entry.key`, `[field]` for a dynamic key). */
74
+ refs: ReadonlyArray<string>;
75
+ /** Its own words, when the first ref's do not fit the wire. */
76
+ text?: string;
77
+ /** What stands for "absent" (an optional plan key). */
78
+ none?: string;
79
+ /** The part a top-level key is written in. */
80
+ part?: PlanPart;
81
+ }
82
+
83
+ const PLAN_SHAPE = SPEC_SITE_PLAN_SHAPE as unknown as Readonly<Record<string, ShapeEntry>>;
84
+ const key = (ref: string, none?: string): WireKey => ({ refs: [ref], ...(none !== undefined ? { none } : {}) });
85
+ const inPart = (part: PlanPart, spec: WireKey): WireKey => ({ ...spec, part });
86
+ /** The roles a content type's fields play (`roles` of the plan shape), in the plan shape's order. */
87
+ const ROLE_KEYS: ReadonlyArray<string> = shapeKeys(PLAN_SHAPE['roles']);
88
+ const roleWords = ROLE_KEYS.map(role => `${role}: ${describeShapeKey(PLAN_SHAPE['roles'], role).replace(/^string — /, '')}`).join('; ');
89
+
90
+ const WIRE_KEYS: Readonly<Record<`${PlanWireEntry}`, Readonly<Record<string, WireKey>>>> = {
91
+ plan: {
92
+ business: inPart(PlanPart.Structure, key('plan.business')),
93
+ contentTypes: inPart(PlanPart.Structure, key('plan.contentTypes')),
94
+ dataSources: inPart(PlanPart.Structure, { refs: ['plan.dataSources'], text: 'dataSource[] — merged by alias into the org library' }),
95
+ pages: inPart(PlanPart.Pages, key('plan.pages')),
96
+ globals: inPart(PlanPart.Pages, key('plan.globals')),
97
+ },
98
+ business: {
99
+ name: key('business.name'),
100
+ tagline: key('business.tagline', "''"),
101
+ },
102
+ contentType: {
103
+ name: key('contentType.name'),
104
+ label: key('contentType.label'),
105
+ roles: { refs: ['contentType.roles'], text: `roleField[] — ${describeShapeKey(PLAN_SHAPE['contentType'], 'roles').replace(/^roles — /, '')}; a role no field plays is left out` },
106
+ fields: key('contentType.fields'),
107
+ seed: { refs: ['contentType.seed'], text: 'seed — the sample items', none: 'count 0 and no values' },
108
+ },
109
+ roleField: {
110
+ role: { refs: ROLE_KEYS.map(role => `roles.${role}`), text: `${ROLE_KEYS.map(r => `'${r}'`).join(' | ')} — ${roleWords}` },
111
+ field: { refs: ['roles.title'], text: 'string — the field of the type that plays it' },
112
+ },
113
+ field: {
114
+ name: key('field.name'),
115
+ type: key('field.type'),
116
+ controller: key('field.controller'),
117
+ required: { refs: ['field.required'], text: 'boolean — the field must be filled', none: 'false' },
118
+ note: key('field.note', "''"),
119
+ },
120
+ seed: {
121
+ count: key('seed.count'),
122
+ sample: key('seed.sample'),
123
+ isPublished: key('seed.isPublished'),
124
+ values: { refs: ['seed.[field]'], text: "seedValue[] — one per field: the samples' value for it" },
125
+ },
126
+ seedValue: {
127
+ field: { refs: ['seed.[field]'], text: 'string — a field of the type' },
128
+ value: { refs: ['seed.[field]'], text: `string | string[] — ${describeShapeKey(PLAN_SHAPE['seed'], '[field]')} (a list for an array field)` },
129
+ },
130
+ dataSource: {
131
+ alias: { refs: ['cmsDataSource.alias', 'relativeDataSource.alias'] },
132
+ type: { refs: ['cmsDataSource.type', 'relativeDataSource.type'], text: "'cms' | 'relative' — which of the keys below are read" },
133
+ mode: key('cmsDataSource.mode', "'' (relative)"),
134
+ contentType: key('cmsDataSource.contentType', "'' (relative)"),
135
+ limit: key('cmsDataSource.limit', '0 (all, or relative)'),
136
+ filterField: { refs: ['cmsDataSource.filterByRouteParam'], text: "string — single only: the field that holds the route param's value (e.g. slug)", none: "''" },
137
+ routeParam: { refs: ['cmsDataSource.filterByRouteParam'], text: 'string — single only: the route param that picks the item (the :param of its page route)', none: "''" },
138
+ of: key('relativeDataSource.of', "'' (cms)"),
139
+ op: key('relativeDataSource.op', "'' (cms)"),
140
+ within: key('relativeDataSource.within', "'' (cms)"),
141
+ },
142
+ page: {
143
+ key: key('page.key'),
144
+ name: key('page.name'),
145
+ route: { refs: ['page.route'], text: `${describeShapeKey(PLAN_SHAPE['page'], 'route')}; the page at "/" is the home page` },
146
+ inNav: key('page.inNav', 'false'),
147
+ archetype: key('page.archetype', "''"),
148
+ need: key('page.need', "''"),
149
+ template: key('page.template', "''"),
150
+ routeSource: key('page.routeSource', "''"),
151
+ seo: key('page.seo'),
152
+ sections: key('page.sections'),
153
+ },
154
+ pageSeo: {
155
+ title: key('pageSeo.title', "''"),
156
+ description: key('pageSeo.description', "''"),
157
+ },
158
+ section: {
159
+ pattern: key('section.pattern'),
160
+ slots: { refs: ['section.slots'], text: "slotText[] — the pattern's declared slots it fills, each within its budget (site.patterns … max × site.copyLengths)", none: '[]' },
161
+ links: { refs: ['section.links'], text: 'slotLink[] — re-points a linked slot (a button, a link) to another page route of the plan', none: '[]' },
162
+ binding: key('section.binding', "''"),
163
+ without: key('section.without', '[]'),
164
+ form: key('section.form', "''"),
165
+ enter: key('section.enter', "'' (the look's)"),
166
+ },
167
+ slotText: {
168
+ slot: { refs: ['section.slots'], text: "string — one of the pattern's slots" },
169
+ text: { refs: ['section.slots'], text: "string — the words, in the owner's language and voice" },
170
+ },
171
+ slotLink: {
172
+ slot: { refs: ['section.links'], text: "string — one of the pattern's linked slots" },
173
+ route: { refs: ['section.links'], text: 'string — a page route of the plan (no :param)' },
174
+ },
175
+ globals: {
176
+ header: key('globals.header'),
177
+ footer: key('globals.footer'),
178
+ },
179
+ };
180
+
181
+ /** The plan keys the model does not write, and where the expander takes each from. */
182
+ export const PLAN_WIRE_DERIVED: Readonly<Record<string, string>> = {
183
+ 'plan.version': `always '${SITE_PLAN_VERSION}'`,
184
+ 'plan.source': 'this run (the context)',
185
+ 'plan.locale': "the reading's language (the context)",
186
+ 'plan.design': "the owner's pick (the context)",
187
+ 'business.facts': "the owner's own facts, verbatim (the context)",
188
+ 'page.isHome': 'the page at "/"',
189
+ 'page.required': "a template page's (the wizard's Pages step), never the AI's",
190
+ sourceTemplate: 'the AI never writes a template plan',
191
+ sourceAi: 'this run (the context)',
192
+ design: "the owner's pick (the context)",
193
+ };
194
+
195
+ const wireText = (spec: WireKey): string => {
196
+ const [entry, planKey] = spec.refs[0].split('.');
197
+ const base = spec.text ?? describeShapeKey(PLAN_SHAPE[entry], planKey);
198
+ return spec.none === undefined ? base : `${base} — ${spec.none} = none`;
199
+ };
200
+
201
+ /** The wire's shape table, in the plan table's words — what the model reads, and what the format is built from. */
202
+ export const PLAN_WIRE_SHAPE: Readonly<Record<`${PlanWireEntry}`, ShapeEntry>> = Object.fromEntries(
203
+ Object.entries(WIRE_KEYS).map(([entry, keys]) => [entry, Object.fromEntries(Object.entries(keys).map(([k, spec]) => [k, wireText(spec)]))]),
204
+ ) as Record<`${PlanWireEntry}`, ShapeEntry>;
205
+
206
+ /** Each part's top-level keys, from the ONE wire table. */
207
+ export const PLAN_PART_KEYS: Readonly<Record<PlanPart, ReadonlyArray<string>>> = Object.fromEntries(
208
+ PLAN_PARTS.map(part => [part, Object.entries(WIRE_KEYS.plan).filter(([, spec]) => spec.part === part).map(([k]) => k)]),
209
+ ) as Record<PlanPart, string[]>;
210
+
211
+ /** The keys of a shape entry, but these. */
212
+ const shapeWithout = (entry: ShapeEntry, keys: ReadonlyArray<string>): ShapeEntry =>
213
+ Object.fromEntries(Object.entries(entry).filter(([k]) => !keys.includes(k.replace(/\?$/, ''))));
214
+
215
+ /**
216
+ * The part a plan path is the answer of (`contentTypes[1].seed.title` → structure, `pages[0].sections[2]` → pages). A
217
+ * path no part writes — the root (the composer), or a key the generation decides — is the LAST part's: it is written
218
+ * knowing everything before it.
219
+ */
220
+ export function planPartOfPath(path: string): PlanPart {
221
+ const top = path.split(/[.[]/, 1)[0];
222
+ return PLAN_PARTS.find(part => PLAN_PART_KEYS[part].includes(top)) ?? PLAN_PARTS[PLAN_PARTS.length - 1];
223
+ }
224
+
225
+ /** Plan keys neither carried by a wire key nor derived — always empty (a spec and the builder hold it). */
226
+ export function planWireGaps(): string[] {
227
+ const carried = new Set(Object.values(WIRE_KEYS).flatMap(keys => Object.values(keys).flatMap(spec => spec.refs)));
228
+ const gaps: string[] = [];
229
+ for (const [entry, shape] of Object.entries(PLAN_SHAPE)) {
230
+ if (PLAN_WIRE_DERIVED[entry]) continue;
231
+ for (const raw of Object.keys(shape)) {
232
+ const ref = `${entry}.${raw.replace(/\?$/, '')}`;
233
+ if (!carried.has(ref) && !PLAN_WIRE_DERIVED[ref]) gaps.push(ref);
234
+ }
235
+ }
236
+ return gaps;
237
+ }
238
+
239
+ export class PlanWireShapeError extends Error {}
240
+
241
+ // ── The format ─────────────────────────────────────────────────────────────────────────────────────────────────────
242
+
243
+ const unique = (values: ReadonlyArray<string>) => [...new Set(values)];
244
+ const NONE = '';
245
+ const orNone = (values: ReadonlyArray<string>): JsonSchema => jsonOneOf([NONE, ...values]);
246
+ const closedOrText = (values: ReadonlyArray<string>): JsonSchema => (values.length ? jsonOneOf(values) : jsonText());
247
+ const shaped = (entry: `${PlanWireEntry}`, properties: Record<string, JsonSchema>) => jsonShapedObject(PLAN_WIRE_SHAPE[entry], properties, `wire ${entry}`);
248
+
249
+ /** The patterns a page section may use (every non-global one), in library order. */
250
+ export function sectionPatternIds(): string[] {
251
+ return Object.entries(SITE_PATTERNS).filter(([, p]) => !p.global).map(([id]) => id);
252
+ }
253
+
254
+ const sectionPatterns = () => sectionPatternIds().map(id => SITE_PATTERNS[id]);
255
+ const globalIds = (position: `${PatternGlobalPosition}`) => Object.entries(SITE_PATTERNS).filter(([, p]) => p.global === position).map(([id]) => id);
256
+
257
+ export function wireFieldJsonSchema(): JsonSchema {
258
+ return shaped(PlanWireEntry.Field, { name: jsonText(), type: jsonText(), controller: jsonText(), required: jsonBoolean(), note: jsonText() });
259
+ }
260
+
261
+ /** What an object of the wire travels without when its children are written as their own entries (a plan edit's). */
262
+ export interface WireOptions {
263
+ /** false: a content type without its `fields`, a page without its `sections` (each added by an edit after it). */
264
+ children?: boolean;
265
+ }
266
+
267
+ /** The keys an entry leaves out under `options`. */
268
+ const CHILD_KEYS: Readonly<Partial<Record<`${PlanWireEntry}`, string>>> = { contentType: 'fields', page: 'sections' };
269
+ const childless = (entry: `${PlanWireEntry}`, options: WireOptions) => (options.children === false && CHILD_KEYS[entry] ? [CHILD_KEYS[entry]!] : []);
270
+
271
+ /** The shape an entry is written in, under `options`. */
272
+ function wireEntryShape(entry: `${PlanWireEntry}`, options: WireOptions = {}): ShapeEntry {
273
+ return shapeWithout(PLAN_WIRE_SHAPE[entry], childless(entry, options));
274
+ }
275
+
276
+ const shapedAs = (entry: `${PlanWireEntry}`, options: WireOptions, properties: Record<string, JsonSchema>) => {
277
+ const left = childless(entry, options);
278
+ return jsonShapedObject(wireEntryShape(entry, options), Object.fromEntries(Object.entries(properties).filter(([k]) => !left.includes(k))), `wire ${entry}`);
279
+ };
280
+
281
+ export function wireContentTypeJsonSchema(options: WireOptions = {}): JsonSchema {
282
+ const value = shaped(PlanWireEntry.SeedValue, { field: jsonText(), value: jsonAnyOf([jsonText(), jsonListOf(jsonText())]) });
283
+ return shapedAs(PlanWireEntry.ContentType, options, {
284
+ name: jsonText(),
285
+ label: jsonText(),
286
+ roles: jsonListOf(shaped(PlanWireEntry.RoleField, { role: jsonOneOf(ROLE_KEYS), field: jsonText() })),
287
+ fields: jsonListOf(wireFieldJsonSchema()),
288
+ seed: shaped(PlanWireEntry.Seed, { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean(), values: jsonListOf(value) }),
289
+ });
290
+ }
291
+
292
+ export function wireDataSourceJsonSchema(): JsonSchema {
293
+ return shaped(PlanWireEntry.DataSource, {
294
+ alias: jsonText(),
295
+ type: jsonOneOf(Object.values(PlanDataSourceType)),
296
+ mode: orNone(Object.values(PlanDataSourceMode)),
297
+ contentType: jsonText(),
298
+ limit: jsonInteger(),
299
+ filterField: jsonText(),
300
+ routeParam: jsonText(),
301
+ of: jsonText(),
302
+ op: orNone(Object.values(RelativeOp)),
303
+ within: jsonText(),
304
+ });
305
+ }
306
+
307
+ export function wireSectionJsonSchema(): JsonSchema {
308
+ const patterns = sectionPatterns();
309
+ return shaped(PlanWireEntry.Section, {
310
+ pattern: jsonOneOf(sectionPatternIds()),
311
+ slots: jsonListOf(shaped(PlanWireEntry.SlotText, { slot: closedOrText(unique(patterns.flatMap(patternSlots))), text: jsonText() })),
312
+ links: jsonListOf(shaped(PlanWireEntry.SlotLink, { slot: closedOrText(unique(patterns.flatMap(patternLinkSlots))), route: jsonText() })),
313
+ binding: jsonText(),
314
+ without: jsonListOf(closedOrText(unique(patterns.flatMap(patternOptionalParts)))),
315
+ form: orNone(FORM_PRESETS.map(p => p.id)),
316
+ enter: orNone(ANIMATE_IDS),
317
+ });
318
+ }
319
+
320
+ export function wirePageJsonSchema(options: WireOptions = {}): JsonSchema {
321
+ return shapedAs(PlanWireEntry.Page, options, {
322
+ key: jsonText(),
323
+ name: jsonText(),
324
+ route: jsonText(),
325
+ inNav: jsonBoolean(),
326
+ archetype: orNone(Object.keys(PAGE_ARCHETYPES)),
327
+ need: orNone(SITE_NEED_IDS),
328
+ template: jsonText(),
329
+ routeSource: jsonText(),
330
+ seo: shaped(PlanWireEntry.PageSeo, { title: jsonText(), description: jsonText() }),
331
+ sections: jsonListOf(wireSectionJsonSchema()),
332
+ });
333
+ }
334
+
335
+ const PLAN_TOP_SCHEMAS: Readonly<Record<string, () => JsonSchema>> = {
336
+ business: () => shaped(PlanWireEntry.Business, { name: jsonText(), tagline: jsonText() }),
337
+ contentTypes: () => jsonListOf(wireContentTypeJsonSchema()),
338
+ dataSources: () => jsonListOf(wireDataSourceJsonSchema()),
339
+ pages: () => jsonListOf(wirePageJsonSchema()),
340
+ globals: () => shaped(PlanWireEntry.Globals, {
341
+ [GlobalSlot.Header]: jsonOneOf(globalIds(PatternGlobalPosition.Before)),
342
+ [GlobalSlot.Footer]: jsonOneOf(globalIds(PatternGlobalPosition.After)),
343
+ }),
344
+ };
345
+
346
+ /** The plan's top-level shape for one part (its keys only) — what that part's request shows and is built from. */
347
+ export function planWirePartShape(part: PlanPart): ShapeEntry {
348
+ return Object.fromEntries(Object.entries(PLAN_WIRE_SHAPE.plan).filter(([k]) => PLAN_PART_KEYS[part].includes(k)));
349
+ }
350
+
351
+ /**
352
+ * The format a model writes one part of a plan in — one request each (vectoriox/iox-cms#47). The same for every studio
353
+ * and run: nothing in it is pinned, so the provider compiles each part's grammar once.
354
+ */
355
+ export function planWirePartJsonSchema(part: PlanPart): JsonSchema {
356
+ const gaps = planWireGaps();
357
+ if (gaps.length) throw new PlanWireShapeError(`the plan wire neither carries nor derives ${gaps.join(', ')}`);
358
+ const missing = Object.keys(WIRE_KEYS.plan).filter(k => !PLAN_TOP_SCHEMAS[k] || !WIRE_KEYS.plan[k].part);
359
+ if (missing.length) throw new PlanWireShapeError(`the plan wire's ${missing.join(', ')} has no part or no format`);
360
+ return jsonShapedObject(planWirePartShape(part), Object.fromEntries(PLAN_PART_KEYS[part].map(k => [k, PLAN_TOP_SCHEMAS[k]()])), `wire plan ${part}`);
361
+ }
362
+
363
+ // ── The wire → the plan ────────────────────────────────────────────────────────────────────────────────────────────
364
+
365
+ /** What the plan takes from the generation, not from the model. */
366
+ export interface PlanWireContext {
367
+ design: DesignChoices;
368
+ source: SitePlanSource;
369
+ /** The owner's own facts (the analysis's, as corrected), verbatim. */
370
+ facts?: Readonly<Partial<Record<FactKey, string>>>;
371
+ locale?: `${SiteLocale}`;
372
+ }
373
+
374
+ /** A value standing for "absent": `''` (or nothing at all). */
375
+ const isNone = (v: unknown) => v === undefined || v === NONE;
376
+ const optional = (name: string, v: unknown, none: (v: unknown) => boolean = isNone): PlainObject => (none(v) ? {} : { [name]: v });
377
+ const emptyList = (v: unknown) => v === undefined || (Array.isArray(v) && v.length === 0);
378
+
379
+ /** `[{ <keyName>, <valueName> }]` → `{ key: value }`; anything else is left for the gate to refuse. */
380
+ function listToMap(list: unknown, keyName: string, valueName: string): unknown {
381
+ if (!Array.isArray(list)) return list;
382
+ const out: PlainObject = {};
383
+ for (const entry of list) {
384
+ if (!isPlainObject(entry) || typeof entry[keyName] !== 'string') return list;
385
+ out[entry[keyName] as string] = entry[valueName];
386
+ }
387
+ return out;
388
+ }
389
+
390
+ const mapToList = (map: Readonly<Record<string, unknown>> | undefined, keyName: string, valueName: string) =>
391
+ Object.entries(map ?? {}).map(([k, v]) => ({ [keyName]: k, [valueName]: v }));
392
+
393
+ const each = (list: unknown, expand: (v: unknown) => unknown) => (Array.isArray(list) ? list.map(expand) : list);
394
+
395
+ export function expandWireField(wire: unknown): ContentFieldPlan | unknown {
396
+ if (!isPlainObject(wire)) return wire;
397
+ const { required, note, ...rest } = wire;
398
+ return { ...rest, ...optional('required', required, v => v === undefined || v === false), ...optional('note', note) };
399
+ }
400
+
401
+ export function expandWireContentType(wire: unknown, options: WireOptions = {}): ContentTypePlan | unknown {
402
+ if (!isPlainObject(wire)) return wire;
403
+ const { roles, fields, seed, ...rest } = options.children === false ? { fields: [], ...wire } : wire;
404
+ const out: PlainObject = { ...rest, roles: listToMap(roles, 'role', 'field'), fields: each(fields, expandWireField) };
405
+ if (!isPlainObject(seed)) return seed === undefined ? out : { ...out, seed };
406
+ const { values, ...fixed } = seed;
407
+ if (fixed['count'] === 0 && emptyList(values)) return out;
408
+ const map = listToMap(values ?? [], 'field', 'value');
409
+ return { ...out, seed: isPlainObject(map) ? { ...fixed, ...map } : { ...fixed, values } };
410
+ }
411
+
412
+ export function expandWireDataSource(wire: unknown): DataSourcePlan | unknown {
413
+ if (!isPlainObject(wire)) return wire;
414
+ const { alias, type, mode, contentType, limit, filterField, routeParam, of, op, within, ...rest } = wire;
415
+ if (type === PlanDataSourceType.Cms) {
416
+ const filter = isNone(filterField) && isNone(routeParam) ? {} : { filterByRouteParam: { field: filterField, routeParam } };
417
+ return { ...rest, alias, type, mode, contentType, ...optional('limit', limit, v => v === undefined || v === 0), ...filter };
418
+ }
419
+ if (type === PlanDataSourceType.Relative) return { ...rest, alias, type, of, op, within };
420
+ return wire;
421
+ }
422
+
423
+ export function expandWireSection(wire: unknown): PageSectionPlan | unknown {
424
+ if (!isPlainObject(wire)) return wire;
425
+ const { slots, links, binding, without, form, enter, ...rest } = wire;
426
+ return {
427
+ ...rest,
428
+ ...optional('slots', listToMap(slots, 'slot', 'text'), v => emptyList(slots) || v === undefined),
429
+ ...optional('links', listToMap(links, 'slot', 'route'), v => emptyList(links) || v === undefined),
430
+ ...optional('binding', binding),
431
+ ...optional('without', without, emptyList),
432
+ ...optional('form', form),
433
+ ...optional('enter', enter),
434
+ };
435
+ }
436
+
437
+ export function expandWirePage(wire: unknown, options: WireOptions = {}): PagePlan | unknown {
438
+ if (!isPlainObject(wire)) return wire;
439
+ const { archetype, need, template, routeSource, seo, sections, ...rest } = options.children === false ? { sections: [], ...wire } : wire;
440
+ const words = isPlainObject(seo) ? { ...optional('title', seo['title']), ...optional('description', seo['description']) } : seo;
441
+ return {
442
+ ...rest,
443
+ ...(wire['route'] === '/' ? { isHome: true } : {}),
444
+ ...optional('archetype', archetype),
445
+ ...optional('need', need),
446
+ ...optional('template', template),
447
+ ...optional('routeSource', routeSource),
448
+ ...optional('seo', words, v => v === undefined || (isPlainObject(v) && Object.keys(v).length === 0)),
449
+ sections: each(sections, expandWireSection),
450
+ };
451
+ }
452
+
453
+ function expandWireBusiness(wire: unknown, facts: PlanWireContext['facts']): unknown {
454
+ if (!isPlainObject(wire)) return wire;
455
+ const { tagline, ...rest } = wire;
456
+ return { ...rest, ...optional('tagline', tagline), ...(facts && Object.keys(facts).length ? { facts: { ...facts } } : {}) };
457
+ }
458
+
459
+ /** A plan on the wire, by part — what each request answered. A part not written yet is absent. */
460
+ export type PlanWireParts = Partial<Record<PlanPart, unknown>>;
461
+
462
+ /**
463
+ * A model's plan (its parts, on the wire) + what the generation decides (the look, the run, the language, the owner's
464
+ * facts) → the Site Plan. Pure. Translates, never repairs: whatever an answer got wrong is still wrong, for
465
+ * `validateSitePlan` — a part that is not an object is the result itself, a part not written yet leaves its keys out
466
+ * (the validator then names them, at their own paths).
467
+ */
468
+ export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext): SitePlan | unknown {
469
+ const written = PLAN_PARTS.filter(part => parts[part] !== undefined).map(part => parts[part]);
470
+ const odd = written.find(value => !isPlainObject(value));
471
+ if (odd !== undefined) return odd;
472
+ const wire: PlainObject = Object.assign({}, ...(written as PlainObject[]));
473
+ const { business, contentTypes, dataSources, pages, ...rest } = wire;
474
+ return {
475
+ ...rest,
476
+ version: SITE_PLAN_VERSION,
477
+ source: { ...context.source },
478
+ business: expandWireBusiness(business, context.facts),
479
+ ...(context.locale ? { locale: context.locale } : {}),
480
+ design: { ...context.design },
481
+ contentTypes: each(contentTypes, expandWireContentType),
482
+ dataSources: each(dataSources, expandWireDataSource),
483
+ pages: each(pages, expandWirePage),
484
+ };
485
+ }
486
+
487
+ // ── The plan → the wire ────────────────────────────────────────────────────────────────────────────────────────────
488
+
489
+ export function fieldToWire(field: ContentFieldPlan): PlainObject {
490
+ return { name: field.name, type: field.type, controller: field.controller, required: field.required ?? false, note: field.note ?? NONE };
491
+ }
492
+
493
+ export function contentTypeToWire(type: ContentTypePlan, options: WireOptions = {}): PlainObject {
494
+ const played = type.roles as Readonly<Record<string, string | undefined>>;
495
+ const roles = ROLE_KEYS.filter(role => played[role]).map(role => ({ role, field: played[role] }));
496
+ const { count = 0, sample = false, isPublished = false, ...values } = type.seed ?? {};
497
+ return {
498
+ name: type.name, label: type.label, roles, ...(options.children === false ? {} : { fields: type.fields.map(fieldToWire) }),
499
+ seed: { count, sample, isPublished, values: mapToList(values, 'field', 'value') },
500
+ };
501
+ }
502
+
503
+ export function dataSourceToWire(source: DataSourcePlan): PlainObject {
504
+ const blank = { alias: source.alias, type: source.type, mode: NONE, contentType: NONE, limit: 0, filterField: NONE, routeParam: NONE, of: NONE, op: NONE, within: NONE };
505
+ if (source.type === PlanDataSourceType.Relative) return { ...blank, of: source.of, op: source.op, within: source.within };
506
+ return {
507
+ ...blank, mode: source.mode, contentType: source.contentType, limit: source.limit ?? 0,
508
+ filterField: source.filterByRouteParam?.field ?? NONE, routeParam: source.filterByRouteParam?.routeParam ?? NONE,
509
+ };
510
+ }
511
+
512
+ export function sectionToWire(section: PageSectionPlan): PlainObject {
513
+ return {
514
+ pattern: section.pattern, slots: mapToList(section.slots, 'slot', 'text'), links: mapToList(section.links, 'slot', 'route'),
515
+ binding: section.binding ?? NONE, without: [...(section.without ?? [])], form: section.form ?? NONE, enter: section.enter ?? NONE,
516
+ };
517
+ }
518
+
519
+ export function pageToWire(page: PagePlan, options: WireOptions = {}): PlainObject {
520
+ return {
521
+ key: page.key, name: page.name, route: page.route, inNav: page.inNav ?? false, archetype: page.archetype ?? NONE, need: page.need ?? NONE,
522
+ template: page.template ?? NONE, routeSource: page.routeSource ?? NONE,
523
+ seo: { title: page.seo?.title ?? NONE, description: page.seo?.description ?? NONE },
524
+ ...(options.children === false ? {} : { sections: page.sections.map(sectionToWire) }),
525
+ };
526
+ }
527
+
528
+ const PLAN_TO_WIRE: Readonly<Record<string, (plan: SitePlan) => unknown>> = {
529
+ business: plan => ({ name: plan.business.name, tagline: plan.business.tagline ?? NONE }),
530
+ contentTypes: plan => plan.contentTypes.map(type => contentTypeToWire(type)),
531
+ dataSources: plan => plan.dataSources.map(dataSourceToWire),
532
+ pages: plan => plan.pages.map(page => pageToWire(page)),
533
+ globals: plan => ({ ...plan.globals }),
534
+ };
535
+
536
+ /** One part of the plan on the wire: what that part's request would have answered. */
537
+ export function planPartToWire(plan: SitePlan, part: PlanPart): PlainObject {
538
+ return Object.fromEntries(PLAN_PART_KEYS[part].map(k => [k, PLAN_TO_WIRE[k](plan)]));
539
+ }
540
+
541
+ /** The plan → the wire, by part: what a model would have written (the change prompt shows it; fakes and evals write it). */
542
+ export function planToWire(plan: SitePlan): Record<PlanPart, PlainObject> {
543
+ return Object.fromEntries(PLAN_PARTS.map(part => [part, planPartToWire(plan, part)])) as Record<PlanPart, PlainObject>;
544
+ }
@@ -27,7 +27,8 @@ export * from './plan-data';
27
27
  export * from './showcase';
28
28
  export * from './site-build-run';
29
29
  export * from './ai-run';
30
- export * from './site-plan-schema';
30
+ export * from './plan-wire';
31
+ export * from './strict-schema';
31
32
  export * from './plan-edits';
32
33
  export * from './plan-words';
33
34
  export * from './audit-site';