@vectoriox/iox-ui 5.25.0 → 5.27.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/design-core/index.d.ts +1153 -191
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +1680 -241
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +7018 -0
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -0
- package/generation-core/index.d.ts +1397 -0
- package/generation-core/package.json +3 -0
- package/package.json +5 -1
- package/src/design-core/brand-neutrals.ts +236 -0
- package/src/design-core/brand.ts +24 -14
- package/src/design-core/colour-eval.ts +117 -0
- package/src/design-core/contrast-audit.ts +214 -0
- package/src/design-core/design-apply.ts +34 -14
- package/src/design-core/design-settings.ts +12 -3
- package/src/design-core/design-token.model.ts +26 -0
- package/src/design-core/flavor-fixtures.generated.ts +150 -0
- package/src/design-core/flavor-spec.generated.ts +1061 -189
- package/src/design-core/flavor-tokens.ts +36 -8
- package/src/design-core/public-api.ts +3 -0
- package/src/design-core/token-css.ts +48 -15
- package/src/generation-core/compose-site.ts +474 -0
- package/src/generation-core/node-baseline.model.ts +10 -0
- package/src/generation-core/node-baselines.generated.ts +4841 -0
- package/src/generation-core/patterns.ts +125 -0
- package/src/generation-core/public-api.ts +20 -0
- package/src/generation-core/serialized-node.ts +91 -0
- package/src/generation-core/site-plan.model.ts +136 -0
- package/src/generation-core/site-spec.generated.ts +1111 -0
- package/src/generation-core/templates.ts +60 -0
- package/src/generation-core/validate-site-plan.ts +413 -0
- package/styles/iox-design.css +16 -0
- package/styles/iox-forms.css +15 -9
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deriveAccentTokens } from './brand';
|
|
2
|
+
import { flavorColours, FlavorColours, preTintAccentGrounds } from './brand-neutrals';
|
|
2
3
|
import { fontStackWithout, formatFontFamily } from './fonts';
|
|
3
4
|
import { SPEC_AXES } from './flavor-spec.generated';
|
|
4
5
|
import {
|
|
@@ -11,7 +12,8 @@ import {
|
|
|
11
12
|
*
|
|
12
13
|
* Layering, each step over the last (the boards' `tokenMap`, with the product's colour math):
|
|
13
14
|
* pack tokens → chosen font pair → shape · density · motion axes → pill-button rule →
|
|
14
|
-
* the author's own font roles →
|
|
15
|
+
* the author's own font roles → every COLOUR token from the brand seed and the background tint
|
|
16
|
+
* (`flavorColours`, DS9: tinted neutrals, the accent quartet, the held contrast pairs).
|
|
15
17
|
*
|
|
16
18
|
* `mode` does not change rows — every row keeps its light value and its dark override; mode is
|
|
17
19
|
* how the stylesheet is compiled.
|
|
@@ -91,13 +93,40 @@ export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): D
|
|
|
91
93
|
if (typeof family === 'string' && family.trim() !== '') set(role, userFontStack(family, byName.get(role)?.value), USER_TOKEN_SOURCE);
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
setColours(byName, flavorColours(pack, { seed: choices.seed, tint: choices.tint ?? pack.defaults.tint }));
|
|
97
|
+
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Colour rows take their light value and the dark one only where it differs (the contract's own rule). */
|
|
102
|
+
function setColours(byName: Map<string, DesignTokenRow>, colours: FlavorColours): void {
|
|
103
|
+
for (const [name, [light, dark]] of Object.entries(colours)) {
|
|
104
|
+
const row = byName.get(name);
|
|
105
|
+
if (!row) continue;
|
|
106
|
+
row.value = light;
|
|
107
|
+
if (dark !== undefined && dark !== light) row.values = { dark };
|
|
108
|
+
else delete row.values;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What the contract wrote for `choices` BEFORE DS9 — the baseline a studio that applied its look before
|
|
114
|
+
* the tint existed is judged against (`designBaseline`, `designTokenRepairs`): the look's own palette, the
|
|
115
|
+
* rows DS9 changed at their old values (`pack.legacy`), and a seed ≠ the pack's own re-deriving the accent
|
|
116
|
+
* set against `bg` alone, as `deriveAccentTokens` was called then. No held pairs, no tint.
|
|
117
|
+
*/
|
|
118
|
+
export function legacyFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): DesignTokenRow[] {
|
|
119
|
+
const rows = buildFlavorTokens(pack, { ...choices, seed: pack.seed, tint: 'neutral' });
|
|
120
|
+
const byName = new Map(rows.map(r => [r.name, r]));
|
|
121
|
+
for (const [name, old] of Object.entries(pack.legacy ?? {})) {
|
|
122
|
+
const row = byName.get(name);
|
|
123
|
+
if (!row) continue;
|
|
124
|
+
row.value = old.value;
|
|
125
|
+
if (old.dark !== undefined) row.values = { dark: old.dark };
|
|
126
|
+
else delete row.values;
|
|
127
|
+
}
|
|
94
128
|
if (choices.seed && choices.seed.toLowerCase() !== pack.seed.toLowerCase()) {
|
|
95
|
-
const
|
|
96
|
-
const inverseBg = byName.get('inverse-bg');
|
|
97
|
-
for (const accent of deriveAccentTokens(choices.seed, {
|
|
98
|
-
bg: bg?.value ?? '', bgDark: bg?.values?.dark,
|
|
99
|
-
inverseBg: inverseBg?.value, inverseBgDark: inverseBg?.values?.dark,
|
|
100
|
-
})) {
|
|
129
|
+
for (const accent of deriveAccentTokens(choices.seed, preTintAccentGrounds(pack.palette))) {
|
|
101
130
|
const row = byName.get(accent.name);
|
|
102
131
|
if (!row) continue;
|
|
103
132
|
row.value = accent.value;
|
|
@@ -105,6 +134,5 @@ export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): D
|
|
|
105
134
|
else delete row.values;
|
|
106
135
|
}
|
|
107
136
|
}
|
|
108
|
-
|
|
109
137
|
return rows;
|
|
110
138
|
}
|
|
@@ -18,6 +18,9 @@ export * from './adopt';
|
|
|
18
18
|
export * from './node-classes';
|
|
19
19
|
export * from './empty-image';
|
|
20
20
|
export * from './brand';
|
|
21
|
+
export * from './colour-eval';
|
|
22
|
+
export * from './brand-neutrals';
|
|
23
|
+
export * from './contrast-audit';
|
|
21
24
|
export * from './flavor-tokens';
|
|
22
25
|
export * from './flavor-seed';
|
|
23
26
|
export * from './spring';
|
|
@@ -121,7 +121,7 @@ export function tokensToDarkVarMap(tokens: ReadonlyArray<DesignTokenLike> | null
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
const TOKEN_REF = /var\(\s*--iox-([\w-]+)\s*(?:,([^()]*))?\)/g;
|
|
124
|
-
const PLACEHOLDER = /\{\{\s*([\w-]+)\s*\}\}/g;
|
|
124
|
+
const PLACEHOLDER = /\{\{\s*(~?)([\w-]+)\s*\}\}/g;
|
|
125
125
|
|
|
126
126
|
/** A compiled colour mode — the contract's modes minus `auto`, which is a compile CHOICE, not values. */
|
|
127
127
|
export type ResolvedMode = Exclude<DesignMode, 'auto'>;
|
|
@@ -163,6 +163,26 @@ export function resolveTokenValues(
|
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Every token's value INSIDE a section wearing `schemeId`, in one mode — what CSS computes there: the
|
|
168
|
+
* scheme's overrides (`{{token}}` / `{{~token}}` placeholders substituted from the root), every other
|
|
169
|
+
* row as declared, and `var()` references resolved within the section (the §3c re-declaration rule
|
|
170
|
+
* makes that the section's own resolution). `default` (or an unknown id) is the root.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveSchemeTokenValues(
|
|
173
|
+
tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
174
|
+
mode: ResolvedMode,
|
|
175
|
+
schemeId: string,
|
|
176
|
+
): Record<string, string> {
|
|
177
|
+
const rows = usableTokens(tokens);
|
|
178
|
+
const set = schemeId === DEFAULT_SCHEME ? undefined : (SPEC_SCHEMES as Readonly<Record<string, { set: Readonly<Record<string, string>> }>>)[schemeId]?.set;
|
|
179
|
+
if (!set) return resolveTokenValues(rows, mode);
|
|
180
|
+
const root = resolveTokenValues(rows, mode);
|
|
181
|
+
const other = resolveTokenValues(rows, mode === DARK ? LIGHT : DARK);
|
|
182
|
+
const overrides = new Map(schemeEntries(set, root, other));
|
|
183
|
+
return resolveTokenValues(rows.map(t => ({ name: t.name, value: overrides.get(t.name) ?? modeValue(t, mode) })), LIGHT);
|
|
184
|
+
}
|
|
185
|
+
|
|
166
186
|
/**
|
|
167
187
|
* `value` with every `var(--iox-…)` replaced by that token's resolved root value in `mode` — what
|
|
168
188
|
* the Style panel's Detach writes, so the node keeps exactly what it showed as a literal. An unknown
|
|
@@ -178,20 +198,25 @@ export function resolveTokenReferences(
|
|
|
178
198
|
resolved[name] ?? (fallback !== undefined ? fallback.trim() : ref));
|
|
179
199
|
}
|
|
180
200
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
201
|
+
type ResolvedValues = Readonly<Record<string, string>>;
|
|
202
|
+
|
|
203
|
+
/** A scheme value with its placeholders replaced — `{{token}}` by the root's resolved value in this
|
|
204
|
+
* mode, `{{~token}}` by its value in the OTHER mode (an inverse section is the other mode's ground,
|
|
205
|
+
* DS9) — or null when a placeholder names a token the set does not have (the declaration is then
|
|
206
|
+
* omitted). */
|
|
207
|
+
function substitute(template: string, resolved: ResolvedValues, opposite: ResolvedValues): string | null {
|
|
184
208
|
let missing = false;
|
|
185
|
-
const value = template.replace(PLACEHOLDER, (_, name: string) => {
|
|
186
|
-
|
|
187
|
-
|
|
209
|
+
const value = template.replace(PLACEHOLDER, (_, tilde: string, name: string) => {
|
|
210
|
+
const from = tilde ? opposite : resolved;
|
|
211
|
+
if (!(name in from)) missing = true;
|
|
212
|
+
return from[name] ?? '';
|
|
188
213
|
});
|
|
189
214
|
return missing ? null : value;
|
|
190
215
|
}
|
|
191
216
|
|
|
192
|
-
const schemeEntries = (set: Readonly<Record<string, string>>, resolved:
|
|
217
|
+
const schemeEntries = (set: Readonly<Record<string, string>>, resolved: ResolvedValues, opposite: ResolvedValues) =>
|
|
193
218
|
Object.entries(set)
|
|
194
|
-
.map(([name, template]): [string, string | null] => [name, substitute(template, resolved)])
|
|
219
|
+
.map(([name, template]): [string, string | null] => [name, substitute(template, resolved, opposite)])
|
|
195
220
|
.filter((e): e is [string, string] => e[1] !== null);
|
|
196
221
|
|
|
197
222
|
const hasPlaceholder = (value: string) => value.includes('{{');
|
|
@@ -209,12 +234,17 @@ const hasPlaceholder = (value: string) => value.includes('{{');
|
|
|
209
234
|
* and CSS makes both invalid. The contract writes `{{accent}}` instead — the token's ROOT value,
|
|
210
235
|
* resolved here at compile time (and again from dark values for the dark twin).
|
|
211
236
|
*/
|
|
212
|
-
export function compileSchemeCss(
|
|
237
|
+
export function compileSchemeCss(
|
|
238
|
+
tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
239
|
+
scope?: string,
|
|
240
|
+
opposite?: ResolvedValues,
|
|
241
|
+
): string {
|
|
213
242
|
const rows = usableTokens(tokens);
|
|
214
243
|
if (!rows.length) return '';
|
|
215
244
|
const resolved = resolveTokenValues(rows, LIGHT);
|
|
245
|
+
const other = opposite ?? resolveTokenValues(rows, DARK);
|
|
216
246
|
return nonDefaultSchemes().map(([id, set]) => rule(schemeSelector(id, scope), [
|
|
217
|
-
...schemeEntries(set, resolved),
|
|
247
|
+
...schemeEntries(set, resolved, other),
|
|
218
248
|
...rows.filter(t => !(t.name in set) && t.value.includes(REFERENCE))
|
|
219
249
|
.map((t): [string, string] => [t.name, t.value]),
|
|
220
250
|
])).filter(Boolean).join('\n');
|
|
@@ -231,9 +261,9 @@ function darkSchemeRules(rows: ReadonlyArray<DesignTokenLike>, scope: string | u
|
|
|
231
261
|
});
|
|
232
262
|
return nonDefaultSchemes().map(([id, set]) => {
|
|
233
263
|
const swapped = Object.fromEntries(Object.entries(set).filter(([, v]) => hasPlaceholder(v)));
|
|
234
|
-
const lightSwap = new Map(schemeEntries(swapped, light));
|
|
264
|
+
const lightSwap = new Map(schemeEntries(swapped, light, dark));
|
|
235
265
|
return rule(schemeSelector(id, scope), [
|
|
236
|
-
...schemeEntries(swapped, dark).filter(([name, value]) => lightSwap.get(name) !== value),
|
|
266
|
+
...schemeEntries(swapped, dark, light).filter(([name, value]) => lightSwap.get(name) !== value),
|
|
237
267
|
...redeclared.filter(t => !(t.name in set)).map((t): [string, string] => [t.name, darkOf(t) as string]),
|
|
238
268
|
], ' ');
|
|
239
269
|
});
|
|
@@ -249,15 +279,18 @@ export function compileTokenCssBlocks(
|
|
|
249
279
|
tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
250
280
|
opts: TokenCssOptions = {},
|
|
251
281
|
): TokenCssBlocks {
|
|
252
|
-
const
|
|
282
|
+
const all = usableTokens(tokens);
|
|
283
|
+
const rows = rowsForMode(all, opts.mode);
|
|
253
284
|
const root = opts.scope ?? ROOT;
|
|
285
|
+
// A forced mode flattens the rows to ONE mode; a `{{~token}}` still reads the other mode's value.
|
|
286
|
+
const opposite = resolveTokenValues(all, opts.mode === DARK ? LIGHT : DARK);
|
|
254
287
|
const present = new Set(rows.map(t => t.name));
|
|
255
288
|
const unset: Array<[string, string]> = opts.isolate
|
|
256
289
|
? [...specTokenNames()].filter(name => !present.has(name)).map((name): [string, string] => [name, 'initial'])
|
|
257
290
|
: [];
|
|
258
291
|
const base = [
|
|
259
292
|
rule(root, [...unset, ...rows.map((t): [string, string] => [t.name, t.value])]),
|
|
260
|
-
compileSchemeCss(rows, opts.scope),
|
|
293
|
+
compileSchemeCss(rows, opts.scope, opposite),
|
|
261
294
|
...ACCESSIBILITY_MEDIA.map(({ query, overrides }) =>
|
|
262
295
|
media(query, [rule(root, presentOverrides(rows, overrides, false), ' ')])),
|
|
263
296
|
].filter(Boolean).join('\n');
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { DesignChoices, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
|
|
3
|
+
import { NODE_BASELINES } from './node-baselines.generated';
|
|
4
|
+
import { BindScope, PatternDirective, PatternNode, PatternPlaceholder, SitePattern, patternById } from './patterns';
|
|
5
|
+
import { assertStyleSlots, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps, ComposeError, walkSerialized } from './serialized-node';
|
|
6
|
+
import {
|
|
7
|
+
ContentTypePlan, DataSourcePlan, FactKey, FACT_KEYS, GlobalSlot, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
|
|
8
|
+
RelativeOp, SitePlan, SitePlanSource, SITE_FACT_LABELS, SiteBusiness,
|
|
9
|
+
} from './site-plan.model';
|
|
10
|
+
import { assertValidSitePlan, NAV_PAGES_ALIAS, nextSourceOf, routeParams } from './validate-site-plan';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* THE composer — a Site Plan in, ordinary IOX data out (design-system plan §6b, W1). Deterministic and
|
|
14
|
+
* pure: the same plan composes to byte-identical output, with no clock, no randomness and no transport.
|
|
15
|
+
* The wizard and the AI agent both end here; W2's site-build service writes what it returns.
|
|
16
|
+
*
|
|
17
|
+
* Every node is created the way the builder creates one (`createSerializedNode`): the type's full
|
|
18
|
+
* constructor defaults, then ONE element style, then the pattern's layout. So a composed page is what
|
|
19
|
+
* an author would have built by hand from the palette — the builder reloads it and saves it unchanged,
|
|
20
|
+
* and the engine renders it with nothing to resolve. Never a colour, size or font a pattern did not
|
|
21
|
+
* name; never a fact the customer did not give (blank → a placeholder carrying the fact's label).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export enum PlaceholderKind {
|
|
25
|
+
/** A FACT the customer did not give (email, phone, city): publish-blocking. */
|
|
26
|
+
Fact = 'fact',
|
|
27
|
+
/** The pattern's own words, for the author to rewrite. */
|
|
28
|
+
Copy = 'copy',
|
|
29
|
+
/** An image slot with no image: the empty-image placeholder (DS3). */
|
|
30
|
+
Image = 'image',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ComposedPlaceholder {
|
|
34
|
+
nodeId: string;
|
|
35
|
+
/** The page key, or the global slot (`header` / `footer`), the node lives in. */
|
|
36
|
+
owner: string;
|
|
37
|
+
kind: PlaceholderKind;
|
|
38
|
+
fact?: FactKey;
|
|
39
|
+
slot?: string;
|
|
40
|
+
/** What the node shows meanwhile: the fact's label, the pattern's copy, or '' for an image. */
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export enum ComposeIssueCode {
|
|
45
|
+
/** A slot the plan and the business left empty and the pattern gives no words for — the node keeps
|
|
46
|
+
* its type's default text. */
|
|
47
|
+
UnfilledSlot = 'unfilled-slot',
|
|
48
|
+
/** A pattern links to a route the plan has no page for (an optional page was left out). */
|
|
49
|
+
DanglingLink = 'dangling-link',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ComposeIssue {
|
|
53
|
+
code: ComposeIssueCode;
|
|
54
|
+
nodeId: string;
|
|
55
|
+
owner: string;
|
|
56
|
+
detail: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ComposedPage {
|
|
60
|
+
key: string;
|
|
61
|
+
name: string;
|
|
62
|
+
route: { path: string };
|
|
63
|
+
isHome: boolean;
|
|
64
|
+
status: 'draft';
|
|
65
|
+
/** DS3: every page the wizard creates follows the look (page-bg / text / font-body). */
|
|
66
|
+
pageSettings: { designSystem: true };
|
|
67
|
+
layout: SerializedNode[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ComposedDataSource {
|
|
71
|
+
id: string;
|
|
72
|
+
alias: string;
|
|
73
|
+
type: string;
|
|
74
|
+
mode: string;
|
|
75
|
+
request: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ComposedItem = Record<string, unknown> & { isPublished: boolean; isSample: boolean };
|
|
79
|
+
|
|
80
|
+
export interface ComposedRoute {
|
|
81
|
+
key: string;
|
|
82
|
+
path: string;
|
|
83
|
+
isHome: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ComposedSite {
|
|
87
|
+
source: SitePlanSource;
|
|
88
|
+
business: SiteBusiness;
|
|
89
|
+
/** The look, completed against its pack (`resolveDesignChoices`) — what the build applies. */
|
|
90
|
+
design: DesignChoices;
|
|
91
|
+
/** The content types to create, as the plan states them. */
|
|
92
|
+
schemas: ContentTypePlan[];
|
|
93
|
+
/** Sample items per content type. `{sampleImage:k}` values name a bundled sample image the build resolves. */
|
|
94
|
+
items: Record<string, ComposedItem[]>;
|
|
95
|
+
/** Merged by alias into the org data-source library. */
|
|
96
|
+
dataSources: ComposedDataSource[];
|
|
97
|
+
pages: ComposedPage[];
|
|
98
|
+
/** Header + footer: root nodes carry `isGlobal` and `globalPosition`. */
|
|
99
|
+
globals: SerializedNode[];
|
|
100
|
+
routes: ComposedRoute[];
|
|
101
|
+
placeholders: ComposedPlaceholder[];
|
|
102
|
+
issues: ComposeIssue[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `{sampleImage:k}` — a bundled abstract sample image, never a stock photo (plan §6b). */
|
|
106
|
+
export const SAMPLE_IMAGE_REF = /^\{sampleImage:(\d+)\}$/;
|
|
107
|
+
const SAMPLE_IMAGE_TEMPLATE = /\{sampleImage:n(?:\+(\d+))?\}/g;
|
|
108
|
+
const ITEM_NUMBER = /\{n\}/g;
|
|
109
|
+
|
|
110
|
+
export function sampleImageRef(index: number): string {
|
|
111
|
+
return `{sampleImage:${index}}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The 1-based index a `{sampleImage:k}` value names, or null. */
|
|
115
|
+
export function parseSampleImageRef(value: unknown): number | null {
|
|
116
|
+
const m = typeof value === 'string' ? SAMPLE_IMAGE_REF.exec(value) : null;
|
|
117
|
+
return m ? Number(m[1]) : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The prop a pattern's `text` / slot / fact fills, per type. */
|
|
121
|
+
const TEXT_PROP: Readonly<Record<string, string>> = {
|
|
122
|
+
Heading: 'content',
|
|
123
|
+
Text: 'content',
|
|
124
|
+
Link: 'label',
|
|
125
|
+
SubmitButton: 'label',
|
|
126
|
+
};
|
|
127
|
+
/** The prop a pattern's `bind` binds, per type. */
|
|
128
|
+
const BIND_PROP: Readonly<Record<string, string>> = {
|
|
129
|
+
Heading: 'content',
|
|
130
|
+
Text: 'content',
|
|
131
|
+
Link: 'label',
|
|
132
|
+
Image: 'src',
|
|
133
|
+
};
|
|
134
|
+
/** Types whose `href` is a website page (`linkType: page` + `pageRoute`). */
|
|
135
|
+
const PAGE_LINK_TYPES: ReadonlySet<string> = new Set(['Link', 'LinkedContainer']);
|
|
136
|
+
const BUTTON_BLOCK = 'ButtonBlock';
|
|
137
|
+
const FORM = 'Form';
|
|
138
|
+
const FORM_STATE_BLOCKS = ['FormSuccess', 'FormError'];
|
|
139
|
+
const LINK_TYPE_PAGE = 'page';
|
|
140
|
+
const ANCHOR_TAG = 'a';
|
|
141
|
+
const LABEL_TAG = 'span';
|
|
142
|
+
const TEXT = 'Text';
|
|
143
|
+
/** A button's label reads the button's type and ink (the consent preset's labels, `consent-preset.ts`). */
|
|
144
|
+
const BUTTON_LABEL_INHERITS = { fontSize: 'inherit', fontWeight: 'inherit', color: 'inherit' };
|
|
145
|
+
const ITEM_VALUE_PATH = '$';
|
|
146
|
+
const STATUS_DRAFT = 'draft';
|
|
147
|
+
|
|
148
|
+
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
|
149
|
+
|
|
150
|
+
/** 32-bit FNV-1a, base 36 — a short stable id fragment. */
|
|
151
|
+
function stableHash(text: string): string {
|
|
152
|
+
let h = 0x811c9dc5;
|
|
153
|
+
for (let i = 0; i < text.length; i++) {
|
|
154
|
+
h ^= text.charCodeAt(i);
|
|
155
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
156
|
+
}
|
|
157
|
+
return h.toString(36).padStart(7, '0').slice(-6);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A page route as a base-relative href — the engine's `resolvePageHref` form (`/contact` → `contact`,
|
|
161
|
+
* `/` → `./`), so a link stays inside the org's `<base href>`. */
|
|
162
|
+
export function baseRelativeHref(route: string): string {
|
|
163
|
+
const path = route.replace(/\/{2,}/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
164
|
+
return path || './';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface RepeaterScope {
|
|
168
|
+
/** The alias a row's children bind (`repeaterItemAlias`: the iterate-path's last segment, else the source). */
|
|
169
|
+
itemAlias: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface Ctx {
|
|
173
|
+
owner: string;
|
|
174
|
+
page: PagePlan | null;
|
|
175
|
+
section: PageSectionPlan | null;
|
|
176
|
+
plan: SitePlan;
|
|
177
|
+
/** `next.*` → this alias. */
|
|
178
|
+
nextAlias: string | null;
|
|
179
|
+
placeholders: ComposedPlaceholder[];
|
|
180
|
+
issues: ComposeIssue[];
|
|
181
|
+
routes: ReadonlySet<string>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function nonEmpty(v: unknown): v is string {
|
|
185
|
+
return typeof v === 'string' && v.trim() !== '';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface TextChoice {
|
|
189
|
+
value?: string;
|
|
190
|
+
placeholder?: { kind: PlaceholderKind; fact?: FactKey };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Where a node's words come from: the plan's slot → the business → the customer's fact → the pattern's
|
|
194
|
+
* own text. A missing fact shows its label and is a placeholder; nothing is ever made up. */
|
|
195
|
+
function chooseText(pn: PatternNode, ctx: Ctx): TextChoice {
|
|
196
|
+
const fact = pn.ph && (FACT_KEYS as ReadonlyArray<string>).includes(pn.ph) ? pn.ph as FactKey : null;
|
|
197
|
+
const slotValue = pn.slot ? ctx.section?.slots?.[pn.slot] : undefined;
|
|
198
|
+
if (nonEmpty(slotValue)) return { value: slotValue };
|
|
199
|
+
if (pn.fromBusiness) {
|
|
200
|
+
const v = (ctx.plan.business as unknown as Record<string, unknown>)[pn.fromBusiness];
|
|
201
|
+
if (nonEmpty(v)) return { value: v };
|
|
202
|
+
}
|
|
203
|
+
if (fact) {
|
|
204
|
+
const given = ctx.plan.business.facts?.[fact];
|
|
205
|
+
return nonEmpty(given) ? { value: given } : { value: SITE_FACT_LABELS[fact], placeholder: { kind: PlaceholderKind.Fact, fact } };
|
|
206
|
+
}
|
|
207
|
+
if (pn.ph === PatternPlaceholder.Copy) return { value: pn.text, placeholder: { kind: PlaceholderKind.Copy } };
|
|
208
|
+
return { value: pn.text };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A `bind` / `param` / `src` path → the binding source + path, in this node's scope. */
|
|
212
|
+
function resolveScoped(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { source: string; path: string } {
|
|
213
|
+
const [scope, ...rest] = path.split('.');
|
|
214
|
+
const sub = rest.join('.');
|
|
215
|
+
switch (scope) {
|
|
216
|
+
case BindScope.Item:
|
|
217
|
+
case BindScope.Page:
|
|
218
|
+
if (!repeater) throw new ComposeError(`${nodeId}: "${path}" outside a Repeater`);
|
|
219
|
+
return { source: repeater.itemAlias, path: sub || ITEM_VALUE_PATH };
|
|
220
|
+
case BindScope.Route:
|
|
221
|
+
if (!ctx.page?.routeSource) throw new ComposeError(`${nodeId}: "${path}" on a page with no routeSource`);
|
|
222
|
+
return { source: ctx.page.routeSource, path: sub || ITEM_VALUE_PATH };
|
|
223
|
+
case BindScope.Next:
|
|
224
|
+
if (!ctx.nextAlias) throw new ComposeError(`${nodeId}: "${path}" with no next-sibling source`);
|
|
225
|
+
return { source: ctx.nextAlias, path: sub || ITEM_VALUE_PATH };
|
|
226
|
+
default:
|
|
227
|
+
throw new ComposeError(`${nodeId}: unknown binding scope "${scope}"`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function addBinding(node: SerializedNode, binding: SerializedBinding): void {
|
|
232
|
+
const declared = NODE_BASELINES[node.type]?.traits ?? [];
|
|
233
|
+
if (!declared.includes(binding.prop) && !binding.prop.startsWith('routeParam:')) {
|
|
234
|
+
throw new ComposeError(`${node.type} has no trait "${binding.prop}" to bind`);
|
|
235
|
+
}
|
|
236
|
+
node.bindings.push(binding);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function assertTraits(type: string, names: ReadonlyArray<string>): void {
|
|
240
|
+
const declared = NODE_BASELINES[type]?.traits ?? [];
|
|
241
|
+
for (const name of names) if (!declared.includes(name)) throw new ComposeError(`${type} has no trait "${name}"`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function setProp(node: SerializedNode, name: string, value: unknown): void {
|
|
245
|
+
assertTraits(node.type, [name]);
|
|
246
|
+
node.props[name] = value;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out. */
|
|
250
|
+
function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode | null {
|
|
251
|
+
if (pn.optional && ctx.section?.without?.includes(pn.optional)) return null;
|
|
252
|
+
|
|
253
|
+
const props: Record<string, unknown> = {};
|
|
254
|
+
if (pn.level) props['level'] = pn.level;
|
|
255
|
+
if (pn.tag) props['htmlTag'] = pn.tag;
|
|
256
|
+
if (pn.scheme) props['colorScheme'] = pn.scheme;
|
|
257
|
+
if (pn.control) props['control'] = pn.control;
|
|
258
|
+
if (pn.label) props['label'] = pn.label;
|
|
259
|
+
if (pn.required !== undefined) props['required'] = pn.required;
|
|
260
|
+
assertTraits(pn.t, Object.keys(props));
|
|
261
|
+
// Props first: a Heading with no `es` wears its LEVEL's default style.
|
|
262
|
+
const node = createSerializedNode(id, pn.t, props, pn.es ?? null);
|
|
263
|
+
|
|
264
|
+
// Words.
|
|
265
|
+
const words = chooseText(pn, ctx);
|
|
266
|
+
if (words.placeholder) {
|
|
267
|
+
const shown = words.value ?? String(NODE_BASELINES[pn.t]?.props[TEXT_PROP[pn.t]] ?? '');
|
|
268
|
+
ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: words.placeholder.kind, ...(words.placeholder.fact ? { fact: words.placeholder.fact } : {}), ...(pn.slot ? { slot: pn.slot } : {}), text: shown });
|
|
269
|
+
} else if (words.value === undefined && (pn.slot || pn.fromBusiness)) {
|
|
270
|
+
ctx.issues.push({ code: ComposeIssueCode.UnfilledSlot, nodeId: id, owner: ctx.owner, detail: `no value for ${pn.slot ? `slot "${pn.slot}"` : `business "${pn.fromBusiness}"`} — keeps the ${pn.t} default` });
|
|
271
|
+
}
|
|
272
|
+
if (words.value !== undefined) {
|
|
273
|
+
if (pn.t === BUTTON_BLOCK) {
|
|
274
|
+
const label = createSerializedNode(`${id}-label`, TEXT, { content: words.value, tag: LABEL_TAG });
|
|
275
|
+
setStyleProps(label, BUTTON_LABEL_INHERITS);
|
|
276
|
+
node.children = [label];
|
|
277
|
+
} else {
|
|
278
|
+
const prop = TEXT_PROP[pn.t];
|
|
279
|
+
if (!prop) throw new ComposeError(`${id}: ${pn.t} cannot hold text`);
|
|
280
|
+
setProp(node, prop, words.value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Images.
|
|
285
|
+
if (pn.ph === PatternPlaceholder.Image) {
|
|
286
|
+
setProp(node, 'src', '');
|
|
287
|
+
ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: PlaceholderKind.Image, text: '' });
|
|
288
|
+
}
|
|
289
|
+
if (pn.aspect) setStyleProps(node, { aspectRatio: pn.aspect });
|
|
290
|
+
|
|
291
|
+
// Links.
|
|
292
|
+
if (pn.href) {
|
|
293
|
+
if (pn.href === PatternDirective.PageRoute) {
|
|
294
|
+
setProp(node, 'linkType', LINK_TYPE_PAGE);
|
|
295
|
+
addBinding(node, { prop: 'pageRoute', ...resolveScoped(`${BindScope.Page}.route`, repeater, ctx, id) });
|
|
296
|
+
} else {
|
|
297
|
+
if (!ctx.routes.has(pn.href)) ctx.issues.push({ code: ComposeIssueCode.DanglingLink, nodeId: id, owner: ctx.owner, detail: `links to ${pn.href}, which the plan has no page for` });
|
|
298
|
+
if (PAGE_LINK_TYPES.has(pn.t)) {
|
|
299
|
+
setProp(node, 'linkType', LINK_TYPE_PAGE);
|
|
300
|
+
setProp(node, 'pageRoute', pn.href);
|
|
301
|
+
} else if (pn.t === BUTTON_BLOCK) {
|
|
302
|
+
setProp(node, 'tag', ANCHOR_TAG);
|
|
303
|
+
setProp(node, 'href', baseRelativeHref(pn.href));
|
|
304
|
+
} else {
|
|
305
|
+
throw new ComposeError(`${id}: ${pn.t} cannot link`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (pn.param) {
|
|
310
|
+
const names = pn.href ? routeParams(pn.href) : [];
|
|
311
|
+
if (names.length !== 1) throw new ComposeError(`${id}: "param" needs an href with one :param`);
|
|
312
|
+
addBinding(node, { prop: `routeParam:${names[0]}`, ...resolveScoped(pn.param, repeater, ctx, id) });
|
|
313
|
+
}
|
|
314
|
+
if (pn.bind) {
|
|
315
|
+
const prop = BIND_PROP[pn.t];
|
|
316
|
+
if (!prop) throw new ComposeError(`${id}: ${pn.t} has no bindable value`);
|
|
317
|
+
addBinding(node, { prop, ...resolveScoped(pn.bind, repeater, ctx, id) });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Layout.
|
|
321
|
+
if (pn.lay) setStyleProps(node, pn.lay);
|
|
322
|
+
if (pn.resp) {
|
|
323
|
+
for (const layer of Object.values(pn.resp)) assertStyleSlots(node.type, Object.keys(layer));
|
|
324
|
+
node.responsive = Object.fromEntries(Object.entries(pn.resp).map(([bp, styleProps]) => [bp, { styleProps: { ...styleProps } }]));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A Repeater's rows.
|
|
328
|
+
let rowScope = repeater;
|
|
329
|
+
if (pn.src) {
|
|
330
|
+
let binding: SerializedBinding;
|
|
331
|
+
if (pn.src === PatternDirective.Binding) {
|
|
332
|
+
if (!ctx.section?.binding) throw new ComposeError(`${id}: "$binding" with no section binding`);
|
|
333
|
+
binding = { prop: 'source', source: ctx.section.binding, path: '' };
|
|
334
|
+
} else if (pn.src === PatternDirective.NavPages) {
|
|
335
|
+
binding = { prop: 'source', source: NAV_PAGES_ALIAS, path: '' };
|
|
336
|
+
} else {
|
|
337
|
+
const scoped = resolveScoped(pn.src, repeater, ctx, id);
|
|
338
|
+
binding = { prop: 'source', source: scoped.source, path: scoped.path === ITEM_VALUE_PATH ? '' : scoped.path };
|
|
339
|
+
}
|
|
340
|
+
addBinding(node, binding);
|
|
341
|
+
const lastSegment = binding.path.split('.').filter(Boolean).pop();
|
|
342
|
+
rowScope = { itemAlias: lastSegment || binding.source };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Children.
|
|
346
|
+
const children = (pn.c ?? [])
|
|
347
|
+
.map((child, i) => composeNode(child, `${id}-${i}`, rowScope, ctx))
|
|
348
|
+
.filter((c): c is SerializedNode => !!c);
|
|
349
|
+
if (pn.c?.length) {
|
|
350
|
+
if (!node.children) throw new ComposeError(`${id}: ${pn.t} holds no children`);
|
|
351
|
+
node.children = [...node.children, ...children];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// A Form: its stable id, and both state blocks — never optional (forms plan D7).
|
|
355
|
+
if (pn.t === FORM) {
|
|
356
|
+
if (pn.preset) setProp(node, 'formId', `${pn.preset}-${stableHash(`${ctx.owner}/${id}`)}`);
|
|
357
|
+
node.children = [
|
|
358
|
+
...(node.children ?? []),
|
|
359
|
+
...FORM_STATE_BLOCKS.map((type, i) => createSerializedNode(`${id}-${(pn.c?.length ?? 0) + i}`, type)),
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
return node;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function composePattern(pattern: SitePattern, id: string, ctx: Ctx): SerializedNode {
|
|
366
|
+
const node = composeNode(pattern.node, id, null, ctx);
|
|
367
|
+
if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional`);
|
|
368
|
+
node.label = pattern.label;
|
|
369
|
+
return node;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function composeDataSource(ds: DataSourcePlan): ComposedDataSource {
|
|
373
|
+
const id = `ds-${ds.alias}`;
|
|
374
|
+
if (ds.type === PlanDataSourceType.Cms) {
|
|
375
|
+
const cms: Record<string, unknown> = { contentType: ds.contentType };
|
|
376
|
+
if (ds.limit !== undefined) cms['limit'] = ds.limit;
|
|
377
|
+
if (ds.filterByRouteParam) cms['filterByRouteParam'] = { ...ds.filterByRouteParam };
|
|
378
|
+
return { id, alias: ds.alias, type: PlanDataSourceType.Cms, mode: ds.mode, request: { cms } };
|
|
379
|
+
}
|
|
380
|
+
const pick = ds.op === RelativeOp.SiblingsNext ? 'next' : 'prev';
|
|
381
|
+
return {
|
|
382
|
+
id, alias: ds.alias, type: PlanDataSourceType.Relative, mode: PlanDataSourceMode.Single,
|
|
383
|
+
request: { relative: { kind: 'siblings', from: ds.within, subjectFrom: ds.of, pick } },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function navPagesSource(plan: SitePlan): ComposedDataSource {
|
|
388
|
+
return {
|
|
389
|
+
id: `ds-${NAV_PAGES_ALIAS}`, alias: NAV_PAGES_ALIAS, type: 'static', mode: PlanDataSourceMode.Query,
|
|
390
|
+
request: { data: plan.pages.filter(p => p.inNav).map(p => ({ name: p.name, route: p.route })) },
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function expandSeedValue(value: unknown, n: number): unknown {
|
|
395
|
+
if (Array.isArray(value)) return value.map(v => expandSeedValue(v, n));
|
|
396
|
+
if (typeof value !== 'string') return value;
|
|
397
|
+
return value
|
|
398
|
+
.replace(ITEM_NUMBER, String(n))
|
|
399
|
+
.replace(SAMPLE_IMAGE_TEMPLATE, (_m, plus?: string) => sampleImageRef(n + (plus ? Number(plus) : 0)));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function composeItems(type: ContentTypePlan): ComposedItem[] {
|
|
403
|
+
const seed = type.seed;
|
|
404
|
+
if (!seed) return [];
|
|
405
|
+
const items: ComposedItem[] = [];
|
|
406
|
+
for (let n = 1; n <= seed.count; n++) {
|
|
407
|
+
const item: Record<string, unknown> = {};
|
|
408
|
+
for (const field of type.fields) if (field.name in seed) item[field.name] = expandSeedValue(seed[field.name], n);
|
|
409
|
+
items.push({ ...item, isPublished: seed.isPublished, isSample: seed.sample });
|
|
410
|
+
}
|
|
411
|
+
return items;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** True when a composed tree iterates the navigation's pages. */
|
|
415
|
+
function bindsAlias(trees: ReadonlyArray<ReadonlyArray<SerializedNode>>, alias: string): boolean {
|
|
416
|
+
let found = false;
|
|
417
|
+
for (const tree of trees) walkSerialized(tree, node => { if (node.bindings.some(b => b.source === alias)) found = true; });
|
|
418
|
+
return found;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Compose a Site Plan. Throws `SitePlanError` (every issue) when the plan does not validate — the
|
|
423
|
+
* composer never repairs a plan.
|
|
424
|
+
*/
|
|
425
|
+
export function composeSite(plan: SitePlan): ComposedSite {
|
|
426
|
+
assertValidSitePlan(plan);
|
|
427
|
+
const placeholders: ComposedPlaceholder[] = [];
|
|
428
|
+
const issues: ComposeIssue[] = [];
|
|
429
|
+
const routes = new Set(plan.pages.map(p => p.route));
|
|
430
|
+
const relatives = new Map(plan.dataSources.map(d => [d.alias, d.type === PlanDataSourceType.Relative ? { type: d.type, of: d.of, op: d.op } : { type: d.type }]));
|
|
431
|
+
const base = { plan, placeholders, issues, routes };
|
|
432
|
+
|
|
433
|
+
const pages: ComposedPage[] = plan.pages.map(page => {
|
|
434
|
+
const nextAlias = page.routeSource ? nextSourceOf(relatives, page.routeSource) : null;
|
|
435
|
+
const layout = page.sections.map((section, i) => {
|
|
436
|
+
const pattern = patternById(section.pattern) as SitePattern;
|
|
437
|
+
return composePattern(pattern, `${page.key}-${i}`, { ...base, owner: page.key, page, section, nextAlias });
|
|
438
|
+
});
|
|
439
|
+
return {
|
|
440
|
+
key: page.key,
|
|
441
|
+
name: page.name,
|
|
442
|
+
route: { path: page.route },
|
|
443
|
+
isHome: page.isHome === true,
|
|
444
|
+
status: STATUS_DRAFT,
|
|
445
|
+
pageSettings: { designSystem: true },
|
|
446
|
+
layout,
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const globals = Object.values(GlobalSlot).map(slot => {
|
|
451
|
+
const pattern = patternById(plan.globals[slot]) as SitePattern;
|
|
452
|
+
const node = composePattern(pattern, slot, { ...base, owner: slot, page: null, section: null, nextAlias: null });
|
|
453
|
+
node.isGlobal = true;
|
|
454
|
+
node.globalPosition = pattern.global;
|
|
455
|
+
return node;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const dataSources = plan.dataSources.map(composeDataSource);
|
|
459
|
+
if (bindsAlias([...pages.map(p => p.layout), globals], NAV_PAGES_ALIAS)) dataSources.push(navPagesSource(plan));
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
source: clone(plan.source),
|
|
463
|
+
business: clone(plan.business),
|
|
464
|
+
design: resolveDesignChoices(clone(plan.design)),
|
|
465
|
+
schemas: clone(plan.contentTypes) as ContentTypePlan[],
|
|
466
|
+
items: Object.fromEntries(plan.contentTypes.map(t => [t.name, composeItems(t)])),
|
|
467
|
+
dataSources,
|
|
468
|
+
pages,
|
|
469
|
+
globals,
|
|
470
|
+
routes: plan.pages.map(p => ({ key: p.key, path: p.route, isHome: p.isHome === true })),
|
|
471
|
+
placeholders,
|
|
472
|
+
issues,
|
|
473
|
+
};
|
|
474
|
+
}
|