/**
* ─────────────────────────────────────────────
* 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);
const label = escapeHtml(info || 'text');
html = [
``,
'
',
`
${label}`,
'
',
'
',
highlighted,
'
',
].join('');
}
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)}`;
}