@vectoriox/iox-ui 5.25.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.
- package/design-core/index.d.ts +1153 -191
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +1680 -241
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +7018 -0
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -0
- package/generation-core/index.d.ts +1397 -0
- package/generation-core/package.json +3 -0
- package/package.json +5 -1
- package/src/design-core/brand-neutrals.ts +236 -0
- package/src/design-core/brand.ts +24 -14
- package/src/design-core/colour-eval.ts +117 -0
- package/src/design-core/contrast-audit.ts +214 -0
- package/src/design-core/design-apply.ts +34 -14
- package/src/design-core/design-settings.ts +12 -3
- package/src/design-core/design-token.model.ts +26 -0
- package/src/design-core/flavor-fixtures.generated.ts +150 -0
- package/src/design-core/flavor-spec.generated.ts +1061 -189
- package/src/design-core/flavor-tokens.ts +36 -8
- package/src/design-core/public-api.ts +3 -0
- package/src/design-core/token-css.ts +48 -15
- package/src/generation-core/compose-site.ts +474 -0
- package/src/generation-core/node-baseline.model.ts +10 -0
- package/src/generation-core/node-baselines.generated.ts +4841 -0
- package/src/generation-core/patterns.ts +125 -0
- package/src/generation-core/public-api.ts +20 -0
- package/src/generation-core/serialized-node.ts +91 -0
- package/src/generation-core/site-plan.model.ts +136 -0
- package/src/generation-core/site-spec.generated.ts +1111 -0
- package/src/generation-core/templates.ts +60 -0
- package/src/generation-core/validate-site-plan.ts +413 -0
- package/styles/iox-design.css +16 -0
- package/styles/iox-forms.css +15 -9
|
@@ -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
|
+
}
|
package/styles/iox-design.css
CHANGED
|
@@ -28,3 +28,19 @@ iox-gallery.iox-es-media[data-iox-empty] > .iox-gallery {
|
|
|
28
28
|
.iox-es-media[data-iox-empty] .iox-gallery__overlay {
|
|
29
29
|
display: none;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/* What a filled button HOLDS reads its ink (DS9, the contract's `inkScopes`, pinned by
|
|
33
|
+
ink-scopes.spec.ts). An Icon dropped into a Primary button wore the `icon` style — `icon-color`
|
|
34
|
+
= accent-text, painted on the accent fill: 1:1 in a Brand section. Inside a button every token a
|
|
35
|
+
child's colour reads becomes `currentColor` — the button's own ink — so the label and the icon read
|
|
36
|
+
alike; an author's literal on the child still wins. */
|
|
37
|
+
.iox-es-button-primary,
|
|
38
|
+
.iox-es-button-secondary,
|
|
39
|
+
.iox-es-button-ghost {
|
|
40
|
+
--iox-icon-color: currentColor;
|
|
41
|
+
--iox-heading-color: currentColor;
|
|
42
|
+
--iox-eyebrow-color: currentColor;
|
|
43
|
+
--iox-link-color: currentColor;
|
|
44
|
+
--iox-link-hover-color: currentColor;
|
|
45
|
+
--iox-text-muted: currentColor;
|
|
46
|
+
}
|
package/styles/iox-forms.css
CHANGED
|
@@ -101,8 +101,8 @@
|
|
|
101
101
|
* A Field wearing the `field` element style renders `iox-es iox-es-field`; everything below is
|
|
102
102
|
* scoped to it, so a Field saved before DS2 renders exactly as above whatever tokens the org holds,
|
|
103
103
|
* and seeding an org's tokens never restyles a live legacy form.
|
|
104
|
-
*
|
|
105
|
-
* iox-ui's
|
|
104
|
+
* Every rule below IS the spec's `fieldClassBaseline`, selector and values verbatim (pinned by
|
|
105
|
+
* iox-ui's token-names spec). Values are bare spec tokens: a marked Field only exists where the
|
|
106
106
|
* builder seeded them. */
|
|
107
107
|
.iox-es-field .iox-field__control {
|
|
108
108
|
background: var(--iox-field-bg);
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
transition: border-color var(--iox-dur-base) var(--iox-ease-standard), box-shadow var(--iox-dur-base) var(--iox-ease-standard);
|
|
118
118
|
}
|
|
119
119
|
.iox-es-field .iox-field__control:focus-visible {
|
|
120
|
-
border-color: var(--iox-accent);
|
|
120
|
+
border-color: var(--iox-accent-text);
|
|
121
121
|
box-shadow: var(--iox-field-focus);
|
|
122
122
|
outline: none;
|
|
123
123
|
}
|
|
@@ -138,6 +138,14 @@
|
|
|
138
138
|
.iox-es-field.iox-invalid .iox-field__control {
|
|
139
139
|
border-color: var(--iox-danger);
|
|
140
140
|
}
|
|
141
|
+
/* DS9 (contrast audit): the placeholder is themed (the UA's #757575 read 1:1 in a Brand section); the
|
|
142
|
+
native controls, the focus edge and the switch read `accent-text` — the accent GATED as text on every
|
|
143
|
+
ground — never the `accent` fill (Pop's orange on its yellow panel: 2.13:1); the off track is
|
|
144
|
+
`text-muted` under a `bg` thumb (the decorative `line` it wore read 1.06:1). WCAG 1.4.11: 3:1. */
|
|
145
|
+
.iox-es-field .iox-field__control::placeholder {
|
|
146
|
+
color: var(--iox-text-muted);
|
|
147
|
+
opacity: 1;
|
|
148
|
+
}
|
|
141
149
|
/* Native-appearance controls (checkbox, radio, range — and the switch, a checkbox) draw themselves:
|
|
142
150
|
the box baseline must not frame them. `:where()` keeps this at the baseline's specificity, so the
|
|
143
151
|
switch rules below still paint the track. */
|
|
@@ -145,18 +153,16 @@
|
|
|
145
153
|
border: none;
|
|
146
154
|
padding: 0;
|
|
147
155
|
background: transparent;
|
|
156
|
+
accent-color: var(--iox-accent-text);
|
|
148
157
|
}
|
|
149
158
|
.iox-es-field .iox-field__control--switch {
|
|
150
|
-
background: var(--iox-
|
|
159
|
+
background: var(--iox-text-muted);
|
|
151
160
|
transition: background var(--iox-dur-fast) var(--iox-ease-standard);
|
|
152
161
|
}
|
|
153
162
|
.iox-es-field .iox-field__control--switch::before {
|
|
154
|
-
background: var(--iox-
|
|
163
|
+
background: var(--iox-bg);
|
|
155
164
|
transition: transform var(--iox-dur-fast) var(--iox-ease-standard);
|
|
156
165
|
}
|
|
157
166
|
.iox-es-field .iox-field__control--switch:checked {
|
|
158
|
-
background: var(--iox-accent);
|
|
159
|
-
}
|
|
160
|
-
.iox-es-field .iox-field__control--range {
|
|
161
|
-
accent-color: var(--iox-accent);
|
|
167
|
+
background: var(--iox-accent-text);
|
|
162
168
|
}
|