From adc251813d7f5deb8aba6aaa852818b5dc92c832 Mon Sep 17 00:00:00 2001 From: max-nothacker Date: Sat, 25 Jul 2026 16:39:38 +0200 Subject: [PATCH 1/4] Fix single-line display math with attributes swallowing rest of document The single-line close check in math_block() only recognized lines ending in "$$", so "$$1+1$$ {#eq-spec0}" fell through to the multi-line scan, which never finds a closing line and consumes the remainder of the document into one math token. Everything after the equation (headings, code cells) was no longer tokenized: no outline entries, no LaTeX preview, and "Editor selection is not within an executable cell" when running later cells. Recognize an optional trailing attribute block in the single-line case, mirroring the multi-line close regex, and store it in token.info the same way. Adds unit tests for the single-line labeled, single-line unlabeled, and multi-line labeled cases. Fixes #976 Co-Authored-By: Claude Fable 5 in the session with id 6b8b182e --- packages/core/src/markdownit/math.ts | 10 +++--- packages/core/test/markdownit-math.test.ts | 42 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/markdownit-math.test.ts diff --git a/packages/core/src/markdownit/math.ts b/packages/core/src/markdownit/math.ts index 4abc7099c..959d0e2bf 100644 --- a/packages/core/src/markdownit/math.ts +++ b/packages/core/src/markdownit/math.ts @@ -163,13 +163,15 @@ function math_block( if (silent) { return true; } - if (firstLine.trim().slice(-2) === "$$") { - // Single line expression - firstLine = firstLine.trim().slice(0, -2); + let attrStr = undefined; + const singleLineMatch = firstLine.trim().match(/^(.*)\$\$\s*(\{.*\})?\s*$/); + if (singleLineMatch) { + // Single line expression, optionally followed by attributes (e.g. {#eq-label}) + firstLine = singleLineMatch[1]; + attrStr = singleLineMatch[2]; found = true; } - let attrStr = undefined; for (next = start; !found; ) { next++; diff --git a/packages/core/test/markdownit-math.test.ts b/packages/core/test/markdownit-math.test.ts new file mode 100644 index 000000000..ba04be920 --- /dev/null +++ b/packages/core/test/markdownit-math.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import MarkdownIt from "markdown-it"; +import { mathjaxPlugin } from "../src/markdownit/math"; + +const parse = (src: string) => { + const md = new MarkdownIt({ html: true }); + md.use(mathjaxPlugin); + return md.parse(src, {}); +}; + +test("single-line math with attributes does not swallow the rest of the document", () => { + const tokens = parse( + "$$1+1$$ {#eq-spec0}\n\n```{python}\n2+2\n```\n" + ); + const math = tokens.filter((t) => t.type === "math_block"); + const fences = tokens.filter((t) => t.type === "fence"); + assert.equal(math.length, 1); + assert.equal(math[0].info, "{#eq-spec0}"); + assert.equal(math[0].content.trim(), "1+1"); + assert.equal(fences.length, 1); +}); + +test("single-line math without attributes still parses", () => { + const tokens = parse("$$1+1$$\n\n```{python}\n2+2\n```\n"); + const math = tokens.filter((t) => t.type === "math_block"); + const fences = tokens.filter((t) => t.type === "fence"); + assert.equal(math.length, 1); + assert.equal(math[0].content.trim(), "1+1"); + assert.equal(fences.length, 1); +}); + +test("multi-line math with attributes on the closing line still parses", () => { + const tokens = parse("$$\n1+1\n$$ {#eq-spec0}\n\n```{python}\n2+2\n```\n"); + const math = tokens.filter((t) => t.type === "math_block"); + const fences = tokens.filter((t) => t.type === "fence"); + assert.equal(math.length, 1); + assert.equal(math[0].info, "{#eq-spec0}"); + assert.equal(math[0].content.trim(), "1+1"); + assert.equal(fences.length, 1); +}); From 72c1bef0112a608b90e5e283389c86b47fa20944 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Mon, 27 Jul 2026 12:09:50 -0600 Subject: [PATCH 2/4] Harden `math_block` against unterminated display math --- packages/core/src/markdownit/math.ts | 26 +++++++++++++-- packages/core/test/markdownit-math.test.ts | 38 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/core/src/markdownit/math.ts b/packages/core/src/markdownit/math.ts index 959d0e2bf..063f39093 100644 --- a/packages/core/src/markdownit/math.ts +++ b/packages/core/src/markdownit/math.ts @@ -23,6 +23,18 @@ import type StateBlock from "markdown-it/lib/rules_block/state_block"; export const kTokMathBlock = "math_block"; export const kTokMathInline = "math_inline"; +// A closing `$$` may be followed by a pandoc attribute block, e.g. `$$ {#eq-label}`. +// Both close checks below share this tail so they cannot drift apart again: the +// single-line check once omitted it, which made a labeled one-line equation +// consume the rest of the document (https://github.com/quarto-dev/quarto/issues/976). +const kAttrTail = String.raw`\s*(\{.*\})?\s*$`; + +// Close on the same line as the opening `$$`, e.g. `$$1+1$$ {#eq-label}` +const kCloseSameLine = new RegExp(String.raw`^(.*)\$\$` + kAttrTail); + +// Close on a line of its own, e.g. `$$ {#eq-label}` +const kCloseOwnLine = new RegExp(String.raw`^\$\$` + kAttrTail); + interface ConvertOptions { display: boolean @@ -163,8 +175,8 @@ function math_block( if (silent) { return true; } - let attrStr = undefined; - const singleLineMatch = firstLine.trim().match(/^(.*)\$\$\s*(\{.*\})?\s*$/); + let attrStr: string | undefined; + const singleLineMatch = firstLine.trim().match(kCloseSameLine); if (singleLineMatch) { // Single line expression, optionally followed by attributes (e.g. {#eq-label}) firstLine = singleLineMatch[1]; @@ -188,7 +200,7 @@ function math_block( } const line = state.src.slice(pos, max).trim(); - const match = line.match(/^\$\$\s*(\{.*\})?\s*$/); + const match = line.match(kCloseOwnLine); if (match) { lastPos = state.src.slice(0, max).lastIndexOf("$$"); lastLine = state.src.slice(pos, lastPos); @@ -197,6 +209,14 @@ function math_block( } } + // Without a close, decline the rule rather than emitting a token that runs to + // the end of the document. Consuming the remainder hides every heading and code + // cell below from the outline and from cell execution, which is what an + // in-progress `$$` looks like while it is still being typed. + if (!found) { + return false; + } + state.line = next + 1; const token = state.push(kTokMathBlock, "math", 0); diff --git a/packages/core/test/markdownit-math.test.ts b/packages/core/test/markdownit-math.test.ts index ba04be920..c2412632a 100644 --- a/packages/core/test/markdownit-math.test.ts +++ b/packages/core/test/markdownit-math.test.ts @@ -19,6 +19,23 @@ test("single-line math with attributes does not swallow the rest of the document assert.equal(math.length, 1); assert.equal(math[0].info, "{#eq-spec0}"); assert.equal(math[0].content.trim(), "1+1"); + // the token must span only the equation's own line, not the rest of the document + assert.deepEqual(math[0].map, [0, 1]); + assert.equal(fences.length, 1); +}); + +test("single-line math with braces in the expression and attributes", () => { + // the greedy (.*) for the expression competes with the optional {...} attribute + // group, so pin the case where the expression itself ends in a brace + const tokens = parse( + "$$\\frac{1}{2}$$ {#eq-spec0}\n\n```{python}\n2+2\n```\n" + ); + const math = tokens.filter((t) => t.type === "math_block"); + const fences = tokens.filter((t) => t.type === "fence"); + assert.equal(math.length, 1); + assert.equal(math[0].info, "{#eq-spec0}"); + assert.equal(math[0].content.trim(), "\\frac{1}{2}"); + assert.deepEqual(math[0].map, [0, 1]); assert.equal(fences.length, 1); }); @@ -38,5 +55,26 @@ test("multi-line math with attributes on the closing line still parses", () => { assert.equal(math.length, 1); assert.equal(math[0].info, "{#eq-spec0}"); assert.equal(math[0].content.trim(), "1+1"); + assert.deepEqual(math[0].map, [0, 3]); assert.equal(fences.length, 1); }); + +// Unterminated math must not be treated as a block that runs to the end of the +// document. A bare "$$" is what the buffer looks like while a display equation +// is being typed, and consuming the remainder would empty the outline and strip +// the Run Cell affordance from every cell below it. +const unterminated: [string, string][] = [ + ["a bare opening delimiter", "$$\n\n```{python}\n2+2\n```\n"], + ["an unclosed expression", "$$1+1\n\n```{python}\n2+2\n```\n"], + ["trailing prose after the close", "$$1+1$$ and more text\n\n```{python}\n2+2\n```\n"], +]; + +for (const [name, src] of unterminated) { + test(`math with ${name} does not swallow the rest of the document`, () => { + const tokens = parse(src); + const math = tokens.filter((t) => t.type === "math_block"); + const fences = tokens.filter((t) => t.type === "fence"); + assert.equal(math.length, 0); + assert.equal(fences.length, 1); + }); +} From 85043c826dcde290853b33fba5480fd239b196d4 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Mon, 27 Jul 2026 12:09:58 -0600 Subject: [PATCH 3/4] Update CHANGELOG --- apps/vscode/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 5364c2632..444cf4e7b 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,7 +2,8 @@ ## 1.136.0 (Unreleased) -- Reduce memory usage by only starting the language server (LSP) in projects containing Quarto documents (https://github.com/quarto-dev/quarto/pull/1059) +- Reduce memory usage by only starting the language server (LSP) in projects containing Quarto documents (https://github.com/quarto-dev/quarto/pull/1059). +- Fixed a bug where single-line display math with a cross-reference label (e.g. `$$1+1$$ {#eq-spec0}`), or an unclosed `$$`, stopped the rest of the document from being parsed, so headings went missing from the outline, LaTeX preview was unavailable, and code cells below could not be run (). ## 1.135.0 (Release on 2026-07-08) From 8dc55fd8949379a669984b92845558acbe5ec345 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Mon, 27 Jul 2026 12:16:32 -0600 Subject: [PATCH 4/4] =?UTF-8?q?We=20NEVER=20ran=20these=20tests=20in=20CI?= =?UTF-8?q?=20=F0=9F=98=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 12 ++++++++++++ package.json | 1 + turbo.json | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0cae70a5e..775d85757 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,6 +10,18 @@ on: workflow_dispatch: jobs: + test-packages: + runs-on: ubuntu-latest + name: Test packages + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: latest + - run: yarn install --immutable --immutable-cache --check-cache + - name: Run package tests + run: yarn test-packages + test-extension: runs-on: ubuntu-latest strategy: diff --git a/package.json b/package.json index 0c7c4827c..a41d0de47 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev-vscode": "turbo run dev --filter quarto...", "build-vscode": "turbo run build --filter quarto...", "test-vscode": "cd apps/vscode && yarn test", + "test-packages": "turbo run test --filter='./packages/*'", "install-vscode": "cd apps/vscode && yarn install-vscode", "install-positron": "cd apps/vscode && yarn install-positron", "lint": "turbo run lint", diff --git a/turbo.json b/turbo.json index 28c5d3ddb..c15dba317 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,10 @@ "lint": { "outputs": [] }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, "dev": { "cache": false }