diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0cae70a5..775d8575 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/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 5364c263..444cf4e7 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) diff --git a/package.json b/package.json index 0c7c4827..a41d0de4 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/packages/core/src/markdownit/math.ts b/packages/core/src/markdownit/math.ts index 4abc7099..063f3909 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,13 +175,15 @@ function math_block( if (silent) { return true; } - if (firstLine.trim().slice(-2) === "$$") { - // Single line expression - firstLine = firstLine.trim().slice(0, -2); + 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]; + attrStr = singleLineMatch[2]; found = true; } - let attrStr = undefined; for (next = start; !found; ) { next++; @@ -186,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); @@ -195,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 new file mode 100644 index 00000000..c2412632 --- /dev/null +++ b/packages/core/test/markdownit-math.test.ts @@ -0,0 +1,80 @@ +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"); + // 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); +}); + +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.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); + }); +} diff --git a/turbo.json b/turbo.json index 28c5d3dd..c15dba31 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,10 @@ "lint": { "outputs": [] }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, "dev": { "cache": false }