import { FORBIDDEN_COLOR_RE } from '@/lib/interactive-demo/constants' import { validateInteractiveDemo } from '@/lib/interactive-demo/validate' import { getPlugin } from '@/lib/simulators' import { INTERACTIVE_PAGE_CAPS, isPageHumanStringKey, } from './constants' import { pageSpecV1Schema } from './schema' import { validateSimExprRefs } from './sim-eval' import type { PageBlock, PageSpecV1, PageValidationIssue, PageValidationResult, } from './types' function issue(code: string, path: string, message: string): PageValidationIssue { return { code, path, message } } function scanForbiddenColors( value: unknown, path: string, out: PageValidationIssue[] ): 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 (isPageHumanStringKey(k)) continue // Nested demos are validated separately (incl. their own color scan) if (k === 'demo') continue scanForbiddenColors(v, path ? `${path}.${k}` : k, out) } } } function countDemos(page: PageSpecV1): number { let n = 0 for (const s of page.sections) { for (const b of s.blocks) { if (b.type === 'demo') n += 1 } } return n } function countSims(page: PageSpecV1): number { let n = 0 for (const s of page.sections) { for (const b of s.blocks) { if (b.type === 'sim') n += 1 } } return n } /** Simulator block: catalog ref integrity / generic exprs safety. */ function validateSim( block: Extract, path: string, out: PageValidationIssue[] ): void { const sim = block.sim if (sim.simId === 'generic-formula') { const generic = sim as Extract const paramIds = new Set(generic.params.map((p) => p.id)) for (const p of generic.params) { if (p.min >= p.max) { out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`)) } if (p.defaultValue < p.min || p.defaultValue > p.max) { out.push( issue('sim_param_default', `${path}.params`, `Param "${p.id}": default outside [min, max]`) ) } } const allowed = new Set(paramIds) for (const c of generic.computed) { const errs = validateSimExprRefs(c.expr, allowed) for (const e of errs) { out.push(issue('sim_expr', `${path}.computed.${c.id}`, e)) } if (errs.length === 0) allowed.add(c.id) // computed may chain } if (generic.visual.kind === 'curve') { if (!paramIds.has(generic.visual.xParamId)) { out.push( issue('sim_curve_param', `${path}.visual.xParamId`, `Unknown param "${generic.visual.xParamId}"`) ) } const errs = validateSimExprRefs(generic.visual.expr, allowed) for (const e of errs) { out.push(issue('sim_expr', `${path}.visual.expr`, e)) } } return } // Catalog plugin: must exist. Sims: preset within bounds + compute finite. const catalog = sim as Extract & { preset?: Record } const plugin = getPlugin(catalog.simId) if (!plugin) { out.push(issue('unknown_simulator', `${path}.simId`, `Unknown simulator "${catalog.simId}"`)) return } if (plugin.family === 'anim') { if (!plugin.beats.length) { out.push(issue('sim_anim_empty', `${path}`, 'Animation plugin has no beats')) } return } const env: Record = {} for (const p of plugin.params) env[p.id] = p.defaultValue if (catalog.preset) { for (const [k, v] of Object.entries(catalog.preset)) { const def = plugin.params.find((p) => p.id === k) if (!def) { out.push(issue('sim_preset_key', `${path}.preset.${k}`, 'Not a parameter of this simulator')) continue } if (v < def.min || v > def.max) { out.push( issue('sim_preset_range', `${path}.preset.${k}`, `Value ${v} outside [${def.min}, ${def.max}]`) ) continue } env[k] = v } } try { const result = plugin.compute(env) for (const o of plugin.outputs) { if (!Number.isFinite(result[o.id])) { out.push( issue('sim_compute', `${path}`, `Output "${o.id}" not finite at preset values`) ) } } } catch { out.push(issue('sim_compute', `${path}`, 'Simulator compute() threw at preset values')) } } function validateTable( block: Extract, path: string, out: PageValidationIssue[] ): void { const cols = block.columns.length for (const [ri, row] of block.rows.entries()) { if (row.length !== cols) { out.push( issue( 'table_shape', `${path}.rows[${ri}]`, `Expected ${cols} cells, got ${row.length}` ) ) } } } function validateImageSrc( block: Extract, path: string, out: PageValidationIssue[] ): void { const src = block.src.trim() // Allow relative /uploads, https, and data:image — reject javascript: etc. if (/^\s*javascript:/i.test(src) || /^\s*data:text\/html/i.test(src)) { out.push( issue('unsafe_image_src', `${path}.src`, 'Unsafe image src rejected') ) } } function semanticValidate(page: PageSpecV1): PageValidationIssue[] { const out: PageValidationIssue[] = [] const jsonBytes = new TextEncoder().encode(JSON.stringify(page)).length if (jsonBytes > INTERACTIVE_PAGE_CAPS.maxJsonBytes) { out.push( issue( 'json_too_large', '', `JSON exceeds ${INTERACTIVE_PAGE_CAPS.maxJsonBytes} bytes (${jsonBytes})` ) ) } const demoCount = countDemos(page) if (demoCount > INTERACTIVE_PAGE_CAPS.maxDemosPerPage) { out.push( issue( 'too_many_demos', 'sections', `At most ${INTERACTIVE_PAGE_CAPS.maxDemosPerPage} demos per page (found ${demoCount})` ) ) } const simCount = countSims(page) if (simCount > INTERACTIVE_PAGE_CAPS.maxSimsPerPage) { out.push( issue( 'too_many_sims', 'sections', `At most ${INTERACTIVE_PAGE_CAPS.maxSimsPerPage} sims per page (found ${simCount})` ) ) } scanForbiddenColors(page, '', out) const sectionIds = new Set() for (const [si, section] of page.sections.entries()) { const sPath = `sections[${si}]` if (sectionIds.has(section.id)) { out.push( issue( 'duplicate_section_id', `${sPath}.id`, `Duplicate section id "${section.id}"` ) ) } sectionIds.add(section.id) if (!/^s\d+$/.test(section.id)) { out.push( issue( 'section_id_format', `${sPath}.id`, `Section id should match /^s\\d+$/ (got "${section.id}")` ) ) } for (const [bi, block] of section.blocks.entries()) { const bPath = `${sPath}.blocks[${bi}]` // Unknown types already hard-rejected by Zod discriminatedUnion. if (block.type === 'table') validateTable(block, bPath, out) if (block.type === 'image') validateImageSrc(block, bPath, out) if (block.type === 'sim') validateSim(block, bPath, out) if (block.type === 'demo') { const demoResult = validateInteractiveDemo(block.demo) if (!demoResult.ok) { for (const iss of demoResult.issues) { out.push({ code: `demo_${iss.code}`, path: `${bPath}.demo${iss.path ? '.' + iss.path : ''}`, message: iss.message, }) } } } } } return out } /** * Validate a PageSpecV1 document. * Structural (Zod allowlist) then semantic (caps, colors, demos, tables). */ export function validateInteractivePage(input: unknown): PageValidationResult { const parsed = pageSpecV1Schema.safeParse(input) if (!parsed.success) { const issues: PageValidationIssue[] = parsed.error.issues.map((e) => ({ code: e.code, path: e.path.join('.'), message: e.message, })) return { ok: false, issues } } const page = parsed.data as PageSpecV1 const semantic = semanticValidate(page) if (semantic.length > 0) { return { ok: false, issues: semantic } } return { ok: true, page } }