Initial Commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Tests for generate-translation-types.ts.
|
||||
*
|
||||
* Uses Bun's native test runner because this script lives outside the
|
||||
* `src/` tree of any workspace package — the repo-wide canonical-runner
|
||||
* rule (openspec/AGENTS.md §7) applies to `packages/<plugin>/<pkg>/src/`,
|
||||
* not to `packages/<plugin>/<pkg>/scripts/`. This matches the precedent
|
||||
* set by `scripts/lint/check-test-runner-imports.test.ts`.
|
||||
*
|
||||
* Real fixtures: every test writes a real en-GB.json to a real temp
|
||||
* directory and reads back the generated files via Bun.file. No mocks.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
flattenKeys,
|
||||
generateTranslationTypes,
|
||||
packageNameToSymbol,
|
||||
renderAugmentationFile,
|
||||
renderTranslationKeysFile,
|
||||
} from "./generate-translation-types.ts";
|
||||
|
||||
interface Fixture {
|
||||
readonly root: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function makeFixture(): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), "gen-translation-types-"));
|
||||
return {
|
||||
root,
|
||||
async cleanup() {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeBundle(
|
||||
root: string,
|
||||
bundle: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const dir = join(root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), JSON.stringify(bundle));
|
||||
}
|
||||
|
||||
describe("flattenKeys", () => {
|
||||
it("returns top-level dot-keys as-is when leaves are strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = flattenKeys({ "ns.key.a": "A", "ns.key.b": "B" });
|
||||
expect(out.sort()).toEqual(["ns.key.a", "ns.key.b"]);
|
||||
});
|
||||
|
||||
it("flattens nested records by dot-joining", { timeout: 5000 }, () => {
|
||||
const out = flattenKeys({
|
||||
errors: { auth: { invalid_token: "x", expired: "y" } },
|
||||
greeting: { hello: "Hi" },
|
||||
});
|
||||
expect(out.sort()).toEqual([
|
||||
"errors.auth.expired",
|
||||
"errors.auth.invalid_token",
|
||||
"greeting.hello",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses plural shapes to a single parent key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = flattenKeys({
|
||||
items: { one: "{count} item", other: "{count} items" },
|
||||
});
|
||||
expect(out).toEqual(["items"]);
|
||||
});
|
||||
|
||||
it("rejects array leaves with a typed error", { timeout: 5000 }, () => {
|
||||
expect(() => flattenKeys({ bad: ["nope"] })).toThrow(
|
||||
/Invalid leaf value at key 'bad'/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects number leaves with a typed error", { timeout: 5000 }, () => {
|
||||
expect(() => flattenKeys({ bad: 42 } as Record<string, unknown>)).toThrow(
|
||||
/Invalid leaf value at key 'bad'/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packageNameToSymbol", () => {
|
||||
it("strips the @scope/ prefix and PascalCases the slug", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(packageNameToSymbol("@suaveplan/react-ui")).toBe("ReactUi");
|
||||
expect(packageNameToSymbol("@suaveplan/react-ui-pack-auth")).toBe(
|
||||
"ReactUiPackAuth"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles names without a scope", { timeout: 5000 }, () => {
|
||||
expect(packageNameToSymbol("plain-name")).toBe("PlainName");
|
||||
});
|
||||
|
||||
it("ignores empty parts in slug splits", { timeout: 5000 }, () => {
|
||||
expect(packageNameToSymbol("@x/--double--dash--")).toBe("DoubleDash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTranslationKeysFile", () => {
|
||||
it("emits a sorted union with the package-derived symbol", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTranslationKeysFile(
|
||||
["ns.key.b", "ns.key.a"],
|
||||
"@suaveplan/react-ui"
|
||||
);
|
||||
expect(out).toContain("export type ReactUiTranslationKey =");
|
||||
const aIndex = out.indexOf('"ns.key.a"');
|
||||
const bIndex = out.indexOf('"ns.key.b"');
|
||||
expect(aIndex).toBeGreaterThan(0);
|
||||
expect(bIndex).toBeGreaterThan(aIndex);
|
||||
});
|
||||
|
||||
it("emits `never` when the key list is empty", { timeout: 5000 }, () => {
|
||||
const out = renderTranslationKeysFile([], "@suaveplan/x");
|
||||
expect(out).toContain("export type XTranslationKey = never;");
|
||||
});
|
||||
|
||||
it("falls back to TranslationKey when no package name is given", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTranslationKeysFile(["a"], undefined);
|
||||
expect(out).toContain("export type TranslationKey =");
|
||||
expect(out).toContain('| "a";');
|
||||
});
|
||||
|
||||
it("dedupes input keys", { timeout: 5000 }, () => {
|
||||
const out = renderTranslationKeysFile(["dup", "dup", "dup"], undefined);
|
||||
const matches = out.match(/"dup"/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderAugmentationFile", () => {
|
||||
it("emits the declare-module block with sorted entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderAugmentationFile(
|
||||
["b.x", "a.y"],
|
||||
"@suaveplan/react-ui"
|
||||
);
|
||||
expect(out).toContain('declare module "@suaveplan/react-i18n"');
|
||||
expect(out).toContain("interface ContributedTranslationKeys");
|
||||
expect(out).toContain('"a.y": true;');
|
||||
expect(out).toContain('"b.x": true;');
|
||||
const aIdx = out.indexOf('"a.y"');
|
||||
const bIdx = out.indexOf('"b.x"');
|
||||
expect(aIdx).toBeGreaterThan(0);
|
||||
expect(bIdx).toBeGreaterThan(aIdx);
|
||||
});
|
||||
|
||||
it("emits an empty interface body when no keys are present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderAugmentationFile([], "@suaveplan/empty");
|
||||
expect(out).toContain("interface ContributedTranslationKeys {");
|
||||
expect(out).toContain(" }");
|
||||
});
|
||||
|
||||
it("renders the unnamed-package header when packageName is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderAugmentationFile(["k"], undefined);
|
||||
expect(out).toContain("this package's translation keys");
|
||||
});
|
||||
|
||||
it("renders the unnamed-package header for translation-keys.ts too", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTranslationKeysFile(["k"], undefined);
|
||||
expect(out).toContain("this package's en-GB bundle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTranslationTypes — end to end", () => {
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
fixture = await makeFixture();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it("reads en-GB.json and writes both output files with sorted keys", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeBundle(fixture.root, {
|
||||
"ns.key.b": "B",
|
||||
"ns.key.a": "A",
|
||||
});
|
||||
const result = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
packageName: "@suaveplan/react-ui",
|
||||
});
|
||||
expect(result.translationKeys.sort()).toEqual([
|
||||
"ns.key.a",
|
||||
"ns.key.b",
|
||||
]);
|
||||
const keysSource = await readFile(
|
||||
result.translationKeysPath,
|
||||
"utf8"
|
||||
);
|
||||
expect(keysSource).toContain("export type ReactUiTranslationKey =");
|
||||
const aIndex = keysSource.indexOf('"ns.key.a"');
|
||||
const bIndex = keysSource.indexOf('"ns.key.b"');
|
||||
expect(aIndex).toBeGreaterThan(0);
|
||||
expect(bIndex).toBeGreaterThan(aIndex);
|
||||
const augSource = await readFile(result.augmentationPath, "utf8");
|
||||
expect(augSource).toContain('"ns.key.a": true;');
|
||||
expect(augSource).toContain('"ns.key.b": true;');
|
||||
expect(augSource).toContain('declare module "@suaveplan/react-i18n"');
|
||||
});
|
||||
|
||||
it("is deterministic across two runs over the same fixture", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeBundle(fixture.root, {
|
||||
errors: { auth: { invalid_token: "x", expired: "y" } },
|
||||
greeting: { hello: "Hi" },
|
||||
});
|
||||
const a = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
packageName: "@suaveplan/x",
|
||||
});
|
||||
const aKeys = await readFile(a.translationKeysPath, "utf8");
|
||||
const aAug = await readFile(a.augmentationPath, "utf8");
|
||||
const b = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
packageName: "@suaveplan/x",
|
||||
});
|
||||
const bKeys = await readFile(b.translationKeysPath, "utf8");
|
||||
const bAug = await readFile(b.augmentationPath, "utf8");
|
||||
expect(aKeys).toBe(bKeys);
|
||||
expect(aAug).toBe(bAug);
|
||||
});
|
||||
|
||||
it("works without a package name", { timeout: 5000 }, async () => {
|
||||
await writeBundle(fixture.root, { simple: "x" });
|
||||
const result = await generateTranslationTypes({
|
||||
packagePath: fixture.root,
|
||||
});
|
||||
const keysSource = await readFile(
|
||||
result.translationKeysPath,
|
||||
"utf8"
|
||||
);
|
||||
expect(keysSource).toContain("export type TranslationKey =");
|
||||
expect(keysSource).toContain('| "simple";');
|
||||
});
|
||||
|
||||
it("throws when en-GB.json is missing", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/cannot read/);
|
||||
});
|
||||
|
||||
it("throws when en-GB.json is unparsable", { timeout: 5000 }, async () => {
|
||||
const dir = join(fixture.root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), "{not-json");
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/cannot parse/);
|
||||
});
|
||||
|
||||
it("throws when en-GB.json is not a top-level object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dir = join(fixture.root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), "[]");
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/top level/);
|
||||
});
|
||||
|
||||
it("throws when bundle JSON is null", { timeout: 5000 }, async () => {
|
||||
const dir = join(fixture.root, "src/locales");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "en-GB.json"), "null");
|
||||
await expect(
|
||||
generateTranslationTypes({ packagePath: fixture.root })
|
||||
).rejects.toThrow(/top level/);
|
||||
});
|
||||
|
||||
it("respects injected readFile / writeFile dependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reads: string[] = [];
|
||||
const writes: Array<{ path: string; content: string }> = [];
|
||||
const fakeRead = async (path: import("node:fs").PathLike) => {
|
||||
reads.push(String(path));
|
||||
return JSON.stringify({ greet: "hi" });
|
||||
};
|
||||
const fakeWrite = async (
|
||||
path: import("node:fs").PathLike,
|
||||
content: string | Uint8Array
|
||||
) => {
|
||||
writes.push({
|
||||
path: String(path),
|
||||
content:
|
||||
typeof content === "string"
|
||||
? content
|
||||
: new TextDecoder().decode(content),
|
||||
});
|
||||
};
|
||||
const result = await generateTranslationTypes(
|
||||
{
|
||||
packagePath: "/virtual/pkg",
|
||||
packageName: "@suaveplan/virtual",
|
||||
},
|
||||
{
|
||||
readFileFn: fakeRead as unknown as typeof readFile,
|
||||
writeFileFn: fakeWrite as unknown as typeof writeFile,
|
||||
}
|
||||
);
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(result.translationKeys).toEqual(["greet"]);
|
||||
expect(writes[0]?.content).toContain('"greet"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* generate-translation-types.ts
|
||||
*
|
||||
* Reads a workspace package's canonical English bundle
|
||||
* (`<package-path>/src/locales/en-GB.json`) and emits two sibling
|
||||
* files:
|
||||
*
|
||||
* <package-path>/src/locales/translation-keys.ts
|
||||
* A string-literal union type of every (flattened) key in the
|
||||
* bundle, sorted alphabetically for diff stability. Useful as a
|
||||
* regular type alias for non-React consumers.
|
||||
*
|
||||
* <package-path>/src/locales/augmentation.d.ts
|
||||
* A `declare module "@suaveplan/react-i18n"` block that registers
|
||||
* every key into the `ContributedTranslationKeys` interface so the
|
||||
* branded `TranslationKey` union widens to include the package's
|
||||
* keys. Side-effect-imported from the package's `src/index.ts` so
|
||||
* downstream consumers automatically inherit the registrations.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* bun packages/react/react-i18n/scripts/generate-translation-types.ts \
|
||||
* <package-path> [--package-name <name>]
|
||||
*
|
||||
* Determinism: keys are flattened with dot-joins, deduped (the same
|
||||
* key is rare but guarded), and sorted via `localeCompare("en")` so
|
||||
* the output is byte-identical for identical input regardless of
|
||||
* input-key insertion order.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — generation succeeded
|
||||
* 1 — bundle missing or malformed (details on stderr)
|
||||
* 2 — internal error (cannot read repo, etc.)
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export interface GenerateOptions {
|
||||
/** Absolute or repo-relative path to the package directory. */
|
||||
readonly packagePath: string;
|
||||
/** Optional package name for the comment header (e.g. `@suaveplan/react-ui`). */
|
||||
readonly packageName?: string;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
readonly bundlePath: string;
|
||||
readonly translationKeysPath: string;
|
||||
readonly augmentationPath: string;
|
||||
readonly translationKeys: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively flatten a nested record of strings (or sub-records) into
|
||||
* a flat list of dot-joined keys. Non-string leaves outside a
|
||||
* recognised plural shape are treated as opaque sub-records and the
|
||||
* walker descends through them.
|
||||
*
|
||||
* Plural shapes (`{ one: '...', other: '...' }` and friends) collapse
|
||||
* to the parent key — that single key is what consumers reference via
|
||||
* `t('items', { count })` — so the leaves under `zero|one|two|few|many|other`
|
||||
* are NOT flattened into separate keys.
|
||||
*/
|
||||
const PLURAL_CATEGORIES = new Set([
|
||||
"zero",
|
||||
"one",
|
||||
"two",
|
||||
"few",
|
||||
"many",
|
||||
"other",
|
||||
]);
|
||||
|
||||
function isPluralShape(value: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 0) return false;
|
||||
return keys.every((k) => PLURAL_CATEGORIES.has(k));
|
||||
}
|
||||
|
||||
export function flattenKeys(
|
||||
bundle: Record<string, unknown>,
|
||||
prefix = ""
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(bundle)) {
|
||||
const joined = prefix === "" ? rawKey : `${prefix}.${rawKey}`;
|
||||
if (typeof rawValue === "string") {
|
||||
out.push(joined);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof rawValue === "object" &&
|
||||
rawValue !== null &&
|
||||
!Array.isArray(rawValue)
|
||||
) {
|
||||
const sub = rawValue as Record<string, unknown>;
|
||||
if (isPluralShape(sub)) {
|
||||
out.push(joined);
|
||||
continue;
|
||||
}
|
||||
out.push(...flattenKeys(sub, joined));
|
||||
continue;
|
||||
}
|
||||
// Arrays / numbers / booleans / nulls are not valid translation
|
||||
// leaves — surface a typed error rather than silently dropping.
|
||||
throw new Error(
|
||||
`Invalid leaf value at key '${joined}': expected string or nested record, got ${typeof rawValue}`
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `translation-keys.ts` payload — a sorted string-literal
|
||||
* union exported as `<PackageId>TranslationKey`. The identifier is
|
||||
* derived from the package name (e.g. `@suaveplan/react-ui` →
|
||||
* `ReactUiTranslationKey`).
|
||||
*/
|
||||
export function renderTranslationKeysFile(
|
||||
keys: readonly string[],
|
||||
packageName: string | undefined
|
||||
): string {
|
||||
const sorted = [...new Set(keys)].sort((a, b) => a.localeCompare(b, "en"));
|
||||
const symbol = packageName
|
||||
? `${packageNameToSymbol(packageName)}TranslationKey`
|
||||
: "TranslationKey";
|
||||
const lines = [
|
||||
"// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts",
|
||||
"// Do not edit by hand — re-run the codegen script after editing en-GB.json.",
|
||||
"",
|
||||
];
|
||||
if (packageName) {
|
||||
lines.push(`/** Translation keys contributed by ${packageName}. */`);
|
||||
} else {
|
||||
lines.push(
|
||||
"/** Translation keys contributed by this package's en-GB bundle. */"
|
||||
);
|
||||
}
|
||||
if (sorted.length === 0) {
|
||||
lines.push(`export type ${symbol} = never;`);
|
||||
} else {
|
||||
lines.push(`export type ${symbol} =`);
|
||||
sorted.forEach((key, i) => {
|
||||
const tail = i === sorted.length - 1 ? ";" : "";
|
||||
lines.push(` | "${key}"${tail}`);
|
||||
});
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `augmentation.d.ts` payload — a `declare module` block
|
||||
* registering every key into `ContributedTranslationKeys`.
|
||||
*/
|
||||
export function renderAugmentationFile(
|
||||
keys: readonly string[],
|
||||
packageName: string | undefined
|
||||
): string {
|
||||
const sorted = [...new Set(keys)].sort((a, b) => a.localeCompare(b, "en"));
|
||||
const lines = [
|
||||
"// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts",
|
||||
"// Do not edit by hand — re-run the codegen script after editing en-GB.json.",
|
||||
"",
|
||||
];
|
||||
if (packageName) {
|
||||
lines.push(
|
||||
`/** Module augmentation: register ${packageName}'s translation keys */`,
|
||||
"/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */"
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"/** Module augmentation: register this package's translation keys */",
|
||||
"/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */"
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
'import "@suaveplan/react-i18n";',
|
||||
"",
|
||||
'declare module "@suaveplan/react-i18n" {',
|
||||
" interface ContributedTranslationKeys {"
|
||||
);
|
||||
if (sorted.length === 0) {
|
||||
// Empty stub keeps the augmentation file syntactically valid
|
||||
// but the interface gains no members.
|
||||
} else {
|
||||
for (const key of sorted) {
|
||||
lines.push(` "${key}": true;`);
|
||||
}
|
||||
}
|
||||
lines.push(" }", "}", "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* `@suaveplan/react-ui` → `ReactUi`. Used to namespace the union
|
||||
* type identifier emitted by `renderTranslationKeysFile`.
|
||||
*/
|
||||
export function packageNameToSymbol(packageName: string): string {
|
||||
const slug = packageName.replace(/^@[^/]+\//, "");
|
||||
return slug
|
||||
.split(/[-_]/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function generateTranslationTypes(
|
||||
options: GenerateOptions,
|
||||
deps: {
|
||||
readonly readFileFn?: typeof readFile;
|
||||
readonly writeFileFn?: typeof writeFile;
|
||||
} = {}
|
||||
): Promise<GenerateResult> {
|
||||
const readFn = deps.readFileFn ?? readFile;
|
||||
const writeFn = deps.writeFileFn ?? writeFile;
|
||||
const packageRoot = resolve(options.packagePath);
|
||||
const bundlePath = resolve(packageRoot, "src/locales/en-GB.json");
|
||||
const translationKeysPath = resolve(
|
||||
packageRoot,
|
||||
"src/locales/translation-keys.ts"
|
||||
);
|
||||
const augmentationPath = resolve(
|
||||
packageRoot,
|
||||
"src/locales/augmentation.d.ts"
|
||||
);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFn(bundlePath, "utf8");
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`generate-translation-types: cannot read ${bundlePath}: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
let bundle: unknown;
|
||||
try {
|
||||
bundle = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`generate-translation-types: cannot parse ${bundlePath}: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
bundle === null ||
|
||||
typeof bundle !== "object" ||
|
||||
Array.isArray(bundle)
|
||||
) {
|
||||
throw new Error(
|
||||
`generate-translation-types: ${bundlePath} must contain a JSON object at the top level`
|
||||
);
|
||||
}
|
||||
const keys = flattenKeys(bundle as Record<string, unknown>);
|
||||
|
||||
const translationKeysSource = renderTranslationKeysFile(
|
||||
keys,
|
||||
options.packageName
|
||||
);
|
||||
const augmentationSource = renderAugmentationFile(
|
||||
keys,
|
||||
options.packageName
|
||||
);
|
||||
|
||||
await writeFn(translationKeysPath, translationKeysSource, "utf8");
|
||||
await writeFn(augmentationPath, augmentationSource, "utf8");
|
||||
|
||||
return {
|
||||
bundlePath,
|
||||
translationKeysPath,
|
||||
augmentationPath,
|
||||
translationKeys: keys,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0) {
|
||||
process.stderr.write(
|
||||
"Usage: bun packages/react/react-i18n/scripts/generate-translation-types.ts <package-path> [--package-name <name>]\n"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let packagePath = "";
|
||||
let packageName: string | undefined;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--package-name" && i + 1 < argv.length) {
|
||||
packageName = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (typeof arg === "string" && !arg.startsWith("--")) {
|
||||
packagePath = arg;
|
||||
}
|
||||
}
|
||||
if (packagePath === "") {
|
||||
process.stderr.write(
|
||||
"generate-translation-types: missing <package-path> argument\n"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const result = await generateTranslationTypes({
|
||||
packagePath,
|
||||
packageName,
|
||||
});
|
||||
process.stdout.write(
|
||||
`generate-translation-types: wrote ${result.translationKeys.length} key${result.translationKeys.length === 1 ? "" : "s"} to:\n ${result.translationKeysPath}\n ${result.augmentationPath}\n`
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (cause) {
|
||||
process.stderr.write(`${(cause as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main();
|
||||
}
|
||||
Reference in New Issue
Block a user