Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/rules/typescript/config-merging.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ canonical in-tree helpers — never raw `lodash.merge` or shallow spread.
| Helper | File | Use when |
|---|---|---|
| `mergeConfigs<T>(config, ...configs): T` | `src/core/config.ts` | General nested-object merge. Deep-merges objects; **arrays union-concat with dedup by `JSON.stringify`** (not last-wins); scalars last-wins. Deep-clones inputs. |
| `mergeFormatMetadata<T>(config, ...configs): T` | `src/config/metadata.ts` | Format-level objects (`.pandoc`, `.metadata`, `.language`, `.render`). Adds `kTblColwidths` replace, `kVariant` pandoc-extension merge, and `kCodeLinks`/`kOtherLinks` `false→[]` semantics on top of `mergeConfigs`. |
| `mergeProjectMetadata<T>(config, ...configs): T` | `src/config/metadata.ts` | Project YAML trees. Adds string-vs-string replace for `contents:` to avoid spurious glob concat. |
| `mergeFormatMetadata<T>(config, ...configs): T` | `src/config/metadata.ts` | Format-level objects (`.pandoc`, `.metadata`, `.language`, `.render`). Adds `kTblColwidths` replace, `kVariant` pandoc-extension merge, `kCodeLinks`/`kOtherLinks` `false→[]` semantics, and `language` merging through `mergeLanguages` (a later plain key also clears that key from earlier locale sub-blocks) on top of `mergeConfigs`. |
| `mergeProjectMetadata<T>(config, ...configs): T` | `src/config/metadata.ts` | Project YAML trees. Adds string-vs-string replace for `contents:` to avoid spurious glob concat, and the same `language` merging as `mergeFormatMetadata`. |
| `mergeConfigsCustomized<T>(customizer, config, ...configs): T` | `src/config/metadata.ts` | Escape hatch — supply a per-key customizer that runs before `mergeArrayCustomizer`. |

Shallow `{ ...a, ...b }` is correct **only** when both sides are flat
Expand Down
20 changes: 14 additions & 6 deletions llm-docs/config-merging.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,39 +67,46 @@ handling of `kTblColwidths` for an in-tree example.

## Format-level: `mergeFormatMetadata`

`src/config/metadata.ts:266` wraps `mergeConfigsCustomized` for `Format`
`src/config/metadata.ts:267` wraps `mergeConfigsCustomized` for `Format`
objects (the `.pandoc`, `.metadata`, `.language`, `.render`, `.execute`,
etc. subtrees on a per-format basis).

Adds three semantics on top of `mergeConfigs`:
Adds four semantics on top of `mergeConfigs`:

- `kTblColwidths`: `srcValue` always wins (replace, not array union).
- `kVariant`: Pandoc extension strings (`+yaml_metadata_block-tex_math_dollars`)
are merged via a dedicated extension-aware combiner so partial overrides
do not lose previously-enabled extensions.
- `kCodeLinks` / `kOtherLinks` (`kBooleanDisableArrays`): explicit `false`
becomes an empty array (the disable signal), not the default array-union.
- `language` (`kLanguageDefaults`), when both sides are objects: merged by
`mergeLanguages` (`src/core/language.ts`) through the shared
`mergeLanguageCustomizer`. A plain key of the later map also clears that
key from every locale sub-block (`fr:`, `fr-CA:`) of the earlier map, so
an earlier `_language-fr.yml` cannot beat a later plain key (#15349).

Use this — not raw `mergeConfigs` — anywhere two `Format` objects need to
combine (project default + directory `_metadata.yml` + document
frontmatter).

## Project-level: `mergeProjectMetadata`

`src/config/metadata.ts:294` wraps `mergeConfigsCustomized` for the project
config tree. Adds one rule:
`src/config/metadata.ts:312` wraps `mergeConfigsCustomized` for the project
config tree. Adds two rules:

- `contents` key holding a string: `srcValue` replaces. Avoids spurious
concat of glob-string lists (`["docs/*"]` merged with `["src/*"]` would
produce `["docs/*", "src/*"]` under the default array-union, often
unintended for `contents:`).
- `language`: the same `mergeLanguageCustomizer` as `mergeFormatMetadata`,
so profiles, includes and project type extensions follow the same rule.

Reach for this when merging project YAML layers (project root, parent
project, includes).

## The escape hatch: `mergeConfigsCustomized`

`src/config/metadata.ts:317` —
`src/config/metadata.ts:335` —
`mergeConfigsCustomized<T>(customizer, config, ...configs): T`.

Use directly only when none of the named wrappers fit. The customizer
Expand All @@ -112,7 +119,8 @@ short-circuit, or return `undefined` to fall through to
| Site | What's merged |
|---|---|
| `src/core/language.ts:formatLanguage` | `_language.yml` defaults ← user-supplied `language:` block. Same FormatLanguage shape Quarto exposes under `$quarto.language.<key>$`. |
| `src/project/project-context.ts:resolveLanguageTranslations` | Project `kLanguageDefaults` ← `translations.language` (user overrides win on project language defaults). |
| `src/project/project-context.ts:resolveLanguageTranslations` | `mergeLanguages(translations.language, projectConfig.language)`: the inline project `language:` wins over `_language.yml` and its `_language-<lang>.yml` sub-blocks. |
| `src/command/render/render-contexts.ts:mergeQuartoConfigs` | Document front matter ← `metadata-files` includes ← `--metadata` flags, through `mergeConfigsCustomized(mergeLanguageCustomizer, …)`. |
| `src/command/render/render-contexts.ts:resolveFormats` | Three-layer `mergeFormatMetadata(projFormat, directoryFormat, inputFormat)` — project / `_metadata.yml` / frontmatter, leaf-key precedence. |
| `src/command/render/defaults.ts:generateDefaults` | `variables.quarto.*` builder output ← user-supplied `variables.quarto.*` escape hatch. Internal namespace; user-set leaf keys win at any depth. |
| `src/command/render/pandoc.ts` | Multiple layered metadata merges across format / theme / extension contributions. |
Expand Down
9 changes: 5 additions & 4 deletions llm-docs/localization-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@ Plus format-native localization (LaTeX babel, Typst `set text(lang:)`) that Quar
- `src/resources/language/_language-<bcp47>.yml` — overrides per language. `_language-fr.yml` provides French; `_language-pt-BR.yml` provides Brazilian Portuguese with `_language-pt.yml` as parent.
- `src/resources/schema/definitions.yml` — `format-language` schema enumerates valid keys. Adding a new key to YAML requires also adding it here.
- `src/config/constants.ts:320-383` — `kCrossref*Title`, `kCrossref*Prefix`, `k*Title`, `kListingPage*` constants for every key, plus `kLanguageDefaultsKeys` array used as the gatekeeper.
- `src/core/language.ts:135` — `readDefaultLanguageTranslations(lang)` resolves the base file, calls `readLanguageTranslations` which walks BCP-47 subtags and merges variation files.
- `src/core/language.ts:142` — `readDefaultLanguageTranslations(lang)` resolves the base file, calls `readLanguageTranslations` which walks BCP-47 subtags and merges variation files.
- `src/core/language.ts:103-130` — `readLanguageTranslations`: per subtag, loads `_language-<variation>.yml` and merges onto base.
- `src/core/language.ts:161-188` — `translationsForLang(language, lang)`: keeps `kLanguageDefaultsKeys`, `crossref-*-title`, `crossref-*-prefix`, and any other non-object (or `null`) value — the last clause is the pass-through for custom/extension keys Quarto does not ship (#14772) — then merges variation sub-objects (`fr-CA` falls back to `fr`).
- `src/core/language.ts:190-211` — `formatLanguage(metadata, language, flags)`: reads `kLang` from flags/metadata (default `"en"`), calls `readDefaultLanguageTranslations`, merges any user-provided `language:` block, calls `translationsForLang`.
- `src/core/language.ts:207-229` — `translationsForLang(language, lang)`: keeps `kLanguageDefaultsKeys`, `crossref-*-title`, `crossref-*-prefix`, and any other non-object (or `null`) value — the last clause is the pass-through for custom/extension keys Quarto does not ship (#14772) — then merges variation sub-objects (`fr-CA` falls back to `fr`).
- `src/core/language.ts:182-205` — `mergeLanguages(base, language)`: merges two `language:` maps from different layers (extension, project, `_metadata.yml`, front matter, profiles, includes). A plain key from the later map also clears that key from every locale sub-block of the earlier map, so it wins over an earlier `_language-fr.yml` (#15349). Inside one map, a locale sub-block still wins over the plain keys. `mergeFormatMetadata` and `mergeProjectMetadata` use it for the `language` key, and `project-context.ts` uses it to merge `_language.yml` with the inline project keys. `formatLanguage` keeps plain `mergeConfigs`, because the Quarto defaults it merges under are already flattened.
- `src/core/language.ts:231-252` — `formatLanguage(metadata, language, flags)`: reads `kLang` from flags/metadata (default `"en"`), calls `readDefaultLanguageTranslations`, merges any user-provided `language:` block, calls `translationsForLang`.
- `src/command/render/render-contexts.ts:254-258` — assigns the resolved object onto every `Format`:
```ts
formats[formatKey].format.language = await formatLanguage(
Expand Down Expand Up @@ -221,7 +222,7 @@ Pick the channel that matches your consumer:
|----------|---------|--------------|
| Lua filter logic, AST text injection | 2a | New `kLanguageDefaultsKeys` constant. Optionally extend `languageFilterParams` if the prefix is unusual. Read in Lua via `param()`. |
| Built-in Pandoc template `$var$` (single scalar, flat) | 2b (TS) | Add copy block in `pandoc.ts` near `kTocTitle`. Pattern: `metadata[k] = metadata[k] || language[k];`. Use this only when changing the template is not an option; otherwise prefer 2d. |
| Pandoc template `$quarto.language.<key>$` (any template, any format) | **2d** | Once the key is in `format.language`, `buildQuartoTemplateVariables` exposes the whole table — no builder-side wiring needed. Step 3 below (adding the key to `kLanguageDefaultsKeys`) is a convention for keys Quarto ships officially, not a runtime gate: any key whose value is a scalar (or `null`), not an object, reaches `format.language` regardless — see the pass-through checks at `src/core/language.ts:174-180` (`translationsForLang`) and `:119-123` (`readLanguageTranslations`). Templates then read `$quarto.language.<key>$`. |
| Pandoc template `$quarto.language.<key>$` (any template, any format) | **2d** | Once the key is in `format.language`, `buildQuartoTemplateVariables` exposes the whole table — no builder-side wiring needed. Step 3 below (adding the key to `kLanguageDefaultsKeys`) is a convention for keys Quarto ships officially, not a runtime gate: any key whose value is a scalar (or `null`), not an object, reaches `format.language` regardless — see the pass-through checks at `src/core/language.ts:171-177` (`isTranslationKey`, used by `translationsForLang` and `mergeLanguages`) and `:119-123` (`readLanguageTranslations`). Templates then read `$quarto.language.<key>$`. |
| New Pandoc template variable under a fresh `$quarto.<area>.<key>$` form (not language) | **2d** | Add a field to `QuartoTemplateVariables` and a contribution branch to `buildQuartoTemplateVariables` in `src/command/render/quarto-template-variables.ts`. Templates read `$quarto.<area>.<key>$`. |
| HTML DOM / front-end JSON | 2c | Add `format.language[key]` read in the appropriate `format-html-*.ts` postprocessor. |
| LaTeX command override | 3b path 2 | Add `maybeRenewCommand("<cmd>", localized-value)` inside the existing `metaInjectLatex` block in `crossref/meta.lua`. |
Expand Down
1 change: 1 addition & 0 deletions news/changelog-1.12.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ All changes included in 1.12:
- ([#15289](https://github.com/quarto-dev/quarto-cli/issues/15289)): Translate the `announcement-dismiss-label` language key, the accessible name of the close button of a website announcement, in Azerbaijani, Basque, Bulgarian, Catalan, Chinese (Simplified and Traditional), Czech, Dutch, Finnish, French, German, Hebrew, Icelandic, Indonesian, Italian, Japanese, Korean, Lithuanian, Norwegian Nynorsk, Polish, Portuguese, Russian, Serbian (Latin), Slovak, Slovenian, Spanish, Swedish, Turkish and Ukrainian, using reviewed machine translation.
- ([#15325](https://github.com/quarto-dev/quarto-cli/issues/15325)): Validate and offer completion for `column`, `fig-column` and `tbl-column` in document front matter. They already took effect there, but an invalid value was silently accepted.
- ([#15341](https://github.com/quarto-dev/quarto-cli/issues/15341)): Fix non-ASCII characters in large output from external tools such as Pandoc, knitr or LaTeX occasionally turning into `�`.
- ([#15349](https://github.com/quarto-dev/quarto-cli/issues/15349)): Fix a per-language file such as `_language-fr.yml`, or a `fr:` block under `language`, from an extension, a project or the front matter replacing the `language` keys that a later layer sets. A key set later now wins over the same key in an earlier per-language block. (author: @mcanouil)
5 changes: 4 additions & 1 deletion src/command/render/render-contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
formatFromMetadata,
formatKeys,
includedMetadata,
mergeConfigsCustomized,
mergeFormatMetadata,
mergeLanguageCustomizer,
metadataAsFormat,
} from "../../config/metadata.ts";

Expand Down Expand Up @@ -388,7 +390,8 @@ function mergeQuartoConfigs(
return config;
};

return mergeConfigs(
return mergeConfigsCustomized(
mergeLanguageCustomizer,
fixupFormat(config),
...configs.map((c) => fixupFormat(c)),
);
Expand Down
24 changes: 21 additions & 3 deletions src/config/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { error } from "../deno_ral/log.ts";

import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts";
import { mergeArrayCustomizer } from "../core/config.ts";
import { mergeLanguages } from "../core/language.ts";
import { Schema } from "../core/lib/yaml-schema/types.ts";

import {
Expand Down Expand Up @@ -44,7 +45,7 @@ import {
kTblColwidths,
kVariant,
} from "./constants.ts";
import { Format, Metadata } from "./types.ts";
import { Format, FormatLanguage, Metadata } from "./types.ts";
import { kGfmCommonmarkVariant } from "../format/markdown/format-markdown-consts.ts";
import { kJupyterEngine, kKnitrEngine } from "../execute/types.ts";

Expand Down Expand Up @@ -283,14 +284,31 @@ export function mergeFormatMetadata<T>(
} else if (kBooleanDisableArrays.includes(key)) {
return mergeDisablableArray(objValue, srcValue);
} else {
return undefined;
return mergeLanguageCustomizer(objValue, srcValue, key);
}
},
config,
...configs,
);
}

export function mergeLanguageCustomizer(
objValue: unknown,
srcValue: unknown,
key: string,
) {
if (
key === kLanguageDefaults && ld.isPlainObject(objValue) &&
ld.isPlainObject(srcValue)
) {
return mergeLanguages(
objValue as FormatLanguage,
srcValue as FormatLanguage,
);
}
return undefined;
}

export function mergeProjectMetadata<T>(
config: T,
...configs: Array<T>
Expand All @@ -306,7 +324,7 @@ export function mergeProjectMetadata<T>(
) {
return srcValue;
} else {
return undefined;
return mergeLanguageCustomizer(objValue, srcValue, key);
}
},
config,
Expand Down
51 changes: 41 additions & 10 deletions src/core/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { FormatLanguage, Metadata } from "../config/types.ts";
import { dirAndStem, normalizePath } from "./path.ts";
import { resourcePath } from "./resources.ts";
import { mergeConfigs } from "./config.ts";
import { isPlainObject } from "./lodash.ts";
import { readAndValidateYamlFromFile } from "./schema/validated-yaml.ts";
import { RenderFlags } from "../command/render/types.ts";
import { getSchemaDefinition } from "./lib/yaml-validation/schema.ts";
Expand Down Expand Up @@ -164,20 +165,50 @@ export async function resolveLanguageMetadata(metadata: Metadata, dir: string) {
}
}

// crossrefs can be custom, so be more lenient
// any other non-object value is a user or extension defined string,
// objects are locale variations
function isTranslationKey(language: FormatLanguage, key: string) {
return kLanguageDefaultsKeys.includes(key) ||
/^crossref-.*-title$/.test(key) ||
/^crossref-.*-prefix$/.test(key) ||
language[key] === null ||
typeof language[key] !== "object";
}

// A variation applies on top of the plain keys of the language that holds it,
// so a plain key of a later language must also replace that key in the
// variations of the earlier languages, or the variation would win.
export function mergeLanguages(
base: FormatLanguage | undefined,
language: FormatLanguage | undefined,
): FormatLanguage {
const merged = { ...base } as FormatLanguage;
const next = language ?? {};
const keys = Object.keys(next).filter((key) => isTranslationKey(next, key));
if (keys.length === 0) {
return mergeConfigs(merged, next);
}
Object.keys(merged).forEach((variation) => {
if (
!isTranslationKey(merged, variation) &&
isPlainObject(merged[variation])
) {
const translations = {
...merged[variation] as Record<string, unknown>,
};
keys.forEach((key) => delete translations[key]);
merged[variation] = translations;
}
});
return mergeConfigs(merged, next);
}

export function translationsForLang(language: FormatLanguage, lang: string) {
// start with the defaults
let translations = {} as FormatLanguage;
Object.keys(language).forEach((key) => {
// crossrefs can be custom, so be more lenient
// any other non-object value is a user or extension defined string,
// objects are locale variations and are merged below instead
if (
kLanguageDefaultsKeys.includes(key) ||
key.match(/^crossref-.*-title$/) ||
key.match(/^crossref-.*-prefix$/) ||
language[key] === null ||
typeof language[key] !== "object"
) {
if (isTranslationKey(language, key)) {
translations[key] = language[key];
}
});
Expand Down
12 changes: 9 additions & 3 deletions src/project/project-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import { existsSync, walk, walkSync } from "../deno_ral/fs.ts";
import * as ld from "../core/lodash.ts";

import { ProjectType } from "./types/types.ts";
import { Format, Metadata, PandocFlags } from "../config/types.ts";
import {
Format,
FormatLanguage,
Metadata,
PandocFlags,
} from "../config/types.ts";
import {
kProjectLibDir,
kProjectOutputDir,
Expand Down Expand Up @@ -53,6 +58,7 @@ import { projectType, projectTypes } from "./types/project-types.ts";

import { resolvePathGlobs } from "../core/path.ts";
import {
mergeLanguages,
readLanguageTranslations,
resolveLanguageMetadata,
} from "../core/language.ts";
Expand Down Expand Up @@ -985,9 +991,9 @@ async function resolveLanguageTranslations(
const translations = await readLanguageTranslations(
join(dir, "_language.yml"),
);
projectConfig[kLanguageDefaults] = mergeConfigs(
projectConfig[kLanguageDefaults] = mergeLanguages(
translations.language,
projectConfig[kLanguageDefaults],
projectConfig[kLanguageDefaults] as FormatLanguage,
);
files.push(...translations.files);
return files;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
toc-title-document: "Sommaire du fichier de langue"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
toc-title-document: "Contents from the language file"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
project:
type: default
lang: fr
language:
toc-title-document: "Contents from the project"
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: "The project language overrides the per-language file next to the project"
toc: true
_quarto:
tests:
html:
ensureFileRegexMatches:
- ['<h2 id="toc-title">Contents from the project</h2>']
- ['Sommaire du fichier de langue', 'Contents from the language file']
---

## Section

The project sets `lang: fr` and `toc-title-document` under `language`.
The project directory also has `_language.yml` and `_language-fr.yml` files.
The value in `_quarto.yml` must replace the value of the French language file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project:
type: default
lang: fr
language: custom.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
toc-title-document: "Sommaire du projet"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
toc-title-document: "Contents from the project"
18 changes: 18 additions & 0 deletions tests/docs/smoke-all/2026/11/12/issue-15349-project/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
title: "The document language overrides the per-language file of the project"
toc: true
language:
toc-title-document: "Contents from the document"
_quarto:
tests:
html:
ensureFileRegexMatches:
- ['<h2 id="toc-title">Contents from the document</h2>']
- ['Sommaire du projet', 'Contents from the project']
---

## Section

The project sets `lang: fr` and `language: custom.yml`, and has a `custom-fr.yml` file next to `custom.yml`.
The document sets `toc-title-document` under `language`.
The value of the document must replace the value of the French file of the project.
Loading
Loading