Skip to content

fix(pandoc): Merge project metadata with document front matter - #14996

Merged
cderv merged 12 commits into
mainfrom
fix/issue-11139-metadata-merge
Oct 6, 2026
Merged

cderv merged 12 commits into
mainfrom
fix/issue-11139-metadata-merge

Conversation

@cderv

@cderv cderv commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Description

Continues #13353, whose author handed the branch over (his commits are kept with authorship). A key defined both in a document's front matter and in _quarto.yml or metadata-files reaches Pandoc templates and Lua filters with only the document's value. Setting params.nested.A in a document drops params.nested.B coming from the project, and a project array such as keywords loses its project entries. This was first reported in discussion #11138 and is #11139. The same loss hits an author list from a directory _metadata.yml combined with an author in a post's front matter, reported in #9864.

options.format.metadata already holds the correct merge. The loss happens in runPandoc, which re-applies each top-level key of the executed front matter with a plain assignment so that knitr inline R in YAML is resolved in the output (added in 2021 for that purpose). The assignment replaces whatever the project contributed to the key.

For a key that execution left unchanged, the front matter as written is now merged 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 input's metadata as written is passed to runPandoc as unexecutedMetadata to tell unchanged keys from keys holding inline expressions. A key with an inline expression takes the executed value as a whole, so the project's children of such a key are still lost. This is a known limit of working per top-level key.

Lists now combine the same way for every key. Before, keywords, tags and other keys passed through here replaced the project's list with the document's, while Quarto's own keys such as css already combined, as the documentation says. As a side effect, a document can no longer clear an inherited list with an empty value (keywords: "" or []), which only worked because of the re-apply step. There is no replacement for it for now, the same as for css.

Merging the front matter on top, rather than substituting the leaves execution changed, keeps the documented precedence: options.format.metadata merges the document first and metadata-files after it, so a leaf substitution would let a metadata file win over the front matter. Substituting leaves also duplicated an array entry when an inline expression evaluated to a value the project already listed. The merge wraps the values in an object because mergeConfigs on two bare arrays merges them by index.

Putting the document's value back means a top-level controls: auto or previewLinks: auto would replace the false that the revealjs format computes for them, and reveal.js would receive a bare auto identifier, which was already the case before this change. Both keys are skipped for revealjs when the value is not a boolean, like theme, and a boolean set in the front matter still wins over metadata-files.

The first commit adds characterization tests, passing on main, for what the re-apply step already protects: format-level keys, knitr inline R in YAML (also with frozen results), book chapter titles and website categories. The remaining tests cover the merge itself: project and document keys, metadata-files, the --metadata-file option, an author list from a directory _metadata.yml combined with a document author, and revealjs controls and previewLinks.

Fixes #11139, fixes #9864, supersedes #13353

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
AI-assisted PR
  • AI tool used: Claude Code
  • Codebase grounding: local clone, DeepWiki MCP
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

Note: autonomous AI agents submitting PRs without human oversight are not permitted — see the Code of Conduct.

cderv and others added 7 commits October 5, 2026 14:53
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.
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) <noreply@anthropic.com>
…g 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.
…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.
… 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.
@posit-snyk-bot

posit-snyk-bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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.
@cderv cderv mentioned this pull request Oct 5, 2026
1 of 6 tasks
cderv added 2 commits October 6, 2026 11:00
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.
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.
@cderv

cderv commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

I am spending time on this to understand all possible side effect. This is not without impact IMO.

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.
@cderv

cderv commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

While checking what changes for a document that sets an inherited list to an empty value, I compared main and this branch on the same documents. This PR does not change how project, directory and metadata-files values are merged: the diff only touches pandoc.ts, render.ts and types.ts, and render-contexts.ts, mergeConfigs and mergeArrayCustomizer are unchanged. options.format.metadata already holds the union, and what changes is that runPandoc no longer overwrites it for keys that execution left unchanged.

How I checked: a project _quarto.yml with keywords: [p-kw] and a custom map with keys a and b, one document per value, rendered to html with the markdown engine and a template that prints the values. The same documents gave identical results with the project values in _quarto.yml, in a directory _metadata.yml and in metadata-files, so the table below holds for all three.

Document front matter main this branch
keywords: "" no keywords p-kw
keywords: [] no keywords p-kw
keywords: [doc-kw] doc-kw p-kw, doc-kw
custom: {} a and b empty a and b kept
custom: {b: doc-b} a lost, b is doc-b a kept, b is doc-b
custom: "" map replaced map replaced

A list key outside the schema behaves like keywords: on main "", [] and ~ cleared the project list, on this branch they keep it. A Quarto key such as css is skipped by the re-apply step, and it combines the same way on both.

The same values set under format: html: show that the merge already worked this way on main. The re-apply step skips keys overridden in a format, so the merged value goes through untouched: keywords: "" and [] keep p-kw, keywords: [doc-kw] gives p-kw, doc-kw, and custom: {} keeps a and b. So a value that never cleared an inherited list at format level only did so at top level because of the re-apply step, which was added for inline R in YAML. Top-level keys now behave like format-level keys and like css, and like the documentation, which says that objects and arrays are merged rather than overwriting each other.

The side effect is that a document can no longer clear an inherited list with an empty value, and keywords: [x] now adds to the project's keywords instead of replacing them. I added both to the changelog entry and to quarto-dev/quarto-web#2270. There is no opt-out today, the same as for css, and any opt-out would have to live in the shared merge rather than here. Whether we want one is a separate question that I'd rather discuss on its own.

I only ran the html format with the markdown engine on dev builds, so knitr and other formats are not covered by this table.

@cderv
cderv merged commit cfb1ed3 into main Oct 6, 2026
51 checks passed
@cderv
cderv deleted the fix/issue-11139-metadata-merge branch October 6, 2026 13:40
cderv added a commit to quarto-dev/quarto-web that referenced this pull request Oct 6, 2026
* docs: describe how metadata from different sources is merged

With quarto-dev/quarto-cli#14996, a document's front matter is merged with
_quarto.yml, _metadata.yml and metadata-files instead of replacing their
values for each top-level key it sets. The section only said that objects
and arrays are merged, which left out how lists are combined, what happens
to a single value merged with a list, and which of an included file or its
parent wins.

Pandoc's --metadata-file was not documented anywhere on the site, and it
merges with the document in its own order, so it gets a short note.

* docs: say a document cannot remove inherited list entries

Lists from the project and a directory always combine with the document's list, so a document has no way to drop their entries. A single value also replaces an object set at a lower level, which the section did not say.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Quarto improperly merges metadata for Lua filter use Some metadata set in _metadata.yml and in YAML header inside document are not correctly merged.

3 participants