Skip to content
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 12 additions & 1 deletion src/core/jupyter/jupyter-embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
8 changes: 5 additions & 3 deletions src/core/pandoc/pandoc-partition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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 || "",
Expand Down
1 change: 1 addition & 0 deletions src/core/pandoc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface PartitionedMarkdown {
yaml?: Metadata;
headingText?: string;
headingAttr?: PandocAttr;
contentBeforeHeading?: boolean;
containsRefs: boolean;
markdown: string;
srcMarkdownNoYaml: string;
Expand Down
29 changes: 29 additions & 0 deletions tests/docs/smoke-all/2026/06/30/14577.qmd
Original file line number Diff line number Diff line change
@@ -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 >}}
31 changes: 31 additions & 0 deletions tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd
Original file line number Diff line number Diff line change
@@ -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 >}}
20 changes: 20 additions & 0 deletions tests/docs/smoke-all/2026/06/30/heading-after-content.ipynb
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions tests/docs/smoke-all/2026/06/30/notebook.ipynb
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 7 additions & 6 deletions tests/smoke/embed/render-embed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
},
Expand Down Expand Up @@ -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();
},
Expand Down Expand Up @@ -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();
},
Expand Down
4 changes: 3 additions & 1 deletion tests/smoke/smoke-all.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
}
Expand Down
Loading