@vectoriox/iox-ui 5.28.0 → 5.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,104 @@
1
- import { NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, elementStyleById, ownedTraitsOf, applyElementStyle, flavorPackById, parseHexColor, SPEC_AXES, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
1
+ import { NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, elementStyleById, ownedTraitsOf, applyElementStyle, flavorPackById, parseHexColor, SPEC_AXES, LinkType, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
2
+ import { formPresetById } from '@vectoriox/iox-ui/forms-core';
2
3
 
3
4
  // GENERATED from iox-ai-guidance flavor-spec.v1.json — do not edit; re-run scripts/gen-flavor-spec.mjs
4
5
  // (npm run gen:flavor-spec). The JSON is itself generated by iox-ai-guidance
5
6
  // architecture/builder/design-system/build-spec.mjs: change a value THERE, then regenerate both.
6
7
  /* eslint-disable */
7
8
  const SPEC_SITE_PLAN_SHAPE = {
8
- "version": "1",
9
- "source": "{ kind: 'template', templateId, templateVersion } | { kind: 'ai', runId }",
10
- "business": "{ name (required), tagline?, facts: { email?, phone?, city? } }",
11
- "design": "{ flavorId, seed, pairId, shape, density, motion, pillButtons, mode } — the DesignChoices of the design system",
12
- "contentTypes": "ContentTypePlan[] — { name, label, fields[], seed }",
13
- "dataSources": "DataSourcePlan[] — merged by alias into the org library",
14
- "pages": "PagePlan[] — { key, name, route, isHome?, template?, routeSource?, sections: { pattern, slots?, binding?, without? }[] }",
15
- "globals": "{ header: patternId, footer: patternId }"
9
+ "plan": {
10
+ "version": "'1'",
11
+ "source": "sourceTemplate | sourceAi",
12
+ "business": "business",
13
+ "design": "design",
14
+ "contentTypes": "contentType[]",
15
+ "dataSources": "(cmsDataSource | relativeDataSource)[] — merged by alias into the org library",
16
+ "pages": "page[]",
17
+ "globals": "globals"
18
+ },
19
+ "sourceTemplate": {
20
+ "kind": "'template'",
21
+ "templateId": "string",
22
+ "templateVersion": "integer"
23
+ },
24
+ "sourceAi": {
25
+ "kind": "'ai'",
26
+ "runId": "string"
27
+ },
28
+ "business": {
29
+ "name": "string — the brand: the header, the hero, the copyright line",
30
+ "tagline?": "string — the hero lead and the footer line",
31
+ "facts?": "{ [fact]: string } — facts (email, phone, city): only the customer's own words; a blank fact is a publish-blocking placeholder, never invented"
32
+ },
33
+ "design": {
34
+ "flavorId": "string — a v2 flavor pack id",
35
+ "seed": "string — #rrggbb brand colour",
36
+ "pairId": "string — one of the pack's font pairings",
37
+ "shape": "string — axes.shapes",
38
+ "density": "string — axes.densities",
39
+ "motion": "string — axes.motions",
40
+ "pillButtons": "boolean",
41
+ "mode": "'light' | 'dark' | 'auto'",
42
+ "tint?": "string — axes.tints (DS9 neutral tint)",
43
+ "fonts?": "object — uploaded font families per role (DS5)"
44
+ },
45
+ "contentType": {
46
+ "name": "string — unique",
47
+ "label": "string",
48
+ "previewTextField?": "string — a field of the type: the item's name on cards and the alt text of its bound images",
49
+ "previewImageField?": "string — a field of the type: the item's picture",
50
+ "fields": "field[]",
51
+ "seed?": "seed"
52
+ },
53
+ "field": {
54
+ "name": "string — unique in the type",
55
+ "type": "string",
56
+ "controller": "string — an IOX input controller",
57
+ "required?": "boolean",
58
+ "note?": "string"
59
+ },
60
+ "seed": {
61
+ "count": "integer ≥ 0",
62
+ "sample": "boolean — items are flagged isSample",
63
+ "isPublished": "boolean",
64
+ "[field]?": "the field's value — strings are templates: {n} = the item's 1-based number, {sampleImage:n} / {sampleImage:n+k} = a bundled sample image"
65
+ },
66
+ "cmsDataSource": {
67
+ "alias": "string — unique; never $… (platform scopes)",
68
+ "type": "'cms'",
69
+ "mode": "'query' | 'single'",
70
+ "contentType": "string — a content type of the plan",
71
+ "limit?": "integer ≥ 1",
72
+ "filterByRouteParam?": "{ field, routeParam } — single only: the route param that picks the item"
73
+ },
74
+ "relativeDataSource": {
75
+ "alias": "string — unique",
76
+ "type": "'relative'",
77
+ "of": "string — a single cms source (the subject)",
78
+ "op": "'$siblings.next' | '$siblings.prev'",
79
+ "within": "string — a query cms source of the same type"
80
+ },
81
+ "page": {
82
+ "key": "string — unique",
83
+ "name": "string — the nav label",
84
+ "route": "string — /lowercase-words, :params; unique",
85
+ "isHome?": "boolean — exactly one page, at /",
86
+ "required?": "boolean — a template page the wizard's Pages step cannot leave out",
87
+ "inNav?": "boolean — a Link in the header and footer navigation, in plan order",
88
+ "template?": "string — the content type a template page renders one item of",
89
+ "routeSource?": "string — the single source read by the route param (route.* binds it)",
90
+ "sections": "section[]"
91
+ },
92
+ "section": {
93
+ "pattern": "string — a non-global pattern id",
94
+ "slots?": "{ [slot]: string } — the pattern's declared slots",
95
+ "binding?": "string — the list source a pattern's $binding repeater iterates (exactly when it has one)",
96
+ "without?": "string[] — the pattern's optional parts to leave out"
97
+ },
98
+ "globals": {
99
+ "header": "string — a header pattern (global: before)",
100
+ "footer": "string — a footer pattern (global: after)"
101
+ }
16
102
  };
17
103
  const SPEC_SITE_FACTS = {
18
104
  "email": "Email address",
@@ -30,6 +116,7 @@ const SPEC_SITE_PATTERNS = {
30
116
  "tag": "nav",
31
117
  "lay": {
32
118
  "display": "flex",
119
+ "flexWrap": "wrap",
33
120
  "justifyContent": "space-between",
34
121
  "alignItems": "center",
35
122
  "gap": "var(--iox-space-gap)"
@@ -55,21 +142,17 @@ const SPEC_SITE_PATTERNS = {
55
142
  "lay": {
56
143
  "display": "flex",
57
144
  "flexWrap": "wrap",
58
- "gap": "28px",
59
- "alignItems": "center"
145
+ "gap": "var(--iox-space-gap)",
146
+ "alignItems": "center",
147
+ "width": "auto"
60
148
  },
61
149
  "c": [
62
150
  {
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
- ]
151
+ "t": "Link",
152
+ "es": "link",
153
+ "each": "$navPages",
154
+ "bind": "page.name",
155
+ "href": "$page.route"
73
156
  },
74
157
  {
75
158
  "t": "ButtonBlock",
@@ -147,16 +230,11 @@ const SPEC_SITE_PATTERNS = {
147
230
  "text": "Pages"
148
231
  },
149
232
  {
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
- ]
233
+ "t": "Link",
234
+ "es": "link",
235
+ "each": "$navPages",
236
+ "bind": "page.name",
237
+ "href": "$page.route"
160
238
  }
161
239
  ]
162
240
  },
@@ -202,7 +280,7 @@ const SPEC_SITE_PATTERNS = {
202
280
  "t": "Text",
203
281
  "es": "small",
204
282
  "slot": "copyright",
205
- "fromBusiness": "copyright"
283
+ "text": "© {year} {business.name}"
206
284
  }
207
285
  ],
208
286
  "lay": {
@@ -680,6 +758,7 @@ const SPEC_SITE_PATTERNS = {
680
758
  "t": "Image",
681
759
  "es": "media",
682
760
  "bind": "item",
761
+ "alt": "",
683
762
  "aspect": "4 / 3"
684
763
  }
685
764
  ]
@@ -854,32 +933,7 @@ const SPEC_SITE_PATTERNS = {
854
933
  "c": [
855
934
  {
856
935
  "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
- ]
936
+ "preset": "contact"
883
937
  }
884
938
  ]
885
939
  }
@@ -1301,7 +1355,9 @@ const NODE_BASELINES = {
1301
1355
  "ButtonBlock": {
1302
1356
  "props": {
1303
1357
  "tag": "button",
1358
+ "linkType": "external",
1304
1359
  "href": "",
1360
+ "pageRoute": "",
1305
1361
  "target": "_self",
1306
1362
  "type": "button"
1307
1363
  },
@@ -1391,7 +1447,9 @@ const NODE_BASELINES = {
1391
1447
  },
1392
1448
  "traits": [
1393
1449
  "tag",
1450
+ "linkType",
1394
1451
  "href",
1452
+ "pageRoute",
1395
1453
  "target",
1396
1454
  "type"
1397
1455
  ],
@@ -5944,9 +6002,168 @@ const NODE_BASELINES = {
5944
6002
  "container": true
5945
6003
  }
5946
6004
  };
6005
+ /** What every style trait saves as when no constructor sets it (buildFullStyleTraits' defaults). */
6006
+ const NEUTRAL_STYLE_PROPS = {
6007
+ "position": "static",
6008
+ "top": "auto",
6009
+ "right": "auto",
6010
+ "bottom": "auto",
6011
+ "left": "auto",
6012
+ "zIndex": "0",
6013
+ "display": "block",
6014
+ "flexDirection": "row",
6015
+ "flexWrap": "nowrap",
6016
+ "justifyContent": "flex-start",
6017
+ "alignItems": "stretch",
6018
+ "gap": "0px",
6019
+ "gridTemplateColumns": "1fr",
6020
+ "gridTemplateRows": "auto",
6021
+ "justifyItems": "stretch",
6022
+ "alignContent": "stretch",
6023
+ "direction": "ltr",
6024
+ "alignSelf": "auto",
6025
+ "order": "0",
6026
+ "flexGrow": "0",
6027
+ "flexShrink": "1",
6028
+ "flexBasis": "auto",
6029
+ "justifySelf": "auto",
6030
+ "gridColumn": "auto",
6031
+ "gridRow": "auto",
6032
+ "width": "auto",
6033
+ "height": "auto",
6034
+ "minWidth": "auto",
6035
+ "minHeight": "auto",
6036
+ "maxWidth": "auto",
6037
+ "maxHeight": "auto",
6038
+ "aspectRatio": "auto",
6039
+ "objectFit": "cover",
6040
+ "overflow": "visible",
6041
+ "fontFamily": "Poppins, sans-serif",
6042
+ "fontSize": "16px",
6043
+ "fontWeight": "400",
6044
+ "color": "#000000",
6045
+ "lineHeight": "1.5",
6046
+ "letterSpacing": "0px",
6047
+ "textTransform": "none",
6048
+ "textAlign": "left",
6049
+ "fontStyle": "normal",
6050
+ "textDecoration": "none",
6051
+ "WebkitTextStrokeWidth": "0px",
6052
+ "WebkitTextStrokeColor": "transparent",
6053
+ "margin": "0px 0px 0px 0px",
6054
+ "padding": "0px 0px 0px 0px",
6055
+ "borderWidth": "0px",
6056
+ "borderStyle": "solid",
6057
+ "borderColor": "#CCCCCC",
6058
+ "borderRadius": "0px",
6059
+ "backgroundColor": "transparent",
6060
+ "opacity": "1",
6061
+ "mixBlendMode": "normal",
6062
+ "cursor": "auto",
6063
+ "boxShadow": "",
6064
+ "textShadow": "",
6065
+ "translateX": "0px",
6066
+ "translateY": "0px",
6067
+ "scaleX": "1",
6068
+ "scaleY": "1",
6069
+ "rotate": "0deg",
6070
+ "skewX": "0deg",
6071
+ "skewY": "0deg",
6072
+ "filterBlur": "0px",
6073
+ "filterHueRotate": "0deg",
6074
+ "filterBrightness": "1",
6075
+ "filterContrast": "1",
6076
+ "filterSaturate": "1",
6077
+ "filterGrayscale": "0",
6078
+ "filterSepia": "0",
6079
+ "filterInvert": "0",
6080
+ "backdropFilter": "none",
6081
+ "backdropBlur": "0px",
6082
+ "backdropBrightness": "1",
6083
+ "backdropContrast": "1",
6084
+ "backdropSaturate": "1",
6085
+ "clipPath": "none",
6086
+ "transitionDuration": "0ms",
6087
+ "transitionDelay": "0ms",
6088
+ "transitionTimingFunction": "ease"
6089
+ };
6090
+
6091
+ /**
6092
+ * Why each type's constructor sets the style values it sets (W1.1, Q1). Every value a type's config
6093
+ * gives that differs from what the trait saves when nobody sets it (`NEUTRAL_STYLE_PROPS`) is exactly
6094
+ * one of:
6095
+ *
6096
+ * - **Layout** — a size or spacing that exists only so an EMPTY node dropped from the palette is visible
6097
+ * and grabbable (a Container's 200px, a Text's 10px padding). A composed node is never empty and its
6098
+ * pattern owns its box, so the composer RELEASES these to the neutral value unless the pattern
6099
+ * declares one: the nav bar was 200px tall and every lead line indented 10px (W1's screenshot).
6100
+ * - **Behaviour** — what makes the type work: a button's inline-flex centring, a form's column, the 44px
6101
+ * touch target, a slide's width, a spacer's height, a list's marker room. Kept.
6102
+ * - **Look** — the type's pre-design-system paint, which its element style replaces. Kept (the style
6103
+ * writes over it; a type with no style keeps its pre-DS2 look, D5).
6104
+ *
6105
+ * Saved pages are untouched: this is the composer's rule, never the builder's (a palette drop still
6106
+ * gets its 200px). `baseline-roles.spec.ts` fails the moment a config gains a default this table does
6107
+ * not classify — so a new default is a decision, not an accident.
6108
+ */
6109
+ var BaselineRole;
6110
+ (function (BaselineRole) {
6111
+ BaselineRole["Layout"] = "layout";
6112
+ BaselineRole["Behaviour"] = "behaviour";
6113
+ BaselineRole["Look"] = "look";
6114
+ })(BaselineRole || (BaselineRole = {}));
6115
+ const { Layout, Behaviour, Look } = BaselineRole;
6116
+ const BASELINE_ROLES = {
6117
+ Button: {},
6118
+ ButtonBlock: {
6119
+ display: Behaviour, justifyContent: Behaviour, alignItems: Behaviour, gap: Behaviour, cursor: Behaviour,
6120
+ padding: Look, borderRadius: Look,
6121
+ },
6122
+ Card: { width: Behaviour, maxWidth: Layout, padding: Look, borderWidth: Look, borderColor: Look, borderRadius: Look, backgroundColor: Look },
6123
+ // `width: 100%` (DEFAULT_WIDTH_BY_TYPE) is what lets a block fill its row — a flex child collapses without it.
6124
+ Container: { width: Behaviour, minHeight: Layout },
6125
+ Heading: { fontSize: Look, fontWeight: Look },
6126
+ Text: { width: Behaviour, minHeight: Layout, padding: Layout },
6127
+ Image: { width: Behaviour, maxWidth: Layout, aspectRatio: Layout },
6128
+ Link: {},
6129
+ LinkedContainer: { minHeight: Layout },
6130
+ Section: { minHeight: Layout },
6131
+ Icon: { lineHeight: Behaviour, fontSize: Behaviour, color: Look },
6132
+ Divider: { width: Behaviour, height: Look, margin: Layout, backgroundColor: Look },
6133
+ Spacer: { width: Behaviour, height: Behaviour },
6134
+ List: { width: Behaviour, padding: Behaviour },
6135
+ ListItem: { width: Behaviour },
6136
+ Repeater: { width: Behaviour },
6137
+ SliderContainer: { width: Behaviour },
6138
+ SliderSlide: { width: Behaviour, aspectRatio: Behaviour },
6139
+ // The crossfade stacks its frames absolutely: without a box it has no height at all.
6140
+ Gallery: { minHeight: Behaviour, backgroundColor: Look },
6141
+ Form: { display: Behaviour, flexDirection: Behaviour, gap: Behaviour, width: Behaviour },
6142
+ Field: { width: Behaviour },
6143
+ FieldGroup: { display: Behaviour, gap: Behaviour, width: Behaviour },
6144
+ SubmitButton: {
6145
+ display: Behaviour, justifyContent: Behaviour, alignItems: Behaviour, minHeight: Behaviour, cursor: Behaviour,
6146
+ color: Look, padding: Look, borderRadius: Look, backgroundColor: Look,
6147
+ },
6148
+ FormSuccess: { width: Behaviour, color: Look, padding: Look, borderRadius: Look, backgroundColor: Look },
6149
+ FormError: { width: Behaviour, color: Look, padding: Look, borderRadius: Look, backgroundColor: Look },
6150
+ Dialog: { width: Behaviour, padding: Look, borderRadius: Look, backgroundColor: Look, boxShadow: Look },
6151
+ };
6152
+ /** The style values a type's constructor sets for an empty palette drop, each mapped to the value the
6153
+ * trait saves when nobody sets it — what a composed node of that type starts from. */
6154
+ function releasedLayoutDefaults(type) {
6155
+ const roles = BASELINE_ROLES[type] ?? {};
6156
+ const out = {};
6157
+ for (const [trait, role] of Object.entries(roles)) {
6158
+ if (role === BaselineRole.Layout && trait in (NODE_BASELINES[type]?.styleProps ?? {}))
6159
+ out[trait] = NEUTRAL_STYLE_PROPS[trait];
6160
+ }
6161
+ return out;
6162
+ }
5947
6163
 
5948
6164
  /**
5949
- * The Site Plan (contract `site.planShape`) — the ONE document the website wizard writes
6165
+ * The Site Plan (contract `site.planShape`, `SPEC_SITE_PLAN_SHAPE`: every key typed and explained, and the
6166
+ * validator reads its key lists from it) — the ONE document the website wizard writes
5950
6167
  * (`source.kind: template`) and the AI agent will write (`source.kind: ai`). One validator, one
5951
6168
  * composer: everything the wizard proves is proven for the agent.
5952
6169
  */
@@ -5998,9 +6215,10 @@ var PatternDirective;
5998
6215
  (function (PatternDirective) {
5999
6216
  /** A Repeater's source: the section's `binding`. */
6000
6217
  PatternDirective["Binding"] = "$binding";
6001
- /** A Repeater's source: the plan's pages that are in the navigation. */
6218
+ /** `each`: one plain node per page of the plan that is in the navigation (`inNav`), in plan order —
6219
+ * words and routes written in, editable on the canvas (W1.1). */
6002
6220
  PatternDirective["NavPages"] = "$navPages";
6003
- /** A Link's route: the nav item's route. */
6221
+ /** A Link's route: the route of the page an `each` node is stamped for. */
6004
6222
  PatternDirective["PageRoute"] = "$page.route";
6005
6223
  })(PatternDirective || (PatternDirective = {}));
6006
6224
  /** The scopes a `bind` / `param` / `src` path starts with. */
@@ -6008,7 +6226,7 @@ var BindScope;
6008
6226
  (function (BindScope) {
6009
6227
  /** The nearest Repeater's row. */
6010
6228
  BindScope["Item"] = "item";
6011
- /** The nearest Repeater's row, when that Repeater iterates the navigation's pages. */
6229
+ /** The page an `each: $navPages` node is stamped for — resolved to plain words, never a binding. */
6012
6230
  BindScope["Page"] = "page";
6013
6231
  /** The page's route item (`routeSource`). */
6014
6232
  BindScope["Route"] = "route";
@@ -6041,11 +6259,26 @@ function patternOptionalParts(pattern) {
6041
6259
  function patternNeedsBinding(pattern) {
6042
6260
  return patternNodes(pattern).some(n => n.src === PatternDirective.Binding);
6043
6261
  }
6262
+ const IMAGE_TYPE = 'Image';
6263
+ /** The scopes whose ITEM a pattern's bound Images take their alt text from (an Image that names no `alt`
6264
+ * of its own): `item` (the row of a `$binding` Repeater — the section's list), `route` or `next`. Each
6265
+ * needs a content type with a `previewTextField` (W1.1, Q7). */
6266
+ function altSourceScopes(pattern) {
6267
+ const scopes = new Set();
6268
+ const visit = (n) => {
6269
+ if (n.t === IMAGE_TYPE && n.bind && n.alt === undefined)
6270
+ scopes.add(n.bind.split('.')[0]);
6271
+ for (const child of n.c ?? [])
6272
+ visit(child);
6273
+ };
6274
+ visit(pattern.node);
6275
+ return scopes;
6276
+ }
6044
6277
  /** The bind scopes a pattern reads (`route`, `next`, …) — what its page must provide. */
6045
6278
  function patternScopes(pattern) {
6046
6279
  const scopes = new Set();
6047
6280
  for (const n of patternNodes(pattern)) {
6048
- for (const path of [n.bind, n.param, n.src]) {
6281
+ for (const path of [n.bind, n.alt, n.param, n.src]) {
6049
6282
  if (path && !path.startsWith('$'))
6050
6283
  scopes.add(path.split('.')[0]);
6051
6284
  }
@@ -6097,9 +6330,10 @@ const clone$1 = (value) => JSON.parse(JSON.stringify(value));
6097
6330
  * A NEW node of `type`, as the builder creates one (ComponentRegistryService.createConfig, serialized):
6098
6331
  * the type's constructor defaults, its text colour released to `inherit` (NEW_NODE_STYLE_DEFAULTS), then
6099
6332
  * ONE element style — `elementStyle` when given, else the type's default for its props (a Heading
6100
- * follows its level). `props` are applied before the style is chosen.
6333
+ * follows its level). `props` are applied before the style is chosen. With `releaseLayoutDefaults` the
6334
+ * constructor's layout sizes are released first, so an element style that owns one still writes it.
6101
6335
  */
6102
- function createSerializedNode(id, type, props = {}, elementStyle) {
6336
+ function createSerializedNode(id, type, props = {}, elementStyle, options = {}) {
6103
6337
  const baseline = NODE_BASELINES[type];
6104
6338
  if (!baseline)
6105
6339
  throw new ComposeError(`"${type}" is not a registered component type`);
@@ -6111,6 +6345,8 @@ function createSerializedNode(id, type, props = {}, elementStyle) {
6111
6345
  bindings: [],
6112
6346
  interactions: [],
6113
6347
  };
6348
+ if (options.releaseLayoutDefaults)
6349
+ Object.assign(node.styleProps, releasedLayoutDefaults(type));
6114
6350
  for (const [name, value] of Object.entries(NEW_NODE_STYLE_DEFAULTS))
6115
6351
  if (name in node.styleProps)
6116
6352
  node.styleProps[name] = value;
@@ -6182,6 +6418,9 @@ var SitePlanIssueCode;
6182
6418
  SitePlanIssueCode["UnknownOptionalPart"] = "unknown-optional-part";
6183
6419
  SitePlanIssueCode["Binding"] = "binding";
6184
6420
  SitePlanIssueCode["MissingScope"] = "missing-scope";
6421
+ /** A pattern binds an item's image, and the item's content type names no `previewTextField` to be
6422
+ * its alt text. */
6423
+ SitePlanIssueCode["MissingPreviewField"] = "missing-preview-field";
6185
6424
  })(SitePlanIssueCode || (SitePlanIssueCode = {}));
6186
6425
  class SitePlanError extends Error {
6187
6426
  constructor(issues) {
@@ -6189,23 +6428,34 @@ class SitePlanError extends Error {
6189
6428
  this.issues = issues;
6190
6429
  }
6191
6430
  }
6192
- /** The alias of the static source the composer writes for the navigation (`$navPages`). A plan source
6193
- * may not take it. */
6194
- const NAV_PAGES_ALIAS = 'navPages';
6195
6431
  /** Route-scope aliases (`$route`, `$current`, …) are platform-owned. */
6196
6432
  const RESERVED_PREFIX = '$';
6197
6433
  const ID_FIELD = '_id';
6198
- const PLAN_KEYS = ['version', 'source', 'business', 'design', 'contentTypes', 'dataSources', 'pages', 'globals'];
6199
- const BUSINESS_KEYS = ['name', 'tagline', 'facts'];
6200
- const DESIGN_REQUIRED = ['flavorId', 'seed', 'pairId', 'shape', 'density', 'motion', 'pillButtons', 'mode'];
6201
- const DESIGN_KEYS = [...DESIGN_REQUIRED, 'tint', 'fonts'];
6202
- const CONTENT_TYPE_KEYS = ['name', 'label', 'previewTextField', 'previewImageField', 'fields', 'seed'];
6203
- const FIELD_KEYS = ['name', 'type', 'controller', 'required', 'note'];
6204
- const SEED_FIXED_KEYS = ['count', 'sample', 'isPublished'];
6205
- const CMS_SOURCE_KEYS = ['alias', 'type', 'mode', 'contentType', 'limit', 'filterByRouteParam'];
6206
- const RELATIVE_SOURCE_KEYS = ['alias', 'type', 'of', 'op', 'within'];
6207
- const PAGE_KEYS = ['key', 'name', 'route', 'isHome', 'required', 'inNav', 'template', 'routeSource', 'sections'];
6208
- const SECTION_KEYS = ['pattern', 'slots', 'binding', 'without'];
6434
+ const OPTIONAL_MARK = '?';
6435
+ const DYNAMIC_KEY = /^\[[^\]]+\]\??$/;
6436
+ const isDynamic = (key) => DYNAMIC_KEY.test(key);
6437
+ /** Every fixed key an object of the shape may carry. */
6438
+ function shapeKeys(entry) {
6439
+ return Object.keys(entry).filter(k => !isDynamic(k)).map(k => (k.endsWith(OPTIONAL_MARK) ? k.slice(0, -1) : k));
6440
+ }
6441
+ /** The keys an object of the shape must carry. */
6442
+ function requiredShapeKeys(entry) {
6443
+ return Object.keys(entry).filter(k => !isDynamic(k) && !k.endsWith(OPTIONAL_MARK));
6444
+ }
6445
+ const SHAPE = SPEC_SITE_PLAN_SHAPE;
6446
+ const PLAN_KEYS = shapeKeys(SHAPE['plan']);
6447
+ const TEMPLATE_SOURCE_KEYS = shapeKeys(SHAPE['sourceTemplate']);
6448
+ const AI_SOURCE_KEYS = shapeKeys(SHAPE['sourceAi']);
6449
+ const BUSINESS_KEYS = shapeKeys(SHAPE['business']);
6450
+ const DESIGN_REQUIRED = requiredShapeKeys(SHAPE['design']);
6451
+ const DESIGN_KEYS = shapeKeys(SHAPE['design']);
6452
+ const CONTENT_TYPE_KEYS = shapeKeys(SHAPE['contentType']);
6453
+ const FIELD_KEYS = shapeKeys(SHAPE['field']);
6454
+ const SEED_FIXED_KEYS = shapeKeys(SHAPE['seed']);
6455
+ const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
6456
+ const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE['relativeDataSource']);
6457
+ const PAGE_KEYS = shapeKeys(SHAPE['page']);
6458
+ const SECTION_KEYS = shapeKeys(SHAPE['section']);
6209
6459
  const GLOBAL_POSITION = {
6210
6460
  [GlobalSlot.Header]: PatternGlobalPosition.Before,
6211
6461
  [GlobalSlot.Footer]: PatternGlobalPosition.After,
@@ -6269,13 +6519,13 @@ function validateSource(c, source) {
6269
6519
  if (!isObject(source))
6270
6520
  return c.add(SitePlanIssueCode.Shape, 'source', 'must be an object');
6271
6521
  if (source['kind'] === SitePlanSourceKind.Template) {
6272
- c.keys(source, ['kind', 'templateId', 'templateVersion'], 'source');
6522
+ c.keys(source, TEMPLATE_SOURCE_KEYS, 'source');
6273
6523
  c.string(source, 'templateId', 'source');
6274
6524
  if (!Number.isInteger(source['templateVersion']))
6275
6525
  c.add(SitePlanIssueCode.Shape, 'source.templateVersion', 'must be an integer');
6276
6526
  }
6277
6527
  else if (source['kind'] === SitePlanSourceKind.Ai) {
6278
- c.keys(source, ['kind', 'runId'], 'source');
6528
+ c.keys(source, AI_SOURCE_KEYS, 'source');
6279
6529
  c.string(source, 'runId', 'source');
6280
6530
  }
6281
6531
  else {
@@ -6357,10 +6607,12 @@ function validateContentTypes(c, list) {
6357
6607
  });
6358
6608
  c.unique(names, j => `${path}.fields[${j}].name`, 'field');
6359
6609
  const fieldSet = new Set(names.filter((n) => !!n));
6610
+ const preview = {};
6360
6611
  for (const key of ['previewTextField', 'previewImageField']) {
6361
6612
  const v = c.string(raw, key, path, false);
6362
6613
  if (v && !fieldSet.has(v))
6363
6614
  c.add(SitePlanIssueCode.UnknownField, `${path}.${key}`, `"${v}" is not a field of ${name}`);
6615
+ preview[key] = v && fieldSet.has(v) ? v : null;
6364
6616
  }
6365
6617
  const seed = raw['seed'];
6366
6618
  if (seed !== undefined) {
@@ -6381,7 +6633,7 @@ function validateContentTypes(c, list) {
6381
6633
  if (name && types.has(name))
6382
6634
  c.add(SitePlanIssueCode.Duplicate, `${path}.name`, `content type "${name}" is used twice`);
6383
6635
  else if (name)
6384
- types.set(name, { fields: fieldSet });
6636
+ types.set(name, { fields: fieldSet, previewTextField: preview['previewTextField'] ?? undefined });
6385
6637
  });
6386
6638
  return types;
6387
6639
  }
@@ -6392,7 +6644,7 @@ function validateDataSources(c, list, types) {
6392
6644
  if (!isObject(raw))
6393
6645
  return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
6394
6646
  const alias = c.string(raw, 'alias', path);
6395
- if (alias && (alias.startsWith(RESERVED_PREFIX) || alias === NAV_PAGES_ALIAS)) {
6647
+ if (alias && alias.startsWith(RESERVED_PREFIX)) {
6396
6648
  c.add(SitePlanIssueCode.ReservedAlias, `${path}.alias`, `"${alias}" is reserved for the platform`);
6397
6649
  }
6398
6650
  if (raw['type'] === PlanDataSourceType.Cms) {
@@ -6557,6 +6809,15 @@ function validatePages(c, list, types, sources) {
6557
6809
  if (scopes.has(BindScope.Next) && routeSourceOk && !nextSourceOf(sources, routeSourceAlias)) {
6558
6810
  c.add(SitePlanIssueCode.MissingScope, `${sp}.pattern`, `"${patternId}" reads the next item — the plan needs a ${RelativeOp.SiblingsNext} source of ${routeSourceAlias}`);
6559
6811
  }
6812
+ // A bound image's alt text is its item's previewTextField (W1.1): the item's type must name one.
6813
+ for (const scope of altSourceScopes(pattern)) {
6814
+ const alias = scope === BindScope.Item ? binding : routeSourceOk ? routeSourceAlias : null;
6815
+ const typeName = alias ? sources.get(alias)?.contentType : undefined;
6816
+ const type = typeName ? types.get(typeName) : undefined;
6817
+ if (type && !type.previewTextField) {
6818
+ c.add(SitePlanIssueCode.MissingPreviewField, `${sp}.pattern`, `"${patternId}" shows ${typeName} images — ${typeName} needs a previewTextField for their alt text`);
6819
+ }
6820
+ }
6560
6821
  });
6561
6822
  });
6562
6823
  c.unique(list.map(p => (isObject(p) && isNonEmptyString(p['key']) ? p['key'] : null)), i => `pages[${i}].key`, 'page key');
@@ -6613,10 +6874,12 @@ function assertValidSitePlan(plan) {
6613
6874
  /**
6614
6875
  * THE composer — a Site Plan in, ordinary IOX data out (design-system plan §6b, W1). Deterministic and
6615
6876
  * pure: the same plan composes to byte-identical output, with no clock, no randomness and no transport.
6616
- * The wizard and the AI agent both end here; W2's site-build service writes what it returns.
6877
+ * The wizard and the AI agent both end here; W2's site-build service writes what it returns. The one
6878
+ * thing that changes with time — the copyright year — comes in through `ComposeContext`.
6617
6879
  *
6618
6880
  * Every node is created the way the builder creates one (`createSerializedNode`): the type's full
6619
- * constructor defaults, then ONE element style, then the pattern's layout. So a composed page is what
6881
+ * constructor defaults — its empty-drop sizes released, so the pattern owns its box (`BASELINE_ROLES`) —
6882
+ * then ONE element style, then the pattern's layout. So a composed page is what
6620
6883
  * an author would have built by hand from the palette — the builder reloads it and saves it unchanged,
6621
6884
  * and the engine renders it with nothing to resolve. Never a colour, size or font a pattern did not
6622
6885
  * name; never a fact the customer did not give (blank → a placeholder carrying the fact's label).
@@ -6635,9 +6898,18 @@ var ComposeIssueCode;
6635
6898
  /** A slot the plan and the business left empty and the pattern gives no words for — the node keeps
6636
6899
  * its type's default text. */
6637
6900
  ComposeIssueCode["UnfilledSlot"] = "unfilled-slot";
6638
- /** A pattern links to a route the plan has no page for (an optional page was left out). */
6639
- ComposeIssueCode["DanglingLink"] = "dangling-link";
6901
+ /** A link to a page the plan leaves out now opens an email to the business (`mailto:`). */
6902
+ ComposeIssueCode["LinkToEmail"] = "link-to-email";
6903
+ /** A link or button to a page the plan leaves out, with no business email to send to: removed. */
6904
+ ComposeIssueCode["LinkRemoved"] = "link-removed";
6905
+ /** A linked box to a page the plan leaves out, with no business email: it keeps its contents as a
6906
+ * plain Container and loses only the link. */
6907
+ ComposeIssueCode["LinkUnlinked"] = "link-unlinked";
6640
6908
  })(ComposeIssueCode || (ComposeIssueCode = {}));
6909
+ /** The issues that are a dangling link (a route the plan has no page for) and what was done about it. */
6910
+ const DANGLING_LINK_CODES = new Set([
6911
+ ComposeIssueCode.LinkToEmail, ComposeIssueCode.LinkRemoved, ComposeIssueCode.LinkUnlinked,
6912
+ ]);
6641
6913
  /** `{sampleImage:k}` — a bundled abstract sample image, never a stock photo (plan §6b). */
6642
6914
  const SAMPLE_IMAGE_REF = /^\{sampleImage:(\d+)\}$/;
6643
6915
  const SAMPLE_IMAGE_TEMPLATE = /\{sampleImage:n(?:\+(\d+))?\}/g;
@@ -6664,19 +6936,38 @@ const BIND_PROP = {
6664
6936
  Link: 'label',
6665
6937
  Image: 'src',
6666
6938
  };
6667
- /** Types whose `href` is a website page (`linkType: page` + `pageRoute`). */
6668
- const PAGE_LINK_TYPES = new Set(['Link', 'LinkedContainer']);
6939
+ const LINK_SLOTS = {
6940
+ Link: { url: 'url' },
6941
+ LinkedContainer: { url: 'url' },
6942
+ ButtonBlock: { url: 'href', fixed: { tag: 'a' } },
6943
+ };
6944
+ /** What a linked box becomes when its link has nowhere to go: the same box, unlinked. */
6945
+ const UNLINKED_TYPE = { LinkedContainer: 'Container' };
6669
6946
  const BUTTON_BLOCK = 'ButtonBlock';
6670
6947
  const FORM = 'Form';
6671
- const FORM_STATE_BLOCKS = ['FormSuccess', 'FormError'];
6672
- const LINK_TYPE_PAGE = 'page';
6673
- const ANCHOR_TAG = 'a';
6948
+ const FIELD = 'Field';
6949
+ const IMAGE = 'Image';
6950
+ const SUBMIT_BUTTON = 'SubmitButton';
6951
+ const FORM_SUCCESS = 'FormSuccess';
6952
+ const FORM_ERROR = 'FormError';
6953
+ const FORM_STATE_BLOCKS = [FORM_SUCCESS, FORM_ERROR];
6954
+ /** The Field traits a preset field fills, 1:1 (`FormPresetField`). */
6955
+ const PRESET_FIELD_PROPS = ['label', 'control', 'required', 'help', 'placeholder', 'optionsText', 'rows'];
6674
6956
  const LABEL_TAG = 'span';
6675
6957
  const TEXT = 'Text';
6676
- /** A button's label reads the button's type and ink (the consent preset's labels, `consent-preset.ts`). */
6677
- const BUTTON_LABEL_INHERITS = { fontSize: 'inherit', fontWeight: 'inherit', color: 'inherit' };
6958
+ /** A button's label reads the button's own type and ink (contract element style, W1.1 Q6). */
6959
+ const BUTTON_LABEL_STYLE = 'button-label';
6678
6960
  const ITEM_VALUE_PATH = '$';
6679
6961
  const STATUS_DRAFT = 'draft';
6962
+ const MAILTO = 'mailto:';
6963
+ /** A pattern's `alt: ''` — the image is decorative and its alt is written empty. */
6964
+ const DECORATIVE_ALT = '';
6965
+ /** What the composer writes for every node: the pattern owns the box (W1.1, Q1). */
6966
+ const COMPOSED = { releaseLayoutDefaults: true };
6967
+ /** The words a pattern may leave for compose time: the year (`ComposeContext`) and the business name. */
6968
+ const TEXT_VARIABLE = /\{(year|business\.name)\}/g;
6969
+ /** What `page.*` may read on a node stamped for a navigation page. */
6970
+ const STAMPED_PAGE_FIELDS = new Set(['name', 'route']);
6680
6971
  const clone = (value) => JSON.parse(JSON.stringify(value));
6681
6972
  /** 32-bit FNV-1a, base 36 — a short stable id fragment. */
6682
6973
  function stableHash(text) {
@@ -6687,15 +6978,14 @@ function stableHash(text) {
6687
6978
  }
6688
6979
  return h.toString(36).padStart(7, '0').slice(-6);
6689
6980
  }
6690
- /** A page route as a base-relative href — the engine's `resolvePageHref` form (`/contact` → `contact`,
6691
- * `/` → `./`), so a link stays inside the org's `<base href>`. */
6692
- function baseRelativeHref(route) {
6693
- const path = route.replace(/\/{2,}/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
6694
- return path || './';
6695
- }
6981
+ const create = (id, type, props = {}, elementStyle) => createSerializedNode(id, type, props, elementStyle, COMPOSED);
6696
6982
  function nonEmpty(v) {
6697
6983
  return typeof v === 'string' && v.trim() !== '';
6698
6984
  }
6985
+ /** A pattern's own words with the compose-time variables filled: `{year}`, `{business.name}`. */
6986
+ function fillVariables(text, ctx) {
6987
+ return text.replace(TEXT_VARIABLE, (_m, name) => (name === 'year' ? String(ctx.year) : ctx.plan.business.name));
6988
+ }
6699
6989
  /** Where a node's words come from: the plan's slot → the business → the customer's fact → the pattern's
6700
6990
  * own text. A missing fact shows its label and is a placeholder; nothing is ever made up. */
6701
6991
  function chooseText(pn, ctx) {
@@ -6712,17 +7002,17 @@ function chooseText(pn, ctx) {
6712
7002
  const given = ctx.plan.business.facts?.[fact];
6713
7003
  return nonEmpty(given) ? { value: given } : { value: SITE_FACT_LABELS[fact], placeholder: { kind: PlaceholderKind.Fact, fact } };
6714
7004
  }
7005
+ const own = pn.text === undefined ? undefined : fillVariables(pn.text, ctx);
6715
7006
  if (pn.ph === PatternPlaceholder.Copy)
6716
- return { value: pn.text, placeholder: { kind: PlaceholderKind.Copy } };
6717
- return { value: pn.text };
7007
+ return { value: own, placeholder: { kind: PlaceholderKind.Copy } };
7008
+ return { value: own };
6718
7009
  }
6719
- /** A `bind` / `param` / `src` path → the binding source + path, in this node's scope. */
7010
+ /** A `bind` / `alt` / `param` / `src` path → the binding source + path, in this node's scope. */
6720
7011
  function resolveScoped(path, repeater, ctx, nodeId) {
6721
7012
  const [scope, ...rest] = path.split('.');
6722
7013
  const sub = rest.join('.');
6723
7014
  switch (scope) {
6724
7015
  case BindScope.Item:
6725
- case BindScope.Page:
6726
7016
  if (!repeater)
6727
7017
  throw new ComposeError(`${nodeId}: "${path}" outside a Repeater`);
6728
7018
  return { source: repeater.itemAlias, path: sub || ITEM_VALUE_PATH };
@@ -6738,6 +7028,25 @@ function resolveScoped(path, repeater, ctx, nodeId) {
6738
7028
  throw new ComposeError(`${nodeId}: unknown binding scope "${scope}"`);
6739
7029
  }
6740
7030
  }
7031
+ /** `page.<field>` on a node stamped for a navigation page: that page's own words, written in. */
7032
+ function stampedPageValue(path, ctx, nodeId) {
7033
+ const field = path.split('.').slice(1).join('.');
7034
+ if (!ctx.navPage)
7035
+ throw new ComposeError(`${nodeId}: "${path}" on a node that is not stamped per page (each)`);
7036
+ if (!STAMPED_PAGE_FIELDS.has(field))
7037
+ throw new ComposeError(`${nodeId}: a page has no "${field}" (${[...STAMPED_PAGE_FIELDS].join(', ')})`);
7038
+ return ctx.navPage[field];
7039
+ }
7040
+ const isPageScope = (path) => !!path && path.split('.')[0] === BindScope.Page;
7041
+ /** The content type behind a data source alias (a relative source reads its `within` list's type). */
7042
+ function contentTypeOfSource(alias, plan) {
7043
+ const ds = plan.dataSources.find(d => d.alias === alias);
7044
+ if (!ds)
7045
+ return undefined;
7046
+ if (ds.type === PlanDataSourceType.Relative)
7047
+ return contentTypeOfSource(ds.within, plan);
7048
+ return plan.contentTypes.find(t => t.name === ds.contentType);
7049
+ }
6741
7050
  function addBinding(node, binding) {
6742
7051
  const declared = NODE_BASELINES[node.type]?.traits ?? [];
6743
7052
  if (!declared.includes(binding.prop) && !binding.prop.startsWith('routeParam:')) {
@@ -6755,10 +7064,136 @@ function setProp(node, name, value) {
6755
7064
  assertTraits(node.type, [name]);
6756
7065
  node.props[name] = value;
6757
7066
  }
6758
- /** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out. */
7067
+ function linkSlots(node) {
7068
+ const slots = LINK_SLOTS[node.type];
7069
+ if (!slots)
7070
+ throw new ComposeError(`${node.id}: ${node.type} cannot link`);
7071
+ return slots;
7072
+ }
7073
+ /** A link to one of the site's pages, by route — the builder shows the route, the engine resolves it. */
7074
+ function writePageLink(node, route) {
7075
+ const slots = linkSlots(node);
7076
+ for (const [k, v] of Object.entries(slots.fixed ?? {}))
7077
+ setProp(node, k, v);
7078
+ setProp(node, 'linkType', LinkType.Page);
7079
+ setProp(node, 'pageRoute', route);
7080
+ }
7081
+ function writeExternalLink(node, url) {
7082
+ const slots = linkSlots(node);
7083
+ for (const [k, v] of Object.entries(slots.fixed ?? {}))
7084
+ setProp(node, k, v);
7085
+ setProp(node, 'linkType', LinkType.External);
7086
+ setProp(node, slots.url, url);
7087
+ }
7088
+ /** What happens to a node linking to a route the plan has no page for (W1.1, Q11) — the same rule for
7089
+ * every pattern and every page: an email to the business when one is given; else a link or button is
7090
+ * removed and a linked box keeps its contents, unlinked. */
7091
+ var DanglingFate;
7092
+ (function (DanglingFate) {
7093
+ DanglingFate["Email"] = "email";
7094
+ DanglingFate["Remove"] = "remove";
7095
+ DanglingFate["Unlink"] = "unlink";
7096
+ })(DanglingFate || (DanglingFate = {}));
7097
+ function danglingFate(pn, ctx) {
7098
+ if (!pn.href || pn.href === PatternDirective.PageRoute || ctx.routes.has(pn.href))
7099
+ return null;
7100
+ if (ctx.email)
7101
+ return DanglingFate.Email;
7102
+ return UNLINKED_TYPE[pn.t] ? DanglingFate.Unlink : DanglingFate.Remove;
7103
+ }
7104
+ const DANGLING_ISSUE = {
7105
+ [DanglingFate.Email]: ComposeIssueCode.LinkToEmail,
7106
+ [DanglingFate.Remove]: ComposeIssueCode.LinkRemoved,
7107
+ [DanglingFate.Unlink]: ComposeIssueCode.LinkUnlinked,
7108
+ };
7109
+ function reportDangling(fate, pn, id, words, ctx) {
7110
+ const what = words ? `"${words}"` : `A ${pn.t === BUTTON_BLOCK ? 'button' : 'link'}`;
7111
+ const outcome = {
7112
+ [DanglingFate.Email]: `it now opens an email to ${ctx.email}`,
7113
+ [DanglingFate.Remove]: 'with no email address to send to instead, it was removed',
7114
+ [DanglingFate.Unlink]: 'with no email address to send to instead, the box keeps its contents without the link',
7115
+ }[fate];
7116
+ ctx.issues.push({
7117
+ code: DANGLING_ISSUE[fate], nodeId: id, owner: ctx.owner, route: pn.href,
7118
+ detail: `${what} linked to ${pn.href}, which this site has no page for — ${outcome}.`,
7119
+ });
7120
+ }
7121
+ /** The alt binding of a bound Image: the pattern's `alt`, else the bound item's preview text field. */
7122
+ function altBinding(pn, repeater, ctx, id) {
7123
+ if (pn.alt)
7124
+ return resolveScoped(pn.alt, repeater, ctx, id);
7125
+ const bound = resolveScoped(pn.bind, repeater, ctx, id);
7126
+ const field = contentTypeOfSource(bound.source, ctx.plan)?.previewTextField;
7127
+ if (!field)
7128
+ throw new ComposeError(`${id}: a bound image needs an alt — the pattern names none and "${bound.source}" has no previewTextField`);
7129
+ return { source: bound.source, path: field };
7130
+ }
7131
+ /** A preset Form's children — the forms-core preset's fields, submit and the WORDS of both state blocks. */
7132
+ function presetChildren(preset, id) {
7133
+ const fields = preset.fields.map((f, i) => {
7134
+ const props = Object.fromEntries(PRESET_FIELD_PROPS.filter(k => f[k] !== undefined).map(k => [k, f[k]]));
7135
+ assertTraits(FIELD, Object.keys(props));
7136
+ return create(`${id}-${i}`, FIELD, props);
7137
+ });
7138
+ const n = fields.length;
7139
+ const submit = create(`${id}-${n}`, SUBMIT_BUTTON, { label: preset.submitLabel });
7140
+ const blocks = [[FORM_SUCCESS, preset.successText], [FORM_ERROR, preset.errorText]].map(([type, words], i) => {
7141
+ const block = create(`${id}-${n + 1 + i}`, type);
7142
+ block.children = [create(`${id}-${n + 1 + i}-0`, TEXT, { content: words })];
7143
+ return block;
7144
+ });
7145
+ return [...fields, submit, ...blocks];
7146
+ }
7147
+ /** A Form: its stable id, its preset's name and children (or the pattern's own children), and both state
7148
+ * blocks — never optional (forms plan D7). */
7149
+ function composeForm(node, pn, id, ctx) {
7150
+ if (!pn.preset) {
7151
+ node.children = [...(node.children ?? []), ...FORM_STATE_BLOCKS.map((type, i) => create(`${id}-${(pn.c?.length ?? 0) + i}`, type))];
7152
+ return;
7153
+ }
7154
+ const preset = formPresetById(pn.preset);
7155
+ if (!preset)
7156
+ throw new ComposeError(`${id}: no form preset "${pn.preset}" in forms-core`);
7157
+ if (pn.c?.length)
7158
+ throw new ComposeError(`${id}: a preset Form takes its fields from the preset, not the pattern`);
7159
+ setProp(node, 'name', preset.formName);
7160
+ setProp(node, 'formId', `${preset.id}-${stableHash(`${ctx.owner}/${id}`)}`);
7161
+ node.children = presetChildren(preset, id);
7162
+ }
7163
+ /** A pattern node's children; an `each` child is stamped once per navigation page, its id carrying the
7164
+ * page key so it survives a page being left out or reordered. */
7165
+ function composeChildren(pn, id, repeater, ctx) {
7166
+ const out = [];
7167
+ (pn.c ?? []).forEach((child, i) => {
7168
+ const childId = `${id}-${i}`;
7169
+ if (!child.each) {
7170
+ const node = composeNode(child, childId, repeater, ctx);
7171
+ if (node)
7172
+ out.push(node);
7173
+ return;
7174
+ }
7175
+ if (child.each !== PatternDirective.NavPages)
7176
+ throw new ComposeError(`${childId}: "each" stamps "${PatternDirective.NavPages}" only`);
7177
+ for (const page of ctx.plan.pages.filter(p => p.inNav)) {
7178
+ const node = composeNode(child, `${childId}-${page.key}`, repeater, { ...ctx, navPage: page });
7179
+ if (node)
7180
+ out.push(node);
7181
+ }
7182
+ });
7183
+ return out;
7184
+ }
7185
+ /** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out or a
7186
+ * dangling link is removed. */
6759
7187
  function composeNode(pn, id, repeater, ctx) {
6760
7188
  if (pn.optional && ctx.section?.without?.includes(pn.optional))
6761
7189
  return null;
7190
+ const words = chooseText(pn, ctx);
7191
+ const fate = danglingFate(pn, ctx);
7192
+ if (fate === DanglingFate.Remove) {
7193
+ reportDangling(fate, pn, id, words.value, ctx);
7194
+ return null;
7195
+ }
7196
+ const type = fate === DanglingFate.Unlink ? UNLINKED_TYPE[pn.t] : pn.t;
6762
7197
  const props = {};
6763
7198
  if (pn.level)
6764
7199
  props['level'] = pn.level;
@@ -6772,32 +7207,29 @@ function composeNode(pn, id, repeater, ctx) {
6772
7207
  props['label'] = pn.label;
6773
7208
  if (pn.required !== undefined)
6774
7209
  props['required'] = pn.required;
6775
- assertTraits(pn.t, Object.keys(props));
7210
+ assertTraits(type, Object.keys(props));
6776
7211
  // Props first: a Heading with no `es` wears its LEVEL's default style.
6777
- const node = createSerializedNode(id, pn.t, props, pn.es ?? null);
7212
+ const node = create(id, type, props, pn.es ?? null);
6778
7213
  // Words.
6779
- const words = chooseText(pn, ctx);
6780
7214
  if (words.placeholder) {
6781
- const shown = words.value ?? String(NODE_BASELINES[pn.t]?.props[TEXT_PROP[pn.t]] ?? '');
7215
+ const shown = words.value ?? String(NODE_BASELINES[type]?.props[TEXT_PROP[type]] ?? '');
6782
7216
  ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: words.placeholder.kind, ...(words.placeholder.fact ? { fact: words.placeholder.fact } : {}), ...(pn.slot ? { slot: pn.slot } : {}), text: shown });
6783
7217
  }
6784
7218
  else if (words.value === undefined && (pn.slot || pn.fromBusiness)) {
6785
- ctx.issues.push({ code: ComposeIssueCode.UnfilledSlot, nodeId: id, owner: ctx.owner, detail: `no value for ${pn.slot ? `slot "${pn.slot}"` : `business "${pn.fromBusiness}"`} — keeps the ${pn.t} default` });
7219
+ ctx.issues.push({ code: ComposeIssueCode.UnfilledSlot, nodeId: id, owner: ctx.owner, detail: `no value for ${pn.slot ? `slot "${pn.slot}"` : `business "${pn.fromBusiness}"`} — keeps the ${type} default` });
6786
7220
  }
6787
7221
  if (words.value !== undefined) {
6788
- if (pn.t === BUTTON_BLOCK) {
6789
- const label = createSerializedNode(`${id}-label`, TEXT, { content: words.value, tag: LABEL_TAG });
6790
- setStyleProps(label, BUTTON_LABEL_INHERITS);
6791
- node.children = [label];
7222
+ if (type === BUTTON_BLOCK) {
7223
+ node.children = [create(`${id}-label`, TEXT, { content: words.value, tag: LABEL_TAG }, BUTTON_LABEL_STYLE)];
6792
7224
  }
6793
7225
  else {
6794
- const prop = TEXT_PROP[pn.t];
7226
+ const prop = TEXT_PROP[type];
6795
7227
  if (!prop)
6796
- throw new ComposeError(`${id}: ${pn.t} cannot hold text`);
7228
+ throw new ComposeError(`${id}: ${type} cannot hold text`);
6797
7229
  setProp(node, prop, words.value);
6798
7230
  }
6799
7231
  }
6800
- // Images.
7232
+ // Images: an empty slot, or a bound one — both fall back to the DS3 empty image, never a stock URL.
6801
7233
  if (pn.ph === PatternPlaceholder.Image) {
6802
7234
  setProp(node, 'src', '');
6803
7235
  ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: PlaceholderKind.Image, text: '' });
@@ -6805,38 +7237,41 @@ function composeNode(pn, id, repeater, ctx) {
6805
7237
  if (pn.aspect)
6806
7238
  setStyleProps(node, { aspectRatio: pn.aspect });
6807
7239
  // Links.
6808
- if (pn.href) {
6809
- if (pn.href === PatternDirective.PageRoute) {
6810
- setProp(node, 'linkType', LINK_TYPE_PAGE);
6811
- addBinding(node, { prop: 'pageRoute', ...resolveScoped(`${BindScope.Page}.route`, repeater, ctx, id) });
6812
- }
6813
- else {
6814
- if (!ctx.routes.has(pn.href))
6815
- ctx.issues.push({ code: ComposeIssueCode.DanglingLink, nodeId: id, owner: ctx.owner, detail: `links to ${pn.href}, which the plan has no page for` });
6816
- if (PAGE_LINK_TYPES.has(pn.t)) {
6817
- setProp(node, 'linkType', LINK_TYPE_PAGE);
6818
- setProp(node, 'pageRoute', pn.href);
6819
- }
6820
- else if (pn.t === BUTTON_BLOCK) {
6821
- setProp(node, 'tag', ANCHOR_TAG);
6822
- setProp(node, 'href', baseRelativeHref(pn.href));
6823
- }
6824
- else {
6825
- throw new ComposeError(`${id}: ${pn.t} cannot link`);
6826
- }
6827
- }
7240
+ if (pn.href === PatternDirective.PageRoute) {
7241
+ writePageLink(node, stampedPageValue(`${BindScope.Page}.route`, ctx, id));
7242
+ }
7243
+ else if (pn.href && !fate) {
7244
+ writePageLink(node, pn.href);
6828
7245
  }
6829
- if (pn.param) {
7246
+ else if (fate) {
7247
+ if (fate === DanglingFate.Email)
7248
+ writeExternalLink(node, `${MAILTO}${ctx.email}`);
7249
+ reportDangling(fate, pn, id, words.value, ctx);
7250
+ }
7251
+ if (pn.param && !fate) {
6830
7252
  const names = pn.href ? routeParams(pn.href) : [];
6831
7253
  if (names.length !== 1)
6832
7254
  throw new ComposeError(`${id}: "param" needs an href with one :param`);
6833
7255
  addBinding(node, { prop: `routeParam:${names[0]}`, ...resolveScoped(pn.param, repeater, ctx, id) });
6834
7256
  }
6835
7257
  if (pn.bind) {
6836
- const prop = BIND_PROP[pn.t];
7258
+ const prop = BIND_PROP[type];
6837
7259
  if (!prop)
6838
- throw new ComposeError(`${id}: ${pn.t} has no bindable value`);
6839
- addBinding(node, { prop, ...resolveScoped(pn.bind, repeater, ctx, id) });
7260
+ throw new ComposeError(`${id}: ${type} has no bindable value`);
7261
+ if (isPageScope(pn.bind))
7262
+ setProp(node, prop, stampedPageValue(pn.bind, ctx, id));
7263
+ else
7264
+ addBinding(node, { prop, ...resolveScoped(pn.bind, repeater, ctx, id) });
7265
+ }
7266
+ if (type === IMAGE && pn.bind && !isPageScope(pn.bind)) {
7267
+ setProp(node, 'src', '');
7268
+ if (pn.alt === DECORATIVE_ALT)
7269
+ setProp(node, 'alt', DECORATIVE_ALT);
7270
+ else
7271
+ addBinding(node, { prop: 'alt', ...altBinding(pn, repeater, ctx, id) });
7272
+ }
7273
+ else if (pn.alt !== undefined) {
7274
+ throw new ComposeError(`${id}: "alt" belongs on a bound Image`);
6840
7275
  }
6841
7276
  // Layout.
6842
7277
  if (pn.lay)
@@ -6855,9 +7290,6 @@ function composeNode(pn, id, repeater, ctx) {
6855
7290
  throw new ComposeError(`${id}: "$binding" with no section binding`);
6856
7291
  binding = { prop: 'source', source: ctx.section.binding, path: '' };
6857
7292
  }
6858
- else if (pn.src === PatternDirective.NavPages) {
6859
- binding = { prop: 'source', source: NAV_PAGES_ALIAS, path: '' };
6860
- }
6861
7293
  else {
6862
7294
  const scoped = resolveScoped(pn.src, repeater, ctx, id);
6863
7295
  binding = { prop: 'source', source: scoped.source, path: scoped.path === ITEM_VALUE_PATH ? '' : scoped.path };
@@ -6867,29 +7299,21 @@ function composeNode(pn, id, repeater, ctx) {
6867
7299
  rowScope = { itemAlias: lastSegment || binding.source };
6868
7300
  }
6869
7301
  // Children.
6870
- const children = (pn.c ?? [])
6871
- .map((child, i) => composeNode(child, `${id}-${i}`, rowScope, ctx))
6872
- .filter((c) => !!c);
6873
7302
  if (pn.c?.length) {
6874
7303
  if (!node.children)
6875
- throw new ComposeError(`${id}: ${pn.t} holds no children`);
6876
- node.children = [...node.children, ...children];
6877
- }
6878
- // A Form: its stable id, and both state blocks — never optional (forms plan D7).
6879
- if (pn.t === FORM) {
6880
- if (pn.preset)
6881
- setProp(node, 'formId', `${pn.preset}-${stableHash(`${ctx.owner}/${id}`)}`);
6882
- node.children = [
6883
- ...(node.children ?? []),
6884
- ...FORM_STATE_BLOCKS.map((type, i) => createSerializedNode(`${id}-${(pn.c?.length ?? 0) + i}`, type)),
6885
- ];
7304
+ throw new ComposeError(`${id}: ${type} holds no children`);
7305
+ node.children = [...node.children, ...composeChildren(pn, id, rowScope, ctx)];
6886
7306
  }
7307
+ if (type === FORM)
7308
+ composeForm(node, pn, id, ctx);
6887
7309
  return node;
6888
7310
  }
6889
7311
  function composePattern(pattern, id, ctx) {
7312
+ if (pattern.node.each)
7313
+ throw new ComposeError(`${id}: a pattern's root cannot be stamped per page`);
6890
7314
  const node = composeNode(pattern.node, id, null, ctx);
6891
7315
  if (!node)
6892
- throw new ComposeError(`${id}: a pattern's root cannot be optional`);
7316
+ throw new ComposeError(`${id}: a pattern's root cannot be optional or a link`);
6893
7317
  node.label = pattern.label;
6894
7318
  return node;
6895
7319
  }
@@ -6909,12 +7333,6 @@ function composeDataSource(ds) {
6909
7333
  request: { relative: { kind: 'siblings', from: ds.within, subjectFrom: ds.of, pick } },
6910
7334
  };
6911
7335
  }
6912
- function navPagesSource(plan) {
6913
- return {
6914
- id: `ds-${NAV_PAGES_ALIAS}`, alias: NAV_PAGES_ALIAS, type: 'static', mode: PlanDataSourceMode.Query,
6915
- request: { data: plan.pages.filter(p => p.inNav).map(p => ({ name: p.name, route: p.route })) },
6916
- };
6917
- }
6918
7336
  function expandSeedValue(value, n) {
6919
7337
  if (Array.isArray(value))
6920
7338
  return value.map(v => expandSeedValue(v, n));
@@ -6938,25 +7356,20 @@ function composeItems(type) {
6938
7356
  }
6939
7357
  return items;
6940
7358
  }
6941
- /** True when a composed tree iterates the navigation's pages. */
6942
- function bindsAlias(trees, alias) {
6943
- let found = false;
6944
- for (const tree of trees)
6945
- walkSerialized(tree, node => { if (node.bindings.some(b => b.source === alias))
6946
- found = true; });
6947
- return found;
6948
- }
6949
7359
  /**
6950
7360
  * Compose a Site Plan. Throws `SitePlanError` (every issue) when the plan does not validate — the
6951
- * composer never repairs a plan.
7361
+ * composer never repairs a plan. `context` carries what the composer may not read for itself (the year).
6952
7362
  */
6953
- function composeSite(plan) {
7363
+ function composeSite(plan, context) {
6954
7364
  assertValidSitePlan(plan);
7365
+ if (!Number.isInteger(context?.year))
7366
+ throw new ComposeError('composeSite needs the year (ComposeContext.year) — the composer reads no clock');
6955
7367
  const placeholders = [];
6956
7368
  const issues = [];
6957
7369
  const routes = new Set(plan.pages.map(p => p.route));
6958
7370
  const relatives = new Map(plan.dataSources.map(d => [d.alias, d.type === PlanDataSourceType.Relative ? { type: d.type, of: d.of, op: d.op } : { type: d.type }]));
6959
- const base = { plan, placeholders, issues, routes };
7371
+ const email = nonEmpty(plan.business.facts?.email) ? plan.business.facts.email.trim() : null;
7372
+ const base = { plan, placeholders, issues, routes, year: context.year, email, navPage: null };
6960
7373
  const pages = plan.pages.map(page => {
6961
7374
  const nextAlias = page.routeSource ? nextSourceOf(relatives, page.routeSource) : null;
6962
7375
  const layout = page.sections.map((section, i) => {
@@ -6980,16 +7393,13 @@ function composeSite(plan) {
6980
7393
  node.globalPosition = pattern.global;
6981
7394
  return node;
6982
7395
  });
6983
- const dataSources = plan.dataSources.map(composeDataSource);
6984
- if (bindsAlias([...pages.map(p => p.layout), globals], NAV_PAGES_ALIAS))
6985
- dataSources.push(navPagesSource(plan));
6986
7396
  return {
6987
7397
  source: clone(plan.source),
6988
7398
  business: clone(plan.business),
6989
7399
  design: resolveDesignChoices(clone(plan.design)),
6990
7400
  schemas: clone(plan.contentTypes),
6991
7401
  items: Object.fromEntries(plan.contentTypes.map(t => [t.name, composeItems(t)])),
6992
- dataSources,
7402
+ dataSources: plan.dataSources.map(composeDataSource),
6993
7403
  pages,
6994
7404
  globals,
6995
7405
  routes: plan.pages.map(p => ({ key: p.key, path: p.route, isHome: p.isHome === true })),
@@ -7014,5 +7424,5 @@ function composeSite(plan) {
7014
7424
  * Generated bundle index. Do not edit.
7015
7425
  */
7016
7426
 
7017
- export { BindScope, ComposeError, ComposeIssueCode, FACT_KEYS, GlobalSlot, NAV_PAGES_ALIAS, NODE_BASELINES, PatternDirective, PatternGlobalPosition, PatternPlaceholder, PlaceholderKind, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SAMPLE_IMAGE_REF, SITE_FACT_LABELS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_FACTS, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_TEMPLATES, STUDIO_TEMPLATE, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, assertStyleSlots, assertValidSitePlan, baseRelativeHref, composeSite, createSerializedNode, nextSourceOf, parseSampleImageRef, patternById, patternNeedsBinding, patternNodes, patternOptionalParts, patternScopes, patternSlots, planFromTemplate, routeParams, sampleImageRef, setStyleProps, validateSitePlan, walkSerialized };
7427
+ export { BASELINE_ROLES, BaselineRole, BindScope, ComposeError, ComposeIssueCode, DANGLING_LINK_CODES, FACT_KEYS, GlobalSlot, NEUTRAL_STYLE_PROPS, NODE_BASELINES, PatternDirective, PatternGlobalPosition, PatternPlaceholder, PlaceholderKind, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SAMPLE_IMAGE_REF, SITE_FACT_LABELS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_FACTS, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_TEMPLATES, STUDIO_TEMPLATE, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, altSourceScopes, assertStyleSlots, assertValidSitePlan, composeSite, createSerializedNode, nextSourceOf, parseSampleImageRef, patternById, patternNeedsBinding, patternNodes, patternOptionalParts, patternScopes, patternSlots, planFromTemplate, releasedLayoutDefaults, requiredShapeKeys, routeParams, sampleImageRef, setStyleProps, shapeKeys, validateSitePlan, walkSerialized };
7018
7428
  //# sourceMappingURL=vectoriox-iox-ui-generation-core.mjs.map