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:
79
scripts/i18n/syncDefaultMessage/index.ts
Normal file
79
scripts/i18n/syncDefaultMessage/index.ts
Normal 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();
|
||||
}
|
||||
92
scripts/i18n/syncDefaultMessage/syncFile.test.ts
Normal file
92
scripts/i18n/syncDefaultMessage/syncFile.test.ts
Normal 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>;
|
||||
}`;
|
||||
}
|
||||
25
scripts/i18n/syncDefaultMessage/syncFile.ts
Normal file
25
scripts/i18n/syncDefaultMessage/syncFile.ts
Normal 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 };
|
||||
}
|
||||
69
scripts/i18n/syncDefaultMessage/syncFormattedMessage.test.ts
Normal file
69
scripts/i18n/syncDefaultMessage/syncFormattedMessage.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
28
scripts/i18n/syncDefaultMessage/syncFormattedMessage.ts
Normal file
28
scripts/i18n/syncDefaultMessage/syncFormattedMessage.ts
Normal 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 };
|
||||
}
|
||||
102
scripts/i18n/syncDefaultMessage/syncIntlFormatMessage.test.ts
Normal file
102
scripts/i18n/syncDefaultMessage/syncIntlFormatMessage.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
47
scripts/i18n/syncDefaultMessage/syncIntlFormatMessage.ts
Normal file
47
scripts/i18n/syncDefaultMessage/syncIntlFormatMessage.ts
Normal 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 };
|
||||
}
|
||||
3
scripts/i18n/syncDefaultMessage/test.tsx
Normal file
3
scripts/i18n/syncDefaultMessage/test.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function TestComponent() {
|
||||
return <div>Test</div>;
|
||||
}
|
||||
6
scripts/i18n/syncDefaultMessage/test2.tsx
Normal file
6
scripts/i18n/syncDefaultMessage/test2.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export function TestComponent() {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div>{intl.formatMessage({ id: "myKey", defaultMessage: "Test" })}</div>
|
||||
);
|
||||
}
|
||||
0
scripts/i18n/syncDefaultMessage/vitest-setup.ts
Normal file
0
scripts/i18n/syncDefaultMessage/vitest-setup.ts
Normal file
9
scripts/i18n/syncDefaultMessage/vitest.config.ts
Normal file
9
scripts/i18n/syncDefaultMessage/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest-setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user