From 62c53aaddcc94c1b1ef0526f665a5bbf5d030929 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 30 Jun 2026 16:53:58 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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; } }