import { alert } from "@mdit/plugin-alert"; import { footnote } from "@mdit/plugin-footnote"; import { tasklist } from "@mdit/plugin-tasklist"; import MarkdownIt from "markdown-it"; import anchor from "markdown-it-anchor"; import { full as emoji } from "markdown-it-emoji"; import { sanitizeMarkup } from "./sanitize"; import type { MarkupContext } from "./paths"; // past this a document renders as plain text, a readme this big is pathological export const SOURCE_LIMIT = 512 * 1024; // github's heading slugs, that is what fragment links in a readme are written // against const slugify = (text: string): string => text .trim() .toLowerCase() .replace(/[^\p{L}\p{N}\p{M}\s_-]+/gu, "") .replace(/\s+/g, "-"); // the dotted dns handle appview/pages/markup/extension/atlink.go matches const MENTION = /^@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z][a-zA-Z0-9-]*\b/; const mentions = (md: MarkdownIt): void => { md.inline.ruler.before("link", "mention", (state, silent) => { // inside a link label a mention would nest an anchor in an anchor. markdown-it // tracks this but its published types leave the field out if ((state as unknown as { linkLevel: number }).linkLevel > 0) return false; if (state.src.charCodeAt(state.pos) !== 0x40) return false; const before = state.pos === 0 ? " " : state.src[state.pos - 1]; if (before !== " " && before !== "\n" && before !== "(") return false; const match = MENTION.exec(state.src.slice(state.pos, state.posMax)); if (!match) return false; if (!silent) { const open = state.push("link_open", "a", 1); open.attrs = [ ["href", `/${match[0].slice(1)}`], ["class", "mention"] ]; state.push("text", "", 0).content = match[0]; state.push("link_close", "a", -1); } state.pos += match[0].length; return true; }); }; const SHA = /^[0-9a-f]{7,40}$/; // `https://host/owner/repo/commit/` is a mouthful to read inline const shortSha = (href: string, host: string): string | null => { let url: URL; try { url = new URL(href); } catch { return null; } if (url.host !== host) return null; const parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/"); if (parts.length !== 4 || parts[2] !== "commit" || !SHA.test(parts[3])) return null; return parts[3].slice(0, 8); }; const commitLinks = (host: string) => (md: MarkdownIt): void => { md.core.ruler.push("commit_link", (state) => { for (const token of state.tokens) { if (token.type !== "inline" || !token.children) continue; for (let i = 0; i < token.children.length; i++) { const open = token.children[i]; // only bare urls, a link someone gave a label to keeps it if (open.type !== "link_open" || open.info !== "auto") continue; const text = token.children[i + 1]; if (text?.type !== "text" || token.children[i + 2]?.type !== "link_close") continue; const sha = shortSha(open.attrGet("href") ?? "", host); if (!sha) continue; const code = new state.Token("code_inline", "code", 0); code.content = sha; token.children[i + 1] = code; } } }); }; // todo: no syntax highlighting, mermaid or math yet. the first two want a client // side renderer, math wants mathjax loaded on demand the way the appview does it const build = (host: string): MarkdownIt => { const md = new MarkdownIt({ html: true, linkify: true, // the appview only rewrites dashes in the blog, readmes keep their text typographer: false }) .use(anchor, { slugify, permalink: anchor.permalink.linkInsideHeader({ class: "anchor", symbol: "#" }) }) .use(footnote) .use(tasklist, { disabled: true, label: true }) .use(alert) .use(emoji) .use(mentions); if (host) md.use(commitLinks(host)); return md; }; const renderers = new Map(); // building an instance means running every plugin, so keep one per host const rendererFor = (host: string): MarkdownIt => { let md = renderers.get(host); if (!md) { md = build(host); renderers.set(host, md); } return md; }; export const renderMarkdown = (source: string, ctx: MarkupContext): string => sanitizeMarkup(rendererFor(ctx.host ?? "").render(source), ctx);