diff --git a/README.md b/README.md index 6b965926af..3fa9ed33fb 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ **A collaborative real-time editor for Quarto and LaTeX presentations and documents.** -Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that adds first-class [Quarto](https://quarto.org) support alongside Overleaf's existing LaTeX toolchain. It keeps Overleaf's real-time collaboration infrastructure and runs **two compilers side by side**: `.qmd` files are built with Quarto (PDF via Typst, or HTML via RevealJS), while `.tex` files still compile with `latexmk`/TeX Live. The engine is selected automatically from the root file's extension, so Quarto and LaTeX projects coexist. +Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that adds first-class [Quarto](https://quarto.org) support alongside Overleaf's existing LaTeX toolchain. It keeps Overleaf's real-time collaboration infrastructure and runs **three compilers side by side**, chosen automatically from the root file's extension: `.qmd` builds with Quarto (PDF via Typst, or HTML via RevealJS), `.tex` with `latexmk`/TeX Live, and `.typ` straight through [Typst](https://typst.app). All three coexist on one server. --- ## Features - **Real-time collaboration** — multiple users editing the same `.qmd` file simultaneously, powered by Overleaf's operational-transformation engine -- **Dual compiler** — `.qmd` files build with Quarto; `.tex` files build with `latexmk`/TeX Live. The runner is chosen by the root file's extension, so LaTeX and Quarto projects live side by side +- **Three compilers** — `.qmd` builds with Quarto, `.tex` with `latexmk`/TeX Live, and `.typ` with Typst. The runner is chosen by the root file's extension, so Quarto, LaTeX and Typst projects live side by side - **Two output formats** — set `format: typst` in the YAML frontmatter for a PDF preview, or `format: revealjs` for an interactive HTML presentation rendered in an iframe - **Document outline** — headings (`#`, `##`, `###`) are extracted and shown in the sidebar outline panel - **Math, code, tables** — standard Quarto/Pandoc Markdown features all work @@ -70,7 +70,7 @@ browser ──→ nginx:80 └── /project/*/output/* → clsi-nginx:8080 (compiled output files) web → document-updater → Redis pub/sub → real-time → browser -web → CLSI (quarto render / latexmk) → output files → nginx → browser +web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser ``` Key services: @@ -80,7 +80,7 @@ Key services: | `web` | HTTP API, React frontend, auth, project management | | `real-time` | WebSocket layer, live cursor and edit sync | | `document-updater` | Operational transformation, Redis pub/sub | -| `clsi` | Compiler — runs `quarto render` (`.qmd`) or `latexmk` (`.tex`) and serves output | +| `clsi` | Compiler — runs `quarto render` (`.qmd`), `latexmk` (`.tex`) or `typst` (`.typ`) and serves output | | `docstore` | Document text storage (MongoDB) | | `filestore` | Binary file storage (S3 or local) | | `project-history` | Change history and version tracking | @@ -123,6 +123,14 @@ point; **Blank LaTeX project** gives you an empty `main.tex`. > extra packages may not build out of the box yet — see `server-ce/Dockerfile-base` > for how to switch to a fuller TeX Live scheme. +## Writing a Typst document + +A project whose root file is a `.typ` file compiles directly to PDF with +[Typst](https://typst.app) — fast, modern markup with a real scripting +language. No extra install is needed: Verso drives the Typst that ships inside +Quarto (`quarto typst compile`). Use the **Blank Typst project** or **Example +Typst project** entries in the *New project* menu to get started. + ## Environment variables Verso inherits all of Overleaf's environment variables (prefixed `OVERLEAF_`). diff --git a/services/clsi/app/js/CompileManager.js b/services/clsi/app/js/CompileManager.js index 4c8799fb1d..2e3d843baf 100644 --- a/services/clsi/app/js/CompileManager.js +++ b/services/clsi/app/js/CompileManager.js @@ -7,6 +7,7 @@ import OError from '@overleaf/o-error' import ResourceWriter from './ResourceWriter.js' import QuartoRunner from './QuartoRunner.js' import LatexRunner from './LatexRunner.js' +import TypstRunner from './TypstRunner.js' import OutputFileFinder from './OutputFileFinder.js' import OutputCacheManager from './OutputCacheManager.js' import ClsiMetrics from './Metrics.js' @@ -47,9 +48,22 @@ function _isQuartoFile(rootResourcePath) { return /\.(qmd|md|rmd)$/i.test(rootResourcePath || '') } +// A bare Typst source (.typ) compiles straight to PDF with the Typst that +// ships inside Quarto (see TypstRunner), separate from the Quarto pipeline. +function _isTypstFile(rootResourcePath) { + return /\.typ$/i.test(rootResourcePath || '') +} + // Return a runner with a uniform { run, isRunning, kill } interface so the // rest of CompileManager doesn't need to know which engine is in use. function _getRunner(rootResourcePath) { + if (_isTypstFile(rootResourcePath)) { + return { + run: (name, opts) => TypstRunner.promises.runTypst(name, opts), + isRunning: name => TypstRunner.isRunning(name), + kill: name => TypstRunner.promises.killTypst(name), + } + } if (_isQuartoFile(rootResourcePath)) { return { run: (name, opts) => QuartoRunner.promises.runQuarto(name, opts), @@ -351,7 +365,9 @@ async function stopCompile(projectId, userId) { // stopCompile has no root path, so check both runners — only one can be // active for a given compileName at a time. const isRunning = - QuartoRunner.isRunning(compileName) || LatexRunner.isRunning(compileName) + QuartoRunner.isRunning(compileName) || + LatexRunner.isRunning(compileName) || + TypstRunner.isRunning(compileName) const lock = LockManager.getExistingLock(getCompileDir(projectId, userId)) let lockReleased if (lock) { @@ -363,6 +379,7 @@ async function stopCompile(projectId, userId) { } await QuartoRunner.promises.killQuarto(compileName) await LatexRunner.promises.killLatex(compileName) + await TypstRunner.promises.killTypst(compileName) await lockReleased } diff --git a/services/clsi/app/js/RequestParser.js b/services/clsi/app/js/RequestParser.js index d3191b050f..d1516b9bb4 100644 --- a/services/clsi/app/js/RequestParser.js +++ b/services/clsi/app/js/RequestParser.js @@ -2,7 +2,14 @@ import { promisify } from 'node:util' import settings from '@overleaf/settings' import OutputCacheManager from './OutputCacheManager.js' -const VALID_COMPILERS = ['quarto', 'pdflatex', 'latex', 'xelatex', 'lualatex'] +const VALID_COMPILERS = [ + 'quarto', + 'typst', + 'pdflatex', + 'latex', + 'xelatex', + 'lualatex', +] const MAX_TIMEOUT = 600 const EDITOR_ID_REGEX = /^[a-f0-9-]{36}$/ // UUID const HISTORY_ID_REGEX = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/ // mongo id or postgres id diff --git a/services/clsi/app/js/TypstRunner.js b/services/clsi/app/js/TypstRunner.js new file mode 100644 index 0000000000..5e3f4ed319 --- /dev/null +++ b/services/clsi/app/js/TypstRunner.js @@ -0,0 +1,104 @@ +import Path from 'node:path' +import { promisify } from 'node:util' +import logger from '@overleaf/logger' +import CommandRunner from './CommandRunner.js' +import fs from 'node:fs' + +// Maps currently-running Typst jobs: compileName → PID (or docker container id) +const ProcessTable = {} + +// Compiles a standalone Typst document (.typ) straight to output.pdf. We reuse +// the Typst that ships inside Quarto via `quarto typst compile`, so there is no +// extra binary to install. This is deliberately the simplest of the three +// runners: Typst only ever produces a PDF, so there is no format detection, +// no HTML asset directory and no extension merging (cf. QuartoRunner). +function runTypst(compileName, options, callback) { + const { directory, mainFile, image, environment, compileGroup } = options + const timeout = options.timeout || 60000 + + logger.debug( + { directory, timeout, mainFile, compileGroup }, + 'starting typst compile' + ) + + const command = _buildTypstCommand(mainFile) + + ProcessTable[compileName] = CommandRunner.run( + compileName, + command, + directory, + image, + timeout, + environment || {}, + compileGroup, + null, + function (error, output) { + delete ProcessTable[compileName] + + // Propagate real process-level errors (killed, timed out) but NOT + // ordinary non-zero exit codes from Typst itself. A compile failure + // (exit code 1) is not a server error — the absence of output.pdf is + // enough for CompileController to return 'failure'. + if (error && (error.terminated || error.timedout)) { + return callback(error) + } + + // On exit-code-1 errors LocalCommandRunner attaches stdout to the error + // object; merge it so _writeLogOutput can persist it. + const combined = output || (error ? { stdout: error.stdout || '' } : null) + _writeLogOutput(compileName, directory, combined, () => + callback(null, combined) + ) + } + ) +} + +function _buildTypstCommand(mainFile) { + // Run through a POSIX shell so stderr (where Typst writes its diagnostics) + // is merged into stdout (2>&1). LocalCommandRunner replaces $COMPILE_DIR + // before the shell sees it; the output path is relative because the shell + // CWD is already the compile directory. + const inputPath = `$COMPILE_DIR/${mainFile}` + const cmd = `quarto typst compile ${inputPath} output.pdf 2>&1` + return ['/bin/sh', '-c', cmd] +} + +function _writeLogOutput(compileName, directory, output, callback) { + const content = (output && output.stdout) || '' + if (!content) return callback() + // Write to output.log so the PDF-preview log panel picks it up. Typst's + // `error:`/`warning:` + `┌─ file:line:col` diagnostics are understood by the + // Quarto/Typst log parser on the web side. + const logFile = Path.join(directory, 'output.log') + fs.unlink(logFile, () => { + fs.writeFile(logFile, content, { flag: 'wx' }, err => { + if (err) { + logger.error({ err, compileName, logFile }, 'error writing typst log') + } + callback() + }) + }) +} + +function isRunning(compileName) { + return ProcessTable[compileName] != null +} + +function killTypst(compileName, callback) { + logger.debug({ compileName }, 'killing running typst compile') + if (!isRunning(compileName)) { + logger.warn({ compileName }, 'no such compile to kill') + return callback(null) + } + CommandRunner.kill(ProcessTable[compileName], callback) +} + +export default { + isRunning, + runTypst, + killTypst, + promises: { + runTypst: promisify(runTypst), + killTypst: promisify(killTypst), + }, +} diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 36e9bdbb57..8ec4a0b2b4 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -344,6 +344,14 @@ const _ProjectController = { 'quarto' ) break + case 'example_typst': + project = await ProjectCreationHandler.promises.createExampleProject( + userId, + projectName, + {}, + 'typst' + ) + break case 'blank_latex': project = await ProjectCreationHandler.promises.createBasicProject( userId, @@ -351,6 +359,13 @@ const _ProjectController = { 'latex' ) break + case 'blank_typst': + project = await ProjectCreationHandler.promises.createBasicProject( + userId, + projectName, + 'typst' + ) + break case 'blank_quarto': case 'none': default: diff --git a/services/web/app/src/Features/Project/ProjectCreationHandler.mjs b/services/web/app/src/Features/Project/ProjectCreationHandler.mjs index e11d433434..a716c1c575 100644 --- a/services/web/app/src/Features/Project/ProjectCreationHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectCreationHandler.mjs @@ -88,13 +88,23 @@ async function createProjectFromSnippet(ownerId, projectName, docLines) { async function createBasicProject(ownerId, projectName, flavour = 'quarto') { const project = await _createBlankProject(ownerId, projectName) - // Verso compiles .qmd with Quarto and .tex with latexmk; the root file's - // extension selects the runner (see CompileManager._isQuartoFile in CLSI), + // Verso compiles .qmd with Quarto, .tex with latexmk and .typ with Typst; + // the root file's extension selects the runner (see CompileManager in CLSI), // so a blank project's flavour is just a choice of template + root name. - const { templateName, rootDocName } = - flavour === 'latex' - ? { templateName: 'mainbasic.tex', rootDocName: 'main.tex' } - : { templateName: 'mainbasic.qmd', rootDocName: 'main.qmd' } + let templateName, rootDocName + switch (flavour) { + case 'latex': + templateName = 'mainbasic.tex' + rootDocName = 'main.tex' + break + case 'typst': + templateName = 'mainbasic.typ' + rootDocName = 'main.typ' + break + default: + templateName = 'mainbasic.qmd' + rootDocName = 'main.qmd' + } const docLines = await _buildTemplate(templateName, ownerId, projectName) await _createRootDoc(project, ownerId, docLines, rootDocName) @@ -179,12 +189,20 @@ async function createExampleProject( ) { const project = await _createBlankProject(ownerId, projectName, attributes) - const { fileEntries, docEntries } = - flavour === 'quarto' - ? await _addQuartoExampleProjectFiles(ownerId, projectName, project) - : await _addExampleProjectFiles(ownerId, projectName, project) + let result + switch (flavour) { + case 'quarto': + result = await _addQuartoExampleProjectFiles(ownerId, projectName, project) + break + case 'typst': + result = await _addTypstExampleProjectFiles(ownerId, projectName, project) + break + default: + result = await _addExampleProjectFiles(ownerId, projectName, project) + } + const { fileEntries, docEntries } = result - if (flavour !== 'quarto') { + if (flavour === 'latex') { // clsi-cache warming keys on a single static example; only do it for the // long-standing LaTeX example to avoid the "content is not static" guard. await populateClsiCacheForExampleProject( @@ -285,6 +303,40 @@ async function _addQuartoExampleProjectFiles(ownerId, projectName, project) { } } +async function _addTypstExampleProjectFiles(ownerId, projectName, project) { + const mainDocLines = await _buildTemplate( + 'example-project-typst/main.typ', + ownerId, + projectName + ) + const rootDoc = await _createRootDoc( + project, + ownerId, + mainDocLines, + 'main.typ' + ) + + const imagePath = path.join( + import.meta.dirname, + '/../../../templates/project_files/example-project-typst/frog.jpg' + ) + const { fileRef } = await ProjectEntityUpdateHandler.promises.addFile( + project._id, + project.rootFolder[0]._id, + 'frog.jpg', + imagePath, + null, + ownerId, + null + ) + return { + fileEntries: [{ path: fileRef.name, file: fileRef }], + docEntries: [ + { path: 'main.typ', doc: rootDoc, docLines: mainDocLines.join('\n') }, + ], + } +} + async function _createBlankProject( ownerId, projectName, diff --git a/services/web/app/templates/project_files/example-project-typst/frog.jpg b/services/web/app/templates/project_files/example-project-typst/frog.jpg new file mode 100644 index 0000000000..5b889ef3cf Binary files /dev/null and b/services/web/app/templates/project_files/example-project-typst/frog.jpg differ diff --git a/services/web/app/templates/project_files/example-project-typst/main.typ b/services/web/app/templates/project_files/example-project-typst/main.typ new file mode 100644 index 0000000000..a271c26873 --- /dev/null +++ b/services/web/app/templates/project_files/example-project-typst/main.typ @@ -0,0 +1,57 @@ +#set document( + title: "<%= project_name %>", + author: "<%= user.first_name %> <%= user.last_name %>", +) +#set page(numbering: "1") +#set heading(numbering: "1.1") +#set par(justify: true) + +#align(center)[ + #text(size: 22pt, weight: "bold")[<%= project_name %>] \ + #v(0.4em) + <%= user.first_name %> <%= user.last_name %> · <%= month %> <%= year %> +] + += Introduction + +Welcome to *Typst* in Verso. Typst is a modern typesetting system that +compiles in milliseconds — edit `main.typ` and hit *Recompile* to see the PDF +update. This example shows off math, figures, tables and lists. + += Mathematics + +Inline math such as $e^(i pi) + 1 = 0$ sits naturally in the text, while block +equations are centred and can be numbered: + +$ integral_(-oo)^(oo) e^(-x^2) dif x = sqrt(pi) $ + += Figures + +Images stored in the project are included with the `image` function: + +#figure( + image("frog.jpg", width: 50%), + caption: [A friendly frog, loaded from a project file.], +) + += Tables + +#figure( + table( + columns: 3, + align: (left, center, left), + [*Engine*], [*Output*], [*Best for*], + [Typst], [PDF], [Fast, modern layout], + [Quarto], [PDF / HTML], [Reproducible documents], + [LaTeX], [PDF], [Classic STEM articles], + ), + caption: [Verso compiles three source formats side by side.], +) + += Lists + +- Write documents in concise, readable markup +- Get near-instant PDF output +- Script your layout with a real programming language + +Now make this document your own! diff --git a/services/web/app/templates/project_files/mainbasic.typ b/services/web/app/templates/project_files/mainbasic.typ new file mode 100644 index 0000000000..20dc28a8bc --- /dev/null +++ b/services/web/app/templates/project_files/mainbasic.typ @@ -0,0 +1,14 @@ +#set document( + title: "<%= project_name %>", + author: "<%= user.first_name %> <%= user.last_name %>", +) +#set page(numbering: "1") +#set heading(numbering: "1.1") + +#align(center)[ + #text(size: 20pt, weight: "bold")[<%= project_name %>] \ + #v(0.4em) + <%= user.first_name %> <%= user.last_name %> · <%= month %> <%= year %> +] + += Introduction diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index a05be47a05..c598847c83 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -55,6 +55,7 @@ const defaultTextExtensions = [ 'ldf', 'rmd', 'qmd', + 'typ', 'lua', 'py', 'gv', @@ -115,7 +116,14 @@ const httpPermissionsPolicy = { }, } -const safeCompilers = ['quarto', 'xelatex', 'pdflatex', 'latex', 'lualatex'] +const safeCompilers = [ + 'quarto', + 'typst', + 'xelatex', + 'pdflatex', + 'latex', + 'lualatex', +] module.exports = { env: 'server-ce', @@ -889,7 +897,7 @@ module.exports = { process.env.FILE_IGNORE_PATTERN || '**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}', - validRootDocExtensions: ['qmd', 'tex', 'Rtex', 'ltx', 'Rnw'], + validRootDocExtensions: ['qmd', 'typ', 'tex', 'Rtex', 'ltx', 'Rnw'], emailConfirmationDisabled: process.env.EMAIL_CONFIRMATION_DISABLED === 'true' || false, diff --git a/services/web/frontend/js/features/pdf-preview/util/output-files.ts b/services/web/frontend/js/features/pdf-preview/util/output-files.ts index 9acc0b0d82..a27aaca74b 100644 --- a/services/web/frontend/js/features/pdf-preview/util/output-files.ts +++ b/services/web/frontend/js/features/pdf-preview/util/output-files.ts @@ -130,11 +130,11 @@ export async function handleLogFiles( MAX_LOG_SIZE ) try { - // Quarto compiles (.qmd/.md/.Rmd, dispatched to QuartoRunner in CLSI by - // root-file extension) produce Typst/Pandoc/Quarto diagnostics that the - // LaTeX log parser does not understand. Route those to a dedicated parser - // so their errors and warnings populate the log tabs like LaTeX ones. - if (isQuartoCompile(data)) { + // Quarto (.qmd/.md/.Rmd) and bare Typst (.typ) compiles produce + // Typst/Pandoc/Quarto diagnostics that the LaTeX log parser does not + // understand. Route those to a dedicated parser so their errors and + // warnings populate the log tabs like LaTeX ones. + if (usesQuartoLogParser(data)) { const { errors, warnings, typesetting } = parseQuartoLog(result.log) accumulateResults({ errors, warnings, typesetting }) } else { @@ -301,12 +301,13 @@ function isTransientWarning(warning: LatexLogEntry): boolean { return TRANSIENT_WARNING_REGEX.test(warning.message || '') } -// Mirror of CompileManager._isQuartoFile in CLSI: the runner is chosen by the -// root file's extension, so we detect Quarto compiles the same way client-side. -const QUARTO_ROOT_REGEX = /\.(qmd|md|rmd)$/i +// Mirrors CompileManager's runner dispatch in CLSI: both the Quarto runner +// (.qmd/.md/.Rmd) and the Typst runner (.typ) emit Typst-style diagnostics, so +// we pick the Quarto/Typst log parser for either, keyed on the root extension. +const QUARTO_TYPST_ROOT_REGEX = /\.(qmd|md|rmd|typ)$/i -function isQuartoCompile(data: CompileResponseData): boolean { - return QUARTO_ROOT_REGEX.test(data.options?.rootResourcePath || '') +function usesQuartoLogParser(data: CompileResponseData): boolean { + return QUARTO_TYPST_ROOT_REGEX.test(data.options?.rootResourcePath || '') } async function fetchFileWithSizeLimit( diff --git a/services/web/frontend/js/features/project-list/components/new-project-button.tsx b/services/web/frontend/js/features/project-list/components/new-project-button.tsx index 46af4164de..16ce3a7b27 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button.tsx @@ -212,6 +212,18 @@ function NewProjectButton({ {t('blank_latex_project')} +