32 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
32 changed files with 648 additions and 178 deletions
+2 -3
View File
@@ -26,13 +26,12 @@ cypress/results/
# Ace themes for conversion # Ace themes for conversion
frontend/js/features/source-editor/themes/ace/ 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.mjs
frontend/js/features/source-editor/lezer-latex/latex.terms.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.mjs
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
frontend/js/features/source-editor/lezer-typst/typst.mjs # typst compiled files are committed (generated via node scripts/lezer-latex/generate.mjs)
frontend/js/features/source-editor/lezer-typst/typst.terms.mjs
!**/fixtures/**/*.log !**/fixtures/**/*.log
@@ -1,6 +1,7 @@
import { pipeline } from 'node:stream/promises' import { pipeline } from 'node:stream/promises'
import Metrics from '@overleaf/metrics' import Metrics from '@overleaf/metrics'
import ProjectGetter from '../Project/ProjectGetter.mjs' import ProjectGetter from '../Project/ProjectGetter.mjs'
import { Project } from '../../models/Project.mjs'
import CompileManager from './CompileManager.mjs' import CompileManager from './CompileManager.mjs'
import ClsiManager from './ClsiManager.mjs' import ClsiManager from './ClsiManager.mjs'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
@@ -8,6 +9,7 @@ import Settings from '@overleaf/settings'
import Errors from '../Errors/Errors.js' import Errors from '../Errors/Errors.js'
import SessionManager from '../Authentication/SessionManager.mjs' import SessionManager from '../Authentication/SessionManager.mjs'
import { userCanInstallPython } from './PythonVenvGate.mjs' import { userCanInstallPython } from './PythonVenvGate.mjs'
import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import Validation from '../../infrastructure/Validation.mjs' import Validation from '../../infrastructure/Validation.mjs'
import Path from 'node:path' import Path from 'node:path'
@@ -205,7 +207,8 @@ const _CompileController = {
// Allow building a per-project Python venv from requirements.txt only for // Allow building a per-project Python venv from requirements.txt only for
// the project owner and invited collaborators — never anonymous or // the project owner and invited collaborators — never anonymous or
// link-sharing users. // link-sharing users.
options.allowPythonInstall = await userCanInstallPython(userId, projectId) const anonToken = TokenAccessHandler.getRequestToken(req, projectId)
options.allowPythonInstall = await userCanInstallPython(userId, projectId, anonToken)
let { let {
enablePdfCaching, enablePdfCaching,
@@ -300,6 +303,26 @@ const _CompileController = {
? getOutputFilesArchiveSpecification(projectId, userId, buildId) ? getOutputFilesArchiveSpecification(projectId, userId, buildId)
: null : null
// Persist quarto output flavor so the project-list badge can distinguish
// RevealJS presentations from PDF documents without needing a compile.
// options.compiler is not sent by the frontend, so we read the stored
// compiler from the DB. Done fire-and-forget so it never delays the response.
if (status === 'success') {
const isHtml = outputFiles.some(f => f.path === 'output.html')
ProjectGetter.promises
.getProject(projectId, { compiler: 1 })
.then(project => {
if (project?.compiler !== 'quarto') return
return Project.updateOne(
{ _id: projectId },
{ quartoFlavor: isHtml ? 'revealjs' : 'pdf' }
).exec()
})
.catch(err =>
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
)
}
res.json({ res.json({
status, status,
outputFiles, outputFiles,
@@ -4,11 +4,12 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
// Whether this user may have the compiler install a project's requirements.txt // Whether this user may have the compiler install a project's requirements.txt
// into a cached venv (so Quarto's Python cells can use libraries beyond the // into a cached venv (so Quarto's Python cells can use libraries beyond the
// bundled base set). Gated to the project owner + invited collaborators (any // bundled base set). Allowed for any user who can access the project owner,
// role): ignorePublicAccess excludes link-sharing/public and anonymous users, // invited collaborators, token-link users, and public-project readers — since
// who fall back to the base Python interpreter. Returns false when the feature // the set of packages to install is already controlled by requirements.vrf
// is disabled or the privilege check fails. // (writable only by project members with write access). Returns false when the
export async function userCanInstallPython(userId, projectId) { // feature is disabled, the privilege check fails, or the user has no access.
export async function userCanInstallPython(userId, projectId, token = null) {
if (!Settings.enableProjectPythonVenv) { if (!Settings.enableProjectPythonVenv) {
return false return false
} }
@@ -17,8 +18,7 @@ export async function userCanInstallPython(userId, projectId) {
await AuthorizationManager.promises.getPrivilegeLevelForProject( await AuthorizationManager.promises.getPrivilegeLevelForProject(
userId, userId,
projectId, projectId,
null, token
{ ignorePublicAccess: true }
) )
return Boolean(privilegeLevel) return Boolean(privilegeLevel)
} catch (err) { } catch (err) {
@@ -681,7 +681,7 @@ async function _getProjects(
const results = await Promise.all([ const results = await Promise.all([
ProjectGetter.promises.findAllUsersProjects( ProjectGetter.promises.findAllUsersProjects(
userId, userId,
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler' 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler quartoFlavor'
), ),
TagsHandler.promises.getAllTags(userId), TagsHandler.promises.getAllTags(userId),
]) ])
@@ -826,6 +826,7 @@ function _formatProjectInfo(project, accessLevel, source, userId) {
archived, archived,
trashed, trashed,
compiler: project.compiler, compiler: project.compiler,
quartoFlavor: project.quartoFlavor,
} }
} }
@@ -880,6 +881,7 @@ async function _injectProjectUsers(projects) {
: users[project.owner_ref.toString()], : users[project.owner_ref.toString()],
owner_ref: undefined, owner_ref: undefined,
compiler: project.compiler, compiler: project.compiler,
quartoFlavor: project.quartoFlavor,
})) }))
} }
+1
View File
@@ -38,6 +38,7 @@ export const ProjectSchema = new Schema(
version: { type: Number }, // incremented for every change in the project structure (folders and filenames) version: { type: Number }, // incremented for every change in the project structure (folders and filenames)
publicAccesLevel: { type: String, default: 'private' }, publicAccesLevel: { type: String, default: 'private' },
compiler: { type: String, default: settings.defaultLatexCompiler }, compiler: { type: String, default: settings.defaultLatexCompiler },
quartoFlavor: { type: String, enum: ['revealjs', 'pdf'] },
spellCheckLanguage: { type: String, default: 'en' }, spellCheckLanguage: { type: String, default: 'en' },
deletedByExternalDataSource: { type: Boolean, default: false }, deletedByExternalDataSource: { type: Boolean, default: false },
description: { type: String, default: '' }, description: { type: String, default: '' },
@@ -8,6 +8,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
import FileTreeActionButton from './file-tree-action-button' import FileTreeActionButton from './file-tree-action-button'
import { useRailContext } from '../../ide-react/context/rail-context' import { useRailContext } from '../../ide-react/context/rail-context'
import PythonRequirementsModal from './python-requirements-modal' import PythonRequirementsModal from './python-requirements-modal'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
export default function FileTreeActionButtons({ export default function FileTreeActionButtons({
fileTreeExpanded, fileTreeExpanded,
@@ -19,6 +20,8 @@ export default function FileTreeActionButtons({
const { write } = usePermissionsContext() const { write } = usePermissionsContext()
const { handlePaneCollapse } = useRailContext() const { handlePaneCollapse } = useRailContext()
const [showPythonModal, setShowPythonModal] = useState(false) const [showPythonModal, setShowPythonModal] = useState(false)
const { compiler } = useProjectSettingsContext()
const isQuarto = compiler === 'quarto'
const { const {
canCreate, canCreate,
@@ -112,7 +115,7 @@ export default function FileTreeActionButtons({
iconType="delete" iconType="delete"
/> />
)} )}
{write && ( {write && isQuarto && (
<FileTreeActionButton <FileTreeActionButton
id="python-packages" id="python-packages"
description={t('python_packages')} description={t('python_packages')}
@@ -4,12 +4,21 @@ import { ProjectCompiler } from '../../../../../../../types/project-settings'
// Map the stored compiler engine to the document format the project produces. // Map the stored compiler engine to the document format the project produces.
// CLSI dispatches the real engine from the root file's extension, but the // CLSI dispatches the real engine from the root file's extension, but the
// compiler field is a faithful, cheap proxy for the project's format. // compiler field is a faithful, cheap proxy for the project's format.
function formatLabel(compiler: ProjectCompiler | undefined): { function formatLabel(
compiler: ProjectCompiler | undefined,
quartoFlavor: 'revealjs' | 'pdf' | undefined
): {
label: string label: string
variant: 'quarto' | 'typst' | 'latex' variant: 'quarto-slides' | 'quarto' | 'typst' | 'latex'
} { } {
switch (compiler) { switch (compiler) {
case 'quarto': case 'quarto':
if (quartoFlavor === 'revealjs') {
return { label: 'Quarto Slides', variant: 'quarto-slides' }
}
if (quartoFlavor === 'pdf') {
return { label: 'Quarto PDF', variant: 'quarto' }
}
return { label: 'Quarto', variant: 'quarto' } return { label: 'Quarto', variant: 'quarto' }
case 'typst': case 'typst':
return { label: 'Typst', variant: 'typst' } return { label: 'Typst', variant: 'typst' }
@@ -24,7 +33,7 @@ type FormatCellProps = {
} }
export default function FormatCell({ project }: FormatCellProps) { export default function FormatCell({ project }: FormatCellProps) {
const { label, variant } = formatLabel(project.compiler) const { label, variant } = formatLabel(project.compiler, project.quartoFlavor)
return ( return (
<span <span
@@ -46,5 +46,6 @@ export const classHighlighter = tagHighlighter([
{ tag: tags.invalid, class: 'tok-invalid' }, { tag: tags.invalid, class: 'tok-invalid' },
{ tag: tags.punctuation, class: 'tok-punctuation' }, { tag: tags.punctuation, class: 'tok-punctuation' },
// additional // additional
{ tag: tags.attributeName, class: 'tok-attributeName' },
{ tag: tags.attributeValue, class: 'tok-attributeValue' }, { tag: tags.attributeValue, class: 'tok-attributeValue' },
]) ])
@@ -203,6 +203,9 @@ const staticTheme = EditorView.theme({
alignItems: 'center', alignItems: 'center',
fontWeight: 'normal', fontWeight: 'normal',
}, },
// Bold and italic markup (e.g. *strong* _emphasis_ in Typst and Markdown)
'.tok-strong': { fontWeight: 'bold' },
'.tok-emphasis': { fontStyle: 'italic' },
'.cm-selectionLayer': { '.cm-selectionLayer': {
zIndex: -10, zIndex: -10,
}, },
@@ -23,32 +23,53 @@ const LEVELS: NestingLevel[] = [
// after it, so this stays clear of code. // after it, so this stays clear of code.
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/ const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
// Count unescaped '$' signs on a line to track math-mode parity.
function countDollars(text: string): number {
let count = 0
for (let i = 0; i < text.length; i++) {
if (text[i] === '\\') { i++; continue }
if (text[i] === '$') count++
}
return count
}
function computeOutline( function computeOutline(
state: EditorState state: EditorState
): ProjectionResult<FlatOutlineItem> { ): ProjectionResult<FlatOutlineItem> {
const items: FlatOutlineItem[] = [] const items: FlatOutlineItem[] = []
// Track whether we are inside a multi-line display math block.
// Each line with an odd number of unescaped '$' toggles the flag.
let inMath = false
for (let n = 1; n <= state.doc.lines; n++) { for (let n = 1; n <= state.doc.lines; n++) {
const line = state.doc.line(n) const line = state.doc.line(n)
const match = HEADING_REGEX.exec(line.text) const text = line.text
if (!match) continue
const depth = match[1].length // Only attempt heading detection when not inside a math block.
const level = LEVELS[Math.min(depth, LEVELS.length) - 1] // (e.g. '= b+c$' on the second line of '$ a \n= b+c$' must be skipped.)
// Strip a trailing line comment, then a trailing label. if (!inMath) {
const title = match[2] const match = HEADING_REGEX.exec(text)
.replace(/\s*\/\/.*$/, '') if (match) {
.replace(/\s*<[\w-]+>\s*$/, '') const depth = match[1].length
.trim() const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
// Strip a trailing line comment, then a trailing label.
const title = match[2]
.replace(/\s*\/\/.*$/, '')
.replace(/\s*<[\w-]+>\s*$/, '')
.trim()
items.push({ items.push({
line: n, line: n,
toLine: n, toLine: n,
title, title,
from: line.from, from: line.from,
to: line.to, to: line.to,
level, level,
} as FlatOutlineItem) } as FlatOutlineItem)
}
}
if (countDollars(text) % 2 === 1) inMath = !inMath
} }
return { items, status: ProjectionStatus.Complete } return { items, status: ProjectionStatus.Complete }
@@ -14,9 +14,8 @@ import { typstDocumentOutline } from './document-outline'
// Note on tree structure: rules starting with a lowercase letter in the grammar // Note on tree structure: rules starting with a lowercase letter in the grammar
// are inline (no tree node), so their children are promoted to the parent. // are inline (no tree node), so their children are promoted to the parent.
// E.g. codeArgItem, codeValue, callSuffix, codeArgList are all inline. // E.g. codeArgItem, codeValue, callSuffix, codeArgList are all inline.
// Therefore: // Named arg keys emit CodeArgKey (not CodeIdent) via codeIdentTokenizer,
// - The named-argument key "CodeIdent" is a *direct* child of CodeArgs. // so CodeArgKey appears at the same level as other codeArgItem children.
// - Positional arguments that are identifiers are wrapped in CallExpr.
export const TypstLanguage = LRLanguage.define({ export const TypstLanguage = LRLanguage.define({
name: 'typst', name: 'typst',
@@ -50,11 +49,13 @@ export const TypstLanguage = LRLanguage.define({
CodeBool: t.atom, CodeBool: t.atom,
// Identifiers: // Identifiers:
// - direct child of CallExpr → function/method name // CodeExpr/CodeIdent — bare #func (no args) → function style
// - direct child of CodeArgs → named argument key (key: value syntax) // FuncExpr/CodeIdent — func call with args/method (#func(...), link.with(url)) → function style
// - everywhere else → plain variable // CodeArgKey — named arg key (tokenizer pre-disambiguates on ':') → attributeName
'CallExpr/CodeIdent': t.function(t.variableName), // CodeIdent — plain variable/constant reference (e.g. 'left', 'center') → variable
'CodeArgs/CodeIdent': t.attributeName, 'CodeExpr/CodeIdent': t.function(t.variableName),
'FuncExpr/CodeIdent': t.function(t.variableName),
CodeArgKey: t.attributeName,
CodeIdent: t.variableName, CodeIdent: t.variableName,
// Literals in code mode // Literals in code mode
@@ -73,8 +74,11 @@ export const TypstLanguage = LRLanguage.define({
MathContent: t.string, MathContent: t.string,
// Markup emphasis // Markup emphasis
'Strong/"*" Strong/StrongText': t.strong, 'Strong/"*" Strong/StrongBody': t.strong,
'Emphasis/"_" Emphasis/EmphText': t.emphasis, 'Emphasis/"_" Emphasis/EmphBody': t.emphasis,
// Bare URLs (https://... / http://...)
URL: t.string,
// Labels (<name>) and references (@name) // Labels (<name>) and references (@name)
'Label/"<" Label/">" Label/LabelName': t.labelName, 'Label/"<" Label/">" Label/LabelName': t.labelName,
@@ -97,6 +101,9 @@ const typstHighlightStyle = HighlightStyle.define([
{ tag: t.heading, fontWeight: 'bold' }, { tag: t.heading, fontWeight: 'bold' },
{ tag: t.strong, fontWeight: 'bold' }, { tag: t.strong, fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' }, { tag: t.emphasis, fontStyle: 'italic' },
// Named arg keys (fill:, caption:, columns:…) — amber colour that reads
// well on both light and dark backgrounds, independent of theme CSS.
{ tag: t.attributeName, color: '#c47900' },
]) ])
export const typst = () => { export const typst = () => {
@@ -8,22 +8,50 @@ import {
RawBlockBody, RawBlockBody,
RawBlockClose, RawBlockClose,
RawInlineContent, RawInlineContent,
CodeBlockBody,
BlockCommentBody, BlockCommentBody,
LineCommentContent, LineCommentContent,
MathContent, MathContent,
CodeKeyword,
CodeIdent,
CodeArgKey,
StrongBody,
EmphBody,
} from './typst.terms.mjs' } from './typst.terms.mjs'
const BACKTICK = 96 // ` const BACKTICK = 96 // `
const SLASH = 47 // / const SLASH = 47 // /
const STAR = 42 // * const STAR = 42 // *
const NEWLINE = 10 // \n const NEWLINE = 10 // \n
const EQUALS = 61 // = const EQUALS = 61 // =
const SPACE = 32 // const SPACE = 32 //
const TAB = 9 // \t const TAB = 9 // \t
const DOLLAR = 36 // $ const DOLLAR = 36 // $
const OPEN_BRACE = 123 // { const OPEN_BRACE = 123 // {
const CLOSE_BRACE = 125 // } const CLOSE_BRACE = 125 // }
const HASH = 35 // #
const UNDERSCORE = 95 // _
const DOT = 46 // .
const OPEN_PAREN = 40 // (
const COMMA = 44 // ,
const COLON = 58 // :
const SEMICOLON = 59 // ;
const OPEN_ANGLE = 60 // <
const CLOSE_ANGLE = 62 // >
const PLUS = 43 // +
const KEYWORDS = new Set([
'let', 'set', 'show', 'import', 'include',
'if', 'else', 'for', 'while', 'return',
'break', 'continue', 'in', 'as',
'and', 'or', 'not', 'context',
])
const BOOLS = new Set(['true', 'false', 'none', 'auto'])
const isAlpha = ch => (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122)
const isDigit = ch => ch >= 48 && ch <= 57
const isIdentHead = ch => isAlpha(ch) || ch === UNDERSCORE
const isIdentTail = ch => isAlpha(ch) || isDigit(ch) || ch === UNDERSCORE || ch === 45
// ── headingTokenizer ──────────────────────────────────────────────────── // ── headingTokenizer ────────────────────────────────────────────────────
// Emits HeadingMark — the "=+" prefix plus the trailing whitespace. // Emits HeadingMark — the "=+" prefix plus the trailing whitespace.
@@ -62,6 +90,17 @@ export const headingTitleTokenizer = new ExternalTokenizer(
while (input.next !== -1 && input.next !== NEWLINE) { while (input.next !== -1 && input.next !== NEWLINE) {
if (input.next === SLASH && if (input.next === SLASH &&
(input.peek(1) === SLASH || input.peek(1) === STAR)) break (input.peek(1) === SLASH || input.peek(1) === STAR)) break
// Stop before a trailing '<label>' so it is parsed as a Label node
// rather than being merged into the heading title text.
// Only stops when '<' is immediately followed by a valid label name and '>'.
if (input.next === OPEN_ANGLE) {
const ch = input.peek(1)
if (isAlpha(ch) || isDigit(ch) || ch === UNDERSCORE) {
let j = 2
while (isIdentTail(input.peek(j)) || input.peek(j) === DOT || input.peek(j) === COLON) j++
if (input.peek(j) === CLOSE_ANGLE) break
}
}
input.advance() input.advance()
hasContent = true hasContent = true
} }
@@ -105,6 +144,20 @@ export const rawTokenizer = new ExternalTokenizer(
} }
if (stack.canShift(RawBlockBody)) { if (stack.canShift(RawBlockBody)) {
// Guard: must genuinely follow a RawBlockOpen (which ends with \n).
// Walk backward past any lang tag (A-Za-z0-9) and require ```.
// This blocks spurious LALR-merged states from consuming body text.
if (input.peek(-1) !== NEWLINE) return
let back = -2
while (
(input.peek(back) >= 65 && input.peek(back) <= 90) ||
(input.peek(back) >= 97 && input.peek(back) <= 122) ||
(input.peek(back) >= 48 && input.peek(back) <= 57)
) { back-- }
if (input.peek(back) !== BACKTICK ||
input.peek(back - 1) !== BACKTICK ||
input.peek(back - 2) !== BACKTICK) return
let hasContent = false let hasContent = false
while (input.next !== -1) { while (input.next !== -1) {
if ( if (
@@ -136,36 +189,6 @@ export const rawInlineTokenizer = new ExternalTokenizer(
{ contextual: false } { contextual: false }
) )
// ── codeBlockTokenizer ──────────────────────────────────────────────────
// Emits CodeBlockBody — the interior of a #{ ... } code block.
// Tracks brace nesting depth so that inner braces (e.g. #{ f({ x }) })
// are included in the body rather than closing the outer block.
export const codeBlockTokenizer = new ExternalTokenizer(
(input, _stack) => {
// The opening '{' has already been consumed by the grammar rule.
let depth = 1
let hasContent = false
while (input.next !== -1) {
const ch = input.next
if (ch === OPEN_BRACE) {
depth++
input.advance()
hasContent = true
} else if (ch === CLOSE_BRACE) {
if (depth === 1) break // leave this '}' for the grammar rule
depth--
input.advance()
hasContent = true
} else {
input.advance()
hasContent = true
}
}
if (hasContent) input.acceptToken(CodeBlockBody)
},
{ contextual: false }
)
// ── blockCommentTokenizer ─────────────────────────────────────────────── // ── blockCommentTokenizer ───────────────────────────────────────────────
// Emits BlockCommentBody — the interior of a /* ... */ comment. // Emits BlockCommentBody — the interior of a /* ... */ comment.
// Typst supports nested block comments (/* /* inner */ outer */), so this // Typst supports nested block comments (/* /* inner */ outer */), so this
@@ -215,9 +238,13 @@ export const lineCommentContentTokenizer = new ExternalTokenizer(
) )
// ── mathContentTokenizer ──────────────────────────────────────────────── // ── mathContentTokenizer ────────────────────────────────────────────────
// Emits MathContent — everything between the $...$ delimiters (no newlines). // Emits MathContent — one line of content between the $...$ delimiters.
// External rather than a @tokens rule for the same reason as LineCommentContent: // Stops at '$' or '\n' so each token is bounded to a single line.
// ![$\n]+ overlaps with spaces, '<', '@', and other literals in merged states. //
// The grammar uses MathContent* (not MathContent?) so multi-line display
// math ($ ... \n ... $) is handled by multiple MathContent tokens, one per
// line, with @skip consuming the newlines in between. This keeps each
// token short and prevents a stray '$' from consuming the whole document.
export const mathContentTokenizer = new ExternalTokenizer( export const mathContentTokenizer = new ExternalTokenizer(
(input, _stack) => { (input, _stack) => {
let hasContent = false let hasContent = false
@@ -229,3 +256,174 @@ export const mathContentTokenizer = new ExternalTokenizer(
}, },
{ contextual: false } { contextual: false }
) )
// ── codeKeywordTokenizer ─────────────────────────────────────────────────
// Emits CodeKeyword (let, set, for, while, in, …) ONLY when the preceding
// character is '#', i.e. we are immediately after the '#' sigil in a CodeExpr.
//
// The peek(-1)==='#' guard is what prevents LALR state-merging from causing
// these tokens to fire in body-text positions. Common English words like
// "in", "for", "while", "return" appear in markup paragraphs; without the
// guard they would be highlighted as keywords due to LALR-merged states where
// CodeKeyword is technically in the valid set.
export const codeKeywordTokenizer = new ExternalTokenizer(
(input, stack) => {
if (!stack.canShift(CodeKeyword)) return
// Valid positions: after '#', ':', '{' (code block start), or ';'.
// Walk back past optional whitespace.
let back = -1
while (input.peek(back) === SPACE || input.peek(back) === TAB || input.peek(back) === NEWLINE) back--
const kwPrev = input.peek(back)
if (kwPrev !== HASH && kwPrev !== COLON && kwPrev !== OPEN_BRACE && kwPrev !== SEMICOLON) return
// Peek ahead to read the full identifier without advancing.
let len = 0
while (true) {
const ch = input.peek(len)
if (isIdentHead(ch) || (len > 0 && isIdentTail(ch))) { len++ } else { break }
}
if (len === 0) return
const chars = []
for (let i = 0; i < len; i++) chars.push(input.peek(i))
const word = String.fromCharCode(...chars)
if (!KEYWORDS.has(word)) return
for (let i = 0; i < len; i++) input.advance()
input.acceptToken(CodeKeyword)
},
{ contextual: true }
)
// ── codeIdentTokenizer ───────────────────────────────────────────────────
// Emits CodeIdent — identifier tokens inside code expressions (#ident,
// #func(args), #obj.method, etc.).
//
// Moving CodeIdent from @tokens to an external tokenizer allows a
// character-level guard: we only emit when the preceding non-whitespace
// character is one of '#', '.', '(', ',' — genuine code-context positions.
// This stops the token from firing in markup body text where LALR-merged
// states would otherwise cause '_italic_' to be consumed as one big
// CodeIdent (since '_' is a valid identHead) instead of opening Emphasis.
//
// Keywords and bools are excluded so codeKeywordTokenizer / CodeBool can
// handle them without conflict.
//
// The backward scan runs BEFORE any canShift gate. canShift(CodeArgKey) is
// unreliable (LALR state merging can suppress it even at genuine arg-key
// positions, e.g. 'caption:' after a complex nested call like 'table(...)').
// We derive couldBeArgKey from character-level evidence ('(' or ',') and use
// that to decide whether to continue even when canShift(CodeIdent) is false.
export const codeIdentTokenizer = new ExternalTokenizer(
(input, stack) => {
const couldBeIdent = stack.canShift(CodeIdent)
// Walk back past whitespace — primary context discriminator.
let back = -1
while (input.peek(back) === SPACE || input.peek(back) === TAB || input.peek(back) === NEWLINE) back--
const prev = input.peek(back)
if (prev !== HASH && prev !== DOT && prev !== OPEN_PAREN && prev !== COMMA && prev !== EQUALS && prev !== COLON && prev !== PLUS) {
if (!isIdentTail(prev)) {
// prev is a structural delimiter (e.g. ')' after a function call, '{' at
// block start, '}' after a nested block). These are valid statement-start
// positions inside a CodeBlock's codeStatement* list. Trust canShift —
// it's reliable in the grammar-parsed code-block states.
if (!couldBeIdent) return
} else {
// prev looks like the tail of a preceding word — scan back to find '#' or ':'.
// Accepting ':' lets multi-word chains like 'show sel: set text' work.
let b = back
while (isIdentTail(input.peek(b))) b--
while (input.peek(b) === SPACE || input.peek(b) === TAB || input.peek(b) === NEWLINE) b--
const chainEnd = input.peek(b)
if (chainEnd !== HASH && chainEnd !== COLON) {
// Could be second+ statement in a code block (e.g. after 'let x = 1').
if (!couldBeIdent) return
}
}
}
// In arg-delimiter positions ('(' or ',') we may emit CodeArgKey regardless
// of canShift(CodeIdent) — LALR merging can suppress canShift(CodeIdent)
// after a complex first argument (e.g. figure(table(...), caption: ...)).
// ':' and '=' are value positions, NOT arg-key positions.
const couldBeArgKey = prev === OPEN_PAREN || prev === COMMA
if (!couldBeIdent && !couldBeArgKey) return
// Must start with an identifier head character.
if (!isIdentHead(input.next)) return
// Peek ahead to read the full identifier.
let len = 0
while (true) {
const ch = input.peek(len)
if (len === 0 ? isIdentHead(ch) : isIdentTail(ch)) { len++ } else { break }
}
if (len === 0) return
const chars = []
for (let i = 0; i < len; i++) chars.push(input.peek(i))
const word = String.fromCharCode(...chars)
// Let codeKeywordTokenizer handle keywords; let CodeBool handle bools.
if (KEYWORDS.has(word) || BOOLS.has(word)) return
// Emit CodeArgKey when this identifier is immediately followed by ':'.
// Only applies in arg-delimiter positions (couldBeArgKey).
let isArgKey = false
if (couldBeArgKey) {
let afterLen = len
while (input.peek(afterLen) === SPACE || input.peek(afterLen) === TAB) afterLen++
isArgKey = (input.peek(afterLen) === COLON)
}
for (let i = 0; i < len; i++) input.advance()
if (isArgKey) {
input.acceptToken(CodeArgKey)
} else if (couldBeIdent) {
input.acceptToken(CodeIdent)
}
},
{ contextual: true }
)
// ── strongBodyTokenizer ──────────────────────────────────────────────────
// Emits StrongBody — the content between the '*' delimiters of a Strong node.
//
// contextual: true — only fires when StrongBody is in the valid set, i.e.
// inside Strong → "*" . StrongBody? "*". This state is very specific and
// is not merged with item* by Lezer's aggressive LALR merging, so canShift
// is a reliable guard here.
//
// Reads everything up to the first '*' or newline (Typst bold does not span
// lines). A trailing '*' that is the closing delimiter is left for the
// grammar rule to consume.
export const strongBodyTokenizer = new ExternalTokenizer(
(input, _stack) => {
let hasContent = false
while (input.next !== -1 && input.next !== STAR && input.next !== NEWLINE) {
input.advance()
hasContent = true
}
if (hasContent) input.acceptToken(StrongBody)
},
{ contextual: true }
)
// ── emphBodyTokenizer ────────────────────────────────────────────────────
// Emits EmphBody — the content between the '_' delimiters of an Emphasis node.
// Same design as strongBodyTokenizer; stops at '_' or newline.
export const emphBodyTokenizer = new ExternalTokenizer(
(input, _stack) => {
let hasContent = false
while (input.next !== -1 && input.next !== UNDERSCORE && input.next !== NEWLINE) {
input.advance()
hasContent = true
}
if (hasContent) input.acceptToken(EmphBody)
},
{ contextual: true }
)
@@ -5,8 +5,10 @@
// headingTitleTokenizer — HeadingTitle: the title text to end of line // headingTitleTokenizer — HeadingTitle: the title text to end of line
// rawTokenizer — triple-backtick raw block open/body/close // rawTokenizer — triple-backtick raw block open/body/close
// rawInlineTokenizer — single-backtick raw inline content // rawInlineTokenizer — single-backtick raw inline content
// codeBlockTokenizer — brace-depth tracking inside #{ ... }
// blockCommentTokenizer — depth-tracked nested /* ... */ comments // blockCommentTokenizer — depth-tracked nested /* ... */ comments
// codeIdentTokenizer — CodeIdent: identifier, only fires in code context
// strongBodyTokenizer — StrongBody: content inside *...*
// emphBodyTokenizer — EmphBody: content inside _..._
@top Document { item* } @top Document { item* }
@@ -24,8 +26,9 @@ item {
Label | Label |
Ref | Ref |
Escape | Escape |
Newline | URL |
MarkupContent MarkupContent |
ClosingSquare
} }
// ── Headings ────────────────────────────────────────────────────────────── // ── Headings ──────────────────────────────────────────────────────────────
@@ -58,63 +61,140 @@ RawInline { "`" RawInlineContent? "`" }
// #[ ... ] — content block (re-parses as markup items) // #[ ... ] — content block (re-parses as markup items)
CodeExpr { "#" codeExprBody } CodeExpr { "#" codeExprBody }
// codeExprBody: forms valid after '#' in markup, or after ':' / '=' in a
// keyword-body. FuncExpr handles ident+callSuffix(s); bare CodeIdent handles
// a plain variable reference (#x). No CallExpr with callSuffix* here — that
// *-quantifier makes both shift and reduce carry !call precedence (a tie that
// @right cannot resolve reliably once codeStatement* state-merging is in play).
codeExprBody { codeExprBody {
KeywordExpr | KeywordExpr |
AtomExpr | AtomExpr |
CallExpr | FuncExpr |
CodeIdent |
CodeBlock | CodeBlock |
ContentBlock ContentBlock
} }
// CallExpr? covers '#set text(size: 12pt)', '#show heading: ...', etc. // callOrValue covers the subject of a keyword expression (#set text, #show link,
// The optional CallExpr is only shifted when the next token is CodeIdent, // #import "pkg", #let name). keywordBody is exclusive: ':' for show-rule bodies
// so there is no shift/reduce conflict with other items that follow keywords. // and '=' for let-binding values (a keyword expression never has both).
KeywordExpr { CodeKeyword CallExpr? } // Two precedences:
// call @right — prefer extending callSuffixes (FuncExpr) over completing the
// FuncExpr and letting '(' start a new statement. The `!call` marker
// encodes the shift as (call << 2) and the FuncExpr reduce as
// (call << 2) - 1 (due to @right); shift > reduce, so callSuffix
// chains are greedily extended. Without @right both actions have
// the same numeric precedence and the conflict is unresolved.
// kw — prefer CodeKeyword !kw callOrValueAndBody over CodeKeyword keywordBody?
// when an identifier follows the keyword. shift = kw << 2, reduce
// (second alternative) = 0; kw > 0, no @right needed.
// add — resolves the shift/reduce conflict when a '+' follows a codeArgValue:
// SHIFT '+' (extend codeArgValue → codeArgValue !add "+" codeValue): prec add
// REDUCE codeArgItem → codeArgValue (complete arg): prec 0
// add > 0 → shift wins, so 0.8pt + brand stays as one arg value.
@precedence { call @right, kw, add }
// KeywordExpr: used in markup-level code (#show, #let, #set …) AND nested
// inside codeExprBody (e.g. the RHS after ':' in a show-rule).
// Same two-alternative structure as codeStatement: the !kw on the first
// alternative gives the shift prec kw > 0 over the unannotated reduce of the
// second alternative (prec 0). This avoids the call-vs-call tie that arises
// from the old `callOrValue?` optional pattern.
KeywordExpr {
CodeKeyword !kw callOrValueAndBody |
CodeKeyword keywordBody?
}
// callOrValue: FuncExpr for "ident(args)" / "ident.method", bare CodeIdent for
// a plain name, CodeString for string subjects like #import "pkg".
// FuncExpr requires at least one callSuffix, so at [CodeIdent ·] seeing '(':
// SHIFT (start callSuffixes, prec call) vs REDUCE bare CodeIdent (prec 0).
// call > 0 → shift wins cleanly.
callOrValue { FuncExpr | CodeIdent | CodeString }
keywordBody { ":" codeExprBody | "=" codeValue }
AtomExpr { CodeBool } AtomExpr { CodeBool }
CallExpr { CodeIdent callSuffix* } // codeStatement is the unit inside a CodeBlock's brace body.
// Two explicit alternatives for the keyword case avoid the LALR ambiguity
// that arises from codeStatement* merging when callOrValue? is optional.
// The !kw annotation on the first alternative (shift callOrValueAndBody) has
// higher precedence than the bare reduce of the second alternative (prec 0),
// so 'show strong: …' grabs 'strong' as callOrValue rather than completing
// KeywordExpr early with empty callOrValue.
codeStatement {
CodeKeyword !kw callOrValueAndBody |
CodeKeyword keywordBody? |
codeValue |
";"
}
callOrValueAndBody { callOrValue keywordBody? }
// FuncExpr: identifier followed by one-or-more call suffixes.
// callSuffixes uses explicit left-recursion (not +) so the !call annotation
// on the recursive extension point gives the shift prec call vs the unannotated
// reduce of codeValue → FuncExpr (prec 0) — shift wins, no @right tie.
callSuffixes { callSuffix | callSuffixes !call callSuffix }
FuncExpr { CodeIdent !call callSuffixes }
callSuffix { callSuffix {
CodeArgs | CodeArgs |
"." CodeIdent "." CodeIdent |
ContentBlock
} }
CodeArgs { "(" codeArgList? ")" } CodeArgs { "(" codeArgList? ")" }
codeArgList { codeArgItem ("," codeArgItem)* ","? } codeArgList { codeArgItem ("," codeArgItem)* ","? }
codeArgItem { codeArgItem {
CodeIdent ":" codeValue | CodeArgKey ":" codeArgValue |
codeValue codeArgValue
} }
// codeArgValue extends codeValue with '+' chaining for expressions like
// `stroke: 0.8pt + brand` or `fill: base + overlay`.
// Left-recursive rule: LALR state for codeArgValue · seeing '+':
// SHIFT '+' (extend, !add prec): prec add > 0
// REDUCE codeArgItem → codeArgValue (complete): prec 0
// add > 0 → shift wins cleanly. No @right needed (strict dominance).
// Only used inside CodeArgs, so codeStatement* LALR-merging does not apply.
codeArgValue { codeValue | codeArgValue !add "+" codeValue }
codeValue { codeValue {
CodeString | CodeString |
CodeNumber | CodeNumber |
CodeBool | CodeBool |
CallExpr | FuncExpr |
CodeIdent |
ContentBlock | ContentBlock |
CodeBlock | CodeBlock |
InlineMath InlineMath |
CodeArray
} }
// CodeBlockBody depth-tracks braces so #{ let x = { 1 } } parses correctly. // Typst array / tuple / dictionary literal: (a, b) or (key: val, …)
CodeBlock { "{" CodeBlockBody? "}" } // Reuses codeArgList so named-key entries like (auto, 1fr) work too.
CodeArray { "(" codeArgList? ")" }
// CodeBlock parses its content as a codeStatement* list so that keywords
// (show, let, set…) and identifiers inside braces receive proper highlighting.
CodeBlock { "{" codeStatement* "}" }
// ContentBlock re-enters markup mode, allowing #[*bold* text]. // ContentBlock re-enters markup mode, allowing #[*bold* text].
ContentBlock { "[" item* "]" } ContentBlock { "[" item* "]" }
// ── Math ────────────────────────────────────────────────────────────────── // ── Math ──────────────────────────────────────────────────────────────────
// Both inline ($x^2$) and display ($ x^2 $) math use the same node type. // Both inline ($x^2$) and display ($ x^2 $) math use the same node type.
InlineMath { "$" MathContent? "$" } // MathContent* (not ?) allows multi-line display math: each line becomes one
// MathContent token (stopping at '\n'), and @skip consumes the newlines between.
InlineMath { "$" MathContent* "$" }
// ── Markup formatting ───────────────────────────────────────────────────── // ── Markup formatting ─────────────────────────────────────────────────────
// Cross-nesting of Strong/Emphasis is intentionally excluded to avoid a // Strong and Emphasis use flat external body tokens (StrongBody / EmphBody)
// mutual-recursion cycle (Strong→Emphasis→Strong) that causes state explosion // rather than recursive strongItem* / emphItem* loops. The loop approach
// in the Lezer LR automaton builder. StrongText includes '_' and EmphText // triggered LALR state merging that caused item*-level tokens (MarkupContent,
// includes '*', so the nested delimiters are treated as plain text inside the // CodeIdent) to win over StrongText/EmphText inside the construct, so the
// opposite construct rather than producing error nodes. // body nodes were never produced. The flat external tokens are contextual
Strong { "*" strongItem* "*" } // (canShift only fires inside Strong/Emphasis) and reliably avoid those
strongItem { CodeExpr | InlineMath | RawInline | Label | Ref | StrongText } // merged states.
Strong { "*" StrongBody? "*" }
Emphasis { "_" emphItem* "_" } Emphasis { "_" EmphBody? "_" }
emphItem { CodeExpr | InlineMath | RawInline | Label | Ref | EmphText }
// ── Labels and references ───────────────────────────────────────────────── // ── Labels and references ─────────────────────────────────────────────────
Label { "<" LabelName ">" } Label { "<" LabelName ">" }
@@ -142,10 +222,6 @@ Escape { "\\" EscapeChar }
RawInlineContent RawInlineContent
} }
@external tokens codeBlockTokenizer from "./tokens.mjs" {
CodeBlockBody
}
@external tokens blockCommentTokenizer from "./tokens.mjs" { @external tokens blockCommentTokenizer from "./tokens.mjs" {
BlockCommentBody BlockCommentBody
} }
@@ -158,30 +234,44 @@ Escape { "\\" EscapeChar }
MathContent MathContent
} }
@external tokens codeKeywordTokenizer from "./tokens.mjs" {
CodeKeyword
}
// CodeIdent is external so codeIdentTokenizer can apply a character-level
// guard: it only emits when the preceding non-whitespace character is one of
// '#', '.', '(', ',' — i.e. genuinely inside a code expression. This stops
// the token from firing in markup body text, where LALR state merging would
// otherwise cause the entire token (including any leading '_') to be consumed
// as a code identifier instead of letting '_' open an Emphasis.
// CodeArgKey is emitted by the same tokenizer when an identifier is immediately
// followed by ':' — the tokenizer pre-disambiguates named arg keys so the LALR
// parser does not need to choose between codeArgItem alternatives on lookahead.
@external tokens codeIdentTokenizer from "./tokens.mjs" {
CodeIdent,
CodeArgKey
}
@external tokens strongBodyTokenizer from "./tokens.mjs" {
StrongBody
}
@external tokens emphBodyTokenizer from "./tokens.mjs" {
EmphBody
}
// ── Regular tokens ──────────────────────────────────────────────────────── // ── Regular tokens ────────────────────────────────────────────────────────
@tokens { @tokens {
// Horizontal whitespace only. Newlines are kept as explicit Newline items // All whitespace including newlines. Heading detection still works because
// so that HeadingMark (which checks start-of-line via input.peek(-1)) can // headingTokenizer uses input.peek(-1) on the raw character stream — it sees
// reliably detect newlines in the raw input stream. // the '\n' byte regardless of what @skip consumes at the token level.
spaces { $[ \t]+ } // Including '\n' here lets multi-line code expressions (e.g. #figure(\n ...\n))
// parse without error instead of triggering Lezer error recovery.
// Keywords take precedence over identifiers when they match fully spaces { $[ \t\n\r]+ }
// (e.g. "let" → CodeKeyword, "letter" → CodeIdent).
CodeKeyword {
"let" | "set" | "show" | "import" | "include" |
"if" | "else" | "for" | "while" | "return" |
"break" | "continue" | "in" | "as" |
"and" | "or" | "not" | "context"
}
// Boolean / null literals — distinct from keywords for highlighting. // Boolean / null literals — distinct from keywords for highlighting.
CodeBool { "true" | "false" | "none" | "auto" } CodeBool { "true" | "false" | "none" | "auto" }
// General identifier: [A-Za-z_][A-Za-z0-9_-]*
CodeIdent { identHead identTail* }
identHead { @asciiLetter | "_" }
identTail { @asciiLetter | @digit | "_" | "-" }
// Double-quoted string with backslash escapes (no single-quoted strings in Typst). // Double-quoted string with backslash escapes (no single-quoted strings in Typst).
CodeString { '"' (!["\\\n] | "\\" _)* '"' } CodeString { '"' (!["\\\n] | "\\" _)* '"' }
@@ -191,41 +281,42 @@ Escape { "\\" EscapeChar }
("pt" | "mm" | "cm" | "in" | "em" | "rem" | "fr" | "deg" | "rad" | "%")? ("pt" | "mm" | "cm" | "in" | "em" | "rem" | "fr" | "deg" | "rad" | "%")?
} }
// Text tokens for markup contexts; each excludes its own delimiters. // URL: bare https:// or http:// links in markup text. Matched as a single
// HeadingText, LineCommentContent, and MathContent are external tokens // token so '://' is never split into ':' + LineComment '//…'. Stops at
// (see above) — broad "read-to-delimiter" tokens that would otherwise // whitespace and angle brackets (labels use '<…>').
// conflict with every other literal token in LALR-merged states. URL { ("https" | "http") "://" (![ \t\n<>])* }
// '<' is excluded from StrongText/EmphText so that Label ('<' LabelName '>')
// is recognised inside strong/emphasis rather than consumed as plain text.
StrongText { ![\n*$#`<@\\]+ }
EmphText { ![\n_$#`<@\\]+ }
// Regular markup: excludes all special-character starters plus whitespace // Regular markup: excludes all special-character starters plus whitespace
// (whitespace is handled by @skip). The '/' is excluded so that '//' and // (whitespace is handled by @skip). The '/' is excluded so that '//' and
// '/*' are not accidentally consumed as plain text. // '/*' are not accidentally consumed as plain text. ']' is excluded so
MarkupContent { ![\n \t=*_$#/<@`\\]+ } // that ContentBlock { "[" item* "]" } can always close reliably — a bare
// ']' in body text is matched as ClosingSquare instead.
MarkupContent { ![\n \t\]=*_$#/<@`\\]+ }
// Fallback for a bare ']' in markup text (outside any ContentBlock).
// Inside ContentBlock the literal "]" terminal wins via @precedence.
ClosingSquare { "]" }
// Label names: identifiers with optional dots/colons (e.g. <sec:intro>). // Label names: identifiers with optional dots/colons (e.g. <sec:intro>).
LabelName { (identHead | @digit) (identTail | "." | ":")* } LabelName { (@asciiLetter | "_" | @digit) (@asciiLetter | @digit | "_" | "-" | "." | ":")* }
RefName { identHead identTail* } RefName { (@asciiLetter | "_") (@asciiLetter | @digit | "_" | "-")* }
// Escape: any single character after backslash. // Escape: any single character after backslash.
EscapeChar { _ } EscapeChar { _ }
// Newline item — kept out of @skip so heading detection works. // Resolve ambiguities in merged states:
Newline { "\n" } // EscapeChar > spaces: after '\', EscapeChar must win over the skip token.
// "(" > "." > "]": callSuffix delimiters must win over MarkupContent after
// Resolve ambiguities: more-specific tokens win over broader catch-alls. // a code identifier (merged states expose these to the markup tokenizer).
// EscapeChar > spaces: after '\', EscapeChar must win over the skip token // "_" > MarkupContent: '_' must open Emphasis rather than being swallowed
// (both match \t; without this, '\t' would be mis-tokenized). // by MarkupContent (redundant since '_' is in MarkupContent's exclusion
// "(" > "." > "]" > text tokens: after '#' CodeIdent, callSuffix delimiters // set, but kept for clarity).
// must win over MarkupContent/StrongText/EmphText in merged states. // CodeIdent and StrongText/EmphText are now external tokens — not listed.
// LineCommentContent and MathContent are external tokens — not listed here. // "[" > MarkupContent: ContentBlock callSuffix wins in merged code/markup states.
// "_" added after CodeIdent: KeywordExpr { CodeKeyword CallExpr? } merges // CodeString > MarkupContent: '"' starts a string literal after a keyword.
// the post-keyword state with markup states where "_" starts Emphasis. // ":" > MarkupContent: keywordBody ':' wins over markup colon in code states.
// CodeIdent wins so '#set _name(...)' is tokenised correctly; in pure markup // URL > MarkupContent: 'https://' / 'http://' wins over plain markup text.
// states CodeIdent is not in the valid set so "_" still opens Emphasis. @precedence { CodeBool EscapeChar CodeString URL "[" ":" "(" "." "+" "]" ClosingSquare "_" spaces MarkupContent }
@precedence { CodeKeyword CodeBool CodeIdent EscapeChar "(" "." "]" "_" spaces MarkupContent StrongText EmphText }
} }
@skip { spaces } @skip { spaces }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
HeadingMark = 1,
HeadingTitle = 2,
RawBlockOpen = 3,
RawBlockBody = 4,
RawBlockClose = 5,
RawInlineContent = 6,
BlockCommentBody = 7,
LineCommentContent = 8,
MathContent = 9,
CodeKeyword = 10,
CodeIdent = 11,
CodeArgKey = 12,
StrongBody = 13,
EmphBody = 14,
Document = 15,
Heading = 16,
LineComment = 17,
BlockComment = 18,
RawBlock = 19,
RawInline = 20,
CodeExpr = 21,
KeywordExpr = 22,
FuncExpr = 23,
CodeArgs = 24,
CodeString = 25,
CodeNumber = 26,
CodeBool = 27,
ContentBlock = 28,
CodeBlock = 29,
InlineMath = 30,
CodeArray = 31,
AtomExpr = 32,
Strong = 33,
Emphasis = 34,
Label = 35,
LabelName = 36,
Ref = 37,
RefName = 38,
Escape = 39,
EscapeChar = 40,
URL = 41,
MarkupContent = 42,
ClosingSquare = 43
@@ -73,6 +73,9 @@
}, },
".tok-variableName": { ".tok-variableName": {
"color": "#9b859d" "color": "#9b859d"
},
".tok-attributeName": {
"color": "#F4BF75"
} }
}, },
"dark": true "dark": true
@@ -74,6 +74,9 @@
}, },
".tok-variableName": { ".tok-variableName": {
"color": "#FF80E1" "color": "#FF80E1"
},
".tok-attributeName": {
"color": "#FFD700"
} }
}, },
"dark": true "dark": true
@@ -56,6 +56,9 @@
}, },
".tok-attributeValue": { ".tok-attributeValue": {
"color": "rgb(0, 64, 128)" "color": "rgb(0, 64, 128)"
},
".tok-attributeName": {
"color": "#994409"
} }
}, },
"dark": false "dark": false
@@ -67,6 +67,9 @@
}, },
".tok-attributeValue": { ".tok-attributeValue": {
"color": "#234A97" "color": "#234A97"
},
".tok-attributeName": {
"color": "#7B3814"
} }
}, },
"dark": false "dark": false
@@ -69,6 +69,9 @@
}, },
".tok-list": { ".tok-list": {
"color": "rgb(185, 6, 144)" "color": "rgb(185, 6, 144)"
},
".tok-attributeName": {
"color": "#994409"
} }
}, },
"dark": false "dark": false
@@ -53,6 +53,9 @@
".tok-regexp": { ".tok-regexp": {
"color": "#009926", "color": "#009926",
"fontWeight": "normal" "fontWeight": "normal"
},
".tok-attributeName": {
"color": "#735C0F"
} }
}, },
"dark": false "dark": false
@@ -71,6 +71,9 @@
".tok-comment": { ".tok-comment": {
"fontStyle": "italic", "fontStyle": "italic",
"color": "#00E060" "color": "#00E060"
},
".tok-attributeName": {
"color": "#F4BF75"
} }
}, },
"dark": true "dark": true
@@ -55,6 +55,9 @@
}, },
".tok-operator": { ".tok-operator": {
"color": "#EBDAB4" "color": "#EBDAB4"
},
".tok-attributeName": {
"color": "#FABD2F"
} }
}, },
"dark": true "dark": true
@@ -61,6 +61,9 @@
".tok-comment": { ".tok-comment": {
"fontStyle": "italic", "fontStyle": "italic",
"color": "#BC9458" "color": "#BC9458"
},
".tok-attributeName": {
"color": "#DA4939"
} }
}, },
"dark": true "dark": true
@@ -70,6 +70,9 @@
}, },
".tok-variableName": { ".tok-variableName": {
"color": "#FF80E1" "color": "#FF80E1"
},
".tok-attributeName": {
"color": "#d4c96e"
} }
}, },
"dark": true "dark": true
@@ -64,6 +64,9 @@
}, },
".tok-list": { ".tok-list": {
"color": "#8F5B26" "color": "#8F5B26"
},
".tok-attributeName": {
"color": "#994409"
} }
}, },
"dark": false "dark": false
@@ -57,6 +57,9 @@
}, },
".tok-number": { ".tok-number": {
"color": "#5A5CAD" "color": "#5A5CAD"
},
".tok-attributeName": {
"color": "#7B3F00"
} }
}, },
"dark": false "dark": false
@@ -66,6 +66,9 @@
}, },
".tok-variableName": { ".tok-variableName": {
"color": "#C1C144" "color": "#C1C144"
},
".tok-attributeName": {
"color": "#ACA0DC"
} }
}, },
"dark": true "dark": true
@@ -72,6 +72,9 @@
}, },
".tok-list": { ".tok-list": {
"color": "rgb(185, 6, 144)" "color": "rgb(185, 6, 144)"
},
".tok-attributeName": {
"color": "#994409"
} }
}, },
"dark": false "dark": false
@@ -69,6 +69,9 @@
}, },
".tok-attributeValue": { ".tok-attributeValue": {
"color": "#7587A6" "color": "#7587A6"
},
".tok-attributeName": {
"color": "#CF6A4C"
} }
}, },
"dark": true "dark": true
@@ -407,15 +407,19 @@ ul.project-list-filters {
white-space: nowrap; white-space: nowrap;
&.project-format-badge-quarto { &.project-format-badge-quarto {
background-color: #447099; background-color: #447099; // Quarto blue (PDF output)
}
&.project-format-badge-quarto-slides {
background-color: #e4637c; // RevealJS pink-red
} }
&.project-format-badge-typst { &.project-format-badge-typst {
background-color: #ee6331; background-color: #239dad; // typst.app brand blue
} }
&.project-format-badge-latex { &.project-format-badge-latex {
background-color: #72994e; background-color: #098842; // Overleaf brand green
} }
} }
+1
View File
@@ -53,6 +53,7 @@ export type ProjectApi = {
accessLevel: ProjectAccessLevel accessLevel: ProjectAccessLevel
source: Source source: Source
compiler?: ProjectCompiler compiler?: ProjectCompiler
quartoFlavor?: 'revealjs' | 'pdf'
} }
export type Project = MergeAndOverride< export type Project = MergeAndOverride<