Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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"');
});
});

View File

@@ -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();
}