🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Merge PR from @nandi to add katex support

juprodh a4ecb96e 97766f93

+270 -8
+2
.gitignore
··· 6 6 public/viz/dist.js 7 7 public/editor/dist.js 8 8 public/dist.css 9 + public/katex.css 10 + public/fonts/ 9 11 .env 10 12 .env.* 11 13 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": "mkdir -p public/fonts && cp node_modules/katex/dist/fonts/* public/fonts/ && cp node_modules/katex/dist/katex.min.css public/katex.css && bunx @tailwindcss/cli -i public/style.css -o public/dist.css", 11 + "dev:css": "mkdir -p public/fonts && cp node_modules/katex/dist/fonts/* public/fonts/ && cp node_modules/katex/dist/katex.min.css public/katex.css && 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\/([^/]+)/);
+5
public/style.css
··· 1 1 @import "tailwindcss"; 2 2 @plugin "@tailwindcss/typography"; 3 3 @source "../src"; 4 + 5 + /* Tailwind's root line-height leaks into KaTeX and shifts scripts. */ 6 + .katex { 7 + line-height: 1.2; 8 + }
+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/constants.ts
··· 23 23 24 24 export const EDITOR_SCRIPTS = ["/public/editor/dist.js"]; 25 25 26 + export const KATEX_STYLESHEETS = ["/public/katex.css"]; 27 + 26 28 export const MIME_TO_EXT: Record<string, string> = { 27 29 "image/jpeg": "jpg", 28 30 "image/png": "png",
+5 -2
src/lib/markdown.ts
··· 1 1 import MarkdownIt from "markdown-it"; 2 + import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; 2 3 import { 3 4 type WikilinkEnv, 4 5 wikilinkPlugin, ··· 8 9 interface RenderResult { 9 10 html: string; 10 11 hasViz: boolean; 12 + hasMath: boolean; 11 13 } 12 14 13 - interface MarkdownEnv extends VizPluginEnv, WikilinkEnv {} 15 + interface MarkdownEnv extends VizPluginEnv, WikilinkEnv, KatexPluginEnv {} 14 16 15 17 const md = new MarkdownIt({ html: false, linkify: true }); 16 18 ··· 53 55 54 56 md.use(wikilinkPlugin); 55 57 md.use(vizPlugin); 58 + md.use(katexPlugin, { throwOnError: false }); 56 59 md.use(youtubePlugin); 57 60 58 61 export function renderMarkdown( ··· 61 64 ): RenderResult { 62 65 const env: MarkdownEnv = wikiSlug ? { wikiSlug } : {}; 63 66 const html = md.render(source, env); 64 - return { html, hasViz: env.hasViz === true }; 67 + return { html, hasViz: env.hasViz === true, hasMath: env.hasMath === true }; 65 68 } 66 69 67 70 /**
+180
src/lib/markdown/katex-plugin.ts
··· 1 + import katex from "katex"; 2 + import type MarkdownIt from "markdown-it"; 3 + import type StateBlock from "markdown-it/lib/rules_block/state_block.mjs"; 4 + import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; 5 + import { escapeHtml } from "../html.ts"; 6 + 7 + interface KatexPluginOptions { 8 + throwOnError?: boolean; 9 + errorColor?: string; 10 + displayMode?: boolean; 11 + } 12 + 13 + export interface KatexPluginEnv { 14 + hasMath?: boolean; 15 + } 16 + 17 + function isValidDelim(state: StateInline, pos: number) { 18 + const max = state.posMax; 19 + const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1; 20 + const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1; 21 + 22 + let canOpen = true; 23 + let canClose = true; 24 + 25 + if ( 26 + prevChar === 0x20 || 27 + prevChar === 0x09 || 28 + (nextChar >= 0x30 && nextChar <= 0x39) 29 + ) { 30 + canClose = false; 31 + } 32 + if (nextChar === 0x20 || nextChar === 0x09) { 33 + canOpen = false; 34 + } 35 + 36 + return { canOpen, canClose }; 37 + } 38 + 39 + function mathInline(state: StateInline, silent: boolean) { 40 + if (state.src[state.pos] !== "$") return false; 41 + 42 + let res = isValidDelim(state, state.pos); 43 + if (!res.canOpen) { 44 + if (!silent) state.pending += "$"; 45 + state.pos += 1; 46 + return true; 47 + } 48 + 49 + const start = state.pos + 1; 50 + let match = start; 51 + let pos = start; 52 + 53 + while (true) { 54 + match = state.src.indexOf("$", match); 55 + if (match === -1) break; 56 + pos = match - 1; 57 + while (state.src[pos] === "\\") pos -= 1; 58 + if ((match - pos) % 2 === 1) break; 59 + match += 1; 60 + } 61 + 62 + if (match === -1) { 63 + if (!silent) state.pending += "$"; 64 + state.pos = start; 65 + return true; 66 + } 67 + 68 + if (match - start === 0) { 69 + if (!silent) state.pending += "$$"; 70 + state.pos = start + 1; 71 + return true; 72 + } 73 + 74 + res = isValidDelim(state, match); 75 + if (!res.canClose) { 76 + if (!silent) state.pending += "$"; 77 + state.pos = start; 78 + return true; 79 + } 80 + 81 + if (!silent) { 82 + const token = state.push("math_inline", "math", 0); 83 + token.markup = "$"; 84 + token.content = state.src.slice(start, match); 85 + } 86 + 87 + state.pos = match + 1; 88 + return true; 89 + } 90 + 91 + function lineMetrics(state: StateBlock, line: number) { 92 + const bMark = state.bMarks[line] ?? 0; 93 + const tShift = state.tShift[line] ?? 0; 94 + const eMark = state.eMarks[line] ?? 0; 95 + return { pos: bMark + tShift, max: eMark, tShift }; 96 + } 97 + 98 + function mathBlock( 99 + state: StateBlock, 100 + start: number, 101 + end: number, 102 + silent: boolean, 103 + ) { 104 + const startMetrics = lineMetrics(state, start); 105 + let { pos, max } = startMetrics; 106 + let firstLine = ""; 107 + let lastLine = ""; 108 + let next = start; 109 + let lastPos = 0; 110 + let found = false; 111 + 112 + if (pos + 2 > max) return false; 113 + if (state.src.slice(pos, pos + 2) !== "$$") return false; 114 + 115 + pos += 2; 116 + firstLine = state.src.slice(pos, max); 117 + 118 + if (silent) return true; 119 + if (firstLine.trim().slice(-2) === "$$") { 120 + firstLine = firstLine.trim().slice(0, -2); 121 + found = true; 122 + } 123 + 124 + for (; !found; ) { 125 + next += 1; 126 + if (next >= end) break; 127 + 128 + const m = lineMetrics(state, next); 129 + pos = m.pos; 130 + max = m.max; 131 + 132 + if (pos < max && m.tShift < state.blkIndent) break; 133 + 134 + if (state.src.slice(pos, max).trim().slice(-2) === "$$") { 135 + lastPos = state.src.slice(0, max).lastIndexOf("$$"); 136 + lastLine = state.src.slice(pos, lastPos); 137 + found = true; 138 + } 139 + } 140 + 141 + state.line = next + 1; 142 + const token = state.push("math_block", "math", 0); 143 + token.block = true; 144 + token.content = 145 + (firstLine.trim() ? `${firstLine}\n` : "") + 146 + state.getLines(start + 1, next, startMetrics.tShift, true) + 147 + (lastLine.trim() ? lastLine : ""); 148 + token.map = [start, state.line]; 149 + token.markup = "$$"; 150 + return true; 151 + } 152 + 153 + function renderKatex( 154 + latex: string, 155 + options: KatexPluginOptions, 156 + displayMode: boolean, 157 + env?: unknown, 158 + ) { 159 + try { 160 + if (env) (env as KatexPluginEnv).hasMath = true; 161 + return katex.renderToString(latex, { 162 + ...options, 163 + displayMode, 164 + }); 165 + } catch (error) { 166 + if (options.throwOnError) console.error(error); 167 + return escapeHtml(latex); 168 + } 169 + } 170 + 171 + export function katexPlugin(md: MarkdownIt, options: KatexPluginOptions = {}) { 172 + md.inline.ruler.after("escape", "math_inline", mathInline); 173 + md.block.ruler.after("blockquote", "math_block", mathBlock, { 174 + alt: ["paragraph", "reference", "blockquote", "list"], 175 + }); 176 + md.renderer.rules["math_inline"] = (tokens, idx, _options, env) => 177 + renderKatex(tokens[idx]?.content ?? "", options, false, env); 178 + md.renderer.rules["math_block"] = (tokens, idx, _options, env) => 179 + `<p>${renderKatex(tokens[idx]?.content ?? "", options, true, env)}</p>\n`; 180 + }
+7 -2
src/server/routes/note.ts
··· 1 1 import { Elysia } from "elysia"; 2 2 import { getAtprotoEnv } from "../../atproto/env.ts"; 3 3 import { resolveWikiContext } from "../../lib/access.ts"; 4 - import { EDITOR_SCRIPTS, VIZ_SCRIPTS } from "../../lib/constants.ts"; 4 + import { 5 + EDITOR_SCRIPTS, 6 + KATEX_STYLESHEETS, 7 + VIZ_SCRIPTS, 8 + } from "../../lib/constants.ts"; 5 9 import { NotFoundError, ValidationError } from "../../lib/errors.ts"; 6 10 import { t } from "../../lib/i18n/index.ts"; 7 11 import { exportNoteMd } from "../../lib/import-export/export.ts"; ··· 124 128 throw new NotFoundError("Note not found"); 125 129 } 126 130 127 - const { html, hasViz } = renderMarkdown( 131 + const { html, hasViz, hasMath } = renderMarkdown( 128 132 data.current.content, 129 133 params.wikiSlug, 130 134 ); ··· 146 150 html, 147 151 { 148 152 ...(hasViz && { scripts: VIZ_SCRIPTS }), 153 + ...(hasMath && { stylesheets: KATEX_STYLESHEETS }), 149 154 session: ctx.session, 150 155 sidebarNotes, 151 156 currentNoteSlug: params.noteSlug,
+3 -2
src/server/routes/wiki.ts
··· 6 6 resolveWikiContext, 7 7 resolveWikiContextSoft, 8 8 } from "../../lib/access.ts"; 9 - import { VIZ_SCRIPTS } from "../../lib/constants.ts"; 9 + import { KATEX_STYLESHEETS, VIZ_SCRIPTS } from "../../lib/constants.ts"; 10 10 import { 11 11 ForbiddenError, 12 12 ImportError, ··· 233 233 const bmHtml = resolveBookmarkHtml(ctx.did, ctx.wiki.at_uri, msg); 234 234 235 235 if (homeData) { 236 - const { html, hasViz } = renderMarkdown( 236 + const { html, hasViz, hasMath } = renderMarkdown( 237 237 homeData.current.content, 238 238 params.wikiSlug, 239 239 ); ··· 253 253 html, 254 254 { 255 255 ...(hasViz && { scripts: VIZ_SCRIPTS }), 256 + ...(hasMath && { stylesheets: KATEX_STYLESHEETS }), 256 257 session: ctx.session, 257 258 sidebarNotes, 258 259 currentNoteSlug: "home",
+6
src/views/layout.ts
··· 19 19 20 20 export interface LayoutOptions { 21 21 scripts?: string[]; 22 + stylesheets?: string[]; 22 23 session?: { did: string; handle: string } | null; 23 24 wikiName?: string; 24 25 wikiSlug?: string; ··· 125 126 .map((src) => ` <script src="${src}"></script>`) 126 127 .join("\n"); 127 128 129 + const extraStyles = (options?.stylesheets ?? []) 130 + .map((href) => ` <link rel="stylesheet" href="${href}">`) 131 + .join("\n"); 132 + 128 133 const locale = options?.locale ?? "en"; 129 134 const msg = t(locale); 130 135 ··· 185 190 : "" 186 191 } 187 192 <link rel="stylesheet" href="/public/dist.css"> 193 + ${extraStyles} 188 194 <script src="https://unpkg.com/htmx.org@2.0.4"></script> 189 195 ${extraScripts} 190 196 </head>
+36
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 + }); 28 + 29 + test("escapes invalid KaTeX to prevent XSS", () => { 30 + const malicious = "$<img src=x onerror=alert(1)>"; 31 + const { html } = renderMarkdown(malicious); 32 + expect(html).not.toContain("<img"); 33 + expect(html).toContain("&lt;img"); 34 + }); 35 + 36 + test("hasMath is true for inline math", () => { 37 + const result = renderMarkdown("Energy is $E = mc^2$."); 38 + expect(result.hasMath).toBe(true); 39 + }); 40 + 41 + test("hasMath is true for block math", () => { 42 + const result = renderMarkdown("$$\\int_0^1 x^2 \\; dx$$"); 43 + expect(result.hasMath).toBe(true); 44 + }); 45 + 46 + test("hasMath is false when no math present", () => { 47 + const result = renderMarkdown("Just plain text."); 48 + expect(result.hasMath).toBe(false); 49 + }); 14 50 }); 15 51 16 52 describe("wikilinks", () => {