Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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