From 74effb49f7904aa23b763db319d17c6637f636a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:45:46 +0100 Subject: [PATCH 1/5] fix: let later language keys beat per-language files A per-language file such as `_language-fr.yml` is stored as a variation object that applies on top of the plain keys. When a later layer (the document, or the inline `language:` of `_quarto.yml`) set the same key as a plain key, the earlier variation still won once `lang` matched. A plain key from a later layer now also removes that key from the variations of the earlier layers. A variation still wins over the plain keys of its own layer. Fixes #15349 --- news/changelog-1.12.md | 1 + src/config/metadata.ts | 11 ++- src/core/language.ts | 45 +++++++--- src/project/project-context.ts | 12 ++- .../12/issue-15349-project-inline/.gitignore | 2 + .../_language-fr.yml | 1 + .../issue-15349-project-inline/_language.yml | 1 + .../12/issue-15349-project-inline/_quarto.yml | 5 ++ .../12/issue-15349-project-inline/index.qmd | 16 ++++ .../2026/11/12/issue-15349-project/.gitignore | 2 + .../11/12/issue-15349-project/_quarto.yml | 4 + .../11/12/issue-15349-project/custom-fr.yml | 1 + .../2026/11/12/issue-15349-project/custom.yml | 1 + .../2026/11/12/issue-15349-project/index.qmd | 18 ++++ .../_extensions/langfile/_extension.yml | 7 ++ .../_extensions/langfile/_language-fr.yml | 1 + .../_extensions/langfile/_language.yml | 1 + .../2026/11/12/issue-15349/issue-15349.qmd | 20 +++++ tests/unit/core/language-merge.test.ts | 82 +++++++++++++++++++ 19 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/.gitignore create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language-fr.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_language.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/_quarto.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project-inline/index.qmd create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project/.gitignore create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project/_quarto.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project/custom-fr.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project/custom.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349-project/index.qmd create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_extension.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language-fr.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349/_extensions/langfile/_language.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349/issue-15349.qmd create mode 100644 tests/unit/core/language-merge.test.ts diff --git a/news/changelog-1.12.md b/news/changelog-1.12.md index 9d1014a83a4..fad4b331d21 100644 --- a/news/changelog-1.12.md +++ b/news/changelog-1.12.md @@ -65,3 +65,4 @@ All changes included in 1.12: - ([#15271](https://github.com/quarto-dev/quarto-cli/issues/15271)): Translate the `tools-share` and `tools-download` language keys in Catalan, Chinese, Czech, Danish, Dutch, Finnish, French, German, Hebrew, Indonesian, Italian, Japanese, Korean, Lithuanian, Norwegian Bokmål, Norwegian Nynorsk, Polish, Portuguese, Russian, Slovak, Swedish, Turkish and Ukrainian, and `tools-share` in Serbian (Latin), using reviewed machine translation. - ([#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. +- ([#15349](https://github.com/quarto-dev/quarto-cli/issues/15349)): Fix a per-language file such as `_language-fr.yml` from an extension or a project replacing the `language` keys that a document or `_quarto.yml` sets. A key set later now wins over the same key in an earlier per-language file. (author: @mcanouil) diff --git a/src/config/metadata.ts b/src/config/metadata.ts index 3872718eee7..d96c82277f1 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"; @@ -282,6 +283,14 @@ export function mergeFormatMetadata( return mergePandocVariant(objValue, srcValue); } else if (kBooleanDisableArrays.includes(key)) { return mergeDisablableArray(objValue, srcValue); + } else if ( + key === kLanguageDefaults && ld.isPlainObject(objValue) && + ld.isPlainObject(srcValue) + ) { + return mergeLanguages( + objValue as FormatLanguage, + srcValue as FormatLanguage, + ); } else { return undefined; } diff --git a/src/core/language.ts b/src/core/language.ts index 72450371624..84561077537 100644 --- a/src/core/language.ts +++ b/src/core/language.ts @@ -164,20 +164,45 @@ 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 = mergeConfigs({} as FormatLanguage, base); + if (!language) { + return merged; + } + const keys = Object.keys(language).filter((key) => + isTranslationKey(language, key) + ); + Object.keys(merged).forEach((variation) => { + if (!isTranslationKey(merged, variation)) { + const translations = merged[variation] as Record; + keys.forEach((key) => delete translations[key]); + } + }); + return mergeConfigs(merged, language); +} + 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/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/unit/core/language-merge.test.ts b/tests/unit/core/language-merge.test.ts new file mode 100644 index 00000000000..2fcdc141c09 --- /dev/null +++ b/tests/unit/core/language-merge.test.ts @@ -0,0 +1,82 @@ +/* + * language-merge.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assertEquals } from "testing/asserts"; + +import { translationsForLang } from "../../../src/core/language.ts"; +import { mergeFormatMetadata } from "../../../src/config/metadata.ts"; +import { FormatLanguage } from "../../../src/config/types.ts"; + +const kKey = "toc-title-document"; + +type Layer = { language: FormatLanguage }; + +function resolve(lang: string, ...languages: FormatLanguage[]) { + const [first, ...rest] = languages.map((language): Layer => ({ language })); + const merged = mergeFormatMetadata(first, ...rest); + return translationsForLang(merged.language, lang)[kKey]; +} + +unitTest( + "language-merge - later plain key replaces earlier variation", + async () => { + assertEquals( + resolve("fr", { [kKey]: "early", fr: { [kKey]: "early fr" } }, { + [kKey]: "late", + }), + "late", + ); + }, +); + +unitTest( + "language-merge - later key replaces every earlier variation", + async () => { + assertEquals( + resolve("fr-CA", { + [kKey]: "early", + fr: { [kKey]: "early fr" }, + "fr-CA": { [kKey]: "early fr-CA" }, + }, { [kKey]: "late" }), + "late", + ); + }, +); + +unitTest("language-merge - variation wins in its own language", async () => { + assertEquals( + resolve("fr", { [kKey]: "early", fr: { [kKey]: "early fr" } }), + "early fr", + ); +}); + +unitTest( + "language-merge - later variation replaces earlier plain key", + async () => { + assertEquals( + resolve("fr", { [kKey]: "early" }, { + [kKey]: "late", + fr: { [kKey]: "late fr" }, + }), + "late fr", + ); + }, +); + +unitTest( + "language-merge - other keys of earlier variation remain", + async () => { + const merged = mergeFormatMetadata( + { language: { fr: { [kKey]: "early fr", "toc-title-website": "site" } } }, + { language: { [kKey]: "late" } }, + ); + assertEquals( + translationsForLang(merged.language, "fr")["toc-title-website"], + "site", + ); + }, +); From 54f9482442db20ae8a2bf514a7787da3179158eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:50:53 +0100 Subject: [PATCH 2/5] fix: apply language merge to project metadata Profiles, `metadata-files` includes and project type extensions merge through `mergeProjectMetadata`, so a plain `language` key there could still lose to an earlier per-language variation. --- src/config/metadata.ts | 29 +++--- src/core/language.ts | 17 ++-- tests/unit/core/language-merge.test.ts | 117 ++++++++++++------------- 3 files changed, 83 insertions(+), 80 deletions(-) diff --git a/src/config/metadata.ts b/src/config/metadata.ts index d96c82277f1..dd07cdfc571 100644 --- a/src/config/metadata.ts +++ b/src/config/metadata.ts @@ -283,16 +283,8 @@ export function mergeFormatMetadata( return mergePandocVariant(objValue, srcValue); } else if (kBooleanDisableArrays.includes(key)) { return mergeDisablableArray(objValue, srcValue); - } else if ( - key === kLanguageDefaults && ld.isPlainObject(objValue) && - ld.isPlainObject(srcValue) - ) { - return mergeLanguages( - objValue as FormatLanguage, - srcValue as FormatLanguage, - ); } else { - return undefined; + return mergeLanguageCustomizer(objValue, srcValue, key); } }, config, @@ -300,6 +292,23 @@ export function mergeFormatMetadata( ); } +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 @@ -315,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 84561077537..266d68bd8d0 100644 --- a/src/core/language.ts +++ b/src/core/language.ts @@ -182,20 +182,19 @@ export function mergeLanguages( base: FormatLanguage | undefined, language: FormatLanguage | undefined, ): FormatLanguage { - const merged = mergeConfigs({} as FormatLanguage, base); - if (!language) { - return merged; - } - const keys = Object.keys(language).filter((key) => - isTranslationKey(language, key) - ); + const merged = { ...base } as FormatLanguage; + const next = language ?? {}; + const keys = Object.keys(next).filter((key) => isTranslationKey(next, key)); Object.keys(merged).forEach((variation) => { if (!isTranslationKey(merged, variation)) { - const translations = merged[variation] as Record; + const translations = { + ...merged[variation] as Record, + }; keys.forEach((key) => delete translations[key]); + merged[variation] = translations; } }); - return mergeConfigs(merged, language); + return mergeConfigs(merged, next); } export function translationsForLang(language: FormatLanguage, lang: string) { diff --git a/tests/unit/core/language-merge.test.ts b/tests/unit/core/language-merge.test.ts index 2fcdc141c09..3763dbc4c21 100644 --- a/tests/unit/core/language-merge.test.ts +++ b/tests/unit/core/language-merge.test.ts @@ -5,78 +5,73 @@ */ import { unitTest } from "../../test.ts"; -import { assertEquals } from "testing/asserts"; +import { assertEquals } from "../../asserts.ts"; import { translationsForLang } from "../../../src/core/language.ts"; -import { mergeFormatMetadata } from "../../../src/config/metadata.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 }; -function resolve(lang: string, ...languages: FormatLanguage[]) { - const [first, ...rest] = languages.map((language): Layer => ({ language })); - const merged = mergeFormatMetadata(first, ...rest); - return translationsForLang(merged.language, lang)[kKey]; -} - -unitTest( - "language-merge - later plain key replaces earlier variation", - async () => { - assertEquals( - resolve("fr", { [kKey]: "early", fr: { [kKey]: "early fr" } }, { - [kKey]: "late", - }), - "late", - ); +const kEarlyFr: Layer = { + language: { + [kKey]: "early", + [kOtherKey]: "early other", + fr: { [kKey]: "early fr", [kOtherKey]: "early other fr" }, + "fr-CA": { [kKey]: "early fr-CA" }, }, -); +}; -unitTest( - "language-merge - later key replaces every earlier variation", - async () => { - assertEquals( - resolve("fr-CA", { - [kKey]: "early", - fr: { [kKey]: "early fr" }, - "fr-CA": { [kKey]: "early fr-CA" }, - }, { [kKey]: "late" }), - "late", - ); - }, -); +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]; +} -unitTest("language-merge - variation wins in its own language", async () => { - assertEquals( - resolve("fr", { [kKey]: "early", fr: { [kKey]: "early fr" } }), - "early fr", +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 - later variation replaces earlier plain key", - async () => { - assertEquals( - resolve("fr", { [kKey]: "early" }, { - [kKey]: "late", - fr: { [kKey]: "late fr" }, - }), - "late fr", - ); - }, -); + 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 - other keys of earlier variation remain", - async () => { - const merged = mergeFormatMetadata( - { language: { fr: { [kKey]: "early fr", "toc-title-website": "site" } } }, - { language: { [kKey]: "late" } }, - ); - assertEquals( - translationsForLang(merged.language, "fr")["toc-title-website"], - "site", - ); - }, -); + 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", + ); + }, + ); +} From 058fe13e94141068b13e269274c71a23576f10da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:59:17 +0100 Subject: [PATCH 3/5] fix: leave non-object language values untouched `mergeLanguages` now rewrites only plain-object locale blocks, and skips the pass when the later layer sets no plain key, so an array value under `language:` stays an array. The merge rules and localisation notes now describe the cross-layer `language` merge. --- .claude/rules/typescript/config-merging.md | 4 ++-- llm-docs/localization-architecture.md | 1 + src/core/language.ts | 9 ++++++++- tests/unit/core/language-merge.test.ts | 8 ++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) 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/localization-architecture.md b/llm-docs/localization-architecture.md index e4d6fe904b9..4043cb57edc 100644 --- a/llm-docs/localization-architecture.md +++ b/llm-docs/localization-architecture.md @@ -82,6 +82,7 @@ Plus format-native localization (LaTeX babel, Typst `set text(lang:)`) that Quar - `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: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` — `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:190-211` — `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 diff --git a/src/core/language.ts b/src/core/language.ts index 266d68bd8d0..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"; @@ -185,8 +186,14 @@ export function mergeLanguages( 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)) { + if ( + !isTranslationKey(merged, variation) && + isPlainObject(merged[variation]) + ) { const translations = { ...merged[variation] as Record, }; diff --git a/tests/unit/core/language-merge.test.ts b/tests/unit/core/language-merge.test.ts index 3763dbc4c21..83dd5d46f0d 100644 --- a/tests/unit/core/language-merge.test.ts +++ b/tests/unit/core/language-merge.test.ts @@ -75,3 +75,11 @@ for ( }, ); } + +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"]); +}); From c6cd7b590300f021ebfaf2efd79ba9f46d77e5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:13:49 +0100 Subject: [PATCH 4/5] fix: apply language merge to metadata-files Document front matter, `metadata-files` and `--metadata` merge through `mergeQuartoConfigs`, so a plain `language` key from a metadata file could still lose to a locale block in the front matter. The merging architecture notes now describe the `language` rule. --- llm-docs/config-merging.md | 20 ++++++++++++----- src/command/render/render-contexts.ts | 5 ++++- src/config/metadata.ts | 2 +- .../issue-15349/_metadata-files-language.yml | 2 ++ .../2026/11/12/issue-15349/metadata-files.qmd | 22 +++++++++++++++++++ 5 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349/_metadata-files-language.yml create mode 100644 tests/docs/smoke-all/2026/11/12/issue-15349/metadata-files.qmd 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/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 dd07cdfc571..4010472b0a6 100644 --- a/src/config/metadata.ts +++ b/src/config/metadata.ts @@ -292,7 +292,7 @@ export function mergeFormatMetadata( ); } -function mergeLanguageCustomizer( +export function mergeLanguageCustomizer( objValue: unknown, srcValue: unknown, key: string, 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/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. From 82b2c39870827e11f744d53d459a4cca3a442635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:23:04 +0100 Subject: [PATCH 5/5] docs: refresh language anchors and changelog Point the localisation notes at the moved functions, and say in the changelog that inline locale blocks are covered too. --- llm-docs/localization-architecture.md | 10 +++++----- news/changelog-1.12.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/llm-docs/localization-architecture.md b/llm-docs/localization-architecture.md index 4043cb57edc..5d65954f0a4 100644 --- a/llm-docs/localization-architecture.md +++ b/llm-docs/localization-architecture.md @@ -79,11 +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` — `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: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( @@ -222,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 fad4b331d21..8a4449e6e9a 100644 --- a/news/changelog-1.12.md +++ b/news/changelog-1.12.md @@ -65,4 +65,4 @@ All changes included in 1.12: - ([#15271](https://github.com/quarto-dev/quarto-cli/issues/15271)): Translate the `tools-share` and `tools-download` language keys in Catalan, Chinese, Czech, Danish, Dutch, Finnish, French, German, Hebrew, Indonesian, Italian, Japanese, Korean, Lithuanian, Norwegian Bokmål, Norwegian Nynorsk, Polish, Portuguese, Russian, Slovak, Swedish, Turkish and Ukrainian, and `tools-share` in Serbian (Latin), using reviewed machine translation. - ([#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. -- ([#15349](https://github.com/quarto-dev/quarto-cli/issues/15349)): Fix a per-language file such as `_language-fr.yml` from an extension or a project replacing the `language` keys that a document or `_quarto.yml` sets. A key set later now wins over the same key in an earlier per-language file. (author: @mcanouil) +- ([#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)