Skip to content

Commit d76b729

Browse files
ashutoshdebugvrcprlmaxprilutskiy
authored
feat(cli): add pseudo-localization mode (#1629)
* feat(cli): add pseudo-localization mode * docs(cli): correct path references in README * docs(cli): remove unwanted files * fix: add changeset --------- Co-authored-by: Veronica Prilutskaya <veronica@lingo.dev> Co-authored-by: Max Prilutskiy <5614659+maxprilutskiy@users.noreply.github.com>
1 parent c855cc4 commit d76b729

9 files changed

Lines changed: 362 additions & 14 deletions

File tree

.changeset/violet-rats-hug.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
Add a pseudo-localization mode (--pseudo) to the CLI, including character mapping, recursive object handling, localizer implementation and tests

packages/cli/src/cli/cmd/run/_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,6 @@ export const flagsSchema = z.object({
5353
watch: z.boolean().default(false),
5454
debounce: z.number().positive().default(5000), // 5 seconds default
5555
sound: z.boolean().optional(),
56+
pseudo: z.boolean().optional(),
5657
});
5758
export type CmdRunFlags = z.infer<typeof flagsSchema>;

packages/cli/src/cli/cmd/run/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ export default new Command()
118118
"--sound",
119119
"Play audio feedback when translations complete (success or failure sounds)",
120120
)
121+
.option(
122+
"--pseudo",
123+
"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",
124+
)
121125
.action(async (args) => {
122126
let email: string | null = null;
123127
try {

packages/cli/src/cli/cmd/run/setup.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,8 @@ export default async function setup(input: CmdRunContext) {
5050
{
5151
title: "Selecting localization provider",
5252
task: async (ctx, task) => {
53-
ctx.localizer = createLocalizer(
54-
ctx.config?.provider,
55-
ctx.flags.apiKey,
56-
);
53+
const provider = ctx.flags.pseudo ? "pseudo" : ctx.config?.provider;
54+
ctx.localizer = createLocalizer(provider, ctx.flags.apiKey);
5755
if (!ctx.localizer) {
5856
throw new Error(
5957
"Could not create localization provider. Please check your i18n.json configuration.",
@@ -62,12 +60,15 @@ export default async function setup(input: CmdRunContext) {
6260
task.title =
6361
ctx.localizer.id === "Lingo.dev"
6462
? `Using ${chalk.hex(colors.green)(ctx.localizer.id)} provider`
65-
: `Using raw ${chalk.hex(colors.yellow)(ctx.localizer.id)} API`;
63+
: ctx.localizer.id === "pseudo"
64+
? `Using ${chalk.hex(colors.blue)("pseudo")} mode for testing`
65+
: `Using raw ${chalk.hex(colors.yellow)(ctx.localizer.id)} API`;
6666
},
6767
},
6868
{
6969
title: "Checking authentication",
70-
enabled: (ctx) => ctx.localizer?.id === "Lingo.dev",
70+
enabled: (ctx) =>
71+
ctx.localizer?.id === "Lingo.dev" && !ctx.flags.pseudo,
7172
task: async (ctx, task) => {
7273
const authStatus = await ctx.localizer!.checkAuth();
7374
if (!authStatus.authenticated) {
@@ -95,6 +96,7 @@ export default async function setup(input: CmdRunContext) {
9596
title: "Initializing localization provider",
9697
async task(ctx, task) {
9798
const isLingoDotDev = ctx.localizer!.id === "Lingo.dev";
99+
const isPseudo = ctx.localizer!.id === "pseudo";
98100

99101
const subTasks = isLingoDotDev
100102
? [
@@ -103,12 +105,18 @@ export default async function setup(input: CmdRunContext) {
103105
"Glossary enabled",
104106
"Quality assurance enabled",
105107
].map((title) => ({ title, task: () => {} }))
106-
: [
107-
"Skipping brand voice",
108-
"Skipping glossary",
109-
"Skipping translation memory",
110-
"Skipping quality assurance",
111-
].map((title) => ({ title, task: () => {}, skip: true }));
108+
: isPseudo
109+
? [
110+
"Pseudo-localization mode active",
111+
"Character replacement configured",
112+
"No external API calls",
113+
].map((title) => ({ title, task: () => {} }))
114+
: [
115+
"Skipping brand voice",
116+
"Skipping glossary",
117+
"Skipping translation memory",
118+
"Skipping quality assurance",
119+
].map((title) => ({ title, task: () => {}, skip: true }));
112120

113121
return task.newListr(subTasks, {
114122
concurrent: true,

packages/cli/src/cli/localizer/_types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export type LocalizerProgressFn = (
1616
) => void;
1717

1818
export interface ILocalizer {
19-
id: "Lingo.dev" | NonNullable<I18nConfig["provider"]>["id"];
19+
id: "Lingo.dev" | "pseudo" | NonNullable<I18nConfig["provider"]>["id"];
2020
checkAuth: () => Promise<{
2121
authenticated: boolean;
2222
username?: string;

packages/cli/src/cli/localizer/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ import { I18nConfig } from "@lingo.dev/_spec";
22

33
import createLingoDotDevLocalizer from "./lingodotdev";
44
import createExplicitLocalizer from "./explicit";
5+
import createPseudoLocalizer from "./pseudo";
56
import { ILocalizer } from "./_types";
67

78
export default function createLocalizer(
8-
provider: I18nConfig["provider"],
9+
provider: I18nConfig["provider"] | "pseudo" | null | undefined,
910
apiKey?: string,
1011
): ILocalizer {
12+
if (provider === "pseudo") {
13+
return createPseudoLocalizer();
14+
}
15+
1116
if (!provider) {
1217
return createLingoDotDevLocalizer(apiKey);
1318
} else {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ILocalizer, LocalizerData } from "./_types";
2+
import { pseudoLocalizeObject } from "../../utils/pseudo-localize";
3+
4+
/**
5+
* Creates a pseudo-localizer that doesn't call any external API.
6+
* Instead, it performs character replacement with accented versions,
7+
* useful for testing UI internationalization readiness.
8+
*/
9+
export default function createPseudoLocalizer(): ILocalizer {
10+
return {
11+
id: "pseudo",
12+
checkAuth: async () => {
13+
return {
14+
authenticated: true,
15+
};
16+
},
17+
localize: async (input: LocalizerData, onProgress) => {
18+
// Nothing to translate – return the input as-is.
19+
if (!Object.keys(input.processableData).length) {
20+
return input;
21+
}
22+
23+
// Pseudo-localize all strings in the processable data
24+
const processedData = pseudoLocalizeObject(input.processableData, {
25+
addMarker: true,
26+
addLengthMarker: false,
27+
});
28+
29+
// Call progress callback if provided, simulating completion
30+
if (onProgress) {
31+
onProgress(100, input.processableData, processedData);
32+
}
33+
34+
return processedData;
35+
},
36+
};
37+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect } from "vitest";
2+
import { pseudoLocalize, pseudoLocalizeObject } from "./pseudo-localize";
3+
4+
describe("pseudoLocalize", () => {
5+
it("should replace characters with accented versions", () => {
6+
const result = pseudoLocalize("hello", { addMarker: false });
7+
expect(result).toBe("ĥèļļø");
8+
});
9+
10+
it("should add marker by default", () => {
11+
const result = pseudoLocalize("hello");
12+
expect(result).toBe("ĥèļļø⚡");
13+
});
14+
15+
it("should not add marker when disabled", () => {
16+
const result = pseudoLocalize("hello", { addMarker: false });
17+
expect(result).not.toContain("⚡");
18+
});
19+
20+
it("should handle uppercase letters", () => {
21+
const result = pseudoLocalize("HELLO", { addMarker: false });
22+
expect(result).toBe("ĤÈĻĻØ");
23+
});
24+
25+
it("should preserve non-alphabetic characters", () => {
26+
const result = pseudoLocalize("Hello123!", { addMarker: false });
27+
expect(result).toBe("Ĥèļļø123!");
28+
});
29+
30+
it("should handle empty strings", () => {
31+
const result = pseudoLocalize("");
32+
expect(result).toBe("");
33+
});
34+
35+
it("should handle strings with spaces", () => {
36+
const result = pseudoLocalize("Hello World", { addMarker: false });
37+
expect(result).toBe("Ĥèļļø Ŵøŕļð");
38+
});
39+
40+
it("should add length expansion when enabled", () => {
41+
const original = "hello";
42+
const result = pseudoLocalize(original, {
43+
addMarker: false,
44+
addLengthMarker: true,
45+
lengthExpansion: 30,
46+
});
47+
// 30% expansion of 5 chars = 2 extra chars (rounded up)
48+
expect(result.length).toBeGreaterThan("ĥèļļø".length);
49+
});
50+
51+
it("should handle example from feature proposal", () => {
52+
const result = pseudoLocalize("Submit");
53+
expect(result).toContain("⚡");
54+
expect(result.startsWith("Š")).toBe(true);
55+
});
56+
57+
it("should handle longer text", () => {
58+
const result = pseudoLocalize("Welcome back!");
59+
expect(result).toBe("Ŵèļçømè ƀãçķ!⚡");
60+
});
61+
});
62+
63+
describe("pseudoLocalizeObject", () => {
64+
it("should pseudo-localize string values", () => {
65+
const obj = { greeting: "hello" };
66+
const result = pseudoLocalizeObject(obj, { addMarker: false });
67+
expect(result.greeting).toBe("ĥèļļø");
68+
});
69+
70+
it("should handle nested objects", () => {
71+
const obj = {
72+
en: {
73+
greeting: "hello",
74+
farewell: "goodbye",
75+
},
76+
};
77+
const result = pseudoLocalizeObject(obj, { addMarker: false });
78+
expect(result.en.greeting).toBe("ĥèļļø");
79+
expect(result.en.farewell).toContain("ĝ");
80+
});
81+
82+
it("should handle arrays", () => {
83+
const obj = {
84+
messages: ["hello", "world"],
85+
};
86+
const result = pseudoLocalizeObject(obj, { addMarker: false });
87+
expect(Array.isArray(result.messages)).toBe(true);
88+
expect(result.messages[0]).toBe("ĥèļļø");
89+
});
90+
91+
it("should preserve non-string values", () => {
92+
const obj = {
93+
greeting: "hello",
94+
count: 42,
95+
active: true,
96+
nothing: null,
97+
};
98+
const result = pseudoLocalizeObject(obj, { addMarker: false });
99+
expect(result.greeting).toBe("ĥèļļø");
100+
expect(result.count).toBe(42);
101+
expect(result.active).toBe(true);
102+
expect(result.nothing).toBe(null);
103+
});
104+
105+
it("should handle complex nested structures", () => {
106+
const obj = {
107+
ui: {
108+
buttons: {
109+
submit: "Submit",
110+
cancel: "Cancel",
111+
},
112+
messages: ["error", "warning"],
113+
},
114+
};
115+
const result = pseudoLocalizeObject(obj, { addMarker: false });
116+
expect(result.ui.buttons.submit).toContain("Š");
117+
expect(result.ui.messages[0]).toContain("è");
118+
});
119+
120+
it("should handle empty objects", () => {
121+
const result = pseudoLocalizeObject({}, { addMarker: false });
122+
expect(result).toEqual({});
123+
});
124+
});

0 commit comments

Comments
 (0)