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)
151 lines
4.1 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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");
|
|
}
|