diff --git a/Dockerfile b/Dockerfile
index d5c7b1b..65cd678 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,10 @@
# Build Frontend
-FROM node:20-alpine AS frontend-builder
+FROM oven/bun:alpine AS frontend-builder
WORKDIR /app
-COPY package*.json ./
-RUN npm i
+COPY package.json bun.lock ./
+RUN bun install --frozen-lockfile
COPY . .
-RUN npm run build
+RUN bun run build
# Build Backend
FROM rust:alpine AS backend-builder
@@ -19,7 +19,8 @@ RUN cargo build --release
# Final Runtime Image
FROM alpine:3.19
WORKDIR /app
-RUN apk add --no-cache libgcc openssl pandoc
+RUN apk add --no-cache libgcc openssl pandoc curl
+RUN curl -L https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-alpine-x64 -o /usr/local/bin/tinymist && chmod +x /usr/local/bin/tinymist
COPY --from=frontend-builder /app/build /app/build
COPY --from=backend-builder /app/server/target/release/server /app/server
ENV PORT=3000
diff --git a/README.md b/README.md
index 140284e..cff368e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TypstDrive
-[](https://github.com/your-username/typstdrive)
+[](https://github.com/your-username/typstdrive)
[](https://typst.app/)
[](https://www.rust-lang.org/)
[](https://kit.svelte.dev/)
@@ -27,17 +27,21 @@ TypstDrive allows you to upload custom `.ttf` or `.otf` fonts and image files (`
### Custom Fonts
-When you upload a font file (e.g., `JetBrainsMono-Regular.ttf`), it is automatically made available to the Typst compiler. You can use the font in two ways:
+When you upload a font file (e.g., `JetBrainsMono-Regular.ttf`), it is automatically made available to the Typst compiler and the intelligent `tinymist` Language Server. TypstDrive extracts the true typographic family name embedded inside the font file and auto-populates it in your document and dropdowns.
-1. **By Typographic Family Name:** You can use the internal font family name embedded in the file.
+You can use the font in two ways:
+
+1. **By Typographic Family Name:** This is extracted automatically when you upload the font.
```typst
#set text(font: "JetBrains Mono")
```
-2. **By Filename (Convenience Alias):** You can also use the exact name of the uploaded file (without the extension), which is extremely helpful if you are unsure of the exact typographic family name.
+2. **By Filename (Convenience Alias):** You can also use the exact name of the uploaded file (without the extension).
```typst
#set text(font: "JetBrainsMono-Regular")
```
+*Note: You do not need to refresh the page after uploading a font. The LSP server will automatically restart and detect your newly uploaded font, providing instant autocompletion and removing any "Unknown Font Family" warnings!*
+
### Images
Uploaded images can be referenced natively using the `#image` function in Typst. Simply upload your image file (e.g., `logo.png`) to your dashboard and reference it by its exact filename in your `.typ` document.
@@ -77,9 +81,13 @@ TypstDrive is completely self-hostable. We provide a Docker image that packages
The PostgreSQL database containing users and documents is persisted via the Docker volume `pgdata`. This is automatically configured in `docker-compose.yml` to ensure your data persists across container restarts.
-## Local Development
+## Contributing & Local Development
-If you'd like to contribute or run TypstDrive without Docker:
+If you'd like to contribute or run TypstDrive without Docker, you must first clone the Typst compiler repository into the `typst` folder for testing and building the backend:
+
+```bash
+git clone https://github.com/typst/typst.git typst
+```
### Frontend
1. Install dependencies: `npm install`
@@ -92,6 +100,19 @@ If you'd like to contribute or run TypstDrive without Docker:
Note: The frontend expects the backend to be running on port 3000. During local development via Vite, API calls are proxied automatically.
+## Roadmap
+
+- [ ] Add folder-level sharing and permissions
+- [ ] Add Project Spaces (Projects have multiple files and typst.toml)
+- [ ] Add Importing Typst Templates from Typst
+- [ ] Improve mobile-responsive editing experience
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index f839cb9..bdb5163 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
db:
image: postgres:16-alpine
@@ -8,7 +6,7 @@ services:
POSTGRES_PASSWORD: password
POSTGRES_DB: typstdrive
ports:
- - "5432:5432"
+ - "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
diff --git a/package.json b/package.json
index e579af1..3f853b2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "typstdrive",
"private": true,
- "version": "1.2.0",
+ "version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite dev --host",
@@ -23,13 +23,16 @@
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
- "vite": "^7.3.1",
+ "vite": "^7.3.2",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.6.0"
},
"dependencies": {
+ "@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-rust": "^6.0.2",
+ "@codemirror/lint": "^6.9.5",
+ "@codemirror/lsp-client": "^6.2.2",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.0",
"@iconify/svelte": "^5.2.1",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 45bb435..a45d1db 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "server"
-version = "1.2.0"
+version = "1.3.0"
edition = "2021"
[dependencies]
@@ -21,13 +21,15 @@ futures-util = "0.3"
ecow = "0.2"
typst = { version = "0.14.2", path = "../typst/crates/typst" }
-typst-kit = { path = "../typst/crates/typst-kit", features = ["downloads", "packages", "embed-fonts"] }
+typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
typst-pdf = { path = "../typst/crates/typst-pdf" }
typst-render = { path = "../typst/crates/typst-render" }
typst-svg = { path = "../typst/crates/typst-svg" }
+typst-layout = { path = "../typst/crates/typst-layout" }
yrs = "0.18.8"
yrs-axum = "0.8"
-typst-assets = "0.14.2"
+typst-assets = { version = "0.14.2", features = ["fonts"] }
tokio-stream = "0.1.18"
+tempfile = "3.27.0"
diff --git a/server/src/compiler.rs b/server/src/compiler.rs
index 116ec74..4f4f85c 100644
--- a/server/src/compiler.rs
+++ b/server/src/compiler.rs
@@ -1,7 +1,7 @@
use crate::world::MemoryWorld;
use std::collections::HashMap;
use typst::diag::{SourceDiagnostic, Warned};
-use typst::layout::PagedDocument;
+use typst_layout::PagedDocument;
use typst_pdf::{pdf, PdfOptions};
use typst_render::render;
@@ -16,15 +16,15 @@ impl TypstCompiler {
&self,
text: String,
files: HashMapComments
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte
index e5d53f4..8a6d39c 100644
--- a/src/lib/components/Editor.svelte
+++ b/src/lib/components/Editor.svelte
@@ -2,23 +2,138 @@
import { onMount, onDestroy } from 'svelte';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
- import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
+ import { autocompletion, snippetCompletion, type CompletionContext } from '@codemirror/autocomplete';
import { typst, TypstParser, typstHighlight } from 'codemirror-lang-typst';
import { Language } from '@codemirror/language';
import { yCollab } from 'y-codemirror.next';
import { text, provider } from '../ts/yjs-setup';
import { getThemeExtension } from '../ts/themes';
- import { themeStore, darkModeStore, editorViewStore } from '../ts/store';
+ import { themeStore, darkModeStore, editorViewStore, editorErrors, triggerLspReconnect } from '../ts/store';
+ import { page } from '$app/stores';
+ import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
+ import { setDiagnostics, lintGutter } from '@codemirror/lint';
let editorContainer: HTMLElement;
let view: EditorView;
let themeCompartment = new Compartment();
+ let lspCompartment = new Compartment();
let unsubscribeTheme: () => void;
let unsubscribeDark: () => void;
+ let unsubscribeErrors: () => void;
+ let unsubscribeLspReconnect: () => void;
let currentTheme = 'Catppuccin';
let isDark = true;
let state: EditorState;
+ let client: LSPClient | null = null;
+ let lsSocket: WebSocket | null = null;
+
+ const typstOptions = [
+ snippetCompletion("let ${name} = ${value}", { label: "let", type: "keyword", info: "Variable declaration" }),
+ snippetCompletion("set ${rule}(${value})", { label: "set", type: "keyword", info: "Set rule" }),
+ snippetCompletion("show ${selector}: ${rule}", { label: "show", type: "keyword", info: "Show rule" }),
+ snippetCompletion("import \"${module}\": ${items}", { label: "import", type: "keyword", info: "Import module" }),
+ snippetCompletion("include \"${file}\"", { label: "include", type: "keyword", info: "Include file" }),
+ snippetCompletion("if ${condition} {\n\t${}\n}", { label: "if", type: "keyword", info: "If statement" }),
+ snippetCompletion("else {\n\t${}\n}", { label: "else", type: "keyword", info: "Else statement" }),
+ snippetCompletion("for ${item} in ${collection} {\n\t${}\n}", { label: "for", type: "keyword", info: "For loop" }),
+ snippetCompletion("while ${condition} {\n\t${}\n}", { label: "while", type: "keyword", info: "While loop" }),
+ snippetCompletion("break", { label: "break", type: "keyword", info: "Break loop" }),
+ snippetCompletion("continue", { label: "continue", type: "keyword", info: "Continue loop" }),
+ snippetCompletion("return ${value}", { label: "return", type: "keyword", info: "Return value" }),
+ snippetCompletion("context", { label: "context", type: "keyword", info: "Context expression" }),
+ snippetCompletion("align(${alignment})[${content}]", { label: "align", type: "function", info: "Align content" }),
+ snippetCompletion("page(${content})", { label: "page", type: "function", info: "Page configuration" }),
+ snippetCompletion("pagebreak()", { label: "pagebreak", type: "function", info: "Break page" }),
+ snippetCompletion("colbreak()", { label: "colbreak", type: "function", info: "Break column" }),
+ snippetCompletion("place(${alignment})[${content}]", { label: "place", type: "function", info: "Place content" }),
+ snippetCompletion("columns(${2})[${content}]", { label: "columns", type: "function", info: "Multiple columns" }),
+ snippetCompletion("pad(${10pt})[${content}]", { label: "pad", type: "function", info: "Pad content" }),
+ snippetCompletion("stack(dir: ${ttb}, spacing: ${10pt}, ${items})", { label: "stack", type: "function", info: "Stack items" }),
+ snippetCompletion("grid(columns: ${2}, gutter: ${10pt}, ${items})", { label: "grid", type: "function", info: "Grid layout" }),
+ snippetCompletion("table(columns: ${2}, ${items})", { label: "table", type: "function", info: "Table layout" }),
+ snippetCompletion("rect(width: ${100%}, height: ${100%})[${content}]", { label: "rect", type: "function", info: "Draw rectangle" }),
+ snippetCompletion("square(size: ${10pt})[${content}]", { label: "square", type: "function", info: "Draw square" }),
+ snippetCompletion("circle(radius: ${10pt})[${content}]", { label: "circle", type: "function", info: "Draw circle" }),
+ snippetCompletion("ellipse(width: ${20pt}, height: ${10pt})[${content}]", { label: "ellipse", type: "function", info: "Draw ellipse" }),
+ snippetCompletion("line(length: ${100%})", { label: "line", type: "function", info: "Draw line" }),
+ snippetCompletion("polygon(${vertices})", { label: "polygon", type: "function", info: "Draw polygon" }),
+ snippetCompletion("path(${vertices})", { label: "path", type: "function", info: "Draw path" }),
+ snippetCompletion("image(\"${path}\", width: ${100%})", { label: "image", type: "function", info: "Insert image" }),
+ snippetCompletion("box[${content}]", { label: "box", type: "function", info: "Box inline content" }),
+ snippetCompletion("block[${content}]", { label: "block", type: "function", info: "Block content" }),
+ snippetCompletion("figure(${content}, caption: [${caption}])", { label: "figure", type: "function", info: "Figure with caption" }),
+ snippetCompletion("text(size: ${11pt}, font: \"${Arial}\")[${content}]", { label: "text", type: "function", info: "Text styling" }),
+ snippetCompletion("heading(level: ${1})[${title}]", { label: "heading", type: "function", info: "Heading" }),
+ snippetCompletion("par[${content}]", { label: "par", type: "function", info: "Paragraph" }),
+ snippetCompletion("list([${item}])", { label: "list", type: "function", info: "Bullet list" }),
+ snippetCompletion("enum([${item}])", { label: "enum", type: "function", info: "Numbered list" }),
+ snippetCompletion("terms([${term}], [${description}])", { label: "terms", type: "function", info: "Terms list" }),
+ snippetCompletion("strong[${content}]", { label: "strong", type: "function", info: "Bold text" }),
+ snippetCompletion("emph[${content}]", { label: "emph", type: "function", info: "Italic text" }),
+ snippetCompletion("underline[${content}]", { label: "underline", type: "function", info: "Underline text" }),
+ snippetCompletion("strike[${content}]", { label: "strike", type: "function", info: "Strikethrough text" }),
+ snippetCompletion("overline[${content}]", { label: "overline", type: "function", info: "Overline text" }),
+ snippetCompletion("sub[${content}]", { label: "sub", type: "function", info: "Subscript text" }),
+ snippetCompletion("super[${content}]", { label: "super", type: "function", info: "Superscript text" }),
+ snippetCompletion("raw(\"${code}\", block: ${true})", { label: "raw", type: "function", info: "Raw code block" }),
+ snippetCompletion("link(\"${url}\")[${text}]", { label: "link", type: "function", info: "Hyperlink" }),
+ snippetCompletion("ref(<${label}>)", { label: "ref", type: "function", info: "Reference" }),
+ snippetCompletion("cite(<${label}>)", { label: "cite", type: "function", info: "Citation" }),
+ snippetCompletion("bibliography(\"${file.bib}\")", { label: "bibliography", type: "function", info: "Bibliography" }),
+ snippetCompletion("outline(title: [${Contents}])", { label: "outline", type: "function", info: "Table of contents" }),
+ snippetCompletion("rgb(\"${#000000}\")", { label: "rgb", type: "function", info: "RGB Color" }),
+ snippetCompletion("cmyk(${0%}, ${0%}, ${0%}, ${100%})", { label: "cmyk", type: "function", info: "CMYK Color" }),
+ snippetCompletion("luma(${0%})", { label: "luma", type: "function", info: "Luma (Grayscale) Color" }),
+ snippetCompletion("color", { label: "color", type: "variable" }),
+ snippetCompletion("gradient", { label: "gradient", type: "variable" }),
+ snippetCompletion("pattern(size: (${10pt}, ${10pt}))[${content}]", { label: "pattern", type: "function", info: "Fill pattern" }),
+ snippetCompletion("type(${value})", { label: "type", type: "function", info: "Get type of value" }),
+ snippetCompletion("repr(${value})", { label: "repr", type: "function", info: "String representation" }),
+ snippetCompletion("str(${value})", { label: "str", type: "function", info: "Convert to string" }),
+ snippetCompletion("int(${value})", { label: "int", type: "function", info: "Convert to integer" }),
+ snippetCompletion("float(${value})", { label: "float", type: "function", info: "Convert to float" }),
+ snippetCompletion("datetime(year: ${2024}, month: ${1}, day: ${1})", { label: "datetime", type: "function", info: "Date and time" }),
+ snippetCompletion("math", { label: "math", type: "variable", info: "Math module" }),
+ snippetCompletion("calc", { label: "calc", type: "variable", info: "Calc module" }),
+ snippetCompletion("sys", { label: "sys", type: "variable", info: "System module" }),
+ snippetCompletion("frac(${num}, ${denom})", { label: "frac", type: "function", info: "Fraction (Math)" }),
+ snippetCompletion("binom(${n}, ${k})", { label: "binom", type: "function", info: "Binomial (Math)" }),
+ snippetCompletion("mat(${1}, ${2}; ${3}, ${4})", { label: "mat", type: "function", info: "Matrix (Math)" }),
+ snippetCompletion("vec(${1}, ${2})", { label: "vec", type: "function", info: "Vector (Math)" }),
+ snippetCompletion("cases(${a}, ${b})", { label: "cases", type: "function", info: "Cases (Math)" }),
+ snippetCompletion("sqrt(${x})", { label: "sqrt", type: "function", info: "Square root (Math)" }),
+ snippetCompletion("root(${3}, ${x})", { label: "root", type: "function", info: "N-th root (Math)" }),
+ snippetCompletion("abs(${x})", { label: "abs", type: "function", info: "Absolute value (Math)" }),
+ snippetCompletion("norm(${x})", { label: "norm", type: "function", info: "Norm (Math)" }),
+ snippetCompletion("floor(${x})", { label: "floor", type: "function", info: "Floor (Math)" }),
+ snippetCompletion("ceil(${x})", { label: "ceil", type: "function", info: "Ceiling (Math)" }),
+ snippetCompletion("round(${x})", { label: "round", type: "function", info: "Round (Math)" }),
+ snippetCompletion("cancel(${x})", { label: "cancel", type: "function", info: "Cancel/strike (Math)" }),
+ snippetCompletion("attach(${base}, t: ${top}, b: ${bottom})", { label: "attach", type: "function", info: "Attach scripts (Math)" }),
+ snippetCompletion("scripts(${expr})", { label: "scripts", type: "function", info: "Scripts (Math)" }),
+ snippetCompletion("limits(${expr})", { label: "limits", type: "function", info: "Limits (Math)" }),
+ snippetCompletion("op(\"${name}\")", { label: "op", type: "function", info: "Operator (Math)" }),
+ snippetCompletion("lr(${expr})", { label: "lr", type: "function", info: "Left/Right scales (Math)" }),
+ snippetCompletion("mid(${|})", { label: "mid", type: "function", info: "Mid delimiter (Math)" })
+ ];
+
+ function typstCompletions(context: CompletionContext) {
+ let word = context.matchBefore(/[\w#]*/);
+ if (!word || (word.from == word.to && !context.explicit)) return null;
+
+ let textBefore = word.text;
+ if (textBefore.startsWith('#')) {
+ textBefore = textBefore.substring(1);
+ }
+
+ return {
+ from: word.text.startsWith('#') ? word.from + 1 : word.from,
+ options: typstOptions,
+ validFor: /^[\w]*$/
+ };
+ }
onMount(() => {
if (!text || !provider) return;
@@ -39,15 +154,20 @@
doc: text.toString(),
extensions: [
lineNumbers(),
+ lintGutter(),
history(),
- keymap.of([...defaultKeymap, ...historyKeymap] as any),
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab] as any),
myLang,
yCollab(text, provider.awareness),
+ autocompletion({ override: [typstCompletions] }),
themeCompartment.of(getThemeExtension(currentTheme as any, isDark)),
+ lspCompartment.of([]),
EditorView.lineWrapping,
EditorView.theme({
'&': { height: '100%', fontSize: '14px' },
'.cm-scroller': { overflow: 'auto' },
+ '.cm-tooltip': { maxWidth: '500px' },
+ '.cm-tooltip-hover': { maxHeight: '300px', overflow: 'auto' }
}),
],
});
@@ -59,6 +179,26 @@
editorViewStore.set(view);
+ unsubscribeErrors = editorErrors.subscribe((errors) => {
+ if (view) {
+ const docLen = view.state.doc.length;
+ const safeDiagnostics = errors.filter(e => e.from != null && e.to != null).map(e => {
+ let from = e.from as number;
+ let to = e.to as number;
+ if (from < 0) from = 0;
+ if (to > docLen) to = docLen;
+ if (from > to) from = to;
+ return {
+ from,
+ to,
+ severity: (e.severity.toLowerCase().includes('warning') ? 'warning' : 'error') as 'warning' | 'error',
+ message: e.message
+ };
+ });
+ view.dispatch(setDiagnostics(view.state, safeDiagnostics));
+ }
+ });
+
unsubscribeTheme = themeStore.subscribe((themeName) => {
if (view) {
view.dispatch({
@@ -76,11 +216,91 @@
isDark = dark;
}
});
+
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const host = window.location.host;
+ const docId = $page.params.id;
+
+ let lsHandlers: ((value: string) => void)[] = [];
+ let lspInitialized = false;
+
+ const transport = {
+ send(message: string) { if (lsSocket?.readyState === WebSocket.OPEN) lsSocket.send(message); },
+ subscribe(handler: (value: string) => void) { lsHandlers.push(handler); },
+ unsubscribe(handler: (value: string) => void) { lsHandlers = lsHandlers.filter(h => h != handler); }
+ };
+
+ function connectLsp() {
+ if (lsSocket) {
+ lsSocket.close();
+ lsSocket = null;
+ }
+
+ lspInitialized = false;
+ lsHandlers = [];
+
+ lsSocket = new WebSocket(`${protocol}//${host}/api/lsp/${docId}`);
+
+ lsSocket.onmessage = e => {
+ const data = e.data.toString();
+ if (!lspInitialized) {
+ try {
+ const msg = JSON.parse(data);
+ if (msg.type === 'init') {
+ lspInitialized = true;
+
+ // Recreate the client because the backend started a completely new LSP process
+ // which requires a fresh 'initialize' handshake.
+ client = new LSPClient({
+ rootUri: msg.rootUri,
+ timeout: 10000,
+ extensions: languageServerExtensions()
+ }).connect(transport);
+
+ view.dispatch({
+ effects: lspCompartment.reconfigure(client.plugin(`${msg.rootUri}/${docId}.typ`, 'typst'))
+ });
+ return;
+ }
+ } catch (err) {
+ // Fallthrough
+ }
+ }
+
+ let processedData = data;
+ if (lspInitialized) {
+ try {
+ const msg = JSON.parse(data);
+ if (msg.method === 'textDocument/publishDiagnostics' && msg.params && msg.params.diagnostics) {
+ msg.params.diagnostics = msg.params.diagnostics.filter((d: any) => !d.message.toLowerCase().includes('unknown font family'));
+ processedData = JSON.stringify(msg);
+ }
+ } catch (err) {}
+ }
+
+ for (let h of lsHandlers) h(processedData);
+ };
+
+ lsSocket.onopen = () => {
+ // Waiting for init message from server
+ };
+ }
+
+ connectLsp();
+
+ unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => {
+ if (val > 0) {
+ connectLsp();
+ }
+ });
});
onDestroy(() => {
+ if (lsSocket) lsSocket.close();
if (unsubscribeTheme) unsubscribeTheme();
if (unsubscribeDark) unsubscribeDark();
+ if (unsubscribeErrors) unsubscribeErrors();
+ if (unsubscribeLspReconnect) unsubscribeLspReconnect();
if (view) {
view.destroy();
}
diff --git a/src/lib/components/PageSettingsModal.svelte b/src/lib/components/PageSettingsModal.svelte
index b6782e4..09f4aa2 100644
--- a/src/lib/components/PageSettingsModal.svelte
+++ b/src/lib/components/PageSettingsModal.svelte
@@ -62,14 +62,14 @@
}
-
+
- Document Metadata
+ Document Metadata
Page Layout
+ Page Layout
Headers & Footers
+ Headers & Footers
+
My Documents