@vectoriox/iox-ui 5.26.0 → 5.27.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.
@@ -0,0 +1,60 @@
1
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { ContentTypePlan, DataSourcePlan, GlobalSlot, PagePlan, SiteBusiness, SitePlan, SitePlanSourceKind, SITE_PLAN_VERSION } from './site-plan.model';
4
+ import { SPEC_SITE_TEMPLATES } from './site-spec.generated';
5
+
6
+ /** A site structure the wizard offers (contract `site.templates`). A template is a Site Plan without the
7
+ * customer's part — the business and the look — which `planFromTemplate` adds. */
8
+ export interface SiteTemplate {
9
+ readonly id: string;
10
+ readonly version: number;
11
+ readonly label: string;
12
+ readonly blurb: string;
13
+ readonly bestFor: ReadonlyArray<string>;
14
+ readonly contentTypes: ReadonlyArray<ContentTypePlan>;
15
+ readonly dataSources: ReadonlyArray<DataSourcePlan>;
16
+ readonly pages: ReadonlyArray<PagePlan>;
17
+ readonly globals: Readonly<Record<`${GlobalSlot}`, string>>;
18
+ }
19
+
20
+ export const SITE_TEMPLATES: Readonly<Record<string, SiteTemplate>> = SPEC_SITE_TEMPLATES;
21
+
22
+ export enum SiteTemplateId {
23
+ Studio = 'studio',
24
+ }
25
+
26
+ export const STUDIO_TEMPLATE: SiteTemplate = SITE_TEMPLATES[SiteTemplateId.Studio];
27
+
28
+ export interface TemplatePlanInput {
29
+ business: SiteBusiness;
30
+ design: DesignChoices;
31
+ /** Keys of the template's OPTIONAL pages to leave out (the wizard's Pages step). */
32
+ omitPages?: ReadonlyArray<string>;
33
+ }
34
+
35
+ export class SiteTemplateError extends Error {}
36
+
37
+ const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
38
+
39
+ /**
40
+ * The template as a Site Plan (`source.kind: template`), with the customer's business and look. A
41
+ * required page cannot be left out, and an unknown key is refused — nothing is guessed.
42
+ */
43
+ export function planFromTemplate(template: SiteTemplate, input: TemplatePlanInput): SitePlan {
44
+ const omit = new Set(input.omitPages ?? []);
45
+ for (const key of omit) {
46
+ const page = template.pages.find(p => p.key === key);
47
+ if (!page) throw new SiteTemplateError(`template ${template.id} has no page "${key}"`);
48
+ if (page.required) throw new SiteTemplateError(`page "${key}" of template ${template.id} is required`);
49
+ }
50
+ return {
51
+ version: SITE_PLAN_VERSION,
52
+ source: { kind: SitePlanSourceKind.Template, templateId: template.id, templateVersion: template.version },
53
+ business: clone(input.business),
54
+ design: clone(input.design),
55
+ contentTypes: clone(template.contentTypes),
56
+ dataSources: clone(template.dataSources),
57
+ pages: clone(template.pages.filter(p => !omit.has(p.key))),
58
+ globals: clone(template.globals),
59
+ };
60
+ }
@@ -0,0 +1,413 @@
1
+ import { flavorPackById, parseHexColor, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import {
4
+ BindScope, patternById, patternNeedsBinding, patternOptionalParts, patternScopes, patternSlots, PatternGlobalPosition,
5
+ } from './patterns';
6
+ import {
7
+ FACT_KEYS, GlobalSlot, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlanSourceKind, SITE_PLAN_VERSION,
8
+ } from './site-plan.model';
9
+
10
+ /**
11
+ * The Site Plan validator — the gate in front of the composer. The wizard's plan and (later) the AI's
12
+ * plan go through the SAME checks: an unknown pattern, a slot the pattern does not declare, a source
13
+ * that does not exist, a page whose route item cannot be resolved — all refused, with a path, before a
14
+ * single page is written. It never repairs: a model gets the issues back and rewrites.
15
+ */
16
+
17
+ export enum SitePlanIssueCode {
18
+ /** A value of the wrong type, or a required value missing. */
19
+ Shape = 'shape',
20
+ /** A key the plan shape does not have. */
21
+ UnknownKey = 'unknown-key',
22
+ Version = 'version',
23
+ UnknownFlavor = 'unknown-flavor',
24
+ InvalidDesign = 'invalid-design',
25
+ MissingBusinessName = 'missing-business-name',
26
+ UnknownFact = 'unknown-fact',
27
+ Duplicate = 'duplicate',
28
+ ReservedAlias = 'reserved-alias',
29
+ UnknownContentType = 'unknown-content-type',
30
+ UnknownField = 'unknown-field',
31
+ UnknownDataSource = 'unknown-data-source',
32
+ InvalidDataSource = 'invalid-data-source',
33
+ InvalidRoute = 'invalid-route',
34
+ Home = 'home',
35
+ RouteSource = 'route-source',
36
+ UnknownPattern = 'unknown-pattern',
37
+ GlobalPattern = 'global-pattern',
38
+ UnknownSlot = 'unknown-slot',
39
+ UnknownOptionalPart = 'unknown-optional-part',
40
+ Binding = 'binding',
41
+ MissingScope = 'missing-scope',
42
+ }
43
+
44
+ export interface SitePlanIssue {
45
+ code: SitePlanIssueCode;
46
+ /** Where, as a dotted path into the plan (`pages[2].sections[0].binding`). */
47
+ path: string;
48
+ message: string;
49
+ }
50
+
51
+ export interface SitePlanValidation {
52
+ valid: boolean;
53
+ issues: SitePlanIssue[];
54
+ }
55
+
56
+ export class SitePlanError extends Error {
57
+ constructor(readonly issues: ReadonlyArray<SitePlanIssue>) {
58
+ super(`invalid site plan: ${issues.map(i => `${i.path}: ${i.message}`).join('; ')}`);
59
+ }
60
+ }
61
+
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
+ /** Route-scope aliases (`$route`, `$current`, …) are platform-owned. */
66
+ const RESERVED_PREFIX = '$';
67
+ const ID_FIELD = '_id';
68
+
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'];
80
+ const GLOBAL_POSITION: Readonly<Record<`${GlobalSlot}`, `${PatternGlobalPosition}`>> = {
81
+ [GlobalSlot.Header]: PatternGlobalPosition.Before,
82
+ [GlobalSlot.Footer]: PatternGlobalPosition.After,
83
+ };
84
+ const ROUTE = /^\/(?:(?:[a-z0-9]+(?:-[a-z0-9]+)*|:[a-zA-Z_][a-zA-Z0-9_]*)(?:\/(?:[a-z0-9]+(?:-[a-z0-9]+)*|:[a-zA-Z_][a-zA-Z0-9_]*))*)?$/;
85
+ const ROUTE_PARAM = /:([a-zA-Z_][a-zA-Z0-9_]*)/g;
86
+
87
+ type Obj = Record<string, unknown>;
88
+
89
+ const isObject = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
90
+ const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.trim() !== '';
91
+ const has = (table: object, key: string) => Object.prototype.hasOwnProperty.call(table, key);
92
+ const at = (path: string, key: string) => (path ? `${path}.${key}` : key);
93
+
94
+ /** The `:param` names of a route, in order. */
95
+ export function routeParams(route: string): string[] {
96
+ return [...route.matchAll(ROUTE_PARAM)].map(m => m[1]);
97
+ }
98
+
99
+ class Collector {
100
+ readonly issues: SitePlanIssue[] = [];
101
+ add(code: SitePlanIssueCode, path: string, message: string): void {
102
+ this.issues.push({ code, path, message });
103
+ }
104
+ keys(obj: Obj, allowed: ReadonlyArray<string>, path: string): void {
105
+ for (const key of Object.keys(obj)) if (!allowed.includes(key)) this.add(SitePlanIssueCode.UnknownKey, at(path, key), `"${key}" is not part of the plan shape`);
106
+ }
107
+ string(obj: Obj, key: string, path: string, required = true): string | null {
108
+ const v = obj[key];
109
+ if (v === undefined && !required) return null;
110
+ if (!isNonEmptyString(v)) {
111
+ this.add(SitePlanIssueCode.Shape, at(path, key), 'must be a non-empty string');
112
+ return null;
113
+ }
114
+ return v;
115
+ }
116
+ boolean(obj: Obj, key: string, path: string): void {
117
+ if (obj[key] !== undefined && typeof obj[key] !== 'boolean') this.add(SitePlanIssueCode.Shape, at(path, key), 'must be a boolean');
118
+ }
119
+ array(obj: Obj, key: string, path: string): unknown[] {
120
+ const v = obj[key];
121
+ if (!Array.isArray(v)) {
122
+ this.add(SitePlanIssueCode.Shape, at(path, key), 'must be an array');
123
+ return [];
124
+ }
125
+ return v;
126
+ }
127
+ unique(values: Array<string | null>, path: (i: number) => string, what: string): void {
128
+ const seen = new Set<string>();
129
+ values.forEach((v, i) => {
130
+ if (v === null) return;
131
+ if (seen.has(v)) this.add(SitePlanIssueCode.Duplicate, path(i), `${what} "${v}" is used twice`);
132
+ seen.add(v);
133
+ });
134
+ }
135
+ }
136
+
137
+ function validateSource(c: Collector, source: unknown): void {
138
+ if (!isObject(source)) return c.add(SitePlanIssueCode.Shape, 'source', 'must be an object');
139
+ if (source['kind'] === SitePlanSourceKind.Template) {
140
+ c.keys(source, ['kind', 'templateId', 'templateVersion'], 'source');
141
+ c.string(source, 'templateId', 'source');
142
+ if (!Number.isInteger(source['templateVersion'])) c.add(SitePlanIssueCode.Shape, 'source.templateVersion', 'must be an integer');
143
+ } else if (source['kind'] === SitePlanSourceKind.Ai) {
144
+ c.keys(source, ['kind', 'runId'], 'source');
145
+ c.string(source, 'runId', 'source');
146
+ } else {
147
+ c.add(SitePlanIssueCode.Shape, 'source.kind', `must be "${SitePlanSourceKind.Template}" or "${SitePlanSourceKind.Ai}"`);
148
+ }
149
+ }
150
+
151
+ function validateBusiness(c: Collector, business: unknown): void {
152
+ if (!isObject(business)) return c.add(SitePlanIssueCode.Shape, 'business', 'must be an object');
153
+ c.keys(business, BUSINESS_KEYS, 'business');
154
+ if (!isNonEmptyString(business['name'])) c.add(SitePlanIssueCode.MissingBusinessName, 'business.name', 'the business name is required');
155
+ if (business['tagline'] !== undefined && typeof business['tagline'] !== 'string') c.add(SitePlanIssueCode.Shape, 'business.tagline', 'must be a string');
156
+ const facts = business['facts'];
157
+ if (facts === undefined) return;
158
+ if (!isObject(facts)) return c.add(SitePlanIssueCode.Shape, 'business.facts', 'must be an object');
159
+ for (const [key, value] of Object.entries(facts)) {
160
+ if (!(FACT_KEYS as ReadonlyArray<string>).includes(key)) c.add(SitePlanIssueCode.UnknownFact, `business.facts.${key}`, `"${key}" is not a fact (${FACT_KEYS.join(', ')})`);
161
+ else if (typeof value !== 'string') c.add(SitePlanIssueCode.Shape, `business.facts.${key}`, 'must be a string');
162
+ }
163
+ }
164
+
165
+ function validateDesign(c: Collector, design: unknown): void {
166
+ if (!isObject(design)) return c.add(SitePlanIssueCode.Shape, 'design', 'must be an object');
167
+ c.keys(design, DESIGN_KEYS, 'design');
168
+ for (const key of DESIGN_REQUIRED) if (design[key] === undefined) c.add(SitePlanIssueCode.Shape, `design.${key}`, 'is required');
169
+ const pack = flavorPackById(typeof design['flavorId'] === 'string' ? design['flavorId'] : null);
170
+ if (design['flavorId'] !== undefined && (!pack || pack.id !== design['flavorId'])) {
171
+ c.add(SitePlanIssueCode.UnknownFlavor, 'design.flavorId', `"${String(design['flavorId'])}" is not a flavor`);
172
+ }
173
+ const bad = (key: string, why: string) => c.add(SitePlanIssueCode.InvalidDesign, `design.${key}`, why);
174
+ if (design['seed'] !== undefined && (typeof design['seed'] !== 'string' || !parseHexColor(design['seed']))) bad('seed', 'must be a #rrggbb colour');
175
+ if (design['pairId'] !== undefined && pack && !pack.pairs.some(p => p.id === design['pairId'])) bad('pairId', `is not a font pairing of ${pack.id}`);
176
+ const axis = (key: string, table: object) => {
177
+ if (design[key] !== undefined && (typeof design[key] !== 'string' || !has(table, design[key] as string))) bad(key, `must be one of ${Object.keys(table).join(', ')}`);
178
+ };
179
+ axis('shape', SPEC_AXES.shapes);
180
+ axis('density', SPEC_AXES.densities);
181
+ axis('motion', SPEC_AXES.motions);
182
+ axis('tint', SPEC_AXES.tints);
183
+ if (design['mode'] !== undefined && !(SPEC_AXES.modes as ReadonlyArray<unknown>).includes(design['mode'])) bad('mode', `must be one of ${SPEC_AXES.modes.join(', ')}`);
184
+ if (design['pillButtons'] !== undefined && typeof design['pillButtons'] !== 'boolean') bad('pillButtons', 'must be a boolean');
185
+ if (design['fonts'] !== undefined && !isObject(design['fonts'])) bad('fonts', 'must be an object');
186
+ }
187
+
188
+ interface ContentTypeInfo { fields: Set<string>; }
189
+
190
+ function validateContentTypes(c: Collector, list: unknown[]): Map<string, ContentTypeInfo> {
191
+ const types = new Map<string, ContentTypeInfo>();
192
+ list.forEach((raw, i) => {
193
+ const path = `contentTypes[${i}]`;
194
+ if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
195
+ c.keys(raw, CONTENT_TYPE_KEYS, path);
196
+ const name = c.string(raw, 'name', path);
197
+ c.string(raw, 'label', path);
198
+ const fields = c.array(raw, 'fields', path);
199
+ const names: Array<string | null> = fields.map((f, j) => {
200
+ const fp = `${path}.fields[${j}]`;
201
+ if (!isObject(f)) { c.add(SitePlanIssueCode.Shape, fp, 'must be an object'); return null; }
202
+ c.keys(f, FIELD_KEYS, fp);
203
+ c.string(f, 'type', fp);
204
+ c.string(f, 'controller', fp);
205
+ c.boolean(f, 'required', fp);
206
+ return c.string(f, 'name', fp);
207
+ });
208
+ c.unique(names, j => `${path}.fields[${j}].name`, 'field');
209
+ const fieldSet = new Set(names.filter((n): n is string => !!n));
210
+ for (const key of ['previewTextField', 'previewImageField']) {
211
+ const v = c.string(raw, key, path, false);
212
+ if (v && !fieldSet.has(v)) c.add(SitePlanIssueCode.UnknownField, `${path}.${key}`, `"${v}" is not a field of ${name}`);
213
+ }
214
+ const seed = raw['seed'];
215
+ if (seed !== undefined) {
216
+ if (!isObject(seed)) c.add(SitePlanIssueCode.Shape, `${path}.seed`, 'must be an object');
217
+ else {
218
+ if (!Number.isInteger(seed['count']) || (seed['count'] as number) < 0) c.add(SitePlanIssueCode.Shape, `${path}.seed.count`, 'must be a whole number');
219
+ for (const key of ['sample', 'isPublished']) if (typeof seed[key] !== 'boolean') c.add(SitePlanIssueCode.Shape, `${path}.seed.${key}`, 'must be a boolean');
220
+ for (const key of Object.keys(seed)) {
221
+ if (!SEED_FIXED_KEYS.includes(key) && !fieldSet.has(key)) c.add(SitePlanIssueCode.UnknownField, `${path}.seed.${key}`, `"${key}" is not a field of ${name}`);
222
+ }
223
+ }
224
+ }
225
+ 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 });
227
+ });
228
+ return types;
229
+ }
230
+
231
+ interface SourceInfo { type: string; mode?: string; contentType?: string; routeParam?: string; of?: string; op?: string; }
232
+
233
+ function validateDataSources(c: Collector, list: unknown[], types: Map<string, ContentTypeInfo>): Map<string, SourceInfo> {
234
+ const sources = new Map<string, SourceInfo>();
235
+ list.forEach((raw, i) => {
236
+ const path = `dataSources[${i}]`;
237
+ if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
238
+ const alias = c.string(raw, 'alias', path);
239
+ if (alias && (alias.startsWith(RESERVED_PREFIX) || alias === NAV_PAGES_ALIAS)) {
240
+ c.add(SitePlanIssueCode.ReservedAlias, `${path}.alias`, `"${alias}" is reserved for the platform`);
241
+ }
242
+ if (raw['type'] === PlanDataSourceType.Cms) {
243
+ c.keys(raw, CMS_SOURCE_KEYS, path);
244
+ const mode = raw['mode'];
245
+ if (mode !== PlanDataSourceMode.Query && mode !== PlanDataSourceMode.Single) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.mode`, `must be "${PlanDataSourceMode.Query}" or "${PlanDataSourceMode.Single}"`);
246
+ const contentType = c.string(raw, 'contentType', path);
247
+ const type = contentType ? types.get(contentType) : undefined;
248
+ if (contentType && !type) c.add(SitePlanIssueCode.UnknownContentType, `${path}.contentType`, `no content type "${contentType}" in the plan`);
249
+ if (raw['limit'] !== undefined && (!Number.isInteger(raw['limit']) || (raw['limit'] as number) < 1)) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.limit`, 'must be a positive whole number');
250
+ let routeParam: string | undefined;
251
+ const byRoute = raw['filterByRouteParam'];
252
+ if (byRoute !== undefined) {
253
+ if (!isObject(byRoute)) c.add(SitePlanIssueCode.Shape, `${path}.filterByRouteParam`, 'must be an object');
254
+ else {
255
+ c.keys(byRoute, ['field', 'routeParam'], `${path}.filterByRouteParam`);
256
+ const field = c.string(byRoute, 'field', `${path}.filterByRouteParam`);
257
+ routeParam = c.string(byRoute, 'routeParam', `${path}.filterByRouteParam`) ?? undefined;
258
+ if (field && type && field !== ID_FIELD && !type.fields.has(field)) c.add(SitePlanIssueCode.UnknownField, `${path}.filterByRouteParam.field`, `"${field}" is not a field of ${contentType}`);
259
+ if (mode !== PlanDataSourceMode.Single) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.filterByRouteParam`, 'only a single source is looked up by a route param');
260
+ }
261
+ }
262
+ if (alias && !sources.has(alias)) sources.set(alias, { type: PlanDataSourceType.Cms, mode: mode as string, contentType: contentType ?? undefined, routeParam });
263
+ } else if (raw['type'] === PlanDataSourceType.Relative) {
264
+ c.keys(raw, RELATIVE_SOURCE_KEYS, path);
265
+ c.string(raw, 'of', path);
266
+ c.string(raw, 'within', path);
267
+ if (!(Object.values(RelativeOp) as unknown[]).includes(raw['op'])) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.op`, `must be one of ${Object.values(RelativeOp).join(', ')}`);
268
+ if (alias && !sources.has(alias)) sources.set(alias, { type: PlanDataSourceType.Relative, of: raw['of'] as string, op: raw['op'] as string });
269
+ } else {
270
+ c.add(SitePlanIssueCode.InvalidDataSource, `${path}.type`, `must be "${PlanDataSourceType.Cms}" or "${PlanDataSourceType.Relative}"`);
271
+ }
272
+ });
273
+ c.unique(list.map(r => (isObject(r) && isNonEmptyString(r['alias']) ? r['alias'] : null)), i => `dataSources[${i}].alias`, 'alias');
274
+ // A relative source locates its subject (a single item) within a collection of the SAME type.
275
+ list.forEach((raw, i) => {
276
+ if (!isObject(raw) || raw['type'] !== PlanDataSourceType.Relative) return;
277
+ const of = sources.get(raw['of'] as string);
278
+ const within = sources.get(raw['within'] as string);
279
+ if (!of || of.type !== PlanDataSourceType.Cms || of.mode !== PlanDataSourceMode.Single) c.add(SitePlanIssueCode.UnknownDataSource, `dataSources[${i}].of`, 'must name a single cms source of the plan');
280
+ if (!within || within.type !== PlanDataSourceType.Cms || within.mode !== PlanDataSourceMode.Query) c.add(SitePlanIssueCode.UnknownDataSource, `dataSources[${i}].within`, 'must name a list (query) cms source of the plan');
281
+ if (of?.contentType && within?.contentType && of.contentType !== within.contentType) c.add(SitePlanIssueCode.InvalidDataSource, `dataSources[${i}]`, `${raw['of']} and ${raw['within']} are different content types`);
282
+ });
283
+ return sources;
284
+ }
285
+
286
+ function validatePages(c: Collector, list: unknown[], types: Map<string, ContentTypeInfo>, sources: Map<string, SourceInfo>): void {
287
+ const homes: number[] = [];
288
+ list.forEach((raw, i) => {
289
+ const path = `pages[${i}]`;
290
+ if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
291
+ c.keys(raw, PAGE_KEYS, path);
292
+ c.string(raw, 'key', path);
293
+ c.string(raw, 'name', path);
294
+ for (const key of ['isHome', 'required', 'inNav']) c.boolean(raw, key, path);
295
+ const route = c.string(raw, 'route', path);
296
+ if (route && !ROUTE.test(route)) c.add(SitePlanIssueCode.InvalidRoute, `${path}.route`, `"${route}" is not a route (/lowercase-words, :params)`);
297
+ if (raw['isHome'] === true) {
298
+ homes.push(i);
299
+ if (route !== '/') c.add(SitePlanIssueCode.Home, `${path}.route`, 'the home page lives at /');
300
+ } else if (route === '/') c.add(SitePlanIssueCode.Home, `${path}.isHome`, 'the page at / is the home page');
301
+
302
+ const params = route ? routeParams(route) : [];
303
+ const template = c.string(raw, 'template', path, false);
304
+ const routeSourceAlias = c.string(raw, 'routeSource', path, false);
305
+ const routeSource = routeSourceAlias ? sources.get(routeSourceAlias) : undefined;
306
+ const routeSourceOk = !!routeSource && routeSource.type === PlanDataSourceType.Cms && routeSource.mode === PlanDataSourceMode.Single;
307
+ if (template && !types.has(template)) c.add(SitePlanIssueCode.UnknownContentType, `${path}.template`, `no content type "${template}" in the plan`);
308
+ if (params.length > 1) c.add(SitePlanIssueCode.InvalidRoute, `${path}.route`, 'a template page has one route param');
309
+ if (params.length && !routeSourceAlias) c.add(SitePlanIssueCode.RouteSource, `${path}.routeSource`, `a route with :${params[0]} names the source that reads it`);
310
+ if (routeSourceAlias) {
311
+ if (!routeSourceOk) {
312
+ c.add(SitePlanIssueCode.RouteSource, `${path}.routeSource`, `"${routeSourceAlias}" must be a single cms source of the plan`);
313
+ } else {
314
+ if (!params.length || routeSource!.routeParam !== params[0]) c.add(SitePlanIssueCode.RouteSource, `${path}.routeSource`, `"${routeSourceAlias}" is not looked up by this route's param`);
315
+ if (template && routeSource!.contentType !== template) c.add(SitePlanIssueCode.RouteSource, `${path}.template`, `"${routeSourceAlias}" reads ${routeSource!.contentType}, not ${template}`);
316
+ }
317
+ }
318
+
319
+ c.array(raw, 'sections', path).forEach((s, j) => {
320
+ const sp = `${path}.sections[${j}]`;
321
+ if (!isObject(s)) return c.add(SitePlanIssueCode.Shape, sp, 'must be an object');
322
+ c.keys(s, SECTION_KEYS, sp);
323
+ const patternId = c.string(s, 'pattern', sp);
324
+ const pattern = patternById(patternId);
325
+ if (patternId && !pattern) return c.add(SitePlanIssueCode.UnknownPattern, `${sp}.pattern`, `no pattern "${patternId}"`);
326
+ if (!pattern) return;
327
+ if (pattern.global) c.add(SitePlanIssueCode.GlobalPattern, `${sp}.pattern`, `"${patternId}" is a header/footer pattern (globals)`);
328
+ const slots = s['slots'];
329
+ if (slots !== undefined) {
330
+ if (!isObject(slots)) c.add(SitePlanIssueCode.Shape, `${sp}.slots`, 'must be an object');
331
+ else {
332
+ const declared = patternSlots(pattern);
333
+ for (const [slot, value] of Object.entries(slots)) {
334
+ if (!declared.includes(slot)) c.add(SitePlanIssueCode.UnknownSlot, `${sp}.slots.${slot}`, `"${patternId}" has no slot "${slot}" (${declared.join(', ') || 'none'})`);
335
+ else if (typeof value !== 'string') c.add(SitePlanIssueCode.Shape, `${sp}.slots.${slot}`, 'must be a string');
336
+ }
337
+ }
338
+ }
339
+ const without = s['without'];
340
+ if (without !== undefined) {
341
+ const parts = patternOptionalParts(pattern);
342
+ if (!Array.isArray(without)) c.add(SitePlanIssueCode.Shape, `${sp}.without`, 'must be an array');
343
+ else without.forEach((w, k) => {
344
+ if (!parts.includes(w as string)) c.add(SitePlanIssueCode.UnknownOptionalPart, `${sp}.without[${k}]`, `"${patternId}" has no optional part "${String(w)}"`);
345
+ });
346
+ }
347
+ const binding = c.string(s, 'binding', sp, false);
348
+ if (patternNeedsBinding(pattern)) {
349
+ if (!binding) c.add(SitePlanIssueCode.Binding, `${sp}.binding`, `"${patternId}" repeats the section's binding — name a list source`);
350
+ else {
351
+ const src = sources.get(binding);
352
+ if (!src) c.add(SitePlanIssueCode.UnknownDataSource, `${sp}.binding`, `no data source "${binding}"`);
353
+ else if (src.type !== PlanDataSourceType.Cms || src.mode !== PlanDataSourceMode.Query) c.add(SitePlanIssueCode.Binding, `${sp}.binding`, `"${binding}" is not a list source`);
354
+ }
355
+ } else if (binding) c.add(SitePlanIssueCode.Binding, `${sp}.binding`, `"${patternId}" repeats nothing — it takes no binding`);
356
+
357
+ // A pattern reading the route item needs a routeSource (an INVALID one is reported above, once).
358
+ const scopes = patternScopes(pattern);
359
+ const readsRoute = scopes.has(BindScope.Route) || scopes.has(BindScope.Next);
360
+ if (readsRoute && !routeSourceAlias) {
361
+ c.add(SitePlanIssueCode.MissingScope, `${sp}.pattern`, `"${patternId}" reads the route item — the page needs a routeSource`);
362
+ }
363
+ if (scopes.has(BindScope.Next) && routeSourceOk && !nextSourceOf(sources, routeSourceAlias as string)) {
364
+ c.add(SitePlanIssueCode.MissingScope, `${sp}.pattern`, `"${patternId}" reads the next item — the plan needs a ${RelativeOp.SiblingsNext} source of ${routeSourceAlias}`);
365
+ }
366
+ });
367
+ });
368
+ c.unique(list.map(p => (isObject(p) && isNonEmptyString(p['key']) ? p['key'] : null)), i => `pages[${i}].key`, 'page key');
369
+ c.unique(list.map(p => (isObject(p) && isNonEmptyString(p['route']) ? p['route'] : null)), i => `pages[${i}].route`, 'route');
370
+ if (homes.length !== 1) c.add(SitePlanIssueCode.Home, 'pages', `exactly one page is the home page (found ${homes.length})`);
371
+ }
372
+
373
+ /** The plan's `$siblings.next` source of `routeSource` — what a pattern's `next.*` reads. */
374
+ export function nextSourceOf(sources: Map<string, { type: string; of?: string; op?: string }>, routeSource: string): string | null {
375
+ for (const [alias, s] of sources) if (s.type === PlanDataSourceType.Relative && s.of === routeSource && s.op === RelativeOp.SiblingsNext) return alias;
376
+ return null;
377
+ }
378
+
379
+ function validateGlobals(c: Collector, globals: unknown): void {
380
+ if (!isObject(globals)) return c.add(SitePlanIssueCode.Shape, 'globals', 'must be an object');
381
+ c.keys(globals, Object.values(GlobalSlot), 'globals');
382
+ for (const slot of Object.values(GlobalSlot)) {
383
+ const id = c.string(globals, slot, 'globals');
384
+ const pattern = patternById(id);
385
+ if (id && !pattern) c.add(SitePlanIssueCode.UnknownPattern, `globals.${slot}`, `no pattern "${id}"`);
386
+ else if (pattern && pattern.global !== GLOBAL_POSITION[slot]) c.add(SitePlanIssueCode.GlobalPattern, `globals.${slot}`, `"${id}" is not a ${slot} pattern`);
387
+ }
388
+ }
389
+
390
+ /** Check a Site Plan against the plan shape and the pattern library. Never throws, never repairs. */
391
+ export function validateSitePlan(plan: unknown): SitePlanValidation {
392
+ const c = new Collector();
393
+ if (!isObject(plan)) {
394
+ c.add(SitePlanIssueCode.Shape, '', 'a site plan is an object');
395
+ return { valid: false, issues: c.issues };
396
+ }
397
+ c.keys(plan, PLAN_KEYS, '');
398
+ if (plan['version'] !== SITE_PLAN_VERSION) c.add(SitePlanIssueCode.Version, 'version', `must be "${SITE_PLAN_VERSION}"`);
399
+ validateSource(c, plan['source']);
400
+ validateBusiness(c, plan['business']);
401
+ validateDesign(c, plan['design']);
402
+ const types = validateContentTypes(c, c.array(plan, 'contentTypes', ''));
403
+ const sources = validateDataSources(c, c.array(plan, 'dataSources', ''), types);
404
+ validatePages(c, c.array(plan, 'pages', ''), types, sources);
405
+ validateGlobals(c, plan['globals']);
406
+ return { valid: c.issues.length === 0, issues: c.issues };
407
+ }
408
+
409
+ /** Throw a `SitePlanError` carrying every issue unless the plan is valid. */
410
+ export function assertValidSitePlan(plan: unknown): void {
411
+ const result = validateSitePlan(plan);
412
+ if (!result.valid) throw new SitePlanError(result.issues);
413
+ }