/** * blog_renderer — pipeline markdown → HTML pour les articles du blog. * * Appelé : * - au seeder (3 articles fondateurs en DB) * - depuis l'admin React au save (PR3) via un endpoint dédié * - depuis le cron weekly_blog_generator (PR4) pour les drafts IA * * Le HTML rendu est cache dans `posts.content_html` pour éviter de re-parser * le markdown à chaque hit page. Si tu changes ce module, prévois une * migration de re-render des posts existants. */ import { Marked, type Tokens } from 'marked' /** Mots/min retenus pour le calcul reading_time (moyenne lecteur fr web). */ const WORDS_PER_MINUTE = 220 /** * Renderer marked configuré pour le blog Rubis : * - GFM (tables, autolinks, ~strikethrough~) * - heading IDs auto pour ancres / future TOC * - liens externes en target=_blank rel=noopener * - br: false (un saut de ligne ne devient pas
, seul un \n\n crée un

) */ const marked = new Marked({ gfm: true, breaks: false, pedantic: false, }) marked.use({ renderer: { heading({ tokens, depth }: Tokens.Heading): string { const text = this.parser.parseInline(tokens) const id = slugify(stripTags(text)) return `${text}\n` }, link({ href, title, tokens }: Tokens.Link): string { const text = this.parser.parseInline(tokens) const isExternal = /^https?:\/\//.test(href) && !href.startsWith('https://rubis.pro') const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : '' const relAttr = isExternal ? ' rel="noopener noreferrer"' : '' const targetAttr = isExternal ? ' target="_blank"' : '' return `${text}` }, image({ href, title, text }: Tokens.Image): string { // Lazy par défaut, dimensions à enrichir via image processing futur. const altAttr = `alt="${escapeHtmlAttr(text || '')}"` const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : '' return `` }, }, }) export type RenderedPost = { contentHtml: string wordCount: number readingTimeMinutes: number } /** Markdown → HTML + métriques de lecture. */ export function renderPost(contentMd: string): RenderedPost { const contentHtml = marked.parse(contentMd, { async: false }) as string const wordCount = countWords(contentMd) const readingTimeMinutes = Math.max(1, Math.round(wordCount / WORDS_PER_MINUTE)) return { contentHtml, wordCount, readingTimeMinutes } } /** * Slug ASCII kebab-case déterministe. * "Comment relancer un client — sans rien casser" → "comment-relancer-un-client-sans-rien-casser" */ export function slugify(input: string): string { return input .normalize('NFD') .replace(/[̀-ͯ]/g, '') // diacritiques .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 200) } function countWords(md: string): number { // On strippe code blocks + balisage MD avant de compter — sinon les ``` et ** // gonflent artificiellement. const text = md .replace(/```[\s\S]*?```/g, ' ') .replace(/`[^`]*`/g, ' ') .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/[*_~#>]/g, ' ') const matches = text.match(/\S+/g) return matches ? matches.length : 0 } function escapeHtmlAttr(s: string): string { return s.replace(/"/g, '"').replace(//g, '>') } function stripTags(s: string): string { return s.replace(/<[^>]+>/g, '') }