Merged in feat/lokalise-rebuild (pull request #2993)

Feat/lokalise rebuild

* chore(lokalise): update translation ids

* chore(lokalise): easier to switch between projects

* chore(lokalise): update translation ids

* .

* .

* .

* .

* .

* .

* chore(lokalise): update translation ids

* chore(lokalise): update translation ids

* .

* .

* .

* chore(lokalise): update translation ids

* chore(lokalise): update translation ids

* .

* .

* chore(lokalise): update translation ids

* chore(lokalise): update translation ids

* chore(lokalise): new translations

* merge

* switch to errors for missing id's

* merge

* sync translations


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-10-22 11:00:03 +00:00
parent bdfe2ab213
commit aafad9781f
499 changed files with 93363 additions and 99164 deletions

View File

@@ -0,0 +1,79 @@
import fs from "fs";
import * as glob from "glob";
import { syncFile } from "./syncFile";
async function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.log(`
Usage:
bun index.ts <path/to/en.json> glob-pattern [--dry-run]
Examples:
# Create mapping and update files in the same directory
bun index.ts dictionaries/en.json 'apps/**/*.{ts,tsx}'
# Dry run - show what would be changed without writing to files
bun index.ts dictionaries/en.json apps/**/*.{ts,tsx} --dry-run
`);
process.exit(1);
}
// Load your messages from the JSON file
const englishTranslations = process.argv[2] || "./locales/en.json";
const translations = flattenTranslations(
JSON.parse(fs.readFileSync(englishTranslations, "utf-8")) as Record<
string,
string | { translation: string }
>
);
const isDryRun = args.includes("--dry-run");
const globPattern = args[1];
// Find all component files
const componentFiles = glob.sync(globPattern);
let filesUpdated = 0;
for (const filePath of componentFiles) {
if (isDryRun) {
console.log(`(dry run) Would sync file: ${filePath}`);
continue;
}
const { updated } = syncFile({ path: filePath, translations });
if (updated) {
filesUpdated++;
console.log(`Updated: ${filePath}`);
}
}
console.log(`\n✓ Sync complete! Updated ${filesUpdated} file(s)`);
}
function flattenTranslations(
translations: Record<string, string | { translation: string }>
): Record<string, string> {
const flat = Object.entries(translations).reduce(
(acc, [key, val]) => {
if (typeof val === "string") {
acc[key] = val;
} else if (
val &&
typeof val === "object" &&
"translation" in val &&
typeof val.translation === "string"
) {
acc[key] = val.translation;
}
return acc;
},
{} as Record<string, string>
);
return flat;
}
// Run CLI if this file is executed directly
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,92 @@
import { vi, it, expect, beforeEach, afterEach, describe } from "vitest";
describe("syncFile", () => {
beforeEach(() => {
vi.resetModules();
vi.mock("fs", () => {
const existsMock = vi.fn();
const readMock = vi.fn();
const writeMock = vi.fn();
return {
existsSync: existsMock,
readFileSync: readMock,
writeFileSync: writeMock,
default: {
existsSync: existsMock,
readFileSync: readMock,
writeFileSync: writeMock,
},
};
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("throws if file does not exist", async () => {
const fsMock = (await import("fs")) as any;
fsMock.existsSync.mockReturnValue(false);
const { syncFile } = await import("./syncFile");
expect(() =>
syncFile({ path: "missing.ts", translations: {} })
).toThrow("File not found: missing.ts");
expect(fsMock.readFileSync).not.toHaveBeenCalled();
expect(fsMock.writeFileSync).not.toHaveBeenCalled();
});
it("reads file, calls syncIntlFormatMessage, writes updated content and returns it", async () => {
const fsMock = (await import("fs")) as any;
fsMock.existsSync.mockReturnValue(true);
fsMock.readFileSync.mockReturnValue(
createMockComponent("myKey", "old message")
);
const { syncFile } = await import("./syncFile");
const { fileContent: result } = syncFile({
path: "file.ts",
translations: { myKey: "new message" },
});
expect(fsMock.readFileSync).toHaveBeenCalledWith("file.ts", "utf-8");
expect(fsMock.writeFileSync).toHaveBeenCalled();
expect(result).toEqual(createMockComponent("myKey", "new message"));
});
it("reads file, calls syncIntlFormatMessage, ignores content if there are no matching keys, writes updated content and returns it", async () => {
const fsMock = (await import("fs")) as any;
fsMock.existsSync.mockReturnValue(true);
fsMock.readFileSync.mockReturnValue(
createMockComponent("myKey", "old message")
);
const { syncFile } = await import("./syncFile");
const { fileContent: result } = syncFile({
path: "file.ts",
translations: { someOtherKey: "not present" },
});
expect(fsMock.readFileSync).toHaveBeenCalledWith("file.ts", "utf-8");
expect(fsMock.writeFileSync).toHaveBeenCalled();
expect(result).toEqual(createMockComponent("myKey", "old message"));
});
});
function createMockComponent(translationId: string, defaultMessage: string) {
return `export function TestComponent() {
const { intl } = useIntl();
const message = intl.formatMessage({
id: "${translationId}",
defaultMessage: "${defaultMessage}",
});
return <div>{message}</div>;
}`;
}

View File

@@ -0,0 +1,25 @@
import fs from "fs";
import { syncIntlFormatMessage } from "./syncIntlFormatMessage";
export function syncFile({
path,
translations,
}: {
path: string;
translations: Record<string, string>;
}) {
if (!fs.existsSync(path)) {
throw new Error(`File not found: ${path}`);
}
const content = fs.readFileSync(path, "utf-8");
const { fileContent, updated } = syncIntlFormatMessage({
translations,
fileContent: content,
});
if (updated) {
fs.writeFileSync(path, fileContent, "utf-8");
}
return { updated, fileContent };
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { syncFormattedMessage } from "./syncFormattedMessage";
describe("syncFormattedMessage", () => {
it("updated false when given empty file and no translations", () => {
expect(
syncFormattedMessage({ fileContent: "", translations: {} })
).toEqual({ updated: false });
});
it("updates <FormattedMessage> components", () => {
expect(
syncFormattedMessage({
fileContent:
'<FormattedMessage id="myKey" defaultMessage="old message" />',
translations: { myKey: "new message" },
})
).toEqual({
updated: true,
fileContent:
'<FormattedMessage id="myKey" defaultMessage="new message" />',
});
});
it("updates multiline <FormattedMessage>", () => {
expect(
syncFormattedMessage({
fileContent: `<FormattedMessage\n\tid="myKey"\n\tdefaultMessage="old message" />`,
translations: { myKey: "new message" },
})
).toEqual({
updated: true,
fileContent: `<FormattedMessage\n\tid="myKey"\n\tdefaultMessage="new message" />`,
});
});
it("updates multiple <FormattedMessage> components", () => {
expect(
syncFormattedMessage({
fileContent:
'<FormattedMessage id="myKey" defaultMessage="old message" />' +
'<FormattedMessage id="anotherKey" defaultMessage="another old message" />',
translations: {
myKey: "new message",
anotherKey: "another new message",
},
})
).toEqual({
updated: true,
fileContent:
'<FormattedMessage id="myKey" defaultMessage="new message" />' +
'<FormattedMessage id="anotherKey" defaultMessage="another new message" />',
});
});
it("updates nothing if no key was found", () => {
expect(
syncFormattedMessage({
fileContent:
'<FormattedMessage id="myKey" defaultMessage="old message" />' +
'<FormattedMessage id="anotherKey" defaultMessage="another old message" />',
translations: {
unusedKey: "new message",
},
})
).toEqual({
updated: false,
});
});
});

View File

@@ -0,0 +1,28 @@
/**
* Pattern 1: FormattedMessage with id and defaultMessage
* @code <FormattedMessage id="myKey" defaultMessage="old message" />
*/
export function syncFormattedMessage({
translations,
fileContent,
}: {
translations: Record<string, string>;
fileContent: string;
}): { updated: false } | { updated: true; fileContent: string } {
let updated = false;
Object.entries(translations).forEach(([messageId, messageValue]) => {
const regex = new RegExp(
`(id=["']${messageId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'].*?defaultMessage=)["']([^"']*?)["']`,
"gs"
);
if (regex.test(fileContent)) {
const escapedValue = messageValue
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
fileContent = fileContent.replace(regex, `$1"${escapedValue}"`);
updated = true;
}
});
return updated ? { updated, fileContent } : { updated };
}

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from "vitest";
import { syncIntlFormatMessage } from "./syncIntlFormatMessage";
describe("syncIntlFormatMessage", () => {
it("updated false when given empty file and no translations", () => {
expect(
syncIntlFormatMessage({ fileContent: "", translations: {} })
).toEqual({ updated: false, fileContent: "" });
});
it("updates int.formatMessage components", () => {
expect(
syncIntlFormatMessage({
fileContent:
'intl.formatMessage({ id: "myKey", defaultMessage: "old message" })',
translations: { myKey: "new message" },
})
).toEqual({
updated: true,
fileContent:
'intl.formatMessage({ id: "myKey", defaultMessage: "new message" })',
});
});
it("updates multiline int.formatMessage", () => {
expect(
syncIntlFormatMessage({
fileContent: `intl.formatMessage({\n\tid: "myKey",\n\tdefaultMessage: "old message"\n })`,
translations: { myKey: "new message" },
})
).toEqual({
updated: true,
fileContent: `intl.formatMessage({\n\tid: "myKey",\n\tdefaultMessage: "new message"\n })`,
});
});
it("updates multiple int.formatMessage components", () => {
expect(
syncIntlFormatMessage({
fileContent:
'intl.formatMessage({ id: "myKey", defaultMessage: "old message" })' +
'intl.formatMessage({ id: "anotherKey", defaultMessage: "another old message" })',
translations: {
myKey: "new message",
anotherKey: "another new message",
},
})
).toEqual({
updated: true,
fileContent:
'intl.formatMessage({ id: "myKey", defaultMessage: "new message" })' +
'intl.formatMessage({ id: "anotherKey", defaultMessage: "another new message" })',
});
});
it("updates nothing if no key was found", () => {
const fileContent =
'intl.formatMessage({ id: "myKey", defaultMessage: "old message" })' +
'intl.formatMessage({ id: "anotherKey", defaultMessage: "another old message" })';
expect(
syncIntlFormatMessage({
fileContent,
translations: {
unusedKey: "new message",
},
})
).toEqual({
updated: false,
fileContent,
});
});
it("updates nothing if not using intl.formatMessage", () => {
const fileContent =
'formatMessage({ id: "myKey", defaultMessage: "old message" })';
expect(
syncIntlFormatMessage({
fileContent,
translations: {
myKey: "new message",
},
})
).toEqual({
updated: false,
fileContent,
});
});
it("updates nothing if no defaultMessage is present", () => {
const fileContent = 'formatMessage({ id: "myKey" })';
expect(
syncIntlFormatMessage({
fileContent,
translations: {
myKey: "new message",
},
})
).toEqual({
updated: false,
fileContent,
});
});
});

View File

@@ -0,0 +1,47 @@
export function syncIntlFormatMessage({
translations,
fileContent,
}: {
translations: Record<string, string>;
fileContent: string;
}): { updated: boolean; fileContent: string } {
let updated = false;
const entries = Object.entries(translations);
for (const [messageId, messageValue] of entries) {
const escapedId = messageId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Find intl.formatMessage({...}) blocks that contain the specific id
const outerRegex = new RegExp(
`intl\\.formatMessage\\(\\s*\\{([^}]*?\\bid\\s*:\\s*['"]${escapedId}['"][^}]*?)\\}\\s*\\)`,
"gs"
);
fileContent = fileContent.replace(
outerRegex,
(fullMatch, innerObject) => {
// Find defaultMessage: '...' or "..."
const dmRegex =
/defaultMessage\s*:\s*(['"])((?:\\.|[\s\S])*?)\1/;
if (!dmRegex.test(innerObject)) return fullMatch;
const newInner = innerObject.replace(
dmRegex,
(_m: unknown, quote: string, _old: unknown) => {
// Escape backslashes first, then the surrounding quote, and newlines
const escaped = messageValue
.replace(/\\/g, "\\\\")
.replace(new RegExp(quote, "g"), `\\${quote}`)
.replace(/\n/g, "\\n");
return `defaultMessage: ${quote}${escaped}${quote}`;
}
);
updated = true;
return `intl.formatMessage({${newInner}})`;
}
);
}
return { updated, fileContent };
}

View File

@@ -0,0 +1,3 @@
export function TestComponent() {
return <div>Test</div>;
}

View File

@@ -0,0 +1,6 @@
export function TestComponent() {
const intl = useIntl();
return (
<div>{intl.formatMessage({ id: "myKey", defaultMessage: "Test" })}</div>
);
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./vitest-setup.ts"],
},
});