/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/render/markdown.mjs * Purpose : GitHub-grade Markdown pipeline — GFM, badges, mermaid, sanitized * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import MarkdownIt from 'markdown-it'; import anchor from 'markdown-it-anchor'; import taskLists from 'markdown-it-task-lists'; import footnote from 'markdown-it-footnote'; import { full as emoji } from 'markdown-it-emoji'; import sanitizeHtml from 'sanitize-html'; import { posix } from 'node:path'; import { escapeHtml } from '../lib/util.mjs'; import { highlightSync, resolveLang, ensureLangs } from './highlight.mjs'; /** * GitHub's heading slug algorithm (lowercase, strip punctuation, dashes). * @param {string} text * @returns {string} */ export function githubSlug(text) { return String(text) .trim() .toLowerCase() .replace(/<[^>]+>/g, '') .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '') .replace(/\s/g, '-'); } const FENCE_PLACEHOLDER_ATTR = 'data-spbgit-fence'; /** * Build the markdown-it instance. Fences render as placeholders and are * swapped back in after sanitization so shiki markup survives untouched. * @param {{fences: string[]}} state collector for rendered fence HTML */ function buildParser(state) { const md = new MarkdownIt({ html: true, linkify: true, breaks: false, }); md.use(anchor, { slugify: githubSlug, permalink: anchor.permalink.linkInsideHeader({ symbol: '', placement: 'before', class: 'heading-anchor', ariaHidden: true, }), }); md.use(taskLists, { enabled: false, label: true }); md.use(footnote); md.use(emoji); md.renderer.rules.fence = (tokens, idx) => { const token = tokens[idx]; const info = (token.info ?? '').trim().split(/\s+/)[0]; let html; if (info === 'mermaid') { html = `
${escapeHtml(token.content)}
`; } else { const lang = resolveLang(info); const highlighted = highlightSync(token.content.replace(/\n$/, ''), lang); html = `
${highlighted}
`; } const index = state.fences.push(html) - 1; return `
`;
  };
  return md;
}

/**
 * Resolve a relative README/blob URL against the repo raw/blob endpoints.
 * @param {string} url as written in the document
 * @param {{repo: string, ref: string, basePath: string}} ctx basePath = dir of the rendered file
 * @param {'raw'|'blob'} mode images go to raw, links go to blob
 * @returns {string}
 */
export function resolveRelativeUrl(url, ctx, mode) {
  if (!url) return url;
  const trimmed = url.trim();
  if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(trimmed)) return trimmed;
  const [pathPart, suffix = ''] = splitUrlSuffix(trimmed);
  const joined = posix.normalize(posix.join(ctx.basePath || '.', pathPart));
  if (joined.startsWith('..')) return trimmed;
  const clean = joined.replace(/^\.\//, '').replace(/^\//, '');
  const encoded = clean.split('/').map(encodeURIComponent).join('/');
  const refEnc = ctx.ref.split('/').map(encodeURIComponent).join('/');
  return mode === 'raw'
    ? `/raw/${encodeURIComponent(ctx.repo)}/${refEnc}/${encoded}${suffix}`
    : `/${encodeURIComponent(ctx.repo)}/blob/${refEnc}/${encoded}${suffix}`;
}

/** Split `path?query#hash` into [path, suffix]. */
function splitUrlSuffix(url) {
  const m = /^([^?#]*)([?#].*)?$/.exec(url);
  return [m[1], m[2] ?? ''];
}

/** Sanitizer allowlist — badges, details, kbd, align HTML all survive. */
function sanitizeOptions(ctx) {
  return {
    allowedTags: [
      'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'dd', 'del',
      'details', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3',
      'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input', 'ins', 'kbd', 'li', 'mark', 'ol',
      'p', 'picture', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small',
      'source', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody',
      'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',
    ],
    allowedAttributes: {
      '*': ['align', 'id', 'class', 'dir', 'lang'],
      a: ['href', 'title', 'rel', 'name'],
      img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],
      source: ['src', 'srcset', 'type', 'media'],
      input: ['type', 'checked', 'disabled'],
      td: ['colspan', 'rowspan', 'align', 'valign'],
      th: ['colspan', 'rowspan', 'align', 'valign', 'scope'],
      details: ['open'],
      pre: [FENCE_PLACEHOLDER_ATTR],
      div: ['data-lang'],
      li: ['value'],
      ol: ['start', 'type'],
      abbr: ['title'],
    },
    allowedSchemes: ['http', 'https', 'mailto'],
    allowedSchemesByTag: { img: ['http', 'https', 'data'] },
    allowProtocolRelative: false,
    disallowedTagsMode: 'discard',
    transformTags: {
      img: (tagName, attribs) => ({
        tagName,
        attribs: {
          ...attribs,
          src: resolveRelativeUrl(attribs.src ?? '', ctx, 'raw'),
          loading: 'lazy',
        },
      }),
      a: (tagName, attribs) => {
        const href = resolveRelativeUrl(attribs.href ?? '', ctx, 'blob');
        const external = /^https?:\/\//i.test(href) && !href.startsWith(ctx.publicUrl);
        return {
          tagName,
          attribs: {
            ...attribs,
            href,
            ...(external ? { rel: 'noopener noreferrer' } : {}),
          },
        };
      },
      input: (tagName, attribs) => {
        if ((attribs.type ?? '').toLowerCase() !== 'checkbox') {
          return { tagName: 'span', attribs: {} };
        }
        return { tagName, attribs: { ...attribs, disabled: 'disabled' } };
      },
    },
  };
}

/**
 * Render a Markdown document to sanitized, GitHub-grade HTML.
 * @param {string} source
 * @param {{repo: string, ref: string, basePath?: string, publicUrl?: string}} ctx
 * @returns {Promise} HTML for insertion inside `
` */ export async function renderMarkdown(source, ctx) { const fullCtx = { basePath: '.', publicUrl: '', ...ctx }; // Preload every fence language so fence rendering can stay synchronous. const fenceLangs = [...source.matchAll(/^\s{0,3}(?:```+|~~~+)\s*([\w#+.-]+)/gm)] .map((m) => resolveLang(m[1])) .filter(Boolean); await ensureLangs(fenceLangs); const state = { fences: [] }; const md = buildParser(state); const rawHtml = md.render(source); let clean = sanitizeHtml(rawHtml, sanitizeOptions(fullCtx)); clean = clean.replace( new RegExp(`
`, 'g'),
    (_m, index) => state.fences[Number(index)] ?? '',
  );
  return clean;
}

/**
 * Plain-text fallback rendering (README.txt / readme.rst).
 * @param {string} source
 * @returns {string}
 */
export function renderPlain(source) {
  return `
${escapeHtml(source)}
`; }