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:
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, "");