@vectoriox/iox-ui 5.61.0 → 5.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,8 @@ import {
4
4
  import { patternById, patternOptionalParts, patternSlots, patternTakesBinding } from './patterns';
5
5
  import {
6
6
  contentTypeToWire, dataSourceToWire, expandWireContentType, expandWireDataSource, expandWireField, expandWirePage, expandWireSection, fieldToWire,
7
- pageToWire, PlanWireShapeError, sectionPatternIds, sectionToWire, wireContentTypeJsonSchema, wireDataSourceJsonSchema, wireFieldJsonSchema,
8
- wirePageJsonSchema, wireSectionJsonSchema,
7
+ pageToWire, PLAN_PARTS, PlanPart, PlanWireShapeError, sectionPatternIds, sectionToWire, wireContentTypeJsonSchema, wireDataSourceJsonSchema,
8
+ wireFieldJsonSchema, WireOptions, wirePageJsonSchema, wireSectionJsonSchema,
9
9
  } from './plan-wire';
10
10
  import {
11
11
  ContentFieldPlan, ContentTypePlan, DataSourcePlan, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType, SitePlan,
@@ -68,10 +68,35 @@ export const PLAN_EDIT_SHAPE: Readonly<Record<`${PlanEditOp}`, ShapeEntry>> = {
68
68
  [PlanEditOp.SwapPattern]: { op: "'swapPattern'", page: 'string — a page key', index: 'integer — the section', pattern: 'string — the pattern it becomes (its slots kept where the new one has them)' },
69
69
  [PlanEditOp.SetSlot]: { op: "'setSlot'", page: 'string — a page key', index: 'integer — the section', slot: "string — one of the pattern's slots", text: "string — the words, in the owner's language and voice" },
70
70
  [PlanEditOp.AddContentType]: { op: "'addContentType'", contentType: 'contentType — a unique name', 'dataSources?': 'dataSource[] — the list / single sources its pages read' },
71
- [PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it" },
71
+ [PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it; absent: what the type's seed already holds for it, else ''" },
72
72
  [PlanEditOp.SetSectionData]: { op: "'setSectionData'", page: 'string — a page key', index: 'integer — the section', binding: 'string — the list source the section repeats' },
73
73
  };
74
74
 
75
+ /**
76
+ * The plan part each op changes (vectoriox/iox-cms#47): a sentence is answered in the plan's two parts, one request each —
77
+ * the structure's edits first, then the pages' written knowing them.
78
+ */
79
+ export const PLAN_EDIT_OP_PART: Readonly<Record<`${PlanEditOp}`, PlanPart>> = {
80
+ [PlanEditOp.AddPage]: PlanPart.Pages,
81
+ [PlanEditOp.RemovePage]: PlanPart.Pages,
82
+ [PlanEditOp.RenamePage]: PlanPart.Pages,
83
+ [PlanEditOp.MergePages]: PlanPart.Pages,
84
+ [PlanEditOp.MovePage]: PlanPart.Pages,
85
+ [PlanEditOp.AddSection]: PlanPart.Pages,
86
+ [PlanEditOp.RemoveSection]: PlanPart.Pages,
87
+ [PlanEditOp.MoveSection]: PlanPart.Pages,
88
+ [PlanEditOp.SwapPattern]: PlanPart.Pages,
89
+ [PlanEditOp.SetSlot]: PlanPart.Pages,
90
+ [PlanEditOp.SetSectionData]: PlanPart.Pages,
91
+ [PlanEditOp.AddContentType]: PlanPart.Structure,
92
+ [PlanEditOp.AddField]: PlanPart.Structure,
93
+ };
94
+
95
+ /** The ops written in one part, in `PlanEditOp` order. */
96
+ export function planEditOps(part: PlanPart): PlanEditOp[] {
97
+ return Object.values(PlanEditOp).filter(op => PLAN_EDIT_OP_PART[op] === part);
98
+ }
99
+
75
100
  export enum PlanChangeKind {
76
101
  Page = 'page',
77
102
  Section = 'section',
@@ -207,6 +232,10 @@ function findSection(d: Draft, pageAt: number, index: unknown, c: Collector, pat
207
232
 
208
233
  const setSections = (d: Draft, at: number, sections: PageSectionPlan[]) => { d.pages[at] = { ...d.pages[at], sections }; };
209
234
 
235
+ /** A child added to a page or type the same edits added is part of that addition — the map marks the parent only. */
236
+ const addedHere = (changed: ReadonlyArray<PlanChange>, kind: PlanChangeKind, at: { page?: string; name?: string }) =>
237
+ changed.some(ch => ch.kind === kind && ch.action === PlanChangeAction.Added && (kind === PlanChangeKind.Page ? ch.page === at.page : ch.name === at.name));
238
+
210
239
  /** One handler per op — a data table, never an if-chain. */
211
240
  const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
212
241
  [PlanEditOp.AddPage]: (d, e, c, path, changed) => {
@@ -260,7 +289,7 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
260
289
  const index = e.at === undefined ? sections.length : clamp(e.at, sections.length);
261
290
  sections.splice(index, 0, e.section as PageSectionPlan);
262
291
  setSections(d, at, sections);
263
- changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
292
+ if (!addedHere(changed, PlanChangeKind.Page, { page: d.pages[at].key })) changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
264
293
  },
265
294
  [PlanEditOp.RemoveSection]: (d, e, c, path, changed) => {
266
295
  const at = findPage(d, e.page, c, `${path}.page`);
@@ -320,9 +349,9 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
320
349
  if (at < 0) return c.add(PlanEditIssueCode.UnknownContentType, `${path}.contentType`, `no content type "${String(e.contentType)}" in the plan`);
321
350
  const type = d.contentTypes[at];
322
351
  if (type.fields.some(f => f.name === e.field.name)) return c.add(PlanEditIssueCode.Duplicate, `${path}.field.name`, `"${type.name}" already has a field "${e.field.name}"`);
323
- const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? '' } : undefined;
352
+ const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? type.seed[e.field.name] ?? '' } : undefined;
324
353
  d.contentTypes[at] = { ...type, fields: [...type.fields, e.field], ...(seed ? { seed } : {}) };
325
- changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
354
+ if (!addedHere(changed, PlanChangeKind.ContentType, { name: type.name })) changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
326
355
  },
327
356
  [PlanEditOp.SetSectionData]: (d, e, c, path, changed) => {
328
357
  const at = findPage(d, e.page, c, `${path}.page`);
@@ -361,7 +390,11 @@ export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>):
361
390
 
362
391
  // ── The format a model writes edits in ─────────────────────────────────────────────────────────────────
363
392
 
364
- export const PLAN_EDITS_SCHEMA_NAME = 'plan_edits';
393
+ /** Each part's answer format name. */
394
+ export const PLAN_EDITS_PART_SCHEMA_NAMES: Readonly<Record<PlanPart, string>> = {
395
+ [PlanPart.Structure]: 'plan_structure_edits',
396
+ [PlanPart.Pages]: 'plan_page_edits',
397
+ };
365
398
 
366
399
  /** A sentence's answer: the edits, or why the ops cannot say it (one line, in the owner's language). */
367
400
  export enum PlanEditsOutcome {
@@ -376,18 +409,28 @@ export interface PlanEditsAnswer {
376
409
  reason: string;
377
410
  }
378
411
 
379
- /** The answer's own keys, explained. */
380
- export const PLAN_EDITS_ANSWER_SHAPE: ShapeEntry = {
381
- outcome: "'edits' | 'cannot' — cannot: the operations cannot do what the sentence asks",
382
- edits: 'edit[] — applied in order, all or none; empty when cannot',
383
- reason: "string — when cannot, ONE short line for the owner in their language (what to do instead); else ''",
412
+ /**
413
+ * Each part's answer, explained. The structure is asked first, so it says whether the sentence is a plan change at all
414
+ * (`outcome`); the pages are asked only when it is, and write only their edits.
415
+ */
416
+ export const PLAN_EDITS_ANSWER_SHAPES: Readonly<Record<PlanPart, ShapeEntry>> = {
417
+ [PlanPart.Structure]: {
418
+ outcome: "'edits' | 'cannot' — cannot: no operation of either part (below) can do what the sentence asks",
419
+ edits: 'edit[] — the content types and fields the sentence adds, in order; empty when it adds none, or cannot',
420
+ reason: "string — when cannot, ONE short line for the owner in their language (what to do instead); else ''",
421
+ },
422
+ [PlanPart.Pages]: {
423
+ edits: 'edit[] — the page and section edits, in order, made after the structure edits (already applied); empty when none',
424
+ },
384
425
  };
385
426
 
386
427
  /**
387
- * ONE edit on the wire (vectoriox/iox-cms#45): a flat object whose keys are ALL required — the 13 ops as `anyOf`
388
- * branches, each embedding whole pages, sections and content types, came to 395 optional parameters against the Claude
389
- * API's 24. `op` says which keys are read (`PLAN_EDIT_WIRE_KEYS`, from `PLAN_EDIT_SHAPE`); every other key holds its
390
- * none. A whole page, section, content type or field travels as a list of exactly one, in the plan wire's format.
428
+ * ONE flat edit per part on the wire (vectoriox/iox-cms#45, #47): an object whose keys are ALL required — the 13 ops as
429
+ * `anyOf` branches came to 395 optional parameters against the Claude API's 24, and one flat edit for all of them to 95
430
+ * properties, a grammar Claude refuses (≤ 60). `op` says which keys are read (`PLAN_EDIT_WIRE_KEYS`, from
431
+ * `PLAN_EDIT_SHAPE`); every other key holds its none. A new page, section, content type or field travels as a list of
432
+ * exactly one, in the plan wire's format — and an added page or content type travels WITHOUT its children: each of its
433
+ * sections is an `addSection` after it, each of its fields an `addField` (a new type's samples ride on its `seed`).
391
434
  */
392
435
  interface EditWireKey {
393
436
  text: string;
@@ -400,6 +443,8 @@ interface EditWireKey {
400
443
  }
401
444
 
402
445
  const NO_INDEX = -1;
446
+ /** A new page or content type on the wire: its children are edits of their own. */
447
+ const CHILDLESS: WireOptions = { children: false };
403
448
 
404
449
  /** A list holding exactly one object: none → absent, one → it (expanded), more → the list (the gate refuses it). */
405
450
  const oneOf = (schema: () => JsonSchema, expand: (v: unknown) => unknown, toWire: (v: any) => unknown, what: string): EditWireKey => ({
@@ -420,17 +465,22 @@ const EDIT_WIRE: Readonly<Record<string, EditWireKey>> = {
420
465
  after: textKey('addPage: the key of the page it follows in the menu'),
421
466
  into: textKey('mergePages: the page that stays'),
422
467
  from: textKey('mergePages: the page whose sections move into it, then removed'),
423
- index: indexKey('the section, 0-based on its page'),
468
+ index: indexKey('the section, 0-based on its page (addSection: the place the new one goes)'),
424
469
  to: indexKey('movePage / moveSection: the new 0-based place'),
425
- at: indexKey('addSection: its 0-based place on the page'),
426
470
  pattern: { text: 'string — swapPattern: the section pattern it becomes', none: '', schema: () => jsonOneOf(['', ...sectionPatternIds()]) },
427
471
  slot: textKey("setSlot: one of the pattern's slots"),
428
472
  text: textKey("setSlot: the words, in the owner's language and voice"),
429
473
  binding: textKey('setSectionData: the list source the section repeats'),
430
474
  contentType: textKey('addField: a content type of the plan'),
431
- newPage: oneOf(wirePageJsonSchema, expandWirePage, pageToWire, 'page'),
475
+ newPage: {
476
+ ...oneOf(() => wirePageJsonSchema(CHILDLESS), v => expandWirePage(v, CHILDLESS), p => pageToWire(p, CHILDLESS), 'page'),
477
+ text: 'page[] — exactly one when the op takes it, WITHOUT sections: each is an addSection after it',
478
+ },
432
479
  newSection: oneOf(wireSectionJsonSchema, expandWireSection, sectionToWire, 'section'),
433
- newContentType: oneOf(wireContentTypeJsonSchema, expandWireContentType, contentTypeToWire, 'contentType'),
480
+ newContentType: {
481
+ ...oneOf(() => wireContentTypeJsonSchema(CHILDLESS), v => expandWireContentType(v, CHILDLESS), t => contentTypeToWire(t, CHILDLESS), 'contentType'),
482
+ text: "contentType[] — exactly one when the op takes it, WITHOUT fields: each is an addField after it (the samples' values stay in its seed)",
483
+ },
434
484
  dataSources: {
435
485
  text: 'dataSource[] — addContentType: the list / single sources its pages read', none: [],
436
486
  schema: () => jsonListOf(wireDataSourceJsonSchema()),
@@ -448,7 +498,7 @@ export const PLAN_EDIT_WIRE_KEYS: Readonly<Record<`${PlanEditOp}`, Readonly<Reco
448
498
  [PlanEditOp.RenamePage]: { page: 'page', name: 'name', route: 'route' },
449
499
  [PlanEditOp.MergePages]: { into: 'into', from: 'from' },
450
500
  [PlanEditOp.MovePage]: { page: 'page', to: 'to' },
451
- [PlanEditOp.AddSection]: { page: 'page', section: 'newSection', at: 'at' },
501
+ [PlanEditOp.AddSection]: { page: 'page', section: 'newSection', at: 'index' },
452
502
  [PlanEditOp.RemoveSection]: { page: 'page', index: 'index' },
453
503
  [PlanEditOp.MoveSection]: { page: 'page', from: 'index', to: 'to' },
454
504
  [PlanEditOp.SwapPattern]: { page: 'page', index: 'index', pattern: 'pattern' },
@@ -458,23 +508,32 @@ export const PLAN_EDIT_WIRE_KEYS: Readonly<Record<`${PlanEditOp}`, Readonly<Reco
458
508
  [PlanEditOp.SetSectionData]: { page: 'page', index: 'index', binding: 'binding' },
459
509
  };
460
510
 
511
+ /** The wire keys a part's edit carries: every key its ops read, in `EDIT_WIRE` order. */
512
+ export function planEditWireKeys(part: PlanPart): string[] {
513
+ const read = new Set(planEditOps(part).flatMap(op => Object.values(PLAN_EDIT_WIRE_KEYS[op])));
514
+ return Object.keys(EDIT_WIRE).filter(k => read.has(k));
515
+ }
516
+
461
517
  const isEditWireNone = (wireKey: string, v: unknown) => {
462
518
  const none = EDIT_WIRE[wireKey].none;
463
519
  return v === undefined || (Array.isArray(none) ? Array.isArray(v) && v.length === 0 : v === none) || (wireKey === 'sample' && Array.isArray(v) && v.length === 0);
464
520
  };
465
521
  const noneText = (none: unknown) => (Array.isArray(none) ? '[]' : typeof none === 'string' ? "''" : String(none));
522
+ const opsText = (part: PlanPart) => planEditOps(part).map(op => `'${op}'`).join(' | ');
523
+
524
+ /** A part's flat edit, as a shape table: every key required, each with its none. */
525
+ export function planEditWireShape(part: PlanPart): ShapeEntry {
526
+ return {
527
+ op: `${opsText(part)} — which keys below are read`,
528
+ ...Object.fromEntries(planEditWireKeys(part).map(k => [k, `${EDIT_WIRE[k].text} — ${noneText(EDIT_WIRE[k].none)} when the op does not read it`])),
529
+ };
530
+ }
466
531
 
467
- /** The flat edit's shape table: every key required, each with its none. */
468
- export const PLAN_EDIT_WIRE_SHAPE: ShapeEntry = {
469
- op: `${OPS.map(op => `'${op}'`).join(' | ')} — which keys below are read`,
470
- ...Object.fromEntries(Object.entries(EDIT_WIRE).map(([k, spec]) => [k, `${spec.text} — ${noneText(spec.none)} when the op does not read it`])),
471
- };
472
-
473
- /** Per op, the wire keys it reads, in `PLAN_EDIT_SHAPE`'s words (`?` = may hold its none) — what the model reads. */
474
- export function planEditWireOps(): Record<string, Record<string, string>> {
475
- return Object.fromEntries(OPS.map(op => {
476
- const entry = PLAN_EDIT_SHAPE[op as PlanEditOp];
477
- const keys = PLAN_EDIT_WIRE_KEYS[op as PlanEditOp];
532
+ /** Per op of a part, the wire keys it reads, in `PLAN_EDIT_SHAPE`'s words (`?` = may hold its none) — what the model reads. */
533
+ export function planEditWireOps(part: PlanPart): Record<string, Record<string, string>> {
534
+ return Object.fromEntries(planEditOps(part).map(op => {
535
+ const entry = PLAN_EDIT_SHAPE[op];
536
+ const keys = PLAN_EDIT_WIRE_KEYS[op];
478
537
  return [op, Object.fromEntries(Object.entries(keys).map(([planKey, wireKey]) => {
479
538
  const optionalKey = `${planKey}?` in entry;
480
539
  return [`${wireKey}${optionalKey ? '?' : ''}`, entry[optionalKey ? `${planKey}?` : planKey]];
@@ -497,19 +556,21 @@ export function planEditWireGaps(): string[] {
497
556
  return gaps;
498
557
  }
499
558
 
500
- /** The format a sentence's answer is written in: the outcome, the flat edits, the reason. */
501
- export function planEditsJsonSchema(): JsonSchema {
559
+ const ANSWER_SCHEMAS: Readonly<Record<string, (part: PlanPart) => JsonSchema>> = {
560
+ outcome: () => jsonOneOf(Object.values(PlanEditsOutcome)),
561
+ reason: () => jsonText(),
562
+ edits: part => jsonListOf(jsonShapedObject(planEditWireShape(part), {
563
+ op: jsonOneOf(planEditOps(part)),
564
+ ...Object.fromEntries(planEditWireKeys(part).map(k => [k, EDIT_WIRE[k].schema()])),
565
+ }, `edit ${part}`)),
566
+ };
567
+
568
+ /** The format one part of a sentence's answer is written in — one request each (vectoriox/iox-cms#47). */
569
+ export function planEditsJsonSchema(part: PlanPart): JsonSchema {
502
570
  const gaps = planEditWireGaps();
503
571
  if (gaps.length) throw new PlanWireShapeError(`the plan edits wire does not carry ${gaps.join(', ')}`);
504
- const edit = jsonShapedObject(PLAN_EDIT_WIRE_SHAPE, {
505
- op: jsonOneOf(OPS),
506
- ...Object.fromEntries(Object.entries(EDIT_WIRE).map(([k, spec]) => [k, spec.schema()])),
507
- }, 'edit');
508
- return jsonShapedObject(PLAN_EDITS_ANSWER_SHAPE, {
509
- outcome: jsonOneOf(Object.values(PlanEditsOutcome)),
510
- edits: jsonListOf(edit),
511
- reason: jsonText(),
512
- }, 'planEdits');
572
+ const shape = PLAN_EDITS_ANSWER_SHAPES[part];
573
+ return jsonShapedObject(shape, Object.fromEntries(shapeKeys(shape).map(k => [k, ANSWER_SCHEMAS[k](part)])), `planEdits ${part}`);
513
574
  }
514
575
 
515
576
  /** One flat edit → the op's edit: the keys it reads, a none left out where the op may go without it. Never repaired. */
@@ -528,9 +589,10 @@ export function expandWireEdit(wire: unknown): PlanEdit | unknown {
528
589
  return out;
529
590
  }
530
591
 
531
- /** An edit → the flat wire edit (what a model would write — fakes and specs write it). */
532
- export function planEditToWire(edit: PlanEdit): PlainObject {
533
- const out: PlainObject = { op: edit.op, ...Object.fromEntries(Object.entries(EDIT_WIRE).map(([k, spec]) => [k, Array.isArray(spec.none) ? [] : spec.none])) };
592
+ /** An edit's own keys → its flat wire edit, in its part's keys. */
593
+ function flatEdit(edit: PlanEdit): PlainObject {
594
+ const part = PLAN_EDIT_OP_PART[edit.op];
595
+ const out: PlainObject = { op: edit.op, ...Object.fromEntries(planEditWireKeys(part).map(k => [k, Array.isArray(EDIT_WIRE[k].none) ? [] : EDIT_WIRE[k].none])) };
534
596
  for (const [planKey, wireKey] of Object.entries(PLAN_EDIT_WIRE_KEYS[edit.op])) {
535
597
  const value = (edit as unknown as PlainObject)[planKey];
536
598
  if (value === undefined) continue;
@@ -539,7 +601,26 @@ export function planEditToWire(edit: PlanEdit): PlainObject {
539
601
  return out;
540
602
  }
541
603
 
542
- /** A model's answer → edits (each flat edit expanded); never repaired. */
604
+ /** The children an added page or content type travels without, as the edits that add them after it. */
605
+ function childEdits(edit: PlanEdit): PlanEdit[] {
606
+ if (edit.op === PlanEditOp.AddPage) return edit.page.sections.map(section => ({ op: PlanEditOp.AddSection, page: edit.page.key, section }));
607
+ if (edit.op === PlanEditOp.AddContentType) return edit.contentType.fields.map(field => ({ op: PlanEditOp.AddField, contentType: edit.contentType.name, field }));
608
+ return [];
609
+ }
610
+
611
+ /** An edit → the flat wire edits a model would write for it: itself, then its children (fakes and specs write them). */
612
+ export function planEditToWire(edit: PlanEdit): PlainObject[] {
613
+ return [flatEdit(edit), ...childEdits(edit).map(flatEdit)];
614
+ }
615
+
616
+ /** Edits → each part's flat wire edits, in order (what each part's answer would hold). */
617
+ export function planEditsToWire(edits: ReadonlyArray<PlanEdit>): Record<PlanPart, PlainObject[]> {
618
+ const out = Object.fromEntries(PLAN_PARTS.map(part => [part, [] as PlainObject[]])) as Record<PlanPart, PlainObject[]>;
619
+ for (const edit of edits) out[PLAN_EDIT_OP_PART[edit.op]].push(...planEditToWire(edit));
620
+ return out;
621
+ }
622
+
623
+ /** A model's answer (either part's) → edits (each flat edit expanded); never repaired. */
543
624
  export function planEditsFromWire(answer: unknown): PlanEditsAnswer | unknown {
544
625
  if (!isPlainObject(answer) || !Array.isArray(answer['edits'])) return answer;
545
626
  return { ...answer, edits: (answer['edits'] as unknown[]).map(expandWireEdit) };
@@ -11,16 +11,21 @@ import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
11
11
  import { isPlainObject, PlainObject, ShapeEntry, shapeKeys } from './shape-check';
12
12
 
13
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.
14
+ * The plan WIRE (AI plan M1-3, vectoriox/iox-cms#45, #47): what a model writes when it plans a site — a compact format
15
+ * inside the Claude API's structured-output limits (≤ 24 optional parameters and ≤ 16 unions per request, and a compiled
16
+ * grammar under the size Claude refuses — measured as ≤ 60 properties; `strict-schema.ts`). The Site Plan itself
17
+ * (`SPEC_SITE_PLAN_SHAPE`) has 213 optional keys once every pattern is a branch, so the model never writes it: it writes
18
+ * this, and ONE pure `expandPlanWire` turns it into the Site Plan that `validateSitePlan` and the stage's gate judge
19
+ * exactly as before.
20
+ *
21
+ * - **Written in two PARTS, one request each** (#47 — the whole plan in one format was 64 properties): the **structure** (the business,
22
+ * the content types, the data sources) and then the **pages** (the pages, the header and footer), written knowing the
23
+ * structure. Every top-level key belongs to exactly one part (`part` below); `expandPlanWire` puts the parts together.
19
24
  *
20
25
  * - **Every key required.** A plan key that may be absent is written with its stated "none" — `''`, `0`, `false`, `[]`.
21
26
  * - **Closed lists as enums** (patterns, page types, needs, slots, parts, forms, entrances, header/footer), from the
22
27
  * 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.
28
+ * - **Maps as lists:** slots `[{ slot, text }]`, links `[{ slot, route }]`, seed values `[{ field, value }]`, roles `[{ role, field }]`.
24
29
  * - **One data source object** for both kinds; `type` says which keys are read (the other kind's hold their none).
25
30
  * - **What the model does not decide is not on the wire** (`PLAN_WIRE_DERIVED`): the look (the owner's pick), the run,
26
31
  * the language (the reading's), the facts (the owner's own, verbatim), the version, the home page (the page at `/`).
@@ -31,13 +36,25 @@ import { isPlainObject, PlainObject, ShapeEntry, shapeKeys } from './shape-check
31
36
  * is placed. The expanders translate, never repair: a value the model got wrong stays wrong, for the gate to refuse.
32
37
  */
33
38
 
34
- export const PLAN_WIRE_SCHEMA_NAME = 'site_plan';
39
+ /** The plan's parts, in the order they are written: the pages bind to the structure's sources and types. */
40
+ export enum PlanPart {
41
+ Structure = 'structure',
42
+ Pages = 'pages',
43
+ }
44
+
45
+ export const PLAN_PARTS: ReadonlyArray<PlanPart> = [PlanPart.Structure, PlanPart.Pages];
46
+
47
+ /** Each part's format name (a request names the format it asks for). */
48
+ export const PLAN_WIRE_PART_SCHEMA_NAMES: Readonly<Record<PlanPart, string>> = {
49
+ [PlanPart.Structure]: 'site_plan_structure',
50
+ [PlanPart.Pages]: 'site_plan_pages',
51
+ };
35
52
 
36
53
  export enum PlanWireEntry {
37
54
  Plan = 'plan',
38
55
  Business = 'business',
39
56
  ContentType = 'contentType',
40
- Roles = 'roles',
57
+ RoleField = 'roleField',
41
58
  Field = 'field',
42
59
  Seed = 'seed',
43
60
  SeedValue = 'seedValue',
@@ -59,18 +76,24 @@ interface WireKey {
59
76
  text?: string;
60
77
  /** What stands for "absent" (an optional plan key). */
61
78
  none?: string;
79
+ /** The part a top-level key is written in. */
80
+ part?: PlanPart;
62
81
  }
63
82
 
64
83
  const PLAN_SHAPE = SPEC_SITE_PLAN_SHAPE as unknown as Readonly<Record<string, ShapeEntry>>;
65
84
  const key = (ref: string, none?: string): WireKey => ({ refs: [ref], ...(none !== undefined ? { none } : {}) });
85
+ const inPart = (part: PlanPart, spec: WireKey): WireKey => ({ ...spec, part });
86
+ /** The roles a content type's fields play (`roles` of the plan shape), in the plan shape's order. */
87
+ const ROLE_KEYS: ReadonlyArray<string> = shapeKeys(PLAN_SHAPE['roles']);
88
+ const roleWords = ROLE_KEYS.map(role => `${role}: ${describeShapeKey(PLAN_SHAPE['roles'], role).replace(/^string — /, '')}`).join('; ');
66
89
 
67
90
  const WIRE_KEYS: Readonly<Record<`${PlanWireEntry}`, Readonly<Record<string, WireKey>>>> = {
68
91
  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'),
92
+ business: inPart(PlanPart.Structure, key('plan.business')),
93
+ contentTypes: inPart(PlanPart.Structure, key('plan.contentTypes')),
94
+ dataSources: inPart(PlanPart.Structure, { refs: ['plan.dataSources'], text: 'dataSource[] — merged by alias into the org library' }),
95
+ pages: inPart(PlanPart.Pages, key('plan.pages')),
96
+ globals: inPart(PlanPart.Pages, key('plan.globals')),
74
97
  },
75
98
  business: {
76
99
  name: key('business.name'),
@@ -79,17 +102,13 @@ const WIRE_KEYS: Readonly<Record<`${PlanWireEntry}`, Readonly<Record<string, Wir
79
102
  contentType: {
80
103
  name: key('contentType.name'),
81
104
  label: key('contentType.label'),
82
- roles: key('contentType.roles'),
105
+ roles: { refs: ['contentType.roles'], text: `roleField[] — ${describeShapeKey(PLAN_SHAPE['contentType'], 'roles').replace(/^roles — /, '')}; a role no field plays is left out` },
83
106
  fields: key('contentType.fields'),
84
107
  seed: { refs: ['contentType.seed'], text: 'seed — the sample items', none: 'count 0 and no values' },
85
108
  },
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', "''"),
109
+ roleField: {
110
+ role: { refs: ROLE_KEYS.map(role => `roles.${role}`), text: `${ROLE_KEYS.map(r => `'${r}'`).join(' | ')} — ${roleWords}` },
111
+ field: { refs: ['roles.title'], text: 'string — the field of the type that plays it' },
93
112
  },
94
113
  field: {
95
114
  name: key('field.name'),
@@ -184,6 +203,25 @@ export const PLAN_WIRE_SHAPE: Readonly<Record<`${PlanWireEntry}`, ShapeEntry>> =
184
203
  Object.entries(WIRE_KEYS).map(([entry, keys]) => [entry, Object.fromEntries(Object.entries(keys).map(([k, spec]) => [k, wireText(spec)]))]),
185
204
  ) as Record<`${PlanWireEntry}`, ShapeEntry>;
186
205
 
206
+ /** Each part's top-level keys, from the ONE wire table. */
207
+ export const PLAN_PART_KEYS: Readonly<Record<PlanPart, ReadonlyArray<string>>> = Object.fromEntries(
208
+ PLAN_PARTS.map(part => [part, Object.entries(WIRE_KEYS.plan).filter(([, spec]) => spec.part === part).map(([k]) => k)]),
209
+ ) as Record<PlanPart, string[]>;
210
+
211
+ /** The keys of a shape entry, but these. */
212
+ const shapeWithout = (entry: ShapeEntry, keys: ReadonlyArray<string>): ShapeEntry =>
213
+ Object.fromEntries(Object.entries(entry).filter(([k]) => !keys.includes(k.replace(/\?$/, ''))));
214
+
215
+ /**
216
+ * The part a plan path is the answer of (`contentTypes[1].seed.title` → structure, `pages[0].sections[2]` → pages). A
217
+ * path no part writes — the root (the composer), or a key the generation decides — is the LAST part's: it is written
218
+ * knowing everything before it.
219
+ */
220
+ export function planPartOfPath(path: string): PlanPart {
221
+ const top = path.split(/[.[]/, 1)[0];
222
+ return PLAN_PARTS.find(part => PLAN_PART_KEYS[part].includes(top)) ?? PLAN_PARTS[PLAN_PARTS.length - 1];
223
+ }
224
+
187
225
  /** Plan keys neither carried by a wire key nor derived — always empty (a spec and the builder hold it). */
188
226
  export function planWireGaps(): string[] {
189
227
  const carried = new Set(Object.values(WIRE_KEYS).flatMap(keys => Object.values(keys).flatMap(spec => spec.refs)));
@@ -220,13 +258,32 @@ export function wireFieldJsonSchema(): JsonSchema {
220
258
  return shaped(PlanWireEntry.Field, { name: jsonText(), type: jsonText(), controller: jsonText(), required: jsonBoolean(), note: jsonText() });
221
259
  }
222
260
 
223
- export function wireContentTypeJsonSchema(): JsonSchema {
224
- const roleKeys = shapeKeys(PLAN_WIRE_SHAPE.roles);
261
+ /** What an object of the wire travels without when its children are written as their own entries (a plan edit's). */
262
+ export interface WireOptions {
263
+ /** false: a content type without its `fields`, a page without its `sections` (each added by an edit after it). */
264
+ children?: boolean;
265
+ }
266
+
267
+ /** The keys an entry leaves out under `options`. */
268
+ const CHILD_KEYS: Readonly<Partial<Record<`${PlanWireEntry}`, string>>> = { contentType: 'fields', page: 'sections' };
269
+ const childless = (entry: `${PlanWireEntry}`, options: WireOptions) => (options.children === false && CHILD_KEYS[entry] ? [CHILD_KEYS[entry]!] : []);
270
+
271
+ /** The shape an entry is written in, under `options`. */
272
+ function wireEntryShape(entry: `${PlanWireEntry}`, options: WireOptions = {}): ShapeEntry {
273
+ return shapeWithout(PLAN_WIRE_SHAPE[entry], childless(entry, options));
274
+ }
275
+
276
+ const shapedAs = (entry: `${PlanWireEntry}`, options: WireOptions, properties: Record<string, JsonSchema>) => {
277
+ const left = childless(entry, options);
278
+ return jsonShapedObject(wireEntryShape(entry, options), Object.fromEntries(Object.entries(properties).filter(([k]) => !left.includes(k))), `wire ${entry}`);
279
+ };
280
+
281
+ export function wireContentTypeJsonSchema(options: WireOptions = {}): JsonSchema {
225
282
  const value = shaped(PlanWireEntry.SeedValue, { field: jsonText(), value: jsonAnyOf([jsonText(), jsonListOf(jsonText())]) });
226
- return shaped(PlanWireEntry.ContentType, {
283
+ return shapedAs(PlanWireEntry.ContentType, options, {
227
284
  name: jsonText(),
228
285
  label: jsonText(),
229
- roles: shaped(PlanWireEntry.Roles, Object.fromEntries(roleKeys.map(k => [k, jsonText()]))),
286
+ roles: jsonListOf(shaped(PlanWireEntry.RoleField, { role: jsonOneOf(ROLE_KEYS), field: jsonText() })),
230
287
  fields: jsonListOf(wireFieldJsonSchema()),
231
288
  seed: shaped(PlanWireEntry.Seed, { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean(), values: jsonListOf(value) }),
232
289
  });
@@ -260,8 +317,8 @@ export function wireSectionJsonSchema(): JsonSchema {
260
317
  });
261
318
  }
262
319
 
263
- export function wirePageJsonSchema(): JsonSchema {
264
- return shaped(PlanWireEntry.Page, {
320
+ export function wirePageJsonSchema(options: WireOptions = {}): JsonSchema {
321
+ return shapedAs(PlanWireEntry.Page, options, {
265
322
  key: jsonText(),
266
323
  name: jsonText(),
267
324
  route: jsonText(),
@@ -275,20 +332,32 @@ export function wirePageJsonSchema(): JsonSchema {
275
332
  });
276
333
  }
277
334
 
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 {
335
+ const PLAN_TOP_SCHEMAS: Readonly<Record<string, () => JsonSchema>> = {
336
+ business: () => shaped(PlanWireEntry.Business, { name: jsonText(), tagline: jsonText() }),
337
+ contentTypes: () => jsonListOf(wireContentTypeJsonSchema()),
338
+ dataSources: () => jsonListOf(wireDataSourceJsonSchema()),
339
+ pages: () => jsonListOf(wirePageJsonSchema()),
340
+ globals: () => shaped(PlanWireEntry.Globals, {
341
+ [GlobalSlot.Header]: jsonOneOf(globalIds(PatternGlobalPosition.Before)),
342
+ [GlobalSlot.Footer]: jsonOneOf(globalIds(PatternGlobalPosition.After)),
343
+ }),
344
+ };
345
+
346
+ /** The plan's top-level shape for one part (its keys only) — what that part's request shows and is built from. */
347
+ export function planWirePartShape(part: PlanPart): ShapeEntry {
348
+ return Object.fromEntries(Object.entries(PLAN_WIRE_SHAPE.plan).filter(([k]) => PLAN_PART_KEYS[part].includes(k)));
349
+ }
350
+
351
+ /**
352
+ * The format a model writes one part of a plan in — one request each (vectoriox/iox-cms#47). The same for every studio
353
+ * and run: nothing in it is pinned, so the provider compiles each part's grammar once.
354
+ */
355
+ export function planWirePartJsonSchema(part: PlanPart): JsonSchema {
280
356
  const gaps = planWireGaps();
281
357
  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
- });
358
+ const missing = Object.keys(WIRE_KEYS.plan).filter(k => !PLAN_TOP_SCHEMAS[k] || !WIRE_KEYS.plan[k].part);
359
+ if (missing.length) throw new PlanWireShapeError(`the plan wire's ${missing.join(', ')} has no part or no format`);
360
+ return jsonShapedObject(planWirePartShape(part), Object.fromEntries(PLAN_PART_KEYS[part].map(k => [k, PLAN_TOP_SCHEMAS[k]()])), `wire plan ${part}`);
292
361
  }
293
362
 
294
363
  // ── The wire → the plan ────────────────────────────────────────────────────────────────────────────────────────────
@@ -329,10 +398,10 @@ export function expandWireField(wire: unknown): ContentFieldPlan | unknown {
329
398
  return { ...rest, ...optional('required', required, v => v === undefined || v === false), ...optional('note', note) };
330
399
  }
331
400
 
332
- export function expandWireContentType(wire: unknown): ContentTypePlan | unknown {
401
+ export function expandWireContentType(wire: unknown, options: WireOptions = {}): ContentTypePlan | unknown {
333
402
  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) };
403
+ const { roles, fields, seed, ...rest } = options.children === false ? { fields: [], ...wire } : wire;
404
+ const out: PlainObject = { ...rest, roles: listToMap(roles, 'role', 'field'), fields: each(fields, expandWireField) };
336
405
  if (!isPlainObject(seed)) return seed === undefined ? out : { ...out, seed };
337
406
  const { values, ...fixed } = seed;
338
407
  if (fixed['count'] === 0 && emptyList(values)) return out;
@@ -365,9 +434,9 @@ export function expandWireSection(wire: unknown): PageSectionPlan | unknown {
365
434
  };
366
435
  }
367
436
 
368
- export function expandWirePage(wire: unknown): PagePlan | unknown {
437
+ export function expandWirePage(wire: unknown, options: WireOptions = {}): PagePlan | unknown {
369
438
  if (!isPlainObject(wire)) return wire;
370
- const { archetype, need, template, routeSource, seo, sections, ...rest } = wire;
439
+ const { archetype, need, template, routeSource, seo, sections, ...rest } = options.children === false ? { sections: [], ...wire } : wire;
371
440
  const words = isPlainObject(seo) ? { ...optional('title', seo['title']), ...optional('description', seo['description']) } : seo;
372
441
  return {
373
442
  ...rest,
@@ -387,12 +456,20 @@ function expandWireBusiness(wire: unknown, facts: PlanWireContext['facts']): unk
387
456
  return { ...rest, ...optional('tagline', tagline), ...(facts && Object.keys(facts).length ? { facts: { ...facts } } : {}) };
388
457
  }
389
458
 
459
+ /** A plan on the wire, by part — what each request answered. A part not written yet is absent. */
460
+ export type PlanWireParts = Partial<Record<PlanPart, unknown>>;
461
+
390
462
  /**
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`.
463
+ * A model's plan (its parts, on the wire) + what the generation decides (the look, the run, the language, the owner's
464
+ * facts) → the Site Plan. Pure. Translates, never repairs: whatever an answer got wrong is still wrong, for
465
+ * `validateSitePlan` — a part that is not an object is the result itself, a part not written yet leaves its keys out
466
+ * (the validator then names them, at their own paths).
393
467
  */
394
- export function expandPlanWire(wire: unknown, context: PlanWireContext): SitePlan | unknown {
395
- if (!isPlainObject(wire)) return wire;
468
+ export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext): SitePlan | unknown {
469
+ const written = PLAN_PARTS.filter(part => parts[part] !== undefined).map(part => parts[part]);
470
+ const odd = written.find(value => !isPlainObject(value));
471
+ if (odd !== undefined) return odd;
472
+ const wire: PlainObject = Object.assign({}, ...(written as PlainObject[]));
396
473
  const { business, contentTypes, dataSources, pages, ...rest } = wire;
397
474
  return {
398
475
  ...rest,
@@ -413,11 +490,12 @@ export function fieldToWire(field: ContentFieldPlan): PlainObject {
413
490
  return { name: field.name, type: field.type, controller: field.controller, required: field.required ?? false, note: field.note ?? NONE };
414
491
  }
415
492
 
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]));
493
+ export function contentTypeToWire(type: ContentTypePlan, options: WireOptions = {}): PlainObject {
494
+ const played = type.roles as Readonly<Record<string, string | undefined>>;
495
+ const roles = ROLE_KEYS.filter(role => played[role]).map(role => ({ role, field: played[role] }));
418
496
  const { count = 0, sample = false, isPublished = false, ...values } = type.seed ?? {};
419
497
  return {
420
- name: type.name, label: type.label, roles, fields: type.fields.map(fieldToWire),
498
+ name: type.name, label: type.label, roles, ...(options.children === false ? {} : { fields: type.fields.map(fieldToWire) }),
421
499
  seed: { count, sample, isPublished, values: mapToList(values, 'field', 'value') },
422
500
  };
423
501
  }
@@ -438,21 +516,29 @@ export function sectionToWire(section: PageSectionPlan): PlainObject {
438
516
  };
439
517
  }
440
518
 
441
- export function pageToWire(page: PagePlan): PlainObject {
519
+ export function pageToWire(page: PagePlan, options: WireOptions = {}): PlainObject {
442
520
  return {
443
521
  key: page.key, name: page.name, route: page.route, inNav: page.inNav ?? false, archetype: page.archetype ?? NONE, need: page.need ?? NONE,
444
522
  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),
523
+ seo: { title: page.seo?.title ?? NONE, description: page.seo?.description ?? NONE },
524
+ ...(options.children === false ? {} : { sections: page.sections.map(sectionToWire) }),
446
525
  };
447
526
  }
448
527
 
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
- };
528
+ const PLAN_TO_WIRE: Readonly<Record<string, (plan: SitePlan) => unknown>> = {
529
+ business: plan => ({ name: plan.business.name, tagline: plan.business.tagline ?? NONE }),
530
+ contentTypes: plan => plan.contentTypes.map(type => contentTypeToWire(type)),
531
+ dataSources: plan => plan.dataSources.map(dataSourceToWire),
532
+ pages: plan => plan.pages.map(page => pageToWire(page)),
533
+ globals: plan => ({ ...plan.globals }),
534
+ };
535
+
536
+ /** One part of the plan on the wire: what that part's request would have answered. */
537
+ export function planPartToWire(plan: SitePlan, part: PlanPart): PlainObject {
538
+ return Object.fromEntries(PLAN_PART_KEYS[part].map(k => [k, PLAN_TO_WIRE[k](plan)]));
539
+ }
540
+
541
+ /** The plan → the wire, by part: what a model would have written (the change prompt shows it; fakes and evals write it). */
542
+ export function planToWire(plan: SitePlan): Record<PlanPart, PlainObject> {
543
+ return Object.fromEntries(PLAN_PARTS.map(part => [part, planPartToWire(plan, part)])) as Record<PlanPart, PlainObject>;
458
544
  }