diff --git a/.changeset/tidy-numbers-speak.md b/.changeset/tidy-numbers-speak.md new file mode 100644 index 000000000..5f9e4758d --- /dev/null +++ b/.changeset/tidy-numbers-speak.md @@ -0,0 +1,6 @@ +--- +"lingo.dev": minor +"@lingo.dev/_sdk": minor +--- + +[Enhancement]: This PR improves the test command options in the CLI by refining its functionality and usability. Additionally, it introduces the AbortController API to the SDK, enhancing request handling and cancellation support. diff --git a/.gitignore b/.gitignore index 71ad1320c..cae9a8bdc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ node_modules .pnp .pnp.js +.idea + # Local env files .env .env.local @@ -37,3 +39,4 @@ yarn-error.log* .DS_Store *.pem i18n.cache +i18n.cache diff --git a/packages/cli/src/cli/cmd/auth.ts b/packages/cli/src/cli/cmd/auth.ts index a502f3383..f1e5a0fac 100644 --- a/packages/cli/src/cli/cmd/auth.ts +++ b/packages/cli/src/cli/cmd/auth.ts @@ -11,8 +11,8 @@ export default new Command() .command("auth") .description("Authenticate with Lingo.dev API") .helpOption("-h, --help", "Show help") - .option("--logout", "Delete existing authentication") - .option("--login", "Authenticate with Lingo.dev API") + .option("--logout", "Delete existing authentication and clear your saved API key.") + .option("--login", "Authenticate with Lingo.dev API.") .action(async (options) => { try { let settings = await getSettings(undefined); diff --git a/packages/cli/src/cli/cmd/cleanup.ts b/packages/cli/src/cli/cmd/cleanup.ts index a73077883..1a65de642 100644 --- a/packages/cli/src/cli/cmd/cleanup.ts +++ b/packages/cli/src/cli/cmd/cleanup.ts @@ -11,10 +11,12 @@ export default new Command() .command("cleanup") .description("Remove keys from target files that do not exist in the source file") .helpOption("-h, --help", "Show help") - .option("--locale ", "Specific locale to cleanup") - .option("--bucket ", "Specific bucket to cleanup") - .option("--dry-run", "Show what would be removed without making changes") - .option("--verbose", "Show verbose output") + .option("--locale ", "Clean up only the specified target locale") + .option("--bucket ", " Clean up only the specified bucket type") + .option("--dry-run", "Show what would be removed without actually modifying any files") + .option("--verbose", "Show detailed output including:\n" + + " - List of keys that would be removed.\n" + + " - Processing steps.") .action(async function (options) { const ora = Ora(); const results: any = []; diff --git a/packages/cli/src/cli/cmd/i18n.ts b/packages/cli/src/cli/cmd/i18n.ts index 8722c6f4e..bea217e6a 100644 --- a/packages/cli/src/cli/cmd/i18n.ts +++ b/packages/cli/src/cli/cmd/i18n.ts @@ -24,14 +24,14 @@ export default new Command() .helpOption("-h, --help", "Show help") .option("--locale ", "Locale to process", (val: string, prev: string[]) => (prev ? [...prev, val] : [val])) .option("--bucket ", "Bucket to process", (val: string, prev: string[]) => (prev ? [...prev, val] : [val])) - .option("--key ", "Key to process") - .option("--frozen", `Don't update the translations and fail if an update is needed`) - .option("--force", "Ignore lockfile and process all keys") - .option("--verbose", "Show verbose output") - .option("--interactive", "Interactive mode") - .option("--api-key ", "Explicitly set the API key to use") - .option("--debug", "Debug mode") - .option("--strict", "Stop on first error") + .option("--key ", "Key to process.Process only a specific translation key. Useful for debugging or updating a single entry.") + .option("--frozen", `Run in read-only mode - fails if any translations need updating. Useful for CI/CD pipelines to detect missing translations.`) + .option("--force", "Ignore lockfile and process all keys, forcing a full re-translation.Use with caution as this may incur higher API costs.") + .option("--verbose", "Show detailed output including intermediate processing data and API communication details.") + .option("--interactive", "Enable interactive mode for reviewing and editing translations before they are applied.") + .option("--api-key ", "Explicitly set the API key to use. Override the default API key from settings.") + .option("--debug", "Pause execution at start for debugging purposes. Waits for user confirmation before proceeding.") + .option("--strict", "Stop processing on first error instead of continuing with other locales/buckets.") .action(async function (options) { updateGitignore(); diff --git a/packages/cli/src/cli/cmd/lockfile.ts b/packages/cli/src/cli/cmd/lockfile.ts index d8be5a56c..9a774cdcc 100644 --- a/packages/cli/src/cli/cmd/lockfile.ts +++ b/packages/cli/src/cli/cmd/lockfile.ts @@ -11,7 +11,7 @@ export default new Command() .command("lockfile") .description("Create a lockfile if it does not exist") .helpOption("-h, --help", "Show help") - .option("-f, --force", "Force create a lockfile") + .option("-f, --force", "Force create a lockfile.") .action(async (options) => { const flags = flagsSchema.parse(options); const ora = Ora(); diff --git a/packages/cli/src/cli/cmd/show/files.ts b/packages/cli/src/cli/cmd/show/files.ts index 7fc7d6cec..95161f4f0 100644 --- a/packages/cli/src/cli/cmd/show/files.ts +++ b/packages/cli/src/cli/cmd/show/files.ts @@ -9,8 +9,8 @@ import { resolveOverridenLocale } from "@lingo.dev/_spec"; export default new Command() .command("files") .description("Print out the list of files managed by Lingo.dev") - .option("--source", "Only show source files") - .option("--target", "Only show target files") + .option("--source", "Only show source files.Files containing the original translations.") + .option("--target", "Only show target files.Files containing translated content.") .helpOption("-h, --help", "Show help") .action(async (type) => { const ora = Ora(); diff --git a/packages/sdk/src/index.spec.ts b/packages/sdk/src/index.spec.ts index a88277fdf..f14340487 100644 --- a/packages/sdk/src/index.spec.ts +++ b/packages/sdk/src/index.spec.ts @@ -87,3 +87,110 @@ describe("ReplexicaEngine", () => { }); }); }); + + + +describe("LingoDotDevEngine Abort Handling", () => { + describe("Abort Signal Propagation", () => { + it("should throw an error when abort signal is triggered before localization starts", async () => { + const engine = new LingoDotDevEngine({ apiKey: "test" }); + const abortController = new AbortController(); + + // Abort immediately + abortController.abort(); + + await expect( + engine.localizeText("Test text", { + sourceLocale: "en", + targetLocale: "es" + }, undefined, abortController.signal) + ).rejects.toThrow("Operation was aborted"); + }); + + it("should throw an error when abort signal is triggered during HTML localization", async () => { + const engine = new LingoDotDevEngine({ apiKey: "test" }); + const abortController = new AbortController(); + + // Mock _localizeRaw to simulate a long-running operation + const mockLocalizeRaw = vi.spyOn(engine as any, "_localizeRaw"); + mockLocalizeRaw.mockImplementation(async () => { + // Simulate an asynchronous operation + await new Promise(resolve => setTimeout(resolve, 100)); + // Abort during the operation + abortController.abort(); + return {}; + }); + + await expect( + engine.localizeHtml("Test", { + sourceLocale: "en", + targetLocale: "es" + }, undefined, abortController.signal) + ).rejects.toThrow("Operation was aborted"); + }); + + it("should propagate abort signal through nested method calls", async () => { + const engine = new LingoDotDevEngine({ apiKey: "test" }); + const abortController = new AbortController(); + + // Spy on internal methods to ensure abort signal is passed + const spyLocalizeChunk = vi.spyOn(engine as any, "localizeChunk"); + const spyCheckAbortSignal = vi.spyOn(engine as any, "checkAbortSignal"); + + // Abort during object localization + abortController.abort(); + + await expect( + engine.localizeObject({ text: "Test" }, { + sourceLocale: "en", + targetLocale: "es" + }, undefined, abortController.signal) + ).rejects.toThrow("Operation was aborted"); + + // Verify abort signal was checked in multiple places + expect(spyCheckAbortSignal).toHaveBeenCalledWith(abortController.signal); + }); + + it("should handle multiple concurrent abort scenarios", async () => { + const engine = new LingoDotDevEngine({ apiKey: "test" }); + + const abortControllers = [ + new AbortController(), + new AbortController(), + new AbortController() + ]; + + const localizationPromises = abortControllers.map((controller, index) => { + // Stagger abort times + setTimeout(() => controller.abort(), index * 50); + + return engine.localizeText(`Test ${index}`, { + sourceLocale: "en", + targetLocale: "es" + }, undefined, controller.signal).catch(err => err); + }); + + const results = await Promise.all(localizationPromises); + + // All promises should result in abort errors + results.forEach(result => { + expect(result.message).toBe("Operation was aborted"); + }); + }); + + it("should allow localization when no abort signal is provided", async () => { + const engine = new LingoDotDevEngine({ apiKey: "test" }); + + // Mock _localizeRaw to return a predictable result + const mockLocalizeRaw = vi.spyOn(engine as any, "_localizeRaw"); + mockLocalizeRaw.mockResolvedValue({ text: "Localized Text" }); + + const result = await engine.localizeText("Test text", { + sourceLocale: "en", + targetLocale: "es" + }); + + expect(result).toBe("Localized Text"); + }); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6ccbc6968..880e54c65 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -35,6 +35,12 @@ export class LingoDotDevEngine { this.config = engineParamsSchema.parse(config); } + private checkAbortSignal(signal? : AbortSignal) { + if (signal?.aborted) { + throw new Error('Operation was aborted'); + } + } + /** * Localize content using the Lingo.dev API * @param payload - The content to be localized @@ -50,8 +56,12 @@ export class LingoDotDevEngine { progress: number, sourceChunk: Record, processedChunk: Record - ) => void + ) => void, + signal ? : AbortSignal ): Promise> { + + this.checkAbortSignal(signal); + const finalPayload = payloadSchema.parse(payload); const finalParams = localizationParamsSchema.parse(params); @@ -60,6 +70,9 @@ export class LingoDotDevEngine { const workflowId = createId(); for (let i = 0; i < chunkedPayload.length; i++) { + if(signal?.aborted) { + throw new Error('Operation was aborted before starting'); + } const chunk = chunkedPayload[i]; const percentageCompleted = Math.round(((i + 1) / chunkedPayload.length) * 100); @@ -68,7 +81,8 @@ export class LingoDotDevEngine { finalParams.targetLocale, { data: chunk, reference: params.reference }, workflowId, - params.fast || false + params.fast || false, + signal ); if (progressCallback) { @@ -96,46 +110,64 @@ export class LingoDotDevEngine { reference?: Z.infer; }, workflowId: string, - fast: boolean + fast: boolean, + signal? : AbortSignal ): Promise> { - const res = await fetch(`${this.config.apiUrl}/i18n`, { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify( - { - params: { workflowId, fast }, - locale: { - source: sourceLocale, - target: targetLocale, - }, - data: payload.data, - reference: payload.reference, - }, - null, - 2 - ), - }); - if (!res.ok) { - if (res.status === 400) { - throw new Error(`Invalid request: ${res.statusText}`); - } else { - const errorText = await res.text(); - throw new Error(errorText); - } + const controller = new AbortController(); + + if(signal) { + signal.addEventListener("abort",() => controller.abort()); } - const jsonResponse = await res.json(); + try { + const res = await fetch(`${this.config.apiUrl}/i18n`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify( + { + params: { workflowId, fast }, + locale: { + source: sourceLocale, + target: targetLocale, + }, + data: payload.data, + reference: payload.reference, + }, + null, + 2 + ), + signal : controller.signal, + }); + + if (!res.ok) { + if (res.status === 400) { + throw new Error(`Invalid request: ${res.statusText}`); + } else { + const errorText = await res.text(); + throw new Error(errorText); + } + } + + const jsonResponse = await res.json(); - // when streaming the error is returned in the response body - if (!jsonResponse.data && jsonResponse.error) { - throw new Error(jsonResponse.error); + // when streaming the error is returned in the response body + if (!jsonResponse.data && jsonResponse.error) { + throw new Error(jsonResponse.error); + } + + return jsonResponse.data || {}; + }catch(err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error('Operation was aborted'); + } + throw err; } - return jsonResponse.data || {}; + } /** @@ -203,9 +235,10 @@ export class LingoDotDevEngine { progress: number, sourceChunk: Record, processedChunk: Record - ) => void + ) => void, + signal?: AbortSignal ): Promise> { - return this._localizeRaw(obj, params, progressCallback); + return this._localizeRaw(obj, params, progressCallback,signal); } /** @@ -221,9 +254,13 @@ export class LingoDotDevEngine { async localizeText( text: string, params: Z.infer, - progressCallback?: (progress: number) => void + progressCallback?: (progress: number) => void, + signal?: AbortSignal ): Promise { - const response = await this._localizeRaw({ text }, params, progressCallback); + + this.checkAbortSignal(signal); + + const response = await this._localizeRaw({ text }, params, progressCallback,signal); return response.text || ""; } @@ -270,9 +307,10 @@ export class LingoDotDevEngine { async localizeChat( chat: Array<{ name: string; text: string }>, params: Z.infer, - progressCallback?: (progress: number) => void + progressCallback?: (progress: number) => void, + signal?: AbortSignal ): Promise> { - const localized = await this._localizeRaw({ chat }, params, progressCallback); + const localized = await this._localizeRaw({ chat }, params, progressCallback,signal); return Object.entries(localized).map(([key, value]) => ({ name: chat[parseInt(key.split("_")[1])].name, @@ -294,8 +332,12 @@ export class LingoDotDevEngine { async localizeHtml( html: string, params: Z.infer, - progressCallback?: (progress: number) => void + progressCallback?: (progress: number) => void, + signal ? : AbortSignal ): Promise { + + this.checkAbortSignal(signal); + const jsdomPackage = await import("jsdom"); const { JSDOM } = jsdomPackage; const dom = new JSDOM(html); @@ -340,6 +382,8 @@ export class LingoDotDevEngine { }; const processNode = (node: Node) => { + this.checkAbortSignal(signal); + let parent = node.parentElement; while (parent) { if (UNLOCALIZABLE_TAGS.includes(parent.tagName.toLowerCase())) { @@ -378,12 +422,20 @@ export class LingoDotDevEngine { .filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim())) .forEach(processNode); + this.checkAbortSignal(signal); + + const localizedContent = await this._localizeRaw(extractedContent, params, progressCallback); + this.checkAbortSignal(signal); + // Update the DOM with localized content document.documentElement.setAttribute("lang", params.targetLocale); Object.entries(localizedContent).forEach(([path, value]) => { + + if (signal?.aborted) return; + const [nodePath, attribute] = path.split("#"); const [rootTag, ...indices] = nodePath.split("/"); @@ -409,6 +461,8 @@ export class LingoDotDevEngine { } }); + this.checkAbortSignal(signal); + return dom.serialize(); } @@ -417,23 +471,43 @@ export class LingoDotDevEngine { * @param text - The text to analyze * @returns Promise resolving to a locale code (e.g., 'en', 'es', 'fr') */ - async recognizeLocale(text: string): Promise { - const response = await fetch(`${this.config.apiUrl}/recognize`, { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify({ text }), - }); + async recognizeLocale( + text: string, + signal? : AbortSignal + ): Promise { - if (!response.ok) { - throw new Error(`Error recognizing locale: ${response.statusText}`); + const controller = new AbortController(); + + if (signal) { + signal.addEventListener('abort', () => controller.abort()); + } + + try { + const response = await fetch(`${this.config.apiUrl}/recognize`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify({ text }), + signal : controller.signal, + }); + + if (!response.ok) { + throw new Error(`Error recognizing locale: ${response.statusText}`); + } + + const jsonResponse = await response.json(); + return jsonResponse.locale; + + }catch (err){ + if (err instanceof Error && err.name === 'AbortError') { + throw new Error('Locale recognition was aborted'); + } + throw err; + } } - const jsonResponse = await response.json(); - return jsonResponse.locale; - } } /** diff --git a/packages/spec/src/locales.ts b/packages/spec/src/locales.ts index 3c4e8fd77..6b14df3d1 100644 --- a/packages/spec/src/locales.ts +++ b/packages/spec/src/locales.ts @@ -221,6 +221,19 @@ export const localeCodeSchema = Z.string().refine((value) => localeCodes.include message: "Invalid locale code", }); +/** + * Resolves a locale code to its full locale representation. + * + * If the provided locale code is already a full locale code, it returns as is. + * If the provided locale code is a short locale code, it returns the first corresponding full locale. + * If the locale code is not found, it throws an error. + * + * @param {localeCodes} value - The locale code to resolve (either short or full) + * @return {LocaleCodeFull} The resolved full locale code + * @throws {Error} If the provided locale code is invalid. + */ + + export const resolveLocaleCode = (value: LocaleCode): LocaleCodeFull => { const existingFullLocaleCode = Object.values(localeMap) .flat() @@ -239,6 +252,15 @@ export const resolveLocaleCode = (value: LocaleCode): LocaleCodeFull => { throw new Error(`Invalid locale code: ${value}`); }; + +/** + * Determines the delimiter used in a locale code + * + * @param {string} locale - the locale string (e.g.,"en_US","en-GB") + * @return { string | null} - The delimiter ("_" or "-") if found, otherwise `null`. + */ + + export const getLocaleCodeDelimiter = (locale: string): string | null => { if (locale.includes("_")) { return "_"; @@ -249,6 +271,16 @@ export const getLocaleCodeDelimiter = (locale: string): string | null => { } }; +/** + * Replaces the delimiter in a locale string with the specified delimiter. + * + * @param {string}locale - The locale string (e.g.,"en_US", "en-GB"). + * @param {"-" | "_" | null} [delimiter] - The new delimiter to replace the existing one. + * @returns {string} The locale string with the replaced delimiter, or the original locale if no delimiter is provided. + */ + + + export const resolveOverridenLocale = (locale: string, delimiter?: "-" | "_" | null): string => { if (!delimiter) { return locale; @@ -262,6 +294,15 @@ export const resolveOverridenLocale = (locale: string, delimiter?: "-" | "_" | n return locale.replace(currentDelimiter, delimiter); }; -export function normalizeLocale(locale: string) { +/** + * Normalizes a locale string by replacing underscores with hyphens + * and removing the "r" in certain regional codes (e.g., "fr-rCA" → "fr-CA") + * + * @param {string} locale - The locale string (e.g.,"en_US", "en-GB"). + * @return {string} The normalized locale string. + */ + + +export const normalizeLocale = (locale: string): string => { return locale.replaceAll("_", "-").replace(/([a-z]{2,3}-)r/, "$1"); }