From 87870ffd9433e8d487a680a8065305f3ed86d4dc Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 3 Nov 2026 13:11:54 +0100 Subject: [PATCH 1/5] Add a parity test for language files Language files drifted from _language.yml (#14822) because nothing checks them. The test fails on stale keys, missing keys not listed in language-known-gaps.yml, stale gap entries, and placeholders that differ from the English value. Region overlays may omit keys. --- tests/unit/language-known-gaps.yml | 13 ++ tests/unit/language-parity.test.ts | 216 +++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 tests/unit/language-known-gaps.yml create mode 100644 tests/unit/language-parity.test.ts diff --git a/tests/unit/language-known-gaps.yml b/tests/unit/language-known-gaps.yml new file mode 100644 index 00000000000..98a6b1f6cbc --- /dev/null +++ b/tests/unit/language-known-gaps.yml @@ -0,0 +1,13 @@ +# : [, ...] languages whose translation of is still missing (languages sorted) +announcement-dismiss-label: [az, bg, ca, cs, da, de, el, es, eu, fi, fr, he, id, is, it, ja, ko, lt, nb, nl, nn, pl, pt, ru, sk, sl, sr-Latn, sv, tr, ua, zh] +code-links-title: [sr-Latn] +launch-binder-title: [sr-Latn] +launch-dev-container-title: [sr-Latn] +link-external-newwindow-label: [az, bg, ca, cs, da, de, es, eu, fi, fr, he, id, is, it, ja, ko, lt, nb, nl, nn, pl, pt, ru, sk, sl, sr-Latn, sv, tr, ua, zh] +listing-page-field-wordcount: [sr-Latn] +listing-page-words: [sr-Latn] +revealjs-menu-button-label: [az, bg, ca, cs, da, de, es, eu, fi, he, id, is, it, ja, ko, lt, nb, nl, nn, pl, pt, ru, sk, sl, sr-Latn, sv, tr, ua, zh] +skip-to-content: [bg, ca, cs, da, eu, fi, he, id, is, ko, lt, nb, nn, pl, ru, sk, sl, sr-Latn, sv, tr, ua] +title-block-modified: [ko] +tools-download: [ca, cs, da, de, fi, fr, he, id, it, ja, ko, lt, nb, nl, nn, pl, pt, ru, sk, sr-Latn, sv, tr, ua, zh] +tools-share: [ca, cs, da, de, fi, fr, he, id, it, ja, ko, lt, nb, nl, nn, pl, pt, ru, sk, sr-Latn, sv, tr, ua, zh] diff --git a/tests/unit/language-parity.test.ts b/tests/unit/language-parity.test.ts new file mode 100644 index 00000000000..c4e5b75875e --- /dev/null +++ b/tests/unit/language-parity.test.ts @@ -0,0 +1,216 @@ +/* + * language-parity.test.ts + * + * Every base language file carries every key of _language.yml (or lists the + * gap in language-known-gaps.yml), has no stale keys, and keeps the + * placeholders of the English value. + * See https://github.com/quarto-dev/quarto-cli/issues/14822 + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assertEquals } from "../asserts.ts"; +import { join } from "../../src/deno_ral/path.ts"; +import { readYaml } from "../../src/core/yaml.ts"; +import { resourcePath } from "../../src/core/resources.ts"; +import { withTempDir } from "../utils.ts"; + +type Translations = Record; + +const placeholders = (value: unknown): string[] => + typeof value === "string" + ? (value.match(/\{\d+\}/g) ?? []).sort() + : []; + +// Returns one line per problem; empty when the files are consistent. +// An overlay (`xx-YY` where `_language-xx.yml` exists) inherits from its base, +// so it may omit keys but not carry unknown ones. +function checkLanguageParity( + dir: string, + knownGaps: Record, +): string[] { + const problems: string[] = []; + const english = readYaml(join(dir, "_language.yml")) as Translations; + + const languages = new Map(); + for (const entry of Deno.readDirSync(dir)) { + const match = entry.name.match(/^_language-(.+)\.yml$/); + if (match) { + languages.set( + match[1], + readYaml(join(dir, entry.name)) as Translations, + ); + } + } + + const isOverlay = (lang: string) => + lang.includes("-") && languages.has(lang.split("-")[0]); + const gapped = (lang: string, key: string) => + knownGaps[key]?.includes(lang) ?? false; + + for (const [lang, translations] of languages) { + for (const key of Object.keys(translations)) { + if (!Object.hasOwn(english, key)) { + problems.push(`${lang}: stale key '${key}' is not in _language.yml`); + } + } + for (const key of Object.keys(english)) { + if (!Object.hasOwn(translations, key)) { + if (!isOverlay(lang) && !gapped(lang, key)) { + problems.push(`${lang}: missing key '${key}'`); + } + continue; + } + const expected = placeholders(english[key]); + const actual = placeholders(translations[key]); + const nullOk = translations[key] === null && expected.length === 0; + if (!nullOk && expected.join() !== actual.join()) { + problems.push( + `${lang}: '${key}' has placeholders [${actual}], expected [${expected}]`, + ); + } + } + } + + for (const [key, langs] of Object.entries(knownGaps)) { + for (const lang of langs) { + const translations = languages.get(lang); + if (!translations) { + problems.push(`known gap '${key}' names unknown language '${lang}'`); + } else if (Object.hasOwn(translations, key)) { + problems.push( + `known gap '${key}' for '${lang}' is stale: the key is now present`, + ); + } + } + } + + return problems; +} + +async function parityOf( + base: Translations, + files: Record, + knownGaps: Record = {}, +): Promise { + return await withTempDir((dir) => { + Deno.writeTextFileSync( + join(dir, "_language.yml"), + JSON.stringify(base), + ); + for (const [lang, translations] of Object.entries(files)) { + Deno.writeTextFileSync( + join(dir, `_language-${lang}.yml`), + JSON.stringify(translations), + ); + } + return checkLanguageParity(dir, knownGaps); + }); +} + +function assertOneProblem(problems: string[], ...fragments: string[]) { + assertEquals(problems.length, 1, problems.join("\n")); + for (const fragment of fragments) { + assertEquals( + problems[0].includes(fragment), + true, + `'${problems[0]}' should mention '${fragment}'`, + ); + } +} + +unitTest("parity - stale key fails", async () => { + const problems = await parityOf({ a: "A" }, { xx: { a: "x", b: "y" } }); + assertOneProblem(problems, "xx", "b"); +}); + +unitTest("parity - missing key fails unless known gap", async () => { + const base = { a: "A", b: "B" }; + const files = { xx: { a: "x" } }; + assertOneProblem(await parityOf(base, files), "xx", "b"); + assertEquals(await parityOf(base, files, { b: ["xx"] }), []); +}); + +unitTest("parity - resolved known gap fails", async () => { + const problems = await parityOf( + { a: "A" }, + { xx: { a: "x" } }, + { a: ["xx"] }, + ); + assertOneProblem(problems, "stale", "xx"); +}); + +unitTest("parity - overlay exempt from missing, not from stale", async () => { + const base = { a: "A", b: "B" }; + const xx = { a: "x", b: "y" }; + assertEquals(await parityOf(base, { xx, "xx-YY": { a: "z" } }), []); + assertOneProblem( + await parityOf(base, { xx, "xx-YY": { a: "z", c: "w" } }), + "xx-YY", + "c", + ); +}); + +unitTest("parity - known gap for unknown language fails", async () => { + const problems = await parityOf({ a: "A" }, { xx: { a: "x" } }, { + a: ["zz"], + }); + assertOneProblem(problems, "zz"); +}); + +unitTest("parity - duplicated placeholder fails", async () => { + const base = { w: "{0} words" }; + assertOneProblem( + await parityOf(base, { xx: { w: "{0} {0} mots" } }), + "xx", + "w", + ); + assertEquals(await parityOf(base, { xx: { w: "{0} mots" } }), []); + assertOneProblem(await parityOf(base, { xx: { w: "mots" } }), "xx", "w"); +}); + +unitTest("parity - empty and null values count as present", async () => { + assertEquals(await parityOf({ a: "" }, { xx: { a: null } }), []); +}); + +unitTest( + "parity - null translation of a placeholder value fails", + async () => { + assertOneProblem( + await parityOf({ w: "{0} words" }, { xx: { w: null } }), + "xx", + "w", + ); + }, +); + +unitTest("parity - unexpected placeholder fails", async () => { + assertOneProblem( + await parityOf({ s: "Share" }, { xx: { s: "Partager {0}" } }), + "xx", + "s", + ); +}); + +unitTest("parity - discovers languages by glob", async () => { + const problems = await parityOf( + { a: "A", b: "B" }, + { xx: { a: "x" }, yy: { a: "y" } }, + ); + assertEquals(problems.length, 2, problems.join("\n")); +}); + +unitTest( + "parity - shipped language files match _language.yml", + // deno-lint-ignore require-await + async () => { + const knownGaps = readYaml( + join(import.meta.dirname!, "language-known-gaps.yml"), + ) as Record; + assertEquals( + checkLanguageParity(resourcePath("language"), knownGaps ?? {}), + [], + ); + }, +); From 91922f9cb464105d87d9bf3ab12f308c48e3dc3f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 3 Nov 2026 13:13:41 +0100 Subject: [PATCH 2/5] Rename stale keys in Lithuanian and Swedish language files lt used `search` and sv used `callout-danger-title`, keys renamed in _language.yml long ago (498f71a56, 748c66dd0), so both strings fell back to English. Same concept, existing values kept. --- src/resources/language/_language-lt.yml | 2 +- src/resources/language/_language-sv.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/language/_language-lt.yml b/src/resources/language/_language-lt.yml index ea84335df15..fb7d69abbe4 100644 --- a/src/resources/language/_language-lt.yml +++ b/src/resources/language/_language-lt.yml @@ -71,7 +71,7 @@ search-clear-button-title: "Išvalyti" search-text-placeholder: "" search-detached-cancel-button-title: "Atšaukti" search-submit-button-title: "Pateikti" -search: "Paieška" +search-label: "Paieška" toggle-section: "Rodyti/Slėpti skiltį" toggle-sidebar: "Rodyti/Slėpti šoninę navigacijos skiltį" diff --git a/src/resources/language/_language-sv.yml b/src/resources/language/_language-sv.yml index 2193d4da970..b7cf34ee2e0 100644 --- a/src/resources/language/_language-sv.yml +++ b/src/resources/language/_language-sv.yml @@ -39,7 +39,7 @@ callout-tip-title: "Tips" callout-note-title: "Notera" callout-warning-title: "Varning" callout-important-title: "Viktigt" -callout-danger-title: "Försiktigt" +callout-caution-title: "Försiktigt" code-summary: "Kod" From 8449f9c26d48ada5a999634d7ebb0c2215f47b94 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 3 Nov 2026 13:33:46 +0100 Subject: [PATCH 3/5] Add translator guidance to the language files Contributors, and the AI tools many of them use, translate the new accessibility and UI keys without knowing which sense is meant, so values come out wrong (for example "Dismiss" as reject) or fall back to English. - _language.yml: translator comments above the 14 keys that are missing somewhere, saying what the string is, where it shows and which sense to use. - README.md: table of all language files with the correct names (kr -> ko), plus contributing guidance (read the notes, reuse existing vocabulary, region files are overlays, disclose AI help, known-gaps list). - AGENTS.md and a rule pointer so coding agents read the same guidance. - Changelog entry for the lt/sv key renames. --- .claude/rules/rendering/localization.md | 2 + news/changelog-1.12.md | 1 + src/resources/language/AGENTS.md | 10 ++++ src/resources/language/README.md | 79 ++++++++++++++++--------- src/resources/language/_language.yml | 27 +++++++++ 5 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 src/resources/language/AGENTS.md diff --git a/.claude/rules/rendering/localization.md b/.claude/rules/rendering/localization.md index 77b2bf410c9..d7f4aca6e52 100644 --- a/.claude/rules/rendering/localization.md +++ b/.claude/rules/rendering/localization.md @@ -7,3 +7,5 @@ paths: # Localization For how localized strings flow from `_language.yml` through metadata merging into Pandoc templates and the crossref/authors/callouts Lua filters, see `llm-docs/localization-architecture.md`. + +For editing `_language-*.yml` files, follow `src/resources/language/README.md` and `src/resources/language/AGENTS.md` (translator notes, placeholders, parity test). diff --git a/news/changelog-1.12.md b/news/changelog-1.12.md index 0c5dddb7b1c..b8ef7091035 100644 --- a/news/changelog-1.12.md +++ b/news/changelog-1.12.md @@ -3,3 +3,4 @@ All changes included in 1.12: ## Other fixes and improvements - ([#14822](https://github.com/quarto-dev/quarto-cli/issues/14822)): Translate the `tools-share`, `tools-download`, and `skip-to-content` language keys in Greek. (author: @sogav21) +- ([#14822](https://github.com/quarto-dev/quarto-cli/issues/14822)): The Lithuanian search label and the Swedish caution callout title no longer fall back to English. diff --git a/src/resources/language/AGENTS.md b/src/resources/language/AGENTS.md new file mode 100644 index 00000000000..a629e2fcd9c --- /dev/null +++ b/src/resources/language/AGENTS.md @@ -0,0 +1,10 @@ +# Language files + +Before editing any `_language-*.yml`: + +- Read `README.md` in this directory. +- Read the translator comment above each key you translate in `_language.yml`. Some strings are screen-reader labels or use words in a specific sense. +- Keep the `{0}`-style placeholders exactly as they appear in the English value. +- Reuse the vocabulary and verb forms already in the target file. +- Region files (`de-CH`, `fr-CA`, `pt-BR`, `zh-TW`) are overlays: only add keys that differ from the base language. +- Run `unit/language-parity.test.ts` (see `.claude/rules/testing/overview.md`); an English key you cannot translate goes in `tests/unit/language-known-gaps.yml`. diff --git a/src/resources/language/README.md b/src/resources/language/README.md index f3b57b8b095..a7885f7fa97 100644 --- a/src/resources/language/README.md +++ b/src/resources/language/README.md @@ -4,29 +4,44 @@ In some circumstances Quarto, Pandoc, and LaTeX will generate textual output tha This directory includes a set of built-in translations, including: -| File | Language | -| --------------------- | ---------------------------- | -| `_language.yml` | English (default) | -| `_language-az.yml` | Azerbaijani | -| `_language-eu.yml` | Basque | -| `_language-ca.yml` | Catalan | -| `_language-zh.yml` | Chinese | -| `_language-cs.yml` | Czech | -| `_language-nl.yml` | Dutch | -| `_language-fi.yml` | Finnish | -| `_language-fr.yml` | French | -| `_language-de.yml` | German | -| `_language-it.yml` | Italian | -| `_language-he.yml` | Hebrew | -| `_language-ja.yml` | Japanese | -| `_language-kr.yml` | Korean | -| `_language-pl.yml` | Polish | -| `_language-pt.yml` | Portuguese | -| `_language-ru.yml` | Russian | -| `_language-es.yml` | Spanish | -| `_language-tr.yml` | Turkish | -| `_language-zh-TW.yml` | Traditional Chinese (Taiwan) | -| `_language-ua.yml` | Ukrainian | +| File | Language | +| ----------------------- | -------------------------------------- | +| `_language.yml` | English (default) | +| `_language-az.yml` | Azerbaijani | +| `_language-eu.yml` | Basque | +| `_language-pt-BR.yml` | Brazilian Portuguese (overlay) | +| `_language-bg.yml` | Bulgarian | +| `_language-fr-CA.yml` | Canadian French (overlay) | +| `_language-ca.yml` | Catalan | +| `_language-zh.yml` | Chinese | +| `_language-cs.yml` | Czech | +| `_language-da.yml` | Danish | +| `_language-nl.yml` | Dutch | +| `_language-fi.yml` | Finnish | +| `_language-fr.yml` | French | +| `_language-de.yml` | German | +| `_language-el.yml` | Greek | +| `_language-he.yml` | Hebrew | +| `_language-is.yml` | Icelandic | +| `_language-id.yml` | Indonesian | +| `_language-it.yml` | Italian | +| `_language-ja.yml` | Japanese | +| `_language-ko.yml` | Korean | +| `_language-lt.yml` | Lithuanian | +| `_language-nb.yml` | Norwegian Bokmål | +| `_language-nn.yml` | Norwegian Nynorsk | +| `_language-pl.yml` | Polish | +| `_language-pt.yml` | Portuguese | +| `_language-ru.yml` | Russian | +| `_language-sr-Latn.yml` | Serbian (Latin script) | +| `_language-sk.yml` | Slovak | +| `_language-sl.yml` | Slovenian | +| `_language-es.yml` | Spanish | +| `_language-sv.yml` | Swedish | +| `_language-de-CH.yml` | Swiss German (overlay) | +| `_language-zh-TW.yml` | Traditional Chinese (Taiwan) (overlay) | +| `_language-tr.yml` | Turkish | +| `_language-ua.yml` | Ukrainian | The use of these translations is triggered by the [`lang`](https://pandoc.org/MANUAL.html#language-variables) Pandoc metadata variable, which identifies the main language of the document using IETF language tags (following the [BCP 47](https://tools.ietf.org/html/bcp47) standard), such as `en` or `en-GB`. The [Language subtag lookup](https://r12a.github.io/app-subtags/) tool can look up or verify these tags. @@ -41,10 +56,20 @@ lang: fr ## Contributing Localizations -We welcome contributions of additional languages! To contribute a localization: +We welcome translations for new languages and for missing strings in existing languages! To contribute a localization: -1. Make a copy of the `_language.yml` file with the appropriate IETF language tag appended to the filename (e.g. `_language-fr.yml`). +1. For a new language, make a copy of the `_language.yml` file with the appropriate IETF language tag appended to the filename (e.g. `_language-fr.yml`). -2. Translate the English strings therein to the target language. +2. Translate the English strings into the target language. Before translating a key, read its translator comment in `_language.yml`. Some strings are screen-reader labels or use words in a specific sense, as the comments explain. -3. Submit a [pull request](https://help.github.com/articles/using-pull-requests) with your new language translation. +3. Reuse the vocabulary and verb forms already used in your language file so the interface stays consistent. + +4. Keep the `{0}`-style placeholders exactly as they appear in the English value. + +5. Submit a [pull request](https://help.github.com/articles/using-pull-requests) with your translation. + +A few more things to know: + +- **Region files are overlays.** `de-CH`, `fr-CA`, `pt-BR` and `zh-TW` are read on top of `de`, `fr`, `pt` and `zh`, so they only need the keys that differ from their base language. +- **AI help is welcome.** Check the translation against the translator comments and existing strings in the language file, and mention in the pull request that you used AI. +- **Checking your file.** `tests/unit/language-parity.test.ts` fails if a language file has a key that is not in `_language.yml`, misses a key, or changes the placeholders. If you add an English key that you cannot translate, add its language entries to `tests/unit/language-known-gaps.yml` instead. diff --git a/src/resources/language/_language.yml b/src/resources/language/_language.yml index 586b0d59dd8..64b0305e6f9 100644 --- a/src/resources/language/_language.yml +++ b/src/resources/language/_language.yml @@ -5,8 +5,14 @@ related-formats-title: "Other Formats" related-notebooks-title: "Notebooks" source-notebooks-prefix: "Source" other-links-title: "Other Links" +# Heading above the list of hyperlinks to the document's code (repository, Binder, source notebook); +# plural noun phrase, parallel to other-links-title. code-links-title: "Code Links" +# Link text that opens the repository in a GitHub Codespace; "Launch" = start/open, not publish. +# "Dev Container" is a product name, usually left untranslated; use the same verb as launch-binder-title. launch-dev-container-title: "Launch Dev Container" +# "Binder" is the mybinder.org service (interactive Jupyter environment); keep the name untranslated. +# Use the same verb as launch-dev-container-title. launch-binder-title: "Launch Binder" article-notebook-label: "Article Notebook" @@ -32,6 +38,8 @@ title-block-author-plural: "Authors" title-block-affiliation-single: "Affiliation" title-block-affiliation-plural: "Affiliations" title-block-published: "Published" +# Label shown above the document's last-modified date (date-modified) in the title block. +# Use the same form as title-block-published (e.g. "Date of modification" if that one is "Date of publication"). title-block-modified: "Modified" title-block-keywords: "Keywords" @@ -39,6 +47,7 @@ callout-tip-title: "Tip" callout-note-title: "Note" callout-warning-title: "Warning" callout-important-title: "Important" +# Callout box title meaning "take care"; keep distinct from callout-warning-title. callout-caution-title: "Caution" code-summary: "Code" @@ -49,7 +58,11 @@ code-tools-hide-all-code: "Hide All Code" code-tools-view-source: "View Source" code-tools-source-code: "Source Code" +# Sidebar menu in books grouping social-media links for the current page; +# "Share" = post a link to the page, not a portion. Pairs with tools-download. tools-share: "Share" +# Book toolbar: label for the downloads menu and prefix before each format name +# (e.g. "Download PDF"). Choose wording that reads naturally before a format name. tools-download: "Download" code-line: "Line" @@ -64,6 +77,9 @@ repo-action-links-issue: "Report an issue" back-to-top: "Back to top" +# Accessibility skip link: first link on the page, shown only on keyboard focus; +# it jumps past the header and navigation to the main content. Use the usual web +# skip-link phrasing ("go to main content"), not "omit". skip-to-content: "Skip to main content" search-no-results-text: "No results" @@ -76,6 +92,8 @@ search-clear-button-title: "Clear" search-text-placeholder: "" search-detached-cancel-button-title: "Cancel" search-submit-button-title: "Submit" +# Accessible name and tooltip for the navbar button that opens site search. +# Use the conventional one-word label for a website search box. search-label: "Search" toggle-section: "Toggle section" @@ -102,12 +120,17 @@ scrollable-code-label: "Scrollable code" scrollable-output-label: "Scrollable output" # Announced by screen readers after a link that opens in a new tab (never shown as visible text). +# Describes the link (third person, "opens"), not a command; keep the parentheses. +# "Tab" is a browser tab, not a window or a tab control. link-external-newwindow-label: "(opens in a new tab)" # Accessible name for the button that dismisses a website announcement (never shown as visible text). +# "Dismiss" = close/hide the banner (the button shows only an X icon), not reject or delete. +# Imperative, like other button labels; use the language's usual "close" verb. announcement-dismiss-label: "Dismiss announcement" # Accessible name for the Reveal.js slide menu button (never shown as visible text). +# "Slide" is a presentation slide (PowerPoint sense); use a noun phrase, not a command. revealjs-menu-button-label: "Slide menu" crossref-fig-title: "Figure" @@ -147,11 +170,15 @@ listing-page-field-filename: "File Name" listing-page-field-filemodified: "Modified" listing-page-field-subtitle: "Subtitle" listing-page-field-readingtime: "Reading Time" +# Label for the number of words in each listed document (a quantity, not a counter tool). +# Shown with other listing fields such as "Reading Time"; keep it short. listing-page-field-wordcount: "Word Count" listing-page-field-categories: "Categories" listing-page-minutes-compact: "{0} min" listing-page-category-all: "All" listing-page-no-matches: "No matching items" +# Word count shown per item in a listing, e.g. "1,234 words"; {0} is the formatted number. +# A single string serves every count; pick the plural form that reads best for large numbers. listing-page-words: "{0} words" listing-page-filter: "Filter" From 1cceef302a3b5ef4fbb5ad98ae5e01c5a1f11cbe Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 3 Nov 2026 13:39:15 +0100 Subject: [PATCH 4/5] Flag known gaps for keys that no longer exist in _language.yml If an English key is renamed, its old name would stay in language-known-gaps.yml and the test kept passing, so the list could go stale. Also correct the code-links-title translator comment: the Code Links list holds the repository, Binder and Dev Container links, not source notebooks. --- src/resources/language/_language.yml | 2 +- tests/unit/language-parity.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/resources/language/_language.yml b/src/resources/language/_language.yml index 64b0305e6f9..0bf40f35064 100644 --- a/src/resources/language/_language.yml +++ b/src/resources/language/_language.yml @@ -5,7 +5,7 @@ related-formats-title: "Other Formats" related-notebooks-title: "Notebooks" source-notebooks-prefix: "Source" other-links-title: "Other Links" -# Heading above the list of hyperlinks to the document's code (repository, Binder, source notebook); +# Heading above the list of hyperlinks to the document's code (repository, Binder, Dev Container); # plural noun phrase, parallel to other-links-title. code-links-title: "Code Links" # Link text that opens the repository in a GitHub Codespace; "Launch" = start/open, not publish. diff --git a/tests/unit/language-parity.test.ts b/tests/unit/language-parity.test.ts index c4e5b75875e..2e2db06f5a4 100644 --- a/tests/unit/language-parity.test.ts +++ b/tests/unit/language-parity.test.ts @@ -74,6 +74,10 @@ function checkLanguageParity( } for (const [key, langs] of Object.entries(knownGaps)) { + if (!Object.hasOwn(english, key)) { + problems.push(`known gap '${key}' is not a key in _language.yml`); + continue; + } for (const lang of langs) { const translations = languages.get(lang); if (!translations) { @@ -159,6 +163,13 @@ unitTest("parity - known gap for unknown language fails", async () => { assertOneProblem(problems, "zz"); }); +unitTest("parity - known gap for key not in _language.yml fails", async () => { + const problems = await parityOf({ a: "A" }, { xx: { a: "x" } }, { + gone: ["xx"], + }); + assertOneProblem(problems, "gone"); +}); + unitTest("parity - duplicated placeholder fails", async () => { const base = { w: "{0} words" }; assertOneProblem( From c685f1ddabb37acf3b5939e9f9575c5556ca7da4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 3 Nov 2026 14:49:37 +0100 Subject: [PATCH 5/5] Avoid new Deno.* calls in the language parity test The runtime boundary check failed on the three direct Deno calls the test added. The contract cases only wrote temp files to feed the checker, so the checker now takes the parsed files, and the cases pass objects directly. Reading the shipped files goes through walkSync from deno_ral, and a test checks that base, region and script files are all loaded. --- tests/unit/language-parity.test.ts | 69 ++++++++++++++---------------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/tests/unit/language-parity.test.ts b/tests/unit/language-parity.test.ts index 2e2db06f5a4..dcaae5c93fa 100644 --- a/tests/unit/language-parity.test.ts +++ b/tests/unit/language-parity.test.ts @@ -12,9 +12,9 @@ import { unitTest } from "../test.ts"; import { assertEquals } from "../asserts.ts"; import { join } from "../../src/deno_ral/path.ts"; +import { walkSync } from "../../src/deno_ral/fs.ts"; import { readYaml } from "../../src/core/yaml.ts"; import { resourcePath } from "../../src/core/resources.ts"; -import { withTempDir } from "../utils.ts"; type Translations = Record; @@ -23,26 +23,28 @@ const placeholders = (value: unknown): string[] => ? (value.match(/\{\d+\}/g) ?? []).sort() : []; +// Reads `_language.yml` and every `_language-.yml` in `dir`, keyed by . +function loadLanguageFiles(dir: string) { + const english = readYaml(join(dir, "_language.yml")) as Translations; + const languages = new Map(); + for (const entry of walkSync(dir, { maxDepth: 1, includeDirs: false })) { + const match = entry.name.match(/^_language-(.+)\.yml$/); + if (match) { + languages.set(match[1], readYaml(entry.path) as Translations); + } + } + return { english, languages }; +} + // Returns one line per problem; empty when the files are consistent. // An overlay (`xx-YY` where `_language-xx.yml` exists) inherits from its base, // so it may omit keys but not carry unknown ones. function checkLanguageParity( - dir: string, + english: Translations, + languages: Map, knownGaps: Record, ): string[] { const problems: string[] = []; - const english = readYaml(join(dir, "_language.yml")) as Translations; - - const languages = new Map(); - for (const entry of Deno.readDirSync(dir)) { - const match = entry.name.match(/^_language-(.+)\.yml$/); - if (match) { - languages.set( - match[1], - readYaml(join(dir, entry.name)) as Translations, - ); - } - } const isOverlay = (lang: string) => lang.includes("-") && languages.has(lang.split("-")[0]); @@ -93,24 +95,14 @@ function checkLanguageParity( return problems; } -async function parityOf( +function parityOf( base: Translations, files: Record, knownGaps: Record = {}, ): Promise { - return await withTempDir((dir) => { - Deno.writeTextFileSync( - join(dir, "_language.yml"), - JSON.stringify(base), - ); - for (const [lang, translations] of Object.entries(files)) { - Deno.writeTextFileSync( - join(dir, `_language-${lang}.yml`), - JSON.stringify(translations), - ); - } - return checkLanguageParity(dir, knownGaps); - }); + return Promise.resolve( + checkLanguageParity(base, new Map(Object.entries(files)), knownGaps), + ); } function assertOneProblem(problems: string[], ...fragments: string[]) { @@ -204,13 +196,17 @@ unitTest("parity - unexpected placeholder fails", async () => { ); }); -unitTest("parity - discovers languages by glob", async () => { - const problems = await parityOf( - { a: "A", b: "B" }, - { xx: { a: "x" }, yy: { a: "y" } }, - ); - assertEquals(problems.length, 2, problems.join("\n")); -}); +unitTest( + "parity - loads base, region and script language files", + // deno-lint-ignore require-await + async () => { + const { english, languages } = loadLanguageFiles(resourcePath("language")); + assertEquals(english["search-label"], "Search"); + for (const lang of ["fr", "fr-CA", "sr-Latn", "zh-TW"]) { + assertEquals(languages.has(lang), true, `'${lang}' not loaded`); + } + }, +); unitTest( "parity - shipped language files match _language.yml", @@ -219,8 +215,9 @@ unitTest( const knownGaps = readYaml( join(import.meta.dirname!, "language-known-gaps.yml"), ) as Record; + const { english, languages } = loadLanguageFiles(resourcePath("language")); assertEquals( - checkLanguageParity(resourcePath("language"), knownGaps ?? {}), + checkLanguageParity(english, languages, knownGaps ?? {}), [], ); },