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

+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
}