Files
Momento/memento-note/lib/publish/transform-editor-blocks.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

209 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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() || ''
}