Files
Momento/memento-note/lib/interactive-demo/validate.ts
Antigravity 69c99e4f4f 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>
2026-07-24 17:51:43 +00:00

453 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>
wildcardAllowed: boolean
} {
const ids = new Set<string>()
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<string> {
const panelIds = new Set<string>()
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<string>()
const dups = new Set<string>()
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<string>,
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<string>()
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<string>()
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<string, unknown> = {}
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 }
}