From 55d6b11aed4bc1ef6929acef80e9b759e7ed5492 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Tue, 24 Dec 2024 15:07:13 +0800 Subject: [PATCH 1/8] feat(cli): init format adapter --- apps/web/components.json | 2 +- apps/web/src/lib/utils.ts | 6 +- biome.json | 46 +++++----- bun.lockb | Bin 135288 -> 156472 bytes examples/i18next/languine.config.mjs | 12 +-- examples/i18next/locales/sv.json | 2 +- examples/next-intl/languine.config.mjs | 12 +-- examples/next-intl/messages/de.json | 2 +- package.json | 1 + packages/cli/src/adapters/index.ts | 38 ++++++++ packages/cli/src/adapters/js.ts | 58 ++++++++++++ packages/cli/src/adapters/json.ts | 47 ++++++++++ packages/cli/src/adapters/md.ts | 16 ++++ packages/cli/src/commands/translate.ts | 118 +++++++++---------------- packages/cli/src/prompt.ts | 28 +++++- packages/cli/src/types.ts | 46 ++++++++++ 16 files changed, 316 insertions(+), 118 deletions(-) create mode 100644 packages/cli/src/adapters/index.ts create mode 100644 packages/cli/src/adapters/js.ts create mode 100644 packages/cli/src/adapters/json.ts create mode 100644 packages/cli/src/adapters/md.ts diff --git a/apps/web/components.json b/apps/web/components.json index d710b49..7b17557 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index bd0c391..a5ef193 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/biome.json b/biome.json index 7df315b..e960d4e 100644 --- a/biome.json +++ b/biome.json @@ -1,26 +1,26 @@ { - "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "ignore": ["node_modules"], - "enabled": true, - "rules": { - "recommended": true, - "a11y": { - "noSvgWithoutTitle": "off", - "useKeyWithClickEvents": "off" - }, - "style": { - "noNonNullAssertion": "off" - }, - "correctness": { - "useExhaustiveDependencies": "off" - } + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "ignore": ["node_modules"], + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "noSvgWithoutTitle": "off", + "useKeyWithClickEvents": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off" } - }, - "formatter": { - "indentStyle": "space" } - } \ No newline at end of file + }, + "formatter": { + "indentStyle": "space" + } +} diff --git a/bun.lockb b/bun.lockb index 0665d5f6d8acb8b16df9fb7b7afc3028e80a0eeb..5ef14fdb9ecae008d8024f0e55036b23264dbbcd 100755 GIT binary patch delta 38878 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 QrsJf*B~knYGv$N-1HV#n5C8xG delta 25058 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 diff --git a/examples/i18next/languine.config.mjs b/examples/i18next/languine.config.mjs index 6c31d61..75f46a5 100644 --- a/examples/i18next/languine.config.mjs +++ b/examples/i18next/languine.config.mjs @@ -2,14 +2,14 @@ export default { version: "1.0.0", locale: { source: "en", - targets: ["sv"] + targets: ["sv"], }, files: { json: { - include: ["locales/[locale].json"] - } + include: ["locales/[locale].json"], + }, }, openai: { - model: "gpt-4-turbo" - } -} \ No newline at end of file + model: "gpt-4-turbo", + }, +}; diff --git a/examples/i18next/locales/sv.json b/examples/i18next/locales/sv.json index c878c82..76491c8 100644 --- a/examples/i18next/locales/sv.json +++ b/examples/i18next/locales/sv.json @@ -6,4 +6,4 @@ "interpolated": "Ha en trevlig dag, {{name}}!", "pluralKey_one": "Det här är ett fint exempel.", "pluralKey_other": "Det här är fina exempel." -} \ No newline at end of file +} diff --git a/examples/next-intl/languine.config.mjs b/examples/next-intl/languine.config.mjs index 86f3547..8965acf 100644 --- a/examples/next-intl/languine.config.mjs +++ b/examples/next-intl/languine.config.mjs @@ -2,14 +2,14 @@ export default { version: "1.0.0", locale: { source: "en", - targets: ["de"] + targets: ["de"], }, files: { json: { - include: ["messages/[locale].json"] - } + include: ["messages/[locale].json"], + }, }, openai: { - model: "gpt-4-turbo" - } -} \ No newline at end of file + model: "gpt-4-turbo", + }, +}; diff --git a/examples/next-intl/messages/de.json b/examples/next-intl/messages/de.json index 890214a..fc97717 100644 --- a/examples/next-intl/messages/de.json +++ b/examples/next-intl/messages/de.json @@ -9,4 +9,4 @@ "number": "Verfügbar ab {price, number, currency}", "date": "Bestellt am {orderDate, date, medium}" } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 6d37bf0..7b0a5b4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@biomejs/biome": "^1.9.4", + "remark": "^15.0.1", "turbo": "2.3.3", "typescript": "^5.7.2" }, diff --git a/packages/cli/src/adapters/index.ts b/packages/cli/src/adapters/index.ts new file mode 100644 index 0000000..8549d14 --- /dev/null +++ b/packages/cli/src/adapters/index.ts @@ -0,0 +1,38 @@ +import type { + Awaitable, + PromptOptions, + PromptResult, + UpdateOptions, + UpdateResult, +} from "../types.js"; +import { typescriptPrompt, typescriptUpdate } from "./js.js"; +import { jsonPrompt, jsonUpdate } from "./json.js"; +import { markdownPrompt, markdownUpdate } from "./md.js"; + +interface Adapter { + onPrompt: (options: PromptOptions) => Awaitable; + onUpdate: (options: UpdateOptions) => Awaitable; +} + +export function getAdapter(format: string): Adapter | undefined { + if (format === "ts" || format === "js") { + return { + onPrompt: typescriptPrompt, + onUpdate: typescriptUpdate, + }; + } + + if (format === "json" || format === "json5") { + return { + onPrompt: jsonPrompt, + onUpdate: jsonUpdate, + }; + } + + if (format === "md" || format === "mdx") { + return { + onPrompt: markdownPrompt, + onUpdate: markdownUpdate, + }; + } +} diff --git a/packages/cli/src/adapters/js.ts b/packages/cli/src/adapters/js.ts new file mode 100644 index 0000000..cf9f0af --- /dev/null +++ b/packages/cli/src/adapters/js.ts @@ -0,0 +1,58 @@ +import { createRecordPrompt } from "../prompt.js"; +import type { + PromptOptions, + PromptResult, + UpdateOptions, + UpdateResult, +} from "../types.js"; +import { extractChangedKeys } from "../utils.js"; + +export function typescriptPrompt(options: PromptOptions): PromptResult { + // Parse source content + const sourceObj = Function( + `return ${options.content.replace(/export default |as const;/g, "")}`, + )(); + + const keysToTranslate = options.force + ? Object.keys(sourceObj) + : extractChangedKeys(options.diff).addedKeys; + + if (keysToTranslate.length === 0) { + return { type: "skip" }; + } + + // If force is true, translate everything. Otherwise only new keys + const contentToTranslate: Record = {}; + for (const key of keysToTranslate) { + contentToTranslate[key] = sourceObj[key]; + } + + return { + type: "success", + prompt: createRecordPrompt(contentToTranslate, options), + }; +} + +export function typescriptUpdate(options: UpdateOptions): UpdateResult { + // Parse the translated content + const translatedObj = Function( + `return ${options.promptResult.replace(/as const;?/g, "")}`, + )(); + + // Merge with existing translations if not force translating + const finalObj = options.force + ? translatedObj + : { + ...(options.content + ? Function( + `return ${options.content.replace(/export default |as const;/g, "")}`, + )() + : {}), + ...translatedObj, + }; + + return { + summary: `Translated ${Object.keys(translatedObj).length} ${options.force ? "total" : "new"} keys`, + content: `export default ${JSON.stringify(finalObj, null, 2)} as const;\n`, + }; +} diff --git a/packages/cli/src/adapters/json.ts b/packages/cli/src/adapters/json.ts new file mode 100644 index 0000000..cdf505a --- /dev/null +++ b/packages/cli/src/adapters/json.ts @@ -0,0 +1,47 @@ +import { createRecordPrompt } from "../prompt.js"; +import type { + PromptOptions, + PromptResult, + UpdateOptions, + UpdateResult, +} from "../types.js"; +import { extractChangedKeys } from "../utils.js"; + +export function jsonPrompt(options: PromptOptions): PromptResult { + const sourceObj = JSON.parse(options.content); + const keysToTranslate = options.force + ? Object.keys(sourceObj) + : extractChangedKeys(options.diff).addedKeys; + + if (keysToTranslate.length === 0) { + return { type: "skip" }; + } + + const contentToTranslate: Record = {}; + for (const key of keysToTranslate) { + contentToTranslate[key] = sourceObj[key]; + } + + return { + type: "success", + prompt: createRecordPrompt(contentToTranslate, options), + }; +} + +export function jsonUpdate(options: UpdateOptions): UpdateResult { + const translatedObj = JSON.parse(options.promptResult); + + // Merge with existing translations if not force translating + const finalObj = options.force + ? translatedObj + : { + ...(options.content ? JSON.parse(options.content) : {}), + ...translatedObj, + }; + + // Format the final content + return { + summary: `Translated ${Object.keys(translatedObj).length} ${options.force ? "total" : "new"} keys`, + content: JSON.stringify(finalObj, null, 2), + }; +} diff --git a/packages/cli/src/adapters/md.ts b/packages/cli/src/adapters/md.ts new file mode 100644 index 0000000..f1713ac --- /dev/null +++ b/packages/cli/src/adapters/md.ts @@ -0,0 +1,16 @@ +import type { + PromptOptions, + PromptResult, + UpdateOptions, + UpdateResult, +} from "../types.js"; + +export function markdownPrompt(options: PromptOptions): PromptResult { + return { type: "skip" }; +} + +export function markdownUpdate(options: UpdateOptions): UpdateResult { + return { + content: options.content ?? "", + }; +} diff --git a/packages/cli/src/commands/translate.ts b/packages/cli/src/commands/translate.ts index bc544aa..1e23338 100644 --- a/packages/cli/src/commands/translate.ts +++ b/packages/cli/src/commands/translate.ts @@ -5,11 +5,10 @@ import { createOpenAI } from "@ai-sdk/openai"; import { intro, outro, spinner } from "@clack/prompts"; import { generateText } from "ai"; import chalk from "chalk"; -import dedent from "dedent"; -import { prompt as defaultPrompt } from "../prompt.js"; -import { extractChangedKeys, getApiKey, getConfig } from "../utils.js"; +import { getApiKey, getConfig } from "../utils.js"; +import { getAdapter } from "../adapters/index.js"; -export async function translate(targetLocale?: string, force?: boolean) { +export async function translate(targetLocale?: string, force: boolean = false) { intro("Starting translation process..."); const config = await getConfig(); @@ -43,24 +42,17 @@ export async function translate(targetLocale?: string, force?: boolean) { const targetPath = pattern.replace("[locale]", locale); try { - let addedKeys: string[] = []; + let diff: string | undefined; if (!force) { // Get git diff for source file if not force translating - const diff = execSync(`git diff HEAD -- ${sourcePath}`, { + diff = execSync(`git diff HEAD -- ${sourcePath}`, { encoding: "utf-8", }); if (!diff) { return { locale, sourcePath, success: true, noChanges: true }; } - - const changes = extractChangedKeys(diff); - addedKeys = changes.addedKeys; - - if (addedKeys.length === 0) { - return { locale, sourcePath, success: true, noChanges: true }; - } } // Read source and target files @@ -69,6 +61,36 @@ export async function translate(targetLocale?: string, force?: boolean) { "utf-8", ); + const adapter = getAdapter(format); + if (!adapter) { + return { + locale, + sourcePath, + success: false, + error: `No available adapter for format: ${format}`, + }; + } + + const prompt = await adapter.onPrompt({ + config, + content: sourceContent, + diff: diff ?? "", + force, + format, + sourceLocale: source, + targetLocale: locale, + }); + + if (prompt.type === "skip") { + return { locale, sourcePath, success: true, noChanges: true }; + } + + // Get translation from OpenAI + const { text } = await generateText({ + model: openai(config.openai.model), + prompt: prompt.prompt, + }); + let targetContent = ""; try { targetContent = await fs.readFile( @@ -83,69 +105,13 @@ export async function translate(targetLocale?: string, force?: boolean) { await fs.mkdir(targetDir, { recursive: true }); } - // Parse source content - const sourceObj = - format === "ts" - ? Function( - `return ${sourceContent.replace(/export default |as const;/g, "")}`, - )() - : JSON.parse(sourceContent); - - // If force is true, translate everything. Otherwise only new keys - const keysToTranslate = force ? Object.keys(sourceObj) : addedKeys; - const contentToTranslate: Record = {}; - for (const key of keysToTranslate) { - contentToTranslate[key] = sourceObj[key]; - } - - const prompt = dedent` - You are a professional translator working with ${format.toUpperCase()} files. - - Task: Translate the content below from ${source} to ${locale}. - ${force ? "" : "Only translate the new keys provided."} - - ${defaultPrompt} - - ${config.instructions ?? ""} - - Source content ${force ? "" : "(new keys only)"}: - ${JSON.stringify(contentToTranslate, null, 2)} - - Return only the translated content with identical structure. - `; - - // Get translation from OpenAI - const { text } = await generateText({ - model: openai(config.openai.model), - prompt, + let { content: finalContent, summary } = await adapter.onUpdate({ + force, + prompt: prompt.prompt, + promptResult: text, + content: targetContent, }); - // Parse the translated content - const translatedObj = - format === "ts" - ? Function(`return ${text.replace(/as const;?/g, "")}`)() - : JSON.parse(text); - - // Merge with existing translations if not force translating - const finalObj = force - ? translatedObj - : { - ...(targetContent - ? format === "ts" - ? Function( - `return ${targetContent.replace(/export default |as const;/g, "")}`, - )() - : JSON.parse(targetContent) - : {}), - ...translatedObj, - }; - - // Format the final content - let finalContent = - format === "ts" - ? `export default ${JSON.stringify(finalObj, null, 2)} as const;\n` - : JSON.stringify(finalObj, null, 2); - // Run afterTranslate hook if defined if (config.hooks?.afterTranslate) { finalContent = await config.hooks.afterTranslate({ @@ -165,7 +131,7 @@ export async function translate(targetLocale?: string, force?: boolean) { locale, sourcePath, success: true, - addedKeys: keysToTranslate, + summary, }; } catch (error) { return { locale, sourcePath, success: false, error }; @@ -187,7 +153,7 @@ export async function translate(targetLocale?: string, force?: boolean) { for (const result of changes) { console.log( chalk.green( - `✓ Translated ${result.addedKeys?.length} ${force ? "total" : "new"} keys for ${result.locale}`, + `✓ Translated ${result.summary ?? "content"} for ${result.locale}`, ), ); } diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index e2265a8..fd13abc 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -1,4 +1,7 @@ -export const prompt = ` +import dedent from "dedent"; +import type { PromptOptions } from "./types.js"; + +export const promptJson = ` Translation Requirements: - Maintain exact file structure, indentation, and formatting - Only translate text content within quotation marks @@ -14,3 +17,26 @@ Translation Requirements: - Translate only user-facing strings - Never add space before a ! or ? `; + +/** + * Create prompt for record-like objects + */ +export function createRecordPrompt( + parsedContent: Record, + options: PromptOptions, +) { + return dedent` + You are a professional translator working with ${options.format.toUpperCase()} files. + + Task: Translate the content below from ${options.sourceLocale} to ${options.targetLocale}. + ${options.force ? "" : "Only translate the new keys provided."} + + ${promptJson} + ${options.config.instructions ?? ""} + + Source content ${options.force ? "" : "(new keys only)"}: + ${JSON.stringify(parsedContent, null, 2)} + + Return only the translated content with identical structure. + `; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 9b90434..606ba99 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -20,3 +20,49 @@ export interface Config { }) => Promise; }; } + +export interface PromptOptions { + format: string; + + targetLocale: string; + sourceLocale: string; + + force: boolean; + + diff: string; + content: string; + + config: Config; +} + +export type PromptResult = + | { + type: "success"; + prompt: string; + } + | { + type: "skip"; + }; + +export interface UpdateOptions { + promptResult: string; + prompt: string; + + force: boolean; + + /** + * Content to update (translated file) + */ + content?: string; +} + +export interface UpdateResult { + /** + * Text summary of updated changes + */ + summary?: string; + + content: string; +} + +export type Awaitable = T | Promise; From 8b431e322d18282e31c67ec9f2a6529b2991d3f7 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Tue, 24 Dec 2024 17:07:20 +0800 Subject: [PATCH 2/8] feat(md): support diffs --- bun.lockb | Bin 156472 -> 135288 bytes package.json | 1 - packages/cli/src/adapters/index.ts | 10 ++- packages/cli/src/adapters/json.ts | 1 + packages/cli/src/adapters/md.ts | 86 ++++++++++++++++++++++++- packages/cli/src/commands/translate.ts | 17 ++--- packages/cli/src/prompt.ts | 58 +++++++++-------- packages/cli/src/types.ts | 2 +- 8 files changed, 130 insertions(+), 45 deletions(-) diff --git a/bun.lockb b/bun.lockb index 5ef14fdb9ecae008d8024f0e55036b23264dbbcd..a2c1f0031889dfcc1682ecbee0e257e834d39eaf 100755 GIT binary patch delta 25036 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 delta 38850 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 z%0D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 Hl@I<8H*s_6 diff --git a/package.json b/package.json index 7b0a5b4..6d37bf0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ }, "dependencies": { "@biomejs/biome": "^1.9.4", - "remark": "^15.0.1", "turbo": "2.3.3", "typescript": "^5.7.2" }, diff --git a/packages/cli/src/adapters/index.ts b/packages/cli/src/adapters/index.ts index 8549d14..8e1cfaf 100644 --- a/packages/cli/src/adapters/index.ts +++ b/packages/cli/src/adapters/index.ts @@ -7,14 +7,18 @@ import type { } from "../types.js"; import { typescriptPrompt, typescriptUpdate } from "./js.js"; import { jsonPrompt, jsonUpdate } from "./json.js"; -import { markdownPrompt, markdownUpdate } from "./md.js"; interface Adapter { onPrompt: (options: PromptOptions) => Awaitable; onUpdate: (options: UpdateOptions) => Awaitable; } -export function getAdapter(format: string): Adapter | undefined { +/** + * Get adapter from file extension/format + * + * This will lazy-load the adapters to reduce memory usage and improve server performance + */ +export async function getAdapter(format: string): Promise { if (format === "ts" || format === "js") { return { onPrompt: typescriptPrompt, @@ -30,6 +34,8 @@ export function getAdapter(format: string): Adapter | undefined { } if (format === "md" || format === "mdx") { + const { markdownPrompt, markdownUpdate } = await import("./md.js"); + return { onPrompt: markdownPrompt, onUpdate: markdownUpdate, diff --git a/packages/cli/src/adapters/json.ts b/packages/cli/src/adapters/json.ts index cdf505a..456fbc9 100644 --- a/packages/cli/src/adapters/json.ts +++ b/packages/cli/src/adapters/json.ts @@ -9,6 +9,7 @@ import { extractChangedKeys } from "../utils.js"; export function jsonPrompt(options: PromptOptions): PromptResult { const sourceObj = JSON.parse(options.content); + const keysToTranslate = options.force ? Object.keys(sourceObj) : extractChangedKeys(options.diff).addedKeys; diff --git a/packages/cli/src/adapters/md.ts b/packages/cli/src/adapters/md.ts index f1713ac..fd500eb 100644 --- a/packages/cli/src/adapters/md.ts +++ b/packages/cli/src/adapters/md.ts @@ -4,13 +4,93 @@ import type { UpdateOptions, UpdateResult, } from "../types.js"; +import { createBasePrompt } from "../prompt.js"; -export function markdownPrompt(options: PromptOptions): PromptResult { - return { type: "skip" }; +function extractDiff(diff: string) { + const added: number[] = []; + const removed: number[] = []; + const lines = diff.split("\n"); + + lines.forEach((line, i) => { + if (line.startsWith("+") && !line.startsWith("+++")) added.push(i); + else if (line.startsWith("-") && !line.startsWith("---")) removed.push(i); + }); + + return { added, removed }; +} + +type ModifiedPromptResult = PromptResult & { + parsedDiff?: ReturnType; +}; + +// TODO: find a better way to translate markdown diffs +// docs usually have a context, it's better to provide the full-context of original text to help AI +export function markdownPrompt(options: PromptOptions): ModifiedPromptResult { + let resultPrompt = "Return only the translated content"; + let parsedDiff: ReturnType | undefined; + + if (!options.force) { + parsedDiff = extractDiff(options.content); + const lineNumbers = parsedDiff.added.map((num) => num + 1); + + if (lineNumbers.length === 0) return { type: "skip" }; + + resultPrompt = `Translate only ${lineNumbers.map((num) => `line ${num}`).join(", ")}, and return in the form of a JSON array like: + ${JSON.stringify(lineNumbers.map((num) => `translated content of line ${num}`))} + \`\`\` + `; + } + + return { + type: "success", + parsedDiff, + prompt: createBasePrompt( + ` + Translation Requirements: + - Only translate frontmatter, and text content (including those in HTML/JSX) + - Keep original line breaks, code and codeblocks + - Keep consistent capitalization, and spacing + - Provide natural, culturally-adapted translations that sound native + - Retain all code elements like variables, functions, and control structures + - Keep any code comments + - Handle special characters and escape sequences correctly + - Respect existing whitespace and newline patterns + - Keep all technical identifiers unchanged + - ${resultPrompt}. + + Source content: + ${options.content} + `, + options, + ), + }; } export function markdownUpdate(options: UpdateOptions): UpdateResult { + const { parsedDiff } = options.prompt as ModifiedPromptResult; + + if (options.force || !parsedDiff) { + return { content: options.promptResult }; + } + + const lines = options.content?.split("\n") ?? []; + const arr = JSON.parse(options.promptResult) as string[]; + let offset = 0; + arr.forEach((translated, i) => { + const lineNum = parsedDiff.added[i]; + + // perform addition + lines.splice(offset + lineNum, 0, translated); + offset++; + }); + + for (const lineNum of parsedDiff.removed) { + lines.splice(offset + lineNum, 1); + offset--; + } + return { - content: options.content ?? "", + summary: `Translated ${arr.length} lines`, + content: lines.join("\n"), }; } diff --git a/packages/cli/src/commands/translate.ts b/packages/cli/src/commands/translate.ts index 1e23338..dd070ef 100644 --- a/packages/cli/src/commands/translate.ts +++ b/packages/cli/src/commands/translate.ts @@ -42,7 +42,7 @@ export async function translate(targetLocale?: string, force: boolean = false) { const targetPath = pattern.replace("[locale]", locale); try { - let diff: string | undefined; + let diff = ""; if (!force) { // Get git diff for source file if not force translating @@ -50,7 +50,7 @@ export async function translate(targetLocale?: string, force: boolean = false) { encoding: "utf-8", }); - if (!diff) { + if (diff.length === 0) { return { locale, sourcePath, success: true, noChanges: true }; } } @@ -61,14 +61,9 @@ export async function translate(targetLocale?: string, force: boolean = false) { "utf-8", ); - const adapter = getAdapter(format); + const adapter = await getAdapter(format); if (!adapter) { - return { - locale, - sourcePath, - success: false, - error: `No available adapter for format: ${format}`, - }; + throw new Error(`No available adapter for format: ${format}`); } const prompt = await adapter.onPrompt({ @@ -91,7 +86,7 @@ export async function translate(targetLocale?: string, force: boolean = false) { prompt: prompt.prompt, }); - let targetContent = ""; + let targetContent = undefined; try { targetContent = await fs.readFile( path.join(process.cwd(), targetPath), @@ -107,7 +102,7 @@ export async function translate(targetLocale?: string, force: boolean = false) { let { content: finalContent, summary } = await adapter.onUpdate({ force, - prompt: prompt.prompt, + prompt, promptResult: text, content: targetContent, }); diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index fd13abc..f571b09 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -1,22 +1,15 @@ import dedent from "dedent"; import type { PromptOptions } from "./types.js"; -export const promptJson = ` -Translation Requirements: -- Maintain exact file structure, indentation, and formatting -- Only translate text content within quotation marks -- Preserve all object/property keys, syntax characters, and punctuation marks exactly -- Keep consistent capitalization, spacing, and line breaks -- Provide natural, culturally-adapted translations that sound native -- Retain all code elements like variables, functions, and control structures -- Exclude any translator notes, comments or explanatory text -- Match source file's JSON/object structure precisely -- Handle special characters and escape sequences correctly -- Respect existing whitespace and newline patterns -- Keep all technical identifiers unchanged -- Translate only user-facing strings -- Never add space before a ! or ? -`; +export function createBasePrompt(text: string, options: PromptOptions) { + return dedent` + You are a professional translator working with ${options.format.toUpperCase()} files. + + Task: Translate the content below from ${options.sourceLocale} to ${options.targetLocale}. + ${options.config.instructions ?? ""} + ${text} + `; +} /** * Create prompt for record-like objects @@ -25,18 +18,29 @@ export function createRecordPrompt( parsedContent: Record, options: PromptOptions, ) { - return dedent` - You are a professional translator working with ${options.format.toUpperCase()} files. - - Task: Translate the content below from ${options.sourceLocale} to ${options.targetLocale}. - ${options.force ? "" : "Only translate the new keys provided."} + return createBasePrompt( + `${options.force ? "" : "Only translate the new keys provided."} - ${promptJson} - ${options.config.instructions ?? ""} + Translation Requirements: + - Maintain exact file structure, indentation, and formatting + - Only translate text content within quotation marks + - Preserve all object/property keys, syntax characters, and punctuation marks exactly + - Keep consistent capitalization, spacing, and line breaks + - Provide natural, culturally-adapted translations that sound native + - Retain all code elements like variables, functions, and control structures + - Exclude any translator notes, comments or explanatory text + - Match source file's JSON/object structure precisely + - Handle special characters and escape sequences correctly + - Respect existing whitespace and newline patterns + - Keep all technical identifiers unchanged + - Translate only user-facing strings + - Never add space before a ! or ? - Source content ${options.force ? "" : "(new keys only)"}: - ${JSON.stringify(parsedContent, null, 2)} + Source content ${options.force ? "" : "(new keys only)"}: + ${JSON.stringify(parsedContent, null, 2)} - Return only the translated content with identical structure. - `; + Return only the translated content with identical structure. + `, + options, + ); } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 606ba99..21d5c92 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -46,7 +46,7 @@ export type PromptResult = export interface UpdateOptions { promptResult: string; - prompt: string; + prompt: Extract; force: boolean; From 45c838905b1b0c623c1275771d48ffad0dc39242 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Tue, 24 Dec 2024 21:21:14 +0800 Subject: [PATCH 3/8] improve abstraction & fix bugs --- bun.lockb | Bin 135288 -> 157448 bytes package.json | 13 ++- packages/cli/package.json | 1 - packages/cli/src/adapters/index.ts | 44 -------- packages/cli/src/adapters/js.ts | 58 ---------- packages/cli/src/adapters/json.ts | 48 -------- packages/cli/src/adapters/md.ts | 96 ---------------- packages/cli/src/commands/translate.ts | 63 +++++------ packages/cli/src/prompt.ts | 7 +- packages/cli/src/translators/index.ts | 32 ++++++ packages/cli/src/translators/js.ts | 63 +++++++++++ packages/cli/src/translators/json.ts | 52 +++++++++ packages/cli/src/translators/md.ts | 104 ++++++++++++++++++ packages/cli/src/types.ts | 39 +++---- packages/cli/src/utils.ts | 12 ++ packages/cli/test/md.test.ts | 66 +++++++++++ packages/cli/test/resources/md-diff.diff.md | 11 ++ .../cli/test/resources/md-diff.translated.md | 7 ++ packages/cli/test/resources/md-new.md | 1 + packages/cli/test/snapshots/md-diff.md | 9 ++ packages/cli/test/snapshots/md-new.md | 1 + packages/cli/tsconfig.json | 2 +- 22 files changed, 415 insertions(+), 314 deletions(-) delete mode 100644 packages/cli/src/adapters/index.ts delete mode 100644 packages/cli/src/adapters/js.ts delete mode 100644 packages/cli/src/adapters/json.ts delete mode 100644 packages/cli/src/adapters/md.ts create mode 100644 packages/cli/src/translators/index.ts create mode 100644 packages/cli/src/translators/js.ts create mode 100644 packages/cli/src/translators/json.ts create mode 100644 packages/cli/src/translators/md.ts create mode 100644 packages/cli/test/md.test.ts create mode 100644 packages/cli/test/resources/md-diff.diff.md create mode 100644 packages/cli/test/resources/md-diff.translated.md create mode 100644 packages/cli/test/resources/md-new.md create mode 100644 packages/cli/test/snapshots/md-diff.md create mode 100644 packages/cli/test/snapshots/md-new.md diff --git a/bun.lockb b/bun.lockb index a2c1f0031889dfcc1682ecbee0e257e834d39eaf..a0e44c661eebbae491373d5ea44f4f4fa4fec443 100755 GIT binary patch delta 39538 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%0AO+wBY2Bau~9|eKk<|R#)$Y3r3|8p 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 DK~nhJ diff --git a/package.json b/package.json index 6d37bf0..3dc6f0c 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,18 @@ "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", "dev": "turbo dev --parallel", - "test": "turbo test --parallel", + "test": "vitest", "format": "biome format --write .", "lint": "turbo lint && manypkg check", "typecheck": "turbo typecheck" }, - "dependencies": { + "packageManager": "bun@1.1.42", + "devDependencies": { "@biomejs/biome": "^1.9.4", + "@types/bun": "^1.1.14", + "@types/node": "^22.10.2", "turbo": "2.3.3", - "typescript": "^5.7.2" - }, - "packageManager": "bun@1.1.42" + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/cli/package.json b/packages/cli/package.json index bd79af4..0796da2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,6 @@ "lint": "biome check .", "format": "biome format --write .", "typecheck": "tsc --noEmit", - "test": "bun test src", "build": "tsup src/index.ts --format esm --dts --clean", "dev": "tsup src/index.ts --format esm --watch --clean", "start": "node dist/index.js" diff --git a/packages/cli/src/adapters/index.ts b/packages/cli/src/adapters/index.ts deleted file mode 100644 index 8e1cfaf..0000000 --- a/packages/cli/src/adapters/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { - Awaitable, - PromptOptions, - PromptResult, - UpdateOptions, - UpdateResult, -} from "../types.js"; -import { typescriptPrompt, typescriptUpdate } from "./js.js"; -import { jsonPrompt, jsonUpdate } from "./json.js"; - -interface Adapter { - onPrompt: (options: PromptOptions) => Awaitable; - onUpdate: (options: UpdateOptions) => Awaitable; -} - -/** - * Get adapter from file extension/format - * - * This will lazy-load the adapters to reduce memory usage and improve server performance - */ -export async function getAdapter(format: string): Promise { - if (format === "ts" || format === "js") { - return { - onPrompt: typescriptPrompt, - onUpdate: typescriptUpdate, - }; - } - - if (format === "json" || format === "json5") { - return { - onPrompt: jsonPrompt, - onUpdate: jsonUpdate, - }; - } - - if (format === "md" || format === "mdx") { - const { markdownPrompt, markdownUpdate } = await import("./md.js"); - - return { - onPrompt: markdownPrompt, - onUpdate: markdownUpdate, - }; - } -} diff --git a/packages/cli/src/adapters/js.ts b/packages/cli/src/adapters/js.ts deleted file mode 100644 index cf9f0af..0000000 --- a/packages/cli/src/adapters/js.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createRecordPrompt } from "../prompt.js"; -import type { - PromptOptions, - PromptResult, - UpdateOptions, - UpdateResult, -} from "../types.js"; -import { extractChangedKeys } from "../utils.js"; - -export function typescriptPrompt(options: PromptOptions): PromptResult { - // Parse source content - const sourceObj = Function( - `return ${options.content.replace(/export default |as const;/g, "")}`, - )(); - - const keysToTranslate = options.force - ? Object.keys(sourceObj) - : extractChangedKeys(options.diff).addedKeys; - - if (keysToTranslate.length === 0) { - return { type: "skip" }; - } - - // If force is true, translate everything. Otherwise only new keys - const contentToTranslate: Record = {}; - for (const key of keysToTranslate) { - contentToTranslate[key] = sourceObj[key]; - } - - return { - type: "success", - prompt: createRecordPrompt(contentToTranslate, options), - }; -} - -export function typescriptUpdate(options: UpdateOptions): UpdateResult { - // Parse the translated content - const translatedObj = Function( - `return ${options.promptResult.replace(/as const;?/g, "")}`, - )(); - - // Merge with existing translations if not force translating - const finalObj = options.force - ? translatedObj - : { - ...(options.content - ? Function( - `return ${options.content.replace(/export default |as const;/g, "")}`, - )() - : {}), - ...translatedObj, - }; - - return { - summary: `Translated ${Object.keys(translatedObj).length} ${options.force ? "total" : "new"} keys`, - content: `export default ${JSON.stringify(finalObj, null, 2)} as const;\n`, - }; -} diff --git a/packages/cli/src/adapters/json.ts b/packages/cli/src/adapters/json.ts deleted file mode 100644 index 456fbc9..0000000 --- a/packages/cli/src/adapters/json.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createRecordPrompt } from "../prompt.js"; -import type { - PromptOptions, - PromptResult, - UpdateOptions, - UpdateResult, -} from "../types.js"; -import { extractChangedKeys } from "../utils.js"; - -export function jsonPrompt(options: PromptOptions): PromptResult { - const sourceObj = JSON.parse(options.content); - - const keysToTranslate = options.force - ? Object.keys(sourceObj) - : extractChangedKeys(options.diff).addedKeys; - - if (keysToTranslate.length === 0) { - return { type: "skip" }; - } - - const contentToTranslate: Record = {}; - for (const key of keysToTranslate) { - contentToTranslate[key] = sourceObj[key]; - } - - return { - type: "success", - prompt: createRecordPrompt(contentToTranslate, options), - }; -} - -export function jsonUpdate(options: UpdateOptions): UpdateResult { - const translatedObj = JSON.parse(options.promptResult); - - // Merge with existing translations if not force translating - const finalObj = options.force - ? translatedObj - : { - ...(options.content ? JSON.parse(options.content) : {}), - ...translatedObj, - }; - - // Format the final content - return { - summary: `Translated ${Object.keys(translatedObj).length} ${options.force ? "total" : "new"} keys`, - content: JSON.stringify(finalObj, null, 2), - }; -} diff --git a/packages/cli/src/adapters/md.ts b/packages/cli/src/adapters/md.ts deleted file mode 100644 index fd500eb..0000000 --- a/packages/cli/src/adapters/md.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { - PromptOptions, - PromptResult, - UpdateOptions, - UpdateResult, -} from "../types.js"; -import { createBasePrompt } from "../prompt.js"; - -function extractDiff(diff: string) { - const added: number[] = []; - const removed: number[] = []; - const lines = diff.split("\n"); - - lines.forEach((line, i) => { - if (line.startsWith("+") && !line.startsWith("+++")) added.push(i); - else if (line.startsWith("-") && !line.startsWith("---")) removed.push(i); - }); - - return { added, removed }; -} - -type ModifiedPromptResult = PromptResult & { - parsedDiff?: ReturnType; -}; - -// TODO: find a better way to translate markdown diffs -// docs usually have a context, it's better to provide the full-context of original text to help AI -export function markdownPrompt(options: PromptOptions): ModifiedPromptResult { - let resultPrompt = "Return only the translated content"; - let parsedDiff: ReturnType | undefined; - - if (!options.force) { - parsedDiff = extractDiff(options.content); - const lineNumbers = parsedDiff.added.map((num) => num + 1); - - if (lineNumbers.length === 0) return { type: "skip" }; - - resultPrompt = `Translate only ${lineNumbers.map((num) => `line ${num}`).join(", ")}, and return in the form of a JSON array like: - ${JSON.stringify(lineNumbers.map((num) => `translated content of line ${num}`))} - \`\`\` - `; - } - - return { - type: "success", - parsedDiff, - prompt: createBasePrompt( - ` - Translation Requirements: - - Only translate frontmatter, and text content (including those in HTML/JSX) - - Keep original line breaks, code and codeblocks - - Keep consistent capitalization, and spacing - - Provide natural, culturally-adapted translations that sound native - - Retain all code elements like variables, functions, and control structures - - Keep any code comments - - Handle special characters and escape sequences correctly - - Respect existing whitespace and newline patterns - - Keep all technical identifiers unchanged - - ${resultPrompt}. - - Source content: - ${options.content} - `, - options, - ), - }; -} - -export function markdownUpdate(options: UpdateOptions): UpdateResult { - const { parsedDiff } = options.prompt as ModifiedPromptResult; - - if (options.force || !parsedDiff) { - return { content: options.promptResult }; - } - - const lines = options.content?.split("\n") ?? []; - const arr = JSON.parse(options.promptResult) as string[]; - let offset = 0; - arr.forEach((translated, i) => { - const lineNum = parsedDiff.added[i]; - - // perform addition - lines.splice(offset + lineNum, 0, translated); - offset++; - }); - - for (const lineNum of parsedDiff.removed) { - lines.splice(offset + lineNum, 1); - offset--; - } - - return { - summary: `Translated ${arr.length} lines`, - content: lines.join("\n"), - }; -} diff --git a/packages/cli/src/commands/translate.ts b/packages/cli/src/commands/translate.ts index dd070ef..92bc02c 100644 --- a/packages/cli/src/commands/translate.ts +++ b/packages/cli/src/commands/translate.ts @@ -3,10 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createOpenAI } from "@ai-sdk/openai"; import { intro, outro, spinner } from "@clack/prompts"; -import { generateText } from "ai"; import chalk from "chalk"; import { getApiKey, getConfig } from "../utils.js"; -import { getAdapter } from "../adapters/index.js"; +import { getTranslator } from "../translators/index.js"; +import type { PromptOptions, UpdateResult } from "../types.js"; export async function translate(targetLocale?: string, force: boolean = false) { intro("Starting translation process..."); @@ -30,6 +30,7 @@ export async function translate(targetLocale?: string, force: boolean = false) { const openai = createOpenAI({ apiKey: await getApiKey("OpenAI", "OPENAI_API_KEY"), }); + const model = openai(config.openai.model); const s = spinner(); s.start("Checking for changes and translating to target locales..."); @@ -61,34 +62,9 @@ export async function translate(targetLocale?: string, force: boolean = false) { "utf-8", ); - const adapter = await getAdapter(format); - if (!adapter) { - throw new Error(`No available adapter for format: ${format}`); - } - - const prompt = await adapter.onPrompt({ - config, - content: sourceContent, - diff: diff ?? "", - force, - format, - sourceLocale: source, - targetLocale: locale, - }); - - if (prompt.type === "skip") { - return { locale, sourcePath, success: true, noChanges: true }; - } - - // Get translation from OpenAI - const { text } = await generateText({ - model: openai(config.openai.model), - prompt: prompt.prompt, - }); - - let targetContent = undefined; + let previousTranslation = undefined; try { - targetContent = await fs.readFile( + previousTranslation = await fs.readFile( path.join(process.cwd(), targetPath), "utf-8", ); @@ -100,12 +76,29 @@ export async function translate(targetLocale?: string, force: boolean = false) { await fs.mkdir(targetDir, { recursive: true }); } - let { content: finalContent, summary } = await adapter.onUpdate({ - force, - prompt, - promptResult: text, - content: targetContent, - }); + const adapter = await getTranslator(format); + if (!adapter) { + throw new Error(`No available adapter for format: ${format}`); + } + + const options: PromptOptions = { + config, + contentLocale: source, + format, + model, + targetLocale: locale, + content: sourceContent, + }; + + let { content: finalContent, summary } = ( + previousTranslation + ? await adapter.onUpdate({ + ...options, + previousTranslation, + diff, + }) + : await adapter.onNew(options) + ) as UpdateResult; // Run afterTranslate hook if defined if (config.hooks?.afterTranslate) { diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index f571b09..d34c112 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -5,7 +5,7 @@ export function createBasePrompt(text: string, options: PromptOptions) { return dedent` You are a professional translator working with ${options.format.toUpperCase()} files. - Task: Translate the content below from ${options.sourceLocale} to ${options.targetLocale}. + Task: Translate the content below from ${options.contentLocale} to ${options.targetLocale}. ${options.config.instructions ?? ""} ${text} `; @@ -19,8 +19,7 @@ export function createRecordPrompt( options: PromptOptions, ) { return createBasePrompt( - `${options.force ? "" : "Only translate the new keys provided."} - + ` Translation Requirements: - Maintain exact file structure, indentation, and formatting - Only translate text content within quotation marks @@ -36,7 +35,7 @@ export function createRecordPrompt( - Translate only user-facing strings - Never add space before a ! or ? - Source content ${options.force ? "" : "(new keys only)"}: + Source content: ${JSON.stringify(parsedContent, null, 2)} Return only the translated content with identical structure. diff --git a/packages/cli/src/translators/index.ts b/packages/cli/src/translators/index.ts new file mode 100644 index 0000000..3260558 --- /dev/null +++ b/packages/cli/src/translators/index.ts @@ -0,0 +1,32 @@ +import type { + Awaitable, + PromptOptions, + PromptResult, + Translator, + UpdateOptions, + UpdateResult, +} from "../types.js"; +import { javascript } from "./js.js"; +import { json } from "./json.js"; +import { markdown } from "./md.js"; + +/** + * Get adapter from file extension/format + * + * This will lazy-load the adapters to reduce memory usage and improve server performance + */ +export async function getTranslator( + format: string, +): Promise { + if (format === "ts" || format === "js") { + return javascript; + } + + if (format === "json" || format === "json5") { + return json; + } + + if (format === "md" || format === "mdx") { + return markdown; + } +} diff --git a/packages/cli/src/translators/js.ts b/packages/cli/src/translators/js.ts new file mode 100644 index 0000000..6e1012c --- /dev/null +++ b/packages/cli/src/translators/js.ts @@ -0,0 +1,63 @@ +import { generateObject } from "ai"; +import { createRecordPrompt } from "../prompt.js"; +import { extractChangedKeys } from "../utils.js"; +import type { Translator } from "../types.js"; + +function parse(content: string) { + return Function( + `return ${content.replace(/export default |as const;/g, "")}`, + )(); +} + +export const javascript: Translator = { + async onUpdate(options) { + const sourceObj = parse(options.content); + + const changes = extractChangedKeys(options.diff); + // Parse the translated content + let translatedObj: object = {}; + + if (changes.addedKeys.length > 0) { + // If force is true, translate everything. Otherwise only new keys + const contentToTranslate: Record = {}; + for (const key of changes.addedKeys) { + contentToTranslate[key] = sourceObj[key]; + } + + const { object } = await generateObject({ + model: options.model, + prompt: createRecordPrompt(contentToTranslate, options), + output: "no-schema", + }); + + translatedObj = object as object; + } + + const finalObj = { + ...parse(options.previousTranslation), + ...translatedObj, + }; + + for (const key of changes.removedKeys) { + delete finalObj[key]; + } + + return { + summary: `Translated ${Object.keys(translatedObj).length} new keys`, + content: `export default ${JSON.stringify(finalObj, null, 2)} as const;\n`, + }; + }, + async onNew(options) { + const sourceObj = parse(options.content); + + const { object } = await generateObject({ + model: options.model, + prompt: createRecordPrompt(sourceObj, options), + output: "no-schema", + }); + + return { + content: `export default ${JSON.stringify(object, null, 2)} as const;\n`, + }; + }, +}; diff --git a/packages/cli/src/translators/json.ts b/packages/cli/src/translators/json.ts new file mode 100644 index 0000000..adb8588 --- /dev/null +++ b/packages/cli/src/translators/json.ts @@ -0,0 +1,52 @@ +import { generateObject } from "ai"; +import { createRecordPrompt } from "../prompt.js"; +import { extractChangedKeys } from "../utils.js"; +import type { Translator } from "../types.js"; + +export const json: Translator = { + async onUpdate(options) { + const sourceObj = JSON.parse(options.content); + const changes = extractChangedKeys(options.diff); + + let translated: object | undefined; + if (changes.addedKeys.length > 0) { + const contentToTranslate: Record = {}; + for (const key of changes.addedKeys) { + contentToTranslate[key] = sourceObj[key]; + } + + translated = await generateObject({ + model: options.model, + prompt: createRecordPrompt(contentToTranslate, options), + output: "no-schema", + }).then((res) => res.object as object); + } + + const mapped = { + ...JSON.parse(options.previousTranslation), + ...(translated as object), + }; + + for (const key of changes.removedKeys) { + delete mapped[key]; + } + + return { + summary: `Translated ${changes.addedKeys.length} new keys`, + content: JSON.stringify(mapped, null, 2), + }; + }, + async onNew(options) { + const sourceObj = JSON.parse(options.content); + + const { object } = await generateObject({ + model: options.model, + prompt: createRecordPrompt(sourceObj, options), + output: "no-schema", + }); + + return { + content: JSON.stringify(object, null, 2), + }; + }, +}; diff --git a/packages/cli/src/translators/md.ts b/packages/cli/src/translators/md.ts new file mode 100644 index 0000000..519d5cc --- /dev/null +++ b/packages/cli/src/translators/md.ts @@ -0,0 +1,104 @@ +import type { PromptOptions, Translator } from "../types.js"; +import { createBasePrompt } from "../prompt.js"; +import dedent from "dedent"; +import { generateObject, generateText } from "ai"; +import { z } from "zod"; +import { getChangedContent } from "../utils.js"; + +function extractDiff(diff: string) { + const added: number[] = []; + const removed: number[] = []; + const lines = diff.split("\n"); + + lines.forEach((line, i) => { + if (line.startsWith("+") && !line.startsWith("+++")) added.push(i); + else if (line.startsWith("-") && !line.startsWith("---")) removed.push(i); + }); + + return { + lines, + added, + removed, + // translate only non-empty lines + translate: added.filter((num) => lines[num].length > 1), + }; +} + +// docs usually have a context, it's better to provide the full-context of original text to help AI +export const markdown: Translator = { + async onUpdate(options) { + const parsedDiff = extractDiff(options.diff); + const linesToTranslate = parsedDiff.translate; + + const resultPrompt = dedent` + Translate only ${linesToTranslate.map((num) => `line ${num + 1}`).join(", ")}, and return in the form of a JSON array like: + ${JSON.stringify(linesToTranslate.map((num) => `translated content of line ${num + 1}`))}`; + + const { object: arr } = await generateObject({ + model: options.model, + schema: z.array(z.string()), + prompt: getPrompt( + `${resultPrompt} + +Source Content: +${options.content}`, + options, + ), + }); + + const lines = options.previousTranslation.split("\n"); + + for (const lineIdx of parsedDiff.added) { + const i = parsedDiff.translate.indexOf(lineIdx); + + lines.splice( + lineIdx, + 0, + i === -1 ? getChangedContent(parsedDiff.lines[lineIdx]) : arr[i], + ); + } + + for (const lineIdx of parsedDiff.removed) { + lines.splice(lineIdx, 1); + } + + return { + summary: `Translated ${arr.length} lines`, + content: lines.join("\n"), + }; + }, + async onNew(options) { + const { text } = await generateText({ + model: options.model, + prompt: getPrompt( + `Return only the translated content + +Source Content: +${options.content}`, + options, + ), + }); + + return { + content: text, + }; + }, +}; + +function getPrompt(base: string, options: PromptOptions) { + const text = dedent` + Translation Requirements: + - Only translate frontmatter, and text content (including those in HTML/JSX) + - Keep original code comments, line breaks, code, and codeblocks + - Keep consistent capitalization, and spacing + - Provide natural, culturally-adapted translations that sound native + - Retain all code elements like variables, functions, and control structures + - Handle special characters and escape sequences correctly + - Respect existing whitespace and newline patterns + - Keep all technical identifiers unchanged + + ${base} + `; + + return createBasePrompt(text, options); +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 21d5c92..2ce7e6e 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,3 +1,5 @@ +import type { LanguageModelV1 } from "ai"; + export interface Config { version: string; locale: { @@ -23,37 +25,25 @@ export interface Config { export interface PromptOptions { format: string; + content: string; + contentLocale: string; targetLocale: string; - sourceLocale: string; - - force: boolean; - - diff: string; - content: string; config: Config; + model: LanguageModelV1; } -export type PromptResult = - | { - type: "success"; - prompt: string; - } - | { - type: "skip"; - }; - -export interface UpdateOptions { - promptResult: string; - prompt: Extract; - - force: boolean; +export interface PromptResult { + content: string; +} +export interface UpdateOptions extends PromptOptions { /** - * Content to update (translated file) + * Content to update (translated content) */ - content?: string; + previousTranslation: string; + diff: string; } export interface UpdateResult { @@ -66,3 +56,8 @@ export interface UpdateResult { } export type Awaitable = T | Promise; + +export interface Translator { + onNew: (options: PromptOptions) => Awaitable; + onUpdate: (options: UpdateOptions) => Awaitable; +} diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 2196d65..c26a89c 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -122,6 +122,18 @@ export function extractChangedKeys(diff: string) { }; } +export function getChangedContent(diff: string) { + return diff + .split("\n") + .flatMap((v) => { + if (v.startsWith("-") && !v.startsWith("---")) return []; + if (v.startsWith("+") && !v.startsWith("+++")) return v.slice(1); + + return v; + }) + .join("\n"); +} + export function updateConfig(config: Config) { fs.writeFileSync(configPath, `export default ${JSON.stringify(config)}`); } diff --git a/packages/cli/test/md.test.ts b/packages/cli/test/md.test.ts new file mode 100644 index 0000000..cf7066c --- /dev/null +++ b/packages/cli/test/md.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "vitest"; +import { markdown } from "../src/translators/md.js"; +import { Config } from "../src/types.js"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { MockLanguageModelV1 } from "ai/test"; +import { getChangedContent } from "../src/utils.js"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); + +test("markdown adapter: new", async () => { + const result = await markdown.onNew({ + config: {} as unknown as Config, + content: await readFile(path.join(dir, "resources/md-new.md")).then((res) => + res.toString(), + ), + format: "mdx", + contentLocale: "en", + targetLocale: "cn", + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: "json", + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + text: `你好,世界,这是一个用于测试翻译的测试文档。`, + }), + }), + }); + + await expect(result.content).toMatchFileSnapshot("snapshots/md-new.md"); +}); + +test("markdown adapter: diff", async () => { + const diff = await readFile(path.join(dir, "resources/md-diff.diff.md")).then( + (res) => res.toString(), + ); + + const result = await markdown.onUpdate({ + config: {} as unknown as Config, + content: getChangedContent(diff), + previousTranslation: await readFile( + path.join(dir, "resources/md-diff.translated.md"), + ).then((res) => res.toString()), + diff, + format: "md", + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: "json", + async doGenerate(v) { + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + text: `[ +"你好,世界,这是一个用于测试翻译的测试文档。", +"
你好,世界
"]`, + }; + }, + }), + contentLocale: "en", + targetLocale: "cn", + }); + + await expect(result.content).toMatchFileSnapshot("snapshots/md-diff.md"); +}); diff --git a/packages/cli/test/resources/md-diff.diff.md b/packages/cli/test/resources/md-diff.diff.md new file mode 100644 index 0000000..c4b6e6a --- /dev/null +++ b/packages/cli/test/resources/md-diff.diff.md @@ -0,0 +1,11 @@ ++Hello world, this is a test document for testing translation. ++ +```tsx +export default function Layout() { + return
Hello World
+} +``` + ++
Hello World
++ +- There's some code: `console.log("Hello Friend!")`, I love Next.js \ No newline at end of file diff --git a/packages/cli/test/resources/md-diff.translated.md b/packages/cli/test/resources/md-diff.translated.md new file mode 100644 index 0000000..c194935 --- /dev/null +++ b/packages/cli/test/resources/md-diff.translated.md @@ -0,0 +1,7 @@ +```tsx +export default function Layout() { + return
Hello World
+} +``` + +这里有一段代码:`console.log("Hello Friend!")`,我喜欢 Next.js \ No newline at end of file diff --git a/packages/cli/test/resources/md-new.md b/packages/cli/test/resources/md-new.md new file mode 100644 index 0000000..b979bc5 --- /dev/null +++ b/packages/cli/test/resources/md-new.md @@ -0,0 +1 @@ +Hello world, this is a test document for testing translation. \ No newline at end of file diff --git a/packages/cli/test/snapshots/md-diff.md b/packages/cli/test/snapshots/md-diff.md new file mode 100644 index 0000000..b25d5ec --- /dev/null +++ b/packages/cli/test/snapshots/md-diff.md @@ -0,0 +1,9 @@ +你好,世界,这是一个用于测试翻译的测试文档。 + +```tsx +export default function Layout() { + return
Hello World
+} +``` + +
你好,世界
diff --git a/packages/cli/test/snapshots/md-new.md b/packages/cli/test/snapshots/md-new.md new file mode 100644 index 0000000..48645bf --- /dev/null +++ b/packages/cli/test/snapshots/md-new.md @@ -0,0 +1 @@ +你好,世界,这是一个用于测试翻译的测试文档。 \ No newline at end of file diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index c398b74..c7897d9 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "test/md.test.ts"], "compilerOptions": { "target": "esnext", "module": "NodeNext", From 092e3794ba2d4cf8cd51539512091695afcbf789 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Tue, 24 Dec 2024 21:51:09 +0800 Subject: [PATCH 4/8] add unit tests & fix bugs --- packages/cli/src/commands/translate.ts | 2 +- packages/cli/src/translators/js.ts | 11 ++- packages/cli/src/translators/json.ts | 10 ++- packages/cli/src/translators/md.ts | 8 +- packages/cli/src/utils.ts | 25 +++--- packages/cli/test/json.test.ts | 81 +++++++++++++++++++ .../cli/test/resources/json-diff.diff.txt | 13 +++ .../test/resources/json-diff.translated.json | 12 +++ packages/cli/test/resources/json-new.json | 12 +++ packages/cli/test/snapshots/json-diff.json | 12 +++ packages/cli/test/snapshots/json-new.json | 12 +++ 11 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 packages/cli/test/json.test.ts create mode 100644 packages/cli/test/resources/json-diff.diff.txt create mode 100644 packages/cli/test/resources/json-diff.translated.json create mode 100644 packages/cli/test/resources/json-new.json create mode 100644 packages/cli/test/snapshots/json-diff.json create mode 100644 packages/cli/test/snapshots/json-new.json diff --git a/packages/cli/src/commands/translate.ts b/packages/cli/src/commands/translate.ts index 92bc02c..323ea48 100644 --- a/packages/cli/src/commands/translate.ts +++ b/packages/cli/src/commands/translate.ts @@ -91,7 +91,7 @@ export async function translate(targetLocale?: string, force: boolean = false) { }; let { content: finalContent, summary } = ( - previousTranslation + previousTranslation && !force ? await adapter.onUpdate({ ...options, previousTranslation, diff --git a/packages/cli/src/translators/js.ts b/packages/cli/src/translators/js.ts index 6e1012c..9d4dc99 100644 --- a/packages/cli/src/translators/js.ts +++ b/packages/cli/src/translators/js.ts @@ -33,18 +33,17 @@ export const javascript: Translator = { translatedObj = object as object; } - const finalObj = { - ...parse(options.previousTranslation), - ...translatedObj, - }; + const output = parse(options.previousTranslation); for (const key of changes.removedKeys) { - delete finalObj[key]; + delete output[key]; } + Object.assign(output, translatedObj); + return { summary: `Translated ${Object.keys(translatedObj).length} new keys`, - content: `export default ${JSON.stringify(finalObj, null, 2)} as const;\n`, + content: `export default ${JSON.stringify(output, null, 2)} as const;\n`, }; }, async onNew(options) { diff --git a/packages/cli/src/translators/json.ts b/packages/cli/src/translators/json.ts index adb8588..0d22123 100644 --- a/packages/cli/src/translators/json.ts +++ b/packages/cli/src/translators/json.ts @@ -22,18 +22,20 @@ export const json: Translator = { }).then((res) => res.object as object); } - const mapped = { + const output = { ...JSON.parse(options.previousTranslation), - ...(translated as object), + ...translated, }; for (const key of changes.removedKeys) { - delete mapped[key]; + if (!translated || !Object.keys(translated).includes(key)) { + delete output[key]; + } } return { summary: `Translated ${changes.addedKeys.length} new keys`, - content: JSON.stringify(mapped, null, 2), + content: JSON.stringify(output, null, 2), }; }, async onNew(options) { diff --git a/packages/cli/src/translators/md.ts b/packages/cli/src/translators/md.ts index 519d5cc..83867b8 100644 --- a/packages/cli/src/translators/md.ts +++ b/packages/cli/src/translators/md.ts @@ -30,15 +30,13 @@ export const markdown: Translator = { const parsedDiff = extractDiff(options.diff); const linesToTranslate = parsedDiff.translate; - const resultPrompt = dedent` - Translate only ${linesToTranslate.map((num) => `line ${num + 1}`).join(", ")}, and return in the form of a JSON array like: - ${JSON.stringify(linesToTranslate.map((num) => `translated content of line ${num + 1}`))}`; - const { object: arr } = await generateObject({ model: options.model, schema: z.array(z.string()), prompt: getPrompt( - `${resultPrompt} + ` +Translate only ${linesToTranslate.map((num) => `line ${num + 1}`).join(", ")}, and return in the form of a JSON array like: +${JSON.stringify(linesToTranslate.map((num) => `translated content of line ${num + 1}`))} Source Content: ${options.content}`, diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index c26a89c..db2248d 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -87,13 +87,18 @@ export function extractChangedKeys(diff: string) { for (const line of diff.split("\n")) { if (line.startsWith("+") && !line.startsWith("+++")) { // Handle both quoted and unquoted keys - const quotedMatch = line.match(/["']([\w_.#]+)["']/); - const unquotedMatch = line.match(/^[+]\s*(\w+):\s*"[^"]*"/); + let match = line.slice(1).match(/["']([\w_.#]+)["']/); - if (quotedMatch) { - addedKeys.add(quotedMatch[1]); - } else if (unquotedMatch) { - addedKeys.add(unquotedMatch[1]); + if (match) { + addedKeys.add(match[1]); + continue; + } + + match = line.slice(1).match(/^[+]\s*(\w+):\s*"[^"]*"/); + + if (match) { + addedKeys.add(match[1]); + continue; } } else if (line.startsWith("-") && !line.startsWith("---")) { // Handle both quoted and unquoted keys @@ -108,14 +113,6 @@ export function extractChangedKeys(diff: string) { } } - // Remove keys that appear in both added and removed (these are modifications) - for (const key of addedKeys) { - if (removedKeys.has(key)) { - addedKeys.delete(key); - removedKeys.delete(key); - } - } - return { addedKeys: Array.from(addedKeys), removedKeys: Array.from(removedKeys), diff --git a/packages/cli/test/json.test.ts b/packages/cli/test/json.test.ts new file mode 100644 index 0000000..8cf596e --- /dev/null +++ b/packages/cli/test/json.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "vitest"; +import { Config } from "../src/types.js"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { MockLanguageModelV1 } from "ai/test"; +import { getChangedContent } from "../src/utils.js"; +import { json } from "../src/translators/json.js"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); + +const translated = { + search: "搜索", + searchNoResult: "未找到结果", + toc: "本页内容", + tocNoHeadings: "无标题", + lastUpdate: "最后更新于", + chooseLanguage: "选择语言", + nextPage: "下一页", + previousPage: "上一页", + chooseTheme: "主题", + editOnGithub: "在GitHub上编辑", +}; + +test("JSON adapter: new", async () => { + const result = await json.onNew({ + config: {} as unknown as Config, + content: await readFile(path.join(dir, "resources/json-new.json")).then( + (res) => res.toString(), + ), + format: "json", + contentLocale: "en", + targetLocale: "cn", + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: "json", + async doGenerate(v) { + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + text: JSON.stringify(translated), + }; + }, + }), + }); + + await expect(result.content).toMatchFileSnapshot("snapshots/json-new.json"); +}); + +test("JSON adapter: diff", async () => { + const diff = await readFile( + path.join(dir, "resources/json-diff.diff.txt"), + ).then((res) => res.toString()); + + const result = await json.onUpdate({ + config: {} as unknown as Config, + content: getChangedContent(diff), + previousTranslation: await readFile( + path.join(dir, "resources/json-diff.translated.json"), + ).then((res) => res.toString()), + diff, + format: "json", + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: "json", + async doGenerate(v) { + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + text: JSON.stringify({ + search: translated.search, + }), + }; + }, + }), + contentLocale: "en", + targetLocale: "cn", + }); + + await expect(result.content).toMatchFileSnapshot("snapshots/json-diff.json"); +}); diff --git a/packages/cli/test/resources/json-diff.diff.txt b/packages/cli/test/resources/json-diff.diff.txt new file mode 100644 index 0000000..3c5145b --- /dev/null +++ b/packages/cli/test/resources/json-diff.diff.txt @@ -0,0 +1,13 @@ +{ +- "search": "Search", ++ "search": "Search Docs", + "searchNoResult": "No results found", + "toc": "On this page", + "tocNoHeadings": "No Headings", + "lastUpdate": "Last updated on", + "chooseLanguage": "Choose a language", + "nextPage": "Next", + "previousPage": "Previous", + "chooseTheme": "Theme", + "editOnGithub": "Edit on GitHub" +} diff --git a/packages/cli/test/resources/json-diff.translated.json b/packages/cli/test/resources/json-diff.translated.json new file mode 100644 index 0000000..caffac6 --- /dev/null +++ b/packages/cli/test/resources/json-diff.translated.json @@ -0,0 +1,12 @@ +{ + "search": "", + "searchNoResult": "", + "toc": "", + "tocNoHeadings": "", + "lastUpdate": "", + "chooseLanguage": "", + "nextPage": "", + "previousPage": "", + "chooseTheme": "", + "editOnGithub": "" +} diff --git a/packages/cli/test/resources/json-new.json b/packages/cli/test/resources/json-new.json new file mode 100644 index 0000000..7d465b6 --- /dev/null +++ b/packages/cli/test/resources/json-new.json @@ -0,0 +1,12 @@ +{ + "search": "Search", + "searchNoResult": "No results found", + "toc": "On this page", + "tocNoHeadings": "No Headings", + "lastUpdate": "Last updated on", + "chooseLanguage": "Choose a language", + "nextPage": "Next", + "previousPage": "Previous", + "chooseTheme": "Theme", + "editOnGithub": "Edit on GitHub" +} diff --git a/packages/cli/test/snapshots/json-diff.json b/packages/cli/test/snapshots/json-diff.json new file mode 100644 index 0000000..e1f3055 --- /dev/null +++ b/packages/cli/test/snapshots/json-diff.json @@ -0,0 +1,12 @@ +{ + "search": "搜索", + "searchNoResult": "", + "toc": "", + "tocNoHeadings": "", + "lastUpdate": "", + "chooseLanguage": "", + "nextPage": "", + "previousPage": "", + "chooseTheme": "", + "editOnGithub": "" +} \ No newline at end of file diff --git a/packages/cli/test/snapshots/json-new.json b/packages/cli/test/snapshots/json-new.json new file mode 100644 index 0000000..22617fa --- /dev/null +++ b/packages/cli/test/snapshots/json-new.json @@ -0,0 +1,12 @@ +{ + "search": "搜索", + "searchNoResult": "未找到结果", + "toc": "本页内容", + "tocNoHeadings": "无标题", + "lastUpdate": "最后更新于", + "chooseLanguage": "选择语言", + "nextPage": "下一页", + "previousPage": "上一页", + "chooseTheme": "主题", + "editOnGithub": "在GitHub上编辑" +} From 2f14b14422b763188bb99ad84094593380569697 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Wed, 25 Dec 2024 12:33:02 +0800 Subject: [PATCH 5/8] re-implement existing translators --- biome.json | 3 +- bun.lockb | Bin 157448 -> 159320 bytes packages/cli/package.json | 3 + packages/cli/src/commands/diff.ts | 18 +- packages/cli/src/commands/translate.ts | 34 ++-- packages/cli/src/prompt.ts | 43 +---- packages/cli/src/translators/index.ts | 11 +- packages/cli/src/translators/js.ts | 148 +++++++++++--- packages/cli/src/translators/json.ts | 180 +++++++++++++++--- packages/cli/src/translators/md.ts | 67 ++++--- packages/cli/src/types.ts | 6 +- packages/cli/src/utils.ts | 51 ----- packages/cli/test/js.test.ts | 44 +++++ packages/cli/test/json.test.ts | 42 ++-- packages/cli/test/md.test.ts | 27 +-- packages/cli/test/resources/js-new.js | 10 + .../cli/test/resources/json-diff.diff.txt | 13 -- packages/cli/test/resources/json-diff.json | 8 + .../test/resources/json-diff.previous.json | 8 + .../test/resources/json-diff.translated.json | 16 +- packages/cli/test/resources/json-new.json | 14 +- packages/cli/test/resources/md-diff.diff.md | 11 -- packages/cli/test/resources/md-diff.md | 9 + .../cli/test/resources/md-diff.previous.md | 7 + packages/cli/test/snapshots/js-new.js | 9 + packages/cli/test/snapshots/js-new.prompt.txt | 41 ++++ packages/cli/test/snapshots/json-diff.json | 19 +- .../cli/test/snapshots/json-diff.prompt.txt | 37 ++++ packages/cli/test/snapshots/json-new.json | 21 +- packages/cli/test/snapshots/md-diff.md | 2 +- .../cli/test/snapshots/md-diff.prompt.txt | 40 ++++ 31 files changed, 641 insertions(+), 301 deletions(-) create mode 100644 packages/cli/test/js.test.ts create mode 100644 packages/cli/test/resources/js-new.js delete mode 100644 packages/cli/test/resources/json-diff.diff.txt create mode 100644 packages/cli/test/resources/json-diff.json create mode 100644 packages/cli/test/resources/json-diff.previous.json delete mode 100644 packages/cli/test/resources/md-diff.diff.md create mode 100644 packages/cli/test/resources/md-diff.md create mode 100644 packages/cli/test/resources/md-diff.previous.md create mode 100644 packages/cli/test/snapshots/js-new.js create mode 100644 packages/cli/test/snapshots/js-new.prompt.txt create mode 100644 packages/cli/test/snapshots/json-diff.prompt.txt create mode 100644 packages/cli/test/snapshots/md-diff.prompt.txt diff --git a/biome.json b/biome.json index e960d4e..4258e45 100644 --- a/biome.json +++ b/biome.json @@ -21,6 +21,7 @@ } }, "formatter": { - "indentStyle": "space" + "indentStyle": "space", + "ignore": ["test/resources", "test/snapshots"] } } diff --git a/bun.lockb b/bun.lockb index a0e44c661eebbae491373d5ea44f4f4fa4fec443..8d27bf0ec3e73e73a2d08d33e50da796851068d6 100755 GIT binary patch delta 9779 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 delta 8660 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 ""); + + if (previousContent === sourceContent) + return { locale, sourcePath, success: true, noChanges: true }; + } + let previousTranslation = undefined; try { previousTranslation = await fs.readFile( @@ -90,15 +89,14 @@ export async function translate(targetLocale?: string, force: boolean = false) { content: sourceContent, }; - let { content: finalContent, summary } = ( - previousTranslation && !force + let { content: finalContent, summary } = + previousTranslation && previousContent && !force ? await adapter.onUpdate({ ...options, previousTranslation, - diff, + previousContent, }) - : await adapter.onNew(options) - ) as UpdateResult; + : ((await adapter.onNew(options)) as UpdateResult); // Run afterTranslate hook if defined if (config.hooks?.afterTranslate) { diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index d34c112..0321639 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -1,6 +1,16 @@ import dedent from "dedent"; import type { PromptOptions } from "./types.js"; +export const baseRequirements = dedent` +Translation Requirements: +- Maintain exact file structure, indentation, and formatting +- Provide natural, culturally-adapted translations that sound native +- Keep all technical identifiers unchanged +- Keep consistent capitalization, spacing, and line breaks +- Respect existing whitespace and newline patterns +- Never add space before a ! or ? +`; + export function createBasePrompt(text: string, options: PromptOptions) { return dedent` You are a professional translator working with ${options.format.toUpperCase()} files. @@ -10,36 +20,3 @@ export function createBasePrompt(text: string, options: PromptOptions) { ${text} `; } - -/** - * Create prompt for record-like objects - */ -export function createRecordPrompt( - parsedContent: Record, - options: PromptOptions, -) { - return createBasePrompt( - ` - Translation Requirements: - - Maintain exact file structure, indentation, and formatting - - Only translate text content within quotation marks - - Preserve all object/property keys, syntax characters, and punctuation marks exactly - - Keep consistent capitalization, spacing, and line breaks - - Provide natural, culturally-adapted translations that sound native - - Retain all code elements like variables, functions, and control structures - - Exclude any translator notes, comments or explanatory text - - Match source file's JSON/object structure precisely - - Handle special characters and escape sequences correctly - - Respect existing whitespace and newline patterns - - Keep all technical identifiers unchanged - - Translate only user-facing strings - - Never add space before a ! or ? - - Source content: - ${JSON.stringify(parsedContent, null, 2)} - - Return only the translated content with identical structure. - `, - options, - ); -} diff --git a/packages/cli/src/translators/index.ts b/packages/cli/src/translators/index.ts index 3260558..fa2110f 100644 --- a/packages/cli/src/translators/index.ts +++ b/packages/cli/src/translators/index.ts @@ -1,11 +1,4 @@ -import type { - Awaitable, - PromptOptions, - PromptResult, - Translator, - UpdateOptions, - UpdateResult, -} from "../types.js"; +import type { Translator } from "../types.js"; import { javascript } from "./js.js"; import { json } from "./json.js"; import { markdown } from "./md.js"; @@ -22,7 +15,7 @@ export async function getTranslator( return javascript; } - if (format === "json" || format === "json5") { + if (format === "json") { return json; } diff --git a/packages/cli/src/translators/js.ts b/packages/cli/src/translators/js.ts index 9d4dc99..575b524 100644 --- a/packages/cli/src/translators/js.ts +++ b/packages/cli/src/translators/js.ts @@ -1,62 +1,148 @@ import { generateObject } from "ai"; -import { createRecordPrompt } from "../prompt.js"; -import { extractChangedKeys } from "../utils.js"; -import type { Translator } from "../types.js"; - -function parse(content: string) { - return Function( - `return ${content.replace(/export default |as const;/g, "")}`, - )(); +import { baseRequirements, createBasePrompt } from "../prompt.js"; +import type { PromptOptions, Translator } from "../types.js"; +import { diffLines } from "diff"; +import { z } from "zod"; + +function createRegex(quote: string, multiline = false) { + return `${quote}(?:\\\\.|[^${quote}\\\\${multiline ? "" : "\\n"}])*${quote}`; +} + +const quotesRegex = new RegExp( + `${createRegex(`"`)}|${createRegex(`'`)}|${createRegex(`\``, true)}`, + "g", +); + +interface StringMatch { + index: number; + + /** + * content, including quotes + */ + content: string; +} + +/** + * Get declared strings from code (e.g. "hello world" or `hello ${world}`) + */ +function getStrings(code: string) { + let match = quotesRegex.exec(code); + + const strings: StringMatch[] = []; + + while (match) { + strings.push({ + index: match.index, + content: match[0], + }); + + match = quotesRegex.exec(code); + } + + return strings; +} + +function replaceStrings( + code: string, + strings: StringMatch[], + replaces: string[], +) { + let out = code; + + replaces.forEach((replace, i) => { + const original = strings[i]; + const offset = out.length - code.length; + + out = + out.slice(0, original.index + offset) + + replace + + out.slice(original.index + original.content.length + offset); + }); + + return out; } export const javascript: Translator = { + // detect changes + // translate changes + // apply translated changes to previous translation (assuming line breaks are identical) async onUpdate(options) { - const sourceObj = parse(options.content); + const diff = diffLines(options.previousContent, options.content); + const strings = getStrings(options.content); + const previousTranslation = getStrings(options.previousTranslation); + const toTranslate: StringMatch[] = []; + + let lineStartIdx = 0; + diff.forEach((change) => { + if (change.added) { + const affected = strings.filter( + (v) => + v.index >= lineStartIdx && + v.index < lineStartIdx + change.value.length, + ); - const changes = extractChangedKeys(options.diff); - // Parse the translated content - let translatedObj: object = {}; + toTranslate.push(...affected); + } - if (changes.addedKeys.length > 0) { - // If force is true, translate everything. Otherwise only new keys - const contentToTranslate: Record = {}; - for (const key of changes.addedKeys) { - contentToTranslate[key] = sourceObj[key]; + if (!change.removed) { + lineStartIdx += change.value.length; } + }); + + let translated: string[] = []; + if (toTranslate.length > 0) { const { object } = await generateObject({ model: options.model, - prompt: createRecordPrompt(contentToTranslate, options), - output: "no-schema", + prompt: getPrompt(toTranslate, options), + schema: z.array(z.string()), }); - translatedObj = object as object; + translated = object; } - const output = parse(options.previousTranslation); + const output = replaceStrings( + options.previousTranslation, + previousTranslation, + strings.map((s, i) => { + const j = toTranslate.indexOf(s); - for (const key of changes.removedKeys) { - delete output[key]; - } + if (j !== -1) { + return translated[j]; + } - Object.assign(output, translatedObj); + return previousTranslation[i].content; + }), + ); return { - summary: `Translated ${Object.keys(translatedObj).length} new keys`, - content: `export default ${JSON.stringify(output, null, 2)} as const;\n`, + summary: `Translated ${toTranslate.length} new keys`, + content: output, }; }, async onNew(options) { - const sourceObj = parse(options.content); + const strings = getStrings(options.content); const { object } = await generateObject({ model: options.model, - prompt: createRecordPrompt(sourceObj, options), - output: "no-schema", + prompt: getPrompt(strings, options), + schema: z.array(z.string()), }); return { - content: `export default ${JSON.stringify(object, null, 2)} as const;\n`, + content: replaceStrings(options.content, strings, object), }; }, }; + +function getPrompt(strings: StringMatch[], options: PromptOptions) { + return createBasePrompt( + `${baseRequirements} + - Preserve all object/property keys, syntax characters, and punctuation marks exactly + - Only translate text content within quotation marks + + A list of javascript codeblocks, return the translated javascript code in a JSON array, make sure to escape special characters like line breaks: + ${strings.map((v) => `\`\`\`${options.format}\n${v.content}\n\`\`\``).join("\n\n")}`, + options, + ); +} diff --git a/packages/cli/src/translators/json.ts b/packages/cli/src/translators/json.ts index 0d22123..ed5abb2 100644 --- a/packages/cli/src/translators/json.ts +++ b/packages/cli/src/translators/json.ts @@ -1,36 +1,36 @@ import { generateObject } from "ai"; -import { createRecordPrompt } from "../prompt.js"; -import { extractChangedKeys } from "../utils.js"; -import type { Translator } from "../types.js"; +import { baseRequirements, createBasePrompt } from "../prompt.js"; +import type { PromptOptions, Translator } from "../types.js"; export const json: Translator = { async onUpdate(options) { const sourceObj = JSON.parse(options.content); - const changes = extractChangedKeys(options.diff); + const previousObj = JSON.parse(options.previousContent); + const changes = extractChangedKeys(previousObj, sourceObj); - let translated: object | undefined; + let translated: unknown | undefined; if (changes.addedKeys.length > 0) { - const contentToTranslate: Record = {}; - for (const key of changes.addedKeys) { - contentToTranslate[key] = sourceObj[key]; - } - translated = await generateObject({ model: options.model, - prompt: createRecordPrompt(contentToTranslate, options), + prompt: getPrompt( + JSON.stringify( + getContentToTranslate(sourceObj, changes.addedKeys), + null, + 2, + ), + options, + ), output: "no-schema", - }).then((res) => res.object as object); + }).then((res) => res.object); } - const output = { - ...JSON.parse(options.previousTranslation), - ...translated, - }; + let output = JSON.parse(options.previousTranslation); - for (const key of changes.removedKeys) { - if (!translated || !Object.keys(translated).includes(key)) { - delete output[key]; - } + applyRemoves(output, changes.removedKeys); + if (typeof translated === "object" && translated) { + applyAdds(output, translated as Record); + } else { + output = translated; } return { @@ -39,11 +39,9 @@ export const json: Translator = { }; }, async onNew(options) { - const sourceObj = JSON.parse(options.content); - const { object } = await generateObject({ model: options.model, - prompt: createRecordPrompt(sourceObj, options), + prompt: getPrompt(options.content, options), output: "no-schema", }); @@ -52,3 +50,139 @@ export const json: Translator = { }; }, }; + +/** + * extract updated keys of object + */ +export function extractChangedKeys( + previous: Record, + updated: Record, +) { + const addedKeys: string[][] = []; + const removedKeys: string[][] = []; + + for (const key of Object.keys(previous)) { + if (!(key in updated)) { + removedKeys.push([key]); + continue; + } + + if (previous[key] === updated[key]) continue; + + if ( + previous[key] !== null && + updated[key] !== null && + typeof previous[key] === "object" && + typeof updated[key] === "object" + ) { + const changes = extractChangedKeys( + previous[key] as Record, + updated[key] as Record, + ); + + for (const v of [...changes.addedKeys, ...changes.removedKeys]) { + v.unshift(key); + } + + addedKeys.push(...changes.addedKeys); + removedKeys.push(...changes.removedKeys); + } else { + addedKeys.push([key]); + removedKeys.push([key]); + } + } + + for (const key of Object.keys(updated)) { + if (!(key in previous)) addedKeys.push([key]); + } + + return { + addedKeys, + removedKeys, + }; +} + +function applyAdds( + obj: Record, + translated: Record, +) { + for (const key of Object.keys(translated)) { + if (typeof translated[key] === "object") { + obj[key] ??= {}; + applyAdds( + obj[key] as Record, + translated[key] as Record, + ); + } else { + obj[key] = translated[key]; + } + } + + return obj; +} + +function applyRemoves( + previous: Record, + removedKeys: string[][], +) { + for (const key of removedKeys) { + let obj = previous; + + for (let i = 0; i < key.length; i++) { + if (i !== key.length - 1) { + obj = obj[key[i]] as Record; + } else { + delete obj[key[i]]; + } + } + } +} + +function getContentToTranslate( + updated: Record, + addedKeys: string[][], +): unknown { + if (typeof updated !== "object") return updated; + + const obj = updated as Record; + const out: Record = {}; + + for (const key of addedKeys) { + let target = out; + let value = obj; + + for (let i = 0; i < key.length; i++) { + if (i !== key.length - 1) { + if (!(key[i] in value)) break; + + value = value[key[i]] as Record; + + target[key[i]] ??= {}; + target = target[key[i]] as Record; + } else { + target[key[i]] = value[key[i]]; + } + } + } + + return out; +} + +function getPrompt(content: string, options: PromptOptions) { + return createBasePrompt( + ` + ${baseRequirements} + - Only translate text content within quotation marks + - Preserve all object/property keys, syntax characters, and punctuation marks exactly + - Retain all code elements like variables, functions, and control structures + - Exclude any translator notes, comments or explanatory text + - Match source file's JSON/object structure precisely + - Handle special characters and escape sequences correctly + + Source content (JSON): + ${content} + + Return only the translated content with identical structure.`, + options, + ); +} diff --git a/packages/cli/src/translators/md.ts b/packages/cli/src/translators/md.ts index 83867b8..e093755 100644 --- a/packages/cli/src/translators/md.ts +++ b/packages/cli/src/translators/md.ts @@ -1,33 +1,42 @@ import type { PromptOptions, Translator } from "../types.js"; -import { createBasePrompt } from "../prompt.js"; -import dedent from "dedent"; +import { baseRequirements, createBasePrompt } from "../prompt.js"; import { generateObject, generateText } from "ai"; import { z } from "zod"; -import { getChangedContent } from "../utils.js"; +import { diffLines } from "diff"; -function extractDiff(diff: string) { +function extractDiff(pervious: string, content: string) { + const contentLines = content.split("\n"); + const diff = diffLines(pervious, content, { oneChangePerToken: true }); const added: number[] = []; const removed: number[] = []; - const lines = diff.split("\n"); + const translate: number[] = []; - lines.forEach((line, i) => { - if (line.startsWith("+") && !line.startsWith("+++")) added.push(i); - else if (line.startsWith("-") && !line.startsWith("---")) removed.push(i); + diff.forEach((change, i) => { + if (change.added) { + added.push(i); + + // translate only non-empty lines + if (change.value.trim().length > 0) translate.push(i); + } + + if (change.removed) { + removed.push(i); + } }); return { - lines, + contentLines, + diff, added, removed, - // translate only non-empty lines - translate: added.filter((num) => lines[num].length > 1), + translate, }; } // docs usually have a context, it's better to provide the full-context of original text to help AI export const markdown: Translator = { async onUpdate(options) { - const parsedDiff = extractDiff(options.diff); + const parsedDiff = extractDiff(options.previousContent, options.content); const linesToTranslate = parsedDiff.translate; const { object: arr } = await generateObject({ @@ -46,19 +55,21 @@ ${options.content}`, const lines = options.previousTranslation.split("\n"); - for (const lineIdx of parsedDiff.added) { - const i = parsedDiff.translate.indexOf(lineIdx); + parsedDiff.diff.forEach((change, lineIdx) => { + if (change.added) { + const i = parsedDiff.translate.indexOf(lineIdx); - lines.splice( - lineIdx, - 0, - i === -1 ? getChangedContent(parsedDiff.lines[lineIdx]) : arr[i], - ); - } + lines.splice( + lineIdx, + 0, + i === -1 ? parsedDiff.contentLines[lineIdx] : arr[i], + ); + } - for (const lineIdx of parsedDiff.removed) { - lines.splice(lineIdx, 1); - } + if (change.removed) { + lines.splice(lineIdx, 1); + } + }); return { summary: `Translated ${arr.length} lines`, @@ -84,17 +95,13 @@ ${options.content}`, }; function getPrompt(base: string, options: PromptOptions) { - const text = dedent` - Translation Requirements: + const text = ` + ${baseRequirements} - Only translate frontmatter, and text content (including those in HTML/JSX) - Keep original code comments, line breaks, code, and codeblocks - - Keep consistent capitalization, and spacing - - Provide natural, culturally-adapted translations that sound native - Retain all code elements like variables, functions, and control structures - - Handle special characters and escape sequences correctly - Respect existing whitespace and newline patterns - - Keep all technical identifiers unchanged - + ${base} `; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 2ce7e6e..cca29c3 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -43,7 +43,11 @@ export interface UpdateOptions extends PromptOptions { * Content to update (translated content) */ previousTranslation: string; - diff: string; + + /** + * source content before updated + */ + previousContent: string; } export interface UpdateResult { diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index db2248d..8803322 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -80,57 +80,6 @@ export async function getConfig() { return config; } -export function extractChangedKeys(diff: string) { - const addedKeys = new Set(); - const removedKeys = new Set(); - - for (const line of diff.split("\n")) { - if (line.startsWith("+") && !line.startsWith("+++")) { - // Handle both quoted and unquoted keys - let match = line.slice(1).match(/["']([\w_.#]+)["']/); - - if (match) { - addedKeys.add(match[1]); - continue; - } - - match = line.slice(1).match(/^[+]\s*(\w+):\s*"[^"]*"/); - - if (match) { - addedKeys.add(match[1]); - continue; - } - } else if (line.startsWith("-") && !line.startsWith("---")) { - // Handle both quoted and unquoted keys - const quotedMatch = line.match(/["']([\w_.#]+)["']/); - const unquotedMatch = line.match(/^[-]\s*(\w+):\s*"[^"]*"/); - - if (quotedMatch) { - removedKeys.add(quotedMatch[1]); - } else if (unquotedMatch) { - removedKeys.add(unquotedMatch[1]); - } - } - } - - return { - addedKeys: Array.from(addedKeys), - removedKeys: Array.from(removedKeys), - }; -} - -export function getChangedContent(diff: string) { - return diff - .split("\n") - .flatMap((v) => { - if (v.startsWith("-") && !v.startsWith("---")) return []; - if (v.startsWith("+") && !v.startsWith("+++")) return v.slice(1); - - return v; - }) - .join("\n"); -} - export function updateConfig(config: Config) { fs.writeFileSync(configPath, `export default ${JSON.stringify(config)}`); } diff --git a/packages/cli/test/js.test.ts b/packages/cli/test/js.test.ts new file mode 100644 index 0000000..3fe0e03 --- /dev/null +++ b/packages/cli/test/js.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "vitest"; +import { Config } from "../src/types.js"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { MockLanguageModelV1 } from "ai/test"; +import { javascript } from "../src/translators/js.js"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); + +test("JSON adapter: new", async () => { + const result = await javascript.onNew({ + config: {} as unknown as Config, + content: await readFile(path.join(dir, "resources/js-new.js")).then((res) => + res.toString(), + ), + format: "js", + contentLocale: "en", + targetLocale: "cn", + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: "json", + async doGenerate(v) { + await expect(v.prompt.at(-1)?.content).toMatchFileSnapshot( + "snapshots/js-new.prompt.txt", + ); + + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + text: JSON.stringify([ + '"标题"', + '"介绍"', + "'在开始之前,请确保您具备以下条件:\\n一个 GitHub 账户'", + "`您可以在自己的云基础设施上自托管 Midday,以便更好地控制您的数据。\\n 本指南将引导您完成设置 Midday 的整个过程。`", + "`当前时间是 ${Date.now()}`", + ]), + }; + }, + }), + }); + + await expect(result.content).toMatchFileSnapshot("snapshots/js-new.js"); +}); diff --git a/packages/cli/test/json.test.ts b/packages/cli/test/json.test.ts index 8cf596e..3324d41 100644 --- a/packages/cli/test/json.test.ts +++ b/packages/cli/test/json.test.ts @@ -4,22 +4,17 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { MockLanguageModelV1 } from "ai/test"; -import { getChangedContent } from "../src/utils.js"; import { json } from "../src/translators/json.js"; const dir = path.dirname(fileURLToPath(import.meta.url)); const translated = { - search: "搜索", - searchNoResult: "未找到结果", - toc: "本页内容", - tocNoHeadings: "无标题", - lastUpdate: "最后更新于", - chooseLanguage: "选择语言", - nextPage: "下一页", - previousPage: "上一页", - chooseTheme: "主题", - editOnGithub: "在GitHub上编辑", + search: "Updated", + description: "Updated", + nested: { + description: "Updated", + words: ["Updated", "Updated"], + }, }; test("JSON adapter: new", async () => { @@ -48,27 +43,34 @@ test("JSON adapter: new", async () => { }); test("JSON adapter: diff", async () => { - const diff = await readFile( - path.join(dir, "resources/json-diff.diff.txt"), - ).then((res) => res.toString()); - const result = await json.onUpdate({ config: {} as unknown as Config, - content: getChangedContent(diff), - previousTranslation: await readFile( - path.join(dir, "resources/json-diff.translated.json"), - ).then((res) => res.toString()), - diff, + content: ( + await readFile(path.join(dir, "resources/json-diff.json")) + ).toString(), + previousTranslation: ( + await readFile(path.join(dir, "resources/json-diff.translated.json")) + ).toString(), + previousContent: ( + await readFile(path.join(dir, "resources/json-diff.previous.json")) + ).toString(), format: "json", model: new MockLanguageModelV1({ defaultObjectGenerationMode: "json", async doGenerate(v) { + await expect(v.prompt.at(-1)).toMatchFileSnapshot( + "snapshots/json-diff.prompt.txt", + ); + return { rawCall: { rawPrompt: null, rawSettings: {} }, finishReason: "stop", usage: { promptTokens: 10, completionTokens: 20 }, text: JSON.stringify({ search: translated.search, + nested: { + description: translated.nested.description, + }, }), }; }, diff --git a/packages/cli/test/md.test.ts b/packages/cli/test/md.test.ts index cf7066c..91861de 100644 --- a/packages/cli/test/md.test.ts +++ b/packages/cli/test/md.test.ts @@ -5,7 +5,6 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { MockLanguageModelV1 } from "ai/test"; -import { getChangedContent } from "../src/utils.js"; const dir = path.dirname(fileURLToPath(import.meta.url)); @@ -33,28 +32,30 @@ test("markdown adapter: new", async () => { }); test("markdown adapter: diff", async () => { - const diff = await readFile(path.join(dir, "resources/md-diff.diff.md")).then( - (res) => res.toString(), - ); - const result = await markdown.onUpdate({ config: {} as unknown as Config, - content: getChangedContent(diff), - previousTranslation: await readFile( - path.join(dir, "resources/md-diff.translated.md"), - ).then((res) => res.toString()), - diff, + content: ( + await readFile(path.join(dir, "resources/md-diff.md")) + ).toString(), + previousTranslation: ( + await readFile(path.join(dir, "resources/md-diff.translated.md")) + ).toString(), + previousContent: ( + await readFile(path.join(dir, "resources/md-diff.previous.md")) + ).toString(), format: "md", model: new MockLanguageModelV1({ defaultObjectGenerationMode: "json", async doGenerate(v) { + await expect(v.prompt.at(-1)).toMatchFileSnapshot( + "snapshots/md-diff.prompt.txt", + ); + return { rawCall: { rawPrompt: null, rawSettings: {} }, finishReason: "stop", usage: { promptTokens: 10, completionTokens: 20 }, - text: `[ -"你好,世界,这是一个用于测试翻译的测试文档。", -"
你好,世界
"]`, + text: `["你好,世界,这是一个用于测试翻译的测试文档。","
你好,世界
"]`, }; }, }), diff --git a/packages/cli/test/resources/js-new.js b/packages/cli/test/resources/js-new.js new file mode 100644 index 0000000..5be8994 --- /dev/null +++ b/packages/cli/test/resources/js-new.js @@ -0,0 +1,10 @@ + +export default { + "title": "Introduction", + description: 'Before you begin, make sure you have the following:\nA GitHub account', + nested: { + content: `You can self-host Midday on your own cloud infrastructure for greater control over your data. + This guide will walk you through the entire process of setting up Midday.` + }, + dynamic: `the current time is ${Date.now()}` +} diff --git a/packages/cli/test/resources/json-diff.diff.txt b/packages/cli/test/resources/json-diff.diff.txt deleted file mode 100644 index 3c5145b..0000000 --- a/packages/cli/test/resources/json-diff.diff.txt +++ /dev/null @@ -1,13 +0,0 @@ -{ -- "search": "Search", -+ "search": "Search Docs", - "searchNoResult": "No results found", - "toc": "On this page", - "tocNoHeadings": "No Headings", - "lastUpdate": "Last updated on", - "chooseLanguage": "Choose a language", - "nextPage": "Next", - "previousPage": "Previous", - "chooseTheme": "Theme", - "editOnGithub": "Edit on GitHub" -} diff --git a/packages/cli/test/resources/json-diff.json b/packages/cli/test/resources/json-diff.json new file mode 100644 index 0000000..dbb0e1c --- /dev/null +++ b/packages/cli/test/resources/json-diff.json @@ -0,0 +1,8 @@ +{ + "search": "Search Docs", + "description": "Hello World", + "nested": { + "description": "nothing is happening", + "words": ["Next.js", "hope"] + } +} diff --git a/packages/cli/test/resources/json-diff.previous.json b/packages/cli/test/resources/json-diff.previous.json new file mode 100644 index 0000000..6f40bda --- /dev/null +++ b/packages/cli/test/resources/json-diff.previous.json @@ -0,0 +1,8 @@ +{ + "search": "Search", + "description": "Hello World", + "nested": { + "description": "something is happening", + "words": ["Next.js", "hope"] + } +} diff --git a/packages/cli/test/resources/json-diff.translated.json b/packages/cli/test/resources/json-diff.translated.json index caffac6..90c9973 100644 --- a/packages/cli/test/resources/json-diff.translated.json +++ b/packages/cli/test/resources/json-diff.translated.json @@ -1,12 +1,8 @@ { - "search": "", - "searchNoResult": "", - "toc": "", - "tocNoHeadings": "", - "lastUpdate": "", - "chooseLanguage": "", - "nextPage": "", - "previousPage": "", - "chooseTheme": "", - "editOnGithub": "" + "search": "Translated", + "description": "Translated", + "nested": { + "description": "Translated", + "words": ["Translated", "Translated"] + } } diff --git a/packages/cli/test/resources/json-new.json b/packages/cli/test/resources/json-new.json index 7d465b6..6f40bda 100644 --- a/packages/cli/test/resources/json-new.json +++ b/packages/cli/test/resources/json-new.json @@ -1,12 +1,8 @@ { "search": "Search", - "searchNoResult": "No results found", - "toc": "On this page", - "tocNoHeadings": "No Headings", - "lastUpdate": "Last updated on", - "chooseLanguage": "Choose a language", - "nextPage": "Next", - "previousPage": "Previous", - "chooseTheme": "Theme", - "editOnGithub": "Edit on GitHub" + "description": "Hello World", + "nested": { + "description": "something is happening", + "words": ["Next.js", "hope"] + } } diff --git a/packages/cli/test/resources/md-diff.diff.md b/packages/cli/test/resources/md-diff.diff.md deleted file mode 100644 index c4b6e6a..0000000 --- a/packages/cli/test/resources/md-diff.diff.md +++ /dev/null @@ -1,11 +0,0 @@ -+Hello world, this is a test document for testing translation. -+ -```tsx -export default function Layout() { - return
Hello World
-} -``` - -+
Hello World
-+ -- There's some code: `console.log("Hello Friend!")`, I love Next.js \ No newline at end of file diff --git a/packages/cli/test/resources/md-diff.md b/packages/cli/test/resources/md-diff.md new file mode 100644 index 0000000..613f118 --- /dev/null +++ b/packages/cli/test/resources/md-diff.md @@ -0,0 +1,9 @@ +Hello world, this is a test document for testing translation. + +```tsx +export default function Layout() { + return
Hello World
+} +``` + +
Hello World
diff --git a/packages/cli/test/resources/md-diff.previous.md b/packages/cli/test/resources/md-diff.previous.md new file mode 100644 index 0000000..4fed2ef --- /dev/null +++ b/packages/cli/test/resources/md-diff.previous.md @@ -0,0 +1,7 @@ +```tsx +export default function Layout() { + return
Hello World
+} +``` + +There's some code: `console.log("Hello Friend!")`, I love Next.js \ No newline at end of file diff --git a/packages/cli/test/snapshots/js-new.js b/packages/cli/test/snapshots/js-new.js new file mode 100644 index 0000000..3ff7bf9 --- /dev/null +++ b/packages/cli/test/snapshots/js-new.js @@ -0,0 +1,9 @@ + +export default { + "标题": "介绍", + description: '在开始之前,请确保您具备以下条件:\n一个 GitHub 账户', + nested: { + content: `您可以在自己的云基础设施上自托管 Midday,以便更好地控制您的数据。\n 本指南将引导您完成设置 Midday 的整个过程。` + }, + dynamic: `当前时间是 ${Date.now()}` +} diff --git a/packages/cli/test/snapshots/js-new.prompt.txt b/packages/cli/test/snapshots/js-new.prompt.txt new file mode 100644 index 0000000..e38d412 --- /dev/null +++ b/packages/cli/test/snapshots/js-new.prompt.txt @@ -0,0 +1,41 @@ +[ + { + "text": "You are a professional translator working with JS files. + + Task: Translate the content below from en to cn. + + Translation Requirements: +- Maintain exact file structure, indentation, and formatting +- Provide natural, culturally-adapted translations that sound native +- Keep all technical identifiers unchanged +- Keep consistent capitalization, spacing, and line breaks +- Respect existing whitespace and newline patterns +- Never add space before a ! or ? +- Preserve all object/property keys, syntax characters, and punctuation marks exactly +- Only translate text content within quotation marks + +A list of javascript codeblocks, return the translated javascript code in a JSON array, make sure to escape special characters like line breaks: +```js +"title" +``` + +```js +"Introduction" +``` + +```js +'Before you begin, make sure you have the following: +A GitHub account' +``` + +```js +`You can self-host Midday on your own cloud infrastructure for greater control over your data. + This guide will walk you through the entire process of setting up Midday.` +``` + +```js +`the current time is ${Date.now()}` +```", + "type": "text", + }, +] \ No newline at end of file diff --git a/packages/cli/test/snapshots/json-diff.json b/packages/cli/test/snapshots/json-diff.json index e1f3055..0c5a74c 100644 --- a/packages/cli/test/snapshots/json-diff.json +++ b/packages/cli/test/snapshots/json-diff.json @@ -1,12 +1,11 @@ { - "search": "搜索", - "searchNoResult": "", - "toc": "", - "tocNoHeadings": "", - "lastUpdate": "", - "chooseLanguage": "", - "nextPage": "", - "previousPage": "", - "chooseTheme": "", - "editOnGithub": "" + "description": "Translated", + "nested": { + "words": [ + "Translated", + "Translated" + ], + "description": "Updated" + }, + "search": "Updated" } \ No newline at end of file diff --git a/packages/cli/test/snapshots/json-diff.prompt.txt b/packages/cli/test/snapshots/json-diff.prompt.txt new file mode 100644 index 0000000..a50e76d --- /dev/null +++ b/packages/cli/test/snapshots/json-diff.prompt.txt @@ -0,0 +1,37 @@ +{ + "content": [ + { + "text": "You are a professional translator working with JSON files. + + Task: Translate the content below from en to cn. + + + Translation Requirements: +- Maintain exact file structure, indentation, and formatting +- Provide natural, culturally-adapted translations that sound native +- Keep all technical identifiers unchanged +- Keep consistent capitalization, spacing, and line breaks +- Respect existing whitespace and newline patterns +- Never add space before a ! or ? + - Only translate text content within quotation marks + - Preserve all object/property keys, syntax characters, and punctuation marks exactly + - Retain all code elements like variables, functions, and control structures + - Exclude any translator notes, comments or explanatory text + - Match source file's JSON/object structure precisely + - Handle special characters and escape sequences correctly + + Source content (JSON): + { +"search": "Search Docs", +"nested": { + "description": "nothing is happening" +} +} + + Return only the translated content with identical structure.", + "type": "text", + }, + ], + "providerMetadata": undefined, + "role": "user", +} \ No newline at end of file diff --git a/packages/cli/test/snapshots/json-new.json b/packages/cli/test/snapshots/json-new.json index 22617fa..5523386 100644 --- a/packages/cli/test/snapshots/json-new.json +++ b/packages/cli/test/snapshots/json-new.json @@ -1,12 +1,11 @@ { - "search": "搜索", - "searchNoResult": "未找到结果", - "toc": "本页内容", - "tocNoHeadings": "无标题", - "lastUpdate": "最后更新于", - "chooseLanguage": "选择语言", - "nextPage": "下一页", - "previousPage": "上一页", - "chooseTheme": "主题", - "editOnGithub": "在GitHub上编辑" -} + "search": "Updated", + "description": "Updated", + "nested": { + "description": "Updated", + "words": [ + "Updated", + "Updated" + ] + } +} \ No newline at end of file diff --git a/packages/cli/test/snapshots/md-diff.md b/packages/cli/test/snapshots/md-diff.md index b25d5ec..f3eb1ea 100644 --- a/packages/cli/test/snapshots/md-diff.md +++ b/packages/cli/test/snapshots/md-diff.md @@ -6,4 +6,4 @@ export default function Layout() { } ``` -
你好,世界
+
你好,世界
\ No newline at end of file diff --git a/packages/cli/test/snapshots/md-diff.prompt.txt b/packages/cli/test/snapshots/md-diff.prompt.txt new file mode 100644 index 0000000..44061de --- /dev/null +++ b/packages/cli/test/snapshots/md-diff.prompt.txt @@ -0,0 +1,40 @@ +{ + "content": [ + { + "text": "You are a professional translator working with MD files. + + Task: Translate the content below from en to cn. + + + Translation Requirements: +- Maintain exact file structure, indentation, and formatting +- Provide natural, culturally-adapted translations that sound native +- Keep all technical identifiers unchanged +- Keep consistent capitalization, spacing, and line breaks +- Respect existing whitespace and newline patterns +- Never add space before a ! or ? + - Only translate frontmatter, and text content (including those in HTML/JSX) + - Keep original code comments, line breaks, code, and codeblocks + - Retain all code elements like variables, functions, and control structures + - Respect existing whitespace and newline patterns + + +Translate only line 1, line 10, and return in the form of a JSON array like: +["translated content of line 1","translated content of line 10"] + +Source Content: +Hello world, this is a test document for testing translation. + +```tsx +export default function Layout() { +return
Hello World
+} +``` + +
Hello World
", + "type": "text", + }, + ], + "providerMetadata": undefined, + "role": "user", +} \ No newline at end of file From 122d343aea5f33f3c3c8016a7b32f0fb3715a8bf Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Wed, 25 Dec 2024 13:52:01 +0800 Subject: [PATCH 6/8] fix `dedent` removed line breaks from prompts --- packages/cli/src/prompt.ts | 12 +++--- packages/cli/src/translators/js.ts | 17 ++++++--- packages/cli/src/translators/json.ts | 17 ++++----- packages/cli/src/translators/md.ts | 7 ++-- packages/cli/test/js.test.ts | 6 +-- packages/cli/test/snapshots/js-new.prompt.txt | 24 +++++------- .../cli/test/snapshots/json-diff.prompt.txt | 37 +++++++++---------- .../cli/test/snapshots/md-diff.prompt.txt | 25 ++++++------- 8 files changed, 67 insertions(+), 78 deletions(-) diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index 0321639..19c6117 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -12,11 +12,9 @@ Translation Requirements: `; export function createBasePrompt(text: string, options: PromptOptions) { - return dedent` - You are a professional translator working with ${options.format.toUpperCase()} files. - - Task: Translate the content below from ${options.contentLocale} to ${options.targetLocale}. - ${options.config.instructions ?? ""} - ${text} - `; + return `You are a professional translator working with ${options.format.toUpperCase()} files. + +Task: Translate the content below from ${options.contentLocale} to ${options.targetLocale}. +${options.config.instructions ?? ""} +${text}`; } diff --git a/packages/cli/src/translators/js.ts b/packages/cli/src/translators/js.ts index 575b524..795d259 100644 --- a/packages/cli/src/translators/js.ts +++ b/packages/cli/src/translators/js.ts @@ -3,6 +3,7 @@ import { baseRequirements, createBasePrompt } from "../prompt.js"; import type { PromptOptions, Translator } from "../types.js"; import { diffLines } from "diff"; import { z } from "zod"; +import dedent from "dedent"; function createRegex(quote: string, multiline = false) { return `${quote}(?:\\\\.|[^${quote}\\\\${multiline ? "" : "\\n"}])*${quote}`; @@ -136,13 +137,17 @@ export const javascript: Translator = { }; function getPrompt(strings: StringMatch[], options: PromptOptions) { - return createBasePrompt( - `${baseRequirements} + const text = dedent` + ${baseRequirements} - Preserve all object/property keys, syntax characters, and punctuation marks exactly - Only translate text content within quotation marks - A list of javascript codeblocks, return the translated javascript code in a JSON array, make sure to escape special characters like line breaks: - ${strings.map((v) => `\`\`\`${options.format}\n${v.content}\n\`\`\``).join("\n\n")}`, - options, - ); + A list of javascript codeblocks, return the translated javascript string in a JSON array of string:`; + const codeblocks = strings + .map((v) => { + return `\`\`\`${options.format}\n${v.content}\n\`\`\``; + }) + .join("\n\n"); + + return createBasePrompt(`${text}\n${codeblocks}`, options); } diff --git a/packages/cli/src/translators/json.ts b/packages/cli/src/translators/json.ts index ed5abb2..41d10cc 100644 --- a/packages/cli/src/translators/json.ts +++ b/packages/cli/src/translators/json.ts @@ -1,6 +1,7 @@ import { generateObject } from "ai"; import { baseRequirements, createBasePrompt } from "../prompt.js"; import type { PromptOptions, Translator } from "../types.js"; +import dedent from "dedent"; export const json: Translator = { async onUpdate(options) { @@ -168,9 +169,8 @@ function getContentToTranslate( return out; } -function getPrompt(content: string, options: PromptOptions) { - return createBasePrompt( - ` +function getPrompt(json: string, options: PromptOptions) { + const text = dedent` ${baseRequirements} - Only translate text content within quotation marks - Preserve all object/property keys, syntax characters, and punctuation marks exactly @@ -178,11 +178,8 @@ function getPrompt(content: string, options: PromptOptions) { - Exclude any translator notes, comments or explanatory text - Match source file's JSON/object structure precisely - Handle special characters and escape sequences correctly - - Source content (JSON): - ${content} - - Return only the translated content with identical structure.`, - options, - ); + + Source content (JSON), Return only the translated content with identical structure: + `; + return createBasePrompt(`${text}\n${json}`, options); } diff --git a/packages/cli/src/translators/md.ts b/packages/cli/src/translators/md.ts index e093755..f9ea15d 100644 --- a/packages/cli/src/translators/md.ts +++ b/packages/cli/src/translators/md.ts @@ -3,6 +3,7 @@ import { baseRequirements, createBasePrompt } from "../prompt.js"; import { generateObject, generateText } from "ai"; import { z } from "zod"; import { diffLines } from "diff"; +import dedent from "dedent"; function extractDiff(pervious: string, content: string) { const contentLines = content.split("\n"); @@ -95,15 +96,13 @@ ${options.content}`, }; function getPrompt(base: string, options: PromptOptions) { - const text = ` + const text = dedent` ${baseRequirements} - Only translate frontmatter, and text content (including those in HTML/JSX) - Keep original code comments, line breaks, code, and codeblocks - Retain all code elements like variables, functions, and control structures - Respect existing whitespace and newline patterns - - ${base} `; - return createBasePrompt(text, options); + return createBasePrompt(`${text}\n${base}`, options); } diff --git a/packages/cli/test/js.test.ts b/packages/cli/test/js.test.ts index 3fe0e03..7111e9a 100644 --- a/packages/cli/test/js.test.ts +++ b/packages/cli/test/js.test.ts @@ -20,9 +20,9 @@ test("JSON adapter: new", async () => { model: new MockLanguageModelV1({ defaultObjectGenerationMode: "json", async doGenerate(v) { - await expect(v.prompt.at(-1)?.content).toMatchFileSnapshot( - "snapshots/js-new.prompt.txt", - ); + await expect( + (v.prompt.at(-1) as any).content[0].text, + ).toMatchFileSnapshot("snapshots/js-new.prompt.txt"); return { rawCall: { rawPrompt: null, rawSettings: {} }, diff --git a/packages/cli/test/snapshots/js-new.prompt.txt b/packages/cli/test/snapshots/js-new.prompt.txt index e38d412..c44866f 100644 --- a/packages/cli/test/snapshots/js-new.prompt.txt +++ b/packages/cli/test/snapshots/js-new.prompt.txt @@ -1,10 +1,8 @@ -[ - { - "text": "You are a professional translator working with JS files. - - Task: Translate the content below from en to cn. - - Translation Requirements: +You are a professional translator working with JS files. + +Task: Translate the content below from en to cn. + +Translation Requirements: - Maintain exact file structure, indentation, and formatting - Provide natural, culturally-adapted translations that sound native - Keep all technical identifiers unchanged @@ -14,7 +12,7 @@ - Preserve all object/property keys, syntax characters, and punctuation marks exactly - Only translate text content within quotation marks -A list of javascript codeblocks, return the translated javascript code in a JSON array, make sure to escape special characters like line breaks: +A list of javascript codeblocks, return the translated javascript string in a JSON array of string: ```js "title" ``` @@ -24,18 +22,14 @@ A list of javascript codeblocks, return the translated javascript code in a JSON ``` ```js -'Before you begin, make sure you have the following: -A GitHub account' +'Before you begin, make sure you have the following:\nA GitHub account' ``` ```js `You can self-host Midday on your own cloud infrastructure for greater control over your data. - This guide will walk you through the entire process of setting up Midday.` + This guide will walk you through the entire process of setting up Midday.` ``` ```js `the current time is ${Date.now()}` -```", - "type": "text", - }, -] \ No newline at end of file +``` \ No newline at end of file diff --git a/packages/cli/test/snapshots/json-diff.prompt.txt b/packages/cli/test/snapshots/json-diff.prompt.txt index a50e76d..4df92b6 100644 --- a/packages/cli/test/snapshots/json-diff.prompt.txt +++ b/packages/cli/test/snapshots/json-diff.prompt.txt @@ -2,33 +2,30 @@ "content": [ { "text": "You are a professional translator working with JSON files. - - Task: Translate the content below from en to cn. - - - Translation Requirements: + +Task: Translate the content below from en to cn. + +Translation Requirements: - Maintain exact file structure, indentation, and formatting - Provide natural, culturally-adapted translations that sound native - Keep all technical identifiers unchanged - Keep consistent capitalization, spacing, and line breaks - Respect existing whitespace and newline patterns - Never add space before a ! or ? - - Only translate text content within quotation marks - - Preserve all object/property keys, syntax characters, and punctuation marks exactly - - Retain all code elements like variables, functions, and control structures - - Exclude any translator notes, comments or explanatory text - - Match source file's JSON/object structure precisely - - Handle special characters and escape sequences correctly +- Only translate text content within quotation marks +- Preserve all object/property keys, syntax characters, and punctuation marks exactly +- Retain all code elements like variables, functions, and control structures +- Exclude any translator notes, comments or explanatory text +- Match source file's JSON/object structure precisely +- Handle special characters and escape sequences correctly - Source content (JSON): - { -"search": "Search Docs", -"nested": { - "description": "nothing is happening" -} -} - - Return only the translated content with identical structure.", +Source content (JSON), Return only the translated content with identical structure: +{ + "search": "Search Docs", + "nested": { + "description": "nothing is happening" + } +}", "type": "text", }, ], diff --git a/packages/cli/test/snapshots/md-diff.prompt.txt b/packages/cli/test/snapshots/md-diff.prompt.txt index 44061de..9c8b0cf 100644 --- a/packages/cli/test/snapshots/md-diff.prompt.txt +++ b/packages/cli/test/snapshots/md-diff.prompt.txt @@ -2,23 +2,21 @@ "content": [ { "text": "You are a professional translator working with MD files. - - Task: Translate the content below from en to cn. - - - Translation Requirements: + +Task: Translate the content below from en to cn. + +Translation Requirements: - Maintain exact file structure, indentation, and formatting - Provide natural, culturally-adapted translations that sound native - Keep all technical identifiers unchanged - Keep consistent capitalization, spacing, and line breaks - Respect existing whitespace and newline patterns - Never add space before a ! or ? - - Only translate frontmatter, and text content (including those in HTML/JSX) - - Keep original code comments, line breaks, code, and codeblocks - - Retain all code elements like variables, functions, and control structures - - Respect existing whitespace and newline patterns - - +- Only translate frontmatter, and text content (including those in HTML/JSX) +- Keep original code comments, line breaks, code, and codeblocks +- Retain all code elements like variables, functions, and control structures +- Respect existing whitespace and newline patterns + Translate only line 1, line 10, and return in the form of a JSON array like: ["translated content of line 1","translated content of line 10"] @@ -27,11 +25,12 @@ Hello world, this is a test document for testing translation. ```tsx export default function Layout() { -return
Hello World
+ return
Hello World
} ``` -
Hello World
", +
Hello World
+", "type": "text", }, ], From 0c2466b916f37345005bb63e3ab1d330f914d069 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Wed, 25 Dec 2024 16:03:52 +0800 Subject: [PATCH 7/8] add test for diff --- packages/cli/src/commands/translate.ts | 2 +- packages/cli/test/js.test.ts | 38 ++++++++++++++++++- packages/cli/test/resources/js-diff.js | 11 ++++++ .../cli/test/resources/js-diff.previous.js | 10 +++++ .../cli/test/resources/js-diff.translated.js | 10 +++++ packages/cli/test/snapshots/js-diff.js | 10 +++++ .../cli/test/snapshots/js-diff.prompt.txt | 31 +++++++++++++++ 7 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/resources/js-diff.js create mode 100644 packages/cli/test/resources/js-diff.previous.js create mode 100644 packages/cli/test/resources/js-diff.translated.js create mode 100644 packages/cli/test/snapshots/js-diff.js create mode 100644 packages/cli/test/snapshots/js-diff.prompt.txt diff --git a/packages/cli/src/commands/translate.ts b/packages/cli/src/commands/translate.ts index e7bc077..e5a9019 100644 --- a/packages/cli/src/commands/translate.ts +++ b/packages/cli/src/commands/translate.ts @@ -77,7 +77,7 @@ export async function translate(targetLocale?: string, force: boolean = false) { const adapter = await getTranslator(format); if (!adapter) { - throw new Error(`No available adapter for format: ${format}`); + throw new Error(`No available translator for format: ${format}`); } const options: PromptOptions = { diff --git a/packages/cli/test/js.test.ts b/packages/cli/test/js.test.ts index 7111e9a..f20fa27 100644 --- a/packages/cli/test/js.test.ts +++ b/packages/cli/test/js.test.ts @@ -8,7 +8,7 @@ import { javascript } from "../src/translators/js.js"; const dir = path.dirname(fileURLToPath(import.meta.url)); -test("JSON adapter: new", async () => { +test("JavaScript adapter: new", async () => { const result = await javascript.onNew({ config: {} as unknown as Config, content: await readFile(path.join(dir, "resources/js-new.js")).then((res) => @@ -42,3 +42,39 @@ test("JSON adapter: new", async () => { await expect(result.content).toMatchFileSnapshot("snapshots/js-new.js"); }); + +test("JavaScript adapter: diff", async () => { + const result = await javascript.onUpdate({ + config: {} as unknown as Config, + content: await readFile(path.join(dir, "resources/js-diff.js")).then((res) => + res.toString(), + ), + previousContent: (await readFile(path.join(dir, 'resources/js-diff.previous.js'))).toString(), + previousTranslation: (await readFile(path.join(dir, 'resources/js-diff.translated.js'))).toString(), + format: "js", + contentLocale: "en", + targetLocale: "cn", + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: "json", + async doGenerate(v) { + await expect( + (v.prompt.at(-1) as any).content[0].text, + ).toMatchFileSnapshot("snapshots/js-diff.prompt.txt"); + + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + text: JSON.stringify([ + "\"title\"", + '"Updated"', + "`Updated\nUpdated`", + "`Updated ${Date.now()}`", + ]), + }; + }, + }), + }); + + await expect(result.content).toMatchFileSnapshot("snapshots/js-diff.js"); +}); diff --git a/packages/cli/test/resources/js-diff.js b/packages/cli/test/resources/js-diff.js new file mode 100644 index 0000000..04b3394 --- /dev/null +++ b/packages/cli/test/resources/js-diff.js @@ -0,0 +1,11 @@ + +export default { + "title": "Getting Stopped", + description: 'Before you begin, make sure you have the following:\nA GitHub account', + + nested: { + content: `Explore ideas and examples of +what you can build with the Midday API` + }, + dynamic: `the current time is not ${Date.now()}` +} diff --git a/packages/cli/test/resources/js-diff.previous.js b/packages/cli/test/resources/js-diff.previous.js new file mode 100644 index 0000000..5be8994 --- /dev/null +++ b/packages/cli/test/resources/js-diff.previous.js @@ -0,0 +1,10 @@ + +export default { + "title": "Introduction", + description: 'Before you begin, make sure you have the following:\nA GitHub account', + nested: { + content: `You can self-host Midday on your own cloud infrastructure for greater control over your data. + This guide will walk you through the entire process of setting up Midday.` + }, + dynamic: `the current time is ${Date.now()}` +} diff --git a/packages/cli/test/resources/js-diff.translated.js b/packages/cli/test/resources/js-diff.translated.js new file mode 100644 index 0000000..d2c44b7 --- /dev/null +++ b/packages/cli/test/resources/js-diff.translated.js @@ -0,0 +1,10 @@ + +export default { + "title": "Translated", + description: 'Translated\nTranslated', + nested: { + content: `Translated + Translated` + }, + dynamic: `Translated ${Date.now()}` +} diff --git a/packages/cli/test/snapshots/js-diff.js b/packages/cli/test/snapshots/js-diff.js new file mode 100644 index 0000000..9706b34 --- /dev/null +++ b/packages/cli/test/snapshots/js-diff.js @@ -0,0 +1,10 @@ + +export default { + "title": "Updated", + description: 'Translated\nTranslated', + nested: { + content: `Updated +Updated` + }, + dynamic: `Updated ${Date.now()}` +} diff --git a/packages/cli/test/snapshots/js-diff.prompt.txt b/packages/cli/test/snapshots/js-diff.prompt.txt new file mode 100644 index 0000000..4b88608 --- /dev/null +++ b/packages/cli/test/snapshots/js-diff.prompt.txt @@ -0,0 +1,31 @@ +You are a professional translator working with JS files. + +Task: Translate the content below from en to cn. + +Translation Requirements: +- Maintain exact file structure, indentation, and formatting +- Provide natural, culturally-adapted translations that sound native +- Keep all technical identifiers unchanged +- Keep consistent capitalization, spacing, and line breaks +- Respect existing whitespace and newline patterns +- Never add space before a ! or ? +- Preserve all object/property keys, syntax characters, and punctuation marks exactly +- Only translate text content within quotation marks + +A list of javascript codeblocks, return the translated javascript string in a JSON array of string: +```js +"title" +``` + +```js +"Getting Stopped" +``` + +```js +`Explore ideas and examples of +what you can build with the Midday API` +``` + +```js +`the current time is not ${Date.now()}` +``` \ No newline at end of file From 37c7369a6d0b1a2c15d186e79c285eb0e2f3e774 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Wed, 25 Dec 2024 16:50:13 +0800 Subject: [PATCH 8/8] improve unit tests --- packages/cli/test/js.test.ts | 27 +++++++++++-------- packages/cli/test/json.test.ts | 3 ++- packages/cli/test/md.test.ts | 3 ++- .../cli/test/snapshots/json-diff.prompt.txt | 11 +------- .../cli/test/snapshots/md-diff.prompt.txt | 12 +-------- packages/cli/test/test-utils.ts | 11 ++++++++ 6 files changed, 33 insertions(+), 34 deletions(-) create mode 100644 packages/cli/test/test-utils.ts diff --git a/packages/cli/test/js.test.ts b/packages/cli/test/js.test.ts index f20fa27..df6bbd6 100644 --- a/packages/cli/test/js.test.ts +++ b/packages/cli/test/js.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import { MockLanguageModelV1 } from "ai/test"; import { javascript } from "../src/translators/js.js"; +import { getPromptText } from "./test-utils.js"; const dir = path.dirname(fileURLToPath(import.meta.url)); @@ -20,9 +21,9 @@ test("JavaScript adapter: new", async () => { model: new MockLanguageModelV1({ defaultObjectGenerationMode: "json", async doGenerate(v) { - await expect( - (v.prompt.at(-1) as any).content[0].text, - ).toMatchFileSnapshot("snapshots/js-new.prompt.txt"); + await expect(getPromptText(v.prompt)).toMatchFileSnapshot( + "snapshots/js-new.prompt.txt", + ); return { rawCall: { rawPrompt: null, rawSettings: {} }, @@ -46,27 +47,31 @@ test("JavaScript adapter: new", async () => { test("JavaScript adapter: diff", async () => { const result = await javascript.onUpdate({ config: {} as unknown as Config, - content: await readFile(path.join(dir, "resources/js-diff.js")).then((res) => - res.toString(), + content: await readFile(path.join(dir, "resources/js-diff.js")).then( + (res) => res.toString(), ), - previousContent: (await readFile(path.join(dir, 'resources/js-diff.previous.js'))).toString(), - previousTranslation: (await readFile(path.join(dir, 'resources/js-diff.translated.js'))).toString(), + previousContent: ( + await readFile(path.join(dir, "resources/js-diff.previous.js")) + ).toString(), + previousTranslation: ( + await readFile(path.join(dir, "resources/js-diff.translated.js")) + ).toString(), format: "js", contentLocale: "en", targetLocale: "cn", model: new MockLanguageModelV1({ defaultObjectGenerationMode: "json", async doGenerate(v) { - await expect( - (v.prompt.at(-1) as any).content[0].text, - ).toMatchFileSnapshot("snapshots/js-diff.prompt.txt"); + await expect(getPromptText(v.prompt)).toMatchFileSnapshot( + "snapshots/js-diff.prompt.txt", + ); return { rawCall: { rawPrompt: null, rawSettings: {} }, finishReason: "stop", usage: { promptTokens: 10, completionTokens: 20 }, text: JSON.stringify([ - "\"title\"", + '"title"', '"Updated"', "`Updated\nUpdated`", "`Updated ${Date.now()}`", diff --git a/packages/cli/test/json.test.ts b/packages/cli/test/json.test.ts index 3324d41..80a168e 100644 --- a/packages/cli/test/json.test.ts +++ b/packages/cli/test/json.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import { MockLanguageModelV1 } from "ai/test"; import { json } from "../src/translators/json.js"; +import { getPromptText } from "./test-utils.js"; const dir = path.dirname(fileURLToPath(import.meta.url)); @@ -58,7 +59,7 @@ test("JSON adapter: diff", async () => { model: new MockLanguageModelV1({ defaultObjectGenerationMode: "json", async doGenerate(v) { - await expect(v.prompt.at(-1)).toMatchFileSnapshot( + await expect(getPromptText(v.prompt)).toMatchFileSnapshot( "snapshots/json-diff.prompt.txt", ); diff --git a/packages/cli/test/md.test.ts b/packages/cli/test/md.test.ts index 91861de..b36f32a 100644 --- a/packages/cli/test/md.test.ts +++ b/packages/cli/test/md.test.ts @@ -1,3 +1,4 @@ +import { getPromptText } from "./test-utils.js"; import { expect, test } from "vitest"; import { markdown } from "../src/translators/md.js"; import { Config } from "../src/types.js"; @@ -47,7 +48,7 @@ test("markdown adapter: diff", async () => { model: new MockLanguageModelV1({ defaultObjectGenerationMode: "json", async doGenerate(v) { - await expect(v.prompt.at(-1)).toMatchFileSnapshot( + await expect(getPromptText(v.prompt)).toMatchFileSnapshot( "snapshots/md-diff.prompt.txt", ); diff --git a/packages/cli/test/snapshots/json-diff.prompt.txt b/packages/cli/test/snapshots/json-diff.prompt.txt index 4df92b6..654ae1e 100644 --- a/packages/cli/test/snapshots/json-diff.prompt.txt +++ b/packages/cli/test/snapshots/json-diff.prompt.txt @@ -1,7 +1,4 @@ -{ - "content": [ - { - "text": "You are a professional translator working with JSON files. +You are a professional translator working with JSON files. Task: Translate the content below from en to cn. @@ -25,10 +22,4 @@ Source content (JSON), Return only the translated content with identical structu "nested": { "description": "nothing is happening" } -}", - "type": "text", - }, - ], - "providerMetadata": undefined, - "role": "user", } \ No newline at end of file diff --git a/packages/cli/test/snapshots/md-diff.prompt.txt b/packages/cli/test/snapshots/md-diff.prompt.txt index 9c8b0cf..824c2a9 100644 --- a/packages/cli/test/snapshots/md-diff.prompt.txt +++ b/packages/cli/test/snapshots/md-diff.prompt.txt @@ -1,7 +1,4 @@ -{ - "content": [ - { - "text": "You are a professional translator working with MD files. +You are a professional translator working with MD files. Task: Translate the content below from en to cn. @@ -30,10 +27,3 @@ export default function Layout() { ```
Hello World
-", - "type": "text", - }, - ], - "providerMetadata": undefined, - "role": "user", -} \ No newline at end of file diff --git a/packages/cli/test/test-utils.ts b/packages/cli/test/test-utils.ts new file mode 100644 index 0000000..63dd6ea --- /dev/null +++ b/packages/cli/test/test-utils.ts @@ -0,0 +1,11 @@ +import type { LanguageModelV1Prompt } from "ai"; + +export function getPromptText(v: LanguageModelV1Prompt) { + const content = v.at(-1)?.content; + if (!content || !Array.isArray(content)) return content; + + return content + .filter((v) => v.type === "text") + .map((v) => v.text) + .join("\n"); +}