100 Commits

Author SHA1 Message Date
claude c249d6a6e9 Prod: load SMTP env via envFrom secretRef (flat, paste-proof)
Build and Deploy Verso / deploy (push) Successful in 1m19s
Build and Deploy Verso (prod) / deploy (push) Successful in 1m9s
Replace the six nested secretKeyRef env entries with a single
'envFrom: - secretRef: { name: verso-smtp, optional: true }' in both the
standalone app manifest and the prod workflow. Avoids the deep nesting that
tripped strict server-side decoding, and is simpler to edit. The secret's keys
must now be named exactly like the env vars (OVERLEAF_EMAIL_*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:40:07 +00:00
claude 2d8f23509a Prod: standalone Deployment/Service bootstrap files; drop namespace create
Build and Deploy Verso / deploy (push) Successful in 1m22s
- Add server-ce/k8s/verso-prod-data.yaml (Mongo + Redis) and
  verso-prod-app.yaml (Verso app), mirroring the workflow so the verso
  namespace can be bootstrapped/validated by hand.
- Drop 'kubectl create namespace verso' from the prod workflow (namespace is
  pre-created), so the runner only needs namespaced rights in verso, matching
  the test namespace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:03:02 +00:00
claude 0f640c74b2 Prod: provision PVCs out of band (storageClass under operator control)
Build and Deploy Verso / deploy (push) Successful in 1m18s
- Add server-ce/k8s/verso-prod-pvcs.yaml (mongo-data/redis-data/verso-data,
  ReadWriteOnce, storageClassName left for the operator to set — use a Ceph RBD
  block class).
- Drop the inline PVC definitions from deploy-verso-prod.yml so it won't fight
  the operator-provisioned PVCs; the deploy now assumes they already exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:36:41 +00:00
claude 54ccb3d712 Add prod deploy workflow (verso namespace, persistent, friends-only)
Build and Deploy Verso / deploy (push) Successful in 1m20s
New .gitea/workflows/deploy-verso-prod.yml triggered by pushes to the 'prod'
branch — a real production target distinct from the ephemeral test rig:

- Runs in the 'verso' namespace; Mongo/Redis/app-data on PersistentVolumeClaims,
  applied idempotently and NEVER deleted (data survives deploys).
- Replica set initialised only once; admin created only if no users exist.
- Builds/pushes verso:stable (separate tag from test's verso:latest);
  imagePullPolicy Always so each rollout pulls the new build.
- SMTP via an optional 'verso-smtp' Secret (no credentials in the repo);
  anonymous read-write sharing left off and public registration off
  (friends-only).
- Example Ingress for verso.alocoq.fr at server-ce/k8s/verso-prod-ingress.example.yaml
  (apply by hand to match the existing TLS/annotation setup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:21:33 +00:00
claude 35fa7cec05 README logo + dates; capitalize Alpha in instance title
Build and Deploy Verso / deploy (push) Successful in 1m21s
- README: show the Verso wordmark logo instead of a text title.
- README: original Overleaf copyright now 2014-2026; Verso modifications 2026.
- Instance/version title: 'alpha' -> 'Alpha'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:01:23 +00:00
claude 2385166213 Dashboard footer fix, larger version text, rewrite README
Build and Deploy Verso / deploy (push) Successful in 9m47s
- Fix the projects dashboard footer needing a scroll to reach: the main area
  used min-height: 100% which always pushed the footer a full screen down.
  Lay the content out as a flex column with main growing (flex: 1 0 auto), so
  the footer sticks to the bottom of the viewport when the list is short.
- Bump the instance-name/version text to ~33px ('7.5', between font-size-07
  and -08).
- Rewrite README to match the current triple-compiler product (Quarto + LaTeX
  + Typst), the editor language support, format badge, publishing flow and
  Python venv option; drop the stale 'Quarto-only / TeX Live removed' notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:31:49 +00:00
claude fddb141d19 Polish: smaller version text, bigger loader logo, keep RevealJS slide on recompile
Build and Deploy Verso / deploy (push) Successful in 9m35s
- Reduce the dashboard instance-name/version font size (07 -> 06).
- Enlarge the Verso logo in the loading animation (160px -> 240px).
- Preserve the current RevealJS slide across recompiles: capture the deck's
  URL hash (same-origin) and re-append it to the iframe src so the new build
  reopens on the same slide instead of jumping to the start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:10:33 +00:00
claude 8272d6de88 Editor/dashboard polish: PDF publish, Typst outline, bigger branding
Build and Deploy Verso / deploy (push) Successful in 9m29s
- Hide the Present button when the current output is a PDF (it only makes
  sense for HTML/RevealJS decks).
- Publish now supports PDF projects: snapshot output.pdf and serve it inline
  via a small index.html wrapper at /p/:token, so link holders can view the
  PDF straight from the published version.
- Add a Typst document outline (scans '=' headings) wired into the file
  outline panel.
- Dashboard branding: enlarge the instance-name/version text and let the
  sidebar Verso wordmark span the full column width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:33:56 +00:00
claude 4fc86ebd3d Editor: qmd/typst autocomplete, format column, compiler gating, Verso loader
Build and Deploy Verso / deploy (push) Successful in 9m48s
- Add a Typst language (stream highlighting + completions) for .typ, and
  Quarto completions (code chunks, callouts, cross-refs) for .qmd/markdown.
- Project dashboard: new Format column (Quarto/Typst/LaTeX) from the cheap
  project compiler field, surfaced through the projects list API.
- Compiler dropdown: grey out engines that don't match the root file's
  extension (.qmd->Quarto, .typ->Typst, .tex->LaTeX engines).
- Replace the Overleaf fill loader with an animated Verso logo: the four
  quadrant circles drift on their own orbits while colour warms up with load
  progress; reused on the token-access screen too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:47:31 +00:00
claude 12cabd1d1b Branding: build-number version, EB Garamond title, blue filters, Present button
Build and Deploy Verso / deploy (push) Successful in 9m21s
- Instance name: stamp the nav title with the build number at deploy time
  ("Verso V0.<run> alpha") via a sed placeholder fed by GITHUB_RUN_NUMBER,
  instead of the static "Verso V1.0 Alpha".

- Title typeface: self-host the EB Garamond latin subset (same one embedded in
  the logo SVGs) and apply it to .navbar-title so the instance name matches the
  Verso wordmark.

- Sidebar wordmark: let the logo fill the full sidebar column width (drop the
  160px cap).

- Project filters: switch the ds-nav active state (filter selection + theme
  toggle) from the green tokens to the blue scale, matching the rail.

- Present button: rename the presentation toolbar action from "Preview" to
  "Present" / "Présenter" and add a tooltip explaining it publishes the
  presentation and opens it in a new tab. New keys present /
  present_publishes_and_opens_in_new_tab in en, fr and extracted-translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:32:07 +00:00
claude 676663ffcc Branding polish: blue rail accent, drop fork link, bigger login logo, dashboard logo placement
Build and Deploy Verso / deploy (push) Successful in 9m41s
- Editor rail: the active item used the Overleaf green accent. Switch
  --ide-rail-link-active-color/background to the blue scale (--blue-10/70,
  --bg-info-03) to match the Verso palette.

- Footers: remove the default "Fork on GitHub!" right_footer item (redundant
  with the "Built on Overleaf" link); right_footer now defaults to [].

- Login: move the hero wordmark into a full-width centered block and bump it to
  max-width 480px so it's no longer constrained by the form column.

- Projects dashboard: restore the instance name in the top-left navbar (set
  OVERLEAF_NAV_TITLE="Verso V1.0 Alpha") instead of the wordmark logo, and move
  the full Verso wordmark to the sidebar's lower section (where the old
  "Digital Science" mark sat). Revert HeaderLogoOrTitle to its title-first
  behaviour now that the dashboard no longer passes a logo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:28:50 +00:00
claude 7d5deebfce Fix Python-packages toolbar icon: add deployed_code to unfilled font subset
Build and Deploy Verso / deploy (push) Successful in 9m35s
The file-tree "Python packages" button rendered the literal text
"deployed_code" in the icon font because that glyph was missing from the
outlined/unfilled Material Symbols subset (MaterialSymbolsRoundedUnfilledPartialSlice.woff2),
so the ligature never resolved. The toolbar buttons all use the unfilled
variant, so switching this one to the full filled font would look inconsistent.

Add 'deployed_code' to unfilled-symbols.mjs and regenerate the subset woff2
(same Google Fonts request build-unfilled.mjs makes) so the box/package icon
renders, matching the other outlined toolbar icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:06:55 +00:00
claude 7a50f42e02 Restore dashboard footer; show navbar logo over title; enlarge login logo
Build and Deploy Verso / deploy (push) Successful in 9m46s
Three follow-ups after the visual-identity deploy:

- Footer: restore the React <Footer> on the projects dashboard (both
  ProjectListDsNav and the legacy DefaultNavbarAndFooter). Removing it earlier
  was an overcorrection — it now renders the Verso/AGPL thin footer rather than
  the old "Powered by Overleaf" line. Other pages already kept the pug footer.

- Navbar brand: HeaderLogoOrTitle previously hid the logo whenever a nav title
  was set, so on the dashboard only the "Verso" instance-name text showed and
  the wired-up Verso logo never appeared. Make a configured logo (custom logo
  or the Verso brand logo) take precedence over the title text; fall back to the
  title only when no logo is provided (unchanged for other navbars).

- Login: enlarge the hero wordmark (max-width 260px -> 380px, full column width).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:41:18 +00:00
claude 38edd5269c CI: retry Yarn Classic dep packing on transient esbuild fetch corruption
Build and Deploy Verso / deploy (push) Successful in 11m5s
Build #78 failed in the compile step while Yarn Classic prepared the
@replit/codemirror-* git deps: fetching esbuild's per-platform binaries
returned truncated tarballs ("the file appears to be corrupt" / missing
.yarn-tarball.tgz). The tmpfs classic cache is fresh each build, so there is no
stale entry to blame and nothing to fall back to — it is a transient download
failure (builds #75-77 passed with an identical Dockerfile).

Wrap both the install and compile steps in a 3-attempt retry loop that wipes
the Yarn Classic cache (/usr/local/share/.cache/yarn) and re-fetches before
giving up, dumping pack.log on final failure. The persistent Berry cache and
YARN_NETWORK_CONCURRENCY=1 are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:03:51 +00:00
claude 2eccfe7f75 Add Verso visual identity (logos + favicon)
Build and Deploy Verso / deploy (push) Has been cancelled
Introduce the Verso brand marks as self-contained SVGs with the EB Garamond
latin subset embedded as a base64 @font-face, so they render identically in
every context (favicon, CSS background, <img>, inline) with no runtime Google
Fonts dependency — important for the self-hosted alpha. Falls back to Georgia
serif if a browser ignores SVG-embedded fonts.

Assets:
- verso-square.svg  — rounded "V" tile (200×200); used as favicon.svg and the
  editor top-left toolbar logo.
- verso-logo.svg / verso-logo-dark.svg — wide "verso · ONLINE EDITOR" wordmark
  (760×200), light + dark wordmark variants.

Wiring:
- favicon: public/favicon.svg replaced with the square mark.
- editor toolbar: --redesign-toolbar-logo-url (light + dark) -> verso-square.svg.
- projects dashboard navbar: ProjectListDsNav logo -> verso-logo(.dark), with
  --navbar-brand-width widened to 200px to fit the wide wordmark.
- login page: centered Verso wordmark above the form; suppress the top navbar
  so the hero logo stands alone (no competing Overleaf mark).

PNG favicons / apple-touch-icon are left as-is (no raster tooling available);
modern browsers use the SVG favicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:43:36 +00:00
claude 7670982f60 Dashboard: drop footer + Digital Science branding; Verso-ify React thin footer
Build and Deploy Verso / deploy (push) Successful in 9m47s
The project dashboard ("main menu") rendered the React <Footer> (ThinFooter)
at the bottom of the page, which forced a page-level scrollbar just to reach a
stale "© 2025 Powered by Overleaf" line. Remove <Footer> from both dashboard
variants (ProjectListDsNav and the legacy DefaultNavbarAndFooter) so the main
menu has no footer and no useless scroll. The login/auth pages keep the Verso
attribution footer (thin-footer.pug), which already satisfies the AGPL source
link requirement.

Also remove the hardcoded "Digital Science" marking from the dashboard sidebar
lower section, and update the React ThinFooter (used by other React pages such
as settings/subscription) to match the pug footer: © <year> Aloïs Coquillard ·
Built on Overleaf, with AGPL licence + source-code links on the right, instead
of "Powered by Overleaf".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:13:19 +00:00
claude f50d6cb053 Footer: put Verso/AGPL attribution in the rendered thin-footer
Build and Deploy Verso / deploy (push) Successful in 9m41s
The login (and other CE) pages render layout/thin-footer, not
fat-footer-website-redesign, because showThinFooter = !hasFeature('saas')
is true in Community Edition. The earlier footer edit therefore never
showed. Replace the static "© 2025 Built on Overleaf" line with the
Verso attribution (© <year> Aloïs Coquillard · Built on Overleaf) and
add an AGPL licence + source-code link group on the right, keeping the
language picker and custom nav items intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:42:06 +00:00
claude e0a4938a78 CI: make Yarn Classic fallback cache a tmpfs (fresh per build)
Build and Deploy Verso / deploy (push) Successful in 11m9s
#74 corrupted the persistent fallback cache again despite serialising the
fetch, so the cause isn't a write race: BuildKit evicts part of that persistent
cache mount between builds (the first build after each id bump always passed,
later ones failed). Mount /usr/local/share/.cache/yarn as tmpfs so it's clean
every build and nothing can be half-evicted; the Berry cache stays persistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:15:38 +00:00
claude 2e657e51d6 Replace marketing footer with Verso attribution/AGPL footer
Swap the Overleaf marketing footer (careers, pricing, 'for universities', …)
shown on the login and other auth pages for a clean Verso footer: copyright
(Aloïs Coquillard -> alocoq.fr), 'Built on Overleaf' (-> Overleaf repo), and on
the right the AGPL licence (-> repo LICENSE) and Source code (-> the Gitea
repo). This also satisfies the AGPL source-offer on the public domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:06:27 +00:00
claude 4c13d139f6 Share modal: 'Add collaborators' heading; fix French 404 title
Build and Deploy Verso / deploy (push) Has been cancelled
Add an 'Add collaborators' heading above the email-invite section in the share
modal so it's visually distinct from the presentation-sharing section.

Add the missing French 'not_found' key so the 404 page shows 'Introuvable'
instead of the raw 'not_found' (the 404 pug template translates server-side;
the key existed in en.json but not fr.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:36:33 +00:00
claude 405c1d27c9 Bundle Python requirements into a dedicated editor; hide requirements.vrf
Add a 'Python packages' button to the file-tree toolbar that opens a modal to
edit the project's requirements.vrf (one package per line, pip syntax), backed
by GET/POST /project/:id/python-requirements (read via ProjectEntityHandler,
write via EditorController.upsertDocWithPath, write-gated). The .vrf file is now
hidden from the file tree, so it is managed only through this editor rather than
appearing as a loose file. Adds python_packages / python_packages_help i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:26:03 +00:00
claude c9727a26e4 Python deps: smart missing-package hint + switch to .vrf requirements file
Build and Deploy Verso / deploy (push) Successful in 9m46s
Option A: when a {python} cell fails with ModuleNotFoundError/ImportError, the
log now suggests the exact PyPI package to add (with a module->package map, e.g.
cv2 -> opencv-python, sklearn -> scikit-learn), names the Verso requirements
file, and notes it could instead be a local module — so the langmuirthermalstudy
case isn't mistaken for a PyPI package.

Switch the per-project requirements file from requirements.txt to a Verso-
specific requirements.vrf (so it won't be confused with arbitrary .txt files);
QuartoRunner now looks for requirements.vrf, and 'vrf' is registered as an
editable text extension. The dedicated in-UI editor (and hiding it from the
file tree) follows in a separate change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:19:01 +00:00
claude 8530c5ebe0 Run Quarto Python kernel inside the project venv, not base python
Build and Deploy Verso / deploy (push) Successful in 9m33s
The global python3 kernelspec hardcodes /usr/bin/python3, so even with
QUARTO_PYTHON pointing at the project venv, Quarto launched the kernel in the
base interpreter — packages installed into the venv (e.g. openpyxl) were not
importable. Register a python3 kernelspec inside the venv via
'ipykernel install --sys-prefix' (kernel.json argv -> the venv's python); since
Quarto runs kernel discovery through QUARTO_PYTHON, the venv's kernelspec is
found ahead of the global one and the kernel runs in the venv.

Bump the completion marker (.verso-complete -> .verso-ready) so venvs built
before this change are rebuilt with the kernelspec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:50:01 +00:00
claude 83b6b323c3 Add cv2/tqdm to base; implement per-project Python venvs (Design B, Phase 1)
Build and Deploy Verso / deploy (push) Successful in 17m0s
Base image: add opencv-python-headless (cv2) and tqdm to the bundled
scientific stack, and python3-venv (needed to build per-project venvs).

Per-project dependencies: a project's requirements.txt is now installed into a
venv cached by its sha256 (python3 -m venv --system-site-packages, so the
bundled stack stays visible and only extra packages are installed); QuartoRunner
points Quarto at it via QUARTO_PYTHON. A per-hash flock serialises concurrent
builds; pip output is merged into output.log; on failure the render falls back
to the base interpreter. Venvs live under PYTHON_VENVS_DIR
(default /var/lib/overleaf/data/python-venvs).

Gating: PythonVenvGate.userCanInstallPython restricts installs to the project
owner + invited collaborators (ignorePublicAccess excludes anonymous/link
users), threaded to CLSI as allowPythonInstall on the editor compile,
presentation export, and publish paths. Behind OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
(enabled in the deployment). Design doc updated; Phase 2 (egress policy) and
Phase 3 (venv eviction) remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:14:47 +00:00
claude 8b9fe4e760 CI: stop Yarn git-dep prepare from corrupting the shared fallback cache
Build and Deploy Verso / deploy (push) Successful in 12m50s
The web build's 'yarn install' re-prepares the git-sourced @replit/codemirror-*
deps whenever the Berry cache misses (BuildKit GCs it between builds). Each
prepare uses Yarn Classic, which pulls every esbuild platform binary into the
single shared /usr/local/share/.cache/yarn folder; running several prepares in
parallel races and corrupts it ('tar content corrupt', EEXIST, missing
.yarn-tarball.tgz). Bumping the cache id only cleared it until the next
cache-miss build (#69).

Serialise Yarn's fetch with YARN_NETWORK_CONCURRENCY=1 on the install and
compile steps so the prepares no longer write that cache concurrently, and bump
the fallback cache id (v2 -> v3) once more to discard the currently-corrupt
cache. Slightly slower fetch, but no more random cache corruption.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:20:35 +00:00
claude 654cd7db9f Fix Quarto Jupyter engine: install PyYAML
Build and Deploy Verso / deploy (push) Has been cancelled
Quarto's own jupyter wrapper (/opt/quarto/share/jupyter/jupyter.py ->
notebook.py) does 'from yaml import safe_load', so executing a {python} cell
failed with ModuleNotFoundError: No module named 'yaml'. The minimal jupyter
stack didn't pull PyYAML in (psutil/ipython already come via ipykernel), so
add pyyaml explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:39:48 +00:00
claude 51620caf8b docs: design for per-project Python dependencies (cached venv)
Captures the proposed requirements.txt -> cached virtualenv approach (keyed by
hash, --system-site-packages, QUARTO_PYTHON), its guard rails (auth gating,
egress restriction, resource caps) given anonymous write is enabled, lifecycle
(eviction, failure UX), a phased rollout, and the open decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:35:38 +00:00
claude 96fc1a90a1 Surface missing-Python-package errors clearly in the Quarto log
Build and Deploy Verso / deploy (push) Successful in 14m45s
When a {python} cell fails with ModuleNotFoundError/ImportError, the Quarto
log parser now emits an actionable error ('Python package "X" is not
installed on the server') noting which scientific packages are pre-installed,
instead of leaking an opaque traceback line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:32:08 +00:00
claude f1d827202f Bundle the scientific-Python stack in the base image
Pre-install numpy, pandas, scipy, matplotlib, seaborn, scikit-learn, sympy,
plotly and tabulate so the common data-science libraries are available to
Quarto's Python code cells out of the box. matplotlib uses the headless Agg
backend automatically in the compile environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:25:18 +00:00
claude 8691907210 Enable Python code execution in Quarto documents (install Jupyter)
Build and Deploy Verso / deploy (push) Successful in 14m1s
Quarto executes ```{python}``` cells via a Jupyter kernel, but the base image
had no Jupyter ('Jupyter: (None)') and the runtime user (www-data) couldn't
create Quarto's log dir or Jupyter's runtime dir ('Permission denied: mkdir
/var/www/.local/...').

Install the headless Jupyter execution stack (jupyter-client, nbclient,
nbformat, ipykernel) for the system python3 Quarto uses, and register a
system-wide python3 kernelspec under /usr/local/share/jupyter. Also make
/var/www/.local writable by www-data so Quarto/Jupyter can write their
runtime/log files (mirrors the existing /var/www/.cache setup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:20:22 +00:00
claude e3fb781042 CI: bust corrupted Yarn fallback build cache
Build and Deploy Verso / deploy (push) Successful in 8m0s
The web compile step failed packing the git-sourced @replit/codemirror-*
deps with 'tar content corrupt' / EEXIST / missing .yarn-tarball.tgz errors,
all under /usr/local/share/.cache/yarn/v6 — i.e. a corrupted BuildKit
fallback-cache mount (likely left half-written by an interrupted build), not
a code or dependency change. Bump the fallback cache id so BuildKit
allocates a fresh empty cache; the berry and webpack caches are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 10:07:14 +00:00
claude f2abd42969 Presentation export: progress modal + inline failure log
Build and Deploy Verso / deploy (push) Has been cancelled
The HTML/PDF export links were plain downloads that left the browser
silently spinning during the server-side render and, on failure, saved an
error page as pdf.txt/pdf.htm. Replace them with fetch-based downloads that
show a modal: a spinner with a 'this can take up to a minute' message while
compiling, and the actual compile log inline if the export fails. The user
can dismiss at any time; a stale request that finishes after dismissal no
longer reopens the modal. Adds the three i18n keys (en/fr + extracted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:57:25 +00:00
claude 7e1c2ce53a Fix standalone-HTML export: inject embed-resources into the deck frontmatter
Build and Deploy Verso / deploy (push) Has been cancelled
embed-resources cannot be enabled from the CLI: Quarto only honours it when
nested under the format, and a document's own format block fully overrides
project/CLI metadata (confirmed in Quarto docs). So --metadata embed-resources
was silently ignored and the 'standalone' HTML was the ordinary non-embedded
deck referencing a sibling _files/ dir — unstyled, no math, no images once
downloaded on its own.

For the html-standalone export, render a temporary copy of the root .qmd with
embed-resources/self-contained-math enabled and chalkboard disabled inside its
revealjs block (replacing an existing chalkboard key rather than duplicating
it), then clean the temp file up. Falls back to the original file if the deck
isn't an editable nested-revealjs document, so the export is never worse than
before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:51:52 +00:00
claude 67b27c2684 Fix PDF export Chromium launch + HTML export caching
Build and Deploy Verso / deploy (push) Successful in 7m54s
The slide-PDF export failed because the CLSI runtime user has no writable
HOME, so Chromium's crashpad couldn't create its database and the browser
died on launch ('chrome_crashpad_handler: --database is required'). Give
decktape's Chromium a fresh writable temp dir via HOME/XDG_*/--user-data-dir
(plus --disable-gpu).

The standalone-HTML export kept returning the old non-embedded file partly
because the GET response had no cache headers, so the browser served its
cached copy; add Cache-Control: no-store to both export responses. Also
switch the embed-resources flags to the long '--metadata KEY:VALUE' form
(the documented Quarto syntax) to remove any ambiguity vs the '-M' alias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:27:44 +00:00
claude 4d9adb2723 Fix presentation export: Quarto -M uses colon syntax, harden decktape
Build and Deploy Verso / deploy (push) Successful in 8m13s
The standalone-HTML export produced a non-self-contained file (no slide
CSS/JS, math or images when opened away from the server) because Quarto's
--metadata/-M flag uses KEY:VALUE (colon), not KEY=VALUE. '-M
embed-resources=true' silently registered a bogus key and left
embed-resources unset. Switch to colon syntax and also embed MathJax
(self-contained-math:true) so equations render offline.

For the slide PDF, add --disable-dev-shm-usage (the usual cause of
Chromium crashing inside a container with a small /dev/shm), and have the
export controller return the compile log as text/plain on failure so a
failed PDF export shows the real decktape/Chromium error instead of an
HTML page the browser saves as 'pdf.htm'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:11:51 +00:00
claude c38e2b8b49 Presentation download menu: standalone HTML + faithful slide PDF (decktape)
Build and Deploy Verso / deploy (push) Failing after 24m2s
In RevealJS mode the download button becomes a 2-choice menu:

- Standalone HTML: a one-off compile with embed-resources (chalkboard and other
  runtime-only plugins are dropped, since they don't survive self-containment),
  yielding a single portable .html.
- Slide PDF: render the deck, then print it with decktape (headless Chromium)
  to a faithful one-slide-per-page PDF.

Implementation:
- Dockerfile-base: install decktape + headless Chromium (open-source; deps via
  playwright install-deps for Ubuntu-Noble correctness). Base-only change.
- QuartoRunner honours options.exportMode ('html-standalone' | 'pdf-slides');
  exportMode is threaded web ClsiManager -> CLSI RequestParser -> CompileManager
  -> runner.
- New GET /project/:id/presentation-export/:format compiles in the matching
  export mode and streams the result as a download (PresentationExportController,
  reusing ClsiManager.getOutputFileStream).
- pdf-hybrid-download-button shows the dropdown when the output is output.html;
  PDF/LaTeX projects keep the single download button.
- i18n: download_as_standalone_html / download_as_pdf_slides (en + fr +
  extracted-translations.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:00:50 +00:00
claude 899879472e Default the instance to French and translate the Verso-specific strings
Build and Deploy Verso / deploy (push) Successful in 7m35s
- Deployment: set OVERLEAF_SITE_LANGUAGE=fr so the UI defaults to French.
- fr.json: add French translations for the Verso strings — blank_/example_
  {quarto,latex,typst}_project, share_compiled_presentation(_info),
  presentation_link_{members,private,public}, reset_link, and preview (which
  was missing from fr.json). Other untranslated keys keep falling back to
  English via the translations-loader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:04:42 +00:00
claude 28a578ec85 Fix untranslated UI keys (raw "snake_case" labels) + anonymous edit links
Build and Deploy Verso / deploy (push) Successful in 7m46s
The frontend bundles only the locale keys listed in
frontend/extracted-translations.json (a custom webpack translations-loader
filters en.json to that set, normally regenerated by i18next-scanner). Every
key added by hand to en.json without also adding it here renders as its raw
key — which is why "blank_quarto_project", "share_compiled_presentation", etc.
showed up literally in the New-project menu and Share dialog.

Add all introduced keys to extracted-translations.json: blank_/example_
{quarto,latex,typst}_project, share_compiled_presentation(_info),
presentation_link_{members,private,public}, reset_link.

Also enable anonymous read-AND-write share links (edit without an account) via
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING; read-only links already worked
through OVERLEAF_ALLOW_PUBLIC_ACCESS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:40:47 +00:00
claude 4766071e69 Published presentations: three access tiers + per-link reset
Build and Deploy Verso / deploy (push) Successful in 7m57s
Adds a project-members-only link tier and independent link rotation.

- Three tokens per project instead of two: publicToken (anyone), loginToken
  (any logged-in user), memberToken (only users who can read the project).
  serve() resolves the token to its tier and enforces accordingly — 'member'
  requires AuthorizationManager.canUserReadProject.
- New POST /project/:id/publish-presentation/regenerate { tier } rotates a
  single tier's token (invalidating only that old link), leaving the snapshot
  and the other links intact.
- Share dialog now shows three links (members / logged-in / anyone), each with
  its own Copy and Reset buttons; Publish refreshes, Unpublish removes all.
  Preview button opens the logged-in-users link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:19:15 +00:00
claude 539cb877b4 Deploy: allow public (anonymous) access so share links work without login
Build and Deploy Verso / deploy (push) Successful in 1m20s
The web service installs a site-wide login gate (router.mjs: webRouter.all('*',
requireGlobalLogin)) whenever Settings.allowPublicAccess is false — which it was,
since OVERLEAF_ALLOW_PUBLIC_ACCESS wasn't set. That gate bounced every anonymous
request to /login, breaking both Overleaf's own link-sharing and the public
presentation links (the dynamic token routes can't be in the exact-match
global whitelist, so there's no per-path exemption — allowPublicAccess is the
intended knob).

Set OVERLEAF_ALLOW_PUBLIC_ACCESS=true on the verso Deployment. Per-project and
per-route authorization still applies, and private presentation links still
require a login (enforced in the serve handler), so only genuinely public
content is reachable anonymously.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:50:48 +00:00
claude 4d3ac2b9ea Published presentations: two fixed links (public + private) instead of a toggle
Build and Deploy Verso / deploy (push) Successful in 7m30s
Replace the single token + visibility toggle with two stable tokens per project
pointing at the same snapshot:
  - publicToken  → anyone with the link
  - privateToken → any logged-in Verso user

This fixes both reported issues: changing visibility no longer mutates a link
(there's no toggle — both links always exist), and a public link can never
become private by accident. It also fixes public links redirecting to login:
access is now decided purely by which token was used (public token = open),
not a per-record flag.

- Model: storageId (snapshot dir) + publicToken + privateToken; drop token/
  visibility.
- Manager.publish: mints both tokens once and reuses them on re-publish; serve
  resolves a token to its record and treats the public token as open.
- Controller: returns { publicUrl, privateUrl }.
- Share dialog: shows the private and public links side by side, each with its
  own copy button; Publish refreshes, Unpublish removes. Preview button opens
  the private link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:07:37 +00:00
claude 2cb81bd246 Serve published decks from a trailing-slash URL so assets load
Build and Deploy Verso / deploy (push) Successful in 7m29s
A deck served at /p/:token (no trailing slash) made the browser resolve its
relative asset references (main_files/... CSS+JS) against /p/, 404ing them —
so the deck rendered as unstyled HTML with no reveal.js. Publish links now end
in a slash, and the bare /p/:token URL 301-redirects to /p/:token/, so relative
assets resolve under /p/:token/ and load correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:49:21 +00:00
claude eae5a0ebc7 Store published presentations on the writable data volume
Build and Deploy Verso / deploy (push) Successful in 2m41s
The default published-presentations folder resolved to the app dir
(/overleaf/services/web/data/published), which isn't writable by the runtime
user → EACCES on publish. Point it at the Overleaf data volume in the
production config (Path.join(DATA_DIR, 'published') = /var/lib/overleaf/data/
published), alongside compiles/output, where the app user can write (and which
persists when a volume is mounted). Overridable via PUBLISHED_PRESENTATIONS_PATH.
2026-06-01 15:41:18 +00:00
claude cb0d9ac9fa Fix publish-presentation failing right after an editor compile
Build and Deploy Verso / deploy (push) Successful in 7m49s
CompileManager.compile debounces compiles via a Redis key set on every compile
(_checkIfRecentlyCompiled), returning {status:'too-recently-compiled',
outputFiles:[]} when the editor has just auto-compiled. Publishing called
compile() and then required output.html, so it threw "did not produce an HTML
presentation" — which is why Preview/Publish errored whenever the deck was
freshly compiled.

- CompileManager.compile: honour options.bypassRecentCompileCheck to skip the
  debounce (still runs the normal autocompile-limit guards).
- PublishedPresentationManager: publish with bypassRecentCompileCheck, and put
  the compile status in the error message for diagnosis.
- Controller: catch publish errors, log them, and return the message so the
  Share dialog can show what went wrong instead of a generic error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:30:46 +00:00
claude 59055aa67e Publish presentations: share-modal section + Preview button (UI)
Build and Deploy Verso / deploy (push) Successful in 7m53s
Wires the two entry points to the publishing backend:

- Share dialog: a "Share compiled presentation" section (owner only) with a
  public / logged-in-users-only choice, Publish/Unpublish, and a copyable link.
- Top-right toolbar: a "Preview" button that publishes a private (logged-in-
  users-only) link in one click and opens the standalone deck in a new tab
  (opened synchronously to dodge popup blockers).

Both talk to /project/:id/publish-presentation. Reuses existing i18n
(publish/unpublish/copy/preview); adds share_compiled_presentation(_info) and
presentation_link_public/private.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 14:39:39 +00:00
claude 18f9220e73 Publish presentations as standalone shareable links (backend)
Adds the engine + API for publishing a project's compiled HTML/RevealJS deck as
a stable, standalone snapshot served at /p/:token, independent of the editor.

- PublishedPresentation model: one per project { token, visibility, buildId },
  re-publishing keeps the same token so shared links stay stable.
- Manager.publish: compiles the project, then copies the HTML deck + its _files
  assets + referenced media (now included thanks to the OutputFileFinder fix)
  into a persistent snapshot dir (Settings.path.publishedPresentationsFolder,
  override with PUBLISHED_PRESENTATIONS_PATH). Logs/aux are excluded.
- Routes: GET/POST/DELETE /project/:id/publish-presentation (owner/reader) for
  status/publish/unpublish; public GET /p/:token(/*) serves the deck full-page.
  Visibility is enforced in the handler: 'public' = anonymous, 'private' = any
  logged-in Verso user. CSP is dropped on these responses so reveal.js renders.

Frontend entry points (share-modal section + top-right Preview button) follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 14:34:06 +00:00
claude 9b01fab383 Serve referenced media for HTML/RevealJS output
Build and Deploy Verso / deploy (push) Successful in 7m39s
OutputFileFinder excluded all incoming project resources from the output set,
and OutputCacheManager only copies outputs into the served build dir. For PDF
that's fine (media is embedded), but for HTML/RevealJS the browser fetches
images/videos/fonts from the output path at runtime — so a deck's referenced
image (a project input file) was never served and rendered broken in the
preview.

When the compile produced output.html, keep media inputs (img/video/audio/font
extensions) in the output set so they're served alongside the deck. PDF/LaTeX
compiles are unaffected. This also makes referenced media land in output.zip,
which the upcoming presentation-publishing feature relies on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 14:01:58 +00:00
claude 7c2b903e4d Warn about missing images/videos in Quarto HTML output
Build and Deploy Verso / deploy (push) Successful in 7m49s
Since we dropped --embed-resources (so RevealJS plugins like chalkboard work),
pandoc no longer tries to fetch referenced media for HTML output, so a missing
image or video produces no compile-time warning — it only renders broken in the
browser. PDF/Typst output is unaffected because Typst hard-errors on a missing
image.

After an HTML render, QuartoRunner now scans output.html for local media
references (img/video/audio/iframe src, poster, RevealJS data-background-*) and
appends a `[WARNING] Missing resource: …` line to output.log for any that don't
exist on disk. External URLs, data URIs, anchors and Quarto's own generated
<basename>_files assets are ignored. The [WARNING] prefix is recognised by the
Quarto/Typst log parser, so these show up in the Warnings tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:24:56 +00:00
claude 2d4ca6f13a Fix LaTeX projects failing to compile (HTTP 500, no logs)
Build and Deploy Verso / deploy (push) Successful in 7m44s
Project.compiler defaults to settings.defaultLatexCompiler ('quarto' in this
fork), so every .tex project carried compiler='quarto'. Since the CLSI runner
is chosen by file extension, a .tex root still goes to LatexRunner, whose
_buildLatexCommand threw `unknown compiler: quarto` — surfacing as an opaque
HTTP 500 with no compile log.

- LatexRunner: fall back to pdfLaTeX when the compiler isn't a known TeX engine
  instead of throwing. Universal safety net (covers existing projects, uploads
  and GitHub imports already saved with compiler='quarto').
- ProjectCreationHandler: store a sensible compiler per flavour at creation via
  a shared _flavourConfig helper — blank/example LaTeX → 'pdflatex',
  Typst → 'typst', Quarto → 'quarto' — so the compiler dropdown reflects the
  engine and LatexRunner receives a valid one directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:08:57 +00:00
claude d67bc77b0e Add a Typst compiler alongside Quarto and LaTeX
Build and Deploy Verso / deploy (push) Successful in 7m37s
A project whose root file is a .typ file now compiles straight to PDF with
Typst, as a third engine beside Quarto (.qmd) and latexmk (.tex). Dispatch
stays purely extension-based.

CLSI:
- New TypstRunner.js: runs `quarto typst compile <main>.typ output.pdf` (reuses
  the Typst bundled in Quarto, so no extra binary / Docker change). stderr is
  merged into output.log.
- CompileManager: _isTypstFile + a TypstRunner branch in _getRunner, and
  TypstRunner added to the isRunning check and stopCompile kill list.
- RequestParser: 'typst' added to VALID_COMPILERS.

web:
- settings.defaults: 'typ' added to validRootDocExtensions and the text
  extensions (so .typ opens in the editor); 'typst' added to safeCompilers.
- output-files: the Quarto/Typst log parser (which already understands Typst
  `error:`/`warning:` + `┌─ file:line:col` diagnostics) now also handles .typ
  compiles, so their errors/warnings populate the log tabs.

Polish:
- New-project menu: "Blank Typst project" + "Example Typst project" in both the
  main and welcome dropdowns, backed by createBasicProject/createExampleProject
  flavour 'typst', a new mainbasic.typ template and an example-project-typst
  presentation (math, an image, a table, lists).
- Compiler dropdown gains a "Typst" option (cosmetic; dispatch is by extension).

README updated: three compilers side by side, with a Writing-a-Typst-document
section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:56:30 +00:00
claude 2a9c4cfe81 New-project menu: split into Quarto and LaTeX blank/example options
Build and Deploy Verso / deploy (push) Successful in 7m44s
Replace the generic "Blank project" / "Example project" entries with four
flavour-specific ones in both the New-project dropdown and the welcome-screen
dropdown:

- Blank Quarto project   -> empty main.qmd (format: typst)
- Blank LaTeX project    -> empty main.tex
- Example Quarto project -> a Reveal.js presentation showcasing images, math,
  a table, code and incremental lists (new template
  project_files/example-project-quarto/)
- Example LaTeX project  -> the existing LaTeX example

Backend: ProjectController.newProject now dispatches the `template` value
(blank_quarto/blank_latex/example_quarto/example_latex, plus the legacy
'example'/'none') to createBasicProject(flavour) / createExampleProject(flavour).
_createRootDoc takes a root-doc name so each flavour gets the right extension —
this also fixes the LaTeX example, whose root doc was wrongly created as
main.qmd, back to main.tex (matching the acceptance test). Signatures stay
backward compatible (flavour defaults: blank=quarto, example=latex).

Also refresh the README: Verso now runs Quarto and LaTeX side by side
(engine chosen by root-file extension), not Quarto instead of LaTeX.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:30:07 +00:00
claude 3e10d1c4ee Recolour remaining green brand-accent surfaces to the accent token
Build and Deploy Verso / deploy (push) Successful in 7m50s
The $accent knob caught primary buttons, but several places still
referenced the green ramp directly as a brand-accent colour (rather than
genuine success semantics). Repoint those at the --bg-accent-* tokens so
they too follow the single $accent knob:

- navbar Sign in / Register ("primary" + subdued/link hover) buttons
- file-tree selected-item highlight and drag background (IDE redesign,
  light and dark)
- document-outline highlighted item (IDE redesign, light and dark)
- the Visual/Code editor-switcher button mixin
- web/content hyperlinks (--link-web*), e.g. on the project dashboard;
  dark-theme variants point at the blue ramp to stay readable on dark

Genuine success/positive greens (notification success icon,
$content-positive, beta badges, etc.) are deliberately left green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:49:12 +00:00
claude b3541ba6f3 CI: skip unchanged base image and add registry build cache
Build and Deploy Verso / deploy (push) Successful in 12m40s
Two build-speed changes to the Gitea Actions deploy workflow.

(#1) Build the base image only when it changes. The base layers' only
repo input is server-ce/Dockerfile-base, so the prepare step hashes that
file and the base is tagged verso-base:base-<hash>; the app builds FROM
that exact tag. If a base with the current hash already exists in the
registry, the heavy base build (apt ~111s, TeX Live ~51s, Quarto, plus
its ~49s export/push) is skipped entirely — which is every commit that
doesn't touch Dockerfile-base.

(#2) Import/export a registry-backed layer cache (verso-cache:base and
verso-cache:app, mode=max) on both builds. Unchanged layers are reused
instead of rebuilt: yarn install is skipped when package.json is
unchanged, and only the web compile re-runs on a frontend source change.
No new cluster resources — the cache lives as extra tags in the same
in-cluster registry.

First run after this is still a full build (populates the caches and the
hash-tagged base); subsequent commits should be substantially faster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:30:20 +00:00
claude aa3fb56458 Parse Quarto logs and make the accent colour a single knob
Build and Deploy Verso / deploy (push) Successful in 11m55s
Quarto compiles (.qmd/.md/.Rmd, dispatched to QuartoRunner) write
Typst/Pandoc/Quarto diagnostics to output.log that the LaTeX log parser
does not understand, so the Errors/Warnings tabs stayed empty. Add a
dedicated quarto-log-parser that recognises Typst `error:`/`warning:`
(+ `┌─ file:line:col`), Pandoc `[WARNING]`/`[ERROR]`, Quarto CLI/Deno
`ERROR:`/`WARNING:`, and knitr `Quitting from lines`. handleLogFiles now
routes to it when the root file is a Quarto file (mirrors CLSI dispatch),
otherwise the LaTeX path is unchanged.

Also decouple the UI accent from the green ramp. The framework already
funnels every primary/accent surface (primary buttons, Bootstrap
$primary/$success, --btn-primary-background) through the --bg-accent-*
tokens; those just happened to point at Overleaf green. Introduce a
single $accent knob in foundations/colors.scss (with auto-derived
hover/tint shades) and repoint the accent tokens at it, defaulting to
the Verso/Quarto blue. Re-skinning the whole UI is now a one-line edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:57:06 +00:00
claude e87bbfe5b0 HTML preview: drop embed-resources and clear stale deck on failure
Build and Deploy Verso / deploy (push) Successful in 12m5s
Two HTML/RevealJS preview fixes:

1. Stop passing --embed-resources to quarto render. A self-contained
   single-file HTML breaks reveal.js plugins that load/store resources at
   runtime (chalkboard, multiplex) and is slow to transfer. Quarto now
   emits the HTML plus a sibling "<basename>_files/" asset dir referenced
   by relative paths; both are served from the same .../output/ path
   (nginx output/(.+) and web :file(.*) both capture slashes), so the
   relative links resolve. The renamed output.html still points at the
   unchanged "<basename>_files" dir. This also fixes the slow-load issue,
   since assets now load on demand instead of one giant inlined file.

2. On a failed compile that follows a successful one, the previous deck
   stayed in the iframe, making the failure look like a success. We now
   clear pdfFile when a non-success status carries a stale output.html.
   The last-good-PDF-beside-the-error behaviour is preserved for PDF
   output (only output.html is dropped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:40:18 +00:00
claude 56d66b109e Outline: ignore YAML frontmatter and strip Quarto attribute blocks
Build and Deploy Verso / deploy (push) Successful in 12m25s
Two fixes to the Markdown/Quarto file outline:

1. The last frontmatter line (e.g. `format: typst`) appeared as a
   heading. The Lezer Markdown grammar has no frontmatter support, so it
   reads the closing `---` of the YAML block as a Setext underline and
   promotes the line above it to a heading. Detect the leading
   `---`...`---`/`...` block and skip any heading inside it.

2. Pandoc/Quarto attribute blocks were shown in titles, e.g.
   `## Slide {.smaller auto-animate="true"}`. Strip a trailing `{...}`
   from the extracted title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:30:15 +00:00
claude b6c1a2d5ce Fix empty titles in Markdown/Quarto file outline
Build and Deploy Verso / deploy (push) Successful in 12m13s
The outline entries showed up at the right lines and jumped correctly,
but their titles were blank. The text-extraction walked the heading
node's children and collected non-HeaderMark child text — but in the
Lezer Markdown grammar a heading has NO child node for its text; the
only children are the HeaderMark nodes. The title text lives in the
gaps between marks, so the walk collected nothing.

Slice the whole heading's source instead and strip the markers:
leading/trailing '#'s for ATX headings and the '==='/'---' underline
for Setext headings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:04:42 +00:00
claude 5f761c1772 Use minimal scheme-basic TeX Live install (small, fast, reversible)
Build and Deploy Verso / deploy (push) Has been cancelled
Reverts the heavy multi-collection texlive install back toward the
original upstream-Overleaf approach: install-tl with scheme-basic
(~300 MB) plus latexmk and texcount via tlmgr, no docfiles/srcfiles.
This restores the fast, small base image we had before LaTeX support
was added in full.

Tradeoff: documents needing tikz/beamer/siunitx/extra fonts won't
compile out of the box for now — those should stay in Quarto/Typst
until the project is mature enough to justify a full TeX Live.

Made deliberately easy to reverse: a header comment documents that
switching scheme-basic -> scheme-full (one line) restores the complete
toolchain, or individual packages can be appended to the tlmgr list.
Uses TEXDIR=/usr/local/texlive (unversioned) so PATH stays stable
across TeX Live releases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:01:07 +00:00
claude 4800a51957 Use a curated TeX Live set instead of near-full to speed up builds
Build and Deploy Verso / deploy (push) Successful in 14m42s
The previous install expanded texlive-full (minus -doc/-lang-), pulling in
essentially every CTAN package plus inkscape's large GTK GUI tree — ~20 min
and several GB. Replace it with a curated set of meta-packages that covers
the vast majority of documents: latex base/recommended/extra, recommended
fonts, plain-generic, science (math/physics), xetex, luatex, bibtex-extra,
extra-utils (texcount), plus latexmk/biber/chktex/pygments.

Smaller and faster to build. Documents needing an omitted package can have
the relevant texlive-* collection added back. Drops inkscape (only used for
auto SVG->PDF conversion) to avoid its heavy GUI dependency chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:52:56 +00:00
claude 7c86657548 CI: pull deploy image via public registry address
Build and Deploy Verso / deploy (push) Has been cancelled
The cluster nodes' containerd can only pull from registry.alocoq.fr, not
the in-cluster service name. Keep pushing via the in-cluster address (to
bypass the Traefik upload-timeout), but reference registry.alocoq.fr/verso
in the test Deployment and the rolling update. Both addresses front the
same registry storage, so the pushed image resolves at the public name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:30:45 +00:00
claude 3af4e2f46a CI: write buildkitd.toml in-container instead of a ConfigMap
Build and Deploy Verso / deploy (push) Failing after 25m12s
The previous approach created a verso-buildkitd-config ConfigMap, but the
workflow's RBAC does not permit creating new cluster resources. Write the
buildkitd.toml (marking the in-cluster registry as http/insecure) directly
inside the buildkit container at runtime via printf, and drop the configMap
volume/mount. No new k8s resources are created.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:01:16 +00:00
claude 8f2f6d1684 CI: push images to in-cluster registry to bypass Traefik
Build and Deploy Verso / deploy (push) Failing after 1s
The TeX Live layer (~3.5 GB) failed to push to registry.alocoq.fr:
Traefik severed the upload mid-stream ("client disconnected during blob
PUT ... unexpected EOF"), buildkit retried at the wrong offset, and the
registry returned "blob upload invalid".

Push to the in-cluster registry Service (registry.git.svc.cluster.local:5000)
instead, so the upload never traverses Traefik. Changes:
- buildctl outputs use registry.insecure=true (registry is plain HTTP)
- add a verso-buildkitd-config ConfigMap with buildkitd.toml marking the
  registry http/insecure, so the second build can pull the base image back
- the verso Deployment and rolling update reference the in-cluster image

NOTE: the cluster nodes' containerd must also treat
registry.git.svc.cluster.local:5000 as an insecure registry, otherwise
the kubelet image pull for the test deployment will fail. That is node-
level config outside this repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:46:30 +00:00
claude 3bb293f7a7 Fix TeX Live install: texcount is not a standalone package
Build and Deploy Verso / deploy (push) Has been cancelled
The base image build failed with "E: Unable to locate package texcount".
texcount ships inside texlive-extra-utils, not as its own apt package.
Replace the bogus texcount entry with texlive-extra-utils (which provides
both texcount and latexmk). latexmk is kept explicit for clarity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:54:51 +00:00
claude 2ae860a1a8 Raise upload limit from 50 MB to 500 MB
Build and Deploy Verso / deploy (push) Has been cancelled
Both limits that gate uploads are bumped in tandem so they don't conflict:
- settings.defaults.js maxUploadSize: 50 MB → 500 MB (app-level check)
- nginx.conf.template client_max_body_size: 50m → 500m (proxy body limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:38:21 +00:00
claude 422ac30e6c Support LaTeX and Quarto compilation in parallel
Build and Deploy Verso / deploy (push) Has been cancelled
Verso now compiles both .tex (latexmk) and .qmd (Quarto) projects,
dispatching by the root file's extension rather than replacing one with
the other. LaTeX and Quarto projects can coexist on the same server.

CompileManager: re-import LatexRunner and add a _getRunner() dispatcher
  that returns a uniform {run, isRunning, kill} interface. .qmd/.md/.Rmd
  → QuartoRunner; everything else (.tex/.ltx/.Rtex/.Rnw) → LatexRunner.
  stopCompile now checks/kills both runners since it has no root path.

compiler-setting.tsx: restore the LaTeX engine choices (pdfLaTeX, LaTeX,
  XeLaTeX, LuaLaTeX) alongside Quarto. The dropdown still controls which
  TeX engine latexmk uses; actual engine dispatch is by file extension.

Dockerfile-base: reinstall TeX Live alongside Quarto (texlive-full minus
  -doc/-lang- packages, plus xetex/luatex/biber/latexmk/texcount/chktex/
  synctex). Restore TEXMFVAR for a writable LuaTeX cache. This brings back
  a large image, which is the accepted cost of full LaTeX+Quarto support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:27:39 +00:00
claude a89b8bd282 Enable gzip for HTML/CSS/JS output in clsi-nginx
Build and Deploy Verso / deploy (push) Successful in 11m13s
RevealJS presentations are served as (currently embed-resources) HTML that
went over the wire uncompressed, because gzip_types only listed text/plain.
This made the HTML preview slow to load for heavy decks.

Add text/html, text/css, application/javascript, application/json and
image/svg+xml to gzip_types so the text-based portion of the output is
compressed. Already-compressed formats (pdf, png/jpeg/webp, woff/woff2)
are intentionally excluded to avoid wasting CPU. Also set gzip_min_length
1024 so tiny responses aren't compressed needlessly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:52:39 +00:00
claude a241e2c201 Pre-install popular Quarto extensions in the Docker image
Build and Deploy Verso / deploy (push) Successful in 11m22s
Dockerfile-base: after Quarto is installed, run 'quarto add --no-prompt'
  for a curated set of extensions into /opt/quarto-extensions/. Quarto
  writes _extensions/<author>/<name>/ in the working dir, giving us a
  clean shared store. Extensions included:
    - igorlima/charged-ieee      — IEEE paper format (Typst)
    - quarto-ext/fontawesome     — Font Awesome icons
    - quarto-ext/attribution     — attribution footer on RevealJS slides
    - quarto-ext/pointer         — laser pointer for presentations
    - quarto-ext/drop            — drop-down overlay for RevealJS
  Adding more: one extra '&& quarto add --no-prompt <author>/<repo>' line.

QuartoRunner: before quarto render, merge /opt/quarto-extensions/_extensions/
  into the compile dir's _extensions/ with 'cp -rn' (no-clobber). This
  makes all pre-installed extensions available to every project without
  any user action. Project-uploaded _extensions/ files take precedence
  since cp -n never overwrites existing files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 16:54:56 +00:00
claude 4460c1d9d6 Fix README copyright: Aloïs Coquillard, 2026
Build and Deploy Verso / deploy (push) Successful in 10m53s
2026-05-31 16:37:59 +00:00
claude 0407e17c68 Rewrite README for Verso
Build and Deploy Verso / deploy (push) Has been cancelled
Replace Overleaf's original README with a Verso-specific one covering:
the project's purpose (collaborative Quarto editor), output formats
(typst/PDF and revealjs/HTML), quick-start Docker instructions, service
architecture overview, a minimal .qmd example, key env vars, and a
clear description of what differs from upstream Overleaf.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 16:30:17 +00:00
claude 24cd4bf13d Fix HTML compile success check: size is undefined for non-PDF outputs
Build and Deploy Verso / deploy (push) Has been cancelled
collectOutputPdfSize() only calls stat() and sets .size on output.pdf.
All other output files (including output.html) keep size: undefined.
The previous check required file.size > 0 for both PDF and HTML, so
undefined > 0 always evaluated false for output.html, making every
RevealJS compile report 'failure' even when the file was produced.

Fix: require size > 0 only for output.pdf; accept output.html
regardless of size (it is always non-empty if Quarto succeeded).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 16:27:02 +00:00
claude 090018c191 Fix QuartoRunner mv: use relative paths to avoid $COMPILE_DIR replacement bug
Build and Deploy Verso / deploy (push) Successful in 11m1s
LocalCommandRunner.replace() uses String.replace() which only substitutes
the FIRST occurrence of '$COMPILE_DIR' in the shell script string. The mv
commands had two more occurrences that stayed as literal '$COMPILE_DIR',
which the shell expanded to '', making 'mv /main.pdf /output.pdf' fail
silently. The file was produced (Quarto logged 'Output created: main.pdf')
but never renamed to output.pdf, so the pipeline reported failure.

Fix: mv uses relative filenames since the shell CWD is already the compile
directory (set by LocalCommandRunner via the spawnCwd option).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:51:01 +00:00
claude 48fd24a6b2 Add HTML/RevealJS preview alongside existing PDF preview
Build and Deploy Verso / deploy (push) Successful in 11m0s
clsi-nginx.conf: the types{} block was overriding all nginx defaults,
  leaving HTML/CSS/JS/fonts as application/octet-stream. Add the full
  set of web MIME types so RevealJS assets are served correctly. Also
  needed for X-Content-Type-Options: nosniff to pass.

CompileController.js: success was hardcoded to require output.pdf.
  Also accept output.html so a RevealJS compile is reported as
  'success' rather than 'failure'.

QuartoRunner.js: remove hardcoded --to typst --output output.pdf.
  Instead run `quarto render` without --to/--output so the YAML
  frontmatter decides the format (typst → PDF, revealjs → HTML, etc.).
  Pass --embed-resources so HTML output is self-contained (flag is
  silently ignored by the typst backend). After render, rename
  main.pdf → output.pdf or main.html → output.html so the pipeline
  finds the standard canonical filename.

output-files.ts: handleOutputFiles now falls back to output.html when
  output.pdf is absent. Download URL uses outputFile.path instead of
  the hardcoded 'output.pdf' string.

pdf-viewer.tsx: when pdfUrl contains output.html, bypass PDF.js
  entirely and render a sandboxed iframe (allow-scripts for RevealJS
  interactivity, allow-presentation for fullscreen).

Usage: set `format: revealjs` in the .qmd YAML frontmatter to get
  an HTML presentation preview; set `format: typst` for PDF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:32:00 +00:00
claude 141cf95f9e Apply Verso brand identity: Quarto palette, logo, UI text
Build and Deploy Verso / deploy (push) Successful in 10m58s
Color palette: introduce Quarto's five brand colours ($verso-blue
  #447099, $verso-blue-dark #1B3B6F, $verso-blue-light #75AADB,
  $verso-green #72994E, $verso-orange #EE6331) as CSS custom
  properties alongside the existing layout vars.

Logo: replace all Overleaf SVG assets (icon, wordmarks, favicons,
  horizontal logos) with the Verso mark — a circle split into four
  Quarto-coloured quadrants (Quarto DNA) with a bold white V
  letterform (Verso identity). Filenames kept so imports stay intact.
  Status favicons keep their layout; brand green #046530#447099.

UI text:
  - appName / nav.title default → 'Verso'
  - Footer copyright → '© Verso'; remove Overleaf social links;
    thin-footer attribution → 'Built on Overleaf' (with OSS link)
  - mask-icon colour → #447099
  - interstitial logo alt → 'Verso'
  - Key locale strings (welcome, agree terms, go-to) → Verso;
    SaaS-specific strings (subscriptions, AI Assist) left as-is
    since CE users never see them

Env var names (OVERLEAF_*) intentionally untouched to avoid breaking
  the build. Code comments citing Overleaf origin preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:06:36 +00:00
claude 1e5ce6c068 Add document outline support for Markdown/Quarto files
Build and Deploy Verso / deploy (push) Successful in 10m47s
outline.ts: export NestingLevel so it can be used outside the file.

markdown/document-outline.ts: new enterMarkdownNode function that walks
  the Lezer Markdown syntax tree and extracts ATXHeading1-6 and
  SetextHeading1-2 nodes, mapping them to the same NestingLevel enum
  used by the LaTeX outline (Section→SubSection→SubSubSection…).
  Wrapped in makeProjectionStateField for incremental updates.

markdown/index.ts: register markdownDocumentOutline as a CodeMirror
  extension in the Markdown LanguageSupport so the StateField is active
  whenever a .qmd file is open.

codemirror-outline.tsx: fall back to markdownDocumentOutline when the
  LaTeX documentOutline StateField is not present in the editor state
  (i.e. when the active language is Markdown, not LaTeX).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 14:35:01 +00:00
claude ce0572e01e Revert QuartoRunner: restore --output output.pdf
Build and Deploy Verso / deploy (push) Successful in 11m9s
The previous compile logs confirm Quarto handles --to typst --output
output.pdf correctly: pandoc produces main.typ, typst compiles it to
main.pdf, then Quarto renames to output.pdf. The mv-based approach was
unnecessary and incorrect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 14:11:03 +00:00
claude 824b873c69 Fix QuartoRunner: drop --output flag to let Quarto run full typst→PDF pipeline
Build and Deploy Verso / deploy (push) Failing after 12m18s
--to typst combined with --output output.pdf caused Quarto to write a
Typst source file (.typ content) named output.pdf instead of invoking
the typst compiler, producing a text file that the PDF viewer could not
render (hence 'markdown not rendered' — it was literally showing the raw
.typ markup). Fix: let Quarto name the PDF after the input file
(main.qmd → main.pdf) and rename to output.pdf with mv afterwards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 14:08:08 +00:00
claude b2b2ed13aa Fix Quarto cache permission: create /var/www/.cache/quarto for www-data
Build and Deploy Verso / deploy (push) Successful in 10m56s
Quarto resolves its cache dir as $HOME/.cache/quarto. The process runs
as www-data (home=/var/www) but that directory is root-owned, so Quarto
crashed immediately with PermissionDenied on mkdir. Pre-create the cache
dir and chown it to www-data at image build time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:39:02 +00:00
claude 09f5329a07 Fix compile error propagation: 'failure' instead of HTTP 500
Build and Deploy Verso / deploy (push) Successful in 10m37s
LocalCommandRunner: attach captured stdout to the error object when
  exit code is 1, so callers can read Quarto's output even on failure.

QuartoRunner: stop propagating plain 'exited' errors from Quarto up
  to CompileManager. A Quarto exit-code-1 is a compile failure, not a
  server error — CLSI already detects failure by the absence of
  output.pdf and returns status='failure' (HTTP 200). Previously it
  fell through to the generic error handler (HTTP 500), which caused
  the frontend to show "Server Error" instead of the log panel.
  Only true process-level errors (terminated, timedout) are propagated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:22:58 +00:00
claude 0323fd4813 Fix Quarto compile pipeline: install, logs, template, draft mode
Build and Deploy Verso / deploy (push) Successful in 10m58s
Dockerfile-base: remove TeX Live (no longer needed), install Quarto
  1.6.39 which bundles Typst for PDF output. This was the root cause
  of all compile failures — the server-ce monolith never had Quarto.

QuartoRunner: run quarto via /bin/sh so stderr is merged into stdout
  with 2>&1; write combined output to output.log (not output.stdout)
  so the PDF-preview log panel picks it up and shows raw output.
  Also write the log on error so failures are always visible.

CompileManager: guard DraftModeManager behind an isLatexFile check —
  injecting LaTeX preamble commands into a .qmd file corrupts it and
  causes a guaranteed compile failure when draft mode is requested.

ProjectCreationHandler + mainbasic.qmd: new projects now create
  main.qmd with a minimal Quarto/Typst frontmatter instead of the
  LaTeX main.tex; _createRootDoc names the file main.qmd accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:59:43 +00:00
claude af54b5fd49 Fix Quarto compiler integration: UI, root doc detection, type safety
Build and Deploy Verso / deploy (push) Successful in 11m58s
- compiler-setting.tsx: replace hardcoded LaTeX compiler list with a
  single Quarto option; drop now-unused getMeta/lodash imports
- project-settings.ts: add 'quarto' to ProjectCompiler union type
- ClsiManager: detect main.qmd as a default root document (preferred
  over main.tex); replace hasMainFile boolean with detectedMainFile
  so we know which filename to use
- settings.defaults.js: add 'qmd' to validRootDocExtensions so .qmd
  files appear as selectable root documents in the UI
- ProjectRootDocManager: sort main.qmd before main.tex in the root
  doc candidate list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:29:12 +00:00
claude 5b5a54f7b1 Merge branch 'ai/quarto-investigation': replace LaTeX with Quarto
Build and Deploy Verso / deploy (push) Successful in 12m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:08:11 +00:00
claude a7c2403c4a Replace LaTeX compiler with Quarto (typst PDF output)
- Add QuartoRunner.js: runs `quarto render --to typst --output output.pdf`,
  using Typst (bundled with Quarto >= 1.4) so no separate LaTeX install needed
- Swap LatexRunner for QuartoRunner in CompileManager; remove latexmk-specific
  stats, fdb metrics, and performance sampling that no longer apply
- Add 'quarto' to VALID_COMPILERS in RequestParser and set it as the default;
  change default rootResourcePath from main.tex to main.qmd
- Add 'quarto' to safeCompilers and set it as the default in web settings
- Replace with-texlive Dockerfile stage with with-quarto (Quarto deb install);
  add Quarto to the default final stage as well

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:08:07 +00:00
alois d52821e7cc Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Successful in 9m49s
2026-05-30 15:00:42 +00:00
alois d3a9259b42 Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Failing after 9m59s
2026-05-30 14:23:03 +00:00
alois a2db1f04be Actualiser server-ce/Dockerfile
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-30 12:08:40 +00:00
alois 070d0c1352 Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-30 12:01:00 +00:00
alois 016da98027 Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Failing after 17m13s
2026-05-30 10:54:21 +00:00
alois 1aae2fb3fc switch to minimal dockerfile
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-30 08:59:24 +00:00
alois 12290c5d93 Ajouter server-ce/Dockerfile-base-minimal
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-30 08:54:39 +00:00
alois 85ecaf1ff6 Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Successful in 9m57s
2026-05-29 21:57:51 +00:00
alois 3b8879b9ca Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Successful in 9m39s
2026-05-29 20:58:02 +00:00
alois f341d6e64d Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Successful in 9m39s
2026-05-29 20:33:30 +00:00
alois ecbf7d4bdc Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Successful in 9m38s
2026-05-29 19:39:37 +00:00
alois ba5d159f28 Fix dockerfile ARG
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-29 20:40:57 +02:00
alois a851f53005 Merge branch 'main' of https://git.alocoq.fr/alois/verso
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-29 20:26:46 +02:00
alois e4db4fe458 Fix corepack yarn install for build 2026-05-29 20:22:40 +02:00
alois 4424286dfd Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-29 18:01:08 +00:00
alois 0771eeab43 Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Failing after 1s
2026-05-29 17:58:05 +00:00
alois 3fe806fc0e Actualiser .gitea/workflows/deploy-verso.yml
Build and Deploy Verso / deploy (push) Has been cancelled
2026-05-29 17:46:19 +00:00
alois cc0db0813f Ajouter .gitea/workflow/deploy-verso.yml 2026-05-29 17:43:06 +00:00
124 changed files with 5670 additions and 647 deletions
+383
View File
@@ -0,0 +1,383 @@
name: Build and Deploy Verso (prod)
# Production deploy. Triggered only by pushes to the `prod` branch — keep `main`
# for day-to-day work and fast-forward `prod` when a build is stable.
#
# Differences from the test deploy (deploy-verso.yml):
# - Runs in the `verso` namespace (test runs in `test`).
# - Mongo / Redis / app data live on PersistentVolumeClaims and are applied
# idempotently: this workflow NEVER deletes them, so data survives deploys.
# - The replica set is initialised only once.
# - Builds/pushes a distinct image tag (verso:stable) so prod and test never
# clobber each other's image.
# - SMTP comes from the `verso-smtp` Secret (create it with kubectl); email is
# optional so the app still boots before the secret exists.
# - Public self-registration stays off (CE default): friends-only, admin
# creates accounts / sends invites.
#
# Out of band (do once): create the PVCs (server-ce/k8s/verso-prod-pvcs.yaml,
# with your storageClass), the `verso-smtp` Secret, and a verso.alocoq.fr
# Ingress (see server-ce/k8s/verso-prod-ingress.example.yaml) + DNS.
on:
push:
branches:
- prod
workflow_dispatch:
env:
SITE_URL: https://verso.alocoq.fr
jobs:
deploy:
runs-on: native
timeout-minutes: 240
steps:
- name: Build and push Verso prod image with BuildKit
run: |
kubectl -n ci delete job verso-buildkit-prod --ignore-not-found=true --wait=true
cat <<'EOF' | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: verso-buildkit-prod
namespace: ci
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: prepare
image: alpine/git:latest
command: ["sh", "-c"]
args:
- |
set -eux
REG=registry.git.svc.cluster.local:5000
git clone --depth 1 --branch prod https://git.alocoq.fr/alois/verso.git /workspace/repo
# Build the base image only when Dockerfile-base changes
# (content-hash tag); otherwise reuse the cached base.
BTAG=$(sha256sum /workspace/repo/server-ce/Dockerfile-base | cut -c1-16)
printf '%s' "$BTAG" > /workspace/base_tag
if wget -qO- "http://$REG/v2/verso-base/tags/list" 2>/dev/null | grep -q "\"base-$BTAG\""; then
echo "Base image base-$BTAG already present - skipping base build"
else
touch /workspace/build-base
echo "Base image base-$BTAG not found - base will be built"
fi
volumeMounts:
- name: workspace
mountPath: /workspace
containers:
- name: buildkit
image: moby/buildkit:latest
securityContext:
privileged: true
command: ["sh", "-c"]
args:
- |
set -eux
REG=registry.git.svc.cluster.local:5000
mkdir -p /etc/buildkit
printf '[registry."%s"]\n http = true\n insecure = true\n' "$REG" > /etc/buildkit/buildkitd.toml
BTAG=$(cat /workspace/base_tag)
BASE_REF="$REG/verso-base:base-$BTAG"
if [ -f /workspace/build-base ]; then
buildctl-daemonless.sh build \
--frontend=dockerfile.v0 \
--local context=/workspace/repo \
--local dockerfile=/workspace/repo/server-ce \
--opt filename=Dockerfile-base \
--import-cache type=registry,ref=$REG/verso-cache:base \
--export-cache type=registry,ref=$REG/verso-cache:base,mode=max \
--output type=image,name=$BASE_REF,push=true,registry.insecure=true
else
echo "Reusing existing base image $BASE_REF"
fi
# App image → verso:stable (prod tag).
buildctl-daemonless.sh build \
--frontend=dockerfile.v0 \
--local context=/workspace/repo \
--local dockerfile=/workspace/repo/server-ce \
--opt filename=Dockerfile \
--opt build-arg:OVERLEAF_BASE_TAG=$BASE_REF \
--import-cache type=registry,ref=$REG/verso-cache:app \
--export-cache type=registry,ref=$REG/verso-cache:app,mode=max \
--output type=image,name=$REG/verso:stable,push=true,registry.insecure=true
volumeMounts:
- name: workspace
mountPath: /workspace
volumes:
- name: workspace
emptyDir: {}
EOF
- name: Wait for build
run: |
kubectl -n ci wait --for=condition=complete job/verso-buildkit-prod --timeout=14400s
- name: Show build logs
if: always()
run: |
kubectl -n ci logs job/verso-buildkit-prod -c prepare || true
kubectl -n ci logs job/verso-buildkit-prod -c buildkit || true
- name: Ensure data services (Mongo + Redis, never deleted)
run: |
# Mongo/Redis. Applied idempotently — this step must never delete
# these, so project data survives every deploy. The namespace and the
# PVCs (server-ce/k8s/verso-prod-pvcs.yaml) are provisioned out of
# band, so the runner only needs namespaced rights in `verso` (like
# `test`). This step assumes the namespace and the
# mongo-data / redis-data / verso-data PVCs already exist.
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: mongo
namespace: verso
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: mongo
template:
metadata:
labels:
app: mongo
spec:
containers:
- name: mongo
image: mongo:8
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
ports:
- containerPort: 27017
volumeMounts:
- name: mongo-data
mountPath: /data/db
volumes:
- name: mongo-data
persistentVolumeClaim:
claimName: mongo-data
---
apiVersion: v1
kind: Service
metadata:
name: mongo
namespace: verso
spec:
selector:
app: mongo
ports:
- name: mongo
port: 27017
targetPort: 27017
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: verso
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7
# AOF persistence so a restart doesn't drop in-flight edits
# before they're flushed to Mongo.
command: ["redis-server", "--appendonly", "yes"]
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: verso
spec:
selector:
app: redis
ports:
- name: redis
port: 6379
targetPort: 6379
EOF
kubectl -n verso rollout status deployment/mongo --timeout=300s
kubectl -n verso rollout status deployment/redis --timeout=300s
- name: Initialise Mongo replica set (only if not already initialised)
run: |
kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval '
try {
rs.status()
print("replica set already initialised")
} catch (e) {
if (e.codeName === "NotYetInitialized" || /no replset config/i.test(e.message)) {
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "mongo:27017" }] })
print("replica set initiated")
} else {
throw e
}
}
'
kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval '
while (rs.status().myState !== 1) { sleep(1000) }
print("Mongo replica set is PRIMARY")
'
- name: Ensure Verso deployment + service
run: |
# Stamp the instance name with this build number, e.g. "Verso V0.12 Alpha".
NAV_TITLE="Verso V0.${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}} Alpha"
cat <<'EOF' | sed "s|__NAV_TITLE__|${NAV_TITLE}|g" | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: verso
namespace: verso
spec:
replicas: 1
# RWO data volume → can't run two pods at once; recreate on update.
strategy:
type: Recreate
selector:
matchLabels:
app: verso
template:
metadata:
labels:
app: verso
spec:
securityContext:
# App runs as www-data (uid/gid 33); make the data volume
# group-writable by it.
fsGroup: 33
initContainers:
- name: init-data-perms
image: busybox:latest
command: ["sh", "-c"]
args:
- |
set -eux
mkdir -p /data/template_files /data/user_files \
/data/compiles /data/cache /data/output /data/published
chown -R 33:33 /data
volumeMounts:
- name: verso-data
mountPath: /data
containers:
- name: verso
image: registry.alocoq.fr/verso:stable
# :stable is a fixed tag, so force a pull on every rollout to
# pick up the freshly built image.
imagePullPolicy: Always
ports:
- containerPort: 80
env:
- name: OVERLEAF_MONGO_URL
value: mongodb://mongo:27017/sharelatex?replicaSet=rs0
- name: OVERLEAF_REDIS_HOST
value: redis
- name: REDIS_HOST
value: redis
- name: OVERLEAF_APP_NAME
value: Verso
- name: OVERLEAF_NAV_TITLE
value: "__NAV_TITLE__"
- name: OVERLEAF_SITE_URL
value: https://verso.alocoq.fr
- name: OVERLEAF_SITE_LANGUAGE
value: fr
# Allow anonymous visitors so public published-presentation
# links and read-only share links work without login.
- name: OVERLEAF_ALLOW_PUBLIC_ACCESS
value: "true"
# NB: anonymous read-AND-write sharing is intentionally NOT
# enabled (compiles are unsandboxed → only trusted accounts
# may trigger them). Public self-registration is also off
# (CE default): admin creates accounts / sends invites.
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
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'
# Secret (its keys must be named exactly like those env
# vars). Optional, so the app boots before the secret exists.
envFrom:
- secretRef:
name: verso-smtp
optional: true
volumeMounts:
- name: verso-data
mountPath: /var/lib/overleaf/data
volumes:
- name: verso-data
persistentVolumeClaim:
claimName: verso-data
---
apiVersion: v1
kind: Service
metadata:
name: verso
namespace: verso
spec:
selector:
app: verso
ports:
- name: http
port: 80
targetPort: 80
EOF
- name: Deploy Verso image
run: |
kubectl -n verso set image deployment/verso \
verso=registry.alocoq.fr/verso:stable
kubectl -n verso rollout restart deployment/verso
kubectl -n verso rollout status deployment/verso --timeout=600s
- name: Create initial admin (only if no users exist)
run: |
COUNT=$(kubectl -n verso exec deploy/mongo -- mongosh sharelatex --quiet --eval 'db.users.countDocuments()' | tr -d '[:space:]')
if [ "$COUNT" = "0" ]; then
echo "No users yet — creating the initial admin account"
kubectl -n verso exec deploy/verso -- bash -lc '
cd /overleaf/services/web
node modules/server-ce-scripts/scripts/create-user \
--admin \
--email=alois.coquillard@gmail.com
'
else
echo "Users already exist ($COUNT) — skipping admin creation"
fi
+339
View File
@@ -0,0 +1,339 @@
name: Build and Deploy Verso
on:
push:
branches:
- main
workflow_dispatch:
env:
SITE_URL: https://test.alocoq.fr
jobs:
deploy:
runs-on: native
timeout-minutes: 240
steps:
- name: Build and push Verso images with BuildKit
run: |
kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true
cat <<'EOF' | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: verso-buildkit
namespace: ci
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: prepare
image: alpine/git:latest
command: ["sh", "-c"]
args:
- |
set -eux
REG=registry.git.svc.cluster.local:5000
git clone --depth 1 https://git.alocoq.fr/alois/verso.git /workspace/repo
# (#1) Build the base image only when it actually changes.
# The base layers' only repo input is Dockerfile-base, so
# we key on a content hash of that file: the base is tagged
# verso-base:base-<hash> and the app builds FROM that exact
# tag. If a base with this hash is already in the registry,
# the heavy base build (apt, TeX Live, Quarto) is skipped.
BTAG=$(sha256sum /workspace/repo/server-ce/Dockerfile-base | cut -c1-16)
printf '%s' "$BTAG" > /workspace/base_tag
if wget -qO- "http://$REG/v2/verso-base/tags/list" 2>/dev/null | grep -q "\"base-$BTAG\""; then
echo "Base image base-$BTAG already present - skipping base build"
else
touch /workspace/build-base
echo "Base image base-$BTAG not found - base will be built"
fi
volumeMounts:
- name: workspace
mountPath: /workspace
containers:
- name: buildkit
image: moby/buildkit:latest
securityContext:
privileged: true
command: ["sh", "-c"]
args:
- |
set -eux
# Push to the in-cluster registry (plain HTTP) to bypass
# the Traefik ingress, whose read timeout was killing the
# multi-GB TeX Live layer upload mid-stream. Mark the
# registry http+insecure so both push and the base pull
# for the app build treat it as plain HTTP. Written inside
# the container so no extra k8s resources are needed.
REG=registry.git.svc.cluster.local:5000
mkdir -p /etc/buildkit
printf '[registry."%s"]\n http = true\n insecure = true\n' "$REG" > /etc/buildkit/buildkitd.toml
BTAG=$(cat /workspace/base_tag)
BASE_REF="$REG/verso-base:base-$BTAG"
# (#1) Base build, only when prepare flagged it changed.
# (#2) Import/export a registry layer cache so that, when
# the base does change, unchanged layers (e.g. apt) are
# still reused instead of rebuilt from scratch.
if [ -f /workspace/build-base ]; then
buildctl-daemonless.sh build \
--frontend=dockerfile.v0 \
--local context=/workspace/repo \
--local dockerfile=/workspace/repo/server-ce \
--opt filename=Dockerfile-base \
--import-cache type=registry,ref=$REG/verso-cache:base \
--export-cache type=registry,ref=$REG/verso-cache:base,mode=max \
--output type=image,name=$BASE_REF,push=true,registry.insecure=true
else
echo "Reusing existing base image $BASE_REF"
fi
# App image, built FROM the content-pinned base tag.
# (#2) The registry cache lets yarn install be skipped when
# package.json is unchanged; the web build only re-runs
# when the frontend source actually changes.
buildctl-daemonless.sh build \
--frontend=dockerfile.v0 \
--local context=/workspace/repo \
--local dockerfile=/workspace/repo/server-ce \
--opt filename=Dockerfile \
--opt build-arg:OVERLEAF_BASE_TAG=$BASE_REF \
--import-cache type=registry,ref=$REG/verso-cache:app \
--export-cache type=registry,ref=$REG/verso-cache:app,mode=max \
--output type=image,name=$REG/verso:latest,push=true,registry.insecure=true
volumeMounts:
- name: workspace
mountPath: /workspace
volumes:
- name: workspace
emptyDir: {}
EOF
- name: Wait for build
run: |
kubectl -n ci wait --for=condition=complete job/verso-buildkit --timeout=14400s
- name: Show build logs
if: always()
run: |
kubectl -n ci logs job/verso-buildkit -c prepare || true
kubectl -n ci logs job/verso-buildkit -c buildkit || true
- name: Recreate test dependencies
run: |
kubectl -n test delete deployment mongo redis --ignore-not-found=true --wait=true
kubectl -n test delete service mongo redis --ignore-not-found=true --wait=true
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: mongo
namespace: test
spec:
replicas: 1
selector:
matchLabels:
app: mongo
template:
metadata:
labels:
app: mongo
spec:
containers:
- name: mongo
image: mongo:8
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
ports:
- containerPort: 27017
volumeMounts:
- name: mongo-data
mountPath: /data/db
volumes:
- name: mongo-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: mongo
namespace: test
spec:
selector:
app: mongo
ports:
- name: mongo
port: 27017
targetPort: 27017
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: test
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: test
spec:
selector:
app: redis
ports:
- name: redis
port: 6379
targetPort: 6379
EOF
kubectl -n test rollout status deployment/mongo --timeout=180s
kubectl -n test rollout status deployment/redis --timeout=180s
sleep 5
kubectl -n test exec deploy/mongo -- mongosh --eval '
rs.initiate({
_id: "rs0",
members: [{ _id: 0, host: "mongo:27017" }]
})
'
kubectl -n test exec deploy/mongo -- mongosh --eval '
while (rs.status().myState !== 1) {
sleep(1000)
}
print("Mongo replica set is PRIMARY")
'
- name: Ensure Verso deployment exists
run: |
# Stamp the instance name with this build number, e.g. "Verso V0.83 Alpha".
NAV_TITLE="Verso V0.${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}} Alpha"
cat <<'EOF' | sed "s|__NAV_TITLE__|${NAV_TITLE}|g" | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: verso
namespace: test
spec:
replicas: 1
selector:
matchLabels:
app: verso
template:
metadata:
labels:
app: verso
spec:
containers:
- name: verso
# Pull via the public address: the cluster nodes' containerd
# is configured for registry.alocoq.fr, not the in-cluster
# service name. Both front the same registry storage, so the
# image pushed via the in-cluster address resolves here too.
image: registry.alocoq.fr/verso:latest
ports:
- containerPort: 80
env:
- name: OVERLEAF_MONGO_URL
value: mongodb://mongo:27017/sharelatex?replicaSet=rs0
- name: OVERLEAF_REDIS_HOST
value: redis
- name: REDIS_HOST
value: redis
- name: OVERLEAF_APP_NAME
value: Verso
- name: OVERLEAF_NAV_TITLE
value: "__NAV_TITLE__"
- name: OVERLEAF_SITE_URL
value: https://test.alocoq.fr
# Default UI language for the instance.
- name: OVERLEAF_SITE_LANGUAGE
value: fr
# Allow anonymous visitors to reach the site so link
# sharing and public presentation links work without a
# login. Per-project and per-route access checks still
# apply; private presentation links still require login.
- name: OVERLEAF_ALLOW_PUBLIC_ACCESS
value: "true"
# Also let anonymous visitors use read-AND-write share
# links (edit without an account). Read-only links only
# need OVERLEAF_ALLOW_PUBLIC_ACCESS above.
- name: OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING
value: "true"
# Let Quarto Python cells use a project's requirements.txt:
# the compiler installs it into a cached venv. Gated to the
# project owner + invited collaborators (never anonymous /
# link-sharing users).
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true"
---
apiVersion: v1
kind: Service
metadata:
name: verso
namespace: test
spec:
selector:
app: verso
ports:
- name: http
port: 80
targetPort: 80
EOF
- name: Deploy Verso image
run: |
kubectl -n test set image deployment/verso \
verso=registry.alocoq.fr/verso:latest
kubectl -n test rollout restart deployment/verso
kubectl -n test rollout status deployment/verso --timeout=300s
- name: Create admin user
run: |
sleep 20
kubectl -n test exec deploy/verso -- bash -lc '
cd /overleaf/services/web
node modules/server-ce-scripts/scripts/create-user \
--admin \
--email=test@example.com || true
'
- name: Cleanup
if: always()
run: |
kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true
+205 -52
View File
@@ -1,80 +1,233 @@
<h1 align="center">
<br>
<a href="https://www.overleaf.com"><img src="doc/logo.png" alt="Overleaf" width="300"></a>
</h1>
<h4 align="center">An open-source online real-time collaborative LaTeX editor.</h4>
<p align="center">
<a href="https://github.com/overleaf/overleaf/wiki">Wiki</a> •
<a href="https://www.overleaf.com/for/enterprises">Server Pro</a> •
<a href="#contributing">Contributing</a> •
<a href="https://mailchi.mp/overleaf.com/community-edition-and-server-pro">Mailing List</a> •
<a href="#authors">Authors</a> •
<a href="#license">License</a>
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
</p>
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Community Edition">
<p align="center">
Figure 1: A screenshot of a project being edited in Overleaf Community Edition.
</p>
**A collaborative, real-time editor for Quarto, LaTeX and Typst — documents and presentations.**
## Community Edition
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:
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. We run a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
| 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 |
> [!CAUTION]
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
All three coexist on one server; no per-project configuration is required to
pick the engine.
For more information on Sandbox Compiles check out our [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles).
---
## Enterprise
## Features
If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises)
- **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.
## Keeping up to date
## Output formats
Sign up to the [mailing list](https://mailchi.mp/overleaf.com/community-edition-and-server-pro) to get updates on Overleaf releases and development.
In the YAML frontmatter of a `.qmd` file:
## Installation
```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
```
We have detailed installation instructions in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
Typst ships inside Quarto, so `format: typst` needs no separate installation.
## Upgrading
> **Note on display math**: keep `$$ … $$` blocks on a single line. Multi-line
> display-math blocks can trigger YAML parse errors in some Quarto versions.
If you are upgrading from a previous version of Overleaf, please see the [Release Notes section on the Wiki](https://github.com/overleaf/overleaf/wiki#release-notes) for all of the versions between your current version and the version you are upgrading to.
## Quick start
## Overleaf Docker Image
### With Docker
This repo contains two dockerfiles, [`Dockerfile-base`](server-ce/Dockerfile-base), which builds the
`sharelatex/sharelatex-base` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the
`sharelatex/sharelatex` (or "community") image.
```bash
docker run -d \
-p 80:80 \
-v ~/verso_data:/var/lib/overleaf \
--name verso \
registry.alocoq.fr/verso:latest
```
The Base image generally contains the basic dependencies like `wget`, plus `texlive`.
We split this out because it's a pretty heavy set of
dependencies, and it's nice to not have to rebuild all of that every time.
Open `http://localhost` in your browser, then visit `/launchpad` on first run to
create the admin account.
The `sharelatex/sharelatex` image extends the base image and adds the actual Overleaf code
and services.
### Build from source
Use `make build-base` and `make build-community` from `server-ce/` to build these images.
```bash
# Build the base image (system deps + Quarto + TeX Live)
cd server-ce
make build-base
We use the [Phusion base-image](https://github.com/phusion/baseimage-docker)
(which is extended by our `base` image) to provide us with a VM-like container
in which to run the Overleaf services. Baseimage uses the `runit` service
manager to manage services, and we add our init-scripts from the `server-ce/runit`
folder.
# Build the application image
make build-community
```
| File | Purpose |
|------|---------|
| `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
Verso is a microservices monorepo (Yarn workspaces). All services run inside a
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 → document-updater → Redis pub/sub → real-time → browser
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
```
| Service | Role |
|---------|------|
| `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` (`.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
Verso inherits all of Overleaf's environment variables (prefixed `OVERLEAF_`).
The most commonly needed:
| Variable | Default | Description |
|----------|---------|-------------|
| `OVERLEAF_APP_NAME` | `Verso` | Name shown in the UI |
| `OVERLEAF_NAV_TITLE` | — | Instance name/version shown in the top bar |
| `OVERLEAF_MONGO_URL` | `mongodb://mongo/sharelatex` | MongoDB connection string |
| `OVERLEAF_REDIS_HOST` | `localhost` | Redis host |
| `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 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).
The main additions on top of upstream are:
- 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.
## Contributing
Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contributing to the development of Overleaf.
## Authors
[The Overleaf Team](https://www.overleaf.com/about)
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
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
GNU Affero General Public License v3 — see [LICENSE](LICENSE).
Copyright (c) Overleaf, 2014-2025.
Copyright © Overleaf, 20142026 (original code).
Verso modifications © Aloïs Coquillard, 2026.
+100
View File
@@ -0,0 +1,100 @@
# Design: per-project Python dependencies (cached virtualenv)
Status: **Phase 1 implemented** (gated behind `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV`,
on in the deployment). Network egress policy and venv eviction (Phases 23)
remain. Captures the plan for letting Quarto `{python}` cells use libraries
beyond the curated base set.
## What ships in Phase 1
- A project root `requirements.vrf` is installed into a venv cached by its
sha256, created with `python3 -m venv --system-site-packages`; `QuartoRunner`
points Quarto at it via `QUARTO_PYTHON`. A per-hash `flock` serialises
concurrent builds; pip output is merged into `output.log`; on failure the
render falls back to the base interpreter (and the missing-package message
surfaces). Venvs live under `PYTHON_VENVS_DIR`
(default `/var/lib/overleaf/data/python-venvs`).
- Gated by `userCanInstallPython` (`PythonVenvGate.mjs`) to the project owner +
invited collaborators (any role) — never anonymous / link-sharing users —
threaded to CLSI as `allowPythonInstall` on the editor compile, presentation
export, and publish paths.
### Known Phase-1 limitations
- The first build of a heavy `requirements.vrf` runs within the compile
timeout; a very large install can be killed and retried next compile (the
venv is only marked complete on success).
- No egress restriction yet (Phase 2) — installs reach PyPI directly.
- No eviction yet (Phase 3) — venvs accumulate under `PYTHON_VENVS_DIR`.
## Background
Quarto executes `` ```{python} `` cells through a Jupyter kernel. The base image
([`server-ce/Dockerfile-base`](../server-ce/Dockerfile-base)) bundles a curated
scientific stack (numpy, pandas, scipy, matplotlib, seaborn, scikit-learn,
sympy, plotly, tabulate). Anything outside that set currently fails the render
with `ModuleNotFoundError`.
As a first step that already shipped, the Quarto log parser
([`quarto-log-parser.ts`](../services/web/frontend/js/ide/log-parser/quarto-log-parser.ts))
turns a missing-package traceback into an actionable message. This document is
the *next* step: letting a project declare and install its own dependencies.
**Key constraint:** the instance runs with anonymous read+write enabled
(`OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true`), so compiles can be
triggered by untrusted users. Installing arbitrary packages is therefore a
security decision, not just a convenience.
## Mechanism
1. **Declaration.** A standard `requirements.vrf` at the project root opts the
project in (familiar, Quarto-agnostic, supports version pinning).
2. **Keying.** CLSI hashes `sha256(requirements.vrf + python version)`. The hash
names a venv directory on a **persistent volume**, e.g.
`…/data/python-venvs/<hash>/`. Identical dependency sets share one venv across
projects and compiles.
3. **Build-if-missing.** `python3 -m venv --system-site-packages <dir>` (so the
bundled stack stays visible and only the *extra* deps are installed — smaller
and faster), then `<dir>/bin/pip install -r requirements.vrf`. Guard with a
per-hash `flock` so concurrent compiles don't build the same venv twice.
4. **Point Quarto at it.** Set `QUARTO_PYTHON=<dir>/bin/python3` in the render
environment (threaded web → CLSI exactly like `exportMode`). With
`--system-site-packages`, `ipykernel` from the base is importable, so the
kernel runs in that interpreter with base + project packages.
## Guard rails
- **Auth gating.** Only run the install path for **logged-in owner/collaborator**
compiles. Anonymous-link compiles use the plain base interpreter and never
trigger installs. Web decides and passes a boolean to CLSI; default-deny.
- **Network egress.** The compile environment must reach PyPI to install.
Restrict egress to PyPI / an internal mirror only (k8s NetworkPolicy + pip
`--index-url`), not arbitrary hosts.
- **Resource caps.** Install timeout, venv size cap, max package count; surface
overruns as a clear log error.
- **Trust boundary.** Even gated, a trusted user installing packages is
arbitrary code execution in the sandbox. Containment stays the CLSI container
+ resource limits + egress policy. This is owner-trust-level by design.
## Lifecycle
- **Eviction.** `touch` the venv on use; an LRU cleanup job prunes the oldest
venvs when the volume exceeds a size budget.
- **Failure UX.** pip errors flow into the log panel (reusing the friendly-error
pattern) showing pip's output.
## Rollout
- **Phase 1.** Detection + `flock` venv build + `QUARTO_PYTHON`, behind a
settings flag (default **off**), gated to logged-in owner, dev volume.
- **Phase 2.** Egress NetworkPolicy + index pinning + eviction job.
- **Phase 3.** Nicer pip-error surfacing + a small project-settings UI
affordance.
## Open decisions
- `requirements.vrf` vs a frontmatter field vs both?
- Shared global venv volume vs per-user namespacing (sharing is cheaper;
per-user is stricter isolation)?
- Allow native/compiled wheels (broader support) vs wheels-only/no-build
(tighter security)?
+37 -7
View File
@@ -3,7 +3,8 @@
# Overleaf Community Edition (overleaf/overleaf)
# ---------------------------------------------
ARG OVERLEAF_BASE_TAG=sharelatex/sharelatex-base:latest
#ARG OVERLEAF_BASE_TAG=sharelatex/sharelatex-base:latest
ARG OVERLEAF_BASE_TAG=sharelatex/sharelatex-base:5
FROM $OVERLEAF_BASE_TAG
WORKDIR /overleaf
@@ -18,26 +19,55 @@ COPY server-ce/genScript.js server-ce/services.js /overleaf/
# Corepack setup, shared between all the images.
ENV PATH="/overleaf/node_modules/.bin:$PATH"
ENV COREPACK_HOME=/opt/corepack
RUN corepack enable && corepack install -g yarn@4.14.1
#RUN corepack enable && corepack install -g yarn@4.14.1
RUN corepack enable && corepack prepare yarn@4.14.1 --activate
ENV COREPACK_ENABLE_NETWORK=0
# Install yarn dependencies
# -------------------------
# The git-sourced @replit/codemirror-* deps are prepared with Yarn Classic,
# whose cache lives in /usr/local/share/.cache/yarn. We mount that as a *tmpfs*
# (fresh every build) rather than a persistent BuildKit cache: when it was
# persistent, BuildKit would garbage-collect/evict part of it between builds,
# leaving a half-populated cache that Yarn Classic then tripped over (missing
# .yarn-tarball.tgz / EEXIST). A clean cache per build is reliable; the cost is
# re-fetching that small set of git deps. The valuable Berry cache
# (server-ce-yarn-cache) stays persistent. YARN_NETWORK_CONCURRENCY=1 is kept
# as cheap insurance against concurrent writes to the fresh cache.
#
# Preparing those git deps also makes Yarn Classic fetch esbuild's ~10
# per-platform binaries, whose downloads occasionally arrive truncated ("the
# file appears to be corrupt" / missing .yarn-tarball.tgz). Since the tmpfs is
# fresh each build there is nothing to fall back to, so we wrap the step in a
# small retry loop that wipes the classic cache and re-fetches before failing.
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
--mount=type=cache,target=/usr/local/share/.cache/yarn,id=server-ce-yarn-fallback-cache \
--mount=type=tmpfs,target=/tmp node genScript install | bash
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
--mount=type=tmpfs,target=/tmp \
for i in 1 2 3; do \
node genScript install | YARN_NETWORK_CONCURRENCY=1 bash && exit 0; \
echo "==== install attempt $i failed; wiping Yarn Classic cache and retrying ===="; \
rm -rf /usr/local/share/.cache/yarn/* 2>/dev/null || true; \
done; \
exit 1
# Add the actual source files
# ---------------------------
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
--mount=type=cache,target=/usr/local/share/.cache/yarn,id=server-ce-yarn-fallback-cache \
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
--mount=type=cache,target=/overleaf/services/web/node_modules/.cache,id=server-ce-webpack-cache \
--mount=type=tmpfs,target=/tmp \
node genScript compile | bash
for i in 1 2 3; do \
node genScript compile | YARN_NETWORK_CONCURRENCY=1 bash && exit 0; \
echo "==== compile attempt $i failed; wiping Yarn Classic cache and retrying ===="; \
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
rm -rf /usr/local/share/.cache/yarn/* 2>/dev/null || true; \
done; \
echo "==== PACK LOGS (all attempts failed) ===="; \
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
exit 1
# Copy runit service startup scripts to its location
# --------------------------------------------------
ADD server-ce/runit /etc/service
+101 -27
View File
@@ -4,7 +4,7 @@
FROM phusion/baseimage:noble-1.0.3
# Makes sure LuaTex cache is writable
# Makes sure LuaTeX cache is writable
# -----------------------------------
ENV TEXMFVAR=/var/lib/overleaf/tmp/texmf-var
@@ -39,44 +39,118 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
/etc/nginx/nginx.conf \
/etc/nginx/sites-enabled/default
# Install TexLive
# ---------------
# CTAN mirrors occasionally fail, in that case install TexLive using a
# different server, for example https://ctan.crest.fr
# Install Quarto (bundles Typst for PDF rendering — no LaTeX needed)
# ------------------------------------------------------------------
ARG QUARTO_VERSION=1.6.39
RUN curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.deb" -o /tmp/quarto.deb \
&& dpkg -i /tmp/quarto.deb \
&& rm /tmp/quarto.deb \
&& mkdir -p /var/www/.cache/quarto /var/www/.local/share \
&& chown -R www-data:www-data /var/www/.cache /var/www/.local
# Pre-install popular Quarto extensions
# -----------------------------------------------------------------------
# Extensions land in /opt/quarto-extensions/_extensions/<author>/<name>/.
# QuartoRunner copies them into each project's compile dir (no-clobber,
# so user-uploaded extensions in their project always take precedence).
# To add more: append another line: && quarto add --no-prompt <author>/<repo>
# -----------------------------------------------------------------------
RUN mkdir -p /opt/quarto-extensions \
&& cd /opt/quarto-extensions \
\
# Typst document formats
&& quarto add --no-prompt igorlima/charged-ieee \
\
# RevealJS presentation plugins (official Quarto extensions)
&& quarto add --no-prompt quarto-ext/fontawesome \
&& quarto add --no-prompt quarto-ext/attribution \
&& quarto add --no-prompt quarto-ext/pointer \
&& quarto add --no-prompt quarto-ext/drop \
\
&& chown -R www-data:www-data /opt/quarto-extensions
# Install Jupyter so Quarto can execute Python code cells in documents/decks
# -----------------------------------------------------------------------
# Quarto runs ```{python}``` cells through a Jupyter kernel. It uses the system
# python3 it detected (/usr/bin/python3), so Jupyter must be installed there.
# We install only the headless execution stack Quarto needs (jupyter-client +
# nbclient/nbformat + the ipykernel kernel + pyyaml, which Quarto's own
# /opt/quarto/share/jupyter wrapper imports), not the notebook/lab servers, and
# register a system-wide "python3" kernelspec under /usr/local/share/jupyter so
# it is discoverable regardless of HOME/XDG. Noble's Python is externally
# managed (PEP 668), hence --break-system-packages in this controlled image.
# The runtime user (www-data) writes Jupyter's runtime/connection files under
# its HOME (/var/www/.local), which is made writable in the Quarto step above.
# python3-venv is needed so a project's requirements.txt can be installed into
# a per-project venv (see QuartoRunner / PythonVenvGate).
RUN apt-get update \
&& apt-get install -y python3-pip python3-venv \
&& pip3 install --no-cache-dir --break-system-packages \
jupyter-core jupyter-client nbclient nbformat ipykernel pyyaml \
&& python3 -m ipykernel install --prefix /usr/local --name python3 --display-name "Python 3" \
# Bundle the common scientific-Python stack so most decks "just work" without
# any per-project install. matplotlib renders headless (Agg) automatically;
# opencv-python-headless is the GUI-less OpenCV build (provides cv2) suited to
# a server. To add more later, append to this list (the cheapest way to cover
# a library many projects need).
&& pip3 install --no-cache-dir --break-system-packages \
numpy pandas scipy matplotlib seaborn scikit-learn sympy plotly tabulate \
opencv-python-headless tqdm \
&& rm -rf /var/lib/apt/lists/* /root/.cache
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
# -----------------------------------------------------------------------
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
# reveal.js slides to a faithful, one-slide-per-page PDF. Chromium is the
# open-source engine (BSD); decktape is MIT, Puppeteer Apache-2.0 — all
# permissive and AGPL-compatible. They are invoked as a separate process
# (QuartoRunner runs `decktape ...`), never linked into the app.
#
# # docker build \
# --build-arg TEXLIVE_MIRROR=https://ctan.crest.fr/tex-archive/systems/texlive/tlnet \
# -f Dockerfile-base -t sharelatex/sharelatex-base .
# Puppeteer downloads its Chromium into PUPPETEER_CACHE_DIR during the global
# install; we put it in a world-readable /opt path so the www-data runtime user
# can launch it. Playwright is used only as a robust, distro-aware installer for
# Chromium's system libraries (handles Ubuntu Noble's t64 package renames).
ENV PUPPETEER_CACHE_DIR=/opt/puppeteer
RUN npm install -g decktape \
&& npx --yes playwright@latest install-deps chromium \
&& chmod -R a+rX /opt/puppeteer \
&& rm -rf /root/.npm /root/.cache
# Install TeX Live (for compiling .tex projects with latexmk)
# -----------------------------------------------------------------------
# Verso compiles .qmd with Quarto and .tex with latexmk; both engines live
# side by side.
#
# MINIMAL install (current): the upstream-Overleaf approach — scheme-basic
# (~300 MB) plus a few essential packages via tlmgr. Fast to build and small.
# Many documents that need extra packages (tikz, beamer, siunitx, extra
# fonts, ...) will NOT compile out of the box; users can be told to keep
# those projects in Quarto/Typst for now.
#
# TO GO FULL LATER (when the project is mature): change
# selected_scheme scheme-basic -> scheme-full
# and optionally drop the explicit `tlmgr install` line. That single change
# restores a complete LaTeX toolchain at the cost of size/build time.
# Alternatively add individual packages to the `tlmgr install` list below.
# -----------------------------------------------------------------------
ARG TEXLIVE_MIRROR=https://mirror.ox.ac.uk/sites/ctan.org/systems/texlive/tlnet
ENV PATH="${PATH}:/usr/local/texlive/bin/x86_64-linux"
RUN mkdir /install-tl-unx \
&& wget --quiet https://tug.org/texlive/files/texlive.asc \
&& gpg --import texlive.asc \
&& rm texlive.asc \
&& wget --quiet ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz \
&& wget --quiet ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz.sha512 \
&& wget --quiet ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz.sha512.asc \
&& gpg --verify install-tl-unx.tar.gz.sha512.asc \
&& sha512sum -c install-tl-unx.tar.gz.sha512 \
&& tar -xz -C /install-tl-unx --strip-components=1 -f install-tl-unx.tar.gz \
&& rm install-tl-unx.tar.gz* \
&& echo "tlpdbopt_autobackup 0" >> /install-tl-unx/texlive.profile \
&& curl -sSL ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz \
| tar -xzC /install-tl-unx --strip-components=1 \
&& echo "tlpdbopt_autobackup 0" >> /install-tl-unx/texlive.profile \
&& echo "tlpdbopt_install_docfiles 0" >> /install-tl-unx/texlive.profile \
&& echo "tlpdbopt_install_srcfiles 0" >> /install-tl-unx/texlive.profile \
&& echo "selected_scheme scheme-basic" >> /install-tl-unx/texlive.profile \
\
&& echo "TEXDIR /usr/local/texlive" >> /install-tl-unx/texlive.profile \
&& /install-tl-unx/install-tl \
-profile /install-tl-unx/texlive.profile \
-repository ${TEXLIVE_MIRROR} \
\
&& $(find /usr/local/texlive -name tlmgr) path add \
&& tlmgr install --repository ${TEXLIVE_MIRROR} \
&& /usr/local/texlive/bin/x86_64-linux/tlmgr install \
--repository ${TEXLIVE_MIRROR} \
latexmk \
texcount \
synctex \
etoolbox \
xetex \
&& tlmgr path add \
&& rm -rf /install-tl-unx
+32
View File
@@ -0,0 +1,32 @@
FROM phusion/baseimage:noble-1.0.2
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
bash \
ca-certificates \
curl \
git \
gnupg \
nginx \
logrotate \
cron \
redis-tools \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Node.js 22
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Needed by Overleaf scripts
RUN npm install -g corepack
# Runit/log dirs expected by Overleaf
RUN mkdir -p /etc/service /var/log/overleaf /overleaf
WORKDIR /overleaf
+6
View File
@@ -173,6 +173,12 @@ const settings = {
clsiCacheDir: Path.join(DATA_DIR, 'cache'),
// Where to write the output files to disk after running LaTeX
outputDir: Path.join(DATA_DIR, 'output'),
// Where to store published-presentation snapshots served at /p/:token.
// Lives on the data volume so it is writable by the app user (and, with a
// persistent volume, survives restarts).
publishedPresentationsFolder:
process.env.PUBLISHED_PRESENTATIONS_PATH ||
Path.join(DATA_DIR, 'published'),
},
// Server Config
+108
View File
@@ -0,0 +1,108 @@
# App tier for the prod (verso namespace) instance: the Verso Deployment and
# Service. Matches what the deploy workflow applies, except OVERLEAF_NAV_TITLE
# is a static "Verso Alpha" here — the workflow overwrites it with the build
# number ("Verso V0.<n> Alpha") on each deploy.
#
# The image registry.alocoq.fr/verso:stable is produced by the prod workflow
# (push to the `prod` branch). If you apply this file before the first prod
# build, the pod will sit in ImagePullBackOff until that image exists — that's
# expected.
#
# kubectl apply -f server-ce/k8s/verso-prod-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: verso
namespace: verso
spec:
replicas: 1
# RWO data volume → can't run two pods at once; recreate on update.
strategy:
type: Recreate
selector:
matchLabels:
app: verso
template:
metadata:
labels:
app: verso
spec:
securityContext:
# App runs as www-data (uid/gid 33); make the data volume
# group-writable by it.
fsGroup: 33
initContainers:
- name: init-data-perms
image: busybox:latest
command: ["sh", "-c"]
args:
- |
set -eux
mkdir -p /data/template_files /data/user_files \
/data/compiles /data/cache /data/output /data/published
chown -R 33:33 /data
volumeMounts:
- name: verso-data
mountPath: /data
containers:
- name: verso
image: registry.alocoq.fr/verso:stable
# :stable is a fixed tag, so force a pull on every rollout to pick up
# the freshly built image.
imagePullPolicy: Always
ports:
- containerPort: 80
env:
- name: OVERLEAF_MONGO_URL
value: mongodb://mongo:27017/sharelatex?replicaSet=rs0
- name: OVERLEAF_REDIS_HOST
value: redis
- name: REDIS_HOST
value: redis
- name: OVERLEAF_APP_NAME
value: Verso
- name: OVERLEAF_NAV_TITLE
value: "Verso Alpha"
- name: OVERLEAF_SITE_URL
value: https://verso.alocoq.fr
- name: OVERLEAF_SITE_LANGUAGE
value: fr
# Allow anonymous visitors so public published-presentation links
# and read-only share links work without login.
- name: OVERLEAF_ALLOW_PUBLIC_ACCESS
value: "true"
# NB: anonymous read-AND-write sharing is intentionally NOT enabled
# (compiles are unsandboxed → only trusted accounts may trigger
# them). Public self-registration is also off (CE default).
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true"
# SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars
# are loaded from the optional 'verso-smtp' Secret — its keys must be
# named exactly like these env vars (see the kubectl create secret
# command in the docs). Optional, so the app still boots before the
# secret exists; email just stays off.
envFrom:
- secretRef:
name: verso-smtp
optional: true
volumeMounts:
- name: verso-data
mountPath: /var/lib/overleaf/data
volumes:
- name: verso-data
persistentVolumeClaim:
claimName: verso-data
---
apiVersion: v1
kind: Service
metadata:
name: verso
namespace: verso
spec:
selector:
app: verso
ports:
- name: http
port: 80
targetPort: 80
+102
View File
@@ -0,0 +1,102 @@
# Data tier for the prod (verso namespace) instance: Mongo + Redis Deployments
# and Services. Identical to what the deploy workflow applies — provided as a
# standalone file so you can bootstrap and validate the namespace before the
# first prod build (and before granting the runner access).
#
# Order:
# 1. kubectl apply -f server-ce/k8s/verso-prod-pvcs.yaml (with storageClass)
# 2. kubectl apply -f server-ce/k8s/verso-prod-data.yaml (this file)
# 3. wait for mongo to be Ready, then initialise the replica set ONCE:
# kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval \
# 'rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]})'
# (the workflow also does this idempotently, so it's optional here)
apiVersion: apps/v1
kind: Deployment
metadata:
name: mongo
namespace: verso
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: mongo
template:
metadata:
labels:
app: mongo
spec:
containers:
- name: mongo
image: mongo:8
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
ports:
- containerPort: 27017
volumeMounts:
- name: mongo-data
mountPath: /data/db
volumes:
- name: mongo-data
persistentVolumeClaim:
claimName: mongo-data
---
apiVersion: v1
kind: Service
metadata:
name: mongo
namespace: verso
spec:
selector:
app: mongo
ports:
- name: mongo
port: 27017
targetPort: 27017
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: verso
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7
# AOF persistence so a restart doesn't drop in-flight edits before
# they're flushed to Mongo.
command: ["redis-server", "--appendonly", "yes"]
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: verso
spec:
selector:
app: redis
ports:
- name: redis
port: 6379
targetPort: 6379
@@ -0,0 +1,44 @@
# Example Ingress for the prod (verso namespace) instance at verso.alocoq.fr.
#
# This is NOT applied by the deploy workflow on purpose: the test ingress is
# managed by hand, and TLS/annotations depend on your cluster's ingress
# controller (Traefik) and cert setup. Copy this, adapt it to match how
# test.alocoq.fr is wired, then `kubectl apply -f` it once.
#
# Prerequisites:
# - DNS: verso.alocoq.fr → your ingress/load-balancer IP.
# - A TLS cert for verso.alocoq.fr (cert-manager, or a manually created
# Secret referenced under tls.secretName).
#
# Adjust:
# - ingressClassName (e.g. "traefik") to match your controller.
# - annotations (cert-manager issuer, Traefik entrypoints/router, etc.) to
# match the test ingress.
# - the TLS block (cert-manager will create the secret; otherwise create it).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: verso
namespace: verso
annotations:
# --- adapt these to match your test.alocoq.fr ingress ---
# cert-manager.io/cluster-issuer: letsencrypt-prod
# traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
tls:
- hosts:
- verso.alocoq.fr
secretName: verso-tls
rules:
- host: verso.alocoq.fr
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: verso
port:
number: 80
+49
View File
@@ -0,0 +1,49 @@
# PersistentVolumeClaims for the prod (verso namespace) instance.
#
# Provisioned out of band (not by the deploy workflow) so the storageClass is
# under your control. Create them ONCE, before the first prod deploy:
#
# kubectl apply -f server-ce/k8s/verso-prod-pvcs.yaml
#
# Use a Ceph RBD (block) storageClass for all three — every volume here is
# single-writer ReadWriteOnce (Mongo, Redis, and the single app pod). Set
# storageClassName below to your RBD class (run `kubectl get storageclass` to
# find its name). Sizes are starting points; RBD supports online expansion.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongo-data
namespace: verso
spec:
accessModes: [ReadWriteOnce]
# storageClassName: ceph-rbd # <- set to your RBD (block) storageClass
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: verso
spec:
accessModes: [ReadWriteOnce]
# storageClassName: ceph-rbd # <- set to your RBD (block) storageClass
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: verso-data
namespace: verso
# verso-data is mounted at /var/lib/overleaf/data: user files, compiles, output
# cache, and published-presentation snapshots.
spec:
accessModes: [ReadWriteOnce]
# storageClassName: ceph-rbd # <- set to your RBD (block) storageClass
resources:
requests:
storage: 20Gi
+19 -4
View File
@@ -20,13 +20,28 @@ server {
access_log off;
# Ignore symlinks possibly created by users
disable_symlinks on;
# enable compression for tex auxiliary files, but not for pdf files
# enable compression for text-based output: tex auxiliary files, logs, and
# HTML/CSS/JS (RevealJS presentations). Already-compressed formats (pdf,
# png/jpeg/webp, woff/woff2) are deliberately omitted to avoid wasting CPU.
gzip on;
gzip_types text/plain;
gzip_types text/plain text/html text/css application/javascript application/json image/svg+xml;
gzip_proxied any;
# only compress responses worth compressing
gzip_min_length 1024;
types {
text/plain log blg aux stdout stderr;
application/pdf pdf;
text/html html htm;
text/css css;
application/javascript js;
application/json json;
image/svg+xml svg svgz;
image/png png;
image/jpeg jpeg jpg;
image/gif gif;
image/webp webp;
font/woff woff;
font/woff2 woff2;
application/pdf pdf;
text/plain log blg aux stdout stderr txt;
}
# handle output files for specific users
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ {
+1 -1
View File
@@ -46,7 +46,7 @@ http {
gzip_disable "msie6";
gzip_proxied any; # allow upstream server to compress.
client_max_body_size 50m;
client_max_body_size 500m;
# gzip_vary on;
# gzip_proxied any;
+15 -3
View File
@@ -47,11 +47,14 @@ COPY libraries/settings/ /overleaf/libraries/settings/
COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/
COPY services/clsi/ /overleaf/services/clsi/
FROM app AS with-texlive
FROM app AS with-quarto
ARG QUARTO_VERSION=1.6.39
RUN apt-get update \
&& apt-cache depends texlive-full | grep "Depends: " | grep -v -- "-doc" | grep -v -- "-lang-" | sed 's/Depends: //' | xargs apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends fontconfig inkscape python3-pygments qpdf \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.deb" -o /tmp/quarto.deb \
&& dpkg -i /tmp/quarto.deb \
&& rm /tmp/quarto.deb \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p cache compiles output \
@@ -60,6 +63,15 @@ RUN mkdir -p cache compiles output \
CMD ["node", "--expose-gc", "app.js"]
FROM app
ARG QUARTO_VERSION=1.6.39
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.deb" -o /tmp/quarto.deb \
&& dpkg -i /tmp/quarto.deb \
&& rm /tmp/quarto.deb \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p cache compiles output \
&& chown node:node cache compiles output
+3 -1
View File
@@ -90,7 +90,9 @@ function compile(req, res, next) {
} else {
if (
outputFiles.some(
file => file.path === 'output.pdf' && file.size > 0
file =>
(file.path === 'output.pdf' && file.size > 0) ||
file.path === 'output.html'
)
) {
status = 'success'
+58 -69
View File
@@ -1,12 +1,13 @@
import fsPromises from 'node:fs/promises'
import os from 'node:os'
import Path from 'node:path'
import { callbackify } from 'node:util'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import ResourceWriter from './ResourceWriter.js'
import QuartoRunner from './QuartoRunner.js'
import LatexRunner from './LatexRunner.js'
import TypstRunner from './TypstRunner.js'
import OutputFileFinder from './OutputFileFinder.js'
import OutputCacheManager from './OutputCacheManager.js'
import ClsiMetrics from './Metrics.js'
@@ -18,16 +19,12 @@ import CommandRunner from './CommandRunner.js'
import ContentCacheMetrics from './ContentCacheMetrics.js'
import SynctexOutputParser from './SynctexOutputParser.js'
import CLSICacheHandler from './CLSICacheHandler.js'
import StatsManager from './StatsManager.js'
import SafeReader from './SafeReader.js'
import LatexMetrics from './LatexMetrics.js'
import { callbackifyMultiResult } from '@overleaf/promise-utils'
import * as HistoryResourceWriter from './HistoryResourceWriter.js'
const { downloadLatestCompileCache, downloadOutputDotSynctexFromCompileCache } =
CLSICacheHandler
const { emitPdfStats } = ContentCacheMetrics
const { enableLatexMkMetrics, addLatexFdbMetrics } = LatexMetrics
const { shouldSkipMetrics } = ClsiMetrics
const KNOWN_LATEXMK_RULES = new Set([
@@ -44,6 +41,43 @@ const KNOWN_LATEXMK_RULES = new Set([
const LATEX_PASSES_RULES = new Set(['latex', 'lualatex', 'xelatex', 'pdflatex'])
// Quarto handles .qmd/.md/.Rmd sources; everything else (.tex, .ltx, .Rtex,
// .Rnw) is compiled with latexmk via LatexRunner. Dispatch is by the root
// file's extension, so LaTeX and Quarto projects can coexist on one server.
function _isQuartoFile(rootResourcePath) {
return /\.(qmd|md|rmd)$/i.test(rootResourcePath || '')
}
// A bare Typst source (.typ) compiles straight to PDF with the Typst that
// ships inside Quarto (see TypstRunner), separate from the Quarto pipeline.
function _isTypstFile(rootResourcePath) {
return /\.typ$/i.test(rootResourcePath || '')
}
// Return a runner with a uniform { run, isRunning, kill } interface so the
// rest of CompileManager doesn't need to know which engine is in use.
function _getRunner(rootResourcePath) {
if (_isTypstFile(rootResourcePath)) {
return {
run: (name, opts) => TypstRunner.promises.runTypst(name, opts),
isRunning: name => TypstRunner.isRunning(name),
kill: name => TypstRunner.promises.killTypst(name),
}
}
if (_isQuartoFile(rootResourcePath)) {
return {
run: (name, opts) => QuartoRunner.promises.runQuarto(name, opts),
isRunning: name => QuartoRunner.isRunning(name),
kill: name => QuartoRunner.promises.killQuarto(name),
}
}
return {
run: (name, opts) => LatexRunner.promises.runLatex(name, opts),
isRunning: name => LatexRunner.isRunning(name),
kill: name => LatexRunner.promises.killLatex(name),
}
}
function getCompileName(projectId, userId) {
if (userId != null) {
return `${projectId}-${userId}`
@@ -124,7 +158,11 @@ async function doCompile(request, stats, timings) {
)
// apply a series of file modifications/creations for draft mode and tikz
if (request.draft) {
// Draft mode injects LaTeX preamble commands — skip for Quarto files
const isLatexFile = /\.(tex|ltx|Rtex)$/i.test(
request.rootResourcePath || ''
)
if (request.draft && isLatexFile) {
await DraftModeManager.promises.injectDraftMode(
Path.join(compileDir, request.rootResourcePath)
)
@@ -196,18 +234,10 @@ async function doCompile(request, stats, timings) {
const compileName = getCompileName(request.project_id, request.user_id)
// Record latexmk -time stats for a subset of users
const recordPerformanceMetrics = StatsManager.sampleRequest(
request,
Settings.performanceLogSamplingPercentage
)
// Define a `latexmk` property on the stats object
// to collect latexmk -time stats.
enableLatexMkMetrics(stats)
const runner = _getRunner(request.rootResourcePath)
try {
await LatexRunner.promises.runLatex(compileName, {
await runner.run(compileName, {
directory: compileDir,
mainFile: request.rootResourcePath,
compiler: request.compiler,
@@ -217,6 +247,8 @@ async function doCompile(request, stats, timings) {
environment: env,
compileGroup: request.compileGroup,
stopOnFirstError: request.stopOnFirstError,
exportMode: request.exportMode,
allowPythonInstall: request.allowPythonInstall,
stats,
timings,
})
@@ -294,50 +326,13 @@ async function doCompile(request, stats, timings) {
})
timings.compileE2E = Date.now() - e2eCompileStart
const status = stats['latexmk-errors'] ? 'error' : 'success'
const status = 'success'
_emitMetrics(request, status, stats, timings)
if (stats['pdf-size'] && !shouldSkipMetrics(request)) {
emitPdfStats(stats, timings, request)
}
// Record compile performance for a subset of users
if (recordPerformanceMetrics) {
// Add fdb metrics if available
try {
const fdbFileContent = await _readFdbFile(compileDir)
if (fdbFileContent) {
addLatexFdbMetrics(fdbFileContent, stats)
}
} catch (err) {
// ignore errors reading fdb file
logger.warn(
{ err, projectId, userId },
'error reading fdb file for performance metrics'
)
}
const loadavg = typeof os.loadavg === 'function' ? os.loadavg() : undefined
logger.info(
{
userId: request.user_id,
projectId: request.project_id,
timeTaken: timings.compile,
clsiRequest: request,
stats,
timings,
// explicitly include latexmk stats to bypass the non-enumerable property
latexmk: stats.latexmk,
loadavg1m: loadavg?.[0],
loadavg5m: loadavg?.[1],
loadavg15m: loadavg?.[2],
samplingPercentage: Settings.performanceLogSamplingPercentage,
},
'sampled performance log'
)
}
return { outputFiles, buildId, baseHistoryVersion }
}
@@ -366,33 +361,27 @@ async function _saveOutputFiles({
return { outputFiles, allEntries, buildId }
}
// Set a maximum size for reading output.fdb_latexmk files
// This limit is chosen to prevent excessive memory usage and ensure performance,
// as fdb files are typically much smaller and only metrics are extracted from them.
const MAX_FDB_FILE_SIZE = 1024 * 1024 // 1 MB
async function _readFdbFile(compileDir) {
const fdbFile = Path.join(compileDir, 'output.fdb_latexmk')
const { result } = await SafeReader.promises.readFile(
fdbFile,
MAX_FDB_FILE_SIZE,
'utf8'
)
return result
}
async function stopCompile(projectId, userId) {
const compileName = getCompileName(projectId, userId)
// stopCompile has no root path, so check both runners — only one can be
// active for a given compileName at a time.
const isRunning =
QuartoRunner.isRunning(compileName) ||
LatexRunner.isRunning(compileName) ||
TypstRunner.isRunning(compileName)
const lock = LockManager.getExistingLock(getCompileDir(projectId, userId))
let lockReleased
if (lock) {
lockReleased = lock.waitForRelease()
} else {
if (!LatexRunner.isRunning(compileName)) return
if (!isRunning) return
logger.warn({ projectId, userId }, 'found running compile without lock')
lockReleased = Promise.resolve()
}
await QuartoRunner.promises.killQuarto(compileName)
await LatexRunner.promises.killLatex(compileName)
await TypstRunner.promises.killTypst(compileName)
await lockReleased
}
+7 -7
View File
@@ -197,13 +197,13 @@ function _buildLatexCommand(mainFile, opts = {}) {
command.push(...opts.flags)
}
// TeX Engine selection
const compilerFlag = COMPILER_FLAGS[opts.compiler]
if (compilerFlag) {
command.push(compilerFlag)
} else {
throw new Error(`unknown compiler: ${opts.compiler}`)
}
// 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
// back to pdfLaTeX rather than throwing — throwing here surfaces as an opaque
// HTTP 500 with no compile log.
const compilerFlag = COMPILER_FLAGS[opts.compiler] || COMPILER_FLAGS.pdflatex
command.push(compilerFlag)
// We want to run latexmk on the tex file which we will automatically
// generate from the Rtex/Rmd/md file.
+2 -1
View File
@@ -89,9 +89,10 @@ export default CommandRunner = {
err.terminated = true
return callback(err)
} else if (code === 1) {
// exit status from chktex
// exit status from chktex (and any compiler that exits 1 on failure)
err = new Error('exited')
err.code = code
err.stdout = stdout // preserve captured output for callers
return callback(err)
} else {
return callback(null, { stdout, exitCode: code })
+14 -1
View File
@@ -20,16 +20,29 @@ async function walkFolder(compileDir, d, files, allEntries) {
}
}
// Media that an HTML/RevealJS deck references at runtime (img/video/audio).
// These are usually project *input* files, which would normally be excluded
// from the output set — but for HTML output the browser fetches them from the
// output path, so they must be served. (For PDF output they are embedded, so
// the exclusion still applies.)
const MEDIA_REGEX =
/\.(png|jpe?g|gif|svg|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|m4v|mp3|wav|m4a|woff2?|ttf|otf)$/i
async function findOutputFiles(resources, directory) {
const files = []
const allEntries = []
await walkFolder(directory, '', files, allEntries)
const incomingResources = new Set(resources.map(resource => resource.path))
// For HTML output (Quarto/RevealJS), referenced media must be served even
// though it is an input file; see MEDIA_REGEX above.
const hasHtmlOutput = files.includes('output.html')
const outputFiles = []
for (const path of files) {
if (incomingResources.has(path)) continue
if (incomingResources.has(path)) {
if (!(hasHtmlOutput && MEDIA_REGEX.test(path))) continue
}
if (path === '.project-sync-state') continue
outputFiles.push({
path,
+365
View File
@@ -0,0 +1,365 @@
import Path from 'node:path'
import { promisify } from 'node:util'
import logger from '@overleaf/logger'
import CommandRunner from './CommandRunner.js'
import fs from 'node:fs'
// Maps currently-running Quarto jobs: compileName → PID (or docker container id)
const ProcessTable = {}
function runQuarto(compileName, options, callback) {
const { directory, mainFile, image, environment, compileGroup } = options
const timeout = options.timeout || 60000
logger.debug(
{ directory, timeout, mainFile, compileGroup },
'starting quarto compile'
)
// For the standalone-HTML export we must render a deck whose frontmatter
// carries embed-resources (it cannot be set from the CLI: Quarto only honours
// embed-resources when it is nested under the format, and a document's own
// format block fully overrides project/CLI metadata). So write a temporary
// copy of the root .qmd with the options injected and render that instead.
let renderTarget = mainFile
if (options.exportMode === 'html-standalone') {
renderTarget = _writeStandaloneVariant(directory, mainFile)
}
// Where cached per-project venvs live (shared across projects, keyed by the
// requirements.vrf hash). Must be on a persistent volume in production.
const venvBaseDir =
process.env.PYTHON_VENVS_DIR || '/var/lib/overleaf/data/python-venvs'
const command = _buildQuartoCommand(
renderTarget,
options.exportMode,
Boolean(options.allowPythonInstall),
venvBaseDir
)
ProcessTable[compileName] = CommandRunner.run(
compileName,
command,
directory,
image,
timeout,
environment || {},
compileGroup,
null,
function (error, output) {
delete ProcessTable[compileName]
// Propagate real process-level errors (killed, timed out) but NOT
// ordinary non-zero exit codes from Quarto itself. A Quarto compile
// failure (exit code 1) is not a server error — the absence of
// output.pdf is sufficient for CompileController to return 'failure'.
if (error && (error.terminated || error.timedout)) {
return callback(error)
}
// On exit-code-1 errors LocalCommandRunner attaches stdout to the
// error object; merge it so _writeLogOutput can persist it.
const combined = output || (error ? { stdout: error.stdout || '' } : null)
_writeLogOutput(compileName, directory, combined, () =>
_appendMissingResourceWarnings(directory, () =>
callback(null, combined)
)
)
}
)
}
function _buildQuartoCommand(
renderTarget,
exportMode,
allowPythonInstall,
venvBaseDir
) {
// Run through a POSIX shell so stderr is merged into stdout (2>&1).
// LocalCommandRunner replaces $COMPILE_DIR before the shell sees it.
//
// We do NOT pass --to or --output: let the YAML frontmatter decide the
// output format (typst → output.pdf, revealjs → output.html, etc.).
//
// For a normal preview compile we do NOT embed resources. A self-contained
// single-file HTML breaks reveal.js plugins that load/store resources at
// runtime (e.g. chalkboard, multiplex) and is slow to transfer. Instead
// Quarto emits the HTML plus a sibling "<basename>_files/" asset directory;
// the HTML references it with relative paths. Both the html and the asset
// dir are served from the same .../output/ path, so the relative links
// resolve. For the 'html-standalone' export, runQuarto instead renders a
// temporary copy of the deck (renderTarget) whose frontmatter enables
// embed-resources, producing a single portable file.
//
// After render we rename the produced top-level file to output.pdf or
// output.html. The asset directory keeps its "<basename>_files" name; the
// renamed output.html still points at it via the unchanged relative refs.
//
// The extension merge (cp -rn, no-clobber so user extensions win) and the
// trailing semicolon (so a missing /opt/quarto-extensions doesn't abort)
// are kept. mv uses relative paths because LocalCommandRunner.replace()
// only substitutes the FIRST $COMPILE_DIR and the shell CWD is the dir.
const inputPath = `$COMPILE_DIR/${renderTarget}`
const baseName = renderTarget.replace(/\.[^/.]+$/, '') // strip extension
let tail =
`(mv ${baseName}.pdf output.pdf 2>/dev/null || ` +
`mv ${baseName}.html output.html 2>/dev/null)`
if (exportMode === 'pdf-slides') {
// After producing output.html, print it to output-slides.pdf with decktape
// (headless Chromium via Puppeteer). The CLSI runtime user has no writable
// HOME, so Chromium's crashpad can't create its database and the browser
// dies on launch ("chrome_crashpad_handler: --database is required").
// Point HOME / XDG dirs / the Chromium user-data-dir at a fresh writable
// temp dir to give it somewhere to write.
// --no-sandbox: Chromium can't sandbox as a non-root container user
// --disable-dev-shm-usage: a tiny container /dev/shm crashes Chromium
// --disable-gpu: there is no GPU in the container
tail +=
` && CHROME_HOME="$(mktemp -d)" && ` +
`HOME="$CHROME_HOME" XDG_CONFIG_HOME="$CHROME_HOME" ` +
`XDG_CACHE_HOME="$CHROME_HOME" decktape ` +
`--chrome-arg=--no-sandbox ` +
`--chrome-arg=--disable-dev-shm-usage ` +
`--chrome-arg=--disable-gpu ` +
`--chrome-arg=--user-data-dir="$CHROME_HOME/data" ` +
`"$(pwd)/output.html" output-slides.pdf 2>&1`
}
// For the standalone export, remove the temporary render copy afterwards so
// it can't be mistaken for a project file or picked up by a later preview
// compile. Runs regardless of render success (";").
const cleanup =
exportMode === 'html-standalone'
? `; rm -rf ${baseName}.qmd ${baseName}_files`
: ''
const venvPrep = allowPythonInstall ? _pythonVenvPrep(venvBaseDir) : ''
const cmd =
`mkdir -p _extensions && ` +
`cp -rn /opt/quarto-extensions/_extensions/. _extensions/ 2>/dev/null; ` +
venvPrep +
`quarto render ${inputPath} 2>&1 && ` +
tail +
cleanup
return ['/bin/sh', '-c', cmd]
}
// Shell snippet (run before `quarto render`, in the compile dir) that installs
// a project's requirements.vrf into a venv cached by the file's sha256 and
// points Quarto at it via QUARTO_PYTHON. Notes:
// - The venv is shared across projects/compiles (keyed by content hash), so
// identical dependency sets are built once.
// - --system-site-packages keeps the bundled scientific stack + ipykernel
// visible, so only the *extra* packages are installed.
// - A per-hash flock serialises concurrent compiles building the same venv.
// - Everything is merged to stdout so pip output/errors land in output.log;
// on failure QUARTO_PYTHON is left unset and the render falls back to the
// base interpreter (the missing-package error then surfaces normally).
// - Only $-shell vars / $(...) are used (no ${...}) to avoid clashing with
// JS template interpolation; only ${venvBaseDir} is substituted by JS.
function _pythonVenvPrep(venvBaseDir) {
return (
`if [ -f requirements.vrf ]; then ` +
`VBASE="${venvBaseDir}"; ` +
`RHASH=$(sha256sum requirements.vrf 2>/dev/null | cut -d" " -f1); ` +
`if [ -n "$RHASH" ]; then ` +
`VDIR="$VBASE/$RHASH"; mkdir -p "$VBASE" 2>/dev/null; ` +
`( flock 9 || exit 0; ` +
`if [ ! -f "$VDIR/.verso-ready" ]; then ` +
`echo "Installing Python packages from requirements.vrf..."; rm -rf "$VDIR"; ` +
`python3 -m venv --system-site-packages "$VDIR" ` +
`&& "$VDIR/bin/pip" install --no-input --disable-pip-version-check -r requirements.vrf ` +
// Register a python3 kernelspec INSIDE the venv (argv -> the venv's python)
// so Quarto runs the kernel in the venv, not the base /usr/bin/python3 from
// the global kernelspec. ipykernel is visible via --system-site-packages.
`&& "$VDIR/bin/python3" -m ipykernel install --sys-prefix --name python3 --display-name "Python 3" ` +
`&& touch "$VDIR/.verso-ready" ` +
`|| echo "ERROR: Failed to install Python packages from requirements.vrf"; ` +
`fi ` +
`) 9>"$VBASE/.$RHASH.lock" 2>&1; ` +
`if [ -f "$VDIR/.verso-ready" ]; then export QUARTO_PYTHON="$VDIR/bin/python3"; fi; ` +
`fi; ` +
`fi; `
)
}
// Write a temporary copy of the root .qmd with embed-resources enabled in its
// frontmatter, returning the temp filename to render. On any problem (no
// frontmatter, not a nested revealjs deck, read/write error) it falls back to
// the original mainFile — the export then just isn't self-contained, which is
// no worse than before. The temp file lives in the same directory so relative
// resources (images, _extensions) still resolve.
function _writeStandaloneVariant(directory, mainFile) {
try {
const content = fs.readFileSync(Path.join(directory, mainFile), 'utf8')
const transformed = _injectRevealjsStandaloneOptions(content)
if (!transformed) return mainFile
const base = mainFile.replace(/\.[^/.]+$/, '')
const tempName = `${base}.verso-standalone.qmd`
fs.writeFileSync(Path.join(directory, tempName), transformed)
return tempName
} catch (err) {
logger.warn({ err, directory, mainFile }, 'could not prepare standalone qmd')
return mainFile
}
}
// Inject the self-contained options into the `revealjs:` block of a deck's
// YAML frontmatter. embed-resources/self-contained-math inline all CSS/JS/
// images/MathJax into one portable file; chalkboard must be off (it is
// incompatible with embed-resources and would error the render). Keys already
// present in the block are overwritten in place (so we never create duplicate
// YAML keys, e.g. an existing `chalkboard: true`); missing keys are inserted.
// Returns the new document text, or null if it isn't a nested-revealjs deck we
// can safely edit.
function _injectRevealjsStandaloneOptions(content) {
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)/)
if (!fmMatch) return null
const [, open, body, close] = fmMatch
const lines = body.split('\n')
const revealIdx = lines.findIndex(l => /^\s*revealjs:\s*$/.test(l))
if (revealIdx === -1) return null // not a `format:\n revealjs:` deck
const revealIndent = lines[revealIdx].match(/^(\s*)/)[1]
// Determine the block's child indent (from the first more-indented line) and
// where the block ends (the first later line indented at/under revealjs:).
let childIndent = revealIndent + ' '
let blockEnd = lines.length
let seenChild = false
for (let i = revealIdx + 1; i < lines.length; i++) {
if (lines[i].trim() === '') continue
const indent = lines[i].match(/^(\s*)/)[1]
if (indent.length <= revealIndent.length) {
blockEnd = i
break
}
if (!seenChild) {
childIndent = indent
seenChild = true
}
}
const desired = {
'embed-resources': 'true',
'self-contained-math': 'true',
chalkboard: 'false',
}
const present = new Set()
for (let i = revealIdx + 1; i < blockEnd; i++) {
const km = lines[i].match(/^\s*([A-Za-z0-9_-]+):/)
if (km && Object.prototype.hasOwnProperty.call(desired, km[1])) {
lines[i] = `${childIndent}${km[1]}: ${desired[km[1]]}`
present.add(km[1])
}
}
const additions = Object.keys(desired)
.filter(k => !present.has(k))
.map(k => `${childIndent}${k}: ${desired[k]}`)
if (additions.length) lines.splice(revealIdx + 1, 0, ...additions)
return open + lines.join('\n') + close + content.slice(fmMatch[0].length)
}
function _writeLogOutput(compileName, directory, output, callback) {
const content = (output && output.stdout) || ''
if (!content) return callback()
// Write to output.log so the PDF-preview log panel picks it up
const logFile = Path.join(directory, 'output.log')
fs.unlink(logFile, () => {
fs.writeFile(logFile, content, { flag: 'wx' }, err => {
if (err) {
logger.error({ err, compileName, logFile }, 'error writing quarto log')
}
callback()
})
})
}
// Quarto's HTML/RevealJS output is NOT self-contained (we deliberately dropped
// --embed-resources so reveal plugins like chalkboard work). A side effect is
// that pandoc no longer tries to fetch referenced media, so a missing image or
// video produces no compile-time warning — it just renders broken in the
// browser. To restore that feedback, scan the produced output.html for local
// media references and emit a [WARNING] for any that don't exist on disk. The
// [WARNING] prefix is understood by the Quarto/Typst log parser on the web
// side, so these surface in the Warnings tab like any other.
//
// Only HTML output is scanned: PDF output (Typst) already hard-errors on a
// missing image, so it needs no extra check.
function _appendMissingResourceWarnings(directory, callback) {
const htmlFile = Path.join(directory, 'output.html')
fs.readFile(htmlFile, 'utf8', (err, html) => {
if (err) return callback() // no HTML output (e.g. a PDF compile)
const missing = _extractLocalMediaRefs(html).filter(ref => {
try {
return !fs.existsSync(Path.join(directory, decodeURIComponent(ref)))
} catch {
return false
}
})
if (missing.length === 0) return callback()
const warnings =
missing
.map(
ref =>
`[WARNING] Missing resource: ${ref} (referenced in the document ` +
`but not found in the project — it will appear broken)`
)
.join('\n') + '\n'
fs.appendFile(Path.join(directory, 'output.log'), '\n' + warnings, () =>
callback()
)
})
}
// Pull local media references (img/video/audio/iframe src, poster, RevealJS
// data-background-*) out of the rendered HTML. External URLs, data URIs and
// in-page anchors are ignored; Quarto's own generated assets (under
// <basename>_files/) exist on disk, so they never get flagged.
function _extractLocalMediaRefs(html) {
const refs = new Set()
const attrRegex =
/(?:src|poster|data-background-image|data-background-video)\s*=\s*["']([^"']+)["']/gi
let match
while ((match = attrRegex.exec(html)) !== null) {
const url = match[1].trim()
if (!url) continue
// Skip absolute URLs, protocol-relative, data/blob URIs and anchors.
if (/^(?:[a-z]+:|\/\/|\/|#|data:|blob:)/i.test(url)) continue
const clean = url.split(/[?#]/)[0] // drop query string / fragment
if (clean) refs.add(clean)
}
return [...refs]
}
function isRunning(compileName) {
return ProcessTable[compileName] != null
}
function killQuarto(compileName, callback) {
logger.debug({ compileName }, 'killing running quarto compile')
if (!isRunning(compileName)) {
logger.warn({ compileName }, 'no such compile to kill')
return callback(null)
}
CommandRunner.kill(ProcessTable[compileName], callback)
}
export default {
isRunning,
runQuarto,
killQuarto,
promises: {
runQuarto: promisify(runQuarto),
killQuarto: promisify(killQuarto),
},
}
+24 -3
View File
@@ -2,7 +2,14 @@ import { promisify } from 'node:util'
import settings from '@overleaf/settings'
import OutputCacheManager from './OutputCacheManager.js'
const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
const VALID_COMPILERS = [
'quarto',
'typst',
'pdflatex',
'latex',
'xelatex',
'lualatex',
]
const MAX_TIMEOUT = 600
const EDITOR_ID_REGEX = /^[a-f0-9-]{36}$/ // UUID
const HISTORY_ID_REGEX = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/ // mongo id or postgres id
@@ -36,7 +43,7 @@ function parse(body, callback) {
}
response.compiler = _parseAttribute('compiler', compile.options.compiler, {
validValues: VALID_COMPILERS,
default: 'pdflatex',
default: 'quarto',
type: 'string',
})
response.compileFromClsiCache = _parseAttribute(
@@ -95,6 +102,20 @@ function parse(body, callback) {
response.check = _parseAttribute('check', compile.options.check, {
type: 'string',
})
// Verso: on-demand presentation export ('html-standalone' | 'pdf-slides'),
// honoured by QuartoRunner; empty for a normal preview compile.
response.exportMode = _parseAttribute(
'exportMode',
compile.options.exportMode,
{ default: '', type: 'string' }
)
// Verso: whether QuartoRunner may install the project's requirements.txt
// into a cached venv (gated by privilege on the web side).
response.allowPythonInstall = _parseAttribute(
'allowPythonInstall',
compile.options.allowPythonInstall,
{ default: false, type: 'boolean' }
)
response.flags = _parseAttribute('flags', compile.options.flags, {
default: [],
type: 'object',
@@ -180,7 +201,7 @@ function parse(body, callback) {
'rootResourcePath',
compile.rootResourcePath,
{
default: 'main.tex',
default: 'main.qmd',
type: 'string',
}
)
+104
View File
@@ -0,0 +1,104 @@
import Path from 'node:path'
import { promisify } from 'node:util'
import logger from '@overleaf/logger'
import CommandRunner from './CommandRunner.js'
import fs from 'node:fs'
// Maps currently-running Typst jobs: compileName → PID (or docker container id)
const ProcessTable = {}
// Compiles a standalone Typst document (.typ) straight to output.pdf. We reuse
// the Typst that ships inside Quarto via `quarto typst compile`, so there is no
// extra binary to install. This is deliberately the simplest of the three
// runners: Typst only ever produces a PDF, so there is no format detection,
// no HTML asset directory and no extension merging (cf. QuartoRunner).
function runTypst(compileName, options, callback) {
const { directory, mainFile, image, environment, compileGroup } = options
const timeout = options.timeout || 60000
logger.debug(
{ directory, timeout, mainFile, compileGroup },
'starting typst compile'
)
const command = _buildTypstCommand(mainFile)
ProcessTable[compileName] = CommandRunner.run(
compileName,
command,
directory,
image,
timeout,
environment || {},
compileGroup,
null,
function (error, output) {
delete ProcessTable[compileName]
// Propagate real process-level errors (killed, timed out) but NOT
// ordinary non-zero exit codes from Typst itself. A compile failure
// (exit code 1) is not a server error — the absence of output.pdf is
// enough for CompileController to return 'failure'.
if (error && (error.terminated || error.timedout)) {
return callback(error)
}
// On exit-code-1 errors LocalCommandRunner attaches stdout to the error
// object; merge it so _writeLogOutput can persist it.
const combined = output || (error ? { stdout: error.stdout || '' } : null)
_writeLogOutput(compileName, directory, combined, () =>
callback(null, combined)
)
}
)
}
function _buildTypstCommand(mainFile) {
// Run through a POSIX shell so stderr (where Typst writes its diagnostics)
// is merged into stdout (2>&1). LocalCommandRunner replaces $COMPILE_DIR
// before the shell sees it; the output path is relative because the shell
// CWD is already the compile directory.
const inputPath = `$COMPILE_DIR/${mainFile}`
const cmd = `quarto typst compile ${inputPath} output.pdf 2>&1`
return ['/bin/sh', '-c', cmd]
}
function _writeLogOutput(compileName, directory, output, callback) {
const content = (output && output.stdout) || ''
if (!content) return callback()
// Write to output.log so the PDF-preview log panel picks it up. Typst's
// `error:`/`warning:` + `┌─ file:line:col` diagnostics are understood by the
// Quarto/Typst log parser on the web side.
const logFile = Path.join(directory, 'output.log')
fs.unlink(logFile, () => {
fs.writeFile(logFile, content, { flag: 'wx' }, err => {
if (err) {
logger.error({ err, compileName, logFile }, 'error writing typst log')
}
callback()
})
})
}
function isRunning(compileName) {
return ProcessTable[compileName] != null
}
function killTypst(compileName, callback) {
logger.debug({ compileName }, 'killing running typst compile')
if (!isRunning(compileName)) {
logger.warn({ compileName }, 'no such compile to kill')
return callback(null)
}
CommandRunner.kill(ProcessTable[compileName], callback)
}
export default {
isRunning,
runTypst,
killTypst,
promises: {
runTypst: promisify(runTypst),
killTypst: promisify(killTypst),
},
}
@@ -1068,7 +1068,7 @@ function _finaliseRequest(projectId, options, project, docs, files) {
let flags
let rootResourcePath = options.rootResourcePath
let rootResourcePathOverride = null
let hasMainFile = false
let detectedMainFile = null
let numberOfDocsInProject = 0
for (let path in docs) {
@@ -1094,8 +1094,11 @@ function _finaliseRequest(projectId, options, project, docs, files) {
) {
rootResourcePathOverride = path
}
if (path === 'main.tex') {
hasMainFile = true
// prefer main.qmd over main.tex as the default root document
if (path === 'main.qmd') {
detectedMainFile = 'main.qmd'
} else if (path === 'main.tex' && detectedMainFile == null) {
detectedMainFile = 'main.tex'
}
}
@@ -1103,8 +1106,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
rootResourcePath = rootResourcePathOverride
}
if (rootResourcePath == null) {
if (hasMainFile) {
rootResourcePath = 'main.tex'
if (detectedMainFile != null) {
rootResourcePath = detectedMainFile
} else if (numberOfDocsInProject === 1) {
// only one file, must be the main document
for (const path in docs) {
@@ -1145,6 +1148,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
imageName: project.imageName,
draft: Boolean(options.draft),
stopOnFirstError: Boolean(options.stopOnFirstError),
exportMode: options.exportMode,
allowPythonInstall: Boolean(options.allowPythonInstall),
check: options.check,
syncType: options.syncType,
syncState: options.syncState,
@@ -7,6 +7,7 @@ import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import Errors from '../Errors/Errors.js'
import SessionManager from '../Authentication/SessionManager.mjs'
import { userCanInstallPython } from './PythonVenvGate.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import Validation from '../../infrastructure/Validation.mjs'
import Path from 'node:path'
@@ -201,6 +202,11 @@ const _CompileController = {
options.incrementalCompilesEnabled = true
}
// Allow building a per-project Python venv from requirements.txt only for
// the project owner and invited collaborators — never anonymous or
// link-sharing users.
options.allowPythonInstall = await userCanInstallPython(userId, projectId)
let {
enablePdfCaching,
pdfCachingMinChunkSize,
@@ -28,12 +28,17 @@ function generateBuildId() {
}
async function compile(projectId, userId, options = {}) {
const recentlyCompiled = await CompileManager._checkIfRecentlyCompiled(
projectId,
userId
)
if (recentlyCompiled) {
return { status: 'too-recently-compiled', outputFiles: [] }
// Publishing a presentation needs a full output set on demand, so it can ask
// to skip the debounce that suppresses compiles right after the editor's
// auto-compile (which would otherwise return no output files).
if (!options.bypassRecentCompileCheck) {
const recentlyCompiled = await CompileManager._checkIfRecentlyCompiled(
projectId,
userId
)
if (recentlyCompiled) {
return { status: 'too-recently-compiled', outputFiles: [] }
}
}
try {
@@ -0,0 +1,122 @@
import { pipeline } from 'node:stream/promises'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import { expressify } from '@overleaf/promise-utils'
import SessionManager from '../Authentication/SessionManager.mjs'
import CompileManager from './CompileManager.mjs'
import ClsiManager from './ClsiManager.mjs'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import { userCanInstallPython } from './PythonVenvGate.mjs'
// On-demand export of a RevealJS deck from the editor's download menu.
// - html → a single self-contained .html (embed-resources)
// - pdf → a faithful slide-per-page PDF (decktape / headless Chromium)
// Each triggers a one-off compile in the matching export mode, then streams the
// produced file back as a download.
const FORMATS = {
html: {
exportMode: 'html-standalone',
file: 'output.html',
ext: 'html',
contentType: 'text/html',
},
pdf: {
exportMode: 'pdf-slides',
file: 'output-slides.pdf',
ext: 'pdf',
contentType: 'application/pdf',
},
}
// Best-effort fetch of the export compile's output.log so export failures can
// show the underlying error (e.g. decktape/Chromium output). Never throws.
async function _fetchLog(projectId, userId, clsiServerId, buildId) {
if (!buildId) return ''
try {
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
const stream = await ClsiManager.promises.getOutputFileStream(
projectId,
compileAsUser,
clsiServerId,
buildId,
'output.log'
)
const chunks = []
for await (const chunk of stream) chunks.push(chunk)
const text = Buffer.concat(chunks).toString('utf8')
// Keep it bounded — the tail holds the relevant error.
return text.length > 8000 ? text.slice(-8000) : text
} catch {
return ''
}
}
async function exportPresentation(req, res) {
const projectId = req.params.Project_id
const userId = SessionManager.getLoggedInUserId(req.session)
const format = FORMATS[req.params.format]
if (!format) return res.status(400).send('Unknown export format')
try {
const { status, outputFiles, clsiServerId, buildId } =
await CompileManager.promises.compile(projectId, userId, {
exportMode: format.exportMode,
bypassRecentCompileCheck: true,
allowPythonInstall: await userCanInstallPython(userId, projectId),
})
if (!buildId || !outputFiles?.some(f => f.path === format.file)) {
// The expected artefact wasn't produced. Surface the compile log (which
// for the PDF path includes decktape/Chromium's own stderr) as plain
// text so the failure is diagnosable, instead of returning an HTML page
// the browser saves as "pdf.htm".
const log = await _fetchLog(projectId, userId, clsiServerId, buildId)
res.status(400).type('text/plain')
res.setHeader('Cache-Control', 'no-store')
return res.send(
`Export failed: the project did not produce ${format.file} ` +
`(compile status: ${status}). This export is only available for ` +
`RevealJS presentations.\n\n` +
(log ? `--- compile log ---\n${log}` : '(no compile log available)')
)
}
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
const stream = await ClsiManager.promises.getOutputFileStream(
projectId,
compileAsUser,
clsiServerId,
buildId,
format.file
)
const project = await ProjectGetter.promises.getProject(projectId, {
name: 1,
})
const safeName = (project?.name || 'presentation')
.replace(/[^a-zA-Z0-9-_ ]+/g, '')
.trim()
.replace(/\s+/g, '-')
res.setHeader('Content-Type', format.contentType)
// Each export is a fresh render; never let the browser hand back a cached
// copy of a previous export for the same URL.
res.setHeader('Cache-Control', 'no-store')
res.setHeader(
'Content-Disposition',
`attachment; filename="${safeName || 'presentation'}.${format.ext}"`
)
await pipeline(stream, res)
} catch (err) {
logger.error(
{ err, projectId, format: req.params.format },
'presentation export failed'
)
if (!res.headersSent) {
res.status(500).send('Export failed. Please try compiling first.')
}
}
}
export default {
exportPresentation: expressify(exportPresentation),
}
@@ -0,0 +1,38 @@
import { expressify } from '@overleaf/promise-utils'
import SessionManager from '../Authentication/SessionManager.mjs'
import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
import EditorController from '../Editor/EditorController.mjs'
// The project's Python dependency list lives in a single Verso requirements
// file at the project root. It is hidden from the file tree and edited through
// the dedicated "Python packages" modal instead.
const REQUIREMENTS_PATH = '/requirements.vrf'
async function getRequirements(req, res) {
const projectId = req.params.Project_id
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
const doc = docs[REQUIREMENTS_PATH]
res.json({ content: doc ? doc.lines.join('\n') : '' })
}
async function setRequirements(req, res) {
const projectId = req.params.Project_id
const userId = SessionManager.getLoggedInUserId(req.session)
const content = typeof req.body.content === 'string' ? req.body.content : ''
// Normalise line endings; an empty body still upserts an (empty) file, which
// is harmless and keeps the editor state simple.
const docLines = content.replace(/\r\n?/g, '\n').split('\n')
await EditorController.promises.upsertDocWithPath(
projectId,
REQUIREMENTS_PATH,
docLines,
'python-requirements',
userId
)
res.json({ content })
}
export default {
getRequirements: expressify(getRequirements),
setRequirements: expressify(setRequirements),
}
@@ -0,0 +1,33 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
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). 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
}
try {
const privilegeLevel =
await AuthorizationManager.promises.getPrivilegeLevelForProject(
userId,
projectId,
null,
{ ignorePublicAccess: true }
)
return Boolean(privilegeLevel)
} catch (err) {
logger.warn(
{ err, projectId, userId },
'could not determine python install privilege; defaulting to false'
)
return false
}
}
export default { userCanInstallPython }
@@ -323,12 +323,59 @@ const _ProjectController = {
req.body.projectName != null ? req.body.projectName.trim() : undefined
const { template } = req.body
const project = await (template === 'example'
? ProjectCreationHandler.promises.createExampleProject(
// The "template" selects flavour (Quarto vs LaTeX) and kind (blank vs
// example). 'example'/'none' are kept for backwards compatibility.
let project
switch (template) {
case 'example':
case 'example_latex':
project = await ProjectCreationHandler.promises.createExampleProject(
userId,
projectName
projectName,
{},
'latex'
)
: ProjectCreationHandler.promises.createBasicProject(userId, projectName))
break
case 'example_quarto':
project = await ProjectCreationHandler.promises.createExampleProject(
userId,
projectName,
{},
'quarto'
)
break
case 'example_typst':
project = await ProjectCreationHandler.promises.createExampleProject(
userId,
projectName,
{},
'typst'
)
break
case 'blank_latex':
project = await ProjectCreationHandler.promises.createBasicProject(
userId,
projectName,
'latex'
)
break
case 'blank_typst':
project = await ProjectCreationHandler.promises.createBasicProject(
userId,
projectName,
'typst'
)
break
case 'blank_quarto':
case 'none':
default:
project = await ProjectCreationHandler.promises.createBasicProject(
userId,
projectName,
'quarto'
)
break
}
ProjectAuditLogHandler.addEntryIfManagedInBackground(
project._id,
@@ -85,11 +85,41 @@ async function createProjectFromSnippet(ownerId, projectName, docLines) {
return project
}
async function createBasicProject(ownerId, projectName) {
const project = await _createBlankProject(ownerId, projectName)
// Per-flavour blank-project template, root document name and stored compiler.
function _flavourConfig(flavour) {
switch (flavour) {
case 'latex':
return {
templateName: 'mainbasic.tex',
rootDocName: 'main.tex',
compiler: 'pdflatex',
}
case 'typst':
return {
templateName: 'mainbasic.typ',
rootDocName: 'main.typ',
compiler: 'typst',
}
default:
return {
templateName: 'mainbasic.qmd',
rootDocName: 'main.qmd',
compiler: 'quarto',
}
}
}
const docLines = await _buildTemplate('mainbasic.tex', ownerId, projectName)
await _createRootDoc(project, ownerId, docLines)
async function createBasicProject(ownerId, projectName, flavour = 'quarto') {
// Verso compiles .qmd with Quarto, .tex with latexmk and .typ with Typst;
// the root file's extension selects the runner (see CompileManager in CLSI).
// Each flavour is a choice of template + root name + the compiler stored on
// the project (so the compiler dropdown reflects the engine and LatexRunner
// never receives a non-LaTeX compiler for a .tex project).
const { templateName, rootDocName, compiler } = _flavourConfig(flavour)
const project = await _createBlankProject(ownerId, projectName, { compiler })
const docLines = await _buildTemplate(templateName, ownerId, projectName)
await _createRootDoc(project, ownerId, docLines, rootDocName)
AnalyticsManager.recordEventForUserInBackground(ownerId, 'project-created', {
projectId: project._id,
@@ -163,20 +193,41 @@ async function populateClsiCacheForExampleProject(
return projectId
}
async function createExampleProject(ownerId, projectName, attributes = {}) {
const project = await _createBlankProject(ownerId, projectName, attributes)
async function createExampleProject(
ownerId,
projectName,
attributes = {},
flavour = 'latex'
) {
const { compiler } = _flavourConfig(flavour)
const project = await _createBlankProject(ownerId, projectName, {
...attributes,
compiler,
})
const { fileEntries, docEntries } = await _addExampleProjectFiles(
ownerId,
projectName,
project
)
await populateClsiCacheForExampleProject(
ownerId,
project,
fileEntries,
docEntries
)
let result
switch (flavour) {
case 'quarto':
result = await _addQuartoExampleProjectFiles(ownerId, projectName, project)
break
case 'typst':
result = await _addTypstExampleProjectFiles(ownerId, projectName, project)
break
default:
result = await _addExampleProjectFiles(ownerId, projectName, project)
}
const { fileEntries, docEntries } = result
if (flavour === 'latex') {
// clsi-cache warming keys on a single static example; only do it for the
// long-standing LaTeX example to avoid the "content is not static" guard.
await populateClsiCacheForExampleProject(
ownerId,
project,
fileEntries,
docEntries
)
}
AnalyticsManager.recordEventForUserInBackground(ownerId, 'project-created', {
projectId: project._id,
@@ -191,7 +242,12 @@ async function _addExampleProjectFiles(ownerId, projectName, project) {
ownerId,
projectName
)
const rootDoc = await _createRootDoc(project, ownerId, mainDocLines)
const rootDoc = await _createRootDoc(
project,
ownerId,
mainDocLines,
'main.tex'
)
const bibDocLines = await _buildTemplate(
`${templateProjectDir}/sample.bib`,
@@ -229,6 +285,74 @@ async function _addExampleProjectFiles(ownerId, projectName, project) {
}
}
async function _addQuartoExampleProjectFiles(ownerId, projectName, project) {
const mainDocLines = await _buildTemplate(
'example-project-quarto/main.qmd',
ownerId,
projectName
)
const rootDoc = await _createRootDoc(
project,
ownerId,
mainDocLines,
'main.qmd'
)
const imagePath = path.join(
import.meta.dirname,
'/../../../templates/project_files/example-project-quarto/frog.jpg'
)
const { fileRef } = await ProjectEntityUpdateHandler.promises.addFile(
project._id,
project.rootFolder[0]._id,
'frog.jpg',
imagePath,
null,
ownerId,
null
)
return {
fileEntries: [{ path: fileRef.name, file: fileRef }],
docEntries: [
{ path: 'main.qmd', doc: rootDoc, docLines: mainDocLines.join('\n') },
],
}
}
async function _addTypstExampleProjectFiles(ownerId, projectName, project) {
const mainDocLines = await _buildTemplate(
'example-project-typst/main.typ',
ownerId,
projectName
)
const rootDoc = await _createRootDoc(
project,
ownerId,
mainDocLines,
'main.typ'
)
const imagePath = path.join(
import.meta.dirname,
'/../../../templates/project_files/example-project-typst/frog.jpg'
)
const { fileRef } = await ProjectEntityUpdateHandler.promises.addFile(
project._id,
project.rootFolder[0]._id,
'frog.jpg',
imagePath,
null,
ownerId,
null
)
return {
fileEntries: [{ path: fileRef.name, file: fileRef }],
docEntries: [
{ path: 'main.typ', doc: rootDoc, docLines: mainDocLines.join('\n') },
],
}
}
async function _createBlankProject(
ownerId,
projectName,
@@ -299,12 +423,17 @@ async function _createBlankProject(
return project
}
async function _createRootDoc(project, ownerId, docLines) {
async function _createRootDoc(
project,
ownerId,
docLines,
rootDocName = 'main.qmd'
) {
try {
const { doc } = await ProjectEntityUpdateHandler.promises.addDoc(
project._id,
project.rootFolder[0]._id,
'main.tex',
rootDocName,
docLines,
ownerId,
null
@@ -679,7 +679,7 @@ async function _getProjects(
const results = await Promise.all([
ProjectGetter.promises.findAllUsersProjects(
userId,
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens'
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler'
),
TagsHandler.promises.getAllTags(userId),
])
@@ -823,6 +823,7 @@ function _formatProjectInfo(project, accessLevel, source, userId) {
source,
archived,
trashed,
compiler: project.compiler,
}
}
@@ -876,6 +877,7 @@ async function _injectProjectUsers(projects) {
? undefined
: users[project.owner_ref.toString()],
owner_ref: undefined,
compiler: project.compiler,
}))
}
@@ -191,12 +191,14 @@ function _rootDocSort(a, b) {
if (a.elements !== b.elements) {
return a.elements - b.elements
}
// ensure main.tex is at the start of each folder
if (a.name === 'main.tex' && b.name !== 'main.tex') {
return -1
}
if (a.name !== 'main.tex' && b.name === 'main.tex') {
return 1
// prefer main.qmd, then main.tex, at the start of each folder
const PREFERRED = ['main.qmd', 'main.tex']
const aIdx = PREFERRED.indexOf(a.name)
const bIdx = PREFERRED.indexOf(b.name)
if (aIdx !== bIdx) {
if (aIdx === -1) return 1
if (bIdx === -1) return -1
return aIdx - bIdx
}
// prefer smaller files
if (a.size !== b.size) {
@@ -0,0 +1,132 @@
import Path from 'node:path'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import SessionManager from '../Authentication/SessionManager.mjs'
import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
import PublishedPresentationManager from './PublishedPresentationManager.mjs'
function _tokenUrl(token) {
// Trailing slash so the deck's relative asset paths (e.g. main_files/...)
// resolve under /p/:token/ rather than /p/.
return `${Settings.siteUrl}/p/${token}/`
}
function _serialize(record) {
if (!record) return { published: false }
return {
published: true,
publicUrl: _tokenUrl(record.publicToken),
loginUrl: _tokenUrl(record.loginToken),
memberUrl: _tokenUrl(record.memberToken),
publishedAt: record.publishedAt,
}
}
async function publish(req, res) {
const projectId = req.params.Project_id
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const record = await PublishedPresentationManager.promises.publish(
projectId,
userId
)
res.json(_serialize(record))
} catch (err) {
logger.error({ err, projectId }, 'failed to publish presentation')
res
.status(400)
.json({ message: err.message || 'failed to publish presentation' })
}
}
async function status(req, res) {
const projectId = req.params.Project_id
const record =
await PublishedPresentationManager.promises.getForProject(projectId)
res.json(_serialize(record))
}
async function regenerate(req, res) {
const projectId = req.params.Project_id
const tier = req.body?.tier
try {
const record = await PublishedPresentationManager.promises.regenerateToken(
projectId,
tier
)
res.json(_serialize(record))
} catch (err) {
logger.error({ err, projectId, tier }, 'failed to regenerate deck link')
res
.status(400)
.json({ message: err.message || 'failed to regenerate link' })
}
}
async function unpublish(req, res) {
const projectId = req.params.Project_id
await PublishedPresentationManager.promises.unpublish(projectId)
res.sendStatus(204)
}
// Public/standalone serving of a published deck and its assets. No editor
// chrome. The token determines the access tier: 'public' is open, 'login'
// needs any logged-in user, 'member' needs read access to the project.
async function serve(req, res) {
const { token } = req.params
const file = req.params.file || 'index.html'
const record = await PublishedPresentationManager.promises.getByToken(token)
if (!record) return res.status(404).send('Presentation not found')
// Normalise the bare token URL to a trailing slash so the deck's relative
// asset references resolve under /p/:token/ instead of /p/.
if (!req.params.file && !req.path.endsWith('/')) {
return res.redirect(301, `/p/${encodeURIComponent(token)}/`)
}
const tier = PublishedPresentationManager.tierForToken(record, token)
if (tier !== 'public') {
const userId = SessionManager.getLoggedInUserId(req.session)
if (!userId) {
return res.redirect(`/login?redir=${encodeURIComponent(req.originalUrl)}`)
}
if (tier === 'member') {
const canRead = await AuthorizationManager.promises.canUserReadProject(
userId,
record.project_id,
null
)
if (!canRead) {
return res.status(403).send('You do not have access to this project')
}
}
}
const dir = PublishedPresentationManager.getSnapshotDir(record.storageId)
const root = Path.resolve(dir)
const target = Path.resolve(root, file)
if (target !== root && !target.startsWith(root + Path.sep)) {
return res.status(400).send('Bad path')
}
// This is a standalone site (reveal.js uses inline scripts); drop the app's
// Content-Security-Policy so the deck renders as it does in the preview.
res.removeHeader('Content-Security-Policy')
res.sendFile(target, err => {
if (err) {
if (!res.headersSent) res.status(404).send('Not found')
logger.debug({ err, token, file }, 'published deck file not found')
}
})
}
export default {
publish: expressify(publish),
regenerate: expressify(regenerate),
status: expressify(status),
unpublish: expressify(unpublish),
serve: expressify(serve),
}
@@ -0,0 +1,239 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import Path from 'node:path'
import { pipeline } from 'node:stream/promises'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import { fetchStream } from '@overleaf/fetch-utils'
import { callbackify } from 'node:util'
import CompileManager from '../Compile/CompileManager.mjs'
import { getOutputFileURL } from '../Compile/ClsiURLHelpers.mjs'
import { userCanInstallPython } from '../Compile/PythonVenvGate.mjs'
import { PublishedPresentation } from '../../models/PublishedPresentation.mjs'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import Errors from '../Errors/Errors.js'
function _escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Wrapper page so a published PDF opens inline (browser PDF viewer) at the
// snapshot root /p/:token, the same way an HTML deck does. The raw file stays
// reachable at /p/:token/output.pdf for direct download.
function _pdfIndexHtml(title) {
const safeTitle = _escapeHtml(title || 'Document')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${safeTitle}</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #525659; }
iframe { display: block; border: 0; width: 100%; height: 100%; }
</style>
</head>
<body>
<iframe src="output.pdf" title="${safeTitle}"></iframe>
</body>
</html>
`
}
const PUBLISHED_DIR = Settings.path.publishedPresentationsFolder
// Output files we never want in a published deck: compile logs and LaTeX aux.
// (OutputFileFinder already excludes the source .qmd/.tex; with the HTML-media
// fix it keeps referenced images, so the rest of the output set is the deck.)
const EXCLUDE_REGEX = /\.(log|blg|aux|fls|fdb_latexmk|synctex\.gz)$/i
function getSnapshotDir(storageId) {
return Path.join(PUBLISHED_DIR, storageId)
}
function _isWithin(root, target) {
const resolvedRoot = Path.resolve(root)
const resolvedTarget = Path.resolve(resolvedRoot, target)
return (
resolvedTarget === resolvedRoot ||
resolvedTarget.startsWith(resolvedRoot + Path.sep)
)
}
async function _downloadOutputFile(
projectId,
userId,
buildId,
filePath,
clsiServerId,
destDir
) {
const url = getOutputFileURL(projectId, userId, buildId, filePath, clsiServerId)
const dest = Path.join(destDir, filePath)
if (!_isWithin(destDir, filePath)) {
throw new Error(`unsafe output path: ${filePath}`)
}
await fs.promises.mkdir(Path.dirname(dest), { recursive: true })
const stream = await fetchStream(url.href)
await pipeline(stream, fs.createWriteStream(dest))
}
// Compile the project, then copy the resulting HTML deck + assets into a stable
// on-disk snapshot served at /p/:token. Re-publishing reuses the project's
// existing tokens, so shared links stay stable.
async function publish(projectId, userId) {
const { status, outputFiles, clsiServerId, buildId } =
await CompileManager.promises.compile(projectId, userId, {
bypassRecentCompileCheck: true,
allowPythonInstall: await userCanInstallPython(userId, projectId),
})
const hasHtml = outputFiles?.some(f => f.path === 'output.html')
const hasPdf = outputFiles?.some(f => f.path === 'output.pdf')
if (!hasHtml && !hasPdf) {
throw new Errors.InvalidError(
`project did not produce an HTML or PDF output (compile status: ${status})`
)
}
if (!buildId) {
throw new Errors.InvalidError('compile produced no build id')
}
// Output files are stored per-user unless per-user compiles are disabled;
// mirror CompileManager so the download URLs resolve to the right location.
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
let record = await PublishedPresentation.findOne({ project_id: projectId })
if (!record) {
record = new PublishedPresentation({
project_id: projectId,
storageId: crypto.randomBytes(16).toString('hex'),
publicToken: crypto.randomBytes(20).toString('hex'),
loginToken: crypto.randomBytes(20).toString('hex'),
memberToken: crypto.randomBytes(20).toString('hex'),
})
}
// Write a fresh snapshot (replace any previous one for this project).
const destDir = getSnapshotDir(record.storageId)
await fs.promises.rm(destDir, { recursive: true, force: true })
await fs.promises.mkdir(destDir, { recursive: true })
for (const file of outputFiles) {
if (EXCLUDE_REGEX.test(file.path)) continue
await _downloadOutputFile(
projectId,
compileAsUser,
buildId,
file.path,
clsiServerId,
destDir
)
}
// Serve at the snapshot root: /p/:token → index.html. An HTML deck is its
// own index; a PDF gets a tiny wrapper page that embeds output.pdf inline.
try {
if (hasHtml) {
await fs.promises.copyFile(
Path.join(destDir, 'output.html'),
Path.join(destDir, 'index.html')
)
} else {
const project = await ProjectGetter.promises.getProject(projectId, {
name: 1,
})
await fs.promises.writeFile(
Path.join(destDir, 'index.html'),
_pdfIndexHtml(project?.name),
'utf8'
)
}
} catch (err) {
logger.warn({ err, projectId }, 'could not create index.html for deck')
}
record.buildId = buildId
record.publishedAt = new Date()
await record.save()
logger.info(
{ projectId, storageId: record.storageId },
'published presentation'
)
return record
}
async function unpublish(projectId) {
const record = await PublishedPresentation.findOne({ project_id: projectId })
if (!record) return
await fs.promises
.rm(getSnapshotDir(record.storageId), { recursive: true, force: true })
.catch(err =>
logger.warn({ err, projectId }, 'could not remove deck snapshot')
)
await PublishedPresentation.deleteOne({ _id: record._id })
}
async function getByToken(token) {
return await PublishedPresentation.findOne({
$or: [
{ publicToken: token },
{ loginToken: token },
{ memberToken: token },
],
})
}
const TIER_FIELDS = {
public: 'publicToken',
login: 'loginToken',
member: 'memberToken',
}
// Which access tier a token grants for a record ('public' | 'login' | 'member').
function tierForToken(record, token) {
if (record.publicToken === token) return 'public'
if (record.loginToken === token) return 'login'
if (record.memberToken === token) return 'member'
return null
}
// Rotate a single tier's token, invalidating the old link for that tier only.
// The snapshot and the other tiers' links are untouched.
async function regenerateToken(projectId, tier) {
const field = TIER_FIELDS[tier]
if (!field) throw new Errors.InvalidError(`unknown link tier: ${tier}`)
const record = await PublishedPresentation.findOne({ project_id: projectId })
if (!record) throw new Errors.NotFoundError('presentation not published')
record[field] = crypto.randomBytes(20).toString('hex')
await record.save()
return record
}
async function getForProject(projectId) {
return await PublishedPresentation.findOne({ project_id: projectId })
}
export default {
getSnapshotDir,
tierForToken,
publish,
regenerateToken,
unpublish,
getByToken,
getForProject,
promises: {
publish,
regenerateToken,
unpublish,
getByToken,
getForProject,
},
// callback-style for any legacy callers
publishCb: callbackify(publish),
}
@@ -0,0 +1,39 @@
import mongoose from '../infrastructure/Mongoose.mjs'
const { Schema } = mongoose
const { ObjectId } = Schema
// A published snapshot of a project's compiled HTML/RevealJS presentation.
// One per project, served standalone at /p/:token. Three stable tokens point at
// the same on-disk snapshot (under storageId), one per access tier:
// - publicToken → anyone with the link
// - loginToken → any logged-in Verso user
// - memberToken → only users who can read the project (collaborators)
// All three always work; the author shares whichever they need, and can
// regenerate any one independently (rotating its token invalidates the old
// link for that tier only). Re-publishing overwrites the snapshot and keeps
// the tokens, so shared links stay stable.
const PublishedPresentationSchema = new Schema(
{
project_id: {
type: ObjectId,
ref: 'Project',
required: true,
unique: true,
},
storageId: { type: String, required: true },
publicToken: { type: String, required: true, unique: true },
loginToken: { type: String, required: true, unique: true },
memberToken: { type: String, required: true, unique: true },
buildId: { type: String },
publishedAt: { type: Date, default: Date.now },
},
{ minimize: false }
)
export const PublishedPresentation = mongoose.model(
'PublishedPresentation',
PublishedPresentationSchema
)
export { PublishedPresentationSchema }
+50
View File
@@ -3,6 +3,9 @@ import ErrorController from './Features/Errors/ErrorController.mjs'
import Features from './infrastructure/Features.mjs'
import ProjectController from './Features/Project/ProjectController.mjs'
import ProjectApiController from './Features/Project/ProjectApiController.mjs'
import PublishedPresentationController from './Features/PublishedPresentation/PublishedPresentationController.mjs'
import PresentationExportController from './Features/Compile/PresentationExportController.mjs'
import PythonRequirementsController from './Features/Compile/PythonRequirementsController.mjs'
import ProjectListController from './Features/Project/ProjectListController.mjs'
import SpellingController from './Features/Spelling/SpellingController.mjs'
import EditorRouter from './Features/Editor/EditorRouter.mjs'
@@ -669,6 +672,53 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanReadProject,
CompileController.deleteAuxFiles
)
// Publish the compiled presentation as a standalone, shareable snapshot.
webRouter.get(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.status
)
webRouter.post(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.publish
)
webRouter.post(
'/project/:Project_id/publish-presentation/regenerate',
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.regenerate
)
webRouter.delete(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.unpublish
)
// On-demand export of a RevealJS deck (download menu): html | pdf.
webRouter.get(
'/project/:Project_id/presentation-export/:format',
AuthorizationMiddleware.ensureUserCanReadProject,
PresentationExportController.exportPresentation
)
// Read/write the project's Python requirements (requirements.vrf), edited via
// the "Python packages" modal rather than as a file in the tree.
webRouter.get(
'/project/:Project_id/python-requirements',
AuthorizationMiddleware.ensureUserCanReadProject,
PythonRequirementsController.getRequirements
)
webRouter.post(
'/project/:Project_id/python-requirements',
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
PythonRequirementsController.setRequirements
)
// Standalone viewer for a published presentation (no editor chrome).
// Visibility is enforced inside the handler: 'public' is anonymous,
// 'private' requires any logged-in Verso user.
webRouter.get('/p/:token', PublishedPresentationController.serve)
webRouter.get('/p/:token/:file(.*)', PublishedPresentationController.serve)
webRouter.get(
'/project/:Project_id/sync/code',
AsyncLocalStorage.middleware,
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

@@ -0,0 +1,84 @@
---
title: "<%= project_name %>"
author: "<%= user.first_name %> <%= user.last_name %>"
date: "<%= month %> <%= year %>"
format:
revealjs:
theme: simple
slide-number: true
chalkboard: true
---
## Welcome to Verso
This is an example **presentation** written in [Quarto](https://quarto.org)
and rendered to interactive HTML slides with Reveal.js.
- Write your slides in plain Markdown
- Mix in math, code, images and tables
- Hit **Recompile** to see your changes instantly
::: aside
Use the arrow keys to navigate, and press `f` for fullscreen.
:::
## Mathematics
Inline math such as $E = mc^2$ renders inline, and display equations are
centered automatically:
$$
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
$$
. . .
You can reveal content step by step by separating it with `. . .`.
## Two columns
:::: {.columns}
::: {.column width="55%"}
### Figures sit nicely beside text
Fenced columns let you place an explanation next to a figure — perfect for
walking an audience through a diagram.
:::
::: {.column width="45%"}
![A friendly frog](frog.jpg)
:::
::::
## Tables
| Engine | Output | Best for |
|--------|:------:|-----------------------|
| Typst | PDF | Fast, modern layout |
| Reveal | HTML | Interactive slides |
| LaTeX | PDF | Classic STEM articles |
: Quarto can target several output formats from one source. {.striped .hover}
## Code
Code blocks are syntax-highlighted out of the box:
```python
def greet(name):
return f"Hello, {name}!"
print(greet("Verso"))
```
## Incremental lists {.incremental}
- Quarto turns Markdown into beautiful documents
- The same source can become a PDF, a website or these slides
- Now make this deck your own — edit `main.qmd`!
## Thank you {.center}
Questions? Start editing and explore what Quarto can do.
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

@@ -0,0 +1,57 @@
#set document(
title: "<%= project_name %>",
author: "<%= user.first_name %> <%= user.last_name %>",
)
#set page(numbering: "1")
#set heading(numbering: "1.1")
#set par(justify: true)
#align(center)[
#text(size: 22pt, weight: "bold")[<%= project_name %>] \
#v(0.4em)
<%= user.first_name %> <%= user.last_name %> · <%= month %> <%= year %>
]
= Introduction
Welcome to *Typst* in Verso. Typst is a modern typesetting system that
compiles in milliseconds — edit `main.typ` and hit *Recompile* to see the PDF
update. This example shows off math, figures, tables and lists.
= Mathematics
Inline math such as $e^(i pi) + 1 = 0$ sits naturally in the text, while block
equations are centred and can be numbered:
$ integral_(-oo)^(oo) e^(-x^2) dif x = sqrt(pi) $
= Figures
Images stored in the project are included with the `image` function:
#figure(
image("frog.jpg", width: 50%),
caption: [A friendly frog, loaded from a project file.],
)
= Tables
#figure(
table(
columns: 3,
align: (left, center, left),
[*Engine*], [*Output*], [*Best for*],
[Typst], [PDF], [Fast, modern layout],
[Quarto], [PDF / HTML], [Reproducible documents],
[LaTeX], [PDF], [Classic STEM articles],
),
caption: [Verso compiles three source formats side by side.],
)
= Lists
- Write documents in concise, readable markup
- Get near-instant PDF output
- Script your layout with a real programming language
Now make this document your own!
@@ -0,0 +1,9 @@
---
title: "<%= project_name %>"
author: "<%= user.first_name %> <%= user.last_name %>"
date: "<%= month %> <%= year %>"
format: typst
---
## Introduction
@@ -0,0 +1,14 @@
#set document(
title: "<%= project_name %>",
author: "<%= user.first_name %> <%= user.last_name %>",
)
#set page(numbering: "1")
#set heading(numbering: "1.1")
#align(center)[
#text(size: 20pt, weight: "bold")[<%= project_name %>] \
#v(0.4em)
<%= user.first_name %> <%= user.last_name %> · <%= month %> <%= year %>
]
= Introduction
+1 -1
View File
@@ -138,7 +138,7 @@ link(rel='apple-touch-icon' href=buildBaseAssetPath() + 'apple-touch-icon.png')
link(
rel='mask-icon'
href=buildBaseAssetPath() + 'mask-favicon.svg'
color='#046530'
color='#447099'
)
//- Canonical Tag for SEO
@@ -1,60 +1,7 @@
.fat-footer-base
.fat-footer-base-section.fat-footer-base-meta
.fat-footer-base-item
.fat-footer-base-copyright © #{new Date().getFullYear()} Overleaf
.fat-footer-base-copyright © #{new Date().getFullYear()} Verso
a(href='/legal') #{translate('privacy_and_terms')}
a(href='https://www.digital-science.com/security-certifications/') #{translate('compliance')}
ul.fat-footer-base-item.list-unstyled.fat-footer-base-language
include language-picker
.fat-footer-base-section.fat-footer-base-social
.fat-footer-base-item
a.fat-footer-social.x-logo(href='https://x.com/overleaf')
svg(
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1200 1227'
height='25'
)
path(
d='M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z'
)
span.visually-hidden #{translate("app_on_x", {social: "X"})}
a.fat-footer-social.facebook-logo(
href='https://www.facebook.com/overleaf.editor'
)
svg(
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 666.66668 666.66717'
height='25'
)
defs
clipPath(id='a' clipPathUnits='userSpaceOnUse')
path(d='M0 700h700V0H0Z')
g(
clip-path='url(#a)'
transform='matrix(1.33333 0 0 -1.33333 -133.333 800)'
)
path.background(
d='M0 0c0 138.071-111.929 250-250 250S-500 138.071-500 0c0-117.245 80.715-215.622 189.606-242.638v166.242h-51.552V0h51.552v32.919c0 85.092 38.508 124.532 122.048 124.532 15.838 0 43.167-3.105 54.347-6.211V81.986c-5.901.621-16.149.932-28.882.932-40.993 0-56.832-15.528-56.832-55.9V0h81.659l-14.028-76.396h-67.631v-171.773C-95.927-233.218 0-127.818 0 0'
fill='#0866ff'
transform='translate(600 350)'
)
path.text(
d='m0 0 14.029 76.396H-67.63v27.019c0 40.372 15.838 55.899 56.831 55.899 12.733 0 22.981-.31 28.882-.931v69.253c-11.18 3.106-38.509 6.212-54.347 6.212-83.539 0-122.048-39.441-122.048-124.533V76.396h-51.552V0h51.552v-166.242a250.559 250.559 0 0 1 60.394-7.362c10.254 0 20.358.632 30.288 1.831V0Z'
fill='#fff'
transform='translate(447.918 273.604)'
)
span.visually-hidden #{translate("app_on_x", {social: "Facebook"})}
a.fat-footer-social.linkedin-logo(
href='https://www.linkedin.com/company/writelatex-limited'
)
svg(xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 72' height='25')
g(fill='none' fill-rule='evenodd')
path.background(
fill='#2867b2'
d='M8 72h56a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H8a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8'
)
path.text(
fill='#FFF'
d='M62 62H51.316V43.802c0-4.99-1.896-7.777-5.845-7.777-4.296 0-6.54 2.901-6.54 7.777V62H28.632V27.333H38.93v4.67s3.096-5.729 10.453-5.729c7.353 0 12.617 4.49 12.617 13.777zM16.35 22.794c-3.508 0-6.35-2.864-6.35-6.397C10 12.864 12.842 10 16.35 10c3.507 0 6.347 2.864 6.347 6.397 0 3.533-2.84 6.397-6.348 6.397ZM11.032 62h10.736V27.333H11.033V62'
)
span.visually-hidden #{translate("app_on_x", {social: "LinkedIn"})}
@@ -1,98 +1,6 @@
footer.fat-footer.hidden-print.website-redesign-fat-footer
.fat-footer-container
.fat-footer-sections(class={hidden: hideFatFooter})
#footer-brand.footer-section
a.footer-brand(href='/' aria-label=settings.appName)
.footer-section
h2.footer-section-heading #{translate('About')}
ul.list-unstyled
li
a(href='/about') #{translate('footer_about_us')}
li
a(href='https://digitalscience.pinpointhq.com/') #{translate('careers')}
li
a(href='/blog') #{translate('blog')}
.footer-section
h2.footer-section-heading #{translate('solutions')}
ul.list-unstyled
li
a(href='/for/enterprises') #{translate('for_business')}
li
a(href='/for/universities') #{translate('for_universities')}
li
a(href='/for/government') #{translate('for_government')}
li
a(href='/for/publishers') #{translate('for_publishers')}
li
a(href='/about/customer-stories') #{translate('customer_stories')}
.footer-section
h2.footer-section-heading #{translate('learn')}
ul.list-unstyled
li
a(
href='https://learn.overleaf.com/101-get-started-with-latex-in-overleaf'
target='_blank'
rel='noopener noreferrer'
) #{translate('get_started_with_latex')}
li
a(href='/latex/templates') #{translate('templates')}
li
a(
href='https://learn.overleaf.com/calendar'
target='_blank'
rel='noopener noreferrer'
) #{translate('webinars')}
li
a(
href='https://learn.overleaf.com/'
target='_blank'
rel='noopener noreferrer'
) #{translate('overleaf_learning_center')}
li
a(href='/learn/latex/Inserting_Images') #{translate('how_to_insert_images')}
li
a(href='/learn/latex/Tables') #{translate('how_to_create_tables')}
.footer-section
h2.footer-section-heading !{translate('pricing')}
ul.list-unstyled
li
a(href='/user/subscription/plans?itm_referrer=footer-for-indv') #{translate('for_individuals')}
li
a(
href='/user/subscription/plans?plan=group&itm_referrer=footer-for-groups'
) !{translate('for_groups_and_organizations')}
li
a(
href='/user/subscription/plans?itm_referrer=footer-for-students#student-annual'
) #{translate('for_students')}
.footer-section
h2.footer-section-heading #{translate('get_involved')}
ul.list-unstyled
li
a(href='https://forms.gle/67PSpN1bLnjGCmPQ9') #{translate('let_us_know_what_you_think')}
if user
li
a(href='/beta/participate') #{translate('join_beta_program')}
.footer-section
h2.footer-section-heading #{translate('help')}
ul.list-unstyled
li
a(href='/learn') #{translate('Documentation')}
li
a(href='/contact') #{translate('footer_contact_us')}
li
a(href='https://status.overleaf.com/') #{translate('website_status')}
include fat-footer-base
footer.hidden-print.verso-footer(style='padding:1rem 1.5rem;border-top:1px solid #dee2e6;font-size:0.85rem;color:#6c757d')
.verso-footer-inner(style='display:flex;flex-wrap:wrap;justify-content:space-between;gap:0.5rem 1.5rem;max-width:1200px;margin:0 auto')
div
| © #{new Date().getFullYear()} #[a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard] · Built on #[a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf]
div
| Distributed under the #[a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence] · #[a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code]
+20 -9
View File
@@ -4,16 +4,20 @@ footer.site-footer
.site-footer-content.hidden-print
.row
ul.site-footer-items.col-lg-9
if !settings.nav.hide_powered_by
li
//- year of Server Pro release, static
| © 2025
|
a(href='https://www.overleaf.com/for/enterprises') Powered by Overleaf
li
| © #{new Date().getFullYear()}
|
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
li
strong.text-muted |
li
| Built on
|
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
if showLanguagePicker || hasCustomLeftNav
li
strong.text-muted |
if showLanguagePicker || hasCustomLeftNav
li
strong.text-muted |
if showLanguagePicker
include language-picker
@@ -30,6 +34,13 @@ footer.site-footer
| !{item.text}
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
strong.text-muted |
li
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
each item in nav.right_footer
li
if item.url
+9
View File
@@ -2,10 +2,19 @@ extends ../layout-website-redesign
block vars
- isWebsiteRedesign = true
- var suppressNavbar = true
block content
main#main-content.content
.container
.row
.col-12
.text-center.mb-4
img.verso-login-logo(
src=buildImgPath('ol-brand/verso-logo.svg')
alt='Verso'
style='width:100%;max-width:480px;height:auto'
)
.row
.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
.page-header
+28 -11
View File
@@ -55,6 +55,8 @@ const defaultTextExtensions = [
'ldf',
'rmd',
'qmd',
'typ',
'vrf', // Verso requirements file (Python deps for Quarto venvs)
'lua',
'py',
'gv',
@@ -115,7 +117,14 @@ const httpPermissionsPolicy = {
},
}
const safeCompilers = ['xelatex', 'pdflatex', 'latex', 'lualatex']
const safeCompilers = [
'quarto',
'typst',
'xelatex',
'pdflatex',
'latex',
'lualatex',
]
module.exports = {
env: 'server-ce',
@@ -375,7 +384,7 @@ module.exports = {
process.env.PROJECT_UPLOAD_TIMEOUT || '120000',
10
),
maxUploadSize: 50 * 1024 * 1024, // 50 MB
maxUploadSize: 500 * 1024 * 1024, // 500 MB
multerOptions: {
preservePath: process.env.MULTER_PRESERVE_PATH,
},
@@ -467,9 +476,15 @@ module.exports = {
process.env.DEFAULT_LATEX_COMPILER
)
? process.env.DEFAULT_LATEX_COMPILER
: 'pdflatex',
: 'quarto',
enableSubscriptions: false,
restrictedCountries: [],
// When true, a project's requirements.txt is installed into a cached venv so
// Quarto's Python cells can use libraries beyond the bundled base set. Gated
// in CompileController to the project owner + invited collaborators only.
enableProjectPythonVenv:
process.env.OVERLEAF_ENABLE_PROJECT_PYTHON_VENV === 'true',
enableOnboardingEmails: process.env.ENABLE_ONBOARDING_EMAILS === 'true',
enabledLinkedFileTypes: (process.env.ENABLED_LINKED_FILE_TYPES || '').split(
@@ -779,6 +794,12 @@ module.exports = {
// them to disk here).
dumpFolder: Path.resolve(__dirname, '../data/dumpFolder'),
uploadFolder: Path.resolve(__dirname, '../data/uploads'),
// Verso: persisted snapshots of published presentations, served at
// /p/:token. Point PUBLISHED_PRESENTATIONS_PATH at a persistent volume in
// production so published links survive container restarts/redeploys.
publishedPresentationsFolder:
process.env.PUBLISHED_PRESENTATIONS_PATH ||
Path.resolve(__dirname, '../data/published'),
},
// Automatic Snapshots
@@ -801,7 +822,7 @@ module.exports = {
userId: process.env.SMOKE_TEST_USER_ID,
},
appName: process.env.APP_NAME || 'Overleaf (Community Edition)',
appName: process.env.APP_NAME || 'Verso',
adminEmail: process.env.ADMIN_EMAIL || 'placeholder@example.com',
adminDomains: process.env.ADMIN_DOMAINS
@@ -809,16 +830,12 @@ module.exports = {
: undefined,
nav: {
title: process.env.APP_NAME || 'Overleaf Community Edition',
title: process.env.APP_NAME || 'Verso',
hide_powered_by: process.env.NAV_HIDE_POWERED_BY === 'true',
left_footer: [],
right_footer: [
{
text: '<a href="https://github.com/overleaf/overleaf">Fork on GitHub!</a>',
},
],
right_footer: [],
showSubscriptionLink: false,
@@ -889,7 +906,7 @@ module.exports = {
process.env.FILE_IGNORE_PATTERN ||
'**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}',
validRootDocExtensions: ['tex', 'Rtex', 'ltx', 'Rnw'],
validRootDocExtensions: ['qmd', 'typ', 'tex', 'Rtex', 'ltx', 'Rnw'],
emailConfirmationDisabled:
process.env.EMAIL_CONFIRMATION_DISABLED === 'true' || false,
@@ -80,6 +80,7 @@
"add_another_email": "",
"add_another_token": "",
"add_comma_separated_emails_help": "",
"add_collaborators": "",
"add_comment": "",
"add_comment_error_message": "",
"add_comment_error_title": "",
@@ -218,7 +219,10 @@
"billing": "",
"billing_period_sentence_case": "",
"binary_history_error": "",
"blank_latex_project": "",
"blank_project": "",
"blank_quarto_project": "",
"blank_typst_project": "",
"blocked_filename": "",
"blog": "",
"bold": "",
@@ -527,7 +531,9 @@
"download": "",
"download_all": "",
"download_as_pdf": "",
"download_as_pdf_slides": "",
"download_as_source_zip": "",
"download_as_standalone_html": "",
"download_csv": "",
"download_metadata": "",
"download_pdf": "",
@@ -639,7 +645,10 @@
"errors": "",
"essential_cookies_only": "",
"event_type": "",
"example_latex_project": "",
"example_project": "",
"example_quarto_project": "",
"example_typst_project": "",
"existing_plan_active_until_term_end": "",
"expand": "",
"experiment_enabled_refresh_page": "",
@@ -1428,6 +1437,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": "",
@@ -1462,7 +1473,15 @@
"premium_feature": "",
"premium_plan_label": "",
"preparing_for_export": "",
"preparing_your_download": "",
"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": "",
@@ -1642,6 +1661,7 @@
"resend_link_sso": "",
"resend_managed_user_invite": "",
"resending_confirmation_code": "",
"reset_link": "",
"resize": "",
"resolve_comment": "",
"resolve_comment_error_message": "",
@@ -1796,6 +1816,8 @@
"settings": "",
"setup_another_account_under_a_personal_email_address": "",
"share": "",
"share_compiled_presentation": "",
"share_compiled_presentation_info": "",
"share_feedback": "",
"share_project": "",
"share_project_name": "",
@@ -0,0 +1,9 @@
/* EB Garamond (latin subset) — used for the Verso wordmark/instance name so
the UI text matches the logo. Self-hosted; same subset embedded in the SVGs. */
@font-face {
font-family: 'EB Garamond';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('EBGaramond-Regular.woff2') format('woff2');
}
@@ -24,6 +24,7 @@ export default /** @type {const} */ ([
'create_new_folder',
'delete_forever',
'delete',
'deployed_code',
'description',
'domain',
'edit',
@@ -2,11 +2,12 @@ import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import React from 'react'
import React, { useState } from 'react'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import FileTreeActionButton from './file-tree-action-button'
import { useRailContext } from '../../ide-react/context/rail-context'
import PythonRequirementsModal from './python-requirements-modal'
export default function FileTreeActionButtons({
fileTreeExpanded,
@@ -17,6 +18,7 @@ export default function FileTreeActionButtons({
const { fileTreeReadOnly } = useFileTreeData()
const { write } = usePermissionsContext()
const { handlePaneCollapse } = useRailContext()
const [showPythonModal, setShowPythonModal] = useState(false)
const {
canCreate,
@@ -110,6 +112,14 @@ export default function FileTreeActionButtons({
iconType="delete"
/>
)}
{write && (
<FileTreeActionButton
id="python-packages"
description={t('python_packages')}
onClick={() => setShowPythonModal(true)}
iconType="deployed_code"
/>
)}
</>
)}
<FileTreeActionButton
@@ -118,6 +128,10 @@ export default function FileTreeActionButtons({
onClick={handlePaneCollapse}
iconType="close"
/>
<PythonRequirementsModal
show={showPythonModal}
onHide={() => setShowPythonModal(false)}
/>
</div>
)
}
@@ -28,7 +28,11 @@ function FileTreeFolderList({
dataTestId?: string
}) {
files = files.map(file => ({ ...file, isFile: true }))
const docsAndFiles: (Doc | ExtendedFileRef)[] = [...docs, ...files]
// The Verso requirements file (requirements.vrf) is managed through the
// dedicated "Python packages" editor, so it is hidden from the file tree.
const docsAndFiles: (Doc | ExtendedFileRef)[] = [...docs, ...files].filter(
entity => !/\.vrf$/i.test(entity.name)
)
return (
<ul
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import LoadingSpinner from '@/shared/components/loading-spinner'
import { useProjectContext } from '@/shared/context/project-context'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
// Editor for the project's Python dependencies (requirements.vrf), reached from
// the file-tree toolbar. The file itself is hidden from the tree; this modal is
// the only entry point. One package per line, pip syntax (e.g. `openpyxl==3.1.5`).
export default function PythonRequirementsModal({
show,
onHide,
}: {
show: boolean
onHide: () => void
}) {
const { t } = useTranslation()
const { projectId } = useProjectContext()
const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!show) return
setError(null)
setLoading(true)
getJSON<{ content: string }>(`/project/${projectId}/python-requirements`)
.then(data => setContent(data.content || ''))
.catch(() => setError(t('generic_something_went_wrong')))
.finally(() => setLoading(false))
}, [show, projectId, t])
const handleSave = useCallback(() => {
setSaving(true)
setError(null)
postJSON(`/project/${projectId}/python-requirements`, {
body: { content },
})
.then(() => onHide())
.catch(() => setError(t('generic_something_went_wrong')))
.finally(() => setSaving(false))
}, [projectId, content, onHide, t])
return (
<OLModal show={show} onHide={onHide}>
<OLModalHeader closeButton>
<OLModalTitle>{t('python_packages')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p className="text-muted">{t('python_packages_help')}</p>
{error && (
<OLNotification type="error" content={error} className="mb-3" />
)}
{loading ? (
<LoadingSpinner />
) : (
<textarea
className="form-control"
rows={10}
spellCheck={false}
style={{ fontFamily: 'monospace' }}
value={content}
onChange={e => setContent(e.target.value)}
placeholder={'openpyxl==3.1.5\nrequests'}
aria-label={t('python_packages')}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onHide} disabled={saving}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={handleSave}
disabled={loading || saving}
isLoading={saving}
>
{t('save')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
@@ -0,0 +1,68 @@
import OLButton from '@/shared/components/ol/ol-button'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { postJSON } from '@/infrastructure/fetch-json'
import getMeta from '@/utils/meta'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
// One-click shortcut: publish the compiled presentation as a private
// (logged-in-users-only) standalone page and open it in a new tab. For finer
// control (public links, unpublish) use the Share dialog.
//
// Only meaningful for HTML/RevealJS decks — a PDF compile has nothing to
// "present", so the button hides itself when the current output isn't HTML.
export default function PresentationPreviewButton() {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const projectId = getMeta('ol-project_id')
const { pdfFile } = useCompileContext()
const isHtmlPresentation = pdfFile?.path === 'output.html'
const handleClick = useCallback(() => {
setLoading(true)
// Open the tab synchronously inside the click handler to avoid popup
// blockers, then redirect it once we have the published URL.
const win = window.open('', '_blank')
postJSON(`/project/${projectId}/publish-presentation`)
.then((data: { loginUrl?: string }) => {
const url = data?.loginUrl
if (url && win) {
win.location.href = url
} else if (url) {
window.open(url, '_blank')
} else if (win) {
win.close()
}
})
.catch(() => {
if (win) win.close()
})
.finally(() => setLoading(false))
}, [projectId])
if (!isHtmlPresentation) {
return null
}
return (
<div className="ide-redesign-toolbar-button-container">
<OLTooltip
id="presentation-present-button"
description={t('present_publishes_and_opens_in_new_tab')}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
size="sm"
variant="secondary"
leadingIcon={<MaterialIcon type="slideshow" />}
onClick={handleClick}
disabled={loading}
>
{t('present')}
</OLButton>
</OLTooltip>
</div>
)
}
@@ -3,6 +3,7 @@ import { ToolbarMenuBar } from './menu-bar'
import { ToolbarProjectTitle } from './project-title'
import { OnlineUsers } from './online-users'
import ShareProjectButton from './share-project-button'
import PresentationPreviewButton from './presentation-preview-button'
import ChangeLayoutButton from './change-layout-button'
import ShowHistoryButton from './show-history-button'
import { useLayoutContext } from '@/shared/context/layout-context'
@@ -59,6 +60,7 @@ export const Toolbar = () => {
{shouldDisplaySubmitButton && cobranding && (
<SubmitProjectButton cobranding={cobranding} />
)}
<PresentationPreviewButton />
<ShareProjectButton />
{getMeta('ol-showUpgradePrompt') && <UpgradeButton />}
</div>
@@ -1,3 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useProjectContext } from '@/shared/context/project-context'
@@ -6,16 +7,174 @@ import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLButton from '@/shared/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import LoadingSpinner from '@/shared/components/loading-spinner'
type ExportFormat = 'html' | 'pdf'
function filenameFromDisposition(disposition: string | null, ext: string) {
const match = disposition?.match(/filename="?([^"]+)"?/)
return match ? match[1] : `presentation.${ext}`
}
function PdfHybridDownloadButton() {
const { pdfDownloadUrl, showLogs } = useCompileContext()
const { pdfDownloadUrl, pdfFile, showLogs } = useCompileContext()
const { sendEvent } = useEditorAnalytics()
const { projectId } = useProjectContext()
const { t } = useTranslation()
// Standalone-HTML / slide-PDF exports trigger an on-demand server-side
// render that can take several seconds; surface that wait (and any failure
// log) in a modal instead of leaving the browser silently spinning.
const [exporting, setExporting] = useState<ExportFormat | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
// Bumped whenever the user dismisses the modal, so a request that finishes
// after they've closed it doesn't pop the modal back open.
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/${projectId}/presentation-export/${format}`,
{ credentials: 'same-origin' }
)
if (requestId !== requestIdRef.current) return // dismissed meanwhile
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 filename = filenameFromDisposition(
response.headers.get('Content-Disposition'),
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()
URL.revokeObjectURL(url)
setExporting(null)
} catch (err) {
if (requestId !== requestIdRef.current) return
setExporting(null)
setExportError(err instanceof Error ? err.message : String(err))
}
},
[projectId]
)
if (showLogs) {
return null
}
// A RevealJS deck compiles to output.html. For it we offer two export
// choices instead of a single download: a self-contained HTML file, or a
// faithful slide PDF (each triggers a one-off server-side export render).
const isPresentation = pdfFile?.path === 'output.html'
if (isPresentation) {
return (
<>
<Dropdown align="end">
<DropdownToggle
id="download-presentation"
variant="link"
className="pdf-toolbar-btn"
aria-label={t('download')}
>
<MaterialIcon type="download" />
</DropdownToggle>
<DropdownMenu>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('html')}
disabled={exporting !== null}
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('pdf')}
disabled={exporting !== null}
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
<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>
</>
)
}
const description = pdfDownloadUrl
? t('download_pdf')
: t('please_compile_pdf_before_download')
@@ -1,4 +1,4 @@
import { lazy, memo } from 'react'
import { lazy, memo, useCallback, useRef } from 'react'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
const PdfJsViewer = lazy(
@@ -8,10 +8,57 @@ const PdfJsViewer = lazy(
function PdfViewer() {
const { pdfUrl, pdfFile, pdfViewer } = useCompileContext()
// Remember the current RevealJS slide (kept in the deck's URL hash, e.g.
// "#/3/2") so that recompiling — which swaps the iframe src for a fresh
// build — reopens the deck on the same slide instead of jumping to the
// start. Only works when the output is same-origin (the usual self-hosted
// case); cross-origin reads fail silently and we just lose the position.
const lastHashRef = useRef('')
const handlePresentationLoad = useCallback(
(event: React.SyntheticEvent<HTMLIFrameElement>) => {
const win = event.currentTarget.contentWindow
if (!win) {
return
}
try {
const capture = () => {
try {
lastHashRef.current = win.location.hash
} catch {
// cross-origin after navigation — ignore
}
}
capture()
win.addEventListener('hashchange', capture)
} catch {
// cross-origin: can't track the slide position
}
},
[]
)
if (!pdfUrl) {
return null
}
// HTML outputs (RevealJS, etc.) must always use the native iframe;
// PDF.js cannot render HTML.
if (pdfUrl.includes('output.html')) {
// Re-append the remembered slide hash so the new build opens where the
// user left off. RevealJS reads the hash on load and navigates there.
const src = `${pdfUrl}${lastHashRef.current}`
return (
<iframe
title="Presentation Preview"
src={src}
onLoad={handlePresentationLoad}
style={{ width: '100%', height: '100%', border: 'none' }}
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
/>
)
}
switch (pdfViewer) {
case 'native':
return <iframe title="PDF Preview" src={pdfUrl} />
@@ -1,4 +1,5 @@
import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
import parseQuartoLog from '../../../ide/log-parser/quarto-log-parser'
import BibLogParser, {
BibLogEntry,
} from '../../../ide/log-parser/bib-log-parser'
@@ -24,7 +25,9 @@ export function handleOutputFiles(
projectId: string,
data: CompileResponseData
): PDFFile | null {
const outputFile = outputFiles.get('output.pdf')
// Accept either a PDF or an HTML output (e.g. RevealJS presentation)
const outputFile =
outputFiles.get('output.pdf') ?? outputFiles.get('output.html')
if (!outputFile) return null
outputFile.editorId = outputFile.editorId || EDITOR_SESSION_ID
@@ -54,7 +57,7 @@ export function handleOutputFiles(
params.set('popupDownload', 'true') // save PDF download as file
params.set('editorId', outputFile.editorId)
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/${outputFile.path}?${params}`
}
return outputFile
@@ -127,19 +130,28 @@ export async function handleLogFiles(
MAX_LOG_SIZE
)
try {
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
result.log,
{
ignoreDuplicates: true,
// Quarto (.qmd/.md/.Rmd) and bare Typst (.typ) compiles produce
// Typst/Pandoc/Quarto diagnostics that the LaTeX log parser does not
// understand. Route those to a dedicated parser so their errors and
// warnings populate the log tabs like LaTeX ones.
if (usesQuartoLogParser(data)) {
const { errors, warnings, typesetting } = parseQuartoLog(result.log)
accumulateResults({ errors, warnings, typesetting })
} else {
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
result.log,
{
ignoreDuplicates: true,
}
)
if (data.status === 'stopped-on-first-error') {
// Hide warnings that could disappear after a second pass
warnings = warnings.filter(warning => !isTransientWarning(warning))
}
)
if (data.status === 'stopped-on-first-error') {
// Hide warnings that could disappear after a second pass
warnings = warnings.filter(warning => !isTransientWarning(warning))
accumulateResults({ errors, warnings, typesetting })
}
accumulateResults({ errors, warnings, typesetting })
} catch (e) {
debugConsole.warn(e) // ignore failure to parse the log file, but log a warning
}
@@ -289,6 +301,15 @@ function isTransientWarning(warning: LatexLogEntry): boolean {
return TRANSIENT_WARNING_REGEX.test(warning.message || '')
}
// Mirrors CompileManager's runner dispatch in CLSI: both the Quarto runner
// (.qmd/.md/.Rmd) and the Typst runner (.typ) emit Typst-style diagnostics, so
// we pick the Quarto/Typst log parser for either, keyed on the root extension.
const QUARTO_TYPST_ROOT_REGEX = /\.(qmd|md|rmd|typ)$/i
function usesQuartoLogParser(data: CompileResponseData): boolean {
return QUARTO_TYPST_ROOT_REGEX.test(data.options?.rootResourcePath || '')
}
async function fetchFileWithSizeLimit(
url: string,
signal: AbortSignal,
@@ -192,24 +192,72 @@ function NewProjectButton({
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'blank_project',
dropdownMenuEvent: 'blank-project',
modalVariant: 'blank_quarto',
dropdownMenuEvent: 'blank-project-quarto',
})
}
>
{t('blank_project')}
{t('blank_quarto_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'example_project',
dropdownMenuEvent: 'example-project',
modalVariant: 'blank_latex',
dropdownMenuEvent: 'blank-project-latex',
})
}
>
{t('example_project')}
{t('blank_latex_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'blank_typst',
dropdownMenuEvent: 'blank-project-typst',
})
}
>
{t('blank_typst_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'example_quarto',
dropdownMenuEvent: 'example-project-quarto',
})
}
>
{t('example_quarto_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'example_latex',
dropdownMenuEvent: 'example-project-latex',
})
}
>
{t('example_latex_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'example_typst',
dropdownMenuEvent: 'example-project-typst',
})
}
>
{t('example_typst_project')}
</DropdownItem>
</li>
<li role="none">
@@ -5,9 +5,14 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types'
type BlankProjectModalProps = {
onHide: () => void
initialTags?: Tag[]
template?: string
}
function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) {
function BlankProjectModal({
onHide,
initialTags,
template = 'blank_quarto',
}: BlankProjectModalProps) {
return (
<OLModal
show
@@ -16,7 +21,11 @@ function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) {
id="blank-project-modal"
backdrop="static"
>
<ModalContentNewProjectForm onCancel={onHide} initialTags={initialTags} />
<ModalContentNewProjectForm
onCancel={onHide}
template={template}
initialTags={initialTags}
/>
</OLModal>
)
}
@@ -5,11 +5,13 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types'
type ExampleProjectModalProps = {
onHide: () => void
initialTags?: Tag[]
template?: string
}
function ExampleProjectModal({
onHide,
initialTags,
template = 'example_latex',
}: ExampleProjectModalProps) {
return (
<OLModal
@@ -21,7 +23,7 @@ function ExampleProjectModal({
>
<ModalContentNewProjectForm
onCancel={onHide}
template="example"
template={template}
initialTags={initialTags}
/>
</OLModal>
@@ -11,8 +11,12 @@ const UploadProjectModal = lazy(() => import('./upload-project-modal'))
const ImportDocumentModal = lazy(() => import('./import-document-modal'))
export type NewProjectButtonModalVariant =
| 'blank_project'
| 'example_project'
| 'blank_quarto'
| 'blank_latex'
| 'blank_typst'
| 'example_quarto'
| 'example_latex'
| 'example_typst'
| 'upload_project'
| 'import_from_github'
| 'import_docx'
@@ -50,10 +54,54 @@ function NewProjectButtonModal({
)
switch (modal) {
case 'blank_project':
return <BlankProjectModal onHide={onHide} initialTags={initialTags} />
case 'example_project':
return <ExampleProjectModal onHide={onHide} initialTags={initialTags} />
case 'blank_quarto':
return (
<BlankProjectModal
onHide={onHide}
initialTags={initialTags}
template="blank_quarto"
/>
)
case 'blank_latex':
return (
<BlankProjectModal
onHide={onHide}
initialTags={initialTags}
template="blank_latex"
/>
)
case 'blank_typst':
return (
<BlankProjectModal
onHide={onHide}
initialTags={initialTags}
template="blank_typst"
/>
)
case 'example_quarto':
return (
<ExampleProjectModal
onHide={onHide}
initialTags={initialTags}
template="example_quarto"
/>
)
case 'example_latex':
return (
<ExampleProjectModal
onHide={onHide}
initialTags={initialTags}
template="example_latex"
/>
)
case 'example_typst':
return (
<ExampleProjectModal
onHide={onHide}
initialTags={initialTags}
template="example_typst"
/>
)
case 'upload_project':
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
@@ -19,10 +19,7 @@ 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 overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
import CookieBanner from '@/shared/components/cookie-banner'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export function ProjectListDsNav() {
@@ -38,7 +35,6 @@ export function ProjectListDsNav() {
tags,
selectedTagId,
} = useProjectListContext()
const activeOverallTheme = useActiveOverallTheme()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const selectedTag = tags.find(tag => tag._id === selectedTagId)
@@ -87,13 +83,7 @@ export function ProjectListDsNav() {
className={`project-ds-nav-page website-redesign${isLibraryEnabled ? ' library-enabled' : ''}`}
>
<SystemMessages />
<DefaultNavbar
{...navbarProps}
overleafLogo={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
showCloseIcon
/>
<DefaultNavbar {...navbarProps} showCloseIcon />
<div className="project-list-wrapper">
<SidebarDsNav />
<div className="project-ds-nav-content-and-messages">
@@ -0,0 +1,37 @@
import { Project } from '../../../../../../../types/project/dashboard/api'
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): {
label: string
variant: 'quarto' | 'typst' | 'latex'
} {
switch (compiler) {
case 'quarto':
return { label: 'Quarto', variant: 'quarto' }
case 'typst':
return { label: 'Typst', variant: 'typst' }
default:
// pdflatex / latex / xelatex / lualatex (and the legacy default)
return { label: 'LaTeX', variant: 'latex' }
}
}
type FormatCellProps = {
project: Project
}
export default function FormatCell({ project }: FormatCellProps) {
const { label, variant } = formatLabel(project.compiler)
return (
<span
className={`project-format-badge project-format-badge-${variant}`}
translate="no"
>
{label}
</span>
)
}
@@ -1,5 +1,6 @@
import { memo } from 'react'
import InlineTags from './cells/inline-tags'
import FormatCell from './cells/format-cell'
import OwnerCell from './cells/owner-cell'
import LastUpdatedCell from './cells/last-updated-cell'
import ActionsCell from './cells/actions-cell'
@@ -31,6 +32,9 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
<LastUpdatedCell project={project} />
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
</td>
<td className="dash-cell-format d-none d-md-table-cell">
<FormatCell project={project} />
</td>
<td className="dash-cell-owner d-none d-md-table-cell">
<OwnerCell project={project} />
</td>
@@ -94,6 +94,12 @@ function ProjectListTable() {
>
{t('date_and_owner')}
</th>
<th
className="dash-cell-format d-none d-md-table-cell"
aria-label={t('format')}
>
{t('format')}
</th>
<th
className="dash-cell-owner d-none d-md-table-cell"
aria-label={t('owner')}
@@ -113,22 +113,82 @@ function WelcomeMessageCreateNewProjectDropdown({
<DropdownItem
as="button"
onClick={e =>
handleDropdownItemClick(e, 'blank_project', 'blank-project')
handleDropdownItemClick(
e,
'blank_quarto',
'blank-project-quarto'
)
}
tabIndex={-1}
>
{t('blank_project')}
{t('blank_quarto_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={e =>
handleDropdownItemClick(e, 'example_project', 'example-project')
handleDropdownItemClick(e, 'blank_latex', 'blank-project-latex')
}
tabIndex={-1}
>
{t('example_project')}
{t('blank_latex_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={e =>
handleDropdownItemClick(e, 'blank_typst', 'blank-project-typst')
}
tabIndex={-1}
>
{t('blank_typst_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={e =>
handleDropdownItemClick(
e,
'example_quarto',
'example-project-quarto'
)
}
tabIndex={-1}
>
{t('example_quarto_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={e =>
handleDropdownItemClick(
e,
'example_latex',
'example-project-latex'
)
}
tabIndex={-1}
>
{t('example_latex_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={e =>
handleDropdownItemClick(
e,
'example_typst',
'example-project-typst'
)
}
tabIndex={-1}
>
{t('example_typst_project')}
</DropdownItem>
</li>
<li role="none">
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import DropdownSetting from '../dropdown-setting'
import type { Option } from '../dropdown-setting'
@@ -6,39 +6,67 @@ import { useTranslation } from 'react-i18next'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { ProjectCompiler } from '@ol-types/project-settings'
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
import getMeta from '@/utils/meta'
import _ from 'lodash'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
// Which compiler engines make sense for a given root-file extension. CLSI
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
// .tex → latexmk), so offering, say, XeLaTeX for a .qmd root is meaningless.
const ENGINES_BY_EXTENSION: Record<string, ProjectCompiler[]> = {
tex: ['pdflatex', 'latex', 'xelatex', 'lualatex'],
qmd: ['quarto'],
rmd: ['quarto'],
typ: ['typst'],
}
function getCompilerOptions(): Option<ProjectCompiler>[] {
const compilerOptions = ['pdfLaTeX', 'LaTeX', 'XeLaTeX', 'LuaLaTeX']
const defaultCompiler = getMeta('ol-defaultLatexCompiler') as ProjectCompiler
const sortedOptions = _.sortBy(
compilerOptions,
option => option.toLowerCase() !== defaultCompiler.toLowerCase()
)
return sortedOptions.map(option => ({
value: option.toLowerCase() as ProjectCompiler,
label: option,
}))
return [
{ value: 'quarto', label: 'Quarto' },
{ value: 'typst', label: 'Typst' },
{ value: 'pdflatex', label: 'pdfLaTeX' },
{ value: 'latex', label: 'LaTeX' },
{ value: 'xelatex', label: 'XeLaTeX' },
{ value: 'lualatex', label: 'LuaLaTeX' },
]
}
export default function CompilerSetting() {
const { compiler, setCompiler } = useProjectSettingsContext()
const { compiler, setCompiler, rootDocId } = useProjectSettingsContext()
const [compilerOptions] = useState(() => getCompilerOptions())
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { docs } = useFileTreeData()
const changeCompiler = useSetCompilationSettingWithEvent(
'compiler',
setCompiler
)
// 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(() => {
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
}
return compilerOptions.map(option => ({
...option,
disabled: !allowed.includes(option.value) && option.value !== compiler,
}))
}, [compilerOptions, docs, rootDocId, compiler])
return (
<DropdownSetting
id="compiler"
label={t('compiler')}
description={t('the_latex_engine_used_for_compiling')}
disabled={!write}
options={compilerOptions}
options={options}
onChange={changeCompiler}
value={compiler}
translateOptions="no"
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import {
getJSON,
postJSON,
deleteJSON,
getUserFacingMessage,
} from '@/infrastructure/fetch-json'
import getMeta from '@/utils/meta'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
type Tier = 'public' | 'login' | 'member'
type PublishState = {
published: boolean
publicUrl?: string
loginUrl?: string
memberUrl?: string
publishedAt?: string
}
// "Share only the compiled result": publish the compiled HTML/RevealJS deck as
// a standalone, linkable page. Three stable links are offered, one per access
// tier (project members / logged-in users / anyone), each independently
// copyable and resettable. Re-publishing refreshes the deck while keeping the
// links; Reset rotates a single link's token, breaking only that old URL.
export default function PublishPresentationSection() {
const { t } = useTranslation()
const { project } = useProjectContext()
const projectId = project?._id || getMeta('ol-project_id')
const [state, setState] = useState<PublishState>({ published: false })
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<Tier | null>(null)
useEffect(() => {
if (!projectId) return
getJSON(`/project/${projectId}/publish-presentation`)
.then((data: PublishState) => setState(data))
.catch(() => {})
}, [projectId])
const run = useCallback((p: Promise<any>) => {
setBusy(true)
setError(null)
p.then((data: PublishState) => setState(data))
.catch(err => setError(getUserFacingMessage(err) ?? 'error'))
.finally(() => setBusy(false))
}, [])
const publish = useCallback(
() => run(postJSON(`/project/${projectId}/publish-presentation`)),
[projectId, run]
)
const regenerate = useCallback(
(tier: Tier) =>
run(
postJSON(`/project/${projectId}/publish-presentation/regenerate`, {
body: { tier },
})
),
[projectId, run]
)
const unpublish = useCallback(() => {
setBusy(true)
setError(null)
deleteJSON(`/project/${projectId}/publish-presentation`)
.then(() => setState({ published: false }))
.catch(err => setError(getUserFacingMessage(err) ?? 'error'))
.finally(() => setBusy(false))
}, [projectId])
const copy = useCallback((tier: Tier, url?: string) => {
if (url && navigator.clipboard) {
navigator.clipboard.writeText(url)
setCopied(tier)
setTimeout(() => setCopied(null), 2000)
}
}, [])
const tiers: { tier: Tier; label: string; url?: string }[] = [
{ tier: 'member', label: t('presentation_link_members'), url: state.memberUrl },
{ tier: 'login', label: t('presentation_link_private'), url: state.loginUrl },
{ tier: 'public', label: t('presentation_link_public'), url: state.publicUrl },
]
return (
<div className="public-access-level mb-4">
<h4>{t('share_compiled_presentation')}</h4>
<p className="small text-muted mb-2">
{t('share_compiled_presentation_info')}
</p>
{state.published &&
tiers.map(({ tier, label, url }) => (
<div className="mb-2" key={tier}>
<label className="small fw-bold mb-1 d-block">{label}</label>
<div className="input-group">
<input
type="text"
readOnly
className="form-control"
value={url || ''}
onFocus={e => e.target.select()}
/>
<OLButton
variant="secondary"
onClick={() => copy(tier, url)}
disabled={busy}
>
{copied === tier ? t('copied') : t('copy')}
</OLButton>
<OLButton
variant="secondary"
onClick={() => regenerate(tier)}
disabled={busy}
>
{t('reset_link')}
</OLButton>
</div>
</div>
))}
<div className="d-flex gap-2 mt-2">
<OLButton
variant="primary"
onClick={publish}
disabled={busy || !projectId}
isLoading={busy}
>
{t('publish')}
</OLButton>
{state.published && (
<OLButton variant="secondary" onClick={unpublish} disabled={busy}>
{t('unpublish')}
</OLButton>
)}
</div>
{error && (
<div className="mt-2">
<OLNotification
type="error"
content={
error === 'error' ? t('generic_something_went_wrong') : error
}
/>
</div>
)}
</div>
)
}
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import EditMember from './edit-member'
import LinkSharing from './link-sharing'
import Invite from './invite'
@@ -15,6 +16,7 @@ import OLNotification from '@/shared/components/ol/ol-notification'
import ErrorMessage from '@/features/share-project-modal/components/error-message'
import ProjectAccess from '@/features/share-project-modal/components/project-access'
import InvitedPeople from '@/features/share-project-modal/components/invited-people'
import PublishPresentationSection from '@/features/share-project-modal/components/publish-presentation-section'
type ShareModalBodyProps = {
isInvitedPeopleScreen: boolean
@@ -27,6 +29,7 @@ export default function ShareModalBody({
setIsInvitedPeopleScreen,
error,
}: ShareModalBodyProps) {
const { t } = useTranslation()
const { project, features } = useProjectContext()
const { members, invites } = project || {}
const { isProjectOwner } = useEditorContext()
@@ -114,13 +117,18 @@ export default function ShareModalBody({
return (
<>
{isProjectOwner && <PublishPresentationSection />}
{isProjectOwner ? (
<SendInvites
canAddCollaborators={canAddCollaborators}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
somePendingEditorsResolved={somePendingEditorsResolved}
/>
<>
<h4>{t('add_collaborators')}</h4>
<SendInvites
canAddCollaborators={canAddCollaborators}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
somePendingEditorsResolved={somePendingEditorsResolved}
/>
</>
) : (
<SendInvitesNotice />
)}
@@ -1,6 +1,8 @@
import { useCodeMirrorStateContext } from './codemirror-context'
import React, { useEffect } from 'react'
import { documentOutline } from '../languages/latex/document-outline'
import { markdownDocumentOutline } from '../languages/markdown/document-outline'
import { typstDocumentOutline } from '../languages/typst/document-outline'
import { ProjectionStatus } from '../utils/tree-operations/projection'
import useDebounce from '../../../shared/hooks/use-debounce'
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
@@ -10,7 +12,11 @@ export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
const state = useCodeMirrorStateContext()
const debouncedState = useDebounce(state, 100)
const outlineResult = debouncedState.field(documentOutline, false)
// Use whichever outline StateField is active for the current language
const outlineResult =
debouncedState.field(documentOutline, false) ??
debouncedState.field(markdownDocumentOutline, false) ??
debouncedState.field(typstDocumentOutline, false)
// when the outline projection changes, calculate the flat outline
useEffect(() => {
@@ -67,4 +67,11 @@ export const languages = [
return import('@codemirror/lang-python').then(m => m.python())
},
}),
LanguageDescription.of({
name: 'typst',
extensions: ['typ'],
load: () => {
return import('./typst').then(m => m.typst())
},
}),
]
@@ -0,0 +1,104 @@
import {
Completion,
CompletionContext,
CompletionResult,
snippetCompletion,
} from '@codemirror/autocomplete'
// Quarto code chunks: ```{lang} … ```
const fenceLanguages = [
'python',
'r',
'julia',
'ojs',
'bash',
'sql',
'mermaid',
'dot',
]
const fenceOptions: Completion[] = fenceLanguages.map(lang =>
snippetCompletion(`\`\`\`{${lang}}\n\${}\n\`\`\``, {
label: `\`\`\`{${lang}}`,
detail: 'code chunk',
type: 'keyword',
})
)
// Quarto callout / fenced div blocks (::: …)
const calloutTypes = [
'callout-note',
'callout-tip',
'callout-warning',
'callout-important',
'callout-caution',
]
const divOptions: Completion[] = [
...calloutTypes.map(type =>
snippetCompletion(`::: {.${type}}\n\${content}\n:::`, {
label: `::: {.${type}}`,
detail: 'callout',
type: 'class',
})
),
snippetCompletion('::: {.column width="${50%}"}\n${content}\n:::', {
label: '::: {.column}',
detail: 'column',
type: 'class',
}),
snippetCompletion('::: {.columns}\n${content}\n:::', {
label: '::: {.columns}',
detail: 'columns',
type: 'class',
}),
snippetCompletion('::: {.panel-tabset}\n${content}\n:::', {
label: '::: {.panel-tabset}',
detail: 'tabset',
type: 'class',
}),
]
// Cross-reference prefixes (@fig-, @tbl-, @sec-, @eq-, …)
const refOptions: Completion[] = [
{ label: '@fig-', detail: 'figure reference', type: 'variable' },
{ label: '@tbl-', detail: 'table reference', type: 'variable' },
{ label: '@sec-', detail: 'section reference', type: 'variable' },
{ label: '@eq-', detail: 'equation reference', type: 'variable' },
{ label: '@lst-', detail: 'listing reference', type: 'variable' },
]
export const quartoCompletions = (
context: CompletionContext
): CompletionResult | null => {
// Code chunk fence
const fence = context.matchBefore(/```\{?[\w-]*\}?/)
if (fence && fence.from !== fence.to) {
return {
from: fence.from,
options: fenceOptions,
validFor: /^```\{?[\w-]*\}?$/,
}
}
// Fenced divs / callouts
const div = context.matchBefore(/:::+\s*\{?\.?[\w-]*\}?/)
if (div && div.from !== div.to) {
return {
from: div.from,
options: divOptions,
}
}
// Cross references
const ref = context.matchBefore(/@[\w-]*/)
if (ref && (ref.from !== ref.to || context.explicit)) {
return {
from: ref.from,
options: refOptions,
validFor: /^@[\w-]*$/,
}
}
return null
}
@@ -0,0 +1,84 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNodeRef } from '@lezer/common'
import {
FlatOutlineItem,
NestingLevel,
} from '../../utils/tree-operations/outline'
import { NodeIntersectsChangeFn } from '../../utils/tree-operations/projection'
import { makeProjectionStateField } from '../../utils/projection-state-field'
// Map Lezer Markdown node names to outline nesting levels
const HEADING_LEVELS: Record<string, NestingLevel> = {
ATXHeading1: NestingLevel.Section,
ATXHeading2: NestingLevel.SubSection,
ATXHeading3: NestingLevel.SubSubSection,
ATXHeading4: NestingLevel.Paragraph,
ATXHeading5: NestingLevel.SubParagraph,
ATXHeading6: NestingLevel.SubParagraph,
SetextHeading1: NestingLevel.Section,
SetextHeading2: NestingLevel.SubSection,
}
// Offset of the end of the YAML frontmatter block, or 0 if there is none.
// Quarto/Pandoc documents start with a `---` line and close with `---` or
// `...`. The Lezer Markdown grammar has no frontmatter support, so it parses
// the closing `---` as a Setext heading underline and turns the last
// frontmatter line (e.g. `format: typst`) into a bogus heading. We use this
// boundary to ignore any heading that falls inside the frontmatter.
function frontmatterEnd(state: EditorState): number {
const firstLine = state.doc.line(1)
if (firstLine.text.trim() !== '---') return 0
for (let n = 2; n <= state.doc.lines; n++) {
const line = state.doc.line(n)
if (line.text.trim() === '---' || line.text.trim() === '...') {
return line.to
}
}
return 0
}
const enterMarkdownNode = (
state: EditorState,
node: SyntaxNodeRef,
items: FlatOutlineItem[],
nodeIntersectsChange: NodeIntersectsChangeFn
): void => {
const level = HEADING_LEVELS[node.name]
if (level === undefined) return
// Skip "headings" that are really inside the YAML frontmatter (see above).
if (node.from < frontmatterEnd(state)) return
if (!nodeIntersectsChange(node)) {
// Node unchanged — already present in items from the previous projection
return
}
// In the Lezer Markdown grammar the heading text is NOT a child node —
// the only children are HeaderMark nodes ('#'s for ATX, the '='/'-'
// underline for Setext). The title text sits in the gaps between marks.
// So we slice the whole heading and strip the marker characters:
// - ATX: leading '#'s and any optional trailing '#'s
// - Setext: the trailing underline line ('===' / '---')
// We also strip a trailing Pandoc/Quarto attribute block, e.g.
// `## Slide {.smaller auto-animate="true"}`.
const raw = state.sliceDoc(node.from, node.to)
const title = raw
.replace(/^\s*#+\s*/, '') // ATX: leading ### markers
.replace(/\s*#+\s*$/, '') // ATX: optional closing ### markers
.replace(/\n[=-]+\s*$/, '') // Setext: underline line
.replace(/\s*\{[^}]*\}\s*$/, '') // Pandoc/Quarto attributes
.trim()
items.push({
line: state.doc.lineAt(node.from).number,
toLine: state.doc.lineAt(node.to).number,
title,
from: node.from,
to: node.to,
level,
} as FlatOutlineItem)
}
export const markdownDocumentOutline =
makeProjectionStateField<FlatOutlineItem>(enterMarkdownNode)
@@ -8,6 +8,8 @@ import {
syntaxHighlighting,
} from '@codemirror/language'
import { tags } from '@lezer/highlight'
import { markdownDocumentOutline } from './document-outline'
import { quartoCompletions } from './complete'
export const markdown = () => {
const { language, support } = markdownLanguage({
@@ -19,6 +21,8 @@ export const markdown = () => {
support,
shortcuts(),
syntaxHighlighting(markdownHighlightStyle),
markdownDocumentOutline,
language.data.of({ autocomplete: quartoCompletions }),
])
}
@@ -0,0 +1,168 @@
import {
Completion,
CompletionContext,
CompletionResult,
snippetCompletion,
} from '@codemirror/autocomplete'
// Common Typst functions and constructs. Snippets use ${} placeholders so Tab
// jumps between fields. The leading '#' is included so completions work whether
// the user has typed it yet or not (the matched range covers an optional '#').
const snippets: Completion[] = [
snippetCompletion('#import "${}": ${}', {
label: '#import',
detail: 'import module',
type: 'keyword',
}),
snippetCompletion('#include "${}"', {
label: '#include',
detail: 'include file',
type: 'keyword',
}),
snippetCompletion('#let ${name} = ${value}', {
label: '#let',
detail: 'binding',
type: 'keyword',
}),
snippetCompletion('#set ${func}(${})', {
label: '#set',
detail: 'set rule',
type: 'keyword',
}),
snippetCompletion('#show ${selector}: ${func}', {
label: '#show',
detail: 'show rule',
type: 'keyword',
}),
snippetCompletion('#figure(\n ${body},\n caption: [${caption}],\n)', {
label: '#figure',
detail: 'figure with caption',
type: 'function',
}),
snippetCompletion('#image("${path}", width: ${width})', {
label: '#image',
detail: 'image',
type: 'function',
}),
snippetCompletion(
'#table(\n columns: ${2},\n ${[a], [b]},\n)',
{
label: '#table',
detail: 'table',
type: 'function',
}
),
snippetCompletion('#grid(\n columns: ${2},\n ${},\n)', {
label: '#grid',
detail: 'grid layout',
type: 'function',
}),
snippetCompletion('#text(${args})[${body}]', {
label: '#text',
detail: 'styled text',
type: 'function',
}),
snippetCompletion('#link("${url}")[${body}]', {
label: '#link',
detail: 'hyperlink',
type: 'function',
}),
snippetCompletion('#cite(<${key}>)', {
label: '#cite',
detail: 'citation',
type: 'function',
}),
snippetCompletion('#footnote[${body}]', {
label: '#footnote',
detail: 'footnote',
type: 'function',
}),
snippetCompletion('#bibliography("${path}")', {
label: '#bibliography',
detail: 'bibliography',
type: 'function',
}),
snippetCompletion('#pagebreak()', {
label: '#pagebreak',
detail: 'page break',
type: 'function',
}),
snippetCompletion('#lorem(${50})', {
label: '#lorem',
detail: 'placeholder text',
type: 'function',
}),
]
// Bare function/keyword names that complete to themselves (callable builtins
// the user may reference inside arguments or set/show rules).
const builtinNames = [
'heading',
'par',
'page',
'document',
'list',
'enum',
'terms',
'raw',
'math',
'align',
'stack',
'box',
'block',
'rect',
'circle',
'ellipse',
'line',
'columns',
'colbreak',
'strong',
'emph',
'underline',
'overline',
'strike',
'smallcaps',
'super',
'sub',
'highlight',
'quote',
'outline',
'numbering',
'counter',
'measure',
'repeat',
'pad',
'move',
'rotate',
'scale',
'place',
'hide',
]
const builtinCompletions: Completion[] = builtinNames.map(name => ({
label: name,
type: 'function',
detail: 'builtin',
}))
const options: Completion[] = [...snippets, ...builtinCompletions]
export const typstCompletions = (
context: CompletionContext
): CompletionResult | null => {
// Match an optional leading '#' plus an identifier, so suggestions appear
// both in code mode (#fig…) and when referencing names in arguments.
const word = context.matchBefore(/#?[\w-]*/)
if (!word) {
return null
}
if (word.from === word.to && !context.explicit) {
return null
}
return {
from: word.from,
options,
validFor: /^#?[\w-]*$/,
}
}
@@ -0,0 +1,69 @@
import { EditorState, StateField } from '@codemirror/state'
import {
ProjectionResult,
ProjectionStatus,
} from '../../utils/tree-operations/projection'
import {
FlatOutlineItem,
NestingLevel,
} from '../../utils/tree-operations/outline'
// Typst headings are '='-prefixed lines: '=' is a section, '==' a subsection,
// and so on. Map heading depth to the shared outline nesting levels.
const LEVELS: NestingLevel[] = [
NestingLevel.Section,
NestingLevel.SubSection,
NestingLevel.SubSubSection,
NestingLevel.Paragraph,
NestingLevel.SubParagraph,
]
// A heading is one or more leading '=' at column 0, a space, then the title.
// '==' as an equality operator never starts a line at column 0 with a space
// after it, so this stays clear of code.
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
function computeOutline(
state: EditorState
): ProjectionResult<FlatOutlineItem> {
const items: FlatOutlineItem[] = []
for (let n = 1; n <= state.doc.lines; n++) {
const line = state.doc.line(n)
const match = HEADING_REGEX.exec(line.text)
if (!match) continue
const depth = match[1].length
const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
// Strip a trailing label, e.g. '= Introduction <intro>'.
const title = match[2].replace(/\s*<[\w-]+>\s*$/, '').trim()
items.push({
line: n,
toLine: n,
title,
from: line.from,
to: line.to,
level,
} as FlatOutlineItem)
}
return { items, status: ProjectionStatus.Complete }
}
// The Typst language uses a StreamLanguage, whose syntax tree has no structural
// heading nodes to project from, so we scan the document text directly. Typst
// files are small, so a full rescan on each edit is cheap.
export const typstDocumentOutline = StateField.define<
ProjectionResult<FlatOutlineItem>
>({
create(state) {
return computeOutline(state)
},
update(value, transaction) {
if (transaction.docChanged) {
return computeOutline(transaction.state)
}
return value
},
})
@@ -0,0 +1,159 @@
import {
StreamLanguage,
StreamParser,
LanguageSupport,
} from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
import { typstCompletions } from './complete'
import { typstDocumentOutline } from './document-outline'
const keywords = new Set([
'let',
'set',
'show',
'import',
'include',
'if',
'else',
'for',
'while',
'return',
'break',
'continue',
'in',
'as',
'and',
'or',
'not',
'context',
])
const atoms = new Set(['none', 'auto', 'true', 'false'])
type TypstState = {
inBlockComment: boolean
}
// A lightweight stream tokenizer for Typst. It is not a full grammar, but it
// gives sensible highlighting for the common constructs: comments, strings,
// headings, code (#... functions and keywords), math delimiters, labels,
// references, numbers with units and markup emphasis markers. Token names are
// mapped to standard highlight tags via `tokenTable`, so the editor's global
// class highlighter themes them automatically (light + dark).
const parser: StreamParser<TypstState> = {
startState() {
return { inBlockComment: false }
},
token(stream, state) {
if (state.inBlockComment) {
if (stream.match(/.*?\*\//)) {
state.inBlockComment = false
} else {
stream.skipToEnd()
}
return 'comment'
}
if (stream.eatSpace()) {
return null
}
// Comments
if (stream.match('/*')) {
state.inBlockComment = true
return 'comment'
}
if (stream.match('//')) {
stream.skipToEnd()
return 'comment'
}
// Strings
if (stream.match(/"(?:[^"\\]|\\.)*"/)) {
return 'string'
}
// Headings: one or more '=' at the start of a line
if (stream.sol() && stream.match(/=+\s/)) {
return 'heading'
}
// Labels <name> and references @name
if (stream.match(/<[\w-]+>/)) {
return 'label'
}
if (stream.match(/@[\w-]+/)) {
return 'ref'
}
// Math delimiter
if (stream.eat('$')) {
return 'operator'
}
// Code mode: '#' introduces a function call or keyword
if (stream.match(/#[A-Za-z_][\w-]*/)) {
const word = stream.current().slice(1)
return keywords.has(word) ? 'keyword' : 'function'
}
if (stream.eat('#')) {
return 'operator'
}
// Identifiers, keywords and atoms (inside code blocks/args)
if (stream.match(/[A-Za-z_][\w-]*/)) {
const word = stream.current()
if (keywords.has(word)) {
return 'keyword'
}
if (atoms.has(word)) {
return 'atom'
}
if (stream.peek() === '(') {
return 'function'
}
return null
}
// Numbers, optionally with a unit
if (stream.match(/\d+(?:\.\d+)?(?:pt|mm|cm|in|em|fr|deg|%)?/)) {
return 'number'
}
// Markup emphasis markers
if (stream.eat('*') || stream.eat('_')) {
return 'operator'
}
stream.next()
return null
},
tokenTable: {
comment: t.comment,
string: t.string,
keyword: t.keyword,
number: t.number,
function: t.function(t.variableName),
heading: t.heading,
label: t.labelName,
ref: t.labelName,
operator: t.operator,
atom: t.atom,
},
languageData: {
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
closeBrackets: { brackets: ['(', '[', '{', '"', '$'] },
},
}
export const TypstLanguage = StreamLanguage.define(parser)
export const typst = () => {
return new LanguageSupport(TypstLanguage, [
TypstLanguage.data.of({ autocomplete: typstCompletions }),
typstDocumentOutline,
])
}
@@ -24,7 +24,7 @@ export class FlatOutlineItem extends ProjectionItem {
export type FlatOutline = FlatOutlineItem[]
/* eslint-disable no-unused-vars */
enum NestingLevel {
export enum NestingLevel {
Book = 1,
Part = 2,
Chapter = 3,
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import InviteNotValid from '@/features/share-project/invite-not-valid'
import getMeta from '@/utils/meta'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import LoadingBranded from '@/shared/components/loading-branded'
export const AccessAttemptScreen: FC<{
loadingScreenBrandHeight: string
@@ -12,6 +13,11 @@ export const AccessAttemptScreen: FC<{
const { t } = useTranslation()
const user = getMeta('ol-user')
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
// The brand height is a "% filled" string (e.g. "20%") driven by request
// progress; map it to the Verso loader's colour-reveal saturation.
const brandProgress = loadingScreenBrandHeight.endsWith('%')
? parseFloat(loadingScreenBrandHeight)
: 0
if (isSharingUpdatesEnabled) {
if (accessError) {
@@ -21,12 +27,7 @@ export const AccessAttemptScreen: FC<{
return (
<div className="vertically-centered-content">
<div className="loading-screen">
<div className="loading-screen-brand-container">
<div
className="loading-screen-brand"
style={{ height: loadingScreenBrandHeight }}
/>
</div>
<LoadingBranded loadProgress={brandProgress} hasError />
<h3 className="loading-screen-label text-center">
{t('join_project')}
@@ -39,12 +40,7 @@ export const AccessAttemptScreen: FC<{
return (
<div className="loading-screen">
<div className="loading-screen-brand-container">
<div
className="loading-screen-brand"
style={{ height: loadingScreenBrandHeight }}
/>
</div>
<LoadingBranded loadProgress={brandProgress} hasError />
<h3 className="loading-screen-label text-center">
{t('join_project')}
@@ -0,0 +1,199 @@
import { LatexLogEntry, ParseResult } from './latex-log-parser'
// Parser for the combined stdout/stderr that `quarto render` writes to
// output.log (see services/clsi/app/js/QuartoRunner.js). Quarto orchestrates
// several tools, each with its own diagnostic style:
//
// - Typst (the engine for .qmd -> PDF): emits
// error: unexpected end of block comment
// ┌─ main.typ:5:10
// ...and the analogous `warning: ...` form. Older builds use `-->` instead
// of the box-drawing arrow.
// - Pandoc (markdown -> typst/html): emits `[WARNING] ...` / `[ERROR] ...`.
// - Quarto CLI itself (YAML validation, project errors, Deno crashes): emits
// `ERROR: ...` / `WARNING: ...` (upper-case) or `error: Uncaught ...`.
// - knitr/R (.Rmd / executable cells): emits `Quitting from lines 3-7 (x.qmd)`
// followed by an `Error: ...` message.
//
// This is deliberately a flat, line-oriented parser rather than the stateful
// LaTeX one: Quarto's output has no nested-file `(...)` structure to track.
// It returns the same ParseResult shape so the rest of the log pipeline
// (HumanReadableLogs consumers, the errors/warnings tabs, editor annotations)
// can treat Quarto entries exactly like LaTeX ones.
// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /\x1b\[[0-9;]*m/g
// Typst / Deno: `error: message`, `warning: message` (lower-case prefix)
const LOWER_DIAG_REGEX = /^(error|warning): (.*)$/
// Quarto CLI: `ERROR: message`, `WARNING: message` (upper-case prefix)
const UPPER_DIAG_REGEX = /^(ERROR|WARNING): (.*)$/
// Pandoc: `[WARNING] message`, `[ERROR] message`, `[INFO] message`
const PANDOC_REGEX = /^\[(WARNING|ERROR|INFO)\] (.*)$/
// knitr/R: `Quitting from lines 3-7 (slides.qmd)`
const R_QUITTING_REGEX = /^Quitting from lines? (\d+)(?:-\d+)?\s*(?:\(([^)]+)\))?/
// Python (Jupyter cell execution): a missing dependency, e.g.
// ModuleNotFoundError: No module named 'pandas'
// ImportError: No module named scipy
const PY_MODULE_REGEX =
/^(?:ModuleNotFoundError|ImportError): No module named ['"]?([\w.]+)['"]?/
// Import (module) name -> PyPI package name, for the common cases where they
// differ. Anything not listed defaults to the module name itself.
const PY_MODULE_TO_PACKAGE: Record<string, string> = {
cv2: 'opencv-python',
sklearn: 'scikit-learn',
skimage: 'scikit-image',
PIL: 'Pillow',
yaml: 'PyYAML',
bs4: 'beautifulsoup4',
Crypto: 'pycryptodome',
OpenSSL: 'pyOpenSSL',
dateutil: 'python-dateutil',
dotenv: 'python-dotenv',
serial: 'pyserial',
usb: 'pyusb',
cairo: 'pycairo',
gi: 'PyGObject',
win32com: 'pywin32',
}
// A typst diagnostic location line: ` ┌─ main.typ:5:10` / ` --> main.typ:5:10`
const TYPST_LOCATION_REGEX = /(?:[┌╭]─|-->)\s*(.+?):(\d+):(\d+)/
function stripAnsi(line: string): string {
return line.replace(ANSI_REGEX, '')
}
function isDiagnosticStart(trimmed: string): boolean {
return (
LOWER_DIAG_REGEX.test(trimmed) ||
UPPER_DIAG_REGEX.test(trimmed) ||
PANDOC_REGEX.test(trimmed)
)
}
export default function parseQuartoLog(rawLog: string): ParseResult {
const lines = rawLog.replace(/\r\n?/g, '\n').split('\n')
const data: LatexLogEntry[] = []
let pendingLocation: { file?: string; line?: number } = {}
for (let i = 0; i < lines.length; i++) {
const clean = stripAnsi(lines[i])
const trimmed = clean.trimStart()
// Remember the most recent knitr location; it precedes the `Error:` line.
const quitting = trimmed.match(R_QUITTING_REGEX)
if (quitting) {
pendingLocation = {
line: parseInt(quitting[1], 10),
file: quitting[2],
}
continue
}
// A missing Python package when executing a {python} cell. Turn the raw
// traceback line into an actionable message rather than letting it slip
// through as an opaque error (or not be surfaced at all).
const pyModule = trimmed.match(PY_MODULE_REGEX)
if (pyModule) {
const moduleName = pyModule[1]
// Suggest the PyPI package for the top-level module (cv2 -> opencv-python).
const topLevel = moduleName.split('.')[0]
const suggestion = PY_MODULE_TO_PACKAGE[topLevel] || topLevel
data.push({
line: pendingLocation.line ?? null,
file: pendingLocation.file,
level: 'error',
message: `Python module "${moduleName}" is not available`,
content:
`${clean}\n\n` +
`If "${topLevel}" is a PyPI package, add \`${suggestion}\` to your ` +
`Verso requirements file (requirements.vrf) and recompile as the ` +
`project owner or a collaborator. If it is your own module, add its ` +
`.py file(s) to the project instead.\n` +
`Pre-installed: numpy, pandas, scipy, matplotlib, seaborn, ` +
`scikit-learn, sympy, plotly, tabulate, opencv-python (cv2), tqdm.`,
raw: clean,
})
pendingLocation = {}
continue
}
let level: LatexLogEntry['level'] | null = null
let message: string | null = null
let m: RegExpMatchArray | null
if ((m = trimmed.match(LOWER_DIAG_REGEX))) {
level = m[1] === 'error' ? 'error' : 'warning'
message = m[2]
} else if ((m = trimmed.match(UPPER_DIAG_REGEX))) {
level = m[1] === 'ERROR' ? 'error' : 'warning'
message = m[2]
} else if ((m = trimmed.match(PANDOC_REGEX))) {
if (m[1] === 'INFO') continue // pandoc INFO lines are not actionable
level = m[1] === 'ERROR' ? 'error' : 'warning'
message = m[2]
}
if (level === null || message === null) continue
// Accumulate any following indented/diagnostic lines (the typst box, a Deno
// stack trace, R traceback) as the entry's content, and pick up a
// file:line:col location from the typst box if present. Stop at a blank
// line or the start of the next diagnostic.
let file = pendingLocation.file
let line: number | null = pendingLocation.line ?? null
let content = clean
let j = i + 1
for (; j < lines.length; j++) {
const next = stripAnsi(lines[j])
if (next.trim() === '') break
if (isDiagnosticStart(next.trimStart())) break
content += '\n' + next
const loc = next.match(TYPST_LOCATION_REGEX)
if (loc && !file) {
file = loc[1]
line = parseInt(loc[2], 10)
}
}
i = j - 1
data.push({
line,
file,
level,
message: message.trim(),
content,
raw: content,
})
pendingLocation = {}
}
return postProcess(data)
}
function postProcess(data: LatexLogEntry[]): ParseResult {
const all: LatexLogEntry[] = []
const byLevel: Record<'error' | 'warning' | 'typesetting', LatexLogEntry[]> = {
error: [],
warning: [],
typesetting: [],
}
const seen = new Set<string>()
for (const entry of data) {
if (seen.has(entry.raw)) continue
seen.add(entry.raw)
byLevel[entry.level]?.push(entry)
all.push(entry)
}
return {
errors: byLevel.error,
warnings: byLevel.warning,
typesetting: byLevel.typesetting,
all,
files: [],
}
}
@@ -40,40 +40,43 @@ function Separator() {
)
}
function ThinFooter({
showPoweredBy,
subdomainLang,
leftItems,
rightItems,
}: FooterMetadata) {
function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
const showLanguagePicker = Boolean(
subdomainLang && Object.keys(subdomainLang).length > 1
)
const hasCustomLeftNav = Boolean(leftItems && leftItems.length > 0)
return (
<footer className="site-footer">
<div className="site-footer-content d-print-none">
<OLRow>
<ul className="site-footer-items col-lg-9">
{showPoweredBy ? (
<>
<li>
{/* year of Server Pro release, static */}© 2025{' '}
<a href="https://www.overleaf.com/for/enterprises">
Powered by Overleaf
</a>
</li>
{showLanguagePicker || hasCustomLeftNav ? <Separator /> : null}
</>
) : null}
<li>
© {new Date().getFullYear()}{' '}
<a
href="https://alocoq.fr"
target="_blank"
rel="noopener noreferrer"
>
Aloïs Coquillard
</a>
</li>
<Separator />
<li>
Built on{' '}
<a
href="https://github.com/overleaf/overleaf"
target="_blank"
rel="noopener noreferrer"
>
Overleaf
</a>
</li>
{showLanguagePicker ? (
<>
<Separator />
<li>
<LanguagePicker showHeader />
</li>
{hasCustomLeftNav ? <Separator /> : null}
</>
) : null}
{leftItems?.map(item => (
@@ -81,6 +84,25 @@ function ThinFooter({
))}
</ul>
<ul className="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
</a>
</li>
<Separator />
<li>
<a
href="https://git.alocoq.fr/alois/verso"
target="_blank"
rel="noopener noreferrer"
>
Source code
</a>
</li>
{rightItems?.map(item => (
<FooterItemLi key={item.text} {...item} />
))}
@@ -18,7 +18,7 @@ export function Interstitial({
}: InterstitialProps) {
return (
<div className={classNames('interstitial', className)}>
{showLogo && <img className="logo" src={overleafLogo} alt="Overleaf" />}
{showLogo && <img className="logo" src={overleafLogo} alt="Verso" />}
{title && <h1 className="h3 interstitial-header">{title}</h1>}
<div className={classNames(contentClassName)}>{children}</div>
</div>
@@ -1,3 +1,5 @@
import { CSSProperties } from 'react'
type LoadingBrandedTypes = {
loadProgress: number // Percentage
label?: string
@@ -9,13 +11,81 @@ export default function LoadingBranded({
label,
hasError = false,
}: LoadingBrandedTypes) {
// Drive the colour reveal from the load progress: the four circles start
// de-saturated and "warm up" to full colour as the project loads, while
// each circle keeps drifting on its own little orbit (see the CSS).
const saturation = Math.max(0, Math.min(1, loadProgress / 100))
return (
<>
<div className="loading-screen-brand-container">
<div
className="loading-screen-brand"
style={{ height: `${loadProgress}%` }}
/>
<svg
className="verso-loader"
viewBox="0 0 200 200"
role="img"
aria-label="Verso"
style={
{
'--verso-loader-saturation': saturation,
} as CSSProperties
}
>
<defs>
<clipPath id="verso-loader-clip">
<rect x="0" y="0" width="200" height="200" rx="44" />
</clipPath>
</defs>
<g clipPath="url(#verso-loader-clip)">
<rect x="0" y="0" width="200" height="200" fill="#0a0a0a" />
<g className="verso-loader-circles">
<circle
className="verso-loader-c1"
cx="72"
cy="72"
r="68"
fill="#447099"
fillOpacity="0.82"
/>
<circle
className="verso-loader-c2"
cx="128"
cy="72"
r="68"
fill="#72994E"
fillOpacity="0.82"
/>
<circle
className="verso-loader-c3"
cx="72"
cy="128"
r="68"
fill="#EE6331"
fillOpacity="0.75"
/>
<circle
className="verso-loader-c4"
cx="128"
cy="128"
r="68"
fill="#4a6fa5"
fillOpacity="0.80"
/>
</g>
<text
className="verso-loader-v"
x="100"
y="164"
fontFamily="'EB Garamond', Georgia, serif"
fontSize="170"
fontWeight="400"
textAnchor="middle"
fill="white"
fillOpacity="0.97"
>
V
</text>
</g>
</svg>
</div>
{!hasError && (
@@ -10,6 +10,9 @@ import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context'
import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items'
import { sendMB } from '@/infrastructure/event-tracking'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import versoLogo from '@/shared/svgs/verso-logo.svg'
import versoLogoDark from '@/shared/svgs/verso-logo-dark.svg'
export function SidebarLowerSection({
showThemeToggle = false,
@@ -30,6 +33,8 @@ export function SidebarLowerSection({
autofillProjectUrl: false,
})
const { sessionUser, showSubscriptionLink, items } = getMeta('ol-navbar')
const { appName } = getMeta('ol-ExposedSettings')
const activeOverallTheme = useActiveOverallTheme()
const helpItem = items.find(
item => item.text === 'help_and_resources'
) as NavbarDropdownItemData
@@ -128,9 +133,18 @@ export function SidebarLowerSection({
</Dropdown>
)}
</nav>
<div className="ds-nav-ds-name" translate="no">
<span>Digital Science</span>
</div>
<a
href="/"
className="ds-nav-verso-logo"
aria-label={appName}
style={{ display: 'block', marginTop: 'var(--spacing-03)' }}
>
<img
src={activeOverallTheme === 'dark' ? versoLogoDark : versoLogo}
alt={appName}
style={{ width: '100%', height: 'auto' }}
/>
</a>
<UserProvider>{contactUsModal}</UserProvider>
</>
)
@@ -505,6 +505,15 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
// set the PDF context
if (data.status === 'success') {
setPdfFile(handleOutputFiles(outputFiles, projectId, data))
} else {
// For PDF output we intentionally keep the last good PDF visible
// next to the error log (standard Overleaf behaviour). But for an
// HTML deck rendered in an iframe, leaving the previous deck up is
// confusing — it looks like the failed compile succeeded. So drop
// a stale HTML output on any non-success status.
setPdfFile(prev =>
prev?.path === 'output.html' ? undefined : prev
)
}
setFileList(buildFileList(outputFiles, data))
@@ -1,9 +1,17 @@
<svg width="129" height="38" viewBox="0 0 129 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_2579_355" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="72" height="38">
<path d="M71.7643 37.6327H0.0244141V0.0717773H71.7643V37.6327Z" fill="white"/>
</mask>
<g mask="url(#mask0_2579_355)">
<path d="M47.2509 26.4555C47.3948 27.7507 47.8985 28.7821 48.81 29.5257C49.6974 30.2692 50.8487 30.653 52.2638 30.653C53.1993 30.653 54.0387 30.4611 54.7823 30.0773C55.5258 29.6696 56.1255 29.1419 56.5572 28.4223H61.0664C60.2989 30.3891 59.1716 31.9002 57.6365 33.0035C56.1255 34.0829 54.3506 34.6345 52.3598 34.6345C51.0166 34.6345 49.7934 34.3947 48.666 33.915C47.5387 33.4352 46.5314 32.7397 45.6199 31.8043C44.7804 30.9168 44.1089 29.9094 43.6531 28.7341C43.1974 27.5589 42.9576 26.3836 42.9576 25.1603C42.9576 23.9131 43.1734 22.7138 43.6052 21.6105C44.0369 20.5072 44.6605 19.4998 45.5 18.6124C46.4114 17.629 47.4668 16.8854 48.6181 16.3817C49.7694 15.8541 50.9686 15.5902 52.1919 15.5902C53.7509 15.5902 55.214 15.95 56.5572 16.6456C57.9004 17.3651 59.0517 18.3485 60.0111 19.6437C60.5867 20.4113 61.0185 21.2747 61.3063 22.2581C61.5941 23.2175 61.714 24.3209 61.714 25.5681C61.714 25.664 61.714 25.8079 61.69 26.0238C61.69 26.2397 61.6661 26.3836 61.6661 26.4795H47.2509V26.4555ZM57.2048 23.1216C56.845 21.9223 56.2454 21.0109 55.4059 20.3873C54.5664 19.7637 53.4871 19.4519 52.2159 19.4519C51.0886 19.4519 50.1052 19.7876 49.2177 20.4592C48.3303 21.1308 47.7306 22.0183 47.4188 23.1216H57.2048ZM71.7638 19.7637C70.1328 19.8836 69.0055 20.3153 68.3579 21.0349C67.7103 21.7544 67.3985 23.0496 67.3985 24.9205V34.1068H63.2011V16.1179H67.1347V18.2046C67.7583 17.3891 68.4539 16.8135 69.2214 16.4297C69.9649 16.0459 70.8284 15.8541 71.7638 15.8541V19.7637ZM32.428 1.24705C27.3432 -0.743722 8.9465 -1.46328 8.92251 9.52196C3.54982 12.9519 0 18.5404 0 24.5367C0 31.7803 5.87638 37.6567 13.1199 37.6567C20.3635 37.6567 26.2399 31.7803 26.2399 24.5367C26.2399 18.9482 22.738 14.1511 17.797 12.2803C16.8376 11.9205 14.7749 11.2729 13.1439 11.4168C10.7934 12.9039 7.91513 15.974 6.57196 19.0441C8.58672 16.6216 11.7288 15.5662 14.5351 16.022C18.6365 16.6936 21.7786 20.2434 21.7786 24.5607C21.7786 29.3338 17.917 33.1954 13.1439 33.1954C10.5055 33.1954 8.15498 32.0201 6.57196 30.1733C4.19742 27.415 3.59779 24.4408 4.07749 21.5386C5.73247 11.3688 17.797 5.58838 26.7675 3.35775C23.8413 4.9168 18.5646 7.45923 14.8708 10.2175C25.6402 14.391 27.3911 5.30056 32.428 1.24705ZM36.7934 34.1308H33.5074L26.6716 16.1179H31.1328L35.3303 28.0865L39.6476 16.1179H43.9889L36.7934 34.1308Z" fill="#1B222C"/>
</g>
<path d="M83.6127 26.4556C83.7567 27.7508 84.2843 28.7822 85.1718 29.5257C86.0592 30.2692 87.2105 30.653 88.6257 30.653C89.5611 30.653 90.4006 30.4611 91.1441 30.0774C91.8877 29.6696 92.4873 29.1419 92.919 28.4224H97.4282C96.6607 30.3892 95.5334 31.9002 93.9984 33.0036C92.4873 34.0829 90.7124 34.6346 88.7216 34.6346C87.3784 34.6346 86.1552 34.3947 85.0279 33.915C83.9006 33.4353 82.8932 32.7397 81.9817 31.8043C81.1423 30.9168 80.4707 29.9095 80.015 28.7342C79.5353 27.5829 79.3194 26.3836 79.3194 25.1604C79.3194 23.9131 79.5353 22.7139 79.967 21.6106C80.3987 20.5072 81.0223 19.4999 81.8618 18.6124C82.7733 17.629 83.8286 16.8855 84.9799 16.3818C86.1312 15.8541 87.3305 15.5903 88.5537 15.5903C90.1128 15.5903 91.5758 15.95 92.919 16.6456C94.2622 17.3652 95.4135 18.3486 96.3729 19.6438C96.9485 20.4113 97.3803 21.2748 97.6681 22.2582C97.9559 23.2176 98.0758 24.3209 98.0758 25.5681C98.0758 25.6641 98.0758 25.808 98.0519 26.0238C98.0519 26.2397 98.0279 26.3836 98.0279 26.4796H83.6127V26.4556ZM93.5426 23.1216C93.1829 21.9224 92.5832 21.0109 91.7437 20.3873C90.9043 19.7637 89.8249 19.4519 88.5537 19.4519C87.4264 19.4519 86.443 19.7877 85.5556 20.4593C84.6681 21.1309 84.0685 22.0183 83.7567 23.1216H93.5426ZM114.698 34.1309V31.9242C114.194 32.8117 113.498 33.4833 112.587 33.915C111.675 34.3467 110.5 34.5626 109.085 34.5626C106.423 34.5626 104.192 33.6512 102.417 31.8283C100.642 30.0054 99.7308 27.7508 99.7308 25.0644C99.7308 23.7932 99.9467 22.594 100.402 21.4667C100.858 20.3393 101.482 19.332 102.321 18.4685C103.209 17.5091 104.216 16.8135 105.295 16.3578C106.375 15.9021 107.622 15.6862 108.989 15.6862C110.308 15.6862 111.436 15.9021 112.371 16.3338C113.306 16.7655 114.074 17.4371 114.65 18.3246V16.1419H118.727V34.1548H114.698V34.1309ZM104.024 24.9685C104.024 26.4796 104.528 27.7508 105.535 28.7822C106.543 29.8135 107.766 30.3172 109.229 30.3172C110.548 30.3172 111.699 29.8135 112.707 28.7822C113.714 27.7508 114.218 26.5515 114.218 25.1844C114.218 23.7213 113.714 22.474 112.707 21.4187C111.699 20.3633 110.524 19.8357 109.157 19.8357C107.742 19.8357 106.543 20.3393 105.535 21.3227C104.528 22.3301 104.024 23.5294 104.024 24.9685ZM129.904 16.1179V19.8596H126.882V34.1309H122.829V19.8596H120.694V16.1179H122.709V15.6382C122.709 13.7434 123.236 12.3283 124.268 11.3929C125.323 10.4574 126.906 10.0017 129.041 10.0017C129.113 10.0017 129.257 10.0017 129.449 10.0257C129.64 10.0257 129.784 10.0497 129.904 10.0497V13.8154H129.616C128.657 13.8154 127.985 13.9833 127.578 14.2711C127.17 14.5829 126.954 15.0866 126.954 15.8301V16.1659H129.904V16.1179ZM73.5869 34.1309H77.6884V10.2895H73.5869V34.1309Z" fill="#1B222C"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
<defs>
<clipPath id="icb"><circle cx="25" cy="25" r="23"/></clipPath>
</defs>
<g clip-path="url(#icb)">
<rect x="2" y="2" width="23" height="23" fill="#447099"/>
<rect x="25" y="2" width="23" height="23" fill="#72994E"/>
<rect x="2" y="25" width="23" height="23" fill="#EE6331"/>
<rect x="25" y="25" width="23" height="23" fill="#1B3B6F"/>
<polyline points="8,12 25,38 42,12"
stroke="white" stroke-width="6" fill="none"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="56" y="33"
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
font-size="26" font-weight="600" fill="#1B3B6F" letter-spacing="-0.5">Verso</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 786 B

@@ -1,9 +1,17 @@
<svg width="104" height="30" viewBox="0 0 104 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_2946_8679" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="58" height="30">
<path d="M57.4311 30H0.132324V0H57.4311V30Z" fill="white"/>
</mask>
<g mask="url(#mask0_2946_8679)">
<path d="M37.8711 21.0606C37.9861 22.0943 38.3884 22.9174 39.1163 23.5108C39.8251 24.1042 40.7446 24.4105 41.8749 24.4105C42.622 24.4105 43.2925 24.2574 43.8863 23.9511C44.4802 23.6257 44.9591 23.2045 45.304 22.6303H48.9054C48.2924 24.1999 47.392 25.4059 46.166 26.2864C44.9591 27.1478 43.5415 27.5881 41.9515 27.5881C40.8787 27.5881 39.9017 27.3967 39.0014 27.0138C38.101 26.631 37.2964 26.0759 36.5685 25.3293C35.898 24.6211 35.3616 23.8171 34.9976 22.8791C34.6336 21.9412 34.4421 21.0032 34.4421 20.027C34.4421 19.0316 34.6145 18.0745 34.9593 17.1939C35.3041 16.3134 35.8022 15.5094 36.4727 14.8011C37.2006 14.0163 38.0435 13.4229 38.9631 13.0209C39.8826 12.5998 40.8404 12.3892 41.8174 12.3892C43.0626 12.3892 44.2312 12.6764 45.304 13.2315C46.3767 13.8058 47.2963 14.5906 48.0625 15.6243C48.5223 16.2368 48.8671 16.9259 49.097 17.7108C49.3269 18.4764 49.4226 19.357 49.4226 20.3524C49.4226 20.4289 49.4227 20.5438 49.4035 20.7161C49.4035 20.8883 49.3843 21.0032 49.3843 21.0798H37.8711V21.0606ZM45.8212 18.3999C45.5338 17.4428 45.0549 16.7154 44.3844 16.2177C43.7139 15.72 42.8519 15.4711 41.8366 15.4711C40.9362 15.4711 40.1508 15.7391 39.442 16.2751C38.7332 16.8111 38.2543 17.5193 38.0052 18.3999H45.8212ZM57.4493 15.72C56.1467 15.8157 55.2463 16.1602 54.7291 16.7345C54.2118 17.3088 53.9628 18.3424 53.9628 19.8355V27.167H50.6104V12.8104H53.7521V14.4757C54.2502 13.8249 54.8057 13.3655 55.4187 13.0592C56.0126 12.7529 56.7022 12.5998 57.4493 12.5998V15.72ZM26.0323 0.94225C21.971 -0.646546 7.27779 -1.22081 7.25864 7.54628C2.96752 10.2836 0.132324 14.7437 0.132324 19.5293C0.132324 25.3102 4.82573 30 10.6111 30C16.3964 30 21.0898 25.3102 21.0898 19.5293C21.0898 15.0691 18.2929 11.2407 14.3466 9.74763C13.5804 9.4605 11.9329 8.94366 10.6302 9.05851C8.75286 10.2453 6.45405 12.6955 5.38127 15.1457C6.99044 13.2124 9.49998 12.3701 11.7413 12.7338C15.0171 13.2698 17.5267 16.1028 17.5267 19.5484C17.5267 23.3577 14.4424 26.4396 10.6302 26.4396C8.52298 26.4396 6.64562 25.5016 5.38127 24.0277C3.48476 21.8263 3.00584 19.4527 3.38897 17.1365C4.71079 9.02023 14.3466 4.40698 21.5113 2.62676C19.1741 3.871 14.9597 5.90006 12.0095 8.10141C20.6109 11.4321 22.0093 4.17727 26.0323 0.94225ZM29.5188 27.1861H26.8943L21.4346 12.8104H24.9978L28.3502 22.3623L31.7984 12.8104H35.2658L29.5188 27.1861Z" fill="#046530"/>
</g>
<path d="M66.8949 21.0719C67.0098 22.1064 67.4313 22.9301 68.1401 23.524C68.8489 24.1179 69.7684 24.4244 70.8987 24.4244C71.6458 24.4244 72.3163 24.2711 72.9102 23.9646C73.5041 23.6389 73.983 23.2175 74.3278 22.6428H77.9293C77.3163 24.2137 76.4159 25.4206 75.1899 26.3018C73.983 27.1639 72.5654 27.6045 70.9753 27.6045C69.9025 27.6045 68.9255 27.4129 68.0251 27.0298C67.1248 26.6466 66.3202 26.0911 65.5922 25.3439C64.9217 24.6351 64.3853 23.8305 64.0213 22.8918C63.6382 21.9723 63.4658 21.0144 63.4658 20.0374C63.4658 19.0412 63.6382 18.0834 63.983 17.2022C64.3278 16.3209 64.8259 15.5163 65.4964 14.8075C66.2244 14.0221 67.0673 13.4282 67.9868 13.0259C68.9064 12.6045 69.8642 12.3937 70.8412 12.3937C72.0864 12.3937 73.255 12.6811 74.3278 13.2366C75.4006 13.8114 76.3201 14.5968 77.0864 15.6313C77.5462 16.2443 77.891 16.934 78.1209 17.7194C78.3508 18.4857 78.4466 19.3669 78.4466 20.3631C78.4466 20.4397 78.4466 20.5547 78.4274 20.7271C78.4274 20.8995 78.4083 21.0144 78.4083 21.0911H66.8949V21.0719ZM74.8259 18.4091C74.5385 17.4512 74.0596 16.7232 73.3891 16.2252C72.7186 15.7271 71.8566 15.478 70.8412 15.478C69.9408 15.478 69.1554 15.7462 68.4466 16.2826C67.7378 16.819 67.2589 17.5278 67.0098 18.4091H74.8259ZM91.7224 27.2022V25.4397C91.3201 26.1485 90.7646 26.6849 90.0366 27.0298C89.3086 27.3746 88.3699 27.547 87.2397 27.547C85.1132 27.547 83.3316 26.819 81.914 25.3631C80.4964 23.9071 79.7684 22.1064 79.7684 19.9608C79.7684 18.9455 79.9408 17.9876 80.3048 17.0872C80.6688 16.1868 81.1669 15.3822 81.8374 14.6926C82.5462 13.9263 83.3508 13.3707 84.2129 13.0068C85.0749 12.6428 86.0711 12.4704 87.1631 12.4704C88.2167 12.4704 89.1171 12.6428 89.8642 12.9876C90.6113 13.3324 91.2244 13.8688 91.6841 14.5776V12.8343H94.9408V27.2213H91.7224V27.2022ZM83.1975 19.8842C83.1975 21.0911 83.5998 22.1064 84.4044 22.9301C85.209 23.7539 86.186 24.1562 87.3546 24.1562C88.4083 24.1562 89.3278 23.7539 90.1324 22.9301C90.937 22.1064 91.3393 21.1485 91.3393 20.0566C91.3393 18.888 90.937 17.8918 90.1324 17.0489C89.3278 16.206 88.3891 15.7845 87.2972 15.7845C86.1669 15.7845 85.209 16.1868 84.4044 16.9723C83.5998 17.7769 83.1975 18.7347 83.1975 19.8842ZM103.868 12.8152V15.8037H101.454V27.2022H98.2167V15.8037H96.5117V12.8152H98.1209V12.432C98.1209 10.9186 98.5424 9.78837 99.3661 9.04124C100.209 8.29411 101.473 7.93013 103.178 7.93013C103.236 7.93013 103.351 7.93013 103.504 7.94929C103.657 7.94929 103.772 7.96844 103.868 7.96844V10.9761H103.638C102.872 10.9761 102.335 11.1102 102.01 11.3401C101.684 11.5891 101.512 11.9914 101.512 12.5853V12.8535H103.868V12.8152ZM58.8872 27.2022H62.1631V8.16002H58.8872V27.2022Z" fill="#046530"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
<defs>
<clipPath id="ic"><circle cx="25" cy="25" r="23"/></clipPath>
</defs>
<g clip-path="url(#ic)">
<rect x="2" y="2" width="23" height="23" fill="#447099"/>
<rect x="25" y="2" width="23" height="23" fill="#72994E"/>
<rect x="2" y="25" width="23" height="23" fill="#EE6331"/>
<rect x="25" y="25" width="23" height="23" fill="#1B3B6F"/>
<polyline points="8,12 25,38 42,12"
stroke="white" stroke-width="6" fill="none"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="56" y="33"
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
font-size="26" font-weight="600" fill="#447099" letter-spacing="-0.5">Verso</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 784 B

@@ -1 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="120" viewBox="0 0 104 120" fill="none"><path d="M103.521 3.769C87.2881 -2.58618 28.56 -4.88324 28.4835 30.1851C11.3321 41.1344 0 58.9749 0 78.117C0 101.241 18.7593 120 41.883 120C65.0067 120 83.7659 101.241 83.7659 78.117C83.7659 60.2766 72.5869 44.9629 56.8138 38.9905C53.7511 37.842 47.1662 35.7746 41.9595 36.234C34.4558 40.9813 25.2676 50.7821 20.9798 60.5828C27.4115 52.8494 37.442 49.4804 46.4005 50.9352C59.4937 53.0791 69.5242 64.4113 69.5242 78.1936C69.5242 93.4307 57.1967 105.758 41.9595 105.758C33.537 105.758 26.0333 102.006 20.9798 96.1106C13.3995 87.3052 11.4853 77.8108 13.0166 68.546C18.2999 36.0809 56.8138 17.6279 85.4504 10.507C76.1091 15.484 59.264 23.6002 47.4725 32.4056C81.8517 45.7285 87.4412 16.7091 103.521 3.769Z" fill="#046530"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104">
<defs>
<clipPath id="c"><circle cx="52" cy="52" r="50"/></clipPath>
</defs>
<g clip-path="url(#c)">
<rect x="2" y="2" width="50" height="50" fill="#447099"/>
<rect x="52" y="2" width="50" height="50" fill="#72994E"/>
<rect x="2" y="52" width="50" height="50" fill="#EE6331"/>
<rect x="52" y="52" width="50" height="50" fill="#1B3B6F"/>
<polyline points="16,25 52,80 88,25"
stroke="white" stroke-width="13" fill="none"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 825 B

After

Width:  |  Height:  |  Size: 631 B

@@ -1 +1,17 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 542 157" enable-background="new 0 0 542 157"><style>.st0{filter:url(#Adobe_OpacityMaskFilter);} .st1{fill:#FFFFFF;} .st2{mask:url(#mask-2);fill:#FFFFFF;}</style><g id="Page-1"><g id="Overleaf"><g id="Group-3"><defs><filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="0" y=".3" width="299.2" height="156.7"><feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/></filter></defs><mask maskUnits="userSpaceOnUse" x="0" y=".3" width="299.2" height="156.7" id="mask-2"><g class="st0"><path id="path-1" class="st1" d="M299.2 156.9H.1V.3h299.1z"/></g></mask><path id="Fill-1" class="st2" d="M197 110.3c.6 5.4 2.7 9.7 6.5 12.8 3.7 3.1 8.5 4.7 14.4 4.7 3.9 0 7.4-.8 10.5-2.4 3.1-1.7 5.6-3.9 7.4-6.9h18.8c-3.2 8.2-7.9 14.5-14.3 19.1-6.3 4.5-13.7 6.8-22 6.8-5.6 0-10.7-1-15.4-3-4.7-2-8.9-4.9-12.7-8.8-3.5-3.7-6.3-7.9-8.2-12.8s-2.9-9.8-2.9-14.9c0-5.2.9-10.2 2.7-14.8 1.8-4.6 4.4-8.8 7.9-12.5 3.8-4.1 8.2-7.2 13-9.3 4.8-2.2 9.8-3.3 14.9-3.3 6.5 0 12.6 1.5 18.2 4.4 5.6 3 10.4 7.1 14.4 12.5 2.4 3.2 4.2 6.8 5.4 10.9 1.2 4 1.7 8.6 1.7 13.8 0 .4 0 1-.1 1.9 0 .9-.1 1.5-.1 1.9H197v-.1zm41.5-13.9c-1.5-5-4-8.8-7.5-11.4-3.5-2.6-8-3.9-13.3-3.9-4.7 0-8.8 1.4-12.5 4.2-3.7 2.8-6.2 6.5-7.5 11.1h40.8zm60.7-14c-6.8.5-11.5 2.3-14.2 5.3-2.7 3-4 8.4-4 16.2v38.3h-17.5v-75h16.4v8.7c2.6-3.4 5.5-5.8 8.7-7.4 3.1-1.6 6.7-2.4 10.6-2.4v16.3zm-164-77.2C114-3.1 37.3-6.1 37.2 39.7 14.8 54 0 77.3 0 102.3 0 132.5 24.5 157 54.7 157c30.2 0 54.7-24.5 54.7-54.7 0-23.3-14.6-43.3-35.2-51.1-4-1.5-12.6-4.2-19.4-3.6-9.8 6.2-21.8 19-27.4 31.8 8.4-10.1 21.5-14.5 33.2-12.6 17.1 2.8 30.2 17.6 30.2 35.6 0 19.9-16.1 36-36 36-11 0-20.8-4.9-27.4-12.6-9.9-11.5-12.4-23.9-10.4-36 6.9-42.4 57.2-66.5 94.6-75.8C99.4 20.5 77.4 31.1 62 42.6c44.9 17.4 52.2-20.5 73.2-37.4zm18.2 137.1h-13.7l-28.5-75.1h18.6l17.5 49.9 18-49.9h18.1l-30 75.1z"/></g><path id="Fill-4" class="st1" d="M348.6 110.3c.6 5.4 2.8 9.7 6.5 12.8 3.7 3.1 8.5 4.7 14.4 4.7 3.9 0 7.4-.8 10.5-2.4 3.1-1.7 5.6-3.9 7.4-6.9h18.8c-3.2 8.2-7.9 14.5-14.3 19.1-6.3 4.5-13.7 6.8-22 6.8-5.6 0-10.7-1-15.4-3-4.7-2-8.9-4.9-12.7-8.8-3.5-3.7-6.3-7.9-8.2-12.8-2-4.8-2.9-9.8-2.9-14.9 0-5.2.9-10.2 2.7-14.8 1.8-4.6 4.4-8.8 7.9-12.5 3.8-4.1 8.2-7.2 13-9.3 4.8-2.2 9.8-3.3 14.9-3.3 6.5 0 12.6 1.5 18.2 4.4 5.6 3 10.4 7.1 14.4 12.5 2.4 3.2 4.2 6.8 5.4 10.9 1.2 4 1.7 8.6 1.7 13.8 0 .4 0 1-.1 1.9 0 .9-.1 1.5-.1 1.9h-60.1v-.1zM390 96.4c-1.5-5-4-8.8-7.5-11.4-3.5-2.6-8-3.9-13.3-3.9-4.7 0-8.8 1.4-12.5 4.2-3.7 2.8-6.2 6.5-7.5 11.1H390zm88.2 45.9v-9.2c-2.1 3.7-5 6.5-8.8 8.3-3.8 1.8-8.7 2.7-14.6 2.7-11.1 0-20.4-3.8-27.8-11.4-7.4-7.6-11.2-17-11.2-28.2 0-5.3.9-10.3 2.8-15 1.9-4.7 4.5-8.9 8-12.5 3.7-4 7.9-6.9 12.4-8.8s9.7-2.8 15.4-2.8c5.5 0 10.2.9 14.1 2.7 3.9 1.8 7.1 4.6 9.5 8.3v-9.1h17v75.1h-16.8v-.1zm-44.5-38.2c0 6.3 2.1 11.6 6.3 15.9 4.2 4.3 9.3 6.4 15.4 6.4 5.5 0 10.3-2.1 14.5-6.4 4.2-4.3 6.3-9.3 6.3-15 0-6.1-2.1-11.3-6.3-15.7-4.2-4.4-9.1-6.6-14.8-6.6-5.9 0-10.9 2.1-15.1 6.2-4.2 4.2-6.3 9.2-6.3 15.2zm107.9-36.9v15.6H529v59.5h-16.9V82.8h-8.9V67.2h8.4v-2c0-7.9 2.2-13.8 6.5-17.7 4.4-3.9 11-5.8 19.9-5.8.3 0 .9 0 1.7.1.8 0 1.4.1 1.9.1v15.7h-1.2c-4 0-6.8.7-8.5 1.9-1.7 1.3-2.6 3.4-2.6 6.5v1.4h12.3v-.2zm-234.8 75.1h17.1V42.9h-17.1v99.4z"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
<defs>
<clipPath id="icw"><circle cx="25" cy="25" r="23"/></clipPath>
</defs>
<g clip-path="url(#icw)">
<rect x="2" y="2" width="23" height="23" fill="#75AADB"/>
<rect x="25" y="2" width="23" height="23" fill="#A5C27E"/>
<rect x="2" y="25" width="23" height="23" fill="#F5A383"/>
<rect x="25" y="25" width="23" height="23" fill="#447099"/>
<polyline points="8,12 25,38 42,12"
stroke="white" stroke-width="6" fill="none"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="56" y="33"
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
font-size="26" font-weight="600" fill="white" letter-spacing="-0.5">Verso</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 784 B

@@ -1 +1,17 @@
<svg width="542" height="157" viewBox="0 0 542 157" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M299.24 156.94H.06V.28h299.18z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path d="M197.05 110.3c.58 5.4 2.74 9.7 6.45 12.78 3.7 3.13 8.5 4.7 14.37 4.7 3.9 0 7.4-.83 10.53-2.46 3.12-1.66 5.6-3.94 7.4-6.9h18.85c-3.2 8.18-7.94 14.54-14.3 19.08-6.34 4.52-13.67 6.78-22 6.78-5.6 0-10.75-.98-15.46-2.96-4.7-1.98-9-4.9-12.7-8.78-3.6-3.68-6.3-7.94-8.3-12.8-2-4.83-3-9.8-3-14.9 0-5.24.9-10.15 2.7-14.77 1.8-4.63 4.4-8.78 7.9-12.46 3.8-4 8.1-7.1 13-9.3 4.8-2.2 9.7-3.3 14.9-3.3 6.5 0 12.6 1.5 18.2 4.4 5.6 3 10.4 7.2 14.4 12.5 2.4 3.3 4.2 6.9 5.3 10.9s1.74 8.6 1.74 13.8c0 .4 0 1-.08 1.9-.07.9-.1 1.5-.1 1.9h-60zm41.4-13.87c-1.48-5-4-8.8-7.53-11.43-3.52-2.6-7.97-3.92-13.33-3.92-4.7 0-8.8 1.42-12.5 4.24-3.7 2.82-6.2 6.52-7.5 11.1h40.8zm60.8-14c-6.8.54-11.52 2.33-14.2 5.34-2.68 3-4.03 8.4-4.03 16.23v38.3h-17.47V67.22h16.37v8.67c2.64-3.4 5.52-5.9 8.67-7.5 3.1-1.6 6.6-2.4 10.6-2.4v16.3zM135.2 5.18c-21.16-8.25-97.87-11.3-98 34.47C14.82 53.98 0 77.35 0 102.33 0 132.53 24.48 157 54.68 157s54.68-24.48 54.68-54.67c0-23.34-14.63-43.28-35.2-51.1C70.2 49.7 61.6 47 54.73 47.58c-9.8 6.23-21.75 19.04-27.4 31.8 8.4-10.08 21.53-14.48 33.16-12.6 17.1 2.77 30.2 17.63 30.2 35.54 0 19.9-16.2 36.02-36.1 36.02-11 0-20.8-4.9-27.4-12.62-9.7-11.42-12.2-23.8-10.2-35.9C23.9 47.4 74.2 23.27 111.6 14 99.4 20.46 77.4 31.07 62 42.63c44.9 17.34 52.17-20.52 73.2-37.46zm18.2 137.12h-13.73l-28.52-75.08h18.62l17.47 49.9 18.03-49.9h18.14l-30 75.08z" fill="#4C4D41" mask="url(#b)"/><path d="M348.63 110.3c.58 5.4 2.75 9.7 6.45 12.78 3.7 3.13 8.5 4.7 14.38 4.7 3.9 0 7.4-.83 10.53-2.46 3.1-1.66 5.5-3.94 7.4-6.9h18.8c-3.2 8.18-8 14.54-14.3 19.08-6.4 4.52-13.7 6.78-22 6.78-5.6 0-10.8-.98-15.5-2.96-4.7-1.98-8.9-4.9-12.7-8.78-3.6-3.68-6.3-7.94-8.3-12.8-2-4.83-3-9.8-3-14.9 0-5.24.9-10.15 2.7-14.77 1.8-4.63 4.42-8.78 7.92-12.46 3.82-4 8.15-7.1 13-9.3 4.9-2.2 9.9-3.3 15-3.3 6.5 0 12.57 1.5 18.2 4.4 5.64 3 10.44 7.2 14.4 12.5 2.42 3.3 4.2 6.9 5.36 10.9s1.77 8.6 1.77 13.8c0 .4-.04 1-.08 1.9-.08.9-.1 1.5-.1 1.9h-60.1zm41.42-13.87c-1.5-5-4-8.8-7.55-11.43-3.52-2.6-7.96-3.92-13.32-3.92-4.66 0-8.8 1.42-12.5 4.24-3.7 2.82-6.16 6.52-7.44 11.1h40.8zm88.13 45.87v-9.22c-2.1 3.72-5.04 6.5-8.83 8.27-3.8 1.77-8.67 2.65-14.58 2.65-11.1 0-20.37-3.8-27.8-11.37-7.43-7.57-11.15-16.98-11.15-28.2 0-5.3.93-10.3 2.8-15.03 1.86-4.73 4.5-8.9 7.98-12.5 3.73-3.95 7.88-6.86 12.42-8.75 4.54-1.88 9.67-2.84 15.35-2.84 5.45 0 10.15 1 14.1 2.8 3.93 1.8 7.1 4.6 9.5 8.3v-9.1h17.07v75.1h-16.86zm-44.47-38.16c0 6.32 2.1 11.6 6.4 15.87 4.2 4.3 9.3 6.4 15.4 6.4 5.5 0 10.4-2.1 14.6-6.4 4.2-4.3 6.3-9.3 6.3-15 0-6.1-2.1-11.3-6.3-15.7-4.2-4.4-9.1-6.6-14.7-6.6-5.9 0-10.97 2.1-15.17 6.3-4.16 4.2-6.25 9.3-6.25 15.3zm108-36.92v15.56h-12.6v59.52h-16.9V82.78h-8.9V67.22h8.4v-2.05c0-7.9 2.2-13.8 6.6-17.7 4.4-3.87 11-5.83 19.9-5.83.4 0 1 .03 1.8.07.8.1 1.4.1 1.9.1v15.7h-1.2c-3.94 0-6.8.7-8.5 2-1.7 1.3-2.52 3.5-2.52 6.5v1.4h12.28zM306.9 142.3h17.06V42.92H306.9v99.38z" fill="#138A07"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
<defs>
<clipPath id="ic"><circle cx="25" cy="25" r="23"/></clipPath>
</defs>
<g clip-path="url(#ic)">
<rect x="2" y="2" width="23" height="23" fill="#447099"/>
<rect x="25" y="2" width="23" height="23" fill="#72994E"/>
<rect x="2" y="25" width="23" height="23" fill="#EE6331"/>
<rect x="25" y="25" width="23" height="23" fill="#1B3B6F"/>
<polyline points="8,12 25,38 42,12"
stroke="white" stroke-width="6" fill="none"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="56" y="33"
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
font-size="26" font-weight="600" fill="#447099" letter-spacing="-0.5">Verso</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 784 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

@@ -68,11 +68,11 @@
@mixin editor-switcher-button {
@include ol-button-variant(
$color: var(--green-60),
$color: var(--bg-accent-02),
$background: var(--bg-accent-03),
$border: var(--green-50),
$border: var(--bg-accent-01),
$hover-background: var(--bg-accent-03),
$hover-border: var(--green-40),
$hover-border: var(--bg-accent-01),
$borderless: false
);
}
@@ -1,6 +1,13 @@
@use 'sass:color';
/* Website redesign */
// Verso brand palette based on Quarto's visual identity
$verso-blue: #447099; // Quarto primary blue main brand colour
$verso-blue-dark: #1B3B6F; // Quarto dark navy
$verso-blue-light: #75AADB; // Quarto light blue
$verso-green: #72994E; // Quarto green
$verso-orange: #EE6331; // Quarto orange accent
/* Website redesign (original Overleaf palette kept for layout compatibility) */
$vivid-tangerine: #f1a695;
$ceil: #9597c9;
$caramel: #f9d38f;
@@ -12,6 +19,13 @@ $green-bright: #13c965;
$green-bright-tint-50: color.mix($green-bright, #fff, 50%);
:root {
// Verso brand
--verso-blue: #{$verso-blue};
--verso-blue-dark: #{$verso-blue-dark};
--verso-blue-light: #{$verso-blue-light};
--verso-green: #{$verso-green};
--verso-orange: #{$verso-orange};
// Legacy layout vars
--vivid-tangerine: #{$vivid-tangerine};
--ceil: #{$ceil};
--caramel: #{$caramel};
@@ -13,8 +13,9 @@
url('../../../public/img/ol-brand/overleaf-white.svg')
);
// Title, when used instead of a logo
--navbar-title-font-size: var(--font-size-05);
// Title, when used instead of a logo (the Verso instance name + version).
// Sits between --font-size-07 (30px) and --font-size-08 (36px): "7.5" 33px.
--navbar-title-font-size: 2.0625rem;
--navbar-title-color: var(--neutral-20);
--navbar-title-color-hover: var(--neutral-40);
@@ -34,20 +35,20 @@
calc(var(--navbar-btn-padding-h) + 1px);
--navbar-subdued-color: var(--white);
--navbar-subdued-hover-bg: var(--white);
--navbar-subdued-hover-color: var(--green-50);
--navbar-subdued-hover-color: var(--bg-accent-01);
// Properties of "primary" items
--navbar-primary-color: var(--white);
--navbar-primary-border-color: var(--green-50);
--navbar-primary-bg: var(--green-50);
--navbar-primary-hover-bg: var(--green-60);
--navbar-primary-border-color: var(--bg-accent-01);
--navbar-primary-bg: var(--bg-accent-01);
--navbar-primary-hover-bg: var(--bg-accent-02);
--navbar-primary-hover-border-color: var(--navbar-primary-hover-bg);
// Links
--navbar-link-color: var(--white);
--navbar-link-border-color: var(--navbar-link-color);
--navbar-link-hover-color: var(--white);
--navbar-link-hover-bg: var(--green-50);
--navbar-link-hover-bg: var(--bg-accent-01);
--navbar-link-hover-border-color: var(--navbar-link-hover-bg);
// Toggler

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