@vectoriox/iox-ui 5.27.0 → 5.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/design-core/index.d.ts +186 -7
  2. package/fesm2022/vectoriox-iox-ui-design-core.mjs +201 -11
  3. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  4. package/fesm2022/vectoriox-iox-ui-forms-core.mjs +119 -5
  5. package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +591 -181
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui.mjs +189 -16
  9. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  10. package/forms-core/index.d.ts +59 -3
  11. package/generation-core/index.d.ts +206 -74
  12. package/index.d.ts +30 -3
  13. package/package.json +1 -1
  14. package/src/design-core/contrast-audit.ts +7 -6
  15. package/src/design-core/flavor-spec.generated.ts +170 -5
  16. package/src/design-core/node-links.ts +32 -0
  17. package/src/design-core/public-api.ts +1 -0
  18. package/src/forms-core/collect.ts +3 -1
  19. package/src/forms-core/form-presets.ts +105 -0
  20. package/src/forms-core/options.ts +29 -0
  21. package/src/forms-core/public-api.ts +2 -0
  22. package/src/forms-core/transport.ts +6 -2
  23. package/src/generation-core/baseline-roles.ts +75 -0
  24. package/src/generation-core/compose-site.ts +256 -95
  25. package/src/generation-core/node-baselines.generated.ts +90 -0
  26. package/src/generation-core/patterns.ts +31 -9
  27. package/src/generation-core/public-api.ts +1 -0
  28. package/src/generation-core/serialized-node.ts +13 -2
  29. package/src/generation-core/site-plan.model.ts +4 -3
  30. package/src/generation-core/site-spec.generated.ts +110 -57
  31. package/src/generation-core/validate-site-plan.ts +52 -20
  32. package/styles/iox-forms.css +136 -8
@@ -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 · `ph` a publish-blocking
11
- * placeholder (a FACT key, `copy` or `image`) · `bind` a binding path on the repeater item (`item.*`,
12
- * `page.*`), the route item (`route.*`) or its next sibling (`next.*`) · `src` what a Repeater iterates
13
- * (`$binding`, `$navPages`, `route.<array>`) · `href` a page route (`$page.route` = the nav item's) ·
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 Form preset · `control` / `label` / `required` a Field's · `c` children.
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
- /** A Repeater's source: the plan's pages that are in the navigation. */
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 nav item's route. */
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 nearest Repeater's row, when that Repeater iterates the navigation's pages. */
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(id: string, type: string, props: Record<string, unknown> = {}, elementStyle?: string | null): SerializedNode {
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`) — the ONE document the website wizard writes
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
- /** Carried by templates (the wizard's Pages step); not part of the documented plan shape. */
112
+ /** A template page the wizard's Pages step cannot leave out (`planFromTemplate`). */
112
113
  readonly required?: boolean;
113
- /** In the navigation (`$navPages`). Carried by templates; not part of the documented plan shape. */
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
- "version": "1",
7
- "source": "{ kind: 'template', templateId, templateVersion } | { kind: 'ai', runId }",
8
- "business": "{ name (required), tagline?, facts: { email?, phone?, city? } }",
9
- "design": "{ flavorId, seed, pairId, shape, density, motion, pillButtons, mode } — the DesignChoices of the design system",
10
- "contentTypes": "ContentTypePlan[] — { name, label, fields[], seed }",
11
- "dataSources": "DataSourcePlan[] — merged by alias into the org library",
12
- "pages": "PagePlan[] — { key, name, route, isHome?, template?, routeSource?, sections: { pattern, slots?, binding?, without? }[] }",
13
- "globals": "{ header: patternId, footer: patternId }"
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": "28px",
59
- "alignItems": "center"
144
+ "gap": "var(--iox-space-gap)",
145
+ "alignItems": "center",
146
+ "width": "auto"
60
147
  },
61
148
  "c": [
62
149
  {
63
- "t": "Repeater",
64
- "src": "$navPages",
65
- "c": [
66
- {
67
- "t": "Link",
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": "Repeater",
151
- "src": "$navPages",
152
- "c": [
153
- {
154
- "t": "Link",
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
- "fromBusiness": "copyright"
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
- const PLAN_KEYS = ['version', 'source', 'business', 'design', 'contentTypes', 'dataSources', 'pages', 'globals'];
70
- const BUSINESS_KEYS = ['name', 'tagline', 'facts'];
71
- const DESIGN_REQUIRED = ['flavorId', 'seed', 'pairId', 'shape', 'density', 'motion', 'pillButtons', 'mode'];
72
- const DESIGN_KEYS = [...DESIGN_REQUIRED, 'tint', 'fonts'];
73
- const CONTENT_TYPE_KEYS = ['name', 'label', 'previewTextField', 'previewImageField', 'fields', 'seed'];
74
- const FIELD_KEYS = ['name', 'type', 'controller', 'required', 'note'];
75
- const SEED_FIXED_KEYS = ['count', 'sample', 'isPublished'];
76
- const CMS_SOURCE_KEYS = ['alias', 'type', 'mode', 'contentType', 'limit', 'filterByRouteParam'];
77
- const RELATIVE_SOURCE_KEYS = ['alias', 'type', 'of', 'op', 'within'];
78
- const PAGE_KEYS = ['key', 'name', 'route', 'isHome', 'required', 'inNav', 'template', 'routeSource', 'sections'];
79
- const SECTION_KEYS = ['pattern', 'slots', 'binding', 'without'];
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, ['kind', 'templateId', 'templateVersion'], '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, ['kind', 'runId'], '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 && (alias.startsWith(RESERVED_PREFIX) || alias === NAV_PAGES_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');