diff --git a/README.md b/README.md index 94d4ffc182..6b965926af 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Verso -**A collaborative real-time editor for Quarto presentations and documents.** +**A collaborative real-time editor for Quarto and LaTeX presentations and documents.** -Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) adapted to compile [Quarto](https://quarto.org) documents instead of LaTeX. It keeps Overleaf's real-time collaboration infrastructure while replacing the LaTeX/CLSI compiler with Quarto, enabling collaborative editing of `.qmd` files with instant PDF (via Typst) or HTML (RevealJS) preview. +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. --- ## Features - **Real-time collaboration** — multiple users editing the same `.qmd` file simultaneously, powered by Overleaf's operational-transformation engine -- **Quarto compilation** — documents are compiled with Quarto on every save +- **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 - **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 @@ -44,7 +44,7 @@ Then open `http://localhost` in your browser. On first run, visit `/launchpad` t ### Build from source ```bash -# Build the base image (installs Quarto) +# Build the base image (installs Quarto + TeX Live) cd server-ce make build-base @@ -56,7 +56,7 @@ The two Dockerfiles are: | File | Purpose | |------|---------| -| `server-ce/Dockerfile-base` | Base OS image — installs system deps and Quarto | +| `server-ce/Dockerfile-base` | Base OS image — installs system deps, Quarto, and a TeX Live (`latexmk`) toolchain | | `server-ce/Dockerfile` | Application image — installs Node services, compiles frontend | ## Architecture @@ -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) → output files → nginx → browser +web → CLSI (quarto render / latexmk) → 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` | Quarto compiler — runs `quarto render` and serves output | +| `clsi` | Compiler — runs `quarto render` (`.qmd`) or `latexmk` (`.tex`) and serves output | | `docstore` | Document text storage (MongoDB) | | `filestore` | Binary file storage (S3 or local) | | `project-history` | Change history and version tracking | @@ -111,6 +111,18 @@ For PDF output, change `format: revealjs` to `format: typst`. > **Note on display math**: put `$$...$$` on a single line. > Multi-line display math blocks can cause YAML parse errors in some Quarto versions. +## Writing a LaTeX document + +LaTeX still works exactly as in Overleaf. A project whose root file is a `.tex` +file (e.g. `main.tex`) compiles with `latexmk`/TeX Live instead of Quarto — the +engine is chosen from the root file's extension, no setting required. The +**Example LaTeX project** in the *New project* menu is a ready-made starting +point; **Blank LaTeX project** gives you an empty `main.tex`. + +> The bundled TeX Live is a minimal `scheme-basic` install. Documents needing +> 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. + ## Environment variables Verso inherits all of Overleaf's environment variables (prefixed `OVERLEAF_`). diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 955c2aea23..36e9bdbb57 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -323,12 +323,44 @@ const _ProjectController = { req.body.projectName != null ? req.body.projectName.trim() : undefined const { template } = req.body - const project = await (template === 'example' - ? ProjectCreationHandler.promises.createExampleProject( + // The "template" selects flavour (Quarto vs LaTeX) and kind (blank vs + // example). 'example'/'none' are kept for backwards compatibility. + let project + switch (template) { + case 'example': + case 'example_latex': + project = await ProjectCreationHandler.promises.createExampleProject( userId, - projectName + projectName, + {}, + 'latex' ) - : ProjectCreationHandler.promises.createBasicProject(userId, projectName)) + break + case 'example_quarto': + project = await ProjectCreationHandler.promises.createExampleProject( + userId, + projectName, + {}, + 'quarto' + ) + break + case 'blank_latex': + project = await ProjectCreationHandler.promises.createBasicProject( + userId, + projectName, + 'latex' + ) + break + case 'blank_quarto': + case 'none': + default: + project = await ProjectCreationHandler.promises.createBasicProject( + userId, + projectName, + 'quarto' + ) + break + } ProjectAuditLogHandler.addEntryIfManagedInBackground( project._id, diff --git a/services/web/app/src/Features/Project/ProjectCreationHandler.mjs b/services/web/app/src/Features/Project/ProjectCreationHandler.mjs index 94b9f68381..e11d433434 100644 --- a/services/web/app/src/Features/Project/ProjectCreationHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectCreationHandler.mjs @@ -85,11 +85,19 @@ async function createProjectFromSnippet(ownerId, projectName, docLines) { return project } -async function createBasicProject(ownerId, projectName) { +async function createBasicProject(ownerId, projectName, flavour = 'quarto') { const project = await _createBlankProject(ownerId, projectName) - const docLines = await _buildTemplate('mainbasic.qmd', ownerId, projectName) - await _createRootDoc(project, ownerId, docLines) + // Verso compiles .qmd with Quarto and .tex with latexmk; the root file's + // extension selects the runner (see CompileManager._isQuartoFile 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' } + + const docLines = await _buildTemplate(templateName, ownerId, projectName) + await _createRootDoc(project, ownerId, docLines, rootDocName) AnalyticsManager.recordEventForUserInBackground(ownerId, 'project-created', { projectId: project._id, @@ -163,20 +171,29 @@ async function populateClsiCacheForExampleProject( return projectId } -async function createExampleProject(ownerId, projectName, attributes = {}) { +async function createExampleProject( + ownerId, + projectName, + attributes = {}, + flavour = 'latex' +) { const project = await _createBlankProject(ownerId, projectName, attributes) - const { fileEntries, docEntries } = await _addExampleProjectFiles( - ownerId, - projectName, - project - ) - await populateClsiCacheForExampleProject( - ownerId, - project, - fileEntries, - docEntries - ) + const { fileEntries, docEntries } = + flavour === 'quarto' + ? await _addQuartoExampleProjectFiles(ownerId, projectName, project) + : await _addExampleProjectFiles(ownerId, projectName, project) + + if (flavour !== 'quarto') { + // 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( + ownerId, + project, + fileEntries, + docEntries + ) + } AnalyticsManager.recordEventForUserInBackground(ownerId, 'project-created', { projectId: project._id, @@ -191,7 +208,12 @@ async function _addExampleProjectFiles(ownerId, projectName, project) { ownerId, projectName ) - const rootDoc = await _createRootDoc(project, ownerId, mainDocLines) + const rootDoc = await _createRootDoc( + project, + ownerId, + mainDocLines, + 'main.tex' + ) const bibDocLines = await _buildTemplate( `${templateProjectDir}/sample.bib`, @@ -229,6 +251,40 @@ async function _addExampleProjectFiles(ownerId, projectName, project) { } } +async function _addQuartoExampleProjectFiles(ownerId, projectName, project) { + const mainDocLines = await _buildTemplate( + 'example-project-quarto/main.qmd', + ownerId, + projectName + ) + const rootDoc = await _createRootDoc( + project, + ownerId, + mainDocLines, + 'main.qmd' + ) + + const imagePath = path.join( + import.meta.dirname, + '/../../../templates/project_files/example-project-quarto/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.qmd', doc: rootDoc, docLines: mainDocLines.join('\n') }, + ], + } +} + async function _createBlankProject( ownerId, projectName, @@ -299,12 +355,17 @@ async function _createBlankProject( return project } -async function _createRootDoc(project, ownerId, docLines) { +async function _createRootDoc( + project, + ownerId, + docLines, + rootDocName = 'main.qmd' +) { try { const { doc } = await ProjectEntityUpdateHandler.promises.addDoc( project._id, project.rootFolder[0]._id, - 'main.qmd', + rootDocName, docLines, ownerId, null diff --git a/services/web/app/templates/project_files/example-project-quarto/frog.jpg b/services/web/app/templates/project_files/example-project-quarto/frog.jpg new file mode 100644 index 0000000000..5b889ef3cf Binary files /dev/null and b/services/web/app/templates/project_files/example-project-quarto/frog.jpg differ diff --git a/services/web/app/templates/project_files/example-project-quarto/main.qmd b/services/web/app/templates/project_files/example-project-quarto/main.qmd new file mode 100644 index 0000000000..e1c5a9621d --- /dev/null +++ b/services/web/app/templates/project_files/example-project-quarto/main.qmd @@ -0,0 +1,84 @@ +--- +title: "<%= project_name %>" +author: "<%= user.first_name %> <%= user.last_name %>" +date: "<%= month %> <%= year %>" +format: + revealjs: + theme: simple + slide-number: true + chalkboard: true +--- + +## Welcome to Verso + +This is an example **presentation** written in [Quarto](https://quarto.org) +and rendered to interactive HTML slides with Reveal.js. + +- Write your slides in plain Markdown +- Mix in math, code, images and tables +- Hit **Recompile** to see your changes instantly + +::: aside +Use the arrow keys to navigate, and press `f` for fullscreen. +::: + +## Mathematics + +Inline math such as $E = mc^2$ renders inline, and display equations are +centered automatically: + +$$ +\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} +$$ + +. . . + +You can reveal content step by step by separating it with `. . .`. + +## Two columns + +:::: {.columns} + +::: {.column width="55%"} +### Figures sit nicely beside text + +Fenced columns let you place an explanation next to a figure — perfect for +walking an audience through a diagram. +::: + +::: {.column width="45%"} +![A friendly frog](frog.jpg) +::: + +:::: + +## Tables + +| Engine | Output | Best for | +|--------|:------:|-----------------------| +| Typst | PDF | Fast, modern layout | +| Reveal | HTML | Interactive slides | +| LaTeX | PDF | Classic STEM articles | + +: Quarto can target several output formats from one source. {.striped .hover} + +## Code + +Code blocks are syntax-highlighted out of the box: + +```python +def greet(name): + return f"Hello, {name}!" + +print(greet("Verso")) +``` + +## Incremental lists {.incremental} + +- Quarto turns Markdown into beautiful documents +- The same source can become a PDF, a website or these slides +- Now make this deck your own — edit `main.qmd`! + +## Thank you {.center} + +Questions? Start editing and explore what Quarto can do. 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 6f34d3049e..46af4164de 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 @@ -192,24 +192,48 @@ function NewProjectButton({ handleModalMenuClick(e, { - modalVariant: 'blank_project', - dropdownMenuEvent: 'blank-project', + modalVariant: 'blank_quarto', + dropdownMenuEvent: 'blank-project-quarto', }) } > - {t('blank_project')} + {t('blank_quarto_project')}
  • handleModalMenuClick(e, { - modalVariant: 'example_project', - dropdownMenuEvent: 'example-project', + modalVariant: 'blank_latex', + dropdownMenuEvent: 'blank-project-latex', }) } > - {t('example_project')} + {t('blank_latex_project')} + +
  • +
  • + + handleModalMenuClick(e, { + modalVariant: 'example_quarto', + dropdownMenuEvent: 'example-project-quarto', + }) + } + > + {t('example_quarto_project')} + +
  • +
  • + + handleModalMenuClick(e, { + modalVariant: 'example_latex', + dropdownMenuEvent: 'example-project-latex', + }) + } + > + {t('example_latex_project')}
  • diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx index dd1ae3ca0e..6224ffaaeb 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx @@ -5,9 +5,14 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types' type BlankProjectModalProps = { onHide: () => void initialTags?: Tag[] + template?: string } -function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) { +function BlankProjectModal({ + onHide, + initialTags, + template = 'blank_quarto', +}: BlankProjectModalProps) { return ( - + ) } diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx index 3989883b6b..26f4718c00 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx @@ -5,11 +5,13 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types' type ExampleProjectModalProps = { onHide: () => void initialTags?: Tag[] + template?: string } function ExampleProjectModal({ onHide, initialTags, + template = 'example_latex', }: ExampleProjectModalProps) { return ( diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx index 6f9f273a3a..b428b5b534 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx @@ -11,8 +11,10 @@ const UploadProjectModal = lazy(() => import('./upload-project-modal')) const ImportDocumentModal = lazy(() => import('./import-document-modal')) export type NewProjectButtonModalVariant = - | 'blank_project' - | 'example_project' + | 'blank_quarto' + | 'blank_latex' + | 'example_quarto' + | 'example_latex' | 'upload_project' | 'import_from_github' | 'import_docx' @@ -50,10 +52,38 @@ function NewProjectButtonModal({ ) switch (modal) { - case 'blank_project': - return - case 'example_project': - return + case 'blank_quarto': + return ( + + ) + case 'blank_latex': + return ( + + ) + case 'example_quarto': + return ( + + ) + case 'example_latex': + return ( + + ) case 'upload_project': return ( }> diff --git a/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-create-new-project-dropdown.tsx b/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-create-new-project-dropdown.tsx index 7c3b0e9573..8df1c737ca 100644 --- a/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-create-new-project-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-create-new-project-dropdown.tsx @@ -113,22 +113,56 @@ function WelcomeMessageCreateNewProjectDropdown({ - handleDropdownItemClick(e, 'blank_project', 'blank-project') + handleDropdownItemClick( + e, + 'blank_quarto', + 'blank-project-quarto' + ) } tabIndex={-1} > - {t('blank_project')} + {t('blank_quarto_project')}
  • - handleDropdownItemClick(e, 'example_project', 'example-project') + handleDropdownItemClick(e, 'blank_latex', 'blank-project-latex') } tabIndex={-1} > - {t('example_project')} + {t('blank_latex_project')} + +
  • +
  • + + handleDropdownItemClick( + e, + 'example_quarto', + 'example-project-quarto' + ) + } + tabIndex={-1} + > + {t('example_quarto_project')} + +
  • +
  • + + handleDropdownItemClick( + e, + 'example_latex', + 'example-project-latex' + ) + } + tabIndex={-1} + > + {t('example_latex_project')}
  • diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 76b16e1213..4e00f8f576 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -292,7 +292,9 @@ "billing": "Billing", "billing_period_sentence_case": "Billing period", "binary_history_error": "Preview not available for this file type", + "blank_latex_project": "Blank LaTeX project", "blank_project": "Blank project", + "blank_quarto_project": "Blank Quarto project", "blocked_filename": "This file name is blocked.", "blog": "Blog", "bold": "Bold", @@ -837,7 +839,9 @@ "everything_in_group_standard_plus": "Everything in Standard group, plus…", "everything_in_standard_plus": "Everything in Standard, plus…", "example": "Example", + "example_latex_project": "Example LaTeX project", "example_project": "Example project", + "example_quarto_project": "Example Quarto project", "examples": "Examples", "examples_lowercase": "examples", "examples_to_help_you_learn": "Examples to help you learn how to use powerful LaTeX packages and techniques.",