diff --git a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs index a29d433106..d9ca805889 100644 --- a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs +++ b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs @@ -252,19 +252,36 @@ export const lineCommentContentTokenizer = new ExternalTokenizer( ) // ── mathContentTokenizer ──────────────────────────────────────────────── -// Emits MathContent — everything between the $...$ delimiters (no newlines). -// External rather than a @tokens rule for the same reason as LineCommentContent: -// ![$\n]+ overlaps with spaces, '<', '@', and other literals in merged states. +// Emits MathContent — everything between the $...$ delimiters. +// +// Typst distinguishes inline math ($x^2$) from display math ($ x^2 $): +// display math has whitespace between the opening '$' and the content. +// We detect this by scanning back to '$': if there is any whitespace +// between '$' and the current position (i.e. @skip consumed it), the +// tokenizer allows newlines so multi-line display math works. Inline math +// keeps the newline stop, preventing a lone '$' from consuming the rest of +// the document. +// +// contextual: true — only fires inside InlineMath after '$', never in +// body text. The '$' token appears nowhere else in the grammar so the +// post-'$' state does not merge with item* states. export const mathContentTokenizer = new ExternalTokenizer( (input, _stack) => { + // Scan back to the opening '$', detecting display vs inline math. + let back = -1 + while (input.peek(back) === SPACE || input.peek(back) === TAB || input.peek(back) === NEWLINE) back-- + if (input.peek(back) !== DOLLAR) return + const isDisplay = back < -1 // whitespace between '$' and current position + let hasContent = false - while (input.next !== -1 && input.next !== DOLLAR && input.next !== NEWLINE) { + while (input.next !== -1 && input.next !== DOLLAR) { + if (!isDisplay && input.next === NEWLINE) break input.advance() hasContent = true } if (hasContent) input.acceptToken(MathContent) }, - { contextual: false } + { contextual: true } ) // ── codeKeywordTokenizer ─────────────────────────────────────────────────