Skip to content

Commit

Permalink
sync
Browse files Browse the repository at this point in the history
  • Loading branch information
pontusab committed Jan 19, 2025
1 parent 67c84a0 commit cf66a36
Show file tree
Hide file tree
Showing 14 changed files with 603 additions and 400 deletions.
3 changes: 0 additions & 3 deletions apps/web/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,6 @@ export default {
},
cta: "Automatisierung starten"
},
test: {
translationProgress: "Übersetzungsfortschritt: {progress}%"
},
createTeam: {
teamName: "Teamname",
teamNamePlaceholder: "Geben Sie den Teamnamen ein",
Expand Down
3 changes: 0 additions & 3 deletions apps/web/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,4 @@ export default {
description_2: "You can now close this tab and return to the CLI",
},
},
test: {
translationProgress: "Translation progress: {progress}%",
},
} as const;
242 changes: 137 additions & 105 deletions apps/web/src/locales/es.ts

Large diffs are not rendered by default.

243 changes: 138 additions & 105 deletions apps/web/src/locales/fr.ts

Large diffs are not rendered by default.

234 changes: 100 additions & 134 deletions apps/web/src/locales/sv.ts

Large diffs are not rendered by default.

13 changes: 0 additions & 13 deletions examples/lingui/locales/de.json

This file was deleted.

13 changes: 0 additions & 13 deletions examples/next-intl/messages/de.json

This file was deleted.

13 changes: 13 additions & 0 deletions packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { commands as authCommands } from "@/commands/auth/index.ts";
import { commands as initCommands } from "@/commands/init.ts";
import { syncCommand } from "@/commands/sync.ts";
import { translateCommand } from "@/commands/translate.ts";
import { isCancel, select } from "@clack/prompts";

Expand All @@ -18,6 +19,10 @@ export async function runCommands() {
await translateCommand([...args, subCommand].filter(Boolean));
break;
}
case "sync": {
await syncCommand([...args, subCommand].filter(Boolean));
break;
}
default:
process.exit(1);
}
Expand All @@ -30,6 +35,10 @@ export async function runCommands() {
{ value: "init", label: "Initialize a new Languine configuration" },
{ value: "auth", label: "Manage authentication" },
{ value: "translate", label: "Translate files" },
{
value: "sync",
label: "Sync deleted keys between source and target files",
},
],
});

Expand All @@ -48,5 +57,9 @@ export async function runCommands() {
await translateCommand([...args, subCommand].filter(Boolean));
break;
}
case "sync": {
await syncCommand([...args, subCommand].filter(Boolean));
break;
}
}
}
169 changes: 169 additions & 0 deletions packages/cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { readFile, writeFile } from "node:fs/promises";
import { createParser } from "@/parsers/index.ts";
import type { Config } from "@/types.js";
import { loadConfig } from "@/utils/config.ts";
import { getDiff } from "@/utils/diff.js";
import { confirm, outro, spinner } from "@clack/prompts";
import chalk from "chalk";
import glob from "fast-glob";
import { z } from "zod";

const argsSchema = z.array(z.string()).transform((args) => {
return {
checkOnly: args.includes("--check"),
};
});

export async function syncCommand(args: string[] = []) {
const { checkOnly } = argsSchema.parse(args);
const s = spinner();

s.start(
checkOnly ? "Checking for deleted keys..." : "Syncing deleted keys...",
);

try {
// Load config file
const config = await loadConfig();

if (!config) {
throw new Error(
"Configuration file not found. Please run `languine init` to create one.",
);
}

const { source: sourceLocale, targets: targetLocales } = config.locale;
let needsUpdates = false;
let syncedAnything = false;

// Process each file configuration
for (const [type, fileConfig] of Object.entries(config.files)) {
const { include } = fileConfig as Config["files"][string];

// Process each file pattern
for (const pattern of include) {
const globPattern =
pattern && typeof pattern === "object" ? pattern.glob : pattern;
const sourcePattern = globPattern.replace("[locale]", sourceLocale);

// Find all matching source files
const sourceFiles = await glob(sourcePattern, { absolute: true });

for (const sourceFilePath of sourceFiles) {
const parser = createParser({ type });

// Get diff to find deleted keys
const changes = await getDiff({ sourceFilePath, type });
const removedKeys = changes.removedKeys;

if (removedKeys.length > 0) {
needsUpdates = true;
if (checkOnly) {
console.log(
chalk.yellow(
`Found ${removedKeys.length} deleted keys in ${sourceFilePath}`,
),
);
continue;
}

let shouldRemoveKeys = false;
s.stop();
shouldRemoveKeys = (await confirm({
message: `${removedKeys.length} keys were removed from ${sourceFilePath}. Do you want to remove them from target files as well?`,
})) as boolean;
s.start();

if (!shouldRemoveKeys) {
s.message(`Skipping deletion of keys in ${sourceFilePath}...`);
continue;
}

// Process each target locale
for (const targetLocale of targetLocales) {
try {
const targetPath = sourceFilePath.replace(
sourceLocale,
targetLocale,
);

// Read existing target file
try {
const existingFile = await readFile(targetPath, "utf-8");
const existingContent = await parser.parse(existingFile);

// Remove deleted keys
let hasRemovedKeys = false;
for (const key of removedKeys) {
if (key in existingContent) {
delete existingContent[key];
hasRemovedKeys = true;
}
}

if (hasRemovedKeys) {
const serialized = await parser.serialize(
targetLocale,
existingContent,
existingContent,
);

// Run afterTranslate hook if configured
let finalContent = serialized;
if (config?.hooks?.afterTranslate) {
finalContent = await config.hooks.afterTranslate({
content: serialized,
filePath: targetPath,
});
}

await writeFile(targetPath, finalContent, "utf-8");
syncedAnything = true;
}
} catch (error) {
// Target file doesn't exist, skip it
console.log(
chalk.yellow(
`Target file ${targetPath} does not exist, skipping...`,
),
);
}
} catch (error) {
const syncError = error as Error;
console.error(
chalk.red(
`Sync failed for ${chalk.bold(
targetLocale,
)}: ${syncError.message}`,
),
);
}
}
}
}
}
}

if (checkOnly) {
if (needsUpdates) {
s.stop("Updates needed");
process.exit(1);
} else {
s.stop("No updates needed");
process.exit(0);
}
} else {
s.stop("Completed");
if (syncedAnything) {
outro("All files synchronized successfully!");
} else {
outro("No files needed synchronization.");
}
}
process.exit(checkOnly && needsUpdates ? 1 : 0);
} catch (error) {
const syncError = error as Error;
console.log(chalk.red(`Sync process failed: ${syncError.message}`));
process.exit(1);
}
}
13 changes: 11 additions & 2 deletions packages/cli/src/commands/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export async function translateCommand(args: string[] = []) {
if (translationInput.length === 0 && !shouldRemoveKeys) {
if (!isSilent) {
s.message(
`No ${forceTranslate ? "" : "changes "}detected in ${sourceFilePath}, skipping...`,
`No ${forceTranslate ? "" : "changes"} detected in ${sourceFilePath}, skipping...`,
);
}
continue;
Expand Down Expand Up @@ -237,7 +237,16 @@ export async function translateCommand(args: string[] = []) {
existingContent,
);

await writeFile(targetPath, serialized, "utf-8");
// Run afterTranslate hook if configured
let finalContent = serialized;
if (config?.hooks?.afterTranslate) {
finalContent = await config.hooks.afterTranslate({
content: serialized,
filePath: targetPath,
});
}

await writeFile(targetPath, finalContent, "utf-8");

if (translationInput.length > 0) {
translatedAnything = true;
Expand Down
29 changes: 20 additions & 9 deletions packages/cli/src/parsers/formats/__tests__/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,33 @@ describe("JSON Parser", () => {
});
});

test("extracts translations from complex objects", async () => {
const input = `{
"messageId": {
"translation": "Translated message",
"message": "Default message",
"description": "Comment for translators"
},
"obsoleteId": {
"translation": "Obsolete message"
}
}`;
const result = await parser.parse(input);
expect(result).toEqual({
"messageId.translation": "Translated message",
"messageId.message": "Default message",
"messageId.description": "Comment for translators",
"obsoleteId.translation": "Obsolete message",
});
});

test("throws on non-object input", async () => {
const input = `"just a string"`;
await expect(parser.parse(input)).rejects.toThrow(
"Translation file must contain a JSON object",
);
});

test("throws on non-string values", async () => {
const input = `{
"key": 123
}`;
await expect(parser.parse(input)).rejects.toThrow(
"Invalid translation value",
);
});

test("handles empty object", async () => {
const input = "{}";
const result = await parser.parse(input);
Expand Down
14 changes: 4 additions & 10 deletions packages/cli/src/parsers/formats/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,15 @@ function formatObjectLiteral(
}

function needsQuotes(key: string): boolean {
// Keys need quotes if they:
// 1. Contain special characters
// 2. Start with a number
// 3. Contain a dot
// 4. Are not valid JavaScript identifiers
return (
/[^a-zA-Z0-9_$]/.test(key) || // Has special chars
/^\d/.test(key) || // Starts with number
key.includes(".") || // Contains dot
!isValidIdentifier(key) // Not a valid identifier
/[^a-zA-Z0-9_$]/.test(key) ||
/^\d/.test(key) ||
key.includes(".") ||
!isValidIdentifier(key)
);
}

function isValidIdentifier(key: string): boolean {
// Check if the key is a valid JavaScript identifier
try {
new Function(`const ${key} = 0;`);
return true;
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/parsers/formats/xcode-stringsdict.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { build as buildPlist, parse as parsePlist } from "plist";
import plist from "plist";
import { createFormatParser } from "../core/format.ts";
import type { Parser } from "../core/types.ts";

export function createXcodeStringsDictParser(): Parser {
return createFormatParser({
async parse(input: string) {
try {
const parsed = parsePlist(input) as Record<string, unknown>;
const parsed = plist.parse(input) as Record<string, unknown>;
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Translation file must contain a valid plist");
}
Expand Down Expand Up @@ -34,7 +34,7 @@ export function createXcodeStringsDictParser(): Parser {
throw new Error(`Value for key "${key}" must be a string`);
}
}
return buildPlist(data);
return plist.build(data);
} catch (error) {
throw new Error(
`Failed to serialize Xcode stringsdict translations: ${(error as Error).message}`,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export interface Config {
include: (string | { glob: string })[];
};
};
/** Hooks */
hooks?: {
/** Hook to run after translation */
afterTranslate?: (args: {
content: string;
filePath: string;
}) => Promise<string>;
};
}

export interface ParserOptions {
Expand Down

0 comments on commit cf66a36

Please sign in to comment.