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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user