spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/render/markdown.mjs8 * Purpose : GitHub-grade Markdown pipeline — GFM, badges, mermaid, sanitized9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import MarkdownIt from 'markdown-it';14import anchor from 'markdown-it-anchor';15import taskLists from 'markdown-it-task-lists';16import footnote from 'markdown-it-footnote';17import { full as emoji } from 'markdown-it-emoji';18import sanitizeHtml from 'sanitize-html';19import { posix } from 'node:path';20import { escapeHtml } from '../lib/util.mjs';21import { highlightSync, resolveLang, ensureLangs } from './highlight.mjs';2223/**24 * GitHub's heading slug algorithm (lowercase, strip punctuation, dashes).25 * @param {string} text26 * @returns {string}27 */28export function githubSlug(text) {29 return String(text)30 .trim()31 .toLowerCase()32 .replace(/<[^>]+>/g, '')33 .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '')34 .replace(/\s/g, '-');35}3637const FENCE_PLACEHOLDER_ATTR = 'data-spbgit-fence';3839/**40 * Build the markdown-it instance. Fences render as placeholders and are41 * swapped back in after sanitization so shiki markup survives untouched.42 * @param {{fences: string[]}} state collector for rendered fence HTML43 */44function buildParser(state) {45 const md = new MarkdownIt({46 html: true,47 linkify: true,48 breaks: false,49 });50 md.use(anchor, {51 slugify: githubSlug,52 permalink: anchor.permalink.linkInsideHeader({53 symbol: '<span class="anchor-icon" aria-hidden="true">#</span>',54 placement: 'before',55 class: 'heading-anchor',56 ariaHidden: true,57 }),58 });59 md.use(taskLists, { enabled: false, label: true });60 md.use(footnote);61 md.use(emoji);6263 md.renderer.rules.fence = (tokens, idx) => {64 const token = tokens[idx];65 const info = (token.info ?? '').trim().split(/\s+/)[0];66 let html;67 if (info === 'mermaid') {68 html = `<div class="mermaid-block"><pre class="mermaid-src">${escapeHtml(token.content)}</pre><div class="mermaid-target" aria-live="polite"></div></div>`;69 } else {70 const lang = resolveLang(info);71 const highlighted = highlightSync(token.content.replace(/\n$/, ''), lang);72 html = `<div class="code-block"${info ? ` data-lang="${escapeHtml(info)}"` : ''}>${highlighted}</div>`;73 }74 const index = state.fences.push(html) - 1;75 return `<pre ${FENCE_PLACEHOLDER_ATTR}="${index}"></pre>`;76 };77 return md;78}7980/**81 * Resolve a relative README/blob URL against the repo raw/blob endpoints.82 * @param {string} url as written in the document83 * @param {{repo: string, ref: string, basePath: string}} ctx basePath = dir of the rendered file84 * @param {'raw'|'blob'} mode images go to raw, links go to blob85 * @returns {string}86 */87export function resolveRelativeUrl(url, ctx, mode) {88 if (!url) return url;89 const trimmed = url.trim();90 if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(trimmed)) return trimmed;91 const [pathPart, suffix = ''] = splitUrlSuffix(trimmed);92 const joined = posix.normalize(posix.join(ctx.basePath || '.', pathPart));93 if (joined.startsWith('..')) return trimmed;94 const clean = joined.replace(/^\.\//, '').replace(/^\//, '');95 const encoded = clean.split('/').map(encodeURIComponent).join('/');96 const refEnc = ctx.ref.split('/').map(encodeURIComponent).join('/');97 return mode === 'raw'98 ? `/raw/${encodeURIComponent(ctx.repo)}/${refEnc}/${encoded}${suffix}`99 : `/${encodeURIComponent(ctx.repo)}/blob/${refEnc}/${encoded}${suffix}`;100}101102/** Split `path?query#hash` into [path, suffix]. */103function splitUrlSuffix(url) {104 const m = /^([^?#]*)([?#].*)?$/.exec(url);105 return [m[1], m[2] ?? ''];106}107108/** Sanitizer allowlist — badges, details, kbd, align HTML all survive. */109function sanitizeOptions(ctx) {110 return {111 allowedTags: [112 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'dd', 'del',113 'details', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3',114 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input', 'ins', 'kbd', 'li', 'mark', 'ol',115 'p', 'picture', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small',116 'source', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody',117 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',118 ],119 allowedAttributes: {120 '*': ['align', 'id', 'class', 'dir', 'lang'],121 a: ['href', 'title', 'rel', 'name'],122 img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],123 source: ['src', 'srcset', 'type', 'media'],124 input: ['type', 'checked', 'disabled'],125 td: ['colspan', 'rowspan', 'align', 'valign'],126 th: ['colspan', 'rowspan', 'align', 'valign', 'scope'],127 details: ['open'],128 pre: [FENCE_PLACEHOLDER_ATTR],129 div: ['data-lang'],130 li: ['value'],131 ol: ['start', 'type'],132 abbr: ['title'],133 },134 allowedSchemes: ['http', 'https', 'mailto'],135 allowedSchemesByTag: { img: ['http', 'https', 'data'] },136 allowProtocolRelative: false,137 disallowedTagsMode: 'discard',138 transformTags: {139 img: (tagName, attribs) => ({140 tagName,141 attribs: {142 ...attribs,143 src: resolveRelativeUrl(attribs.src ?? '', ctx, 'raw'),144 loading: 'lazy',145 },146 }),147 a: (tagName, attribs) => {148 const href = resolveRelativeUrl(attribs.href ?? '', ctx, 'blob');149 const external = /^https?:\/\//i.test(href) && !href.startsWith(ctx.publicUrl);150 return {151 tagName,152 attribs: {153 ...attribs,154 href,155 ...(external ? { rel: 'noopener noreferrer' } : {}),156 },157 };158 },159 input: (tagName, attribs) => {160 if ((attribs.type ?? '').toLowerCase() !== 'checkbox') {161 return { tagName: 'span', attribs: {} };162 }163 return { tagName, attribs: { ...attribs, disabled: 'disabled' } };164 },165 },166 };167}168169/**170 * Render a Markdown document to sanitized, GitHub-grade HTML.171 * @param {string} source172 * @param {{repo: string, ref: string, basePath?: string, publicUrl?: string}} ctx173 * @returns {Promise<string>} HTML for insertion inside `<article class="markdown-body">`174 */175export async function renderMarkdown(source, ctx) {176 const fullCtx = { basePath: '.', publicUrl: '', ...ctx };177 // Preload every fence language so fence rendering can stay synchronous.178 const fenceLangs = [...source.matchAll(/^\s{0,3}(?:```+|~~~+)\s*([\w#+.-]+)/gm)]179 .map((m) => resolveLang(m[1]))180 .filter(Boolean);181 await ensureLangs(fenceLangs);182183 const state = { fences: [] };184 const md = buildParser(state);185 const rawHtml = md.render(source);186 let clean = sanitizeHtml(rawHtml, sanitizeOptions(fullCtx));187 clean = clean.replace(188 new RegExp(`<pre ${FENCE_PLACEHOLDER_ATTR}="(\\d+)"></pre>`, 'g'),189 (_m, index) => state.fences[Number(index)] ?? '',190 );191 return clean;192}193194/**195 * Plain-text fallback rendering (README.txt / readme.rst).196 * @param {string} source197 * @returns {string}198 */199export function renderPlain(source) {200 return `<pre class="plain-readme">${escapeHtml(source)}</pre>`;201}202