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
@@ -6,7 +6,9 @@ one binary. It was called `narl-diary` until it grew everything but the diary.
|
|||||||
|
|
||||||
- **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend.
|
- **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend.
|
||||||
- Spaces (`diary`, `work`, `uni`, …) with folders nested to any depth inside them.
|
- Spaces (`diary`, `work`, `uni`, …) with folders nested to any depth inside them.
|
||||||
- Markdown documents with full-text search across a whole space (SQLite FTS5).
|
- Markdown documents with full-text search across a whole space (SQLite FTS5),
|
||||||
|
and math in them set by KaTeX: `$x^2$` inline, `$$…$$` or a ```` ```math ````
|
||||||
|
block on its own.
|
||||||
- Import a folder — or a zip of one — as a tree of documents, links and all.
|
- Import a folder — or a zip of one — as a tree of documents, links and all.
|
||||||
- A kanban board per space, so what is open at work is not mixed up with uni.
|
- A kanban board per space, so what is open at work is not mixed up with uni.
|
||||||
- Drag, drop or paste images, video and audio straight into an entry.
|
- Drag, drop or paste images, video and audio straight into an entry.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@replit/codemirror-vim": "^6.2.1",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
|
"katex": "^0.19.0",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -212,6 +213,8 @@
|
|||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"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=="],
|
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"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=="],
|
"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=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@replit/codemirror-vim": "^6.2.1",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
|
"katex": "^0.19.0",
|
||||||
"marked": "^15.0.6"
|
"marked": "^15.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,6 +517,8 @@ input::placeholder { color: var(--muted); }
|
|||||||
margin: 0 0 1em;
|
margin: 0 0 1em;
|
||||||
}
|
}
|
||||||
.md pre code { background: none; border: 0; padding: 0; font-size: inherit; }
|
.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 hr { border: 0; border-top: 1px solid var(--overlay); margin: 2.4em 0; }
|
||||||
.md img, .md video {
|
.md img, .md video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
<footer>
|
<footer>
|
||||||
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and
|
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
|
<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
|
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 —
|
deletes are copied to the system clipboard —
|
||||||
<span class="key">:set noclipboard</span> keeps them in vim's registers.
|
<span class="key">:set noclipboard</span> keeps them in vim's registers.
|
||||||
|
|||||||
+62
-3
@@ -1,8 +1,66 @@
|
|||||||
import DOMPurify from 'dompurify'
|
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 })
|
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,
|
* 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
|
* 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
|
const html = marked.parse(md, { async: false }) as string
|
||||||
|
|
||||||
return DOMPurify.sanitize(html, {
|
return DOMPurify.sanitize(html, {
|
||||||
ADD_TAGS: ['video', 'audio', 'source'],
|
// `semantics` and `annotation` carry KaTeX's MathML, for screen readers.
|
||||||
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload'],
|
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 { defineConfig } from 'vite'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
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({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte(), katexWoff2Only],
|
||||||
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
|
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
|
||||||
server: {
|
server: {
|
||||||
port: 4243,
|
port: 4243,
|
||||||
|
|||||||
Reference in new issue
Block a user