Skip to content

Add quarto.* Pandoc template variable namespace#14530

Open
cderv wants to merge 10 commits into
mainfrom
feature/pandoc-language-template-variables
Open

Add quarto.* Pandoc template variable namespace#14530
cderv wants to merge 10 commits into
mainfrom
feature/pandoc-language-template-variables

Conversation

@cderv
Copy link
Copy Markdown
Member

@cderv cderv commented May 20, 2026

Pandoc templates today cannot see Quarto's resolved localized strings
without per-key meta copies. Those copies leak into the rendered YAML
header for writers that emit yaml_metadata_block (markdown, native,
json) and require format-by-format gating to suppress.

format.language (the merged result of Quarto's bundled
_language.yml plus per-locale and per-document overrides) is now
surfaced to Pandoc templates via the defaults-file variables:
section, under a reserved internal namespace quarto.*:

variables:
  quarto:
    language:
      crossref-ch-prefix: Chapter
      toc-title-document: Table of contents
      # ... every key from format.language

Templates access keys via dotted form:

$quarto.language.crossref-ch-prefix$
$quarto.language.toc-title-document$

The defaults-file variables: section is not serialized back into
rendered output by any writer, so the leakage problem goes away
without per-format gating.

Why a new namespace

quarto.* is reserved as an internal, Quarto-owned namespace inside
Pandoc template variables. Users overriding a localized string keep
using the existing top-level language: YAML key (see #14466). The
schema does not surface variables.quarto.* as a user-facing option,
and on collision the user-set value wins, so an explicit YAML escape
hatch keeps working.

Contribution point

A single file — src/command/render/quarto-template-variables.ts
is the contribution point for new keys under quarto.*. Adding a new
internal value to expose to templates is a typed two-step:

  1. Add a field to QuartoTemplateVariables (use the tightest
    existing type, e.g. FormatLanguage).
  2. Add a contribution branch in buildQuartoTemplateVariables that
    reads from PandocOptions and assigns to that field.

The wiring into the defaults file (generateDefaults in
src/command/render/defaults.ts) is single-point and does not need
changes per new key.

Opportunities this opens

  • Future internal exposures fit the same channel without any further
    template-channel design work — $quarto.format.X$,
    $quarto.project.X$, $quarto.version$, etc.
  • The orange-book Typst running-header bug (Typst book running header not localized when lang: is set to a non-English locale #14524, tracked as a
    separate PR) becomes a one-line template edit on top of this
    channel, replacing a Lua filter workaround.
  • Templates that today read flat $toc-title$ / $abstract-title$
    (populated via per-key meta copies in src/command/render/pandoc.ts)
    can migrate to $quarto.language.toc-title-document$ /
    $quarto.language.section-title-abstract$. Once migrated, those
    per-key meta copies and their per-format gating can be removed.

Localization architecture

Channel 2d (defaults-file variables:) is now documented as the
canonical bulk channel for new language keys, alongside Lua filter
params and Pandoc meta. See llm-docs/localization-architecture.md.

Test plan

  • tests/docs/smoke-all/markdown/lang-fr-template-resolve.qmd — custom Pandoc template + lang: fr resolves $quarto.language.crossref-ch-prefix$ to "Chapitre"
  • tests/docs/smoke-all/markdown/lang-fr-no-yaml-leakage.qmd — regression guard: crossref-* and toc-title-document stay out of the rendered markdown YAML header
  • tests/docs/smoke-all/markdown/lang-fr-user-override-quarto-variables.qmd — user-set variables.quarto.language.crossref-ch-prefix wins on collision
  • tests/docs/smoke-all/markdown/lang-ja-template-resolve.qmd — unicode round-trip: lang: ja resolves to "チャプター"
  • tests/unit/render/quarto-template-variables.test.ts — builder contract: empty in → undefined out; reference-passing; empty-object case

Related to #14524

cderv added 7 commits May 19, 2026 18:17
…language

Pandoc templates can now resolve `$quarto.language.<key>$` (e.g.
`$quarto.language.crossref-ch-prefix$`) without per-key TypeScript
copies or Lua surfacing into Pandoc meta. The defaults-file
variables: section already accepts nested maps natively, so the
whole format.language table is wired with one nested-write in
generateDefaults. No format.metadata writes are involved, so writers
with +yaml_metadata_block (markdown, native, json) do not serialize
these keys to output.

Tests:

- tests/docs/smoke-all/markdown/lang-fr-template-resolve.qmd —
  positive resolution via a custom Pandoc template referencing
  $quarto.language.crossref-ch-prefix$, asserts the localized
  "Chapitre" value when lang: fr is set. format: plain with a
  one-line template gives clean end-to-end variable substitution.

- tests/docs/smoke-all/markdown/lang-fr-no-yaml-leakage.qmd —
  leakage guard renders to format: markdown with lang: fr and
  asserts crossref-* / toc-title-document do NOT appear in the
  rendered YAML header. Regression guard so any future per-key
  TypeScript copy or Lua surfacing that re-introduces the leakage
  fails CI.
…faults variables)

Documents how lang: <bcp47> resolves into localized strings across
HTML, LaTeX, and Typst. Highlights the new defaults-file
variables.quarto.language channel as the canonical bulk channel for
new keys consumed by Pandoc templates. §3c covers the orange-book
typst-show.typ migration to $quarto.language.crossref-*$. §4
adding-a-new-key decision table recommends 2d for template
consumers. §5 pitfalls leads with the 2d vs 2b tradeoff (metadata
leakage via +yaml_metadata_block).
Replace the inline format.language wiring in generateDefaults with a
named builder so the reserved quarto.* Pandoc template-variable
namespace has a single, typed contribution point. Future surfaces
(quarto.version, quarto.project, etc.) add a field to the
QuartoTemplateVariables interface and a contribution branch to
buildQuartoTemplateVariables rather than another if-block at the
defaults call site.

The language field is typed as FormatLanguage (the same canonical
shape used across HTML/website/book consumers), not Record<string,
unknown>, so future template references like
$quarto.language.<key>$ are grounded in the existing schema.

No behavior change: smoke-all guards lang-fr-template-resolve and
lang-fr-no-yaml-leakage still pass.
Channel 2d's contribution point moved from an inline block in
generateDefaults to a dedicated builder file. Update the section 2d
prose, the "Adding a new localized string" table (separate row for
adding to format.language vs. exposing a brand-new quarto.<area> key),
and key_files so future readers find the builder rather than reading
defaults.ts looking for the wiring.
isObject (already re-exported) is the broad lodash variant that
returns true for arrays and functions. For the "user-supplied YAML
value that should be a plain map before we spread it" use-case the
narrower lodash.isPlainObject is the right contract: rejects arrays,
functions, Date, Map, etc. Add it to the curated re-export surface
in src/core/lodash.ts so call sites can use ld.isPlainObject instead
of inlining `value !== null && typeof value === "object" && !Array.isArray(value)`.

First consumer lands with the variables.quarto.* override-precedence
fix in src/command/render/defaults.ts.
variables.quarto.* is an internal namespace populated by Quarto; the
user-facing override path for localized strings is the top-level
language: YAML key, which already flows through formatLanguage into
options.format.language and through the builder.

But if a user explicitly sets variables: { quarto: ... } in YAML as
an escape hatch, the previous spread order in generateDefaults
silently clobbered their value with the builder output. Reverse the
spread so builder values go in first and any pre-existing
variables.quarto.* survives on top. Guard the merge with
ld.isPlainObject so a non-object value (string, number, array)
written into variables.quarto is ignored defensively rather than
producing a runtime spread on a non-object.

The builder file's contribution-point comment and the channel-2d
section of llm-docs/localization-architecture.md now spell out the
internal-namespace contract and the precedence rule.

New smoke-all guard tests/docs/smoke-all/markdown/lang-fr-user-override-quarto-variables.qmd
pins the precedence: with lang: fr (default Chapitre) and
variables.quarto.language.crossref-ch-prefix: "Bouquin", the user
value resolves in the template.
Add unit tests for buildQuartoTemplateVariables that lock its
contract directly, without going through the render pipeline: missing
format.language returns undefined, populated language is surfaced
under the language field, the reference is passed through verbatim,
and an empty (defined) language still produces a wrapper. These run
in tests/unit/ and give future contributors a fast feedback target
when adding a new field to the QuartoTemplateVariables interface.

Add a smoke-all guard with lang: ja that asserts the Japanese value
"チャプター" for crossref-ch-prefix resolves through the entire
stringify -> defaults YAML -> Pandoc template tokenizer pipeline.
Guards against Unicode breakage in any of those stages.
@posit-snyk-bot
Copy link
Copy Markdown
Collaborator

posit-snyk-bot commented May 20, 2026

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.

@cscheid
Copy link
Copy Markdown
Member

cscheid commented May 20, 2026

(I think this is a great idea.)

cderv added 3 commits May 20, 2026 18:17
The previous spread `{ ...quartoVars, ...existingQuarto }` only merged at
depth 1 of `variables.quarto`. A user-supplied
`variables.quarto.language.crossref-ch-prefix: Bouquin` would replace the
entire localized `language` map, silently dropping every other
`$quarto.language.*$` resolution in templates (toc-title-document,
crossref-fig-title, etc.). The smoke test added with the original
precedence fix passed only because its template read the single
overridden key.

Switch to the in-tree canonical deep-merge helper `mergeConfigs` in
`src/core/config.ts`. Same helper is used in `core/language.ts`
`formatLanguage` for the upstream merge of user-supplied `language:`
onto `_language.yml` defaults — so the FormatLanguage tree is now
merged with consistent semantics at both ends of the pipeline.

The new smoke-all test exercises a two-key template
`$quarto.language.crossref-ch-prefix$|$quarto.language.toc-title-document$`
under `lang: fr` with the user overriding only the first key. Pre-fix
the second key would resolve empty; with deep-merge both render
correctly.
…l 2d row

Section 3c previously described orange-book typst-show.typ as already
migrated to `$quarto.language.*$` with a `supplement-chapter` argument
wired. Verified at the source: line 28-31 still uses the legacy
channel-2b form `$if(crossref.lof-title)$$crossref.lof-title$$else$$crossref-lof-title$$endif$`
and there is no `supplement-chapter` argument. The migration is still
pending at #14524. Describe the actual current state and flag the
+yaml_metadata_block leakage path that channel-2d migration will
eliminate.

Section 2d prose updated to describe the new deep-merge semantics from
the previous commit, with a worked example showing leaf-key precedence.

Section 4 channel-2d table row tightened so it no longer reads as if
adding to `_language.yml` is sufficient on its own. The key still has
to reach `format.language`, which is filtered by `kLanguageDefaultsKeys`
+ the `crossref-*-{title,prefix}` patterns at `src/core/language.ts:167-173`.
Step 3 of the general "Steps for every new key" already covers that;
the row now points back to it explicitly.
PR #14530 surfaced two pieces of architectural knowledge that were not
written down anywhere in the repo:

1. The canonical deep-merge helper family. mergeConfigs in core/config.ts
   wraps lodash.mergeWith with mergeArrayCustomizer (array union-concat
   with JSON.stringify dedup — surprisingly NOT last-in wins for arrays).
   mergeFormatMetadata, mergeProjectMetadata, and mergeConfigsCustomized
   layer additional per-key rules on top. Raw lodash.merge is never used
   in the render path. Without this written down, contributors reach for
   a shallow spread or for lodash.merge directly; both are wrong on
   nested config trees.

2. The multi-key smoke-test heuristic. A precedence smoke-all test where
   the template reads ONLY the overridden key can pass even under a
   deep-merge bug that drops every other sibling key. The fix is to
   probe at least one non-overridden sibling key in the same template.
   The lang-fr-user-override-deep-merge.qmd test added in the previous
   commit is the worked example.

Files:

- .claude/rules/typescript/config-merging.md (paths-scoped to src/**/*.ts):
  short rule that fires on TS work; points at the architectural doc.

- llm-docs/config-merging.md: full architectural reference following the
  existing llm-docs frontmatter convention (main_commit, analyzed_date,
  key_files). Covers helper hierarchy, call-site map, common pitfalls,
  guidance for adding a new wrapper.

- llm-docs/testing-patterns.md: appended a section under the existing
  "Smoke-All Tests (YAML-Based)" heading covering the multi-key probe
  heuristic.
@cderv
Copy link
Copy Markdown
Member Author

cderv commented May 20, 2026

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.

3 participants