taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit d2addd170b64a9cebde860fa380915d44ca5eccd
parent 87b11c9e2571022cba7145732a66a76f6831a644
Author: Florian Dold <dold@taler.net>
Date:   Sat,  8 Aug 2026 08:14:31 +0200

pogen: tooling fixes and improvements

Diffstat:
Mpackages/pogen/README.md | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Apackages/pogen/src/check.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/pogen/src/po2ts.test.ts | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/pogen/src/po2ts.ts | 199++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/pogen/src/pogen.ts | 29+++++++++++++++++++++++++++--
Mpackages/pogen/src/potextract.test.ts | 402++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/pogen/src/potextract.ts | 548+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
7 files changed, 1400 insertions(+), 322 deletions(-)

diff --git a/packages/pogen/README.md b/packages/pogen/README.md @@ -1,79 +1,142 @@ -# pogen - string extraction for internationalizing TypeScript programs +# pogen — string extraction for internationalizing TypeScript programs -The ``pogen`` tool extracts internationalizable strings from TypeScript programs. +`pogen` extracts translatable strings from TypeScript/TSX sources into a +gettext `.pot` template, merges that template into per-language `.po` files, and +emits a `strings.ts` catalogue the application imports at runtime. +## Invocation -## Invocation and Configuration +Run from the root of an NPM package. The input files are whatever the TypeScript +compiler would use, taken from the package's `tsconfig.json`. -The ``pogen`` tool must be called from the root of an NPM package. +```shell +pogen extract # sources -> src/i18n/<domain>.pot +pogen merge # <domain>.pot -> src/i18n/*.po (runs GNU msgmerge) +pogen emit # src/i18n/*.po -> src/i18n/strings.ts +pogen check # validate the catalogues; exits non-zero on any problem +``` -The input files are determined from the ``tsconfig.json`` file at the root of -the package. All input files inside the package that the compiler would use -are automatically processed. +`merge` requires **GNU gettext** (`msgmerge`) on the `PATH`; `check` uses +`msgfmt` when it is available and skips that half with a message when it is not. -Further configuration options are specified in the package's ``package.json`` file. -The following configuration options are supported: +## Configuration -``` +One key, in the package's `package.json`: + +```json { + "pogen": { + "domain": "taler-merchant-webui-ng" + } +} +``` - // [ ... ] +`domain` names the template: `src/i18n/<domain>.pot`. Paths are otherwise fixed — +`src/i18n/*.po` in, `src/i18n/strings.ts` out. Two optional files are read if +present: `src/i18n/poheader` (replaces the default `.pot` header) and +`src/i18n/strings-prelude` (replaces the preamble of the emitted `strings.ts`). - "pogen": { - // Output location of the pofile (mandatory) - "pofile": "...", +> Earlier versions of this document described `pofile`, `plainI18nPackage` and +> `reactI18nPackage`, and claimed extraction was scoped to strings imported from +> a named package. None of that was ever implemented. Extraction is purely +> syntactic — see below — and `domain` is the only key read. - // Calls to plain i18n functions are extracted if they - // are imported from this package. - "plainI18nPackage": "@gnu-taler/taler-util", +## What gets extracted - // Calls to react-style i18n functions are extracted if they - // are imported from this package. - "reactI18nPackage": "@gnu-taler/preact-i18n", - } +Extraction matches **tagged templates** whose tag begins with the identifier +`t` or `i18n`, and the `i18n` JSX components. It is syntactic: any member of +those roots works (`i18n.str`, `i18n.lazy`, `t.context(...)`), and nothing checks +where the identifier came from. -} +```ts +t`Hello world` +t`Hello ${user}` // msgid "Hello %1$s" +i18n.str`Hello world` +i18n.context("navigation")`Orders` // msgctxt "navigation" ``` +```tsx +<i18n.Translate>Hello, world</i18n.Translate> -## Syntax +// Placeholders must be wrapped in an element, not bare: +<i18n.Translate>Hello, <span>{userName}</span></i18n.Translate> -Two flavors of syntax are supported: +<i18n.TranslateSwitch target={n}> + <i18n.TranslateSingular>I have <span>{n}</span> apple</i18n.TranslateSingular> + <i18n.TranslatePlural>I have <span>{n}</span> apples</i18n.TranslatePlural> +</i18n.TranslateSwitch> +``` -Template strings: +Plurals via a call take the two forms below; both work. +```ts +i18n.plural(i18n.lazy`one apple`, i18n.lazy`${n} apples`) +i18n.plural(n, i18n.lazy`one apple`, i18n.lazy`${n} apples`) ``` -import { i18n } from "@gnu-taler/taler-util"; -console.log(i18n.str`Hello World`); -console.log(i18n.str`Hello ${user}`); +### What does *not* get extracted + +**A plain call is invisible to the extractor**, even though a runtime `t` may +accept one: -console.log(i18n.plural(n, i18n.lazy`I have ${n} apple`, i18n.lazy`I have ${n} apples`)); +```ts +t("Charge amount") // NOT extracted — pogen warns +t(someVariable) // NOT extracted — cannot be, in general +t(`All Products (${n})`) // NOT extracted — pogen warns; a new msgid per value ``` -React components: +`pogen extract` reports the first and third with a file and line. The second is +silent, because it is indistinguishable from legitimate use. -``` +To translate a string that lives in a data table, do not translate it at the +call site — build the table from a factory that takes `t` as a parameter **named +`t`**, so the tagged templates inside it are extracted normally: -import { - Translate, - TranslateSwitch, - TranslateSingular, - TranslatePlural -} from "@gnu-taler/preact-i18n"; +```ts +function tabLabels(t: TranslateFn) { + return { all: t`All`, paid: t`Paid` }; // extracted +} +``` -<Translate>Hello, World</Translate> +### Placeholders and `%` -// Placeholders are other React elements -<Translate>Hello, <span className="highlight">{userName}<span></Translate> +An interpolation becomes `%1$s`, `%2$s`, … in source order. A literal `%` is +passed through untouched and needs no escaping: these strings are **not** C +format strings — the runtimes substitute `%N$s` by string replacement — and +`pogen` deliberately does not emit a `#, c-format` flag. Placeholder consistency +between a msgid and its translations is checked by `pogen check` instead. -// Plain placeholders are not supported, they must be surrounded -// by an element: -// WRONG: <Translate>Hello, {userName}</Translate> +### Comments -<TranslateSwitch n={numApples}> - <TranslateSingular>I have <span>{n}</span> apple</TranslateSingular> - <TranslatePlural>I have <span>{n}</span> apple</TranslatePlural> -</TranslateSwitch> +A comment on the line immediately above a translatable string becomes a `#.` +comment for the translator. A run of `//` lines is kept whole; a blank line +between the comment and the string breaks the association. +```ts +// Shown on the receipt, so keep it short. +t`Thank you` ``` + +Note `msgmerge` regenerates the `#.` block from the `.pot` on every merge, so a +`#.` comment written by hand in a `.po` file will not survive. A plain `# ` +translator comment does survive. + +## What `pogen check` verifies + +Exits non-zero on any of: `msgfmt --check-format` failures; a translation whose +`%N$s` placeholders do not match its msgid; or a non-`en` catalogue whose +completeness falls below the threshold the language picker uses to auto-select a +language. Run it in CI — the other three subcommands are deliberately permissive +and will not tell you a catalogue is broken. + +## Notes and known limitations + +- **Extraction is not scoped to a package.** `pogen` walks every source file the + TypeScript program reaches, which in a workspace includes sibling packages. A + package's `.pot` may therefore contain strings that belong to a dependency. +- **`completeness`** in `strings.ts` is `translated / (translated + fuzzy + + untranslated)`. It measures coverage, not quality: a msgstr that merely repeats + the English counts as translated. +- **`en` is special-cased to 100.** The `en.po` files are English-to-English + identity catalogues that exist so `en` appears in the language list; they are + not filled in. diff --git a/packages/pogen/src/check.ts b/packages/pogen/src/check.ts @@ -0,0 +1,191 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Validate the `.po` catalogues of a package. + * + * Three checks, each of which can fail the run on its own: + * + * - `msgfmt --check-format`, if GNU gettext is installed; + * - a placeholder comparison between msgid and msgstr, which needs nothing + * but this package and so always runs; + * - the coverage threshold that decides whether a browser will auto-select + * the language at all. + */ + +import * as child_process from "node:child_process"; +import * as fs from "node:fs"; +import * as gettextParser from "gettext-parser"; +import * as glob from "glob"; +import { poToStrings } from "./po2ts.js"; + +/** + * Below this, `web-util`'s `useLang` will not auto-select the language and the + * browser silently falls back to English — see + * `packages/web-util/src/hooks/useLang.ts` (`MIN_LANG_COVERAGE_THRESHOLD`). + * Failing here means the regeneration commit that would cause that is loud. + */ +export const MIN_LANG_COVERAGE_THRESHOLD = 85; + +/** + * The placeholders a message uses, sorted so two messages can be compared + * regardless of the word order the target language needs. + * + * Kept identical to the check in + * `packages/merchant-webui-ng/src/i18n/catalog.test.ts`. + */ +export function placeholders(s: string): string[] { + return (s.match(/%\d+\$s|%s|%%/g) ?? []).sort(); +} + +/** + * Compare the placeholders of every live entry of one catalogue. + * + * "Live" means it will actually be emitted into `strings.ts`: it has a + * translation and is not marked fuzzy. A msgstr carrying a `%1$s` that its + * msgid does not have renders the placeholder literally to the user. + * + * Returns one human-readable line per problem; an empty array means clean. + */ +export function checkPlaceholders(poText: string, name: string): string[] { + const parsed = gettextParser.po.parse(poText); + const problems: string[] = []; + + for (const msgctxt of Object.keys(parsed.translations)) { + const bucket = parsed.translations[msgctxt] || {}; + for (const msgid of Object.keys(bucket)) { + if (msgid === "") { + continue; + } + const entry = bucket[msgid]; + const flags = (entry.comments && entry.comments.flag) || ""; + if (flags.split(",").some((f) => f.trim() === "fuzzy")) { + continue; + } + const label = msgctxt === "" ? msgid : `${msgctxt}|${msgid}`; + const msgstr = entry.msgstr || []; + for (let i = 0; i < msgstr.length; i++) { + if (!msgstr[i]) { + // Untranslated: falls back to English, nothing to compare. + continue; + } + // For a plural, form 0 answers the singular msgid and every later + // form answers the msgid_plural. + const source = + entry.msgid_plural && i > 0 ? entry.msgid_plural : msgid; + const a = placeholders(source); + const b = placeholders(msgstr[i]); + if (a.join() !== b.join()) { + problems.push( + `${name}: placeholder mismatch ${JSON.stringify( + source, + )} -> ${JSON.stringify(msgstr[i])}`, + ); + } + } + } + } + return problems; +} + +function haveGettext(): boolean { + try { + child_process.execFileSync("msgfmt", ["--version"], { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } +} + +/** + * Run every check over `src/i18n/*.po`, relative to the current directory. + * Exits non-zero if anything at all is wrong. + */ +export function check(): void { + const files = glob.sync("src/i18n/*.po"); + + if (files.length === 0) { + console.error("no .po files found in src/i18n/"); + process.exit(1); + } + + let failed = false; + + if (!haveGettext()) { + console.log( + "skipping 'msgfmt --check-format': GNU gettext is not installed " + + "(install gettext to enable it); the placeholder and coverage " + + "checks below do not need it", + ); + } else { + for (const f of files) { + const r = child_process.spawnSync( + "msgfmt", + ["--check-format", "--output-file=/dev/null", f], + { encoding: "utf-8" }, + ); + const output = `${r.stdout || ""}${r.stderr || ""}`.trim(); + if (output) { + console.error(output); + } + if (r.status !== 0) { + console.error(`msgfmt --check-format failed on ${f}`); + failed = true; + } + } + } + + for (const f of files) { + const poText = fs.readFileSync(f, "utf-8"); + + const problems = checkPlaceholders(poText, f); + for (const p of problems) { + console.error(p); + } + if (problems.length > 0) { + failed = true; + } + + // The same number `emit` will write into strings.ts. + let completeness: number; + let lang: string; + try { + const m = f.match(/([a-zA-Z0-9-_]+)\.po$/); + const strings = poToStrings(poText, m ? m[1] : f); + completeness = strings.completeness; + lang = strings.lang; + } catch (e) { + console.error(`${f}: ${e instanceof Error ? e.message : e}`); + failed = true; + continue; + } + if (lang !== "en" && completeness < MIN_LANG_COVERAGE_THRESHOLD) { + console.error( + `${f}: only ${completeness}% translated, below the ` + + `${MIN_LANG_COVERAGE_THRESHOLD}% threshold at which a browser stops ` + + `auto-selecting '${lang}' and silently falls back to English`, + ); + failed = true; + } else { + console.log(`${f}: ${completeness}% translated`); + } + } + + if (failed) { + process.exit(1); + } + console.log("all catalogues pass"); +} diff --git a/packages/pogen/src/po2ts.test.ts b/packages/pogen/src/po2ts.test.ts @@ -0,0 +1,196 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { CONTEXT_DELIMITER, poToStrings } from "./po2ts.js"; + +function header(lang: string, pluralForms = "nplurals=2; plural=(n != 1);"): string { + return `msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Language: ${lang}\\n" +"Plural-Forms: ${pluralForms}\\n" + +`; +} + +test("a msgctxt entry is emitted, keyed the way jed looks it up", () => { + const s = poToStrings( + header("de") + + `msgctxt "title" +msgid "%1$s: Settings" +msgstr "%1$s: Einstellungen" +`, + "de", + ); + const messages = s.locale_data.messages; + assert.deepEqual( + messages[`title${CONTEXT_DELIMITER}%1$s: Settings`], + ["%1$s: Einstellungen"], + "context entry must be keyed msgctxt + EOT + msgid", + ); + // jed's Jed.context_delimiter, EOT (U+0004). + assert.equal(CONTEXT_DELIMITER.length, 1); + assert.equal(CONTEXT_DELIMITER.charCodeAt(0), 4); + // The bare msgid must not be claimed: `i18n.str` and `i18n.ctx("title")` + // are different messages. + assert.equal(messages["%1$s: Settings"], undefined); + assert.equal(s.completeness, 100); +}); + +test("a msgid with and without context are separate entries", () => { + const s = poToStrings( + header("de") + + `msgid "Order" +msgstr "Bestellung" + +msgctxt "verb" +msgid "Order" +msgstr "Bestellen" +`, + "de", + ); + const messages = s.locale_data.messages; + assert.deepEqual(messages["Order"], ["Bestellung"]); + assert.deepEqual(messages[`verb${CONTEXT_DELIMITER}Order`], ["Bestellen"]); +}); + +test("a fuzzy entry is dropped from the catalogue but counts as untranslated", () => { + const s = poToStrings( + header("de") + + `msgid "Hello" +msgstr "Hallo" + +#, fuzzy +msgid "Goodbye" +msgstr "Auf Wiedersehen" +`, + "de", + ); + assert.deepEqual(s.locale_data.messages["Hello"], ["Hallo"]); + assert.equal( + s.locale_data.messages["Goodbye"], + undefined, + "a fuzzy entry must not be emitted", + ); + // 1 of 2, not 1 of 1: the fuzzy entry renders English, so it is missing. + assert.equal(s.completeness, 50); +}); + +test("an empty msgstr counts in the denominator only", () => { + const s = poToStrings( + header("de") + + `msgid "Hello" +msgstr "Hallo" + +msgid "Goodbye" +msgstr "" + +msgid "Thanks" +msgstr "" +`, + "de", + ); + assert.deepEqual(s.locale_data.messages["Goodbye"], [""]); + assert.equal(s.completeness, 33); +}); + +test("a plural keeps [msgid_plural, ...msgstr] and needs every form filled in", () => { + const s = poToStrings( + header("de") + + `msgid "one file" +msgid_plural "%1$s files" +msgstr[0] "eine Datei" +msgstr[1] "%1$s Dateien" +`, + "de", + ); + assert.deepEqual(s.locale_data.messages["one file"], [ + "%1$s files", + "eine Datei", + "%1$s Dateien", + ]); + assert.equal(s.completeness, 100); +}); + +test("a plural with an unfilled form does not count as translated", () => { + // The old counter looked at `v[0]`, which for a plural is the English + // msgid_plural, so every plural counted as done no matter what. + const s = poToStrings( + header("de") + + `msgid "one file" +msgid_plural "%1$s files" +msgstr[0] "eine Datei" +msgstr[1] "" +`, + "de", + ); + assert.deepEqual(s.locale_data.messages["one file"], [ + "%1$s files", + "eine Datei", + "", + ]); + assert.equal(s.completeness, 0); +}); + +test("an entirely untranslated 'en' catalogue is still 100% complete", () => { + // The en.po files in this tree are English-to-English identity catalogues + // with every msgstr empty. Reporting their honest 0% would drop 'en' out of + // the language picker. + const s = poToStrings( + header("en") + + `msgid "Hello" +msgstr "" + +msgid "Goodbye" +msgstr "" +`, + "en", + ); + assert.equal(s.lang, "en"); + assert.equal(s.completeness, 100); +}); + +test("an empty catalogue reports 0 rather than NaN", () => { + const s = poToStrings(header("de"), "de"); + assert.equal(s.completeness, 0); +}); + +test("a catalogue without a Language header is rejected", () => { + assert.throws( + () => + poToStrings( + `msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\\n" + +msgid "Hello" +msgstr "Hallo" +`, + "de", + ), + /Language/, + ); +}); + +test("the header carries lang and plural forms through", () => { + const s = poToStrings(header("fr", "nplurals=2; plural=(n > 1);"), "fr"); + assert.equal(s.lang, "fr"); + assert.equal(s.plural_forms, "nplurals=2; plural=(n > 1);"); + assert.equal(s.domain, "messages"); +}); diff --git a/packages/pogen/src/po2ts.ts b/packages/pogen/src/po2ts.ts @@ -39,7 +39,7 @@ interface pojsonType { } // ----------- end pf po2json -interface StringsType { +export interface StringsType { // X-Domain or 'messages' domain: string; lang: string; @@ -65,6 +65,121 @@ export interface StringsType { const DEFAULT_STRING_PRELUDE = `${TYPES_FOR_STRING_PRELUDE}export const strings: Record<string,StringsType> = {};\n\n` +/** + * Separator between a msgctxt and its msgid in the emitted catalogue. + * + * This is jed's `Jed.context_delimiter` (EOT, U+0004) — see + * `jed/jed.js:117,263` — and jed is what actually reads the emitted + * `strings.ts` at runtime, via `setupI18n` and `i18n.ctx(...)` in + * `@gnu-taler/taler-util` (`src/i18n.ts:66-76`, which calls + * `jed.translate(...).withContext(ctx)`). Keying context entries any other + * way puts them in the file but leaves them unreachable. + */ +export const CONTEXT_DELIMITER = "\u0004"; + +/** + * Convert the text of one `.po` file into the value that is emitted as + * `strings['<lang>']`. + * + * Pure: no file system access, no `process.exit`. `catalogueName` is only + * used in error messages (it is the `<lang>` part of the file name); the + * `lang` of the result comes from the `Language:` header of the catalogue. + */ +export function poToStrings(poText: string, catalogueName: string): StringsType { + const parsedPo = gettextParser.po.parse(poText); + const messages: any = { + "": { + domain: "messages", + lang: parsedPo.headers["Language"] || parsedPo.headers["language"] || "", + plural_forms: + parsedPo.headers["Plural-Forms"] || + parsedPo.headers["plural-forms"] || + "", + }, + }; + + // Every message the catalogue is responsible for, and the subset of those + // that actually carry a usable translation. Counted here rather than over + // `messages` below, because entries that are dropped from the emitted + // catalogue (fuzzy ones) still have to count against the total: a fuzzy + // entry renders as English, so it is untranslated as far as the reader is + // concerned. + let totalKeys = 0; + let totalTranslated = 0; + + // `gettext-parser` files each entry under its msgctxt, with the empty string + // for entries that have none. Every bucket has to be read: only looking at + // `translations[""]` silently discards every string extracted through + // `i18n.ctx(...)`. + for (const msgctxt of Object.keys(parsedPo.translations)) { + const contextTranslations = parsedPo.translations[msgctxt] || {}; + for (const msgid of Object.keys(contextTranslations)) { + if (msgid === "") { + // The catalogue header, already handled above. + continue; + } + const entry = contextTranslations[msgid]; + const key = + msgctxt === "" ? msgid : `${msgctxt}${CONTEXT_DELIMITER}${msgid}`; + totalKeys++; + + // Treat fuzzy entries as untranslated (standard gettext behaviour): + // msgmerge seeds fuzzy translations from unrelated nearby strings, so + // shipping them would surface wrong-meaning text. Fall back to English. + const flags = (entry.comments && entry.comments.flag) || ""; + if (flags.split(",").some((f) => f.trim() === "fuzzy")) { + continue; + } + + const msgstr = entry.msgstr || []; + if (entry.msgid_plural) { + // A plural is only translated once *every* form is filled in; a + // catalogue with `msgstr[1] ""` renders English for those counts. + if (msgstr.length > 0 && msgstr.every((s) => !!s)) { + totalTranslated++; + } + messages[key] = [entry.msgid_plural, ...msgstr]; + } else { + if (msgstr.length > 0 && !!msgstr[0]) { + totalTranslated++; + } + messages[key] = msgstr; + } + } + } + + const poAsJson: pojsonType = { + domain: "messages", + locale_data: { + messages: messages as MessagesType, + }, + }; + const header = poAsJson.locale_data.messages[""]; + if (!header.lang) { + throw new Error( + `missing 'Language' property in the catalogue '${catalogueName}'`, + ); + } + + // 'en' is always complete. Keep this: the `en.po` files in this tree are + // English-to-English identity catalogues with every msgstr empty, so they + // honestly measure 0%. Reporting that would push `en` below the coverage + // threshold in `web-util/src/hooks/useLang.ts` and drop it out of the + // language picker (`web-util/src/context/translation.ts:85-90`), which + // filters on a non-zero completeness. + const completeness = + header.lang === "en" + ? 100 + : Math.floor((totalTranslated * 100) / totalKeys); + + return { + locale_data: poAsJson.locale_data, + domain: poAsJson.domain, + plural_forms: header.plural_forms, + lang: header.lang, + completeness: Number.isNaN(completeness) ? 0 : completeness, + }; +} export function po2ts(): void { const files = glob.sync("src/i18n/*.po"); @@ -94,61 +209,14 @@ export function po2ts(): void { } const lang = m[1]; - const poBuffer = fs.readFileSync(filename); - const parsedPo = gettextParser.po.parse(poBuffer); - const messages: any = { - "": { - domain: "messages", - lang: parsedPo.headers["Language"] || parsedPo.headers["language"] || "", - plural_forms: - parsedPo.headers["Plural-Forms"] || - parsedPo.headers["plural-forms"] || - "", - }, - }; - const domainTranslations = parsedPo.translations[""] || {}; - for (const msgid of Object.keys(domainTranslations)) { - if (msgid === "") { - continue; - } - const entry = domainTranslations[msgid]; - // Treat fuzzy entries as untranslated (standard gettext behaviour): - // msgmerge seeds fuzzy translations from unrelated nearby strings, so - // shipping them would surface wrong-meaning text. Fall back to English. - const flags = (entry.comments && entry.comments.flag) || ""; - if (flags.split(",").some((f) => f.trim() === "fuzzy")) { - continue; - } - if (entry.msgid_plural) { - messages[msgid] = [entry.msgid_plural, ...entry.msgstr]; - } else { - messages[msgid] = entry.msgstr; - } - } - const poAsJson: pojsonType = { - domain: "messages", - locale_data: { - messages: messages as MessagesType, - }, - }; - const header = poAsJson.locale_data.messages[""]; - if (!header.lang) { - console.error("error: missing 'Language' property in " + filename); + const poText = fs.readFileSync(filename, "utf-8"); + let strings: StringsType; + try { + strings = poToStrings(poText, lang); + } catch (e) { + console.error(`error: ${e instanceof Error ? e.message : e} (${filename})`); process.exit(1); } - const total = calculateTotalTranslations(poAsJson.locale_data.messages) - const completeness = - (header.lang === "en" - ? 100 // 'en' is always complete - : Math.floor(total.translations * 100 / total.keys)); - - const strings: StringsType = { - locale_data: poAsJson.locale_data, - domain: poAsJson.domain, - plural_forms: header.plural_forms, - lang: header.lang, - completeness: Number.isNaN(completeness) ? 0 : completeness, - } const value = JSON.stringify(strings, undefined, 2) const s = `strings['${lang}'] = ${value};\n\n` chunks.push(s); @@ -158,25 +226,3 @@ export function po2ts(): void { fs.writeFileSync("src/i18n/strings.ts", tsContents); } - -function calculateTotalTranslations(msgs: MessagesType): { keys: number, translations: number } { - const kv = Object.entries(msgs) - const [keys, translations] = kv.reduce(([total, withTranslation], translation) => { - if (!translation || translation.length !== 2 || !translation[1]) { - //current key is empty - return [total, withTranslation] - } - const v = translation[1] - if (!Array.isArray(v)) { - // this is not a translation - return [total, withTranslation] - } - if (!v.length || !v[0].length) { - //translation is missing - return [total + 1, withTranslation] - } - //current key has a translation - return [total + 1, withTranslation + 1] - }, [0, 0]) - return { keys, translations } -} -\ No newline at end of file diff --git a/packages/pogen/src/pogen.ts b/packages/pogen/src/pogen.ts @@ -3,12 +3,30 @@ import * as child_process from "child_process"; import * as fs from "node:fs"; import * as glob from "glob"; import { po2ts } from "./po2ts.js"; +import { check } from "./check.js"; function usage(): never { - console.log("usage: pogen <extract|merge|emit>"); + console.log("usage: pogen <extract|merge|emit|check>"); process.exit(1); } +/** + * `merge` shells out to GNU gettext. Without this the absence of the toolchain + * surfaces as a raw execSync throw with a shell's "command not found" buried in + * it. + */ +function requireGettextTool(tool: string): void { + try { + child_process.execFileSync(tool, ["--version"], { stdio: "ignore" }); + } catch (e) { + console.error( + `'${tool}' not found: this subcommand needs GNU gettext installed ` + + `(Debian/Ubuntu: 'apt install gettext')`, + ); + process.exit(1); + } +} + export function main() { const subcommand = process.argv[2]; if (process.argv.includes("--help") || !subcommand) { @@ -30,12 +48,16 @@ export function main() { console.error("missing 'pogen.domain' field in package.json"); process.exit(1); } + requireGettextTool("msgmerge"); const files = glob.sync("src/i18n/*.po"); console.log(files); for (const f of files) { console.log(`merging ${f}`); + // --previous keeps the old msgid on every entry msgmerge marks fuzzy, + // as a '#| msgid' line. Without it a translator sees a fuzzy string + // with no way to tell what it was matched against. child_process.execSync( - `msgmerge -o '${f}' '${f}' 'src/i18n/${poDomain}.pot'`, + `msgmerge --previous -o '${f}' '${f}' 'src/i18n/${poDomain}.pot'`, ); } break; @@ -43,6 +65,9 @@ export function main() { case "emit": po2ts(); break; + case "check": + check(); + break; default: console.error(`unknown subcommand '${subcommand}'`); usage(); diff --git a/packages/pogen/src/potextract.test.ts b/packages/pogen/src/potextract.test.ts @@ -17,7 +17,7 @@ import { test } from "node:test"; import assert from "node:assert"; import * as ts from "typescript"; -import { processFileForTesting } from "./potextract.js"; +import { ParseError, processFileForTesting } from "./potextract.js"; function wrapIntoFunction(src: string): string { return ` @@ -28,13 +28,41 @@ return ${src} `; } -function process(src: string): string { - const source = ts.createSourceFile( - "test.tsx", +function sourceOf(name: string, src: string): ts.SourceFile { + return ts.createSourceFile( + name, wrapIntoFunction(src), ts.ScriptTarget.ES2023, ); - return processFileForTesting(source).trim(); +} + +function process(src: string): string { + return processFileForTesting(sourceOf("test.tsx", src)).trim(); +} + +/** + * Same as process(), but over several files, so that the accumulation of + * source references for one msgid can be observed. + */ +function processMany(files: [string, string][]): string { + return processFileForTesting( + ...files.map(([name, src]) => sourceOf(name, src)), + ).trim(); +} + +/** Run f() while collecting everything it writes to stderr. */ +function captureStderr(f: () => void): string[] { + const original = console.error; + const collected: string[] = []; + console.error = (...args: any[]) => { + collected.push(args.map((a) => String(a)).join(" ")); + }; + try { + f(); + } finally { + console.error = original; + } + return collected; } test("should extract the key from inner body", (t) => { @@ -42,7 +70,6 @@ test("should extract the key from inner body", (t) => { process(`<i18n.Translate>something</i18n.Translate>`), `#. screenid: 5 #: test.tsx:4 -#, c-format msgid "something" msgstr ""`, ); @@ -57,7 +84,6 @@ test("should support context on tags", (t) => { ), `#. screenid: 5 #: test.tsx:5 -#, c-format msgctxt "some_context" msgid "something" msgstr ""`, @@ -69,7 +95,6 @@ test("should support context on string template", (t) => { process(`return i18n.context("wire transfer")\`send\`;`), `#. screenid: 5 #: test.tsx:4 -#, c-format msgctxt "wire transfer" msgid "send" msgstr ""`, @@ -83,14 +108,12 @@ test("should support same message id with different context", (t) => { ), `#. screenid: 5 #: test.tsx:4 -#, c-format msgctxt "wire transfer" msgid "send" msgstr "" #. screenid: 5 #: test.tsx:4 -#, c-format msgctxt "gift" msgid "send" msgstr ""`, @@ -105,7 +128,6 @@ test("should support on string template", (t) => { `#. screenid: 5 #. comment of the translation #: test.tsx:6 -#, c-format msgid "another key" msgstr ""`, ); @@ -123,13 +145,11 @@ test("should override screen id", (t) => { `#. screenid: 6 #. comment of the translation #: test.tsx:8 -#, c-format msgid "another key" msgstr ""`, ); }); - test("should support nested tags", (t) => { assert.deepStrictEqual( process(` @@ -143,14 +163,366 @@ test("should support nested tags", (t) => { `), `#. screenid: 5 #: test.tsx:6 -#, c-format msgid "Purging an instance %1$s" msgstr "" #. screenid: 5 #: test.tsx:9 -#, c-format msgid "This cannot be undone!" msgstr ""`, ); }); + +// +// The "t" tag, which is the form used throughout merchant-webui-ng and +// which the suite never covered. +// + +test("should extract the bare t tag", (t) => { + assert.deepStrictEqual( + process(`t\`Orders\`;`), + `#. screenid: 5 +#: test.tsx:4 +msgid "Orders" +msgstr ""`, + ); +}); + +test("should extract the t tag with interpolation", (t) => { + assert.deepStrictEqual( + process(`t\`found \${n} orders\`;`), + `#. screenid: 5 +#: test.tsx:4 +msgid "found %1$s orders" +msgstr ""`, + ); +}); + +test("should extract a t tag with a context", (t) => { + assert.deepStrictEqual( + process(`t.context("navigation")\`Orders\`;`), + `#. screenid: 5 +#: test.tsx:4 +msgctxt "navigation" +msgid "Orders" +msgstr ""`, + ); +}); + +// +// Plain calls. None of these is extractable; the two literal forms must at +// least be reported so that the gap is visible. +// + +test("t(literal) is not extracted but warns", (t) => { + let out = ""; + const errors = captureStderr(() => { + out = process(`t("Wire transfer");`); + }); + assert.deepStrictEqual(out, ""); + assert.deepStrictEqual(errors.length, 1); + assert.ok( + errors[0].includes("test.tsx:4"), + `expected a file:line in ${JSON.stringify(errors[0])}`, + ); + assert.ok(errors[0].includes("not extractable"), errors[0]); +}); + +test("t(variable) is not extracted and does not warn", (t) => { + let out = ""; + const errors = captureStderr(() => { + out = process(`t(someLabel);`); + }); + assert.deepStrictEqual(out, ""); + assert.deepStrictEqual(errors, []); +}); + +test("t(template with interpolation) is not extracted but warns", (t) => { + let out = ""; + const errors = captureStderr(() => { + out = process(`t(\`All Products (\${n})\`);`); + }); + assert.deepStrictEqual(out, ""); + assert.deepStrictEqual(errors.length, 1); + assert.ok(errors[0].includes("test.tsx:4"), errors[0]); + assert.ok(errors[0].includes("msgid"), errors[0]); +}); + +test("a context specifier call is not mistaken for a message", (t) => { + let out = ""; + const errors = captureStderr(() => { + out = process(`i18n.context("wire transfer")\`send\`;`); + }); + assert.deepStrictEqual( + out, + `#. screenid: 5 +#: test.tsx:4 +msgctxt "wire transfer" +msgid "send" +msgstr ""`, + ); + assert.deepStrictEqual(errors, []); +}); + +// +// "%" must be passed through verbatim, because the runtime builds its lookup +// key from the raw template parts. +// + +test("should not escape % in the head of a template", (t) => { + assert.deepStrictEqual( + process(`t\`100% of \${n} done\`;`), + `#. screenid: 5 +#: test.tsx:4 +msgid "100% of %1$s done" +msgstr ""`, + ); +}); + +test("should not escape % in a tail fragment of a template", (t) => { + assert.deepStrictEqual( + process(`t\`\${n} is 100% done\`;`), + `#. screenid: 5 +#: test.tsx:4 +msgid "%1$s is 100% done" +msgstr ""`, + ); +}); + +test("should not escape % in a template without interpolation", (t) => { + assert.deepStrictEqual( + process(`t\`100% done\`;`), + `#. screenid: 5 +#: test.tsx:4 +msgid "100% done" +msgstr ""`, + ); +}); + +test("should never emit the c-format flag", (t) => { + assert.ok(!process(`t\`\${n} is 100% done\`;`).includes("c-format")); + assert.ok( + !process(`<i18n.Translate>50% off</i18n.Translate>`).includes("c-format"), + ); +}); + +// +// Comments. +// + +test("should keep every line of a multi-line // comment", (t) => { + assert.deepStrictEqual( + process(` + // The header of the event list, + // so the title says what happened. + return i18n.str\`another key\`;`), + `#. screenid: 5 +#. The header of the event list, +#. so the title says what happened. +#: test.tsx:7 +msgid "another key" +msgstr ""`, + ); +}); + +test("should not absorb a comment separated by a blank line", (t) => { + assert.deepStrictEqual( + process(` + // an unrelated remark + + // the actual comment + return i18n.str\`another key\`;`), + `#. screenid: 5 +#. the actual comment +#: test.tsx:8 +msgid "another key" +msgstr ""`, + ); +}); + +test("should attach a comment to a JSX message", (t) => { + assert.deepStrictEqual( + process(` + // explains the button + <i18n.Translate>Confirm</i18n.Translate>`), + `#. screenid: 5 +#. explains the button +#: test.tsx:6 +msgid "Confirm" +msgstr ""`, + ); +}); + +// +// Duplicates. +// + +test("should list every source reference of a repeated msgid", (t) => { + assert.deepStrictEqual( + processMany([ + ["a.tsx", "t`Orders`;"], + ["b.tsx", "t`Orders`;"], + ]), + `#. screenid: 5 +#: a.tsx:4 +#: b.tsx:4 +msgid "Orders" +msgstr ""`, + ); +}); + +test("should list every source reference within one file", (t) => { + assert.deepStrictEqual( + process(` + <div> + <i18n.Translate>Orders</i18n.Translate> + <i18n.Translate>Orders</i18n.Translate> + </div>`), + `#. screenid: 5 +#: test.tsx:6 +#: test.tsx:7 +msgid "Orders" +msgstr ""`, + ); +}); + +test("the dedupe key must separate msgctxt from msgid", (t) => { + // msgctxt "ab" + msgid "c" must not collide with msgctxt "a" + msgid "bc". + assert.deepStrictEqual( + process(`i18n.context("ab")\`c\` + i18n.context("a")\`bc\`;`), + `#. screenid: 5 +#: test.tsx:4 +msgctxt "ab" +msgid "c" +msgstr "" + +#. screenid: 5 +#: test.tsx:4 +msgctxt "a" +msgid "bc" +msgstr ""`, + ); +}); + +test("a repeated TranslateSwitch must not emit an orphan header", (t) => { + const out = process(` + <div> + <i18n.TranslateSwitch target={n}> + <i18n.TranslateSingular>one order</i18n.TranslateSingular> + <i18n.TranslatePlural>many orders</i18n.TranslatePlural> + </i18n.TranslateSwitch> + <i18n.TranslateSwitch target={n}> + <i18n.TranslateSingular>one order</i18n.TranslateSingular> + <i18n.TranslatePlural>many orders</i18n.TranslatePlural> + </i18n.TranslateSwitch> + </div>`); + assert.deepStrictEqual( + out, + `#. screenid: 5 +#: test.tsx:6 +#: test.tsx:10 +msgid "one order" +msgid_plural "many orders" +msgstr[0] "" +msgstr[1] ""`, + ); + // No "#:" block may be left without a msgid following it. + const lines = out.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("#: ")) { + assert.ok( + lines.slice(i).some((l) => l.startsWith("msgid ")), + "orphan source reference without a msgid", + ); + } + } +}); + +// +// i18n.plural, both the documented and the historic argument order. +// + +test("should accept i18n.plural(count, singular, plural) as documented", (t) => { + assert.deepStrictEqual( + process( + "i18n.plural(n, i18n.lazy`I have ${n} apple`, i18n.lazy`I have ${n} apples`);", + ), + `#. screenid: 5 +#: test.tsx:4 +msgid "I have %1$s apple" +msgid_plural "I have %1$s apples" +msgstr[0] "" +msgstr[1] ""`, + ); +}); + +test("should accept i18n.plural(singular, plural)", (t) => { + assert.deepStrictEqual( + process( + "i18n.plural(i18n.lazy`I have ${n} apple`, i18n.lazy`I have ${n} apples`);", + ), + `#. screenid: 5 +#: test.tsx:4 +msgid "I have %1$s apple" +msgid_plural "I have %1$s apples" +msgstr[0] "" +msgstr[1] ""`, + ); +}); + +test("should not crash on an i18n.plural with too few arguments", (t) => { + assert.deepStrictEqual(process("i18n.plural();"), ""); + assert.deepStrictEqual(process("i18n.plural(n);"), ""); + // Degenerate, but must not throw: the lone tagged template is still picked + // up by the ordinary tagged-template rule. + assert.deepStrictEqual( + process("i18n.plural(i18n.lazy`one`);"), + `#. screenid: 5 +#: test.tsx:4 +msgid "one" +msgstr ""`, + ); +}); + +// +// Error handling. +// + +test("an empty template is a ParseError with a one-based line", (t) => { + assert.throws( + () => process("i18n.str``;"), + (e: unknown) => { + assert.ok(e instanceof ParseError, `not a ParseError: ${e}`); + assert.deepStrictEqual(e.line, 3); + assert.ok( + e.message.includes("test.tsx:4"), + `expected a one-based line in ${e.message}`, + ); + return true; + }, + ); +}); + +test("class name is ParseError, not ParseErrror", (t) => { + assert.deepStrictEqual(ParseError.name, "ParseError"); +}); + +test("should not crash on a shorthand boolean JSX attribute", (t) => { + assert.deepStrictEqual( + process(`<i18n.Translate disabled>something</i18n.Translate>`), + `#. screenid: 5 +#: test.tsx:4 +msgid "something" +msgstr ""`, + ); +}); + +test("should not crash on a spread JSX attribute", (t) => { + assert.deepStrictEqual( + process(`<i18n.Translate {...props}>something</i18n.Translate>`), + `#. screenid: 5 +#: test.tsx:4 +msgid "something" +msgstr ""`, + ); +}); diff --git a/packages/pogen/src/potextract.ts b/packages/pogen/src/potextract.ts @@ -54,7 +54,10 @@ function getTemplate(node: ts.Node): string { let textFragments = [te.head.text]; for (let tsp of te.templateSpans) { textFragments.push(`%${(textFragments.length - 1) / 2 + 1}$s`); - textFragments.push(tsp.literal.text.replace(/%/g, "%%")); + // No escaping of "%" here: the runtime is not a printf formatter, it + // substitutes "%N$s" by literal string replacement over the *raw* + // template parts. An escaped msgid could never be looked up again. + textFragments.push(tsp.literal.text); } return textFragments.join(""); } @@ -69,41 +72,65 @@ function getComment( lastTokLine: number, node: ts.Node, ): string { - let lc = ts.getLineAndCharacterOfPosition(sourceFile, node.pos); - let lastComments: ts.CommentRange[] | undefined = undefined; + let lc = ts.getLineAndCharacterOfPosition( + sourceFile, + node.getStart(sourceFile), + ); + const found: ts.CommentRange[] = []; for (let l = preLastTokLine; l < lastTokLine; l++) { let pos = ts.getPositionOfLineAndCharacter(sourceFile, l, 0); let comments = ts.getTrailingCommentRanges(sourceFile.text, pos); - if (comments) { - lastComments = comments; + if (!comments) { + continue; + } + for (const c of comments) { + if (found.length && found[found.length - 1].pos >= c.pos) { + continue; + } + found.push(c); } } - if (!lastComments) { + if (!found.length) { return ""; } - let candidate = lastComments[lastComments.length - 1]; - let candidateEndLine = ts.getLineAndCharacterOfPosition( - sourceFile, - candidate.end, - ).line; - if (candidateEndLine != lc.line - 1) { + const startLineOf = (c: ts.CommentRange) => + ts.getLineAndCharacterOfPosition(sourceFile, c.pos).line; + const endLineOf = (c: ts.CommentRange) => + ts.getLineAndCharacterOfPosition(sourceFile, c.end).line; + + let first = found.length - 1; + if (endLineOf(found[first]) != lc.line - 1) { return ""; } - let text = sourceFile.text.slice(candidate.pos, candidate.end); - switch (candidate.kind) { - case ts.SyntaxKind.SingleLineCommentTrivia: - // Remove comment leader - text = text.replace(/^[/][/]\s*/, ""); - break; - case ts.SyntaxKind.MultiLineCommentTrivia: - // Remove comment leader and trailer, - // handling white space just like xgettext. - text = text - .replace(/^[/][*](\s*?\n|\s*)?/, "") - .replace(/(\n[ \t]*?)?[*][/]$/, ""); - break; + // A run of consecutive "//" lines is one comment, just like in xgettext. + if (found[first].kind === ts.SyntaxKind.SingleLineCommentTrivia) { + while ( + first > 0 && + found[first - 1].kind === ts.SyntaxKind.SingleLineCommentTrivia && + endLineOf(found[first - 1]) === startLineOf(found[first]) - 1 + ) { + first--; + } } - return text; + const lines: string[] = []; + for (const candidate of found.slice(first)) { + let text = sourceFile.text.slice(candidate.pos, candidate.end); + switch (candidate.kind) { + case ts.SyntaxKind.SingleLineCommentTrivia: + // Remove comment leader + text = text.replace(/^[/][/]\s*/, ""); + break; + case ts.SyntaxKind.MultiLineCommentTrivia: + // Remove comment leader and trailer, + // handling white space just like xgettext. + text = text + .replace(/^[/][*](\s*?\n|\s*)?/, "") + .replace(/(\n[ \t]*?)?[*][/]$/, ""); + break; + } + lines.push(text); + } + return lines.join("\n"); } function getPath(node: ts.Node): { path: string[]; ctx: string } { @@ -189,55 +216,118 @@ function processTaggedTemplateExpression( type ScreenInfo = { fileMap: Record<string, Set<string>>; - missing: Set<string>; maxId: number; }; const SCREEN_INFO: ScreenInfo = { fileMap: {}, - missing: new Set(), maxId: 0, }; -function formatScreenId( +function registerScreenId( sourceFile: ts.SourceFile, - outChunks: string[], screenId: string | undefined, -): void { +): string[] { if (!screenId) { - return; + return []; } const screen = Number.parseInt(screenId, 10); if (!screen || Number.isNaN(screen)) { - SCREEN_INFO.missing.add(sourceFile.fileName); - } else { - if (!SCREEN_INFO.fileMap[screenId]) { - SCREEN_INFO.fileMap[screenId] = new Set(); - } - SCREEN_INFO.fileMap[screenId].add(sourceFile.fileName); - - if (SCREEN_INFO.maxId < screen) { - SCREEN_INFO.maxId = screen; - } + return []; + } + if (!SCREEN_INFO.fileMap[screenId]) { + SCREEN_INFO.fileMap[screenId] = new Set(); + } + SCREEN_INFO.fileMap[screenId].add(sourceFile.fileName); - outChunks.push(`#. screenid: ${screenId}\n`); + if (SCREEN_INFO.maxId < screen) { + SCREEN_INFO.maxId = screen; } + + return [screenId]; +} + +/** + * One message of the catalogue. Messages are buffered instead of being + * written out directly, so that every source reference of a msgid that occurs + * more than once can be listed together, the way xgettext does it. + */ +interface PoEntry { + screenIds: string[]; + comments: string[]; + refs: string[]; + context: string; + msgid: string; + msgidPlural?: string; } -function formatMsgComment( +/** Buffered messages, keyed by msgctxt+msgid, in first-seen order. */ +export type PoEntries = Map<string, PoEntry>; + +/** + * Dedupe key. The EOT separator is the gettext convention; without it + * msgctxt "ab"+msgid "c" would collide with msgctxt "a"+msgid "bc". + */ +function msgKey(context: string, msgid: string): string { + return `${context}${msgid}`; +} + +function sourceRef( projectPrefix: string, sourceFile: ts.SourceFile, - outChunks: string[], line: number, - comment?: string, -) { - if (comment) { - for (let cl of comment.split("\n")) { - outChunks.push(`#. ${cl}\n`); +): string { + const fn = path.relative(projectPrefix, sourceFile.fileName); + return `${fn}:${line + 1}`; +} + +function addEntry(entries: PoEntries, entry: PoEntry): void { + const key = msgKey(entry.context, entry.msgid); + const known = entries.get(key); + if (!known) { + entries.set(key, entry); + return; + } + const merge = (into: string[], from: string[]) => { + for (const x of from) { + if (!into.includes(x)) { + into.push(x); + } } + }; + merge(known.screenIds, entry.screenIds); + merge(known.comments, entry.comments); + merge(known.refs, entry.refs); + if (!known.msgidPlural && entry.msgidPlural) { + known.msgidPlural = entry.msgidPlural; } - const fn = path.relative(projectPrefix, sourceFile.fileName); - outChunks.push(`#: ${fn}:${line + 1}\n`); - outChunks.push(`#, c-format\n`); +} + +function renderEntries(entries: PoEntries): string { + const outChunks: string[] = []; + for (const entry of entries.values()) { + for (const screenId of entry.screenIds) { + outChunks.push(`#. screenid: ${screenId}\n`); + } + for (const comment of entry.comments) { + for (let cl of comment.split("\n")) { + outChunks.push(`#. ${cl}\n`); + } + } + for (const ref of entry.refs) { + outChunks.push(`#: ${ref}\n`); + } + formatMsgLine(outChunks, "msgctxt", entry.context); + formatMsgLine(outChunks, "msgid", entry.msgid); + if (entry.msgidPlural !== undefined) { + formatMsgLine(outChunks, "msgid_plural", entry.msgidPlural); + outChunks.push(`msgstr[0] ""\n`); + outChunks.push(`msgstr[1] ""\n`); + } else { + outChunks.push(`msgstr ""\n`); + } + outChunks.push("\n"); + } + return outChunks.join(""); } function formatMsgLine(outChunks: string[], head: string, msg: string) { @@ -299,12 +389,18 @@ function getJsxAttribute(sour: ts.SourceFile, node: ts.Node) { case ts.SyntaxKind.JsxOpeningElement: { let e = childNode as ts.JsxOpeningElement; - e.attributes.properties.map((p) => { - const id = p.getChildAt(0, sour).getText(sour); - const v = p.getChildAt(2, sour); - if (v.kind !== ts.SyntaxKind.StringLiteral) { - return undefined; + e.attributes.properties.forEach((p) => { + // Skip spreads ({...props}) and shorthand boolean attributes + // (<i18n.Translate disabled>), which have no initializer at all. + if (p.kind !== ts.SyntaxKind.JsxAttribute) { + return; } + const attr = p as ts.JsxAttribute; + const v = attr.initializer; + if (!v || v.kind !== ts.SyntaxKind.StringLiteral) { + return; + } + const id = attr.name.getText(sour); result[id] = JSON.parse(v.getText(sour)); }); return; @@ -348,7 +444,6 @@ function getJsxContent(node: ts.Node) { case ts.SyntaxKind.JsxClosingElement: break; default: { - console.log("unhandled node type: ", childNode.kind); let lc = ts.getLineAndCharacterOfPosition( childNode.getSourceFile(), childNode.getStart(), @@ -444,27 +539,104 @@ function searchScreenId(parents: ts.Node[], sourceFile: ts.SourceFile) { return result; } +/** + * Report a call site that looks like it wants to be translated but can never + * be extracted. Non-fatal on purpose: the point is that these stop being + * invisible. + */ +function warnUnextractableCall( + sourceFile: ts.SourceFile, + line: number, + form: string, + detail: string, +) { + console.error( + `pogen: warning: ${sourceFile.fileName}:${line + 1}: ${form} is not extractable, ${detail}`, + ); +} + +function checkUnextractableCall( + parents: ts.Node[], + ce: ts.CallExpression, + callee: string[], + sourceFile: ts.SourceFile, + line: number, +) { + if (callee[0] !== "i18n" && callee[0] !== "t") { + return; + } + // i18n.context("x")`y` is a context specifier for the tagged template that + // follows, not a message of its own. + const parent = parents[0]; + if ( + parent !== undefined && + parent.kind === ts.SyntaxKind.TaggedTemplateExpression && + (parent as ts.TaggedTemplateExpression).tag === ce + ) { + return; + } + if (ce.arguments.length !== 1) { + return; + } + const arg = ce.arguments[0]; + const name = callee.join("."); + switch (arg.kind) { + case ts.SyntaxKind.StringLiteral: + case ts.SyntaxKind.NoSubstitutionTemplateLiteral: + warnUnextractableCall( + sourceFile, + line, + `${name}(<string literal>)`, + `use a tagged template ${name}\`...\` instead`, + ); + break; + case ts.SyntaxKind.TemplateExpression: + warnUnextractableCall( + sourceFile, + line, + `${name}(\`...\${...}...\`)`, + `it mints a new msgid for every runtime value; use a tagged template ${name}\`...\` instead`, + ); + break; + } +} + function processNode( parents: ts.Node[], node: ts.Node, preLastTokLine: number, lastTokLine: number, sourceFile: ts.SourceFile, - outChunks: string[], - knownMessageIds: Set<string>, + entries: PoEntries, projectPrefix: string, ) { - const { line } = ts.getLineAndCharacterOfPosition(sourceFile, node.pos); + const { line } = ts.getLineAndCharacterOfPosition( + sourceFile, + node.getStart(sourceFile), + ); try { switch (node.kind) { case ts.SyntaxKind.JsxElement: { let path = getJsxElementPath(node); + if ( + arrayEq(path, ["i18n", "Translate"]) || + arrayEq(path, ["i18n", "TranslateSwitch"]) + ) { + // Track the line of the element itself, so that getComment has a + // range to scan. Without this a JSX message never sees a comment. + if (line != lastTokLine) { + preLastTokLine = lastTokLine; + lastTokLine = line; + } + } // <i18n.Translate>text</i18n.Translate> if (arrayEq(path, ["i18n", "Translate"])) { const content = getJsxContent(node); if (!content) { - throw Error( - `string to be translated can't be empty: ${sourceFile.fileName}:${line}`, + throw new ParseError( + `string to be translated can't be empty`, + sourceFile.fileName, + line, ); } const comment = getComment( @@ -474,78 +646,85 @@ function processNode( node, ); const context = getJsxAttribute(sourceFile, node)["context"] ?? ""; - const msgid = context + content; - if (!knownMessageIds.has(msgid)) { - knownMessageIds.add(msgid); - const screenId = searchScreenId(parents, sourceFile); - formatScreenId(sourceFile, outChunks, screenId); - formatMsgComment( - projectPrefix, + addEntry(entries, { + screenIds: registerScreenId( sourceFile, - outChunks, - line, - comment, - ); - formatMsgLine(outChunks, "msgctxt", context); - formatMsgLine(outChunks, "msgid", content); - outChunks.push(`msgstr ""\n`); - outChunks.push("\n"); - } + searchScreenId(parents, sourceFile), + ), + comments: comment ? [comment] : [], + refs: [sourceRef(projectPrefix, sourceFile, line)], + context, + msgid: content, + }); } else if (arrayEq(path, ["i18n", "TranslateSwitch"])) { - const { line } = ts.getLineAndCharacterOfPosition( - sourceFile, - node.pos, - ); const comment = getComment( sourceFile, preLastTokLine, lastTokLine, node, ); - formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment); const content = getJsxSingular(node); if (!content) { - throw Error( - `string to be translated can't be empty, singular is missing: ${sourceFile.fileName}:${line}`, + throw new ParseError( + `string to be translated can't be empty, singular is missing`, + sourceFile.fileName, + line, ); } const pluralForm = getJsxPlural(node); if (!pluralForm) { - throw Error( - `string to be translated can't be empty, plural is missing: ${sourceFile.fileName}:${line}`, + throw new ParseError( + `string to be translated can't be empty, plural is missing`, + sourceFile.fileName, + line, ); } const context = getJsxAttribute(sourceFile, node)["context"] ?? ""; - const msgid = context + content; - if (!knownMessageIds.has(msgid)) { - knownMessageIds.add(msgid); - const screenId = searchScreenId(parents, sourceFile); - formatScreenId(sourceFile, outChunks, screenId); - formatMsgLine(outChunks, "msgctxt", context); - formatMsgLine(outChunks, "msgid", content); - formatMsgLine(outChunks, "msgid_plural", pluralForm); - outChunks.push(`msgstr[0] ""\n`); - outChunks.push(`msgstr[1] ""\n`); - outChunks.push(`\n`); - } + addEntry(entries, { + screenIds: registerScreenId( + sourceFile, + searchScreenId(parents, sourceFile), + ), + comments: comment ? [comment] : [], + refs: [sourceRef(projectPrefix, sourceFile, line)], + context, + msgid: content, + msgidPlural: pluralForm, + }); } break; } case ts.SyntaxKind.CallExpression: { - // might be i18n.plural(i18n[.X]`...`, i18n[.X]`...`) + // might be i18n.plural(n?, i18n[.X]`...`, i18n[.X]`...`) let ce = <ts.CallExpression>node; let path = getPath(ce.expression); if (!arrayEq(path.path, ["i18n", "plural"])) { + checkUnextractableCall(parents, ce, path.path, sourceFile, line); + break; + } + // The README documents i18n.plural(n, i18n.lazy`..`, i18n.lazy`..`); + // older code passes just the two forms. Accept both. + let first = 0; + if ( + ce.arguments.length > 0 && + ce.arguments[0].kind != ts.SyntaxKind.TaggedTemplateExpression + ) { + first = 1; + } + if (ce.arguments.length < first + 2) { break; } - if (ce.arguments[0].kind != ts.SyntaxKind.TaggedTemplateExpression) { + if ( + ce.arguments[first].kind != ts.SyntaxKind.TaggedTemplateExpression + ) { break; } - if (ce.arguments[1].kind != ts.SyntaxKind.TaggedTemplateExpression) { + if ( + ce.arguments[first + 1].kind != ts.SyntaxKind.TaggedTemplateExpression + ) { break; } - let { line } = ts.getLineAndCharacterOfPosition(sourceFile, ce.pos); - const tte1 = <ts.TaggedTemplateExpression>ce.arguments[0]; + const tte1 = <ts.TaggedTemplateExpression>ce.arguments[first]; let lc1 = ts.getLineAndCharacterOfPosition(sourceFile, tte1.pos); if (lc1.line != lastTokLine) { preLastTokLine = lastTokLine; // HERE @@ -559,14 +738,14 @@ function processNode( ); const content = t1.template; if (!content) { - throw new ParseErrror( + throw new ParseError( `string to be translated can't be empty`, sourceFile.fileName, line, ); } - const tte2 = <ts.TaggedTemplateExpression>ce.arguments[1]; + const tte2 = <ts.TaggedTemplateExpression>ce.arguments[first + 1]; let lc2 = ts.getLineAndCharacterOfPosition(sourceFile, tte2.pos); if (lc2.line != lastTokLine) { preLastTokLine = lastTokLine; // HERE @@ -579,19 +758,17 @@ function processNode( tte2, ); let comment = getComment(sourceFile, preLastTokLine, lastTokLine, ce); - const msgid = path.ctx + content; - if (!knownMessageIds.has(msgid)) { - knownMessageIds.add(msgid); - const screenId = searchScreenId(parents, sourceFile); - formatScreenId(sourceFile, outChunks, screenId); - formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment); - formatMsgLine(outChunks, "msgctxt", path.ctx); - formatMsgLine(outChunks, "msgid", content); - formatMsgLine(outChunks, "msgid_plural", t2.template); - outChunks.push(`msgstr[0] ""\n`); - outChunks.push(`msgstr[1] ""\n`); - outChunks.push("\n"); - } + addEntry(entries, { + screenIds: registerScreenId( + sourceFile, + searchScreenId(parents, sourceFile), + ), + comments: comment ? [comment] : [], + refs: [sourceRef(projectPrefix, sourceFile, line)], + context: path.ctx, + msgid: content, + msgidPlural: t2.template, + }); // Important: no processing for child i18n expressions here return; @@ -603,34 +780,38 @@ function processNode( preLastTokLine = lastTokLine; lastTokLine = lc2.line; } - const { comment, template, line, path, context } = - processTaggedTemplateExpression( - sourceFile, - preLastTokLine, - lastTokLine, - tte, - ); - if (path[0] != "i18n") { + const { + comment, + template, + line: tteLine, + path, + context, + } = processTaggedTemplateExpression( + sourceFile, + preLastTokLine, + lastTokLine, + tte, + ); + if (path[0] != "i18n" && path[0] != "t") { break; } if (!template) { - throw new ParseErrror( + throw new ParseError( `string to be translated can't be empty`, sourceFile.fileName, - line, + tteLine, ); } - const msgid = context + template; - if (!knownMessageIds.has(msgid)) { - knownMessageIds.add(msgid); - const screenId = searchScreenId(parents, sourceFile); - formatScreenId(sourceFile, outChunks, screenId); - formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment); - formatMsgLine(outChunks, "msgctxt", context); - formatMsgLine(outChunks, "msgid", template); - outChunks.push(`msgstr ""\n`); - outChunks.push("\n"); - } + addEntry(entries, { + screenIds: registerScreenId( + sourceFile, + searchScreenId(parents, sourceFile), + ), + comments: comment ? [comment] : [], + refs: [sourceRef(projectPrefix, sourceFile, tteLine)], + context, + msgid: template, + }); break; } } @@ -639,59 +820,53 @@ function processNode( processNode( [node, ...parents], child, - lastTokLine, preLastTokLine, + lastTokLine, sourceFile, - outChunks, - knownMessageIds, + entries, projectPrefix, ); }); } catch (error) { - if (error instanceof ParseErrror) { + if (error instanceof ParseError) { throw error; } else { - throw new ParseErrror(String(error), sourceFile.fileName, line); + throw new ParseError(String(error), sourceFile.fileName, line); } } } -class ParseErrror extends Error { +export class ParseError extends Error { + /** Zero-based, like everything else the TypeScript API hands out. */ line: number; filename: string; reason: string; constructor(reason: string, filename: string, line: number) { - super(`failed by "${reason}" at ${filename}:${line}`); + // Reported one-based, so that it agrees with the "#:" lines and with + // what an editor shows. + super(`failed by "${reason}" at ${filename}:${line + 1}`); this.reason = reason; this.filename = filename; this.line = line; } } -export function processFileForTesting(sourceFile: ts.SourceFile): string { - const result: string[] = new Array<string>(); - processNode([], sourceFile, 0, 0, sourceFile, result, new Set<string>(), ""); - return result.join(""); +export function processFileForTesting( + ...sourceFiles: ts.SourceFile[] +): string { + const entries: PoEntries = new Map(); + for (const sourceFile of sourceFiles) { + processFile(sourceFile, entries, ""); + } + return renderEntries(entries); } export function processFile( sourceFile: ts.SourceFile, - outChunks: string[], - knownMessageIds: Set<string>, + entries: PoEntries, projectPrefix: string, ) { - // let lastTokLine = 0; - // let preLastTokLine = 0; - processNode( - [], - sourceFile, - 0, - 0, - sourceFile, - outChunks, - knownMessageIds, - projectPrefix, - ); + processNode([], sourceFile, 0, 0, sourceFile, entries, projectPrefix); } function searchIntoParents(directory: string, fileFlag: string) { @@ -758,21 +933,27 @@ export function potextract(searchPath: string = "./") { const gitRoot = searchIntoParents(process.cwd(), ".git"); - const chunks = [header]; - const knownMessageIds = new Set<string>(); + const entries: PoEntries = new Map(); + const failed: string[] = []; for (const f of ownFiles) { - processFile(f, chunks, knownMessageIds, gitRoot); + // One unparseable file must not cost us the whole catalogue. + try { + processFile(f, entries, gitRoot); + } catch (e) { + failed.push(f.fileName); + if (e instanceof ParseError) { + console.error( + `pogen: error: ${e.filename}:${e.line + 1}: ${e.reason} (file skipped)`, + ); + } else { + console.error( + `pogen: error: ${f.fileName}: ${String(e)} (file skipped)`, + ); + } + } } - if (SCREEN_INFO.missing.size) { - console.error( - `There are some files with translation strings that do not have a constant TALER_SCREEN_ID on the root. This constant should be a unique integer number to facilitate the identification from other locations. Add a "const TALER_SCREEN_ID = <number>;" after the last import. It does not need to be exported and it can be removed from runtime. Files:`, - ); - SCREEN_INFO.missing.forEach((fileName) => { - console.error(` * ${fileName}`); - }); - } const haveRepetition = Object.values(SCREEN_INFO.fileMap).find((files) => files.size > 1) !== undefined; @@ -791,9 +972,7 @@ export function potextract(searchPath: string = "./") { }); } - const pot = chunks.join(""); - - //console.log(pot); + const pot = header + renderEntries(entries); const packageJson = JSON.parse( fs.readFileSync("./package.json", { encoding: "utf-8" }), @@ -805,4 +984,11 @@ export function potextract(searchPath: string = "./") { process.exit(1); } fs.writeFileSync(`./src/i18n/${poDomain}.pot`, pot); + + if (failed.length) { + console.error( + `pogen: ${failed.length} file(s) could not be processed, the catalogue is incomplete`, + ); + process.exitCode = 1; + } }