Files
Momento/memento-note/lib/interactive-page/normalize.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

336 lines
9.9 KiB
TypeScript

/**
* 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
}