@vectoriox/iox-ui 5.27.0 → 5.29.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 +186 -7
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +201 -11
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs +119 -5
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +591 -181
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +189 -16
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/forms-core/index.d.ts +59 -3
- package/generation-core/index.d.ts +206 -74
- package/index.d.ts +30 -3
- package/package.json +1 -1
- package/src/design-core/contrast-audit.ts +7 -6
- package/src/design-core/flavor-spec.generated.ts +170 -5
- package/src/design-core/node-links.ts +32 -0
- package/src/design-core/public-api.ts +1 -0
- package/src/forms-core/collect.ts +3 -1
- package/src/forms-core/form-presets.ts +105 -0
- package/src/forms-core/options.ts +29 -0
- package/src/forms-core/public-api.ts +2 -0
- package/src/forms-core/transport.ts +6 -2
- package/src/generation-core/baseline-roles.ts +75 -0
- package/src/generation-core/compose-site.ts +256 -95
- package/src/generation-core/node-baselines.generated.ts +90 -0
- package/src/generation-core/patterns.ts +31 -9
- package/src/generation-core/public-api.ts +1 -0
- package/src/generation-core/serialized-node.ts +13 -2
- package/src/generation-core/site-plan.model.ts +4 -3
- package/src/generation-core/site-spec.generated.ts +110 -57
- package/src/generation-core/validate-site-plan.ts +52 -20
- package/styles/iox-forms.css +136 -8
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
import { DesignChoices, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
|
|
1
|
+
import { DesignChoices, LinkType, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
|
|
2
3
|
|
|
3
4
|
import { NODE_BASELINES } from './node-baselines.generated';
|
|
4
5
|
import { BindScope, PatternDirective, PatternNode, PatternPlaceholder, SitePattern, patternById } from './patterns';
|
|
5
|
-
import { assertStyleSlots, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps
|
|
6
|
+
import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps } from './serialized-node';
|
|
6
7
|
import {
|
|
7
8
|
ContentTypePlan, DataSourcePlan, FactKey, FACT_KEYS, GlobalSlot, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
|
|
8
9
|
RelativeOp, SitePlan, SitePlanSource, SITE_FACT_LABELS, SiteBusiness,
|
|
9
10
|
} from './site-plan.model';
|
|
10
|
-
import { assertValidSitePlan,
|
|
11
|
+
import { assertValidSitePlan, nextSourceOf, routeParams } from './validate-site-plan';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* THE composer — a Site Plan in, ordinary IOX data out (design-system plan §6b, W1). Deterministic and
|
|
14
15
|
* 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
|
+
* The wizard and the AI agent both end here; W2's site-build service writes what it returns. The one
|
|
17
|
+
* thing that changes with time — the copyright year — comes in through `ComposeContext`.
|
|
16
18
|
*
|
|
17
19
|
* Every node is created the way the builder creates one (`createSerializedNode`): the type's full
|
|
18
|
-
* constructor defaults
|
|
20
|
+
* constructor defaults — its empty-drop sizes released, so the pattern owns its box (`BASELINE_ROLES`) —
|
|
21
|
+
* then ONE element style, then the pattern's layout. So a composed page is what
|
|
19
22
|
* an author would have built by hand from the palette — the builder reloads it and saves it unchanged,
|
|
20
23
|
* and the engine renders it with nothing to resolve. Never a colour, size or font a pattern did not
|
|
21
24
|
* name; never a fact the customer did not give (blank → a placeholder carrying the fact's label).
|
|
@@ -45,15 +48,34 @@ export enum ComposeIssueCode {
|
|
|
45
48
|
/** A slot the plan and the business left empty and the pattern gives no words for — the node keeps
|
|
46
49
|
* its type's default text. */
|
|
47
50
|
UnfilledSlot = 'unfilled-slot',
|
|
48
|
-
/** A
|
|
49
|
-
|
|
51
|
+
/** A link to a page the plan leaves out now opens an email to the business (`mailto:`). */
|
|
52
|
+
LinkToEmail = 'link-to-email',
|
|
53
|
+
/** A link or button to a page the plan leaves out, with no business email to send to: removed. */
|
|
54
|
+
LinkRemoved = 'link-removed',
|
|
55
|
+
/** A linked box to a page the plan leaves out, with no business email: it keeps its contents as a
|
|
56
|
+
* plain Container and loses only the link. */
|
|
57
|
+
LinkUnlinked = 'link-unlinked',
|
|
50
58
|
}
|
|
51
59
|
|
|
60
|
+
/** The issues that are a dangling link (a route the plan has no page for) and what was done about it. */
|
|
61
|
+
export const DANGLING_LINK_CODES: ReadonlySet<ComposeIssueCode> = new Set([
|
|
62
|
+
ComposeIssueCode.LinkToEmail, ComposeIssueCode.LinkRemoved, ComposeIssueCode.LinkUnlinked,
|
|
63
|
+
]);
|
|
64
|
+
|
|
52
65
|
export interface ComposeIssue {
|
|
53
66
|
code: ComposeIssueCode;
|
|
54
67
|
nodeId: string;
|
|
55
68
|
owner: string;
|
|
69
|
+
/** In plain words, for the wizard to show. */
|
|
56
70
|
detail: string;
|
|
71
|
+
/** A dangling link's route. */
|
|
72
|
+
route?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What the composer may not decide for itself — today the clock. */
|
|
76
|
+
export interface ComposeContext {
|
|
77
|
+
/** The calendar year the copyright line reads (`{year}`), from the caller's clock. */
|
|
78
|
+
year: number;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
export interface ComposedPage {
|
|
@@ -131,19 +153,40 @@ const BIND_PROP: Readonly<Record<string, string>> = {
|
|
|
131
153
|
Link: 'label',
|
|
132
154
|
Image: 'src',
|
|
133
155
|
};
|
|
134
|
-
/**
|
|
135
|
-
|
|
156
|
+
/** Where each linking type writes a page link and an external URL (a ButtonBlock links only as an `<a>`). */
|
|
157
|
+
interface LinkSlots { url: string; fixed?: Readonly<Record<string, string>>; }
|
|
158
|
+
const LINK_SLOTS: Readonly<Record<string, LinkSlots>> = {
|
|
159
|
+
Link: { url: 'url' },
|
|
160
|
+
LinkedContainer: { url: 'url' },
|
|
161
|
+
ButtonBlock: { url: 'href', fixed: { tag: 'a' } },
|
|
162
|
+
};
|
|
163
|
+
/** What a linked box becomes when its link has nowhere to go: the same box, unlinked. */
|
|
164
|
+
const UNLINKED_TYPE: Readonly<Record<string, string>> = { LinkedContainer: 'Container' };
|
|
136
165
|
const BUTTON_BLOCK = 'ButtonBlock';
|
|
137
166
|
const FORM = 'Form';
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const
|
|
167
|
+
const FIELD = 'Field';
|
|
168
|
+
const IMAGE = 'Image';
|
|
169
|
+
const SUBMIT_BUTTON = 'SubmitButton';
|
|
170
|
+
const FORM_SUCCESS = 'FormSuccess';
|
|
171
|
+
const FORM_ERROR = 'FormError';
|
|
172
|
+
const FORM_STATE_BLOCKS = [FORM_SUCCESS, FORM_ERROR];
|
|
173
|
+
/** The Field traits a preset field fills, 1:1 (`FormPresetField`). */
|
|
174
|
+
const PRESET_FIELD_PROPS: ReadonlyArray<keyof FormPresetField> = ['label', 'control', 'required', 'help', 'placeholder', 'optionsText', 'rows'];
|
|
141
175
|
const LABEL_TAG = 'span';
|
|
142
176
|
const TEXT = 'Text';
|
|
143
|
-
/** A button's label reads the button's type and ink (
|
|
144
|
-
const
|
|
177
|
+
/** A button's label reads the button's own type and ink (contract element style, W1.1 Q6). */
|
|
178
|
+
const BUTTON_LABEL_STYLE = 'button-label';
|
|
145
179
|
const ITEM_VALUE_PATH = '$';
|
|
146
180
|
const STATUS_DRAFT = 'draft';
|
|
181
|
+
const MAILTO = 'mailto:';
|
|
182
|
+
/** A pattern's `alt: ''` — the image is decorative and its alt is written empty. */
|
|
183
|
+
const DECORATIVE_ALT = '';
|
|
184
|
+
/** What the composer writes for every node: the pattern owns the box (W1.1, Q1). */
|
|
185
|
+
const COMPOSED = { releaseLayoutDefaults: true };
|
|
186
|
+
/** The words a pattern may leave for compose time: the year (`ComposeContext`) and the business name. */
|
|
187
|
+
const TEXT_VARIABLE = /\{(year|business\.name)\}/g;
|
|
188
|
+
/** What `page.*` may read on a node stamped for a navigation page. */
|
|
189
|
+
const STAMPED_PAGE_FIELDS: ReadonlySet<string> = new Set(['name', 'route']);
|
|
147
190
|
|
|
148
191
|
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
|
149
192
|
|
|
@@ -157,12 +200,8 @@ function stableHash(text: string): string {
|
|
|
157
200
|
return h.toString(36).padStart(7, '0').slice(-6);
|
|
158
201
|
}
|
|
159
202
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
export function baseRelativeHref(route: string): string {
|
|
163
|
-
const path = route.replace(/\/{2,}/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
164
|
-
return path || './';
|
|
165
|
-
}
|
|
203
|
+
const create = (id: string, type: string, props: Record<string, unknown> = {}, elementStyle?: string | null) =>
|
|
204
|
+
createSerializedNode(id, type, props, elementStyle, COMPOSED);
|
|
166
205
|
|
|
167
206
|
interface RepeaterScope {
|
|
168
207
|
/** The alias a row's children bind (`repeaterItemAlias`: the iterate-path's last segment, else the source). */
|
|
@@ -174,6 +213,11 @@ interface Ctx {
|
|
|
174
213
|
page: PagePlan | null;
|
|
175
214
|
section: PageSectionPlan | null;
|
|
176
215
|
plan: SitePlan;
|
|
216
|
+
year: number;
|
|
217
|
+
/** The business email a dangling link is pointed at, when the customer gave one. */
|
|
218
|
+
email: string | null;
|
|
219
|
+
/** The navigation page an `each: $navPages` node is being stamped for. */
|
|
220
|
+
navPage: PagePlan | null;
|
|
177
221
|
/** `next.*` → this alias. */
|
|
178
222
|
nextAlias: string | null;
|
|
179
223
|
placeholders: ComposedPlaceholder[];
|
|
@@ -185,6 +229,11 @@ function nonEmpty(v: unknown): v is string {
|
|
|
185
229
|
return typeof v === 'string' && v.trim() !== '';
|
|
186
230
|
}
|
|
187
231
|
|
|
232
|
+
/** A pattern's own words with the compose-time variables filled: `{year}`, `{business.name}`. */
|
|
233
|
+
function fillVariables(text: string, ctx: Ctx): string {
|
|
234
|
+
return text.replace(TEXT_VARIABLE, (_m, name: string) => (name === 'year' ? String(ctx.year) : ctx.plan.business.name));
|
|
235
|
+
}
|
|
236
|
+
|
|
188
237
|
interface TextChoice {
|
|
189
238
|
value?: string;
|
|
190
239
|
placeholder?: { kind: PlaceholderKind; fact?: FactKey };
|
|
@@ -204,17 +253,17 @@ function chooseText(pn: PatternNode, ctx: Ctx): TextChoice {
|
|
|
204
253
|
const given = ctx.plan.business.facts?.[fact];
|
|
205
254
|
return nonEmpty(given) ? { value: given } : { value: SITE_FACT_LABELS[fact], placeholder: { kind: PlaceholderKind.Fact, fact } };
|
|
206
255
|
}
|
|
207
|
-
|
|
208
|
-
return { value:
|
|
256
|
+
const own = pn.text === undefined ? undefined : fillVariables(pn.text, ctx);
|
|
257
|
+
if (pn.ph === PatternPlaceholder.Copy) return { value: own, placeholder: { kind: PlaceholderKind.Copy } };
|
|
258
|
+
return { value: own };
|
|
209
259
|
}
|
|
210
260
|
|
|
211
|
-
/** A `bind` / `param` / `src` path → the binding source + path, in this node's scope. */
|
|
261
|
+
/** A `bind` / `alt` / `param` / `src` path → the binding source + path, in this node's scope. */
|
|
212
262
|
function resolveScoped(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { source: string; path: string } {
|
|
213
263
|
const [scope, ...rest] = path.split('.');
|
|
214
264
|
const sub = rest.join('.');
|
|
215
265
|
switch (scope) {
|
|
216
266
|
case BindScope.Item:
|
|
217
|
-
case BindScope.Page:
|
|
218
267
|
if (!repeater) throw new ComposeError(`${nodeId}: "${path}" outside a Repeater`);
|
|
219
268
|
return { source: repeater.itemAlias, path: sub || ITEM_VALUE_PATH };
|
|
220
269
|
case BindScope.Route:
|
|
@@ -228,6 +277,24 @@ function resolveScoped(path: string, repeater: RepeaterScope | null, ctx: Ctx, n
|
|
|
228
277
|
}
|
|
229
278
|
}
|
|
230
279
|
|
|
280
|
+
/** `page.<field>` on a node stamped for a navigation page: that page's own words, written in. */
|
|
281
|
+
function stampedPageValue(path: string, ctx: Ctx, nodeId: string): string {
|
|
282
|
+
const field = path.split('.').slice(1).join('.');
|
|
283
|
+
if (!ctx.navPage) throw new ComposeError(`${nodeId}: "${path}" on a node that is not stamped per page (each)`);
|
|
284
|
+
if (!STAMPED_PAGE_FIELDS.has(field)) throw new ComposeError(`${nodeId}: a page has no "${field}" (${[...STAMPED_PAGE_FIELDS].join(', ')})`);
|
|
285
|
+
return (ctx.navPage as unknown as Record<string, string>)[field];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const isPageScope = (path: string | undefined) => !!path && path.split('.')[0] === BindScope.Page;
|
|
289
|
+
|
|
290
|
+
/** The content type behind a data source alias (a relative source reads its `within` list's type). */
|
|
291
|
+
function contentTypeOfSource(alias: string, plan: SitePlan): ContentTypePlan | undefined {
|
|
292
|
+
const ds = plan.dataSources.find(d => d.alias === alias);
|
|
293
|
+
if (!ds) return undefined;
|
|
294
|
+
if (ds.type === PlanDataSourceType.Relative) return contentTypeOfSource(ds.within, plan);
|
|
295
|
+
return plan.contentTypes.find(t => t.name === ds.contentType);
|
|
296
|
+
}
|
|
297
|
+
|
|
231
298
|
function addBinding(node: SerializedNode, binding: SerializedBinding): void {
|
|
232
299
|
const declared = NODE_BASELINES[node.type]?.traits ?? [];
|
|
233
300
|
if (!declared.includes(binding.prop) && !binding.prop.startsWith('routeParam:')) {
|
|
@@ -246,10 +313,135 @@ function setProp(node: SerializedNode, name: string, value: unknown): void {
|
|
|
246
313
|
node.props[name] = value;
|
|
247
314
|
}
|
|
248
315
|
|
|
249
|
-
|
|
316
|
+
function linkSlots(node: SerializedNode): LinkSlots {
|
|
317
|
+
const slots = LINK_SLOTS[node.type];
|
|
318
|
+
if (!slots) throw new ComposeError(`${node.id}: ${node.type} cannot link`);
|
|
319
|
+
return slots;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A link to one of the site's pages, by route — the builder shows the route, the engine resolves it. */
|
|
323
|
+
function writePageLink(node: SerializedNode, route: string): void {
|
|
324
|
+
const slots = linkSlots(node);
|
|
325
|
+
for (const [k, v] of Object.entries(slots.fixed ?? {})) setProp(node, k, v);
|
|
326
|
+
setProp(node, 'linkType', LinkType.Page);
|
|
327
|
+
setProp(node, 'pageRoute', route);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function writeExternalLink(node: SerializedNode, url: string): void {
|
|
331
|
+
const slots = linkSlots(node);
|
|
332
|
+
for (const [k, v] of Object.entries(slots.fixed ?? {})) setProp(node, k, v);
|
|
333
|
+
setProp(node, 'linkType', LinkType.External);
|
|
334
|
+
setProp(node, slots.url, url);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** What happens to a node linking to a route the plan has no page for (W1.1, Q11) — the same rule for
|
|
338
|
+
* every pattern and every page: an email to the business when one is given; else a link or button is
|
|
339
|
+
* removed and a linked box keeps its contents, unlinked. */
|
|
340
|
+
enum DanglingFate {
|
|
341
|
+
Email = 'email',
|
|
342
|
+
Remove = 'remove',
|
|
343
|
+
Unlink = 'unlink',
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function danglingFate(pn: PatternNode, ctx: Ctx): DanglingFate | null {
|
|
347
|
+
if (!pn.href || pn.href === PatternDirective.PageRoute || ctx.routes.has(pn.href)) return null;
|
|
348
|
+
if (ctx.email) return DanglingFate.Email;
|
|
349
|
+
return UNLINKED_TYPE[pn.t] ? DanglingFate.Unlink : DanglingFate.Remove;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const DANGLING_ISSUE: Readonly<Record<DanglingFate, ComposeIssueCode>> = {
|
|
353
|
+
[DanglingFate.Email]: ComposeIssueCode.LinkToEmail,
|
|
354
|
+
[DanglingFate.Remove]: ComposeIssueCode.LinkRemoved,
|
|
355
|
+
[DanglingFate.Unlink]: ComposeIssueCode.LinkUnlinked,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
function reportDangling(fate: DanglingFate, pn: PatternNode, id: string, words: string | undefined, ctx: Ctx): void {
|
|
359
|
+
const what = words ? `"${words}"` : `A ${pn.t === BUTTON_BLOCK ? 'button' : 'link'}`;
|
|
360
|
+
const outcome = {
|
|
361
|
+
[DanglingFate.Email]: `it now opens an email to ${ctx.email}`,
|
|
362
|
+
[DanglingFate.Remove]: 'with no email address to send to instead, it was removed',
|
|
363
|
+
[DanglingFate.Unlink]: 'with no email address to send to instead, the box keeps its contents without the link',
|
|
364
|
+
}[fate];
|
|
365
|
+
ctx.issues.push({
|
|
366
|
+
code: DANGLING_ISSUE[fate], nodeId: id, owner: ctx.owner, route: pn.href,
|
|
367
|
+
detail: `${what} linked to ${pn.href}, which this site has no page for — ${outcome}.`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The alt binding of a bound Image: the pattern's `alt`, else the bound item's preview text field. */
|
|
372
|
+
function altBinding(pn: PatternNode, repeater: RepeaterScope | null, ctx: Ctx, id: string): { source: string; path: string } {
|
|
373
|
+
if (pn.alt) return resolveScoped(pn.alt, repeater, ctx, id);
|
|
374
|
+
const bound = resolveScoped(pn.bind as string, repeater, ctx, id);
|
|
375
|
+
const field = contentTypeOfSource(bound.source, ctx.plan)?.previewTextField;
|
|
376
|
+
if (!field) throw new ComposeError(`${id}: a bound image needs an alt — the pattern names none and "${bound.source}" has no previewTextField`);
|
|
377
|
+
return { source: bound.source, path: field };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A preset Form's children — the forms-core preset's fields, submit and the WORDS of both state blocks. */
|
|
381
|
+
function presetChildren(preset: FormPresetDef, id: string): SerializedNode[] {
|
|
382
|
+
const fields = preset.fields.map((f, i) => {
|
|
383
|
+
const props = Object.fromEntries(PRESET_FIELD_PROPS.filter(k => f[k] !== undefined).map(k => [k, f[k]]));
|
|
384
|
+
assertTraits(FIELD, Object.keys(props));
|
|
385
|
+
return create(`${id}-${i}`, FIELD, props);
|
|
386
|
+
});
|
|
387
|
+
const n = fields.length;
|
|
388
|
+
const submit = create(`${id}-${n}`, SUBMIT_BUTTON, { label: preset.submitLabel });
|
|
389
|
+
const blocks = [[FORM_SUCCESS, preset.successText], [FORM_ERROR, preset.errorText]].map(([type, words], i) => {
|
|
390
|
+
const block = create(`${id}-${n + 1 + i}`, type);
|
|
391
|
+
block.children = [create(`${id}-${n + 1 + i}-0`, TEXT, { content: words })];
|
|
392
|
+
return block;
|
|
393
|
+
});
|
|
394
|
+
return [...fields, submit, ...blocks];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A Form: its stable id, its preset's name and children (or the pattern's own children), and both state
|
|
398
|
+
* blocks — never optional (forms plan D7). */
|
|
399
|
+
function composeForm(node: SerializedNode, pn: PatternNode, id: string, ctx: Ctx): void {
|
|
400
|
+
if (!pn.preset) {
|
|
401
|
+
node.children = [...(node.children ?? []), ...FORM_STATE_BLOCKS.map((type, i) => create(`${id}-${(pn.c?.length ?? 0) + i}`, type))];
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const preset = formPresetById(pn.preset);
|
|
405
|
+
if (!preset) throw new ComposeError(`${id}: no form preset "${pn.preset}" in forms-core`);
|
|
406
|
+
if (pn.c?.length) throw new ComposeError(`${id}: a preset Form takes its fields from the preset, not the pattern`);
|
|
407
|
+
setProp(node, 'name', preset.formName);
|
|
408
|
+
setProp(node, 'formId', `${preset.id}-${stableHash(`${ctx.owner}/${id}`)}`);
|
|
409
|
+
node.children = presetChildren(preset, id);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** A pattern node's children; an `each` child is stamped once per navigation page, its id carrying the
|
|
413
|
+
* page key so it survives a page being left out or reordered. */
|
|
414
|
+
function composeChildren(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode[] {
|
|
415
|
+
const out: SerializedNode[] = [];
|
|
416
|
+
(pn.c ?? []).forEach((child, i) => {
|
|
417
|
+
const childId = `${id}-${i}`;
|
|
418
|
+
if (!child.each) {
|
|
419
|
+
const node = composeNode(child, childId, repeater, ctx);
|
|
420
|
+
if (node) out.push(node);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (child.each !== PatternDirective.NavPages) throw new ComposeError(`${childId}: "each" stamps "${PatternDirective.NavPages}" only`);
|
|
424
|
+
for (const page of ctx.plan.pages.filter(p => p.inNav)) {
|
|
425
|
+
const node = composeNode(child, `${childId}-${page.key}`, repeater, { ...ctx, navPage: page });
|
|
426
|
+
if (node) out.push(node);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out or a
|
|
433
|
+
* dangling link is removed. */
|
|
250
434
|
function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode | null {
|
|
251
435
|
if (pn.optional && ctx.section?.without?.includes(pn.optional)) return null;
|
|
252
436
|
|
|
437
|
+
const words = chooseText(pn, ctx);
|
|
438
|
+
const fate = danglingFate(pn, ctx);
|
|
439
|
+
if (fate === DanglingFate.Remove) {
|
|
440
|
+
reportDangling(fate, pn, id, words.value, ctx);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
const type = fate === DanglingFate.Unlink ? UNLINKED_TYPE[pn.t] : pn.t;
|
|
444
|
+
|
|
253
445
|
const props: Record<string, unknown> = {};
|
|
254
446
|
if (pn.level) props['level'] = pn.level;
|
|
255
447
|
if (pn.tag) props['htmlTag'] = pn.tag;
|
|
@@ -257,31 +449,28 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
257
449
|
if (pn.control) props['control'] = pn.control;
|
|
258
450
|
if (pn.label) props['label'] = pn.label;
|
|
259
451
|
if (pn.required !== undefined) props['required'] = pn.required;
|
|
260
|
-
assertTraits(
|
|
452
|
+
assertTraits(type, Object.keys(props));
|
|
261
453
|
// Props first: a Heading with no `es` wears its LEVEL's default style.
|
|
262
|
-
const node =
|
|
454
|
+
const node = create(id, type, props, pn.es ?? null);
|
|
263
455
|
|
|
264
456
|
// Words.
|
|
265
|
-
const words = chooseText(pn, ctx);
|
|
266
457
|
if (words.placeholder) {
|
|
267
|
-
const shown = words.value ?? String(NODE_BASELINES[
|
|
458
|
+
const shown = words.value ?? String(NODE_BASELINES[type]?.props[TEXT_PROP[type]] ?? '');
|
|
268
459
|
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
460
|
} 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 ${
|
|
461
|
+
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 ${type} default` });
|
|
271
462
|
}
|
|
272
463
|
if (words.value !== undefined) {
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
setStyleProps(label, BUTTON_LABEL_INHERITS);
|
|
276
|
-
node.children = [label];
|
|
464
|
+
if (type === BUTTON_BLOCK) {
|
|
465
|
+
node.children = [create(`${id}-label`, TEXT, { content: words.value, tag: LABEL_TAG }, BUTTON_LABEL_STYLE)];
|
|
277
466
|
} else {
|
|
278
|
-
const prop = TEXT_PROP[
|
|
279
|
-
if (!prop) throw new ComposeError(`${id}: ${
|
|
467
|
+
const prop = TEXT_PROP[type];
|
|
468
|
+
if (!prop) throw new ComposeError(`${id}: ${type} cannot hold text`);
|
|
280
469
|
setProp(node, prop, words.value);
|
|
281
470
|
}
|
|
282
471
|
}
|
|
283
472
|
|
|
284
|
-
// Images.
|
|
473
|
+
// Images: an empty slot, or a bound one — both fall back to the DS3 empty image, never a stock URL.
|
|
285
474
|
if (pn.ph === PatternPlaceholder.Image) {
|
|
286
475
|
setProp(node, 'src', '');
|
|
287
476
|
ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: PlaceholderKind.Image, text: '' });
|
|
@@ -289,32 +478,31 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
289
478
|
if (pn.aspect) setStyleProps(node, { aspectRatio: pn.aspect });
|
|
290
479
|
|
|
291
480
|
// Links.
|
|
292
|
-
if (pn.href) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
}
|
|
481
|
+
if (pn.href === PatternDirective.PageRoute) {
|
|
482
|
+
writePageLink(node, stampedPageValue(`${BindScope.Page}.route`, ctx, id));
|
|
483
|
+
} else if (pn.href && !fate) {
|
|
484
|
+
writePageLink(node, pn.href);
|
|
485
|
+
} else if (fate) {
|
|
486
|
+
if (fate === DanglingFate.Email) writeExternalLink(node, `${MAILTO}${ctx.email}`);
|
|
487
|
+
reportDangling(fate, pn, id, words.value, ctx);
|
|
308
488
|
}
|
|
309
|
-
if (pn.param) {
|
|
489
|
+
if (pn.param && !fate) {
|
|
310
490
|
const names = pn.href ? routeParams(pn.href) : [];
|
|
311
491
|
if (names.length !== 1) throw new ComposeError(`${id}: "param" needs an href with one :param`);
|
|
312
492
|
addBinding(node, { prop: `routeParam:${names[0]}`, ...resolveScoped(pn.param, repeater, ctx, id) });
|
|
313
493
|
}
|
|
314
494
|
if (pn.bind) {
|
|
315
|
-
const prop = BIND_PROP[
|
|
316
|
-
if (!prop) throw new ComposeError(`${id}: ${
|
|
317
|
-
|
|
495
|
+
const prop = BIND_PROP[type];
|
|
496
|
+
if (!prop) throw new ComposeError(`${id}: ${type} has no bindable value`);
|
|
497
|
+
if (isPageScope(pn.bind)) setProp(node, prop, stampedPageValue(pn.bind, ctx, id));
|
|
498
|
+
else addBinding(node, { prop, ...resolveScoped(pn.bind, repeater, ctx, id) });
|
|
499
|
+
}
|
|
500
|
+
if (type === IMAGE && pn.bind && !isPageScope(pn.bind)) {
|
|
501
|
+
setProp(node, 'src', '');
|
|
502
|
+
if (pn.alt === DECORATIVE_ALT) setProp(node, 'alt', DECORATIVE_ALT);
|
|
503
|
+
else addBinding(node, { prop: 'alt', ...altBinding(pn, repeater, ctx, id) });
|
|
504
|
+
} else if (pn.alt !== undefined) {
|
|
505
|
+
throw new ComposeError(`${id}: "alt" belongs on a bound Image`);
|
|
318
506
|
}
|
|
319
507
|
|
|
320
508
|
// Layout.
|
|
@@ -331,8 +519,6 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
331
519
|
if (pn.src === PatternDirective.Binding) {
|
|
332
520
|
if (!ctx.section?.binding) throw new ComposeError(`${id}: "$binding" with no section binding`);
|
|
333
521
|
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
522
|
} else {
|
|
337
523
|
const scoped = resolveScoped(pn.src, repeater, ctx, id);
|
|
338
524
|
binding = { prop: 'source', source: scoped.source, path: scoped.path === ITEM_VALUE_PATH ? '' : scoped.path };
|
|
@@ -343,28 +529,18 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
343
529
|
}
|
|
344
530
|
|
|
345
531
|
// Children.
|
|
346
|
-
const children = (pn.c ?? [])
|
|
347
|
-
.map((child, i) => composeNode(child, `${id}-${i}`, rowScope, ctx))
|
|
348
|
-
.filter((c): c is SerializedNode => !!c);
|
|
349
532
|
if (pn.c?.length) {
|
|
350
|
-
if (!node.children) throw new ComposeError(`${id}: ${
|
|
351
|
-
node.children = [...node.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
|
-
];
|
|
533
|
+
if (!node.children) throw new ComposeError(`${id}: ${type} holds no children`);
|
|
534
|
+
node.children = [...node.children, ...composeChildren(pn, id, rowScope, ctx)];
|
|
361
535
|
}
|
|
536
|
+
if (type === FORM) composeForm(node, pn, id, ctx);
|
|
362
537
|
return node;
|
|
363
538
|
}
|
|
364
539
|
|
|
365
540
|
function composePattern(pattern: SitePattern, id: string, ctx: Ctx): SerializedNode {
|
|
541
|
+
if (pattern.node.each) throw new ComposeError(`${id}: a pattern's root cannot be stamped per page`);
|
|
366
542
|
const node = composeNode(pattern.node, id, null, ctx);
|
|
367
|
-
if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional`);
|
|
543
|
+
if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional or a link`);
|
|
368
544
|
node.label = pattern.label;
|
|
369
545
|
return node;
|
|
370
546
|
}
|
|
@@ -384,13 +560,6 @@ function composeDataSource(ds: DataSourcePlan): ComposedDataSource {
|
|
|
384
560
|
};
|
|
385
561
|
}
|
|
386
562
|
|
|
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
563
|
function expandSeedValue(value: unknown, n: number): unknown {
|
|
395
564
|
if (Array.isArray(value)) return value.map(v => expandSeedValue(v, n));
|
|
396
565
|
if (typeof value !== 'string') return value;
|
|
@@ -411,24 +580,19 @@ function composeItems(type: ContentTypePlan): ComposedItem[] {
|
|
|
411
580
|
return items;
|
|
412
581
|
}
|
|
413
582
|
|
|
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
583
|
/**
|
|
422
584
|
* Compose a Site Plan. Throws `SitePlanError` (every issue) when the plan does not validate — the
|
|
423
|
-
* composer never repairs a plan.
|
|
585
|
+
* composer never repairs a plan. `context` carries what the composer may not read for itself (the year).
|
|
424
586
|
*/
|
|
425
|
-
export function composeSite(plan: SitePlan): ComposedSite {
|
|
587
|
+
export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSite {
|
|
426
588
|
assertValidSitePlan(plan);
|
|
589
|
+
if (!Number.isInteger(context?.year)) throw new ComposeError('composeSite needs the year (ComposeContext.year) — the composer reads no clock');
|
|
427
590
|
const placeholders: ComposedPlaceholder[] = [];
|
|
428
591
|
const issues: ComposeIssue[] = [];
|
|
429
592
|
const routes = new Set(plan.pages.map(p => p.route));
|
|
430
593
|
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
|
|
594
|
+
const email = nonEmpty(plan.business.facts?.email) ? plan.business.facts!.email!.trim() : null;
|
|
595
|
+
const base = { plan, placeholders, issues, routes, year: context.year, email, navPage: null };
|
|
432
596
|
|
|
433
597
|
const pages: ComposedPage[] = plan.pages.map(page => {
|
|
434
598
|
const nextAlias = page.routeSource ? nextSourceOf(relatives, page.routeSource) : null;
|
|
@@ -455,16 +619,13 @@ export function composeSite(plan: SitePlan): ComposedSite {
|
|
|
455
619
|
return node;
|
|
456
620
|
});
|
|
457
621
|
|
|
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
622
|
return {
|
|
462
623
|
source: clone(plan.source),
|
|
463
624
|
business: clone(plan.business),
|
|
464
625
|
design: resolveDesignChoices(clone(plan.design)),
|
|
465
626
|
schemas: clone(plan.contentTypes) as ContentTypePlan[],
|
|
466
627
|
items: Object.fromEntries(plan.contentTypes.map(t => [t.name, composeItems(t)])),
|
|
467
|
-
dataSources,
|
|
628
|
+
dataSources: plan.dataSources.map(composeDataSource),
|
|
468
629
|
pages,
|
|
469
630
|
globals,
|
|
470
631
|
routes: plan.pages.map(p => ({ key: p.key, path: p.route, isHome: p.isHome === true })),
|
|
@@ -196,7 +196,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
196
196
|
"ButtonBlock": {
|
|
197
197
|
"props": {
|
|
198
198
|
"tag": "button",
|
|
199
|
+
"linkType": "external",
|
|
199
200
|
"href": "",
|
|
201
|
+
"pageRoute": "",
|
|
200
202
|
"target": "_self",
|
|
201
203
|
"type": "button"
|
|
202
204
|
},
|
|
@@ -286,7 +288,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
286
288
|
},
|
|
287
289
|
"traits": [
|
|
288
290
|
"tag",
|
|
291
|
+
"linkType",
|
|
289
292
|
"href",
|
|
293
|
+
"pageRoute",
|
|
290
294
|
"target",
|
|
291
295
|
"type"
|
|
292
296
|
],
|
|
@@ -4839,3 +4843,89 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
4839
4843
|
"container": true
|
|
4840
4844
|
}
|
|
4841
4845
|
};
|
|
4846
|
+
|
|
4847
|
+
/** What every style trait saves as when no constructor sets it (buildFullStyleTraits' defaults). */
|
|
4848
|
+
export const NEUTRAL_STYLE_PROPS: Readonly<Record<string, unknown>> = {
|
|
4849
|
+
"position": "static",
|
|
4850
|
+
"top": "auto",
|
|
4851
|
+
"right": "auto",
|
|
4852
|
+
"bottom": "auto",
|
|
4853
|
+
"left": "auto",
|
|
4854
|
+
"zIndex": "0",
|
|
4855
|
+
"display": "block",
|
|
4856
|
+
"flexDirection": "row",
|
|
4857
|
+
"flexWrap": "nowrap",
|
|
4858
|
+
"justifyContent": "flex-start",
|
|
4859
|
+
"alignItems": "stretch",
|
|
4860
|
+
"gap": "0px",
|
|
4861
|
+
"gridTemplateColumns": "1fr",
|
|
4862
|
+
"gridTemplateRows": "auto",
|
|
4863
|
+
"justifyItems": "stretch",
|
|
4864
|
+
"alignContent": "stretch",
|
|
4865
|
+
"direction": "ltr",
|
|
4866
|
+
"alignSelf": "auto",
|
|
4867
|
+
"order": "0",
|
|
4868
|
+
"flexGrow": "0",
|
|
4869
|
+
"flexShrink": "1",
|
|
4870
|
+
"flexBasis": "auto",
|
|
4871
|
+
"justifySelf": "auto",
|
|
4872
|
+
"gridColumn": "auto",
|
|
4873
|
+
"gridRow": "auto",
|
|
4874
|
+
"width": "auto",
|
|
4875
|
+
"height": "auto",
|
|
4876
|
+
"minWidth": "auto",
|
|
4877
|
+
"minHeight": "auto",
|
|
4878
|
+
"maxWidth": "auto",
|
|
4879
|
+
"maxHeight": "auto",
|
|
4880
|
+
"aspectRatio": "auto",
|
|
4881
|
+
"objectFit": "cover",
|
|
4882
|
+
"overflow": "visible",
|
|
4883
|
+
"fontFamily": "Poppins, sans-serif",
|
|
4884
|
+
"fontSize": "16px",
|
|
4885
|
+
"fontWeight": "400",
|
|
4886
|
+
"color": "#000000",
|
|
4887
|
+
"lineHeight": "1.5",
|
|
4888
|
+
"letterSpacing": "0px",
|
|
4889
|
+
"textTransform": "none",
|
|
4890
|
+
"textAlign": "left",
|
|
4891
|
+
"fontStyle": "normal",
|
|
4892
|
+
"textDecoration": "none",
|
|
4893
|
+
"WebkitTextStrokeWidth": "0px",
|
|
4894
|
+
"WebkitTextStrokeColor": "transparent",
|
|
4895
|
+
"margin": "0px 0px 0px 0px",
|
|
4896
|
+
"padding": "0px 0px 0px 0px",
|
|
4897
|
+
"borderWidth": "0px",
|
|
4898
|
+
"borderStyle": "solid",
|
|
4899
|
+
"borderColor": "#CCCCCC",
|
|
4900
|
+
"borderRadius": "0px",
|
|
4901
|
+
"backgroundColor": "transparent",
|
|
4902
|
+
"opacity": "1",
|
|
4903
|
+
"mixBlendMode": "normal",
|
|
4904
|
+
"cursor": "auto",
|
|
4905
|
+
"boxShadow": "",
|
|
4906
|
+
"textShadow": "",
|
|
4907
|
+
"translateX": "0px",
|
|
4908
|
+
"translateY": "0px",
|
|
4909
|
+
"scaleX": "1",
|
|
4910
|
+
"scaleY": "1",
|
|
4911
|
+
"rotate": "0deg",
|
|
4912
|
+
"skewX": "0deg",
|
|
4913
|
+
"skewY": "0deg",
|
|
4914
|
+
"filterBlur": "0px",
|
|
4915
|
+
"filterHueRotate": "0deg",
|
|
4916
|
+
"filterBrightness": "1",
|
|
4917
|
+
"filterContrast": "1",
|
|
4918
|
+
"filterSaturate": "1",
|
|
4919
|
+
"filterGrayscale": "0",
|
|
4920
|
+
"filterSepia": "0",
|
|
4921
|
+
"filterInvert": "0",
|
|
4922
|
+
"backdropFilter": "none",
|
|
4923
|
+
"backdropBlur": "0px",
|
|
4924
|
+
"backdropBrightness": "1",
|
|
4925
|
+
"backdropContrast": "1",
|
|
4926
|
+
"backdropSaturate": "1",
|
|
4927
|
+
"clipPath": "none",
|
|
4928
|
+
"transitionDuration": "0ms",
|
|
4929
|
+
"transitionDelay": "0ms",
|
|
4930
|
+
"transitionTimingFunction": "ease"
|
|
4931
|
+
};
|