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 const label = escapeHtml(info || 'text');73 html = [74 `<div class="code-block"${info ? ` data-lang="${escapeHtml(info)}"` : ''}>`,75 '<div class="code-head">',76 `<span class="code-lang">${label}</span>`,77 '<button type="button" class="code-copy" data-code-copy aria-label="Copy code">',78 '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>',79 '<span class="code-copy-label">Copy</span>',80 '</button>',81 '</div>',82 highlighted,83 '</div>',84 ].join('');85 }86 const index = state.fences.push(html) - 1;87 return `<pre ${FENCE_PLACEHOLDER_ATTR}="${index}"></pre>`;88 };89 return md;90}9192/**93 * Resolve a relative README/blob URL against the repo raw/blob endpoints.94 * @param {string} url as written in the document95 * @param {{repo: string, ref: string, basePath: string}} ctx basePath = dir of the rendered file96 * @param {'raw'|'blob'} mode images go to raw, links go to blob97 * @returns {string}98 */99export function resolveRelativeUrl(url, ctx, mode) {100 if (!url) return url;101 const trimmed = url.trim();102 if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(trimmed)) return trimmed;103 const [pathPart, suffix = ''] = splitUrlSuffix(trimmed);104 const joined = posix.normalize(posix.join(ctx.basePath || '.', pathPart));105 if (joined.startsWith('..')) return trimmed;106 const clean = joined.replace(/^\.\//, '').replace(/^\//, '');107 const encoded = clean.split('/').map(encodeURIComponent).join('/');108 const refEnc = ctx.ref.split('/').map(encodeURIComponent).join('/');109 return mode === 'raw'110 ? `/raw/${encodeURIComponent(ctx.repo)}/${refEnc}/${encoded}${suffix}`111 : `/${encodeURIComponent(ctx.repo)}/blob/${refEnc}/${encoded}${suffix}`;112}113114/** Split `path?query#hash` into [path, suffix]. */115function splitUrlSuffix(url) {116 const m = /^([^?#]*)([?#].*)?$/.exec(url);117 return [m[1], m[2] ?? ''];118}119120/** Sanitizer allowlist — badges, details, kbd, align HTML all survive. */121function sanitizeOptions(ctx) {122 return {123 allowedTags: [124 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'dd', 'del',125 'details', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3',126 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input', 'ins', 'kbd', 'li', 'mark', 'ol',127 'p', 'picture', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small',128 'source', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody',129 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',130 ],131 allowedAttributes: {132 '*': ['align', 'id', 'class', 'dir', 'lang'],133 a: ['href', 'title', 'rel', 'name'],134 img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],135 source: ['src', 'srcset', 'type', 'media'],136 input: ['type', 'checked', 'disabled'],137 td: ['colspan', 'rowspan', 'align', 'valign'],138 th: ['colspan', 'rowspan', 'align', 'valign', 'scope'],139 details: ['open'],140 pre: [FENCE_PLACEHOLDER_ATTR],141 div: ['data-lang'],142 li: ['value'],143 ol: ['start', 'type'],144 abbr: ['title'],145 },146 allowedSchemes: ['http', 'https', 'mailto'],147 allowedSchemesByTag: { img: ['http', 'https', 'data'] },148 allowProtocolRelative: false,149 disallowedTagsMode: 'discard',150 transformTags: {151 img: (tagName, attribs) => ({152 tagName,153 attribs: {154 ...attribs,155 src: resolveRelativeUrl(attribs.src ?? '', ctx, 'raw'),156 loading: 'lazy',157 },158 }),159 a: (tagName, attribs) => {160 const href = resolveRelativeUrl(attribs.href ?? '', ctx, 'blob');161 const external = /^https?:\/\//i.test(href) && !href.startsWith(ctx.publicUrl);162 return {163 tagName,164 attribs: {165 ...attribs,166 href,167 ...(external ? { rel: 'noopener noreferrer' } : {}),168 },169 };170 },171 input: (tagName, attribs) => {172 if ((attribs.type ?? '').toLowerCase() !== 'checkbox') {173 return { tagName: 'span', attribs: {} };174 }175 return { tagName, attribs: { ...attribs, disabled: 'disabled' } };176 },177 },178 };179}180181/**182 * Render a Markdown document to sanitized, GitHub-grade HTML.183 * @param {string} source184 * @param {{repo: string, ref: string, basePath?: string, publicUrl?: string}} ctx185 * @returns {Promise<string>} HTML for insertion inside `<article class="markdown-body">`186 */187export async function renderMarkdown(source, ctx) {188 const fullCtx = { basePath: '.', publicUrl: '', ...ctx };189 // Preload every fence language so fence rendering can stay synchronous.190 const fenceLangs = [...source.matchAll(/^\s{0,3}(?:```+|~~~+)\s*([\w#+.-]+)/gm)]191 .map((m) => resolveLang(m[1]))192 .filter(Boolean);193 await ensureLangs(fenceLangs);194195 const state = { fences: [] };196 const md = buildParser(state);197 const rawHtml = md.render(source);198 let clean = sanitizeHtml(rawHtml, sanitizeOptions(fullCtx));199 clean = clean.replace(200 new RegExp(`<pre ${FENCE_PLACEHOLDER_ATTR}="(\\d+)"></pre>`, 'g'),201 (_m, index) => state.fences[Number(index)] ?? '',202 );203 return clean;204}205206/**207 * Plain-text fallback rendering (README.txt / readme.rst).208 * @param {string} source209 * @returns {string}210 */211export function renderPlain(source) {212 return `<pre class="plain-readme">${escapeHtml(source)}</pre>`;213}214