@vectoriox/iox-ui 5.28.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.
@@ -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, ComposeError, walkSerialized } from './serialized-node';
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, NAV_PAGES_ALIAS, nextSourceOf, routeParams } from './validate-site-plan';
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, then ONE element style, then the pattern's layout. So a composed page is what
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 pattern links to a route the plan has no page for (an optional page was left out). */
49
- DanglingLink = 'dangling-link',
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
- /** Types whose `href` is a website page (`linkType: page` + `pageRoute`). */
135
- const PAGE_LINK_TYPES: ReadonlySet<string> = new Set(['Link', 'LinkedContainer']);
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 FORM_STATE_BLOCKS = ['FormSuccess', 'FormError'];
139
- const LINK_TYPE_PAGE = 'page';
140
- const ANCHOR_TAG = 'a';
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 (the consent preset's labels, `consent-preset.ts`). */
144
- const BUTTON_LABEL_INHERITS = { fontSize: 'inherit', fontWeight: 'inherit', color: 'inherit' };
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
- /** 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
- }
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
- if (pn.ph === PatternPlaceholder.Copy) return { value: pn.text, placeholder: { kind: PlaceholderKind.Copy } };
208
- return { value: pn.text };
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
- /** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out. */
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(pn.t, Object.keys(props));
452
+ assertTraits(type, Object.keys(props));
261
453
  // Props first: a Heading with no `es` wears its LEVEL's default style.
262
- const node = createSerializedNode(id, pn.t, props, pn.es ?? null);
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[pn.t]?.props[TEXT_PROP[pn.t]] ?? '');
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 ${pn.t} default` });
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 (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];
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[pn.t];
279
- if (!prop) throw new ComposeError(`${id}: ${pn.t} cannot hold text`);
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
- 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
- }
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[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) });
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}: ${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
- ];
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 base = { plan, placeholders, issues, routes };
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
+ };