@vectoriox/iox-ui 5.54.0 → 5.55.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 +849 -44
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs +450 -30
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
- package/generation-core/index.d.ts +302 -2
- package/harness-core/index.d.ts +297 -16
- package/package.json +1 -1
- package/src/generation-core/ai-run.ts +17 -2
- package/src/generation-core/json-schema.ts +97 -0
- package/src/generation-core/plan-edits.ts +428 -0
- package/src/generation-core/plan-words.ts +114 -0
- package/src/generation-core/public-api.ts +4 -0
- package/src/generation-core/site-plan-schema.ts +239 -0
- package/src/harness-core/generation-view.ts +115 -0
- package/src/harness-core/palette.ts +198 -0
- package/src/harness-core/public-api.ts +4 -0
- package/src/harness-core/reference-colour.ts +45 -0
- package/src/harness-core/site-analysis.ts +8 -29
- package/src/harness-core/site-reading.ts +214 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import {
|
|
2
|
+
jsonAnyOf, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
|
|
3
|
+
} from './json-schema';
|
|
4
|
+
import { patternById, patternOptionalParts, patternSlots, patternTakesBinding } from './patterns';
|
|
5
|
+
import {
|
|
6
|
+
contentTypeFromWire, contentTypeJsonSchema, dataSourceJsonSchema, fieldJsonSchema, pageJsonSchema, sectionFromWire, sectionJsonSchema,
|
|
7
|
+
sectionPatternIds,
|
|
8
|
+
} from './site-plan-schema';
|
|
9
|
+
import {
|
|
10
|
+
ContentFieldPlan, ContentTypePlan, DataSourcePlan, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType, SitePlan,
|
|
11
|
+
} from './site-plan.model';
|
|
12
|
+
import { isNonEmptyString, isPlainObject, PathIssue, PathIssueCollector, PlainObject, ShapeEntry, shapeKeys } from './shape-check';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Changing a plan by sentence (AI plan §3d "The Plan prompt box", M1-5; reused by M2 after a build): the CLOSED list
|
|
16
|
+
* of operations (N-D6) a model may write against the current plan, and the ONE pure `applyPlanEdits` that carries them
|
|
17
|
+
* out. Nothing is written until Build, so an edit is a plan → plan function; the result goes through
|
|
18
|
+
* `validateSitePlan` like any plan. Pages are named by `key`, sections by their index on the page. A set of edits is
|
|
19
|
+
* applied whole or not at all: one edit that cannot apply refuses the set, with a path, and the plan is unchanged.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export enum PlanEditOp {
|
|
23
|
+
AddPage = 'addPage',
|
|
24
|
+
RemovePage = 'removePage',
|
|
25
|
+
RenamePage = 'renamePage',
|
|
26
|
+
MergePages = 'mergePages',
|
|
27
|
+
/** Its place in the menu (the plan's page order). */
|
|
28
|
+
MovePage = 'movePage',
|
|
29
|
+
AddSection = 'addSection',
|
|
30
|
+
RemoveSection = 'removeSection',
|
|
31
|
+
MoveSection = 'moveSection',
|
|
32
|
+
SwapPattern = 'swapPattern',
|
|
33
|
+
SetSlot = 'setSlot',
|
|
34
|
+
AddContentType = 'addContentType',
|
|
35
|
+
AddField = 'addField',
|
|
36
|
+
SetSectionData = 'setSectionData',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type PlanEdit =
|
|
40
|
+
| { op: `${PlanEditOp.AddPage}`; page: PagePlan; after?: string }
|
|
41
|
+
| { op: `${PlanEditOp.RemovePage}`; page: string }
|
|
42
|
+
| { op: `${PlanEditOp.RenamePage}`; page: string; name?: string; route?: string }
|
|
43
|
+
| { op: `${PlanEditOp.MergePages}`; into: string; from: string }
|
|
44
|
+
| { op: `${PlanEditOp.MovePage}`; page: string; to: number }
|
|
45
|
+
| { op: `${PlanEditOp.AddSection}`; page: string; section: PageSectionPlan; at?: number }
|
|
46
|
+
| { op: `${PlanEditOp.RemoveSection}`; page: string; index: number }
|
|
47
|
+
| { op: `${PlanEditOp.MoveSection}`; page: string; from: number; to: number }
|
|
48
|
+
| { op: `${PlanEditOp.SwapPattern}`; page: string; index: number; pattern: string }
|
|
49
|
+
| { op: `${PlanEditOp.SetSlot}`; page: string; index: number; slot: string; text: string }
|
|
50
|
+
| { op: `${PlanEditOp.AddContentType}`; contentType: ContentTypePlan; dataSources?: DataSourcePlan[] }
|
|
51
|
+
| { op: `${PlanEditOp.AddField}`; contentType: string; field: ContentFieldPlan; sample?: string | string[] }
|
|
52
|
+
| { op: `${PlanEditOp.SetSectionData}`; page: string; index: number; binding: string };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every op's keys, explained — what the format is built from and what `validatePlanEdits` reads (`key?` optional).
|
|
56
|
+
* A page is named by its `key`; a section by its 0-based `index` on that page.
|
|
57
|
+
*/
|
|
58
|
+
export const PLAN_EDIT_SHAPE: Readonly<Record<`${PlanEditOp}`, ShapeEntry>> = {
|
|
59
|
+
[PlanEditOp.AddPage]: { op: "'addPage'", page: 'page — a whole new page (a unique key and route)', 'after?': 'string — the key of the page it follows in the menu; absent: last' },
|
|
60
|
+
[PlanEditOp.RemovePage]: { op: "'removePage'", page: 'string — the key of the page to remove (never the home page)' },
|
|
61
|
+
[PlanEditOp.RenamePage]: { op: "'renamePage'", page: 'string — a page key', 'name?': 'string — its new name (the menu label)', 'route?': 'string — its new route' },
|
|
62
|
+
[PlanEditOp.MergePages]: { op: "'mergePages'", into: 'string — the page that stays', from: 'string — the page whose sections move into it, then removed' },
|
|
63
|
+
[PlanEditOp.MovePage]: { op: "'movePage'", page: 'string — a page key', to: 'integer — its new 0-based place in the menu' },
|
|
64
|
+
[PlanEditOp.AddSection]: { op: "'addSection'", page: 'string — a page key', section: 'section', 'at?': 'integer — its 0-based place on the page; absent: last' },
|
|
65
|
+
[PlanEditOp.RemoveSection]: { op: "'removeSection'", page: 'string — a page key', index: 'integer — the section' },
|
|
66
|
+
[PlanEditOp.MoveSection]: { op: "'moveSection'", page: 'string — a page key', from: 'integer — the section', to: 'integer — its new place' },
|
|
67
|
+
[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)' },
|
|
68
|
+
[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" },
|
|
69
|
+
[PlanEditOp.AddContentType]: { op: "'addContentType'", contentType: 'contentType — a unique name', 'dataSources?': 'dataSource[] — the list / single sources its pages read' },
|
|
70
|
+
[PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it" },
|
|
71
|
+
[PlanEditOp.SetSectionData]: { op: "'setSectionData'", page: 'string — a page key', index: 'integer — the section', binding: 'string — the list source the section repeats' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export enum PlanChangeKind {
|
|
75
|
+
Page = 'page',
|
|
76
|
+
Section = 'section',
|
|
77
|
+
ContentType = 'contentType',
|
|
78
|
+
Field = 'field',
|
|
79
|
+
DataSource = 'dataSource',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export enum PlanChangeAction {
|
|
83
|
+
Added = 'added',
|
|
84
|
+
Removed = 'removed',
|
|
85
|
+
Changed = 'changed',
|
|
86
|
+
Moved = 'moved',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What an edit changed — the Plan map marks it. `index` is the section's index in the RESULT (or before, when removed). */
|
|
90
|
+
export interface PlanChange {
|
|
91
|
+
kind: `${PlanChangeKind}`;
|
|
92
|
+
action: `${PlanChangeAction}`;
|
|
93
|
+
page?: string;
|
|
94
|
+
index?: number;
|
|
95
|
+
name?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export enum PlanEditIssueCode {
|
|
99
|
+
Shape = 'shape',
|
|
100
|
+
UnknownKey = 'unknown-key',
|
|
101
|
+
Duplicate = 'duplicate',
|
|
102
|
+
UnknownOp = 'unknown-op',
|
|
103
|
+
UnknownPage = 'unknown-page',
|
|
104
|
+
UnknownSection = 'unknown-section',
|
|
105
|
+
UnknownPattern = 'unknown-pattern',
|
|
106
|
+
UnknownSlot = 'unknown-slot',
|
|
107
|
+
UnknownContentType = 'unknown-content-type',
|
|
108
|
+
UnknownDataSource = 'unknown-data-source',
|
|
109
|
+
/** The home page stays (a plan has exactly one). */
|
|
110
|
+
HomePage = 'home-page',
|
|
111
|
+
/** A template page (one item per route) and a plain page cannot be merged. */
|
|
112
|
+
MergeTemplate = 'merge-template',
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type PlanEditIssue = PathIssue<PlanEditIssueCode>;
|
|
116
|
+
|
|
117
|
+
export interface PlanEditResult {
|
|
118
|
+
plan: SitePlan;
|
|
119
|
+
changed: PlanChange[];
|
|
120
|
+
/** Non-empty: nothing was applied (`plan` is the input). */
|
|
121
|
+
issues: PlanEditIssue[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
class Collector extends PathIssueCollector<PlanEditIssueCode> {
|
|
125
|
+
constructor() {
|
|
126
|
+
super('plan edit', { shape: PlanEditIssueCode.Shape, unknownKey: PlanEditIssueCode.UnknownKey, duplicate: PlanEditIssueCode.Duplicate });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const OPS = Object.values(PlanEditOp) as string[];
|
|
131
|
+
/** A page's own header section (`page-header.simple`) — what a merge leaves behind with the page it named. */
|
|
132
|
+
const PAGE_HEADER_FAMILY = 'header';
|
|
133
|
+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
134
|
+
const clamp = (n: number, max: number) => Math.max(0, Math.min(Math.trunc(n), max));
|
|
135
|
+
|
|
136
|
+
/** The edits' structure: a known op, its keys (`PLAN_EDIT_SHAPE`), integers where an index is. Never repairs. */
|
|
137
|
+
export function validatePlanEdits(edits: unknown): { valid: boolean; issues: PlanEditIssue[] } {
|
|
138
|
+
const c = new Collector();
|
|
139
|
+
if (!Array.isArray(edits)) {
|
|
140
|
+
c.add(PlanEditIssueCode.Shape, 'edits', 'must be an array');
|
|
141
|
+
return { valid: false, issues: c.issues };
|
|
142
|
+
}
|
|
143
|
+
edits.forEach((raw, i) => {
|
|
144
|
+
const path = `edits[${i}]`;
|
|
145
|
+
if (!isPlainObject(raw)) return c.add(PlanEditIssueCode.Shape, path, 'must be an object');
|
|
146
|
+
const op = raw['op'];
|
|
147
|
+
if (typeof op !== 'string' || !OPS.includes(op)) return c.add(PlanEditIssueCode.UnknownOp, `${path}.op`, `must be one of ${OPS.join(', ')}`);
|
|
148
|
+
const entry = PLAN_EDIT_SHAPE[op as PlanEditOp];
|
|
149
|
+
c.keys(raw, shapeKeys(entry), path);
|
|
150
|
+
for (const [key, text] of Object.entries(entry)) {
|
|
151
|
+
const optional = key.endsWith('?');
|
|
152
|
+
const name = optional ? key.slice(0, -1) : key;
|
|
153
|
+
const v = raw[name];
|
|
154
|
+
if (v === undefined) { if (!optional) c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'is required'); continue; }
|
|
155
|
+
if (text.startsWith('string | string[]')) {
|
|
156
|
+
if (typeof v !== 'string' && !(Array.isArray(v) && v.every(x => typeof x === 'string'))) c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be a string or a list of strings');
|
|
157
|
+
} else if (text.startsWith('string') && !isNonEmptyString(v)) c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be a non-empty string');
|
|
158
|
+
if (text.startsWith('integer') && !Number.isInteger(v)) c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be a whole number');
|
|
159
|
+
if (['page', 'section', 'contentType', 'field'].includes(text.split(' ')[0]) && !isPlainObject(v)) c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be an object');
|
|
160
|
+
if (text.startsWith('dataSource[]') && !Array.isArray(v)) c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be an array');
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return { valid: c.issues.length === 0, issues: c.issues };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The aliases a page reads (its route source and its sections' bindings). */
|
|
167
|
+
function aliasesOf(pages: ReadonlyArray<PagePlan>): Set<string> {
|
|
168
|
+
const out = new Set<string>();
|
|
169
|
+
for (const page of pages) {
|
|
170
|
+
if (page.routeSource) out.add(page.routeSource);
|
|
171
|
+
for (const s of page.sections) if (s.binding) out.add(s.binding);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A relative source (`project.next`'s) is read while its subject is, and keeps the list it walks. */
|
|
177
|
+
function withDependencies(aliases: Set<string>, sources: ReadonlyArray<DataSourcePlan>): Set<string> {
|
|
178
|
+
const out = new Set(aliases);
|
|
179
|
+
for (const s of sources) {
|
|
180
|
+
if (s.type === PlanDataSourceType.Relative && aliases.has(s.of)) { out.add(s.alias); out.add(s.within); }
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface Draft {
|
|
186
|
+
pages: PagePlan[];
|
|
187
|
+
contentTypes: ContentTypePlan[];
|
|
188
|
+
dataSources: DataSourcePlan[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type Apply = (d: Draft, e: any, c: Collector, path: string, changed: PlanChange[]) => void;
|
|
192
|
+
|
|
193
|
+
const pageIndex = (d: Draft, key: unknown) => d.pages.findIndex(p => p.key === key);
|
|
194
|
+
|
|
195
|
+
function findPage(d: Draft, key: unknown, c: Collector, path: string): number {
|
|
196
|
+
const at = pageIndex(d, key);
|
|
197
|
+
if (at < 0) c.add(PlanEditIssueCode.UnknownPage, path, `no page "${String(key)}" in the plan (${d.pages.map(p => p.key).join(', ')})`);
|
|
198
|
+
return at;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function findSection(d: Draft, pageAt: number, index: unknown, c: Collector, path: string): boolean {
|
|
202
|
+
const ok = Number.isInteger(index) && (index as number) >= 0 && (index as number) < d.pages[pageAt].sections.length;
|
|
203
|
+
if (!ok) c.add(PlanEditIssueCode.UnknownSection, path, `page "${d.pages[pageAt].key}" has sections 0–${d.pages[pageAt].sections.length - 1}`);
|
|
204
|
+
return ok;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const setSections = (d: Draft, at: number, sections: PageSectionPlan[]) => { d.pages[at] = { ...d.pages[at], sections }; };
|
|
208
|
+
|
|
209
|
+
/** One handler per op — a data table, never an if-chain. */
|
|
210
|
+
const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
|
|
211
|
+
[PlanEditOp.AddPage]: (d, e, c, path, changed) => {
|
|
212
|
+
const page = { ...(e.page as PagePlan), sections: (e.page.sections ?? []).map(sectionFromWire) } as PagePlan;
|
|
213
|
+
if (pageIndex(d, page.key) >= 0) return c.add(PlanEditIssueCode.Duplicate, `${path}.page.key`, `page "${page.key}" is already in the plan`);
|
|
214
|
+
const after = e.after === undefined ? d.pages.length - 1 : findPage(d, e.after, c, `${path}.after`);
|
|
215
|
+
if (after < 0 && e.after !== undefined) return;
|
|
216
|
+
d.pages.splice(after + 1, 0, page);
|
|
217
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Added, page: page.key, name: page.name });
|
|
218
|
+
},
|
|
219
|
+
[PlanEditOp.RemovePage]: (d, e, c, path, changed) => {
|
|
220
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
221
|
+
if (at < 0) return;
|
|
222
|
+
if (d.pages[at].isHome) return c.add(PlanEditIssueCode.HomePage, `${path}.page`, 'the home page stays — rename or change it instead');
|
|
223
|
+
const [gone] = d.pages.splice(at, 1);
|
|
224
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Removed, page: gone.key, name: gone.name });
|
|
225
|
+
},
|
|
226
|
+
[PlanEditOp.RenamePage]: (d, e, c, path, changed) => {
|
|
227
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
228
|
+
if (at < 0) return;
|
|
229
|
+
d.pages[at] = { ...d.pages[at], ...(e.name !== undefined ? { name: e.name } : {}), ...(e.route !== undefined ? { route: e.route } : {}) };
|
|
230
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Changed, page: d.pages[at].key, name: d.pages[at].name });
|
|
231
|
+
},
|
|
232
|
+
[PlanEditOp.MergePages]: (d, e, c, path, changed) => {
|
|
233
|
+
const into = findPage(d, e.into, c, `${path}.into`);
|
|
234
|
+
const from = findPage(d, e.from, c, `${path}.from`);
|
|
235
|
+
if (into < 0 || from < 0) return;
|
|
236
|
+
if (into === from) return c.add(PlanEditIssueCode.Duplicate, `${path}.from`, 'a page is merged into another page');
|
|
237
|
+
const a = d.pages[into];
|
|
238
|
+
const b = d.pages[from];
|
|
239
|
+
if (!!a.template !== !!b.template) return c.add(PlanEditIssueCode.MergeTemplate, `${path}.from`, 'a page per item and a plain page cannot be merged');
|
|
240
|
+
if (b.isHome) return c.add(PlanEditIssueCode.HomePage, `${path}.from`, 'the home page stays — merge the other page into it');
|
|
241
|
+
const moved = b.sections.filter((s, i) => !(i === 0 && patternById(s.pattern)?.family === PAGE_HEADER_FAMILY));
|
|
242
|
+
setSections(d, into, [...a.sections, ...moved]);
|
|
243
|
+
moved.forEach((_, k) => changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: a.key, index: a.sections.length + k }));
|
|
244
|
+
d.pages.splice(from, 1);
|
|
245
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Changed, page: a.key, name: a.name });
|
|
246
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Removed, page: b.key, name: b.name });
|
|
247
|
+
},
|
|
248
|
+
[PlanEditOp.MovePage]: (d, e, c, path, changed) => {
|
|
249
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
250
|
+
if (at < 0) return;
|
|
251
|
+
const [page] = d.pages.splice(at, 1);
|
|
252
|
+
d.pages.splice(clamp(e.to, d.pages.length), 0, page);
|
|
253
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Moved, page: page.key, name: page.name });
|
|
254
|
+
},
|
|
255
|
+
[PlanEditOp.AddSection]: (d, e, c, path, changed) => {
|
|
256
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
257
|
+
if (at < 0) return;
|
|
258
|
+
const sections = [...d.pages[at].sections];
|
|
259
|
+
const index = e.at === undefined ? sections.length : clamp(e.at, sections.length);
|
|
260
|
+
sections.splice(index, 0, sectionFromWire(e.section) as PageSectionPlan);
|
|
261
|
+
setSections(d, at, sections);
|
|
262
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
|
|
263
|
+
},
|
|
264
|
+
[PlanEditOp.RemoveSection]: (d, e, c, path, changed) => {
|
|
265
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
266
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`)) return;
|
|
267
|
+
setSections(d, at, d.pages[at].sections.filter((_, i) => i !== e.index));
|
|
268
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Removed, page: d.pages[at].key, index: e.index });
|
|
269
|
+
},
|
|
270
|
+
[PlanEditOp.MoveSection]: (d, e, c, path, changed) => {
|
|
271
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
272
|
+
if (at < 0 || !findSection(d, at, e.from, c, `${path}.from`)) return;
|
|
273
|
+
const sections = [...d.pages[at].sections];
|
|
274
|
+
const [section] = sections.splice(e.from, 1);
|
|
275
|
+
const to = clamp(e.to, sections.length);
|
|
276
|
+
sections.splice(to, 0, section);
|
|
277
|
+
setSections(d, at, sections);
|
|
278
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Moved, page: d.pages[at].key, index: to });
|
|
279
|
+
},
|
|
280
|
+
[PlanEditOp.SwapPattern]: (d, e, c, path, changed) => {
|
|
281
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
282
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`)) return;
|
|
283
|
+
const next = patternById(e.pattern);
|
|
284
|
+
if (!next || next.global) return c.add(PlanEditIssueCode.UnknownPattern, `${path}.pattern`, `no section pattern "${String(e.pattern)}"`);
|
|
285
|
+
const old = d.pages[at].sections[e.index];
|
|
286
|
+
const slots = Object.fromEntries(Object.entries(old.slots ?? {}).filter(([slot]) => patternSlots(next).includes(slot)));
|
|
287
|
+
const without = (old.without ?? []).filter(w => patternOptionalParts(next).includes(w));
|
|
288
|
+
const section: PageSectionPlan = {
|
|
289
|
+
pattern: e.pattern,
|
|
290
|
+
...(Object.keys(slots).length ? { slots } : {}),
|
|
291
|
+
...(old.binding && patternTakesBinding(next) ? { binding: old.binding } : {}),
|
|
292
|
+
...(without.length ? { without } : {}),
|
|
293
|
+
};
|
|
294
|
+
setSections(d, at, d.pages[at].sections.map((s, i) => (i === e.index ? section : s)));
|
|
295
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
|
|
296
|
+
},
|
|
297
|
+
[PlanEditOp.SetSlot]: (d, e, c, path, changed) => {
|
|
298
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
299
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`)) return;
|
|
300
|
+
const section = d.pages[at].sections[e.index];
|
|
301
|
+
const declared = patternSlots(patternById(section.pattern)!);
|
|
302
|
+
if (!declared.includes(e.slot)) return c.add(PlanEditIssueCode.UnknownSlot, `${path}.slot`, `"${section.pattern}" has no slot "${e.slot}" (${declared.join(', ') || 'none'})`);
|
|
303
|
+
setSections(d, at, d.pages[at].sections.map((s, i) => (i === e.index ? { ...s, slots: { ...(s.slots ?? {}), [e.slot]: e.text } } : s)));
|
|
304
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
|
|
305
|
+
},
|
|
306
|
+
[PlanEditOp.AddContentType]: (d, e, c, path, changed) => {
|
|
307
|
+
const type = contentTypeFromWire(e.contentType) as ContentTypePlan;
|
|
308
|
+
if (d.contentTypes.some(t => t.name === type.name)) return c.add(PlanEditIssueCode.Duplicate, `${path}.contentType.name`, `content type "${type.name}" is already in the plan`);
|
|
309
|
+
d.contentTypes.push(type);
|
|
310
|
+
changed.push({ kind: PlanChangeKind.ContentType, action: PlanChangeAction.Added, name: type.name });
|
|
311
|
+
for (const [k, source] of ((e.dataSources ?? []) as DataSourcePlan[]).entries()) {
|
|
312
|
+
if (d.dataSources.some(s => s.alias === source.alias)) { c.add(PlanEditIssueCode.Duplicate, `${path}.dataSources[${k}].alias`, `data source "${source.alias}" is already in the plan`); continue; }
|
|
313
|
+
d.dataSources.push(source);
|
|
314
|
+
changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Added, name: source.alias });
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
[PlanEditOp.AddField]: (d, e, c, path, changed) => {
|
|
318
|
+
const at = d.contentTypes.findIndex(t => t.name === e.contentType);
|
|
319
|
+
if (at < 0) return c.add(PlanEditIssueCode.UnknownContentType, `${path}.contentType`, `no content type "${String(e.contentType)}" in the plan`);
|
|
320
|
+
const type = d.contentTypes[at];
|
|
321
|
+
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}"`);
|
|
322
|
+
const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? '' } : undefined;
|
|
323
|
+
d.contentTypes[at] = { ...type, fields: [...type.fields, e.field], ...(seed ? { seed } : {}) };
|
|
324
|
+
changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
|
|
325
|
+
},
|
|
326
|
+
[PlanEditOp.SetSectionData]: (d, e, c, path, changed) => {
|
|
327
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
328
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`)) return;
|
|
329
|
+
const source = d.dataSources.find(s => s.alias === e.binding);
|
|
330
|
+
if (!source || source.type !== PlanDataSourceType.Cms || source.mode !== PlanDataSourceMode.Query) {
|
|
331
|
+
return c.add(PlanEditIssueCode.UnknownDataSource, `${path}.binding`, `no list source "${String(e.binding)}" in the plan`);
|
|
332
|
+
}
|
|
333
|
+
setSections(d, at, d.pages[at].sections.map((s, i) => (i === e.index ? { ...s, binding: e.binding } : s)));
|
|
334
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Apply a set of edits to a plan — all of them, or none (any issue refuses the set and returns the plan unchanged). The
|
|
340
|
+
* sources only removed pages and sections read are dropped with them; sources nothing read before are kept.
|
|
341
|
+
* The result still has to pass `validateSitePlan`.
|
|
342
|
+
*/
|
|
343
|
+
export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>): PlanEditResult {
|
|
344
|
+
const structure = validatePlanEdits(edits);
|
|
345
|
+
if (!structure.valid) return { plan, changed: [], issues: structure.issues };
|
|
346
|
+
const draft: Draft = { pages: clone([...plan.pages]), contentTypes: clone([...plan.contentTypes]), dataSources: clone([...plan.dataSources]) };
|
|
347
|
+
const c = new Collector();
|
|
348
|
+
const changed: PlanChange[] = [];
|
|
349
|
+
edits.forEach((edit, i) => APPLY[edit.op](draft, edit, c, `edits[${i}]`, changed));
|
|
350
|
+
if (c.issues.length) return { plan, changed: [], issues: c.issues };
|
|
351
|
+
|
|
352
|
+
const before = withDependencies(aliasesOf(plan.pages), plan.dataSources);
|
|
353
|
+
const after = withDependencies(aliasesOf(draft.pages), draft.dataSources);
|
|
354
|
+
const added = new Set(changed.filter(ch => ch.kind === PlanChangeKind.DataSource).map(ch => ch.name));
|
|
355
|
+
const dataSources = draft.dataSources.filter(s => after.has(s.alias) || !before.has(s.alias) || added.has(s.alias));
|
|
356
|
+
for (const s of draft.dataSources) if (!dataSources.includes(s)) changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Removed, name: s.alias });
|
|
357
|
+
|
|
358
|
+
return { plan: { ...plan, pages: draft.pages, contentTypes: draft.contentTypes, dataSources }, changed, issues: [] };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── The format a model writes edits in ─────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
export const PLAN_EDITS_SCHEMA_NAME = 'plan_edits';
|
|
364
|
+
|
|
365
|
+
/** A sentence's answer: the edits, or why the ops cannot say it (one line, in the owner's language). */
|
|
366
|
+
export enum PlanEditsOutcome {
|
|
367
|
+
Edits = 'edits',
|
|
368
|
+
Cannot = 'cannot',
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface PlanEditsAnswer {
|
|
372
|
+
outcome: `${PlanEditsOutcome}`;
|
|
373
|
+
edits: PlanEdit[];
|
|
374
|
+
/** `cannot`: the one line the owner reads. `edits`: empty. */
|
|
375
|
+
reason: string;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The answer's own keys, explained. */
|
|
379
|
+
export const PLAN_EDITS_ANSWER_SHAPE: ShapeEntry = {
|
|
380
|
+
outcome: "'edits' | 'cannot' — cannot: the operations cannot do what the sentence asks",
|
|
381
|
+
edits: 'edit[] — applied in order, all or none; empty when cannot',
|
|
382
|
+
reason: "string — when cannot, ONE short line for the owner in their language (what to do instead); else ''",
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const one = (value: string): JsonSchema => jsonOneOf([value]);
|
|
386
|
+
|
|
387
|
+
/** The schema of each op's value types (what `PLAN_EDIT_SHAPE` names). */
|
|
388
|
+
function opSchema(op: `${PlanEditOp}`): JsonSchema {
|
|
389
|
+
const entry = PLAN_EDIT_SHAPE[op];
|
|
390
|
+
const valueOf = (key: string): JsonSchema => {
|
|
391
|
+
const text = entry[key] ?? entry[`${key}?`];
|
|
392
|
+
if (key === 'op') return one(op);
|
|
393
|
+
if (key === 'pattern') return jsonOneOf(sectionPatternIds());
|
|
394
|
+
const head = text.split(' ')[0];
|
|
395
|
+
const table: Record<string, () => JsonSchema> = {
|
|
396
|
+
page: pageJsonSchema, section: sectionJsonSchema, contentType: contentTypeJsonSchema, field: fieldJsonSchema,
|
|
397
|
+
'dataSource[]': () => jsonListOf(dataSourceJsonSchema()), integer: jsonInteger, string: jsonText,
|
|
398
|
+
};
|
|
399
|
+
if (text.startsWith('string | string[]')) return jsonAnyOf([jsonText(), jsonListOf(jsonText())]);
|
|
400
|
+
return (table[head] ?? jsonText)();
|
|
401
|
+
};
|
|
402
|
+
return jsonShapedObject(entry, Object.fromEntries(shapeKeys(entry).map(key => [key, valueOf(key)])), op);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The format a sentence's answer is written in: the outcome, the edits (one `anyOf` branch per op), the reason. */
|
|
406
|
+
export function planEditsJsonSchema(): JsonSchema {
|
|
407
|
+
return jsonShapedObject(PLAN_EDITS_ANSWER_SHAPE, {
|
|
408
|
+
outcome: jsonOneOf(Object.values(PlanEditsOutcome)),
|
|
409
|
+
edits: jsonListOf(jsonAnyOf(OPS.map(op => opSchema(op as PlanEditOp)))),
|
|
410
|
+
reason: jsonText(),
|
|
411
|
+
}, 'planEdits');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A model's answer → edits (sections and content types off the wire); never repaired. */
|
|
415
|
+
export function planEditsFromWire(answer: unknown): PlanEditsAnswer | unknown {
|
|
416
|
+
if (!isPlainObject(answer) || !Array.isArray(answer['edits'])) return answer;
|
|
417
|
+
const edits = (answer['edits'] as unknown[]).map(raw => {
|
|
418
|
+
if (!isPlainObject(raw)) return raw;
|
|
419
|
+
const e: PlainObject = { ...raw };
|
|
420
|
+
if (isPlainObject(e['page']) && Array.isArray((e['page'] as PlainObject)['sections'])) {
|
|
421
|
+
e['page'] = { ...(e['page'] as PlainObject), sections: ((e['page'] as PlainObject)['sections'] as unknown[]).map(sectionFromWire) };
|
|
422
|
+
}
|
|
423
|
+
if (isPlainObject(e['section'])) e['section'] = sectionFromWire(e['section']);
|
|
424
|
+
if (isPlainObject(e['contentType'])) e['contentType'] = contentTypeFromWire(e['contentType']);
|
|
425
|
+
return e;
|
|
426
|
+
});
|
|
427
|
+
return { ...answer, edits };
|
|
428
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { PathIssue } from './shape-check';
|
|
2
|
+
import { FACT_KEYS, FactKey, SitePlan } from './site-plan.model';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The plan stage's word checks (AI plan §3d / §3h, M1-3): every text a model WROTE into a plan — the tagline, page
|
|
6
|
+
* names, section slots, content-type labels, sample values — scanned for the words the owner and their kind of
|
|
7
|
+
* business must not use (`avoidWords`, the analysis's + the type's `words`), and for a fact the owner never gave
|
|
8
|
+
* (an email, phone number, price or opening time that is not one of the owner's own facts). A finding is a refusal
|
|
9
|
+
* with a path — the model rewrites; nothing here repairs a word. Pure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export enum PlanWordIssueCode {
|
|
13
|
+
/** A word the owner (or their kind of business) must not use. */
|
|
14
|
+
AvoidWord = 'avoid-word',
|
|
15
|
+
/** Something that looks like a fact — an email, phone, price or time — that is not one of the owner's facts. */
|
|
16
|
+
InventedFact = 'invented-fact',
|
|
17
|
+
/** `business.facts` holds a fact the owner did not give, or not in the owner's words. */
|
|
18
|
+
FactNotTheOwners = 'fact-not-the-owners',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PlanWordIssue = PathIssue<PlanWordIssueCode>;
|
|
22
|
+
|
|
23
|
+
export interface PlanWordContext {
|
|
24
|
+
avoidWords: ReadonlyArray<string>;
|
|
25
|
+
/** The owner's own facts (the analysis's, as corrected) — the only ones a plan may carry. */
|
|
26
|
+
facts: Readonly<Partial<Record<FactKey, string>>>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A text a model wrote, and where. */
|
|
30
|
+
export interface WrittenText {
|
|
31
|
+
path: string;
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every text in a plan a model writes (never ids, routes, field names or pattern ids). */
|
|
36
|
+
export function writtenTexts(plan: SitePlan): WrittenText[] {
|
|
37
|
+
const out: WrittenText[] = [];
|
|
38
|
+
const add = (path: string, text: unknown) => { if (typeof text === 'string' && text.trim()) out.push({ path, text }); };
|
|
39
|
+
add('business.tagline', plan.business?.tagline);
|
|
40
|
+
plan.pages?.forEach((page, p) => {
|
|
41
|
+
add(`pages[${p}].name`, page.name);
|
|
42
|
+
page.sections?.forEach((section, s) => {
|
|
43
|
+
for (const [slot, text] of Object.entries(section.slots ?? {})) add(`pages[${p}].sections[${s}].slots.${slot}`, text);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
plan.contentTypes?.forEach((type, t) => {
|
|
47
|
+
add(`contentTypes[${t}].label`, type.label);
|
|
48
|
+
for (const [field, value] of Object.entries(type.seed ?? {})) {
|
|
49
|
+
const values = Array.isArray(value) ? value : [value];
|
|
50
|
+
values.forEach((v, k) => add(`contentTypes[${t}].seed.${field}${Array.isArray(value) ? `[${k}]` : ''}`, v));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HEBREW = /[-]/;
|
|
57
|
+
/** Hebrew joins up to two one-letter words (and, the, in, to, from, that, as) to the next: "והטוב" holds "טוב". */
|
|
58
|
+
const HEBREW_PREFIXES = '[והבלמשכ]{0,2}';
|
|
59
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
const normalise = (s: string) => s.normalize('NFKC').toLowerCase();
|
|
61
|
+
|
|
62
|
+
/** A word (or phrase) as a whole-word pattern; Hebrew allows its one-letter prefixes. */
|
|
63
|
+
export function wordPattern(word: string): RegExp {
|
|
64
|
+
const w = escape(normalise(word.trim())).replace(/\s+/g, '\\s+');
|
|
65
|
+
const prefix = HEBREW.test(word) ? HEBREW_PREFIXES : '';
|
|
66
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${prefix}${w}(?![\\p{L}\\p{N}])`, 'u');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What a fact looks like when a model writes one — each judged against the owner's facts. */
|
|
70
|
+
const FACT_SHAPES: ReadonlyArray<{ kind: string; pattern: RegExp; key: (match: string) => string }> = [
|
|
71
|
+
{ kind: 'email', pattern: /[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu, key: m => m.toLowerCase() },
|
|
72
|
+
{ kind: 'phone', pattern: /\+?\d[\d\s().-]{6,}\d/g, key: m => m.replace(/\D/g, '') },
|
|
73
|
+
{ kind: 'price', pattern: /(?:[$€£₪]\s?\d[\d,.]*|\d[\d,.]*\s?(?:[$€£₪]|ש"ח|NIS|USD|EUR|GBP))/gu, key: m => m.replace(/[^\d]/g, '') },
|
|
74
|
+
{ kind: 'time', pattern: /(?<!\d)\d{1,2}[:.]\d{2}(?!\d)/g, key: m => m.replace(/\D/g, '') },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const MIN_PHONE_DIGITS = 8;
|
|
78
|
+
/** "2019 – 2024" is two years, not a phone number. */
|
|
79
|
+
const YEAR_RANGE = /^\s*\d{4}\s*[-–]\s*\d{4}\s*$/;
|
|
80
|
+
|
|
81
|
+
function ownedKeys(facts: PlanWordContext['facts']): Set<string> {
|
|
82
|
+
const keys = new Set<string>();
|
|
83
|
+
for (const value of Object.values(facts)) {
|
|
84
|
+
if (typeof value !== 'string') continue;
|
|
85
|
+
for (const shape of FACT_SHAPES) for (const m of value.match(shape.pattern) ?? []) keys.add(`${shape.kind}:${shape.key(m)}`);
|
|
86
|
+
}
|
|
87
|
+
return keys;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The words to avoid and the facts never given, in every written text of the plan. */
|
|
91
|
+
export function planWordIssues(plan: SitePlan, ctx: PlanWordContext): PlanWordIssue[] {
|
|
92
|
+
const issues: PlanWordIssue[] = [];
|
|
93
|
+
const avoid = [...new Set(ctx.avoidWords.map(w => w.trim()).filter(Boolean))].map(w => ({ word: w, pattern: wordPattern(w) }));
|
|
94
|
+
const owned = ownedKeys(ctx.facts);
|
|
95
|
+
for (const { path, text } of writtenTexts(plan)) {
|
|
96
|
+
const words = normalise(text);
|
|
97
|
+
for (const a of avoid) if (a.pattern.test(words)) issues.push({ code: PlanWordIssueCode.AvoidWord, path, message: `uses "${a.word}" — a word this site must not use` });
|
|
98
|
+
for (const shape of FACT_SHAPES) {
|
|
99
|
+
for (const m of text.match(shape.pattern) ?? []) {
|
|
100
|
+
const key = shape.key(m);
|
|
101
|
+
if (shape.kind === 'phone' && (key.length < MIN_PHONE_DIGITS || YEAR_RANGE.test(m))) continue;
|
|
102
|
+
if (!owned.has(`${shape.kind}:${key}`)) issues.push({ code: PlanWordIssueCode.InventedFact, path, message: `"${m.trim()}" looks like a ${shape.kind} the owner never gave — leave it out` });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const [key, value] of Object.entries(plan.business?.facts ?? {})) {
|
|
107
|
+
const own = ctx.facts[key as FactKey];
|
|
108
|
+
if (!(FACT_KEYS as ReadonlyArray<string>).includes(key)) continue;
|
|
109
|
+
if (typeof own !== 'string' || normalise(own).trim() !== normalise(String(value)).trim()) {
|
|
110
|
+
issues.push({ code: PlanWordIssueCode.FactNotTheOwners, path: `business.facts.${key}`, message: own ? `must be the owner's own words: "${own}"` : 'the owner did not give it — leave it out (it becomes a placeholder)' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return issues;
|
|
114
|
+
}
|
|
@@ -18,6 +18,7 @@ export * from './patterns';
|
|
|
18
18
|
export * from './templates';
|
|
19
19
|
export * from './serialized-node';
|
|
20
20
|
export * from './shape-check';
|
|
21
|
+
export * from './json-schema';
|
|
21
22
|
export * from './validate-site-plan';
|
|
22
23
|
export * from './compose-site';
|
|
23
24
|
export * from './nav-shape';
|
|
@@ -25,3 +26,6 @@ export * from './entrances';
|
|
|
25
26
|
export * from './plan-data';
|
|
26
27
|
export * from './site-build-run';
|
|
27
28
|
export * from './ai-run';
|
|
29
|
+
export * from './site-plan-schema';
|
|
30
|
+
export * from './plan-edits';
|
|
31
|
+
export * from './plan-words';
|