@vectoriox/iox-ui 5.59.1 → 5.61.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,458 @@
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): what a model writes when it plans a site — a compact format inside
15
+ * the Claude API's structured-output limits (≤ 24 optional parameters and ≤ 16 unions per request; `strict-schema.ts`).
16
+ * The Site Plan itself (`SPEC_SITE_PLAN_SHAPE`) has 213 optional keys once every pattern is a branch, so the model never
17
+ * writes it: it writes this, and ONE pure `expandPlanWire` turns it into the Site Plan that `validateSitePlan` and the
18
+ * stage's gate judge exactly as before.
19
+ *
20
+ * - **Every key required.** A plan key that may be absent is written with its stated "none" — `''`, `0`, `false`, `[]`.
21
+ * - **Closed lists as enums** (patterns, page types, needs, slots, parts, forms, entrances, header/footer), from the
22
+ * contract — never per-pattern branches: the gate refuses a slot or a part the chosen pattern does not have, with its path.
23
+ * - **Maps as lists:** slots `[{ slot, text }]`, links `[{ slot, route }]`, seed values `[{ field, value }]`, roles fixed.
24
+ * - **One data source object** for both kinds; `type` says which keys are read (the other kind's hold their none).
25
+ * - **What the model does not decide is not on the wire** (`PLAN_WIRE_DERIVED`): the look (the owner's pick), the run,
26
+ * the language (the reading's), the facts (the owner's own, verbatim), the version, the home page (the page at `/`).
27
+ * So the format is the same for every studio and every run — one compiled grammar, cached by the provider.
28
+ *
29
+ * ONE source: every wire key names the plan keys it carries (`refs`) and is described in the plan table's own words, and
30
+ * every plan key is either carried or derived (`planWireGaps()`); a key the plan shape gains breaks the builder until it
31
+ * is placed. The expanders translate, never repair: a value the model got wrong stays wrong, for the gate to refuse.
32
+ */
33
+
34
+ export const PLAN_WIRE_SCHEMA_NAME = 'site_plan';
35
+
36
+ export enum PlanWireEntry {
37
+ Plan = 'plan',
38
+ Business = 'business',
39
+ ContentType = 'contentType',
40
+ Roles = 'roles',
41
+ Field = 'field',
42
+ Seed = 'seed',
43
+ SeedValue = 'seedValue',
44
+ DataSource = 'dataSource',
45
+ Page = 'page',
46
+ PageSeo = 'pageSeo',
47
+ Section = 'section',
48
+ SlotText = 'slotText',
49
+ SlotLink = 'slotLink',
50
+ Globals = 'globals',
51
+ }
52
+
53
+ type PlanShapeName = keyof typeof SPEC_SITE_PLAN_SHAPE;
54
+
55
+ interface WireKey {
56
+ /** The plan keys it carries (`entry.key`, `[field]` for a dynamic key). */
57
+ refs: ReadonlyArray<string>;
58
+ /** Its own words, when the first ref's do not fit the wire. */
59
+ text?: string;
60
+ /** What stands for "absent" (an optional plan key). */
61
+ none?: string;
62
+ }
63
+
64
+ const PLAN_SHAPE = SPEC_SITE_PLAN_SHAPE as unknown as Readonly<Record<string, ShapeEntry>>;
65
+ const key = (ref: string, none?: string): WireKey => ({ refs: [ref], ...(none !== undefined ? { none } : {}) });
66
+
67
+ const WIRE_KEYS: Readonly<Record<`${PlanWireEntry}`, Readonly<Record<string, WireKey>>>> = {
68
+ plan: {
69
+ business: key('plan.business'),
70
+ contentTypes: key('plan.contentTypes'),
71
+ dataSources: { refs: ['plan.dataSources'], text: 'dataSource[] — merged by alias into the org library' },
72
+ pages: key('plan.pages'),
73
+ globals: key('plan.globals'),
74
+ },
75
+ business: {
76
+ name: key('business.name'),
77
+ tagline: key('business.tagline', "''"),
78
+ },
79
+ contentType: {
80
+ name: key('contentType.name'),
81
+ label: key('contentType.label'),
82
+ roles: key('contentType.roles'),
83
+ fields: key('contentType.fields'),
84
+ seed: { refs: ['contentType.seed'], text: 'seed — the sample items', none: 'count 0 and no values' },
85
+ },
86
+ roles: {
87
+ title: key('roles.title'),
88
+ summary: key('roles.summary', "''"),
89
+ image: key('roles.image', "''"),
90
+ meta: key('roles.meta', "''"),
91
+ date: key('roles.date', "''"),
92
+ gallery: key('roles.gallery', "''"),
93
+ },
94
+ field: {
95
+ name: key('field.name'),
96
+ type: key('field.type'),
97
+ controller: key('field.controller'),
98
+ required: { refs: ['field.required'], text: 'boolean — the field must be filled', none: 'false' },
99
+ note: key('field.note', "''"),
100
+ },
101
+ seed: {
102
+ count: key('seed.count'),
103
+ sample: key('seed.sample'),
104
+ isPublished: key('seed.isPublished'),
105
+ values: { refs: ['seed.[field]'], text: "seedValue[] — one per field: the samples' value for it" },
106
+ },
107
+ seedValue: {
108
+ field: { refs: ['seed.[field]'], text: 'string — a field of the type' },
109
+ value: { refs: ['seed.[field]'], text: `string | string[] — ${describeShapeKey(PLAN_SHAPE['seed'], '[field]')} (a list for an array field)` },
110
+ },
111
+ dataSource: {
112
+ alias: { refs: ['cmsDataSource.alias', 'relativeDataSource.alias'] },
113
+ type: { refs: ['cmsDataSource.type', 'relativeDataSource.type'], text: "'cms' | 'relative' — which of the keys below are read" },
114
+ mode: key('cmsDataSource.mode', "'' (relative)"),
115
+ contentType: key('cmsDataSource.contentType', "'' (relative)"),
116
+ limit: key('cmsDataSource.limit', '0 (all, or relative)'),
117
+ filterField: { refs: ['cmsDataSource.filterByRouteParam'], text: "string — single only: the field that holds the route param's value (e.g. slug)", none: "''" },
118
+ routeParam: { refs: ['cmsDataSource.filterByRouteParam'], text: 'string — single only: the route param that picks the item (the :param of its page route)', none: "''" },
119
+ of: key('relativeDataSource.of', "'' (cms)"),
120
+ op: key('relativeDataSource.op', "'' (cms)"),
121
+ within: key('relativeDataSource.within', "'' (cms)"),
122
+ },
123
+ page: {
124
+ key: key('page.key'),
125
+ name: key('page.name'),
126
+ route: { refs: ['page.route'], text: `${describeShapeKey(PLAN_SHAPE['page'], 'route')}; the page at "/" is the home page` },
127
+ inNav: key('page.inNav', 'false'),
128
+ archetype: key('page.archetype', "''"),
129
+ need: key('page.need', "''"),
130
+ template: key('page.template', "''"),
131
+ routeSource: key('page.routeSource', "''"),
132
+ seo: key('page.seo'),
133
+ sections: key('page.sections'),
134
+ },
135
+ pageSeo: {
136
+ title: key('pageSeo.title', "''"),
137
+ description: key('pageSeo.description', "''"),
138
+ },
139
+ section: {
140
+ pattern: key('section.pattern'),
141
+ slots: { refs: ['section.slots'], text: "slotText[] — the pattern's declared slots it fills, each within its budget (site.patterns … max × site.copyLengths)", none: '[]' },
142
+ links: { refs: ['section.links'], text: 'slotLink[] — re-points a linked slot (a button, a link) to another page route of the plan', none: '[]' },
143
+ binding: key('section.binding', "''"),
144
+ without: key('section.without', '[]'),
145
+ form: key('section.form', "''"),
146
+ enter: key('section.enter', "'' (the look's)"),
147
+ },
148
+ slotText: {
149
+ slot: { refs: ['section.slots'], text: "string — one of the pattern's slots" },
150
+ text: { refs: ['section.slots'], text: "string — the words, in the owner's language and voice" },
151
+ },
152
+ slotLink: {
153
+ slot: { refs: ['section.links'], text: "string — one of the pattern's linked slots" },
154
+ route: { refs: ['section.links'], text: 'string — a page route of the plan (no :param)' },
155
+ },
156
+ globals: {
157
+ header: key('globals.header'),
158
+ footer: key('globals.footer'),
159
+ },
160
+ };
161
+
162
+ /** The plan keys the model does not write, and where the expander takes each from. */
163
+ export const PLAN_WIRE_DERIVED: Readonly<Record<string, string>> = {
164
+ 'plan.version': `always '${SITE_PLAN_VERSION}'`,
165
+ 'plan.source': 'this run (the context)',
166
+ 'plan.locale': "the reading's language (the context)",
167
+ 'plan.design': "the owner's pick (the context)",
168
+ 'business.facts': "the owner's own facts, verbatim (the context)",
169
+ 'page.isHome': 'the page at "/"',
170
+ 'page.required': "a template page's (the wizard's Pages step), never the AI's",
171
+ sourceTemplate: 'the AI never writes a template plan',
172
+ sourceAi: 'this run (the context)',
173
+ design: "the owner's pick (the context)",
174
+ };
175
+
176
+ const wireText = (spec: WireKey): string => {
177
+ const [entry, planKey] = spec.refs[0].split('.');
178
+ const base = spec.text ?? describeShapeKey(PLAN_SHAPE[entry], planKey);
179
+ return spec.none === undefined ? base : `${base} — ${spec.none} = none`;
180
+ };
181
+
182
+ /** The wire's shape table, in the plan table's words — what the model reads, and what the format is built from. */
183
+ export const PLAN_WIRE_SHAPE: Readonly<Record<`${PlanWireEntry}`, ShapeEntry>> = Object.fromEntries(
184
+ Object.entries(WIRE_KEYS).map(([entry, keys]) => [entry, Object.fromEntries(Object.entries(keys).map(([k, spec]) => [k, wireText(spec)]))]),
185
+ ) as Record<`${PlanWireEntry}`, ShapeEntry>;
186
+
187
+ /** Plan keys neither carried by a wire key nor derived — always empty (a spec and the builder hold it). */
188
+ export function planWireGaps(): string[] {
189
+ const carried = new Set(Object.values(WIRE_KEYS).flatMap(keys => Object.values(keys).flatMap(spec => spec.refs)));
190
+ const gaps: string[] = [];
191
+ for (const [entry, shape] of Object.entries(PLAN_SHAPE)) {
192
+ if (PLAN_WIRE_DERIVED[entry]) continue;
193
+ for (const raw of Object.keys(shape)) {
194
+ const ref = `${entry}.${raw.replace(/\?$/, '')}`;
195
+ if (!carried.has(ref) && !PLAN_WIRE_DERIVED[ref]) gaps.push(ref);
196
+ }
197
+ }
198
+ return gaps;
199
+ }
200
+
201
+ export class PlanWireShapeError extends Error {}
202
+
203
+ // ── The format ─────────────────────────────────────────────────────────────────────────────────────────────────────
204
+
205
+ const unique = (values: ReadonlyArray<string>) => [...new Set(values)];
206
+ const NONE = '';
207
+ const orNone = (values: ReadonlyArray<string>): JsonSchema => jsonOneOf([NONE, ...values]);
208
+ const closedOrText = (values: ReadonlyArray<string>): JsonSchema => (values.length ? jsonOneOf(values) : jsonText());
209
+ const shaped = (entry: `${PlanWireEntry}`, properties: Record<string, JsonSchema>) => jsonShapedObject(PLAN_WIRE_SHAPE[entry], properties, `wire ${entry}`);
210
+
211
+ /** The patterns a page section may use (every non-global one), in library order. */
212
+ export function sectionPatternIds(): string[] {
213
+ return Object.entries(SITE_PATTERNS).filter(([, p]) => !p.global).map(([id]) => id);
214
+ }
215
+
216
+ const sectionPatterns = () => sectionPatternIds().map(id => SITE_PATTERNS[id]);
217
+ const globalIds = (position: `${PatternGlobalPosition}`) => Object.entries(SITE_PATTERNS).filter(([, p]) => p.global === position).map(([id]) => id);
218
+
219
+ export function wireFieldJsonSchema(): JsonSchema {
220
+ return shaped(PlanWireEntry.Field, { name: jsonText(), type: jsonText(), controller: jsonText(), required: jsonBoolean(), note: jsonText() });
221
+ }
222
+
223
+ export function wireContentTypeJsonSchema(): JsonSchema {
224
+ const roleKeys = shapeKeys(PLAN_WIRE_SHAPE.roles);
225
+ const value = shaped(PlanWireEntry.SeedValue, { field: jsonText(), value: jsonAnyOf([jsonText(), jsonListOf(jsonText())]) });
226
+ return shaped(PlanWireEntry.ContentType, {
227
+ name: jsonText(),
228
+ label: jsonText(),
229
+ roles: shaped(PlanWireEntry.Roles, Object.fromEntries(roleKeys.map(k => [k, jsonText()]))),
230
+ fields: jsonListOf(wireFieldJsonSchema()),
231
+ seed: shaped(PlanWireEntry.Seed, { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean(), values: jsonListOf(value) }),
232
+ });
233
+ }
234
+
235
+ export function wireDataSourceJsonSchema(): JsonSchema {
236
+ return shaped(PlanWireEntry.DataSource, {
237
+ alias: jsonText(),
238
+ type: jsonOneOf(Object.values(PlanDataSourceType)),
239
+ mode: orNone(Object.values(PlanDataSourceMode)),
240
+ contentType: jsonText(),
241
+ limit: jsonInteger(),
242
+ filterField: jsonText(),
243
+ routeParam: jsonText(),
244
+ of: jsonText(),
245
+ op: orNone(Object.values(RelativeOp)),
246
+ within: jsonText(),
247
+ });
248
+ }
249
+
250
+ export function wireSectionJsonSchema(): JsonSchema {
251
+ const patterns = sectionPatterns();
252
+ return shaped(PlanWireEntry.Section, {
253
+ pattern: jsonOneOf(sectionPatternIds()),
254
+ slots: jsonListOf(shaped(PlanWireEntry.SlotText, { slot: closedOrText(unique(patterns.flatMap(patternSlots))), text: jsonText() })),
255
+ links: jsonListOf(shaped(PlanWireEntry.SlotLink, { slot: closedOrText(unique(patterns.flatMap(patternLinkSlots))), route: jsonText() })),
256
+ binding: jsonText(),
257
+ without: jsonListOf(closedOrText(unique(patterns.flatMap(patternOptionalParts)))),
258
+ form: orNone(FORM_PRESETS.map(p => p.id)),
259
+ enter: orNone(ANIMATE_IDS),
260
+ });
261
+ }
262
+
263
+ export function wirePageJsonSchema(): JsonSchema {
264
+ return shaped(PlanWireEntry.Page, {
265
+ key: jsonText(),
266
+ name: jsonText(),
267
+ route: jsonText(),
268
+ inNav: jsonBoolean(),
269
+ archetype: orNone(Object.keys(PAGE_ARCHETYPES)),
270
+ need: orNone(SITE_NEED_IDS),
271
+ template: jsonText(),
272
+ routeSource: jsonText(),
273
+ seo: shaped(PlanWireEntry.PageSeo, { title: jsonText(), description: jsonText() }),
274
+ sections: jsonListOf(wireSectionJsonSchema()),
275
+ });
276
+ }
277
+
278
+ /** The format a model writes a plan in. The same for every studio and run: nothing in it is pinned. */
279
+ export function planWireJsonSchema(): JsonSchema {
280
+ const gaps = planWireGaps();
281
+ if (gaps.length) throw new PlanWireShapeError(`the plan wire neither carries nor derives ${gaps.join(', ')}`);
282
+ return shaped(PlanWireEntry.Plan, {
283
+ business: shaped(PlanWireEntry.Business, { name: jsonText(), tagline: jsonText() }),
284
+ contentTypes: jsonListOf(wireContentTypeJsonSchema()),
285
+ dataSources: jsonListOf(wireDataSourceJsonSchema()),
286
+ pages: jsonListOf(wirePageJsonSchema()),
287
+ globals: shaped(PlanWireEntry.Globals, {
288
+ [GlobalSlot.Header]: jsonOneOf(globalIds(PatternGlobalPosition.Before)),
289
+ [GlobalSlot.Footer]: jsonOneOf(globalIds(PatternGlobalPosition.After)),
290
+ }),
291
+ });
292
+ }
293
+
294
+ // ── The wire → the plan ────────────────────────────────────────────────────────────────────────────────────────────
295
+
296
+ /** What the plan takes from the generation, not from the model. */
297
+ export interface PlanWireContext {
298
+ design: DesignChoices;
299
+ source: SitePlanSource;
300
+ /** The owner's own facts (the analysis's, as corrected), verbatim. */
301
+ facts?: Readonly<Partial<Record<FactKey, string>>>;
302
+ locale?: `${SiteLocale}`;
303
+ }
304
+
305
+ /** A value standing for "absent": `''` (or nothing at all). */
306
+ const isNone = (v: unknown) => v === undefined || v === NONE;
307
+ const optional = (name: string, v: unknown, none: (v: unknown) => boolean = isNone): PlainObject => (none(v) ? {} : { [name]: v });
308
+ const emptyList = (v: unknown) => v === undefined || (Array.isArray(v) && v.length === 0);
309
+
310
+ /** `[{ <keyName>, <valueName> }]` → `{ key: value }`; anything else is left for the gate to refuse. */
311
+ function listToMap(list: unknown, keyName: string, valueName: string): unknown {
312
+ if (!Array.isArray(list)) return list;
313
+ const out: PlainObject = {};
314
+ for (const entry of list) {
315
+ if (!isPlainObject(entry) || typeof entry[keyName] !== 'string') return list;
316
+ out[entry[keyName] as string] = entry[valueName];
317
+ }
318
+ return out;
319
+ }
320
+
321
+ const mapToList = (map: Readonly<Record<string, unknown>> | undefined, keyName: string, valueName: string) =>
322
+ Object.entries(map ?? {}).map(([k, v]) => ({ [keyName]: k, [valueName]: v }));
323
+
324
+ const each = (list: unknown, expand: (v: unknown) => unknown) => (Array.isArray(list) ? list.map(expand) : list);
325
+
326
+ export function expandWireField(wire: unknown): ContentFieldPlan | unknown {
327
+ if (!isPlainObject(wire)) return wire;
328
+ const { required, note, ...rest } = wire;
329
+ return { ...rest, ...optional('required', required, v => v === undefined || v === false), ...optional('note', note) };
330
+ }
331
+
332
+ export function expandWireContentType(wire: unknown): ContentTypePlan | unknown {
333
+ if (!isPlainObject(wire)) return wire;
334
+ const { roles, fields, seed, ...rest } = wire;
335
+ const out: PlainObject = { ...rest, roles: isPlainObject(roles) ? Object.fromEntries(Object.entries(roles).filter(([, v]) => !isNone(v))) : roles, fields: each(fields, expandWireField) };
336
+ if (!isPlainObject(seed)) return seed === undefined ? out : { ...out, seed };
337
+ const { values, ...fixed } = seed;
338
+ if (fixed['count'] === 0 && emptyList(values)) return out;
339
+ const map = listToMap(values ?? [], 'field', 'value');
340
+ return { ...out, seed: isPlainObject(map) ? { ...fixed, ...map } : { ...fixed, values } };
341
+ }
342
+
343
+ export function expandWireDataSource(wire: unknown): DataSourcePlan | unknown {
344
+ if (!isPlainObject(wire)) return wire;
345
+ const { alias, type, mode, contentType, limit, filterField, routeParam, of, op, within, ...rest } = wire;
346
+ if (type === PlanDataSourceType.Cms) {
347
+ const filter = isNone(filterField) && isNone(routeParam) ? {} : { filterByRouteParam: { field: filterField, routeParam } };
348
+ return { ...rest, alias, type, mode, contentType, ...optional('limit', limit, v => v === undefined || v === 0), ...filter };
349
+ }
350
+ if (type === PlanDataSourceType.Relative) return { ...rest, alias, type, of, op, within };
351
+ return wire;
352
+ }
353
+
354
+ export function expandWireSection(wire: unknown): PageSectionPlan | unknown {
355
+ if (!isPlainObject(wire)) return wire;
356
+ const { slots, links, binding, without, form, enter, ...rest } = wire;
357
+ return {
358
+ ...rest,
359
+ ...optional('slots', listToMap(slots, 'slot', 'text'), v => emptyList(slots) || v === undefined),
360
+ ...optional('links', listToMap(links, 'slot', 'route'), v => emptyList(links) || v === undefined),
361
+ ...optional('binding', binding),
362
+ ...optional('without', without, emptyList),
363
+ ...optional('form', form),
364
+ ...optional('enter', enter),
365
+ };
366
+ }
367
+
368
+ export function expandWirePage(wire: unknown): PagePlan | unknown {
369
+ if (!isPlainObject(wire)) return wire;
370
+ const { archetype, need, template, routeSource, seo, sections, ...rest } = wire;
371
+ const words = isPlainObject(seo) ? { ...optional('title', seo['title']), ...optional('description', seo['description']) } : seo;
372
+ return {
373
+ ...rest,
374
+ ...(wire['route'] === '/' ? { isHome: true } : {}),
375
+ ...optional('archetype', archetype),
376
+ ...optional('need', need),
377
+ ...optional('template', template),
378
+ ...optional('routeSource', routeSource),
379
+ ...optional('seo', words, v => v === undefined || (isPlainObject(v) && Object.keys(v).length === 0)),
380
+ sections: each(sections, expandWireSection),
381
+ };
382
+ }
383
+
384
+ function expandWireBusiness(wire: unknown, facts: PlanWireContext['facts']): unknown {
385
+ if (!isPlainObject(wire)) return wire;
386
+ const { tagline, ...rest } = wire;
387
+ return { ...rest, ...optional('tagline', tagline), ...(facts && Object.keys(facts).length ? { facts: { ...facts } } : {}) };
388
+ }
389
+
390
+ /**
391
+ * A model's plan (the wire) + what the generation decides (the look, the run, the language, the owner's facts) → the
392
+ * Site Plan. Pure. Translates, never repairs: whatever the answer got wrong is still wrong, for `validateSitePlan`.
393
+ */
394
+ export function expandPlanWire(wire: unknown, context: PlanWireContext): SitePlan | unknown {
395
+ if (!isPlainObject(wire)) return wire;
396
+ const { business, contentTypes, dataSources, pages, ...rest } = wire;
397
+ return {
398
+ ...rest,
399
+ version: SITE_PLAN_VERSION,
400
+ source: { ...context.source },
401
+ business: expandWireBusiness(business, context.facts),
402
+ ...(context.locale ? { locale: context.locale } : {}),
403
+ design: { ...context.design },
404
+ contentTypes: each(contentTypes, expandWireContentType),
405
+ dataSources: each(dataSources, expandWireDataSource),
406
+ pages: each(pages, expandWirePage),
407
+ };
408
+ }
409
+
410
+ // ── The plan → the wire ────────────────────────────────────────────────────────────────────────────────────────────
411
+
412
+ export function fieldToWire(field: ContentFieldPlan): PlainObject {
413
+ return { name: field.name, type: field.type, controller: field.controller, required: field.required ?? false, note: field.note ?? NONE };
414
+ }
415
+
416
+ export function contentTypeToWire(type: ContentTypePlan): PlainObject {
417
+ const roles = Object.fromEntries(shapeKeys(PLAN_WIRE_SHAPE.roles).map(role => [role, (type.roles as Readonly<Record<string, string>>)[role] ?? NONE]));
418
+ const { count = 0, sample = false, isPublished = false, ...values } = type.seed ?? {};
419
+ return {
420
+ name: type.name, label: type.label, roles, fields: type.fields.map(fieldToWire),
421
+ seed: { count, sample, isPublished, values: mapToList(values, 'field', 'value') },
422
+ };
423
+ }
424
+
425
+ export function dataSourceToWire(source: DataSourcePlan): PlainObject {
426
+ const blank = { alias: source.alias, type: source.type, mode: NONE, contentType: NONE, limit: 0, filterField: NONE, routeParam: NONE, of: NONE, op: NONE, within: NONE };
427
+ if (source.type === PlanDataSourceType.Relative) return { ...blank, of: source.of, op: source.op, within: source.within };
428
+ return {
429
+ ...blank, mode: source.mode, contentType: source.contentType, limit: source.limit ?? 0,
430
+ filterField: source.filterByRouteParam?.field ?? NONE, routeParam: source.filterByRouteParam?.routeParam ?? NONE,
431
+ };
432
+ }
433
+
434
+ export function sectionToWire(section: PageSectionPlan): PlainObject {
435
+ return {
436
+ pattern: section.pattern, slots: mapToList(section.slots, 'slot', 'text'), links: mapToList(section.links, 'slot', 'route'),
437
+ binding: section.binding ?? NONE, without: [...(section.without ?? [])], form: section.form ?? NONE, enter: section.enter ?? NONE,
438
+ };
439
+ }
440
+
441
+ export function pageToWire(page: PagePlan): PlainObject {
442
+ return {
443
+ key: page.key, name: page.name, route: page.route, inNav: page.inNav ?? false, archetype: page.archetype ?? NONE, need: page.need ?? NONE,
444
+ template: page.template ?? NONE, routeSource: page.routeSource ?? NONE,
445
+ seo: { title: page.seo?.title ?? NONE, description: page.seo?.description ?? NONE }, sections: page.sections.map(sectionToWire),
446
+ };
447
+ }
448
+
449
+ /** The plan → the wire: what a model would have written (the plan edits' prompt shows it; fakes and evals write it). */
450
+ export function planToWire(plan: SitePlan): PlainObject {
451
+ return {
452
+ business: { name: plan.business.name, tagline: plan.business.tagline ?? NONE },
453
+ contentTypes: plan.contentTypes.map(contentTypeToWire),
454
+ dataSources: plan.dataSources.map(dataSourceToWire),
455
+ pages: plan.pages.map(pageToWire),
456
+ globals: { ...plan.globals },
457
+ };
458
+ }
@@ -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';
@@ -8,31 +8,63 @@ import { SitePlan } from './site-plan.model';
8
8
  * into the studio's own storage (never hot-linked), placed only in an EMPTY picture slot of a section that may show
9
9
  * one — never the owner's work (a collection, a detail page, a gallery: what the nine portfolio types sell is theirs),
10
10
  * never a person (testimonials, the team), never anywhere when the business type says `stock: false`. Its alt text is
11
- * the provider's description of what it shows (never a claim), and the credit line its licence requires is placed in
12
- * the footer. Pure: the build step does the copying.
11
+ * the provider's description of what it shows (never a claim), and the credit line its licence requires — when it
12
+ * requires one — is placed in the footer. Pure: the build step does the copying.
13
13
  */
14
14
 
15
15
  /** Where a stock photo comes from (the library's provider). */
16
16
  export enum StockProviderId {
17
17
  Magnific = 'magnific',
18
+ Pixabay = 'pixabay',
18
19
  }
19
20
 
20
- /** What a picture's licence asks of the site that shows it. */
21
+ /** Words and the address they link to. */
22
+ export interface StockLink {
23
+ line: string;
24
+ url: string;
25
+ }
26
+
27
+ /** What a provider's terms ask of IOX, per provider — the ONE place a credit line is decided. */
28
+ export interface StockProviderTerms {
29
+ name: string;
30
+ /** The line a site showing one of its pictures must carry (in the footer), or null when its licence asks none. */
31
+ credit: StockLink | null;
32
+ /** What to show wherever its SEARCH RESULTS are displayed — the operator's review of the library, never a site. */
33
+ sourceNotice: StockLink;
34
+ }
35
+
36
+ /**
37
+ * The providers' terms (AI plan §3g, I2; read 2026-10-25 / 2026-10-27). Magnific's free licence requires "Designed by
38
+ * Magnific", linked, next to the resource or in the site's footer. Pixabay's Content License: "You do not need to credit
39
+ * Pixabay or the contributor" — but its API asks to "show your users where the images and videos are from, whenever
40
+ * search results are displayed", which only the operator's review does.
41
+ */
42
+ export const STOCK_PROVIDER_TERMS: Readonly<Record<StockProviderId, StockProviderTerms>> = {
43
+ [StockProviderId.Magnific]: {
44
+ name: 'Magnific',
45
+ credit: { line: 'Designed by Magnific', url: 'https://www.magnific.com' },
46
+ sourceNotice: { line: 'Designed by Magnific', url: 'https://www.magnific.com' },
47
+ },
48
+ [StockProviderId.Pixabay]: {
49
+ name: 'Pixabay',
50
+ credit: null,
51
+ sourceNotice: { line: 'Images from Pixabay', url: 'https://pixabay.com/' },
52
+ },
53
+ };
54
+
55
+ /** Where a picture came from and under which licence — recorded with every library photo and every studio copy. */
21
56
  export interface StockCredit {
22
57
  provider: `${StockProviderId}`;
23
58
  /** The provider's id of the picture. */
24
59
  resourceId: string;
25
60
  /** The provider's page for it. */
26
61
  sourceUrl: string;
27
- /** The provider's own title — what the picture shows. */
62
+ /** The provider's own words for what the picture shows (Magnific's title, Pixabay's tags). */
28
63
  title: string;
29
64
  author: string;
30
- /** `freemium` — the free licence (premium pictures never enter the library). */
65
+ /** The licence it is used under, as the provider names it (Magnific `freemium`, Pixabay `content-license`). */
31
66
  licence: string;
32
67
  licenceUrl: string;
33
- /** The words the licence requires, and where they link. */
34
- line: string;
35
- lineUrl: string;
36
68
  }
37
69
 
38
70
  /** One picture of the platform library (`iox-stock/<type>/`), as its manifest lists it. */
@@ -105,10 +137,18 @@ export function applyStockPhotos(trees: ReadonlyArray<SerializedNode>, placed: R
105
137
  return changed;
106
138
  }
107
139
 
108
- /** The credit lines a set of photos needs — one per distinct line (a free Magnific photo: "Designed by Magnific"). */
109
- export function stockCreditLines(photos: ReadonlyArray<Pick<StockPhoto, 'credit'>>): Array<{ line: string; url: string }> {
140
+ /** The line a picture's licence requires on the site, or null when it requires none (`STOCK_PROVIDER_TERMS`). */
141
+ export function stockCreditLine(credit: Pick<StockCredit, 'provider'>): StockLink | null {
142
+ return STOCK_PROVIDER_TERMS[credit.provider as StockProviderId]?.credit ?? null;
143
+ }
144
+
145
+ /** The credit lines a set of photos needs — one per distinct line, none for a provider whose licence asks none. */
146
+ export function stockCreditLines(photos: ReadonlyArray<Pick<StockPhoto, 'credit'>>): StockLink[] {
110
147
  const seen = new Map<string, string>();
111
- for (const p of photos) if (!seen.has(p.credit.line)) seen.set(p.credit.line, p.credit.lineUrl);
148
+ for (const p of photos) {
149
+ const credit = stockCreditLine(p.credit);
150
+ if (credit && !seen.has(credit.line)) seen.set(credit.line, credit.url);
151
+ }
112
152
  return [...seen.entries()].map(([line, url]) => ({ line, url }));
113
153
  }
114
154
 
@@ -119,12 +159,12 @@ export const stockCreditNodeId = (index: number) => `stock-credit-${index + 1}`;
119
159
  * The credit line, as the builder would create a Link (a plain node the owner may move or restyle): the licence's words,
120
160
  * linking where it asks, opening in a new tab. Placed at the end of the footer — the place the licence names for a site.
121
161
  */
122
- export function stockCreditNode(index: number, credit: { line: string; url: string }): SerializedNode {
162
+ export function stockCreditNode(index: number, credit: StockLink): SerializedNode {
123
163
  return createSerializedNode(stockCreditNodeId(index), 'Link', { label: credit.line, linkType: 'external', url: credit.url, target: '_blank' }, 'link');
124
164
  }
125
165
 
126
166
  /** Append the credit lines to the footer root (once each). Returns how many it added. */
127
- export function addStockCredits(footer: SerializedNode, credits: ReadonlyArray<{ line: string; url: string }>): number {
167
+ export function addStockCredits(footer: SerializedNode, credits: ReadonlyArray<StockLink>): number {
128
168
  footer.children ??= [];
129
169
  let added = 0;
130
170
  credits.forEach((credit, i) => {