@vectoriox/iox-ui 5.57.0 → 5.58.1

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.
Files changed (32) hide show
  1. package/composite-core/index.d.ts +3 -3
  2. package/design-core/index.d.ts +40 -2
  3. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +6 -10
  4. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  5. package/fesm2022/vectoriox-iox-ui-design-core.mjs +46 -1
  6. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +4043 -740
  8. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  9. package/fesm2022/vectoriox-iox-ui-harness-core.mjs +5 -7
  10. package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
  11. package/fesm2022/vectoriox-iox-ui.mjs +27 -1
  12. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  13. package/generation-core/index.d.ts +3740 -1100
  14. package/harness-core/index.d.ts +6 -6
  15. package/index.d.ts +22 -1
  16. package/package.json +1 -1
  17. package/src/composite-core/accordion-runtime.ts +2 -1
  18. package/src/composite-core/lightbox.ts +5 -6
  19. package/src/design-core/entrance-timing.ts +56 -0
  20. package/src/design-core/public-api.ts +1 -0
  21. package/src/generation-core/compose-site.ts +213 -42
  22. package/src/generation-core/entrances.ts +82 -13
  23. package/src/generation-core/patterns.ts +127 -15
  24. package/src/generation-core/plan-words.ts +3 -2
  25. package/src/generation-core/public-api.ts +3 -2
  26. package/src/generation-core/showcase.ts +81 -0
  27. package/src/generation-core/site-plan-schema.ts +21 -8
  28. package/src/generation-core/site-plan.model.ts +75 -4
  29. package/src/generation-core/site-spec.generated.ts +4019 -1174
  30. package/src/generation-core/validate-site-plan.ts +129 -21
  31. package/src/harness-core/harness-spec.generated.ts +3 -3
  32. package/src/harness-core/intake.ts +3 -4
@@ -1,4 +1,5 @@
1
- import { SPEC_SITE_PATTERNS } from './site-spec.generated';
1
+ import type { EntrancePart, EntranceSide } from './entrances';
2
+ import { SPEC_SITE_COPY_LENGTHS, SPEC_SITE_DENSITY_COPY, SPEC_SITE_LIBRARY, SPEC_SITE_PATTERNS } from './site-spec.generated';
2
3
 
3
4
  /**
4
5
  * The pattern library (contract `site.patterns`): structure and element styles only — never a colour,
@@ -28,6 +29,11 @@ import { SPEC_SITE_PATTERNS } from './site-spec.generated';
28
29
  * label) · on a card recipe: `words` (title / text / badge), `badge` (the badge's tone) and `icon` (its glyph), `scheme`
29
30
  * (the Brand card), and `c` — nodes joining its Content column · `icon` an Icon's glyph · `layer` the node's name in the
30
31
  * layer tree.
32
+ *
33
+ * The library (AI plan L0-2 · L0-3 · M1-6b): `max` a slot's budget in characters (at the standard copy length) ·
34
+ * `@<role>` in a binding path = the bound type's field for that role (`item.@title`; `?` = left out when the type has
35
+ * no such role) · `detail` = a link to the page showing that scope's item (`item`, `next`) · `enter` = the entrance
36
+ * part this node is (`side` = where a row's picture sits) · `ph: 'stat'` a number the studio types.
31
37
  */
32
38
  export interface PatternNode {
33
39
  readonly t: string;
@@ -76,8 +82,18 @@ export interface PatternNode {
76
82
  readonly icon?: string;
77
83
  /** CC4b: the node's name in the layer tree (`Price · Monthly`). */
78
84
  readonly layer?: string;
85
+ /** L0-2: a slot's budget in characters at the standard copy length. */
86
+ readonly max?: number;
87
+ /** M1-6b: the entrance part this node is — what the look's How animated may move. */
88
+ readonly enter?: `${EntrancePart}`;
89
+ /** M1-6b: the side a row's picture sits on (`rowMedia` slides in from it). */
90
+ readonly side?: `${EntranceSide}`;
91
+ /** L0-3: a link to the page that shows this scope's item (the plan's template page of its type). */
92
+ readonly detail?: `${BindScope.Item}` | `${BindScope.Next}`;
79
93
  }
80
94
 
95
+
96
+
81
97
  /** The composite-core recipes a pattern may stamp (`recipe`). */
82
98
  export enum PatternRecipe {
83
99
  Card = 'card',
@@ -116,6 +132,8 @@ export enum PatternGlobalPosition {
116
132
  export interface SitePattern {
117
133
  readonly family: string;
118
134
  readonly label: string;
135
+ /** The one sentence the model reads when it chooses the pattern (L0-2). */
136
+ readonly intent: string;
119
137
  /** Set on a header/footer pattern: it renders on every page, before or after the page. */
120
138
  readonly global?: `${PatternGlobalPosition}`;
121
139
  /** A navigation pattern's phone shape (CC1): `planNavShape` adds it after the pattern is stamped. */
@@ -133,6 +151,8 @@ export enum PatternPlaceholder {
133
151
  Video = 'video',
134
152
  /** CC4b (Q37): a price the studio types — free text, publish-blocking, its label shown meanwhile, never a number. */
135
153
  Price = 'price',
154
+ /** L2 (`proof.stat-row`): a number the studio stands behind — publish-blocking, its label shown, never a number. */
155
+ Stat = 'stat',
136
156
  }
137
157
 
138
158
  /** CC3: what an `opens` node opens. */
@@ -202,28 +222,120 @@ export function patternTakesBinding(pattern: SitePattern): boolean {
202
222
  }
203
223
 
204
224
  const IMAGE_TYPE = 'Image';
225
+ const ROLE_MARK = '@';
226
+ const OPTIONAL_MARK = '?';
205
227
 
206
- /** The scopes whose ITEM a pattern's bound Images take their alt text from (an Image that names no `alt`
207
- * of its own): `item` (the row of a `$binding` Repeater — the section's list), `route` or `next`. Each
208
- * needs a content type with a `previewTextField` (W1.1, Q7). */
209
- export function altSourceScopes(pattern: SitePattern): Set<string> {
210
- const scopes = new Set<string>();
211
- const visit = (n: PatternNode) => {
212
- if (n.t === IMAGE_TYPE && n.bind && n.alt === undefined) scopes.add(n.bind.split('.')[0]);
213
- // A Card v2's bound image takes its alt from the same item.
214
- const cardImage = n.recipe === PatternRecipe.Card ? n.binds?.[CardBindTarget.Image] : undefined;
215
- if (cardImage) scopes.add(cardImage.split('.')[0]);
216
- for (const child of n.c ?? []) visit(child);
228
+ /** A binding path read apart: its scope (`item`, `route`, `next`, `page`), and — when it names a ROLE (`@title`) — the
229
+ * role and whether the part may go without it (`@image?`); else the plain field path after the scope. */
230
+ export interface PatternPath {
231
+ scope: string;
232
+ role?: string;
233
+ optional: boolean;
234
+ field: string;
235
+ }
236
+
237
+ export function parsePatternPath(path: string): PatternPath {
238
+ const [scope, ...rest] = path.split('.');
239
+ const tail = rest.join('.');
240
+ if (!tail.startsWith(ROLE_MARK)) return { scope, optional: false, field: tail };
241
+ const optional = tail.endsWith(OPTIONAL_MARK);
242
+ return { scope, role: tail.slice(ROLE_MARK.length, optional ? -OPTIONAL_MARK.length : undefined), optional, field: '' };
243
+ }
244
+
245
+ /** A role a pattern reads from the item of `scope` (`item` = the section's list, `route`, `next`) — and whether the
246
+ * pattern can do without it. A bound image that names no `alt` of its own reads the item's title (W1.1, Q7). */
247
+ export interface PatternRoleNeed {
248
+ scope: string;
249
+ role: string;
250
+ optional: boolean;
251
+ }
252
+
253
+ export function patternRoleNeeds(pattern: SitePattern): PatternRoleNeed[] {
254
+ const out: PatternRoleNeed[] = [];
255
+ const add = (path: string | undefined) => {
256
+ if (!path) return;
257
+ const p = parsePatternPath(path);
258
+ if (p.role) out.push({ scope: p.scope, role: p.role, optional: p.optional });
217
259
  };
218
- visit(pattern.node);
219
- return scopes;
260
+ for (const n of patternNodes(pattern)) {
261
+ for (const path of [...nodeBindPaths(n), n.src]) add(path);
262
+ if (n.t === IMAGE_TYPE && n.bind && n.alt === undefined && !parsePatternPath(n.bind).optional) {
263
+ out.push({ scope: parsePatternPath(n.bind).scope, role: 'title', optional: false });
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /** Every slot's budget in characters at the standard copy length (L0-2). */
270
+ export function patternSlotBudgets(pattern: SitePattern): Record<string, number> {
271
+ const out: Record<string, number> = {};
272
+ for (const n of patternNodes(pattern)) if (n.slot && typeof n.max === 'number') out[n.slot] = n.max;
273
+ return out;
274
+ }
275
+
276
+ /** The copy length a density writes at (contract `site.densityCopy`) and its multiplier (`site.copyLengths`). */
277
+ export function copyLengthOf(density: string | null | undefined): number {
278
+ const length = (SPEC_SITE_DENSITY_COPY as Readonly<Record<string, string>>)[density ?? ''] ?? 'standard';
279
+ return (SPEC_SITE_COPY_LENGTHS as Readonly<Record<string, number>>)[length] ?? 1;
280
+ }
281
+
282
+ /** A slot's budget under the look's density (M1-6c): its `max` × the density's copy length, whole characters. */
283
+ export function slotBudget(pattern: SitePattern, slot: string, density?: string | null): number | null {
284
+ const max = patternSlotBudgets(pattern)[slot];
285
+ return max === undefined ? null : Math.round(max * copyLengthOf(density));
286
+ }
287
+
288
+ /** The slots whose node links somewhere (a button, a link): what a section's `links` may re-point. */
289
+ export function patternLinkSlots(pattern: SitePattern): string[] {
290
+ return [...new Set(patternNodes(pattern).filter(n => n.slot && n.href && !n.href.startsWith('$')).map(n => n.slot as string))];
291
+ }
292
+
293
+ /** True when the pattern holds a Form a section's `form` may give another preset. */
294
+ export function patternHasForm(pattern: SitePattern): boolean {
295
+ return patternNodes(pattern).some(n => !!n.preset);
296
+ }
297
+
298
+ /** The library's 31 sections (contract `site.library`), each with whether it is composed. */
299
+ export const SITE_LIBRARY: ReadonlyArray<{ readonly id: string; readonly family: string; readonly composed: boolean }> = SPEC_SITE_LIBRARY;
300
+
301
+ /** One pattern as the model reads it (L0-2): what it is for, what it may write and how long, and what it binds. */
302
+ export interface PatternManifestEntry {
303
+ id: string;
304
+ family: string;
305
+ label: string;
306
+ intent: string;
307
+ /** Header (`before`) or footer (`after`): a `globals` choice, never a page section. */
308
+ global?: string;
309
+ /** Each slot the plan may fill and its budget under the density. */
310
+ slots: Record<string, number>;
311
+ /** The slots a section's `links` may re-point. */
312
+ links: string[];
313
+ /** It iterates a list source (`binding`): required, optional, or none. */
314
+ binding: 'required' | 'optional' | 'none';
315
+ /** The roles it reads from the bound or route item (optional ones marked `?`). */
316
+ roles: string[];
317
+ optionalParts: string[];
318
+ form: boolean;
319
+ }
320
+
321
+ /** The composed library as the model reads it — every pattern, its budgets under `density` (M1-3's manifest). */
322
+ export function patternManifest(density?: string | null): PatternManifestEntry[] {
323
+ return Object.entries(SITE_PATTERNS).map(([id, p]) => ({
324
+ id, family: p.family, label: p.label, intent: p.intent, ...(p.global ? { global: p.global } : {}),
325
+ slots: Object.fromEntries(Object.keys(patternSlotBudgets(p)).map(slot => [slot, slotBudget(p, slot, density) as number])),
326
+ links: patternLinkSlots(p),
327
+ binding: patternNeedsBinding(p) ? 'required' : patternTakesBinding(p) ? 'optional' : 'none',
328
+ roles: [...new Set(patternRoleNeeds(p).map(r => `${r.scope}.@${r.role}${r.optional ? OPTIONAL_MARK : ''}`))],
329
+ optionalParts: patternOptionalParts(p),
330
+ form: patternHasForm(p),
331
+ }));
220
332
  }
221
333
 
222
334
  /** The bind scopes a pattern reads (`route`, `next`, …) — what its page must provide. */
223
335
  export function patternScopes(pattern: SitePattern): Set<string> {
224
336
  const scopes = new Set<string>();
225
337
  for (const n of patternNodes(pattern)) {
226
- for (const path of [...nodeBindPaths(n), n.alt, n.param, n.src]) {
338
+ for (const path of [...nodeBindPaths(n), n.alt, n.param, n.src, n.detail]) {
227
339
  if (path && !path.startsWith('$')) scopes.add(path.split('.')[0]);
228
340
  }
229
341
  }
@@ -54,8 +54,9 @@ export function writtenTexts(plan: SitePlan): WrittenText[] {
54
54
  }
55
55
 
56
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}';
57
+ /** Hebrew joins up to two one-letter words (and, the, in, to, from, that, as) to the next: "והטוב" holds "טוב". The
58
+ * ONE rule — the intake's keyword match (harness-core) uses it too. */
59
+ export const HEBREW_PREFIXES = '[והבלמשכ]{0,2}';
59
60
  const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
61
  const normalise = (s: string) => s.normalize('NFKC').toLowerCase();
61
62
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @vectoriox/iox-ui/generation-core — the shared, FRAMEWORK-FREE site-plan core (design-system plan
3
- * §6b, W1): the Site Plan shape + its validator, the pattern library and the Studio template (both
4
- * generated 1:1 from iox-ai-guidance `flavor-spec.v1.json`), and `composeSite` — a plan in, the
3
+ * §6b, W1): the Site Plan shape + its validator, the pattern library, the page types and the three structures (Studio ·
4
+ * Practice · Place — all generated 1:1 from iox-ai-guidance `flavor-spec.v1.json`), and `composeSite` — a plan in, the
5
5
  * schemas, sample items, data sources, pages, routes and header/footer globals out, as serialized
6
6
  * layout JSON. The website wizard (W2–W4) and the AI agent drive the SAME composer.
7
7
  * Pure: no transport, no clock, no randomness. Consumed by iox-cms (the server-side site build), the
@@ -24,6 +24,7 @@ export * from './compose-site';
24
24
  export * from './nav-shape';
25
25
  export * from './entrances';
26
26
  export * from './plan-data';
27
+ export * from './showcase';
27
28
  export * from './site-build-run';
28
29
  export * from './ai-run';
29
30
  export * from './site-plan-schema';
@@ -0,0 +1,81 @@
1
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { BindScope, patternNeedsBinding, PatternGlobalPosition, patternScopes, SITE_PATTERNS } from './patterns';
4
+ import { GlobalSlot, PagePlan, PageSectionPlan, SiteBusiness, SiteLocale, SitePlan, SitePlanSourceKind, SITE_PLAN_VERSION } from './site-plan.model';
5
+ import { STUDIO_TEMPLATE } from './templates';
6
+
7
+ /**
8
+ * THE LIBRARY ON SHOW — every composed pattern, stamped by the real composer onto ordinary pages (one page per family,
9
+ * the item page holding the patterns that read a route item), over the Studio's content and sources. One plan per
10
+ * header × footer pairing so every global variant is composed too. What the render-parity e2e, the specs and any
11
+ * gallery of the library compose — ONE definition, never a hand-picked list that forgets the newest pattern. Pure.
12
+ */
13
+
14
+ export interface ShowcaseInput {
15
+ business: SiteBusiness;
16
+ design: DesignChoices;
17
+ locale?: `${SiteLocale}`;
18
+ }
19
+
20
+ const ITEM_ROUTE = '/work/:slug';
21
+ const LIST_SOURCE = 'projects';
22
+
23
+ const routeOf = (family: string) => `/${family}`;
24
+ /** A page key: the family — never a global slot's name (`header` would share the site header's ids). */
25
+ const keyOf = (family: string) => ((Object.values(GlobalSlot) as string[]).includes(family) ? `${family}-page` : family);
26
+
27
+ function sectionFor(id: string): PageSectionPlan {
28
+ return patternNeedsBinding(SITE_PATTERNS[id]) ? { pattern: id, binding: LIST_SOURCE } : { pattern: id };
29
+ }
30
+
31
+ /** True when the pattern reads the route item (its page must be the item page). */
32
+ function readsRouteItem(id: string): boolean {
33
+ const scopes = patternScopes(SITE_PATTERNS[id]);
34
+ return scopes.has(BindScope.Route) || scopes.has(BindScope.Next);
35
+ }
36
+
37
+ /** The showcase's pages: Home (the heroes), one page per other family in library order, and the item page. */
38
+ export function showcasePages(): PagePlan[] {
39
+ const byFamily = new Map<string, string[]>();
40
+ const onItemPage: string[] = [];
41
+ for (const [id, p] of Object.entries(SITE_PATTERNS)) {
42
+ if (p.global) continue;
43
+ if (readsRouteItem(id)) { onItemPage.push(id); continue; }
44
+ byFamily.set(p.family, [...(byFamily.get(p.family) ?? []), id]);
45
+ }
46
+ const heroes = byFamily.get('hero') ?? [];
47
+ byFamily.delete('hero');
48
+ const home: PagePlan = { key: 'home', name: 'Home', route: '/', isHome: true, inNav: false, sections: heroes.map(sectionFor) };
49
+ const families = [...byFamily.entries()].map(([family, ids]): PagePlan => ({
50
+ key: keyOf(family), name: family.charAt(0).toUpperCase() + family.slice(1), route: routeOf(family), inNav: true, sections: ids.map(sectionFor),
51
+ }));
52
+ const item: PagePlan = {
53
+ key: 'item', name: 'Item', route: ITEM_ROUTE, inNav: false, template: STUDIO_TEMPLATE.contentTypes[0].name, routeSource: 'project',
54
+ sections: onItemPage.map(sectionFor),
55
+ };
56
+ return [home, ...families, item];
57
+ }
58
+
59
+ /** Every header and footer pattern, in contract order. */
60
+ export function showcaseGlobals(): { headers: string[]; footers: string[] } {
61
+ const of = (position: `${PatternGlobalPosition}`) => Object.entries(SITE_PATTERNS).filter(([, p]) => p.global === position).map(([id]) => id);
62
+ return { headers: of(PatternGlobalPosition.Before), footers: of(PatternGlobalPosition.After) };
63
+ }
64
+
65
+ /** The showcase as Site Plans — as many as it takes for every header and every footer to be composed once. */
66
+ export function showcasePlans(input: ShowcaseInput): SitePlan[] {
67
+ const { headers, footers } = showcaseGlobals();
68
+ const count = Math.max(headers.length, footers.length, 1);
69
+ const pages = showcasePages();
70
+ return Array.from({ length: count }, (_, i): SitePlan => ({
71
+ version: SITE_PLAN_VERSION,
72
+ source: { kind: SitePlanSourceKind.Template, templateId: 'showcase', templateVersion: 1 },
73
+ business: JSON.parse(JSON.stringify(input.business)),
74
+ ...(input.locale ? { locale: input.locale } : {}),
75
+ design: JSON.parse(JSON.stringify(input.design)),
76
+ contentTypes: JSON.parse(JSON.stringify(STUDIO_TEMPLATE.contentTypes)),
77
+ dataSources: JSON.parse(JSON.stringify(STUDIO_TEMPLATE.dataSources)),
78
+ pages: JSON.parse(JSON.stringify(pages)),
79
+ globals: { [GlobalSlot.Header]: headers[i % headers.length], [GlobalSlot.Footer]: footers[i % footers.length] } as SitePlan['globals'],
80
+ }));
81
+ }
@@ -1,11 +1,15 @@
1
- import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
1
+ import { ANIMATE_IDS, DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+ import { FORM_PRESETS } from '@vectoriox/iox-ui/forms-core';
2
3
 
3
4
  import {
4
5
  jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
5
6
  } from './json-schema';
6
- import { patternOptionalParts, patternSlots, patternTakesBinding, PatternGlobalPosition, SITE_PATTERNS, SitePattern } from './patterns';
7
7
  import {
8
- FACT_KEYS, GlobalSlot, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlan, SitePlanSource, SitePlanSourceKind, SITE_PLAN_VERSION,
8
+ patternHasForm, patternLinkSlots, patternOptionalParts, patternSlots, patternTakesBinding, PatternGlobalPosition, SITE_PATTERNS, SitePattern,
9
+ } from './patterns';
10
+ import {
11
+ FACT_KEYS, GlobalSlot, PAGE_ARCHETYPES, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlan, SitePlanSource, SitePlanSourceKind, SITE_LOCALES,
12
+ SITE_PLAN_VERSION,
9
13
  } from './site-plan.model';
10
14
  import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
11
15
  import { isPlainObject, PlainObject, ShapeEntry } from './shape-check';
@@ -88,8 +92,10 @@ export function contentTypeJsonSchema(): JsonSchema {
88
92
  const values = jsonListOf(jsonClosedObject({ [SitePlanWireKey.SeedField]: jsonText(), [SitePlanWireKey.SeedValue]: value }, [SitePlanWireKey.SeedField, SitePlanWireKey.SeedValue]));
89
93
  const seed = jsonShapedObject(SHAPE['seed'], { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean() }, 'seed',
90
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');
91
97
  return jsonShapedObject(SHAPE['contentType'], {
92
- name: jsonText(), label: jsonText(), previewTextField: jsonText(), previewImageField: jsonText(), fields: jsonListOf(fieldJsonSchema()), seed,
98
+ name: jsonText(), label: jsonText(), roles, fields: jsonListOf(fieldJsonSchema()), seed,
93
99
  }, 'contentType');
94
100
  }
95
101
 
@@ -114,20 +120,25 @@ export function sectionPatternIds(): string[] {
114
120
  /** A key a pattern has nothing for: only `{}` (or leaving it out) — the decoder drops it. */
115
121
  const NOTHING = (): JsonSchema => jsonClosedObject({}, []);
116
122
 
117
- /** One section branch: this pattern's id, its declared slots (a list), its optional parts, a binding when it takes one. */
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. */
118
125
  function sectionBranch(id: string, pattern: SitePattern): JsonSchema {
119
126
  const slots = patternSlots(pattern);
120
127
  const parts = patternOptionalParts(pattern);
128
+ const linkSlots = patternLinkSlots(pattern);
121
129
  const slotList = slots.length
122
130
  ? jsonListOf(jsonClosedObject({ [SitePlanWireKey.SlotName]: jsonOneOf(slots), [SitePlanWireKey.SlotText]: jsonText() }, [SitePlanWireKey.SlotName, SitePlanWireKey.SlotText]))
123
131
  : NOTHING();
124
132
  const branch = jsonShapedObject(SHAPE['section'], {
125
133
  pattern: one(id),
126
134
  slots: slotList,
135
+ links: linkSlots.length ? jsonClosedObject(Object.fromEntries(linkSlots.map(slot => [slot, jsonText()])), []) : NOTHING(),
127
136
  binding: patternTakesBinding(pattern) ? jsonText() : NOTHING(),
128
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),
129
140
  }, 'section');
130
- return { ...branch, description: `${id}: ${pattern.label}` };
141
+ return { ...branch, description: `${id}: ${pattern.intent}` };
131
142
  }
132
143
 
133
144
  /** A page section: one branch per pattern. */
@@ -137,9 +148,10 @@ export function sectionJsonSchema(): JsonSchema {
137
148
 
138
149
  /** A page. */
139
150
  export function pageJsonSchema(): JsonSchema {
151
+ const seo = jsonShapedObject(SHAPE['pageSeo'], { title: jsonText(), description: jsonText() }, 'pageSeo');
140
152
  return jsonShapedObject(SHAPE['page'], {
141
153
  key: jsonText(), name: jsonText(), route: jsonText(), isHome: jsonBoolean(), required: jsonBoolean(), inNav: jsonBoolean(),
142
- template: jsonText(), routeSource: jsonText(), sections: jsonListOf(sectionJsonSchema()),
154
+ archetype: jsonOneOf(Object.keys(PAGE_ARCHETYPES)), template: jsonText(), routeSource: jsonText(), seo, sections: jsonListOf(sectionJsonSchema()),
143
155
  }, 'page');
144
156
  }
145
157
 
@@ -155,6 +167,7 @@ export function sitePlanJsonSchema(pin: SitePlanSchemaPin): JsonSchema {
155
167
  version: one(SITE_PLAN_VERSION),
156
168
  source: sourceSchema(pin.source),
157
169
  business: businessSchema(),
170
+ locale: jsonOneOf(Object.keys(SITE_LOCALES)),
158
171
  design: pinnedDesign(pin.design),
159
172
  contentTypes: jsonListOf(contentTypeJsonSchema()),
160
173
  dataSources: jsonListOf(dataSourceJsonSchema()),
@@ -182,7 +195,7 @@ function slotsFromWire(slots: unknown): unknown {
182
195
  export function sectionFromWire(section: unknown): unknown {
183
196
  if (!isPlainObject(section)) return section;
184
197
  const out: PlainObject = { ...section };
185
- for (const key of ['slots', 'binding', 'without']) if (isEmptyObject(out[key])) delete out[key];
198
+ for (const key of ['slots', 'links', 'binding', 'without', 'form']) if (isEmptyObject(out[key])) delete out[key];
186
199
  if ('slots' in out) out['slots'] = slotsFromWire(out['slots']);
187
200
  return out;
188
201
  }
@@ -1,6 +1,6 @@
1
- import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
1
+ import type { AnimateId, DesignChoices } from '@vectoriox/iox-ui/design-core';
2
2
 
3
- import { SPEC_SITE_FACTS } from './site-spec.generated';
3
+ import { SPEC_SITE_ARCHETYPES, SPEC_SITE_FACTS, SPEC_SITE_LOCALES, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES } from './site-spec.generated';
4
4
 
5
5
  /**
6
6
  * The Site Plan (contract `site.planShape`, `SPEC_SITE_PLAN_SHAPE`: every key typed and explained, and the
@@ -49,15 +49,42 @@ export interface ContentSeedPlan {
49
49
  readonly [field: string]: unknown;
50
50
  }
51
51
 
52
+ /** The part a field plays (contract `site.roles`, AI plan L0-3): what a pattern binds instead of a field name, so one
53
+ * card grid shows projects, services or a menu. `title` is required on every type. */
54
+ export enum ContentRole {
55
+ Title = 'title',
56
+ Summary = 'summary',
57
+ Image = 'image',
58
+ Meta = 'meta',
59
+ Date = 'date',
60
+ Gallery = 'gallery',
61
+ }
62
+
63
+ /** What each role means, verbatim from the contract — what the model reads. */
64
+ export const CONTENT_ROLE_MEANINGS: Readonly<Record<`${ContentRole}`, string>> = SPEC_SITE_ROLES;
65
+ export const REQUIRED_CONTENT_ROLES: ReadonlyArray<`${ContentRole}`> = SPEC_SITE_REQUIRED_ROLES;
66
+
67
+ /** Which field plays each role (a field name of the type). */
68
+ export type ContentRoles = Readonly<{ title: string } & Partial<Record<Exclude<`${ContentRole}`, 'title'>, string>>>;
69
+
52
70
  export interface ContentTypePlan {
53
71
  readonly name: string;
54
72
  readonly label: string;
55
- readonly previewTextField?: string;
56
- readonly previewImageField?: string;
73
+ readonly roles: ContentRoles;
57
74
  readonly fields: ReadonlyArray<ContentFieldPlan>;
58
75
  readonly seed?: ContentSeedPlan;
59
76
  }
60
77
 
78
+ /** The CMS schema's preview fields of a content type — its title and image roles (the CMS's own names for them). */
79
+ export function contentTypePreviewFields(type: Pick<ContentTypePlan, 'roles'>): { previewTextField: string; previewImageField?: string } {
80
+ return { previewTextField: type.roles.title, ...(type.roles.image ? { previewImageField: type.roles.image } : {}) };
81
+ }
82
+
83
+ /** The field playing `role` in `type`, or undefined. */
84
+ export function roleField(type: Pick<ContentTypePlan, 'roles'> | null | undefined, role: string): string | undefined {
85
+ return (type?.roles as Readonly<Record<string, string>> | undefined)?.[role];
86
+ }
87
+
61
88
  export enum PlanDataSourceType {
62
89
  Cms = 'cms',
63
90
  Relative = 'relative',
@@ -98,12 +125,38 @@ export type DataSourcePlan = CmsDataSourcePlan | RelativeDataSourcePlan;
98
125
  export interface PageSectionPlan {
99
126
  readonly pattern: string;
100
127
  readonly slots?: Readonly<Record<string, string>>;
128
+ /** A linked slot (a button, a link) re-pointed to another page route of the plan. */
129
+ readonly links?: Readonly<Record<string, string>>;
101
130
  /** The list source alias a pattern's `$binding` repeater iterates. */
102
131
  readonly binding?: string;
103
132
  /** Optional pattern parts (`optional: <id>`) to leave out. */
104
133
  readonly without?: ReadonlyArray<string>;
134
+ /** A forms-core preset for the pattern's form, instead of the pattern's own. */
135
+ readonly form?: string;
136
+ /** How this section moves in, instead of the look's How animated (M1-6b; M2's "make the hero calmer"). */
137
+ readonly enter?: AnimateId;
138
+ }
139
+
140
+ /** A page's search-result words, when the plan writes them (each within the seo-core limits). */
141
+ export interface PageSeoPlan {
142
+ readonly title?: string;
143
+ readonly description?: string;
105
144
  }
106
145
 
146
+ /** The kinds of page a plan is made of (contract `site.archetypes`, L0-2). */
147
+ export type PageArchetypeId = keyof typeof SPEC_SITE_ARCHETYPES;
148
+ export interface PageArchetypeSlot {
149
+ readonly label: string;
150
+ readonly families: ReadonlyArray<string>;
151
+ readonly required: boolean;
152
+ }
153
+ export interface PageArchetype {
154
+ readonly label: string;
155
+ readonly intent: string;
156
+ readonly slots: ReadonlyArray<PageArchetypeSlot>;
157
+ }
158
+ export const PAGE_ARCHETYPES: Readonly<Record<string, PageArchetype>> = SPEC_SITE_ARCHETYPES;
159
+
107
160
  export interface PagePlan {
108
161
  readonly key: string;
109
162
  readonly name: string;
@@ -117,6 +170,9 @@ export interface PagePlan {
117
170
  readonly template?: string;
118
171
  /** The single source bound to the route item (`route.*`). */
119
172
  readonly routeSource?: string;
173
+ /** The kind of page (`PAGE_ARCHETYPES`): its sections fill the type's required slots from the families it allows. */
174
+ readonly archetype?: PageArchetypeId;
175
+ readonly seo?: PageSeoPlan;
120
176
  readonly sections: ReadonlyArray<PageSectionPlan>;
121
177
  }
122
178
 
@@ -125,10 +181,25 @@ export enum GlobalSlot {
125
181
  Footer = 'footer',
126
182
  }
127
183
 
184
+ /** The site's language (contract `site.locales`): every page's locale, and its direction. */
185
+ export enum SiteLocale {
186
+ En = 'en',
187
+ He = 'he',
188
+ }
189
+ export const DEFAULT_SITE_LOCALE = SiteLocale.En;
190
+ export const SITE_LOCALES: Readonly<Record<`${SiteLocale}`, { label: string; dir: 'ltr' | 'rtl' }>> = SPEC_SITE_LOCALES;
191
+
192
+ /** True when `locale` reads right to left. */
193
+ export function isRtlLocale(locale: string | null | undefined): boolean {
194
+ return (SITE_LOCALES as Readonly<Record<string, { dir: string }>>)[locale ?? '']?.dir === 'rtl';
195
+ }
196
+
128
197
  export interface SitePlan {
129
198
  readonly version: typeof SITE_PLAN_VERSION;
130
199
  readonly source: SitePlanSource;
131
200
  readonly business: SiteBusiness;
201
+ /** The site's language — absent = English. */
202
+ readonly locale?: `${SiteLocale}`;
132
203
  readonly design: Readonly<DesignChoices>;
133
204
  readonly contentTypes: ReadonlyArray<ContentTypePlan>;
134
205
  readonly dataSources: ReadonlyArray<DataSourcePlan>;