feat: publication IA (magazine/brief/essay) + fixes critique
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-06-28 07:32:57 +00:00
parent 902fe95a69
commit 96e7902f01
169 changed files with 5382 additions and 1527 deletions

View 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, '&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() || ''
}