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:
86
memento-note/lib/interactive-page/constants.ts
Normal file
86
memento-note/lib/interactive-page/constants.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/** Interactive Page schema v1 — caps & allowlists (spec Kimi / AttnRes). */
|
||||
|
||||
export const INTERACTIVE_PAGE_SCHEMA_VERSION = 1 as const
|
||||
|
||||
export const INTERACTIVE_PAGE_CAPS = {
|
||||
maxSections: 8,
|
||||
maxBlocksPerSection: 12,
|
||||
maxDemosPerPage: 5,
|
||||
maxSimsPerPage: 3,
|
||||
maxOverviewCards: 4,
|
||||
minOverviewCards: 2,
|
||||
maxStatsItems: 5,
|
||||
minStatsItems: 2,
|
||||
maxJsonBytes: 128 * 1024,
|
||||
} as const
|
||||
|
||||
export const SIM_CAPS = {
|
||||
maxParams: 4,
|
||||
maxComputed: 6,
|
||||
maxExprChars: 200,
|
||||
} as const
|
||||
|
||||
export const PAGE_BLOCK_TYPES = [
|
||||
'prose',
|
||||
'formula',
|
||||
'callout',
|
||||
'demo',
|
||||
'chart',
|
||||
'stats',
|
||||
'table',
|
||||
'image',
|
||||
'sim',
|
||||
] as const
|
||||
|
||||
export const CALLOUT_KINDS = [
|
||||
'definition',
|
||||
'warning',
|
||||
'tip',
|
||||
'note',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Human / locale strings — may contain hex in prose; skipped by color scan.
|
||||
* Must stay in sync with validate + translate strip.
|
||||
*/
|
||||
export const PAGE_HUMAN_STRING_KEYS = [
|
||||
// page-level
|
||||
'lang',
|
||||
'kicker',
|
||||
'title',
|
||||
'subtitle',
|
||||
'meta',
|
||||
'lead',
|
||||
'badge',
|
||||
'body',
|
||||
'footer',
|
||||
// blocks
|
||||
'md',
|
||||
'tex',
|
||||
'caption',
|
||||
'alt',
|
||||
'value',
|
||||
'label',
|
||||
'columns',
|
||||
'rows',
|
||||
// sim blocks
|
||||
'symbol',
|
||||
'expr',
|
||||
'intro',
|
||||
'xLabel',
|
||||
'yLabel',
|
||||
// inherited from demos (speak etc. scanned via demo validator)
|
||||
'speak',
|
||||
'text',
|
||||
'disclaimer',
|
||||
'rowLabels',
|
||||
'colLabels',
|
||||
] as const
|
||||
|
||||
export type PageHumanStringKey = (typeof PAGE_HUMAN_STRING_KEYS)[number]
|
||||
|
||||
const PAGE_HUMAN_SET = new Set<string>(PAGE_HUMAN_STRING_KEYS)
|
||||
|
||||
export function isPageHumanStringKey(key: string): boolean {
|
||||
return PAGE_HUMAN_SET.has(key)
|
||||
}
|
||||
354
memento-note/lib/interactive-page/fixtures/thermo-page.json
Normal file
354
memento-note/lib/interactive-page/fixtures/thermo-page.json
Normal file
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "page.thermo-test",
|
||||
"lang": "fr",
|
||||
"hero": {
|
||||
"kicker": "EXPLAINER INTERACTIF",
|
||||
"title": "Cycle frigorifique",
|
||||
"subtitle": "Compression, condensation, détente, évaporation",
|
||||
"meta": "Note de cours · valeurs illustratives"
|
||||
},
|
||||
"overview": {
|
||||
"lead": "Le cycle frigorifique déplace de la chaleur du froid vers le chaud grâce à un travail $W$.",
|
||||
"cards": [
|
||||
{
|
||||
"badge": "PROBLEM",
|
||||
"title": "Objectif",
|
||||
"body": "Extraire $Q_e$ à basse température.",
|
||||
"intent": "warning"
|
||||
},
|
||||
{
|
||||
"badge": "APPROACH",
|
||||
"title": "Cycle",
|
||||
"body": "Quatre organes en boucle fermée.",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"badge": "RESULT",
|
||||
"title": "COP",
|
||||
"body": "$\\mathrm{COP}=Q_e/W$",
|
||||
"intent": "output"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"id": "s1",
|
||||
"title": "Le problème",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "prose",
|
||||
"md": "On veut **refroidir** un volume en rejetant la chaleur à l’extérieur."
|
||||
},
|
||||
{
|
||||
"type": "callout",
|
||||
"kind": "definition",
|
||||
"title": "Travail",
|
||||
"md": "Le compresseur fournit $W=h_2-h_1$."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"title": "Échanges",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "formula",
|
||||
"tex": "\\mathrm{COP}=\\frac{Q_e}{W}",
|
||||
"caption": "Coefficient de performance"
|
||||
},
|
||||
{
|
||||
"type": "sim",
|
||||
"sim": {
|
||||
"simId": "ts-diagram"
|
||||
},
|
||||
"caption": "Le même cycle sur le diagramme T–s : les aires sont les chaleurs échangées."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s3",
|
||||
"title": "Isotherme",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "chart",
|
||||
"payload": {
|
||||
"chartType": "line",
|
||||
"series": [
|
||||
{
|
||||
"id": "p",
|
||||
"label": "P(V)",
|
||||
"values": [
|
||||
4,
|
||||
2.5,
|
||||
1.8,
|
||||
1.4,
|
||||
1.2
|
||||
],
|
||||
"intent": "flow"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caption": "Pression vs volume (illustratif)"
|
||||
},
|
||||
{
|
||||
"type": "demo",
|
||||
"caption": "Le cycle à compression de vapeur — 4 organes en boucle fermée.",
|
||||
"demo": {
|
||||
"schemaVersion": 1,
|
||||
"id": "demo.vapor-cycle",
|
||||
"lang": "fr",
|
||||
"disclaimer": "Schéma pédagogique — valeurs illustratives.",
|
||||
"scene": {
|
||||
"id": "scene.cycle",
|
||||
"panels": [
|
||||
{
|
||||
"id": "panel.cycle",
|
||||
"type": "svg-scene",
|
||||
"payload": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "comp",
|
||||
"label": "1 · Compresseur\n$W = h_2 - h_1$",
|
||||
"intent": "compute"
|
||||
},
|
||||
{
|
||||
"id": "cond",
|
||||
"label": "2 · Condenseur\n$Q_c = h_2 - h_3$",
|
||||
"intent": "output"
|
||||
},
|
||||
{
|
||||
"id": "exp",
|
||||
"label": "3 · Détente\n$h_3 = h_4$",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "evap",
|
||||
"label": "4 · Évaporateur\n$Q_e = h_1 - h_4$",
|
||||
"intent": "cache"
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"label": "Travail fourni $W$",
|
||||
"intent": "highlight"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "e12",
|
||||
"from": "comp",
|
||||
"to": "cond",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "e23",
|
||||
"from": "cond",
|
||||
"to": "exp",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "e34",
|
||||
"from": "exp",
|
||||
"to": "evap",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "e41",
|
||||
"from": "evap",
|
||||
"to": "comp",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "ew",
|
||||
"from": "work",
|
||||
"to": "comp",
|
||||
"style": "dashed",
|
||||
"intent": "highlight"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"acts": [
|
||||
{
|
||||
"id": "a1",
|
||||
"title": "Cycle",
|
||||
"pattern": "flowTrace",
|
||||
"steps": [
|
||||
{
|
||||
"id": "a1.s1",
|
||||
"speak": "Le compresseur fournit le travail $W$ au fluide.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"comp"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"comp",
|
||||
"work",
|
||||
"ew"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s2",
|
||||
"speak": "Au condenseur, la chaleur $Q_c$ est rejetée vers l'extérieur.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"cond"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"cond",
|
||||
"e12"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s3",
|
||||
"speak": "La détente est isenthalpique : $h_3 = h_4$.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"exp"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"exp",
|
||||
"e23"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s4",
|
||||
"speak": "À l'évaporateur, le fluide absorbe $Q_e$ : c'est le froid utile.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"evap"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"evap",
|
||||
"e34"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s5",
|
||||
"speak": "Le cycle se boucle : le fluide retourne au compresseur.",
|
||||
"pattern": "overview",
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"comp",
|
||||
"cond",
|
||||
"exp",
|
||||
"evap",
|
||||
"work",
|
||||
"e12",
|
||||
"e23",
|
||||
"e34",
|
||||
"e41",
|
||||
"ew"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "sim",
|
||||
"sim": {
|
||||
"simId": "carnot-cycle",
|
||||
"preset": {
|
||||
"t_cold": 260,
|
||||
"t_hot": 300,
|
||||
"q_cold": 100
|
||||
},
|
||||
"disclaimer": "Valeurs illustratives — les COP réels sont inférieurs au COP de Carnot."
|
||||
},
|
||||
"caption": "Manipulez les températures des sources : le COP maximal suit le 2ᵉ principe."
|
||||
},
|
||||
{
|
||||
"type": "sim",
|
||||
"sim": {
|
||||
"simId": "carnot-cycle-anim"
|
||||
},
|
||||
"caption": "Le cycle de Carnot battement par battement — piston et diagramme P–V en direct."
|
||||
},
|
||||
{
|
||||
"type": "stats",
|
||||
"items": [
|
||||
{
|
||||
"value": "3.2",
|
||||
"label": "COP typique",
|
||||
"intent": "output"
|
||||
},
|
||||
{
|
||||
"value": "1.25×",
|
||||
"label": "Gain relatif",
|
||||
"intent": "highlight"
|
||||
},
|
||||
{
|
||||
"value": "<2%",
|
||||
"label": "Pertes",
|
||||
"intent": "warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s4",
|
||||
"title": "Synthèse",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "table",
|
||||
"columns": [
|
||||
"Organe",
|
||||
"Échange"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"Compresseur",
|
||||
"$W$"
|
||||
],
|
||||
[
|
||||
"Condenseur",
|
||||
"$Q_c$"
|
||||
],
|
||||
[
|
||||
"Évaporateur",
|
||||
"$Q_e$"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "prose",
|
||||
"md": "Le COP mesure l’efficacité du cycle."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footer": "Valeurs pédagogiques — pas des mesures expérimentales."
|
||||
}
|
||||
25
memento-note/lib/interactive-page/index.ts
Normal file
25
memento-note/lib/interactive-page/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
INTERACTIVE_PAGE_CAPS,
|
||||
INTERACTIVE_PAGE_SCHEMA_VERSION,
|
||||
PAGE_BLOCK_TYPES,
|
||||
CALLOUT_KINDS,
|
||||
PAGE_HUMAN_STRING_KEYS,
|
||||
isPageHumanStringKey,
|
||||
} from './constants'
|
||||
export { pageSpecV1Schema, pageBlockSchema } from './schema'
|
||||
export { validateInteractivePage } from './validate'
|
||||
export { normalizeInteractivePageCandidate } from './normalize'
|
||||
export type {
|
||||
PageSpecV1,
|
||||
PageSection,
|
||||
PageBlock,
|
||||
PageHero,
|
||||
PageOverview,
|
||||
PageValidationIssue,
|
||||
PageValidationResult,
|
||||
IntentId,
|
||||
SimRef,
|
||||
CatalogSimRef,
|
||||
GenericFormulaSim,
|
||||
SimBlock,
|
||||
} from './types'
|
||||
335
memento-note/lib/interactive-page/normalize.ts
Normal file
335
memento-note/lib/interactive-page/normalize.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Deterministic repairs for LLM PageSpecV1 before Zod validation.
|
||||
* Mirrors interactive-demo/normalize — fix common shape/field aliases.
|
||||
*/
|
||||
|
||||
import { normalizeInteractiveDemoCandidate } from '@/lib/interactive-demo/normalize'
|
||||
import { CALLOUT_KINDS, INTERACTIVE_PAGE_CAPS } from './constants'
|
||||
|
||||
const CALLOUT_SET = new Set<string>(CALLOUT_KINDS)
|
||||
|
||||
const CALLOUT_ALIASES: Record<string, string> = {
|
||||
definition: 'definition',
|
||||
def: 'definition',
|
||||
warning: 'warning',
|
||||
warn: 'warning',
|
||||
danger: 'warning',
|
||||
alert: 'warning',
|
||||
tip: 'tip',
|
||||
hint: 'tip',
|
||||
advice: 'tip',
|
||||
note: 'note',
|
||||
info: 'note',
|
||||
remark: 'note',
|
||||
}
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === 'object' && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return String(v)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function slugId(title: string, fallback: string): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
return s ? `page.${s}` : fallback
|
||||
}
|
||||
|
||||
function pickMd(obj: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(obj.md) ||
|
||||
asString(obj.content) ||
|
||||
asString(obj.text) ||
|
||||
asString(obj.body) ||
|
||||
asString(obj.html) ||
|
||||
asString(obj.markdown)
|
||||
)
|
||||
}
|
||||
|
||||
function pickTex(obj: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(obj.tex) ||
|
||||
asString(obj.latex) ||
|
||||
asString(obj.formula) ||
|
||||
asString(obj.math) ||
|
||||
asString(obj.equation)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCalloutKind(v: unknown): string {
|
||||
if (typeof v !== 'string') return 'note'
|
||||
const key = v.trim().toLowerCase()
|
||||
const mapped = CALLOUT_ALIASES[key] || key
|
||||
return CALLOUT_SET.has(mapped) ? mapped : 'note'
|
||||
}
|
||||
|
||||
function normalizeBlock(
|
||||
raw: unknown,
|
||||
index: number
|
||||
): Record<string, unknown> | null {
|
||||
const obj = asRecord(raw)
|
||||
if (!obj) return null
|
||||
const type = asString(obj.type)?.toLowerCase()
|
||||
if (!type) return null
|
||||
|
||||
if (type === 'prose' || type === 'text' || type === 'markdown' || type === 'paragraph') {
|
||||
const md = pickMd(obj)
|
||||
if (!md) return null
|
||||
return { type: 'prose', md }
|
||||
}
|
||||
|
||||
if (type === 'formula' || type === 'math' || type === 'equation' || type === 'katex') {
|
||||
const tex = pickTex(obj)
|
||||
if (!tex) return null
|
||||
const out: Record<string, unknown> = { type: 'formula', tex }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'callout' || type === 'aside' || type === 'box') {
|
||||
const md = pickMd(obj) || '—'
|
||||
const title = asString(obj.title) || asString(obj.heading) || 'Note'
|
||||
return {
|
||||
type: 'callout',
|
||||
kind: normalizeCalloutKind(obj.kind || obj.variant || obj.style),
|
||||
title,
|
||||
md,
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'demo' || type === 'interactive' || type === 'animation') {
|
||||
const nested = obj.demo ?? obj.spec ?? obj.interactiveDemo
|
||||
if (!nested) return null
|
||||
const normalized = normalizeInteractiveDemoCandidate(nested)
|
||||
const out: Record<string, unknown> = { type: 'demo', demo: normalized }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'chart' || type === 'graph') {
|
||||
const payload = asRecord(obj.payload) || asRecord(obj.chart) || obj
|
||||
const series = Array.isArray((payload as Record<string, unknown>).series)
|
||||
? (payload as Record<string, unknown>).series
|
||||
: null
|
||||
if (!series) return null
|
||||
const chartType =
|
||||
asString((payload as Record<string, unknown>).chartType) ||
|
||||
asString(obj.chartType) ||
|
||||
'line'
|
||||
const out: Record<string, unknown> = {
|
||||
type: 'chart',
|
||||
payload: {
|
||||
chartType: ['line', 'bar', 'area'].includes(chartType) ? chartType : 'line',
|
||||
series,
|
||||
},
|
||||
}
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'stats' || type === 'metrics' || type === 'kpis') {
|
||||
const items = Array.isArray(obj.items) ? obj.items : Array.isArray(obj.stats) ? obj.stats : null
|
||||
if (!items || items.length < 2) return null
|
||||
return {
|
||||
type: 'stats',
|
||||
items: items.slice(0, INTERACTIVE_PAGE_CAPS.maxStatsItems).map((it) => {
|
||||
const r = asRecord(it) || {}
|
||||
return {
|
||||
value: asString(r.value) || asString(r.v) || '—',
|
||||
label: asString(r.label) || asString(r.name) || '—',
|
||||
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'table') {
|
||||
const columns = Array.isArray(obj.columns)
|
||||
? obj.columns.map((c) => asString(c) || '—')
|
||||
: []
|
||||
const rows = Array.isArray(obj.rows) ? obj.rows : []
|
||||
if (!columns.length) return null
|
||||
const out: Record<string, unknown> = { type: 'table', columns, rows }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'image' || type === 'img' || type === 'figure') {
|
||||
const src = asString(obj.src) || asString(obj.url)
|
||||
const alt = asString(obj.alt) || asString(obj.title) || 'Image'
|
||||
if (!src) return null
|
||||
const out: Record<string, unknown> = { type: 'image', src, alt }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'sim' || type === 'simulation' || type === 'slider' || type === 'interactive-sim') {
|
||||
const sim = asRecord(obj.sim) || asRecord(obj.simulator) || obj
|
||||
const simId = asString(sim.simId) || asString(sim.simulator) || asString(sim.id)
|
||||
if (!simId) return null
|
||||
const out: Record<string, unknown> = { type: 'sim', sim: { ...sim, simId } }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeSection(
|
||||
raw: unknown,
|
||||
index: number
|
||||
): Record<string, unknown> | null {
|
||||
const obj = asRecord(raw)
|
||||
if (!obj) return null
|
||||
const title = asString(obj.title) || asString(obj.heading) || `Section ${index + 1}`
|
||||
const id = asString(obj.id) || `s${index + 1}`
|
||||
const blocksRaw = Array.isArray(obj.blocks)
|
||||
? obj.blocks
|
||||
: Array.isArray(obj.content)
|
||||
? obj.content
|
||||
: []
|
||||
const blocks = blocksRaw
|
||||
.map((b, i) => normalizeBlock(b, i))
|
||||
.filter(Boolean)
|
||||
.slice(0, INTERACTIVE_PAGE_CAPS.maxBlocksPerSection) as Record<string, unknown>[]
|
||||
if (!blocks.length) {
|
||||
blocks.push({
|
||||
type: 'prose',
|
||||
md: asString(obj.summary) || asString(obj.lead) || title,
|
||||
})
|
||||
}
|
||||
return { id, title, blocks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort normalize of an LLM page candidate.
|
||||
* Returns a plain object ready for validateInteractivePage.
|
||||
*/
|
||||
export function normalizeInteractivePageCandidate(
|
||||
input: unknown,
|
||||
lang = 'fr'
|
||||
): Record<string, unknown> | null {
|
||||
const root = asRecord(input)
|
||||
if (!root) return null
|
||||
|
||||
const heroIn = asRecord(root.hero) || {}
|
||||
const title =
|
||||
asString(heroIn.title) ||
|
||||
asString(root.title) ||
|
||||
'Page interactive'
|
||||
const kicker =
|
||||
asString(heroIn.kicker) ||
|
||||
asString(heroIn.eyebrow) ||
|
||||
asString(heroIn.label) ||
|
||||
(lang.startsWith('fr') ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER')
|
||||
|
||||
const hero: Record<string, unknown> = {
|
||||
kicker,
|
||||
title,
|
||||
}
|
||||
const subtitle = asString(heroIn.subtitle) || asString(root.subtitle)
|
||||
const meta = asString(heroIn.meta) || asString(root.meta)
|
||||
if (subtitle) hero.subtitle = subtitle
|
||||
if (meta) hero.meta = meta
|
||||
|
||||
let overview: Record<string, unknown> | undefined
|
||||
const overviewIn = asRecord(root.overview)
|
||||
if (overviewIn) {
|
||||
const lead =
|
||||
asString(overviewIn.lead) ||
|
||||
asString(overviewIn.summary) ||
|
||||
asString(overviewIn.text)
|
||||
const cardsRaw = Array.isArray(overviewIn.cards) ? overviewIn.cards : []
|
||||
const cards = cardsRaw
|
||||
.map((c) => {
|
||||
const r = asRecord(c)
|
||||
if (!r) return null
|
||||
const badge = asString(r.badge) || asString(r.label) || 'IDEA'
|
||||
const cTitle = asString(r.title) || badge
|
||||
const body = asString(r.body) || asString(r.text) || asString(r.md) || cTitle
|
||||
return {
|
||||
badge,
|
||||
title: cTitle,
|
||||
body,
|
||||
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (lead && cards.length >= INTERACTIVE_PAGE_CAPS.minOverviewCards) {
|
||||
overview = {
|
||||
lead,
|
||||
cards: cards.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
||||
}
|
||||
} else if (lead) {
|
||||
// Pad to min cards so validation can pass
|
||||
const padded = [...cards]
|
||||
while (padded.length < INTERACTIVE_PAGE_CAPS.minOverviewCards) {
|
||||
padded.push({
|
||||
badge: `C${padded.length + 1}`,
|
||||
title: title,
|
||||
body: lead.slice(0, 120),
|
||||
})
|
||||
}
|
||||
overview = {
|
||||
lead,
|
||||
cards: padded.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sectionsRaw = Array.isArray(root.sections) ? root.sections : []
|
||||
let sections = sectionsRaw
|
||||
.map((s, i) => normalizeSection(s, i))
|
||||
.filter(Boolean) as Record<string, unknown>[]
|
||||
|
||||
// Cap demos (speed + reliability): keep first 2
|
||||
let demoCount = 0
|
||||
sections = sections.map((sec) => {
|
||||
const blocks = (sec.blocks as Record<string, unknown>[]).filter((b) => {
|
||||
if (b.type !== 'demo') return true
|
||||
demoCount += 1
|
||||
return demoCount <= 2
|
||||
})
|
||||
return { ...sec, blocks }
|
||||
})
|
||||
|
||||
sections = sections.slice(0, 5)
|
||||
if (!sections.length) {
|
||||
sections = [
|
||||
{
|
||||
id: 's1',
|
||||
title: title,
|
||||
blocks: [{ type: 'prose', md: asString(root.summary) || title }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
schemaVersion: 1,
|
||||
id: asString(root.id) || slugId(title, 'page.generated'),
|
||||
lang: asString(root.lang) || lang,
|
||||
hero,
|
||||
sections,
|
||||
}
|
||||
if (overview) out.overview = overview
|
||||
const footer = asString(root.footer)
|
||||
if (footer) out.footer = footer
|
||||
return out
|
||||
}
|
||||
210
memento-note/lib/interactive-page/schema.ts
Normal file
210
memento-note/lib/interactive-page/schema.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { z } from 'zod'
|
||||
import { INTENT_IDS } from '@/lib/interactive-demo/constants'
|
||||
import { interactiveDemoV1Schema } from '@/lib/interactive-demo/schema'
|
||||
import {
|
||||
CALLOUT_KINDS,
|
||||
INTERACTIVE_PAGE_CAPS,
|
||||
INTERACTIVE_PAGE_SCHEMA_VERSION,
|
||||
PAGE_BLOCK_TYPES,
|
||||
SIM_CAPS,
|
||||
} from './constants'
|
||||
|
||||
const intentSchema = z.enum(INTENT_IDS).optional()
|
||||
|
||||
const langSchema = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag')
|
||||
|
||||
const chartPayloadSchema = z.object({
|
||||
chartType: z.enum(['line', 'bar', 'area']),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().optional(),
|
||||
values: z.array(z.number()),
|
||||
intent: intentSchema,
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
|
||||
const proseBlock = z.object({
|
||||
type: z.literal('prose'),
|
||||
md: z.string().min(1),
|
||||
})
|
||||
|
||||
const formulaBlock = z.object({
|
||||
type: z.literal('formula'),
|
||||
tex: z.string().min(1),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const calloutBlock = z.object({
|
||||
type: z.literal('callout'),
|
||||
kind: z.enum(CALLOUT_KINDS),
|
||||
title: z.string().min(1),
|
||||
md: z.string().min(1),
|
||||
})
|
||||
|
||||
const demoBlock = z.object({
|
||||
type: z.literal('demo'),
|
||||
demo: interactiveDemoV1Schema,
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const chartBlock = z.object({
|
||||
type: z.literal('chart'),
|
||||
payload: chartPayloadSchema,
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const statsBlock = z.object({
|
||||
type: z.literal('stats'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
intent: intentSchema,
|
||||
})
|
||||
)
|
||||
.min(INTERACTIVE_PAGE_CAPS.minStatsItems)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxStatsItems),
|
||||
})
|
||||
|
||||
const tableBlock = z.object({
|
||||
type: z.literal('table'),
|
||||
columns: z.array(z.string().min(1)).min(1),
|
||||
rows: z.array(z.array(z.string())),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const imageBlock = z.object({
|
||||
type: z.literal('image'),
|
||||
src: z.string().min(1),
|
||||
alt: z.string().min(1),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const simParamIdSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
|
||||
|
||||
const genericSimParamSchema = z.object({
|
||||
id: simParamIdSchema,
|
||||
symbol: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
step: z.number().positive(),
|
||||
defaultValue: z.number(),
|
||||
unit: z.string().optional(),
|
||||
intent: intentSchema,
|
||||
})
|
||||
|
||||
const genericSimComputedSchema = z.object({
|
||||
id: simParamIdSchema,
|
||||
symbol: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
expr: z.string().min(1).max(SIM_CAPS.maxExprChars),
|
||||
unit: z.string().optional(),
|
||||
intent: intentSchema,
|
||||
})
|
||||
|
||||
const genericSimSchema = z.object({
|
||||
simId: z.literal('generic-formula'),
|
||||
title: z.string().min(1),
|
||||
intro: z.string().optional(),
|
||||
params: z.array(genericSimParamSchema).min(1).max(SIM_CAPS.maxParams),
|
||||
computed: z.array(genericSimComputedSchema).min(1).max(SIM_CAPS.maxComputed),
|
||||
visual: z.union([
|
||||
z.object({ kind: z.literal('gauges') }),
|
||||
z.object({ kind: z.literal('bars') }),
|
||||
z.object({
|
||||
kind: z.literal('curve'),
|
||||
xParamId: simParamIdSchema,
|
||||
expr: z.string().min(1),
|
||||
xLabel: z.string().optional(),
|
||||
yLabel: z.string().optional(),
|
||||
}),
|
||||
]),
|
||||
disclaimer: z.string().optional(),
|
||||
})
|
||||
|
||||
const catalogSimSchema = z.object({
|
||||
simId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((s) => s !== 'generic-formula', {
|
||||
message: 'generic-formula must use the full inline schema',
|
||||
}),
|
||||
title: z.string().optional(),
|
||||
preset: z.record(z.string(), z.number()).optional(),
|
||||
disclaimer: z.string().optional(),
|
||||
})
|
||||
|
||||
const simBlock = z.object({
|
||||
type: z.literal('sim'),
|
||||
sim: z.union([genericSimSchema, catalogSimSchema]),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
export const pageBlockSchema = z.discriminatedUnion('type', [
|
||||
proseBlock,
|
||||
formulaBlock,
|
||||
calloutBlock,
|
||||
demoBlock,
|
||||
chartBlock,
|
||||
statsBlock,
|
||||
tableBlock,
|
||||
imageBlock,
|
||||
simBlock,
|
||||
])
|
||||
|
||||
const sectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
blocks: z
|
||||
.array(pageBlockSchema)
|
||||
.min(1)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxBlocksPerSection),
|
||||
})
|
||||
|
||||
const overviewSchema = z.object({
|
||||
lead: z.string().min(1),
|
||||
cards: z
|
||||
.array(
|
||||
z.object({
|
||||
badge: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
intent: intentSchema,
|
||||
})
|
||||
)
|
||||
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
||||
})
|
||||
|
||||
export const pageSpecV1Schema = z.object({
|
||||
schemaVersion: z.literal(INTERACTIVE_PAGE_SCHEMA_VERSION),
|
||||
id: z.string().min(1),
|
||||
lang: langSchema,
|
||||
hero: z.object({
|
||||
kicker: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
meta: z.string().optional(),
|
||||
}),
|
||||
overview: overviewSchema.optional(),
|
||||
sections: z
|
||||
.array(sectionSchema)
|
||||
.min(1)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxSections),
|
||||
footer: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PageSpecV1Parsed = z.infer<typeof pageSpecV1Schema>
|
||||
|
||||
/** Re-export for callers. */
|
||||
export { PAGE_BLOCK_TYPES }
|
||||
355
memento-note/lib/interactive-page/sim-eval.ts
Normal file
355
memento-note/lib/interactive-page/sim-eval.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Safe math-expression evaluator for the generic-formula simulator.
|
||||
* Tokenizer + recursive-descent parser — NO eval/Function, no object access,
|
||||
* no loops. Identifiers resolve only against an explicit environment;
|
||||
* functions come from a fixed allowlist.
|
||||
*/
|
||||
|
||||
type Token =
|
||||
| { t: 'num'; v: number }
|
||||
| { t: 'id'; v: string }
|
||||
| { t: 'op'; v: string }
|
||||
| { t: 'lparen' }
|
||||
| { t: 'rparen' }
|
||||
| { t: 'comma' }
|
||||
|
||||
const CONSTANTS: Record<string, number> = {
|
||||
pi: Math.PI,
|
||||
e: Math.E,
|
||||
}
|
||||
|
||||
type Fn = (...args: number[]) => number
|
||||
const FUNCTIONS: Record<string, { fn: Fn; minArgs: number; maxArgs: number }> = {
|
||||
sqrt: { fn: Math.sqrt, minArgs: 1, maxArgs: 1 },
|
||||
abs: { fn: Math.abs, minArgs: 1, maxArgs: 1 },
|
||||
exp: { fn: Math.exp, minArgs: 1, maxArgs: 1 },
|
||||
ln: { fn: Math.log, minArgs: 1, maxArgs: 1 },
|
||||
log: { fn: Math.log10, minArgs: 1, maxArgs: 1 },
|
||||
round: { fn: Math.round, minArgs: 1, maxArgs: 1 },
|
||||
floor: { fn: Math.floor, minArgs: 1, maxArgs: 1 },
|
||||
ceil: { fn: Math.ceil, minArgs: 1, maxArgs: 1 },
|
||||
min: { fn: Math.min, minArgs: 1, maxArgs: 8 },
|
||||
max: { fn: Math.max, minArgs: 1, maxArgs: 8 },
|
||||
}
|
||||
|
||||
const ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
export type SimExprError = { message: string }
|
||||
|
||||
type Ast =
|
||||
| { k: 'num'; v: number }
|
||||
| { k: 'id'; v: string }
|
||||
| { k: 'call'; name: string; args: Ast[] }
|
||||
| { k: 'un'; op: '-'; a: Ast }
|
||||
| { k: 'bin'; op: string; a: Ast; b: Ast }
|
||||
|
||||
function tokenize(src: string): Token[] | SimExprError {
|
||||
const tokens: Token[] = []
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const ch = src[i]
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
let j = i
|
||||
while (j < src.length && /[0-9.]/.test(src[j])) j++
|
||||
const raw = src.slice(i, j)
|
||||
const v = Number(raw)
|
||||
if (!Number.isFinite(v)) return { message: `invalid number "${raw}"` }
|
||||
tokens.push({ t: 'num', v })
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9') {
|
||||
let j = i + 1
|
||||
while (j < src.length && /[0-9]/.test(src[j])) j++
|
||||
tokens.push({ t: 'num', v: Number(src.slice(i, j)) })
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
let j = i
|
||||
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++
|
||||
tokens.push({ t: 'id', v: src.slice(i, j) })
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if ('+-*/^%'.includes(ch)) {
|
||||
tokens.push({ t: 'op', v: ch })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === '(') {
|
||||
tokens.push({ t: 'lparen' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === ')') {
|
||||
tokens.push({ t: 'rparen' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === ',') {
|
||||
tokens.push({ t: 'comma' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
return { message: `unexpected character "${ch}"` }
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private pos = 0
|
||||
constructor(private tokens: Token[]) {}
|
||||
|
||||
private peek(): Token | undefined {
|
||||
return this.tokens[this.pos]
|
||||
}
|
||||
private next(): Token | undefined {
|
||||
return this.tokens[this.pos++]
|
||||
}
|
||||
private expectOp(): string | null {
|
||||
const t = this.peek()
|
||||
return t?.t === 'op' ? t.v : null
|
||||
}
|
||||
|
||||
parseExpr(): Ast | SimExprError {
|
||||
let left = this.parseTerm()
|
||||
if ('message' in left) return left
|
||||
for (;;) {
|
||||
const op = this.expectOp()
|
||||
if (op !== '+' && op !== '-') break
|
||||
this.next()
|
||||
const right = this.parseTerm()
|
||||
if ('message' in right) return right
|
||||
left = { k: 'bin', op, a: left, b: right }
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
private parseTerm(): Ast | SimExprError {
|
||||
let left = this.parseUnary()
|
||||
if ('message' in left) return left
|
||||
for (;;) {
|
||||
const op = this.expectOp()
|
||||
if (op !== '*' && op !== '/' && op !== '%') break
|
||||
this.next()
|
||||
const right = this.parseUnary()
|
||||
if ('message' in right) return right
|
||||
left = { k: 'bin', op, a: left, b: right }
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
private parseUnary(): Ast | SimExprError {
|
||||
if (this.expectOp() === '-') {
|
||||
this.next()
|
||||
const a = this.parseUnary()
|
||||
if ('message' in a) return a
|
||||
return { k: 'un', op: '-', a }
|
||||
}
|
||||
if (this.expectOp() === '+') {
|
||||
this.next()
|
||||
return this.parseUnary()
|
||||
}
|
||||
return this.parsePower()
|
||||
}
|
||||
|
||||
private parsePower(): Ast | SimExprError {
|
||||
const base = this.parseAtom()
|
||||
if ('message' in base) return base
|
||||
if (this.expectOp() === '^') {
|
||||
this.next()
|
||||
const exp = this.parseUnary() // right-assoc
|
||||
if ('message' in exp) return exp
|
||||
return { k: 'bin', op: '^', a: base, b: exp }
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private parseAtom(): Ast | SimExprError {
|
||||
const t = this.next()
|
||||
if (!t) return { message: 'unexpected end of expression' }
|
||||
if (t.t === 'num') return { k: 'num', v: t.v }
|
||||
if (t.t === 'lparen') {
|
||||
const inner = this.parseExpr()
|
||||
if ('message' in inner) return inner
|
||||
const close = this.next()
|
||||
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
|
||||
return inner
|
||||
}
|
||||
if (t.t === 'id') {
|
||||
if (this.peek()?.t === 'lparen') {
|
||||
this.next() // consume (
|
||||
const args: Ast[] = []
|
||||
if (this.peek()?.t !== 'rparen') {
|
||||
for (;;) {
|
||||
const arg = this.parseExpr()
|
||||
if ('message' in arg) return arg
|
||||
args.push(arg)
|
||||
if (this.peek()?.t === 'comma') {
|
||||
this.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
const close = this.next()
|
||||
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
|
||||
return { k: 'call', name: t.v, args }
|
||||
}
|
||||
return { k: 'id', v: t.v }
|
||||
}
|
||||
return { message: `unexpected token "${'v' in t ? t.v : t.t}"` }
|
||||
}
|
||||
|
||||
parseTop(): Ast | SimExprError {
|
||||
const ast = this.parseExpr()
|
||||
if ('message' in ast) return ast
|
||||
if (this.pos < this.tokens.length) {
|
||||
return { message: 'trailing tokens after expression' }
|
||||
}
|
||||
return ast
|
||||
}
|
||||
}
|
||||
|
||||
function evalAst(ast: Ast, env: Record<string, number>): number {
|
||||
switch (ast.k) {
|
||||
case 'num':
|
||||
return ast.v
|
||||
case 'id': {
|
||||
if (ast.v in CONSTANTS) return CONSTANTS[ast.v]
|
||||
const v = env[ast.v]
|
||||
return typeof v === 'number' ? v : NaN
|
||||
}
|
||||
case 'un':
|
||||
return -evalAst(ast.a, env)
|
||||
case 'bin': {
|
||||
const a = evalAst(ast.a, env)
|
||||
const b = evalAst(ast.b, env)
|
||||
switch (ast.op) {
|
||||
case '+':
|
||||
return a + b
|
||||
case '-':
|
||||
return a - b
|
||||
case '*':
|
||||
return a * b
|
||||
case '/':
|
||||
return b === 0 ? NaN : a / b
|
||||
case '%':
|
||||
return b === 0 ? NaN : a % b
|
||||
case '^':
|
||||
return Math.pow(a, b)
|
||||
default:
|
||||
return NaN
|
||||
}
|
||||
}
|
||||
case 'call': {
|
||||
const def = FUNCTIONS[ast.name]
|
||||
if (!def) return NaN
|
||||
const args = ast.args.map((a) => evalAst(a, env))
|
||||
if (args.some((x) => Number.isNaN(x))) return NaN
|
||||
return def.fn(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectIds(ast: Ast, out: Set<string>): void {
|
||||
switch (ast.k) {
|
||||
case 'num':
|
||||
return
|
||||
case 'id':
|
||||
out.add(ast.v)
|
||||
return
|
||||
case 'un':
|
||||
collectIds(ast.a, out)
|
||||
return
|
||||
case 'bin':
|
||||
collectIds(ast.a, out)
|
||||
collectIds(ast.b, out)
|
||||
return
|
||||
case 'call':
|
||||
ast.args.forEach((a) => collectIds(a, out))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export type ParsedSimExpr = {
|
||||
/** Evaluate against an environment; NaN when uncomputable. */
|
||||
evaluate(env: Record<string, number>): number
|
||||
/** Identifiers used (params/computed refs), excluding constants. */
|
||||
identifiers: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a safe math expression. Returns error message on syntax problems.
|
||||
* Unknown function names are rejected at parse time.
|
||||
*/
|
||||
export function parseSimExpr(src: string): ParsedSimExpr | SimExprError {
|
||||
const trimmed = src.trim()
|
||||
if (!trimmed || trimmed.length > 200) {
|
||||
return { message: 'expression empty or too long (max 200 chars)' }
|
||||
}
|
||||
const tokens = tokenize(trimmed)
|
||||
if ('message' in tokens) return tokens
|
||||
const ast = new Parser(tokens).parseTop()
|
||||
if ('message' in ast) return ast
|
||||
|
||||
const ids = new Set<string>()
|
||||
collectIds(ast, ids)
|
||||
for (const id of ids) {
|
||||
if (id in FUNCTIONS) {
|
||||
return { message: `"${id}" is a function name — call it with (...)` }
|
||||
}
|
||||
}
|
||||
// Validate function calls (unknown names, arity)
|
||||
const checkCalls = (node: Ast): SimExprError | null => {
|
||||
if (node.k === 'call') {
|
||||
const def = FUNCTIONS[node.name]
|
||||
if (!def) return { message: `unknown function "${node.name}"` }
|
||||
if (node.args.length < def.minArgs || node.args.length > def.maxArgs) {
|
||||
return { message: `function "${node.name}" expects ${def.minArgs}–${def.maxArgs} args` }
|
||||
}
|
||||
for (const a of node.args) {
|
||||
const err = checkCalls(a)
|
||||
if (err) return err
|
||||
}
|
||||
} else if (node.k === 'un') {
|
||||
return checkCalls(node.a)
|
||||
} else if (node.k === 'bin') {
|
||||
return checkCalls(node.a) ?? checkCalls(node.b)
|
||||
}
|
||||
return null
|
||||
}
|
||||
const callErr = checkCalls(ast)
|
||||
if (callErr) return callErr
|
||||
|
||||
const identifiers = [...ids].filter((id) => !(id in CONSTANTS))
|
||||
return {
|
||||
evaluate: (env) => evalAst(ast, env),
|
||||
identifiers,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation helper: parse + require every identifier ∈ allowedIds.
|
||||
* Returns a list of issue messages (empty = OK).
|
||||
*/
|
||||
export function validateSimExprRefs(
|
||||
src: string,
|
||||
allowedIds: Set<string>
|
||||
): string[] {
|
||||
const parsed = parseSimExpr(src)
|
||||
if ('message' in parsed) return [parsed.message]
|
||||
return parsed.identifiers
|
||||
.filter((id) => !allowedIds.has(id))
|
||||
.map((id) => `unknown identifier "${id}"`)
|
||||
}
|
||||
|
||||
/** Type guard helper for zod refinements. */
|
||||
export function isValidSimParamId(id: string): boolean {
|
||||
return ID_RE.test(id) && !(id in FUNCTIONS) && !(id in CONSTANTS)
|
||||
}
|
||||
153
memento-note/lib/interactive-page/types.ts
Normal file
153
memento-note/lib/interactive-page/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { ChartPayload, IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
|
||||
import type { CALLOUT_KINDS, PAGE_BLOCK_TYPES } from './constants'
|
||||
|
||||
export type { IntentId }
|
||||
export type PageBlockType = (typeof PAGE_BLOCK_TYPES)[number]
|
||||
export type CalloutKind = (typeof CALLOUT_KINDS)[number]
|
||||
|
||||
export type PageHero = {
|
||||
kicker: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
export type OverviewCard = {
|
||||
badge: string
|
||||
title: string
|
||||
body: string
|
||||
intent?: IntentId
|
||||
}
|
||||
|
||||
export type PageOverview = {
|
||||
lead: string
|
||||
cards: OverviewCard[]
|
||||
}
|
||||
|
||||
export type ProseBlock = { type: 'prose'; md: string }
|
||||
export type FormulaBlock = { type: 'formula'; tex: string; caption?: string }
|
||||
export type CalloutBlock = {
|
||||
type: 'callout'
|
||||
kind: CalloutKind
|
||||
title: string
|
||||
md: string
|
||||
}
|
||||
export type DemoBlock = {
|
||||
type: 'demo'
|
||||
demo: InteractiveDemoV1
|
||||
caption?: string
|
||||
}
|
||||
export type ChartBlock = {
|
||||
type: 'chart'
|
||||
payload: ChartPayload
|
||||
caption?: string
|
||||
}
|
||||
export type StatsBlock = {
|
||||
type: 'stats'
|
||||
items: { value: string; label: string; intent?: IntentId }[]
|
||||
}
|
||||
export type TableBlock = {
|
||||
type: 'table'
|
||||
columns: string[]
|
||||
rows: string[][]
|
||||
caption?: string
|
||||
}
|
||||
export type ImageBlock = {
|
||||
type: 'image'
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
// ── Simulator block (plugin catalog + generic formula) ──────────────────────
|
||||
|
||||
/** Curated simulator from the plugin registry — AI only picks + presets. */
|
||||
export type CatalogSimRef = {
|
||||
simId: string
|
||||
title?: string
|
||||
preset?: Record<string, number>
|
||||
disclaimer?: string
|
||||
}
|
||||
|
||||
export type GenericSimParam = {
|
||||
id: string
|
||||
symbol: string
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
defaultValue: number
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
}
|
||||
|
||||
export type GenericSimComputed = {
|
||||
id: string
|
||||
symbol: string
|
||||
label: string
|
||||
/** Safe math expression (see sim-eval) over params + previous computed. */
|
||||
expr: string
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
}
|
||||
|
||||
export type GenericSimVisual =
|
||||
| { kind: 'gauges' }
|
||||
| { kind: 'bars' }
|
||||
| { kind: 'curve'; xParamId: string; expr: string; xLabel?: string; yLabel?: string }
|
||||
|
||||
/** Inline custom simulation — expressions validated by sim-eval (no eval). */
|
||||
export type GenericFormulaSim = {
|
||||
simId: 'generic-formula'
|
||||
title: string
|
||||
intro?: string
|
||||
params: GenericSimParam[]
|
||||
computed: GenericSimComputed[]
|
||||
visual: GenericSimVisual
|
||||
disclaimer?: string
|
||||
}
|
||||
|
||||
export type SimRef = CatalogSimRef | GenericFormulaSim
|
||||
|
||||
export type SimBlock = {
|
||||
type: 'sim'
|
||||
sim: SimRef
|
||||
caption?: string
|
||||
}
|
||||
|
||||
export type PageBlock =
|
||||
| ProseBlock
|
||||
| FormulaBlock
|
||||
| CalloutBlock
|
||||
| DemoBlock
|
||||
| ChartBlock
|
||||
| StatsBlock
|
||||
| TableBlock
|
||||
| ImageBlock
|
||||
| SimBlock
|
||||
|
||||
export type PageSection = {
|
||||
id: string
|
||||
title: string
|
||||
blocks: PageBlock[]
|
||||
}
|
||||
|
||||
export type PageSpecV1 = {
|
||||
schemaVersion: 1
|
||||
id: string
|
||||
lang: string
|
||||
hero: PageHero
|
||||
overview?: PageOverview
|
||||
sections: PageSection[]
|
||||
footer?: string
|
||||
}
|
||||
|
||||
export type PageValidationIssue = {
|
||||
code: string
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type PageValidationResult =
|
||||
| { ok: true; page: PageSpecV1 }
|
||||
| { ok: false; issues: PageValidationIssue[] }
|
||||
303
memento-note/lib/interactive-page/validate.ts
Normal file
303
memento-note/lib/interactive-page/validate.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user