Skip to content
5 changes: 5 additions & 0 deletions .changeset/violet-rats-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"lingo.dev": patch
---

Add a pseudo-localization mode (--pseudo) to the CLI, including character mapping, recursive object handling, localizer implementation and tests
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/run/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof flagsSchema>;
4 changes: 4 additions & 0 deletions packages/cli/src/cli/cmd/run/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 20 additions & 12 deletions packages/cli/src/cli/cmd/run/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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) {
Expand Down Expand Up @@ -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
? [
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/localizer/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type LocalizerProgressFn = (
) => void;

export interface ILocalizer {
id: "Lingo.dev" | NonNullable<I18nConfig["provider"]>["id"];
id: "Lingo.dev" | "pseudo" | NonNullable<I18nConfig["provider"]>["id"];
checkAuth: () => Promise<{
authenticated: boolean;
username?: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/cli/localizer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/cli/localizer/pseudo.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
124 changes: 124 additions & 0 deletions packages/cli/src/utils/pseudo-localize.spec.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
Loading