/**
* 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 ``
},
},
})
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, '')
}