151 Commits

Author SHA1 Message Date
claude 8b7da8296c fix: pass session token so anonymous users can install python packages
Build and Deploy Verso / deploy (push) Successful in 14m23s
Build and Deploy Verso (prod) / deploy (push) Successful in 3m55s
userCanInstallPython passed null as the token, so anonymous users
accessing via a share link got privilege level NONE from the WithoutUser
path and allowPythonInstall was always false for them.

Read the token from req.session.anonTokenAccess via
TokenAccessHandler.getRequestToken and forward it through
userCanInstallPython to getPrivilegeLevelForProject.  For TOKEN_BASED
projects this resolves the anonymous user's access level via
getPrivilegeLevelForProjectWithToken, enabling package installation.

Also update Quarto Slides badge color to #e4637c.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:17:30 +00:00
claude e6773c6baf fix: read stored project compiler for quartoFlavor update
Build and Deploy Verso / deploy (push) Successful in 20m28s
options.compiler is set from req.body.compiler which the frontend never
sends, so the condition was never true and quartoFlavor was never written.
Use ProjectGetter to read the stored compiler instead. Fire-and-forget so
it does not delay the compile response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:48:25 +00:00
claude 2c8dad08f6 project-list: show plain 'Quarto' badge when quartoFlavor is unset
Build and Deploy Verso / deploy (push) Successful in 13m55s
Existing projects have no quartoFlavor value in the database (new field),
so defaulting to 'Quarto PDF' incorrectly labelled all of them. Show the
plain 'Quarto' label until the first compile sets the flavor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:30:34 +00:00
claude 7fecaf491a compile: allow python package install for all users who can access the project
Build and Deploy Verso / deploy (push) Successful in 9m23s
Previously userCanInstallPython used ignorePublicAccess: true, which
blocked token-link users (not-yet-joined) and logged-in readers of public
projects from installing packages. This caused Quarto presentations with
Python cells to fail for shared read-only users even when the required
packages were already listed in requirements.vrf.

The security model is: what gets installed is fully controlled by
requirements.vrf, which is only writable by members with write access.
There is therefore no security reason to block other readers from
triggering installation of already-approved packages.

Drop ignorePublicAccess so all users with any privilege level (direct,
token-based, or public-project) can trigger the venv install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:20:46 +00:00
claude 9c7a10aa39 fix: correct import path and minor issues in quartoFlavor update
Build and Deploy Verso / deploy (push) Successful in 13m44s
- Fix wrong import path '../models/Project.mjs' → '../../models/Project.mjs'
  (from Features/Compile/, '..' is Features/, not src/; the server would
  crash on startup with ERR_MODULE_NOT_FOUND in Node.js ESM)
- Log MongoDB errors instead of silently swallowing them
- Remove null from Mongoose String enum (not a valid enum value for strings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:06:00 +00:00
claude bbf532d282 project-list: distinguish Quarto PDF vs Quarto Slides in format badge
Build and Deploy Verso / deploy (push) Successful in 9m30s
Add a quartoFlavor field ('revealjs' | 'pdf') to the Project model.
After each successful Quarto compile, CompileController detects the output
type (output.html → revealjs, otherwise pdf) and persists it.
ProjectListController includes it in the projection and serialization so
it reaches the frontend without an extra round-trip.

Badge variants:
  - quartoFlavor unset (new/uncompiled) → "Quarto PDF" #447099
  - quartoFlavor 'pdf'                  → "Quarto PDF" #447099 (Quarto blue)
  - quartoFlavor 'revealjs'             → "Quarto Slides" #7e56c2 (purple)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 07:55:45 +00:00
claude eada1e9979 file-tree: show python packages button for all quarto projects
Build and Deploy Verso / deploy (push) Successful in 13m42s
The previous approach (pdfFile?.path === 'output.html') caused a
chicken-and-egg problem: the button only appeared after a successful
RevealJS compile, but you need to add packages before the first compile.

Use compiler === 'quarto' from ProjectSettingsContext instead — this is
set from project metadata and available immediately, before any compile.
Quarto supports Jupyter Python cells in all output formats (RevealJS HTML,
PDF via LaTeX, PDF via Typst), so showing the button for any Quarto project
is the correct behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 07:41:21 +00:00
claude 2f88ad124d ui: correct LaTeX badge to Overleaf button green #098842
Build and Deploy Verso / deploy (push) Successful in 13m47s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 07:05:08 +00:00
claude a398127522 ui: brand badge colors and hide python packages for non-revealjs
Build and Deploy Verso / deploy (push) Successful in 13m8s
- LaTeX badge: #13c965 (Overleaf brand green, from upstream overleaf/overleaf)
- Typst badge: #239dad (Typst brand blue/teal, from typst.app)
- Python packages toolbar button: only shown when the compiled output is
  output.html, i.e. a Quarto RevealJS presentation.  Uses the same
  pdfFile?.path === 'output.html' check as PresentationPreviewButton.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 06:51:30 +00:00
claude 083b195462 typst: support '+' binary operator in arg values
Build and Deploy Verso / deploy (push) Successful in 9m27s
stroke: 0.8pt + brand broke arg-list parsing because '+' was not a grammar
terminal. The parser exited CodeArgs via error recovery, so subsequent
named args (radius:, inset:, fill:) were never seen as CodeArgKey.

Add codeArgValue { codeValue | codeArgValue !add "+" codeValue } — a
left-recursive inline rule used only inside CodeArgs.  The !add cut point
gives the shift strict dominance over the reduce (prec add > 0 vs 0), so
a '+' after a value greedily extends the expression.  Because codeArgValue
only appears inside CodeArgs, the codeStatement* LALR-merging that caused
trouble for the earlier callSuffix* approach does not apply here.

Also add PLUS to codeIdentTokenizer's valid-predecessor list so identifiers
after '+' (the right-hand operand) are correctly tokenized as CodeIdent.
Add "+" to @tokens @precedence so it beats MarkupContent in merged states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 06:07:32 +00:00
claude 0656ddfe52 fix(typst): highlight keywords/idents inside #{} code blocks
Build and Deploy Verso / deploy (push) Successful in 9m32s
Replace the opaque CodeBlockBody external tokenizer with grammar-parsed
codeStatement* so that keywords (show, let, set, …) and identifiers
inside #{ } code blocks receive proper Lezer nodes and are highlighted.

Key grammar changes:
- CodeBlock { "{" codeStatement* "}" } — structured, not opaque
- codeStatement uses two explicit alternatives for keyword lines:
    CodeKeyword !kw callOrValueAndBody  (grabs the subject eagerly)
    CodeKeyword keywordBody?            (bare keyword or body-only form)
  The !kw cut-point gives shift prec kw > 0 over the unannotated reduce,
  resolving the LALR merge ambiguity without @left/@right on kw.
- callOrValue { FuncExpr | CodeIdent | CodeString } — replaces CallExpr
  { CodeIdent !call callSuffix* }.  The * quantifier annotated both
  shift and reduce with !call, making them a same-prec tie that @right
  could not reliably resolve in merged states.  Using FuncExpr (required
  callSuffixes) + bare CodeIdent makes the tie strict (call > 0 for
  FuncExpr shift vs 0 for bare-ident reduce), then @right handles only
  the extension-of-callSuffixes case (shift = call<<2, FuncExpr reduce
  = call<<2 - 1 via @right encoding).
- KeywordExpr gets the same two-alternative structure as codeStatement
  so nested show/set/let inside a code block (e.g. show sel: set text)
  also parse without LALR state-merge conflicts.
- CallExpr removed; its role is split between FuncExpr (has args/chain)
  and bare CodeIdent (no args).  Styling updated: CodeExpr/CodeIdent
  replaces CallExpr/CodeIdent for bare #ident function-style highlights.
- codeKeywordTokenizer and codeIdentTokenizer already accept keywords /
  identifiers after { and ; (added in previous commit) — consistent with
  the new grammar.

Parse results:
  #{ show strong: link.with(url); body }
  → CodeKeyword "show", CodeIdent "strong", FuncExpr "link.with(url)",
    CodeIdent "body" — all properly highlighted, no ⚠ errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:47:21 +00:00
claude 056d9a7f47 fix(typst): add CodeArray so tuple args don't break nested call parsing
Build and Deploy Verso / deploy (push) Successful in 9m44s
align: (left, center, left) is a Typst array literal.  Without a grammar
rule for it, the parser treated the ')' closing the tuple as the ')' closing
the enclosing function call, so everything after align: — all ContentBlock
args and any subsequent named keys like 'caption:' — fell outside the parsed
call tree and was highlighted as MarkupContent.

Add CodeArray { "(" codeArgList? ")" } as a codeValue alternative so
parenthesised arrays and dictionaries parse correctly.  Also regenerate
typst.mjs / typst.terms.mjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:13:38 +00:00
claude 8ed44cc352 fix(typst): style CodeArgKey via HighlightStyle, not theme CSS
Build and Deploy Verso / deploy (push) Successful in 13m38s
The tok-attributeName CSS class relies on each theme defining it, but
26 of 41 themes never had it. Defining the colour directly in
typstHighlightStyle (like we do for heading/strong/emphasis) applies
it universally regardless of which theme is active.

Amber #c47900 is legible on both light and dark backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:56:20 +00:00
claude 52ebff6286 debug(typst): force arg keys bright red to confirm token pipeline
Build and Deploy Verso / deploy (push) Successful in 10m0s
Adds { tag: t.attributeName, color: '#cc0000', fontWeight: 'bold' } to
typstHighlightStyle so named arg keys are unmistakably red if the
CodeArgKey token is reaching the highlighter.  Will be removed once
the pipeline is confirmed working and replaced with per-theme colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:41:57 +00:00
claude 7c0ec9dd39 fix(typst): commit compiled grammar so CI always uses current parser
Build and Deploy Verso / deploy (push) Successful in 13m45s
The webpack plugin that compiles typst.grammar may silently skip
recompilation when file mtimes are ambiguous in Docker BuildKit layers.
Committing typst.mjs and typst.terms.mjs guarantees the build always
ships the correct parser without depending on build-time generation.

To regenerate after grammar changes:
  node -e "const {buildParserFile}=require('/tmp/lezertest/...'); ..."
  (or: yarn run lezer-latex:generate from services/web)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:02:16 +00:00
claude e9a34a5bd8 fix(typst): exclude ] from MarkupContent so ContentBlock always closes
Build and Deploy Verso / deploy (push) Successful in 13m35s
The core bug: MarkupContent { ![...]+ } did not exclude ']', so inside
#figure(table([A],[B]), caption:[...]) the tokenizer consumed ']' as
MarkupContent, ContentBlocks never closed, and all remaining args like
'caption:' were swallowed as MarkupContent instead of CodeArgKey.

Fix mirrors the LaTeX grammar pattern (its Normal token excludes \] and
\[): add ']' to MarkupContent's exclusion set and provide ClosingSquare
{ "]" } as an item alternative for bare ']' in body text.  The grammar's
existing @precedence { "]" ClosingSquare } ensures "]" wins and closes
the ContentBlock; outside a ContentBlock only ClosingSquare is valid.

Also change URL style tag from t.url (tok-url, unstyled in all themes)
to t.string (tok-string, styled in every theme).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:33:20 +00:00
claude f9fc0d9905 typst: fix URL comment split and heading label highlighting
Build and Deploy Verso / deploy (push) Successful in 13m56s
- Add URL token (https://... / http://...) so '://' is never split into
  ':' + LineComment '//', preventing URLs from being styled as comments
- Stop headingTitleTokenizer before '<label>' patterns so labels at the
  end of headings get Label node styling instead of being consumed as
  heading title text
- Style URL nodes with t.url tag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:38:32 +00:00
claude d7ca7b194d feat(typst): parse show-rule bodies, let-value bindings, and content-block call args
Build and Deploy Verso / deploy (push) Successful in 14m13s
Three grammar gaps caused large blocks of code to be unhighlighted:

1. KeywordExpr now accepts an exclusive keywordBody: '#show sel: body' is
   parsed via ':', and '#let name = value' via '='.  callOrValue extends
   the subject to include CodeString so '#import "pkg"' highlights the path.

2. ContentBlock added to callSuffix so '#func("arg")[content]' and
   '#next-step("url")[...]' parse their trailing content block as code
   rather than falling back to markup.

3. Tokenizer: COLON added as a valid predecessor so identifiers (e.g. 'blue'
   in 'fill: blue') and keywords (e.g. 'set' in '#show link: set text(...)')
   are recognised after ':'.  EQUALS already added in the previous commit.
   The ident-chain backward scan now also skips whitespace before testing for
   '#' or ':', enabling 'text' in 'set text' to trace back to '#' through the
   keyword gap.  @precedence updated with CodeString, '[', ':' to resolve
   overlapping-token conflicts with MarkupContent in merged states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:57:21 +00:00
claude 47cf84f20b fix(typst): highlight named arg keys after complex nested calls
Build and Deploy Verso / deploy (push) Successful in 13m48s
canShift(CodeIdent) returns false in LALR-merged states that arise after
reducing a complex first argument (e.g. figure(table(...), caption: ...)).
The previous guard `!couldBeIdent && !canShift(CodeArgKey)` then caused
an early exit before the character-level scan ran, silently dropping the
CodeArgKey token for any named arg key that follows such a reduction.

Fix: run the backward character scan first and derive `couldBeArgKey`
from the raw predecessor char ('(' or ',') rather than from canShift.
The early-exit now reads `!couldBeIdent && !couldBeArgKey`, so arg-key
positions always proceed to the full scan regardless of parser state.
Also stop calling canShift(CodeArgKey) entirely — it is unreliable here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:57:10 +00:00
claude 2d3e64da92 themes: add tok-attributeName color to 15 themes that were missing it
Build and Deploy Verso / deploy (push) Successful in 14m6s
The Typst grammar now emits CodeArgKey (mapped to tok-attributeName) for
named argument keys like 'columns:', 'align:', 'caption:'.  15 of 41
editor themes had no .tok-attributeName rule, so those keys appeared in
the default text color (black) despite the correct CSS class being set.

Chose colors that complement each theme's existing palette:
light themes → warm dark-orange family (#994409 / #7B3814 / #735C0F)
dark themes  → each theme's accent color (gold, warm red, lavender…)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:28:09 +00:00
claude 2db6e63162 typst: fix CodeArgKey detection using character-level context
Build and Deploy Verso / deploy (push) Successful in 9m31s
canShift(CodeArgKey) was consistently returning false because LALR
state merging folds the codeArgItem start state into others where
CodeArgKey is not in the valid set.  As a result, named arg keys like
'columns:', 'align:', 'caption:' were always falling through to
CodeIdent (black) instead of CodeArgKey (tok-attributeName).

Fix: detect named arg key position by inspecting the nearest
non-whitespace predecessor character instead of trusting canShift.
prev == '(' or ',' means we are inside a call argument list — the only
positions where a named arg key can appear.  prev == last char of a
keyword word (e.g. 'w' of 'show') correctly excludes '#show heading:'
from being treated as a named arg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:38:05 +00:00
claude f2b7034b51 typst: distinguish function calls from value identifiers; fix math outline
Build and Deploy Verso / deploy (push) Successful in 9m49s
Named value identifiers like 'left', 'center', 'right' were being
highlighted as tok-function (blue) because codeValue used CallExpr
(callSuffix*), which styled any identifier in value position as a
function.  Fix: add FuncExpr { CodeIdent callSuffix+ } (requires at
least one argument list or method suffix) and use it in codeValue
instead of CallExpr.  Plain identifiers in value position now fall
through to CodeIdent → tok-variableName.  CallExpr (callSuffix*) is
kept for codeExprBody and KeywordExpr where zero-suffix idents are
valid.

Tokenizer safety: only acceptToken(CodeIdent) when canShift(CodeIdent)
is true, preventing emission in LALR-merged states where neither
CodeArgKey nor CodeIdent is expected.

Outline: track '$'-parity across lines so that lines inside a display
math block (e.g. '= b+c$') are not incorrectly reported as headings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:04:33 +00:00
claude 4c6032bce0 typst: fix named-arg key highlighting and multi-line math
Build and Deploy Verso / deploy (push) Successful in 13m41s
Named arg keys (columns:, align:, caption:) were appearing in black
because LALR state merging broke the CodeArgs/CodeIdent path for
multi-line expressions.  Fix: emit a dedicated CodeArgKey token from
codeIdentTokenizer (forward-peek for ':' to pre-disambiguate), declare
it in the grammar's codeArgItem rule, and map it to t.attributeName in
styleTags — bypassing LALR lookahead entirely.

Multi-line display math ($ ...\n... $) was consuming the rest of the
document as orange text when contextual:true caused a backward scan to
find a previous closing '$' and falsely set isDisplay=true.  Fix:
revert mathContentTokenizer to contextual:false with '\n' stop (each
MathContent token covers one line), and change InlineMath to
MathContent* so @skip consumes the newlines between lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:25:31 +00:00
claude 2fdb155547 fix(typst): support multi-line display math ($...$)
Build and Deploy Verso / deploy (push) Successful in 9m44s
mathContentTokenizer now detects inline vs display math by scanning back
to the opening '$': if @skip consumed whitespace between '$' and the
content the tokenizer removes the newline stop (display math), otherwise
it keeps it (inline math).  contextual: true prevents the tokenizer from
firing outside InlineMath entirely, avoiding the orange-body-text
regression seen when this was previously attempted without the guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:52:29 +00:00
claude 75bc3bcc73 fix(typst): highlight idents after #keyword and wire tok-attributeName
Build and Deploy Verso / deploy (push) Successful in 9m34s
- codeIdentTokenizer: extend guard to scan back through the keyword word
  and accept when '#' immediately precedes it, so 'text' in '#set text(...)'
  and 'heading' in '#show heading:' are highlighted as function names
- classHighlighter: add tags.attributeName → tok-attributeName mapping;
  all 26 themes already define .tok-attributeName colours but the tag was
  never mapped to the class, leaving named arg keys (columns:, caption:)
  completely unstyled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:18:31 +00:00
claude 1b773fdda0 fix(typst): add newlines to @skip so multi-line code args parse cleanly
Build and Deploy Verso / deploy (push) Successful in 9m31s
'\n' inside CodeArgs was an invalid token, triggering Lezer error recovery
and resetting parser state before codeIdentTokenizer could fire.  Heading
detection is unaffected — headingTokenizer uses raw input.peek(-1) char
reads which see the '\n' byte regardless of what @skip consumes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:47:47 +00:00
claude 019b4041a8 fix(typst): highlight function names across newlines in code args
Build and Deploy Verso / deploy (push) Successful in 9m35s
codeIdentTokenizer's backward scan stopped at \n, so identifiers at the
start of a new indented line inside multi-line arg lists (e.g. image(),
table() inside #figure(...)) never matched the '(' guard and stayed black.
Extend the whitespace skip to also cross newlines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 07:28:43 +00:00
claude 4aca4aaac6 fix: make CodeIdent external and replace strongItem*/emphItem* with flat body tokens
Build and Deploy Verso / deploy (push) Successful in 13m36s
Two LALR state-merging bugs prevented Strong/Emphasis nodes from ever being
produced (confirmed: tok-strong/tok-emphasis count = 0 in browser diagnostic).

Bug 1 — _italic_ consumed as CodeIdent:
  CodeIdent was a @tokens rule with identHead = [A-Za-z_], so '_italic_' (the
  entire string including both underscores) matched as one CodeIdent token.
  LALR merging caused CodeIdent to be in item*'s valid set, and CodeIdent >
  "_" in @precedence, so the parser never opened Emphasis.

  Fix: move CodeIdent to an external tokenizer (codeIdentTokenizer) with a
  character-level guard — only fires when the preceding non-whitespace char
  is one of '#', '.', '(', ',' (genuine code-context positions).  In body
  text where peek-back finds a newline, space, or markup delimiter, the
  tokenizer returns without emitting, letting '"_"' open Emphasis correctly.

Bug 2 — StrongText never produced inside Strong:
  The strongItem* / emphItem* loops merged with item* states via Lezer's
  aggressive LALR merging.  In the merged state MarkupContent was in the
  valid set (from the item* side) and MarkupContent > StrongText in
  @precedence, so MarkupContent was always produced — not a valid strongItem,
  leading to error recovery with no StrongText in the tree.

  Fix: replace the recursive strongItem* / emphItem* loops with flat external
  tokens StrongBody / EmphBody (contextual: true).  These fire only inside
  Strong → "*" . StrongBody? "*" and Emphasis → "_" . EmphBody? "_", states
  specific enough that canShift is reliable.  They read everything up to the
  closing delimiter or newline in one token, bypassing the LALR merging
  entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:15:22 +00:00
claude f9d46aabeb fix: revert mathContentTokenizer regression (contextual + no-newline-stop)
Build and Deploy Verso / deploy (push) Successful in 9m43s
The previous change switched mathContentTokenizer to contextual:true with no
newline stop, intending to support multi-line Typst block math.  However,
LALR state merging causes canShift(MathContent) to spuriously return true in
body-text positions (e.g. after a RawInline backtick close), so the tokenizer
consumed everything until the next '$' — turning a full paragraph orange.

Revert to contextual:false with newline stop.  This correctly handles both
inline ($x^2$) and single-line block ($ integral ... $) math.  Multi-line
block math ($ formula\n continuation $) remains a separate issue for later.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:50:15 +00:00
claude 54ab282efc Fix Typst: support multi-line math blocks ($ ... multi-line ... $)
Build and Deploy Verso / deploy (push) Successful in 14m1s
mathContentTokenizer was stopping at newlines, causing a parse error for
Typst block math that spans multiple lines.  The parser then entered a bad
state that cascaded: the stale error-recovery left the item* parser in a
degraded mode, causing body text below the math block to be highlighted as
t.string (orange).

Fix: switch to contextual: true (only fires inside InlineMath where
MathContent is actually expected) and remove the newline restriction so
the tokenizer reads until the closing '$' regardless of line boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:03:17 +00:00
claude f976c5ba92 Fix Typst: bold/italic rendering and keyword false-highlights in body text
Build and Deploy Verso / deploy (push) Successful in 14m15s
Add .tok-strong and .tok-emphasis CSS to the static editor theme so
bold/italic markup actually renders visually.

Move CodeKeyword from @tokens to an external tokenizer (codeKeywordTokenizer)
with a peek(-1)==='#' guard. LALR state-merging causes code-mode states to be
reachable in markup positions, making common English words like "in", "for",
"while", "return" trigger CodeKeyword highlighting in body text. The '#' guard
ensures keywords only fire immediately after the '#' sigil, never in prose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:20:02 +00:00
claude f5a94c0ced fix(typst): guard RawBlockBody against LALR-merged body-text states
Build and Deploy Verso / deploy (push) Successful in 10m0s
canShift(RawBlockBody) returns true in states LALR-merged with the
post-RawBlockOpen state, causing the tokenizer to consume all remaining
body text as one giant RawBlockBody. Add a backward character scan:
require newline immediately before input.pos, then walk back past any
lang tag (A-Za-z0-9) and verify three backticks precede it. Body-text
positions never have backtick-backtick-backtick there, so the guard
rejects them.

This was the root cause of everything after the first heading being
black: RawBlockBody swallowed the entire document from the user-name
line onward, making headings, bold, italic and math invisible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:44:05 +00:00
claude 11d852fe18 Revert "chore: add browser diagnostic script for Typst highlighting"
Build and Deploy Verso / deploy (push) Successful in 1m15s
This reverts commit 8c9088d054.
2026-06-08 19:29:46 +00:00
claude cc0d97903c Revert "chore: fix CodeMirror view accessor in diagnostic script"
This reverts commit 5850ffcad7.
2026-06-08 19:29:46 +00:00
claude 5850ffcad7 chore: fix CodeMirror view accessor in diagnostic script
Build and Deploy Verso / deploy (push) Successful in 1m4s
2026-06-08 19:27:06 +00:00
claude 8c9088d054 chore: add browser diagnostic script for Typst highlighting
Build and Deploy Verso / deploy (push) Successful in 1m12s
2026-06-08 19:21:30 +00:00
claude 974a9c4fb3 fix(typst): restore HeadingMark+HeadingTitle with character-level bleed guard
Build and Deploy Verso / deploy (push) Successful in 10m0s
The single-HeadingLine token approach caused everything after the first
heading to be unparsed. Reverting to the two-token structure but adding a
backward character scan in headingTitleTokenizer: after canShift(), walk
backward past whitespace and require '=' immediately before the current
position. Body-text positions in LALR-merged states will have a letter or
closing bracket there instead, so the tokenizer returns without accepting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:17:17 +00:00
claude 34025dc084 fix(typst): use HeadingLine single token to fix inline element highlighting
Build and Deploy Verso / deploy (push) Successful in 9m35s
The two-token approach (HeadingMark + HeadingTitle) caused LALR state
merging: the parser state waiting for HeadingTitle after HeadingMark was
merged into body-text item* states. In those merged states the
headingTitleTokenizer fired for every paragraph line, swallowing bold,
italic, math and inline function tokens — leaving body text black.

Fix: collapse the heading into a single HeadingLine external token that
covers the entire heading line (= prefix + title). A single-token Heading
rule leaves no post-token parser state waiting for a second token, so no
LALR merging can occur. The ViewPlugin and all HeadingMark/HeadingTitle
infrastructure are removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:55:09 +00:00
claude 0099672015 fix(typst): restore HeadingTitle token to fix broken syntax highlighting
Build and Deploy Verso / deploy (push) Successful in 10m12s
Removing HeadingTitle from the grammar left HeadingTitle? undeclared,
causing the Lezer grammar compiler to fail and producing no parser
output — hence everything rendered as unstyled black text.

Dual approach to prevent heading style bleed:
- HeadingTitle exists in grammar with contextual: true + canShift guard
  (prevents it from matching in body-text LALR states)
- HeadingTitle is intentionally absent from styleTags so even spurious
  matches cannot apply heading colour to body text
- ViewPlugin styles heading titles by finding HeadingMark nodes and
  extending tok-heading decoration to end-of-line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:38:38 +00:00
claude a5ca432396 Fix heading bleeding and smooth_pdf_transition translation
Build and Deploy Verso / deploy (push) Successful in 13m35s
Two fixes:

1. Heading style bleeding (Typst): the HeadingTitle external token approach
   was unreliable — even with contextual:true and canShift(), body text was
   being styled as headings. Remove HeadingTitle from the grammar entirely.
   Instead, a ViewPlugin (headingLinePlugin in languages/typst/index.ts)
   walks the syntax tree, finds HeadingMark nodes, and decorates the rest of
   the line with tok-heading class + bold. This is unconditionally correct
   because it is based on the syntax tree rather than the LR tokenizer state.

2. smooth_pdf_transition raw key shown in all locales: the key was in the
   JSON locale files but missing from extracted-translations.json, which is
   the allowlist the webpack translation loader uses to decide what to bundle.
   Add it there so all locales (including fr, es, de already added) resolve
   to their translated strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:22:16 +00:00
claude f1abcaa4ce Hide LaTeX-only compile options for Typst/Quarto projects; add smooth_pdf_transition translations
Build and Deploy Verso / deploy (push) Successful in 9m35s
Draft compile mode and stop-on-first-error are LaTeX-only features not
supported by TypstRunner or QuartoRunner. Hide both sections from the
recompile dropdown for non-LaTeX projects. Also detect Quarto root files
(.qmd/.md/.Rmd) alongside Typst (.typ) to correctly set isLatexProject.

Add missing smooth_pdf_transition translations for French, Spanish, and
German (the English key already existed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:45:02 +00:00
claude 1c323351a2 fix: parse Quarto schema YAML errors and stop heading style bleeding
Build and Deploy Verso / deploy (push) Successful in 9m46s
Two unrelated fixes:

1. quarto-log-parser: handle the two-line Quarto schema-validation
   error format:
     ERROR: In file main.qmd
     (line 6, columns 24--27) Field "section-numbering" has value …
   Previously neither the file name nor the line number were extracted,
   so the error appeared without a red highlight. Now the first line
   stores the filename in pendingLocation and the second line creates
   the log entry with the correct file and line so the editor can jump
   to and highlight it.

2. headingTitleTokenizer: change contextual: false → contextual: true
   and guard with stack.canShift(HeadingTitle). With contextual: false
   Lezer calls the tokenizer speculatively at positions beyond the strict
   post-HeadingMark state; in some LALR-merged states the resulting token
   was accepted for body-text lines, making them render as bold-blue
   heading text. The contextual guard ensures the tokenizer only fires
   in the one state where HeadingTitle is legitimately valid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:31:26 +00:00
claude 55e8208892 chore: add TODO.md with next-alpha Typst experience ideas
Build and Deploy Verso / deploy (push) Successful in 13m41s
Two ideas borrowed from the Collabst project (a Typst-native
collaborative editor): typst.ts WASM in-browser preview and Tinymist
LSP integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:26:07 +00:00
claude b8543c8bb9 fix: capture typst diagnostics emitted after the status line
Build and Deploy Verso / deploy (push) Has been cancelled
typst watch outputs the "[HH:MM:SS] compiled with errors" status line
FIRST, then the full diagnostic output (file:line:col, source snippets,
hints) AFTERWARDS. The previous code resolved the pending compile
promise as soon as COMPILE_DONE_RE fired, discarding all post-status
diagnostic lines. Those lines then got cleared by the next cycle's
COMPILE_START_RE, so output.log only ever contained the bare status
line — explaining the "zero verbosity" symptom.

Fix: introduce a two-phase buffering model. When COMPILE_DONE_RE fires,
enter "post-done" phase (storing doneResult) and keep accumulating into
currentLines. _finalizeCompile() is called either when the next
COMPILE_START_RE arrives (zero added latency) or after FLUSH_DELAY_MS
(150 ms fallback for the last compile). It concatenates pre-done and
post-done lines before resolving, so output.log now contains the full
diagnostic output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:25:43 +00:00
claude 7e6c8c30cc fix: cache typst compile result to eliminate race-condition failures
Build and Deploy Verso / deploy (push) Successful in 14m6s
When typst watch detects a file change and compiles before the CLSI
resolver is registered (ResourceWriter writes files → typst compiles →
runTypst is called), _resolveAllPending was discarding the result
because pendingResolvers was empty. This caused two symptoms:

1. output.log only contained "compiled with errors" (no diagnostics)
   because the result carrying the full stdout was thrown away.

2. Every other manual compile failed with "compilation already gone"
   because the missed result caused a timeout, which killed the watcher
   and triggered a watcher restart cycle (success → miss → timeout →
   kill → restart → success → miss → ...).

Fix: when _resolveAllPending fires with no pending resolvers, store the
result in entry.pendingResult. _waitForNextCompile checks this field
first and resolves immediately if a cached result is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:48:46 +00:00
claude e0c717c131 fix: suppress interim compile errors while typing, show Typst error logs, dark-mode footer
Build and Deploy Verso / deploy (push) Successful in 1m43s
- local-compile-context: suppress failure/exited error state when
  changedAt > 0 (another compile is already queued), preventing the UI
  from flashing an error banner mid-typing that resolves moments later

- TypstRunner + CompileController: detect "compiled with errors" from
  typst watch and non-zero exit from typst compile, and signal
  status:'failure' to the frontend so the log panel opens automatically
  with the parsed error details (previously always returned 'success')

- footer.scss: add dark-mode overrides for footer.site-footer so the
  thin footer on project/marketing pages uses bg-dark-primary and
  content-primary-dark text in dark theme instead of hardcoded light bg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:19:36 +00:00
claude 7a5218d472 typst: fix CodeIdent vs "_" token overlap after #keyword CallExpr?
Build and Deploy Verso / deploy (push) Failing after 8m41s
KeywordExpr { CodeKeyword CallExpr? } merges the post-keyword LR state
with document-level markup states, where "_" opens Emphasis.  CodeIdent
starts with identHead which includes "_", so the two tokens overlap.

Adding "_" after CodeIdent in @precedence resolves the conflict: CodeIdent
wins in the merged state (correct for '#set _name(...)'), and in pure markup
states CodeIdent is not in the valid set so "_" still opens Emphasis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:16:35 +00:00
claude b16b096744 projection: visit overlay/mounted subtrees during tree iteration
Build and Deploy Verso / deploy (push) Successful in 22m39s
yamlFrontmatter() embeds the Markdown content as an overlay on the top-level
YAML-frontmatter tree.  The previous mode (IgnoreMounts | IgnoreOverlays)
skipped that overlay entirely, so ATXHeading nodes were never visited and the
Quarto (.qmd) file outline was always empty.

Dropping the mode flag lets the iterator descend into overlay and mounted
subtrees.  This is safe because every enterNode function already filters by
node name — visiting extra nodes from foreign-language mounts is a no-op.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:03:43 +00:00
claude e9cc63a261 typst: highlight function name after #set / #show keywords
Build and Deploy Verso / deploy (push) Has been cancelled
KeywordExpr now optionally includes a CallExpr, so '#set text(size: 12pt)'
parses 'text' as a CallExpr/CodeIdent and gets the function-name highlight
colour.  The optional CallExpr only shifts when the lookahead is CodeIdent,
so there is no shift/reduce conflict with other items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:40:28 +00:00
claude 07c72cf7e5 typst: stop heading title at // or /* comment markers
Build and Deploy Verso / deploy (push) Successful in 9m24s
headingTitleTokenizer now stops reading when it encounters '//' or '/*',
so '= Heading // note' correctly produces a HeadingTitle token for 'Heading'
and a LineComment for the rest of the line.  Without this, the comment was
consumed into HeadingTitle, getting heading highlight and appearing verbatim
in the file outline.

Also strip trailing line comments from heading titles in the regex-based
document outline scanner, which reads raw text independently of the tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:27:02 +00:00
claude 4f98abbc5d classHighlighter: map function(variableName) to tok-function
Build and Deploy Verso / deploy (push) Has been cancelled
All cm6 themes define .tok-function but the classHighlighter had no entry
for tags.function(tags.variableName), so function-name tokens fell back to
tok-variableName (which themes leave unstyled).  This affected Typst function
calls (#func(...)) and would affect any future language that tags function
names with t.function(t.variableName).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:21:16 +00:00
claude 1dcd6e24f4 lezer-typst: convert LineCommentContent and MathContent to external tokenizers
Build and Deploy Verso / deploy (push) Successful in 10m10s
Both tokens are "read until delimiter" catchalls that match almost every
non-newline character, causing buildTokenGroups conflicts with every other
literal token in LALR-merged states.  Moving them to ExternalTokenizer (the
same pattern already used for HeadingTitle, RawBlockBody, etc.) makes them
context-isolated: the LR state machine only calls them when those tokens are
actually valid, so they never participate in the static token-group overlap
check.

Also exclude '<' from StrongText/EmphText so Label ('<' LabelName '>') is
recognised inside strong/emphasis spans rather than being consumed as plain
text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:56:20 +00:00
claude e21f7cc0d5 fix(typst): resolve all overlapping-token errors via @precedence
Build and Deploy Verso / deploy (push) Has been cancelled
Lezer's buildTokenGroups rejects grammars with ambiguous token sets.
Eight overlaps existed:

  EscapeChar vs spaces       — EscapeChar { _ } matches \t; after '\'
                               it must win over the @skip spaces token.
  "(" / "." vs text tokens   — in the LALR-merged state after #CodeIdent,
                               callSuffix delimiters must beat
                               MarkupContent / StrongText / EmphText.
  "]" vs LineCommentContent  — inside #[...], the ContentBlock closer
                               must win even if it follows "//".

One extended @precedence declaration resolves all eight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:41:03 +00:00
claude e4f5385e35 fix(typst): fix zero-length token error for LineCommentContent
Build and Deploy Verso / deploy (push) Has been cancelled
LineCommentContent { ![\n]* } matches the empty string, which Lezer
rejects as a zero-length token (infinite-loop risk). Change to ![\n]+
and mark it optional in the LineComment rule so empty // comments parse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 06:54:56 +00:00
claude 2f3e3e7363 fix(typst): make HeadingTitle an external token to end LALR conflicts
Build and Deploy Verso / deploy (push) Has been cancelled
Any item shared between headingTitleItem and document-level item causes
a shift/reduce conflict: the LALR automaton merges the two contexts and
makes the shared token ambiguous. The only structural fix is to make
HeadingTitle a terminal (external tokenizer) that reads greedily to EOL,
giving the LR state machine a context-isolated token that can never
collide with document-level item tokens.

Removes headingTitleItem sub-rule, HeadingText token, and updates
styleTags to match HeadingTitle directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:14:18 +00:00
claude 94e8ff3503 fix(typst): eliminate LALR(1) conflict on heading title items
Build and Deploy Verso / deploy (push) Failing after 3h10m10s
Removing Strong and Emphasis from headingTitleItem eliminates the
conflict: both appear in document-level item, causing the LR automaton
to merge heading-title states with document-item states and make "*"
ambiguous (Strong opener vs. end of heading).

HeadingText is widened to ![\n$#`<@\\]+ so "*" and "_" inside headings
are consumed as plain text rather than producing error nodes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:58:05 +00:00
claude 26da1f6205 fix(editor): resolve HeadingTitle shift/reduce conflict in Typst grammar
Build and Deploy Verso / deploy (push) Has been cancelled
headingTitleItem* allowed an empty HeadingTitle, causing a shift/reduce
conflict: after HeadingMark, seeing "*" the LR parser couldn't decide
whether to shift it as a Strong inside the heading or reduce HeadingTitle
to empty and treat "*" as a document-level item.

Changing to headingTitleItem+ forces HeadingTitle to be non-empty, so
"*" after HeadingMark must be inside the heading. Empty headings are
handled by Lezer's error recovery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:35:42 +00:00
claude 5287ea6f00 fix(editor): break Strong/Emphasis mutual recursion in Typst grammar
Build and Deploy Verso / deploy (push) Has been cancelled
Strong{strongItem{Emphasis}} and Emphasis{emphItem{Strong}} created a
mutual-recursion cycle that caused Lezer's LR automaton builder to
produce exponentially many states and crash.

Remove each construct from the other's item list. StrongText already
includes '_' and EmphText already includes '*', so nested delimiters
render as plain text inside the opposite construct rather than errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:14:33 +00:00
claude 045d458875 feat(editor): native Lezer grammar for Typst syntax highlighting
Build and Deploy Verso / deploy (push) Has been cancelled
Replace the StreamLanguage tokenizer with a full LR grammar compiled by
@lezer/generator, giving Typst the same parse-tree infrastructure that
LaTeX and BibTeX already use.

Grammar features:
- Headings (=, ==, …) via SOL-detecting external tokenizer
- Code expressions (#keyword, #func(args), #ident.method, #{…}, #[…])
- Named argument highlighting (key: value in function calls)
- Inline and display math ($…$)
- Strong (*…*) and emphasis (_…_) with bold/italic formatting
- Raw blocks (```lang…```) and inline raw (`…`)
- Nested block comments (/* /* */ */) via depth-tracking external tokenizer
- Labels (<name>) and references (@name)
- Backslash escapes

Infrastructure changes:
- lezer-typst/typst.grammar — new Lezer grammar
- lezer-typst/tokens.mjs — external tokenizers for context-sensitive lexing
- scripts/lezer-latex/generate.mjs — Typst added to grammars array so the
  existing lezer-latex:generate script (and Dockerfile step) compile it
- .gitignore — generated typst.mjs / typst.terms.mjs excluded from git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:49:17 +00:00
claude 2c0f387cef feat(editor): use @codemirror/lang-yaml for Quarto YAML frontmatter
Build and Deploy Verso / deploy (push) Successful in 12m20s
Replace the custom regex-based ViewPlugin with the official
@codemirror/lang-yaml package. yamlFrontmatter({ content: mdLS })
wraps the Markdown language with a mixed parser: the leading ---/---
block is handed to the full Lezer YAML parser (proper key/value/scalar/
anchor/alias highlighting), while the document body continues to use
the Markdown parser. The manual Frontmatter extension import is also
removed since yamlFrontmatter handles frontmatter recognition itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 18:30:35 +00:00
claude f9788a1c69 feat(editor): improve syntax highlighting for Typst and Quarto documents
Build and Deploy Verso / deploy (push) Successful in 10m50s
Typst: heading tokenizer now colors the entire heading line (not just the
'=' prefix), and bold/italic markers (*/_) map to strong/emphasis tags
rather than the generic operator tag. A typstHighlightStyle applies
bold/italic formatting even when the active theme lacks .tok-heading.

Quarto: enable @lezer/markdown's Frontmatter extension so the YAML header
is no longer mis-parsed as Setext headings. A new ViewPlugin decorates
frontmatter lines with type-appropriate CSS classes: keys (tok-typeName),
string/bool/number values, comments, and the --- delimiter markers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:42:51 +00:00
claude 489bdb01ec feat(pdf): dark mode for Quarto RevealJS HTML output
Build and Deploy Verso / deploy (push) Successful in 10m36s
Apply the same CSS inversion filter to the HTML iframe as is already
applied to pdfjs PDF pages, so Quarto RevealJS presentations respect
the dark mode toggle. Also show the theme button for HTML outputs and
relax the darkModePdf condition to activate for iframes regardless of
the pdfViewer setting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:18:29 +00:00
claude 0b8897540d Fix Quarto RevealJS media missing on second compile
Build and Deploy Verso / deploy (push) Successful in 11m49s
When output.html exists, findOutputFiles includes project media files
(images, videos) in outputFiles via the MEDIA_REGEX exception so they
get served from the cache.  _removeExtraneousFiles then treated them
as extraneous and deleted them.  On the next incremental compile,
unchanged binary files are not re-synced, so the files were gone when
Quarto ran and when _appendMissingResourceWarnings checked for them.

Fix: skip deletion for any file that is a project input resource.
Those files appear in outputFiles to be served, not cleaned up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:30:08 +00:00
claude 2ead377ebc Fix stale error lines bleeding into next Typst compile log
Build and Deploy Verso / deploy (push) Failing after 36m24s
When typst watch doesn't emit "compiled with errors" after a failed
compile, currentLines accumulates indefinitely. The next successful
compile then flushes the buffer including the stale error from the
prior cycle. Reset currentLines at the start of each compile cycle
("[HH:MM:SS] compiling ...") so each log only contains output from
one compile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:10:59 +00:00
claude 5a85e1b9d8 Add smooth PDF transition toggle to compile settings
Build and Deploy Verso / deploy (push) Successful in 11m53s
Per-project-type setting: Typst defaults to on, LaTeX defaults to off.
Toggle appears in the compile dropdown under "Smooth PDF transition".
The enableTransition flag is read via a ref so toggling does not
reload the current PDF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:19:37 +00:00
claude 165219dcb1 fix(autocompile): prevent compile chaining — wait for previous compile before starting next
Build and Deploy Verso / deploy (push) Successful in 11m38s
The auto-compile effect was calling debouncedAutoCompile() on every changedAt
update (every keystroke), including while a compile was already running.  With
a 1000ms maxWait the debounce fired every second even mid-compile, chaining
compiles back-to-back and making the user wait for all of them to drain.

Fix: add `compiling` to the effect's dependency array.
- While compiling: the effect cancels the debounce immediately, preventing
  any new compile from being queued.
- When compile finishes (compiling → false): the effect re-runs; if changedAt
  is still > 0 (changes were made during the compile), it re-arms the debounce
  exactly once.  One follow-up compile, then idle.

Also remove the debouncedAutoCompile() re-queue from compiler.ts's
wasCompiling guard — the effect now owns that responsibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:38:43 +00:00
claude 71755e5cee fix(pdf): replace document.startViewTransition with non-blocking canvas fade
Build and Deploy Verso / deploy (push) Successful in 14m22s
document.startViewTransition with an async callback places a ::view-transition
overlay on top of the entire page, intercepting pointer events for the duration
of the callback (up to the 1s safety timeout + 250ms animation).  With rapid
auto-compiles this created interface freezes and overlapping transitions that
could leave the visual lock in a broken state, causing 'stuck on compiling'.

Replace with a canvas snapshot overlay + CSS opacity fade-out:
- pointer-events:none so the overlay never blocks input
- snapshot covers the canvas-clear from setDocument() (no white flash)
- on pagerendered: opacity transitions to 0 over 250ms, then overlay removed
- gives the same smooth visual crossfade, reliably, in all browsers

Chrome 126+ retains the element-level startViewTransition path which is
scoped to the PDF container and does not affect the rest of the page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:09:18 +00:00
claude 453439e611 fix(pdf): suppress root view-transition animation to isolate fade to PDF pane only
Build and Deploy Verso / deploy (push) Successful in 11m47s
document.startViewTransition generates both a named pseudo-element for the PDF
container (ol-pdf-viewer) and a root-level pseudo-element that covers the entire
page, causing the editor to fade along with the PDF.

Inject a temporary <style> that sets animation:none on the root pseudo-elements
before starting the transition, then remove it in transition.finished.  Only the
named PDF container crossfades; the editor is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:39:01 +00:00
claude d895e14e48 feat(pdf): restore smooth crossfade for Chrome 111+ using document.startViewTransition
Build and Deploy Verso / deploy (push) Successful in 11m24s
The original code used container.startViewTransition(setDocument) with a
synchronous callback, giving a 250ms CSS crossfade that looked smooth when
the PDF happened to re-render before the animation ended — but was a race.

Now there are three tiers:
- Chrome 126+: element-level startViewTransition, async, waits for pagerendered
- Chrome 111+ (Brave 138, Edge 111+): document-level startViewTransition with
  view-transition-name scoped to the PDF container, same async pattern
- Firefox / Safari / older Chromium: canvas snapshot overlay (no animation,
  but seamless — introduced in build #108)

The document-level path restores the smooth fade the user saw on Edge build
#93, now guaranteed to crossfade old→new rather than old→blank.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:22:46 +00:00
claude 4410a83146 fix: eliminate too-recently-compiled error and PDF flicker on fast Typst compiles
Build and Deploy Verso / deploy (push) Successful in 11m25s
Rate limit: auto-compile requests already have a client-side debounce; skip
the 1-second server-side recently-compiled gate for them to avoid spurious
'too-recently-compiled' rejections that were blocking ~1/3 of Typst compiles.

PDF flicker: add _snapshotCanvases() fallback for browsers without element-level
View Transitions (Chrome <126, Firefox, Safari).  Before setDocument() clears the
canvases it copies each rendered page to a positioned overlay; the overlay is
removed once the first page of the new document fires pagerendered, giving a
seamless old→new swap in all browsers.  Chrome 126+ continues to use the
startViewTransition async callback path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:00:01 +00:00
claude db162e54af fix(typst): correct auto-compile default and debounce detection for Typst projects
Build and Deploy Verso / deploy (push) Successful in 11m37s
The previous implementation used useState() to detect the project type, but the
file tree is loaded asynchronously after the WebSocket joinProject event, so
pathInFolder() always returns null on the initial render.

Use useEffect() instead — it re-runs when getRootDocInfo's reference changes
(i.e. when the file tree populates), correctly detecting .typ root docs.
Also adds updateAutoCompileDebounce() to DocumentCompiler so the tight
debounce can be applied at that point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:37:28 +00:00
claude 228ad00075 feat(typst): auto-compile on by default with fast debounce + smoother PDF transitions
Build and Deploy Verso / deploy (push) Successful in 14m23s
- Typst projects default autocompile to enabled (300ms debounce / 1s max-wait
  instead of 2.5s/5s), so the PDF refreshes nearly as the user types.
- Make startViewTransition wait for the first page to render before completing
  the crossfade, eliminating the old-PDF→blank flash on Chrome 126+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:40:10 +00:00
claude 7eaeaedcd8 Implement persistent typst watch for incremental compilation
Build and Deploy Verso / deploy (push) Successful in 59m27s
Instead of cold-starting 'typst compile' on every request, TypstRunner
now maintains a long-lived 'typst watch' process per project. Subsequent
compiles reuse the warm process, which caches fonts, packages, and the
compiled AST via Typst's comemo framework — dramatically faster.

Architecture:
- WatchTable: maps compileName → live watcher process + state
- _startWatcher: spawns 'typst watch input.typ output.pdf', registers
  stdout/close handlers, then immediately awaits the first compile result.
  The resolver is pushed to pendingResolvers synchronously inside the
  Promise constructor before any I/O event can fire — eliminating the
  race between file-write detection and resolver registration.
- _onWatcherData: parses stdout line-by-line, resolves pending callers
  on "compiled successfully/with warnings/with errors" (the three terminal
  lines typst watch emits at the end of each compile cycle).
- Graceful restart: watcher is restarted after MAX_COMPILES_BEFORE_RESTART
  (1000) cycles to stay clear of Typst's ~65k FileId limit, or immediately
  if the "ran out of file ids" message is detected in stdout.
- killTypst: tears down both the watcher and any cold-start fallback job;
  called by stopCompile (user-initiated) and clearProject/clearProjectWithListing
  (before compile-dir deletion).
- Docker fallback: Settings.clsi.dockerRunner=true falls back to the
  original cold-start 'typst compile' path unchanged.
- process.on('exit') kills all watcher process groups on CLSI shutdown.

CompileManager: call TypstRunner.promises.killTypst before deleting the
compile directory in both clearProject and clearProjectWithListing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:09:33 +00:00
claude 54c510c818 Revert Typst SyncTeX attempt; clean up diagnostic logging
Build and Deploy Verso / deploy (push) Has been cancelled
Typst has no --synctex CLI option (open feature request #289 since 2023).
Revert the frontend guard back to LaTeX-only and remove --synctex from
the Typst compile command. Also remove the temporary logger.warn calls
added for diagnosing the LaTeX synctex issue (now resolved).

The official Typst binary installation in Dockerfile-base is kept as it
is cleaner than using Quarto's modified fork for .typ compilation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:43:18 +00:00
claude 5796c0157c Install official Typst binary and use it for .typ compilation
Build and Deploy Verso / deploy (push) Has been cancelled
Quarto bundles a modified Typst fork that lacks --synctex, making
bidirectional sync impossible. Install the official Typst binary
(v0.13.1) from upstream and use it in TypstRunner instead.

This also means .typ projects now use the unmodified Typst compiler,
which is correct since TypstRunner handles plain .typ files (not .qmd).
QuartoRunner continues to use Quarto's bundled Typst internally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:40:48 +00:00
claude 3f68c147a4 Call Typst binary directly for compile and SyncTeX support
Build and Deploy Verso / deploy (push) Successful in 13m8s
Instead of going through 'quarto typst compile' (which intercepts
--synctex before it reaches Typst), call the Typst binary bundled in
the Quarto .deb directly at /opt/quarto/bin/tools/x86_64/typst.

This allows passing --synctex output.synctex.gz to generate the SyncTeX
file for bidirectional editor↔PDF sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:24:20 +00:00
claude 0780963bc7 Fix --synctex argument order for Typst compile
Build and Deploy Verso / deploy (push) Successful in 11m33s
Typst's CLI requires options before positional arguments (INPUT OUTPUT).
Placing --synctex after output.pdf caused it to be treated as an extra
positional arg and rejected with 'unexpected argument'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:09:52 +00:00
claude 43a622cd71 Add SyncTeX support for Typst projects
Build and Deploy Verso / deploy (push) Successful in 11m58s
- TypstRunner: add --synctex output.synctex.gz to quarto typst compile,
  generating a synctex file alongside the PDF (requires Typst 0.11+,
  bundled in Quarto 1.5+).
- use-synctex: extend the root-doc guard from LaTeX-only to also cover
  .typ files, enabling the Show in PDF / Show in code buttons for Typst.

The rest of the sync infrastructure (OutputCacheManager, synctex binary,
SynctexOutputParser, CLSI routes) is already format-agnostic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:52:05 +00:00
claude 9079b545f7 Switch TeX Live from scheme-basic to scheme-full
Build and Deploy Verso / deploy (push) Successful in 50m16s
Replaces the minimal scheme-basic install (plus explicit latexmk,
texcount, synctex additions) with scheme-full, giving users access
to the complete LaTeX package ecosystem without manual tlmgr installs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:55:06 +00:00
claude eb45ececf0 Install synctex binary via tlmgr for SyncTeX support
Build and Deploy Verso / deploy (push) Has been cancelled
The synctex binary was not included in scheme-basic and was not
explicitly installed, causing `spawn synctex ENOENT` on every
sync request. Add it alongside latexmk and texcount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:53:28 +00:00
claude e6add1e6f0 Add diagnostic logging to synctex to identify failure cause
Build and Deploy Verso / deploy (push) Successful in 9m42s
Logs: request params, directory used, whether output.synctex.gz
is found, and the actual synctex binary output or error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:21:35 +00:00
claude 170818e6fc fix(synctex): gate sync buttons to LaTeX-only projects
Build and Deploy Verso / deploy (push) Successful in 11m0s
Verso added 'qmd' and 'typ' to validRootDocExtensions, which caused
isValidTeXFile() to return true for Typst/Quarto files — enabling
SyncTeX UI controls for projects that never produce output.synctex.gz.

Replace the open-doc extension check in canSyncToPdf with a
LaTeX-only regex on the project root document path (tex|ltx|Rtex|Rnw),
and add the same guard in _syncToCode so PDF-click sync never fires
an API request for non-LaTeX projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:50:02 +00:00
claude 9ea904f78f Merge upstream Overleaf up to PR #34297 (68 commits)
Build and Deploy Verso / deploy (push) Successful in 11m30s
Conflicts resolved:
- fat-footer-website-redesign.pug: keep Verso footer (discard Overleaf marketing footer)
- MaterialSymbolsRoundedUnfilledPartialSlice.woff2: regenerated from merged
  unfilled-symbols.mjs (preserves Verso's deployed_code + adds upstream's spellcheck)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 13:39:32 +00:00
roo hutton 757735b075 Merge pull request #34297 from overleaf/rh-prev-plan-type-cancel
Update previous_plan_type on subscription expiry

GitOrigin-RevId: 19381e5516fdbfd2650a9a5b94b61791e0da909f
2026-06-05 08:07:27 +00:00
Mathias Jakobsen fa36cd508b Merge pull request #34310 from overleaf/mj-handle-lazy-errors-for-search-and-share
[web] Handle errors while loading full project search and share modal

GitOrigin-RevId: 29d863324a54fa872022002f612498335f88f377
2026-06-05 08:07:10 +00:00
Mathias Jakobsen b7735d402d Merge pull request #34195 from overleaf/mj-command-palette-menu-labels
[web] Add menu labels to commands, and add more commands to command palette

GitOrigin-RevId: 21e17142bb3112b5fdcda85a472122b011979f49
2026-06-05 08:07:06 +00:00
Mathias Jakobsen cabe0046c5 Merge pull request #34102 from overleaf/mj-document-import-errors
[web] Expose pandoc errors in import

GitOrigin-RevId: 55f89b91a52099a99a5d955bc05f3657b87b2cdc
2026-06-05 08:07:02 +00:00
Anna Claire Fields 97247b8ea5 [PnP migration] Remove mock-fs dependency (#33835)
GitOrigin-RevId: ff8df32d85b2ecd2837c9eee6d6d2b3b95285239
2026-06-05 08:06:54 +00:00
Anna Claire Fields 3fcd133198 [patch] update sandboxed-module for Yarn PnP compatibility and add mongodb-legacy type definitions (#33983)
GitOrigin-RevId: 8f1e9a4e4b4b5fbf3a770951a070b5a259abdcee
2026-06-05 08:06:50 +00:00
Anna Claire Fields 44dee7592a use require.resolve for mocha reporter paths (#34235)
GitOrigin-RevId: af607dfdeac8f91f63db294a964ade7622225932
2026-06-05 08:06:46 +00:00
Anna Claire Fields bfcf75855a [PnP migration] Convert .prettierrc to .prettierrc.cjs with require.resolve (#34237)
GitOrigin-RevId: ab57ca143bca8bfd2b44f03f9712a1aae70b2c1c
2026-06-05 08:06:42 +00:00
Antoine Clausse 3140e46e68 [web] Replace token-link email verification with 6-digit code on SSO registration (ORCID) (#33889)
* Replace token-link email with 6-digit code on SSO registration

Unverified SSO emails previously received a long-lived token link
(90-day TTL) via UserEmailsConfirmationHandler. This replaces that
flow with the same 6-digit code verification used for password
registration, redirecting through /registration/confirm-email.

- SSOManager.registerSSO now always confirms email (caller must
  verify first); removes sendConfirmationEmail / _finishRegistration
- SSOController._signUp sends confirmation code and stores
  pendingSSORegistration in session when IdP email_verified is false
- New SSOConfirmEmailHandler completes registration after code check
  via completeSSOEmailConfirmation module hook
- OnboardingController confirm-email handlers accept
  pendingSSORegistration alongside pendingUserRegistration

confirmEmailFromToken (POST /user/emails/confirm) removal is deferred
to a follow-up PR to avoid breaking in-flight 90-day tokens.

Closes #28607

* Fix unverified-email edge cases; Add ORCID e2e tests;

* Rename `confirmEmail` parameter to `emailVerifiedByIdP` in _signUp function

* Remove `sendConfirmationEmail`

* Mock getUserByAnyEmail in tests

* Extract _finishSSORegistration helper to deduplicate the register →
set session flags → allocate referral → finishSaasLogin → finishLogin
sequence shared by both the direct and deferred (code-confirmed) paths.

* Stop duplicating session data in pendingSSORegistration

analyticsId, splitTests, and referal_* are already in the session at
confirmation time — no need to copy them into pendingSSORegistration.
Re-fetch splitTests fresh on completion instead.

* Simplify the code

* Remove dead confirmEmail template

No callers remain after sendConfirmationEmail was deleted. The token-link
flow (confirmEmailFromToken) only validates tokens, never sends email.

* Remove dead reconfirmEmail template

* Address comments from Copilot

* Clear stale pending registration when starting a new flow

* Add unit tests for completeSSOEmailConfirmation

* Add `verificationMethod` param

* Fix camelcase issues

* Extract _createSSOUser and _registerAndFinish helpers to deduplicate registration logic

* Remove obscure "registration_error"

* Prevent FormTextIcon from shrinking

* Enable "email_already_registered_sso" error

* Misc. improvements to confirm-email-form.tsx

* Remove `UserEmailsConfirmationHandler` mock

Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com>

* Add info on sso_email.pug page

---------

Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com>
GitOrigin-RevId: d0196ebc6d81ff61bcd27726d0b899b743d08d64
2026-06-05 08:06:34 +00:00
Daniel Kontšek 2570b6559d Merge pull request #34112 from overleaf/dn0-migration-check-predeploy-hook
Add predeploy migration gate for Mongo-bundling services

GitOrigin-RevId: d9eb192ea32b5328fb24fd453ddb1370f373858e
2026-06-05 08:06:30 +00:00
Davinder Singh 6ce36a2606 adding web changes of Export HTML (#34117)
GitOrigin-RevId: 804c576faefebfc6683a0363b45372e66a43d8fc
2026-06-05 08:06:19 +00:00
Jakob Ackermann fc2abf5b24 [web] fix submit modal in Codespaces (#34137)
GitOrigin-RevId: dc057ed736e97265a901b1cf21995c1f391339a5
2026-06-05 08:06:15 +00:00
Malik Glossop b8fc478e1f Merge pull request #34185 from overleaf/worktree-mg-error-assist-paywall
Show paywall from gutter when user hits suggestion limit

GitOrigin-RevId: 36c09e3d93ac38e1e675aa8ffb419e928094d68e
2026-06-05 08:06:11 +00:00
Malik Glossop d25b032e16 Merge pull request #33450 from overleaf/worktree-mg-writefull-spelling-tab
Add writefull language suggestions section to Spelling and language tab

GitOrigin-RevId: 6195683ca175a4c3da25a7ab334a605c67db04b8
2026-06-05 08:06:07 +00:00
Mathias Jakobsen fc31a88767 Merge pull request #34145 from overleaf/ds-download-html-using-pandoc-clsi-1
[CLSI] Download as HTML feature

GitOrigin-RevId: 374101c1f957a00eda423a6be0363c08b5de7a95
2026-06-05 08:06:03 +00:00
Jakob Ackermann 0501586743 [latexqc] migrate to local s3, add codespaces support, add e2e tests (#34136)
GitOrigin-RevId: 167171103c14ed3c4ba2939d80231c343645e53a
2026-06-05 08:05:59 +00:00
Jakob Ackermann df61bfc788 [clsi] initial version of /convert/pdf-to-jpeg (#33752)
* [monorepo] consolidate clsi-lb host/ip env-vars

Target env-var is CLSI_LB_HOST. Keep CLSI_LB_IP populated for a week.

* [clsi] initial version of /convert/pdf-to-jpeg

* [rails] use fake-secrets in CI and Codespaces

* [rails] adapt tests for using clsi to convert PDFs to image

* [rails] add rake task for comparing clsi conversion with transloadit

* [clsi] double check that output.jpg is a regular file

Co-authored-by: Brian Gough <brian.gough@overleaf.com>

* [clsi] fix composing basename

* [monorepo] fix clsi-lb host env-var post merge

* [monorepo] sort dev-environment.env hosts

* [rails] use local pdf file rather than downloading it again

Download from the old renderer code path still. It's dead code.

* [terraform] clsi: enable pdf to jpg conversion

---------

Co-authored-by: Brian Gough <brian.gough@overleaf.com>
GitOrigin-RevId: 5ecaa8559d299486340bb3961f06b29f7c4dfcca
2026-06-05 08:05:55 +00:00
Brian Gough 9ec0ff065d add missing mongo dependencies (#34298)
* add missing mongo dependency for analytics

* update build scripts for analytics

* add missing mongo dependency for third-party-datastore

* update build scripts for third-party-datastore

* add missing mongo dependency for third-party-references

* update build scripts for third-party-references

* update yarn.lock for buildscript changes

GitOrigin-RevId: 1c42e49af5075529a334d50648da990e4cedb1b4
2026-06-05 08:05:50 +00:00
Olzhas Askar 8e36f20950 Merge pull request #34267 from overleaf/oa-move-upgrade
[web] Moving the upgrade button

GitOrigin-RevId: 33dcdcfa4e816e29177abe2c045e919edd7a4e08
2026-06-04 08:07:21 +00:00
roo hutton 06e99fe62a Merge pull request #34130 from overleaf/rh-enterprise-cio
Expose enterprise indicators and previous_plan_type for first subscriptions to customer.io

GitOrigin-RevId: 693db7f796609f00ecd31216a6d6be32c1f569c8
2026-06-04 08:07:09 +00:00
Maria Florencia Besteiro Gonzalez d112271b1c Merge pull request #34184 from overleaf/cs-icon-button-labs-library
feat(library): add Labs feedback badge to Library heading

GitOrigin-RevId: 6dacc588cc58300a09b8195ca800d042d40f4c89
2026-06-04 08:06:52 +00:00
Antoine Clausse 0658bd9a31 [web] Change plans order in Change Plan modal (#34096)
* [web] Order plans in Change Plan modal consistently

Reorder the plans returned by `buildPlansListForSubscriptionDash` so the
Subscription page "Change plan" modal lists them top-to-bottom as:

  1. Student annual
  2. Student monthly
  3. Standard monthly
  4. Standard annual
  5. Pro monthly
  6. Pro annual

Previously `buildPlansList` produced three per-period buckets which the
dash function concatenated, giving an order that flipped per family.
Replace that with an explicit `CHANGE_PLAN_MODAL_PLAN_CODES` list so the
order matches the Design QA spec at a glance. The now-unused
`studentAccounts`, `individualMonthlyPlans`, `individualAnnualPlans`,
`groupMonthlyPlans`, and `groupAnnualPlans` buckets are dropped from
`buildPlansList` (no other callers).

Closes #34024

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [web] Update personal-plan acceptance test for new buildPlansList shape

The previous test asserted `buildPlansList().individualMonthlyPlans`,
which no longer exists after the change-plan modal reorder dropped the
per-period buckets. Move the assertion to
`buildPlansListForSubscriptionDash()`, which is where the personal-plan
exclusion is now enforced (via `CHANGE_PLAN_MODAL_PLAN_CODES`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [web] Drop now-dead client-side plan filter

`IndividualPlansTable` used to filter out `paid-personal`,
`paid-personal-annual` and `institutional_commons` defensively because
the old `buildPlansListForSubscriptionDash` returned every non-group
plan that wasn't `hideFromUsers`. The previous commit pins the modal to
an explicit six-plan list (`CHANGE_PLAN_MODAL_PLAN_CODES`), so none of
those plan codes ever reach the frontend and the filter is dead. Remove
it and the now-unused `useMemo` import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "[web] Drop now-dead client-side plan filter"

This reverts commit 83e8448f2cfa2c68e44b749d5a2bc350a7443c6d.

We'll do that in a later cleanup

* Swap "Student monthly" and "Student annual" for consistency

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitOrigin-RevId: 046a235e14e7ad6622288f5a5a723f5a4f7f14da
2026-06-04 08:06:40 +00:00
Antoine Clausse b07d141397 [web] Fix /user/subscription/plans#ai-assist redirects (#34124)
* [web] Redirect missing AI add-on purchase to subscription dashboard

The two error paths in `previewAddonPurchase` redirected to
`/user/subscription/plans#ai-assist`, but the `#ai-assist` anchor was
removed when the AI Assist add-on was retired, so users land at the top
of the plans page with no context. Align both with the other error
branches in the same function and the `plans-2026-phase-1` enabled
branch, which already redirect to
`/user/subscription?redirect-reason=ai-assist-unavailable` — the
subscription dashboard shows the matching warning alert
(`redirect-alerts.tsx`).

Update the acceptance test to match the new redirect target.

Closes #34074

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [web] Update ai-assist-unavailable warning to reflect bundled AI features

The previous copy said "AI Assist isn't available to you due to your
current subscription type", which read as a hard block. Now that the AI
Assist add-on has been retired and AI features are included with every
paid plan, the warning should point users to the pricing page instead of
implying their plan can't access AI at all.

Keep the existing translation key for now — a follow-up can rename it
once #33624 (AI page CTA destination) is resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [web] Link the ai-assist-unavailable warning to the pricing page

* [web] Rename key `ai_assist_unavailable_due_to_subscription_type` -> `ai_assist_unavailable`

* [web] Update french and german translations

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitOrigin-RevId: ae1319fa5b857d8f292de77c82ef0bda1c7ad144
2026-06-04 08:06:31 +00:00
Jakob Ackermann 6869ad5bdf [misc] remove HTTP method override capability (#34243)
GitOrigin-RevId: 2d88b9cdb63c7861e0604bb19d0e0c924701f3e4
2026-06-04 08:06:22 +00:00
Domagoj Kriskovic 9cf1085fbb [web] use updateProject for saving trackChangesState
GitOrigin-RevId: eecb2b78ff18547e8b3653fdff2d380d295c367f
2026-06-04 08:06:14 +00:00
Domagoj Kriskovic ea57ae9125 Rename sourceEditorVisualExtensions to sourceEditorMarkdownExtensions
GitOrigin-RevId: a242742c3844cccb355d4a98eb27b74123ad107e
2026-06-04 08:06:09 +00:00
Domagoj Kriskovic 5cf1b43ce7 Add Markdown visual editor support
GitOrigin-RevId: 4ec2ffb276c729a58f82ccb26ed571f4187a4178
2026-06-04 08:06:04 +00:00
Chris Dryden e38f4e18e4 Merge pull request #33868 from overleaf/dk-package-loading-tests
[web] Add tests for pyodide worker streams and output pane rendering

GitOrigin-RevId: 41ffc25230be23d68d50c61980cfaf1260a0247d
2026-06-04 08:06:00 +00:00
Liam O'Brien f1282ee5cd Helper script for changing expiry of git pat (#34234)
* Helper script for changing expiry of git pat

* Validation fail for invalid date

GitOrigin-RevId: 6786d4e808e0e4e87ef1293f4c22236257948128
2026-06-04 08:05:51 +00:00
Copilot a2f72adf67 Remove useSecondary from backup worker to fix false positive errors during MongoDB failover (#34186)
* Initial plan

* Remove useSecondary from configureBackup to fix false positive backup errors

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>
GitOrigin-RevId: f7c9bb88fb2f7526948fee196f0444bd33a96e56
2026-06-04 08:05:47 +00:00
Brian Gough 0da93aaab3 add script to finalise broken history-v1 chunks (#34005)
* add script to finalise broken history-v1 chunks

* use history-id instead of project-id

* update project-id to history-id in tests

* silence unwanted event emitter warnings

* fix up test for historyId

GitOrigin-RevId: 58d2a768f1eff296e921e2ed985f6faf3929f619
2026-06-04 08:05:42 +00:00
Liam O'Brien e53c6f2aea Notify users about expiring git PATs and expose PATs in admin panel (#33802)
* Allow admin access to user PATs

* Tests for new screen in admin panel

* Adding error for invalid token and way to parse error for OAuth 2

* Git bridge handles expired PAT

* Script for alerting on close to expiry and expired git tokens

* Refactoring and simplifying

* Updating email templates to match agreed docs

* tweak to email subject to include Overleaf

* Allowing dry run in scripts and general tidy up

* removing redundant tests and dry running script

* Fixing CI errors

* Adding new tab to admin test expectation

* Address PR feedback on oauth2-server changes

- Replace ad-hoc overleafErrorCode prop with a TokenExpiredError subclass
- Collapse listTokens/listTokensForAdmin into a single hook

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Adding cron definitions for alerting on expiring git pat

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
GitOrigin-RevId: 69b9fd901a201592a580c69abe7bd7d603e85d3a
2026-06-04 08:05:26 +00:00
jmescuderowritefull a553a8390d Clean 'writefull-keywords-generator' flag (#34200)
GitOrigin-RevId: 3014f02eba721b002eb35ec81750252993597748
2026-06-03 08:07:27 +00:00
Mathias Jakobsen 5ad548e7d7 Merge pull request #34199 from overleaf/mj-tabs-divider-tweaks
[web] Drop dividers next to active tab

GitOrigin-RevId: 9610e22b0aa7f036233108282687772c30f4c1b0
2026-06-03 08:07:06 +00:00
Maria Florencia Besteiro Gonzalez 021b2e305c Merge pull request #34108 from overleaf/mfb-show-warning-of-duplicate-citation-keys
Show duplicate citation keys as a warning beside the relevant entry

GitOrigin-RevId: e8506b2d77febec6d269a242f6d9b237171db66f
2026-06-03 08:06:44 +00:00
Mathias Jakobsen cc762bb7e6 Merge pull request #33994 from overleaf/mj-command-palette-synctex
[web] Add synctex to command palette

GitOrigin-RevId: 10e769dae6088d279d010fcfa3577b489c6ff89c
2026-06-03 08:06:40 +00:00
Brian Gough f8c7e092fa upgrade to eslint v10 (#34054)
* upgrade from eslint version 8 to eslint version 10

* remove unsupported eslint-env directive

* include jsx files in latexqc linting

* use basePath and extends to maintain paths in writefull eslint

* fix yarn.lock

with ./bin/yarn install

* preserve existing glob patterns in web eslint config

* restore original comments

* fix worker path

* corrected comment about eslint-plugin-mocha

* remove unused imports

* remove unused import of includeIgnoreFile

* switch to individual eslit.config.mjs files

* fix lint errors on eslint.config.mjs in web

* update build scripts for eslint.config.mjs

* update volumes for RUN_LINTING_CI_MONOREPO in web Makefile

updated manually as this makefile is not autogenerated
the RUN_LINTING_CI_MONOREPO command is only used for prettier, not eslint, but updating for consistency.

* migrate from mocha/no-skipped-tests to mocha/no-pending-tests

see https://github.com/lo1tuma/eslint-plugin-mocha/pull/365
"rule no-skipped-tests has been removed, its functionality has been merged into the existing no-pending-tests rule"

GitOrigin-RevId: 2c8f25c8049a0dba374a51df1214286bb5093a51
2026-06-03 08:06:29 +00:00
Mathias Jakobsen 98bd09c31d Merge pull request #34189 from overleaf/mj-fix-flaky-review-panel-tests
[web] Fix flaky <ReviewPanel /> Cypress tests

GitOrigin-RevId: b34dc9a0ca53da5a282513e8fb92297e4b2f702a
2026-06-03 08:06:17 +00:00
Alf Eaton 78bea8d574 Use Emulation.setFocusEmulationEnabled in Cypress (#33787)
GitOrigin-RevId: d3b9ba1b2362bdb23dbf8282514c972c52c83fec
2026-06-03 08:06:04 +00:00
Alf Eaton 979f065581 Upgrade to MathJax v4 (#15030)
GitOrigin-RevId: d1536bce67286da23e15aa18eb525dd83859978b
2026-06-03 08:05:55 +00:00
Andrew Rumble b6451d5bb0 Merge pull request #34179 from overleaf/ar-analytics-upgrade-development-postgres
[analytics] upgrade test postgres version to 14

GitOrigin-RevId: fac9d063c2572d4393d885554ef688876c300c29
2026-06-03 08:05:50 +00:00
Lucie Germain d52b5ae141 [Security upgrade] bump brace-expansion to 5.0.6 (#33915)
* Bump brace-expansion to 5.0.6 in linked-url-proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* drop unnecessary brace-expansion resolution; ^5.0.5 already permits 5.0.6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
GitOrigin-RevId: 837dcd88e5e0a6181d3ac2fe4f512a6ec1904002
2026-06-03 08:05:41 +00:00
Jakob Ackermann ec84a88eb3 [server-ce] tests: build git-bridge latest locally in CI (#34177)
GitOrigin-RevId: 78bf654eee77a62185a53b1abdf2cd9ae0662802
2026-06-02 08:08:39 +00:00
Mathias Jakobsen 96e0830eef Merge pull request #34167 from overleaf/mj-conversion-error-update
[web] Point conversion errors to docs page

GitOrigin-RevId: 1a5208065252159b6a69bc6ae4cecae1dd0cd4d8
2026-06-02 08:08:31 +00:00
Copilot a9a9f6ee6b Migrate history-v1 recover_zip scripts from archiver to zip-stream (#32813)
* migrate recover_zip_from_backup from archiver to zip-stream

Replace the `archiver` package with `zip-stream` (the lower-level library
that `archiver` wraps) in the `recover_zip_from_backup.mjs` script and
`backupArchiver.mjs` library. The `archiver` package has known issues with
hanging when creating large zip files and is no longer actively maintained.

Changes:
- Add `zip-stream@^7.0.2` as a direct dependency
- Update `backupArchiver.mjs` to use promisified `ZipStream.entry()`
  instead of `Archiver.append()`
- Rewrite `recover_zip_from_backup.mjs` to use `ZipStream` with
  `stream/promises.pipeline` for cleaner async flow
- Keep `archiver` dependency for `project_archive.js` (separate code path)

Agent-Logs-Url: https://github.com/overleaf/internal/sessions/0df27a8b-97f1-43cc-ac26-f5247a84313f

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

* extract finalize timeout to named constant

Agent-Logs-Url: https://github.com/overleaf/internal/sessions/0df27a8b-97f1-43cc-ac26-f5247a84313f

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

* convert recover_zip.js to zip-stream, remove finalize timeout, add verbose logging

Agent-Logs-Url: https://github.com/overleaf/internal/sessions/9380d08a-d813-4e9f-a2ac-4891122c163b

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

* add acceptance tests for recover_zip_from_backup in raw and latest modes

Agent-Logs-Url: https://github.com/overleaf/internal/sessions/9380d08a-d813-4e9f-a2ac-4891122c163b

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

* fix comment formatting in recover_zip_from_backup.mjs

Agent-Logs-Url: https://github.com/overleaf/internal/sessions/9380d08a-d813-4e9f-a2ac-4891122c163b

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

* restore EventEmitter.defaultMaxListeners in recover_zip.js, add acceptance test

Agent-Logs-Url: https://github.com/overleaf/internal/sessions/e7443126-22d5-4d0e-a176-a7a5dba49ffd

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

* fix formatting

* refactor: simplify stream handling by using named imports for pipeline

* fix blob hash verification in backup acceptance tests

* fix recover_zip script and tests

* fix: exit with non-zero status on error in recover_zip.js

Agent-Logs-Url: https://github.com/overleaf/internal/sessions/ef3f109b-488f-47c9-84a5-b5269387166a

Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>

* migrate from npm to yarn

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: briangough <7457354+briangough@users.noreply.github.com>
Co-authored-by: Brian Gough <briangough@users.noreply.github.com>
GitOrigin-RevId: 6255f9610f3c846790e2ed8b1979ac08b7effece
2026-06-02 08:08:18 +00:00
Mathias Jakobsen 7053434da6 Merge pull request #34160 from overleaf/mj-tabs-design-tweaks
[web] Apply design tweaks to editor tabs

GitOrigin-RevId: c0b064c4f5977bb13a961f03d2c5f2949d338cfe
2026-06-02 08:08:13 +00:00
Mathias Jakobsen 24dba36060 Merge pull request #34152 from overleaf/mj-select-all
[web] Add select all to context menu

GitOrigin-RevId: ff5fb828db8e1cd57d1361a2e572918339e5e18b
2026-06-02 08:08:08 +00:00
Brian Gough fda0283490 Merge pull request #33377 from overleaf/lucie/js-yaml-security-fix
[Security Upgrade]: js-yaml in yarn.lock

GitOrigin-RevId: 4f388ca74de0e33a4f8894b1aa7e7963d1de552d
2026-06-02 08:07:45 +00:00
Antoine Clausse 2a5f1be811 [web] Fix "For students" link, fix toggles and navigation (#34051)
* [web] Fix footer For Students link to activate student toggle

The footer link only set itm_referrer plus a #student-annual hash. The
plans page reads the active plan/period from `plan` and `period` query
params (PlansHelper.getPlansPageViewOptions), so the student tab never
activated from the footer link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* syncStudentModeFromPlanType after in handleDeprecatedHash

* Change URL update to use replaceState in the pricing page

* Revert "Change URL update to use replaceState in the pricing page"

This reverts commit eac71f193029e3f1c75e0c97261d8a5982c0d35c.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitOrigin-RevId: 69d689d0fe89fc68cefab9233739fc61da8f2ced
2026-06-02 08:07:40 +00:00
Antoine Clausse 105a0ff35c [web] Add nonprofit discount FAQ to plans page (#34126)
Insert a new "Do you offer discounts for nonprofits?" accordion item
under the educational group discount question in the "Overleaf
multi-license plans" FAQ tab. Routes the "contact sales" link through
the existing `faqContactLink` mixin so click tracking stays consistent
with the other FAQ contact links.

Closes #33494

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitOrigin-RevId: 582517f1d1f1f7934610253c252cf0f8af2b68a2
2026-06-02 08:07:35 +00:00
Antoine Clausse 2c7129be3a [web] Stop bolding AI features on the interstitial plans page (#34125)
* [web] Stop bolding AI features unconditionally on the interstitial

The four `strong: true` flags on the AI features in `sectionMain2026`
caused those rows to render bold on every interstitial visit, regardless
of paywall context. The original intent (per Design QA #34022) was for
boldness to highlight the features relevant to the specific paywall the
user came from (e.g. AI paywall -> AI features bolded) — that
conditional logic was never wired up, and currently no `purchaseReferrer`
or paywall reason is plumbed through to the feature config.

Remove the unconditional `strong: true` so the cards render consistently
with the pricing page. Reintroduce conditional bolding in a follow-up
once the paywall→features mapping is scoped by design.

Closes #34022

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove `card-include-strong` and related code

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitOrigin-RevId: 2112214217f3b53d34518efbca546082ce559e26
2026-06-02 08:07:31 +00:00
Antoine Clausse bd4f73b836 [web] Render "Try for free instead" CTA as link, not button (#34098)
* [web] Render "Try for free instead" CTA as link, not button

Design QA wants the "Try for free instead" CTAs on the pricing and
interstitial pages styled as marketing links (`link-monospace link-lg`)
rather than the current `btn-ghost` button. Add a `link` button type to
the `plans-cta` mixin that drops the `btn` class and applies the link
classes, and set `buttonType: 'link'` on the six `try_for_free_instead`
CTAs (plans-individual, plans-student, interstitial-payment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make link smaller

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitOrigin-RevId: f911698a9bfa19f8180e58edb3cebcea90468cbd
2026-06-02 08:07:26 +00:00
Antoine Clausse 0e4fe4090a [web] Migrate manual plurals to i18next _plural convention (#33989)
* Add tests on plurals

* Update `collabs_per_proj` and its pluralisations

* Update `n_user` and its pluralisations

* Update `showing_x_results` and its pluralisations

* Update `show_x_more_projects` and its pluralisations

* `bin/run web npm run extract-translations`

* Populate `_plural` keys in non-en locales

For 2-form languages (da, de, es, fi, fr, it, nl, no, pt, sv, tr), copy
the existing bare-key value into the new `_plural` sibling to prevent
i18next from falling back to English for count!=1.

Also remove orphan singular keys (`collabs_per_proj_single`,
`showing_1_result*`) left over from the previous commits.

Bare-key values remain in their original plural form pending translator
review — count=1 will still render the plural form in non-en until
translators flip those to singular. Multi-form (cs, pl, ru) and
single-form (ja, ko, zh-CN, zh-TW) locales are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Flip non-en bare-key values to singular form

Per review, the i18next v3 plural convention uses the bare key for count=1
(singular) and `_plural` for count!=1. The non-en bare-key values were
left as the original plural form by the previous commit so the `_plural`
siblings could be copied from them; this commit flips the bare values to
the singular form per language.

Languages where singular and plural noun forms coincide (Finnish, Swedish,
Turkish) are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Apply suggestions from code review

Co-authored-by: Olzhas Askar <olzhas.askar@gmail.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Olzhas Askar <olzhas.askar@gmail.com>
GitOrigin-RevId: 628513ca792c2dcce023247e52b7320e2741cc54
2026-06-02 08:07:17 +00:00
Antoine Clausse 58884231c1 [web] Redirect to plans page when previewing subscription change without an existing subscription (#33925)
GitOrigin-RevId: feb47fb519dd7872149d787a8543293cae66a908
2026-06-02 08:07:12 +00:00
Chris Dryden db1deb1617 Merge pull request #33895 from overleaf/cd-pyodide-unsupported-module-message
Improve error messaging for python modules unsupported by Pyodide

GitOrigin-RevId: 038c672ad9da46ea6d4640b8ed37426c92d22e72
2026-06-02 08:07:01 +00:00
Brian Gough b8067723b6 Merge pull request #33628 from overleaf/lg-otel-security-upgrade
Bump @opentelemetry/sdk-node and auto-instrumentations-node (GHSA-q7rr-3cgh-j5r3)

GitOrigin-RevId: 2d5bac25735e9ef8a462423505f142f49ef73d8b
2026-06-02 08:06:52 +00:00
Davinder Singh c09ada9ddb Revert "[WEB] Move Review Toggle into the toolbar (#34066)" (#34150)
This reverts commit 36847e0debdc4dce5f96492261d25e7cc46b2e96.

GitOrigin-RevId: 9bab306156006f683314fd59eea45854f62eae62
2026-06-02 08:06:47 +00:00
Davinder Singh 8b61e8cdca [WEB] removing the beta icon from import modal (#34025)
* removing the beta icon from import modal

* removing the unused classes

GitOrigin-RevId: 11dbe04f31ba831f96e30ab93f3f6c732166e08f
2026-06-02 08:06:38 +00:00
Davinder Singh e0f542a241 [WEB] Move Review Toggle into the toolbar (#34066)
* move Review Toggle into the toolbar

* cleaning up and adding a comment

* adding the cursor styling

* adding isolation on writefull toolbar to adjust z-index of writefull toolbar

* fixing the dark mode colours for review dropdown trigger

* Fix review mode switcher dark mode styles

GitOrigin-RevId: 36847e0debdc4dce5f96492261d25e7cc46b2e96
2026-06-02 08:06:34 +00:00
Davinder Singh ac83bc520c Merge pull request #34065 from overleaf/ds-move-toggle-to-right
[WEB] Move Code/Visual toggle to right-hand side and redesign

GitOrigin-RevId: efc1aa062fd44e20fdf719a6d4ecba9d8bb0e5e8
2026-06-02 08:06:28 +00:00
Eric Mc Sween 31fbc3daee Merge pull request #34128 from overleaf/em-library-analytics
Add analytics events to the account-level library page

GitOrigin-RevId: d0357f37a89ec29cca5b6b375a9553fbdf021b00
2026-06-01 08:05:02 +00:00
Gernot Schulz a0ca344065 Merge pull request #34127 from overleaf/gs-j-cd-hooks
Add deploy pipeline trigger hooks to Jenkinsfiles

GitOrigin-RevId: 80bb89615ae16b733009dca21a5fc41b5c30e993
2026-06-01 08:04:55 +00:00
Malik Glossop 54e122610e Merge pull request #34100 from overleaf/mg-fix-style
Stop inherited color overriding active list group item colour

GitOrigin-RevId: 7e36c2129661b4582658a5ccd9edfb15f12e701c
2026-06-01 08:04:52 +00:00
Domagoj Kriskovic 10ef1d0f34 [web] Fix empty lines being invisible in Python script output
GitOrigin-RevId: eb4b732cae74fa050384fd4cec6bd96a9caae152
2026-06-01 08:04:45 +00:00
Domagoj Kriskovic 987b3a1f71 Track script-runner-opened analytics event
GitOrigin-RevId: fb95aa2f5ad649061a6b8e9797789024a3345f3b
2026-06-01 08:04:41 +00:00
domagojk 270cbaf84e Move python labs icon next to run button
Closes #33892

GitOrigin-RevId: c48d920ee982ddd5e4295fc1279b0f70096820d1
2026-06-01 08:04:37 +00:00
Jakob Ackermann 3c763015ce [monorepo] consolidate clsi-lb host/ip env-vars (#33894)
* [monorepo] consolidate clsi-lb host/ip env-vars

Target env-var is CLSI_LB_HOST. Keep CLSI_LB_IP populated for a week.

* [monorepo] sort dev-environment.env hosts

GitOrigin-RevId: 95d12753c86ffb91264f8971e1c2c412c60de790
2026-06-01 08:04:31 +00:00
Olzhas Askar b5a73efaeb Merge pull request #34060 from overleaf/oa-timeout-cta
[web] Compile timeout CTA

GitOrigin-RevId: c1dd014150964ffec1b556943f572d3e5a8069ce
2026-06-01 08:04:24 +00:00
321 changed files with 8366 additions and 17680 deletions
@@ -1,5 +1,5 @@
diff --git a/lib/sandboxed_module.js b/lib/sandboxed_module.js
index 1cd6743fe221cbe91ea92fea3707ed07a8a2ded3..46889217d96d5534a206549ae7bd97100e41c3e4 100644
index 1cd6743..4718b97 100644
--- a/lib/sandboxed_module.js
+++ b/lib/sandboxed_module.js
@@ -4,7 +4,7 @@ var Module = require('module');
@@ -11,3 +11,47 @@ index 1cd6743fe221cbe91ea92fea3707ed07a8a2ded3..46889217d96d5534a206549ae7bd9710
var parent = module.parent;
var globalOptions = {};
var registeredBuiltInSourceTransformers = ['coffee'];
@@ -157,12 +157,20 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
var cache = Object.create(null);
var required = this._getRequires();
for (var key in required) {
- var injectedFilename = requireLike(this.filename).resolve(key);
- cache[injectedFilename] = required[key];
+ // Under Yarn PnP, resolution from a transitive dependency's context may fail
+ // for packages not declared in that dependency's package.json. Silently skip
+ // cache pre-population on failure; the mock will still be injected via the
+ // inject map in requireInterceptor or resolved via RecursiveRequireProxy fallback.
+ try {
+ var injectedFilename = requireLike(this.filename).resolve(key);
+ cache[injectedFilename] = required[key];
+ } catch (e) {}
}
cache[this.filename] = this.exports;
var globals = this.globals;
+ // Store the top-level module's filename for PnP fallback resolution
+ var topLevelFilename = this.filename;
var options;
if(!this._options.sourceTransformersSingleOnly && this._options.sourceTransformers){
options = {
@@ -208,8 +216,18 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
if (request in cache) return cache[request];
return require(request);
}
- // cached modules
- var requestedFilename = requireLike(this.filename).resolve(request);
+ // Resolve the requested module filename.
+ // Under Yarn PnP, packages can only resolve their declared dependencies.
+ // When sandboxed-module loads a transitive dependency, the resolution context
+ // may not have access to all needed packages. Fall back to resolving from
+ // the top-level module's context (the module under test).
+ var requestedFilename;
+ try {
+ requestedFilename = requireLike(this.filename).resolve(request);
+ } catch (e) {
+ if (this.filename === topLevelFilename) throw e;
+ requestedFilename = requireLike(topLevelFilename).resolve(request);
+ }
if (requestedFilename in cache) return cache[requestedFilename];
var sandboxedModule = createInnerSandboxedModule(requestedFilename)
return sandboxedModule.exports;
+41
View File
@@ -0,0 +1,41 @@
# Verso — Next Alpha Roadmap
Ideas and features deferred from the current alpha.
---
## Next alpha (post-current)
### Typst editing experience (inspired by Collabst)
- **typst.ts WASM preview** — Run the Typst compiler in the browser via
WebAssembly (typst.ts). This would give instant, sub-second preview
without a server round-trip, and would eliminate the entire class of
race conditions in the CLSI watcher (files written → typst compiles →
resolver missed). Could coexist with the CLSI watcher for PDF export
while using the WASM path for live preview.
- **Tinymist LSP integration** — Wire up
[Tinymist](https://github.com/Myriad-Dreamin/tinymist) (the Typst
language server) behind a WebSocket proxy. Would give Typst files
first-class autocomplete, hover docs, go-to-definition, and inline
error diagnostics — the main editing comfort gap vs. a native editor.
### Editor UX for non-LaTeX formats (.typ, .qmd, .md)
- **Visual/rich-text editing mode** — A toggle between raw source and a
rendered-in-place view for `.typ`, `.qmd`, and `.md` files (similar to
Overleaf's rich-text mode for LaTeX). Users who don't know Typst or
Markdown syntax should be able to edit content without seeing markup.
CodeMirror 6 already supports this pattern via a custom `NodeView` layer
or a separate Prosemirror bridge.
- **Toolbar / insertion shortcuts** — A formatting toolbar and keyboard
shortcuts for common operations, adapted per file type:
- **All formats**: bold, italic, underline, headings, bullet/numbered
lists, inline code, links.
- **Quarto / Markdown**: insert image, insert table, insert code block
with language tag.
- **Quarto RevealJS**: insert slide divider (`---`), insert speaker
notes (`::: notes`), insert columns layout, insert video embed
(using Quarto's `{{< video >}}` shortcode).
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6
View File
@@ -1,4 +1,10 @@
const pkg = require('./package.json')
module.exports = {
meta: {
name: pkg.name,
version: pkg.version,
},
rules: {
'no-unnecessary-trans': require('./no-unnecessary-trans'),
'prefer-kebab-url': require('./prefer-kebab-url'),
+2 -2
View File
@@ -8,10 +8,10 @@
"lodash": "^4.18.1"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.50.0"
"@typescript-eslint/parser": "^8.59.4"
},
"peerDependencies": {
"eslint": "^8.51.0"
"eslint": "^10.4.0"
},
"scripts": {
"test": "node rules.test.js"
@@ -22,7 +22,7 @@ module.exports = {
},
},
create(context) {
const currentFilePath = context.getFilename()
const currentFilePath = context.filename
// ESLint can sometimes pass <text> or <input> for snippets not in a file
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
return {}
@@ -81,9 +81,10 @@ module.exports = {
typeof firstArg.value !== 'string'
) {
if (firstArg.type === 'Identifier') {
const variable = context
.getScope()
.variables.find(v => v.name === firstArg.name)
const scope = context.sourceCode.getScope(node)
const variable = scope.variables.find(
v => v.name === firstArg.name
)
if (
variable &&
variable.defs.length > 0 &&
+18 -7
View File
@@ -1,4 +1,5 @@
const { RuleTester } = require('eslint')
const tsParser = require('@typescript-eslint/parser')
const noThrowInCallback = require('./no-throw-in-callback')
const preferKebabUrl = require('./prefer-kebab-url')
const noUnnecessaryTrans = require('./no-unnecessary-trans')
@@ -8,10 +9,10 @@ const viDoMockValidPath = require('./require-vi-doMock-valid-path')
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
parserOptions: { ecmaFeatures: { jsx: true } },
},
})
@@ -33,19 +34,27 @@ ruleTester.run('prefer-kebab-url', preferKebabUrl, {
invalid: [
{
code: `app.get('/fooBar')`,
errors: [{ message: 'Route path should be in kebab-case.' }],
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `app.get('/fooBar/:id')`,
errors: [{ message: 'Route path should be in kebab-case.' }],
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
errors: [{ message: 'Route path should be in kebab-case.' }],
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
errors: [{ message: 'Route path should be in kebab-case.' }],
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
],
})
@@ -153,6 +162,7 @@ ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
{
message:
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
suggestions: [],
},
],
},
@@ -163,6 +173,7 @@ ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
{
message:
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
suggestions: [],
},
],
},
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -6
View File
@@ -10,12 +10,12 @@
"dependencies": {
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google-cloud/profiler": "^6.0.4",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/resources": "^2.6.0",
"@opentelemetry/sdk-node": "^0.214.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"compression": "^1.7.4",
"prom-client": "^14.1.1",
"yn": "^3.1.1"
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
-1
View File
@@ -38,7 +38,6 @@
"mocha": "^11.1.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"mock-fs": "^5.2.0",
"mongodb": "6.12.0",
"sandboxed-module": "^2.0.4",
"sinon": "^9.2.4",
@@ -3,9 +3,12 @@ const fs = require('node:fs')
const fsPromises = require('node:fs/promises')
const { glob } = require('glob')
const Path = require('node:path')
const { promisify } = require('node:util')
const { PassThrough } = require('node:stream')
const { pipeline } = require('node:stream/promises')
const openCb = promisify(fs.open)
const AbstractPersistor = require('./AbstractPersistor')
const { ReadError, WriteError, NotImplementedError } = require('./Errors')
const PersistorHelper = require('./PersistorHelper')
@@ -85,8 +88,9 @@ module.exports = class FSPersistor extends AbstractPersistor {
})
const fsPath = this._getFsPath(location, name, opts.useSubdirectories)
let fd
try {
opts.fd = await fsPromises.open(fsPath, 'r')
fd = await openCb(fsPath, 'r')
} catch (err) {
throw PersistorHelper.wrapError(
err,
@@ -96,7 +100,7 @@ module.exports = class FSPersistor extends AbstractPersistor {
)
}
const stream = fs.createReadStream(null, opts)
const stream = fs.createReadStream(null, { ...opts, fd })
// Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
const pass = new PassThrough()
pipeline(stream, observer, pass).catch(() => {})
@@ -1,6 +1,6 @@
const crypto = require('node:crypto')
const os = require('node:os')
const { expect } = require('chai')
const mockFs = require('mock-fs')
const fs = require('node:fs')
const fsPromises = require('node:fs/promises')
const Path = require('node:path')
@@ -10,22 +10,59 @@ const Errors = require('../../src/Errors')
const MODULE_PATH = '../../src/FSPersistor.js'
function createTree(base, tree) {
fs.mkdirSync(base, { recursive: true })
for (const [name, content] of Object.entries(tree)) {
const fullPath = Path.join(base, name)
if (Buffer.isBuffer(content) || typeof content === 'string') {
fs.writeFileSync(fullPath, content)
} else if (content && typeof content.symlink === 'string') {
fs.symlinkSync(content.symlink, fullPath)
} else {
createTree(fullPath, content)
}
}
}
describe('FSPersistorTests', function () {
const localFiles = {
'/uploads/info.txt': Buffer.from('This information is critical', {
const fileContents = {
'info.txt': Buffer.from('This information is critical', {
encoding: 'utf-8',
}),
'/uploads/other.txt': Buffer.from('Some other content', {
'other.txt': Buffer.from('Some other content', {
encoding: 'utf-8',
}),
}
const location = '/bucket'
let tmpDir
let location
let notADirPath
const files = {
wombat: 'animals/wombat.tex',
giraffe: 'animals/giraffe.tex',
potato: 'vegetables/potato.tex',
}
beforeEach(function () {
tmpDir = fs.mkdtempSync(Path.join(os.tmpdir(), 'fs-persistor-test-'))
createTree(tmpDir, {
uploads: {
'info.txt': fileContents['info.txt'],
'other.txt': fileContents['other.txt'],
},
'not-a-dir':
'This regular file is meant to prevent using this path as a directory',
directory: {
subdirectory: {},
},
})
notADirPath = Path.join(tmpDir, 'not-a-dir')
location = Path.join(tmpDir, 'bucket')
})
afterEach(function () {
fs.rmSync(tmpDir, { recursive: true })
})
const scenarios = [
{
description: 'default settings',
@@ -54,31 +91,26 @@ describe('FSPersistorTests', function () {
persistor = new FSPersistor(scenario.settings)
})
beforeEach(function () {
mockFs({
...localFiles,
'/not-a-dir':
'This regular file is meant to prevent using this path as a directory',
'/directory/subdirectory': {},
})
})
afterEach(function () {
mockFs.restore()
})
describe('sendFile', function () {
it('should copy the file', async function () {
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
await persistor.sendFile(
location,
files.wombat,
Path.join(tmpDir, 'uploads', 'info.txt')
)
const contents = await fsPromises.readFile(
scenario.fsPath(files.wombat)
)
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
expect(contents.equals(fileContents['info.txt'])).to.be.true
})
it('should return an error if the file cannot be stored', async function () {
await expect(
persistor.sendFile('/not-a-dir', files.wombat, '/uploads/info.txt')
persistor.sendFile(
notADirPath,
files.wombat,
Path.join(tmpDir, 'uploads', 'info.txt')
)
).to.be.rejectedWith(Errors.WriteError)
})
})
@@ -88,7 +120,9 @@ describe('FSPersistorTests', function () {
describe("when the file doesn't exist", function () {
beforeEach(function () {
stream = fs.createReadStream('/uploads/info.txt')
stream = fs.createReadStream(
Path.join(tmpDir, 'uploads', 'info.txt')
)
})
it('should write the stream to disk', async function () {
@@ -96,7 +130,7 @@ describe('FSPersistorTests', function () {
const contents = await fsPromises.readFile(
scenario.fsPath(files.wombat)
)
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
expect(contents.equals(fileContents['info.txt'])).to.be.true
})
it('should delete the temporary file', async function () {
@@ -109,7 +143,7 @@ describe('FSPersistorTests', function () {
describe('on error', function () {
beforeEach(async function () {
await expect(
persistor.sendStream('/not-a-dir', files.wombat, stream)
persistor.sendStream(notADirPath, files.wombat, stream)
).to.be.rejectedWith(Errors.WriteError)
})
@@ -129,13 +163,12 @@ describe('FSPersistorTests', function () {
describe('when the md5 hash matches', function () {
it('should write the stream to disk', async function () {
await persistor.sendStream(location, files.wombat, stream, {
sourceMd5: md5(localFiles['/uploads/info.txt']),
sourceMd5: md5(fileContents['info.txt']),
})
const contents = await fsPromises.readFile(
scenario.fsPath(files.wombat)
)
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
.true
expect(contents.equals(fileContents['info.txt'])).to.be.true
})
})
@@ -169,9 +202,11 @@ describe('FSPersistorTests', function () {
await persistor.sendFile(
location,
files.wombat,
'/uploads/info.txt'
Path.join(tmpDir, 'uploads', 'info.txt')
)
stream = fs.createReadStream(
Path.join(tmpDir, 'uploads', 'other.txt')
)
stream = fs.createReadStream('/uploads/other.txt')
})
it('should write the stream to disk', async function () {
@@ -179,7 +214,7 @@ describe('FSPersistorTests', function () {
const contents = await fsPromises.readFile(
scenario.fsPath(files.wombat)
)
expect(contents.equals(localFiles['/uploads/other.txt'])).to.be.true
expect(contents.equals(fileContents['other.txt'])).to.be.true
})
it('should delete the temporary file', async function () {
@@ -192,7 +227,7 @@ describe('FSPersistorTests', function () {
describe('on error', function () {
beforeEach(async function () {
await expect(
persistor.sendStream('/not-a-dir', files.wombat, stream)
persistor.sendStream(notADirPath, files.wombat, stream)
).to.be.rejectedWith(Errors.WriteError)
})
@@ -200,8 +235,7 @@ describe('FSPersistorTests', function () {
const contents = await fsPromises.readFile(
scenario.fsPath(files.wombat)
)
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
.true
expect(contents.equals(fileContents['info.txt'])).to.be.true
})
it('should delete the temporary file', async function () {
@@ -215,13 +249,12 @@ describe('FSPersistorTests', function () {
describe('when the md5 hash matches', function () {
it('should write the stream to disk', async function () {
await persistor.sendStream(location, files.wombat, stream, {
sourceMd5: md5(localFiles['/uploads/other.txt']),
sourceMd5: md5(fileContents['other.txt']),
})
const contents = await fsPromises.readFile(
scenario.fsPath(files.wombat)
)
expect(contents.equals(localFiles['/uploads/other.txt'])).to.be
.true
expect(contents.equals(fileContents['other.txt'])).to.be.true
})
})
@@ -238,8 +271,7 @@ describe('FSPersistorTests', function () {
const contents = await fsPromises.readFile(
scenario.fsPath(files.wombat)
)
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
.true
expect(contents.equals(fileContents['info.txt'])).to.be.true
})
it('should delete the temporary file', async function () {
@@ -254,13 +286,17 @@ describe('FSPersistorTests', function () {
describe('getObjectStream', function () {
beforeEach(async function () {
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
await persistor.sendFile(
location,
files.wombat,
Path.join(tmpDir, 'uploads', 'info.txt')
)
})
it('should return a string with the object contents', async function () {
const stream = await persistor.getObjectStream(location, files.wombat)
const contents = await streamToBuffer(stream)
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
expect(contents.equals(fileContents['info.txt'])).to.be.true
})
it('should support ranges', async function () {
@@ -274,8 +310,8 @@ describe('FSPersistorTests', function () {
)
const contents = await streamToBuffer(stream)
// end is inclusive in ranges, but exclusive in slice()
expect(contents.equals(localFiles['/uploads/info.txt'].slice(5, 17)))
.to.be.true
expect(contents.equals(fileContents['info.txt'].slice(5, 17))).to.be
.true
})
it('should give a NotFoundError if the file does not exist', async function () {
@@ -287,13 +323,17 @@ describe('FSPersistorTests', function () {
describe('getObjectSize', function () {
beforeEach(async function () {
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
await persistor.sendFile(
location,
files.wombat,
Path.join(tmpDir, 'uploads', 'info.txt')
)
})
it('should return the file size', async function () {
expect(
await persistor.getObjectSize(location, files.wombat)
).to.equal(localFiles['/uploads/info.txt'].length)
).to.equal(fileContents['info.txt'].length)
})
it('should throw a NotFoundError if the file does not exist', async function () {
@@ -305,7 +345,11 @@ describe('FSPersistorTests', function () {
describe('copyObject', function () {
beforeEach(async function () {
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
await persistor.sendFile(
location,
files.wombat,
Path.join(tmpDir, 'uploads', 'info.txt')
)
})
it('Should copy the file to the new location', async function () {
@@ -313,13 +357,17 @@ describe('FSPersistorTests', function () {
const contents = await fsPromises.readFile(
scenario.fsPath(files.potato)
)
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
expect(contents.equals(fileContents['info.txt'])).to.be.true
})
})
describe('deleteObject', function () {
beforeEach(async function () {
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
await persistor.sendFile(
location,
files.wombat,
Path.join(tmpDir, 'uploads', 'info.txt')
)
await fsPromises.access(scenario.fsPath(files.wombat))
})
@@ -337,7 +385,11 @@ describe('FSPersistorTests', function () {
describe('deleteDirectory', function () {
beforeEach(async function () {
for (const file of Object.values(files)) {
await persistor.sendFile(location, file, '/uploads/info.txt')
await persistor.sendFile(
location,
file,
Path.join(tmpDir, 'uploads', 'info.txt')
)
await fsPromises.access(scenario.fsPath(file))
}
})
@@ -365,7 +417,11 @@ describe('FSPersistorTests', function () {
describe('checkIfObjectExists', function () {
beforeEach(async function () {
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
await persistor.sendFile(
location,
files.wombat,
Path.join(tmpDir, 'uploads', 'info.txt')
)
})
it('should return true for existing files', async function () {
@@ -384,13 +440,17 @@ describe('FSPersistorTests', function () {
describe('directorySize', function () {
beforeEach(async function () {
for (const file of Object.values(files)) {
await persistor.sendFile(location, file, '/uploads/info.txt')
await persistor.sendFile(
location,
file,
Path.join(tmpDir, 'uploads', 'info.txt')
)
}
})
it('should sum directory files size', async function () {
expect(await persistor.directorySize(location, 'animals')).to.equal(
2 * localFiles['/uploads/info.txt'].length
2 * fileContents['info.txt'].length
)
})
@@ -404,7 +464,11 @@ describe('FSPersistorTests', function () {
describe('listDirectoryKeys', function () {
beforeEach(async function () {
for (const file of Object.values(files)) {
await persistor.sendFile(location, file, '/uploads/info.txt')
await persistor.sendFile(
location,
file,
Path.join(tmpDir, 'uploads', 'info.txt')
)
}
})
@@ -427,7 +491,11 @@ describe('FSPersistorTests', function () {
describe('listDirectoryStats', function () {
beforeEach(async function () {
for (const file of Object.values(files)) {
await persistor.sendFile(location, file, '/uploads/info.txt')
await persistor.sendFile(
location,
file,
Path.join(tmpDir, 'uploads', 'info.txt')
)
}
})
@@ -438,7 +506,7 @@ describe('FSPersistorTests', function () {
expect(keys).to.include(scenario.fsPath(files.wombat))
expect(keys).to.include(scenario.fsPath(files.giraffe))
for (const stat of stats) {
expect(stat.size).to.equal(localFiles['/uploads/info.txt'].length)
expect(stat.size).to.equal(fileContents['info.txt'].length)
}
})
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+17 -16
View File
@@ -3,39 +3,41 @@
"private": true,
"packageManager": "yarn@4.14.1",
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@overleaf/eslint-plugin": "workspace:*",
"@prettier/plugin-pug": "^3.4.0",
"@types/chai": "^4.3.0",
"@types/chai-as-promised": "^7.1.8",
"@types/mocha": "^10.0.6",
"@types/multer": "^2.1.0",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"@vitest/eslint-plugin": "^1.5.0",
"eslint": "^8.15.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-standard": "^17.0.0",
"eslint-plugin-chai-expect": "^3.0.0",
"eslint-plugin-chai-friendly": "^0.7.2",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-mocha": "^10.1.0",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-promise": "^6.0.0",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.0.1",
"eslint-formatter-unix": "^8.40.0",
"eslint-plugin-chai-expect": "^4.0.0",
"eslint-plugin-chai-friendly": "^1.1.0",
"eslint-plugin-cypress": "^4.1.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-mocha": "^11.0.0",
"eslint-plugin-n": "^18.0.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-unicorn": "^56.0.0",
"globals": "^17.6.0",
"prettier": "3.7.4",
"prettier-plugin-groovy": "0.2.1",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20.0.0"
"node": ">=20.19.0"
},
"resolutions": {
"@xmldom/xmldom": "0.8.13",
"argparse/underscore": "1.13.8",
"east/underscore": "1.13.8",
"referer-parser/js-yaml": "^4.1.0",
"referer-parser/js-yaml": "^4.1.1",
"sandboxed-module": "patch:sandboxed-module@npm%3A2.0.4#~/.yarn/patches/sandboxed-module-npm-2.0.4-f8b45aacc9.patch",
"request/tough-cookie": "5.1.2",
"request/form-data": "2.5.5",
@@ -99,7 +101,6 @@
"knip": "5.64.1",
"eslint-plugin-testing-library": "7.5.3",
"chart.js": "4.0.1",
"mock-fs": "5.2.0",
"@customerio/cdp-analytics-node": "0.3.9",
"@google-cloud/bigquery": "8.1.1",
"moment": "2.29.4",
-29
View File
@@ -1,29 +0,0 @@
{
"extends": [
"eslint:recommended",
"standard",
"prettier"
],
"plugins": [
"unicorn"
],
"parserOptions": {
"ecmaVersion": 2020
},
"env": {
"node": true
},
"rules": {
// Do not allow importing of implicit dependencies.
"import/no-extraneous-dependencies": "error",
"unicorn/prefer-node-protocol": "error"
},
"overrides": [
// Extra rules for Cypress tests
{ "files": ["**/*.spec.ts"], "extends": ["plugin:cypress/recommended"] }
],
"ignorePatterns": [
"hotfix/",
"develop/"
]
}
+7 -5
View File
@@ -48,6 +48,12 @@ RUN curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QU
&& mkdir -p /var/www/.cache/quarto /var/www/.local/share \
&& chown -R www-data:www-data /var/www/.cache /var/www/.local
# Install official Typst binary (Quarto bundles a modified fork without --synctex)
# ---------------------------------------------------------------------------------
ARG TYPST_VERSION=0.13.1
RUN curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" \
| tar -xJC /usr/local/bin --strip-components=1 "typst-x86_64-unknown-linux-musl/typst"
# Pre-install popular Quarto extensions
# -----------------------------------------------------------------------
# Extensions land in /opt/quarto-extensions/_extensions/<author>/<name>/.
@@ -142,15 +148,11 @@ RUN mkdir /install-tl-unx \
&& 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 "selected_scheme scheme-full" >> /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} \
&& /usr/local/texlive/bin/x86_64-linux/tlmgr install \
--repository ${TEXLIVE_MIRROR} \
latexmk \
texcount \
&& rm -rf /install-tl-unx
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import cypress from 'eslint-plugin-cypress/flat'
import path from 'node:path'
import baseConfig from '../eslint.config.mjs'
const ROOT_DIR = path.resolve(import.meta.dirname, '..')
export default defineConfig([
globalIgnores(['**/hotfix/', '**/develop/']),
{
basePath: ROOT_DIR,
extends: baseConfig,
languageOptions: {
ecmaVersion: 2020,
},
},
{
// The cypress block in baseConfig has patterns rooted at the
// monorepo root (`server-ce/test/helpers/*.ts`). When ESLint loads
// this file (server-ce/eslint.config.mjs) as the closest config --
// which happens when running `yarn run lint` from
// /overleaf/server-ce/test/ -- patterns from baseConfig are
// resolved relative to /overleaf/server-ce/, so those cross-dir
// patterns don't match. Re-declare with paths relative to this
// config file.
files: [
'test/helpers/*.ts',
'test/cypress/support/*.{js,jsx,mjs,cjs,ts,tsx}',
'**/*.spec.ts',
],
...cypress.configs.recommended,
},
])
+1
View File
@@ -11,6 +11,7 @@ export TEX_LIVE_DOCKER_IMAGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/t
export ALL_TEX_LIVE_DOCKER_IMAGES ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1,us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1
export IMAGE_TAG_CE ?= sharelatex/sharelatex:main
export IMAGE_TAG_PRO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro:main
export IMAGE_TAG_GIT_BRIDGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/git-bridge:main
export CYPRESS_SHARD ?=
export COMPOSE_PROJECT_NAME ?= test
export USER_UID=$(shell id -u)
+1
View File
@@ -245,6 +245,7 @@ describe('admin panel', function () {
'Deleted Projects',
'Audit Log',
'Sessions',
'Personal Access Tokens',
]
cy.findAllByRole('tab').should('have.length', tabs.length)
tabs.forEach(tabName => {
+7 -6
View File
@@ -78,12 +78,12 @@ services:
working_dir: $PWD
volumes:
- $PWD:$PWD
- $MONOREPO/libraries:$MONOREPO/libraries:ro
- $MONOREPO/node_modules:$MONOREPO/node_modules:ro
- $MONOREPO/.yarn:$MONOREPO/.yarn:ro
- $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml:ro
- $MONOREPO/package.json:$MONOREPO/package.json:ro
- $MONOREPO/yarn.lock:$MONOREPO/yarn.lock:ro
- $MONOREPO/libraries:$MONOREPO/libraries
- $MONOREPO/node_modules:$MONOREPO/node_modules
- $MONOREPO/.yarn:$MONOREPO/.yarn
- $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml
- $MONOREPO/package.json:$MONOREPO/package.json
- $MONOREPO/yarn.lock:$MONOREPO/yarn.lock
environment:
MONOREPO:
CYPRESS_SHARD:
@@ -130,6 +130,7 @@ services:
ALL_TEX_LIVE_DOCKER_IMAGES:
IMAGE_TAG_CE:
IMAGE_TAG_PRO:
IMAGE_TAG_GIT_BRIDGE:
healthcheck:
test: curl --fail http://localhost/status
interval: 3s
+2
View File
@@ -44,6 +44,7 @@ describe('editor', function () {
cy.log(`change project language to '${lng}'`)
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Spelling and language' }).click()
cy.findByLabelText('Spellcheck language').select(lng)
})
cy.get('body').type('{esc}')
@@ -76,6 +77,7 @@ describe('editor', function () {
cy.log('remove word from dictionary')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Spelling and language' }).click()
cy.findByLabelText('Dictionary').click()
})
cy.findByTestId('dictionary-modal').within(() => {
+3 -2
View File
@@ -32,11 +32,12 @@ const PATHS = {
const IMAGES = {
CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''),
PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/:.+/, ''),
}
const LATEST = {
CE: process.env.IMAGE_TAG_CE.replace(/.+:/, '') || 'latest',
PRO: process.env.IMAGE_TAG_PRO.replace(/.+:/, '') || 'latest',
GIT_BRIDGE: 'latest', // TODO, build in CI?
GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/.+:/, '') || 'latest',
}
function defaultDockerComposeOverride() {
@@ -242,7 +243,7 @@ function setVarsDockerCompose({
cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version === 'latest' ? (pro ? LATEST.PRO : LATEST.CE) : version}`
cfg.services['git-bridge'].image =
`quay.io/sharelatex/git-bridge:${version === 'latest' ? LATEST.GIT_BRIDGE : version}`
`${IMAGES.GIT_BRIDGE}:${version === 'latest' ? LATEST.GIT_BRIDGE : version}`
cfg.services.sharelatex.environment = vars
+1 -1
View File
@@ -333,7 +333,7 @@ describe('SandboxedCompiles', function () {
})
// https://github.com/overleaf/internal/issues/20216
// eslint-disable-next-line mocha/no-skipped-tests
// eslint-disable-next-line mocha/no-pending-tests
describe.skip('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({ pro: false, vars: enabledVars, resetData: true })
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -2
View File
@@ -54,6 +54,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -66,8 +70,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
+1
View File
@@ -1,5 +1,6 @@
chat
--dependencies=mongo
--deploy-pipeline=chat
--env-add=
--env-pass-through=
--esmock-loader=False
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+2
View File
@@ -32,6 +32,7 @@ COPY libraries/overleaf-editor-core/package.json /overleaf/libraries/overleaf-ed
COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json
COPY libraries/settings/package.json /overleaf/libraries/settings/package.json
COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json
COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json
COPY services/clsi/package.json /overleaf/services/clsi/package.json
COPY .yarn/patches/ /overleaf/.yarn/patches/
@@ -45,6 +46,7 @@ COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/
COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/
COPY libraries/settings/ /overleaf/libraries/settings/
COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/
COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/
COPY services/clsi/ /overleaf/services/clsi/
FROM app AS with-quarto
+10 -4
View File
@@ -25,6 +25,7 @@ IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \
$(MONOREPO)/libraries/promise-utils/package.json \
$(MONOREPO)/libraries/settings/package.json \
$(MONOREPO)/libraries/stream-utils/package.json \
$(MONOREPO)/libraries/validation-tools/package.json \
$(MONOREPO)/services/clsi/package.json \
$(MONOREPO)/.yarn/patches/* \
| sha256sum | cut -d '-' -f1)
@@ -54,6 +55,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -67,8 +72,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
@@ -165,8 +170,9 @@ test_acceptance_clean:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
test_acceptance_pre_run:
docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc:3.9
docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc-staging:3.9
-docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc:3.9
-docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc-staging:3.9
-cd ../../ && docker build -t us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pdftocairo:24.02 dockerfiles/pdftocairo
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run
endif
+5
View File
@@ -145,6 +145,11 @@ app.post(
bodyParser.json({ limit: Settings.compileSizeLimit }),
ConversionController.convertProjectToDocument
)
app.post(
'/convert/pdf-to-jpeg',
FileUploadMiddleware.multerMiddleware,
ConversionController.convertPDFToJPEG
)
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
app.get('/coverage', (req, res) => {
@@ -80,6 +80,10 @@ function compile(req, res, next) {
{ err: error, projectId: request.project_id },
'timeout running compile'
)
} else if (error?.typstCompileFailure) {
// Typst compiled but with errors — treat as a compile failure so
// the frontend shows the error log rather than the old PDF.
status = 'failure'
} else if (error) {
status = 'error'
code = 500
+7
View File
@@ -386,11 +386,18 @@ async function stopCompile(projectId, userId) {
}
async function clearProject(projectId, userId) {
// Kill any live typst watcher before deleting its files.
const compileName = getCompileName(projectId, userId)
await TypstRunner.promises.killTypst(compileName)
const compileDir = getCompileDir(projectId, userId)
await fsPromises.rm(compileDir, { force: true, recursive: true })
}
async function clearProjectWithListing(projectId, userId, allEntries) {
// Kill any live typst watcher (e.g. timedout compile where killTypst
// was not already called) before removing files from under it.
const compileName = getCompileName(projectId, userId)
await TypstRunner.promises.killTypst(compileName)
const compileDir = getCompileDir(projectId, userId)
const exists = await _checkDirectory(compileDir)
@@ -14,10 +14,12 @@ import RequestParser from './RequestParser.js'
import { pipeline } from 'node:stream/promises'
import Settings from '@overleaf/settings'
import Path from 'node:path'
import { z } from '@overleaf/validation-tools'
const CONVERSION_CONFIGS = {
docx: { extension: 'docx' },
markdown: { extension: 'zip' },
html: { extension: 'zip' },
}
async function convertDocumentToLaTeX(req, res) {
@@ -77,6 +79,51 @@ async function convertDocumentToLaTeX(req, res) {
}
}
const PDFToJPEGQuerySchema = z.object({
mode: z.enum(['preview', 'thumbnail']),
})
async function convertPDFToJPEG(req, res) {
const { path } = req.file
if (!Settings.enablePdfConversions) {
await fs.unlink(path).catch(() => {})
return res.sendStatus(404)
}
const parsed = PDFToJPEGQuerySchema.safeParse(req.query)
if (!parsed.success) {
await fs.unlink(path).catch(() => {})
return res.sendStatus(400)
}
const { mode } = parsed.data
logger.debug({ path, mode }, 'received pdf for conversion to jpeg')
const conversionId = crypto.randomUUID()
let jpegPath
try {
jpegPath = await ConversionManager.promises.convertPDFToJPEGWithLock(
conversionId,
path,
mode
)
} finally {
await fs.unlink(path).catch(() => {})
}
try {
const jpegStat = await fs.stat(jpegPath)
res.setHeader('Content-Length', jpegStat.size)
res.attachment('output.jpg')
res.setHeader('X-Content-Type-Options', 'nosniff')
const readStream = fsSync.createReadStream(jpegPath)
await pipeline(readStream, res)
} finally {
await fs
.rm(Path.dirname(jpegPath), { recursive: true, force: true })
.catch(() => {})
}
}
async function convertProjectToDocument(req, res) {
if (!Settings.enablePandocConversions) {
return res.sendStatus(404)
@@ -207,4 +254,5 @@ async function convertProjectToDocument(req, res) {
export default {
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
convertProjectToDocument: expressify(convertProjectToDocument),
convertPDFToJPEG: expressify(convertPDFToJPEG),
}
+92
View File
@@ -18,6 +18,18 @@ const CONVERSION_CONFIGS = {
},
}
const PDF_TO_JPEG_CONFIGS = {
preview: { width: 794, quality: 90 },
thumbnail: { width: 190, quality: 50 },
}
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
const PDF_TO_JPEG_OUTPUT_FILENAME = 'output.jpg'
const PDF_TO_JPEG_OUTPUT_BASENAME = Path.basename(
PDF_TO_JPEG_OUTPUT_FILENAME,
'.jpg'
)
async function convertToLaTeXWithLock(conversionId, inputPath, conversionType) {
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
const lock = LockManager.acquire(conversionDir)
@@ -150,6 +162,19 @@ const LATEX_EXPORT_CONFIGS = {
'markdown',
],
},
html: {
fileExtension: 'html',
compressOutput: true,
getPandocArgs: ({ outputPath }) => [
'--output',
outputPath,
'--from',
'latex',
'--to',
'html',
'--standalone',
],
},
}
async function convertLaTeXToDocumentInDirWithLock(
@@ -298,9 +323,76 @@ async function convertLaTeXToDocumentInDir(
return Path.join(compileDir, finalOutputName)
}
async function convertPDFToJPEGWithLock(conversionId, inputPath, mode) {
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
const lock = LockManager.acquire(conversionDir)
try {
return await convertPDFToJPEG(conversionId, conversionDir, inputPath, mode)
} finally {
lock.release()
}
}
async function convertPDFToJPEG(conversionId, conversionDir, inputPath, mode) {
const config = PDF_TO_JPEG_CONFIGS[mode]
await fs.mkdir(conversionDir, { recursive: true })
const newSourcePath = Path.join(conversionDir, PDF_TO_JPEG_INPUT_FILENAME)
await fs.copyFile(inputPath, newSourcePath)
const dstPath = Path.join(conversionDir, PDF_TO_JPEG_OUTPUT_FILENAME)
try {
const { stdout, stderr, exitCode } = await CommandRunner.promises.run(
conversionId,
[
'pdftocairo',
'-jpeg',
'-jpegopt',
`quality=${config.quality}`,
'-singlefile',
'-scale-to-x',
config.width.toString(),
'-scale-to-y',
'-1', // maintain aspect ratio
PDF_TO_JPEG_INPUT_FILENAME,
PDF_TO_JPEG_OUTPUT_BASENAME,
],
conversionDir,
Settings.pdftocairoImage,
Settings.conversionTimeoutSeconds * 1000,
{},
'conversions',
null
)
if (exitCode !== 0) {
throw new OError('Non-zero exit code from pdftocairo', {
exitCode,
stderr,
})
}
logger.debug(
{ stdout, stderr, exitCode },
'pdf-to-jpeg conversion completed'
)
const stat = await fs.lstat(dstPath)
if (!stat.isFile()) {
throw new OError('output.jpg is not a regular file', { stat })
}
// Clean up the source PDF to leave only the conversion result
await fs.unlink(newSourcePath).catch(() => {})
} catch (error) {
await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
throw new OError('pdf-to-jpeg conversion failed').withCause(error)
}
return dstPath
}
export default {
promises: {
convertToLaTeXWithLock,
convertLaTeXToDocumentInDirWithLock,
convertPDFToJPEGWithLock,
},
}
+8
View File
@@ -209,8 +209,16 @@ export default ResourceWriter = {
return callback(error)
}
// Project input resources are in outputFiles only to be served from
// the output cache (HTML media exception in OutputFileFinder). They
// must never be deleted here — incremental compiles don't re-sync
// unchanged binary files, so deleting them would leave them missing
// for Quarto and for _appendMissingResourceWarnings.
const incomingPaths = new Set(resources.map(r => r.path))
const jobs = []
for (const { path } of outputFiles || []) {
if (incomingPaths.has(path)) continue
const shouldDelete = ResourceWriter.isExtraneousFile(path)
if (shouldDelete) {
jobs.push(callback =>
+455 -66
View File
@@ -1,96 +1,485 @@
import Path from 'node:path'
import { spawn } from 'node:child_process'
import { promisify } from 'node:util'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import CommandRunner from './CommandRunner.js'
import fs from 'node:fs'
// Maps currently-running Typst jobs: compileName → PID (or docker container id)
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// Max lines kept from the current compile cycle (prevents unbounded growth
// for documents that produce many warnings).
const MAX_LOG_LINES = 500
// How long to wait for the watcher process to emit its first output.
const WATCH_START_TIMEOUT_MS = 15_000
// Matches the start-of-compile marker typst watch emits before each cycle:
// "[HH:MM:SS] compiling ..."
// Used to reset the line buffer so stale output from a failed compile that
// didn't emit a "compiled with errors" footer cannot bleed into the next log.
const COMPILE_START_RE = /^\[\d{2}:\d{2}:\d{2}\] compiling/
// Matches the three terminal lines that typst watch emits at the end of each
// compile cycle regardless of outcome:
// "[HH:MM:SS] compiled successfully in 42ms"
// "[HH:MM:SS] compiled with warnings in 42ms"
// "[HH:MM:SS] compiled with errors"
const COMPILE_DONE_RE = /compiled (successfully|with (errors|warnings))/
// Signals FileId exhaustion in a long-lived typst process (typst issue #7434).
const FILE_ID_EXHAUSTION_RE = /ran out of file ids/i
// Proactively restart the watcher before FileId exhaustion.
// Typst uses ~65 IDs per compile; 1000 compiles ≈ 65 000 — safely under 65 535.
const MAX_COMPILES_BEFORE_RESTART = 1000
// typst watch emits the "[HH:MM:SS] compiled with errors" status line FIRST,
// then the full diagnostic output (file:line:col, code snippets) AFTERWARDS.
// We buffer post-done lines and resolve after this delay if no new compile
// cycle starts sooner. 150 ms is well above the ~1 chunk latency for typst's
// diagnostic flush and imperceptible on top of a typical compile time.
const FLUSH_DELAY_MS = 150
// ---------------------------------------------------------------------------
// State (module-level, never exported)
// ---------------------------------------------------------------------------
// Active cold-start compile jobs (Docker fallback): compileName → PID
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
// Long-lived watcher processes: compileName → WatchEntry
// WatchEntry shape:
// proc ChildProcess
// directory compile dir (absolute path)
// mainFile root .typ filename
// environment env vars passed to the runner
// compilationCount total successful compile cycles on this watcher
// restartPending flag to restart at the next runTypst call
// accumulator incomplete trailing line from the last data chunk
// currentLines lines accumulated in the current phase (pre-done or
// post-done, see _onWatcherData for the two-phase logic)
// doneResult { preLines, compiledWithErrors } held between the
// COMPILE_DONE_RE line and the post-done diagnostic flush;
// null when not in post-done phase
// flushTimeout timeout handle that finalises doneResult when no next
// compile cycle starts within FLUSH_DELAY_MS
// pendingResolvers Array<{resolve, reject, timeoutHandle}>
// pendingResult compile result cached when typst finished before a
// resolver was registered (race-condition safety net)
const WatchTable = {}
logger.debug(
{ directory, timeout, mainFile, compileGroup },
'starting typst compile'
)
// PIDs we have intentionally killed, so the close handler can distinguish
// an expected exit from an unexpected crash.
const _killedWatchPids = new Set()
const command = _buildTypstCommand(mainFile)
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
ProcessTable[compileName] = CommandRunner.run(
compileName,
command,
directory,
image,
timeout,
environment || {},
compileGroup,
null,
function (error, output) {
delete ProcessTable[compileName]
async function runTypstAsync(compileName, options) {
// Docker / sandboxed mode: fall back to a cold-start compile per request.
if (Settings.clsi?.dockerRunner) {
return _runColdStart(compileName, options)
}
// 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)
}
const timeout = options.timeout || 60_000
const entry = WatchTable[compileName]
// 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)
)
const needsStart =
!entry ||
entry.restartPending ||
entry.proc.exitCode !== null
if (needsStart) {
if (entry) _killWatchEntry(compileName)
// _startWatcher spawns the process, registers all handlers, then calls
// _waitForNextCompile synchronously — the resolver is in pendingResolvers
// before any I/O event can fire, eliminating the race condition.
const result = await _startWatcher(compileName, options)
await _writeLogOutputAsync(compileName, options.directory, result)
if (result.compiledWithErrors) {
throw Object.assign(new Error('typst-compile-failure'), {
typstCompileFailure: true,
})
}
return result
}
// Watcher is alive. _waitForNextCompile adds the resolver synchronously
// inside the Promise constructor, before this function yields — safe.
const result = await _waitForNextCompile(compileName, timeout)
await _writeLogOutputAsync(compileName, options.directory, result)
if (result.compiledWithErrors) {
throw Object.assign(new Error('typst-compile-failure'), {
typstCompileFailure: true,
})
}
return result
}
// ---------------------------------------------------------------------------
// Watcher lifecycle
// ---------------------------------------------------------------------------
async function _startWatcher(compileName, options) {
const { directory, mainFile, environment } = options
const timeout = options.timeout || 60_000
const absInput = Path.join(directory, mainFile)
const absOutput = Path.join(directory, 'output.pdf')
const env = { ...process.env, ...(environment || {}) }
logger.debug({ compileName, absInput }, 'starting typst watcher')
const proc = spawn(
'/bin/sh',
['-c', `typst watch "${absInput}" "${absOutput}" 2>&1`],
{
cwd: directory,
env,
stdio: ['ignore', 'pipe', 'ignore'],
detached: true,
}
)
const entry = {
proc,
directory,
mainFile,
environment,
compilationCount: 0,
restartPending: false,
accumulator: '',
currentLines: [],
doneResult: null,
flushTimeout: null,
pendingResolvers: [],
pendingResult: null,
}
WatchTable[compileName] = entry
// Register handlers synchronously — before any I/O events can fire.
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', chunk => _onWatcherData(compileName, chunk))
proc.on('error', err => {
logger.error({ err, compileName }, 'typst watcher process error')
_rejectAllPending(
compileName,
Object.assign(err, { terminated: true })
)
if (WatchTable[compileName]?.proc === proc) {
delete WatchTable[compileName]
}
})
proc.on('close', (code, signal) => {
logger.warn({ code, signal, compileName }, 'typst watcher exited')
const wasKilled = _killedWatchPids.delete(proc.pid)
if (!wasKilled) {
_rejectAllPending(
compileName,
Object.assign(new Error('typst watcher exited unexpectedly'), {
terminated: true,
})
)
}
if (WatchTable[compileName]?.proc === proc) {
delete WatchTable[compileName]
}
})
// typst watch performs an initial compile immediately on startup.
// _waitForNextCompile adds the resolver synchronously here (inside the
// Promise constructor) before we yield, so it will catch that first event.
return _waitForNextCompile(compileName, timeout + WATCH_START_TIMEOUT_MS)
}
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 _killWatchEntry(compileName) {
const entry = WatchTable[compileName]
if (!entry) return
clearTimeout(entry.flushTimeout)
delete WatchTable[compileName]
try {
_killedWatchPids.add(entry.proc.pid)
process.kill(-entry.proc.pid) // kill entire process group
} catch (err) {
_killedWatchPids.delete(entry.proc.pid)
logger.warn({ err, compileName }, 'error killing typst watcher process group')
}
}
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')
// ---------------------------------------------------------------------------
// Stdout parsing
// ---------------------------------------------------------------------------
function _onWatcherData(compileName, chunk) {
const entry = WatchTable[compileName]
if (!entry) return
entry.accumulator += chunk
const lines = entry.accumulator.split('\n')
entry.accumulator = lines.pop() // keep the incomplete trailing fragment
for (const line of lines) {
if (COMPILE_START_RE.test(line)) {
// A new compile cycle is starting. If we were in the post-done phase
// (collecting diagnostic lines that typst emits AFTER the status line),
// finalise the previous result now — all diagnostics have arrived.
if (entry.doneResult) {
_finalizeCompile(compileName)
}
callback()
})
// Start fresh for the new cycle.
entry.currentLines = [line]
continue
}
entry.currentLines.push(line)
if (entry.currentLines.length > MAX_LOG_LINES) {
entry.currentLines.shift()
}
if (FILE_ID_EXHAUSTION_RE.test(line)) {
logger.warn({ compileName }, 'typst watcher: FileId exhaustion detected')
entry.restartPending = true
}
if (COMPILE_DONE_RE.test(line)) {
entry.compilationCount++
if (entry.compilationCount >= MAX_COMPILES_BEFORE_RESTART) {
logger.info(
{ compileName, compilationCount: entry.compilationCount },
'typst watcher: scheduling restart (FileId threshold)'
)
entry.restartPending = true
}
// typst watch outputs the "[HH:MM:SS] compiled with errors" status
// line FIRST, then the full diagnostics (file:line:col, code snippets)
// AFTERWARDS. Enter post-done phase: keep accumulating into currentLines
// and flush after FLUSH_DELAY_MS (or immediately when the next compile
// cycle's COMPILE_START_RE arrives, whichever comes first).
entry.doneResult = {
preLines: entry.currentLines,
compiledWithErrors: /compiled with errors/.test(line),
}
entry.currentLines = []
clearTimeout(entry.flushTimeout)
entry.flushTimeout = setTimeout(
() => _finalizeCompile(compileName),
FLUSH_DELAY_MS
)
}
}
}
// Combines the pre-done lines (up to/including the status line) with any
// post-done diagnostic lines and resolves all pending waiters.
function _finalizeCompile(compileName) {
const entry = WatchTable[compileName]
if (!entry || !entry.doneResult) return
clearTimeout(entry.flushTimeout)
entry.flushTimeout = null
const { preLines, compiledWithErrors } = entry.doneResult
entry.doneResult = null
// Merge: status line(s) first, then the post-done diagnostics.
const allLines = preLines.concat(entry.currentLines)
entry.currentLines = []
_resolveAllPending(compileName, {
stdout: allLines.join('\n'),
compiledWithErrors,
})
}
// ---------------------------------------------------------------------------
// Resolver helpers
// ---------------------------------------------------------------------------
function _waitForNextCompile(compileName, timeout) {
return new Promise((resolve, reject) => {
const entry = WatchTable[compileName]
if (!entry) {
return reject(new Error('no typst watcher for ' + compileName))
}
// If typst finished a compile cycle before this resolver was registered
// (race: ResourceWriter wrote files → typst compiled → runTypst called),
// consume the cached result immediately instead of waiting for a timeout.
if (entry.pendingResult) {
const result = entry.pendingResult
entry.pendingResult = null
return resolve(result)
}
// Push synchronously inside the Promise constructor — before the first
// await in the caller, so no data event can fire in the gap.
const timeoutHandle = setTimeout(() => {
entry.pendingResolvers = entry.pendingResolvers.filter(r => r !== resolver)
reject(
Object.assign(new Error('typst compile timed out'), { timedout: true })
)
}, timeout)
const resolver = { resolve, reject, timeoutHandle }
entry.pendingResolvers.push(resolver)
})
}
function _resolveAllPending(compileName, result) {
const entry = WatchTable[compileName]
if (!entry) return
const resolvers = entry.pendingResolvers.splice(0)
if (resolvers.length === 0) {
// typst compiled before a resolver was registered — cache the result so
// the next _waitForNextCompile call can consume it immediately.
entry.pendingResult = result
return
}
for (const { resolve, timeoutHandle } of resolvers) {
clearTimeout(timeoutHandle)
resolve(result)
}
}
function _rejectAllPending(compileName, err) {
const entry = WatchTable[compileName]
if (!entry) return
for (const { reject, timeoutHandle } of entry.pendingResolvers.splice(0)) {
clearTimeout(timeoutHandle)
reject(err)
}
}
// ---------------------------------------------------------------------------
// Log output
// ---------------------------------------------------------------------------
async function _writeLogOutputAsync(compileName, directory, output) {
const content = (output && output.stdout) || ''
if (!content) return
// Write to output.log so the PDF-preview log panel picks it up.
const logFile = Path.join(directory, 'output.log')
try {
await fs.promises.unlink(logFile)
} catch (err) {
if (err.code !== 'ENOENT') {
logger.error({ err, compileName, logFile }, 'error removing typst log')
}
}
try {
await fs.promises.writeFile(logFile, content, { flag: 'wx' })
} catch (err) {
if (err.code !== 'EEXIST') {
logger.error({ err, compileName, logFile }, 'error writing typst log')
}
}
}
// ---------------------------------------------------------------------------
// Cold-start fallback (Docker / sandboxed mode)
// ---------------------------------------------------------------------------
function _runColdStart(compileName, options) {
return new Promise((resolve, reject) => {
const { directory, mainFile, image, environment, compileGroup } = options
const timeout = options.timeout || 60_000
logger.debug({ directory, mainFile, compileGroup }, 'typst cold-start compile')
const inputPath = `$COMPILE_DIR/${mainFile}`
const command = ['/bin/sh', '-c', `typst compile ${inputPath} output.pdf 2>&1`]
ProcessTable[compileName] = CommandRunner.run(
compileName,
command,
directory,
image,
timeout,
environment || {},
compileGroup,
null,
function (error, output) {
delete ProcessTable[compileName]
if (error && (error.terminated || error.timedout)) {
return reject(error)
}
const combined =
output || (error ? { stdout: error.stdout || '' } : null)
_writeLogOutputAsync(compileName, directory, combined).then(
() => {
if (error && combined?.stdout) {
// Non-zero exit with output = typst compile error (not a
// system/infra error). Signal failure so the log panel opens.
reject(
Object.assign(new Error('typst-compile-failure'), {
typstCompileFailure: true,
})
)
} else if (error) {
reject(error)
} else {
resolve(combined)
}
},
reject
)
}
)
})
}
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
function isRunning(compileName) {
return ProcessTable[compileName] != null
return (
ProcessTable[compileName] != null ||
(WatchTable[compileName]?.pendingResolvers.length > 0)
)
}
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)
logger.debug({ compileName }, 'killing typst (watcher + any active compile)')
// Cold-start fallback path
if (ProcessTable[compileName] != null) {
CommandRunner.kill(ProcessTable[compileName], () => {})
delete ProcessTable[compileName]
}
CommandRunner.kill(ProcessTable[compileName], callback)
// Reject any in-flight waiters and tear down the watcher process
_rejectAllPending(
compileName,
Object.assign(new Error('terminated'), { terminated: true })
)
_killWatchEntry(compileName)
callback(null)
}
// Kill all watcher processes when the CLSI Node process exits.
process.on('exit', () => {
for (const compileName of Object.keys(WatchTable)) {
_killWatchEntry(compileName)
}
})
const runTypst = (compileName, options, callback) => {
runTypstAsync(compileName, options).then(
result => callback(null, result),
err => callback(err)
)
}
export default {
@@ -98,7 +487,7 @@ export default {
runTypst,
killTypst,
promises: {
runTypst: promisify(runTypst),
runTypst: runTypstAsync,
killTypst: promisify(killTypst),
},
}
+1 -1
View File
@@ -1,7 +1,7 @@
clsi
--data-dirs=cache,compiles,output
--dependencies=
--env-add=DOWNLOAD_HOST=http://clsi-nginx:8080,ALLOWED_COMPILE_GROUPS=clsi-perf simple-latex-file,ENABLE_PDF_CACHING=true,PDF_CACHING_ENABLE_WORKER_POOL=true,ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2025.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER=tex,SANDBOXED_COMPILES=true,SANDBOXED_COMPILES_HOST_DIR_COMPILES=$PWD/compiles,SANDBOXED_COMPILES_HOST_DIR_OUTPUT=$PWD/output,ENABLE_PANDOC_CONVERSIONS=true
--env-add=DOWNLOAD_HOST=http://clsi-nginx:8080,ALLOWED_COMPILE_GROUPS=clsi-perf simple-latex-file,ENABLE_PDF_CACHING=true,PDF_CACHING_ENABLE_WORKER_POOL=true,ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2025.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER=tex,SANDBOXED_COMPILES=true,SANDBOXED_COMPILES_HOST_DIR_COMPILES=$PWD/compiles,SANDBOXED_COMPILES_HOST_DIR_OUTPUT=$PWD/output,ENABLE_PANDOC_CONVERSIONS=true,ENABLE_PDF_CONVERSIONS=true
--env-pass-through=
--esmock-loader=False
--node-version=24.14.1
@@ -31,6 +31,9 @@ module.exports = {
parseInt(process.env.CLSI_CONVERSION_TIMEOUT_SECONDS, 10) || 60,
pandocImage: process.env.PANDOC_IMAGE || 'quay.io/sharelatex/pandoc:3.9',
enablePandocConversions: process.env.ENABLE_PANDOC_CONVERSIONS === 'true',
pdftocairoImage:
process.env.PDFTOCAIRO_IMAGE || 'quay.io/sharelatex/pdftocairo:24.02',
enablePdfConversions: process.env.ENABLE_PDF_CONVERSIONS === 'true',
maxUploadSize: 50 * 1024 * 1024,
internal: {
+2 -1
View File
@@ -30,7 +30,7 @@ services:
ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file
ENABLE_PDF_CACHING: true
PDF_CACHING_ENABLE_WORKER_POOL: true
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02
TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1
TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker
TEXLIVE_IMAGE_USER: tex
@@ -38,6 +38,7 @@ services:
SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output
ENABLE_PANDOC_CONVERSIONS: true
ENABLE_PDF_CONVERSIONS: true
volumes:
- ./reports:/overleaf/services/clsi/reports
- ./compiles:/overleaf/services/clsi/compiles
+2 -1
View File
@@ -53,7 +53,7 @@ services:
ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file
ENABLE_PDF_CACHING: true
PDF_CACHING_ENABLE_WORKER_POOL: true
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02
TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1
TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker
TEXLIVE_IMAGE_USER: tex
@@ -61,6 +61,7 @@ services:
SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output
ENABLE_PANDOC_CONVERSIONS: true
ENABLE_PDF_CONVERSIONS: true
depends_on:
clsi-nginx:
condition: service_started
+1 -1
View File
@@ -23,6 +23,7 @@
"@overleaf/promise-utils": "workspace:*",
"@overleaf/settings": "workspace:*",
"@overleaf/stream-utils": "workspace:*",
"@overleaf/validation-tools": "workspace:*",
"archiver": "5.3.2",
"async": "^3.2.5",
"body-parser": "1.20.4",
@@ -45,7 +46,6 @@
"mocha": "^11.1.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"mock-fs": "^5.1.2",
"node-fetch": "^2.7.0",
"nyc": "^17.1.0",
"sinon": "~9.0.1",
@@ -0,0 +1,83 @@
import Client from './helpers/Client.js'
import ClsiApp from './helpers/ClsiApp.js'
import Path from 'node:path'
import fs from 'node:fs/promises'
import { promisify } from 'node:util'
import { execFile as execFileCb } from 'node:child_process'
import { expect } from 'chai'
const execFile = promisify(execFileCb)
const FIXTURE_PDF = Path.join(import.meta.dirname, '../fixtures/minimal.pdf')
const MODE_EXPECTATIONS = {
preview: { width: 794 },
thumbnail: { width: 190 },
}
async function writeResponseToTempfile(response) {
const buffer = Buffer.from(await response.arrayBuffer())
const tmpPath = `/tmp/clsi-acceptance-pdf-to-jpeg-${crypto.randomUUID()}.jpg`
await fs.writeFile(tmpPath, buffer)
return { tmpPath, buffer }
}
describe('pdf-to-jpeg conversion', function () {
before(async function () {
await ClsiApp.ensureRunning()
})
for (const [mode, { width: expectedWidth }] of Object.entries(
MODE_EXPECTATIONS
)) {
describe(`with mode=${mode}`, function () {
let response
let tmpPath
let buffer
before(async function () {
response = await Client.convertPdfToJpeg(FIXTURE_PDF, mode)
expect(response.status).to.equal(200)
;({ tmpPath, buffer } = await writeResponseToTempfile(response))
})
after(async function () {
if (tmpPath) {
await fs.unlink(tmpPath).catch(() => {})
}
})
it('returns a JPEG (per `file`)', async function () {
const { stdout } = await execFile('file', ['--brief', tmpPath])
expect(stdout).to.match(/JPEG image data/)
})
it(`has the expected width of ${expectedWidth}px`, async function () {
const { stdout } = await execFile('identify', [
'-format',
'%w %h',
tmpPath,
])
const [width, height] = stdout.trim().split(' ').map(Number)
expect(width).to.equal(expectedWidth)
// A4 portrait is taller than wide; height must be positive and
// larger than the width (so the aspect ratio was preserved).
expect(height).to.be.greaterThan(width)
})
it('returns a non-empty body matching Content-Length', function () {
expect(buffer.length).to.be.greaterThan(0)
expect(buffer.length).to.equal(
Number(response.headers.get('content-length'))
)
})
})
}
describe('with an unsupported mode', function () {
it('returns 400', async function () {
const response = await Client.convertPdfToJpeg(FIXTURE_PDF, 'not-a-mode')
expect(response.status).to.equal(400)
})
})
})
@@ -53,6 +53,16 @@ async function convertDocument(path, type) {
}
}
async function convertPdfToJpeg(path, mode) {
const formData = new FormData()
formData.append('qqfile', await fsPromises.readFile(path), 'input.pdf')
return await fetch(`${host}/convert/pdf-to-jpeg?mode=${mode}`, {
method: 'POST',
headers: formData.getHeaders(),
body: formData.getBuffer(),
})
}
async function convertProjectToDocument(
projectId,
userId,
@@ -239,6 +249,7 @@ export default {
compile,
convertProjectToDocument,
convertDocument,
convertPdfToJpeg,
stopCompile,
clearCache,
getOutputFile,
@@ -18,6 +18,7 @@ describe('ConversionController', function () {
ctx.documentStat = { size: 5678 }
ctx.Settings = {
enablePandocConversions: true,
enablePdfConversions: true,
path: {
compilesDir: '/compiles',
outputDir: '/output',
@@ -591,6 +592,37 @@ describe('ConversionController', function () {
})
})
describe('with conversionType=html', function () {
beforeEach(async function (ctx) {
ctx.req.query = { type: 'html' }
ctx.fs.stat.resolves(ctx.documentStat)
await ctx.ConversionController.convertProjectToDocument(
ctx.req,
ctx.res,
sinon.stub()
)
})
it('should call convertLaTeXToDocumentInDirWithLock with type=html', function (ctx) {
sinon.assert.calledWith(
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock,
sinon.match(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
),
sinon.match(
/^\/compiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
),
'main.tex',
'html'
)
})
it('should set the attachment filename with .zip extension', function (ctx) {
sinon.assert.calledWith(ctx.res.attachment, 'output.zip')
})
})
describe('when conversion fails', function () {
beforeEach(async function (ctx) {
ctx.next = sinon.stub()
@@ -77,6 +77,24 @@ const LATEX_TO_DOCUMENT_CASES = [
'--extract-media=.',
],
},
{
type: 'html',
extension: 'html',
compressOutput: true,
pandocArgs: outputId => [
'pandoc',
Path.join('..', 'main.tex'),
'--output',
'main.html',
'--from',
'latex',
'--to',
'html',
'--standalone',
'--resource-path=..',
'--extract-media=.',
],
},
]
describe('ConversionManager', function () {
@@ -1,7 +1,8 @@
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
import Path from 'node:path'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import mockFs from 'mock-fs'
import os from 'node:os'
const MODULE_PATH = Path.join(
import.meta.dirname,
@@ -15,20 +16,19 @@ describe('DraftModeManager', () => {
}))
ctx.DraftModeManager = (await import(MODULE_PATH)).default
ctx.filename = '/mock/filename.tex'
ctx.tmpDir = fs.mkdtempSync(Path.join(os.tmpdir(), 'draft-mode-test-'))
ctx.filename = Path.join(ctx.tmpDir, 'filename.tex')
ctx.contents = `\
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}\
`
mockFs({
[ctx.filename]: ctx.contents,
})
fs.writeFileSync(ctx.filename, ctx.contents)
})
afterEach(() => {
mockFs.restore()
afterEach(ctx => {
fs.rmSync(ctx.tmpDir, { recursive: true })
})
describe('injectDraftMode', () => {
@@ -1,6 +1,6 @@
import sinon from 'sinon'
import { expect, describe, beforeEach, afterEach, it } from 'vitest'
import mockFs from 'mock-fs'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const modulePath = path.join(
@@ -8,30 +8,40 @@ const modulePath = path.join(
'../../../app/js/OutputFileFinder'
)
function createTree(base, tree) {
fs.mkdirSync(base, { recursive: true })
for (const [name, content] of Object.entries(tree)) {
const fullPath = path.join(base, name)
if (Buffer.isBuffer(content) || typeof content === 'string') {
fs.writeFileSync(fullPath, content)
} else if (content && content.symlink) {
fs.symlinkSync(content.symlink, fullPath)
} else {
createTree(fullPath, content)
}
}
}
describe('OutputFileFinder', function () {
beforeEach(async function (ctx) {
ctx.OutputFileFinder = (await import(modulePath)).default
ctx.directory = '/test/dir'
ctx.callback = sinon.stub()
mockFs({
[ctx.directory]: {
resource: {
'path.tex': 'a source file',
},
'output.pdf': 'a generated pdf file',
extra: {
'file.tex': 'a generated tex file',
},
'sneaky-file': mockFs.symlink({
path: '../foo',
}),
ctx.directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'output-finder-test-')
)
createTree(ctx.directory, {
resource: {
'path.tex': 'a source file',
},
'output.pdf': 'a generated pdf file',
extra: {
'file.tex': 'a generated tex file',
},
'sneaky-file': { symlink: '../foo' },
})
})
afterEach(function () {
mockFs.restore()
afterEach(function (ctx) {
fs.rmSync(ctx.directory, { recursive: true })
})
describe('findOutputFiles', function () {
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -2
View File
@@ -56,6 +56,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -68,8 +72,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
+1
View File
@@ -1,5 +1,6 @@
docstore
--dependencies=mongo,gcs
--deploy-pipeline=docstore
--env-add=
--env-pass-through=
--esmock-loader=False
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -2
View File
@@ -57,6 +57,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -69,8 +73,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
@@ -1,5 +1,6 @@
document-updater
--dependencies=mongo,redis
--deploy-pipeline=document-updater
--env-add=
--env-pass-through=
--esmock-loader=False
+3
View File
@@ -0,0 +1,3 @@
declare module 'mongodb-legacy' {
export * from 'mongodb'
}
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -2
View File
@@ -53,6 +53,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -66,8 +70,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
+1
View File
@@ -1,6 +1,7 @@
filestore
--data-dirs=uploads,template_files
--dependencies=s3,gcs
--deploy-pipeline=filestore-readonly
--env-add=ENABLE_CONVERSIONS=true,USE_PROM_METRICS=true,AWS_S3_USER_FILES_STORAGE_CLASS=REDUCED_REDUNDANCY,AWS_S3_USER_FILES_BUCKET_NAME=fake-user-files,AWS_S3_USER_FILES_DEK_BUCKET_NAME=fake-user-files-dek,AWS_S3_TEMPLATE_FILES_BUCKET_NAME=fake-template-files,GCS_USER_FILES_BUCKET_NAME=fake-gcs-user-files,GCS_TEMPLATE_FILES_BUCKET_NAME=fake-gcs-template-files
--env-pass-through=
--esmock-loader=False
+4
View File
@@ -15,6 +15,10 @@ IMAGE_REPO_BRANCH ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)
IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main
IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
runtime-conf:
/opt/envsubst < conf/envsubst_template.json > conf/runtime.json
@@ -5,6 +5,10 @@ import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpHeaders;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpResponse;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -107,15 +111,18 @@ public class Oauth2Filter implements Filter {
// fail later (for example, in the unlikely event that the token
// expired between the two requests). In that case, JGit will
// return a 401 without a custom error message.
int statusCode = checkAccessToken(this.oauth2Server, password, getClientIp(request));
if (statusCode == 429) {
AccessTokenCheck check = checkAccessToken(this.oauth2Server, password, getClientIp(request));
if (check.statusCode == 429) {
handleRateLimit(projectId, username, request, response);
return;
} else if (statusCode == 401) {
} else if (check.statusCode == 401 && "token_expired".equals(check.errorCode)) {
handleExpiredAccessToken(projectId, request, response);
return;
} else if (check.statusCode == 401) {
handleBadAccessToken(projectId, request, response);
return;
} else if (statusCode >= 400) {
handleUnknownOauthServerError(projectId, statusCode, request, response);
} else if (check.statusCode >= 400) {
handleUnknownOauthServerError(projectId, check.statusCode, request, response);
return;
}
cred.setAccessToken(password);
@@ -229,8 +236,52 @@ public class Oauth2Filter implements Filter {
"https://www.overleaf.com/learn/how-to/Git_integration"));
}
private int checkAccessToken(String oauth2Server, String accessToken, String clientIp)
private void handleExpiredAccessToken(
String projectId, HttpServletRequest request, HttpServletResponse response)
throws IOException {
Log.debug("[{}] Expired access token, ip={}", projectId, getClientIp(request));
sendResponse(
response,
401,
Arrays.asList(
"Your Overleaf Git authentication token has expired.",
"",
"Generate a new authentication token in your Overleaf Account Settings,",
"then run the git command again."));
}
static class AccessTokenCheck {
final int statusCode;
final String errorCode;
AccessTokenCheck(int statusCode, String errorCode) {
this.statusCode = statusCode;
this.errorCode = errorCode;
}
}
static String parseErrorCode(String body) {
if (body == null || body.isEmpty()) {
return null;
}
try {
JsonElement element = new Gson().fromJson(body, JsonElement.class);
if (element == null || !element.isJsonObject()) {
return null;
}
JsonObject obj = element.getAsJsonObject();
JsonElement codeElement = obj.get("error_code");
if (codeElement == null || codeElement.isJsonNull()) {
return null;
}
return codeElement.getAsString();
} catch (JsonSyntaxException | UnsupportedOperationException e) {
return null;
}
}
private AccessTokenCheck checkAccessToken(
String oauth2Server, String accessToken, String clientIp) throws IOException {
GenericUrl url = new GenericUrl(oauth2Server + "/oauth/token/info?client_ip=" + clientIp);
HttpRequest request = Instance.httpRequestFactory.buildGetRequest(url);
HttpHeaders headers = new HttpHeaders();
@@ -239,8 +290,12 @@ public class Oauth2Filter implements Filter {
request.setThrowExceptionOnExecuteError(false);
HttpResponse response = request.execute();
int statusCode = response.getStatusCode();
String errorCode = null;
if (statusCode >= 400 && statusCode < 500) {
errorCode = parseErrorCode(response.parseAsString());
}
response.disconnect();
return statusCode;
return new AccessTokenCheck(statusCode, errorCode);
}
private void handleUnknownOauthServerError(
@@ -0,0 +1,51 @@
package uk.ac.ic.wlgitbridge.server;
import org.junit.Assert;
import org.junit.Test;
public class Oauth2FilterTest {
@Test
public void parseErrorCode_returnsTokenExpired_whenBodyContainsIt() {
String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_expired\"}";
Assert.assertEquals("token_expired", Oauth2Filter.parseErrorCode(body));
}
@Test
public void parseErrorCode_returnsTokenInvalid_whenBodyContainsIt() {
String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_invalid\"}";
Assert.assertEquals("token_invalid", Oauth2Filter.parseErrorCode(body));
}
@Test
public void parseErrorCode_returnsNull_whenErrorCodeFieldIsMissing() {
String body = "{\"error\":\"invalid_token\"}";
Assert.assertNull(Oauth2Filter.parseErrorCode(body));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsNull() {
Assert.assertNull(Oauth2Filter.parseErrorCode(null));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsEmpty() {
Assert.assertNull(Oauth2Filter.parseErrorCode(""));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsNotJson() {
Assert.assertNull(Oauth2Filter.parseErrorCode("not json at all"));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsJsonArray() {
Assert.assertNull(Oauth2Filter.parseErrorCode("[1, 2, 3]"));
}
@Test
public void parseErrorCode_returnsNull_whenErrorCodeFieldIsJsonNull() {
String body = "{\"error\":\"invalid_token\",\"error_code\":null}";
Assert.assertNull(Oauth2Filter.parseErrorCode(body));
}
}
-2
View File
@@ -1,2 +0,0 @@
archive/
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -2
View File
@@ -59,6 +59,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -71,8 +75,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
+1
View File
@@ -1,5 +1,6 @@
history-v1
--dependencies=postgres,gcs,mongo,redis,s3
--deploy-pipeline=history-v1
--env-add=
--env-pass-through=
--esmock-loader=False
+1 -1
View File
@@ -109,7 +109,7 @@ services:
- mongo:127.0.0.1
postgres:
image: postgres:10
image: postgres:14
environment:
POSTGRES_USER: overleaf
POSTGRES_PASSWORD: overleaf
+1 -1
View File
@@ -124,7 +124,7 @@ services:
- mongo:127.0.0.1
postgres:
image: postgres:10
image: postgres:14
environment:
POSTGRES_USER: overleaf
POSTGRES_PASSWORD: overleaf
+2 -1
View File
@@ -44,7 +44,8 @@
"temp": "^0.8.3",
"throng": "^4.0.0",
"tsscmp": "^1.0.6",
"utf-8-validate": "^5.0.4"
"utf-8-validate": "^5.0.4",
"zip-stream": "^7.0.2"
},
"devDependencies": {
"@overleaf/migrations": "workspace:*",
@@ -185,13 +185,30 @@ class BackupBlobStore {
*/
/**
* @typedef {(import('archiver').Archiver)} Archiver
* @typedef {(import('zip-stream').default)} ZipStream
*/
/**
* @typedef {(import('overleaf-editor-core').FileMap)} FileMap
*/
/**
* Promisified wrapper for ZipStream's entry method.
*
* @param {ZipStream} archive
* @param {Buffer|NodeJS.ReadableStream|string} source
* @param {{ name: string }} data
* @return {Promise<void>}
*/
function addEntry(archive, source, data) {
return new Promise((resolve, reject) => {
archive.entry(source, data, err => {
if (err) reject(err)
else resolve()
})
})
}
/**
*
* @param historyId
@@ -254,14 +271,15 @@ async function fetchBlob(historyId, hash, persistor) {
/**
* @typedef {object} AddChunkOptions
* @property {string} [prefix] Should include trailing slash (if length > 0)
* @property {string} [prefix]
* @property {boolean} [useBackupGlobalBlobs]
* @property {boolean} [verbose]
*/
/**
*
* @param {History} history
* @param {Archiver} archive
* @param {ZipStream} archive
* @param {CachedPerProjectEncryptedS3Persistor} projectCache
* @param {string} historyId
* @param {AddChunkOptions} [options]
@@ -272,7 +290,7 @@ async function addChunkToArchive(
archive,
projectCache,
historyId,
{ prefix = '', useBackupGlobalBlobs = false } = {}
{ prefix = '', useBackupGlobalBlobs = false, verbose = false } = {}
) {
const chunkBlobs = new Set()
history.findBlobHashes(chunkBlobs)
@@ -334,9 +352,16 @@ async function addChunkToArchive(
}
content = await blobStore.getStream(hash)
}
archive.append(content, {
if (content == null) {
logger.error({ filePath }, 'File content is empty')
continue
}
await addEntry(archive, content, {
name: `${prefix}${filePath}`,
})
if (verbose) {
logger.info({ filePath: `${prefix}${filePath}` }, 'added to archive')
}
}
})
}
@@ -358,17 +383,20 @@ async function findStartVersionOfLatestChunk(historyId) {
/**
* Restore a project from the latest snapshot
*
* There is an assumption that the database backup has been restored.
* There is an assumption that the database backup
* has been restored.
*
* @param {Archiver} archive
* @param {ZipStream} archive
* @param {string} historyId
* @param {boolean} [useBackupGlobalBlobs]
* @param {boolean} [verbose]
* @return {Promise<void>}
*/
export async function archiveLatestChunk(
archive,
historyId,
useBackupGlobalBlobs = false
useBackupGlobalBlobs = false,
verbose = false
) {
logger.info({ historyId, useBackupGlobalBlobs }, 'Archiving latest chunk')
@@ -386,20 +414,28 @@ export async function archiveLatestChunk(
await addChunkToArchive(backedUpChunk, archive, projectCache, historyId, {
useBackupGlobalBlobs,
verbose,
})
return archive
}
/**
* Fetches all raw blobs from the project and adds them to the archive.
* Fetches all raw blobs from the project and adds
* them to the archive.
*
* @param {string} historyId
* @param {Archiver} archive
* @param {ZipStream} archive
* @param {CachedPerProjectEncryptedS3Persistor} projectCache
* @param {boolean} [verbose]
* @return {Promise<void>}
*/
async function addRawBlobsToArchive(historyId, archive, projectCache) {
async function addRawBlobsToArchive(
historyId,
archive,
projectCache,
verbose = false
) {
const blobKeys = await projectCache.listDirectoryKeys(
projectBlobsBucket,
projectKey.format(historyId)
@@ -411,9 +447,13 @@ async function addRawBlobsToArchive(historyId, archive, projectCache) {
key,
{ autoGunzip: true }
)
archive.append(stream, {
name: path.join(historyId, 'blobs', key),
const entryName = path.join(historyId, 'blobs', key)
await addEntry(archive, stream, {
name: entryName,
})
if (verbose) {
logger.info({ entryName }, 'added to archive')
}
} catch (err) {
logger.warn({ err, path: key }, 'Failed to append blob to archive')
}
@@ -425,17 +465,20 @@ async function addRawBlobsToArchive(historyId, archive, projectCache) {
*
* This can work without the database being backed up.
*
* It will split the project into chunks per directory and download the blobs alongside the chunk.
* It will split the project into chunks per directory
* and download the blobs alongside the chunk.
*
* @param {Archiver} archive
* @param {ZipStream} archive
* @param {string} historyId
* @param {boolean} [useBackupGlobalBlobs]
* @param {boolean} [verbose]
* @return {Promise<void>}
*/
export async function archiveRawProject(
archive,
historyId,
useBackupGlobalBlobs = false
useBackupGlobalBlobs = false,
verbose = false
) {
const projectCache = await getProjectPersistor(historyId)
@@ -454,11 +497,15 @@ export async function archiveRawProject(
const { buffer } = await loadChunkByKey(projectCache, key)
archive.append(buffer, {
name: `${historyId}/chunks/${chunkId}/chunk.json`,
const entryName = `${historyId}/chunks/${chunkId}/chunk.json`
await addEntry(archive, buffer, {
name: entryName,
})
if (verbose) {
logger.info({ entryName }, 'added to archive')
}
}
await addRawBlobsToArchive(historyId, archive, projectCache)
await addRawBlobsToArchive(historyId, archive, projectCache, verbose)
}
export class BackupPersistorError extends OError {}
+4 -4
View File
@@ -6,19 +6,19 @@
*/
'use strict'
const Stream = require('node:stream')
const { pipeline } = require('node:stream/promises')
const zlib = require('node:zlib')
const { WritableBuffer } = require('@overleaf/stream-utils')
/**
* Create a promise for the result of reading a stream to a buffer.
*
* @param {Stream.Readable} readStream
* @param {import('node:stream').Readable} readStream
* @return {Promise<Buffer>}
*/
async function readStreamToBuffer(readStream) {
const bufferStream = new WritableBuffer()
await Stream.promises.pipeline(readStream, bufferStream)
await pipeline(readStream, bufferStream)
return bufferStream.contents()
}
@@ -33,7 +33,7 @@ exports.readStreamToBuffer = readStreamToBuffer
async function gunzipStreamToBuffer(readStream) {
const gunzip = zlib.createGunzip()
const bufferStream = new WritableBuffer()
await Stream.promises.pipeline(readStream, gunzip, bufferStream)
await pipeline(readStream, gunzip, bufferStream)
return bufferStream.contents()
}
@@ -19,7 +19,7 @@ const LAG_TIME_BUCKETS_HRS = [
] // hours
// Configure backup settings to match worker concurrency
configureBackup({ concurrency: UPLOAD_CONCURRENCY, useSecondary: true })
configureBackup({ concurrency: UPLOAD_CONCURRENCY })
let gracefulShutdownInitiated = false
@@ -0,0 +1,129 @@
// Finalise the current chunk for a project and start a new empty chunk
// whose starting snapshot is the end snapshot of the (now-closed) current
// chunk.
//
// This is intended as a recovery tool for projects whose current chunk has
// become corrupted in such a way that further changes can no longer be
// persisted, but where the end snapshot of the current chunk can still be
// computed.
import logger from '@overleaf/logger'
import commandLineArgs from 'command-line-args'
import { Change, Chunk, History, NoOperation } from 'overleaf-editor-core'
import * as redis from '../lib/redis.js'
import knex from '../lib/knex.js'
import knexReadOnly from '../lib/knex_read_only.js'
import { client as mongoClient } from '../lib/mongodb.js'
import chunkStore from '../lib/chunk_store/index.js'
import redisBackend from '../lib/chunk_store/redis.js'
import { loadGlobalBlobs } from '../lib/blob_store/index.js'
import { fileURLToPath } from 'node:url'
import { EventEmitter } from 'node:events'
EventEmitter.defaultMaxListeners = 20
logger.initialize('finalise-chunk')
const optionDefinitions = [
{ name: 'historyId', type: String },
{ name: 'dry-run', alias: 'd', type: Boolean },
]
const options = commandLineArgs(optionDefinitions)
const HISTORY_ID = options.historyId
const DRY_RUN = options['dry-run'] || false
if (!HISTORY_ID) {
console.error('Usage: finalise_chunk.mjs --historyId <id> [--dry-run]')
process.exit(2)
}
async function finaliseCurrentChunk(historyId) {
// Validates the history id and selects the backend (postgres or mongo).
chunkStore.getBackend(historyId)
await loadGlobalBlobs()
const currentChunk = await chunkStore.loadLatest(historyId, {
persistedOnly: true,
})
const startVersion = currentChunk.getStartVersion()
const endVersion = currentChunk.getEndVersion()
const numChanges = currentChunk.getChanges().length
logger.info(
{ historyId, startVersion, endVersion, numChanges },
'loaded current chunk'
)
if (endVersion === startVersion) {
throw new Error(
`current chunk for history ${historyId} is already empty (no changes); refusing to create another empty chunk`
)
}
let nonPersistedChanges
try {
nonPersistedChanges = await redisBackend.getNonPersistedChanges(
historyId,
endVersion
)
} catch (err) {
throw new Error(
`unable to read non-persisted changes from redis for history ${historyId}: ${err.message}`
)
}
if (nonPersistedChanges.length > 0) {
throw new Error(
`history ${historyId} has ${nonPersistedChanges.length} non-persisted change(s) in redis; persist or expire them before running this script`
)
}
const endSnapshot = currentChunk.getSnapshot().clone()
endSnapshot.applyAll(currentChunk.getChanges())
// The chunks table has a unique constraint on (doc_id, end_version), so the
// new chunk cannot share an end_version with the chunk we are closing. Add a
// single NoOperation change to bump end_version by 1 without mutating the
// snapshot.
const recoveryChange = new Change([new NoOperation()], new Date(), [])
const newChunk = new Chunk(
new History(endSnapshot, [recoveryChange]),
endVersion
)
if (DRY_RUN) {
logger.info(
{ historyId, endVersion },
'dry run: would close current chunk and create new empty chunk'
)
return
}
await chunkStore.create(historyId, newChunk)
logger.info(
{ historyId, endVersion },
'closed current chunk and created new empty chunk'
)
}
async function main() {
try {
await finaliseCurrentChunk(HISTORY_ID)
} catch (err) {
logger.fatal({ err, historyId: HISTORY_ID }, 'failed to finalise chunk')
process.exitCode = 1
} finally {
await redis.disconnect()
await mongoClient.close()
await knex.destroy()
await knexReadOnly.destroy()
}
}
const currentScriptPath = fileURLToPath(import.meta.url)
if (process.argv[1] === currentScriptPath) {
main()
}
export { finaliseCurrentChunk }
@@ -17,9 +17,10 @@ const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const util = require('node:util')
const { pipeline } = require('node:stream/promises')
// Something is registering 11 listeners, over the limit of 10, which generates
// a lot of warning noise.
// Something is registering 11 listeners, over the limit
// of 10, which generates a lot of warning noise.
require('node:events').EventEmitter.defaultMaxListeners = 11
const config = require('config')
@@ -27,11 +28,23 @@ const config = require('config')
// eslint-disable-next-line import/no-extraneous-dependencies
const { Storage } = require('@google-cloud/storage')
const isValidUtf8 = require('utf-8-validate')
// zip-stream@7 uses ESM default export
const ZipStream = require('zip-stream').default
function createStorage() {
const opts = {}
if (config.has('persistor.gcs.endpoint.apiEndpoint')) {
opts.apiEndpoint = config.get('persistor.gcs.endpoint.apiEndpoint')
}
if (config.has('persistor.gcs.endpoint.projectId')) {
opts.projectId = config.get('persistor.gcs.endpoint.projectId')
}
return new Storage(opts)
}
const core = require('overleaf-editor-core')
const projectKey = require('@overleaf/object-persistor/src/ProjectKey.js')
const streams = require('../lib/streams')
const ProjectArchive = require('../lib/project_archive')
const {
values: { verbose: VERBOSE },
@@ -53,7 +66,7 @@ if (HISTORY_IDS.length === 0) {
async function listDeletedChunks(historyId) {
const bucketName = config.get('chunkStore.bucket')
const storage = new Storage()
const storage = createStorage()
const [files] = await storage.bucket(bucketName).getFiles({
prefix: projectKey.format(historyId),
versions: true,
@@ -137,7 +150,7 @@ class RecoveryBlobStore {
if (VERBOSE) console.log('fetching blob', hash)
const bucketName = config.get('blobStore.projectBucket')
const storage = new Storage()
const storage = createStorage()
const [files] = await storage.bucket(bucketName).getFiles({
prefix: this.makeProjectBlobKey(hash),
versions: true,
@@ -158,7 +171,7 @@ class RecoveryBlobStore {
async fetchGlobalBlob(hash, destination) {
const bucketName = config.get('blobStore.globalBucket')
const storage = new Storage()
const storage = createStorage()
const file = storage.bucket(bucketName).file(this.makeGlobalBlobKey(hash))
await file.download({ destination })
}
@@ -203,9 +216,18 @@ class RecoveryBlobStore {
async function uploadZip(historyId, zipPathname) {
const bucketName = config.get('zipStore.bucket')
const deadline = 24 * 3600 * 1000 // lifecycle limit on the zips bucket
const storage = new Storage()
const storage = createStorage()
const destination = `${historyId}-recovered.zip`
await storage.bucket(bucketName).upload(zipPathname, { destination })
await storage.bucket(bucketName).upload(zipPathname, {
destination,
resumable: false,
})
if (config.has('persistor.gcs.endpoint.apiEndpoint')) {
// In emulator mode, signed URLs aren't available
const apiEndpoint = config.get('persistor.gcs.endpoint.apiEndpoint')
return `${apiEndpoint}/storage/v1/b/${bucketName}/o/${encodeURIComponent(destination)}?alt=media`
}
const signedUrls = await storage
.bucket(bucketName)
@@ -219,6 +241,23 @@ async function uploadZip(historyId, zipPathname) {
return signedUrls[0]
}
/**
* Promisified wrapper for ZipStream's entry method.
*
* @param {ZipStream} archive
* @param {Buffer|NodeJS.ReadableStream|string} source
* @param {{ name: string }} data
* @return {Promise<void>}
*/
function addEntry(archive, source, data) {
return new Promise((resolve, reject) => {
archive.entry(source, data, err => {
if (err) reject(err)
else resolve()
})
})
}
async function restoreProject(historyId) {
const tmp = await fs.promises.mkdtemp(
path.join(os.tmpdir(), historyId.toString())
@@ -237,9 +276,40 @@ async function restoreProject(historyId) {
if (VERBOSE) console.log('zipping', historyId)
const zipPathname = path.join(tmp, `${historyId}.zip`)
const zipTimeoutMs = 60 * 1000
const archive = new ProjectArchive(snapshot, zipTimeoutMs)
await archive.writeZip(blobStore, zipPathname)
const outputFile = fs.createWriteStream(zipPathname)
const archive = new ZipStream()
const pipelinePromise = pipeline(archive, outputFile)
for (const pathname of snapshot.getFilePathnames()) {
const file = snapshot.getFile(pathname)
if (!file) continue
await file.load('eager', blobStore)
let content = file.getContent({
filterTrackedDeletes: true,
})
if (content === null) {
const hash = file.getHash()
content = await blobStore.getStream(hash)
}
if (content == null) continue
if (typeof content === 'string') {
content = Buffer.from(content)
}
await addEntry(archive, content, { name: pathname })
if (VERBOSE) console.log(`${pathname} added`)
}
archive.finalize()
await pipelinePromise
if (VERBOSE) {
console.log(`Wrote ${archive.getBytesWritten()} bytes`)
}
if (VERBOSE) console.log('uploading', historyId)
@@ -252,4 +322,7 @@ async function main() {
console.log(signedUrl)
}
}
main().catch(console.error)
main().catch(err => {
console.error(err)
process.exit(1)
})
@@ -4,6 +4,7 @@ import commandLineArgs from 'command-line-args'
import assert from '../lib/assert.js'
import fs from 'node:fs'
import { setTimeout } from 'node:timers/promises'
import { pipeline } from 'node:stream/promises'
import {
archiveLatestChunk,
archiveRawProject,
@@ -11,17 +12,13 @@ import {
} from '../lib/backupArchiver.mjs'
import knex from '../lib/knex.js'
import { client } from '../lib/mongodb.js'
import archiver from 'archiver'
import Events from 'node:events'
import ZipStream from 'zip-stream'
import { Chunk } from 'overleaf-editor-core'
import _ from 'lodash'
// Silence warning.
Events.setMaxListeners(20)
const SUPPORTED_MODES = ['raw', 'latest']
// Pads the mode name to a fixed length for better alignment in output.
// Pads the mode name to a fixed length for alignment.
const padModeName = _.partialRight(
_.padEnd,
Math.max(...SUPPORTED_MODES.map(mode => mode.length))
@@ -65,25 +62,6 @@ function usage() {
})
}
/**
* @typedef {import('archiver').ZipArchive} ZipArchive
*/
/**
* @typedef {import('archiver').ProgressData} ProgressData
*/
/**
* @typedef {import('archiver').EntryData} EntryData
*/
/**
* @typedef {Object} ArchiverError
* @property {string} message
* @property {string} code
* @property {Object} data
*/
let historyId, help, mode, output, useBackupGlobalBlobs, verbose
try {
@@ -136,80 +114,37 @@ await loadGlobalBlobs()
outputFile = fs.createWriteStream(output)
const archive = archiver.create('zip', {})
const archive = new ZipStream()
archive.on('close', function () {
console.log(archive.pointer() + ' total bytes')
console.log(`Wrote ${output}`)
shutdown().catch(e => console.error('Error shutting down', e))
archive.on('error', function (e) {
console.error(`Error writing archive: ${e.message}`)
})
archive.on(
'error',
/**
*
* @param {ArchiverError} e
*/
function (e) {
console.error(`Error writing archive: ${e.message}`)
}
)
archive.on('end', function () {
console.log(`Wrote ${archive.pointer()} total bytes to ${output}`)
shutdown().catch(e => console.error('Error shutting down', e))
})
archive.on(
'progress',
/**
*
* @param {ProgressData} progress
*/
function (progress) {
if (verbose) {
console.log(
`${progress.entries.processed} processed out of ${progress.entries.total}`
)
}
}
)
archive.on(
'entry',
/**
*
* @param {EntryData} entry
*/
function (entry) {
if (verbose) {
console.log(`${entry.name} added`)
}
}
)
archive.on(
'warning',
/**
*
* @param {ArchiverError} warning
*/
function (warning) {
console.warn(`Warning encountered when writing archive: ${warning.message}`)
}
)
try {
// Pipe archive to the output file before adding entries.
// pipeline handles backpressure and will resolve when
// the archive stream ends.
const pipelinePromise = pipeline(archive, outputFile)
switch (mode) {
case 'latest':
await archiveLatestChunk(archive, historyId, useBackupGlobalBlobs)
await archiveLatestChunk(
archive,
historyId,
useBackupGlobalBlobs,
verbose
)
break
case 'raw':
default:
await archiveRawProject(archive, historyId, useBackupGlobalBlobs)
await archiveRawProject(archive, historyId, useBackupGlobalBlobs, verbose)
break
}
archive.pipe(outputFile)
archive.finalize()
await pipelinePromise
console.log(`Wrote ${archive.getBytesWritten()} total bytes to ${output}`)
} catch (error) {
if (error instanceof BackupPersistorError) {
console.error(error.message)
@@ -222,12 +157,7 @@ try {
} else {
console.error('Error encountered when writing archive')
}
} finally {
await Promise.race([
await archive.finalize(),
setTimeout(10000).then(() => {
console.error('Archive did not finalize in time')
return shutdown(1)
}),
])
await shutdown(1)
}
await shutdown(0)
@@ -507,7 +507,7 @@ describe('project controller', function () {
})
})
// eslint-disable-next-line mocha/no-skipped-tests
// eslint-disable-next-line mocha/no-pending-tests
describe.skip('getLatestContent', function () {
// TODO: remove this endpoint entirely, see
// https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862
@@ -647,6 +647,68 @@ describe('backup script', function () {
expect(newBackupStatus.backupStatus.lastBackedUpVersion).to.equal(50) // backup fails on final chunk
expect(newBackupStatus.currentEndVersion).to.equal(54) // backup is incomplete due to missing blob
})
it('can recover zip file from backup in raw mode', async function () {
// First, run backup so data is available
await runBackupScript(['--projectId', projectId])
const zipPath = `/tmp/test-recover-raw-${historyId}.zip`
try {
await runRecoverZipFromBackupScript([
'--historyId',
historyId,
'--output',
zipPath,
'--mode=raw',
])
// Verify the zip file is valid
const { stdout } = await promisify(execFile)('unzip', ['-l', zipPath], {
encoding: 'utf-8',
})
// Raw mode includes chunk and blob keys
// Verify chunks are present
expect(stdout).to.include('chunk.json')
// Verify blob hashes are present (hashes are stored as {hash[0:2]}/{hash[2:]})
expect(stdout).to.include(testFiles.GRAPH_PNG_HASH.slice(2))
expect(stdout).to.include(testFiles.NON_BMP_TXT_HASH.slice(2))
} finally {
await fs.promises.unlink(zipPath).catch(() => {})
}
})
it('can recover zip file from backup in latest mode', async function () {
// First, run backup so data is available
await runBackupScript(['--projectId', projectId])
const zipPath = `/tmp/test-recover-latest-${historyId}.zip`
try {
await runRecoverZipFromBackupScript([
'--historyId',
historyId,
'--output',
zipPath,
'--mode=latest',
])
// Verify the zip file is valid
const { stdout } = await promisify(execFile)('unzip', ['-l', zipPath], {
encoding: 'utf-8',
})
// Latest mode includes the project files
expect(stdout).to.include('main.tex')
expect(stdout).to.include('chapter1.tex')
expect(stdout).to.include('chapter2.tex')
expect(stdout).to.include('bibliography.bib')
expect(stdout).to.include('graph.png')
expect(stdout).to.include('unicodeFile.tex')
} finally {
await fs.promises.unlink(zipPath).catch(() => {})
}
})
})
})
@@ -683,3 +745,37 @@ async function runBackupScript(args) {
}
return result
}
/**
* Run the recover_zip_from_backup script with given arguments
* @param {string[]} args
*/
async function runRecoverZipFromBackupScript(args) {
const TIMEOUT = 30 * 1000
let result
try {
result = await promisify(execFile)(
'node',
['storage/scripts/recover_zip_from_backup.mjs', ...args],
{
encoding: 'utf-8',
timeout: TIMEOUT,
env: {
...process.env,
LOG_LEVEL: 'debug',
},
}
)
result.status = 0
} catch (err) {
const { stdout, stderr, code } = err
if (typeof code !== 'number') {
console.log(err)
}
result = { stdout, stderr, status: code }
}
if (result.status !== 0) {
throw new Error(`recover_zip_from_backup failed: ${result.stderr}`)
}
return result
}
@@ -0,0 +1,205 @@
'use strict'
const { promisify } = require('node:util')
const { execFile } = require('node:child_process')
const { expect } = require('chai')
const {
Change,
AddFileOperation,
EditFileOperation,
TextOperation,
File,
} = require('overleaf-editor-core')
const cleanup = require('./support/cleanup')
const fixtures = require('./support/fixtures')
const { setupProjectState } = require('./support/redis')
const chunkStore = require('../../../../storage/lib/chunk_store')
const persistChanges = require('../../../../storage/lib/persist_changes')
const knex = require('../../../../storage/lib/knex')
const SCRIPT_PATH = 'storage/scripts/finalise_chunk.mjs'
async function runScript(args) {
try {
const result = await promisify(execFile)('node', [SCRIPT_PATH, ...args], {
encoding: 'utf-8',
timeout: 10000,
env: { ...process.env, LOG_LEVEL: 'debug' },
})
return { ...result, status: 0 }
} catch (err) {
if (typeof err.code !== 'number') {
throw err
}
return { stdout: err.stdout, stderr: err.stderr, status: err.code }
}
}
async function getChunkRows(projectId) {
return await knex('chunks')
.select('id', 'start_version', 'end_version', 'closed')
.where('doc_id', parseInt(projectId, 10))
.orderBy('end_version')
}
describe('finalise_chunk script', function () {
before(cleanup.everything)
let limitsToPersistImmediately
before(function () {
const farFuture = new Date()
farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
limitsToPersistImmediately = {
minChangeTimestamp: farFuture,
maxChangeTimestamp: farFuture,
maxChunkChanges: 100,
}
})
beforeEach(async function () {
await cleanup.everything()
await fixtures.create()
})
describe('with a populated current chunk', function () {
let projectId
let initialContent
let initialEndVersion
let initialChunkId
beforeEach(async function () {
projectId = await chunkStore.initializeProject()
initialContent = 'Hello world.'
const change1 = new Change(
[new AddFileOperation('main.tex', File.fromString(initialContent))],
new Date(Date.now() - 30000),
[]
)
const change2 = new Change(
[
new EditFileOperation(
'main.tex',
new TextOperation().retain(initialContent.length).insert(' More.')
),
],
new Date(Date.now() - 20000),
[]
)
await persistChanges(
projectId,
[change1, change2],
limitsToPersistImmediately,
0
)
const metadata = await chunkStore.getLatestChunkMetadata(projectId)
initialEndVersion = metadata.endVersion
initialChunkId = metadata.id
expect(initialEndVersion).to.equal(2)
})
it('closes the current chunk and creates a new empty chunk', async function () {
const result = await runScript(['--historyId', projectId])
expect(result.status, result.stderr).to.equal(0)
const rows = await getChunkRows(projectId)
expect(rows).to.have.length(2)
const oldRow = rows.find(r => r.id.toString() === initialChunkId)
expect(oldRow, 'old chunk still in DB').to.exist
expect(oldRow.closed).to.equal(true)
expect(oldRow.end_version).to.equal(initialEndVersion)
const newRow = rows.find(r => r.id.toString() !== initialChunkId)
expect(newRow, 'new chunk inserted').to.exist
expect(newRow.start_version).to.equal(initialEndVersion)
expect(newRow.end_version).to.equal(initialEndVersion + 1)
expect(newRow.closed).to.equal(false)
const newChunk = await chunkStore.loadLatest(projectId, {
persistedOnly: true,
})
expect(newChunk.getStartVersion()).to.equal(initialEndVersion)
expect(newChunk.getEndVersion()).to.equal(initialEndVersion + 1)
// The new chunk holds a single NoOperation change as a placeholder so
// its end_version is distinct from the closed chunk's end_version.
expect(newChunk.getChanges()).to.have.length(1)
const newSnapshot = newChunk.getSnapshot()
const file = newSnapshot.getFile('main.tex')
expect(file, 'main.tex carried over to new chunk snapshot').to.exist
})
it('does not modify state on a dry-run', async function () {
const result = await runScript(['--historyId', projectId, '--dry-run'])
expect(result.status, result.stderr).to.equal(0)
const rows = await getChunkRows(projectId)
expect(rows).to.have.length(1)
expect(rows[0].id.toString()).to.equal(initialChunkId)
expect(rows[0].closed).to.equal(false)
})
})
describe('safety checks', function () {
it('refuses to act on an already-empty current chunk', async function () {
const projectId = await chunkStore.initializeProject()
const result = await runScript(['--historyId', projectId])
expect(result.status).to.not.equal(0)
expect(result.stdout + result.stderr).to.match(/already empty/)
const rows = await getChunkRows(projectId)
expect(rows).to.have.length(1)
expect(rows[0].start_version).to.equal(0)
expect(rows[0].end_version).to.equal(0)
expect(rows[0].closed).to.equal(false)
})
it('refuses when there are non-persisted changes in redis', async function () {
const projectId = await chunkStore.initializeProject()
const initialContent = 'Initial.'
const initialChange = new Change(
[new AddFileOperation('main.tex', File.fromString(initialContent))],
new Date(Date.now() - 30000),
[]
)
await persistChanges(
projectId,
[initialChange],
limitsToPersistImmediately,
0
)
const metadata = await chunkStore.getLatestChunkMetadata(projectId)
const initialChunkId = metadata.id
const bufferedChange = new Change(
[
new EditFileOperation(
'main.tex',
new TextOperation()
.retain(initialContent.length)
.insert(' Buffered.')
),
],
new Date(Date.now() - 10000),
[]
)
await setupProjectState(projectId, {
persistTime: Date.now() - 1000,
headVersion: 2,
persistedVersion: 1,
changes: [bufferedChange],
expireTimeFuture: true,
})
const result = await runScript(['--historyId', projectId])
expect(result.status).to.not.equal(0)
expect(result.stdout + result.stderr).to.match(/non-persisted/)
const rows = await getChunkRows(projectId)
expect(rows).to.have.length(1)
expect(rows[0].id.toString()).to.equal(initialChunkId)
expect(rows[0].closed).to.equal(false)
})
})
})
@@ -0,0 +1,169 @@
import { expect } from 'chai'
import config from 'config'
import { execFile } from 'node:child_process'
import fs from 'node:fs'
import { promisify } from 'node:util'
import { Change, Operation, File, TextOperation } from 'overleaf-editor-core'
// We depend on this via object-persistor.
// eslint-disable-next-line import/no-extraneous-dependencies
import { Storage } from '@google-cloud/storage'
import {
loadGlobalBlobs,
BlobStore,
} from '../../../../storage/lib/blob_store/index.js'
import ChunkStore from '../../../../storage/lib/chunk_store/index.js'
import persistChanges from '../../../../storage/lib/persist_changes.js'
import testFiles from '../storage/support/test_files.js'
import cleanup from './support/cleanup.js'
import { getZipEntries } from './support/unzip.js'
describe('recover_zip script', function () {
let projectId
let limitsToPersistImmediately
before(async function () {
const farFuture = new Date()
farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
limitsToPersistImmediately = {
minChangeTimestamp: farFuture,
maxChangeTimestamp: farFuture,
maxChanges: 10,
maxChunkChanges: 10,
}
const gcsEndpoint = config.get('persistor.gcs.endpoint')
const storage = new Storage({
apiEndpoint: gcsEndpoint.apiEndpoint,
projectId: gcsEndpoint.projectId,
})
const bucketName = config.get('zipStore.bucket')
try {
const [exists] = await storage.bucket(bucketName).exists()
if (!exists) {
await storage.createBucket(bucketName)
}
} catch (err) {
if (err.code !== 409) throw err
}
})
beforeEach(cleanup.everything)
beforeEach(async function () {
await loadGlobalBlobs()
projectId = '123'
// Initialize the project in the chunk store
await ChunkStore.initializeProject(projectId)
const blobStore = new BlobStore(projectId)
// Upload binary file blob
await blobStore.putFile(testFiles.path('graph.png'))
// Create initial snapshot with text and binary files
const addMainTex = Operation.addFile(
'main.tex',
File.fromString('hello world')
)
const addGraphPng = Operation.addFile(
'graph.png',
File.fromHash(testFiles.GRAPH_PNG_HASH)
)
const change1 = new Change([addMainTex, addGraphPng], new Date(), [])
await persistChanges(projectId, [change1], limitsToPersistImmediately, 0)
// Add a text edit
const textOp = TextOperation.fromJSON({
textOperation: ['hello world'.length, ' more'],
})
const editOp = Operation.editFile('main.tex', textOp)
const change2 = new Change([editOp], new Date(), [])
await persistChanges(projectId, [change2], limitsToPersistImmediately, 1)
})
it('creates a valid zip from GCS data', async function () {
this.timeout(30 * 1000)
const zipPath = `/tmp/test-recover-zip-${projectId}.zip`
try {
const { stdout } = await runRecoverZipScript([projectId])
// The script logs the signed URL to stdout
const urlMatch = stdout.match(/(https?:\/\/[^\s]+)/)
expect(urlMatch).to.not.be.null
const signedUrl = urlMatch[1]
// Download the zip via fetch
const res = await fetch(signedUrl)
expect(res.ok).to.be.true
const buffer = await res.arrayBuffer()
await fs.promises.writeFile(zipPath, Buffer.from(buffer))
const zipEntries = await getZipEntries(zipPath)
const fileNames = zipEntries.map(e => e.fileName).sort()
expect(fileNames).to.deep.equal(['graph.png', 'main.tex'])
// Verify text content size (after edit)
const mainTexEntry = zipEntries.find(e => e.fileName === 'main.tex')
expect(mainTexEntry.uncompressedSize).to.equal('hello world more'.length)
// Verify binary content size
const graphEntry = zipEntries.find(e => e.fileName === 'graph.png')
expect(graphEntry.uncompressedSize).to.equal(
testFiles.GRAPH_PNG_BYTE_LENGTH
)
} finally {
await fs.promises.unlink(zipPath).catch(() => {})
}
})
it('supports the --verbose flag', async function () {
this.timeout(30 * 1000)
const { stdout } = await runRecoverZipScript(['--verbose', projectId])
// Verbose mode logs each file as it's added
expect(stdout).to.include('main.tex added')
expect(stdout).to.include('graph.png added')
})
})
/**
* Run the recover_zip.js script with given arguments
* @param {string[]} args
*/
async function runRecoverZipScript(args) {
const TIMEOUT = 30 * 1000
let result
try {
result = await promisify(execFile)(
'node',
['storage/scripts/recover_zip.js', ...args],
{
encoding: 'utf-8',
timeout: TIMEOUT,
env: {
...process.env,
LOG_LEVEL: 'debug',
},
}
)
result.status = 0
} catch (err) {
const { stdout, stderr, code } = err
if (typeof code !== 'number') {
console.log(err)
}
result = { stdout, stderr, status: code }
}
if (result.status !== 0 || result.stderr) {
throw new Error(
`recover_zip failed (exit ${result.status}):\n` +
`stdout: ${result.stdout}\n` +
`stderr: ${result.stderr}`
)
}
return result
}
+6 -2
View File
@@ -55,6 +55,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -67,8 +71,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
-2
View File
@@ -9,7 +9,6 @@ import express, {
type ErrorRequestHandler,
type NextFunction,
} from 'express'
import methodOverride from 'method-override'
import { mongoClient } from './app/js/mongodb.js'
import NotificationsController from './app/js/NotificationsController.ts'
import HealthCheckController from './app/js/HealthCheckController.ts'
@@ -25,7 +24,6 @@ logger.initialize('notifications')
metrics.memory.monitor(logger)
metrics.open_sockets.monitor()
app.use(methodOverride())
app.use(express.json())
app.use(metrics.http.monitor(logger))
+1
View File
@@ -1,5 +1,6 @@
notifications
--dependencies=mongo
--deploy-pipeline=notifications
--env-add=
--env-pass-through=
--esmock-loader=False
-2
View File
@@ -30,14 +30,12 @@
"body-parser": "1.20.4",
"bunyan": "^1.8.15",
"express": "4.22.1",
"method-override": "^3.0.0",
"mongodb-legacy": "6.1.3",
"zod": "^4.1.7",
"zod-validation-error": "^4.0.1"
},
"devDependencies": {
"@overleaf/migrations": "workspace:*",
"@types/method-override": "^3.0.0",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"mocha": "^11.1.0",
-1
View File
@@ -1 +0,0 @@
app/lib/*.js
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -2
View File
@@ -58,6 +58,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -70,8 +74,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
+1
View File
@@ -1,5 +1,6 @@
project-history
--dependencies=mongo,redis
--deploy-pipeline=project-history
--env-add=
--env-pass-through=
--esmock-loader=True
@@ -1,5 +1,3 @@
/* eslint-env mongo */
// add a TTL index to expire entries for completed resyncs in the
// projectHistorySyncState collection. The entries should only be expired if
// resyncProjectStructure is false and resyncDocContents is a zero-length array.
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+6 -2
View File
@@ -54,6 +54,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
@@ -66,8 +70,8 @@ clean:
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/real-time/reports:/overleaf/services/real-time/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/real-time/reports:/overleaf/services/real-time/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/real-time/reports:/overleaf/services/real-time/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/real-time/reports:/overleaf/services/real-time/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
-12
View File
@@ -1,12 +0,0 @@
# NOTE: changing paths may require updating them in the Makefile too.
data/
scripts/translations/.cache/
node_modules
frontend/js/vendor
modules/**/frontend/js/vendor
/public/
frontend/js/features/source-editor/lezer-latex/latex.mjs
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs
-665
View File
@@ -1,665 +0,0 @@
const _ = require('lodash')
const confusingBrowserGlobals = require('confusing-browser-globals')
const globals = require('globals')
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'standard',
'prettier',
'plugin:storybook/recommended',
],
plugins: ['@overleaf'],
env: {
es2020: true,
},
settings: {
// Tell eslint-plugin-react to detect which version of React we are using
react: {
version: 'detect',
},
},
rules: {
'no-constant-binary-expression': 'error',
'no-restricted-globals': ['error', ...confusingBrowserGlobals],
// do not allow importing of implicit dependencies.
'import/no-extraneous-dependencies': 'error',
'@overleaf/prefer-kebab-url': 'error',
'@overleaf/require-cio-snake-case-properties': 'error',
// disable some TypeScript rules
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': [
'error',
{ functions: false, classes: false, variables: false },
],
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useCommandProvider)',
},
],
},
overrides: [
// NOTE: changing paths may require updating them in the Makefile too.
{
// Node
files: [
'**/app/src/**/*.{js,mjs}',
'app.{js,mjs}',
'i18next-scanner.config.js',
'scripts/**/*.{js,mjs}',
'webpack.config*.js',
],
env: {
node: true,
},
},
{
// Test specific rules
files: ['**/test/**/*.*'],
excludedFiles: [
'**/test/unit/src/**/*.test.mjs',
'test/unit/bootstrap.mjs',
], // exclude vitest files
plugins: ['mocha', 'chai-expect', 'chai-friendly'],
env: {
mocha: true,
},
rules: {
// mocha-specific rules
'mocha/handle-done-callback': 'error',
'mocha/no-exclusive-tests': 'error',
'mocha/no-global-tests': 'error',
'mocha/no-identical-title': 'error',
'mocha/no-nested-tests': 'error',
'mocha/no-pending-tests': 'error',
'mocha/no-skipped-tests': 'error',
'mocha/no-mocha-arrows': 'error',
// Swap the no-unused-expressions rule with a more chai-friendly one
'no-unused-expressions': 'off',
'chai-friendly/no-unused-expressions': 'error',
// chai-specific rules
'chai-expect/missing-assertion': 'error',
'chai-expect/terminating-properties': 'error',
// prefer-arrow-callback applies to all callbacks, not just ones in mocha tests.
// we don't enforce this at the top-level - just in tests to manage `this` scope
// based on mocha's context mechanism
'mocha/prefer-arrow-callback': 'error',
'@typescript-eslint/no-unused-expressions': 'off',
},
},
{
files: ['**/test/unit/src/**/*.test.mjs', 'test/unit/bootstrap.mjs'],
env: {
jest: true, // best match for vitest API etc.
},
plugins: ['@vitest', 'chai-expect', 'chai-friendly'], // still using chai for now
rules: {
// vitest-specific rules
'@vitest/no-focused-tests': 'error',
'@vitest/no-disabled-tests': 'error',
// Swap the no-unused-expressions rule with a more chai-friendly one
'no-unused-expressions': 'off',
'chai-friendly/no-unused-expressions': 'error',
// chai-specific rules
'chai-expect/missing-assertion': 'error',
'chai-expect/terminating-properties': 'error',
'@typescript-eslint/no-unused-expressions': 'off',
'@overleaf/require-vi-doMock-valid-path': 'error',
},
},
{
// ES specific rules
files: [
'**/app/src/**/*.mjs',
'modules/*/index.mjs',
'app.mjs',
'scripts/**/*.mjs',
'migrations/**/*.mjs',
'**/test/acceptance/src/**/*.mjs',
'**/test/unit/src/**/*.mjs',
],
excludedFiles: [
// migration template file
'migrations/lib/template.mjs',
],
parserOptions: {
sourceType: 'module',
},
plugins: ['unicorn'],
rules: {
'import/no-unresolved': [
'error',
{
// eslint-plugin-import does not support exports directive in package.json
// https://github.com/import-js/eslint-plugin-import/issues/1810
ignore: ['^p-queue$'],
},
],
'import/named': 'error',
'import/default': 'error',
'import/extensions': [
'error',
'ignorePackages',
{
js: 'always',
mjs: 'always',
},
],
'unicorn/prefer-module': 'error',
'unicorn/prefer-node-protocol': 'error',
},
},
{
// Backend specific rules
files: ['**/app/src/**/*.{js,mjs}', 'app.{js,mjs}'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.backend.json',
},
rules: {
// do not allow importing of implicit dependencies.
'import/no-extraneous-dependencies': [
'error',
{
// do not allow importing of devDependencies.
devDependencies: false,
},
],
'no-restricted-syntax': [
'error',
// do not allow node-fetch in backend code
{
selector:
"CallExpression[callee.name='require'] > .arguments[value='node-fetch']",
message:
'Requiring node-fetch is not allowed in production services, please use fetch-utils.',
},
// mongoose populate must set fields to populate
{
selector:
"CallExpression[callee.property.name='populate'][arguments.length<2]",
message:
"Populate without a second argument returns the whole document. Use populate('field',['prop1','prop2']) instead",
},
// Require `new` when constructing ObjectId (For mongo + mongoose upgrade)
{
selector:
"CallExpression[callee.name='ObjectId'], CallExpression[callee.property.name='ObjectId']",
message:
'Construct ObjectId with `new ObjectId()` instead of `ObjectId()`',
},
// Require `new` when mapping a list of ids to a list of ObjectId (For mongo + mongoose upgrade)
{
selector:
"CallExpression[callee.property.name='map'] Identifier[name='ObjectId']:first-child, CallExpression[callee.property.name='map'] MemberExpression[property.name='ObjectId']:first-child",
message:
"Don't map ObjectId directly. Use `id => new ObjectId(id)` instead",
},
// Catch incorrect usage of `await db.collection.find()`
{
selector:
"AwaitExpression > CallExpression > MemberExpression[property.name='find'][object.object.name='db']",
message:
'Mongo find returns a cursor not a promise, use `for await (const result of cursor)` or `.toArray()` instead.',
},
],
'@typescript-eslint/no-floating-promises': [
'error',
{ checkThenables: true },
],
},
},
{
// Backend scripts specific rules
files: ['**/scripts/**/*.{js,mjs}'],
rules: {
'no-restricted-syntax': [
'error',
// Require `new` when constructing ObjectId (For mongo + mongoose upgrade)
{
selector:
"CallExpression[callee.name='ObjectId'], CallExpression[callee.property.name='ObjectId']",
message:
'Construct ObjectId with `new ObjectId()` instead of `ObjectId()`',
},
// Require `new` when mapping a list of ids to a list of ObjectId (For mongo + mongoose upgrade)
{
selector:
"CallExpression[callee.property.name='map'] Identifier[name='ObjectId']:first-child, CallExpression[callee.property.name='map'] MemberExpression[property.name='ObjectId']:first-child",
message:
"Don't map ObjectId directly. Use `id => new ObjectId(id)` instead",
},
// Catch incorrect usage of `await db.collection.find()`
{
selector:
"AwaitExpression > CallExpression > MemberExpression[property.name='find'][object.object.name='db']",
message:
'Mongo find returns a cursor not a promise, use `for await (const result of cursor)` or `.toArray()` instead.',
},
],
},
},
{
// Insist on using Script Runner for new scripts. Old scripts should be
// converted to use Script Runner in future, but are excluded for now
rules: {
'@overleaf/require-script-runner': 'error',
},
files: ['**/scripts/**/*.mjs'], // ESM only
excludedFiles: [
'modules/admin-roles/scripts/import_admin_role_assignments.mjs',
'modules/admin-roles/scripts/remove_admin_role_from_user.mjs',
'modules/admin-roles/scripts/remove_admin_roles_from_non_admins.mjs',
'modules/admin-roles/scripts/utils.mjs',
'modules/institutions/scripts/apply_policy_to_institution.mjs',
'modules/server-ce-scripts/scripts/change-compile-timeout.mjs',
'modules/server-ce-scripts/scripts/check-mongodb.mjs',
'modules/server-ce-scripts/scripts/check-redis.mjs',
'modules/server-ce-scripts/scripts/check-texlive-images.mjs',
'modules/server-ce-scripts/scripts/create-user.mjs',
'modules/server-ce-scripts/scripts/delete-user.mjs',
'modules/server-ce-scripts/scripts/export-user-projects.mjs',
'modules/server-ce-scripts/scripts/migrate-user-emails.mjs',
'modules/server-ce-scripts/scripts/rename-tag.mjs',
'modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs',
'modules/server-ce-scripts/scripts/upgrade-user-features.mjs',
'modules/subscriptions/scripts/backfill_user_last_trial.mjs',
'scripts/add_feature_override.mjs',
'scripts/add_subscription_members_csv.mjs',
'scripts/analytics/helpers/GoogleBigQueryHelper.mjs',
'scripts/attach_dangling_comments_to_doc.mjs',
'scripts/backfill_mixpanel_user_properties.mjs',
'scripts/backfill_project_image_name.mjs',
'scripts/backfill_user_properties.mjs',
'scripts/backfill_users_sso_attribute.mjs',
'scripts/bench_bcrypt.mjs',
'scripts/check_institution_users.mjs',
'scripts/check_overleafModuleImports.mjs',
'scripts/check_saml_emails.mjs',
'scripts/clear_feedback_collection.mjs',
'scripts/clear_sessions_set_must_reconfirm.mjs',
'scripts/count_files_in_projects.mjs',
'scripts/count_project_size.mjs',
'scripts/create_oauth_personal_access_token.mjs',
'scripts/create_project.mjs',
'scripts/deactivate_projects.mjs',
'scripts/delete-duplicate-splittest-versions/delete_test_dupes.mjs',
'scripts/delete-orphaned-docs/delete-orphaned-docs.mjs',
'scripts/delete_dangling_comments.mjs',
'scripts/delete_orphaned_chat_threads.mjs',
'scripts/delete_orphaned_data_helper.mjs',
'scripts/delete_subscriptions.mjs',
'scripts/devcontainer_setup.mjs',
'scripts/e2e_test_setup.mjs',
'scripts/ensure_affiliations.mjs',
'scripts/esm-check-migration.mjs',
'scripts/example/script_for_migration.mjs',
'scripts/fix_collaborator_refs_null.mjs',
'scripts/fix_comment_id.mjs',
'scripts/helpers/chunkArray.mjs',
'scripts/helpers/env_variable_helper.mjs',
'scripts/inst_table.mjs',
'scripts/invalidate_tokens.mjs',
'scripts/ip_matcher_ranges.mjs',
'scripts/learn/checkSanitize/checkSanitizeOptions.mjs',
'scripts/learn/checkSanitize/scrape.mjs',
'scripts/lezer-latex/benchmark.mjs',
'scripts/lezer-latex/print-tree.mjs',
'scripts/lezer-latex/random.mjs',
'scripts/lezer-latex/run.mjs',
'scripts/lezer-latex/test-incremental-parser.mjs',
'scripts/mark_migration.mjs',
'scripts/marketing-exports/error-assistant-export.mjs',
'scripts/marketing-exports/export.mjs',
'scripts/marketing-exports/linked-papers-users.mjs',
'scripts/marketing-exports/papers-export.mjs',
'scripts/marketing-exports/writefull-export.mjs',
'scripts/oauth/upgrade_token_scopes.mjs',
'scripts/plan-prices/plans.mjs',
'scripts/process_lapsed_reconfirmations.mjs',
'scripts/purge_non_logged_in_sessions.mjs',
'scripts/recurly/generate_recurly_prices.mjs',
'scripts/recurly/get_paypal_accounts_csv.mjs',
'scripts/recurly/recurly_prices.mjs',
'scripts/recurly/resync_recurly_state_single_subscription.mjs',
'scripts/recurly/resync_subscriptions.mjs',
'scripts/recurly/set_manually_collected_subscriptions.mjs',
'scripts/refresh_features.mjs',
'scripts/regenerate_duplicate_referral_ids.mjs',
'scripts/remove_deleted_users_from_token_access_refs.mjs',
'scripts/remove_email.mjs',
'scripts/remove_user_enrollment.mjs',
'scripts/sso_id_migration_check.mjs',
'scripts/stress_test.mjs',
'scripts/suspend_users.mjs',
'scripts/sync-user-entitlements/sync-user-entitlements.mjs',
'scripts/update_project_image_name.mjs',
'scripts/user-export/analytics.mjs',
'scripts/user-export/fs.mjs',
'scripts/user-export/http.mjs',
'scripts/user-export/observer.mjs',
'scripts/user-export/options.mjs',
'scripts/user-export/project.mjs',
'scripts/user-export/scrubber.mjs',
'scripts/user-export/stream.mjs',
'scripts/user-export/user.mjs',
'scripts/validate-data-of-model.mjs',
],
},
{
// Cypress specific rules
files: [
'cypress/**/*.{js,jsx,ts,tsx}',
'**/test/frontend/**/*.spec.{js,jsx,ts,tsx}',
],
extends: ['plugin:cypress/recommended'],
},
{
// Frontend test specific rules
files: ['**/frontend/**/*.test.{js,jsx,ts,tsx}'],
plugins: ['testing-library'],
extends: ['plugin:testing-library/react'],
rules: {
'testing-library/no-await-sync-events': 'off',
'testing-library/no-await-sync-queries': 'off',
'testing-library/no-container': 'off',
'testing-library/no-node-access': 'off',
'testing-library/no-render-in-lifecycle': 'off',
'testing-library/no-wait-for-multiple-assertions': 'off',
'testing-library/no-wait-for-side-effects': 'off',
'testing-library/prefer-query-by-disappearance': 'off',
'testing-library/prefer-screen-queries': 'off',
'testing-library/render-result-naming-convention': 'off',
},
},
{
// Frontend specific rules
files: [
'**/frontend/js/**/*.{js,jsx,ts,tsx}',
'**/frontend/stories/**/*.{js,jsx,ts,tsx}',
'**/*.stories.{js,jsx,ts,tsx}',
'**/test/frontend/**/*.{js,jsx,ts,tsx}',
'**/test/frontend/components/**/*.spec.{js,jsx,ts,tsx}',
],
env: {
browser: true,
},
parserOptions: {
sourceType: 'module',
},
plugins: ['jsx-a11y'],
extends: [
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'standard-jsx',
'prettier',
],
globals: {
__webpack_public_path__: true,
$: true,
ga: true,
},
rules: {
// TODO: remove once https://github.com/standard/eslint-config-standard-react/issues/68 (support eslint@8) is fixed.
// START: inline standard-react rules
// "react/jsx-no-bind": ["error", {
// "allowArrowFunctions": true,
// "allowBind": false,
// "ignoreRefs": true
// },],
'react/no-did-update-set-state': 'error',
'react/no-unused-prop-types': 'error',
'react/prop-types': 'error',
'@overleaf/no-generated-editor-themes': 'error',
// "react/react-in-jsx-scope": "error",
// END: inline standard-react rules
'react/no-unknown-property': [
'error',
{
ignore: ['dnd-container', 'dropdown-toggle'],
},
],
'react/jsx-no-target-blank': [
'error',
{
allowReferrer: true,
},
],
// Prevent usage of legacy string refs
'react/no-string-refs': 'error',
// Prevent curly braces around strings (as they're unnecessary)
'react/jsx-curly-brace-presence': [
'error',
{
props: 'never',
children: 'never',
},
],
// Don't import React for JSX; the JSX runtime is added by a Babel plugin
'react/react-in-jsx-scope': 'off',
'react/jsx-uses-react': 'off',
// Allow functions as JSX props
'react/jsx-no-bind': 'off', // TODO: fix occurrences and re-enable this
// Fix conflict between prettier & standard by overriding to prefer
// double quotes
'jsx-quotes': ['error', 'prefer-double'],
// Override weird behaviour of jsx-a11y label-has-for (says labels must be
// nested *and* have for/id attributes)
'jsx-a11y/label-has-for': [
'error',
{
required: {
some: ['nesting', 'id'],
},
},
],
// Require .jsx or .tsx file extension when using JSX
'react/jsx-filename-extension': [
'error',
{
extensions: ['.jsx', '.tsx'],
},
],
'no-restricted-syntax': [
'error',
// prohibit direct calls to methods of window.localStorage
{
selector:
"CallExpression[callee.object.object.name='window'][callee.object.property.name='localStorage']",
message:
'Modify location via customLocalStorage instead of calling window.localStorage methods directly',
},
],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
ignoreRestSiblings: false,
caughtErrors: 'none',
vars: 'all',
varsIgnorePattern: '^_',
},
],
},
},
{
// Sorting for Meta
files: ['frontend/js/utils/meta.ts'],
rules: {
'@typescript-eslint/member-ordering': [
'error',
{ interfaces: { order: 'alphabetically' } },
],
},
},
{
// React component specific rules
//
files: [
'**/frontend/js/**/components/**/*.{js,jsx,ts,tsx}',
'**/frontend/js/**/hooks/**/*.{js,jsx,ts,tsx}',
],
rules: {
'@overleaf/no-unnecessary-trans': 'error',
'@overleaf/should-unescape-trans': 'error',
'@overleaf/require-loading-label': 'error',
// https://astexplorer.net/
'no-restricted-syntax': [
'error',
// prohibit direct calls to methods of window.location
{
selector:
"CallExpression[callee.object.object.name='window'][callee.object.property.name='location']",
message:
'Modify location via useLocation instead of calling window.location methods directly',
},
// prohibit assignment to window.location
{
selector:
"AssignmentExpression[left.object.name='window'][left.property.name='location']",
message:
'Modify location via useLocation instead of calling window.location methods directly',
},
// prohibit assignment to window.location.href
{
selector:
"AssignmentExpression[left.object.object.name='window'][left.object.property.name='location'][left.property.name='href']",
message:
'Modify location via useLocation instead of calling window.location methods directly',
},
// prohibit using lookbehinds due to incidents with Safari simply crashing when the script is parsed
{
selector: 'Literal[regex.pattern=/\\(\\?<[!=]/]',
message: 'Lookbehind is not supported in older Safari versions.',
},
// prohibit direct calls to methods of window.localStorage
// NOTE: this rule is also defined for all frontend files, but those rules are overriden by the React component-specific config
{
selector:
"CallExpression[callee.object.object.name='window'][callee.object.property.name='localStorage']",
message:
'Modify location via customLocalStorage instead of calling window.localStorage methods directly',
},
],
},
},
// React + TypeScript-specific rules
{
files: ['**/*.tsx'],
rules: {
'react/prop-types': 'off',
'no-undef': 'off',
},
},
// TypeScript-specific rules
{
files: ['**/*.ts'],
rules: {
'no-undef': 'off',
},
},
// JavaScript-specific rules
{
files: ['**/*.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
files: ['scripts/ukamf/*.js'],
rules: {
// Do not allow importing of any dependencies unless specified in either
// - web/package.json
// - web/scripts/ukamf/package.json
'import/no-extraneous-dependencies': [
'error',
{ packageDir: ['.', 'scripts/ukamf'] },
],
},
},
{
files: ['scripts/learn/checkSanitize/*.js'],
rules: {
// The checkSanitize script is used in the dev-env only.
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
packageDir: ['.', '../../'],
},
],
},
},
{
files: [
// Backend: Use @overleaf/logger
// Docs: https://manual.dev-overleaf.com/development/code/logging/#structured-logging
'**/app/**/*.{js,cjs,mjs}',
'app.{js,mjs}',
'modules/*/*.{js,mjs}',
// Frontend: Prefer debugConsole over bare console
// Docs: https://manual.dev-overleaf.com/development/code/logging/#frontend
'**/frontend/**/*.{js,jsx,ts,tsx}',
// Tests
'**/test/**/*.{js,cjs,mjs,jsx,ts,tsx}',
],
excludedFiles: [
// Allow console logs in scripts
'**/scripts/**/*.js',
// Allow console logs in stories
'**/stories/**/*.{js,jsx,ts,tsx}',
// Workers do not have access to the search params for enabling ?debug=true.
// self.location.url is the URL of the worker script.
'*.worker.{js,ts}',
],
rules: {
'no-console': 'error',
},
},
{
files: ['**/*.worker.{js,ts}'],
rules: {
'no-restricted-globals': [
'error',
..._.difference(
Object.keys({ ...globals.browser, ...globals.node }),
Object.keys(globals.worker)
),
],
},
},
],
}
+2 -1
View File
@@ -26,11 +26,12 @@ cypress/results/
# Ace themes for conversion
frontend/js/features/source-editor/themes/ace/
# Compiled parser files
# Compiled parser files (latex/bibtex are generated by webpack plugin at build time)
frontend/js/features/source-editor/lezer-latex/latex.mjs
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
# typst compiled files are committed (generated via node scripts/lezer-latex/generate.mjs)
!**/fixtures/**/*.log
+7 -2
View File
@@ -6,6 +6,11 @@ ifeq ($(BRANCH_NAME),)
export BRANCH_NAME := $(shell git rev-parse --abbrev-ref HEAD)
endif
BRANCH_NAME_TAG_SAFE := $(shell echo "$(BRANCH_NAME)" | sed 's/\//\-\-/g' | cut -c1-96)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
ifeq ($(COMMIT_SHA),)
export COMMIT_SHA := $(shell git rev-parse HEAD)
endif
@@ -488,13 +493,13 @@ ci:
# Run the linting commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/web/data/reports:/overleaf/services/web/data/reports --volume $(MONOREPO)/node_modules/.cache/:/overleaf/node_modules/.cache/ -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/web/data/reports:/overleaf/services/web/data/reports --volume $(MONOREPO)/node_modules/.cache/:/overleaf/node_modules/.cache/ -w /overleaf $(IMAGE_CI) yarn run --silent
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
ORG_PATH = /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN_LINT_FORMAT ?= \
docker run --rm --env BRANCH_NAME --env CI --env COMMIT_SHA --env MONOREPO --env PATH=/overleaf/node_modules/.bin:$(ORG_PATH) --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume ${PWD}/data/reports:/overleaf/services/web/data/reports --volume $(MONOREPO)/node_modules/.cache/:/overleaf/node_modules/.cache/ ${IMAGE_CI}
docker run --rm --env BRANCH_NAME --env CI --env COMMIT_SHA --env MONOREPO --env PATH=/overleaf/node_modules/.bin:$(ORG_PATH) --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume ${PWD}/data/reports:/overleaf/services/web/data/reports --volume $(MONOREPO)/node_modules/.cache/:/overleaf/node_modules/.cache/ ${IMAGE_CI}
NODE_MODULES_PATH := ${PATH}:${PWD}/node_modules/.bin:/overleaf/services/web/node_modules/.bin
WITH_NODE_MODULES_PATH = \

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