This repository has no description
1import { alert } from "@mdit/plugin-alert";
2import { footnote } from "@mdit/plugin-footnote";
3import { tasklist } from "@mdit/plugin-tasklist";
4import MarkdownIt from "markdown-it";
5import anchor from "markdown-it-anchor";
6import { full as emoji } from "markdown-it-emoji";
7import { sanitizeMarkup } from "./sanitize";
8import type { MarkupContext } from "./paths";
9
10// past this a document renders as plain text, a readme this big is pathological
11export const SOURCE_LIMIT = 512 * 1024;
12
13// github's heading slugs, that is what fragment links in a readme are written
14// against
15const slugify = (text: string): string =>
16 text
17 .trim()
18 .toLowerCase()
19 .replace(/[^\p{L}\p{N}\p{M}\s_-]+/gu, "")
20 .replace(/\s+/g, "-");
21
22// the dotted dns handle appview/pages/markup/extension/atlink.go matches
23const MENTION = /^@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z][a-zA-Z0-9-]*\b/;
24
25const mentions = (md: MarkdownIt): void => {
26 md.inline.ruler.before("link", "mention", (state, silent) => {
27 // inside a link label a mention would nest an anchor in an anchor. markdown-it
28 // tracks this but its published types leave the field out
29 if ((state as unknown as { linkLevel: number }).linkLevel > 0) return false;
30 if (state.src.charCodeAt(state.pos) !== 0x40) return false;
31 const before = state.pos === 0 ? " " : state.src[state.pos - 1];
32 if (before !== " " && before !== "\n" && before !== "(") return false;
33 const match = MENTION.exec(state.src.slice(state.pos, state.posMax));
34 if (!match) return false;
35
36 if (!silent) {
37 const open = state.push("link_open", "a", 1);
38 open.attrs = [
39 ["href", `/${match[0].slice(1)}`],
40 ["class", "mention"]
41 ];
42 state.push("text", "", 0).content = match[0];
43 state.push("link_close", "a", -1);
44 }
45 state.pos += match[0].length;
46 return true;
47 });
48};
49
50const SHA = /^[0-9a-f]{7,40}$/;
51
52// `https://host/owner/repo/commit/<sha>` is a mouthful to read inline
53const shortSha = (href: string, host: string): string | null => {
54 let url: URL;
55 try {
56 url = new URL(href);
57 } catch {
58 return null;
59 }
60 if (url.host !== host) return null;
61 const parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
62 if (parts.length !== 4 || parts[2] !== "commit" || !SHA.test(parts[3])) return null;
63 return parts[3].slice(0, 8);
64};
65
66const commitLinks =
67 (host: string) =>
68 (md: MarkdownIt): void => {
69 md.core.ruler.push("commit_link", (state) => {
70 for (const token of state.tokens) {
71 if (token.type !== "inline" || !token.children) continue;
72 for (let i = 0; i < token.children.length; i++) {
73 const open = token.children[i];
74 // only bare urls, a link someone gave a label to keeps it
75 if (open.type !== "link_open" || open.info !== "auto") continue;
76 const text = token.children[i + 1];
77 if (text?.type !== "text" || token.children[i + 2]?.type !== "link_close") continue;
78 const sha = shortSha(open.attrGet("href") ?? "", host);
79 if (!sha) continue;
80 const code = new state.Token("code_inline", "code", 0);
81 code.content = sha;
82 token.children[i + 1] = code;
83 }
84 }
85 });
86 };
87
88// todo: no syntax highlighting, mermaid or math yet. the first two want a client
89// side renderer, math wants mathjax loaded on demand the way the appview does it
90const build = (host: string): MarkdownIt => {
91 const md = new MarkdownIt({
92 html: true,
93 linkify: true,
94 // the appview only rewrites dashes in the blog, readmes keep their text
95 typographer: false
96 })
97 .use(anchor, {
98 slugify,
99 permalink: anchor.permalink.linkInsideHeader({ class: "anchor", symbol: "#" })
100 })
101 .use(footnote)
102 .use(tasklist, { disabled: true, label: true })
103 .use(alert)
104 .use(emoji)
105 .use(mentions);
106
107 if (host) md.use(commitLinks(host));
108 return md;
109};
110
111const renderers = new Map<string, MarkdownIt>();
112
113// building an instance means running every plugin, so keep one per host
114const rendererFor = (host: string): MarkdownIt => {
115 let md = renderers.get(host);
116 if (!md) {
117 md = build(host);
118 renderers.set(host, md);
119 }
120 return md;
121};
122
123export const renderMarkdown = (source: string, ctx: MarkupContext): string =>
124 sanitizeMarkup(rendererFor(ctx.host ?? "").render(source), ctx);