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/')
|
||||
}
|
||||
286
memento-note/lib/publish/shared-css.ts
Normal file
286
memento-note/lib/publish/shared-css.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/** CSS KaTeX commun aux pages publiées */
|
||||
export const KATEX_PUBLISH_CSS = `
|
||||
.r-math-display {
|
||||
margin: 1.75em 0;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
.r-math-inline { display: inline; }
|
||||
.r-math-inline .katex { font-size: 1.05em; }
|
||||
.r-math-fallback {
|
||||
font-family: 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.pub-rewrite-body .katex-display { margin: 0; }
|
||||
.katex { font-size: 1.1em; }
|
||||
.katex-display { margin: 0; overflow-x: auto; overflow-y: hidden; }
|
||||
`
|
||||
|
||||
export const REWRITE_SHARED_CSS = `
|
||||
${KATEX_PUBLISH_CSS}
|
||||
/* ─── Résumé introductif ─────────────────────────────── */
|
||||
.pub-rewrite-summary {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.7;
|
||||
font-style: italic;
|
||||
color: var(--pub-summary-color, #444);
|
||||
margin-bottom: 2em;
|
||||
padding-bottom: 1.5em;
|
||||
border-bottom: 2px solid var(--pub-accent, #A47148);
|
||||
}
|
||||
|
||||
/* ─── Exercices ──────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-exercise {
|
||||
border: 2px solid var(--pub-exercise-border, #e0d4c3);
|
||||
border-radius: 12px;
|
||||
margin: 1.75em 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
background: var(--pub-exercise-header-bg, #f6f1ea);
|
||||
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-num {
|
||||
font-weight: 700;
|
||||
font-size: 0.82em;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pub-accent, #A47148);
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-body {
|
||||
padding: 16px 20px;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-body p:first-child { margin-top: 0; }
|
||||
.pub-rewrite-body .pub-exercise-body p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* Corps réécrit : texte toujours sombre sur fond clair */
|
||||
.pub-rewrite-body {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body p,
|
||||
.pub-rewrite-body li,
|
||||
.pub-rewrite-body td,
|
||||
.pub-rewrite-body th {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Solution collapsible */
|
||||
.pub-rewrite-body .pub-solution {
|
||||
border-top: 1px dashed var(--pub-exercise-border, #e0d4c3);
|
||||
}
|
||||
.pub-rewrite-body .pub-solution > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--pub-accent, #A47148);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.pub-rewrite-body .pub-solution > summary::before { content: '▶'; font-size: 10px; transition: transform 0.2s; }
|
||||
.pub-rewrite-body .pub-solution[open] > summary::before { transform: rotate(90deg); }
|
||||
.pub-rewrite-body .pub-solution-body {
|
||||
padding: 14px 20px 18px;
|
||||
background: var(--pub-solution-bg, rgba(164,113,72,0.04));
|
||||
border-top: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* ─── Étapes tutoriel ────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-step {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
.pub-rewrite-body .pub-step-num {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--pub-accent, #A47148);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.pub-rewrite-body .pub-step-body p:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── Définitions / Concepts ─────────────────────────── */
|
||||
.pub-rewrite-body .pub-definition {
|
||||
margin: 1.5em 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pub-definition-border, #ddd);
|
||||
}
|
||||
.pub-rewrite-body .pub-definition-term {
|
||||
padding: 8px 16px;
|
||||
background: var(--pub-accent, #A47148);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.88em;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.pub-rewrite-body .pub-definition-body {
|
||||
padding: 12px 16px;
|
||||
background: var(--pub-definition-bg, #fafaf8);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body .pub-definition-body p { margin: 0.4em 0; color: #1a1a1a; }
|
||||
.pub-rewrite-body .pub-definition-body p:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── Toggle / Collapsible ───────────────────────────── */
|
||||
.pub-rewrite-body .pub-toggle {
|
||||
border: 1px solid var(--pub-toggle-border, #e5e5e5);
|
||||
border-radius: 10px;
|
||||
margin: 1.2em 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle > .pub-toggle-trigger {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
background: var(--pub-toggle-header-bg, #f7f7f5);
|
||||
user-select: none;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle > .pub-toggle-trigger::before {
|
||||
content: '▶';
|
||||
font-size: 10px;
|
||||
color: var(--pub-accent, #A47148);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle[open] > .pub-toggle-trigger::before { transform: rotate(90deg); }
|
||||
.pub-rewrite-body .pub-toggle-body {
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--pub-toggle-border, #e5e5e5);
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle-body p:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── Mise en avant ──────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-highlight {
|
||||
margin: 1.5em 0;
|
||||
padding: 16px 20px;
|
||||
background: var(--pub-highlight-bg, #fffbf5);
|
||||
border-left: 4px solid var(--pub-accent, #A47148);
|
||||
border-radius: 0 10px 10px 0;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body .pub-highlight p { margin: 0; color: #1a1a1a; }
|
||||
|
||||
/* ─── Callouts ───────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-callout {
|
||||
margin: 1.4em 0;
|
||||
padding: 14px 18px 14px 52px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
font-size: 0.93em;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout::before {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 14px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: inherit;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout p { margin: 0.3em 0; color: inherit; }
|
||||
.pub-rewrite-body .pub-callout p:first-of-type { margin-top: 0; }
|
||||
|
||||
.pub-rewrite-body .pub-callout-info {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout-info::before { content: 'ℹ️'; }
|
||||
|
||||
.pub-rewrite-body .pub-callout-tip {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #6ee7b7;
|
||||
color: #065f46;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout-tip::before { content: '💡'; }
|
||||
|
||||
.pub-rewrite-body .pub-callout-warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fbbf24;
|
||||
color: #78350f;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout-warning::before { content: '⚠️'; }
|
||||
|
||||
/* ─── Checklist ──────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-checklist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.pub-rewrite-body .pub-checklist li {
|
||||
padding: 8px 12px 8px 36px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
background: var(--pub-checklist-bg, rgba(0,0,0,0.02));
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body .pub-checklist li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 8px;
|
||||
font-weight: 700;
|
||||
color: var(--pub-accent, #A47148);
|
||||
}
|
||||
|
||||
/* ─── Code ───────────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-code {
|
||||
margin: 1.5em 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pub-rewrite-body .pub-code code {
|
||||
display: block;
|
||||
padding: 20px 22px;
|
||||
font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.65;
|
||||
overflow-x: auto;
|
||||
}
|
||||
`
|
||||
116
memento-note/lib/publish/template-render.ts
Normal file
116
memento-note/lib/publish/template-render.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createHash } from 'crypto'
|
||||
import type { PublishEnhanceSpec, PublishRewriteSpec, PublishTemplateId } from './types'
|
||||
import { extractPublishImageUrls, isAllowedImageSrc, processNoteHtmlForPublish } from './process-note-html'
|
||||
|
||||
function esc(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function heroFigure(src: string): string {
|
||||
if (!isAllowedImageSrc(src)) return ''
|
||||
return `<figure class="pub-hero"><img src="${esc(src)}" alt="" class="pub-hero-img" loading="eager" decoding="async" /></figure>`
|
||||
}
|
||||
|
||||
function galleryFigures(urls: string[]): string {
|
||||
const items = urls.map((src) => {
|
||||
if (!isAllowedImageSrc(src)) return ''
|
||||
return `<figure class="pub-gallery-item"><img src="${esc(src)}" alt="" class="pub-gallery-img" loading="lazy" decoding="async" /></figure>`
|
||||
}).filter(Boolean).join('')
|
||||
if (!items) return ''
|
||||
return `<div class="pub-gallery" aria-label="Images">${items}</div>`
|
||||
}
|
||||
|
||||
function removeFirstImageFromHtml(html: string): string {
|
||||
return html.replace(/<figure[^>]*>[\s\S]*?<img[^>]*>[\s\S]*?<\/figure>|<img[^>]*>/i, '')
|
||||
}
|
||||
|
||||
/* ─── Mode éditorial (texte original + habillage) ──────────────────────── */
|
||||
export function renderPublishedTemplate(
|
||||
spec: PublishEnhanceSpec,
|
||||
template: PublishTemplateId,
|
||||
sourceHtml: string,
|
||||
): string {
|
||||
const images = extractPublishImageUrls(sourceHtml)
|
||||
const bodySource = images[0] ? removeFirstImageFromHtml(sourceHtml) : sourceHtml
|
||||
const processedBody = processNoteHtmlForPublish(bodySource)
|
||||
const hero = images[0] ? heroFigure(images[0]) : ''
|
||||
const gallery = images.length > 1 ? galleryFigures(images.slice(1)) : ''
|
||||
|
||||
const summary = esc(spec.summary)
|
||||
const pullQuote = spec.pullQuote
|
||||
? `<blockquote class="pub-pull-quote"><p>${esc(spec.pullQuote)}</p></blockquote>`
|
||||
: ''
|
||||
const epigraph = spec.epigraph
|
||||
? `<p class="pub-epigraph">${esc(spec.epigraph)}</p>`
|
||||
: ''
|
||||
const keyPoints = spec.keyPoints?.length
|
||||
? `<div class="pub-key-points">
|
||||
<p class="pub-key-points-label">Points clés</p>
|
||||
<ul>${spec.keyPoints.map((k) => `<li>${esc(k)}</li>`).join('')}</ul>
|
||||
</div>`
|
||||
: ''
|
||||
|
||||
const bodyBlock = `<div class="pub-body-source">${processedBody}</div>`
|
||||
|
||||
if (template === 'brief') {
|
||||
return `<div class="pub-tpl pub-tpl-brief">
|
||||
${hero}
|
||||
${summary ? `<div class="pub-brief-lead"><p>${summary}</p></div>` : ''}
|
||||
${keyPoints}
|
||||
${bodyBlock}
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
if (template === 'essay') {
|
||||
return `<div class="pub-tpl pub-tpl-essay">
|
||||
${hero}
|
||||
${epigraph}
|
||||
${summary ? `<p class="pub-essay-summary">${summary}</p>` : ''}
|
||||
${bodyBlock}
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
return `<div class="pub-tpl pub-tpl-magazine">
|
||||
${hero}
|
||||
${summary ? `<p class="pub-magazine-dek">${summary}</p>` : ''}
|
||||
${pullQuote}
|
||||
${bodyBlock}
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
/* ─── Mode réécriture (HTML sémantique IA) ─────────────────────────────── */
|
||||
export function renderRewrittenTemplate(
|
||||
spec: PublishRewriteSpec,
|
||||
template: PublishTemplateId,
|
||||
sourceHtml: string,
|
||||
): string {
|
||||
const images = extractPublishImageUrls(sourceHtml)
|
||||
const hero = images[0] ? heroFigure(images[0]) : ''
|
||||
const gallery = images.length > 1 ? galleryFigures(images.slice(1)) : ''
|
||||
|
||||
const summary = spec.summary
|
||||
? `<p class="pub-rewrite-summary">${esc(spec.summary)}</p>`
|
||||
: ''
|
||||
|
||||
const processedBody = processNoteHtmlForPublish(spec.body)
|
||||
|
||||
return `<div class="pub-tpl pub-tpl-${template} pub-tpl-rewrite" data-content-type="${spec.contentType}">
|
||||
${hero}
|
||||
${summary}
|
||||
<div class="pub-rewrite-body">
|
||||
${processedBody}
|
||||
</div>
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
export function computePublishedSourceHash(content: string): string {
|
||||
return createHash('sha256').update(content || '').digest('hex').slice(0, 16)
|
||||
}
|
||||
208
memento-note/lib/publish/transform-editor-blocks.ts
Normal file
208
memento-note/lib/publish/transform-editor-blocks.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { load, type Cheerio, type CheerioAPI } from 'cheerio'
|
||||
import type { AnyNode } from 'domhandler'
|
||||
|
||||
const CALLOUT_PUB_CLASS: Record<string, string> = {
|
||||
info: 'pub-callout-info',
|
||||
warning: 'pub-callout-warning',
|
||||
tip: 'pub-callout-tip',
|
||||
success: 'pub-callout-tip',
|
||||
danger: 'pub-callout-warning',
|
||||
}
|
||||
|
||||
function stripTags(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function esc(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function firstParagraphText($: CheerioAPI, $el: Cheerio<AnyNode>): string {
|
||||
const p = $el.find('p').first()
|
||||
const text = p.length ? stripTags(p.html() || '') : stripTags($el.html() || '')
|
||||
return text || 'Voir le contenu'
|
||||
}
|
||||
|
||||
function isSolutionToggle(summary: string): boolean {
|
||||
return /solution|réponse|answer|corrigé|corrige|reveal|révéler/i.test(summary)
|
||||
}
|
||||
|
||||
function transformToggleBlocks($: CheerioAPI): void {
|
||||
$('div[data-type="toggle-block"]').each((_, el) => {
|
||||
const $el = $(el)
|
||||
const opened = $el.attr('data-opened') !== 'false'
|
||||
const innerHtml = $el.html() || ''
|
||||
const $inner = load(`<div>${innerHtml}</div>`, { decodeEntities: false } as never)
|
||||
const summaryText = firstParagraphText($, $inner.root())
|
||||
|
||||
const firstP = $inner('p').first()
|
||||
if (firstP.length && /cliquer|révéler|reveal|click/i.test(stripTags(firstP.html() || ''))) {
|
||||
firstP.remove()
|
||||
}
|
||||
$inner('h3').each((__, h) => {
|
||||
if (/solution|réponse|answer/i.test(stripTags($(h).html() || ''))) $(h).remove()
|
||||
})
|
||||
|
||||
const bodyHtml = $inner.root().html() || innerHtml
|
||||
const isSolution = isSolutionToggle(summaryText)
|
||||
const summary = isSolution ? 'Voir la solution' : esc(summaryText.slice(0, 120))
|
||||
const cssClass = isSolution ? 'pub-solution' : 'pub-toggle'
|
||||
const summaryClass = isSolution ? '' : ' class="pub-toggle-trigger"'
|
||||
const bodyClass = isSolution ? 'pub-solution-body' : 'pub-toggle-body'
|
||||
const openAttr = opened ? ' open' : ''
|
||||
|
||||
$el.replaceWith(
|
||||
`<details class="${cssClass}"${openAttr}><summary${summaryClass}>${summary}</summary><div class="${bodyClass}">${bodyHtml}</div></details>`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function transformCalloutBlocks($: CheerioAPI): void {
|
||||
$('div[data-type="callout-block"]').each((_, el) => {
|
||||
const $el = $(el)
|
||||
if ($el.closest('.pub-exercise').length) return
|
||||
const type = ($el.attr('data-callout-type') || 'info').toLowerCase()
|
||||
const pubClass = CALLOUT_PUB_CLASS[type] || 'pub-callout-info'
|
||||
const inner = $el.html() || ''
|
||||
$el.replaceWith(`<div class="pub-callout ${pubClass}">${inner}</div>`)
|
||||
})
|
||||
}
|
||||
|
||||
function extractExerciseMeta(headerHtml: string): { label: string; meta: string } {
|
||||
const text = stripTags(headerHtml)
|
||||
const match = text.match(/(exercice|exercise|تمرین)\s*(\d+)?/i)
|
||||
const label = match
|
||||
? `${match[1].charAt(0).toUpperCase()}${match[1].slice(1).toLowerCase()}${match[2] ? ` ${match[2]}` : ''}`
|
||||
: 'Exercice'
|
||||
const meta = text.replace(new RegExp(label, 'i'), '').replace(/^[\s—–-]+/, '').trim()
|
||||
return { label, meta }
|
||||
}
|
||||
|
||||
function isExerciseHeaderEl($: CheerioAPI, $el: Cheerio<AnyNode>): boolean {
|
||||
return $el.hasClass('pub-callout-warning')
|
||||
&& /exercice|exercise|تمرین/i.test(stripTags($el.html() || ''))
|
||||
}
|
||||
|
||||
function isEnonceHeadingEl($: CheerioAPI, $el: Cheerio<AnyNode>): boolean {
|
||||
if (!$el.is('h2, h3')) return false
|
||||
const text = stripTags($el.html() || '').toLowerCase()
|
||||
return /énoncé|enonce|problem|question|statement/.test(text)
|
||||
}
|
||||
|
||||
function bundleExerciseSequences($: CheerioAPI, $root: Cheerio<AnyNode>): void {
|
||||
const nodes = $root.children().toArray()
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < nodes.length) {
|
||||
const $node = $(nodes[i])
|
||||
if (!isExerciseHeaderEl($, $node)) {
|
||||
out.push($.html(nodes[i]) || '')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const { label, meta } = extractExerciseMeta($node.html() || '')
|
||||
const bodyParts: string[] = []
|
||||
let solutionHtml = ''
|
||||
let j = i + 1
|
||||
|
||||
if (j < nodes.length && isEnonceHeadingEl($, $(nodes[j]))) {
|
||||
bodyParts.push($.html(nodes[j]) || '')
|
||||
j++
|
||||
}
|
||||
|
||||
while (j < nodes.length) {
|
||||
const $sib = $(nodes[j])
|
||||
if (isExerciseHeaderEl($, $sib)) break
|
||||
if ($sib.is('details')) {
|
||||
const sum = stripTags($sib.find('summary').first().text())
|
||||
$sib.removeClass('pub-toggle').addClass('pub-solution')
|
||||
$sib.find('summary').first().text('Voir la solution')
|
||||
$sib.find('.pub-toggle-body').removeClass('pub-toggle-body').addClass('pub-solution-body')
|
||||
solutionHtml = $.html(nodes[j]) || ''
|
||||
j++
|
||||
break
|
||||
}
|
||||
if ($sib.is('h2') && bodyParts.length > 0) break
|
||||
bodyParts.push($.html(nodes[j]) || '')
|
||||
j++
|
||||
}
|
||||
|
||||
const metaSpan = meta ? `<span class="pub-exercise-meta">${esc(meta)}</span>` : ''
|
||||
out.push(`<div class="pub-exercise">
|
||||
<div class="pub-exercise-header"><span class="pub-exercise-num">${esc(label)}</span>${metaSpan}</div>
|
||||
<div class="pub-exercise-body">${bodyParts.join('')}</div>
|
||||
${solutionHtml}
|
||||
</div>`)
|
||||
i = j
|
||||
}
|
||||
|
||||
$root.html(out.join(''))
|
||||
}
|
||||
|
||||
function promoteDefinitionBlocks($: CheerioAPI): void {
|
||||
const headings = $('h2, h3').toArray()
|
||||
for (const el of headings) {
|
||||
const $h = $(el)
|
||||
if ($h.closest('.pub-exercise, .pub-definition').length) continue
|
||||
|
||||
const title = stripTags($h.html() || '')
|
||||
const $next = $h.next()
|
||||
if (!title || title.length > 80 || !$next.length) continue
|
||||
|
||||
const isShortTitle = title.split(/\s+/).length <= 6
|
||||
const looksLikeDefinition = /définition|definition|concept/i.test(title)
|
||||
|| (isShortTitle && $next.is('p, ul, ol') && !/énoncé|exercice|chapitre|partie/i.test(title))
|
||||
|
||||
if (!looksLikeDefinition) continue
|
||||
|
||||
const chunks: Cheerio<AnyNode>[] = []
|
||||
let $cursor: Cheerio<AnyNode> | null = $next
|
||||
while ($cursor?.length) {
|
||||
const tag = $cursor.prop('tagName')?.toLowerCase()
|
||||
if (tag === 'h2' || tag === 'h3') break
|
||||
if ($cursor.is('p, ul, ol, blockquote')) {
|
||||
chunks.push($cursor)
|
||||
$cursor = $cursor.next()
|
||||
} else break
|
||||
}
|
||||
|
||||
if (chunks.length === 0) continue
|
||||
|
||||
const bodyHtml = chunks.map((c) => $.html(c)).join('')
|
||||
chunks.forEach((c) => c.remove())
|
||||
$h.replaceWith(`<div class="pub-definition">
|
||||
<div class="pub-definition-term">${esc(title)}</div>
|
||||
<div class="pub-definition-body">${bodyHtml}</div>
|
||||
</div>`)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanEditorArtifacts($: CheerioAPI): void {
|
||||
$('button').remove()
|
||||
$('div[data-type="outline-block"]').remove()
|
||||
$('.link-preview-searchable').remove()
|
||||
$('[contenteditable]').removeAttr('contenteditable')
|
||||
}
|
||||
|
||||
/** Convertit le HTML TipTap en blocs web (exercices, toggles, callouts…). */
|
||||
export function transformEditorBlocksForPublish(html: string): string {
|
||||
if (!html?.trim()) return ''
|
||||
|
||||
const $ = load(`<div id="pub-root">${html}</div>`, { decodeEntities: false } as never)
|
||||
const $root = $('#pub-root')
|
||||
|
||||
cleanEditorArtifacts($)
|
||||
transformToggleBlocks($)
|
||||
transformCalloutBlocks($)
|
||||
bundleExerciseSequences($, $root)
|
||||
promoteDefinitionBlocks($)
|
||||
cleanEditorArtifacts($)
|
||||
|
||||
return $root.html() || ''
|
||||
}
|
||||
21
memento-note/lib/publish/types.ts
Normal file
21
memento-note/lib/publish/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay'] as const
|
||||
export type PublishTemplateId = (typeof PUBLISH_TEMPLATES)[number]
|
||||
|
||||
/** Métadonnées éditoriales IA — corps = HTML source original. */
|
||||
export interface PublishEnhanceSpec {
|
||||
summary: string
|
||||
pullQuote?: string
|
||||
epigraph?: string
|
||||
keyPoints?: string[]
|
||||
}
|
||||
|
||||
/** Réécriture IA — corps = HTML sémantique généré par l'IA. */
|
||||
export interface PublishRewriteSpec {
|
||||
contentType: 'article' | 'exercises' | 'tutorial' | 'reference' | 'mixed'
|
||||
summary: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export function isPublishTemplateId(value: string): value is PublishTemplateId {
|
||||
return (PUBLISH_TEMPLATES as readonly string[]).includes(value)
|
||||
}
|
||||
Reference in New Issue
Block a user