@vectoriox/iox-ui 5.55.0 → 5.56.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/fesm2022/vectoriox-iox-ui-generation-core.mjs +431 -12
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs +28 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
- package/generation-core/index.d.ts +223 -4
- package/harness-core/index.d.ts +20 -2
- package/package.json +1 -1
- package/src/generation-core/audit-site.ts +386 -0
- package/src/generation-core/public-api.ts +2 -0
- package/src/generation-core/site-build-run.ts +7 -0
- package/src/generation-core/stock-photos.ts +137 -0
- package/src/harness-core/public-api.ts +1 -0
- package/src/harness-core/starting-design.ts +44 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { ELEMENT_ACTION_DESCRIPTORS, resolveActionAvailability } from '@vectoriox/iox-ui/actions-core';
|
|
2
|
+
import { auditDesignContrast, DesignTokenLike } from '@vectoriox/iox-ui/design-core';
|
|
3
|
+
import { collectFormSpec, ControlType, FIELD_NODE_TYPE, FORM_NODE_TYPE } from '@vectoriox/iox-ui/forms-core';
|
|
4
|
+
|
|
5
|
+
import { blocksPublish, ComposedPlaceholder, PlaceholderKind } from './compose-site';
|
|
6
|
+
import { navShapeOf } from './nav-shape';
|
|
7
|
+
import { PatternGlobalPosition } from './patterns';
|
|
8
|
+
import { SerializedNode, walkSerialized } from './serialized-node';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* THE Publishable gate (AI plan §3e, M1-7): ONE pure function over a snapshot of a site — its pages, globals, the
|
|
12
|
+
* connectors' health, the look's token rows, what a build left to fill — run by the Publish dialog, the builder's
|
|
13
|
+
* Needs attention and the server on publish (iox-cms refuses with the same findings). Works on any site, generated
|
|
14
|
+
* or hand-built. Severity is decision D8: an unfilled fact, a policy placeholder or an action that points at nothing
|
|
15
|
+
* BLOCKS; samples, copy placeholders and stock photos ASK ("Publish anyway"); the rest WARN. Design advice
|
|
16
|
+
* (harness-core `lintDesign`) is not one of the eight — the caller adds it, and it never blocks.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export enum SiteCheckId {
|
|
20
|
+
Navigation = 'navigation',
|
|
21
|
+
Forms = 'forms',
|
|
22
|
+
Consent = 'consent',
|
|
23
|
+
Seo = 'seo',
|
|
24
|
+
Facts = 'facts',
|
|
25
|
+
Accessibility = 'accessibility',
|
|
26
|
+
Tokens = 'tokens',
|
|
27
|
+
Actions = 'actions',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The eight, in §3e's order. */
|
|
31
|
+
export const SITE_CHECKS: ReadonlyArray<SiteCheckId> = Object.values(SiteCheckId);
|
|
32
|
+
|
|
33
|
+
export enum CheckSeverity {
|
|
34
|
+
Block = 'block',
|
|
35
|
+
Ask = 'ask',
|
|
36
|
+
Warn = 'warn',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export enum SiteFindingCode {
|
|
40
|
+
NoHeader = 'no-header',
|
|
41
|
+
NoPhoneMenu = 'no-phone-menu',
|
|
42
|
+
FormWithoutId = 'form-without-id',
|
|
43
|
+
EmailNeedsMailer = 'email-needs-mailer',
|
|
44
|
+
TrackerWithoutBanner = 'tracker-without-banner',
|
|
45
|
+
NoTitle = 'no-title',
|
|
46
|
+
NoDescription = 'no-description',
|
|
47
|
+
UnfilledFact = 'unfilled-fact',
|
|
48
|
+
CopyPlaceholder = 'copy-placeholder',
|
|
49
|
+
SampleItems = 'sample-items',
|
|
50
|
+
StockPhoto = 'stock-photo',
|
|
51
|
+
NoPrivacyPage = 'no-privacy-page',
|
|
52
|
+
PrivacyPagePlaceholder = 'privacy-page-placeholder',
|
|
53
|
+
LowContrast = 'low-contrast',
|
|
54
|
+
HeadingOrder = 'heading-order',
|
|
55
|
+
ImageWithoutAlt = 'image-without-alt',
|
|
56
|
+
LiteralColour = 'literal-colour',
|
|
57
|
+
DeadLink = 'dead-link',
|
|
58
|
+
DeadDialogTarget = 'dead-dialog-target',
|
|
59
|
+
ActionNeedsConnector = 'action-needs-connector',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Each finding's severity — one table (D8), never decided at a call site. */
|
|
63
|
+
export const FINDING_SEVERITY: Readonly<Record<SiteFindingCode, CheckSeverity>> = {
|
|
64
|
+
[SiteFindingCode.NoHeader]: CheckSeverity.Warn,
|
|
65
|
+
[SiteFindingCode.NoPhoneMenu]: CheckSeverity.Warn,
|
|
66
|
+
[SiteFindingCode.FormWithoutId]: CheckSeverity.Block,
|
|
67
|
+
[SiteFindingCode.EmailNeedsMailer]: CheckSeverity.Warn,
|
|
68
|
+
[SiteFindingCode.TrackerWithoutBanner]: CheckSeverity.Warn,
|
|
69
|
+
[SiteFindingCode.NoTitle]: CheckSeverity.Warn,
|
|
70
|
+
[SiteFindingCode.NoDescription]: CheckSeverity.Warn,
|
|
71
|
+
[SiteFindingCode.UnfilledFact]: CheckSeverity.Block,
|
|
72
|
+
[SiteFindingCode.CopyPlaceholder]: CheckSeverity.Ask,
|
|
73
|
+
[SiteFindingCode.SampleItems]: CheckSeverity.Ask,
|
|
74
|
+
[SiteFindingCode.StockPhoto]: CheckSeverity.Ask,
|
|
75
|
+
[SiteFindingCode.NoPrivacyPage]: CheckSeverity.Ask,
|
|
76
|
+
[SiteFindingCode.PrivacyPagePlaceholder]: CheckSeverity.Block,
|
|
77
|
+
[SiteFindingCode.LowContrast]: CheckSeverity.Warn,
|
|
78
|
+
[SiteFindingCode.HeadingOrder]: CheckSeverity.Warn,
|
|
79
|
+
[SiteFindingCode.ImageWithoutAlt]: CheckSeverity.Warn,
|
|
80
|
+
[SiteFindingCode.LiteralColour]: CheckSeverity.Warn,
|
|
81
|
+
[SiteFindingCode.DeadLink]: CheckSeverity.Block,
|
|
82
|
+
[SiteFindingCode.DeadDialogTarget]: CheckSeverity.Block,
|
|
83
|
+
[SiteFindingCode.ActionNeedsConnector]: CheckSeverity.Block,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const CHECK_OF: Readonly<Record<SiteFindingCode, SiteCheckId>> = {
|
|
87
|
+
[SiteFindingCode.NoHeader]: SiteCheckId.Navigation,
|
|
88
|
+
[SiteFindingCode.NoPhoneMenu]: SiteCheckId.Navigation,
|
|
89
|
+
[SiteFindingCode.FormWithoutId]: SiteCheckId.Forms,
|
|
90
|
+
[SiteFindingCode.EmailNeedsMailer]: SiteCheckId.Forms,
|
|
91
|
+
[SiteFindingCode.TrackerWithoutBanner]: SiteCheckId.Consent,
|
|
92
|
+
[SiteFindingCode.NoTitle]: SiteCheckId.Seo,
|
|
93
|
+
[SiteFindingCode.NoDescription]: SiteCheckId.Seo,
|
|
94
|
+
[SiteFindingCode.UnfilledFact]: SiteCheckId.Facts,
|
|
95
|
+
[SiteFindingCode.CopyPlaceholder]: SiteCheckId.Facts,
|
|
96
|
+
[SiteFindingCode.SampleItems]: SiteCheckId.Facts,
|
|
97
|
+
[SiteFindingCode.StockPhoto]: SiteCheckId.Facts,
|
|
98
|
+
[SiteFindingCode.NoPrivacyPage]: SiteCheckId.Facts,
|
|
99
|
+
[SiteFindingCode.PrivacyPagePlaceholder]: SiteCheckId.Facts,
|
|
100
|
+
[SiteFindingCode.LowContrast]: SiteCheckId.Accessibility,
|
|
101
|
+
[SiteFindingCode.HeadingOrder]: SiteCheckId.Accessibility,
|
|
102
|
+
[SiteFindingCode.ImageWithoutAlt]: SiteCheckId.Accessibility,
|
|
103
|
+
[SiteFindingCode.LiteralColour]: SiteCheckId.Tokens,
|
|
104
|
+
[SiteFindingCode.DeadLink]: SiteCheckId.Actions,
|
|
105
|
+
[SiteFindingCode.DeadDialogTarget]: SiteCheckId.Actions,
|
|
106
|
+
[SiteFindingCode.ActionNeedsConnector]: SiteCheckId.Actions,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export interface SiteFinding {
|
|
110
|
+
check: SiteCheckId;
|
|
111
|
+
severity: CheckSeverity;
|
|
112
|
+
code: SiteFindingCode;
|
|
113
|
+
/** In plain words, for the screen. */
|
|
114
|
+
message: string;
|
|
115
|
+
pageId?: string;
|
|
116
|
+
nodeId?: string;
|
|
117
|
+
/** The global slot (`header` / `footer`) when it is in a global. */
|
|
118
|
+
owner?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface SiteCheck {
|
|
122
|
+
id: SiteCheckId;
|
|
123
|
+
passed: boolean;
|
|
124
|
+
findings: SiteFinding[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SiteAudit {
|
|
128
|
+
checks: SiteCheck[];
|
|
129
|
+
blocking: number;
|
|
130
|
+
asking: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A page as the gate reads it. */
|
|
134
|
+
export interface AuditPage {
|
|
135
|
+
id: string;
|
|
136
|
+
name: string;
|
|
137
|
+
route: string;
|
|
138
|
+
layout: ReadonlyArray<SerializedNode>;
|
|
139
|
+
seo?: unknown;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A placeholder a build left, and whether the node still shows it (the caller compares the node's text). */
|
|
143
|
+
export interface AuditPlaceholder extends ComposedPlaceholder {
|
|
144
|
+
pageId?: string;
|
|
145
|
+
filled: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SiteAuditSnapshot {
|
|
149
|
+
pages: ReadonlyArray<AuditPage>;
|
|
150
|
+
globals: ReadonlyArray<SerializedNode>;
|
|
151
|
+
/** Connector type → usable (configured or provided, and healthy). */
|
|
152
|
+
connectors: Readonly<Record<string, boolean>>;
|
|
153
|
+
/** Connector types that set a tracker (a consent category) and are configured. */
|
|
154
|
+
trackers?: ReadonlyArray<string>;
|
|
155
|
+
/** The look's token rows and the mode(s) the site wears. */
|
|
156
|
+
design?: { rows: ReadonlyArray<DesignTokenLike>; modes: ReadonlyArray<'light' | 'dark'> } | null;
|
|
157
|
+
placeholders?: ReadonlyArray<AuditPlaceholder>;
|
|
158
|
+
/** Sample items still in the site (`isSample`). */
|
|
159
|
+
samples?: ReadonlyArray<{ contentType: string; id: string; label?: string }>;
|
|
160
|
+
/** Stock photos a build placed that are still on the page. */
|
|
161
|
+
stock?: ReadonlyArray<{ nodeId: string; pageId?: string; owner?: string }>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface SiteAuditOptions {
|
|
165
|
+
/** Audit as if only this page were being published (its own page checks + every site-wide one). */
|
|
166
|
+
pageId?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const LINKING_TYPES = new Set(['Link', 'Heading', 'Button', 'ButtonBlock', 'LinkedContainer', 'Image']);
|
|
170
|
+
const PAGE_LINK = 'page';
|
|
171
|
+
const PERSONAL_CONTROLS: ReadonlySet<string> = new Set([ControlType.Email, ControlType.Tel, ControlType.Text, ControlType.Textarea]);
|
|
172
|
+
const PRIVACY_PAGE = /privacy|פרטיות|מדיניות/i;
|
|
173
|
+
const MAILER = 'mailer';
|
|
174
|
+
const EMAIL_DELIVERY = 'email';
|
|
175
|
+
const LITERAL_COLOUR = /#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i;
|
|
176
|
+
const COLOUR_TRAITS = /color|Color|^background$|boxShadow|textShadow/;
|
|
177
|
+
const HEADING_LEVEL = /^h([1-6])$/;
|
|
178
|
+
const DIALOG_TYPE = 'Dialog';
|
|
179
|
+
const SELF = 'self';
|
|
180
|
+
|
|
181
|
+
/** A route pattern (`/work/:slug`) matches a concrete path (`/work/blue-bowl`). */
|
|
182
|
+
export function routeMatches(pattern: string, path: string): boolean {
|
|
183
|
+
const a = pattern.split('/').filter(Boolean);
|
|
184
|
+
const b = String(path ?? '').split(/[?#]/)[0].split('/').filter(Boolean);
|
|
185
|
+
return a.length === b.length && a.every((seg, i) => seg.startsWith(':') || seg === b[i]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const nonEmpty = (v: unknown) => typeof v === 'string' && v.trim() !== '';
|
|
189
|
+
|
|
190
|
+
/** A stored SEO field (a string, or `{ override, binding, auto }`) has a value, or may be derived. */
|
|
191
|
+
function seoField(raw: unknown, derivable: boolean): boolean {
|
|
192
|
+
if (nonEmpty(raw)) return true;
|
|
193
|
+
if (raw && typeof raw === 'object') {
|
|
194
|
+
const v = raw as { override?: unknown; binding?: unknown; auto?: unknown };
|
|
195
|
+
if (nonEmpty(v.override) || v.binding) return true;
|
|
196
|
+
if (v.auto === false) return false;
|
|
197
|
+
}
|
|
198
|
+
return derivable;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hasContent(layout: ReadonlyArray<SerializedNode>, type: string): boolean {
|
|
202
|
+
let found = false;
|
|
203
|
+
walkSerialized(layout, n => {
|
|
204
|
+
if (n.type !== type || found) return;
|
|
205
|
+
found = nonEmpty(n.props['content']) || n.bindings.some(b => b.prop === 'content');
|
|
206
|
+
});
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface Ctx {
|
|
211
|
+
out: SiteFinding[];
|
|
212
|
+
routes: string[];
|
|
213
|
+
nodeIds: Map<string, string>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function add(ctx: Ctx, code: SiteFindingCode, message: string, where: { pageId?: string; nodeId?: string; owner?: string } = {}): void {
|
|
217
|
+
ctx.out.push({ check: CHECK_OF[code], severity: FINDING_SEVERITY[code], code, message, ...where });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Every node of a page or a global, with where it lives. */
|
|
221
|
+
function eachNode(snapshot: SiteAuditSnapshot, pages: ReadonlyArray<AuditPage>, visit: (n: SerializedNode, where: { pageId?: string; owner?: string }) => void): void {
|
|
222
|
+
for (const page of pages) walkSerialized(page.layout, n => visit(n, { pageId: page.id }));
|
|
223
|
+
for (const global of snapshot.globals) {
|
|
224
|
+
const owner = global.globalPosition === PatternGlobalPosition.Before ? 'header' : 'footer';
|
|
225
|
+
walkSerialized([global], n => visit(n, { owner }));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function checkNavigation(ctx: Ctx, snapshot: SiteAuditSnapshot): void {
|
|
230
|
+
const header = snapshot.globals.find(g => g.globalPosition === PatternGlobalPosition.Before);
|
|
231
|
+
if (!header) return add(ctx, SiteFindingCode.NoHeader, 'The site has no header, so visitors have no menu to move around with.');
|
|
232
|
+
if (!navShapeOf(snapshot.globals)) add(ctx, SiteFindingCode.NoPhoneMenu, 'The header has no phone menu — on a phone its links are hard to reach.', { owner: 'header', nodeId: header.id });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function checkForms(ctx: Ctx, snapshot: SiteAuditSnapshot, pages: ReadonlyArray<AuditPage>): boolean {
|
|
236
|
+
let personal = false;
|
|
237
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
238
|
+
if (n.type !== FORM_NODE_TYPE) return;
|
|
239
|
+
const spec = collectFormSpec(n as never);
|
|
240
|
+
if (!spec || !nonEmpty(spec.formId)) add(ctx, SiteFindingCode.FormWithoutId, 'A form has no id, so what visitors send is never stored.', { ...where, nodeId: n.id });
|
|
241
|
+
if (spec?.actions.some(a => a.type === EMAIL_DELIVERY) && snapshot.connectors[MAILER] !== true) {
|
|
242
|
+
add(ctx, SiteFindingCode.EmailNeedsMailer, 'A form emails what visitors send, but Email is not connected — it is stored and never sent.', { ...where, nodeId: n.id });
|
|
243
|
+
}
|
|
244
|
+
walkSerialized(n.children ?? [], f => {
|
|
245
|
+
if (f.type === FIELD_NODE_TYPE && PERSONAL_CONTROLS.has(String(f.props['control'] ?? ControlType.Text))) personal = true;
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
return personal;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function checkConsent(ctx: Ctx, snapshot: SiteAuditSnapshot): void {
|
|
252
|
+
if (!snapshot.trackers?.length) return;
|
|
253
|
+
let banner = false;
|
|
254
|
+
for (const g of snapshot.globals) walkSerialized([g], n => { if (n.type === DIALOG_TYPE && n.props['consentRole'] === true) banner = true; });
|
|
255
|
+
if (!banner) add(ctx, SiteFindingCode.TrackerWithoutBanner, `${snapshot.trackers.join(', ')} is connected, but the site has no cookie banner — it stays off for every visitor.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function checkSeo(ctx: Ctx, pages: ReadonlyArray<AuditPage>): void {
|
|
259
|
+
for (const page of pages) {
|
|
260
|
+
const seo = (page.seo ?? {}) as Record<string, unknown>;
|
|
261
|
+
if (!seoField(seo['title'], hasContent(page.layout, 'Heading'))) add(ctx, SiteFindingCode.NoTitle, `"${page.name}" has no title for search results.`, { pageId: page.id });
|
|
262
|
+
if (!seoField(seo['description'], hasContent(page.layout, 'Text'))) add(ctx, SiteFindingCode.NoDescription, `"${page.name}" has no description for search results.`, { pageId: page.id });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function checkFacts(ctx: Ctx, snapshot: SiteAuditSnapshot, pages: ReadonlyArray<AuditPage>, personal: boolean): void {
|
|
267
|
+
const inScope = (p: { pageId?: string }) => !p.pageId || pages.some(page => page.id === p.pageId);
|
|
268
|
+
for (const p of (snapshot.placeholders ?? []).filter(p => !p.filled && inScope(p))) {
|
|
269
|
+
const where = { pageId: p.pageId, nodeId: p.nodeId, ...(p.pageId ? {} : { owner: p.owner }) };
|
|
270
|
+
if (blocksPublish(p.kind)) add(ctx, SiteFindingCode.UnfilledFact, `"${p.text || p.fact || p.kind}" is still blank — fill it in before the site goes live.`, where);
|
|
271
|
+
else if (p.kind === PlaceholderKind.Copy) add(ctx, SiteFindingCode.CopyPlaceholder, 'Some words are still the template\'s.', where);
|
|
272
|
+
}
|
|
273
|
+
for (const s of snapshot.samples ?? []) add(ctx, SiteFindingCode.SampleItems, `"${s.label ?? s.id}" is a sample — replace or remove it.`, { nodeId: s.id });
|
|
274
|
+
for (const s of (snapshot.stock ?? []).filter(inScope)) add(ctx, SiteFindingCode.StockPhoto, 'A stock photo stands in for one of yours.', { pageId: s.pageId, nodeId: s.nodeId, owner: s.owner });
|
|
275
|
+
if (!personal) return;
|
|
276
|
+
const privacy = snapshot.pages.find(p => PRIVACY_PAGE.test(p.route) || PRIVACY_PAGE.test(p.name));
|
|
277
|
+
if (!privacy) return add(ctx, SiteFindingCode.NoPrivacyPage, 'A form asks for personal details, and the site has no privacy page saying what you do with them.');
|
|
278
|
+
if ((snapshot.placeholders ?? []).some(p => p.pageId === privacy.id && !p.filled)) {
|
|
279
|
+
add(ctx, SiteFindingCode.PrivacyPagePlaceholder, 'The privacy page still holds template words — only you can write it.', { pageId: privacy.id });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function checkAccessibility(ctx: Ctx, snapshot: SiteAuditSnapshot, pages: ReadonlyArray<AuditPage>): void {
|
|
284
|
+
for (const mode of snapshot.design?.modes ?? []) {
|
|
285
|
+
const failing = auditDesignContrast(snapshot.design!.rows, mode).filter(f => !f.pass);
|
|
286
|
+
if (failing.length) add(ctx, SiteFindingCode.LowContrast, `${failing.length} text and background pair(s) are hard to read in ${mode} mode.`);
|
|
287
|
+
}
|
|
288
|
+
for (const page of pages) {
|
|
289
|
+
let last = 0;
|
|
290
|
+
let h1 = 0;
|
|
291
|
+
walkSerialized(page.layout, n => {
|
|
292
|
+
const level = n.type === 'Heading' ? HEADING_LEVEL.exec(String(n.props['level'] ?? ''))?.[1] : undefined;
|
|
293
|
+
if (!level) return;
|
|
294
|
+
const l = Number(level);
|
|
295
|
+
if (l === 1) h1++;
|
|
296
|
+
if (last && l > last + 1) add(ctx, SiteFindingCode.HeadingOrder, `A heading on "${page.name}" skips a level (h${last} → h${l}).`, { pageId: page.id, nodeId: n.id });
|
|
297
|
+
last = l;
|
|
298
|
+
});
|
|
299
|
+
if (h1 > 1) add(ctx, SiteFindingCode.HeadingOrder, `"${page.name}" has ${h1} main headings (h1); one is clearer.`, { pageId: page.id });
|
|
300
|
+
}
|
|
301
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
302
|
+
if (n.type !== 'Image') return;
|
|
303
|
+
const decorative = n.props['alt'] === '' && n.props['decorative'] === true;
|
|
304
|
+
const bound = n.bindings.some(b => b.prop === 'alt');
|
|
305
|
+
if (!decorative && !bound && !nonEmpty(n.props['alt'])) add(ctx, SiteFindingCode.ImageWithoutAlt, 'A picture has no description for people who cannot see it.', { ...where, nodeId: n.id });
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function checkTokens(ctx: Ctx, snapshot: SiteAuditSnapshot, pages: ReadonlyArray<AuditPage>): void {
|
|
310
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
311
|
+
if (!n.elementStyle) return;
|
|
312
|
+
const maps: Array<Record<string, unknown> | undefined> = [n.styleProps, ...Object.values(n.stateStyles ?? {}), ...Object.values(n.responsive ?? {}).map(l => l?.styleProps)];
|
|
313
|
+
const literal = maps.some(map => Object.entries(map ?? {}).some(([k, v]) =>
|
|
314
|
+
COLOUR_TRAITS.test(k) && LITERAL_COLOUR.test(String(v)) && !(k === 'borderColor' && n.styleProps['borderWidth'] === '0px')));
|
|
315
|
+
if (literal) add(ctx, SiteFindingCode.LiteralColour, 'An element paints a fixed colour, so a new look will not reach it.', { ...where, nodeId: n.id });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function checkActions(ctx: Ctx, snapshot: SiteAuditSnapshot, pages: ReadonlyArray<AuditPage>): void {
|
|
320
|
+
const routeExists = (route: unknown) => typeof route === 'string' && ctx.routes.some(r => routeMatches(r, route));
|
|
321
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
322
|
+
if (LINKING_TYPES.has(n.type) && n.props['linkType'] === PAGE_LINK && !routeExists(n.props['pageRoute'])) {
|
|
323
|
+
add(ctx, SiteFindingCode.DeadLink, `"${String(n.props['label'] ?? n.props['content'] ?? n.label ?? n.type)}" links to ${String(n.props['pageRoute'] || 'no page')}, which is not a page of the site.`, { ...where, nodeId: n.id });
|
|
324
|
+
}
|
|
325
|
+
for (const ix of (n.interactions ?? []) as Array<{ actions?: Array<{ type?: string; target?: string; params?: Record<string, unknown>; disabled?: boolean }> }>) {
|
|
326
|
+
for (const action of ix?.actions ?? []) {
|
|
327
|
+
if (!action || action.disabled) continue;
|
|
328
|
+
const descriptor = ELEMENT_ACTION_DESCRIPTORS.find(d => d.type === action.type);
|
|
329
|
+
if (!descriptor) continue;
|
|
330
|
+
const types = descriptor.target?.types;
|
|
331
|
+
if (descriptor.target?.required && action.target !== SELF) {
|
|
332
|
+
const targetType = action.target ? ctx.nodeIds.get(action.target) : undefined;
|
|
333
|
+
if (!targetType || (types && !types.includes(targetType))) {
|
|
334
|
+
add(ctx, SiteFindingCode.DeadDialogTarget, `"${n.label ?? n.type}" opens something that is no longer on the site.`, { ...where, nodeId: n.id });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (action.type === 'navigate' && action.params?.['navigateTo'] === PAGE_LINK && !routeExists(action.params?.['page'])) {
|
|
338
|
+
add(ctx, SiteFindingCode.DeadLink, `"${n.label ?? n.type}" goes to a page the site does not have.`, { ...where, nodeId: n.id });
|
|
339
|
+
}
|
|
340
|
+
// A connector the snapshot does not say is usable is not: unknown is never "fine".
|
|
341
|
+
const connectors = descriptor.connector ? { [descriptor.connector]: snapshot.connectors[descriptor.connector] === true } : {};
|
|
342
|
+
const availability = resolveActionAvailability(descriptor, { connectors });
|
|
343
|
+
if (availability.state === 'connector-missing') {
|
|
344
|
+
add(ctx, SiteFindingCode.ActionNeedsConnector, `"${n.label ?? n.type}" needs ${availability.connector} connected.`, { ...where, nodeId: n.id });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Run the eight checks. `pageId` narrows the page checks to the page being published; site-wide ones always run. */
|
|
352
|
+
export function auditSite(snapshot: SiteAuditSnapshot, options: SiteAuditOptions = {}): SiteAudit {
|
|
353
|
+
const pages = options.pageId ? snapshot.pages.filter(p => p.id === options.pageId) : snapshot.pages;
|
|
354
|
+
const nodeIds = new Map<string, string>();
|
|
355
|
+
eachNode(snapshot, snapshot.pages, n => nodeIds.set(n.id, n.type));
|
|
356
|
+
const ctx: Ctx = { out: [], routes: snapshot.pages.map(p => p.route), nodeIds };
|
|
357
|
+
checkNavigation(ctx, snapshot);
|
|
358
|
+
const personal = checkForms(ctx, snapshot, pages);
|
|
359
|
+
checkConsent(ctx, snapshot);
|
|
360
|
+
checkSeo(ctx, pages);
|
|
361
|
+
checkFacts(ctx, snapshot, pages, personal);
|
|
362
|
+
checkAccessibility(ctx, snapshot, pages);
|
|
363
|
+
checkTokens(ctx, snapshot, pages);
|
|
364
|
+
checkActions(ctx, snapshot, pages);
|
|
365
|
+
const checks = SITE_CHECKS.map(id => {
|
|
366
|
+
const findings = ctx.out.filter(f => f.check === id);
|
|
367
|
+
return { id, passed: !findings.some(f => f.severity !== CheckSeverity.Warn), findings };
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
checks,
|
|
371
|
+
blocking: ctx.out.filter(f => f.severity === CheckSeverity.Block).length,
|
|
372
|
+
asking: ctx.out.filter(f => f.severity === CheckSeverity.Ask).length,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** What a publish request must acknowledge: every ASK finding's code (a BLOCK cannot be acknowledged). */
|
|
377
|
+
export function requiredAcknowledgements(audit: SiteAudit): SiteFindingCode[] {
|
|
378
|
+
return [...new Set(audit.checks.flatMap(c => c.findings).filter(f => f.severity === CheckSeverity.Ask).map(f => f.code))];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The publish verdict: blocked (never acknowledgeable), needs these acknowledgements, or free to go. */
|
|
382
|
+
export function publishVerdict(audit: SiteAudit, acknowledged: ReadonlyArray<string> = []): { ok: boolean; blocked: boolean; missing: SiteFindingCode[] } {
|
|
383
|
+
const missing = requiredAcknowledgements(audit).filter(code => !acknowledged.includes(code));
|
|
384
|
+
const blocked = audit.blocking > 0;
|
|
385
|
+
return { ok: !blocked && missing.length === 0, blocked, missing };
|
|
386
|
+
}
|
|
@@ -29,6 +29,8 @@ export enum BuildStep {
|
|
|
29
29
|
Preflight = 'preflight',
|
|
30
30
|
ContentTypes = 'content-types',
|
|
31
31
|
SampleImages = 'sample-images',
|
|
32
|
+
/** I2: the curated stock photos the plan's empty picture slots take, copied into the studio (never hot-linked). */
|
|
33
|
+
StockPhotos = 'stock-photos',
|
|
32
34
|
Items = 'items',
|
|
33
35
|
DataSources = 'data-sources',
|
|
34
36
|
Pages = 'pages',
|
|
@@ -51,6 +53,8 @@ export enum CreatedKind {
|
|
|
51
53
|
/** Fields the build added to a content type it found (types are shared by every organization of a studio). */
|
|
52
54
|
ContentTypeFields = 'content-type-fields',
|
|
53
55
|
SampleImage = 'sample-image',
|
|
56
|
+
/** A stock photo copied into the studio's storage (`media/stock/…`), with its assets row. */
|
|
57
|
+
StockPhoto = 'stock-photo',
|
|
54
58
|
Item = 'item',
|
|
55
59
|
DataSource = 'data-source',
|
|
56
60
|
Page = 'page',
|
|
@@ -120,6 +124,8 @@ export enum BuildNoticeCode {
|
|
|
120
124
|
NoStorage = 'no-storage',
|
|
121
125
|
/** A content type the build reuses is not public in this studio: the site will not show its items. */
|
|
122
126
|
ContentTypeNotPublic = 'content-type-not-public',
|
|
127
|
+
/** The stock library could not be read: the picture slots keep the empty box, and the run says so. */
|
|
128
|
+
StockUnavailable = 'stock-unavailable',
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
export interface BuildNotice {
|
|
@@ -195,6 +201,7 @@ export const buildKeys = {
|
|
|
195
201
|
contentTypeFields: (name: string) => `schema-fields:${name}`,
|
|
196
202
|
item: (contentType: string, n: number) => `item:${contentType}:${n}`,
|
|
197
203
|
sampleImage: (n: number) => `sample-image:${n}`,
|
|
204
|
+
stockPhoto: (libraryKey: string) => `stock-photo:${libraryKey}`,
|
|
198
205
|
dataSource: (alias: string) => `data-source:${alias}`,
|
|
199
206
|
page: (key: string) => `page:${key}`,
|
|
200
207
|
global: (nodeId: string) => `global:${nodeId}`,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ComposedSite, PlaceholderKind } from './compose-site';
|
|
2
|
+
import { patternById } from './patterns';
|
|
3
|
+
import { createSerializedNode, SerializedNode, walkSerialized } from './serialized-node';
|
|
4
|
+
import { SitePlan } from './site-plan.model';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stock photos in a built site (AI plan §3g / §3m, I2): a FREE picture from the platform's curated library, copied
|
|
8
|
+
* into the studio's own storage (never hot-linked), placed only in an EMPTY picture slot of a section that may show
|
|
9
|
+
* one — never the owner's work (a collection, a detail page, a gallery: what the nine portfolio types sell is theirs),
|
|
10
|
+
* never a person (testimonials, the team), never anywhere when the business type says `stock: false`. Its alt text is
|
|
11
|
+
* the provider's description of what it shows (never a claim), and the credit line its licence requires is placed in
|
|
12
|
+
* the footer. Pure: the build step does the copying.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Where a stock photo comes from (the library's provider). */
|
|
16
|
+
export enum StockProviderId {
|
|
17
|
+
Magnific = 'magnific',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What a picture's licence asks of the site that shows it. */
|
|
21
|
+
export interface StockCredit {
|
|
22
|
+
provider: `${StockProviderId}`;
|
|
23
|
+
/** The provider's id of the picture. */
|
|
24
|
+
resourceId: string;
|
|
25
|
+
/** The provider's page for it. */
|
|
26
|
+
sourceUrl: string;
|
|
27
|
+
/** The provider's own title — what the picture shows. */
|
|
28
|
+
title: string;
|
|
29
|
+
author: string;
|
|
30
|
+
/** `freemium` — the free licence (premium pictures never enter the library). */
|
|
31
|
+
licence: string;
|
|
32
|
+
licenceUrl: string;
|
|
33
|
+
/** The words the licence requires, and where they link. */
|
|
34
|
+
line: string;
|
|
35
|
+
lineUrl: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One picture of the platform library (`iox-stock/<type>/`), as its manifest lists it. */
|
|
39
|
+
export interface StockPhoto {
|
|
40
|
+
/** The library key (`spa/12345.jpg`) — the copy's file name is derived from it. */
|
|
41
|
+
key: string;
|
|
42
|
+
/** Where the build downloads it from (the platform bucket). */
|
|
43
|
+
url: string;
|
|
44
|
+
/** What it shows, in the provider's words — the alt text. Never a claim. */
|
|
45
|
+
alt: string;
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
credit: StockCredit;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The section families a stock photo may stand in for a picture in. */
|
|
52
|
+
export const STOCK_FAMILIES: ReadonlySet<string> = new Set(['hero', 'feature', 'content', 'story', 'cta', 'contact', 'header']);
|
|
53
|
+
|
|
54
|
+
export interface StockSlot {
|
|
55
|
+
/** The Image node the photo goes into. */
|
|
56
|
+
nodeId: string;
|
|
57
|
+
/** The page key it lives on. */
|
|
58
|
+
owner: string;
|
|
59
|
+
pattern: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The empty picture slots of a composed site a stock photo may fill, in page and section order. */
|
|
63
|
+
export function stockSlots(plan: SitePlan, composed: Pick<ComposedSite, 'pages' | 'placeholders'>): StockSlot[] {
|
|
64
|
+
const empty = new Set(composed.placeholders.filter(p => p.kind === PlaceholderKind.Image).map(p => p.nodeId));
|
|
65
|
+
const out: StockSlot[] = [];
|
|
66
|
+
composed.pages.forEach((page, p) => {
|
|
67
|
+
page.layout.forEach((root, s) => {
|
|
68
|
+
const pattern = plan.pages[p]?.sections[s]?.pattern;
|
|
69
|
+
const family = patternById(pattern)?.family;
|
|
70
|
+
if (!pattern || !family || !STOCK_FAMILIES.has(family)) return;
|
|
71
|
+
walkSerialized([root], n => {
|
|
72
|
+
if (n.type === 'Image' && empty.has(n.id) && !n.bindings.length) out.push({ nodeId: n.id, owner: page.key, pattern });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface StockPlacement extends StockSlot {
|
|
80
|
+
photo: StockPhoto;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Which photo goes where: the library's photos in order, one per slot, never twice; none when the type says no stock. */
|
|
84
|
+
export function planStockPhotos(slots: ReadonlyArray<StockSlot>, photos: ReadonlyArray<StockPhoto>, stockAllowed: boolean): StockPlacement[] {
|
|
85
|
+
if (!stockAllowed) return [];
|
|
86
|
+
return slots.slice(0, photos.length).map((slot, i) => ({ ...slot, photo: photos[i] }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A placed photo's copy in the studio's storage: the key the build writes it under. */
|
|
90
|
+
export function stockCopyKey(photo: Pick<StockPhoto, 'key'>): string {
|
|
91
|
+
return `media/stock/${photo.key.replace(/[^a-zA-Z0-9._-]+/g, '-')}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Put the studio's copy of each photo into its slot (src + the provider's alt). Returns the nodes changed. */
|
|
95
|
+
export function applyStockPhotos(trees: ReadonlyArray<SerializedNode>, placed: ReadonlyArray<{ nodeId: string; url: string; alt: string }>): number {
|
|
96
|
+
const byId = new Map(placed.map(p => [p.nodeId, p]));
|
|
97
|
+
let changed = 0;
|
|
98
|
+
walkSerialized(trees, n => {
|
|
99
|
+
const p = byId.get(n.id);
|
|
100
|
+
if (!p || n.type !== 'Image') return;
|
|
101
|
+
n.props['src'] = p.url;
|
|
102
|
+
n.props['alt'] = p.alt;
|
|
103
|
+
changed++;
|
|
104
|
+
});
|
|
105
|
+
return changed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The credit lines a set of photos needs — one per distinct line (a free Magnific photo: "Designed by Magnific"). */
|
|
109
|
+
export function stockCreditLines(photos: ReadonlyArray<Pick<StockPhoto, 'credit'>>): Array<{ line: string; url: string }> {
|
|
110
|
+
const seen = new Map<string, string>();
|
|
111
|
+
for (const p of photos) if (!seen.has(p.credit.line)) seen.set(p.credit.line, p.credit.lineUrl);
|
|
112
|
+
return [...seen.entries()].map(([line, url]) => ({ line, url }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The footer node id of a credit line (stable — a build that runs twice finds it). */
|
|
116
|
+
export const stockCreditNodeId = (index: number) => `stock-credit-${index + 1}`;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The credit line, as the builder would create a Link (a plain node the owner may move or restyle): the licence's words,
|
|
120
|
+
* linking where it asks, opening in a new tab. Placed at the end of the footer — the place the licence names for a site.
|
|
121
|
+
*/
|
|
122
|
+
export function stockCreditNode(index: number, credit: { line: string; url: string }): SerializedNode {
|
|
123
|
+
return createSerializedNode(stockCreditNodeId(index), 'Link', { label: credit.line, linkType: 'external', url: credit.url, target: '_blank' }, 'link');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Append the credit lines to the footer root (once each). Returns how many it added. */
|
|
127
|
+
export function addStockCredits(footer: SerializedNode, credits: ReadonlyArray<{ line: string; url: string }>): number {
|
|
128
|
+
footer.children ??= [];
|
|
129
|
+
let added = 0;
|
|
130
|
+
credits.forEach((credit, i) => {
|
|
131
|
+
const id = stockCreditNodeId(i);
|
|
132
|
+
if (footer.children!.some(c => c.id === id)) return;
|
|
133
|
+
footer.children!.push(stockCreditNode(i, credit));
|
|
134
|
+
added++;
|
|
135
|
+
});
|
|
136
|
+
return added;
|
|
137
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DesignChoices, presetChoices } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
|
|
3
|
+
import { motionFor, MotionInput } from './motion';
|
|
4
|
+
import { RankInput, rankWithReferences, StyleRead } from './rank';
|
|
5
|
+
import { referenceSeed } from './reference-colour';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The look a business STARTS from (AI plan §3d "Build with these", §3o, MO-D3) — ONE rule for the wizard's Design step
|
|
9
|
+
* (a Look pick) and the server's "Build with these": the preset at its defaults, Scrolling · How animated · Between
|
|
10
|
+
* pages started from the kind of business (and the references), the owner's own Motion choices kept, and — given
|
|
11
|
+
* references — the best-ranked style with the reference colour as its seed when that colour passes AA in every mode
|
|
12
|
+
* the style offers (`referenceSeed`). Pure.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A preset for this business: its choices, Motion from the business (`motionFor`), what the owner already set kept. */
|
|
16
|
+
export function presetDesign(
|
|
17
|
+
presetId: string,
|
|
18
|
+
input: MotionInput | string | null,
|
|
19
|
+
kept: Partial<Pick<DesignChoices, 'scroll' | 'animate' | 'between' | 'motion'>> = {},
|
|
20
|
+
read: StyleRead | null = null,
|
|
21
|
+
): DesignChoices | null {
|
|
22
|
+
const base = presetChoices(presetId);
|
|
23
|
+
if (!base) return null;
|
|
24
|
+
const motionInput: MotionInput = typeof input === 'string' || input === null ? { businessType: input } : input;
|
|
25
|
+
const start = motionFor(motionInput, presetId, read);
|
|
26
|
+
return { ...base, scroll: start.scroll, animate: start.animate, between: start.between, ...kept };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StartingDesign {
|
|
30
|
+
presetId: string;
|
|
31
|
+
design: DesignChoices;
|
|
32
|
+
/** The reference colour was used as the seed (it passed AA in every mode the style offers). */
|
|
33
|
+
referenceColour: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The look a business starts from: its best-ranked style (with the references' boost), at its defaults. */
|
|
37
|
+
export function startingDesign(input: (RankInput & MotionInput) | string | null, read: StyleRead | null = null): StartingDesign | null {
|
|
38
|
+
const rankInput: RankInput & MotionInput = typeof input === 'string' || input === null ? { businessType: input } : input;
|
|
39
|
+
const top = rankWithReferences(rankInput, read)[0];
|
|
40
|
+
const design = top ? presetDesign(top.id, rankInput, {}, read) : null;
|
|
41
|
+
if (!top || !design) return null;
|
|
42
|
+
const seed = referenceSeed(read, top.id);
|
|
43
|
+
return seed?.used ? { presetId: top.id, design: { ...design, seed: seed.seed }, referenceColour: true } : { presetId: top.id, design, referenceColour: false };
|
|
44
|
+
}
|