@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.
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +651 -266
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/generation-core/index.d.ts +165 -70
- 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/stock-photos.ts +53 -13
- package/src/generation-core/strict-schema.ts +96 -0
- package/src/generation-core/site-plan-schema.ts +0 -252
|
@@ -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
|
+
}
|
|
@@ -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
|
-
}
|