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
This commit is contained in:
111
memento-note/lib/publish/process-note-html.ts
Normal file
111
memento-note/lib/publish/process-note-html.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||
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/')
|
||||
}
|
||||
Reference in New Issue
Block a user