Skip to content

Custom language keys are accepted, then silently discarded #14772

Description

@mcanouil

I have:

  • searched the issue tracker for similar issues
  • installed the latest version of Quarto CLI
  • formatted my issue following the Bug Reports guide

Bug description

A key set under language that is not part of Quarto's own string set passes validation, is merged over the defaults, and is then discarded before templates see it.
Reading it back through $quarto.language.<key>$ yields an empty string, with no warning at any point.

translationsForLang rebuilds the language table from scratch and copies across only keys present in kLanguageDefaultsKeys or matching crossref-.*-title / crossref-.*-prefix, and formatLanguage runs it after merging the user's language object over the defaults.
So a key such as custom-key survives schema validation and the merge, then is dropped on the way out.

  • 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
    if (
    kLanguageDefaultsKeys.includes(key) ||
    key.match(/^crossref-.*-title$/) ||
    key.match(/^crossref-.*-prefix$/)
    ) {
    translations[key] = language[key];
    }
    });
  • const defaultLanguage = translationsForLang(
    (await readDefaultLanguageTranslations(langCode)).language,
    langCode,
    );
    // merge any user provided language w/ the defaults
    language = mergeConfigs(defaultLanguage, language);
    // now select the correct variations based on the lang code and translations
    return translationsForLang(language, langCode);
  • export const kLanguageDefaultsKeys = [
    kTocTitleDocument,
    kTocTitleWebsite,

This looks like a bug rather than a deliberate restriction, for four reasons:

  • The value is accepted before it is thrown away.
    The format-language schema is an ordinary object with no closed: true, unlike neighbouring definitions such as website-about a few lines below, which do set it.
    An unknown key is therefore valid input by the project's own schema rules.

  • The same function already supports user-defined keys.
    The crossref-.*-title and crossref-.*-prefix escape hatches exist because users define their own crossref types, and the comment there reads "crossrefs can be custom, so be more lenient".

  • The documentation never describes a closed set.
    https://quarto.org/docs/authoring/language.html presents language as a YAML file of translations and tells readers to copy _language.yml and translate the values.

  • The failure mode is the worst available one: neither a value nor a diagnostic.
    In a template the result is an empty string, so the visible symptom is a blank heading or a missing label, at a distance from the cause.
    A typo in a known key, say toc-title-documnt, behaves identically.

  • - id: format-language
    object:
    properties:
    toc-title-document: string
    toc-title-website: string

  • - id: website-about
    object:
    closed: true

It became more consequential with $quarto.language.*$.
Now that resolved strings are exposed to Pandoc templates under the reserved quarto.language namespace, format extensions can read localised strings but cannot supply any of their own.
An extension whose template contains wording such as "Signature", "Confidential" or "Page N of M" has no supported path to a localised value and must fall back to ordinary metadata, duplicating a mechanism that language already looks like it provides.

Steps to reproduce

issue.qmd:

---
title: "Custom `language` keys do not reach `$quarto.language.*$`"
format:
  typst:
    template-partials:
      - typst-show.typ
    keep-typ: true
language:
  toc-title-document: "Overridden built-in"
  custom-key: "Custom value"
---

The `language` schema accepts a free-form object, so `custom-key` above passes
validation, but only keys shipped in Quarto's `_language.yml` reach the
`quarto.language` template namespace.

typst-show.typ, next to it:

#show: doc => {
  [built-in overridden: `$quarto.language.toc-title-document$`]
  linebreak()
  [built-in untouched: `$quarto.language.section-title-abstract$`]
  linebreak()
  [custom key: `$quarto.language.custom-key$`]
  doc
}

Then:

quarto render issue.qmd
grep -n "built-in\|custom key" issue.typ

The same happens through the file form, language: custom.yml: the file loads, its override of a known key applies, and its custom key is dropped without an error.

Actual behavior

415:  [built-in overridden: `Overridden built-in`]
417:  [built-in untouched: `Abstract`]
419:  [custom key: ``]

The first two lines confirm the namespace itself works and that user overrides are merged, so the empty value is specific to keys absent from _language.yml.

language entry Read back as Outcome
toc-title-document: "Overridden built-in" Overridden built-in override honoured
section-title-abstract (not set) Abstract default honoured
crossref-xyz-title: "Custom crossref title" Custom crossref title custom key honoured
custom-key: "Custom value" (empty) dropped

Expected behavior

Line 419 to read `Custom value`.

More generally, either outcome would be fine, but the current silent empty string is neither:

  1. Pass unknown keys through.
    Keep the filter for locale-variation subkeys but let unrecognised scalar keys survive into translations, so extension and template authors can define their own localised strings alongside Quarto's.
    This is what this report proposes, and it needs one extra clause on the condition in translationsForLang.
  2. Reject unknown keys.
    Set closed: true on format-language so an unrecognised key fails validation with a message.
    This also catches typos in known keys, at the cost of ruling out extension-owned strings, and it would need the crossref patterns expressed in the schema.

Your environment

  • IDE: Visual Studio Code
  • OS: macOS, darwin 25.6.0

Quarto check output

Confirmed on a local dev build of quarto-cli at abc6a78ed68f9e8bc9d54e27851093bd687a1cb7, which reports version 99.9.9.

Quarto 99.9.9
[✓] Checking environment information...
      Quarto cache location: /Users/mcanouil/Library/Caches/quarto
[✓] Checking versions of quarto binary dependencies...
      Pandoc version 3.10.0: OK
      Dart Sass version 1.101.0: OK
      Deno version 2.7.14: OK
      Typst version 0.15.1: OK
[✓] Checking versions of quarto dependencies......OK
[✓] Checking Quarto installation......OK
      Version: 99.9.9
      commit: abc6a78ed68f9e8bc9d54e27851093bd687a1cb7
      Path: /Users/mcanouil/Projects/quarto-dev/quarto-cli/package/dist/bin
[✓] Checking tools....................OK
      TinyTeX: v2026.07
      Chrome Headless Shell: (not installed)
      VeraPDF: (not installed)
[✓] Checking LaTeX....................OK
      Using: TinyTex
      Path: /Users/mcanouil/Library/TinyTeX/bin/universal-darwin
      Version: 2026
[✓] Checking Chrome Headless....................OK
      Using: Chrome from QUARTO_CHROMIUM
      Path: /Applications/Brave Browser.app/Contents/MacOS/Brave Browser
[✓] Checking basic markdown render....OK
[✓] Checking R installation...........OK
      Version: 4.6.1
      Path: /Library/Frameworks/R.framework/Versions/4.6/Resources
      knitr: 1.51
      rmarkdown: 2.31
[✓] Checking Knitr engine render......OK
[✓] Checking Python 3 installation....OK
      Version: 3.14.3
      Jupyter: 5.9.1
      Kernels: julia-1.12, python3
[✓] Checking Jupyter engine render....OK

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions