feat: page interactive, démos Play/Step et simulateur Carnot

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>
This commit is contained in:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,303 @@
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 }
}