SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
8.3 KB · 214 lines javascript
Raw Blame History
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