mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
889f99f8d6
commit
fc593e6452
9 files changed
+111
-6
No files matched your search
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
+15
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user