diff --git a/.claude/rules/typescript/config-merging.md b/.claude/rules/typescript/config-merging.md index fd08c09a698..31d45e18701 100644 --- a/.claude/rules/typescript/config-merging.md +++ b/.claude/rules/typescript/config-merging.md @@ -12,8 +12,8 @@ canonical in-tree helpers — never raw `lodash.merge` or shallow spread. | Helper | File | Use when | |---|---|---| | `mergeConfigs(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(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(config, ...configs): T` | `src/config/metadata.ts` | Project YAML trees. Adds string-vs-string replace for `contents:` to avoid spurious glob concat. | +| `mergeFormatMetadata(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(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(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 diff --git a/llm-docs/config-merging.md b/llm-docs/config-merging.md index 4b6ae4b0a42..e4deb8eabde 100644 --- a/llm-docs/config-merging.md +++ b/llm-docs/config-merging.md @@ -67,11 +67,11 @@ 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`) @@ -79,6 +79,11 @@ Adds three semantics on top of `mergeConfigs`: 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 @@ -86,20 +91,22 @@ 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(customizer, config, ...configs): T`. Use directly only when none of the named wrappers fit. The customizer @@ -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.$`. | -| `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-.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. | diff --git a/llm-docs/localization-architecture.md b/llm-docs/localization-architecture.md index e4d6fe904b9..5d65954f0a4 100644 --- a/llm-docs/localization-architecture.md +++ b/llm-docs/localization-architecture.md @@ -79,10 +79,11 @@ Plus format-native localization (LaTeX babel, Typst `set text(lang:)`) that Quar - `src/resources/language/_language-.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-.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( @@ -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.$` (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.$`. | +| Pandoc template `$quarto.language.$` (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.$`. | | New Pandoc template variable under a fresh `$quarto..$` 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..$`. | | 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("", localized-value)` inside the existing `metaInjectLatex` block in `crossref/meta.lua`. | diff --git a/news/changelog-1.12.md b/news/changelog-1.12.md index 4645655562d..97d78c5ab06 100644 --- a/news/changelog-1.12.md +++ b/news/changelog-1.12.md @@ -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) diff --git a/src/command/render/render-contexts.ts b/src/command/render/render-contexts.ts index 470de3a1b59..a03964779dc 100644 --- a/src/command/render/render-contexts.ts +++ b/src/command/render/render-contexts.ts @@ -23,7 +23,9 @@ import { formatFromMetadata, formatKeys, includedMetadata, + mergeConfigsCustomized, mergeFormatMetadata, + mergeLanguageCustomizer, metadataAsFormat, } from "../../config/metadata.ts"; @@ -388,7 +390,8 @@ function mergeQuartoConfigs( return config; }; - return mergeConfigs( + return mergeConfigsCustomized( + mergeLanguageCustomizer, fixupFormat(config), ...configs.map((c) => fixupFormat(c)), ); diff --git a/src/config/metadata.ts b/src/config/metadata.ts index 3872718eee7..4010472b0a6 100644 --- a/src/config/metadata.ts +++ b/src/config/metadata.ts @@ -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 { @@ -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"; @@ -283,7 +284,7 @@ export function mergeFormatMetadata( } else if (kBooleanDisableArrays.includes(key)) { return mergeDisablableArray(objValue, srcValue); } else { - return undefined; + return mergeLanguageCustomizer(objValue, srcValue, key); } }, config, @@ -291,6 +292,23 @@ export function mergeFormatMetadata( ); } +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( config: T, ...configs: Array @@ -306,7 +324,7 @@ export function mergeProjectMetadata( ) { return srcValue; } else { - return undefined; + return mergeLanguageCustomizer(objValue, srcValue, key); } }, config, diff --git a/src/core/language.ts b/src/core/language.ts index 72450371624..82f6b67026e 100644 --- a/src/core/language.ts +++ b/src/core/language.ts @@ -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"; @@ -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, + }; + 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]; } }); diff --git a/src/project/project-context.ts b/src/project/project-context.ts index 205c8a54ae0..e3ff477bf88 100644 --- a/src/project/project-context.ts +++ b/src/project/project-context.ts @@ -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, @@ -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"; @@ -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; diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/.gitignore b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language-fr.yml b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language-fr.yml new file mode 100644 index 00000000000..9eb3942704e --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language-fr.yml @@ -0,0 +1 @@ +toc-title-document: "Sommaire du fichier de langue" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language.yml b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language.yml new file mode 100644 index 00000000000..df3ccb10911 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language.yml @@ -0,0 +1 @@ +toc-title-document: "Contents from the language file" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_quarto.yml b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_quarto.yml new file mode 100644 index 00000000000..9b9872184c1 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_quarto.yml @@ -0,0 +1,5 @@ +project: + type: default +lang: fr +language: + toc-title-document: "Contents from the project" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/index.qmd b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/index.qmd new file mode 100644 index 00000000000..6aa0e63777c --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/index.qmd @@ -0,0 +1,16 @@ +--- +title: "The project language overrides the per-language file next to the project" +toc: true +_quarto: + tests: + html: + ensureFileRegexMatches: + - ['

Contents from the project

'] + - ['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. diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project/.gitignore b/tests/docs/smoke-all/2026/11/12/issue-15349-project/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project/_quarto.yml b/tests/docs/smoke-all/2026/11/12/issue-15349-project/_quarto.yml new file mode 100644 index 00000000000..3067736dea1 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project/_quarto.yml @@ -0,0 +1,4 @@ +project: + type: default +lang: fr +language: custom.yml diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project/custom-fr.yml b/tests/docs/smoke-all/2026/11/12/issue-15349-project/custom-fr.yml new file mode 100644 index 00000000000..720957173fd --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project/custom-fr.yml @@ -0,0 +1 @@ +toc-title-document: "Sommaire du projet" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project/custom.yml b/tests/docs/smoke-all/2026/11/12/issue-15349-project/custom.yml new file mode 100644 index 00000000000..304ef5fc48b --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project/custom.yml @@ -0,0 +1 @@ +toc-title-document: "Contents from the project" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349-project/index.qmd b/tests/docs/smoke-all/2026/11/12/issue-15349-project/index.qmd new file mode 100644 index 00000000000..c2cc32dbcb3 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349-project/index.qmd @@ -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: + - ['

Contents from the document

'] + - ['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. diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_extension.yml b/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_extension.yml new file mode 100644 index 00000000000..a2af6c7a08c --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_extension.yml @@ -0,0 +1,7 @@ +title: Language file +author: Quarto Dev Team +version: 1.0.0 +contributes: + formats: + html: + language: _language.yml diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language-fr.yml b/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language-fr.yml new file mode 100644 index 00000000000..dca666edd51 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language-fr.yml @@ -0,0 +1 @@ +toc-title-document: "Sommaire de l extension" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language.yml b/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language.yml new file mode 100644 index 00000000000..e86622bd2fa --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language.yml @@ -0,0 +1 @@ +toc-title-document: "Contents from the extension" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349/_metadata-files-language.yml b/tests/docs/smoke-all/2026/11/12/issue-15349/_metadata-files-language.yml new file mode 100644 index 00000000000..72d5f13144f --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349/_metadata-files-language.yml @@ -0,0 +1,2 @@ +language: + toc-title-document: "Contents from the metadata file" diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349/issue-15349.qmd b/tests/docs/smoke-all/2026/11/12/issue-15349/issue-15349.qmd new file mode 100644 index 00000000000..7a9e2edb8c4 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349/issue-15349.qmd @@ -0,0 +1,20 @@ +--- +title: "The document language overrides the per-language file of an extension" +format: langfile-html +toc: true +lang: fr +language: + toc-title-document: "Contents from the document" +_quarto: + tests: + langfile-html: + ensureFileRegexMatches: + - ['

Contents from the document

'] + - ['Sommaire de l extension', 'Contents from the extension'] +--- + +## Section + +The `langfile` extension has a `_language-fr.yml` file next to its `_language.yml` file. +The document sets `toc-title-document` under `language`, with `lang: fr`. +The value of the document must replace the value of the French file of the extension. diff --git a/tests/docs/smoke-all/2026/11/12/issue-15349/metadata-files.qmd b/tests/docs/smoke-all/2026/11/12/issue-15349/metadata-files.qmd new file mode 100644 index 00000000000..764616ded44 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/12/issue-15349/metadata-files.qmd @@ -0,0 +1,22 @@ +--- +title: "A metadata file overrides the per-language block of the front matter" +toc: true +lang: fr +language: + fr: + toc-title-document: "Sommaire du document" +metadata-files: + - _metadata-files-language.yml +_quarto: + tests: + html: + ensureFileRegexMatches: + - ['

Contents from the metadata file

'] + - ['Sommaire du document'] +--- + +## Section + +The front matter sets `toc-title-document` under `language: fr:`. +The file in `metadata-files` sets `toc-title-document` as a plain key under `language`. +Quarto merges the metadata file after the front matter, so its value must win. diff --git a/tests/unit/core/language-merge.test.ts b/tests/unit/core/language-merge.test.ts new file mode 100644 index 00000000000..83dd5d46f0d --- /dev/null +++ b/tests/unit/core/language-merge.test.ts @@ -0,0 +1,85 @@ +/* + * language-merge.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assertEquals } from "../../asserts.ts"; + +import { translationsForLang } from "../../../src/core/language.ts"; +import { + mergeFormatMetadata, + mergeProjectMetadata, +} from "../../../src/config/metadata.ts"; +import { FormatLanguage } from "../../../src/config/types.ts"; + +const kKey = "toc-title-document"; +const kOtherKey = "toc-title-website"; + +type Layer = { language: FormatLanguage }; + +const kEarlyFr: Layer = { + language: { + [kKey]: "early", + [kOtherKey]: "early other", + fr: { [kKey]: "early fr", [kOtherKey]: "early other fr" }, + "fr-CA": { [kKey]: "early fr-CA" }, + }, +}; + +function resolve( + lang: string, + key: string, + merge: (config: Layer, ...configs: Layer[]) => Layer, + ...layers: Layer[] +) { + const [first, ...rest] = layers; + return translationsForLang(merge(first, ...rest).language, lang)[key]; +} + +for ( + const [name, merge] of [ + ["mergeFormatMetadata", mergeFormatMetadata], + ["mergeProjectMetadata", mergeProjectMetadata], + ] as const +) { + unitTest( + `language-merge - ${name} - later plain key replaces earlier variations`, + async () => { + const late = { language: { [kKey]: "late" } }; + assertEquals(resolve("fr", kKey, merge, kEarlyFr, late), "late"); + assertEquals(resolve("fr-CA", kKey, merge, kEarlyFr, late), "late"); + }, + ); + + unitTest( + `language-merge - ${name} - earlier variation keeps other keys`, + async () => { + const late = { language: { [kKey]: "late" } }; + assertEquals( + resolve("fr", kOtherKey, merge, kEarlyFr, late), + "early other fr", + ); + }, + ); + + unitTest( + `language-merge - ${name} - later variation replaces earlier plain key`, + async () => { + const late = { language: { [kKey]: "late", fr: { [kKey]: "late fr" } } }; + assertEquals( + resolve("fr", kKey, merge, { language: { [kKey]: "early" } }, late), + "late fr", + ); + }, + ); +} + +unitTest("language-merge - earlier array value stays an array", async () => { + const merged = mergeFormatMetadata( + { language: { custom: ["a", "b"] } as unknown as FormatLanguage }, + { language: { [kKey]: "late" } }, + ); + assertEquals(merged.language.custom, ["a", "b"]); +});