@vectoriox/iox-ui 5.57.0 → 5.58.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/composite-core/index.d.ts +3 -3
- package/design-core/index.d.ts +40 -2
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +6 -10
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +46 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +4041 -739
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs +3 -3
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +27 -1
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +3737 -1100
- package/harness-core/index.d.ts +6 -6
- package/index.d.ts +22 -1
- package/package.json +1 -1
- package/src/composite-core/accordion-runtime.ts +2 -1
- package/src/composite-core/lightbox.ts +5 -6
- package/src/design-core/entrance-timing.ts +56 -0
- package/src/design-core/public-api.ts +1 -0
- package/src/generation-core/compose-site.ts +213 -42
- package/src/generation-core/entrances.ts +82 -13
- package/src/generation-core/patterns.ts +127 -15
- package/src/generation-core/public-api.ts +3 -2
- package/src/generation-core/showcase.ts +81 -0
- package/src/generation-core/site-plan-schema.ts +21 -8
- package/src/generation-core/site-plan.model.ts +75 -4
- package/src/generation-core/site-spec.generated.ts +4019 -1174
- package/src/generation-core/validate-site-plan.ts +129 -21
- package/src/harness-core/harness-spec.generated.ts +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { COLOR_SCHEMES, DesignChoices, LinkType, resolveDesignChoices, setColorScheme } from '@vectoriox/iox-ui/design-core';
|
|
1
|
+
import { AnimateId, COLOR_SCHEMES, DesignChoices, LinkType, resolveDesignChoices, setColorScheme } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
import { SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, SeoRawDoc } from '@vectoriox/iox-ui/seo-core';
|
|
2
3
|
import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
|
|
3
4
|
import {
|
|
4
5
|
ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_WORDS, accordionAnswerOf, accordionItems, accordionQuestionOf, AccordionRole,
|
|
@@ -8,12 +9,15 @@ import {
|
|
|
8
9
|
TABS_TEXT_BIND_PROP, TABS_WORDS, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
|
|
9
10
|
} from '@vectoriox/iox-ui/composite-core';
|
|
10
11
|
|
|
12
|
+
import { EntranceMark, EntrancePart, planEntrances } from './entrances';
|
|
11
13
|
import { NODE_BASELINES } from './node-baselines.generated';
|
|
12
|
-
import { BindScope, CardBindTarget, PatternDirective, PatternNode, PatternOpens, PatternPlaceholder, PatternRecipe, SitePattern, patternById } from './patterns';
|
|
13
|
-
import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps } from './serialized-node';
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
BindScope, CardBindTarget, parsePatternPath, PatternDirective, PatternNode, PatternOpens, PatternPlaceholder, PatternRecipe, SitePattern, patternById,
|
|
16
|
+
} from './patterns';
|
|
17
|
+
import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps, walkSerialized } from './serialized-node';
|
|
18
|
+
import {
|
|
19
|
+
ContentRole, ContentTypePlan, DataSourcePlan, DEFAULT_SITE_LOCALE, FactKey, FACT_KEYS, GlobalSlot, isRtlLocale, PagePlan, PageSectionPlan, PlanDataSourceMode,
|
|
20
|
+
PlanDataSourceType, RelativeOp, roleField, SiteLocale, SitePlan, SitePlanSource, SITE_FACT_LABELS, SiteBusiness,
|
|
17
21
|
} from './site-plan.model';
|
|
18
22
|
import { assertValidSitePlan, nextSourceOf, routeParams } from './validate-site-plan';
|
|
19
23
|
import { NavShape, planNavShape } from './nav-shape';
|
|
@@ -44,11 +48,14 @@ export enum PlaceholderKind {
|
|
|
44
48
|
/** CC4b (Q37): a price — a FACT the customer could be held to. Free text the studio types ("₪ 1,200", "From $40",
|
|
45
49
|
* "On request"); its label shows meanwhile ("Monthly price"), never a number. No currency model. */
|
|
46
50
|
Price = 'price',
|
|
51
|
+
/** L2 (`proof.stat-row`): a number the studio stands behind ("12 years", "300 clients") — a fact, its label shown
|
|
52
|
+
* meanwhile ("Number"), never a number written for it. */
|
|
53
|
+
Stat = 'stat',
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
/** The placeholders a page cannot be published with unfilled (W4's gate): a fact, a price, the studio's own
|
|
50
|
-
* Copy and an empty image are the author's to rewrite, never blocking. */
|
|
51
|
-
export const PUBLISH_BLOCKING_PLACEHOLDERS: ReadonlySet<PlaceholderKind> = new Set([PlaceholderKind.Fact, PlaceholderKind.Price, PlaceholderKind.Video]);
|
|
56
|
+
/** The placeholders a page cannot be published with unfilled (W4's gate): a fact, a price, a number, the studio's own
|
|
57
|
+
* video. Copy and an empty image are the author's to rewrite, never blocking. */
|
|
58
|
+
export const PUBLISH_BLOCKING_PLACEHOLDERS: ReadonlySet<PlaceholderKind> = new Set([PlaceholderKind.Fact, PlaceholderKind.Price, PlaceholderKind.Stat, PlaceholderKind.Video]);
|
|
52
59
|
|
|
53
60
|
/** True when a placeholder of `kind` blocks publishing until the studio fills it. */
|
|
54
61
|
export function blocksPublish(kind: PlaceholderKind): boolean {
|
|
@@ -106,8 +113,13 @@ export interface ComposedPage {
|
|
|
106
113
|
route: { path: string };
|
|
107
114
|
isHome: boolean;
|
|
108
115
|
status: 'draft';
|
|
116
|
+
/** The site's language (L0-2): the page's locale — the fonts it loads, the side its entrances slide from. */
|
|
117
|
+
locale: `${SiteLocale}`;
|
|
109
118
|
/** DS3: every page the wizard creates follows the look (page-bg / text / font-body). */
|
|
110
119
|
pageSettings: { designSystem: true };
|
|
120
|
+
/** M1-6c: the page's search-result words — written (a static page) or bound to the route item (a template page),
|
|
121
|
+
* in the seo-core document shape the shared `resolveSeo` reads. */
|
|
122
|
+
seo: SeoRawDoc;
|
|
111
123
|
layout: SerializedNode[];
|
|
112
124
|
}
|
|
113
125
|
|
|
@@ -234,6 +246,9 @@ const create = (id: string, type: string, props: Record<string, unknown> = {}, e
|
|
|
234
246
|
interface RepeaterScope {
|
|
235
247
|
/** The alias a row's children bind (`repeaterItemAlias`: the iterate-path's last segment, else the source). */
|
|
236
248
|
itemAlias: string;
|
|
249
|
+
/** The data source whose items the rows are (its content type's roles), or null when the rows are bare values (a
|
|
250
|
+
* gallery's URLs). */
|
|
251
|
+
source: string | null;
|
|
237
252
|
}
|
|
238
253
|
|
|
239
254
|
interface Ctx {
|
|
@@ -258,6 +273,8 @@ interface Ctx {
|
|
|
258
273
|
/** A section inserted from the palette (`composeSection`): a button to a page the studio lacks keeps its words,
|
|
259
274
|
* unlinked — the author links it — instead of being removed. */
|
|
260
275
|
keepButtons?: boolean;
|
|
276
|
+
/** M1-6b: the pattern's entrance parts as stamped, for `planEntrances` once the pattern is whole. */
|
|
277
|
+
entrances?: Array<Omit<EntranceMark, 'index'>>;
|
|
261
278
|
}
|
|
262
279
|
|
|
263
280
|
interface ShownWhen {
|
|
@@ -298,28 +315,66 @@ function chooseText(pn: PatternNode, ctx: Ctx): TextChoice {
|
|
|
298
315
|
const own = pn.text === undefined ? undefined : fillVariables(pn.text, ctx);
|
|
299
316
|
if (pn.ph === PatternPlaceholder.Copy) return { value: own, placeholder: { kind: PlaceholderKind.Copy } };
|
|
300
317
|
if (pn.ph === PatternPlaceholder.Price) return { value: own, placeholder: { kind: PlaceholderKind.Price } };
|
|
318
|
+
if (pn.ph === PatternPlaceholder.Stat) return { value: own, placeholder: { kind: PlaceholderKind.Stat } };
|
|
301
319
|
return { value: own };
|
|
302
320
|
}
|
|
303
321
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
322
|
+
interface ScopedPath {
|
|
323
|
+
source: string;
|
|
324
|
+
path: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The binding source of a scope, and the data source whose content type its item is (for its roles). */
|
|
328
|
+
function scopeOf(scope: string, path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { source: string; typeSource: string | null } {
|
|
308
329
|
switch (scope) {
|
|
309
330
|
case BindScope.Item:
|
|
310
331
|
if (!repeater) throw new ComposeError(`${nodeId}: "${path}" outside a Repeater`);
|
|
311
|
-
return { source: repeater.itemAlias,
|
|
332
|
+
return { source: repeater.itemAlias, typeSource: repeater.source };
|
|
312
333
|
case BindScope.Route:
|
|
313
334
|
if (!ctx.page?.routeSource) throw new ComposeError(`${nodeId}: "${path}" on a page with no routeSource`);
|
|
314
|
-
return { source: ctx.page.routeSource,
|
|
335
|
+
return { source: ctx.page.routeSource, typeSource: ctx.page.routeSource };
|
|
315
336
|
case BindScope.Next:
|
|
316
337
|
if (!ctx.nextAlias) throw new ComposeError(`${nodeId}: "${path}" with no next-sibling source`);
|
|
317
|
-
return { source: ctx.nextAlias,
|
|
338
|
+
return { source: ctx.nextAlias, typeSource: ctx.nextAlias };
|
|
318
339
|
default:
|
|
319
340
|
throw new ComposeError(`${nodeId}: unknown binding scope "${scope}"`);
|
|
320
341
|
}
|
|
321
342
|
}
|
|
322
343
|
|
|
344
|
+
/**
|
|
345
|
+
* A `bind` / `alt` / `param` / `src` path → the binding source + path, in this node's scope. A ROLE (`item.@title`,
|
|
346
|
+
* L0-3) is the field of the item's content type that plays it; an optional role the type does not name (`@image?`)
|
|
347
|
+
* gives null — the part is left out. A required one it lacks is a plan the validator should have refused.
|
|
348
|
+
*/
|
|
349
|
+
function resolveScopedPath(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): ScopedPath | null {
|
|
350
|
+
const parsed = parsePatternPath(path);
|
|
351
|
+
const { source, typeSource } = scopeOf(parsed.scope, path, repeater, ctx, nodeId);
|
|
352
|
+
if (!parsed.role) return { source, path: parsed.field || ITEM_VALUE_PATH };
|
|
353
|
+
const field = roleField(typeSource ? contentTypeOfSource(typeSource, ctx.plan) : undefined, parsed.role);
|
|
354
|
+
if (field) return { source, path: field };
|
|
355
|
+
if (parsed.optional) return null;
|
|
356
|
+
throw new ComposeError(`${nodeId}: "${path}" — the item's content type names no ${parsed.role} field`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function resolveScoped(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): ScopedPath {
|
|
360
|
+
const scoped = resolveScopedPath(path, repeater, ctx, nodeId);
|
|
361
|
+
if (!scoped) throw new ComposeError(`${nodeId}: "${path}" names an optional role where one is required`);
|
|
362
|
+
return scoped;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The page that shows ONE item of `scope`'s content type (the plan's template page for it), and the binding of its
|
|
366
|
+
* route param — `detail: item | next` (L0-3). Null when the plan has no such page: the link is simply not made. */
|
|
367
|
+
function detailLinkOf(scope: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { route: string; param: string; binding: ScopedPath } | null {
|
|
368
|
+
const { typeSource } = scopeOf(scope, scope, repeater, ctx, nodeId);
|
|
369
|
+
const type = typeSource ? contentTypeOfSource(typeSource, ctx.plan) : undefined;
|
|
370
|
+
const page = type ? ctx.plan.pages.find(p => p.template === type.name && !!p.routeSource) : undefined;
|
|
371
|
+
const byRoute = page ? ctx.plan.dataSources.find(d => d.alias === page.routeSource) : undefined;
|
|
372
|
+
const filter = byRoute && byRoute.type === PlanDataSourceType.Cms ? byRoute.filterByRouteParam : undefined;
|
|
373
|
+
const [param] = page ? routeParams(page.route) : [];
|
|
374
|
+
if (!page || !filter || !param) return null;
|
|
375
|
+
return { route: page.route, param, binding: resolveScoped(`${scope}.${filter.field}`, repeater, ctx, nodeId) };
|
|
376
|
+
}
|
|
377
|
+
|
|
323
378
|
/** `page.<field>` on a node stamped for a navigation page: that page's own words, written in. */
|
|
324
379
|
function stampedPageValue(path: string, ctx: Ctx, nodeId: string): string {
|
|
325
380
|
const field = path.split('.').slice(1).join('.');
|
|
@@ -413,13 +468,12 @@ function reportDangling(fate: DanglingFate, pn: PatternNode, id: string, words:
|
|
|
413
468
|
});
|
|
414
469
|
}
|
|
415
470
|
|
|
416
|
-
/** The alt binding of a bound Image: the pattern's `alt`, else the bound item's
|
|
417
|
-
|
|
471
|
+
/** The alt binding of a bound Image: the pattern's `alt`, else the bound item's title role (W1.1, Q7 — every content
|
|
472
|
+
* type names its title). */
|
|
473
|
+
function altBinding(pn: PatternNode, repeater: RepeaterScope | null, ctx: Ctx, id: string): ScopedPath {
|
|
418
474
|
if (pn.alt) return resolveScoped(pn.alt, repeater, ctx, id);
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
if (!field) throw new ComposeError(`${id}: a bound image needs an alt — the pattern names none and "${bound.source}" has no previewTextField`);
|
|
422
|
-
return { source: bound.source, path: field };
|
|
475
|
+
const scope = parsePatternPath(pn.bind as string).scope;
|
|
476
|
+
return resolveScoped(`${scope}.@${ContentRole.Title}`, repeater, ctx, id);
|
|
423
477
|
}
|
|
424
478
|
|
|
425
479
|
/** A preset Form's children — the forms-core preset's fields, submit and the WORDS of both state blocks. */
|
|
@@ -446,8 +500,10 @@ function composeForm(node: SerializedNode, pn: PatternNode, id: string, ctx: Ctx
|
|
|
446
500
|
node.children = [...(node.children ?? []), ...FORM_STATE_BLOCKS.map((type, i) => create(`${id}-${(pn.c?.length ?? 0) + i}`, type))];
|
|
447
501
|
return;
|
|
448
502
|
}
|
|
449
|
-
|
|
450
|
-
|
|
503
|
+
// A section may give the pattern's form another preset (`form`: Practice's contact page asks to book).
|
|
504
|
+
const presetId = ctx.section?.form ?? pn.preset;
|
|
505
|
+
const preset = formPresetById(presetId);
|
|
506
|
+
if (!preset) throw new ComposeError(`${id}: no form preset "${presetId}" in forms-core`);
|
|
451
507
|
if (pn.c?.length) throw new ComposeError(`${id}: a preset Form takes its fields from the preset, not the pattern`);
|
|
452
508
|
setProp(node, 'name', preset.formName);
|
|
453
509
|
setProp(node, 'formId', `${preset.id}-${stableHash(`${ctx.owner}/${id}`)}`);
|
|
@@ -476,9 +532,26 @@ function composeChildren(pn: PatternNode, id: string, repeater: RepeaterScope |
|
|
|
476
532
|
|
|
477
533
|
/** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out or a
|
|
478
534
|
* dangling link is removed. */
|
|
479
|
-
|
|
535
|
+
/** A section re-points a linked slot to another page of the plan (`links`, validated): the node, with that route. */
|
|
536
|
+
function withSectionLink(pn: PatternNode, ctx: Ctx): PatternNode {
|
|
537
|
+
const route = pn.slot ? ctx.section?.links?.[pn.slot] : undefined;
|
|
538
|
+
return route ? { ...pn, href: route } : pn;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** M1-6b: a marked node joins the pattern's entrance parts (counted among its siblings once the pattern is whole). */
|
|
542
|
+
function markEntrance(pn: PatternNode, nodeId: string, repeater: RepeaterScope | null, ctx: Ctx): void {
|
|
543
|
+
if (!pn.enter) return;
|
|
544
|
+
(ctx.entrances ??= []).push({ nodeId, part: pn.enter as EntrancePart, repeated: !!repeater, ...(pn.side ? { side: pn.side } : {}) });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function composeNode(given: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode | null {
|
|
548
|
+
const pn = withSectionLink(given, ctx);
|
|
480
549
|
if (pn.optional && ctx.section?.without?.includes(pn.optional)) return null;
|
|
481
550
|
if (pn.recipe) return composeRecipe(pn, id, repeater, ctx);
|
|
551
|
+
// A part bound to a role its item's type does not have (`@meta?`) is left out (L0-3).
|
|
552
|
+
const bound = pn.bind && !isPageScope(pn.bind) ? resolveScopedPath(pn.bind, repeater, ctx, id) : undefined;
|
|
553
|
+
if (bound === null) return null;
|
|
554
|
+
const detail = pn.detail ? detailLinkOf(pn.detail, repeater, ctx, id) : null;
|
|
482
555
|
|
|
483
556
|
const words = chooseText(pn, ctx);
|
|
484
557
|
const fate = danglingFate(pn, ctx);
|
|
@@ -486,7 +559,9 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
486
559
|
reportDangling(fate, pn, id, words.value, ctx);
|
|
487
560
|
return null;
|
|
488
561
|
}
|
|
489
|
-
|
|
562
|
+
// A box that would link to its item's own page, where the plan has none, is the same box unlinked.
|
|
563
|
+
const unlinked = fate === DanglingFate.Unlink || (!!pn.detail && !detail);
|
|
564
|
+
const type = unlinked ? UNLINKED_TYPE[pn.t] ?? pn.t : pn.t;
|
|
490
565
|
|
|
491
566
|
const props: Record<string, unknown> = {};
|
|
492
567
|
if (pn.level) props['level'] = pn.level;
|
|
@@ -499,6 +574,7 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
499
574
|
// Props first: a Heading with no `es` wears its LEVEL's default style.
|
|
500
575
|
const node = create(id, type, props, pn.es ?? null);
|
|
501
576
|
if (pn.role) node.role = pn.role;
|
|
577
|
+
markEntrance(pn, id, repeater, ctx);
|
|
502
578
|
|
|
503
579
|
// Words.
|
|
504
580
|
if (words.placeholder) {
|
|
@@ -559,11 +635,15 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
559
635
|
if (names.length !== 1) throw new ComposeError(`${id}: "param" needs an href with one :param`);
|
|
560
636
|
addBinding(node, { prop: `routeParam:${names[0]}`, ...resolveScoped(pn.param, repeater, ctx, id) });
|
|
561
637
|
}
|
|
638
|
+
if (detail) {
|
|
639
|
+
writePageLink(node, detail.route);
|
|
640
|
+
addBinding(node, { prop: `routeParam:${detail.param}`, ...detail.binding });
|
|
641
|
+
}
|
|
562
642
|
if (pn.bind) {
|
|
563
643
|
const prop = BIND_PROP[type];
|
|
564
644
|
if (!prop) throw new ComposeError(`${id}: ${type} has no bindable value`);
|
|
565
645
|
if (isPageScope(pn.bind)) setProp(node, prop, stampedPageValue(pn.bind, ctx, id));
|
|
566
|
-
else addBinding(node, { prop, ...
|
|
646
|
+
else addBinding(node, { prop, ...(bound as ScopedPath) });
|
|
567
647
|
}
|
|
568
648
|
if (type === IMAGE && pn.bind && !isPageScope(pn.bind)) {
|
|
569
649
|
setProp(node, 'src', '');
|
|
@@ -590,7 +670,8 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
590
670
|
}
|
|
591
671
|
addBinding(node, binding);
|
|
592
672
|
const lastSegment = binding.path.split('.').filter(Boolean).pop();
|
|
593
|
-
|
|
673
|
+
// Rows of a list source are its items (their type's roles); rows of an item's array are bare values.
|
|
674
|
+
rowScope = { itemAlias: lastSegment || binding.source, source: binding.path ? null : binding.source };
|
|
594
675
|
}
|
|
595
676
|
|
|
596
677
|
// Children.
|
|
@@ -640,12 +721,12 @@ function wireShownWhen(root: SerializedNode, entries: ReadonlyArray<ShownWhen>):
|
|
|
640
721
|
}
|
|
641
722
|
}
|
|
642
723
|
|
|
643
|
-
/** A Card v2 `binds` key → the part it binds
|
|
644
|
-
const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string }>> = {
|
|
645
|
-
[CardBindTarget.Image]: { role: CardRole.Media, prop: 'src' },
|
|
646
|
-
[CardBindTarget.Eyebrow]: { role: CardRole.Eyebrow, prop: 'content' },
|
|
724
|
+
/** A Card v2 `binds` key → the part it binds, that part's bound prop, and the optional part it is (the title is not). */
|
|
725
|
+
const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string; part?: CardPart }>> = {
|
|
726
|
+
[CardBindTarget.Image]: { role: CardRole.Media, prop: 'src', part: CardPart.Media },
|
|
727
|
+
[CardBindTarget.Eyebrow]: { role: CardRole.Eyebrow, prop: 'content', part: CardPart.Eyebrow },
|
|
647
728
|
[CardBindTarget.Title]: { role: CardRole.Title, prop: 'content' },
|
|
648
|
-
[CardBindTarget.Text]: { role: CardRole.Text, prop: 'content' },
|
|
729
|
+
[CardBindTarget.Text]: { role: CardRole.Text, prop: 'content', part: CardPart.Text },
|
|
649
730
|
};
|
|
650
731
|
|
|
651
732
|
/**
|
|
@@ -670,10 +751,22 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
|
|
|
670
751
|
const tone = pn.badge as BadgeTone | undefined;
|
|
671
752
|
if (tone && !BADGE_TONES.includes(tone)) throw new ComposeError(`${id}: a badge has no tone "${pn.badge}"`);
|
|
672
753
|
for (const key of Object.keys(pn.words ?? {})) if (!CARD_WORD_KEYS.includes(key)) throw new ComposeError(`${id}: a card has no words "${key}"`);
|
|
754
|
+
// L0-3: each part binds a ROLE of the row's type; a part whose optional role the type lacks starts switched off.
|
|
755
|
+
const binds: Array<[CardBindTarget, ScopedPath, string]> = [];
|
|
756
|
+
const parts: Partial<Record<CardPart, boolean>> = { ...(pn.parts as Partial<Record<CardPart, boolean>> | undefined) };
|
|
757
|
+
for (const [key, path] of Object.entries(pn.binds ?? {})) {
|
|
758
|
+
const target = CARD_BINDS[key as CardBindTarget];
|
|
759
|
+
if (!target) throw new ComposeError(`${id}: a card cannot bind "${key}"`);
|
|
760
|
+
const scoped = resolveScopedPath(path, repeater, ctx, id);
|
|
761
|
+
if (scoped) binds.push([key as CardBindTarget, scoped, path]);
|
|
762
|
+
else if (target.part) parts[target.part] = false;
|
|
763
|
+
else throw new ComposeError(`${id}: a card's ${key} cannot be left out`);
|
|
764
|
+
}
|
|
673
765
|
const card = buildCardRecipe(factory, {
|
|
674
|
-
layout, parts
|
|
766
|
+
layout, parts, words: pn.words as Partial<CardWords> | undefined, badgeTone: tone,
|
|
675
767
|
...(pn.icon ? { badgeIcon: pn.icon } : {}),
|
|
676
768
|
}) as SerializedNode;
|
|
769
|
+
markEntrance(pn, id, repeater, ctx);
|
|
677
770
|
if (pn.scheme) {
|
|
678
771
|
if (!COLOR_SCHEMES.some(c => c.id === pn.scheme)) throw new ComposeError(`${id}: no colour scheme "${pn.scheme}"`);
|
|
679
772
|
setColorScheme(card, pn.scheme);
|
|
@@ -684,19 +777,27 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
|
|
|
684
777
|
content.children = [...(content.children ?? []), ...composeChildren(pn, id, repeater, ctx)];
|
|
685
778
|
}
|
|
686
779
|
|
|
687
|
-
for (const [key, path] of
|
|
688
|
-
const target = CARD_BINDS[key
|
|
689
|
-
if (!target) throw new ComposeError(`${id}: a card cannot bind "${key}"`);
|
|
780
|
+
for (const [key, scoped, path] of binds) {
|
|
781
|
+
const target = CARD_BINDS[key];
|
|
690
782
|
const part = findByRole(card as CompositeNode, target.role) as SerializedNode | undefined;
|
|
691
783
|
if (!part) throw new ComposeError(`${id}: "${key}" binds a part the card leaves out`);
|
|
692
784
|
const node = target.role === CardRole.Media ? part.children?.find(c => c.type === IMAGE) : part;
|
|
693
785
|
if (!node) throw new ComposeError(`${id}: the card's media holds no image`);
|
|
694
|
-
addBinding(node, { prop: target.prop, ...
|
|
786
|
+
addBinding(node, { prop: target.prop, ...scoped });
|
|
695
787
|
if (node.type === IMAGE) addBinding(node, { prop: 'alt', ...altBinding({ ...pn, t: IMAGE, bind: path, alt: undefined }, repeater, ctx, id) });
|
|
696
788
|
}
|
|
697
789
|
|
|
698
|
-
|
|
790
|
+
// The whole card links to its item's own page through its title (`detail`) — or, by route, to a page (`href`).
|
|
699
791
|
const title = findByRole(card as CompositeNode, CardRole.Title) as SerializedNode;
|
|
792
|
+
if (pn.detail) {
|
|
793
|
+
const detail = detailLinkOf(pn.detail, repeater, ctx, id);
|
|
794
|
+
if (detail) {
|
|
795
|
+
writePageLink(title, detail.route);
|
|
796
|
+
addBinding(title, { prop: `routeParam:${detail.param}`, ...detail.binding });
|
|
797
|
+
}
|
|
798
|
+
return card;
|
|
799
|
+
}
|
|
800
|
+
const fate = danglingFate({ ...pn, t: 'Heading' }, ctx);
|
|
700
801
|
if (pn.href && !fate) writePageLink(title, pn.href);
|
|
701
802
|
else if (fate) {
|
|
702
803
|
if (fate === DanglingFate.Email) writeExternalLink(title, `${MAILTO}${ctx.email}`);
|
|
@@ -890,17 +991,81 @@ function wireOpeners(root: SerializedNode, openers: ReadonlyArray<string>): void
|
|
|
890
991
|
visit(root);
|
|
891
992
|
}
|
|
892
993
|
|
|
893
|
-
|
|
994
|
+
/** How a composed section moves in (M1-6b): the look's How animated (or the section's own) on the page's direction. */
|
|
995
|
+
interface SectionMotion {
|
|
996
|
+
level: AnimateId | string | null;
|
|
997
|
+
rtl: boolean;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function composePattern(pattern: SitePattern, id: string, ctx: Ctx, motion: SectionMotion | null = null): SerializedNode {
|
|
894
1001
|
if (pattern.node.each) throw new ComposeError(`${id}: a pattern's root cannot be stamped per page`);
|
|
895
|
-
const own: Ctx = { ...ctx, openers: [], shownWhen: [] };
|
|
1002
|
+
const own: Ctx = { ...ctx, openers: [], shownWhen: [], entrances: [] };
|
|
896
1003
|
const node = composeNode(pattern.node, id, null, own);
|
|
897
1004
|
if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional or a link`);
|
|
898
1005
|
wireOpeners(node, own.openers ?? []);
|
|
899
1006
|
wireShownWhen(node, own.shownWhen ?? []);
|
|
1007
|
+
if (motion && !pattern.global) stampEntrances(node, pattern.family, own.entrances ?? [], motion);
|
|
900
1008
|
node.label = pattern.label;
|
|
901
1009
|
return node;
|
|
902
1010
|
}
|
|
903
1011
|
|
|
1012
|
+
/**
|
|
1013
|
+
* M1-6b: the look's entrances on a composed section — `planEntrances` over the pattern's marked parts, each counted
|
|
1014
|
+
* among the marked siblings of its part (static cards a literal delay apart), appended to the node's interactions
|
|
1015
|
+
* (`origin: 'look'`, `viewportEnter`, once, on itself — a duplicated node needs no remap).
|
|
1016
|
+
*/
|
|
1017
|
+
function stampEntrances(root: SerializedNode, family: string, entrances: ReadonlyArray<Omit<EntranceMark, 'index'>>, motion: SectionMotion): void {
|
|
1018
|
+
if (!entrances.length && !root.children?.length) return;
|
|
1019
|
+
const parentOf = new Map<string, string>();
|
|
1020
|
+
const nodes = new Map<string, SerializedNode>();
|
|
1021
|
+
walkSerialized([root], (n, ancestors) => { nodes.set(n.id, n); if (ancestors.length) parentOf.set(n.id, ancestors[ancestors.length - 1].id); });
|
|
1022
|
+
const seen = new Map<string, number>();
|
|
1023
|
+
const marks: EntranceMark[] = entrances.map(e => {
|
|
1024
|
+
const key = `${parentOf.get(e.nodeId) ?? ''}|${e.part}`;
|
|
1025
|
+
const index = seen.get(key) ?? 0;
|
|
1026
|
+
seen.set(key, index + 1);
|
|
1027
|
+
return { ...e, index };
|
|
1028
|
+
});
|
|
1029
|
+
const stamps = planEntrances({ family, level: motion.level, marks, contentIds: (root.children ?? []).map(c => c.id), rtl: motion.rtl });
|
|
1030
|
+
for (const { nodeId, interaction } of stamps) {
|
|
1031
|
+
const node = nodes.get(nodeId);
|
|
1032
|
+
if (node) node.interactions = [...node.interactions, interaction];
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const SEO_JOIN = ' · ';
|
|
1037
|
+
const SEO_LINE = ' — ';
|
|
1038
|
+
|
|
1039
|
+
/** Cut to `max` characters on a word boundary, with an ellipsis only when something was cut (seo-core's limits). */
|
|
1040
|
+
function fitSeo(text: string, max: number): string {
|
|
1041
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
1042
|
+
if (clean.length <= max) return clean;
|
|
1043
|
+
const cut = clean.slice(0, max - 1);
|
|
1044
|
+
const at = cut.lastIndexOf(' ');
|
|
1045
|
+
return `${(at > max / 2 ? cut.slice(0, at) : cut).trimEnd()}…`;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* A composed page's search-result words (M1-6c), in the seo-core shape `resolveSeo` reads — every page resolves a
|
|
1050
|
+
* title and a description, and nothing is invented: the plan's own words when it writes them; else Home is the
|
|
1051
|
+
* business (and its line), an inner page "<page> · <business>", described by the business's line or, without one,
|
|
1052
|
+
* by the page, the business and the city it gave. A TEMPLATE page writes neither unless the plan does: the shared
|
|
1053
|
+
* ladder's content rung reads its own bound heading and lead, so each item's page has its own title and line (the old
|
|
1054
|
+
* one-title-for-every-template-page defect, seo-plan.md). Plain words only — a stored `{ binding }` value is refused by
|
|
1055
|
+
* the CMS's page schema today (vectoriox/iox-cms#42).
|
|
1056
|
+
*/
|
|
1057
|
+
function composePageSeo(page: PagePlan, plan: SitePlan): SeoRawDoc {
|
|
1058
|
+
const name = plan.business.name.trim();
|
|
1059
|
+
const tagline = nonEmpty(plan.business.tagline) ? plan.business.tagline.trim() : '';
|
|
1060
|
+
const city = nonEmpty(plan.business.facts?.city) ? plan.business.facts!.city!.trim() : '';
|
|
1061
|
+
const perItem = !!page.routeSource;
|
|
1062
|
+
const title = nonEmpty(page.seo?.title) ? page.seo!.title
|
|
1063
|
+
: perItem ? undefined : fitSeo(page.isHome ? (tagline ? `${name}${SEO_LINE}${tagline}` : name) : `${page.name}${SEO_JOIN}${name}`, SEO_TITLE_MAX);
|
|
1064
|
+
const description = nonEmpty(page.seo?.description) ? page.seo!.description
|
|
1065
|
+
: perItem ? undefined : fitSeo(tagline || `${page.name}${SEO_LINE}${name}${city ? `, ${city}` : ''}.`, SEO_DESCRIPTION_MAX);
|
|
1066
|
+
return { ...(title ? { title } : {}), ...(description ? { description } : {}) };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
904
1069
|
function composeDataSource(ds: DataSourcePlan): ComposedDataSource {
|
|
905
1070
|
const id = `ds-${ds.alias}`;
|
|
906
1071
|
if (ds.type === PlanDataSourceType.Cms) {
|
|
@@ -950,11 +1115,15 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
|
|
|
950
1115
|
const email = nonEmpty(plan.business.facts?.email) ? plan.business.facts!.email!.trim() : null;
|
|
951
1116
|
const base = { plan, placeholders, issues, routes, year: context.year, email, navPage: null };
|
|
952
1117
|
|
|
1118
|
+
const design = resolveDesignChoices(clone(plan.design));
|
|
1119
|
+
const locale = plan.locale ?? DEFAULT_SITE_LOCALE;
|
|
1120
|
+
const rtl = isRtlLocale(locale);
|
|
953
1121
|
const pages: ComposedPage[] = plan.pages.map(page => {
|
|
954
1122
|
const nextAlias = page.routeSource ? nextSourceOf(relatives, page.routeSource) : null;
|
|
955
1123
|
const layout = page.sections.map((section, i) => {
|
|
956
1124
|
const pattern = patternById(section.pattern) as SitePattern;
|
|
957
|
-
|
|
1125
|
+
const motion: SectionMotion = { level: section.enter ?? design.animate ?? null, rtl };
|
|
1126
|
+
return composePattern(pattern, `${page.key}-${i}`, { ...base, owner: page.key, page, section, nextAlias }, motion);
|
|
958
1127
|
});
|
|
959
1128
|
return {
|
|
960
1129
|
key: page.key,
|
|
@@ -962,7 +1131,9 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
|
|
|
962
1131
|
route: { path: page.route },
|
|
963
1132
|
isHome: page.isHome === true,
|
|
964
1133
|
status: STATUS_DRAFT,
|
|
1134
|
+
locale,
|
|
965
1135
|
pageSettings: { designSystem: true },
|
|
1136
|
+
seo: composePageSeo(page, plan),
|
|
966
1137
|
layout,
|
|
967
1138
|
};
|
|
968
1139
|
});
|
|
@@ -984,7 +1155,7 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
|
|
|
984
1155
|
return {
|
|
985
1156
|
source: clone(plan.source),
|
|
986
1157
|
business: clone(plan.business),
|
|
987
|
-
design
|
|
1158
|
+
design,
|
|
988
1159
|
schemas: clone(plan.contentTypes) as ContentTypePlan[],
|
|
989
1160
|
items: Object.fromEntries(plan.contentTypes.map(t => [t.name, composeItems(t)])),
|
|
990
1161
|
dataSources: plan.dataSources.map(composeDataSource),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { AnimateId, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
|
|
1
|
+
import { AnimateId, EntranceParam, SPEC_AXES, staggerDelay } from '@vectoriox/iox-ui/design-core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* How animated (AI plan rev 3.1 §3p) — the ONE reading of the contract's `axes.animations` (what each level
|
|
5
5
|
* moves, how) and `axes.animationFamilies` (which parts a section family lets move). The pieces every stamper
|
|
6
|
-
* shares: the Design step's business specimen (H4-4)
|
|
6
|
+
* shares: the Design step's business specimen (H4-4) and `planEntrances`, which the composer runs per section (M1-6b).
|
|
7
|
+
* When an entrance plays — a static item's literal delay, a Repeater row's share — is design-core's `staggerDelay` /
|
|
8
|
+
* `entranceDelay`, which both interaction runtimes read too. Pure.
|
|
7
9
|
*
|
|
8
10
|
* A level with `content` moves a section's content together, once; otherwise it names parts (heading, items,
|
|
9
11
|
* media…) and each part enters on its own, items one after another. Navigation, footers and globals are a family
|
|
@@ -52,7 +54,7 @@ export interface EntranceAction {
|
|
|
52
54
|
delay: number;
|
|
53
55
|
easing: typeof ENTRANCE_EASING;
|
|
54
56
|
once: true;
|
|
55
|
-
params?: { distance
|
|
57
|
+
params?: { distance?: number; stagger?: number; cap?: number };
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export interface EntranceInteraction {
|
|
@@ -84,23 +86,42 @@ export function sectionEntrances(level: AnimateId | string | null | undefined, f
|
|
|
84
86
|
return { content: null, parts };
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
/**
|
|
88
|
-
export
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return Math.min(Math.max(0, index), cap - 1) * spec.stagger;
|
|
89
|
+
/** Which side of its row a picture sits on (`rowMedia`): an end-side picture enters from the end. */
|
|
90
|
+
export enum EntranceSide {
|
|
91
|
+
Start = 'start',
|
|
92
|
+
End = 'end',
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
export interface EntranceActionOptions {
|
|
96
|
+
/** The item's place among the items that enter together (a literal delay, `staggerDelay`). */
|
|
97
|
+
index?: number;
|
|
98
|
+
/** A right-to-left page: what slides comes from the mirrored side. */
|
|
99
|
+
rtl?: boolean;
|
|
100
|
+
/** A row picture's side (default end — the side the spec's own type enters from). */
|
|
101
|
+
side?: EntranceSide | `${EntranceSide}`;
|
|
102
|
+
/** The node is a Repeater's template: its rows take their share at runtime (`entranceDelay`), so the stagger is
|
|
103
|
+
* carried as params and nothing is written into the delay. */
|
|
104
|
+
repeated?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The action a node plays: the spec's type (mirrored for a start-side picture and on a right-to-left page — both
|
|
108
|
+
* together cancel out), its distance, and its delay (or, for a Repeater's rows, the stagger the runtime adds). */
|
|
109
|
+
export function entranceAction(spec: EntranceSpec, opts: EntranceActionOptions = {}): EntranceAction {
|
|
110
|
+
const mirrored = (opts.side === EntranceSide.Start) !== !!opts.rtl;
|
|
111
|
+
const params: Record<string, number> = {};
|
|
112
|
+
if (spec.distance) params[EntranceParam.Distance] = spec.distance;
|
|
113
|
+
if (opts.repeated && spec.stagger) {
|
|
114
|
+
params[EntranceParam.Stagger] = spec.stagger;
|
|
115
|
+
params[EntranceParam.Cap] = spec.cap ?? 1;
|
|
116
|
+
}
|
|
96
117
|
return {
|
|
97
|
-
type:
|
|
118
|
+
type: mirrored && spec.mirror ? spec.mirror : spec.type,
|
|
98
119
|
target: 'self',
|
|
99
120
|
duration: spec.duration,
|
|
100
|
-
delay: staggerDelay(spec, opts.index),
|
|
121
|
+
delay: opts.repeated ? 0 : staggerDelay(spec, opts.index),
|
|
101
122
|
easing: ENTRANCE_EASING,
|
|
102
123
|
once: true,
|
|
103
|
-
...(
|
|
124
|
+
...(Object.keys(params).length ? { params } : {}),
|
|
104
125
|
};
|
|
105
126
|
}
|
|
106
127
|
|
|
@@ -108,3 +129,51 @@ export function entranceAction(spec: EntranceSpec, opts: { index?: number; rtl?:
|
|
|
108
129
|
export function entranceInteraction(nodeId: string, action: EntranceAction): EntranceInteraction {
|
|
109
130
|
return { id: `${nodeId}-enter`, trigger: ENTRANCE_TRIGGER, actions: [action], origin: ENTRANCE_ORIGIN_LOOK };
|
|
110
131
|
}
|
|
132
|
+
|
|
133
|
+
/** A node a pattern marks as an entrance part (`enter`), as the composer stamped it. */
|
|
134
|
+
export interface EntranceMark {
|
|
135
|
+
nodeId: string;
|
|
136
|
+
part: EntrancePart | `${EntrancePart}`;
|
|
137
|
+
/** Its place among the marks of the same part under the same parent — what a static item's delay is counted by. */
|
|
138
|
+
index: number;
|
|
139
|
+
/** A Repeater's template (its rows share the node). */
|
|
140
|
+
repeated: boolean;
|
|
141
|
+
side?: EntranceSide | `${EntranceSide}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface EntranceStamp {
|
|
145
|
+
nodeId: string;
|
|
146
|
+
interaction: EntranceInteraction;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface PlanEntrancesInput {
|
|
150
|
+
/** The section's family (contract `animationFamilies`). */
|
|
151
|
+
family: string;
|
|
152
|
+
/** How animated — the look's, or the section's own (`PageSectionPlan.enter`). */
|
|
153
|
+
level: AnimateId | string | null | undefined;
|
|
154
|
+
/** The section's marked parts, in document order. */
|
|
155
|
+
marks: ReadonlyArray<EntranceMark>;
|
|
156
|
+
/** The section's own children: what a level that moves the CONTENT fades when the pattern marks no content node. */
|
|
157
|
+
contentIds: ReadonlyArray<string>;
|
|
158
|
+
rtl?: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The entrances a composed section gets (AI plan §3p, M1-6b): a level that moves the CONTENT fades the pattern's
|
|
163
|
+
* `content` marks — else every child of the section — together, once; otherwise each marked part its family allows
|
|
164
|
+
* enters on its own (static items a literal delay apart, a Repeater's rows by the runtime), a row picture from its side.
|
|
165
|
+
* Nothing for a header or footer family, Still, or an unknown level. Pure.
|
|
166
|
+
*/
|
|
167
|
+
export function planEntrances(input: PlanEntrancesInput): EntranceStamp[] {
|
|
168
|
+
const plan = sectionEntrances(input.level, input.family);
|
|
169
|
+
const stamp = (nodeId: string, spec: EntranceSpec, opts: EntranceActionOptions): EntranceStamp =>
|
|
170
|
+
({ nodeId, interaction: entranceInteraction(nodeId, entranceAction(spec, { ...opts, rtl: input.rtl })) });
|
|
171
|
+
if (plan.content) {
|
|
172
|
+
const marked = input.marks.filter(m => m.part === EntrancePart.Content).map(m => m.nodeId);
|
|
173
|
+
return (marked.length ? marked : input.contentIds).map(id => stamp(id, plan.content!, {}));
|
|
174
|
+
}
|
|
175
|
+
return input.marks.flatMap(m => {
|
|
176
|
+
const spec = m.part === EntrancePart.Content ? undefined : plan.parts[m.part as EntrancePart];
|
|
177
|
+
return spec ? [stamp(m.nodeId, spec, { index: m.index, repeated: m.repeated, side: m.side })] : [];
|
|
178
|
+
});
|
|
179
|
+
}
|