@vectoriox/iox-ui 5.54.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.
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { requiredShapeKeys, ShapeEntry, shapeKeys } from './shape-check';
2
+
3
+ /**
4
+ * The ONE set of builders every structured-output format is written with (AI plan §3c, M0-5 / M1-1 / M1-3 / M1-5):
5
+ * the SiteAnalysis, the reading, the Site Plan and the plan edits. Each object is built FROM a documented shape table
6
+ * (`shape-check`): its keys are the table's keys, the required ones are the table's required ones, each described in
7
+ * the table's own words — and a builder handed a key list that disagrees with its table THROWS, so a shape that gains
8
+ * a key cannot ship a format that silently lacks it. The subset is the strictest both providers' native strict modes
9
+ * accept (Anthropic `output_config.format`, OpenRouter `json_schema` + `strict`): every object closed
10
+ * (`additionalProperties: false`), unions as `anyOf`, single values as a one-entry `enum`, no length / count / number
11
+ * bounds, no `$ref`. The format is a hint the provider enforces; the shared validator stays the gate.
12
+ */
13
+
14
+ export type JsonSchema = { [key: string]: unknown };
15
+
16
+ const OPTIONAL_MARK = '?';
17
+
18
+ export class JsonSchemaShapeError extends Error {}
19
+
20
+ export const jsonText = (): JsonSchema => ({ type: 'string' });
21
+ export const jsonBoolean = (): JsonSchema => ({ type: 'boolean' });
22
+ export const jsonInteger = (): JsonSchema => ({ type: 'integer' });
23
+ export const jsonOneOf = (values: ReadonlyArray<string>): JsonSchema => ({ type: 'string', enum: [...values] });
24
+ export const jsonListOf = (items: JsonSchema): JsonSchema => ({ type: 'array', items });
25
+ export const jsonAnyOf = (options: ReadonlyArray<JsonSchema>): JsonSchema => ({ anyOf: [...options] });
26
+
27
+ /** A closed object with exactly these properties. */
28
+ export function jsonClosedObject(properties: Record<string, JsonSchema>, required: ReadonlyArray<string>, description?: string): JsonSchema {
29
+ return {
30
+ type: 'object',
31
+ properties,
32
+ required: [...required],
33
+ additionalProperties: false,
34
+ ...(description ? { description } : {}),
35
+ };
36
+ }
37
+
38
+ /** The table's own words for a key (`key` or `key?`). */
39
+ export function describeShapeKey(entry: ShapeEntry, key: string): string {
40
+ return String(entry[key] ?? entry[`${key}${OPTIONAL_MARK}`] ?? '');
41
+ }
42
+
43
+ /** An entry's `[name]` keys (the object's own field names) written as ONE list on the wire — no strict mode can say
44
+ * "any key", so the map travels as `[{ name, value }]` under `key` and the decoder turns it back. */
45
+ export interface DynamicKeysAsList {
46
+ /** The wire key holding the list. */
47
+ key: string;
48
+ /** The list's schema. */
49
+ schema: JsonSchema;
50
+ }
51
+
52
+ /**
53
+ * An object of one shape-table entry: exactly its fixed keys, its required keys required, each described by the
54
+ * table; an entry's `[name]` keys are not object keys — `dynamic` writes them as one list. Throws when the properties
55
+ * given are not the table's keys.
56
+ */
57
+ export function jsonShapedObject(entry: ShapeEntry, properties: Record<string, JsonSchema>, name = 'shape', dynamic?: DynamicKeysAsList): JsonSchema {
58
+ const expected = [...shapeKeys(entry)].sort();
59
+ const given = Object.keys(properties).sort();
60
+ if (expected.join() !== given.join()) {
61
+ throw new JsonSchemaShapeError(`${name}: the format's keys (${given.join(', ')}) are not the shape table's (${expected.join(', ')})`);
62
+ }
63
+ const dynamicKey = Object.keys(entry).find(k => /^\[[^\]]+\]\??$/.test(k));
64
+ if (!!dynamic !== !!dynamicKey) throw new JsonSchemaShapeError(`${name}: ${dynamicKey ? `its ${dynamicKey} keys need a list on the wire` : 'it has no [name] keys to list'}`);
65
+ const described = Object.fromEntries(Object.entries(properties).map(([key, schema]) => [key, { ...schema, description: describeShapeKey(entry, key) }]));
66
+ if (dynamic && dynamicKey) described[dynamic.key] = { ...dynamic.schema, description: `${dynamicKey}: ${entry[dynamicKey]}` };
67
+ return jsonClosedObject(described, requiredShapeKeys(entry));
68
+ }
69
+
70
+ /** Every keyword a schema uses — what a spec checks against the strict subset. */
71
+ export function jsonSchemaKeywords(node: unknown, out = new Set<string>()): Set<string> {
72
+ if (Array.isArray(node)) node.forEach(n => jsonSchemaKeywords(n, out));
73
+ else if (node && typeof node === 'object') {
74
+ for (const [k, v] of Object.entries(node)) {
75
+ out.add(k);
76
+ if (k === 'properties') Object.values(v as object).forEach(p => jsonSchemaKeywords(p, out));
77
+ else if (k !== 'enum' && k !== 'required' && k !== 'description') jsonSchemaKeywords(v, out);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** The keywords the strict subset never uses. */
84
+ export const JSON_SCHEMA_BANNED_KEYWORDS: ReadonlyArray<string> = [
85
+ 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'pattern', 'oneOf', '$ref', 'const', 'format',
86
+ ];
87
+
88
+ /** Every object node of a schema (through properties, items and anyOf). */
89
+ export function jsonSchemaObjects(node: unknown, out: JsonSchema[] = []): JsonSchema[] {
90
+ if (!node || typeof node !== 'object') return out;
91
+ const n = node as JsonSchema;
92
+ if (n['type'] === 'object') out.push(n);
93
+ for (const p of Object.values((n['properties'] as object | undefined) ?? {})) jsonSchemaObjects(p, out);
94
+ if (n['items']) jsonSchemaObjects(n['items'], out);
95
+ for (const option of (n['anyOf'] as unknown[] | undefined) ?? []) jsonSchemaObjects(option, out);
96
+ return out;
97
+ }