Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
62c53aa
fix(embed): don't use a code-cell comment as the embedded notebook ti…
cderv Jun 30, 2026
26286e2
test(embed): use safeRemoveSync in render-embed teardowns
cderv Jun 30, 2026
cdcaa8c
test(smoke-all): make postRenderCleanup remove directories
cderv Jun 30, 2026
f4c6de9
test: move #14577 regression test into smoke-all
cderv Jun 30, 2026
9f7269c
test: drop weak pandoc-partition unit tests
cderv Jun 30, 2026
5b1e8ce
docs(embed): explain why findTitle uses the contentBeforeHeading gate
cderv Jun 30, 2026
d095db3
test(embed): cover notebook title alignment with fixupFrontMatter
cderv Jun 30, 2026
c1f0c08
docs(embed): scope the findTitle/fixupFrontMatter comparison to the s…
cderv Jun 30, 2026
076dfe2
fix(embed): make markdownWithExtractedHeading fence-aware
cderv Jul 1, 2026
a0f4440
fix(embed): drop the contentBeforeHeading gate from findTitle now tha…
cderv Jul 1, 2026
dea92f0
test(embed): assert prose-before-heading is still promoted in embed t…
cderv Jul 1, 2026
c800228
docs(changelog): describe the fence-aware #14577 fix
cderv Jul 1, 2026
283629d
docs(pandoc): note why contentBeforeHeading is not forwarded by parti…
cderv Jul 1, 2026
2967a28
refactor(embed): trim inline comments that restate the diff instead o…
cderv Jul 1, 2026
3dbbd72
test(pandoc-partition): add direct coverage for markdownWithExtracted…
cderv Jul 1, 2026
6b309f2
docs(pandoc): note indented fences are intentionally out of scope
cderv Jul 1, 2026
2d928e4
fix(pandoc): recognize fence markers indented up to 3 spaces
cderv Jul 1, 2026
5e4158b
test(pandoc-partition): cover a fence closed by an indented marker
cderv Jul 1, 2026
9d9284a
Remove unnecessary comments
cderv Jul 1, 2026
1a430de
Adapt changelog
cderv Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down 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 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
29 changes: 28 additions & 1 deletion src/core/pandoc/pandoc-partition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -72,13 +72,40 @@ export function partitionMarkdown(markdown: string): PartitionedMarkdown {
};
}

// 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[] = [];
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 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(kFenceCloseRegex);
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);
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 >}}
26 changes: 26 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,26 @@
---
title: "Embedded notebook title still promotes a heading after prose"
format: html
_quarto:
tests:
html:
ensureFileRegexMatches:
-
# 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
- heading-after-content.out.ipynb
- heading-after-content_files
---

The embedded notebook's first markdown cell has prose before its only heading
(`# 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 >}}
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
150 changes: 148 additions & 2 deletions tests/unit/pandoc-partition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,3 +73,149 @@ 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);
},
);

// 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 - 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",
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);
},
);
Loading