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 { collapseCodeblocks } from "#lib/markup/collapse.ts";
import { highlightCodeblocks } from "#lib/markup/highlight.ts";
import { renderMermaidBlocks } from "#lib/markup/mermaid.ts";
import { sanitizeMarkup } from "#lib/markup/sanitize.ts";
import type { MarkupContext } from "#lib/markup/paths.ts";
// 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 deliberi/markup/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/<sha>` 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;
}
}
});
};
const sourceLines = (md: MarkdownIt): void => {
md.core.ruler.push("source_line", (state) => {
for (const token of state.tokens) {
if (token.map && token.nesting >= 0 && token.block && token.type !== "html_block") {
token.attrSet("data-line", String(token.map[0]));
}
}
});
};
const mermaid = (md: MarkdownIt): void => {
const fence = md.renderer.rules.fence!;
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx];
if (token.info.trim().split(/\s+/)[0] !== "mermaid")
return fence(tokens, idx, options, env, self);
token.attrJoin("class", "mermaid");
return `<pre${self.renderAttrs(token)}>${md.utils.escapeHtml(token.content)}</pre>\n`;
};
};
// todo: math still wants a client side renderer, it wants mathjax loaded on
// demand the way the appview does it
const build = (host: string, lines: boolean): 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)
.use(mermaid);
if (host) md.use(commitLinks(host));
if (lines) md.use(sourceLines);
return md;
};
const renderers = new Map<string, MarkdownIt>();
// building an instance means running every plugin, so keep one per host
const rendererFor = (host: string, lines: boolean): MarkdownIt => {
const key = `${host}\n${lines}`;
let md = renderers.get(key);
if (!md) {
md = build(host, lines);
renderers.set(key, md);
}
return md;
};
export const renderMarkdown = async (source: string, ctx: MarkupContext): Promise<string> => {
const raw = rendererFor(ctx.host ?? "", !!ctx.sourceLines).render(source);
// sanitize before highlighting and drawing so the allowlist doesn't have to allow spans or svg
const clean = sanitizeMarkup(raw, ctx);
return highlightCodeblocks(
renderMermaidBlocks(ctx.collapseCode ? collapseCodeblocks(clean) : clean)
);
};