@vectoriox/iox-ui 5.60.0 → 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.
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +620 -260
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/generation-core/index.d.ts +132 -48
- package/package.json +1 -1
- package/src/generation-core/plan-edits.ts +156 -38
- package/src/generation-core/plan-wire.ts +458 -0
- package/src/generation-core/public-api.ts +2 -1
- package/src/generation-core/strict-schema.ts +96 -0
- package/src/generation-core/site-plan-schema.ts +0 -252
|
@@ -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 './
|
|
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';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { JsonSchema } from './json-schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a structured-output request costs the provider's grammar compiler, counted the way the Claude API counts it
|
|
5
|
+
* (platform.claude.com/docs/en/build-with-claude/structured-outputs, "Schema complexity limits", read 2026-10-27):
|
|
6
|
+
* "Total optional parameters across all strict tool schemas and JSON output schemas. Each parameter not listed in
|
|
7
|
+
* `required` counts toward this limit" (24); "Total parameters that use `anyOf` or type arrays (for example,
|
|
8
|
+
* `"type": ["string", "null"]`) across all strict schemas" (16); "Maximum number of tools with `strict: true`" (20).
|
|
9
|
+
* The limits are per REQUEST — every strict schema in it together. The ONE counter: the specs hold every schema iox-cms
|
|
10
|
+
* sends to it, and the fake Claude API refuses with it exactly as Claude does — the plan stage once sent 213 optional
|
|
11
|
+
* parameters and every suite stayed green because the fake accepted anything (vectoriox/iox-cms#45).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const STRICT_SCHEMA_LIMITS = { strictTools: 20, optional: 24, unions: 16 } as const;
|
|
15
|
+
|
|
16
|
+
/** What a schema iox-cms sends may use — half of each limit, so a step can gain a field (or a strict tool) without a 400. */
|
|
17
|
+
export const STRICT_SCHEMA_BUDGET = { optional: 12, unions: 8 } as const;
|
|
18
|
+
|
|
19
|
+
export interface StrictSchemaComplexity {
|
|
20
|
+
/** Properties not listed in their object's `required`, every object of every schema. */
|
|
21
|
+
optional: number;
|
|
22
|
+
/** Nodes using `anyOf` or a type array. */
|
|
23
|
+
unions: number;
|
|
24
|
+
/** Tools with `strict: true` (a JSON output format is not a tool). */
|
|
25
|
+
strictTools: number;
|
|
26
|
+
/** What the strict mode does not support, each with its path (`pages.items.properties.route: minLength`). */
|
|
27
|
+
unsupported: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The keywords the docs list as not supported ("Numerical constraints", "String constraints", "Array constraints beyond `minItems` of 0 or 1"). */
|
|
31
|
+
const UNSUPPORTED_KEYWORDS: ReadonlyArray<string> = [
|
|
32
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'minLength', 'maxLength', 'maxItems', 'uniqueItems', 'contains',
|
|
33
|
+
'minContains', 'maxContains', 'prefixItems', 'patternProperties', 'propertyNames', 'dependentSchemas', 'if', 'then', 'else', 'not',
|
|
34
|
+
];
|
|
35
|
+
const NESTED_LISTS: ReadonlyArray<string> = ['anyOf', 'allOf', 'oneOf'];
|
|
36
|
+
const NESTED_MAPS: ReadonlyArray<string> = ['properties', '$defs', 'definitions'];
|
|
37
|
+
|
|
38
|
+
const isObject = (v: unknown): v is JsonSchema => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
39
|
+
|
|
40
|
+
function count(node: unknown, path: string, out: StrictSchemaComplexity): void {
|
|
41
|
+
if (!isObject(node)) return;
|
|
42
|
+
const at = (key: string) => (path ? `${path}.${key}` : key);
|
|
43
|
+
if (Array.isArray(node['anyOf']) || Array.isArray(node['type'])) out.unions++;
|
|
44
|
+
const properties = isObject(node['properties']) ? node['properties'] : null;
|
|
45
|
+
if (node['type'] === 'object' || properties) {
|
|
46
|
+
const required = new Set(Array.isArray(node['required']) ? (node['required'] as string[]) : []);
|
|
47
|
+
out.optional += Object.keys(properties ?? {}).filter(key => !required.has(key)).length;
|
|
48
|
+
if (node['additionalProperties'] !== false) out.unsupported.push(`${at('additionalProperties')}: must be false`);
|
|
49
|
+
}
|
|
50
|
+
for (const key of UNSUPPORTED_KEYWORDS) if (key in node) out.unsupported.push(`${at(key)}: not supported`);
|
|
51
|
+
if ('minItems' in node && node['minItems'] !== 0 && node['minItems'] !== 1) out.unsupported.push(`${at('minItems')}: only 0 or 1`);
|
|
52
|
+
if (Array.isArray(node['enum']) && (node['enum'] as unknown[]).some(v => v !== null && typeof v === 'object')) out.unsupported.push(`${at('enum')}: complex types`);
|
|
53
|
+
if (typeof node['$ref'] === 'string' && !(node['$ref'] as string).startsWith('#')) out.unsupported.push(`${at('$ref')}: external`);
|
|
54
|
+
for (const key of NESTED_MAPS) {
|
|
55
|
+
if (isObject(node[key])) for (const [name, child] of Object.entries(node[key] as JsonSchema)) count(child, at(`${key}.${name}`), out);
|
|
56
|
+
}
|
|
57
|
+
for (const key of NESTED_LISTS) {
|
|
58
|
+
if (Array.isArray(node[key])) (node[key] as unknown[]).forEach((child, i) => count(child, at(`${key}[${i}]`), out));
|
|
59
|
+
}
|
|
60
|
+
if (isObject(node['items'])) count(node['items'], at('items'), out);
|
|
61
|
+
if (isObject(node['additionalProperties'])) count(node['additionalProperties'], at('additionalProperties'), out);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The cost of ONE request's strict schemas together: its JSON output format and its strict tools' input schemas. */
|
|
65
|
+
export function strictSchemaComplexity(schemas: ReadonlyArray<JsonSchema>, strictTools = 0): StrictSchemaComplexity {
|
|
66
|
+
const out: StrictSchemaComplexity = { optional: 0, unions: 0, strictTools, unsupported: [] };
|
|
67
|
+
schemas.forEach((schema, i) => count(schema, schemas.length > 1 ? `[${i}]` : '', out));
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A refusal shaped like the Claude API's (HTTP 400, `invalid_request_error`). */
|
|
72
|
+
export interface StrictSchemaRefusal {
|
|
73
|
+
status: 400;
|
|
74
|
+
type: 'invalid_request_error';
|
|
75
|
+
message: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The 400 the Claude API answers a request over its limits with, or null. The optional-parameter message is the one
|
|
80
|
+
* Claude answered on 2026-10-27, verbatim; the union, strict-tool and unsupported messages follow its shape (not yet
|
|
81
|
+
* captured).
|
|
82
|
+
*/
|
|
83
|
+
export function strictSchemaRefusal(complexity: StrictSchemaComplexity): StrictSchemaRefusal | null {
|
|
84
|
+
const refuse = (message: string): StrictSchemaRefusal => ({ status: 400, type: 'invalid_request_error', message });
|
|
85
|
+
if (complexity.unsupported.length) return refuse(`Invalid schema: ${complexity.unsupported.join('; ')}`);
|
|
86
|
+
if (complexity.strictTools > STRICT_SCHEMA_LIMITS.strictTools) {
|
|
87
|
+
return refuse(`Too many strict tools (${complexity.strictTools}). Reduce the number of tools with strict: true (limit: ${STRICT_SCHEMA_LIMITS.strictTools}).`);
|
|
88
|
+
}
|
|
89
|
+
if (complexity.optional > STRICT_SCHEMA_LIMITS.optional) {
|
|
90
|
+
return refuse(`Schemas contains too many optional parameters (${complexity.optional}), which would make grammar compilation inefficient. Reduce the number of optional parameters in your tool schemas (limit: ${STRICT_SCHEMA_LIMITS.optional}).`);
|
|
91
|
+
}
|
|
92
|
+
if (complexity.unions > STRICT_SCHEMA_LIMITS.unions) {
|
|
93
|
+
return refuse(`Schemas contains too many parameters with union types (${complexity.unions}), which would make grammar compilation inefficient. Reduce the number of parameters with union types in your tool schemas (limit: ${STRICT_SCHEMA_LIMITS.unions}).`);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|