clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / markdown.ts
markdown.ts 6.2K
1// Markdown for READMEs, wiki pages and .md files.2//3// Raw HTML is allowed through a sanitizer (the common README idioms:4// centered paragraphs, sized images, <details>, <picture>), never scripts,5// styles, event handlers or SVG. Relative links and images resolve to forge6// pages and raw files, in markdown and in raw HTML alike.7import { randomUUID } from 'node:crypto';8import MarkdownIt from 'markdown-it';9import sanitize from 'sanitize-html';10import { diagram } from './diagrams.ts';11import { highlight, resolve } from './highlight.ts';1213type Token = ReturnType<MarkdownIt['parse']>[number];1415// Fences with a known language are highlighted; others stay plain.16const md = new MarkdownIt({ html: true, linkify: true, typographer: false, highlight: (code, lang) => highlight(code, resolve(lang)) ?? '' });17// "NOTICE.md" is not a website, even though .md is a TLD.18md.linkify.set({ fuzzyLink: false });1920// ```mermaid blocks become SVG diagrams (lib/diagrams.ts), sanitized on21// their own. In their place the markdown's HTML has a placeholder, which22// names a random token the source can't know, until it has been sanitized.23interface Env {24 token: string;25 diagrams: string[];26 href: (url: string) => string;27}28const fence = md.renderer.rules.fence!;29md.renderer.rules.fence = (tokens, idx, options, env: Env, self) => {30 const t = tokens[idx];31 if (t.info.trim() !== 'mermaid') return fence(tokens, idx, options, env, self);32 env.diagrams.push(diagram(t.content, env.href));33 return `<div>${env.token}:${env.diagrams.length - 1}</div>\n`;34};3536// Inline code never breaks at a hyphen (lib/text-widths.sh isn't37// lib/text- and widths.sh): a word with one is kept whole (.nobr in38// style.css), and a path may break after its slashes instead. The text is39// unchanged, for copying.40md.renderer.rules.code_inline = (tokens, idx, _options, _env, self) => {41 const t = tokens[idx];42 const word = (w: string): string => w.split('/').map((part) => (part.includes('-') ? `<span class="nobr">${md.utils.escapeHtml(part)}</span>` : md.utils.escapeHtml(part))).join('/<wbr>');43 return `<code${self.renderAttrs(t)}>${t.content.split(/(\s+)/).map((w, i) => (i % 2 ? md.utils.escapeHtml(w) : word(w))).join('')}</code>`;44};4546const isRelative = (u: string | null | undefined): u is string => !!u && !/^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(u);4748// GitHub-style heading ids, so README links to #some-section resolve.49export function slugger(): (text: string) => string {50 const seen = new Map<string, number>();51 return (text) => {52 const base = text.toLowerCase().trim().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');53 const n = seen.get(base) ?? 0;54 seen.set(base, n + 1);55 return n ? `${base}-${n}` : base;56 };57}5859export interface LinkResolver {60 link?: (url: string) => string;61 image?: (url: string) => string;62}6364const ALLOWED = {65 allowedTags: [66 'p', 'div', 'span', 'br', 'hr', 'a', 'img', 'picture', 'source', 'details', 'summary',67 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote',68 'pre', 'code', 'kbd', 'samp', 'var', 'sub', 'sup', 'b', 'i', 'em', 'strong', 's', 'del', 'ins', 'mark', 'small',69 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'figure', 'figcaption',70 'input', 'wbr',71 ],72 allowedAttributes: {73 '*': ['align', 'id', 'title', 'class', 'dir', 'lang'],74 a: ['href', 'name', 'target', 'rel'],75 img: ['src', 'alt', 'width', 'height', 'loading', 'decoding'],76 source: ['srcset', 'media', 'type', 'width', 'height'],77 td: ['colspan', 'rowspan'],78 th: ['colspan', 'rowspan', 'scope'],79 ol: ['start', 'type'],80 details: ['open'],81 input: ['type', 'checked', 'disabled'],82 },83 // highlight.js tokens: "hljs-title" plus sub-scopes like "function_".84 allowedClasses: { '*': [/^language-/], span: [/^hljs-/, /^[a-z]+_+$/, 'nobr'] },85 allowedSchemes: ['http', 'https', 'mailto'],86 allowProtocolRelative: false,87 selfClosing: [...sanitize.defaults.selfClosing, 'wbr'],88};8990export function renderMarkdown(src: string, { link = (u) => u, image = (u) => u }: LinkResolver = {}): string {91 const env: Env = { token: randomUUID(), diagrams: [], href: (u) => (isRelative(u) ? link(u) : u) };92 const tokens = md.parse(src, env);93 const slug = slugger();94 const walk = (list: Token[]): void => {95 for (const t of list) {96 if (t.type === 'link_open') {97 const u = t.attrGet('href');98 if (isRelative(u)) t.attrSet('href', link(u));99 } else if (t.type === 'image') {100 const u = t.attrGet('src');101 if (isRelative(u)) t.attrSet('src', image(u));102 t.attrSet('loading', 'lazy');103 t.attrSet('decoding', 'async');104 }105 if (t.children) walk(t.children);106 }107 };108 walk(tokens);109 tokens.forEach((t, i) => {110 if (t.type === 'heading_open') t.attrSet('id', slug(tokens[i + 1]?.children?.map((c) => c.content).join('') ?? ''));111 });112 const rendered = md.renderer.render(tokens, md.options, env);113 // Raw HTML in the source (img, a, source) resolves the same way markdown does.114 const html = sanitize(rendered, {115 ...ALLOWED,116 transformTags: {117 img: (tag, attribs) => ({ tagName: tag, attribs: { ...attribs, src: isRelative(attribs.src) ? image(attribs.src) : attribs.src, loading: 'lazy', decoding: 'async' } }),118 a: (tag, attribs) => ({ tagName: tag, attribs: isRelative(attribs.href) ? { ...attribs, href: link(attribs.href) } : attribs }),119 source: (tag, attribs) => ({120 tagName: tag,121 attribs: { ...attribs, srcset: (attribs.srcset ?? '').split(',').map((part) => {122 const [u, ...rest] = part.trim().split(/\s+/);123 return [isRelative(u) ? image(u) : u, ...rest].join(' ');124 }).join(', ') },125 }),126 },127 });128 return env.diagrams.length ? html.replace(new RegExp(`<div>${env.token}:(\\d+)</div>`, 'g'), (_, i: string) => env.diagrams[Number(i)]) : html;129}130131// Resolve a relative link against the directory of the file it appears in.132export function resolvePath(fromFile: string, rel: string): { path: string; frag: string } {133 const [p, frag = ''] = rel.split('#');134 const parts = fromFile.split('/').slice(0, -1);135 for (const s of p.split('/')) {136 if (s === '..') parts.pop();137 else if (s && s !== '.') parts.push(s);138 }139 return { path: parts.join('/'), frag: frag ? '#' + frag : '' };140}