Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
304 lines
8.5 KiB
TypeScript
304 lines
8.5 KiB
TypeScript
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<PageBlock, { type: 'sim' }>,
|
|
path: string,
|
|
out: PageValidationIssue[]
|
|
): void {
|
|
const sim = block.sim
|
|
if (sim.simId === 'generic-formula') {
|
|
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
|
|
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<string>(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<typeof sim, { simId: string }> & {
|
|
preset?: Record<string, number>
|
|
}
|
|
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<string, number> = {}
|
|
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<PageBlock, { type: 'table' }>,
|
|
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<PageBlock, { type: 'image' }>,
|
|
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<string>()
|
|
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 }
|
|
}
|