taler-typescript-core

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

commit 8314e696699031f2dbed575928da8702d6ab630b
parent ea0c06ae6ca2b1bfaf731a19c4a97a9991491465
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 22:13:41 +0200

pogen: extract fixed translations without lowering coverage

Diffstat:
Mpackages/pogen/README.md | 48+++++++++++++++++++++++++++++++++---------------
Mpackages/pogen/src/po2ts.test.ts | 21++++++++++++++++++++-
Mpackages/pogen/src/po2ts.ts | 42+++++++++++++++++++++++++++---------------
Mpackages/pogen/src/potextract.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Mpackages/pogen/src/potextract.ts | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
5 files changed, 207 insertions(+), 36 deletions(-)

diff --git a/packages/pogen/README.md b/packages/pogen/README.md @@ -49,12 +49,30 @@ those roots works (`i18n.str`, `i18n.lazy`, `t.context(...)`), and nothing check 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" +t`Hello world`; +t`Hello ${user}`; // msgid "Hello %1$s" +i18n.str`Hello world`; +i18n.context("navigation")`Orders`; // msgctxt "navigation" ``` +Prescribed translations can live in source while still participating in +gettext extraction: + +```ts +i18n.fixed({ en: "Declaration", de: "Erklärung" }); +i18n.fixedOnly({ en: "Declaration", de: "Erklärung" }); +``` + +For `fixed`, an exact or base-language entry in the object takes precedence; +languages not listed there use the normal gettext catalogue and then the +English source. `fixedOnly` does not consult gettext and falls directly back +to the English source. In both cases the static `en` property is the msgid that +`pogen` extracts. The translation object must be inline at the call site so the +extractor can read it. These optional catalogue translations do not count +against a language's completeness percentage, because the runtime has its +source-controlled fallback. Pass values for `%1$s`, `%2$s`, and later +placeholders as arguments following the translation object. + ```tsx <i18n.Translate>Hello, world</i18n.Translate> @@ -70,19 +88,19 @@ i18n.context("navigation")`Orders` // msgctxt "navigation" 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`) +i18n.plural(i18n.lazy`one apple`, i18n.lazy`${n} apples`); +i18n.plural(n, i18n.lazy`one apple`, i18n.lazy`${n} apples`); ``` -### What does *not* get extracted +### What does _not_ get extracted -**A plain call is invisible to the extractor**, even though a runtime `t` may -accept one: +Except for the `fixed` and `fixedOnly` forms described above, **a plain call is +invisible to the extractor**, even though a runtime `t` may accept one: ```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 +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 ``` `pogen extract` reports the first and third with a file and line. The second is @@ -94,7 +112,7 @@ call site — build the table from a factory that takes `t` as a parameter **nam ```ts function tabLabels(t: TranslateFn) { - return { all: t`All`, paid: t`Paid` }; // extracted + return { all: t`All`, paid: t`Paid` }; // extracted } ``` @@ -114,7 +132,7 @@ between the comment and the string breaks the association. ```ts // Shown on the receipt, so keep it short. -t`Thank you` +t`Thank you`; ``` Note `msgmerge` regenerates the `#.` block from the `.pot` on every merge, so a @@ -135,7 +153,7 @@ and will not tell you a catalogue is broken. 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 +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 diff --git a/packages/pogen/src/po2ts.test.ts b/packages/pogen/src/po2ts.test.ts @@ -18,7 +18,10 @@ 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 { +function header( + lang: string, + pluralForms = "nplurals=2; plural=(n != 1);", +): string { return `msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\\n" @@ -110,6 +113,22 @@ msgstr "" assert.equal(s.completeness, 33); }); +test("optional fixed translations do not lower catalogue completeness", () => { + const s = poToStrings( + header("fr") + + `msgid "Hello" +msgstr "Bonjour" + +#. pogen: optional fixed translation +msgid "Declaration" +msgstr "" +`, + "fr", + ); + assert.deepEqual(s.locale_data.messages["Declaration"], [""]); + assert.equal(s.completeness, 100); +}); + test("a plural keeps [msgid_plural, ...msgstr] and needs every form filled in", () => { const s = poToStrings( header("de") + diff --git a/packages/pogen/src/po2ts.ts b/packages/pogen/src/po2ts.ts @@ -26,7 +26,7 @@ import * as glob from "glob"; type Header = { domain: string; lang: string; - "plural_forms": string; + plural_forms: string; }; type MessagesType = Record<string, undefined | Array<string>> & { "": Header }; @@ -43,11 +43,11 @@ export interface StringsType { // X-Domain or 'messages' domain: string; lang: string; - completeness: number, - 'plural_forms': string; + completeness: number; + plural_forms: string; locale_data: { - messages: Record<string, undefined | Array<string>> - } + messages: Record<string, undefined | Array<string>>; + }; } // This prelude match the types above @@ -63,7 +63,7 @@ export interface StringsType { }; `; -const DEFAULT_STRING_PRELUDE = `${TYPES_FOR_STRING_PRELUDE}export const strings: Record<string,StringsType> = {};\n\n` +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. @@ -85,7 +85,10 @@ export const CONTEXT_DELIMITER = "\u0004"; * 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 { +export function poToStrings( + poText: string, + catalogueName: string, +): StringsType { const parsedPo = gettextParser.po.parse(poText); const messages: any = { "": { @@ -121,7 +124,14 @@ export function poToStrings(poText: string, catalogueName: string): StringsType const entry = contextTranslations[msgid]; const key = msgctxt === "" ? msgid : `${msgctxt}${CONTEXT_DELIMITER}${msgid}`; - totalKeys++; + const extractedComments = + (entry.comments && entry.comments.extracted) || ""; + const optionalFixed = extractedComments + .split("\n") + .some( + (comment) => comment.trim() === "pogen: optional fixed translation", + ); + if (!optionalFixed) totalKeys++; // Treat fuzzy entries as untranslated (standard gettext behaviour): // msgmerge seeds fuzzy translations from unrelated nearby strings, so @@ -136,12 +146,12 @@ export function poToStrings(poText: string, catalogueName: string): StringsType // 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++; + if (!optionalFixed) totalTranslated++; } messages[key] = [entry.msgid_plural, ...msgstr]; } else { if (msgstr.length > 0 && !!msgstr[0]) { - totalTranslated++; + if (!optionalFixed) totalTranslated++; } messages[key] = msgstr; } @@ -193,9 +203,9 @@ export function po2ts(): void { let prelude: string; try { - prelude = fs.readFileSync("src/i18n/strings-prelude", "utf-8") + prelude = fs.readFileSync("src/i18n/strings-prelude", "utf-8"); } catch (e) { - prelude = DEFAULT_STRING_PRELUDE + prelude = DEFAULT_STRING_PRELUDE; } const chunks = [prelude]; @@ -214,11 +224,13 @@ export function po2ts(): void { try { strings = poToStrings(poText, lang); } catch (e) { - console.error(`error: ${e instanceof Error ? e.message : e} (${filename})`); + console.error( + `error: ${e instanceof Error ? e.message : e} (${filename})`, + ); process.exit(1); } - const value = JSON.stringify(strings, undefined, 2) - const s = `strings['${lang}'] = ${value};\n\n` + const value = JSON.stringify(strings, undefined, 2); + const s = `strings['${lang}'] = ${value};\n\n`; chunks.push(s); } diff --git a/packages/pogen/src/potextract.test.ts b/packages/pogen/src/potextract.test.ts @@ -133,6 +133,51 @@ msgstr ""`, ); }); +test("should extract fixed and fixed-only translation objects", () => { + assert.deepStrictEqual( + process(`i18n.fixed({ en: "Fixed translation", de: "Feste Übersetzung" }); + i18n.fixedOnly({ + en: \`Fixed-only translation\`, + de: "Nur feste Übersetzung", + }); + `), + `#. pogen: optional fixed translation +#. screenid: 5 +#: test.tsx:4 +msgid "Fixed translation" +msgstr "" + +#. pogen: optional fixed translation +#. screenid: 5 +#: test.tsx:5 +msgid "Fixed-only translation" +msgstr ""`, + ); +}); + +test("a normally translated use keeps a shared fixed msgid in coverage", () => { + assert.deepStrictEqual( + process(`i18n.str\`Shared\`; + i18n.fixed({ en: "Shared", de: "Geteilt" }); + `), + `#. screenid: 5 +#: test.tsx:4 +#: test.tsx:5 +msgid "Shared" +msgstr ""`, + ); +}); + +test("fixed translations require a static inline English source", () => { + for (const source of [ + `i18n.fixed({ de: "Deutsch" });`, + `i18n.fixed({ en: dynamic, de: "Deutsch" });`, + `i18n.fixed(translations);`, + ]) { + assert.throws(() => process(source), ParseError); + } +}); + test("should override screen id", (t) => { assert.deepStrictEqual( process(` diff --git a/packages/pogen/src/potextract.ts b/packages/pogen/src/potextract.ts @@ -177,6 +177,54 @@ function getPath(node: ts.Node): { path: string[]; ctx: string } { }; } +function fixedEnglishSource( + call: ts.CallExpression, + sourceFile: ts.SourceFile, + line: number, +): string { + const translations = call.arguments[0]; + if (!translations || !ts.isObjectLiteralExpression(translations)) { + throw new ParseError( + `fixed translation requires an inline object with a static English source`, + sourceFile.fileName, + line, + ); + } + const english = translations.properties.find((property) => { + if (!ts.isPropertyAssignment(property)) return false; + const name = property.name; + return ( + (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === "en" + ); + }); + if (!english || !ts.isPropertyAssignment(english)) { + throw new ParseError( + `fixed translation requires an English source property`, + sourceFile.fileName, + line, + ); + } + const value = english.initializer; + if ( + !ts.isStringLiteral(value) && + !ts.isNoSubstitutionTemplateLiteral(value) + ) { + throw new ParseError( + `fixed translation English source must be a static string`, + sourceFile.fileName, + line, + ); + } + if (!value.text) { + throw new ParseError( + `string to be translated can't be empty`, + sourceFile.fileName, + line, + ); + } + return value.text; +} + function arrayEq<T>(a1: T[], a2: T[]) { if (a1.length != a2.length) { return false; @@ -258,6 +306,7 @@ interface PoEntry { context: string; msgid: string; msgidPlural?: string; + optionalFixed?: boolean; } /** Buffered messages, keyed by msgctxt+msgid, in first-seen order. */ @@ -297,6 +346,7 @@ function addEntry(entries: PoEntries, entry: PoEntry): void { merge(known.screenIds, entry.screenIds); merge(known.comments, entry.comments); merge(known.refs, entry.refs); + known.optionalFixed = Boolean(known.optionalFixed && entry.optionalFixed); if (!known.msgidPlural && entry.msgidPlural) { known.msgidPlural = entry.msgidPlural; } @@ -305,6 +355,9 @@ function addEntry(entries: PoEntries, entry: PoEntry): void { function renderEntries(entries: PoEntries): string { const outChunks: string[] = []; for (const entry of entries.values()) { + if (entry.optionalFixed) { + outChunks.push("#. pogen: optional fixed translation\n"); + } for (const screenId of entry.screenIds) { outChunks.push(`#. screenid: ${screenId}\n`); } @@ -327,7 +380,8 @@ function renderEntries(entries: PoEntries): string { } outChunks.push("\n"); } - return outChunks.join(""); + const output = outChunks.join(""); + return output ? `${output.trimEnd()}\n` : ""; } function formatMsgLine(outChunks: string[], head: string, msg: string) { @@ -695,9 +749,34 @@ function processNode( break; } case ts.SyntaxKind.CallExpression: { - // might be i18n.plural(n?, i18n[.X]`...`, i18n[.X]`...`) let ce = <ts.CallExpression>node; let path = getPath(ce.expression); + const fixedCall = + (path.path[0] === "i18n" || path.path[0] === "t") && + (path.path[path.path.length - 1] === "fixed" || + path.path[path.path.length - 1] === "fixedOnly"); + if (fixedCall) { + const content = fixedEnglishSource(ce, sourceFile, line); + const comment = getComment( + sourceFile, + preLastTokLine, + lastTokLine, + ce, + ); + addEntry(entries, { + screenIds: registerScreenId( + sourceFile, + searchScreenId(parents, sourceFile), + ), + comments: comment ? [comment] : [], + refs: [sourceRef(projectPrefix, sourceFile, line)], + context: "", + msgid: content, + optionalFixed: true, + }); + break; + } + // might be i18n.plural(n?, i18n[.X]`...`, i18n[.X]`...`) if (!arrayEq(path.path, ["i18n", "plural"])) { checkUnextractableCall(parents, ce, path.path, sourceFile, line); break; @@ -851,9 +930,7 @@ export class ParseError extends Error { } } -export function processFileForTesting( - ...sourceFiles: ts.SourceFile[] -): string { +export function processFileForTesting(...sourceFiles: ts.SourceFile[]): string { const entries: PoEntries = new Map(); for (const sourceFile of sourceFiles) { processFile(sourceFile, entries, "");