import { load, type Cheerio, type CheerioAPI } from 'cheerio' import type { AnyNode } from 'domhandler' const CALLOUT_PUB_CLASS: Record = { 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, '"') } function firstParagraphText($: CheerioAPI, $el: Cheerio): 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(`
${innerHtml}
`, { 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( `
${summary}
${bodyHtml}
`, ) }) } 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(`
${inner}
`) }) } 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): boolean { return $el.hasClass('pub-callout-warning') && /exercice|exercise|تمرین/i.test(stripTags($el.html() || '')) } function isEnonceHeadingEl($: CheerioAPI, $el: Cheerio): 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): 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 ? `${esc(meta)}` : '' out.push(`
${esc(label)}${metaSpan}
${bodyParts.join('')}
${solutionHtml}
`) 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[] = [] let $cursor: Cheerio | 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(`
${esc(title)}
${bodyHtml}
`) } } 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(`
${html}
`, { decodeEntities: false } as never) const $root = $('#pub-root') cleanEditorArtifacts($) transformToggleBlocks($) transformCalloutBlocks($) bundleExerciseSequences($, $root) promoteDefinitionBlocks($) cleanEditorArtifacts($) return $root.html() || '' }