This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / markup / markdown.ts
4.2 kB 124 lines
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);