mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
typst: a block of it in markdown is typeset on the server
A ```typst (or ```typ) block is compiled by the typst crate into SVG and
shown as an image where it stands, so math, tables and figures can be
written in typst beside the markdown around them. It shows its source while
it is set, and the error typst gives, with the block's line, when it cannot
be.
Each block is a document of its own: its source is the only file in its
world and the fonts are typst-assets', inside the binary, so nothing is read
from disk or fetched — packages and images are refused. The page fits what
is on it, is transparent, and carries the theme's text colour. Answers are
cached by source, two blocks are laid out at a time off the async threads,
and comemo's memo is evicted after each compile.
A share page asks POST /api/share/{token}/typst, which takes the reader's
cookie and typesets only a block the shared document holds.
typst brings a palette crate whose AsRef impls on arrays made four
`kek.as_ref().into()` in the vault ambiguous; they deref instead.
This commit is contained in:
1 parent
73ac961751
commit
0245402ead
34 files changed
+2295
-18
No files matched your search
@@ -515,6 +515,14 @@ input::placeholder { color: var(--muted); }
|
||||
.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; }
|
||||
|
||||
/* ```typst: the block as typst set it, at its own size, scrolling sideways
|
||||
when wider than the column; its source while it is being set, and what
|
||||
typst said under the source when it could not be. */
|
||||
.md figure.typst { margin: 1em 0; overflow-x: auto; overflow-y: hidden; }
|
||||
.md figure.typst img { display: block; max-width: none; border: 0; border-radius: 0; background: none; padding: 0; }
|
||||
.md pre.typst-source.pending { opacity: 0.55; }
|
||||
.md pre.typst-error { margin-top: -0.6em; color: var(--alarm); border-color: color-mix(in oklab, var(--alarm) 40%, transparent); white-space: pre-wrap; }
|
||||
.md hr { border: 0; border-top: 1px solid var(--overlay); margin: 2.4em 0; }
|
||||
.md img, .md video {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'],
|
||||
['drop · paste', 'an image, video or audio file attaches it'],
|
||||
['$x^2$ · $$…$$', 'set as math'],
|
||||
['```typst', 'a block typst sets, shown as an image — math, tables, figures; no packages or files'],
|
||||
[':12', 'jumps straight to entry 12'],
|
||||
['y · d', 'copied to the system clipboard — :set noclipboard keeps them in the registers'],
|
||||
['files', 'open in place — pdf, text and code, markdown, csv, certificates and keystores'],
|
||||
|
||||
@@ -631,6 +631,13 @@ export const api = {
|
||||
readShared: (token: string, key: string) =>
|
||||
request<SharedDocument>(`/share/${encodeURIComponent(token)}`, { method: 'POST', body: body({ key }) }),
|
||||
|
||||
/** A ```typst block as SVG — or, on a share page, a block of the shared document. */
|
||||
typeset: (source: string, share?: string) =>
|
||||
request<{ svg?: string; error?: string }>(share ? `/share/${encodeURIComponent(share)}/typst` : '/typst', {
|
||||
method: 'POST',
|
||||
body: body({ source }),
|
||||
}),
|
||||
|
||||
sessions: () => request<SignedIn[]>('/sessions'),
|
||||
revokeSession: (id: string) =>
|
||||
request<{ ok: true }>(`/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
+23
-1
@@ -1,6 +1,7 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked, type TokenizerAndRendererExtension } from 'marked'
|
||||
import { mathEngine } from './math.svelte'
|
||||
import { typeset } from './typst.svelte'
|
||||
import { hardWrapped, titleHref } from './util'
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
@@ -48,6 +49,8 @@ const WIKILINK = /^\[\[([^[\]|\n]+)(?:\|([^[\]\n]*))?\]\]/
|
||||
|
||||
/** What the page being rendered says about its `[[titles]]`, for the renderer. */
|
||||
let wikiContext: { leads: Record<string, string>; sharing: boolean } = { leads: {}, sharing: false }
|
||||
/** The share the page is read under, whose server route typesets its typst. */
|
||||
let shareToken: string | undefined
|
||||
|
||||
const wikilink: TokenizerAndRendererExtension = {
|
||||
name: 'wikilink',
|
||||
@@ -89,7 +92,9 @@ marked.use({
|
||||
},
|
||||
renderer: {
|
||||
code(token) {
|
||||
return token.lang === 'math' ? tex(token.text, true) : false
|
||||
if (token.lang === 'math') return tex(token.text, true)
|
||||
if (token.lang === 'typst' || token.lang === 'typ') return typst(token.text)
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -105,6 +110,22 @@ function tex(source: string, display: boolean): string {
|
||||
return katex.renderToString(source, { displayMode: display, throwOnError: false })
|
||||
}
|
||||
|
||||
/*
|
||||
* A ```typst block as the SVG the server set it in, shown as an image: each
|
||||
* SVG names its glyphs with the same ids, which inline on one page would
|
||||
* draw each other's letters. Until it arrives, and when it does not compile,
|
||||
* the source shows — with what typst said under it.
|
||||
*/
|
||||
function typst(source: string): string {
|
||||
const answer = typeset(source, shareToken)
|
||||
if (answer && 'svg' in answer) {
|
||||
const data = encodeURIComponent(answer.svg)
|
||||
return `<figure class="typst"><img src="data:image/svg+xml;charset=utf-8,${data}" alt="${escapeHtml(source)}" /></figure>`
|
||||
}
|
||||
const error = answer ? `<pre class="typst-error">${escapeHtml(answer.error)}</pre>` : ''
|
||||
return `<pre class="typst-source${answer ? '' : ' pending'}"><code>${escapeHtml(source)}</code></pre>${error}`
|
||||
}
|
||||
|
||||
/*
|
||||
* A link that opens another window leaves it no handle on this one. Only
|
||||
* `_blank` is kept as a target: a named one reuses whatever window has that
|
||||
@@ -144,6 +165,7 @@ export function renderMarkdown(
|
||||
: source
|
||||
|
||||
wikiContext = { leads: wikilinks, sharing: !!share }
|
||||
shareToken = share?.token
|
||||
const html = marked.parse(md, { async: false }) as string
|
||||
if (!share) return DOMPurify.sanitize(html, SANITIZE)
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* ```typst blocks, typeset on the server. A block shows as its source until
|
||||
* its SVG arrives, as math does until KaTeX has loaded; the answers are
|
||||
* state, so markdown rendered without one renders again once it is here.
|
||||
* Each block is asked for once per page load, however often the page is
|
||||
* drawn — while writing, the preview asks only for a block that changed.
|
||||
*/
|
||||
import { api } from './api'
|
||||
|
||||
type Answer = { svg: string } | { error: string }
|
||||
|
||||
let answers = $state.raw(new Map<string, Answer>())
|
||||
const asked = new Set<string>()
|
||||
|
||||
/** Forget a block once it is this far out of date: the preview of a long session asks for many. */
|
||||
const KEEP = 200
|
||||
|
||||
export function typeset(source: string, share?: string): Answer | null {
|
||||
const key = `${share ?? ''}\n${source}`
|
||||
const answer = answers.get(key)
|
||||
if (answer || asked.has(key)) return answer ?? null
|
||||
asked.add(key)
|
||||
api
|
||||
.typeset(source, share)
|
||||
.then((done) => (done.svg !== undefined ? { svg: done.svg } : { error: done.error ?? 'typst said nothing' }))
|
||||
.catch((e: Error) => ({ error: e.message }))
|
||||
.then((done) => {
|
||||
const next = new Map(answers)
|
||||
if (next.size >= KEEP) next.delete(next.keys().next().value!)
|
||||
answers = next.set(key, done)
|
||||
})
|
||||
return null
|
||||
}
|
||||
Reference in new issue
Block a user