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-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" diff --git a/src/resources/language/_language.yml b/src/resources/language/_language.yml index 586b0d59dd8..0bf40f35064 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, 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. +# "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" 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..dcaae5c93fa --- /dev/null +++ b/tests/unit/language-parity.test.ts @@ -0,0 +1,224 @@ +/* + * 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 { walkSync } from "../../src/deno_ral/fs.ts"; +import { readYaml } from "../../src/core/yaml.ts"; +import { resourcePath } from "../../src/core/resources.ts"; + +type Translations = Record; + +const placeholders = (value: unknown): string[] => + typeof value === "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( + english: Translations, + languages: Map, + knownGaps: Record, +): string[] { + const problems: string[] = []; + + 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)) { + 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) { + 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; +} + +function parityOf( + base: Translations, + files: Record, + knownGaps: Record = {}, +): Promise { + return Promise.resolve( + checkLanguageParity(base, new Map(Object.entries(files)), 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 - 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( + 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 - 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", + // deno-lint-ignore require-await + async () => { + const knownGaps = readYaml( + join(import.meta.dirname!, "language-known-gaps.yml"), + ) as Record; + const { english, languages } = loadLanguageFiles(resourcePath("language")); + assertEquals( + checkLanguageParity(english, languages, knownGaps ?? {}), + [], + ); + }, +);