🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add KaTeX support to markdown rendering

juprodh 5dfbeb75 97766f93

+204 -2
+1
.gitignore
··· 6 6 public/viz/dist.js 7 7 public/editor/dist.js 8 8 public/dist.css 9 + public/fonts/ 9 10 .env 10 11 .env.* 11 12 data/blobs/
+5
bun.lock
··· 18 18 "diff-match-patch": "^1.0.5", 19 19 "elysia": "^1.4.27", 20 20 "fflate": "^0.8.2", 21 + "katex": "^0.16.45", 21 22 "markdown-it": "^14.1.1", 22 23 "sharp": "^0.34.5", 23 24 "ws": "^8.0.0", ··· 868 869 869 870 "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], 870 871 872 + "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], 873 + 871 874 "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], 872 875 873 876 "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], ··· 1095 1098 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 1096 1099 1097 1100 "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 1101 + 1102 + "katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], 1098 1103 1099 1104 "key-encoder": ["key-encoder@2.0.3", "", { "dependencies": { "@types/elliptic": "^6.4.9", "asn1.js": "^5.0.1", "bn.js": "^4.11.8", "elliptic": "^6.4.1" } }, "sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg=="], 1100 1105
+4 -2
package.json
··· 6 6 "scripts": { 7 7 "dev": "bun run --watch src/server/index.ts", 8 8 "dev:firehose": "bun run --watch src/firehose/index.ts", 9 - "build:css": "bunx @tailwindcss/cli -i public/style.css -o public/dist.css", 10 - "dev:css": "bunx @tailwindcss/cli -i public/style.css -o public/dist.css --watch", 9 + "build:fonts": "mkdir -p public/fonts && cp node_modules/katex/dist/fonts/* public/fonts/", 10 + "build:css": "bun run build:fonts && bunx @tailwindcss/cli -i public/style.css -o public/dist.css", 11 + "dev:css": "bun run build:fonts && bunx @tailwindcss/cli -i public/style.css -o public/dist.css --watch", 11 12 "test": "bun test", 12 13 "test:unit": "bun test tests/lib/ tests/atproto/ tests/firehose/ tests/server/", 13 14 "test:integration": "bun test tests/integration/", ··· 61 62 "diff-match-patch": "^1.0.5", 62 63 "elysia": "^1.4.27", 63 64 "fflate": "^0.8.2", 65 + "katex": "^0.16.45", 64 66 "markdown-it": "^14.1.1", 65 67 "sharp": "^0.34.5", 66 68 "ws": "^8.0.0"
+2
public/editor/preview.ts
··· 1 1 import MarkdownIt from "markdown-it"; 2 + import { katexPlugin } from "../../src/lib/markdown/katex-plugin.ts"; 2 3 import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; 3 4 4 5 const md = new MarkdownIt({ html: false, linkify: true }); 5 6 md.use(wikilinkPlugin); 7 + md.use(katexPlugin, { throwOnError: false }); 6 8 7 9 function getWikiSlug(): string | undefined { 8 10 const match = window.location.pathname.match(/^\/wiki\/([^/]+)/);
+6
public/style.css
··· 1 1 @import "tailwindcss"; 2 + @import "katex/dist/katex.min.css"; 2 3 @plugin "@tailwindcss/typography"; 3 4 @source "../src"; 5 + 6 + /* Tailwind's root line-height leaks into KaTeX and shifts scripts. */ 7 + .katex { 8 + line-height: 1.2; 9 + }
+13
scripts/dev-full.ts
··· 13 13 const children: Subprocess[] = []; 14 14 15 15 async function main() { 16 + console.log("Copying KaTeX fonts..."); 17 + const fonts = spawn({ 18 + cmd: [ 19 + "sh", 20 + "-c", 21 + "mkdir -p public/fonts && cp node_modules/katex/dist/fonts/* public/fonts/", 22 + ], 23 + stdout: "inherit", 24 + stderr: "inherit", 25 + }); 26 + children.push(fonts); 27 + await fonts.exited; 28 + 16 29 console.log("Building CSS (watch mode)..."); 17 30 const css = spawn({ 18 31 cmd: [
+2
src/lib/markdown.ts
··· 3 3 type WikilinkEnv, 4 4 wikilinkPlugin, 5 5 } from "./markdown/wikilink-plugin.ts"; 6 + import { katexPlugin } from "./markdown/katex-plugin.ts"; 6 7 import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; 7 8 8 9 interface RenderResult { ··· 53 54 54 55 md.use(wikilinkPlugin); 55 56 md.use(vizPlugin); 57 + md.use(katexPlugin, { throwOnError: false }); 56 58 md.use(youtubePlugin); 57 59 58 60 export function renderMarkdown(
+157
src/lib/markdown/katex-plugin.ts
··· 1 + import katex from "katex"; 2 + import type MarkdownIt from "markdown-it"; 3 + 4 + interface KatexPluginOptions { 5 + throwOnError?: boolean; 6 + errorColor?: string; 7 + displayMode?: boolean; 8 + } 9 + 10 + function isValidDelim(state: MarkdownIt.StateInline, pos: number) { 11 + const max = state.posMax; 12 + const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1; 13 + const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1; 14 + 15 + let canOpen = true; 16 + let canClose = true; 17 + 18 + if ( 19 + prevChar === 0x20 || 20 + prevChar === 0x09 || 21 + (nextChar >= 0x30 && nextChar <= 0x39) 22 + ) { 23 + canClose = false; 24 + } 25 + if (nextChar === 0x20 || nextChar === 0x09) { 26 + canOpen = false; 27 + } 28 + 29 + return { canOpen, canClose }; 30 + } 31 + 32 + function mathInline(state: MarkdownIt.StateInline, silent: boolean) { 33 + if (state.src[state.pos] !== "$") return false; 34 + 35 + let res = isValidDelim(state, state.pos); 36 + if (!res.canOpen) { 37 + if (!silent) state.pending += "$"; 38 + state.pos += 1; 39 + return true; 40 + } 41 + 42 + const start = state.pos + 1; 43 + let match = start; 44 + let pos = start; 45 + 46 + while ((match = state.src.indexOf("$", match)) !== -1) { 47 + pos = match - 1; 48 + while (state.src[pos] === "\\") pos -= 1; 49 + if ((match - pos) % 2 === 1) break; 50 + match += 1; 51 + } 52 + 53 + if (match === -1) { 54 + if (!silent) state.pending += "$"; 55 + state.pos = start; 56 + return true; 57 + } 58 + 59 + if (match - start === 0) { 60 + if (!silent) state.pending += "$$"; 61 + state.pos = start + 1; 62 + return true; 63 + } 64 + 65 + res = isValidDelim(state, match); 66 + if (!res.canClose) { 67 + if (!silent) state.pending += "$"; 68 + state.pos = start; 69 + return true; 70 + } 71 + 72 + if (!silent) { 73 + const token = state.push("math_inline", "math", 0); 74 + token.markup = "$"; 75 + token.content = state.src.slice(start, match); 76 + } 77 + 78 + state.pos = match + 1; 79 + return true; 80 + } 81 + 82 + function mathBlock( 83 + state: MarkdownIt.StateBlock, 84 + start: number, 85 + end: number, 86 + silent: boolean, 87 + ) { 88 + let pos = state.bMarks[start] + state.tShift[start]; 89 + let max = state.eMarks[start]; 90 + let firstLine = ""; 91 + let lastLine = ""; 92 + let next = start; 93 + let lastPos = 0; 94 + let found = false; 95 + 96 + if (pos + 2 > max) return false; 97 + if (state.src.slice(pos, pos + 2) !== "$$") return false; 98 + 99 + pos += 2; 100 + firstLine = state.src.slice(pos, max); 101 + 102 + if (silent) return true; 103 + if (firstLine.trim().slice(-2) === "$$") { 104 + firstLine = firstLine.trim().slice(0, -2); 105 + found = true; 106 + } 107 + 108 + for (; !found; ) { 109 + next += 1; 110 + if (next >= end) break; 111 + 112 + pos = state.bMarks[next] + state.tShift[next]; 113 + max = state.eMarks[next]; 114 + 115 + if (pos < max && state.tShift[next] < state.blkIndent) break; 116 + 117 + if (state.src.slice(pos, max).trim().slice(-2) === "$$") { 118 + lastPos = state.src.slice(0, max).lastIndexOf("$$"); 119 + lastLine = state.src.slice(pos, lastPos); 120 + found = true; 121 + } 122 + } 123 + 124 + state.line = next + 1; 125 + const token = state.push("math_block", "math", 0); 126 + token.block = true; 127 + token.content = 128 + (firstLine && firstLine.trim() ? `${firstLine}\n` : "") + 129 + state.getLines(start + 1, next, state.tShift[start], true) + 130 + (lastLine && lastLine.trim() ? lastLine : ""); 131 + token.map = [start, state.line]; 132 + token.markup = "$$"; 133 + return true; 134 + } 135 + 136 + function renderKatex(latex: string, options: KatexPluginOptions, displayMode: boolean) { 137 + try { 138 + return katex.renderToString(latex, { 139 + ...options, 140 + displayMode, 141 + }); 142 + } catch (error) { 143 + if (options.throwOnError) console.error(error); 144 + return latex; 145 + } 146 + } 147 + 148 + export function katexPlugin(md: MarkdownIt, options: KatexPluginOptions = {}) { 149 + md.inline.ruler.after("escape", "math_inline", mathInline); 150 + md.block.ruler.after("blockquote", "math_block", mathBlock, { 151 + alt: ["paragraph", "reference", "blockquote", "list"], 152 + }); 153 + md.renderer.rules.math_inline = (tokens, idx) => 154 + renderKatex(tokens[idx].content, options, false); 155 + md.renderer.rules.math_block = (tokens, idx) => 156 + `<p>${renderKatex(tokens[idx].content, options, true)}</p>\n`; 157 + }
+14
tests/lib/markdown.test.ts
··· 11 11 const { html } = renderMarkdown(""); 12 12 expect(html).toBe(""); 13 13 }); 14 + 15 + test("renders inline KaTeX math", () => { 16 + const { html } = renderMarkdown("Energy is $E = mc^2$."); 17 + expect(html).toContain('class="katex"'); 18 + expect(html).toContain("katex-mathml"); 19 + expect(html).toContain("E = mc^2"); 20 + }); 21 + 22 + test("renders block KaTeX math", () => { 23 + const { html } = renderMarkdown("$$\\int_0^1 x^2 \\; dx$$"); 24 + expect(html).toContain('class="katex-display"'); 25 + expect(html).toContain("katex-mathml"); 26 + expect(html).toContain(String.raw`\int_0^1 x^2 \; dx`); 27 + }); 14 28 }); 15 29 16 30 describe("wikilinks", () => {