From 62c53aaddcc94c1b1ef0526f665a5bbf5d030929 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 16:53:58 +0200 Subject: [PATCH 01/20] fix(embed): don't use a code-cell comment as the embedded notebook title (#14577) When embedding a notebook with no YAML title and no markdown heading cell, the "Source" link and sidebar showed a Python comment (e.g. `# plt.savefig(...)`) instead of the notebook filename. findTitle() in jupyter-embed.ts scans every cell's markdown, including code cells whose markdown is their fenced source. partitionMarkdown is not fence-aware, so a `#` comment line inside the fence matches the ATX-heading regex and was returned as the title. The non-embed code path already guards against this: the Dec-2023 "Improve title snipping in notebooks" work (24672a4af, fixes #5363/#6411) made fixupFrontMatter only promote a heading to a notebook title when no content precedes it. findTitle predates that change and was never brought in line, so it still promoted any heading-like line. Apply the same `!contentBeforeHeading` gate here by exposing contentBeforeHeading (already computed by markdownWithExtractedHeading) through PartitionedMarkdown. A fenced code block's opening delimiter always counts as content before the comment, so the comment is no longer mistaken for a title and the filename is used instead. --- news/changelog-1.10.md | 1 + src/core/jupyter/jupyter-embed.ts | 6 ++- src/core/pandoc/pandoc-partition.ts | 8 ++-- src/core/pandoc/types.ts | 1 + tests/docs/embed/issue-14577/index.qmd | 9 +++++ tests/docs/embed/issue-14577/notebook.ipynb | 35 ++++++++++++++++++ tests/smoke/embed/render-embed.test.ts | 41 +++++++++++++++++++++ tests/unit/pandoc-partition.test.ts | 28 ++++++++++++++ 8 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 tests/docs/embed/issue-14577/index.qmd create mode 100644 tests/docs/embed/issue-14577/notebook.ipynb diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 5d5917be61a..c2da347b4d4 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -104,6 +104,7 @@ All changes included in 1.10: - ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) - ([#14529](https://github.com/quarto-dev/quarto-cli/issues/14529)): Fix bundled Julia engine path leaking into rendered YAML metadata and pandoc log output when running an installed Quarto. The internal subtree-engine filter only matched the source-tree share-path layout (`resources/extension-subtrees/`) and missed installed layouts where the path is `share/extension-subtrees/`. - ([#14576](https://github.com/quarto-dev/quarto-cli/issues/14576)): Fix website render hanging when sidebar titles contain double-underscore (dunder) names. +- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix a code comment being used as the title of an embedded notebook that has no markdown heading or title; the notebook filename is used instead. - ([#14582](https://github.com/quarto-dev/quarto-cli/issues/14582)): Fix format detection for extension formats (e.g. `acm-pdf`) in project preview, manuscript notebooks, MECA bundles, and website format ordering. - ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves. - ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment diff --git a/src/core/jupyter/jupyter-embed.ts b/src/core/jupyter/jupyter-embed.ts index 718c2f03105..06b59b054f4 100644 --- a/src/core/jupyter/jupyter-embed.ts +++ b/src/core/jupyter/jupyter-embed.ts @@ -654,7 +654,11 @@ function findTitle(cells: JupyterCellOutput[]) { const partitioned = partitionMarkdown(cell.markdown); if (partitioned.yaml?.title) { return partitioned.yaml.title as string; - } else if (partitioned.headingText) { + } else if (partitioned.headingText && !partitioned.contentBeforeHeading) { + // Only promote a heading to the title when nothing precedes it, matching + // how a notebook's front-matter title is derived in jupyter-fixups.ts. + // This excludes a `#` comment inside a code cell's fenced source, whose + // fence delimiter always counts as content before the heading (issue 14577). return partitioned.headingText; } } diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 08cbdf86572..0749ceddf54 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -53,9 +53,10 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { markdown = partitioned ? partitioned.markdown : markdown; // extract heading - const { lines, headingText, headingAttr } = markdownWithExtractedHeading( - markdown, - ); + const { lines, headingText, headingAttr, contentBeforeHeading } = + markdownWithExtractedHeading( + markdown, + ); // does this contain refs? const containsRefs = lines.some((line) => @@ -66,6 +67,7 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { yaml: (partitioned ? readYamlFromMarkdown(partitioned.yaml) : undefined), headingText, headingAttr, + contentBeforeHeading, containsRefs, markdown: lines.join("\n"), srcMarkdownNoYaml: partitioned?.markdown || "", diff --git a/src/core/pandoc/types.ts b/src/core/pandoc/types.ts index 9a298fa9ea8..33adfeaac52 100644 --- a/src/core/pandoc/types.ts +++ b/src/core/pandoc/types.ts @@ -17,6 +17,7 @@ export interface PartitionedMarkdown { yaml?: Metadata; headingText?: string; headingAttr?: PandocAttr; + contentBeforeHeading?: boolean; containsRefs: boolean; markdown: string; srcMarkdownNoYaml: string; diff --git a/tests/docs/embed/issue-14577/index.qmd b/tests/docs/embed/issue-14577/index.qmd new file mode 100644 index 00000000000..f8bd31e9401 --- /dev/null +++ b/tests/docs/embed/issue-14577/index.qmd @@ -0,0 +1,9 @@ +--- +title: "Embed code-comment title (issue 14577)" +--- + +The embedded notebook has no markdown heading cells and a code cell whose last +line is a Python comment (`# plt.savefig(...)`). The notebook title used for the +"Source" link and the sidebar must be the filename, not the code comment. + +{{< embed notebook.ipynb echo=true >}} diff --git a/tests/docs/embed/issue-14577/notebook.ipynb b/tests/docs/embed/issue-14577/notebook.ipynb new file mode 100644 index 00000000000..776d3c30c5f --- /dev/null +++ b/tests/docs/embed/issue-14577/notebook.ipynb @@ -0,0 +1,35 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "c1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "hello\n" + ] + } + ], + "source": [ + "print('hello')\n", + "# plt.savefig('./fig/Shockley-parity/lowest_solution.svg')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/smoke/embed/render-embed.test.ts b/tests/smoke/embed/render-embed.test.ts index dfa151177ea..a2616641fd2 100644 --- a/tests/smoke/embed/render-embed.test.ts +++ b/tests/smoke/embed/render-embed.test.ts @@ -103,6 +103,47 @@ testRender(ipynbInput, format, false, [ }, }); +// Embedded notebook title must not be taken from a Python code comment +// (issue 14577). When a notebook has no markdown heading cells but a code +// cell whose source contains a `# comment` line, the title used for the +// "Source" link / sidebar must fall back to the filename, not the comment. +const commentTitleInput = docs("embed/issue-14577/index.qmd"); +const commentTitleOutput = outputForInput(commentTitleInput, format); +const commentTitlePreviewNb = join( + dirname(commentTitleOutput.outputPath), + "notebook-preview.html", +); +const commentTitlePreviewSupporting = join( + dirname(commentTitleOutput.outputPath), + "notebook_files", +); +const commentTitlePreviewRendered = join( + dirname(commentTitleOutput.outputPath), + "notebook.out.ipynb", +); + +testRender(commentTitleInput, format, false, [ + noErrorsOrWarnings, + ensureFileRegexMatches( + commentTitleOutput.outputPath, + [ + // The notebook source link uses the filename as the title + /Source:\s*notebook\.ipynb/, + ], + [ + // The Python comment must never be used as the notebook title + /Source:\s*(#\s*)?plt\.savefig/, + ], + ), +], { + teardown: () => { + Deno.removeSync(commentTitlePreviewNb); + Deno.removeSync(commentTitlePreviewSupporting, { recursive: true }); + Deno.removeSync(commentTitlePreviewRendered); + return Promise.resolve(); + }, +}); + // Test different echo settings (bug 8472) const docInput = docs("embed/qmd-embed/index.qmd"); const docOutput = outputForInput(docInput, "html"); diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index 44ddd6881db..4c43aafd841 100644 --- a/tests/unit/pandoc-partition.test.ts +++ b/tests/unit/pandoc-partition.test.ts @@ -55,6 +55,34 @@ unitTest("partitionYaml", async () => { ); }); +// A `#` line inside a fenced code block is not a heading; the fence delimiter +// is content that precedes it, so contentBeforeHeading must be true. This is the +// shape of an embedded code cell's markdown (issue 14577): a Python comment must +// not be promoted to the notebook title. +// deno-lint-ignore require-await +unitTest("partitionMarkdown - fenced code comment is not a leading heading", async () => { + const markdown = "```python\nprint('hello')\n# plt.savefig('out.svg')\n```\n"; + const partmd = partitionMarkdown(markdown); + assert( + partmd.headingText === "plt.savefig('out.svg')", + "Comment line should still be extracted as headingText (not fence-aware)", + ); + assert( + partmd.contentBeforeHeading === true, + "Fence delimiter precedes the comment, so contentBeforeHeading must be true", + ); +}); + +// deno-lint-ignore require-await +unitTest("partitionMarkdown - leading heading has no content before it", async () => { + const partmd = partitionMarkdown("# Real Title\n\nsome body text\n"); + assert(partmd.headingText === "Real Title", "Heading text not parsed"); + assert( + partmd.contentBeforeHeading === false, + "Heading is first content, so contentBeforeHeading must be false", + ); +}); + // deno-lint-ignore require-await unitTest("languagesWithClasses - dot-joined syntax", async () => { const md = `\`\`\`{python.marimo} From 26286e25b571520f6b24a3d2365451917378eb46 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 17:04:35 +0200 Subject: [PATCH 02/20] test(embed): use safeRemoveSync in render-embed teardowns The teardowns called raw Deno.removeSync, which throws NotFound when a preview artifact is already absent (e.g. a prior teardown partially ran, or a sibling test cleaned a shared path). A throwing teardown aborts the test and leaves the fixture directory half-cleaned, so the next run trips over stale state. safeRemoveSync (deno_ral/fs.ts) tolerates already-removed paths and only rethrows genuine errors, keeping cleanup idempotent. --- tests/smoke/embed/render-embed.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/smoke/embed/render-embed.test.ts b/tests/smoke/embed/render-embed.test.ts index a2616641fd2..51ce564011a 100644 --- a/tests/smoke/embed/render-embed.test.ts +++ b/tests/smoke/embed/render-embed.test.ts @@ -5,6 +5,7 @@ * */ import { dirname, extname, join } from "../../../src/deno_ral/path.ts"; +import { safeRemoveSync } from "../../../src/deno_ral/fs.ts"; import { docs, outputForInput } from "../../utils.ts"; import { ensureFileRegexMatches, @@ -42,8 +43,8 @@ testRender(input, format, false, [ ], { teardown: () => { // clean up the notebook that is referenced by `embed-qmd-qmd` - Deno.removeSync(nbOutput); - Deno.removeSync(nbSupporting, { recursive: true }); + safeRemoveSync(nbOutput); + safeRemoveSync(nbSupporting, { recursive: true }); return Promise.resolve(); }, @@ -95,9 +96,9 @@ testRender(ipynbInput, format, false, [ ], { teardown: () => { // clean up the notebook that is referenced by `embed-qmd-qmd` - Deno.removeSync(ipynbPreviewNb); - Deno.removeSync(ipynbPreviewSupporting, { recursive: true }); - Deno.removeSync(ipynbPreviewRendered); + safeRemoveSync(ipynbPreviewNb); + safeRemoveSync(ipynbPreviewSupporting, { recursive: true }); + safeRemoveSync(ipynbPreviewRendered); return Promise.resolve(); }, @@ -137,9 +138,9 @@ testRender(commentTitleInput, format, false, [ ), ], { teardown: () => { - Deno.removeSync(commentTitlePreviewNb); - Deno.removeSync(commentTitlePreviewSupporting, { recursive: true }); - Deno.removeSync(commentTitlePreviewRendered); + safeRemoveSync(commentTitlePreviewNb); + safeRemoveSync(commentTitlePreviewSupporting, { recursive: true }); + safeRemoveSync(commentTitlePreviewRendered); return Promise.resolve(); }, }); @@ -169,7 +170,7 @@ testRender(docInput, "html", false, [ const cleanup = ["notebook.embed_files", "notebook.embed-preview.html", "notebook2.embed_files", "notebook2.embed-preview.html"]; cleanup.forEach((path) => { - Deno.removeSync(join(dir, path), {recursive: true}); + safeRemoveSync(join(dir, path), {recursive: true}); }) return Promise.resolve(); }, From cdcaa8c9a11268afbf121fa6a87dff73a7b04142 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 17:22:28 +0200 Subject: [PATCH 03/20] test(smoke-all): make postRenderCleanup remove directories postRenderCleanup registered entries were removed with a non-recursive Deno.removeSync, so an entry could only be a single file. Embedded-notebook tests produce a `*_files` support directory alongside the preview HTML, which could not be declared for cleanup and was left behind. Use safeRemoveSync with recursive removal so a registered entry can be a directory as well as a file. --- tests/smoke/smoke-all.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/smoke/smoke-all.test.ts b/tests/smoke/smoke-all.test.ts index 65b4b08f5f3..1172d2f13e4 100644 --- a/tests/smoke/smoke-all.test.ts +++ b/tests/smoke/smoke-all.test.ts @@ -170,7 +170,9 @@ const postRenderCleanup = () => { for (const file of postRenderCleanupFiles) { console.log(`Cleaning up ${file} in ${Deno.cwd()}`); if (safeExistsSync(file)) { - Deno.removeSync(file); + // recursive so a registered entry can be a directory (e.g. an embedded + // notebook's `*_files` support dir), not just a single file + safeRemoveSync(file, { recursive: true }); } } } From f4c6de93976696385e1c74842cf63b5cb41682cc Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 17:22:52 +0200 Subject: [PATCH 04/20] test: move #14577 regression test into smoke-all The regression test for the embedded-notebook title fix started as a bespoke testRender block in render-embed.test.ts with manual teardown of the preview artifacts. smoke-all is the established home for issue-regression tests and its framework already handles output cleanup, so move it there: a 14577.qmd that embeds notebook.ipynb and asserts the Source link uses the filename, not the Python comment, with the preview artifacts declared for postRenderCleanup. --- tests/docs/embed/issue-14577/index.qmd | 9 ---- tests/docs/smoke-all/2026/06/30/14577.qmd | 29 +++++++++++++ .../2026/06/30}/notebook.ipynb | 0 tests/smoke/embed/render-embed.test.ts | 41 ------------------- 4 files changed, 29 insertions(+), 50 deletions(-) delete mode 100644 tests/docs/embed/issue-14577/index.qmd create mode 100644 tests/docs/smoke-all/2026/06/30/14577.qmd rename tests/docs/{embed/issue-14577 => smoke-all/2026/06/30}/notebook.ipynb (100%) diff --git a/tests/docs/embed/issue-14577/index.qmd b/tests/docs/embed/issue-14577/index.qmd deleted file mode 100644 index f8bd31e9401..00000000000 --- a/tests/docs/embed/issue-14577/index.qmd +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: "Embed code-comment title (issue 14577)" ---- - -The embedded notebook has no markdown heading cells and a code cell whose last -line is a Python comment (`# plt.savefig(...)`). The notebook title used for the -"Source" link and the sidebar must be the filename, not the code comment. - -{{< embed notebook.ipynb echo=true >}} diff --git a/tests/docs/smoke-all/2026/06/30/14577.qmd b/tests/docs/smoke-all/2026/06/30/14577.qmd new file mode 100644 index 00000000000..38a7e5b022f --- /dev/null +++ b/tests/docs/smoke-all/2026/06/30/14577.qmd @@ -0,0 +1,29 @@ +--- +title: "Embed code-comment title (issue 14577)" +format: html +_quarto: + tests: + html: + ensureFileRegexMatches: + - + # The notebook source link uses the filename as the title + - 'Source:\s*notebook\.ipynb' + - + # The Python comment must never be used as the notebook title + - 'Source:\s*(#\s*)?plt\.savefig' + postRenderCleanup: + - notebook-preview.html + - notebook.out.ipynb + - notebook_files +--- + +The embedded notebook has no markdown heading cells and a code cell whose last +line is a Python comment (`# plt.savefig(...)`). The notebook markdown a code +cell carries is its fenced source, and heading extraction is not fence-aware, +so the `#` comment matched the ATX-heading regex and was promoted to the +notebook title — surfacing in the "Source" link and sidebar. + +The title used for the "Source" link must fall back to the notebook filename, +not the code comment. + +{{< embed notebook.ipynb echo=true >}} diff --git a/tests/docs/embed/issue-14577/notebook.ipynb b/tests/docs/smoke-all/2026/06/30/notebook.ipynb similarity index 100% rename from tests/docs/embed/issue-14577/notebook.ipynb rename to tests/docs/smoke-all/2026/06/30/notebook.ipynb diff --git a/tests/smoke/embed/render-embed.test.ts b/tests/smoke/embed/render-embed.test.ts index 51ce564011a..db80d860e6b 100644 --- a/tests/smoke/embed/render-embed.test.ts +++ b/tests/smoke/embed/render-embed.test.ts @@ -104,47 +104,6 @@ testRender(ipynbInput, format, false, [ }, }); -// Embedded notebook title must not be taken from a Python code comment -// (issue 14577). When a notebook has no markdown heading cells but a code -// cell whose source contains a `# comment` line, the title used for the -// "Source" link / sidebar must fall back to the filename, not the comment. -const commentTitleInput = docs("embed/issue-14577/index.qmd"); -const commentTitleOutput = outputForInput(commentTitleInput, format); -const commentTitlePreviewNb = join( - dirname(commentTitleOutput.outputPath), - "notebook-preview.html", -); -const commentTitlePreviewSupporting = join( - dirname(commentTitleOutput.outputPath), - "notebook_files", -); -const commentTitlePreviewRendered = join( - dirname(commentTitleOutput.outputPath), - "notebook.out.ipynb", -); - -testRender(commentTitleInput, format, false, [ - noErrorsOrWarnings, - ensureFileRegexMatches( - commentTitleOutput.outputPath, - [ - // The notebook source link uses the filename as the title - /Source:\s*notebook\.ipynb/, - ], - [ - // The Python comment must never be used as the notebook title - /Source:\s*(#\s*)?plt\.savefig/, - ], - ), -], { - teardown: () => { - safeRemoveSync(commentTitlePreviewNb); - safeRemoveSync(commentTitlePreviewSupporting, { recursive: true }); - safeRemoveSync(commentTitlePreviewRendered); - return Promise.resolve(); - }, -}); - // Test different echo settings (bug 8472) const docInput = docs("embed/qmd-embed/index.qmd"); const docOutput = outputForInput(docInput, "html"); From 9f7269c1d111b108e79f9cf1c985d33f7d34be75 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 17:33:28 +0200 Subject: [PATCH 05/20] test: drop weak pandoc-partition unit tests These asserted pre-existing, unchanged extraction behavior plus a trivial pass-through (partitionMarkdown now forwards contentBeforeHeading, a value markdownWithExtractedHeading already computed). They pinned behavior rather than exercising the fix. The fix lives in findTitle and is covered end-to-end by the smoke-all regression test, so the unit additions added no real coverage. --- tests/unit/pandoc-partition.test.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index 4c43aafd841..44ddd6881db 100644 --- a/tests/unit/pandoc-partition.test.ts +++ b/tests/unit/pandoc-partition.test.ts @@ -55,34 +55,6 @@ unitTest("partitionYaml", async () => { ); }); -// A `#` line inside a fenced code block is not a heading; the fence delimiter -// is content that precedes it, so contentBeforeHeading must be true. This is the -// shape of an embedded code cell's markdown (issue 14577): a Python comment must -// not be promoted to the notebook title. -// deno-lint-ignore require-await -unitTest("partitionMarkdown - fenced code comment is not a leading heading", async () => { - const markdown = "```python\nprint('hello')\n# plt.savefig('out.svg')\n```\n"; - const partmd = partitionMarkdown(markdown); - assert( - partmd.headingText === "plt.savefig('out.svg')", - "Comment line should still be extracted as headingText (not fence-aware)", - ); - assert( - partmd.contentBeforeHeading === true, - "Fence delimiter precedes the comment, so contentBeforeHeading must be true", - ); -}); - -// deno-lint-ignore require-await -unitTest("partitionMarkdown - leading heading has no content before it", async () => { - const partmd = partitionMarkdown("# Real Title\n\nsome body text\n"); - assert(partmd.headingText === "Real Title", "Heading text not parsed"); - assert( - partmd.contentBeforeHeading === false, - "Heading is first content, so contentBeforeHeading must be false", - ); -}); - // deno-lint-ignore require-await unitTest("languagesWithClasses - dot-joined syntax", async () => { const md = `\`\`\`{python.marimo} From 5b1e8cebfb5f1c1f6275ddb138d6f9531ca13da0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 17:51:22 +0200 Subject: [PATCH 06/20] docs(embed): explain why findTitle uses the contentBeforeHeading gate Record why findTitle can't mirror fixupFrontMatter's markdown-cells-only guard: it runs on rendered JupyterCellOutput, which carries no cell_type, so the !contentBeforeHeading check is the available equivalent for excluding a code cell's fenced source. --- src/core/jupyter/jupyter-embed.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/core/jupyter/jupyter-embed.ts b/src/core/jupyter/jupyter-embed.ts index 06b59b054f4..8828cfbe57a 100644 --- a/src/core/jupyter/jupyter-embed.ts +++ b/src/core/jupyter/jupyter-embed.ts @@ -656,9 +656,17 @@ function findTitle(cells: JupyterCellOutput[]) { return partitioned.yaml.title as string; } else if (partitioned.headingText && !partitioned.contentBeforeHeading) { // Only promote a heading to the title when nothing precedes it, matching - // how a notebook's front-matter title is derived in jupyter-fixups.ts. - // This excludes a `#` comment inside a code cell's fenced source, whose - // fence delimiter always counts as content before the heading (issue 14577). + // how fixupFrontMatter derives a notebook's front-matter title in + // jupyter-fixups.ts. + // + // fixupFrontMatter has a stronger guard: it only inspects markdown cells + // (cell.cell_type === "markdown"), so a code cell's source is never even + // considered. We can't do that here: findTitle runs on rendered + // JupyterCellOutput, which carries no cell_type, and a code cell's + // `markdown` is its fenced source. The !contentBeforeHeading check covers + // the same case anyway — a code cell's markdown always opens with the + // fence delimiter, which counts as content before any `#` line inside it, + // so a code comment can never be the leading heading (issue 14577). return partitioned.headingText; } } From d095db3f269ed3046629bc1df52cff8df5648c99 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 17:59:45 +0200 Subject: [PATCH 07/20] test(embed): cover notebook title alignment with fixupFrontMatter Add a smoke-all test for an embedded notebook whose only heading has prose before it: the heading must not become the embed title, which falls back to the filename. This is the same rule fixupFrontMatter applies to a notebook's own front-matter title since 24672a4af (#5363/#6411); the embed title now aligns with it. Broaden the changelog entry to describe this general behavior, not just the code-comment case. --- news/changelog-1.10.md | 2 +- .../06/30/embed-heading-after-content.qmd | 31 +++++++++++++++++++ .../2026/06/30/heading-after-content.ipynb | 20 ++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd create mode 100644 tests/docs/smoke-all/2026/06/30/heading-after-content.ipynb diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index c2da347b4d4..970c869e96d 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -104,7 +104,7 @@ All changes included in 1.10: - ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) - ([#14529](https://github.com/quarto-dev/quarto-cli/issues/14529)): Fix bundled Julia engine path leaking into rendered YAML metadata and pandoc log output when running an installed Quarto. The internal subtree-engine filter only matched the source-tree share-path layout (`resources/extension-subtrees/`) and missed installed layouts where the path is `share/extension-subtrees/`. - ([#14576](https://github.com/quarto-dev/quarto-cli/issues/14576)): Fix website render hanging when sidebar titles contain double-underscore (dunder) names. -- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix a code comment being used as the title of an embedded notebook that has no markdown heading or title; the notebook filename is used instead. +- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix an embedded notebook with no explicit title using a heading that isn't its first content (such as a code-cell comment) as the notebook title. The embedded title now follows the same rule as the notebook's own title — a heading is only used when nothing precedes it — and otherwise falls back to the filename. - ([#14582](https://github.com/quarto-dev/quarto-cli/issues/14582)): Fix format detection for extension formats (e.g. `acm-pdf`) in project preview, manuscript notebooks, MECA bundles, and website format ordering. - ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves. - ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment diff --git a/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd b/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd new file mode 100644 index 00000000000..3c20642c4b1 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd @@ -0,0 +1,31 @@ +--- +title: "Embedded notebook title aligns with fixupFrontMatter" +format: html +_quarto: + tests: + html: + ensureFileRegexMatches: + - + # No leading heading, so the Source link falls back to the filename + - 'Source:\s*heading-after-content\.ipynb' + - + # A heading with content before it must not become the title + - 'Source:\s*Some Header' + postRenderCleanup: + - heading-after-content-preview.html + - heading-after-content.out.ipynb + - heading-after-content_files +--- + +The embedded notebook's first markdown cell has prose before its only heading +(`# Some Header`). The heading must not be promoted to the embedded notebook's +title. + +This intentionally aligns the embed title with how a notebook's own +front-matter title is derived. Since #5363 / #6411 (commit 24672a4af), +`fixupFrontMatter` only promotes a heading to the notebook title when no +content precedes it; otherwise no title is set. The embed "Source" link follows +the same rule and falls back to the filename here, rather than using a heading +that isn't really the document title. + +{{< embed heading-after-content.ipynb >}} diff --git a/tests/docs/smoke-all/2026/06/30/heading-after-content.ipynb b/tests/docs/smoke-all/2026/06/30/heading-after-content.ipynb new file mode 100644 index 00000000000..7efb9ec2bc1 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/30/heading-after-content.ipynb @@ -0,0 +1,20 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "m1", + "metadata": {}, + "source": ["Some intro content here.\n", "\n", "# Some Header\n", "\n", "More content after the header.\n"] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c1", + "metadata": {}, + "outputs": [{"name": "stdout", "output_type": "stream", "text": ["hi\n"]}], + "source": ["print('hi')"] + } + ], + "metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, "language_info": {"name": "python"}}, + "nbformat": 4, "nbformat_minor": 5 +} From c1f0c085233e3fe509a0eb9908c715edd132b652 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 18:39:43 +0200 Subject: [PATCH 08/20] docs(embed): scope the findTitle/fixupFrontMatter comparison to the shared gate The comment and changelog claimed findTitle now derives the title the same way fixupFrontMatter does. Only the content-before-heading gate is shared: findTitle still scans every cell (fixupFrontMatter inspects only the first markdown cell) and cannot filter on cell_type, so the two are not equivalent. State that the gate is borrowed and list the divergences, so the comparison is not read as a guarantee of identical behavior. --- news/changelog-1.10.md | 2 +- src/core/jupyter/jupyter-embed.ts | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 970c869e96d..12e87e17433 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -104,7 +104,7 @@ All changes included in 1.10: - ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) - ([#14529](https://github.com/quarto-dev/quarto-cli/issues/14529)): Fix bundled Julia engine path leaking into rendered YAML metadata and pandoc log output when running an installed Quarto. The internal subtree-engine filter only matched the source-tree share-path layout (`resources/extension-subtrees/`) and missed installed layouts where the path is `share/extension-subtrees/`. - ([#14576](https://github.com/quarto-dev/quarto-cli/issues/14576)): Fix website render hanging when sidebar titles contain double-underscore (dunder) names. -- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix an embedded notebook with no explicit title using a heading that isn't its first content (such as a code-cell comment) as the notebook title. The embedded title now follows the same rule as the notebook's own title — a heading is only used when nothing precedes it — and otherwise falls back to the filename. +- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix an embedded notebook with no explicit title using a heading that isn't its first content (such as a code-cell comment) as the notebook title. A heading is now only used as the title when nothing precedes it; otherwise the title falls back to the notebook filename. - ([#14582](https://github.com/quarto-dev/quarto-cli/issues/14582)): Fix format detection for extension formats (e.g. `acm-pdf`) in project preview, manuscript notebooks, MECA bundles, and website format ordering. - ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves. - ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment diff --git a/src/core/jupyter/jupyter-embed.ts b/src/core/jupyter/jupyter-embed.ts index 8828cfbe57a..8d97b7efd72 100644 --- a/src/core/jupyter/jupyter-embed.ts +++ b/src/core/jupyter/jupyter-embed.ts @@ -655,18 +655,17 @@ function findTitle(cells: JupyterCellOutput[]) { if (partitioned.yaml?.title) { return partitioned.yaml.title as string; } else if (partitioned.headingText && !partitioned.contentBeforeHeading) { - // Only promote a heading to the title when nothing precedes it, matching - // how fixupFrontMatter derives a notebook's front-matter title in - // jupyter-fixups.ts. + // Only promote a heading to the title when nothing precedes it. This + // borrows the same content-before-heading gate that fixupFrontMatter uses + // in jupyter-fixups.ts; the two are not otherwise equivalent (this scans + // every cell, fixupFrontMatter inspects only the first markdown cell, and + // it can filter on cell.cell_type === "markdown" where findTitle can't — + // rendered JupyterCellOutput carries no cell_type). // - // fixupFrontMatter has a stronger guard: it only inspects markdown cells - // (cell.cell_type === "markdown"), so a code cell's source is never even - // considered. We can't do that here: findTitle runs on rendered - // JupyterCellOutput, which carries no cell_type, and a code cell's - // `markdown` is its fenced source. The !contentBeforeHeading check covers - // the same case anyway — a code cell's markdown always opens with the - // fence delimiter, which counts as content before any `#` line inside it, - // so a code comment can never be the leading heading (issue 14577). + // The gate is what fixes issue 14577: a code cell's `markdown` is its + // fenced source, whose opening fence delimiter always counts as content + // before any `#` line inside it, so a code comment can never be mistaken + // for the leading heading. return partitioned.headingText; } } From 076dfe2408f2598837becfd0acc4d8b0d9798f25 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 13:56:26 +0200 Subject: [PATCH 09/20] fix(embed): make markdownWithExtractedHeading fence-aware --- src/core/pandoc/pandoc-partition.ts | 25 ++++++++++ tests/unit/pandoc-partition.test.ts | 75 ++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 0749ceddf54..0d3d89911b5 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -74,13 +74,38 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { }; } +const kFenceOpenRegex = /^(`{3,}|~{3,})/; + export function markdownWithExtractedHeading(markdown: string) { const mdLines: string[] = []; let headingText: string | undefined; let headingAttr: PandocAttr | undefined; let contentBeforeHeading = false; + let fence: { char: string; length: number } | undefined; for (const line of lines(markdown)) { + // Skip heading detection for any line inside a fenced code block + // (``` or ~~~, CommonMark-style) so a line that merely looks like an + // ATX heading -- e.g. a Python `# comment` in a code cell's fenced + // source -- is never mistaken for the document heading. + if (fence) { + mdLines.push(line); + const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/); + if ( + closeMatch && closeMatch[1][0] === fence.char && + closeMatch[1].length >= fence.length + ) { + fence = undefined; + } + continue; + } + const openMatch = !headingText && line.match(kFenceOpenRegex); + if (openMatch) { + fence = { char: openMatch[1][0], length: openMatch[1].length }; + mdLines.push(line); + continue; + } + if (!headingText) { if (line.match(/^\#{1,}\s/)) { const parsedHeading = parsePandocTitle(line); diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index 44ddd6881db..a39de17bb2b 100644 --- a/tests/unit/pandoc-partition.test.ts +++ b/tests/unit/pandoc-partition.test.ts @@ -4,9 +4,9 @@ * Copyright (C) 2020-2022 Posit Software, PBC * */ -import { assert } from "testing/asserts"; +import { assert, assertEquals } from "testing/asserts"; import { Metadata } from "../../src/config/types.ts"; -import { languagesWithClasses, partitionMarkdown } from "../../src/core/pandoc/pandoc-partition.ts"; +import { languagesWithClasses, markdownWithExtractedHeading, partitionMarkdown } from "../../src/core/pandoc/pandoc-partition.ts"; import { unitTest } from "../test.ts"; // deno-lint-ignore require-await @@ -73,3 +73,74 @@ y = 2 assert(result.has("python"), "Should have language 'python'"); assert(result.get("python") === "foo", "python should have class 'foo'"); }); + +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - ignores an ATX-heading-like line inside a fenced code block", + async () => { + const markdown = [ + "```{python}", + "print('hello')", + "# plt.savefig('x.svg')", + "```", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals( + result.headingText, + undefined, + "A comment inside a fenced code block must not be treated as a heading", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - still extracts a real heading after a closed fenced code block", + async () => { + const markdown = [ + "```{python}", + "print('hello')", + "```", + "", + "# Real Heading", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals(result.headingText, "Real Heading"); + assert( + result.contentBeforeHeading, + "The fenced block content should still count as content before the heading", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - still promotes a heading that follows a prose paragraph (no fence)", + async () => { + const markdown = [ + "Some intro paragraph.", + "", + "# Real Heading", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals( + result.headingText, + "Real Heading", + "Non-fenced prose before a heading must not prevent heading extraction", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - ignores an ATX-heading-like line inside a tilde-fenced code block", + async () => { + const markdown = [ + "~~~python", + "# not a heading", + "~~~", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals(result.headingText, undefined); + }, +); From a0f444083c70331669ff88402a2286a5675e7bb4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 14:21:02 +0200 Subject: [PATCH 10/20] fix(embed): drop the contentBeforeHeading gate from findTitle now that heading extraction is fence-aware --- src/core/jupyter/jupyter-embed.ts | 18 ++++++------------ src/core/pandoc/pandoc-partition.ts | 8 +++----- src/core/pandoc/types.ts | 1 - 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/core/jupyter/jupyter-embed.ts b/src/core/jupyter/jupyter-embed.ts index 8d97b7efd72..5872b9eb156 100644 --- a/src/core/jupyter/jupyter-embed.ts +++ b/src/core/jupyter/jupyter-embed.ts @@ -654,18 +654,12 @@ function findTitle(cells: JupyterCellOutput[]) { const partitioned = partitionMarkdown(cell.markdown); if (partitioned.yaml?.title) { return partitioned.yaml.title as string; - } else if (partitioned.headingText && !partitioned.contentBeforeHeading) { - // Only promote a heading to the title when nothing precedes it. This - // borrows the same content-before-heading gate that fixupFrontMatter uses - // in jupyter-fixups.ts; the two are not otherwise equivalent (this scans - // every cell, fixupFrontMatter inspects only the first markdown cell, and - // it can filter on cell.cell_type === "markdown" where findTitle can't — - // rendered JupyterCellOutput carries no cell_type). - // - // The gate is what fixes issue 14577: a code cell's `markdown` is its - // fenced source, whose opening fence delimiter always counts as content - // before any `#` line inside it, so a code comment can never be mistaken - // for the leading heading. + } else if (partitioned.headingText) { + // A code cell's `markdown` is its fenced source, so a comment that + // looks like an ATX heading (e.g. `# plt.savefig(...)`) never reaches + // here as `headingText` -- markdownWithExtractedHeading (via + // partitionMarkdown) skips heading detection inside fenced code + // blocks. See #14577. return partitioned.headingText; } } diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 0d3d89911b5..2292db63add 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -53,10 +53,9 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { markdown = partitioned ? partitioned.markdown : markdown; // extract heading - const { lines, headingText, headingAttr, contentBeforeHeading } = - markdownWithExtractedHeading( - markdown, - ); + const { lines, headingText, headingAttr } = markdownWithExtractedHeading( + markdown, + ); // does this contain refs? const containsRefs = lines.some((line) => @@ -67,7 +66,6 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { yaml: (partitioned ? readYamlFromMarkdown(partitioned.yaml) : undefined), headingText, headingAttr, - contentBeforeHeading, containsRefs, markdown: lines.join("\n"), srcMarkdownNoYaml: partitioned?.markdown || "", diff --git a/src/core/pandoc/types.ts b/src/core/pandoc/types.ts index 33adfeaac52..9a298fa9ea8 100644 --- a/src/core/pandoc/types.ts +++ b/src/core/pandoc/types.ts @@ -17,7 +17,6 @@ export interface PartitionedMarkdown { yaml?: Metadata; headingText?: string; headingAttr?: PandocAttr; - contentBeforeHeading?: boolean; containsRefs: boolean; markdown: string; srcMarkdownNoYaml: string; From dea92f0c501ad141b833f482fd8be90821f7851f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 14:27:31 +0200 Subject: [PATCH 11/20] test(embed): assert prose-before-heading is still promoted in embed titles --- .../06/30/embed-heading-after-content.qmd | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd b/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd index 3c20642c4b1..c1c498523c7 100644 --- a/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd +++ b/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd @@ -1,15 +1,13 @@ --- -title: "Embedded notebook title aligns with fixupFrontMatter" +title: "Embedded notebook title still promotes a heading after prose" format: html _quarto: tests: html: ensureFileRegexMatches: - - # No leading heading, so the Source link falls back to the filename - - 'Source:\s*heading-after-content\.ipynb' - - - # A heading with content before it must not become the title + # A heading is still promoted to the title even when a prose + # paragraph precedes it in the notebook's markdown cell. - 'Source:\s*Some Header' postRenderCleanup: - heading-after-content-preview.html @@ -18,14 +16,11 @@ _quarto: --- The embedded notebook's first markdown cell has prose before its only heading -(`# Some Header`). The heading must not be promoted to the embedded notebook's -title. - -This intentionally aligns the embed title with how a notebook's own -front-matter title is derived. Since #5363 / #6411 (commit 24672a4af), -`fixupFrontMatter` only promotes a heading to the notebook title when no -content precedes it; otherwise no title is set. The embed "Source" link follows -the same rule and falls back to the filename here, rather than using a heading -that isn't really the document title. +(`# Some Header`). Unlike `fixupFrontMatter` (used for a notebook's own +front-matter title, see #5363 / #6411, commit `24672a4af`), the embed "Source" +link title (`findTitle` in `jupyter-embed.ts`) does not require the heading to +be the very first thing in the cell — it promotes the first heading it finds +in any cell. Only content inside a fenced code block (a comment that merely +looks like an ATX heading, see #14577) is excluded from heading detection. {{< embed heading-after-content.ipynb >}} From c8002286666c51c0ba5ab17f28d2fd01dbb68756 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 14:32:51 +0200 Subject: [PATCH 12/20] docs(changelog): describe the fence-aware #14577 fix --- news/changelog-1.10.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 12e87e17433..8eff181dc0c 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -104,7 +104,7 @@ All changes included in 1.10: - ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) - ([#14529](https://github.com/quarto-dev/quarto-cli/issues/14529)): Fix bundled Julia engine path leaking into rendered YAML metadata and pandoc log output when running an installed Quarto. The internal subtree-engine filter only matched the source-tree share-path layout (`resources/extension-subtrees/`) and missed installed layouts where the path is `share/extension-subtrees/`. - ([#14576](https://github.com/quarto-dev/quarto-cli/issues/14576)): Fix website render hanging when sidebar titles contain double-underscore (dunder) names. -- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix an embedded notebook with no explicit title using a heading that isn't its first content (such as a code-cell comment) as the notebook title. A heading is now only used as the title when nothing precedes it; otherwise the title falls back to the notebook filename. +- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix an embedded notebook with no explicit title using a Python comment inside a code cell as the notebook title. Heading extraction now skips fenced code blocks, so a comment that merely looks like a heading is never promoted; a real markdown heading anywhere in the notebook is still used as before. - ([#14582](https://github.com/quarto-dev/quarto-cli/issues/14582)): Fix format detection for extension formats (e.g. `acm-pdf`) in project preview, manuscript notebooks, MECA bundles, and website format ordering. - ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves. - ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment From 283629de819c6ed71df9c13cc6869f967bcf25c0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 14:44:07 +0200 Subject: [PATCH 13/20] docs(pandoc): note why contentBeforeHeading is not forwarded by partitionMarkdown Prevents a future reader from removing the seemingly-unused field and breaking fixupFrontMatter's own gate, flagged in final branch review. --- src/core/pandoc/pandoc-partition.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 2292db63add..ee2c6991b85 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -131,6 +131,8 @@ export function markdownWithExtractedHeading(markdown: string) { lines: mdLines, headingText, headingAttr, + // Consumed only by fixupFrontMatter's direct call to this function; + // partitionMarkdown intentionally does not forward it (see PartitionedMarkdown). contentBeforeHeading, }; } From 2967a28634ccc602ff64ae0188c83f7700b230b9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 15:08:37 +0200 Subject: [PATCH 14/20] refactor(embed): trim inline comments that restate the diff instead of the code Both comments explained the fix's history and cited the issue number rather than something non-obvious about the code itself; that context belongs in the commit trail, not the source. --- src/core/jupyter/jupyter-embed.ts | 7 ++----- src/core/pandoc/pandoc-partition.ts | 7 +++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/core/jupyter/jupyter-embed.ts b/src/core/jupyter/jupyter-embed.ts index 5872b9eb156..9b8fd47cdb2 100644 --- a/src/core/jupyter/jupyter-embed.ts +++ b/src/core/jupyter/jupyter-embed.ts @@ -655,11 +655,8 @@ function findTitle(cells: JupyterCellOutput[]) { if (partitioned.yaml?.title) { return partitioned.yaml.title as string; } else if (partitioned.headingText) { - // A code cell's `markdown` is its fenced source, so a comment that - // looks like an ATX heading (e.g. `# plt.savefig(...)`) never reaches - // here as `headingText` -- markdownWithExtractedHeading (via - // partitionMarkdown) skips heading detection inside fenced code - // blocks. See #14577. + // Fence-aware heading detection means a fenced code comment that + // looks like a heading never reaches here as headingText. return partitioned.headingText; } } diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index ee2c6991b85..ac68db974f1 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -82,10 +82,9 @@ export function markdownWithExtractedHeading(markdown: string) { let fence: { char: string; length: number } | undefined; for (const line of lines(markdown)) { - // Skip heading detection for any line inside a fenced code block - // (``` or ~~~, CommonMark-style) so a line that merely looks like an - // ATX heading -- e.g. a Python `# comment` in a code cell's fenced - // source -- is never mistaken for the document heading. + // Skip heading detection while inside a fenced code block (``` or ~~~, + // CommonMark-style) so a line that merely looks like a heading is + // never mistaken for the document heading. if (fence) { mdLines.push(line); const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/); From 3dbbd72865655f736f2840f25b90053419875ccd Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 15:09:08 +0200 Subject: [PATCH 15/20] test(pandoc-partition): add direct coverage for markdownWithExtractedHeading's other cases Prior tests exercised the ATX heading path only indirectly through partitionMarkdown, and the fence work this branch added only covered fenced-block cases. Setext headings, the no-heading case, and contentBeforeHeading's false branch had no direct test. --- tests/unit/pandoc-partition.test.ts | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index a39de17bb2b..ae85f016ffd 100644 --- a/tests/unit/pandoc-partition.test.ts +++ b/tests/unit/pandoc-partition.test.ts @@ -144,3 +144,47 @@ unitTest( assertEquals(result.headingText, undefined); }, ); + +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - extracts a setext-style heading", + async () => { + const markdown = [ + "Real Heading", + "===", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals(result.headingText, "Real Heading"); + assertEquals(result.lines, []); + }, +); + +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - no heading present", + async () => { + const markdown = [ + "Just a paragraph.", + "", + "Another paragraph.", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals(result.headingText, undefined); + assertEquals(result.lines, markdown.split("\n")); + }, +); + +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - contentBeforeHeading is false when the heading is the first line", + async () => { + const markdown = [ + "# Real Heading", + "", + "Some body text.", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals(result.headingText, "Real Heading"); + assertEquals(result.contentBeforeHeading, false); + }, +); From 6b309f2874788a2e9601049e98bd20dbb5f69c42 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 15:21:23 +0200 Subject: [PATCH 16/20] docs(pandoc): note indented fences are intentionally out of scope CommonMark allows fences indented up to 3 spaces, but every fence this scanner sees comes from code cell source, which always starts at column 0. --- src/core/pandoc/pandoc-partition.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index ac68db974f1..c552e58e7ae 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -72,6 +72,9 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { }; } +// CommonMark also allows fences indented up to 3 spaces; intentionally +// unsupported here since the fences this scanner sees (code cell source) +// always start at column 0. const kFenceOpenRegex = /^(`{3,}|~{3,})/; export function markdownWithExtractedHeading(markdown: string) { From 2d928e486c263896e8137162c494f66e042994ad Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 15:29:28 +0200 Subject: [PATCH 17/20] fix(pandoc): recognize fence markers indented up to 3 spaces markdownWithExtractedHeading scanned every notebook cell, not just code cells. Code cells always emit fences at column 0, but markdown cells carry arbitrary user-authored content, where a fenced block nested in a list item can have its fence markers indented while a line inside stays at column 0. That line was previously invisible to fence tracking and could still be mistaken for a heading. --- src/core/pandoc/pandoc-partition.ts | 10 +++++----- tests/unit/pandoc-partition.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index c552e58e7ae..7488c79dfda 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -72,10 +72,10 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { }; } -// CommonMark also allows fences indented up to 3 spaces; intentionally -// unsupported here since the fences this scanner sees (code cell source) -// always start at column 0. -const kFenceOpenRegex = /^(`{3,}|~{3,})/; +// CommonMark allows fence markers indented up to 3 spaces (content lines +// inside the fence can have any indentation, including none). +const kFenceOpenRegex = /^ {0,3}(`{3,}|~{3,})/; +const kFenceCloseRegex = /^ {0,3}(`{3,}|~{3,})\s*$/; export function markdownWithExtractedHeading(markdown: string) { const mdLines: string[] = []; @@ -90,7 +90,7 @@ export function markdownWithExtractedHeading(markdown: string) { // never mistaken for the document heading. if (fence) { mdLines.push(line); - const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/); + const closeMatch = line.match(kFenceCloseRegex); if ( closeMatch && closeMatch[1][0] === fence.char && closeMatch[1].length >= fence.length diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index ae85f016ffd..eaebc0d286f 100644 --- a/tests/unit/pandoc-partition.test.ts +++ b/tests/unit/pandoc-partition.test.ts @@ -145,6 +145,22 @@ unitTest( }, ); +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - ignores an ATX-heading-like line inside a fenced code block whose fence markers are indented up to 3 spaces", + async () => { + const markdown = [ + "- a list item with a nested code block", + "", + " ```python", + "# not a heading", + " ```", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals(result.headingText, undefined); + }, +); + // deno-lint-ignore require-await unitTest( "markdownWithExtractedHeading - extracts a setext-style heading", From 5e4158b24c21eac71e2fae962ddfb91f5c5a5795 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 15:38:42 +0200 Subject: [PATCH 18/20] test(pandoc-partition): cover a fence closed by an indented marker Distinct from the indented-open case already covered: opening fence at column 0, closing marker indented, followed by a real heading. Confirms the indented fence-close fix also resolves the case where a heading after such a fence would otherwise never be reached. --- tests/unit/pandoc-partition.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index eaebc0d286f..4da658dfefa 100644 --- a/tests/unit/pandoc-partition.test.ts +++ b/tests/unit/pandoc-partition.test.ts @@ -161,6 +161,21 @@ unitTest( }, ); +// deno-lint-ignore require-await +unitTest( + "markdownWithExtractedHeading - closes a fence whose closing marker is indented, then extracts the following heading", + async () => { + const markdown = [ + "```python", + "code", + " ```", + "# Real Heading", + ].join("\n"); + const result = markdownWithExtractedHeading(markdown); + assertEquals(result.headingText, "Real Heading"); + }, +); + // deno-lint-ignore require-await unitTest( "markdownWithExtractedHeading - extracts a setext-style heading", From 9d9284acc3150103e077c68f40735c070be3e6e4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 16:01:03 +0200 Subject: [PATCH 19/20] Remove unnecessary comments --- src/core/jupyter/jupyter-embed.ts | 2 -- src/core/pandoc/pandoc-partition.ts | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/core/jupyter/jupyter-embed.ts b/src/core/jupyter/jupyter-embed.ts index 9b8fd47cdb2..718c2f03105 100644 --- a/src/core/jupyter/jupyter-embed.ts +++ b/src/core/jupyter/jupyter-embed.ts @@ -655,8 +655,6 @@ function findTitle(cells: JupyterCellOutput[]) { if (partitioned.yaml?.title) { return partitioned.yaml.title as string; } else if (partitioned.headingText) { - // Fence-aware heading detection means a fenced code comment that - // looks like a heading never reaches here as headingText. return partitioned.headingText; } } diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 7488c79dfda..2719f3b878b 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -63,7 +63,7 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown { ); return { - yaml: (partitioned ? readYamlFromMarkdown(partitioned.yaml) : undefined), + yaml: partitioned ? readYamlFromMarkdown(partitioned.yaml) : undefined, headingText, headingAttr, containsRefs, @@ -133,8 +133,6 @@ export function markdownWithExtractedHeading(markdown: string) { lines: mdLines, headingText, headingAttr, - // Consumed only by fixupFrontMatter's direct call to this function; - // partitionMarkdown intentionally does not forward it (see PartitionedMarkdown). contentBeforeHeading, }; } From 1a430de883085c6cd784b4357992b2a854aea14b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Jul 2026 16:54:09 +0200 Subject: [PATCH 20/20] Adapt changelog --- news/changelog-1.10.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 8eff181dc0c..4655b2db756 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -16,7 +16,7 @@ All changes included in 1.10: - ([#14468](https://github.com/quarto-dev/quarto-cli/issues/14468)): The `axe` accessibility report UI (HTML overlay, revealjs report slide, dashboard offcanvas) now uses its own theme-independent colors instead of inheriting from `brand` or theme. Keeps the report readable regardless of page styling, and stops `axe` from clobbering brand colors set via `_brand.yml`. - ([#14602](https://github.com/quarto-dev/quarto-cli/pull/14602), [#14632](https://github.com/quarto-dev/quarto-cli/pull/14632)): Fix ORCID profile link having no accessible name for screen readers in HTML, Reveal.js, and ipynb title-block author metadata. (author: @mcanouil for #14602) -- ([#14604](https://github.com/quarto-dev/quarto-cli/issues/14604)): The `axe` accessibility report UI now shows each violation's WCAG conformance level (e.g. `WCAG 2.0 AA (1.4.3)`) or `Best Practice`, derived from the violation's axe-core tags. +- ([#14604](https://github.com/quarto-dev/quarto-cli/issues/14604)): The `axe` accessibility report UI now shows each violation's WCAG conformance level (e.g. `WCAG 2.0 AA (1.4.3)`) or `Best Practice`, derived from the violation's axe-core tags. ## Formats @@ -104,7 +104,7 @@ All changes included in 1.10: - ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) - ([#14529](https://github.com/quarto-dev/quarto-cli/issues/14529)): Fix bundled Julia engine path leaking into rendered YAML metadata and pandoc log output when running an installed Quarto. The internal subtree-engine filter only matched the source-tree share-path layout (`resources/extension-subtrees/`) and missed installed layouts where the path is `share/extension-subtrees/`. - ([#14576](https://github.com/quarto-dev/quarto-cli/issues/14576)): Fix website render hanging when sidebar titles contain double-underscore (dunder) names. -- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix an embedded notebook with no explicit title using a Python comment inside a code cell as the notebook title. Heading extraction now skips fenced code blocks, so a comment that merely looks like a heading is never promoted; a real markdown heading anywhere in the notebook is still used as before. +- ([#14577](https://github.com/quarto-dev/quarto-cli/issues/14577)): Fix an embedded notebook with no explicit title using a Python comment inside a code cell as the notebook title. Heading extraction now skips fenced code blocks, so a comment that merely looks like a heading is never promoted; first real markdown heading anywhere in the notebook markdown body is still used as before if not `title` provided. - ([#14582](https://github.com/quarto-dev/quarto-cli/issues/14582)): Fix format detection for extension formats (e.g. `acm-pdf`) in project preview, manuscript notebooks, MECA bundles, and website format ordering. - ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves. - ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment