feat(web): keep Lingui catalogs complete and in sync

The committed catalogs were last extracted weeks ago: about 330 source
messages were missing from every locale, and production builds keep only
message ids, so those strings rendered as hash ids (sidebar sections,
privacy settings, profile, timeline, ...). German also had 226 empty
entries, and every extraction rewrote the files because references carried
line numbers.

- lingui.config.ts: po references without line numbers, and a stable sort
  that breaks Lingui's ordering ties (explicit ids, equal text) by id, so
  the output no longer depends on file-system order.
- Re-extract all catalogs with --clean (drops 25-44 obsolete entries per
  locale; no existing translation changed).
- Translate every German entry (du form, consistent with the existing
  catalog). Other locales keep empty entries and fall back to English.
- i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails
  when the committed files differ or a German entry is empty, so stale
  catalogs fail the unit test job (offline, about 5 s).

Adds @lingui/format-po (already installed through @lingui/cli) as a direct
dev dependency to configure the formatter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:24:21 +00:00
parent 8ae07cdf11
commit dc259f0491
No known key found for this signature in database
13 changed files with 22759 additions and 12358 deletions

View File

@ -1,4 +1,28 @@
import type { CatalogFormatter, CatalogType } from "@lingui/conf";
import { defineConfig } from "@lingui/conf"; import { defineConfig } from "@lingui/conf";
import { formatter } from "@lingui/format-po";
// File references without line numbers, so catalogs change only when messages change.
const po = formatter({ lineNumbers: false });
const collator = new Intl.Collator("en-US");
// Lingui orders entries by source text but leaves ties (explicit ids, equal text) in the order the
// files were found, which differs between file systems. Break ties by id so output is stable.
function sortCatalog(catalog: CatalogType): CatalogType {
return Object.fromEntries(
Object.entries(catalog).sort(
([aId, a], [bId, b]) =>
collator.compare(a.message ?? aId, b.message ?? bId) ||
collator.compare(a.context ?? "", b.context ?? "") ||
(aId < bId ? -1 : aId > bId ? 1 : 0),
),
);
}
const stablePo: CatalogFormatter = {
...po,
serialize: (catalog, ctx) => po.serialize(sortCatalog(catalog), ctx),
};
export default defineConfig({ export default defineConfig({
sourceLocale: "en", sourceLocale: "en",
@ -10,5 +34,6 @@ export default defineConfig({
exclude: ["**/locales/**", "**/*.test.*"], exclude: ["**/locales/**", "**/*.test.*"],
}, },
], ],
format: stablePo,
compileNamespace: "es", compileNamespace: "es",
}); });

View File

@ -36,6 +36,7 @@
"@lingui/babel-plugin-lingui-macro": "6.6.0", "@lingui/babel-plugin-lingui-macro": "6.6.0",
"@lingui/cli": "6.6.0", "@lingui/cli": "6.6.0",
"@lingui/conf": "6.6.0", "@lingui/conf": "6.6.0",
"@lingui/format-po": "6.6.0",
"@lingui/vite-plugin": "6.6.0", "@lingui/vite-plugin": "6.6.0",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",

View File

@ -0,0 +1,68 @@
import { cpSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AllCatalogsType } from "@lingui/cli/api";
import { getCatalogs } from "@lingui/cli/api";
import { getConfig } from "@lingui/conf";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
const webRoot = fileURLToPath(new URL("../..", import.meta.url));
const localesDir = path.join(webRoot, "src", "locales");
const config = getConfig({ cwd: webRoot, configPath: path.join(webRoot, "lingui.config.ts") });
const extractHint = "Run `pnpm --filter @rakazo/web intl:extract` and commit the catalogs.";
/** Runs `lingui extract --clean` against a copy of the committed catalogs. */
async function extractIntoCopy(dir: string): Promise<AllCatalogsType> {
cpSync(localesDir, dir, { recursive: true });
const catalogs = await getCatalogs({
...config,
catalogs: config.catalogs.map((catalog) => ({
...catalog,
path: path.join(dir, "{locale}", "messages"),
// Absolute, because the CLI resolves relative paths from apps/web and tests run from the root.
include: [path.join(webRoot, "src")],
exclude: catalog.exclude ?? [],
})),
});
expect(catalogs).toHaveLength(1);
const result = await catalogs[0]!.make({
clean: true,
overwrite: false,
orderBy: config.orderBy,
});
if (!result) throw new Error("Message extraction failed");
return result;
}
describe("lingui catalogs stay in sync with the source", () => {
let dir: string;
let extracted: AllCatalogsType;
beforeAll(async () => {
dir = mkdtempSync(path.join(tmpdir(), "evoo-locales-"));
extracted = await extractIntoCopy(dir);
}, 120_000);
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
it("matches a fresh extraction for every locale", () => {
const stale = config.locales.filter((locale) => {
const file = path.join(locale, "messages.po");
return (
readFileSync(path.join(dir, file), "utf8") !==
readFileSync(path.join(localesDir, file), "utf8")
);
});
expect(stale, extractHint).toEqual([]);
});
it("translates every German message", () => {
const untranslated = Object.entries(extracted.de ?? {})
.filter(([, entry]) => !entry.translation)
.map(([id, entry]) => entry.message ?? id);
expect(untranslated).toEqual([]);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3
pnpm-lock.yaml generated
View File

@ -319,6 +319,9 @@ importers:
'@lingui/conf': '@lingui/conf':
specifier: 6.6.0 specifier: 6.6.0
version: 6.6.0 version: 6.6.0
'@lingui/format-po':
specifier: 6.6.0
version: 6.6.0
'@lingui/vite-plugin': '@lingui/vite-plugin':
specifier: 6.6.0 specifier: 6.6.0
version: 6.6.0(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.6.0(@babel/core@7.29.7)(@babel/types@7.29.8))(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)))(esbuild@0.28.2)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)) version: 6.6.0(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.6.0(@babel/core@7.29.7)(@babel/types@7.29.8))(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)))(esbuild@0.28.2)(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0))