karbe/src/lib/markdown.ts
Claude Integration 68f37f554f feat(plugins): content-pages + legal-pages (Phase 4.1 + 4.3)
Plugin content-pages :
- Modèle Prisma ContentPage (slug PK, title, body markdown, category, published)
- lib/content-pages.ts : helpers upsert/get/list/unpublish
- lib/markdown.ts : mini-renderer markdown server-side sans deps externe
  (h1-h3, paragraphes, gras/italique, liens, listes ul/ol, hr, blockquote,
  échappement HTML)
- ContentPageRenderer server component, applique le theme Guyane (font-serif)
- 5 pages seedées : /a-propos, /faq, /comment-ca-marche,
  /pour-comites-entreprise, /devenir-loueur
- Routes publiques + force-dynamic + guard requirePluginOr404

Plugin legal-pages :
- Réutilise le même modèle ContentPage, catégorie 'legal'
- 3 pages seedées : /cgv, /mentions-legales, /politique-de-confidentialite
  (contenu de base, à valider par avocat avant prod réelle)

Admin :
- /admin/content-pages : table par catégorie, statut publié/dépublié
- /admin/content-pages/[slug] : éditeur markdown + toggle publié
- PATCH /api/admin/content-pages/[slug]

Hooks plugin :
- onEnable seed + republish toutes les pages
- onDisable dépublie toute la catégorie sans la supprimer (preserve les edits)
2026-05-31 10:12:13 +00:00

151 lines
4.1 KiB
TypeScript

/**
* Mini-renderer markdown sans dépendance externe.
*
* Volontairement minimal et stable : pas de plugins, pas d'extension HTML
* arbitraire. Couvre les besoins des pages CMS Karbé (À propos, FAQ, CGV,
* etc.) sans introduire de surface d'attaque XSS.
*
* Supporte :
* # H1 / ## H2 / ### H3
* paragraphes (séparés par ligne vide)
* **gras** et *italique*
* [texte](https://lien)
* - liste à puces
* 1. liste numérotée
* --- (séparateur)
* > citation (blockquote)
*
* Toute autre balise HTML dans le markdown est échappée.
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function inline(text: string): string {
let out = escapeHtml(text);
// **bold**
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// *italic*
out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
// [text](url)
out = out.replace(
/\[([^\]]+)\]\(((?:https?:\/\/|\/|mailto:)[^)\s]+)\)/g,
(_m, label: string, href: string) => {
const safe = href.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const isExternal = /^https?:/.test(href);
const extra = isExternal ? ' target="_blank" rel="noopener noreferrer"' : "";
return `<a href="${safe}" class="text-[var(--color-karbe-canopy-700)] underline hover:text-[var(--color-karbe-laterite-700)]"${extra}>${label}</a>`;
},
);
return out;
}
export function renderMarkdown(md: string): string {
const lines = md.replace(/\r\n?/g, "\n").split("\n");
const out: string[] = [];
let paragraph: string[] = [];
let listType: "ul" | "ol" | null = null;
let inBlockquote = false;
const blockquote: string[] = [];
function flushParagraph() {
if (paragraph.length) {
out.push(`<p class="my-4 leading-relaxed">${inline(paragraph.join(" "))}</p>`);
paragraph = [];
}
}
function flushList() {
if (listType) {
out.push(`</${listType}>`);
listType = null;
}
}
function flushBlockquote() {
if (inBlockquote) {
out.push(
`<blockquote class="my-4 border-l-4 border-[var(--color-karbe-laterite-500)] pl-4 italic text-[var(--color-karbe-ink)]/75">${blockquote
.map((l) => inline(l))
.join(" ")}</blockquote>`,
);
blockquote.length = 0;
inBlockquote = false;
}
}
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line.trim()) {
flushParagraph();
flushList();
flushBlockquote();
continue;
}
if (/^---+$/.test(line.trim())) {
flushParagraph();
flushList();
flushBlockquote();
out.push(`<hr class="my-6 border-[var(--color-karbe-canopy-100)]" />`);
continue;
}
let m: RegExpExecArray | null;
if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) {
flushParagraph();
flushList();
flushBlockquote();
const level = m[1].length;
const sizes = ["", "text-3xl mt-8 mb-4 font-medium font-serif", "text-2xl mt-7 mb-3 font-medium font-serif", "text-xl mt-6 mb-2 font-semibold"];
out.push(`<h${level} class="${sizes[level]}">${inline(m[2])}</h${level}>`);
continue;
}
if ((m = /^[-*]\s+(.+)$/.exec(line))) {
flushParagraph();
flushBlockquote();
if (listType !== "ul") {
flushList();
out.push(`<ul class="my-4 list-disc space-y-1 pl-6">`);
listType = "ul";
}
out.push(`<li>${inline(m[1])}</li>`);
continue;
}
if ((m = /^\d+\.\s+(.+)$/.exec(line))) {
flushParagraph();
flushBlockquote();
if (listType !== "ol") {
flushList();
out.push(`<ol class="my-4 list-decimal space-y-1 pl-6">`);
listType = "ol";
}
out.push(`<li>${inline(m[1])}</li>`);
continue;
}
if ((m = /^>\s?(.*)$/.exec(line))) {
flushParagraph();
flushList();
inBlockquote = true;
blockquote.push(m[1]);
continue;
}
flushList();
flushBlockquote();
paragraph.push(line);
}
flushParagraph();
flushList();
flushBlockquote();
return out.join("\n");
}