import { FORBIDDEN_COLOR_RE, INTERACTIVE_DEMO_CAPS, isHumanStringKey, } from './constants' import { interactiveDemoV1Schema } from './schema' import type { DemoAct, DemoScene, InteractiveDemoV1, Panel, ValidationIssue, ValidationResult, } from './types' function issue(code: string, path: string, message: string): ValidationIssue { return { code, path, message } } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** Heatmap cell ids — respect triangular mode (lower → 36 cells for 8×8). */ export function heatmapCellIds( rows: number, cols: number, triangular?: 'lower' | 'upper' | 'none' ): string[] { const ids: string[] = [] for (let r = 1; r <= rows; r++) { for (let c = 1; c <= cols; c++) { if (triangular === 'lower' && c > r) continue if (triangular === 'upper' && c < r) continue ids.push(`r${r}.c${c}`) } } return ids } /** Collect every addressable element id in a scene (nodes, edges, series, heatmap cells). */ export function collectSceneElementIds(scene: DemoScene): { ids: Set wildcardAllowed: boolean } { const ids = new Set() let wildcardAllowed = false for (const panel of scene.panels) { if (panel.type === 'svg-scene') { for (const n of panel.payload.nodes) ids.add(n.id) for (const e of panel.payload.edges ?? []) { ids.add(e.id) } } else if (panel.type === 'chart') { wildcardAllowed = true for (const s of panel.payload.series) ids.add(s.id) } else if (panel.type === 'heatmap-matrix') { wildcardAllowed = true const { rows, cols, triangular } = panel.payload for (const id of heatmapCellIds(rows, cols, triangular)) { ids.add(id) } } } return { ids, wildcardAllowed } } function countDeclaredScenes(demo: InteractiveDemoV1): number { let n = 1 // demo.scene for (const act of demo.acts) { if (act.scene) n += 1 } return n } /** * Scan for free color literals — skip human/translatable string fields * so prose like « couleur #FF0000 » does not hard-reject. */ function scanForbiddenColors( value: unknown, path: string, out: ValidationIssue[] ): void { if (typeof value === 'string') { if (FORBIDDEN_COLOR_RE.test(value)) { out.push( issue( 'forbidden_color', path, 'Free color literals (hex/rgb) are forbidden — use intent enums' ) ) } return } if (Array.isArray(value)) { value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out)) return } if (value && typeof value === 'object') { for (const [k, v] of Object.entries(value)) { if (isHumanStringKey(k)) continue scanForbiddenColors(v, path ? `${path}.${k}` : k, out) } } } function validateSvgEdges(panel: Panel, path: string, out: ValidationIssue[]): void { if (panel.type !== 'svg-scene') return const nodeIds = new Set(panel.payload.nodes.map((n) => n.id)) for (const [i, edge] of (panel.payload.edges ?? []).entries()) { if (!nodeIds.has(edge.from)) { out.push( issue( 'unknown_edge_endpoint', `${path}.edges[${i}].from`, `Edge from "${edge.from}" is not a node id in this panel` ) ) } if (!nodeIds.has(edge.to)) { out.push( issue( 'unknown_edge_endpoint', `${path}.edges[${i}].to`, `Edge to "${edge.to}" is not a node id in this panel` ) ) } } } function validateHeatmapShape(panel: Panel, path: string, out: ValidationIssue[]): void { if (panel.type !== 'heatmap-matrix') return const { rows, cols, values, rowLabels, colLabels } = panel.payload if (values.length !== rows) { out.push( issue( 'heatmap_shape', `${path}.values`, `Expected ${rows} rows, got ${values.length}` ) ) } for (const [ri, row] of values.entries()) { if (row.length !== cols) { out.push( issue( 'heatmap_shape', `${path}.values[${ri}]`, `Expected ${cols} cols, got ${row.length}` ) ) } } if (rowLabels && rowLabels.length !== rows) { out.push( issue( 'heatmap_labels', `${path}.rowLabels`, `rowLabels length must equal rows (${rows})` ) ) } if (colLabels && colLabels.length !== cols) { out.push( issue( 'heatmap_labels', `${path}.colLabels`, `colLabels length must equal cols (${cols})` ) ) } } function validateScene( scene: DemoScene, path: string, out: ValidationIssue[] ): Set { const panelIds = new Set() for (const [pi, panel] of scene.panels.entries()) { const pPath = `${path}.panels[${pi}]` if (panelIds.has(panel.id)) { out.push(issue('duplicate_panel_id', `${pPath}.id`, `Duplicate panel id "${panel.id}"`)) } panelIds.add(panel.id) validateSvgEdges(panel, pPath, out) validateHeatmapShape(panel, pPath, out) } const seen = new Set() const dups = new Set() for (const panel of scene.panels) { const local: string[] = [] if (panel.type === 'svg-scene') { local.push(...panel.payload.nodes.map((n) => n.id)) local.push(...(panel.payload.edges ?? []).map((e) => e.id)) } else if (panel.type === 'chart') { local.push(...panel.payload.series.map((s) => s.id)) } for (const id of local) { if (seen.has(id)) dups.add(id) seen.add(id) } } for (const id of dups) { out.push( issue('duplicate_element_id', path, `Duplicate element id "${id}" in scene`) ) } return collectSceneElementIds(scene).ids } function validateActRefs( act: DemoAct, actIndex: number, elementIds: Set, wildcardAllowed: boolean, out: ValidationIssue[] ): void { const actPath = `acts[${actIndex}]` const stepIdRe = new RegExp(`^${escapeRegExp(act.id)}\\.s\\d+$`) for (const [si, step] of act.steps.entries()) { const stepPath = `${actPath}.steps[${si}]` if (!stepIdRe.test(step.id)) { out.push( issue( 'step_id_format', `${stepPath}.id`, `Step id must match /^${act.id}\\.s\\d+$/ (got "${step.id}")` ) ) } for (const id of step.pointTo ?? []) { if (!elementIds.has(id)) { out.push( issue( 'unknown_element_id', `${stepPath}.pointTo`, `pointTo references unknown element "${id}" in active scene` ) ) } } for (const [ri, rev] of (step.reveal ?? []).entries()) { for (const id of rev.ids) { if (id === '*') { if (!wildcardAllowed) { out.push( issue( 'wildcard_not_allowed', `${stepPath}.reveal[${ri}]`, '"*" reveal is only allowed when the active scene has heatmap or chart panels' ) ) } continue } if (!elementIds.has(id)) { out.push( issue( 'unknown_element_id', `${stepPath}.reveal[${ri}].ids`, `reveal references unknown element "${id}"` ) ) } } } for (const [ai, ann] of (step.annotate ?? []).entries()) { for (const id of ann.targetIds) { if ((step.pointTo ?? []).includes(id)) { out.push( issue( 'annotate_pointto_overlap', `${stepPath}.annotate[${ai}]`, `Do not annotate "${id}" in the same step as pointTo — spotlight is enough` ) ) } if (!elementIds.has(id)) { out.push( issue( 'unknown_element_id', `${stepPath}.annotate[${ai}].targetIds`, `annotate references unknown element "${id}"` ) ) } } } } } function semanticValidate(demo: InteractiveDemoV1): ValidationIssue[] { const out: ValidationIssue[] = [] const jsonBytes = new TextEncoder().encode(JSON.stringify(demo)).length if (jsonBytes > INTERACTIVE_DEMO_CAPS.maxJsonBytes) { out.push( issue( 'json_too_large', '', `JSON exceeds ${INTERACTIVE_DEMO_CAPS.maxJsonBytes} bytes (${jsonBytes})` ) ) } const sceneCount = countDeclaredScenes(demo) if (sceneCount > INTERACTIVE_DEMO_CAPS.maxScenes) { out.push( issue( 'too_many_scenes', 'scene', `At most ${INTERACTIVE_DEMO_CAPS.maxScenes} scenes allowed (found ${sceneCount})` ) ) } scanForbiddenColors(demo, '', out) validateScene(demo.scene, 'scene', out) let activeScene = demo.scene let { ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene) const actIds = new Set() for (const [ai, act] of demo.acts.entries()) { if (actIds.has(act.id)) { out.push(issue('duplicate_act_id', `acts[${ai}].id`, `Duplicate act id "${act.id}"`)) } actIds.add(act.id) if (act.scene) { validateScene(act.scene, `acts[${ai}].scene`, out) activeScene = act.scene ;({ ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene)) } const stepIds = new Set() for (const [si, step] of act.steps.entries()) { if (stepIds.has(step.id)) { out.push( issue( 'duplicate_step_id', `acts[${ai}].steps[${si}].id`, `Duplicate step id "${step.id}"` ) ) } stepIds.add(step.id) } validateActRefs(act, ai, elementIds, wildcardAllowed, out) } return out } /** * Validate an Interactive Demo document. * Structural (Zod allowlist) then semantic (caps, refs, no hex, wildcards). */ export function validateInteractiveDemo(input: unknown): ValidationResult { const parsed = interactiveDemoV1Schema.safeParse(input) if (!parsed.success) { const issues: ValidationIssue[] = parsed.error.issues.map((e) => ({ code: e.code, path: e.path.join('.'), message: e.message, })) return { ok: false, issues } } const demo = parsed.data as InteractiveDemoV1 const semantic = semanticValidate(demo) if (semantic.length > 0) { return { ok: false, issues: semantic } } return { ok: true, demo } } /** * Translate must preserve geometry. Default: strings are structural (kept). * Only HUMAN_STRING_KEYS are nullified — adding a structural key is protected by default. */ export function assertTranslatePreservesStructure( source: InteractiveDemoV1, translated: InteractiveDemoV1 ): ValidationResult { const stripHumanStrings = (value: unknown): unknown => { if (typeof value === 'number' || typeof value === 'boolean' || value === null) { return value } if (typeof value === 'string') { // Context-free string: structural until proven otherwise. return value } if (Array.isArray(value)) return value.map(stripHumanStrings) if (value && typeof value === 'object') { const out: Record = {} for (const [k, v] of Object.entries(value)) { if (isHumanStringKey(k)) { out[k] = null continue } out[k] = stripHumanStrings(v) } return out } return value } const a = JSON.stringify(stripHumanStrings(source)) const b = JSON.stringify(stripHumanStrings(translated)) if (a !== b) { return { ok: false, issues: [ issue( 'translate_structure_drift', '', 'Translated demo must preserve structure and ids; only human strings may change' ), ], } } if (source.id !== translated.id) { return { ok: false, issues: [ issue( 'translate_id_mismatch', 'id', 'Translated variant must keep the same demo.id' ), ], } } return { ok: true, demo: translated } }