diff --git a/packages/cli/src/cli/cmd/run/_types.ts b/packages/cli/src/cli/cmd/run/_types.ts index d5ea1316d..1a9634af2 100644 --- a/packages/cli/src/cli/cmd/run/_types.ts +++ b/packages/cli/src/cli/cmd/run/_types.ts @@ -53,5 +53,6 @@ export const flagsSchema = z.object({ watch: z.boolean().default(false), debounce: z.number().positive().default(5000), // 5 seconds default sound: z.boolean().optional(), + pseudo: z.boolean().optional(), }); export type CmdRunFlags = z.infer; diff --git a/packages/cli/src/cli/cmd/run/index.ts b/packages/cli/src/cli/cmd/run/index.ts index 385f31346..ba867c0aa 100644 --- a/packages/cli/src/cli/cmd/run/index.ts +++ b/packages/cli/src/cli/cmd/run/index.ts @@ -118,6 +118,10 @@ export default new Command() "--sound", "Play audio feedback when translations complete (success or failure sounds)", ) + .option( + "--pseudo", + "Enable pseudo-localization mode: automatically pseudo-translates all extracted strings with accented characters and visual markers without calling any external API. Useful for testing UI internationalization readiness", + ) .action(async (args) => { let authId: string | null = null; try { diff --git a/packages/cli/src/cli/cmd/run/setup.ts b/packages/cli/src/cli/cmd/run/setup.ts index ca6652109..63729eb69 100644 --- a/packages/cli/src/cli/cmd/run/setup.ts +++ b/packages/cli/src/cli/cmd/run/setup.ts @@ -50,10 +50,8 @@ export default async function setup(input: CmdRunContext) { { title: "Selecting localization provider", task: async (ctx, task) => { - ctx.localizer = createLocalizer( - ctx.config?.provider, - ctx.flags.apiKey, - ); + const provider = ctx.flags.pseudo ? "pseudo" : ctx.config?.provider; + ctx.localizer = createLocalizer(provider, ctx.flags.apiKey); if (!ctx.localizer) { throw new Error( "Could not create localization provider. Please check your i18n.json configuration.", @@ -62,12 +60,15 @@ export default async function setup(input: CmdRunContext) { task.title = ctx.localizer.id === "Lingo.dev" ? `Using ${chalk.hex(colors.green)(ctx.localizer.id)} provider` - : `Using raw ${chalk.hex(colors.yellow)(ctx.localizer.id)} API`; + : ctx.localizer.id === "pseudo" + ? `Using ${chalk.hex(colors.blue)("pseudo")} mode for testing` + : `Using raw ${chalk.hex(colors.yellow)(ctx.localizer.id)} API`; }, }, { title: "Checking authentication", - enabled: (ctx) => ctx.localizer?.id === "Lingo.dev", + enabled: (ctx) => + ctx.localizer?.id === "Lingo.dev" && !ctx.flags.pseudo, task: async (ctx, task) => { const authStatus = await ctx.localizer!.checkAuth(); if (!authStatus.authenticated) { @@ -95,6 +96,7 @@ export default async function setup(input: CmdRunContext) { title: "Initializing localization provider", async task(ctx, task) { const isLingoDotDev = ctx.localizer!.id === "Lingo.dev"; + const isPseudo = ctx.localizer!.id === "pseudo"; const subTasks = isLingoDotDev ? [ @@ -103,12 +105,18 @@ export default async function setup(input: CmdRunContext) { "Glossary enabled", "Quality assurance enabled", ].map((title) => ({ title, task: () => {} })) - : [ - "Skipping brand voice", - "Skipping glossary", - "Skipping translation memory", - "Skipping quality assurance", - ].map((title) => ({ title, task: () => {}, skip: true })); + : isPseudo + ? [ + "Pseudo-localization mode active", + "Character replacement configured", + "No external API calls", + ].map((title) => ({ title, task: () => {} })) + : [ + "Skipping brand voice", + "Skipping glossary", + "Skipping translation memory", + "Skipping quality assurance", + ].map((title) => ({ title, task: () => {}, skip: true })); return task.newListr(subTasks, { concurrent: true, diff --git a/packages/cli/src/cli/localizer/_types.ts b/packages/cli/src/cli/localizer/_types.ts index 263340571..94f03b64a 100644 --- a/packages/cli/src/cli/localizer/_types.ts +++ b/packages/cli/src/cli/localizer/_types.ts @@ -16,7 +16,7 @@ export type LocalizerProgressFn = ( ) => void; export interface ILocalizer { - id: "Lingo.dev" | NonNullable["id"]; + id: "Lingo.dev" | "pseudo" | NonNullable["id"]; checkAuth: () => Promise<{ authenticated: boolean; username?: string; diff --git a/packages/cli/src/cli/localizer/index.ts b/packages/cli/src/cli/localizer/index.ts index 8bf58b8f5..bc754f96d 100644 --- a/packages/cli/src/cli/localizer/index.ts +++ b/packages/cli/src/cli/localizer/index.ts @@ -2,12 +2,17 @@ import { I18nConfig } from "@lingo.dev/_spec"; import createLingoDotDevLocalizer from "./lingodotdev"; import createExplicitLocalizer from "./explicit"; +import createPseudoLocalizer from "./pseudo"; import { ILocalizer } from "./_types"; export default function createLocalizer( - provider: I18nConfig["provider"], + provider: I18nConfig["provider"] | "pseudo" | null | undefined, apiKey?: string, ): ILocalizer { + if (provider === "pseudo") { + return createPseudoLocalizer(); + } + if (!provider) { return createLingoDotDevLocalizer(apiKey); } else { diff --git a/packages/cli/src/cli/localizer/pseudo.ts b/packages/cli/src/cli/localizer/pseudo.ts new file mode 100644 index 000000000..d20a3e20d --- /dev/null +++ b/packages/cli/src/cli/localizer/pseudo.ts @@ -0,0 +1,37 @@ +import { ILocalizer, LocalizerData } from "./_types"; +import { pseudoLocalizeObject } from "../../utils/pseudo-localize"; + +/** + * Creates a pseudo-localizer that doesn't call any external API. + * Instead, it performs character replacement with accented versions, + * useful for testing UI internationalization readiness. + */ +export default function createPseudoLocalizer(): ILocalizer { + return { + id: "pseudo", + checkAuth: async () => { + return { + authenticated: true, + }; + }, + localize: async (input: LocalizerData, onProgress) => { + // Nothing to translate – return the input as-is. + if (!Object.keys(input.processableData).length) { + return input; + } + + // Pseudo-localize all strings in the processable data + const processedData = pseudoLocalizeObject(input.processableData, { + addMarker: true, + addLengthMarker: false, + }); + + // Call progress callback if provided, simulating completion + if (onProgress) { + onProgress(100, input.processableData, processedData); + } + + return processedData; + }, + }; +} diff --git a/packages/cli/src/utils/pseudo-localize.spec.ts b/packages/cli/src/utils/pseudo-localize.spec.ts new file mode 100644 index 000000000..cc3ca48a0 --- /dev/null +++ b/packages/cli/src/utils/pseudo-localize.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { pseudoLocalize, pseudoLocalizeObject } from "./pseudo-localize"; + +describe("pseudoLocalize", () => { + it("should replace characters with accented versions", () => { + const result = pseudoLocalize("hello", { addMarker: false }); + expect(result).toBe("ĥèļļø"); + }); + + it("should add marker by default", () => { + const result = pseudoLocalize("hello"); + expect(result).toBe("ĥèļļø⚡"); + }); + + it("should not add marker when disabled", () => { + const result = pseudoLocalize("hello", { addMarker: false }); + expect(result).not.toContain("⚡"); + }); + + it("should handle uppercase letters", () => { + const result = pseudoLocalize("HELLO", { addMarker: false }); + expect(result).toBe("ĤÈĻĻØ"); + }); + + it("should preserve non-alphabetic characters", () => { + const result = pseudoLocalize("Hello123!", { addMarker: false }); + expect(result).toBe("Ĥèļļø123!"); + }); + + it("should handle empty strings", () => { + const result = pseudoLocalize(""); + expect(result).toBe(""); + }); + + it("should handle strings with spaces", () => { + const result = pseudoLocalize("Hello World", { addMarker: false }); + expect(result).toBe("Ĥèļļø Ŵøŕļð"); + }); + + it("should add length expansion when enabled", () => { + const original = "hello"; + const result = pseudoLocalize(original, { + addMarker: false, + addLengthMarker: true, + lengthExpansion: 30, + }); + // 30% expansion of 5 chars = 2 extra chars (rounded up) + expect(result.length).toBeGreaterThan("ĥèļļø".length); + }); + + it("should handle example from feature proposal", () => { + const result = pseudoLocalize("Submit"); + expect(result).toContain("⚡"); + expect(result.startsWith("Š")).toBe(true); + }); + + it("should handle longer text", () => { + const result = pseudoLocalize("Welcome back!"); + expect(result).toBe("Ŵèļçømè ƀãçķ!⚡"); + }); +}); + +describe("pseudoLocalizeObject", () => { + it("should pseudo-localize string values", () => { + const obj = { greeting: "hello" }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.greeting).toBe("ĥèļļø"); + }); + + it("should handle nested objects", () => { + const obj = { + en: { + greeting: "hello", + farewell: "goodbye", + }, + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.en.greeting).toBe("ĥèļļø"); + expect(result.en.farewell).toContain("ĝ"); + }); + + it("should handle arrays", () => { + const obj = { + messages: ["hello", "world"], + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages[0]).toBe("ĥèļļø"); + }); + + it("should preserve non-string values", () => { + const obj = { + greeting: "hello", + count: 42, + active: true, + nothing: null, + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.greeting).toBe("ĥèļļø"); + expect(result.count).toBe(42); + expect(result.active).toBe(true); + expect(result.nothing).toBe(null); + }); + + it("should handle complex nested structures", () => { + const obj = { + ui: { + buttons: { + submit: "Submit", + cancel: "Cancel", + }, + messages: ["error", "warning"], + }, + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.ui.buttons.submit).toContain("Š"); + expect(result.ui.messages[0]).toContain("è"); + }); + + it("should handle empty objects", () => { + const result = pseudoLocalizeObject({}, { addMarker: false }); + expect(result).toEqual({}); + }); +}); diff --git a/packages/cli/src/utils/pseudo-localize.ts b/packages/cli/src/utils/pseudo-localize.ts new file mode 100644 index 000000000..77abbc7b7 --- /dev/null +++ b/packages/cli/src/utils/pseudo-localize.ts @@ -0,0 +1,164 @@ +/** + * Pseudo-localization utility for testing UI internationalization readiness + * without waiting for actual translations. + * + * Implements character replacement with accented versions and optional lengthening, + * following standard i18n practices used by Google, Microsoft, and Mozilla. + */ + +/** + * Character mapping for pseudo-localization (en-XA style) + * Each ASCII character is replaced with a visually similar accented version + */ +const PSEUDO_CHAR_MAP: Record = { + a: "ã", + b: "ƀ", + c: "ç", + d: "ð", + e: "è", + f: "ƒ", + g: "ĝ", + h: "ĥ", + i: "í", + j: "ĵ", + k: "ķ", + l: "ļ", + m: "m", + n: "ñ", + o: "ø", + p: "þ", + q: "q", + r: "ŕ", + s: "š", + t: "ţ", + u: "û", + v: "ṽ", + w: "ŵ", + x: "x", + y: "ý", + z: "ž", + + A: "Ã", + B: "Ḃ", + C: "Ĉ", + D: "Ð", + E: "È", + F: "Ḟ", + G: "Ĝ", + H: "Ĥ", + I: "Í", + J: "Ĵ", + K: "Ķ", + L: "Ļ", + M: "M", + N: "Ñ", + O: "Ø", + P: "Þ", + Q: "Q", + R: "Ŕ", + S: "Š", + T: "Ţ", + U: "Û", + V: "Ṽ", + W: "Ŵ", + X: "X", + Y: "Ý", + Z: "Ž", +}; + +/** + * Pseudo-localizes a string by replacing characters with accented versions + * and optionally extending the length to simulate expansion. + * + * @param text - The text to pseudo-localize + * @param options - Configuration options + * @returns The pseudo-localized text + * + * @example + * ```ts + * pseudoLocalize("Submit") // "Šûbmíţ⚡" + * pseudoLocalize("Welcome back!", { addLengthMarker: true }) // "Ŵêļçømèƀäçķ!⚡" + * ``` + */ +export function pseudoLocalize( + text: string, + options: { + /** + * Add a visual marker (⚡) at the end to indicate pseudo-localization + * @default true + */ + addMarker?: boolean; + /** + * Extend text length by adding padding characters to simulate text expansion. + * Useful for testing UI layout with longer translations. + * @default false + */ + addLengthMarker?: boolean; + /** + * The percentage to extend the text (0-100). + * @default 30 + */ + lengthExpansion?: number; + } = {}, +): string { + const { + addMarker = true, + addLengthMarker = false, + lengthExpansion = 30, + } = options; + + if (!text) { + return text; + } + + // Replace characters with accented versions + let result = ""; + for (const char of text) { + result += PSEUDO_CHAR_MAP[char] ?? char; + } + + // Add length expansion if requested + if (addLengthMarker) { + const extraChars = Math.ceil((text.length * lengthExpansion) / 100); + // Add combining diacritical marks to simulate expansion + result += "̌".repeat(extraChars); + } + + // Add visual marker if requested + if (addMarker) { + result += "⚡"; + } + + return result; +} + +/** + * Pseudo-localizes all strings in an object (recursively). + * Handles nested objects and arrays. + * + * @param obj - The object to pseudo-localize + * @param options - Configuration options for pseudoLocalize + * @returns A new object with all string values pseudo-localized + */ +export function pseudoLocalizeObject( + obj: any, + options?: Parameters[1], +): any { + if (typeof obj === "string") { + return pseudoLocalize(obj, options); + } + + if (Array.isArray(obj)) { + return obj.map((item) => pseudoLocalizeObject(item, options)); + } + + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = pseudoLocalizeObject(value, options); + } + return result; + } + + return obj; +}