@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.
- package/composite-core/index.d.ts +3 -3
- package/design-core/index.d.ts +40 -2
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +6 -10
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +46 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +4043 -740
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs +5 -7
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +27 -1
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +3740 -1100
- package/harness-core/index.d.ts +6 -6
- package/index.d.ts +22 -1
- package/package.json +1 -1
- package/src/composite-core/accordion-runtime.ts +2 -1
- package/src/composite-core/lightbox.ts +5 -6
- package/src/design-core/entrance-timing.ts +56 -0
- package/src/design-core/public-api.ts +1 -0
- package/src/generation-core/compose-site.ts +213 -42
- package/src/generation-core/entrances.ts +82 -13
- package/src/generation-core/patterns.ts +127 -15
- package/src/generation-core/plan-words.ts +3 -2
- package/src/generation-core/public-api.ts +3 -2
- package/src/generation-core/showcase.ts +81 -0
- package/src/generation-core/site-plan-schema.ts +21 -8
- package/src/generation-core/site-plan.model.ts +75 -4
- package/src/generation-core/site-spec.generated.ts +4019 -1174
- package/src/generation-core/validate-site-plan.ts +129 -21
- package/src/harness-core/harness-spec.generated.ts +3 -3
- package/src/harness-core/intake.ts +3 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
/**
|
|
207
|
-
*
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(),
|
|
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),
|
|
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.
|
|
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
|
|
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>;
|