Files
Momento/memento-note/lib/publish/process-note-html.ts
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

112 lines
4.0 KiB
TypeScript

import katex from 'katex'
import { sanitizePublishedHtml } from '@/lib/sanitize-content'
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
import { transformEditorBlocksForPublish } from '@/lib/publish/transform-editor-blocks'
function decodeHtml(text: string): string {
const map: Record<string, string> = { '&quot;': '"', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&#39;': "'" }
return text.replace(/&[a-z#0-9]+;/gi, (m) => map[m] || m)
}
function extractLatex(attrs: string, inner: string): string {
const fromAttr = attrs.match(/data-latex=["']([^"']*)["']/i)?.[1]
if (fromAttr) return decodeHtml(fromAttr)
const text = inner.replace(/<[^>]+>/g, '').trim()
return decodeHtml(text)
}
function isAlreadyKatex(html: string): boolean {
return /class=["'][^"']*\bkatex\b/i.test(html)
}
function renderKatexDisplay(latex: string): string {
const trimmed = latex.trim()
if (!trimmed) return ''
try {
const rendered = katex.renderToString(trimmed, { displayMode: true, throwOnError: false })
return `<div class="r-math-display">${rendered}</div>`
} catch {
return `<div class="r-math-display r-math-fallback">${trimmed}</div>`
}
}
function renderKatexInline(latex: string): string {
const trimmed = latex.trim()
if (!trimmed) return ''
try {
const rendered = katex.renderToString(trimmed, { displayMode: false, throwOnError: false })
return `<span class="r-math-inline">${rendered}</span>`
} catch {
return `<span class="r-math-inline r-math-fallback">${trimmed}</span>`
}
}
/** Convertit nœuds éditeur + délimiteurs LaTeX en HTML KaTeX. */
export function renderMathInHtml(html: string): string {
if (!html?.trim()) return ''
let result = preprocessMathInHtml(html)
// Blocs : data-type="math-equation" ou .math-equation-block
result = result.replace(
/<div\b([^>]*(?:data-type=["']math-equation["']|class=["'][^"']*math-equation-block)[^>]*)>([\s\S]*?)<\/div>/gi,
(full, attrs, inner) => {
if (isAlreadyKatex(full)) return full
const latex = extractLatex(attrs, inner)
return latex ? renderKatexDisplay(latex) : full
},
)
// Inline : data-type="inline-math" ou .inline-math
result = result.replace(
/<span\b([^>]*(?:data-type=["']inline-math["']|class=["'][^"']*inline-math)[^>]*)>([\s\S]*?)<\/span>/gi,
(full, attrs, inner) => {
if (isAlreadyKatex(full)) return full
const latex = extractLatex(attrs, inner)
return latex ? renderKatexInline(latex) : full
},
)
return result
}
/** Prépare le HTML de la note pour affichage public (KaTeX, callouts, nettoyage éditeur). */
export function processNoteHtmlForPublish(html: string): string {
if (!html?.trim()) return ''
let result = renderMathInHtml(html)
// Blocs éditeur TipTap → HTML publication (exercices, toggles, callouts…)
result = transformEditorBlocksForPublish(result)
result = result.replace(/<div[^>]*class="link-preview-searchable"[^>]*>[\s\S]*?<\/div>/g, '')
// Figures éditeur → sémantique publication (img déjà traités ignorés)
result = result.replace(/<img([^>]*?)>/gi, (match, attrs) => {
if (/class=["'][^"']*pub-/i.test(match)) return match
const altMatch = attrs.match(/\balt=["']([^"']*)["']/i)
const alt = altMatch?.[1] || ''
return `<figure class="pub-figure"><img${attrs} loading="lazy" class="pub-figure-img" />${alt ? `<figcaption class="pub-figure-caption">${alt}</figcaption>` : ''}</figure>`
})
return sanitizePublishedHtml(result)
}
export function extractPublishImageUrls(html: string): string[] {
if (!html) return []
const urls = new Set<string>()
for (const match of html.matchAll(/<img[^>]+src=["']([^"'>]+)["']/gi)) {
const src = match[1]?.trim()
if (src && isAllowedImageSrc(src)) urls.add(src)
}
return Array.from(urls)
}
export function isAllowedImageSrc(src: string): boolean {
const t = src.trim()
return t.startsWith('/uploads/')
|| t.startsWith('https://')
|| t.startsWith('http://')
|| t.startsWith('/api/')
}