From 5f03c82233681dd52d4fb3241eb5bc4fce0f3e88 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 5 Oct 2026 14:53:35 +0200 Subject: [PATCH 01/12] Add characterization tests for front matter re-applied before pandoc Before calling pandoc, runPandoc re-applies the executed front matter over the merged metadata so knitr inline R results in YAML reach the output. Several guards protect values Quarto computes itself from that overwrite. These tests pin today's behavior ahead of changing how that step merges values (#11139): - a key set at top level and under `format: html:` keeps the format value - knitr inline R in YAML is evaluated for title, a custom key, and a nested key - the same inline R values survive a render that reuses frozen results - a book chapter title keeps its computed chapter number - website categories from project, directory and document all show up They pass on main. --- .../docs/smoke-all/metadata-merge/.gitignore | 2 + .../book-chapter-title/.gitignore | 4 ++ .../book-chapter-title/_quarto.yml | 12 ++++ .../book-chapter-title/chapter.qmd | 14 +++++ .../book-chapter-title/index.qmd | 3 + .../metadata-merge/format-key-precedence.qmd | 18 ++++++ .../metadata-merge/knitr-inline-yaml.qmd | 26 +++++++++ .../website-categories/.gitignore | 4 ++ .../website-categories/_quarto.yml | 8 +++ .../website-categories/index.qmd | 5 ++ .../website-categories/posts/_metadata.yml | 2 + .../website-categories/posts/post.qmd | 16 +++++ tests/smoke/render/render-freeze.test.ts | 58 ++++++++++++++++++- 13 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 tests/docs/smoke-all/metadata-merge/.gitignore create mode 100644 tests/docs/smoke-all/metadata-merge/book-chapter-title/.gitignore create mode 100644 tests/docs/smoke-all/metadata-merge/book-chapter-title/_quarto.yml create mode 100644 tests/docs/smoke-all/metadata-merge/book-chapter-title/chapter.qmd create mode 100644 tests/docs/smoke-all/metadata-merge/book-chapter-title/index.qmd create mode 100644 tests/docs/smoke-all/metadata-merge/format-key-precedence.qmd create mode 100644 tests/docs/smoke-all/metadata-merge/knitr-inline-yaml.qmd create mode 100644 tests/docs/smoke-all/metadata-merge/website-categories/.gitignore create mode 100644 tests/docs/smoke-all/metadata-merge/website-categories/_quarto.yml create mode 100644 tests/docs/smoke-all/metadata-merge/website-categories/index.qmd create mode 100644 tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml create mode 100644 tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd diff --git a/tests/docs/smoke-all/metadata-merge/.gitignore b/tests/docs/smoke-all/metadata-merge/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/metadata-merge/book-chapter-title/.gitignore b/tests/docs/smoke-all/metadata-merge/book-chapter-title/.gitignore new file mode 100644 index 00000000000..7359d935c76 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/book-chapter-title/.gitignore @@ -0,0 +1,4 @@ +/.quarto/ +_book/ + +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/metadata-merge/book-chapter-title/_quarto.yml b/tests/docs/smoke-all/metadata-merge/book-chapter-title/_quarto.yml new file mode 100644 index 00000000000..59a8fd4797a --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/book-chapter-title/_quarto.yml @@ -0,0 +1,12 @@ +project: + type: book + +book: + title: "Metadata merge book" + chapters: + - index.qmd + - chapter.qmd + +format: + html: + number-sections: true diff --git a/tests/docs/smoke-all/metadata-merge/book-chapter-title/chapter.qmd b/tests/docs/smoke-all/metadata-merge/book-chapter-title/chapter.qmd new file mode 100644 index 00000000000..bcf6ae7a0af --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/book-chapter-title/chapter.qmd @@ -0,0 +1,14 @@ +--- +title: Chapter from YAML +_quarto: + tests: + html: + ensureHtmlElements: + - ['h1.title span.chapter-number'] + - [] + ensureHtmlElementContents: + selectors: ['h1.title'] + matches: ['^\s*1\s+Chapter from YAML\s*$'] +--- + +A book chapter whose title comes from the YAML `title` key keeps the chapter number Quarto computes for it. The document's own `title` value must not replace the numbered title. diff --git a/tests/docs/smoke-all/metadata-merge/book-chapter-title/index.qmd b/tests/docs/smoke-all/metadata-merge/book-chapter-title/index.qmd new file mode 100644 index 00000000000..ff4bd80cfb7 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/book-chapter-title/index.qmd @@ -0,0 +1,3 @@ +# Preface {.unnumbered} + +Home page of the fixture. The assertions live in `chapter.qmd`. diff --git a/tests/docs/smoke-all/metadata-merge/format-key-precedence.qmd b/tests/docs/smoke-all/metadata-merge/format-key-precedence.qmd new file mode 100644 index 00000000000..5a9879960f6 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/format-key-precedence.qmd @@ -0,0 +1,18 @@ +--- +title: Top-level title +subtitle: Top-level subtitle +format: + html: + title: Format title + subtitle: Format subtitle +_quarto: + tests: + html: + ensureFileRegexMatches: + - + - '

Format title

' + - '

Format subtitle

' + - [] +--- + +A key set both at the top level and under `format: html:` in the same document takes the format-level value in the output. The document is not executed (markdown engine), so this checks the merge order of document metadata on its own: the executed-front-matter substitution done before calling pandoc must not bring the top-level value back. diff --git a/tests/docs/smoke-all/metadata-merge/knitr-inline-yaml.qmd b/tests/docs/smoke-all/metadata-merge/knitr-inline-yaml.qmd new file mode 100644 index 00000000000..f4f005a6b02 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/knitr-inline-yaml.qmd @@ -0,0 +1,26 @@ +--- +title: "Title `r 1 + 1`" +custom-key: "Custom `{r} 2 + 3`" +nested: + inner: "Nested `r 3 * 3`" +engine: knitr +_quarto: + tests: + html: + ensureFileRegexMatches: + - + - '

Title 2

' + - '
\s*

Custom 5

' + - '
\s*

Nested 9

' + - [] +--- + +Inline R expressions in YAML front matter are evaluated by knitr, and the evaluated values must reach pandoc: the standard `title`, a custom top-level key, and a nested key with no project-level counterpart. + +::: {#custom-key} +{{< meta custom-key >}} +::: + +::: {#nested-inner} +{{< meta nested.inner >}} +::: diff --git a/tests/docs/smoke-all/metadata-merge/website-categories/.gitignore b/tests/docs/smoke-all/metadata-merge/website-categories/.gitignore new file mode 100644 index 00000000000..812977e5f74 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/website-categories/.gitignore @@ -0,0 +1,4 @@ +/.quarto/ +_site/ + +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/metadata-merge/website-categories/_quarto.yml b/tests/docs/smoke-all/metadata-merge/website-categories/_quarto.yml new file mode 100644 index 00000000000..b0ae96b01d0 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/website-categories/_quarto.yml @@ -0,0 +1,8 @@ +project: + type: website + +website: + title: "metadata-merge categories" + +categories: + - from-project diff --git a/tests/docs/smoke-all/metadata-merge/website-categories/index.qmd b/tests/docs/smoke-all/metadata-merge/website-categories/index.qmd new file mode 100644 index 00000000000..79c0997e309 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/website-categories/index.qmd @@ -0,0 +1,5 @@ +--- +title: Website categories merge +--- + +Home page of the fixture. The assertions live in `posts/post.qmd`. diff --git a/tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml b/tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml new file mode 100644 index 00000000000..c9f4ef99b15 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml @@ -0,0 +1,2 @@ +categories: + - from-directory diff --git a/tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd b/tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd new file mode 100644 index 00000000000..706900ed932 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd @@ -0,0 +1,16 @@ +--- +title: A post +categories: + - from-document +_quarto: + tests: + html: + ensureFileRegexMatches: + - + - 'class="quarto-category">from-project<' + - 'class="quarto-category">from-directory<' + - 'class="quarto-category">from-document<' + - [] +--- + +In a website, `categories` from `_quarto.yml`, the directory `_metadata.yml` and the document front matter are all shown on the page. diff --git a/tests/smoke/render/render-freeze.test.ts b/tests/smoke/render/render-freeze.test.ts index 82979a58444..4babf9ca3d6 100644 --- a/tests/smoke/render/render-freeze.test.ts +++ b/tests/smoke/render/render-freeze.test.ts @@ -12,7 +12,11 @@ import { Metadata } from "../../../src/config/types.ts"; import { removeIfEmptyDir, safeRemoveIfExists } from "../../../src/core/path.ts"; import { runQuarto } from "../../quarto-cmd.ts"; import { ExecuteOutput, Verify } from "../../test.ts"; -import { noErrors, outputCreated } from "../../verify.ts"; +import { + ensureHtmlElementContents, + noErrors, + outputCreated, +} from "../../verify.ts"; import { testRender } from "./render.ts"; const regex = /output file: .*\.knit\.md/m; @@ -185,3 +189,55 @@ testRender( teardown: testContext.teardown, }, ); + +// Inline R expressions in YAML front matter stay evaluated when the render +// reuses the frozen execution results instead of running knitr again +const inlineYamlFileName = "freeze-inline-yaml"; +const inlineYamlDir = Deno.makeTempDirSync(); +const inlineYamlPath = join(inlineYamlDir, `${inlineYamlFileName}.qmd`); +const inlineYamlContext = testFileContext( + inlineYamlPath, + { + title: "Title `r 1 + 1`", + "custom-key": "Custom `r 2 + 3`", + format: "html", + freeze: true, + }, + [ + "::: {#custom-key}", + "{{< meta custom-key >}}", + ":::", + "", + "```{r}", + "1 + 1", + "```", + "", + ], +); +testRender( + inlineYamlDir + "/", + "html", + false, + [ + noErrors, + useFrozen, + ensureHtmlElementContents( + join(inlineYamlDir, `${inlineYamlFileName}.html`), + { selectors: ["h1.title"], matches: [/^\s*Title 2\s*$/] }, + ), + ensureHtmlElementContents( + join(inlineYamlDir, `${inlineYamlFileName}.html`), + { selectors: ["#custom-key"], matches: [/^\s*Custom 5\s*$/] }, + ), + ], + { + name: "frozen inline R in YAML", + setup: inlineYamlContext.setup, + teardown: async () => { + await inlineYamlContext.teardown(); + const freezerDir = join(inlineYamlDir, "_freeze"); + safeRemoveIfExists(join(freezerDir, inlineYamlFileName)); + removeIfEmptyDir(freezerDir); + }, + }, +); From ed0e52b41db324c8d955a31d178dd4b2063294ce Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Tue, 9 Sep 2025 12:21:05 +0200 Subject: [PATCH 02/12] don't overwrite but merge keys for pandoc --- src/command/render/pandoc.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index 10293088d5e..4ac8fe470f7 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -1181,8 +1181,22 @@ export async function runPandoc( if (key === kFieldCategories && projectIsWebsite(options.project)) { continue; } - // perform the override - pandocMetadata[key] = engineMetadata[key]; + + if ( + (typeof pandocMetadata[key] === "object" && + typeof engineMetadata[key] === "object") || + (Array.isArray(pandocMetadata[key]) && + Array.isArray(engineMetadata[key])) + ) { + // Merge objects and arrays instead of replacing + pandocMetadata[key] = mergeConfigs( + pandocMetadata[key], + engineMetadata[key], + ); + } else { + // Replace values otherwise + pandocMetadata[key] = engineMetadata[key]; + } } } From 3766880ef6408a21098eb872bb96616a98d5ee75 Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Tue, 9 Sep 2025 13:38:16 +0200 Subject: [PATCH 03/12] simplify and fix array bug by calling merge on temp objects instead --- src/command/render/pandoc.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index 4ac8fe470f7..e1fae7eab89 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -1182,21 +1182,12 @@ export async function runPandoc( continue; } - if ( - (typeof pandocMetadata[key] === "object" && - typeof engineMetadata[key] === "object") || - (Array.isArray(pandocMetadata[key]) && - Array.isArray(engineMetadata[key])) - ) { - // Merge objects and arrays instead of replacing - pandocMetadata[key] = mergeConfigs( - pandocMetadata[key], - engineMetadata[key], - ); - } else { - // Replace values otherwise - pandocMetadata[key] = engineMetadata[key]; - } + // to handle all possible objects correctly when merging, + // we call mergeConfigs on temporary objects so the structure + // matches what that function expects + const a = { content: pandocMetadata[key] }; + const b = { content: engineMetadata[key] }; + pandocMetadata[key] = mergeConfigs(a, b).content; } } From e24182c534d1b2a03a4a75104e3424907b9f1f48 Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Tue, 22 Sep 2026 10:27:57 +0200 Subject: [PATCH 04/12] fix(pandoc): Keep merged metadata when re-applying executed front matter Metadata handed to pandoc starts from the fully resolved options.format.metadata and then overwrites individual top-level keys with the document's own front matter, re-read after execution so that inline expressions are resolved. For a key defined in both _quarto.yml and the document, that assignment discarded everything the project file contributed, even though options.format.metadata had merged it correctly. Merging the two instead is not enough: options.format.metadata already contains the document's unresolved values, so concatenating the resolved array on top leaves an inline expression in the result next to the value it evaluated to. Treat options.format.metadata as the correct merge and substitute only the leaves that execution actually changed, comparing against the front matter as written, which is now passed through as PandocOptions.unexecutedMetadata. Keys the engine did not touch keep their merged value, so mappings keep the project's keys and arrays keep the project's entries. Co-Authored-By: Claude Opus 5 (1M context) --- news/changelog-1.11.md | 1 + src/command/render/pandoc.ts | 59 ++++++++++++++++--- src/command/render/render.ts | 1 + src/command/render/types.ts | 3 + tests/docs/project/metadata-merge/.gitignore | 2 + tests/docs/project/metadata-merge/_quarto.yml | 15 +++++ tests/docs/project/metadata-merge/doc.qmd | 22 +++++++ .../docs/project/metadata-merge/template.html | 8 +++ .../project/project-metadata-merge.test.ts | 47 +++++++++++++++ 9 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/docs/project/metadata-merge/.gitignore create mode 100644 tests/docs/project/metadata-merge/_quarto.yml create mode 100644 tests/docs/project/metadata-merge/doc.qmd create mode 100644 tests/docs/project/metadata-merge/template.html create mode 100644 tests/smoke/project/project-metadata-merge.test.ts diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 5a46cc678c2..0bed8138e01 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -99,3 +99,4 @@ All changes included in 1.11: - ([#14775](https://github.com/quarto-dev/quarto-cli/issues/14775)): Fix a crash when the `QUARTO_R` environment variable is set to a malformed path. Quarto now warns and falls back to other R lookup methods. - ([#14865](https://github.com/quarto-dev/quarto-cli/issues/14865)): Fix internal links in a preview being treated as external when the preview is reached through a proxy, such as on Posit Workbench. Links are now classified against the origin the browser sees. - ([#14878](https://github.com/quarto-dev/quarto-cli/pull/14878)): Add `az` (Azerbaijani) language translation. (author: @abdanar) +- ([#13353](https://github.com/quarto-dev/quarto-cli/pull/13353)): Fix metadata that both `_quarto.yml` and a document's front matter define being replaced by the document's value instead of merged before it reaches Pandoc templates. (author: @jkrumbiegel) diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index e1fae7eab89..5f4ddd4f15b 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -1181,13 +1181,11 @@ export async function runPandoc( if (key === kFieldCategories && projectIsWebsite(options.project)) { continue; } - - // to handle all possible objects correctly when merging, - // we call mergeConfigs on temporary objects so the structure - // matches what that function expects - const a = { content: pandocMetadata[key] }; - const b = { content: engineMetadata[key] }; - pandocMetadata[key] = mergeConfigs(a, b).content; + pandocMetadata[key] = withExecutedValues( + pandocMetadata[key], + options.unexecutedMetadata?.[key], + engineMetadata[key], + ); } } @@ -1887,3 +1885,50 @@ function escapeAtInMetadata(value: any): any { } return value; } + +// options.format.metadata already merged the project's and the document's +// values for this key; engineMetadata only re-resolved the leaves that held +// inline expressions. Swap in exactly those leaves so the merge survives, +// rather than replacing the whole key with the document's own values. +function withExecutedValues( + resolved: unknown, + unexecuted: unknown, + executed: unknown, +): unknown { + if (ld.isEqual(unexecuted, executed)) { + return resolved; + } + + if ( + ld.isPlainObject(resolved) && ld.isPlainObject(unexecuted) && + ld.isPlainObject(executed) + ) { + const result = { ...(resolved as Metadata) }; + for (const key of Object.keys(executed as Metadata)) { + result[key] = withExecutedValues( + result[key], + (unexecuted as Metadata)[key], + (executed as Metadata)[key], + ); + } + return result; + } + + // execution rewrites array entries in place, so an entry still matching the + // front matter as written maps to the entry at that position in the executed + // array; a differing length means that correspondence no longer holds + if ( + Array.isArray(resolved) && Array.isArray(unexecuted) && + Array.isArray(executed) && unexecuted.length === executed.length + ) { + const substituted = resolved.map((item) => { + const index = unexecuted.findIndex((value) => ld.isEqual(value, item)); + return index === -1 ? item : executed[index]; + }); + // an expression can evaluate to a value the project already contributed, + // so dedupe as the merge that produced `resolved` would have + return ld.uniqBy(substituted, (value: unknown) => JSON.stringify(value)); + } + + return executed; +} diff --git a/src/command/render/render.ts b/src/command/render/render.ts index 622a1f58775..15c5b5a4b60 100644 --- a/src/command/render/render.ts +++ b/src/command/render/render.ts @@ -194,6 +194,7 @@ export async function renderPandoc( args: recipe.args, services: context.options.services, metadata: executeResult.metadata, + unexecutedMetadata: context.target.metadata, quiet, flags: context.options.flags, }; diff --git a/src/command/render/types.ts b/src/command/render/types.ts index 2826cdf9f6e..7b70484aba0 100644 --- a/src/command/render/types.ts +++ b/src/command/render/types.ts @@ -214,6 +214,9 @@ export interface PandocOptions { // extra metadata to merge metadata?: Metadata; + // the input's metadata as written, before execution resolved inline expressions + unexecutedMetadata?: Metadata; + // optional execution engine executionEngine?: string; diff --git a/tests/docs/project/metadata-merge/.gitignore b/tests/docs/project/metadata-merge/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/project/metadata-merge/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/project/metadata-merge/_quarto.yml b/tests/docs/project/metadata-merge/_quarto.yml new file mode 100644 index 00000000000..b289e25357d --- /dev/null +++ b/tests/docs/project/metadata-merge/_quarto.yml @@ -0,0 +1,15 @@ +project: + type: default + +keywords: + - project-keyword + - shared-keyword + +colliding: + - alice + +custom: + from-project: project-value + shared: project-value + list: + - project-item diff --git a/tests/docs/project/metadata-merge/doc.qmd b/tests/docs/project/metadata-merge/doc.qmd new file mode 100644 index 00000000000..ca6654aaf7c --- /dev/null +++ b/tests/docs/project/metadata-merge/doc.qmd @@ -0,0 +1,22 @@ +--- +title: Metadata merge +format: + html: + template: template.html +keywords: + - '`{r} paste0("evaluated", "-keyword")`' + - shared-keyword +colliding: + - '`{r} paste0("al", "ice")`' +custom: + from-document: document-value + shared: document-value + list: + - '`{r} paste0("evaluated", "-item")`' + evaluated: '`{r} paste0("evaluated", "-scalar")`' +--- + +```{r} +#| echo: false +invisible(1) +``` diff --git a/tests/docs/project/metadata-merge/template.html b/tests/docs/project/metadata-merge/template.html new file mode 100644 index 00000000000..8a4988b7b82 --- /dev/null +++ b/tests/docs/project/metadata-merge/template.html @@ -0,0 +1,8 @@ +KEYWORDS: [$for(keywords)$$it$$sep$, $endfor$] +CUSTOM-LIST: [$for(custom.list)$$it$$sep$, $endfor$] +FROM-PROJECT: [$custom.from-project$] +SHARED: [$custom.shared$] +FROM-DOCUMENT: [$custom.from-document$] +EVALUATED: [$custom.evaluated$] +COLLIDING: [$for(colliding)$$it$$sep$, $endfor$] +$body$ diff --git a/tests/smoke/project/project-metadata-merge.test.ts b/tests/smoke/project/project-metadata-merge.test.ts new file mode 100644 index 00000000000..004f033255b --- /dev/null +++ b/tests/smoke/project/project-metadata-merge.test.ts @@ -0,0 +1,47 @@ +/* + * project-metadata-merge.test.ts + * + * Copyright (C) 2020-2022 Posit Software, PBC + */ +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { join } from "../../../src/deno_ral/path.ts"; + +import { testQuartoCmd } from "../../test.ts"; +import { docs } from "../../utils.ts"; + +import { ensureFileRegexMatches, noErrorsOrWarnings } from "../../verify.ts"; + +const projectDir = docs("project/metadata-merge"); +const output = join(projectDir, "doc.html"); + +testQuartoCmd( + "render", + [projectDir, "--to", "html"], + [ + noErrorsOrWarnings, + ensureFileRegexMatches( + output, + [ + /KEYWORDS: \[project-keyword, shared-keyword, evaluated-keyword\]/, + /CUSTOM-LIST: \[project-item, evaluated-item\]/, + /FROM-PROJECT: \[project-value\]/, + /SHARED: \[document-value\]/, + /FROM-DOCUMENT: \[document-value\]/, + /EVALUATED: \[evaluated-scalar\]/, + /COLLIDING: \[alice\]/, + ], + [/[^<]*paste0/], + ), + ], + { + teardown: () => { + for (const file of ["doc.html", "doc_files"]) { + const path = join(projectDir, file); + if (existsSync(path)) { + Deno.removeSync(path, { recursive: true }); + } + } + return Promise.resolve(); + }, + }, +); From 13be287b2ab7c49def4be3c21a19f7ef40051392 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 5 Oct 2026 15:49:13 +0200 Subject: [PATCH 05/12] fix(pandoc): Merge unchanged front matter keys instead of substituting leaves Substituting only the leaves execution changed, compared against the resolved metadata, also changes precedence: options.format.metadata merges the document first and metadata-files / --metadata-file after it, so for a key the engine left alone the metadata file's value won over the front matter. Document front matter winning over its own metadata files is the documented behavior and what the overwrite before pandoc has produced so far. The leaf substitution also duplicated array entries when an inline expression evaluated to a value the project already listed. For a key execution left unchanged, merge the front matter as written back on top of the resolved value: other sources keep the leaves and list entries the document does not set, and the document still wins the ones it does. The merge is wrapped in an object because mergeConfigs on two bare arrays merges them by index. A key that holds an inline expression takes the executed value as a whole, as before; the project's children of such a key are still lost, a known limit of working per top-level key. Putting the document's value back means a top-level `controls: auto` or `previewLinks: auto` would again replace the `false` that the revealjs format computes for them, and reveal.js receives a bare `auto` identifier (already the case before this change). Skip both keys for revealjs, like `theme`. The test fixture is split into a markdown and a knitr document, since with an inline expression in every shared key the old fixture renders as before. knitr only accepts scalar params, so the params case uses flat keys. --- news/changelog-1.11.md | 2 +- src/command/render/pandoc.ts | 72 ++++++------------- tests/docs/project/metadata-merge/_quarto.yml | 11 ++- tests/docs/project/metadata-merge/doc.qmd | 22 ------ tests/docs/project/metadata-merge/knitr.qmd | 26 +++++++ .../docs/project/metadata-merge/markdown.qmd | 17 +++++ .../docs/project/metadata-merge/template.html | 7 +- .../metadata-merge/metadata-files-extra.yml | 6 ++ .../metadata-merge/metadata-files.qmd | 40 +++++++++++ .../metadata-merge/revealjs-controls-auto.qmd | 22 ++++++ .../project/project-metadata-merge.test.ts | 41 +++++++---- 11 files changed, 174 insertions(+), 92 deletions(-) delete mode 100644 tests/docs/project/metadata-merge/doc.qmd create mode 100644 tests/docs/project/metadata-merge/knitr.qmd create mode 100644 tests/docs/project/metadata-merge/markdown.qmd create mode 100644 tests/docs/smoke-all/metadata-merge/metadata-files-extra.yml create mode 100644 tests/docs/smoke-all/metadata-merge/metadata-files.qmd create mode 100644 tests/docs/smoke-all/metadata-merge/revealjs-controls-auto.qmd diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 0bed8138e01..5d5dbeea660 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -99,4 +99,4 @@ All changes included in 1.11: - ([#14775](https://github.com/quarto-dev/quarto-cli/issues/14775)): Fix a crash when the `QUARTO_R` environment variable is set to a malformed path. Quarto now warns and falls back to other R lookup methods. - ([#14865](https://github.com/quarto-dev/quarto-cli/issues/14865)): Fix internal links in a preview being treated as external when the preview is reached through a proxy, such as on Posit Workbench. Links are now classified against the origin the browser sees. - ([#14878](https://github.com/quarto-dev/quarto-cli/pull/14878)): Add `az` (Azerbaijani) language translation. (author: @abdanar) -- ([#13353](https://github.com/quarto-dev/quarto-cli/pull/13353)): Fix metadata that both `_quarto.yml` and a document's front matter define being replaced by the document's value instead of merged before it reaches Pandoc templates. (author: @jkrumbiegel) +- ([#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence. (author: @jkrumbiegel) diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index 5f4ddd4f15b..c01090118fd 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -1177,15 +1177,30 @@ export async function runPandoc( if (key === kTheme && isRevealjsOutput(options.format.pandoc)) { continue; } + // - controls and previewLinks are replaced for revealjs when set to 'auto', which is not a valid reveal.js value + if ( + (key === "controls" || key === "previewLinks") && + isRevealjsOutput(options.format.pandoc) + ) { + continue; + } // - categories are handled specifically already for website projects with a metadata override and should not be overridden by user input if (key === kFieldCategories && projectIsWebsite(options.project)) { continue; } - pandocMetadata[key] = withExecutedValues( - pandocMetadata[key], - options.unexecutedMetadata?.[key], - engineMetadata[key], - ); + // a key that execution left unchanged keeps what other sources merged + // into it, with the document's own values on top; a key holding inline + // expressions takes the executed value as a whole + if (ld.isEqual(options.unexecutedMetadata?.[key], engineMetadata[key])) { + // wrapped so that arrays and scalars are concatenated or replaced, + // not merged by index + pandocMetadata[key] = mergeConfigs( + { value: pandocMetadata[key] }, + { value: options.unexecutedMetadata?.[key] }, + ).value; + } else { + pandocMetadata[key] = engineMetadata[key]; + } } } @@ -1885,50 +1900,3 @@ function escapeAtInMetadata(value: any): any { } return value; } - -// options.format.metadata already merged the project's and the document's -// values for this key; engineMetadata only re-resolved the leaves that held -// inline expressions. Swap in exactly those leaves so the merge survives, -// rather than replacing the whole key with the document's own values. -function withExecutedValues( - resolved: unknown, - unexecuted: unknown, - executed: unknown, -): unknown { - if (ld.isEqual(unexecuted, executed)) { - return resolved; - } - - if ( - ld.isPlainObject(resolved) && ld.isPlainObject(unexecuted) && - ld.isPlainObject(executed) - ) { - const result = { ...(resolved as Metadata) }; - for (const key of Object.keys(executed as Metadata)) { - result[key] = withExecutedValues( - result[key], - (unexecuted as Metadata)[key], - (executed as Metadata)[key], - ); - } - return result; - } - - // execution rewrites array entries in place, so an entry still matching the - // front matter as written maps to the entry at that position in the executed - // array; a differing length means that correspondence no longer holds - if ( - Array.isArray(resolved) && Array.isArray(unexecuted) && - Array.isArray(executed) && unexecuted.length === executed.length - ) { - const substituted = resolved.map((item) => { - const index = unexecuted.findIndex((value) => ld.isEqual(value, item)); - return index === -1 ? item : executed[index]; - }); - // an expression can evaluate to a value the project already contributed, - // so dedupe as the merge that produced `resolved` would have - return ld.uniqBy(substituted, (value: unknown) => JSON.stringify(value)); - } - - return executed; -} diff --git a/tests/docs/project/metadata-merge/_quarto.yml b/tests/docs/project/metadata-merge/_quarto.yml index b289e25357d..22c38d61c24 100644 --- a/tests/docs/project/metadata-merge/_quarto.yml +++ b/tests/docs/project/metadata-merge/_quarto.yml @@ -1,12 +1,19 @@ project: type: default +format: + html: + template: template.html + keywords: - project-keyword - shared-keyword -colliding: - - alice +tags: + - project-tag + +params: + from-project: project-value custom: from-project: project-value diff --git a/tests/docs/project/metadata-merge/doc.qmd b/tests/docs/project/metadata-merge/doc.qmd deleted file mode 100644 index ca6654aaf7c..00000000000 --- a/tests/docs/project/metadata-merge/doc.qmd +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Metadata merge -format: - html: - template: template.html -keywords: - - '`{r} paste0("evaluated", "-keyword")`' - - shared-keyword -colliding: - - '`{r} paste0("al", "ice")`' -custom: - from-document: document-value - shared: document-value - list: - - '`{r} paste0("evaluated", "-item")`' - evaluated: '`{r} paste0("evaluated", "-scalar")`' ---- - -```{r} -#| echo: false -invisible(1) -``` diff --git a/tests/docs/project/metadata-merge/knitr.qmd b/tests/docs/project/metadata-merge/knitr.qmd new file mode 100644 index 00000000000..e01da0db8c2 --- /dev/null +++ b/tests/docs/project/metadata-merge/knitr.qmd @@ -0,0 +1,26 @@ +--- +title: Knitr document +keywords: + - document-keyword + - shared-keyword +params: + from-document: document-value +custom: + from-document: document-value + shared: document-value + list: + - document-item +tags: + - '`{r} "project-tag"`' + - document-tag +evaluated: '`{r} paste0("evaluated", "-value")`' +--- + +```{r} +#| echo: false +invisible(1) +``` + +::: {#lua-params} +{{< meta params.from-project >}} +::: diff --git a/tests/docs/project/metadata-merge/markdown.qmd b/tests/docs/project/metadata-merge/markdown.qmd new file mode 100644 index 00000000000..a7b8680aeba --- /dev/null +++ b/tests/docs/project/metadata-merge/markdown.qmd @@ -0,0 +1,17 @@ +--- +title: Markdown document +keywords: + - document-keyword + - shared-keyword +params: + from-document: document-value +custom: + from-document: document-value + shared: document-value + list: + - document-item +--- + +::: {#lua-params} +{{< meta params.from-project >}} +::: diff --git a/tests/docs/project/metadata-merge/template.html b/tests/docs/project/metadata-merge/template.html index 8a4988b7b82..832a8fbba5f 100644 --- a/tests/docs/project/metadata-merge/template.html +++ b/tests/docs/project/metadata-merge/template.html @@ -1,8 +1,9 @@ KEYWORDS: [$for(keywords)$$it$$sep$, $endfor$] -CUSTOM-LIST: [$for(custom.list)$$it$$sep$, $endfor$] +TAGS: [$for(tags)$$it$$sep$, $endfor$] +PARAMS: [$params.from-project$] [$params.from-document$] FROM-PROJECT: [$custom.from-project$] SHARED: [$custom.shared$] FROM-DOCUMENT: [$custom.from-document$] -EVALUATED: [$custom.evaluated$] -COLLIDING: [$for(colliding)$$it$$sep$, $endfor$] +CUSTOM-LIST: [$for(custom.list)$$it$$sep$, $endfor$] +EVALUATED: [$evaluated$] $body$ diff --git a/tests/docs/smoke-all/metadata-merge/metadata-files-extra.yml b/tests/docs/smoke-all/metadata-merge/metadata-files-extra.yml new file mode 100644 index 00000000000..73820f4c8c9 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/metadata-files-extra.yml @@ -0,0 +1,6 @@ +shared: metadata-file-value +custom: + shared: metadata-file-value + from-metadata-file: metadata-file-value +keywords: + - metadata-file-keyword diff --git a/tests/docs/smoke-all/metadata-merge/metadata-files.qmd b/tests/docs/smoke-all/metadata-merge/metadata-files.qmd new file mode 100644 index 00000000000..57ae326f97c --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/metadata-files.qmd @@ -0,0 +1,40 @@ +--- +title: Front matter and metadata-files +metadata-files: + - metadata-files-extra.yml +shared: document-value +custom: + shared: document-value + from-document: document-value +keywords: + - document-keyword +_quarto: + tests: + html: + ensureFileRegexMatches: + - + - '' + - '
\s*

document-value

' + - '
\s*

document-value

' + - '
\s*

document-value

' + - '
\s*

metadata-file-value

' + - [] +--- + +The document's front matter wins over its `metadata-files` for a value both define, and what only the metadata file defines is kept, including entries of a list and keys of a mapping the front matter also defines. + +::: {#shared} +{{< meta shared >}} +::: + +::: {#custom-shared} +{{< meta custom.shared >}} +::: + +::: {#custom-from-document} +{{< meta custom.from-document >}} +::: + +::: {#custom-from-metadata-file} +{{< meta custom.from-metadata-file >}} +::: diff --git a/tests/docs/smoke-all/metadata-merge/revealjs-controls-auto.qmd b/tests/docs/smoke-all/metadata-merge/revealjs-controls-auto.qmd new file mode 100644 index 00000000000..6158e2cc9fe --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/revealjs-controls-auto.qmd @@ -0,0 +1,22 @@ +--- +title: Controls and preview links set to auto +format: revealjs +controls: auto +previewLinks: auto +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'controls: false,' + - "'controlsAuto': true," + - 'previewLinks: false,' + - "'previewLinksAuto': true," + - + - 'controls: auto,' + - 'previewLinks: auto,' +--- + +## Slide + +`auto` is not a valid reveal.js value: Quarto passes `false` to reveal.js and enables its own automatic behavior instead. diff --git a/tests/smoke/project/project-metadata-merge.test.ts b/tests/smoke/project/project-metadata-merge.test.ts index 004f033255b..e5439e55422 100644 --- a/tests/smoke/project/project-metadata-merge.test.ts +++ b/tests/smoke/project/project-metadata-merge.test.ts @@ -1,7 +1,7 @@ /* * project-metadata-merge.test.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2026 Posit Software, PBC */ import { existsSync } from "../../../src/deno_ral/fs.ts"; import { join } from "../../../src/deno_ral/path.ts"; @@ -12,30 +12,47 @@ import { docs } from "../../utils.ts"; import { ensureFileRegexMatches, noErrorsOrWarnings } from "../../verify.ts"; const projectDir = docs("project/metadata-merge"); -const output = join(projectDir, "doc.html"); + +// keys defined in both _quarto.yml and the front matter are merged: the +// document wins shared leaves, the project's other leaves and list entries stay +const mergedKeys = [ + /KEYWORDS: \[project-keyword, shared-keyword, document-keyword\]/, + /PARAMS: \[project-value\] \[document-value\]/, + /FROM-PROJECT: \[project-value\]/, + /SHARED: \[document-value\]/, + /FROM-DOCUMENT: \[document-value\]/, + /CUSTOM-LIST: \[project-item, document-item\]/, + /
\s*

project-value<\/p>/, +]; testQuartoCmd( "render", [projectDir, "--to", "html"], [ noErrorsOrWarnings, + ensureFileRegexMatches(join(projectDir, "markdown.html"), mergedKeys), ensureFileRegexMatches( - output, + join(projectDir, "knitr.html"), [ - /KEYWORDS: \[project-keyword, shared-keyword, evaluated-keyword\]/, - /CUSTOM-LIST: \[project-item, evaluated-item\]/, - /FROM-PROJECT: \[project-value\]/, - /SHARED: \[document-value\]/, - /FROM-DOCUMENT: \[document-value\]/, - /EVALUATED: \[evaluated-scalar\]/, - /COLLIDING: \[alice\]/, + ...mergedKeys, + // a key holding an inline expression takes the executed value as a + // whole, without repeating a value the project also lists + /TAGS: \[project-tag, document-tag\]/, + /EVALUATED: \[evaluated-value\]/, ], - [/[^<]*paste0/], + [/`\{r\}/], ), ], { teardown: () => { - for (const file of ["doc.html", "doc_files"]) { + for ( + const file of [ + "markdown.html", + "markdown_files", + "knitr.html", + "knitr_files", + ] + ) { const path = join(projectDir, file); if (existsSync(path)) { Deno.removeSync(path, { recursive: true }); From 336e0b8f1ff1b590d5e334f509a78d66cbc38e9e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 5 Oct 2026 16:38:59 +0200 Subject: [PATCH 06/12] fix(revealjs): Only skip non-boolean controls and previewLinks front matter The revealjs format replaces only a non-boolean `controls` or an 'auto' `previewLinks` with `false`. Skipping the keys for every value also skipped a boolean set in the front matter, so a `metadata-files` value for the same key, merged after the document, won over it. --- src/command/render/pandoc.ts | 3 ++- .../revealjs-controls-boolean-extra.yml | 2 ++ .../revealjs-controls-boolean.qmd | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean-extra.yml create mode 100644 tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean.qmd diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index c01090118fd..067580e221b 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -1177,9 +1177,10 @@ export async function runPandoc( if (key === kTheme && isRevealjsOutput(options.format.pandoc)) { continue; } - // - controls and previewLinks are replaced for revealjs when set to 'auto', which is not a valid reveal.js value + // - controls and previewLinks are replaced for revealjs when not a boolean (e.g. 'auto'), which is not a valid reveal.js value if ( (key === "controls" || key === "previewLinks") && + typeof engineMetadata[key] !== "boolean" && isRevealjsOutput(options.format.pandoc) ) { continue; diff --git a/tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean-extra.yml b/tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean-extra.yml new file mode 100644 index 00000000000..f6118275244 --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean-extra.yml @@ -0,0 +1,2 @@ +controls: true +previewLinks: true diff --git a/tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean.qmd b/tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean.qmd new file mode 100644 index 00000000000..3e75b5e844b --- /dev/null +++ b/tests/docs/smoke-all/metadata-merge/revealjs-controls-boolean.qmd @@ -0,0 +1,22 @@ +--- +title: Controls and preview links set in front matter and metadata-files +format: revealjs +metadata-files: + - revealjs-controls-boolean-extra.yml +controls: false +previewLinks: false +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'controls: false,' + - 'previewLinks: false,' + - + - 'controls: true,' + - 'previewLinks: true,' +--- + +## Slide + +A boolean `controls` or `previewLinks` in the front matter wins over its `metadata-files`, like any other key. From f134d67eb456026065901c6fff8af10663249354 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 5 Oct 2026 18:10:36 +0200 Subject: [PATCH 07/12] docs(changelog): Add entry for revealjs controls and previewLinks set to auto The revealjs guard added in this PR fixes a separate user-visible failure with no issue of its own: a top-level `controls: auto` or `previewLinks: auto` was written as a bare `auto` identifier into the reveal.js initialization script, a ReferenceError that stops the presentation from loading. The entry references this PR instead. --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 5d5dbeea660..e5316a3c01c 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -100,3 +100,4 @@ All changes included in 1.11: - ([#14865](https://github.com/quarto-dev/quarto-cli/issues/14865)): Fix internal links in a preview being treated as external when the preview is reached through a proxy, such as on Posit Workbench. Links are now classified against the origin the browser sees. - ([#14878](https://github.com/quarto-dev/quarto-cli/pull/14878)): Add `az` (Azerbaijani) language translation. (author: @abdanar) - ([#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence. (author: @jkrumbiegel) +- ([#14996](https://github.com/quarto-dev/quarto-cli/pull/14996)): Fix `controls: auto` and `previewLinks: auto` set at the top level of a revealjs document's front matter producing a presentation that fails to load, because `auto` was written unquoted into the generated JavaScript. From 60a39501f754070cb861b204259149bac67dabb2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 5 Oct 2026 18:27:56 +0200 Subject: [PATCH 08/12] test(pandoc): Cover --metadata-file merging with document front matter The document-level metadata-files case was covered, the command line flag was not. Both go through the same merge, but a --metadata-file is read separately from the document, so its precedence and list handling are pinned on their own: the document wins a shared value, what only the file defines is kept, and the file's list entries come before the document's. --- .../metadata-merge-cli/cli-metadata-file.qmd | 25 ++++++++++ .../metadata-merge-cli/cli-metadata-file.yml | 6 +++ .../render/render-cli-metadata-file.test.ts | 50 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/docs/metadata-merge-cli/cli-metadata-file.qmd create mode 100644 tests/docs/metadata-merge-cli/cli-metadata-file.yml create mode 100644 tests/smoke/render/render-cli-metadata-file.test.ts diff --git a/tests/docs/metadata-merge-cli/cli-metadata-file.qmd b/tests/docs/metadata-merge-cli/cli-metadata-file.qmd new file mode 100644 index 00000000000..925cf2331a2 --- /dev/null +++ b/tests/docs/metadata-merge-cli/cli-metadata-file.qmd @@ -0,0 +1,25 @@ +--- +title: Front matter and --metadata-file +shared: document-value +custom: + shared: document-value + from-document: document-value +keywords: + - document-keyword +--- + +::: {#shared} +{{< meta shared >}} +::: + +::: {#custom-shared} +{{< meta custom.shared >}} +::: + +::: {#custom-from-document} +{{< meta custom.from-document >}} +::: + +::: {#custom-from-metadata-file} +{{< meta custom.from-metadata-file >}} +::: diff --git a/tests/docs/metadata-merge-cli/cli-metadata-file.yml b/tests/docs/metadata-merge-cli/cli-metadata-file.yml new file mode 100644 index 00000000000..73820f4c8c9 --- /dev/null +++ b/tests/docs/metadata-merge-cli/cli-metadata-file.yml @@ -0,0 +1,6 @@ +shared: metadata-file-value +custom: + shared: metadata-file-value + from-metadata-file: metadata-file-value +keywords: + - metadata-file-keyword diff --git a/tests/smoke/render/render-cli-metadata-file.test.ts b/tests/smoke/render/render-cli-metadata-file.test.ts new file mode 100644 index 00000000000..1e9075b4d44 --- /dev/null +++ b/tests/smoke/render/render-cli-metadata-file.test.ts @@ -0,0 +1,50 @@ +/* + * render-cli-metadata-file.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { join } from "../../../src/deno_ral/path.ts"; + +import { testQuartoCmd } from "../../test.ts"; +import { docs } from "../../utils.ts"; + +import { ensureFileRegexMatches, noErrorsOrWarnings } from "../../verify.ts"; + +const dir = docs("metadata-merge-cli"); +const input = join(dir, "cli-metadata-file.qmd"); +const output = join(dir, "cli-metadata-file.html"); + +// the document's front matter wins over a --metadata-file for a value both +// define, and what only the metadata file defines is kept +testQuartoCmd( + "render", + [ + input, + "--to", + "html", + "--metadata-file", + join(dir, "cli-metadata-file.yml"), + ], + [ + noErrorsOrWarnings, + ensureFileRegexMatches(output, [ + //, + /

\s*

document-value<\/p>/, + /

\s*

document-value<\/p>/, + /

\s*

document-value<\/p>/, + /

\s*

metadata-file-value<\/p>/, + ]), + ], + { + teardown: () => { + for (const file of ["cli-metadata-file.html", "cli-metadata-file_files"]) { + const path = join(dir, file); + if (existsSync(path)) { + Deno.removeSync(path, { recursive: true }); + } + } + return Promise.resolve(); + }, + }, +); From bc3b8b437c6a40335fe7ba686223173f59a10bd9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 6 Oct 2026 11:00:22 +0200 Subject: [PATCH 09/12] Reorder changelog --- news/changelog-1.11.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e5316a3c01c..13acc8482f9 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -38,6 +38,10 @@ All changes included in 1.11: - ([#14982](https://github.com/quarto-dev/quarto-cli/issues/14982)): Fix a footnote in document metadata, such as `title` or `author`, making PDF and Typst output shift heading levels, which turned the first level-1 heading into the document title. +### `revealjs` + +- ([#14996](https://github.com/quarto-dev/quarto-cli/pull/14996)): Fix `controls: auto` and `previewLinks: auto` set at the top level of a revealjs document's front matter producing a presentation that fails to load, because `auto` was written unquoted into the generated JavaScript. + ### `typst` - ([#14847](https://github.com/quarto-dev/quarto-cli/pull/14847)): Fix `toc_title` auto-fallback in typst outline template that was ignoring the computed fallback value when `toc_title` is `none`. @@ -57,6 +61,10 @@ All changes included in 1.11: - ([#14974](https://github.com/quarto-dev/quarto-cli/issues/14974)): Fix math in `.llms.md` files from `llms-txt` coming out garbled with the default `mathjax` method, and with `katex` and `webtex`. Math is now written as `$...$` and `$$...$$` with the `mathjax`, `katex`, `webtex` and `mathml` methods. +## Lua API + +- ([#14894](https://github.com/quarto-dev/quarto-cli/pull/14894)): Fix `quarto.version` and `quarto.config.version()` crashing filters with `table expected, got string` when the version string contains semver build metadata (such as a distro packager's revision suffix) or otherwise does not start with a digit. + ## Commands ### `call` @@ -90,14 +98,9 @@ All changes included in 1.11: - ([#13966](https://github.com/quarto-dev/quarto-cli/issues/13966)): Fix doubled newlines in HTML output of stdout and stderr containing ANSI escape codes. -## Lua API - -- ([#14894](https://github.com/quarto-dev/quarto-cli/pull/14894)): Fix `quarto.version` and `quarto.config.version()` crashing filters with `table expected, got string` when the version string contains semver build metadata (such as a distro packager's revision suffix) or otherwise does not start with a digit. - ## Other fixes and improvements +- ([#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence. (author: @jkrumbiegel) - ([#14775](https://github.com/quarto-dev/quarto-cli/issues/14775)): Fix a crash when the `QUARTO_R` environment variable is set to a malformed path. Quarto now warns and falls back to other R lookup methods. - ([#14865](https://github.com/quarto-dev/quarto-cli/issues/14865)): Fix internal links in a preview being treated as external when the preview is reached through a proxy, such as on Posit Workbench. Links are now classified against the origin the browser sees. - ([#14878](https://github.com/quarto-dev/quarto-cli/pull/14878)): Add `az` (Azerbaijani) language translation. (author: @abdanar) -- ([#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence. (author: @jkrumbiegel) -- ([#14996](https://github.com/quarto-dev/quarto-cli/pull/14996)): Fix `controls: auto` and `previewLinks: auto` set at the top level of a revealjs document's front matter producing a presentation that fails to load, because `auto` was written unquoted into the generated JavaScript. From 400972c5458d6b621f80dc04540109dacc80ff63 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 6 Oct 2026 12:03:05 +0200 Subject: [PATCH 10/12] test(pandoc): Cover directory and document author merging in websites The website-categories fixture only asserted on categories, which already merged correctly before the fix, so it could not catch the overwrite reported in #9864. An author list in the directory _metadata.yml combined with a scalar author in the post front matter did regress: only the document author reached the page. Extend the fixture so the post must emit both authors. Against the pre-fix runPandoc the from-directory author meta tag is missing; with the fix both are present. --- .../metadata-merge/website-categories/posts/_metadata.yml | 2 ++ .../metadata-merge/website-categories/posts/post.qmd | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml b/tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml index c9f4ef99b15..9b26cbfdc26 100644 --- a/tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml +++ b/tests/docs/smoke-all/metadata-merge/website-categories/posts/_metadata.yml @@ -1,2 +1,4 @@ +author: + - name: from-directory categories: - from-directory diff --git a/tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd b/tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd index 706900ed932..61b185cefdd 100644 --- a/tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd +++ b/tests/docs/smoke-all/metadata-merge/website-categories/posts/post.qmd @@ -1,5 +1,6 @@ --- title: A post +author: from-document categories: - from-document _quarto: @@ -10,7 +11,9 @@ _quarto: - 'class="quarto-category">from-project<' - 'class="quarto-category">from-directory<' - 'class="quarto-category">from-document<' + - '' + - '' - [] --- -In a website, `categories` from `_quarto.yml`, the directory `_metadata.yml` and the document front matter are all shown on the page. +In a website, `categories` from `_quarto.yml`, the directory `_metadata.yml` and the document front matter are all shown on the page. The `author` from the directory `_metadata.yml` and from the document front matter are both emitted too. From 6a26ec0bc84deaab334bb45b6d280b532ac91e47 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 6 Oct 2026 12:43:30 +0200 Subject: [PATCH 11/12] docs(changelog): Reference #9864 in the metadata merge entry The directory _metadata.yml author case reported in #9864 is fixed by the same change, so the entry lists it alongside #11139 and names the directory _metadata.yml source. --- news/changelog-1.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 13acc8482f9..3e5b2aaeabe 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -100,7 +100,7 @@ All changes included in 1.11: ## Other fixes and improvements -- ([#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence. (author: @jkrumbiegel) +- ([#9864](https://github.com/quarto-dev/quarto-cli/issues/9864), [#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml`, a directory `_metadata.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence. (author: @jkrumbiegel) - ([#14775](https://github.com/quarto-dev/quarto-cli/issues/14775)): Fix a crash when the `QUARTO_R` environment variable is set to a malformed path. Quarto now warns and falls back to other R lookup methods. - ([#14865](https://github.com/quarto-dev/quarto-cli/issues/14865)): Fix internal links in a preview being treated as external when the preview is reached through a proxy, such as on Posit Workbench. Links are now classified against the origin the browser sees. - ([#14878](https://github.com/quarto-dev/quarto-cli/pull/14878)): Add `az` (Azerbaijani) language translation. (author: @abdanar) From 707f73a0731d3793a19669264faf9b85183d90fd Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 6 Oct 2026 14:37:00 +0200 Subject: [PATCH 12/12] docs(changelog): State that lists are combined instead of replaced Before the fix, keywords, tags and other keys passed through the re-apply step replaced the project's list with the document's, so a document could also clear an inherited list with an empty value. Lists now combine for every key, as Quarto's own keys such as css already did, so the entry says so. --- news/changelog-1.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 3e5b2aaeabe..886550db8c3 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -100,7 +100,7 @@ All changes included in 1.11: ## Other fixes and improvements -- ([#9864](https://github.com/quarto-dev/quarto-cli/issues/9864), [#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml`, a directory `_metadata.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence. (author: @jkrumbiegel) +- ([#9864](https://github.com/quarto-dev/quarto-cli/issues/9864), [#11139](https://github.com/quarto-dev/quarto-cli/issues/11139)): Fix metadata defined both in a document's front matter and in `_quarto.yml`, a directory `_metadata.yml` or `metadata-files` reaching Pandoc templates and Lua filters with only the document's values. The values are now merged, and the document's values still take precedence for single values. Lists are combined instead of replaced, so a document can no longer drop entries that the project, a directory `_metadata.yml` or `metadata-files` add to a list such as `keywords`. (author: @jkrumbiegel) - ([#14775](https://github.com/quarto-dev/quarto-cli/issues/14775)): Fix a crash when the `QUARTO_R` environment variable is set to a malformed path. Quarto now warns and falls back to other R lookup methods. - ([#14865](https://github.com/quarto-dev/quarto-cli/issues/14865)): Fix internal links in a preview being treated as external when the preview is reached through a proxy, such as on Posit Workbench. Links are now classified against the origin the browser sees. - ([#14878](https://github.com/quarto-dev/quarto-cli/pull/14878)): Add `az` (Azerbaijani) language translation. (author: @abdanar)