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:
nvrl committed 2026-10-21 15:41:45 +02:00
1 parent 73ac961751
commit 0245402ead
34 files changed
+2295 -18

No files matched your search

Generated
+1854 -7
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -73,6 +73,13 @@ pkcs8 = { version = "0.11", features = ["encryption", "3des", "std"] }
sec1 = { version = "0.8", features = ["der"] }
sha1 = "0.10"
# ```typst blocks in markdown, typeset on the server into SVG. The fonts come
# from typst-assets, inside the binary, so nothing is fetched to render.
typst = "0.15"
typst-svg = "0.15"
typst-layout = "0.15"
typst-assets = { version = "0.15", features = ["fonts"] }
[dev-dependencies]
# A paused clock, so tests of timed loops do not wait in real time.
tokio = { version = "1", features = ["test-util"] }
+4 -1
View File
@@ -26,7 +26,8 @@ Everything is also in the [wiki](https://github.com/narrrl/narl-workspace/wiki).
- **Write.** Markdown documents in spaces (`diary`, `work`, `uni`, …) with
folders to any depth. Real vim keys on the desktop (CodeMirror 6), plain taps
on a phone. Math by KaTeX, `$x^2$` and `$$…$$`. `[[Title]]` links that are
on a phone. Math by KaTeX, `$x^2$` and `$$…$$`, and ` ```typst ` blocks
typeset by Typst on the server. `[[Title]]` links that are
rewritten when what they point at is renamed or moved, and backlinks.
- **Plan.** A kanban board per space, due dates said the way you say them
(`fri`, `+3d`), cards that come back once done, and an agenda of what is late,
@@ -883,6 +884,7 @@ sealed or not.
| `POST` | `/api/backup/login/totp`, `/api/backup/login/captcha` | the two-factor code `{"code"}`, or the solved human check `{"token"}`, a waiting login asked for |
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
| `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns |
| `POST` | `/api/typst` | `{source}` of a ` ```typst ` block → `{svg}`, or `{error}` as typst words it |
| `GET` | `/api/spaces/overview` | every space with what was last written in it |
| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) |
| `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) |
@@ -893,6 +895,7 @@ sealed or not.
| `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts |
| `POST` | `/api/share/{token}` | public: read a shared document, body `{"key"}`; sets the share's media cookie |
| `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared document, with that cookie |
| `POST` | `/api/share/{token}/typst` | public: a typst block the shared document holds, with that cookie |
## Deploying
Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+2
View File
@@ -47,6 +47,7 @@ sealed or not.
| `POST` | `/api/backup/login/totp`, `/api/backup/login/captcha` | the two-factor code `{"code"}`, or the solved human check `{"token"}`, a waiting login asked for |
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
| `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns |
| `POST` | `/api/typst` | `{source}` of a ` ```typst ` block → `{svg}`, or `{error}` as typst words it |
| `GET` | `/api/spaces/overview` | every space with what was last written in it |
| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) |
| `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) |
@@ -57,3 +58,4 @@ sealed or not.
| `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts |
| `POST` | `/api/share/{token}` | public: read a shared document, body `{"key"}`; sets the share's media cookie |
| `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared document, with that cookie |
| `POST` | `/api/share/{token}/typst` | public: a typst block the shared document holds, with that cookie |
+17
View File
@@ -59,6 +59,23 @@ Math is set by KaTeX:
![Math in a document](../images/math.png)
### Typst
A ` ```typst ` block (or ` ```typ `) is set by [Typst](https://typst.app) on
the server and shows as an image where it stands: math, tables, figures,
anything typst lays out. It shows its source while it is being set, and the
error typst gives, with the line of the block, when it cannot be.
- The page fits what is on it and is transparent, and the text is the
theme's. `#set page(width: 15cm)` makes a block of prose wrap, and any other
`#set` changes what it says.
- A block is typeset with nothing but itself and the fonts built into the
binary: `#import "@preview/…"` and `#image("…")` are refused.
- On a share page the blocks are set too, for the reader holding the key, and
only blocks the shared document holds.
![A typst block in a document](../images/typst.png)
## Links
A document links to another in two ways:
+26
View File
@@ -250,6 +250,28 @@ $$
The trace is the sum of the eigenvalues, $\\operatorname{tr} A = 4$, and the determinant their product, $\\det A = 3$.
`,
)
await doc(
algebra.id,
'Spectral theorem',
`A symmetric matrix is diagonalised by an orthonormal basis of eigenvectors.
\`\`\`typst
#let A = $mat(2, 1; 1, 2)$
$ A = Q Lambda Q^T, quad Q^T Q = I, quad Lambda = mat(3, 0; 0, 1) $
#table(
columns: 3,
stroke: 0.5pt + rgb("#545c52"),
inset: 6pt,
[*eigenvalue*], [*eigenvector*], [*multiplicity*],
$3$, $1/sqrt(2) vec(1, 1)$, $1$,
$1$, $1/sqrt(2) vec(1, -1)$, $1$,
)
\`\`\`
The block above is typst; the rest of the page is markdown.
`,
)
await doc(algebra.id, 'Exercise sheet 4', `- [x] 4.1 characteristic polynomial\n- [x] 4.2 diagonalise\n- [ ] 4.3 Jordan form\n`)
const thesis = await folder(uni, 'thesis')
await doc(thesis.id, 'Outline', `## Chapters\n\n1. Introduction\n2. Related work\n3. Method\n4. Evaluation\n`)
@@ -388,6 +410,10 @@ await shot('editor')
await open('/n/uni/linear-algebra/eigenvalues')
await shot('math')
await open('/n/uni/linear-algebra/spectral-theorem')
await sleep(1500)
await shot('typst')
await open('/n/work')
await keys('b')
await sleep(800)
+1
View File
@@ -13,6 +13,7 @@ mod static_files;
mod sweep;
mod tasks;
mod throttle;
mod typeset;
mod vault;
use std::sync::Arc;
+4
View File
@@ -18,6 +18,7 @@ pub mod share;
pub mod ssh_keys;
pub mod tokens;
pub mod trash;
pub mod typeset;
pub mod vault;
use axum::{
@@ -108,6 +109,8 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/media/{id}", get(media::serve).delete(media::remove))
.route("/share/{token}", post(share::read))
.route("/share/{token}/media/{id}", get(share::serve_media))
.route("/share/{token}/typst", post(share::typeset))
.route("/typst", post(typeset::render))
.route("/trash", get(trash::list).delete(trash::empty))
.route("/trash/{id}", delete(trash::purge))
.route("/trash/{id}/restore", post(trash::restore))
@@ -139,6 +142,7 @@ pub(crate) mod tests {
"/logout",
"/share/{token}",
"/share/{token}/media/{id}",
"/share/{token}/typst",
];
async fn state() -> AppState {
+29 -1
View File
@@ -16,7 +16,7 @@ use crate::{
auth::{self, Session},
config::Config,
error::{AppError, AppResult},
routes::{media, trash},
routes::{media, trash, typeset},
state::AppState,
};
@@ -253,6 +253,34 @@ pub async fn serve_media(
media::stream_media(&state, &id, false, headers.get(header::RANGE)).await
}
/// `POST /api/share/{token}/typst` — public, as the media are: a ```typst
/// block of a shared document, typeset for a browser that read the document
/// with its key. Only a block the document holds is typeset, so the link is
/// not a typesetter for whoever has it.
pub async fn typeset(
State(state): State<AppState>,
Path(token): Path<String>,
headers: HeaderMap,
Json(body): Json<typeset::TypesetRequest>,
) -> AppResult<Json<typeset::Typeset>> {
let row = sqlx::query("SELECT id, body, share_key_hash FROM nodes WHERE share_token = ?1")
.bind(&token)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let stored: Option<String> = row.get("share_key_hash");
let allowed =
stored.is_some_and(|stored| holds_share_cookie(&state.config, &headers, &token, &stored));
let document: String = row.get("body");
if !allowed || body.source.trim().is_empty() || !document.contains(body.source.trim()) {
return Err(AppError::NotFound);
}
if trash::in_trash(&mut *state.db.acquire().await?, row.get("id")).await? {
return Err(AppError::NotFound);
}
typeset::answer(body.source).await.map(Json)
}
#[cfg(test)]
mod tests {
use axum::{
+68
View File
@@ -0,0 +1,68 @@
//! `POST /api/typst`: a ```typst block of a document, typeset into SVG.
use std::sync::LazyLock;
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use tokio::sync::Semaphore;
use crate::{
auth::Session,
error::{AppError, AppResult},
state::AppState,
typeset,
};
#[derive(Deserialize)]
pub struct TypesetRequest {
pub source: String,
}
/// The block as SVG, or what typst said about it. A block that does not
/// compile is still an answer: the page shows the error where the block is.
#[derive(Serialize, Debug)]
pub struct Typeset {
#[serde(skip_serializing_if = "Option::is_none")]
pub svg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// A page with many blocks asks for all of them at once; two are laid out at a
/// time, so they do not take every core from everyone else.
static AT_ONCE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(2));
pub async fn render(
_: Session,
State(_): State<AppState>,
Json(body): Json<TypesetRequest>,
) -> AppResult<Json<Typeset>> {
answer(body.source).await.map(Json)
}
/// Typeset one block, off the async threads.
pub(crate) async fn answer(source: String) -> AppResult<Typeset> {
if source.len() > typeset::MAX_SOURCE {
return Err(AppError::BadRequest(format!(
"a typst block is at most {} KiB",
typeset::MAX_SOURCE / 1024
)));
}
let _turn = AT_ONCE
.acquire()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let done = tokio::task::spawn_blocking(move || typeset::render(&source))
.await
.map_err(|e| AppError::Internal(e.into()))?;
Ok(match done {
Ok(svg) => Typeset {
svg: Some(svg),
error: None,
},
Err(error) => Typeset {
svg: None,
error: Some(error),
},
})
}
+206
View File
@@ -0,0 +1,206 @@
//! ```typst blocks in markdown, typeset into SVG.
//!
//! Each block is a document of its own: its source is the only file there is,
//! and the fonts are the ones typst-assets builds into the binary. Nothing is
//! read from disk or fetched — an `#import "@preview/…"` or an `#image("…")`
//! is answered with an error in place of the block, as typst words it.
//!
//! The page fits what is on it and is transparent, and the text is the
//! theme's, so a block reads as part of the page it sits on. A block that
//! wants otherwise says so with its own `#set page(…)` or `#set text(…)`.
use std::{
collections::HashMap,
hash::{DefaultHasher, Hash, Hasher},
sync::{LazyLock, Mutex},
};
use chrono::{Datelike, TimeDelta, Utc};
use typst::{
diag::{FileError, FileResult, Severity, SourceDiagnostic},
foundations::{Bytes, Datetime, Duration},
layout::Abs,
syntax::{FileId, Source, VirtualRoot},
text::{Font, FontBook},
utils::LazyHash,
Library, LibraryExt, World, WorldExt,
};
use typst_layout::PagedDocument;
/// Set ahead of every block, on one line so an error's line number is the
/// block's own once one is taken off. `--fg` and the 14 px of the page's text.
const PREAMBLE: &str = "#set page(width: auto, height: auto, margin: 4pt, fill: none); \
#set text(fill: rgb(\"#ccd2cb\"), size: 10.5pt)\n";
/// A block longer than this is not typeset: it is a document, not a block.
pub const MAX_SOURCE: usize = 64 * 1024;
/// How many blocks are kept typeset. Rendering the same page again, or the
/// preview while the rest of the document is written, asks for the same ones.
const CACHE_SIZE: usize = 256;
static LIBRARY: LazyLock<LazyHash<Library>> = LazyLock::new(|| LazyHash::new(Library::default()));
static FONTS: LazyLock<(LazyHash<FontBook>, Vec<Font>)> = LazyLock::new(|| {
let fonts: Vec<Font> = typst_assets::fonts()
.flat_map(|data| Font::iter(Bytes::new(data)))
.collect();
(LazyHash::new(FontBook::from_fonts(&fonts)), fonts)
});
static CACHE: LazyLock<Mutex<HashMap<u64, Typeset>>> = LazyLock::new(Default::default);
/// What a block became: its SVG, or what typst said about it.
pub type Typeset = Result<String, String>;
/// Typeset `source`, from the cache when it was typeset before. Blocking: the
/// layout runs on the calling thread, so call it from `spawn_blocking`.
pub fn render(source: &str) -> Typeset {
let key = {
let mut hasher = DefaultHasher::new();
source.hash(&mut hasher);
hasher.finish()
};
if let Some(done) = CACHE.lock().unwrap().get(&key) {
return done.clone();
}
let done = typeset(source);
// comemo keeps what it memoised for every compile; let it go of what the
// last few did not use, or it grows with every block ever written.
typst::comemo::evict(10);
let mut cache = CACHE.lock().unwrap();
if cache.len() >= CACHE_SIZE {
cache.clear();
}
cache.insert(key, done.clone());
done
}
fn typeset(source: &str) -> Typeset {
let block = Block {
source: Source::detached(format!("{PREAMBLE}{source}")),
};
let document = typst::compile::<PagedDocument>(&block)
.output
.map_err(|errors| block.explain(&errors))?;
Ok(typst_svg::svg_merged(
&document,
&Default::default(),
Abs::pt(8.0),
))
}
/// The world a block is typeset in: its own source, and nothing else.
struct Block {
source: Source,
}
impl Block {
/// The errors as typst words them, each with the line of the block it is
/// on and the hints it gives.
fn explain(&self, errors: &[SourceDiagnostic]) -> String {
let lines = self.source.lines();
errors
.iter()
.filter(|error| error.severity == Severity::Error)
.map(|error| {
let line = self
.range(error.span)
.and_then(|range| lines.byte_to_line(range.start))
// The preamble is line 0, so the block's own count from 1.
.filter(|&line| line > 0);
let mut said = match line {
Some(line) => format!("line {line}: {}", error.message),
None => error.message.to_string(),
};
for hint in &error.hints {
said.push_str(&format!("\n hint: {}", hint.v));
}
said
})
.collect::<Vec<_>>()
.join("\n")
}
fn only(&self, id: FileId) -> FileResult<()> {
if id == self.source.id() {
return Ok(());
}
Err(FileError::Other(Some(
if matches!(id.root(), VirtualRoot::Package(_)) {
"packages are not available here: a block is typeset with nothing but itself"
} else {
"files are not available here: a block is typeset with nothing but itself"
}
.into(),
)))
}
}
impl World for Block {
fn library(&self) -> &LazyHash<Library> {
&LIBRARY
}
fn book(&self) -> &LazyHash<FontBook> {
&FONTS.0
}
fn main(&self) -> FileId {
self.source.id()
}
fn source(&self, id: FileId) -> FileResult<Source> {
self.only(id).map(|()| self.source.clone())
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
self.only(id)
.map(|()| Bytes::from_string(self.source.text().to_owned()))
}
fn font(&self, index: usize) -> Option<Font> {
FONTS.1.get(index).cloned()
}
/// The day in UTC, moved by the hours asked for, or by none: the server's
/// zone is not the reader's, and `datetime.today()` is rarely what a block
/// is for.
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
let shift = offset.map_or(0, |offset| offset.seconds() as i64);
let now = Utc::now() + TimeDelta::seconds(shift);
Datetime::from_ymd(now.year(), now.month() as u8, now.day() as u8)
}
}
#[cfg(test)]
mod tests {
use super::render;
#[test]
fn math_comes_out_as_svg() {
let svg = render("$ sum_(k=1)^n k = (n(n+1)) / 2 $").unwrap();
assert!(svg.starts_with("<svg"), "{svg}");
// Glyphs are drawn as paths, in the theme's text colour.
assert!(svg.contains("#ccd2cb"), "{svg}");
}
#[test]
fn an_error_says_the_line_of_the_block() {
let error = render("fine\n#let x = (\nmore").unwrap_err();
assert!(error.starts_with("line 2: "), "{error}");
}
#[test]
fn nothing_outside_the_block_is_read() {
let error = render("#import \"@preview/cetz:0.3.0\"").unwrap_err();
assert!(error.contains("packages are not available"), "{error}");
let error = render("#image(\"/etc/passwd\")").unwrap_err();
assert!(error.contains("files are not available"), "{error}");
}
#[test]
fn a_loop_without_end_is_stopped() {
assert!(render("#while true {}").is_err());
}
}
+5 -8
View File
@@ -116,7 +116,7 @@ fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option<Key> {
/// Seal one 32-byte key under another.
fn wrap(kek: &Key, key: &Key, aad: &[u8]) -> Result<([u8; WRAP_NONCE], Vec<u8>)> {
let nonce = random::<WRAP_NONCE>();
let sealed = Aes256Gcm::new(kek.as_ref().into())
let sealed = Aes256Gcm::new((&**kek).into())
.encrypt(
Nonce::from_slice(&nonce),
Payload {
@@ -134,7 +134,7 @@ fn unwrap(kek: &Key, nonce: &[u8], sealed: &[u8], aad: &[u8]) -> Option<Key> {
return None;
}
let plain = Zeroizing::new(
Aes256Gcm::new(kek.as_ref().into())
Aes256Gcm::new((&**kek).into())
.decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad })
.ok()?,
);
@@ -157,7 +157,7 @@ impl Sealer {
let wrap_nonce = random::<WRAP_NONCE>();
let stream_nonce = random::<STREAM_NONCE>();
let sealed_key = Aes256Gcm::new(master.as_ref().into())
let sealed_key = Aes256Gcm::new((&**master).into())
.encrypt(
Nonce::from_slice(&wrap_nonce),
Payload {
@@ -174,7 +174,7 @@ impl Sealer {
header.extend_from_slice(&stream_nonce);
let stream = EncryptorBE32::from_aead(
Aes256Gcm::new(file_key.as_ref().into()),
Aes256Gcm::new((&*file_key).into()),
stream_nonce.as_slice().into(),
);
Ok((
@@ -1000,10 +1000,7 @@ mod tests {
let kek = derive("correct horse", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
let master = key(5);
let (nonce, sealed) = seal_master(&kek, &master).expect("seal");
assert_eq!(
open_master(&kek, &nonce, &sealed).expect("opens").as_ref(),
master.as_ref()
);
assert_eq!(*open_master(&kek, &nonce, &sealed).expect("opens"), *master);
let wrong = derive("correct horse!", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
assert!(open_master(&wrong, &nonce, &sealed).is_none());
}
+8
View File
@@ -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%;
+1
View File
@@ -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'],
+7
View File
@@ -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
View File
@@ -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)
+33
View File
@@ -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
}