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:
parent
8ae07cdf11
commit
dc259f0491
@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
68
apps/web/src/lib/i18n-catalog-sync.test.ts
Normal file
68
apps/web/src/lib/i18n-catalog-sync.test.ts
Normal 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
3
pnpm-lock.yaml
generated
@ -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))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user