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:
452
memento-note/lib/interactive-demo/validate.ts
Normal file
452
memento-note/lib/interactive-demo/validate.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user