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
112 lines
4.0 KiB
TypeScript
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> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
|
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/')
|
|
}
|