Skip to content

Commit

Permalink
Add support for yaml and po
Browse files Browse the repository at this point in the history
  • Loading branch information
pontusab committed Dec 25, 2024
1 parent 6b221b7 commit 5e58376
Show file tree
Hide file tree
Showing 14 changed files with 508 additions and 3 deletions.
Binary file modified bun.lockb
Binary file not shown.
16 changes: 16 additions & 0 deletions examples/po/languine.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default {
version: "0.6.2",
locale: {
source: "en",
targets: ["es"],
},
files: {
po: {
include: ["locales/[locale].po"],
},
},
llm: {
provider: "openai",
model: "gpt-4-turbo",
},
};
7 changes: 7 additions & 0 deletions examples/po/locales/en.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# This is a PO file containing English strings that serve as the source for translations.
# Each entry consists of a msgid (string identifier) and msgstr (translated string).
msgid "welcome_message"
msgstr "Welcome to our application!"

msgid "error_message"
msgstr "An error occurred: {error_code}"
7 changes: 7 additions & 0 deletions examples/po/locales/es.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# This is a PO file containing English strings that serve as the source for translations.
# Each entry consists of a msgid (string identifier) and msgstr (translated string).
msgid "welcome_message"
msgstr "Bienvenido a nuestra aplicación!"

msgid "error_message"
msgstr "Ocurrió un error: {error_code}"
16 changes: 16 additions & 0 deletions examples/yaml/languine.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default {
version: "0.6.2",
locale: {
source: "en",
targets: ["es"],
},
files: {
yaml: {
include: ["locales/[locale].yml"],
},
},
llm: {
provider: "openai",
model: "gpt-4-turbo",
},
}
48 changes: 48 additions & 0 deletions examples/yaml/locales/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# General messages
welcome: Welcome to our application!
goodbye: Thanks for using our app. See you next time!

# Navigation
nav:
home: Home
about: About
contact: Contact Us
settings: Settings

# User messages
user:
greeting: "Hello, {name}!"
profile:
title: Your Profile
edit: Edit Profile
save: Save Changes
cancel: Cancel

# Error messages
errors:
not_found: Page not found
server_error: An error occurred on the server
validation:
required: This field is required
email: Please enter a valid email address
password: Password must be at least 8 characters

# Form labels
form:
email: Email Address
password: Password
submit: Submit
reset: Reset Form

# Success messages
success:
saved: Changes saved successfully
uploaded: File uploaded successfully
deleted: Item deleted successfully

# Time-related
time:
today: Today
yesterday: Yesterday
tomorrow: Tomorrow
days_ago: "{count} days ago"
35 changes: 35 additions & 0 deletions examples/yaml/locales/es.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
welcome: ¡Bienvenido a nuestra aplicación!
goodbye: Gracias por usar nuestra app. ¡Hasta la próxima!
nav:
home: Inicio
about: Acerca de
contact: Contáctenos
settings: Configuración
user:
greeting: Hola, {name}!
profile:
title: Tu Perfil
edit: Editar Perfil
save: Guardar Cambios
cancel: Cancelar
errors:
not_found: Página no encontrada
server_error: Ocurrió un error en el servidor
validation:
required: Este campo es obligatorio
email: Por favor, introduce una dirección de correo válida
password: La contraseña debe tener al menos 8 caracteres
form:
email: Dirección de Correo Electrónico
password: Contraseña
submit: Enviar
reset: Restablecer Formulario
success:
saved: Cambios guardados exitosamente
uploaded: Archivo subido exitosamente
deleted: Elemento eliminado exitosamente
time:
today: Hoy
yesterday: Ayer
tomorrow: Mañana
days_ago: Hace {count} días
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"ollama-ai-provider": "^1.1.0",
"rambda": "^9.4.1",
"simple-git": "^3.27.0",
"yaml": "^2.6.1",
"zod": "^3.24.1"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ function getDefaultPattern(format: string) {
return "locales/[locale].json";
case "md":
return "docs/[locale]/*.md";
case ".po":
return "locales/[locale].po";
case "xml":
return "locales/[locale].xml";
case ".arb":
return "locales/[locale].arb";
default:
return `locales/[locale].${format}`;
}
Expand Down Expand Up @@ -87,6 +93,9 @@ export async function init() {
{ value: "xcode-stringsdict", label: "Xcode Stringsdict (.stringsdict)" },
{ value: "xcode-xcstrings", label: "Xcode XCStrings (.xcstrings)" },
{ value: "yaml", label: "YAML (.yml)" },
{ value: "xml", label: "XML (.xml)" },
{ value: "arb", label: "Arb (.arb)" },
{ value: "po", label: "Gettext (.po)" },
],
})) as string;

Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/envs.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,26 @@
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
dotenv.config();

// Try to find root directory by looking for package.json with workspaces
function findMonorepoRoot(dir: string): string {
const pkgPath = path.join(dir, "package.json");

if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (pkg.workspaces) {
return dir;
}
}

const parentDir = path.dirname(dir);
if (parentDir === dir) {
return process.cwd(); // Reached root, fallback to cwd
}

return findMonorepoRoot(parentDir);
}

const rootDir = findMonorepoRoot(process.cwd());
dotenv.config({ path: path.join(rootDir, ".env") });
dotenv.config(); // Fallback to current directory
11 changes: 11 additions & 0 deletions packages/cli/src/translators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type { Translator } from "../types.js";
import { javascript } from "./js.js";
import { json } from "./json.js";
import { markdown } from "./md.js";
import { po } from "./po.js";
import { xcodeStrings } from "./xcode-strings.js";
import { xcodeXCStrings } from "./xcode-xcstrings.js";
import { yaml } from "./yaml.js";

/**
* Get adapter from file extension/format
Expand All @@ -26,6 +28,15 @@ export async function getTranslator(
return xcodeStrings;
case "xcode-xcstrings":
return xcodeXCStrings;
case "po":
return po;
case "yaml":
return yaml;
// case "xml":
// return xml;
// case "arb":
// return arb;

default:
return undefined;
}
Expand Down
175 changes: 175 additions & 0 deletions packages/cli/src/translators/po.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { generateObject } from "ai";
import dedent from "dedent";
import { z } from "zod";
import { baseRequirements, createBasePrompt } from "../prompt.js";
import type { PromptOptions, Translator } from "../types.js";

interface PoEntry {
key: string;
value: string;
comments: string[];
}

function parsePoFile(content: string) {
const entries: PoEntry[] = [];
const lines = content.split("\n");
let currentComments: string[] = [];
let currentKey = "";
let currentValue = "";

for (const line of lines) {
const trimmed = line.trim();

// Collect comments
if (trimmed.startsWith("#")) {
currentComments.push(line);
continue;
}

// On empty line, reset comments if we haven't started an entry
if (!trimmed && !currentKey) {
currentComments = [];
continue;
}

if (trimmed.startsWith("msgid")) {
if (currentKey) {
entries.push({
key: currentKey,
value: currentValue,
comments: currentComments,
});
}
currentKey = trimmed.match(/msgid "(.*)"/)?.[1] ?? "";
currentValue = "";
} else if (trimmed.startsWith("msgstr")) {
currentValue = trimmed.match(/msgstr "(.*)"/)?.[1] ?? "";
} else if (!trimmed && currentKey) {
entries.push({
key: currentKey,
value: currentValue,
comments: currentComments,
});
currentKey = "";
currentValue = "";
currentComments = [];
}
}

if (currentKey) {
entries.push({
key: currentKey,
value: currentValue,
comments: currentComments,
});
}

return entries;
}

function stringifyPoFile(entries: PoEntry[]) {
return entries
.map(({ key, value, comments }) => {
const commentLines =
comments.length > 0 ? `${comments.join("\n")}\n` : "";
return `${commentLines}msgid "${key}"\nmsgstr "${value}"`;
})
.join("\n\n");
}

export const po: Translator = {
async onUpdate(options) {
const sourceEntries = parsePoFile(options.content);
const previousEntries = parsePoFile(options.previousContent);
const previousTranslationEntries = parsePoFile(options.previousTranslation);

const sourceMap = new Map(sourceEntries.map((entry) => [entry.key, entry]));
const previousMap = new Map(
previousEntries.map((entry) => [entry.key, entry]),
);
const prevTransMap = new Map(
previousTranslationEntries.map((entry) => [entry.key, entry]),
);

const addedKeys = sourceEntries
.filter(({ key, value }) => {
const prev = previousMap.get(key);
return !prev || prev.value !== value;
})
.map((entry) => entry.key);

if (addedKeys.length === 0) {
return {
summary: "No new keys to translate",
content: options.previousTranslation,
};
}

const toTranslate = Object.fromEntries(
addedKeys.map((key) => [key, sourceMap.get(key)!.value]),
);

const { object } = await generateObject({
model: options.model,
temperature: options.config.llm?.temperature ?? 0,
prompt: getPrompt(JSON.stringify(toTranslate, null, 2), options),
schema: z.object({
items: z.array(z.string().describe("Translated string value")),
}),
});

// Update translations while preserving order and comments
const updatedEntries = sourceEntries.map(({ key, comments }) => {
const translationIndex = addedKeys.indexOf(key);
const value =
translationIndex !== -1
? object.items[translationIndex]
: (prevTransMap.get(key)?.value ?? "");

return { key, value, comments };
});

return {
summary: `Translated ${addedKeys.length} new keys`,
content: stringifyPoFile(updatedEntries),
};
},

async onNew(options) {
const sourceEntries = parsePoFile(options.content);
const sourceStrings = Object.fromEntries(
sourceEntries.map((entry) => [entry.key, entry.value]),
);

const { object } = await generateObject({
model: options.model,
prompt: getPrompt(JSON.stringify(sourceStrings, null, 2), options),
temperature: options.config.llm?.temperature ?? 0,
schema: z.object({
items: z.array(z.string().describe("Translated string value")),
}),
});

const translatedEntries = sourceEntries.map((entry, index) => ({
...entry,
value: object.items[index],
}));

return {
content: stringifyPoFile(translatedEntries),
};
},
};

function getPrompt(base: string, options: PromptOptions) {
const text = dedent`
${baseRequirements}
- Preserve all msgid values exactly as they appear
- Only translate the msgstr values, not the msgid values
- Return translations as a JSON array of strings in the same order as input
- Maintain all format specifiers like %s, %d, etc. in the exact same order
- Preserve any HTML tags or special formatting in the strings
`;

return createBasePrompt(`${text}\n${base}`, options);
}
Loading

0 comments on commit 5e58376

Please sign in to comment.