@pulsetechnologies/i18n 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ The shared language layer for every Pulse product (PulseWork epic **ENG-1**, thi
7
7
  - **Matching**: `matchLocale` maps real-world tags to the languages we offer: `zh-TW` → `zh-Hant`, `pt-PT` → `pt-BR`, `es-MX` → `es`, `tl` → `fil`.
8
8
  - **Formatting**: `createFormatters(locale, { timeZone })` covers numbers, percent, `money(minor, currency)` for Pulse's minor-unit amounts, dates, times, relative times and lists. Haitian Creole borrows French formats because CLDR has no data for it.
9
9
  - **i18next**: `createI18n({ resources, locale })` sets up English fallback, no catalog bleed between `zh-Hans` and `zh-Hant`, React-safe interpolation, and an optional `en-XA` pseudo-locale for catching text that was never extracted.
10
- - **Catalog check**: `missingKeys(en, target, locale)` is plural-aware per language. Arabic needs six plural forms, Japanese one, and Spanish needs `_many`. Without it, Spanish users see English for counts in the millions. ENG-4 runs it in CI.
10
+ - **Catalog check**: `missingKeys(en, target, locale)` is plural-aware per language. Arabic needs six plural forms, Japanese one, and Spanish needs `_many`. Without it, Spanish users see English for counts in the millions. The `pulse-i18n check` command below runs it in CI.
11
11
  - **React**: `<LanguagePicker persist={saveToProfile} />` is an accessible native `<select>` that shows each language in its own script. It keeps `<html lang dir>` in step, writes the cookie and calls `persist` (the ENG-3 profile save). `<LanguageSelect>` and `useLanguage()` are the lower-level pieces.
12
12
 
13
13
  ```tsx
@@ -30,6 +30,46 @@ npm test # vitest (the picker tests use jsdom)
30
30
  npm run demo # http://localhost:5480, a sample invoice in 5 languages + pseudo
31
31
  ```
32
32
 
33
+ ## Translating a product: the `pulse-i18n` CLI (ENG-4)
34
+
35
+ Each product keeps its English catalogs at `<dir>/en/<namespace>.json` and commits the other languages next to them. Add a `pulse-i18n.config.json` at the product's root:
36
+
37
+ ```json
38
+ {
39
+ "dir": "src/locales",
40
+ "locales": ["es", "fr", "zh-Hans"],
41
+ "glossary": ["PulseVoice", "PiP", "Pulse"],
42
+ "review": ["legal.", "billing:", "e911."],
43
+ "context": "PulseVoice is a business phone system for companies and their staff."
44
+ }
45
+ ```
46
+
47
+ - `locales` is the set of languages the product ships. Leave it out to ship all 18.
48
+ - `glossary` lists terms that are never translated, and checks that they come back unchanged.
49
+ - `review` lists key prefixes (`"ns:key"` or `"key"`) whose translations a person must sign off: legal, invoice and 911 text.
50
+
51
+ Commands:
52
+
53
+ | Command | What it does |
54
+ |---|---|
55
+ | `npx pulse-i18n check` | **The CI gate.** Exits 1 when, for any language, a string is missing, a translation was made from English that has since changed, a key no longer exists in English, or a `{{placeholder}}`, `$t()` or `<0>` tag, or a glossary term, was dropped or invented. Plural forms are checked per language: Arabic needs 6, Japanese 1, and Spanish, French and Portuguese need `_many`. Pending reviews show as warnings. |
56
+ | `npx pulse-i18n translate [--locale es,fr] [--batch 60]` | Fills exactly what `check` flags, using Claude Opus 5.5 (structured JSON output, `fallbacks: "default"`, a cached prompt prefix). It validates every answer, retries once naming the problem, and reports anything still wrong without writing it. Needs `ANTHROPIC_API_KEY` (or `ant auth login`) and `npm i -D @anthropic-ai/sdk`. |
57
+ | `npx pulse-i18n approve <locale> [prefix]` | Marks reviewed translations as approved. |
58
+
59
+ `translate` keeps `<dir>/.pulse-i18n-lock.json` (commit it). It records the English each translation was made from, so:
60
+
61
+ - when English changes, that string is re-translated;
62
+ - a translation someone **edited by hand** is never overwritten;
63
+ - translations that existed before the tool are adopted as they are.
64
+
65
+ A hand edit that breaks a placeholder is redone, because it would fail at runtime.
66
+
67
+ Product CI:
68
+
69
+ ```yaml
70
+ - run: npx pulse-i18n check
71
+ ```
72
+
33
73
  ## Release
34
74
 
35
75
  The package is published publicly to npm as `@pulsetechnologies/i18n`. To release:
@@ -0,0 +1,19 @@
1
+ export type Flat = Map<string, string>;
2
+ /** Namespaces English has: the files that every other language is checked against. */
3
+ export declare function namespacesOf(dir: string): string[];
4
+ export declare function readCatalog(dir: string, locale: string, ns: string): Flat;
5
+ /** Flat "a.b.c" keys back to nested JSON, in the order given. */
6
+ export declare function unflatten(entries: Iterable<[string, string]>): Record<string, unknown>;
7
+ export declare function writeCatalog(dir: string, locale: string, ns: string, entries: Iterable<[string, string]>): void;
8
+ /** Short, stable fingerprint of an English string: what a translation was made from. */
9
+ export declare const fingerprint: (s: string) => string;
10
+ export interface LockEntry {
11
+ src: string;
12
+ review?: true;
13
+ }
14
+ export interface Lock {
15
+ version: 1;
16
+ locales: Record<string, Record<string, LockEntry>>;
17
+ }
18
+ export declare function readLock(dir: string): Lock;
19
+ export declare function writeLock(dir: string, lock: Lock): void;
@@ -0,0 +1,71 @@
1
+ // Reading and writing a product's catalogs on disk, for the pulse-i18n CLI (ENG-4).
2
+ //
3
+ // Layout: <dir>/<locale>/<namespace>.json, nested JSON of strings, English (en) the source.
4
+ // src/locales/en/common.json { "nav": { "home": "Home" }, "items_one": "{{count}} item" }
5
+ // src/locales/es/common.json
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { flattenCatalog } from "../catalog.js";
10
+ import { SOURCE_LOCALE } from "../locales.js";
11
+ /** Namespaces English has: the files that every other language is checked against. */
12
+ export function namespacesOf(dir) {
13
+ const en = join(dir, SOURCE_LOCALE);
14
+ if (!existsSync(en))
15
+ throw new Error(`no English catalogs at ${en}`);
16
+ return readdirSync(en).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).sort();
17
+ }
18
+ export function readCatalog(dir, locale, ns) {
19
+ const file = join(dir, locale, `${ns}.json`);
20
+ if (!existsSync(file))
21
+ return new Map();
22
+ let json;
23
+ try {
24
+ json = JSON.parse(readFileSync(file, "utf8"));
25
+ }
26
+ catch (e) {
27
+ throw new Error(`${file} is not valid JSON: ${e.message}`);
28
+ }
29
+ return flattenCatalog(json);
30
+ }
31
+ /** Flat "a.b.c" keys back to nested JSON, in the order given. */
32
+ export function unflatten(entries) {
33
+ const out = {};
34
+ for (const [key, value] of entries) {
35
+ const parts = key.split(".");
36
+ let node = out;
37
+ for (const p of parts.slice(0, -1)) {
38
+ const next = node[p];
39
+ if (typeof next === "string")
40
+ throw new Error(`key "${key}" collides with the string at "${p}"`);
41
+ node = (node[p] ??= {});
42
+ }
43
+ node[parts.at(-1)] = value;
44
+ }
45
+ return out;
46
+ }
47
+ export function writeCatalog(dir, locale, ns, entries) {
48
+ mkdirSync(join(dir, locale), { recursive: true });
49
+ writeFileSync(join(dir, locale, `${ns}.json`), JSON.stringify(unflatten(entries), null, 2) + "\n");
50
+ }
51
+ /** Short, stable fingerprint of an English string: what a translation was made from. */
52
+ export const fingerprint = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
53
+ const LOCK_FILE = ".pulse-i18n-lock.json";
54
+ export function readLock(dir) {
55
+ const file = join(dir, LOCK_FILE);
56
+ if (!existsSync(file))
57
+ return { version: 1, locales: {} };
58
+ const lock = JSON.parse(readFileSync(file, "utf8"));
59
+ if (lock.version !== 1)
60
+ throw new Error(`${file}: unsupported lock version ${String(lock.version)}`);
61
+ return lock;
62
+ }
63
+ export function writeLock(dir, lock) {
64
+ // Sorted, so the file diffs cleanly in review.
65
+ const locales = Object.fromEntries(Object.keys(lock.locales).sort().map((l) => [
66
+ l,
67
+ Object.fromEntries(Object.keys(lock.locales[l]).sort().map((k) => [k, lock.locales[l][k]])),
68
+ ]));
69
+ writeFileSync(join(dir, LOCK_FILE), JSON.stringify({ version: 1, locales }, null, 2) + "\n");
70
+ }
71
+ //# sourceMappingURL=catalogs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalogs.js","sourceRoot":"","sources":["../../src/tools/catalogs.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import type { Translator } from "./pipeline.js";
2
+ export declare const MODEL = "claude-opus-5-5";
3
+ export declare function systemPrompt(glossary: string[], context?: string): string;
4
+ export declare function claudeTranslator(opts?: {
5
+ apiKey?: string;
6
+ }): Promise<Translator>;
@@ -0,0 +1,70 @@
1
+ export const MODEL = "claude-opus-5-5";
2
+ const INSTRUCTIONS = `You translate the user-interface text of business software from English into another language.
3
+
4
+ Each item has an "id", the "english" text, and sometimes a "note". Return one translation per item, with the same id.
5
+
6
+ Rules:
7
+ - Translate the meaning for someone using the product, the way a well-localized professional product reads in that language. Keep it as short as the English; these are buttons, labels, menus, messages and emails.
8
+ - Use the register business software uses in that language (for example, formal "Sie" in German, "vous" in French).
9
+ - Copy these exactly as they appear, untranslated: anything in {{double braces}}, $t(...) references, and tags like <0>, </0>, <b>, <br/>. Move them within the sentence if the grammar needs it.
10
+ - Leave the glossary terms exactly as written, in Latin script, even in languages with another script.
11
+ - A note about a plural form tells you which plural category the text is for; write that form naturally for the language.
12
+ - If a note says your previous answer was rejected, fix exactly what it names.
13
+ - Never add explanations, quotes or extra text to a translation.`;
14
+ const SCHEMA = {
15
+ type: "object",
16
+ properties: {
17
+ translations: {
18
+ type: "array",
19
+ items: {
20
+ type: "object",
21
+ properties: { id: { type: "string" }, text: { type: "string" } },
22
+ required: ["id", "text"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ },
27
+ required: ["translations"],
28
+ additionalProperties: false,
29
+ };
30
+ export function systemPrompt(glossary, context) {
31
+ return [
32
+ INSTRUCTIONS,
33
+ context ? `About the product: ${context}` : "",
34
+ glossary.length ? `Glossary (never translate): ${glossary.join(", ")}` : "",
35
+ ].filter(Boolean).join("\n\n");
36
+ }
37
+ export async function claudeTranslator(opts = {}) {
38
+ let Anthropic;
39
+ try {
40
+ Anthropic = (await import("@anthropic-ai/sdk")).default;
41
+ }
42
+ catch {
43
+ throw new Error("pulse-i18n translate needs @anthropic-ai/sdk: npm i -D @anthropic-ai/sdk");
44
+ }
45
+ const client = new Anthropic(opts.apiKey ? { apiKey: opts.apiKey } : {});
46
+ return async (req) => {
47
+ const stream = client.beta.messages.stream({
48
+ model: MODEL,
49
+ max_tokens: 64000,
50
+ betas: ["server-side-fallback-2026-07-01"],
51
+ fallbacks: "default",
52
+ cache_control: { type: "ephemeral" },
53
+ output_config: { effort: "medium", format: { type: "json_schema", schema: SCHEMA } },
54
+ system: systemPrompt(req.glossary, req.context),
55
+ messages: [{
56
+ role: "user",
57
+ content: `Translate into ${req.language} (${req.locale}).\n\n${JSON.stringify(req.items, null, 1)}`,
58
+ }],
59
+ });
60
+ const msg = await stream.finalMessage();
61
+ if (msg.stop_reason === "refusal")
62
+ throw new Error(`the model declined to translate this batch into ${req.locale}`);
63
+ if (msg.stop_reason === "max_tokens")
64
+ throw new Error(`the answer for ${req.locale} was cut off; use a smaller --batch`);
65
+ const text = msg.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
66
+ const parsed = JSON.parse(text);
67
+ return new Map(parsed.translations.map((t) => [t.id, t.text]));
68
+ };
69
+ }
70
+ //# sourceMappingURL=claude.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude.js","sourceRoot":"","sources":["../../src/tools/claude.ts"],"names":[],"mappings":"AAUA,MAAM,CAAC,MAAM,KAAK,GAAG,iBAAiB,CAAC;AAEvC,MAAM,YAAY,GAAG;;;;;;;;;;;iEAW4C,CAAC;AAElE,MAAM,MAAM,GAAG;IACb,IAAI,EAAE,QAAQ;IACd,UAAU,EAAE;QACV,YAAY,EAAE;YACZ,IAAI,EAAE,OAAO;YACb,KAAK,EAAE;gBACL,IAAI,EAAE,QAAQ;gBACd,UAAU,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE;gBAChE,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC;gBACxB,oBAAoB,EAAE,KAAK;aAC5B;SACF;KACF;IACD,QAAQ,EAAE,CAAC,cAAc,CAAC;IAC1B,oBAAoB,EAAE,KAAK;CACnB,CAAC;AAEX,MAAM,UAAU,YAAY,CAAC,QAAkB,EAAE,OAAgB;IAC/D,OAAO;QACL,YAAY;QACZ,OAAO,CAAC,CAAC,CAAC,sBAAsB,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE;QAC9C,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,+BAA+B,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE;KAC5E,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACjC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,OAA4B,EAAE;IACnE,IAAI,SAAqD,CAAC;IAC1D,IAAI,CAAC;QACH,SAAS,GAAG,CAAC,MAAM,MAAM,CAAC,mBAAmB,CAAC,CAAC,CAAC,OAAO,CAAC;IAC1D,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,0EAA0E,CAAC,CAAC;IAC9F,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAEzE,OAAO,KAAK,EAAE,GAAqB,EAAE,EAAE;QACrC,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;YACzC,KAAK,EAAE,KAAK;YACZ,UAAU,EAAE,KAAK;YACjB,KAAK,EAAE,CAAC,iCAAiC,CAAC;YAC1C,SAAS,EAAE,SAAS;YACpB,aAAa,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE;YACpC,aAAa,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,EAAE,MAA4C,EAAE,EAAE;YAC1H,MAAM,EAAE,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,OAAO,CAAC;YAC/C,QAAQ,EAAE,CAAC;oBACT,IAAI,EAAE,MAAM;oBACZ,OAAO,EAAE,kBAAkB,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,MAAM,SAAS,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE;iBACpG,CAAC;SACH,CAAC,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,YAAY,EAAE,CAAC;QACxC,IAAI,GAAG,CAAC,WAAW,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QACpH,IAAI,GAAG,CAAC,WAAW,KAAK,YAAY;YAAE,MAAM,IAAI,KAAK,CAAC,kBAAkB,GAAG,CAAC,MAAM,qCAAqC,CAAC,CAAC;QACzH,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACtF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAqD,CAAC;QACpF,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACjE,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { type NamespaceReport, type PipelineConfig } from "./pipeline.js";
3
+ export declare function loadConfig(path: string): PipelineConfig;
4
+ export declare function formatReport(reports: NamespaceReport[]): string[];
5
+ export declare function main(argv: string[]): Promise<number>;
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // pulse-i18n: check and translate a product's catalogs (ENG-4).
3
+ //
4
+ // pulse-i18n check CI gate; exits 1 when anything is missing, outdated or broken
5
+ // pulse-i18n translate [--locale es,fr] [--batch 60]
6
+ // fill what check flags, with Claude (needs ANTHROPIC_API_KEY)
7
+ // pulse-i18n approve <locale> [prefix]
8
+ // mark translations a person has reviewed
9
+ //
10
+ // Settings come from pulse-i18n.config.json in the working directory (or --config <file>):
11
+ // { "dir": "src/locales", "locales": ["es", "fr"], "glossary": ["PulseVoice"],
12
+ // "review": ["legal:", "billing."], "context": "PulseVoice is a business phone system." }
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+ import { isLocaleCode } from "../locales.js";
16
+ import { claudeTranslator } from "./claude.js";
17
+ import { approve, check, translate } from "./pipeline.js";
18
+ function flag(args, name) {
19
+ const i = args.indexOf(`--${name}`);
20
+ return i >= 0 ? args[i + 1] : undefined;
21
+ }
22
+ export function loadConfig(path) {
23
+ const raw = JSON.parse(readFileSync(path, "utf8"));
24
+ if (!raw.dir)
25
+ throw new Error(`${path}: "dir" is required`);
26
+ for (const l of raw.locales ?? [])
27
+ if (!isLocaleCode(l))
28
+ throw new Error(`${path}: "${l}" is not a language Pulse offers`);
29
+ return { ...raw, dir: resolve(dirname(path), raw.dir) };
30
+ }
31
+ export function formatReport(reports) {
32
+ const lines = [];
33
+ for (const r of reports) {
34
+ const where = `${r.locale}/${r.ns}`;
35
+ const list = (label, keys) => {
36
+ if (keys.length)
37
+ lines.push(`${where}: ${keys.length} ${label}: ${keys.slice(0, 8).join(", ")}${keys.length > 8 ? ", …" : ""}`);
38
+ };
39
+ list("missing", r.missing);
40
+ list("outdated (English changed)", r.outdated);
41
+ list("no longer in English", r.stale);
42
+ for (const b of r.broken)
43
+ lines.push(`${where}: ${b.key}: ${b.problems.join("; ")}`);
44
+ if (r.pendingReview.length)
45
+ lines.push(`${where}: ${r.pendingReview.length} awaiting review (warning)`);
46
+ }
47
+ return lines;
48
+ }
49
+ export async function main(argv) {
50
+ const [cmd, ...args] = argv;
51
+ const cfg = loadConfig(resolve(flag(args, "config") ?? "pulse-i18n.config.json"));
52
+ if (cmd === "check") {
53
+ const { ok, reports } = check(cfg);
54
+ for (const line of formatReport(reports))
55
+ console.log(line);
56
+ console.log(ok ? "pulse-i18n: all translations complete" : "pulse-i18n: translations need work (run pulse-i18n translate)");
57
+ return ok ? 0 : 1;
58
+ }
59
+ if (cmd === "translate") {
60
+ const only = flag(args, "locale")?.split(",").map((s) => s.trim());
61
+ for (const l of only ?? [])
62
+ if (!isLocaleCode(l))
63
+ throw new Error(`"${l}" is not a language Pulse offers`);
64
+ const batch = Number(flag(args, "batch") ?? 60);
65
+ const r = await translate(cfg, await claudeTranslator(), { batchSize: batch, only: only, log: (l) => console.log(l) });
66
+ console.log(`translated ${r.translated}, adopted ${r.adopted} existing, removed ${r.removed} stale, ${r.flaggedForReview} flagged for review`);
67
+ for (const f of r.failed)
68
+ console.error(`FAILED ${f.locale}/${f.ns} ${f.key}: ${f.problems.join("; ")}`);
69
+ return r.failed.length ? 1 : 0;
70
+ }
71
+ if (cmd === "approve") {
72
+ const [locale, prefix] = args.filter((a) => !a.startsWith("--") && a !== flag(args, "config"));
73
+ if (!isLocaleCode(locale))
74
+ throw new Error("usage: pulse-i18n approve <locale> [prefix]");
75
+ console.log(`approved ${approve(cfg, locale, prefix ?? "")} in ${locale}`);
76
+ return 0;
77
+ }
78
+ console.error("usage: pulse-i18n check | translate [--locale es,fr] [--batch 60] | approve <locale> [prefix] [--config file]");
79
+ return 2;
80
+ }
81
+ // Run when executed directly (the bin), not when imported by tests.
82
+ if (process.argv[1] && /cli\.(js|ts)$/.test(process.argv[1])) {
83
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
84
+ const hint = /authentication method/i.test(e.message) ? " (set ANTHROPIC_API_KEY, or sign in with `ant auth login`)" : "";
85
+ console.error(`pulse-i18n: ${e.message}${hint}`);
86
+ process.exit(2);
87
+ });
88
+ }
89
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/tools/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export * from "./catalogs.js";
2
+ export * from "./placeholders.js";
3
+ export * from "./pipeline.js";
4
+ export { claudeTranslator, systemPrompt, MODEL } from "./claude.js";
@@ -0,0 +1,5 @@
1
+ export * from "./catalogs.js";
2
+ export * from "./placeholders.js";
3
+ export * from "./pipeline.js";
4
+ export { claudeTranslator, systemPrompt, MODEL } from "./claude.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/tools/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,77 @@
1
+ import { type LocaleCode } from "../locales.js";
2
+ import { type Flat } from "./catalogs.js";
3
+ export interface PipelineConfig {
4
+ /** Directory holding <locale>/<namespace>.json. */
5
+ dir: string;
6
+ /** Languages this product ships; defaults to every language Pulse offers. */
7
+ locales?: LocaleCode[];
8
+ /** Terms never translated: product names and the like ("PulseVoice", "PiP"). */
9
+ glossary?: string[];
10
+ /** Keys needing a person's review once translated: prefixes of "ns:key" or of the key. */
11
+ review?: string[];
12
+ /** A sentence or two about the product, so word choices fit it. */
13
+ context?: string;
14
+ }
15
+ /** One target key and the English it's translated from. */
16
+ interface Slot {
17
+ key: string;
18
+ english: string;
19
+ plural: boolean;
20
+ }
21
+ /** Every key a language needs for one namespace, in English's order, with its English source. */
22
+ export declare function slotsFor(en: Flat, locale: string): Slot[];
23
+ export declare const needsReview: (cfg: PipelineConfig, ns: string, key: string) => boolean;
24
+ /** Glossary terms the English uses that the translation lost. */
25
+ export declare function glossaryProblems(english: string, translation: string, glossary?: string[]): string[];
26
+ export interface NamespaceReport {
27
+ locale: LocaleCode;
28
+ ns: string;
29
+ missing: string[];
30
+ outdated: string[];
31
+ stale: string[];
32
+ broken: {
33
+ key: string;
34
+ problems: string[];
35
+ }[];
36
+ pendingReview: string[];
37
+ }
38
+ export declare const targetLocales: (cfg: PipelineConfig) => LocaleCode[];
39
+ /** The CI gate. `ok` is false when anything but pending reviews is found. */
40
+ export declare function check(cfg: PipelineConfig): {
41
+ ok: boolean;
42
+ reports: NamespaceReport[];
43
+ };
44
+ export interface TranslateItem {
45
+ id: string;
46
+ english: string;
47
+ note?: string;
48
+ }
49
+ export interface TranslateRequest {
50
+ locale: LocaleCode;
51
+ language: string;
52
+ items: TranslateItem[];
53
+ glossary: string[];
54
+ context?: string;
55
+ }
56
+ /** Turns English items into the target language, keyed by item id. */
57
+ export type Translator = (req: TranslateRequest) => Promise<Map<string, string>>;
58
+ export interface TranslateResult {
59
+ translated: number;
60
+ adopted: number;
61
+ removed: number;
62
+ failed: {
63
+ locale: LocaleCode;
64
+ ns: string;
65
+ key: string;
66
+ problems: string[];
67
+ }[];
68
+ flaggedForReview: number;
69
+ }
70
+ export declare function translate(cfg: PipelineConfig, translator: Translator, opts?: {
71
+ batchSize?: number;
72
+ only?: LocaleCode[];
73
+ log?: (line: string) => void;
74
+ }): Promise<TranslateResult>;
75
+ /** Mark translations as reviewed by a person: every pending one in a locale, or those matching a prefix. */
76
+ export declare function approve(cfg: PipelineConfig, locale: LocaleCode, prefix?: string): number;
77
+ export {};
@@ -0,0 +1,190 @@
1
+ // The translation pipeline behind `pulse-i18n check` and `pulse-i18n translate` (ENG-4).
2
+ //
3
+ // check : the CI gate. Per language and namespace: strings still missing, translations made
4
+ // from an English string that has since changed, keys English no longer has, broken
5
+ // placeholders, and (as a warning) translations still awaiting a person's review.
6
+ // translate : fills exactly what check would flag (missing or outdated), through a Translator
7
+ // (Claude in production, a fake in tests), validates every answer, and records what
8
+ // each translation was made from in the lock file. Hand edits are never overwritten.
9
+ import { pluralCategoriesOf } from "../catalog.js";
10
+ import { LOCALE_CODES, localeInfo, SOURCE_LOCALE } from "../locales.js";
11
+ import { fingerprint, namespacesOf, readCatalog, readLock, writeCatalog, writeLock } from "./catalogs.js";
12
+ import { placeholderProblems } from "./placeholders.js";
13
+ const PLURAL = /_(zero|one|two|few|many|other)$/;
14
+ /** Every key a language needs for one namespace, in English's order, with its English source. */
15
+ export function slotsFor(en, locale) {
16
+ const cats = pluralCategoriesOf(locale);
17
+ const out = [];
18
+ const seen = new Set();
19
+ for (const key of en.keys()) {
20
+ const m = PLURAL.exec(key);
21
+ if (!m) {
22
+ out.push({ key, english: en.get(key), plural: false });
23
+ continue;
24
+ }
25
+ const base = key.slice(0, m.index);
26
+ if (seen.has(base))
27
+ continue;
28
+ seen.add(base);
29
+ for (const cat of cats) {
30
+ // A form English has (one, other, an optional zero) is its own source; the rest read from other.
31
+ const english = en.get(`${base}_${cat}`) ?? en.get(`${base}_other`) ?? en.get(key);
32
+ out.push({ key: `${base}_${cat}`, english, plural: true });
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ const isOptionalZero = (key, en) => key.endsWith("_zero") && [...en.keys()].some((k) => k.startsWith(key.slice(0, -5) + "_"));
38
+ export const needsReview = (cfg, ns, key) => (cfg.review ?? []).some((p) => `${ns}:${key}`.startsWith(p) || key.startsWith(p));
39
+ /** Glossary terms the English uses that the translation lost. */
40
+ export function glossaryProblems(english, translation, glossary = []) {
41
+ return glossary.filter((t) => english.includes(t) && !translation.includes(t)).map((t) => `"${t}" must stay as is`);
42
+ }
43
+ export const targetLocales = (cfg) => (cfg.locales ?? LOCALE_CODES).filter((l) => l !== SOURCE_LOCALE);
44
+ function inspect(cfg, lock, locale, ns, en) {
45
+ const tgt = readCatalog(cfg.dir, locale, ns);
46
+ const entries = lock.locales[locale] ?? {};
47
+ const slots = slotsFor(en, locale);
48
+ const report = { locale, ns, missing: [], outdated: [], stale: [], broken: [], pendingReview: [] };
49
+ for (const s of slots) {
50
+ const text = tgt.get(s.key);
51
+ const entry = entries[`${ns}:${s.key}`];
52
+ if (!text?.trim()) {
53
+ report.missing.push(s.key);
54
+ continue;
55
+ }
56
+ if (entry && entry.src !== fingerprint(s.english))
57
+ report.outdated.push(s.key);
58
+ const problems = [...placeholderProblems(s.english, text, { plural: s.plural }), ...glossaryProblems(s.english, text, cfg.glossary)];
59
+ if (problems.length)
60
+ report.broken.push({ key: s.key, problems });
61
+ if (entry?.review)
62
+ report.pendingReview.push(s.key);
63
+ }
64
+ const wanted = new Set(slots.map((s) => s.key));
65
+ for (const key of tgt.keys())
66
+ if (!wanted.has(key) && !isOptionalZero(key, en))
67
+ report.stale.push(key);
68
+ return { report, slots, tgt };
69
+ }
70
+ /** The CI gate. `ok` is false when anything but pending reviews is found. */
71
+ export function check(cfg) {
72
+ const lock = readLock(cfg.dir);
73
+ const reports = [];
74
+ for (const ns of namespacesOf(cfg.dir)) {
75
+ const en = readCatalog(cfg.dir, SOURCE_LOCALE, ns);
76
+ for (const locale of targetLocales(cfg))
77
+ reports.push(inspect(cfg, lock, locale, ns, en).report);
78
+ }
79
+ const ok = reports.every((r) => !r.missing.length && !r.outdated.length && !r.stale.length && !r.broken.length);
80
+ return { ok, reports };
81
+ }
82
+ const chunk = (xs, n) => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n));
83
+ export async function translate(cfg, translator, opts = {}) {
84
+ const lock = readLock(cfg.dir);
85
+ const log = opts.log ?? (() => { });
86
+ const result = { translated: 0, adopted: 0, removed: 0, failed: [], flaggedForReview: 0 };
87
+ const locales = targetLocales(cfg).filter((l) => !opts.only || opts.only.includes(l));
88
+ for (const ns of namespacesOf(cfg.dir)) {
89
+ const en = readCatalog(cfg.dir, SOURCE_LOCALE, ns);
90
+ for (const locale of locales) {
91
+ const { report, slots, tgt } = inspect(cfg, lock, locale, ns, en);
92
+ const entries = (lock.locales[locale] ??= {});
93
+ // Broken placeholders fail at runtime whoever wrote them, so those are redone too.
94
+ const todo = new Set([...report.missing, ...report.outdated, ...report.broken.map((b) => b.key)]);
95
+ const bySlot = new Map(slots.map((s) => [s.key, s]));
96
+ const fresh = new Map();
97
+ const run = async (keys, hint) => {
98
+ for (const batch of chunk(keys, opts.batchSize ?? 60)) {
99
+ const items = batch.map((key) => {
100
+ const s = bySlot.get(key);
101
+ const notes = [];
102
+ if (s.plural)
103
+ notes.push(`plural form "${key.replace(/^.*_/, "")}": used when the count falls in that ${localeInfo(locale).english} plural category`);
104
+ if (hint?.get(key))
105
+ notes.push(`your previous answer was rejected: ${hint.get(key).join("; ")}`);
106
+ return { id: key, english: s.english, ...(notes.length ? { note: notes.join(". ") } : {}) };
107
+ });
108
+ const out = await translator({ locale, language: localeInfo(locale).english, items, glossary: cfg.glossary ?? [], context: cfg.context });
109
+ for (const key of batch) {
110
+ const text = out.get(key);
111
+ if (text !== undefined)
112
+ fresh.set(key, text);
113
+ }
114
+ }
115
+ };
116
+ const problemsOf = (key) => {
117
+ const s = bySlot.get(key);
118
+ const text = fresh.get(key);
119
+ if (!text?.trim())
120
+ return ["no translation returned"];
121
+ return [...placeholderProblems(s.english, text, { plural: s.plural }), ...glossaryProblems(s.english, text, cfg.glossary)];
122
+ };
123
+ if (todo.size) {
124
+ log(`${locale}/${ns}: translating ${todo.size}`);
125
+ await run([...todo]);
126
+ // One retry for anything that came back wrong, telling the model what was wrong.
127
+ const bad = new Map([...todo].map((k) => [k, problemsOf(k)]).filter(([, p]) => p.length));
128
+ if (bad.size) {
129
+ for (const k of bad.keys())
130
+ fresh.delete(k);
131
+ await run([...bad.keys()], bad);
132
+ }
133
+ }
134
+ const next = [];
135
+ for (const s of slots) {
136
+ const lockKey = `${ns}:${s.key}`;
137
+ if (todo.has(s.key)) {
138
+ const problems = problemsOf(s.key);
139
+ if (problems.length) {
140
+ result.failed.push({ locale, ns, key: s.key, problems });
141
+ // Keep an outdated translation rather than nothing; its lock entry still says outdated.
142
+ const old = tgt.get(s.key);
143
+ if (old?.trim())
144
+ next.push([s.key, old]);
145
+ continue;
146
+ }
147
+ next.push([s.key, fresh.get(s.key)]);
148
+ const review = needsReview(cfg, ns, s.key);
149
+ entries[lockKey] = { src: fingerprint(s.english), ...(review ? { review: true } : {}) };
150
+ result.translated++;
151
+ if (review)
152
+ result.flaggedForReview++;
153
+ }
154
+ else {
155
+ next.push([s.key, tgt.get(s.key)]);
156
+ // A translation nobody recorded (written by hand, or before this tool) is adopted as is.
157
+ if (!entries[lockKey]) {
158
+ entries[lockKey] = { src: fingerprint(s.english) };
159
+ result.adopted++;
160
+ }
161
+ }
162
+ }
163
+ // Optional "_zero" forms a translator added by hand are kept.
164
+ for (const key of tgt.keys())
165
+ if (!bySlot.has(key) && isOptionalZero(key, en))
166
+ next.push([key, tgt.get(key)]);
167
+ result.removed += report.stale.length;
168
+ for (const key of report.stale)
169
+ delete entries[`${ns}:${key}`];
170
+ if (todo.size || report.stale.length)
171
+ writeCatalog(cfg.dir, locale, ns, next);
172
+ writeLock(cfg.dir, lock);
173
+ }
174
+ }
175
+ return result;
176
+ }
177
+ /** Mark translations as reviewed by a person: every pending one in a locale, or those matching a prefix. */
178
+ export function approve(cfg, locale, prefix = "") {
179
+ const lock = readLock(cfg.dir);
180
+ let n = 0;
181
+ for (const [k, e] of Object.entries(lock.locales[locale] ?? {})) {
182
+ if (e.review && (k.startsWith(prefix) || k.split(":")[1].startsWith(prefix))) {
183
+ delete e.review;
184
+ n++;
185
+ }
186
+ }
187
+ writeLock(cfg.dir, lock);
188
+ return n;
189
+ }
190
+ //# sourceMappingURL=pipeline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pipeline.js","sourceRoot":"","sources":["../../src/tools/pipeline.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ /** Each token as a comparable string, with how many times it appears. */
2
+ export declare function placeholdersOf(text: string): Map<string, number>;
3
+ /**
4
+ * What's wrong with a translation's placeholders, compared with its English source, or [] when
5
+ * nothing is. A plural form may leave out {{count}} ("one item" rather than "1 item"); everything
6
+ * else must appear exactly as often as in English.
7
+ */
8
+ export declare function placeholderProblems(source: string, translation: string, opts?: {
9
+ plural?: boolean;
10
+ }): string[];
@@ -0,0 +1,36 @@
1
+ // The parts of a string a translation must carry over untouched: i18next interpolations
2
+ // ({{name}}, {{count, number}}), nesting ($t(other.key)) and the numbered or named tags <Trans>
3
+ // renders (<0>…</0>, <b>…</b>, <br/>). A translation that drops or invents one breaks at runtime.
4
+ const TOKEN = /\{\{\s*([^}\s,]+)[^}]*\}\}|\$t\(([^)]*)\)|<\/?([a-zA-Z0-9]+)\s*\/?>/g;
5
+ /** Each token as a comparable string, with how many times it appears. */
6
+ export function placeholdersOf(text) {
7
+ const out = new Map();
8
+ for (const m of text.matchAll(TOKEN)) {
9
+ const id = m[1] !== undefined ? `{{${m[1]}}}` : m[2] !== undefined ? `$t(${m[2].trim()})` : `<${m[0].startsWith("</") ? "/" : ""}${m[3]}>`;
10
+ out.set(id, (out.get(id) ?? 0) + 1);
11
+ }
12
+ return out;
13
+ }
14
+ /**
15
+ * What's wrong with a translation's placeholders, compared with its English source, or [] when
16
+ * nothing is. A plural form may leave out {{count}} ("one item" rather than "1 item"); everything
17
+ * else must appear exactly as often as in English.
18
+ */
19
+ export function placeholderProblems(source, translation, opts = {}) {
20
+ const want = placeholdersOf(source);
21
+ const got = placeholdersOf(translation);
22
+ const problems = [];
23
+ for (const [id, n] of want) {
24
+ const have = got.get(id) ?? 0;
25
+ if (have === n)
26
+ continue;
27
+ if (opts.plural && id === "{{count}}" && have <= n)
28
+ continue;
29
+ problems.push(have === 0 ? `missing ${id}` : `${id} appears ${have}× (English has ${n})`);
30
+ }
31
+ for (const id of got.keys())
32
+ if (!want.has(id))
33
+ problems.push(`unexpected ${id}`);
34
+ return problems;
35
+ }
36
+ //# sourceMappingURL=placeholders.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"placeholders.js","sourceRoot":"","sources":["../../src/tools/placeholders.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,gGAAgG;AAChG,kGAAkG;AAElG,MAAM,KAAK,GAAG,sEAAsE,CAAC;AAErF,yEAAyE;AACzE,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,MAAM,GAAG,GAAG,IAAI,GAAG,EAAkB,CAAC;IACtC,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;QAC3I,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACtC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc,EAAE,WAAmB,EAAE,OAA6B,EAAE;IACtG,MAAM,IAAI,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACpC,MAAM,GAAG,GAAG,cAAc,CAAC,WAAW,CAAC,CAAC;IACxC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;QAC3B,MAAM,IAAI,GAAG,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;QAC9B,IAAI,IAAI,KAAK,CAAC;YAAE,SAAS;QACzB,IAAI,IAAI,CAAC,MAAM,IAAI,EAAE,KAAK,WAAW,IAAI,IAAI,IAAI,CAAC;YAAE,SAAS;QAC7D,QAAQ,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,YAAY,IAAI,kBAAkB,CAAC,GAAG,CAAC,CAAC;IAC5F,CAAC;IACD,KAAK,MAAM,EAAE,IAAI,GAAG,CAAC,IAAI,EAAE;QAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC;IAClF,OAAO,QAAQ,CAAC;AAClB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsetechnologies/i18n",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Shared language list, detection, formatting and language picker for every Pulse product.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -26,6 +26,10 @@
26
26
  "./react": {
27
27
  "types": "./dist/react/index.d.ts",
28
28
  "import": "./dist/react/index.js"
29
+ },
30
+ "./tools": {
31
+ "types": "./dist/tools/index.d.ts",
32
+ "import": "./dist/tools/index.js"
29
33
  }
30
34
  },
31
35
  "files": [
@@ -44,7 +48,8 @@
44
48
  "peerDependencies": {
45
49
  "i18next": ">=25",
46
50
  "react": ">=18",
47
- "react-i18next": ">=15"
51
+ "react-i18next": ">=15",
52
+ "@anthropic-ai/sdk": ">=0.131"
48
53
  },
49
54
  "peerDependenciesMeta": {
50
55
  "react": {
@@ -52,10 +57,15 @@
52
57
  },
53
58
  "react-i18next": {
54
59
  "optional": true
60
+ },
61
+ "@anthropic-ai/sdk": {
62
+ "optional": true
55
63
  }
56
64
  },
57
65
  "devDependencies": {
66
+ "@anthropic-ai/sdk": "^0.131.0",
58
67
  "@testing-library/react": "^16.3.3",
68
+ "@types/node": "^24.19.0",
59
69
  "@types/react": "^19.3.0",
60
70
  "@types/react-dom": "^19.3.0",
61
71
  "@vitejs/plugin-react": "^6.1.1",
@@ -67,5 +77,8 @@
67
77
  "typescript": "^5.9.3",
68
78
  "vite": "^8.3.1",
69
79
  "vitest": "^5.0.3"
80
+ },
81
+ "bin": {
82
+ "pulse-i18n": "dist/tools/cli.js"
70
83
  }
71
84
  }