SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
7.4 KB · 202 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      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