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
209 lines
7.1 KiB
TypeScript
209 lines
7.1 KiB
TypeScript
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() || ''
|
||
}
|