diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 5d5917be61a..12e87e17433 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 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 718c2f03105..8d97b7efd72 100644 --- a/src/core/jupyter/jupyter-embed.ts +++ b/src/core/jupyter/jupyter-embed.ts @@ -654,7 +654,18 @@ 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. 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. 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/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/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 +} diff --git a/tests/docs/smoke-all/2026/06/30/notebook.ipynb b/tests/docs/smoke-all/2026/06/30/notebook.ipynb new file mode 100644 index 00000000000..776d3c30c5f --- /dev/null +++ b/tests/docs/smoke-all/2026/06/30/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..db80d860e6b 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(); }, @@ -128,7 +129,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(); }, 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 }); } } }