@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.
- package/design-core/index.d.ts +42 -3
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +48 -6
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs +80 -1
- 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 +117 -2
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/forms-core/index.d.ts +42 -2
- package/generation-core/index.d.ts +206 -74
- package/index.d.ts +17 -1
- package/package.json +1 -1
- package/src/design-core/contrast-audit.ts +7 -6
- package/src/design-core/flavor-spec.generated.ts +17 -0
- package/src/design-core/node-links.ts +32 -0
- package/src/design-core/public-api.ts +1 -0
- package/src/forms-core/form-presets.ts +105 -0
- package/src/forms-core/public-api.ts +1 -0
- 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
|
@@ -7,12 +7,16 @@ import { SPEC_SITE_PATTERNS } from './site-spec.generated';
|
|
|
7
7
|
* `t` component type · `es` element style · `scheme` section colour scheme · `tag` a Container's html
|
|
8
8
|
* tag · `level` a Heading's level · `lay` layout-only styleProps · `resp` per-breakpoint styleProps ·
|
|
9
9
|
* `aspect` an Image's aspect ratio · `slot` author-editable text, filled from the plan's section slots ·
|
|
10
|
-
* `fromBusiness` text from the plan's business · `text` the pattern's own words
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
* (
|
|
10
|
+
* `fromBusiness` text from the plan's business · `text` the pattern's own words (`{year}` and
|
|
11
|
+
* `{business.name}` are filled at compose time) · `ph` a publish-blocking placeholder (a FACT key,
|
|
12
|
+
* `copy` or `image`) · `bind` a binding path on the repeater item (`item.*`), the route item
|
|
13
|
+
* (`route.*`) or its next sibling (`next.*`) — or, on an `each` node, the page it is stamped for
|
|
14
|
+
* (`page.*`, written as plain words) · `alt` an Image's alt binding (default: the bound item's preview
|
|
15
|
+
* text field; `''` = decorative, written empty) · `src` what a Repeater iterates (`$binding`, `route.<array>`) · `each` stamp the node once
|
|
16
|
+
* per page in the navigation (`$navPages`) · `href` a page route (`$page.route` = the stamped page's) ·
|
|
14
17
|
* `param` the value of the route's `:param` · `optional` a part a page may leave out (`without`) ·
|
|
15
|
-
* `preset` a
|
|
18
|
+
* `preset` a forms-core form preset (its fields, submit and Success/Error words) · `control` / `label` /
|
|
19
|
+
* `required` a Field's · `c` children.
|
|
16
20
|
*/
|
|
17
21
|
export interface PatternNode {
|
|
18
22
|
readonly t: string;
|
|
@@ -28,7 +32,9 @@ export interface PatternNode {
|
|
|
28
32
|
readonly text?: string;
|
|
29
33
|
readonly ph?: string;
|
|
30
34
|
readonly bind?: string;
|
|
35
|
+
readonly alt?: string;
|
|
31
36
|
readonly src?: string;
|
|
37
|
+
readonly each?: string;
|
|
32
38
|
readonly href?: string;
|
|
33
39
|
readonly param?: string;
|
|
34
40
|
readonly optional?: string;
|
|
@@ -64,9 +70,10 @@ export enum PatternPlaceholder {
|
|
|
64
70
|
export enum PatternDirective {
|
|
65
71
|
/** A Repeater's source: the section's `binding`. */
|
|
66
72
|
Binding = '$binding',
|
|
67
|
-
/**
|
|
73
|
+
/** `each`: one plain node per page of the plan that is in the navigation (`inNav`), in plan order —
|
|
74
|
+
* words and routes written in, editable on the canvas (W1.1). */
|
|
68
75
|
NavPages = '$navPages',
|
|
69
|
-
/** A Link's route: the
|
|
76
|
+
/** A Link's route: the route of the page an `each` node is stamped for. */
|
|
70
77
|
PageRoute = '$page.route',
|
|
71
78
|
}
|
|
72
79
|
|
|
@@ -74,7 +81,7 @@ export enum PatternDirective {
|
|
|
74
81
|
export enum BindScope {
|
|
75
82
|
/** The nearest Repeater's row. */
|
|
76
83
|
Item = 'item',
|
|
77
|
-
/** The
|
|
84
|
+
/** The page an `each: $navPages` node is stamped for — resolved to plain words, never a binding. */
|
|
78
85
|
Page = 'page',
|
|
79
86
|
/** The page's route item (`routeSource`). */
|
|
80
87
|
Route = 'route',
|
|
@@ -113,11 +120,26 @@ export function patternNeedsBinding(pattern: SitePattern): boolean {
|
|
|
113
120
|
return patternNodes(pattern).some(n => n.src === PatternDirective.Binding);
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
const IMAGE_TYPE = 'Image';
|
|
124
|
+
|
|
125
|
+
/** The scopes whose ITEM a pattern's bound Images take their alt text from (an Image that names no `alt`
|
|
126
|
+
* of its own): `item` (the row of a `$binding` Repeater — the section's list), `route` or `next`. Each
|
|
127
|
+
* needs a content type with a `previewTextField` (W1.1, Q7). */
|
|
128
|
+
export function altSourceScopes(pattern: SitePattern): Set<string> {
|
|
129
|
+
const scopes = new Set<string>();
|
|
130
|
+
const visit = (n: PatternNode) => {
|
|
131
|
+
if (n.t === IMAGE_TYPE && n.bind && n.alt === undefined) scopes.add(n.bind.split('.')[0]);
|
|
132
|
+
for (const child of n.c ?? []) visit(child);
|
|
133
|
+
};
|
|
134
|
+
visit(pattern.node);
|
|
135
|
+
return scopes;
|
|
136
|
+
}
|
|
137
|
+
|
|
116
138
|
/** The bind scopes a pattern reads (`route`, `next`, …) — what its page must provide. */
|
|
117
139
|
export function patternScopes(pattern: SitePattern): Set<string> {
|
|
118
140
|
const scopes = new Set<string>();
|
|
119
141
|
for (const n of patternNodes(pattern)) {
|
|
120
|
-
for (const path of [n.bind, n.param, n.src]) {
|
|
142
|
+
for (const path of [n.bind, n.alt, n.param, n.src]) {
|
|
121
143
|
if (path && !path.startsWith('$')) scopes.add(path.split('.')[0]);
|
|
122
144
|
}
|
|
123
145
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
export * from './site-spec.generated';
|
|
13
13
|
export * from './node-baseline.model';
|
|
14
14
|
export * from './node-baselines.generated';
|
|
15
|
+
export * from './baseline-roles';
|
|
15
16
|
export * from './site-plan.model';
|
|
16
17
|
export * from './patterns';
|
|
17
18
|
export * from './templates';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyElementStyle, defaultElementStyleFor, elementStyleById, NEW_NODE_STYLE_DEFAULTS, ownedTraitsOf } from '@vectoriox/iox-ui/design-core';
|
|
2
2
|
|
|
3
|
+
import { releasedLayoutDefaults } from './baseline-roles';
|
|
3
4
|
import { NODE_BASELINES } from './node-baselines.generated';
|
|
4
5
|
import { PatternGlobalPosition } from './patterns';
|
|
5
6
|
|
|
@@ -40,13 +41,22 @@ export class ComposeError extends Error {}
|
|
|
40
41
|
|
|
41
42
|
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
|
42
43
|
|
|
44
|
+
export interface CreateNodeOptions {
|
|
45
|
+
/** The composer's rule (W1.1): the type's empty-drop sizes (`BaselineRole.Layout`) start neutral, so
|
|
46
|
+
* the PATTERN owns the box. The builder never does this — a palette drop keeps its 200px. */
|
|
47
|
+
releaseLayoutDefaults?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
51
|
* A NEW node of `type`, as the builder creates one (ComponentRegistryService.createConfig, serialized):
|
|
45
52
|
* the type's constructor defaults, its text colour released to `inherit` (NEW_NODE_STYLE_DEFAULTS), then
|
|
46
53
|
* ONE element style — `elementStyle` when given, else the type's default for its props (a Heading
|
|
47
|
-
* follows its level). `props` are applied before the style is chosen.
|
|
54
|
+
* follows its level). `props` are applied before the style is chosen. With `releaseLayoutDefaults` the
|
|
55
|
+
* constructor's layout sizes are released first, so an element style that owns one still writes it.
|
|
48
56
|
*/
|
|
49
|
-
export function createSerializedNode(
|
|
57
|
+
export function createSerializedNode(
|
|
58
|
+
id: string, type: string, props: Record<string, unknown> = {}, elementStyle?: string | null, options: CreateNodeOptions = {},
|
|
59
|
+
): SerializedNode {
|
|
50
60
|
const baseline = NODE_BASELINES[type];
|
|
51
61
|
if (!baseline) throw new ComposeError(`"${type}" is not a registered component type`);
|
|
52
62
|
const node: SerializedNode = {
|
|
@@ -57,6 +67,7 @@ export function createSerializedNode(id: string, type: string, props: Record<str
|
|
|
57
67
|
bindings: [],
|
|
58
68
|
interactions: [],
|
|
59
69
|
};
|
|
70
|
+
if (options.releaseLayoutDefaults) Object.assign(node.styleProps, releasedLayoutDefaults(type));
|
|
60
71
|
for (const [name, value] of Object.entries(NEW_NODE_STYLE_DEFAULTS)) if (name in node.styleProps) node.styleProps[name] = value;
|
|
61
72
|
const styleId = elementStyle ?? defaultElementStyleFor(type, node.props);
|
|
62
73
|
if (styleId) {
|
|
@@ -3,7 +3,8 @@ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
|
|
|
3
3
|
import { SPEC_SITE_FACTS } from './site-spec.generated';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* The Site Plan (contract `site.planShape`
|
|
6
|
+
* The Site Plan (contract `site.planShape`, `SPEC_SITE_PLAN_SHAPE`: every key typed and explained, and the
|
|
7
|
+
* validator reads its key lists from it) — the ONE document the website wizard writes
|
|
7
8
|
* (`source.kind: template`) and the AI agent will write (`source.kind: ai`). One validator, one
|
|
8
9
|
* composer: everything the wizard proves is proven for the agent.
|
|
9
10
|
*/
|
|
@@ -108,9 +109,9 @@ export interface PagePlan {
|
|
|
108
109
|
readonly name: string;
|
|
109
110
|
readonly route: string;
|
|
110
111
|
readonly isHome?: boolean;
|
|
111
|
-
/**
|
|
112
|
+
/** A template page the wizard's Pages step cannot leave out (`planFromTemplate`). */
|
|
112
113
|
readonly required?: boolean;
|
|
113
|
-
/**
|
|
114
|
+
/** A Link in the header and footer navigation (`each: $navPages`), in plan order. */
|
|
114
115
|
readonly inNav?: boolean;
|
|
115
116
|
/** A template page: the content type it renders one item of. */
|
|
116
117
|
readonly template?: string;
|
|
@@ -3,14 +3,99 @@
|
|
|
3
3
|
// architecture/builder/design-system/build-spec.mjs: change a value THERE, then regenerate both.
|
|
4
4
|
/* eslint-disable */
|
|
5
5
|
export const SPEC_SITE_PLAN_SHAPE = {
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
"plan": {
|
|
7
|
+
"version": "'1'",
|
|
8
|
+
"source": "sourceTemplate | sourceAi",
|
|
9
|
+
"business": "business",
|
|
10
|
+
"design": "design",
|
|
11
|
+
"contentTypes": "contentType[]",
|
|
12
|
+
"dataSources": "(cmsDataSource | relativeDataSource)[] — merged by alias into the org library",
|
|
13
|
+
"pages": "page[]",
|
|
14
|
+
"globals": "globals"
|
|
15
|
+
},
|
|
16
|
+
"sourceTemplate": {
|
|
17
|
+
"kind": "'template'",
|
|
18
|
+
"templateId": "string",
|
|
19
|
+
"templateVersion": "integer"
|
|
20
|
+
},
|
|
21
|
+
"sourceAi": {
|
|
22
|
+
"kind": "'ai'",
|
|
23
|
+
"runId": "string"
|
|
24
|
+
},
|
|
25
|
+
"business": {
|
|
26
|
+
"name": "string — the brand: the header, the hero, the copyright line",
|
|
27
|
+
"tagline?": "string — the hero lead and the footer line",
|
|
28
|
+
"facts?": "{ [fact]: string } — facts (email, phone, city): only the customer's own words; a blank fact is a publish-blocking placeholder, never invented"
|
|
29
|
+
},
|
|
30
|
+
"design": {
|
|
31
|
+
"flavorId": "string — a v2 flavor pack id",
|
|
32
|
+
"seed": "string — #rrggbb brand colour",
|
|
33
|
+
"pairId": "string — one of the pack's font pairings",
|
|
34
|
+
"shape": "string — axes.shapes",
|
|
35
|
+
"density": "string — axes.densities",
|
|
36
|
+
"motion": "string — axes.motions",
|
|
37
|
+
"pillButtons": "boolean",
|
|
38
|
+
"mode": "'light' | 'dark' | 'auto'",
|
|
39
|
+
"tint?": "string — axes.tints (DS9 neutral tint)",
|
|
40
|
+
"fonts?": "object — uploaded font families per role (DS5)"
|
|
41
|
+
},
|
|
42
|
+
"contentType": {
|
|
43
|
+
"name": "string — unique",
|
|
44
|
+
"label": "string",
|
|
45
|
+
"previewTextField?": "string — a field of the type: the item's name on cards and the alt text of its bound images",
|
|
46
|
+
"previewImageField?": "string — a field of the type: the item's picture",
|
|
47
|
+
"fields": "field[]",
|
|
48
|
+
"seed?": "seed"
|
|
49
|
+
},
|
|
50
|
+
"field": {
|
|
51
|
+
"name": "string — unique in the type",
|
|
52
|
+
"type": "string",
|
|
53
|
+
"controller": "string — an IOX input controller",
|
|
54
|
+
"required?": "boolean",
|
|
55
|
+
"note?": "string"
|
|
56
|
+
},
|
|
57
|
+
"seed": {
|
|
58
|
+
"count": "integer ≥ 0",
|
|
59
|
+
"sample": "boolean — items are flagged isSample",
|
|
60
|
+
"isPublished": "boolean",
|
|
61
|
+
"[field]?": "the field's value — strings are templates: {n} = the item's 1-based number, {sampleImage:n} / {sampleImage:n+k} = a bundled sample image"
|
|
62
|
+
},
|
|
63
|
+
"cmsDataSource": {
|
|
64
|
+
"alias": "string — unique; never $… (platform scopes)",
|
|
65
|
+
"type": "'cms'",
|
|
66
|
+
"mode": "'query' | 'single'",
|
|
67
|
+
"contentType": "string — a content type of the plan",
|
|
68
|
+
"limit?": "integer ≥ 1",
|
|
69
|
+
"filterByRouteParam?": "{ field, routeParam } — single only: the route param that picks the item"
|
|
70
|
+
},
|
|
71
|
+
"relativeDataSource": {
|
|
72
|
+
"alias": "string — unique",
|
|
73
|
+
"type": "'relative'",
|
|
74
|
+
"of": "string — a single cms source (the subject)",
|
|
75
|
+
"op": "'$siblings.next' | '$siblings.prev'",
|
|
76
|
+
"within": "string — a query cms source of the same type"
|
|
77
|
+
},
|
|
78
|
+
"page": {
|
|
79
|
+
"key": "string — unique",
|
|
80
|
+
"name": "string — the nav label",
|
|
81
|
+
"route": "string — /lowercase-words, :params; unique",
|
|
82
|
+
"isHome?": "boolean — exactly one page, at /",
|
|
83
|
+
"required?": "boolean — a template page the wizard's Pages step cannot leave out",
|
|
84
|
+
"inNav?": "boolean — a Link in the header and footer navigation, in plan order",
|
|
85
|
+
"template?": "string — the content type a template page renders one item of",
|
|
86
|
+
"routeSource?": "string — the single source read by the route param (route.* binds it)",
|
|
87
|
+
"sections": "section[]"
|
|
88
|
+
},
|
|
89
|
+
"section": {
|
|
90
|
+
"pattern": "string — a non-global pattern id",
|
|
91
|
+
"slots?": "{ [slot]: string } — the pattern's declared slots",
|
|
92
|
+
"binding?": "string — the list source a pattern's $binding repeater iterates (exactly when it has one)",
|
|
93
|
+
"without?": "string[] — the pattern's optional parts to leave out"
|
|
94
|
+
},
|
|
95
|
+
"globals": {
|
|
96
|
+
"header": "string — a header pattern (global: before)",
|
|
97
|
+
"footer": "string — a footer pattern (global: after)"
|
|
98
|
+
}
|
|
14
99
|
} as const;
|
|
15
100
|
|
|
16
101
|
export const SPEC_SITE_FACTS = {
|
|
@@ -30,6 +115,7 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
30
115
|
"tag": "nav",
|
|
31
116
|
"lay": {
|
|
32
117
|
"display": "flex",
|
|
118
|
+
"flexWrap": "wrap",
|
|
33
119
|
"justifyContent": "space-between",
|
|
34
120
|
"alignItems": "center",
|
|
35
121
|
"gap": "var(--iox-space-gap)"
|
|
@@ -55,21 +141,17 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
55
141
|
"lay": {
|
|
56
142
|
"display": "flex",
|
|
57
143
|
"flexWrap": "wrap",
|
|
58
|
-
"gap": "
|
|
59
|
-
"alignItems": "center"
|
|
144
|
+
"gap": "var(--iox-space-gap)",
|
|
145
|
+
"alignItems": "center",
|
|
146
|
+
"width": "auto"
|
|
60
147
|
},
|
|
61
148
|
"c": [
|
|
62
149
|
{
|
|
63
|
-
"t": "
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"es": "link",
|
|
69
|
-
"bind": "page.name",
|
|
70
|
-
"href": "$page.route"
|
|
71
|
-
}
|
|
72
|
-
]
|
|
150
|
+
"t": "Link",
|
|
151
|
+
"es": "link",
|
|
152
|
+
"each": "$navPages",
|
|
153
|
+
"bind": "page.name",
|
|
154
|
+
"href": "$page.route"
|
|
73
155
|
},
|
|
74
156
|
{
|
|
75
157
|
"t": "ButtonBlock",
|
|
@@ -147,16 +229,11 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
147
229
|
"text": "Pages"
|
|
148
230
|
},
|
|
149
231
|
{
|
|
150
|
-
"t": "
|
|
151
|
-
"
|
|
152
|
-
"
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"es": "link",
|
|
156
|
-
"bind": "page.name",
|
|
157
|
-
"href": "$page.route"
|
|
158
|
-
}
|
|
159
|
-
]
|
|
232
|
+
"t": "Link",
|
|
233
|
+
"es": "link",
|
|
234
|
+
"each": "$navPages",
|
|
235
|
+
"bind": "page.name",
|
|
236
|
+
"href": "$page.route"
|
|
160
237
|
}
|
|
161
238
|
]
|
|
162
239
|
},
|
|
@@ -202,7 +279,7 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
202
279
|
"t": "Text",
|
|
203
280
|
"es": "small",
|
|
204
281
|
"slot": "copyright",
|
|
205
|
-
"
|
|
282
|
+
"text": "© {year} {business.name}"
|
|
206
283
|
}
|
|
207
284
|
],
|
|
208
285
|
"lay": {
|
|
@@ -680,6 +757,7 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
680
757
|
"t": "Image",
|
|
681
758
|
"es": "media",
|
|
682
759
|
"bind": "item",
|
|
760
|
+
"alt": "",
|
|
683
761
|
"aspect": "4 / 3"
|
|
684
762
|
}
|
|
685
763
|
]
|
|
@@ -854,32 +932,7 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
854
932
|
"c": [
|
|
855
933
|
{
|
|
856
934
|
"t": "Form",
|
|
857
|
-
"preset": "contact"
|
|
858
|
-
"c": [
|
|
859
|
-
{
|
|
860
|
-
"t": "Field",
|
|
861
|
-
"control": "text",
|
|
862
|
-
"label": "Name",
|
|
863
|
-
"required": true
|
|
864
|
-
},
|
|
865
|
-
{
|
|
866
|
-
"t": "Field",
|
|
867
|
-
"control": "email",
|
|
868
|
-
"label": "Email",
|
|
869
|
-
"required": true
|
|
870
|
-
},
|
|
871
|
-
{
|
|
872
|
-
"t": "Field",
|
|
873
|
-
"control": "textarea",
|
|
874
|
-
"label": "Message",
|
|
875
|
-
"required": true
|
|
876
|
-
},
|
|
877
|
-
{
|
|
878
|
-
"t": "SubmitButton",
|
|
879
|
-
"es": "button-primary",
|
|
880
|
-
"text": "Send"
|
|
881
|
-
}
|
|
882
|
-
]
|
|
935
|
+
"preset": "contact"
|
|
883
936
|
}
|
|
884
937
|
]
|
|
885
938
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { flavorPackById, parseHexColor, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
BindScope, patternById, patternNeedsBinding, patternOptionalParts, patternScopes, patternSlots, PatternGlobalPosition,
|
|
4
|
+
altSourceScopes, BindScope, patternById, patternNeedsBinding, patternOptionalParts, patternScopes, patternSlots, PatternGlobalPosition,
|
|
5
5
|
} from './patterns';
|
|
6
6
|
import {
|
|
7
7
|
FACT_KEYS, GlobalSlot, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlanSourceKind, SITE_PLAN_VERSION,
|
|
8
8
|
} from './site-plan.model';
|
|
9
|
+
import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The Site Plan validator — the gate in front of the composer. The wizard's plan and (later) the AI's
|
|
@@ -39,6 +40,9 @@ export enum SitePlanIssueCode {
|
|
|
39
40
|
UnknownOptionalPart = 'unknown-optional-part',
|
|
40
41
|
Binding = 'binding',
|
|
41
42
|
MissingScope = 'missing-scope',
|
|
43
|
+
/** A pattern binds an item's image, and the item's content type names no `previewTextField` to be
|
|
44
|
+
* its alt text. */
|
|
45
|
+
MissingPreviewField = 'missing-preview-field',
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export interface SitePlanIssue {
|
|
@@ -59,24 +63,40 @@ export class SitePlanError extends Error {
|
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/** The alias of the static source the composer writes for the navigation (`$navPages`). A plan source
|
|
63
|
-
* may not take it. */
|
|
64
|
-
export const NAV_PAGES_ALIAS = 'navPages';
|
|
65
66
|
/** Route-scope aliases (`$route`, `$current`, …) are platform-owned. */
|
|
66
67
|
const RESERVED_PREFIX = '$';
|
|
67
68
|
const ID_FIELD = '_id';
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
70
|
+
/** The documented plan shape (contract `site.planShape`): key → type; a key ending in `?` is optional and
|
|
71
|
+
* a `[name]` key stands for the object's own field names. The key lists below are READ from it, so a
|
|
72
|
+
* key the plan documents is a key the gate validates — and an undocumented one is refused. */
|
|
73
|
+
type ShapeEntry = Readonly<Record<string, string>>;
|
|
74
|
+
const OPTIONAL_MARK = '?';
|
|
75
|
+
const DYNAMIC_KEY = /^\[[^\]]+\]\??$/;
|
|
76
|
+
const isDynamic = (key: string) => DYNAMIC_KEY.test(key);
|
|
77
|
+
/** Every fixed key an object of the shape may carry. */
|
|
78
|
+
export function shapeKeys(entry: ShapeEntry): string[] {
|
|
79
|
+
return Object.keys(entry).filter(k => !isDynamic(k)).map(k => (k.endsWith(OPTIONAL_MARK) ? k.slice(0, -1) : k));
|
|
80
|
+
}
|
|
81
|
+
/** The keys an object of the shape must carry. */
|
|
82
|
+
export function requiredShapeKeys(entry: ShapeEntry): string[] {
|
|
83
|
+
return Object.keys(entry).filter(k => !isDynamic(k) && !k.endsWith(OPTIONAL_MARK));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SHAPE: Readonly<Record<string, ShapeEntry>> = SPEC_SITE_PLAN_SHAPE;
|
|
87
|
+
const PLAN_KEYS = shapeKeys(SHAPE['plan']);
|
|
88
|
+
const TEMPLATE_SOURCE_KEYS = shapeKeys(SHAPE['sourceTemplate']);
|
|
89
|
+
const AI_SOURCE_KEYS = shapeKeys(SHAPE['sourceAi']);
|
|
90
|
+
const BUSINESS_KEYS = shapeKeys(SHAPE['business']);
|
|
91
|
+
const DESIGN_REQUIRED = requiredShapeKeys(SHAPE['design']);
|
|
92
|
+
const DESIGN_KEYS = shapeKeys(SHAPE['design']);
|
|
93
|
+
const CONTENT_TYPE_KEYS = shapeKeys(SHAPE['contentType']);
|
|
94
|
+
const FIELD_KEYS = shapeKeys(SHAPE['field']);
|
|
95
|
+
const SEED_FIXED_KEYS = shapeKeys(SHAPE['seed']);
|
|
96
|
+
const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
|
|
97
|
+
const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE['relativeDataSource']);
|
|
98
|
+
const PAGE_KEYS = shapeKeys(SHAPE['page']);
|
|
99
|
+
const SECTION_KEYS = shapeKeys(SHAPE['section']);
|
|
80
100
|
const GLOBAL_POSITION: Readonly<Record<`${GlobalSlot}`, `${PatternGlobalPosition}`>> = {
|
|
81
101
|
[GlobalSlot.Header]: PatternGlobalPosition.Before,
|
|
82
102
|
[GlobalSlot.Footer]: PatternGlobalPosition.After,
|
|
@@ -137,11 +157,11 @@ class Collector {
|
|
|
137
157
|
function validateSource(c: Collector, source: unknown): void {
|
|
138
158
|
if (!isObject(source)) return c.add(SitePlanIssueCode.Shape, 'source', 'must be an object');
|
|
139
159
|
if (source['kind'] === SitePlanSourceKind.Template) {
|
|
140
|
-
c.keys(source,
|
|
160
|
+
c.keys(source, TEMPLATE_SOURCE_KEYS, 'source');
|
|
141
161
|
c.string(source, 'templateId', 'source');
|
|
142
162
|
if (!Number.isInteger(source['templateVersion'])) c.add(SitePlanIssueCode.Shape, 'source.templateVersion', 'must be an integer');
|
|
143
163
|
} else if (source['kind'] === SitePlanSourceKind.Ai) {
|
|
144
|
-
c.keys(source,
|
|
164
|
+
c.keys(source, AI_SOURCE_KEYS, 'source');
|
|
145
165
|
c.string(source, 'runId', 'source');
|
|
146
166
|
} else {
|
|
147
167
|
c.add(SitePlanIssueCode.Shape, 'source.kind', `must be "${SitePlanSourceKind.Template}" or "${SitePlanSourceKind.Ai}"`);
|
|
@@ -185,7 +205,7 @@ function validateDesign(c: Collector, design: unknown): void {
|
|
|
185
205
|
if (design['fonts'] !== undefined && !isObject(design['fonts'])) bad('fonts', 'must be an object');
|
|
186
206
|
}
|
|
187
207
|
|
|
188
|
-
interface ContentTypeInfo { fields: Set<string>; }
|
|
208
|
+
interface ContentTypeInfo { fields: Set<string>; previewTextField?: string; }
|
|
189
209
|
|
|
190
210
|
function validateContentTypes(c: Collector, list: unknown[]): Map<string, ContentTypeInfo> {
|
|
191
211
|
const types = new Map<string, ContentTypeInfo>();
|
|
@@ -207,9 +227,11 @@ function validateContentTypes(c: Collector, list: unknown[]): Map<string, Conten
|
|
|
207
227
|
});
|
|
208
228
|
c.unique(names, j => `${path}.fields[${j}].name`, 'field');
|
|
209
229
|
const fieldSet = new Set(names.filter((n): n is string => !!n));
|
|
230
|
+
const preview: Record<string, string | null> = {};
|
|
210
231
|
for (const key of ['previewTextField', 'previewImageField']) {
|
|
211
232
|
const v = c.string(raw, key, path, false);
|
|
212
233
|
if (v && !fieldSet.has(v)) c.add(SitePlanIssueCode.UnknownField, `${path}.${key}`, `"${v}" is not a field of ${name}`);
|
|
234
|
+
preview[key] = v && fieldSet.has(v) ? v : null;
|
|
213
235
|
}
|
|
214
236
|
const seed = raw['seed'];
|
|
215
237
|
if (seed !== undefined) {
|
|
@@ -223,7 +245,7 @@ function validateContentTypes(c: Collector, list: unknown[]): Map<string, Conten
|
|
|
223
245
|
}
|
|
224
246
|
}
|
|
225
247
|
if (name && types.has(name)) c.add(SitePlanIssueCode.Duplicate, `${path}.name`, `content type "${name}" is used twice`);
|
|
226
|
-
else if (name) types.set(name, { fields: fieldSet });
|
|
248
|
+
else if (name) types.set(name, { fields: fieldSet, previewTextField: preview['previewTextField'] ?? undefined });
|
|
227
249
|
});
|
|
228
250
|
return types;
|
|
229
251
|
}
|
|
@@ -236,7 +258,7 @@ function validateDataSources(c: Collector, list: unknown[], types: Map<string, C
|
|
|
236
258
|
const path = `dataSources[${i}]`;
|
|
237
259
|
if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
|
|
238
260
|
const alias = c.string(raw, 'alias', path);
|
|
239
|
-
if (alias &&
|
|
261
|
+
if (alias && alias.startsWith(RESERVED_PREFIX)) {
|
|
240
262
|
c.add(SitePlanIssueCode.ReservedAlias, `${path}.alias`, `"${alias}" is reserved for the platform`);
|
|
241
263
|
}
|
|
242
264
|
if (raw['type'] === PlanDataSourceType.Cms) {
|
|
@@ -363,6 +385,16 @@ function validatePages(c: Collector, list: unknown[], types: Map<string, Content
|
|
|
363
385
|
if (scopes.has(BindScope.Next) && routeSourceOk && !nextSourceOf(sources, routeSourceAlias as string)) {
|
|
364
386
|
c.add(SitePlanIssueCode.MissingScope, `${sp}.pattern`, `"${patternId}" reads the next item — the plan needs a ${RelativeOp.SiblingsNext} source of ${routeSourceAlias}`);
|
|
365
387
|
}
|
|
388
|
+
|
|
389
|
+
// A bound image's alt text is its item's previewTextField (W1.1): the item's type must name one.
|
|
390
|
+
for (const scope of altSourceScopes(pattern)) {
|
|
391
|
+
const alias = scope === BindScope.Item ? binding : routeSourceOk ? routeSourceAlias : null;
|
|
392
|
+
const typeName = alias ? sources.get(alias)?.contentType : undefined;
|
|
393
|
+
const type = typeName ? types.get(typeName) : undefined;
|
|
394
|
+
if (type && !type.previewTextField) {
|
|
395
|
+
c.add(SitePlanIssueCode.MissingPreviewField, `${sp}.pattern`, `"${patternId}" shows ${typeName} images — ${typeName} needs a previewTextField for their alt text`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
366
398
|
});
|
|
367
399
|
});
|
|
368
400
|
c.unique(list.map(p => (isObject(p) && isNonEmptyString(p['key']) ? p['key'] : null)), i => `pages[${i}].key`, 'page key');
|