diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 5d5917be61a..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,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 diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 08cbdf86572..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, @@ -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); 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..c1c498523c7 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/30/embed-heading-after-content.qmd @@ -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 >}} 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 }); } } } diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index 44ddd6881db..4da658dfefa 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,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); + }, +);