@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.
@@ -1,252 +0,0 @@
1
- import { ANIMATE_IDS, DesignChoices } from '@vectoriox/iox-ui/design-core';
2
- import { FORM_PRESETS } from '@vectoriox/iox-ui/forms-core';
3
-
4
- import {
5
- jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
6
- } from './json-schema';
7
- import {
8
- patternHasForm, patternLinkSlots, patternOptionalParts, patternSlots, patternTakesBinding, PatternGlobalPosition, SITE_PATTERNS, SitePattern,
9
- } from './patterns';
10
- import {
11
- FACT_KEYS, GlobalSlot, PAGE_ARCHETYPES, SITE_NEED_IDS, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlan, SitePlanSource, SitePlanSourceKind, SITE_LOCALES,
12
- SITE_PLAN_VERSION,
13
- } from './site-plan.model';
14
- import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
15
- import { isPlainObject, PlainObject, ShapeEntry } from './shape-check';
16
-
17
- /**
18
- * The structured-output format a model writes a Site Plan in (AI plan §3d step 4, M1-3) — built from the SAME shape
19
- * table `validateSitePlan` reads (`SPEC_SITE_PLAN_SHAPE`) and the SAME pattern library, so the plan a model is told
20
- * about is the plan the gate refuses. ONE source: a key the table gains breaks this builder until it is written here.
21
- *
22
- * Two maps of the shape are written as LISTS on the wire, because no strict mode can say "any key": a section's
23
- * `slots` (`[{ slot, text }]`, the slot names closed per pattern) and a seed's field values (`values: [{ field,
24
- * value }]`). `sitePlanFromWire` turns them back; nothing else differs. Every section is one `anyOf` branch per
25
- * pattern — its id, its declared slots, its optional parts, a binding only when it takes one — and the design, the
26
- * source and the version are PINNED to the owner's pick and this run (one-value enums), so the provider cannot let
27
- * a model change the look. The gate still checks both (`plan.design` deep-equals the pick).
28
- */
29
-
30
- export const SITE_PLAN_SCHEMA_NAME = 'site_plan';
31
-
32
- /** The wire keys of the two maps written as lists. */
33
- export enum SitePlanWireKey {
34
- SlotName = 'slot',
35
- SlotText = 'text',
36
- SeedValues = 'values',
37
- SeedField = 'field',
38
- SeedValue = 'value',
39
- }
40
-
41
- const SHAPE: Readonly<Record<string, ShapeEntry>> = SPEC_SITE_PLAN_SHAPE;
42
-
43
- export interface SitePlanSchemaPin {
44
- /** The owner's pick, verbatim — every design key becomes a one-value enum. */
45
- design: DesignChoices;
46
- /** This generation. */
47
- source: SitePlanSource;
48
- }
49
-
50
- const one = (value: string): JsonSchema => jsonOneOf([value]);
51
- const oneBoolean = (value: boolean): JsonSchema => ({ type: 'boolean', enum: [value] });
52
-
53
- /** The pick, key by key, as one-value enums; an optional key the pick leaves out may only be left out (or empty). */
54
- function pinnedDesign(design: DesignChoices): JsonSchema {
55
- const entry = SHAPE['design'];
56
- const values = design as unknown as Record<string, unknown>;
57
- const properties: Record<string, JsonSchema> = {};
58
- for (const key of Object.keys(entry).map(k => k.replace(/\?$/, ''))) {
59
- const v = values[key];
60
- if (typeof v === 'string') properties[key] = one(v);
61
- else if (typeof v === 'boolean') properties[key] = oneBoolean(v);
62
- else if (isPlainObject(v)) properties[key] = jsonClosedObject(
63
- Object.fromEntries(Object.entries(v).map(([role, family]) => [role, one(String(family))])), Object.keys(v));
64
- else properties[key] = jsonClosedObject({}, []);
65
- }
66
- const shaped = jsonShapedObject(entry, properties, 'design');
67
- // Everything the pick carries is required: a model may not leave a choice out and let a default stand in.
68
- const given = Object.keys(values).filter(k => values[k] !== undefined && k in properties);
69
- return { ...shaped, required: [...new Set([...(shaped['required'] as string[]), ...given])] };
70
- }
71
-
72
- function sourceSchema(source: SitePlanSource): JsonSchema {
73
- if (source.kind === SitePlanSourceKind.Ai) return jsonShapedObject(SHAPE['sourceAi'], { kind: one(source.kind), runId: one(source.runId) }, 'sourceAi');
74
- return jsonShapedObject(SHAPE['sourceTemplate'], {
75
- kind: one(source.kind), templateId: one(source.templateId), templateVersion: { type: 'integer', enum: [source.templateVersion] },
76
- }, 'sourceTemplate');
77
- }
78
-
79
- function businessSchema(): JsonSchema {
80
- const facts = jsonClosedObject(Object.fromEntries(FACT_KEYS.map(key => [key, jsonText()])), []);
81
- return jsonShapedObject(SHAPE['business'], { name: jsonText(), tagline: jsonText(), facts }, 'business');
82
- }
83
-
84
- /** A content type's field. */
85
- export function fieldJsonSchema(): JsonSchema {
86
- return jsonShapedObject(SHAPE['field'], { name: jsonText(), type: jsonText(), controller: jsonText(), required: jsonBoolean(), note: jsonText() }, 'field');
87
- }
88
-
89
- /** A content type, its seed's field values as a list (`values: [{ field, value }]`). */
90
- export function contentTypeJsonSchema(): JsonSchema {
91
- const value = jsonAnyOf([jsonText(), jsonListOf(jsonText())]);
92
- const values = jsonListOf(jsonClosedObject({ [SitePlanWireKey.SeedField]: jsonText(), [SitePlanWireKey.SeedValue]: value }, [SitePlanWireKey.SeedField, SitePlanWireKey.SeedValue]));
93
- const seed = jsonShapedObject(SHAPE['seed'], { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean() }, 'seed',
94
- { key: SitePlanWireKey.SeedValues, schema: values });
95
- const roleKeys = Object.keys(SHAPE['roles']).map(k => k.replace(/\?$/, ''));
96
- const roles = jsonShapedObject(SHAPE['roles'], Object.fromEntries(roleKeys.map(k => [k, jsonText()])), 'roles');
97
- return jsonShapedObject(SHAPE['contentType'], {
98
- name: jsonText(), label: jsonText(), roles, fields: jsonListOf(fieldJsonSchema()), seed,
99
- }, 'contentType');
100
- }
101
-
102
- /** A data source: a cms list / single item, or a relative one. */
103
- export function dataSourceJsonSchema(): JsonSchema {
104
- const byRoute = jsonClosedObject({ field: jsonText(), routeParam: jsonText() }, ['field', 'routeParam']);
105
- const cms = jsonShapedObject(SHAPE['cmsDataSource'], {
106
- alias: jsonText(), type: one(PlanDataSourceType.Cms), mode: jsonOneOf(Object.values(PlanDataSourceMode)),
107
- contentType: jsonText(), limit: jsonInteger(), filterByRouteParam: byRoute,
108
- }, 'cmsDataSource');
109
- const relative = jsonShapedObject(SHAPE['relativeDataSource'], {
110
- alias: jsonText(), type: one(PlanDataSourceType.Relative), of: jsonText(), op: jsonOneOf(Object.values(RelativeOp)), within: jsonText(),
111
- }, 'relativeDataSource');
112
- return jsonAnyOf([cms, relative]);
113
- }
114
-
115
- /** The patterns a page section may use (every non-global one), in library order. */
116
- export function sectionPatternIds(): string[] {
117
- return Object.entries(SITE_PATTERNS).filter(([, p]) => !p.global).map(([id]) => id);
118
- }
119
-
120
- /** A key a pattern has nothing for: only `{}` (or leaving it out) — the decoder drops it. */
121
- const NOTHING = (): JsonSchema => jsonClosedObject({}, []);
122
-
123
- /** One section branch: this pattern's id, its declared slots (a list), the slots it may re-point (`links`, closed per
124
- * pattern), its optional parts, a binding when it takes one, a form preset when it holds a form, its own entrance. */
125
- function sectionBranch(id: string, pattern: SitePattern): JsonSchema {
126
- const slots = patternSlots(pattern);
127
- const parts = patternOptionalParts(pattern);
128
- const linkSlots = patternLinkSlots(pattern);
129
- const slotList = slots.length
130
- ? jsonListOf(jsonClosedObject({ [SitePlanWireKey.SlotName]: jsonOneOf(slots), [SitePlanWireKey.SlotText]: jsonText() }, [SitePlanWireKey.SlotName, SitePlanWireKey.SlotText]))
131
- : NOTHING();
132
- const branch = jsonShapedObject(SHAPE['section'], {
133
- pattern: one(id),
134
- slots: slotList,
135
- links: linkSlots.length ? jsonClosedObject(Object.fromEntries(linkSlots.map(slot => [slot, jsonText()])), []) : NOTHING(),
136
- binding: patternTakesBinding(pattern) ? jsonText() : NOTHING(),
137
- without: parts.length ? jsonListOf(jsonOneOf(parts)) : NOTHING(),
138
- form: patternHasForm(pattern) ? jsonOneOf(FORM_PRESETS.map(p => p.id)) : NOTHING(),
139
- enter: jsonOneOf(ANIMATE_IDS),
140
- }, 'section');
141
- return { ...branch, description: `${id}: ${pattern.intent}` };
142
- }
143
-
144
- /** A page section: one branch per pattern. */
145
- export function sectionJsonSchema(): JsonSchema {
146
- return jsonAnyOf(sectionPatternIds().map(id => sectionBranch(id, SITE_PATTERNS[id])));
147
- }
148
-
149
- /** A page. */
150
- export function pageJsonSchema(): JsonSchema {
151
- const seo = jsonShapedObject(SHAPE['pageSeo'], { title: jsonText(), description: jsonText() }, 'pageSeo');
152
- return jsonShapedObject(SHAPE['page'], {
153
- key: jsonText(), name: jsonText(), route: jsonText(), isHome: jsonBoolean(), required: jsonBoolean(), inNav: jsonBoolean(),
154
- archetype: jsonOneOf(Object.keys(PAGE_ARCHETYPES)), need: jsonOneOf(SITE_NEED_IDS), template: jsonText(), routeSource: jsonText(), seo, sections: jsonListOf(sectionJsonSchema()),
155
- }, 'page');
156
- }
157
-
158
- const globalIds = (position: `${PatternGlobalPosition}`) => Object.entries(SITE_PATTERNS).filter(([, p]) => p.global === position).map(([id]) => id);
159
-
160
- /** The Site Plan format, pinned to the owner's pick and this run. */
161
- export function sitePlanJsonSchema(pin: SitePlanSchemaPin): JsonSchema {
162
- const globals = jsonShapedObject(SHAPE['globals'], {
163
- [GlobalSlot.Header]: jsonOneOf(globalIds(PatternGlobalPosition.Before)),
164
- [GlobalSlot.Footer]: jsonOneOf(globalIds(PatternGlobalPosition.After)),
165
- }, 'globals');
166
- return jsonShapedObject(SHAPE['plan'], {
167
- version: one(SITE_PLAN_VERSION),
168
- source: sourceSchema(pin.source),
169
- business: businessSchema(),
170
- locale: jsonOneOf(Object.keys(SITE_LOCALES)),
171
- design: pinnedDesign(pin.design),
172
- contentTypes: jsonListOf(contentTypeJsonSchema()),
173
- dataSources: jsonListOf(dataSourceJsonSchema()),
174
- pages: jsonListOf(pageJsonSchema()),
175
- globals,
176
- }, 'plan');
177
- }
178
-
179
- // ── The wire → the plan ─────────────────────────────────────────────────────────────────────────────
180
-
181
- const isEmptyObject = (v: unknown) => isPlainObject(v) && Object.keys(v).length === 0;
182
-
183
- /** `[{ slot, text }]` → `{ slot: text }`; anything that is not that list is left for the gate to refuse. */
184
- function slotsFromWire(slots: unknown): unknown {
185
- if (!Array.isArray(slots)) return slots;
186
- const out: PlainObject = {};
187
- for (const entry of slots) {
188
- if (!isPlainObject(entry) || typeof entry[SitePlanWireKey.SlotName] !== 'string') return slots;
189
- out[entry[SitePlanWireKey.SlotName] as string] = entry[SitePlanWireKey.SlotText];
190
- }
191
- return out;
192
- }
193
-
194
- /** A section off the wire: its slots back to a map; a key the pattern has nothing for (`{}`) dropped. */
195
- export function sectionFromWire(section: unknown): unknown {
196
- if (!isPlainObject(section)) return section;
197
- const out: PlainObject = { ...section };
198
- for (const key of ['slots', 'links', 'binding', 'without', 'form']) if (isEmptyObject(out[key])) delete out[key];
199
- if ('slots' in out) out['slots'] = slotsFromWire(out['slots']);
200
- return out;
201
- }
202
-
203
- /** A content type off the wire: its seed's `values` list back to field keys. */
204
- export function contentTypeFromWire(type: unknown): unknown {
205
- if (!isPlainObject(type) || !isPlainObject(type['seed'])) return type;
206
- const { [SitePlanWireKey.SeedValues]: values, ...seed } = type['seed'] as PlainObject;
207
- if (Array.isArray(values)) {
208
- for (const entry of values) {
209
- if (isPlainObject(entry) && typeof entry[SitePlanWireKey.SeedField] === 'string') seed[entry[SitePlanWireKey.SeedField] as string] = entry[SitePlanWireKey.SeedValue];
210
- }
211
- } else if (values !== undefined) seed[SitePlanWireKey.SeedValues] = values;
212
- return { ...type, seed };
213
- }
214
-
215
- function pageFromWire(page: unknown): unknown {
216
- if (!isPlainObject(page) || !Array.isArray(page['sections'])) return page;
217
- return { ...page, sections: (page['sections'] as unknown[]).map(sectionFromWire) };
218
- }
219
-
220
- /** The design off the wire: an empty optional object (a choice the pick does not carry) dropped. */
221
- function designFromWire(design: unknown): unknown {
222
- if (!isPlainObject(design)) return design;
223
- return Object.fromEntries(Object.entries(design).filter(([, v]) => !isEmptyObject(v)));
224
- }
225
-
226
- /**
227
- * A model's answer → a Site Plan: the two listed maps back to maps, nothing else touched. Never repairs — whatever the
228
- * answer got wrong is still wrong, for `validateSitePlan` to refuse with a path.
229
- */
230
- export function sitePlanFromWire(wire: unknown): SitePlan | unknown {
231
- if (!isPlainObject(wire)) return wire;
232
- const out: PlainObject = { ...wire };
233
- if (Array.isArray(out['contentTypes'])) out['contentTypes'] = (out['contentTypes'] as unknown[]).map(contentTypeFromWire);
234
- if (Array.isArray(out['pages'])) out['pages'] = (out['pages'] as unknown[]).map(pageFromWire);
235
- if ('design' in out) out['design'] = designFromWire(out['design']);
236
- return out;
237
- }
238
-
239
- /** The plan → the wire (what a re-ask shows the model, and what the plan edits' prompt carries). */
240
- export function sitePlanToWire(plan: SitePlan): PlainObject {
241
- const slotsToWire = (slots: Readonly<Record<string, string>> | undefined) =>
242
- Object.entries(slots ?? {}).map(([slot, text]) => ({ [SitePlanWireKey.SlotName]: slot, [SitePlanWireKey.SlotText]: text }));
243
- return {
244
- ...plan,
245
- contentTypes: plan.contentTypes.map(t => {
246
- if (!t.seed) return t;
247
- const { count, sample, isPublished, ...values } = t.seed;
248
- return { ...t, seed: { count, sample, isPublished, [SitePlanWireKey.SeedValues]: Object.entries(values).map(([field, value]) => ({ [SitePlanWireKey.SeedField]: field, [SitePlanWireKey.SeedValue]: value })) } };
249
- }),
250
- pages: plan.pages.map(p => ({ ...p, sections: p.sections.map(s => (s.slots ? { ...s, slots: slotsToWire(s.slots) } : s)) })),
251
- };
252
- }