markdown: math set by KaTeX, fetched only when a page has some

$x^2$ inline, $$…$$ for display, and a ```math block, the way GitHub
writes them, in documents, shared pages and .md files alike. KaTeX loads
on first use; until then the math shows as its source. Only the woff2
fonts are bundled.
This commit is contained in:
nvrl committed 2026-10-20 10:25:26 +02:00
1 parent 889f99f8d6
commit fc593e6452
9 files changed
+111 -6

No files matched your search

+5
View File
@@ -15,6 +15,7 @@
"@lezer/highlight": "^1.2.1",
"@replit/codemirror-vim": "^6.2.1",
"dompurify": "^3.2.4",
"katex": "^0.19.0",
"marked": "^15.0.6",
},
"devDependencies": {
@@ -212,6 +213,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -234,6 +237,8 @@
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"katex": ["katex@0.19.0", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+1
View File
@@ -27,6 +27,7 @@
"@lezer/highlight": "^1.2.1",
"@replit/codemirror-vim": "^6.2.1",
"dompurify": "^3.2.4",
"katex": "^0.19.0",
"marked": "^15.0.6"
}
}
+2
View File
@@ -517,6 +517,8 @@ input::placeholder { color: var(--muted); }
margin: 0 0 1em;
}
.md pre code { background: none; border: 0; padding: 0; font-size: inherit; }
/* A long equation scrolls on its own rather than widening the page. */
.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; }
.md hr { border: 0; border-top: 1px solid var(--overlay); margin: 2.4em 0; }
.md img, .md video {
max-width: 100%;
+1 -1
View File
@@ -141,7 +141,7 @@
<footer>
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and
<span class="key">:w</span> to write. drop or paste an image, video or audio file into the
editor to attach it. a file opens in place — pdf, text and code, markdown, csv, and
editor to attach it. <span class="key">$x^2$</span> and <span class="key">$$…$$</span> are set as math. a file opens in place — pdf, text and code, markdown, csv, and
certificates and keystores, which ask for their password. <span class="key">:12</span> jumps straight to entry 12. yanks and
deletes are copied to the system clipboard —
<span class="key">:set noclipboard</span> keeps them in vim's registers.
+62 -3
View File
@@ -1,8 +1,66 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { marked, type TokenizerAndRendererExtension } from 'marked'
import { mathEngine } from './math.svelte'
marked.setOptions({ gfm: true, breaks: true })
/*
* Math the way GitHub writes it: `$x^2$` inline, `$$…$$` on lines of its own
* or inline for display, and a ```math block. A `$` opening on a space or
* closing before a digit is a price, not math — "$5 and $10" stays text — and
* `\$` is a dollar sign as ever.
*/
const DISPLAY = /^\$\$((?:\\.|[^\\$])+?)\$\$/
const INLINE = /^\$(?!\s)((?:\\.|[^\\$\n])+?)(?<!\s)\$(?!\d)/
const mathBlock: TokenizerAndRendererExtension = {
name: 'mathBlock',
level: 'block',
start: (src) => src.match(/^\$\$/m)?.index,
tokenizer(src) {
const match = /^\$\$((?:\\.|[^\\$])+?)\$\$[^\S\n]*(?:\n+|$)/.exec(src)
if (match) return { type: 'mathBlock', raw: match[0], text: match[1].trim() }
},
renderer: (token) => tex(token.text, true),
}
const mathInline: TokenizerAndRendererExtension = {
name: 'mathInline',
level: 'inline',
start: (src) => src.indexOf('$'),
tokenizer(src) {
const display = DISPLAY.exec(src)
if (display) return { type: 'mathInline', raw: display[0], text: display[1].trim(), display: true }
const inline = INLINE.exec(src)
if (inline) return { type: 'mathInline', raw: inline[0], text: inline[1], display: false }
},
renderer: (token) => tex(token.text, token.display),
}
marked.use({
extensions: [mathBlock, mathInline],
renderer: {
code(token) {
return token.lang === 'math' ? tex(token.text, true) : false
},
},
})
/** TeX as KaTeX sets it, or as its source while KaTeX is still loading. */
function tex(source: string, display: boolean): string {
const katex = mathEngine()
if (!katex) {
const text = escapeHtml(source)
return display ? `<pre class="math">${text}</pre>` : `<code class="math">${text}</code>`
}
// An error shows in place, in red, rather than throwing away the page.
return katex.renderToString(source, { displayMode: display, throwOnError: false })
}
function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
/**
* Render entry markdown to sanitised HTML. When share credentials are given,
* media URLs are rewritten to the public route — token and key both, since that
@@ -20,8 +78,9 @@ export function renderMarkdown(source: string, share?: { token: string; key: str
const html = marked.parse(md, { async: false }) as string
return DOMPurify.sanitize(html, {
ADD_TAGS: ['video', 'audio', 'source'],
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload'],
// `semantics` and `annotation` carry KaTeX's MathML, for screen readers.
ADD_TAGS: ['video', 'audio', 'source', 'semantics', 'annotation'],
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload', 'encoding'],
})
}
+20
View File
@@ -0,0 +1,20 @@
/*
* KaTeX, fetched the first time something on the page has math in it rather
* than with the app: it is larger than the rest of the app put together.
* Until it arrives the math shows as its source. The engine is state, so
* markdown rendered without it renders again once it is here.
*/
import type Katex from 'katex'
let engine = $state.raw<typeof Katex | null>(null)
let loading = false
export function mathEngine(): typeof Katex | null {
if (!engine && !loading) {
loading = true
Promise.all([import('katex'), import('katex/dist/katex.min.css')])
.then(([katex]) => (engine = katex.default))
.catch((e) => console.error('math: could not load KaTeX', e))
}
return engine
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+15 -1
View File
@@ -1,8 +1,22 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import type { Plugin } from 'vite'
/*
* KaTeX lists each font as woff2, woff and ttf. Every browser the app runs in
* takes woff2, and the other two would be another megabyte in the binary.
*/
const katexWoff2Only: Plugin = {
name: 'katex-woff2-only',
enforce: 'pre',
transform(code, id) {
if (!/katex(\.min)?\.css$/.test(id)) return
return code.replace(/,\s*url\([^)]*\.(?:woff|ttf)\)\s*format\("(?:woff|truetype)"\)/g, '')
},
}
export default defineConfig({
plugins: [svelte()],
plugins: [svelte(), katexWoff2Only],
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
server: {
port: 4243,