Skip to content

Commit

Permalink
add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
pontusab committed Jan 22, 2025
1 parent 16ee6e8 commit b06a3ce
Show file tree
Hide file tree
Showing 8 changed files with 282 additions and 17 deletions.
2 changes: 1 addition & 1 deletion examples/next-international/languine.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineConfig } from "languine";
export default defineConfig({
locale: {
source: "en",
targets: ["fr"],
targets: ["sv"],
},
files: {
ts: {
Expand Down
16 changes: 13 additions & 3 deletions examples/next-international/locales/en.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
export default {
hello: {
world: "Hello World",
},
hello: "Hello",
welcome: "Hello {name}!",
"about.you": "Hello {name}! You are {age} years old",
"scope.test": "A scope",
"scope.more.test": "A scope",
"scope.more.param": "A scope with {param}",
"scope.more.and.more.test": "A scope",
"scope.more.stars#one": "1 star on GitHub",
"scope.more.stars#other": "{count} stars on GitHub",
"missing.translation.in.fr": "This should work",
"cows#one": "A cow",
"cows#other": "{count} cows",
"languine.hello": "Hello Languine",
} as const;
Empty file.
15 changes: 15 additions & 0 deletions examples/next-international/locales/sv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default {
hello: "Hej",
welcome: "Hej {name}!",
"about.you": "Hej {name}! Du är {age} år gammal",
"scope.test": "Ett omfång",
"scope.more.test": "Ett omfång",
"scope.more.param": "Ett omfång med {param}",
"scope.more.and.more.test": "Ett omfång",
"scope.more.stars#one": "1 stjärna på GitHub",
"scope.more.stars#other": "{count} stjärnor på GitHub",
"missing.translation.in.fr": "Detta borde fungera",
"cows#one": "En ko",
"cows#other": "{count} kor",
"languine.hello": "Hej Languine",
} as const;
9 changes: 5 additions & 4 deletions packages/cli/src/commands/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,15 @@ export async function translateCommand(args: string[] = []) {
const parser = createParser({ type });

// Read and parse the source file
const sourceFile = await readFile(sourceFilePath, "utf-8");
const sourceContent = await parser.parse(sourceFile);
const sourceFileContent = await readFile(sourceFilePath, "utf-8");
const parsedSourceFile = await parser.parse(sourceFileContent);

let keysToTranslate: string[];
let removedKeys: string[] = [];

if (forceTranslate) {
// If force flag is used, translate all keys
keysToTranslate = Object.keys(sourceContent);
keysToTranslate = Object.keys(parsedSourceFile);
} else {
// Otherwise use normal diff detection
try {
Expand Down Expand Up @@ -167,7 +167,7 @@ export async function translateCommand(args: string[] = []) {
if (checkOnly) continue;

// Convert the content to the expected format
const translationInput = Object.entries(sourceContent)
const translationInput = Object.entries(parsedSourceFile)
.filter(([key]) => keysToTranslate.includes(key))
.map(([key, sourceText]) => ({
key,
Expand Down Expand Up @@ -350,6 +350,7 @@ export async function translateCommand(args: string[] = []) {
targetLocale,
mergedContent,
originalFileContent,
sourceFileContent,
);

// Run afterTranslate hook if configured
Expand Down
154 changes: 149 additions & 5 deletions packages/cli/src/parsers/__tests__/javascript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,63 @@ describe("JavaScript/TypeScript Parser", () => {
"Invalid translation value",
);
});

test("handles template literals", async () => {
const input = `{
message: "Hello {name}",
template: "Multi line string"
}`;
const result = await parser.parse(input);
expect(result).toEqual({
message: "Hello {name}",
template: "Multi line string",
});
});

test("handles comments in source", async () => {
const input = `{
// Single line comment
key: "value",
/* Multi
line
comment */
other: "test"
}`;
const result = await parser.parse(input);
expect(result).toEqual({
key: "value",
other: "test",
});
});

test("preserves whitespace in translation strings", async () => {
const input = `{
greeting: " Hello World ",
multiline: "Line 1\\nLine 2\\nLine 3"
}`;
const result = await parser.parse(input);
expect(result).toEqual({
greeting: " Hello World ",
multiline: "Line 1\nLine 2\nLine 3",
});
});

test("serializes with consistent ordering", async () => {
const originalData = {
b: "original second",
a: "original first",
c: "original third",
};
const input = {
b: "second",
a: "first",
c: "third",
};
const result = await parser.serialize("en", input, originalData);
expect(result).toBe(
`export default {\n b: "second",\n a: "first",\n c: "third"\n} as const;\n`,
);
});
});

describe("serialize", () => {
Expand All @@ -174,14 +231,49 @@ describe("JavaScript/TypeScript Parser", () => {
expect(result).toBe(`export default {\n key: "value"\n} as const;\n`);
});

test("serializes dot notation keys", async () => {
const input = {
test("serializes dot notation keys when original uses dot notation", async () => {
const originalData = {
"nested.key": "value",
"nested.deeper.another": "test",
"scope.more.stars#one": "1 star on GitHub",
};
const result = await parser.serialize("en", input);

const input = {
"nested.key": "valeur",
"nested.deeper.another": "tester",
"scope.more.stars#one": "1 étoile sur GitHub",
};

const result = await parser.serialize("fr", input, originalData);
expect(result).toBe(
`export default {\n "nested.key": "value",\n "nested.deeper.another": "test"\n} as const;\n`,
`export default {\n "nested.key": "valeur",\n "nested.deeper.another": "tester",\n "scope.more.stars#one": "1 étoile sur GitHub"\n} as const;\n`,
);
});

test("serializes as nested objects when original uses nested structure", async () => {
const originalData = {
nested: {
key: "value",
deeper: {
another: "test",
},
},
scope: {
more: {
"stars#one": "1 star on GitHub",
},
},
};

const input = {
"nested.key": "valeur",
"nested.deeper.another": "tester",
"scope.more.stars#one": "1 étoile sur GitHub",
};

const result = await parser.serialize("fr", input, originalData);
expect(result).toBe(
`export default {\n nested: {\n key: "valeur",\n deeper: {\n another: "tester"\n }\n },\n scope: {\n more: {\n "stars#one": "1 étoile sur GitHub"\n }\n }\n} as const;\n`,
);
});

Expand Down Expand Up @@ -210,7 +302,7 @@ describe("JavaScript/TypeScript Parser", () => {
);
});

test("serializes complex keys with dots and parameters", async () => {
test("defaults to dot notation when no original data is provided", async () => {
const input = {
"scope.more.stars#one": "1 star on GitHub",
"scope.more.stars#other": "{count} stars on GitHub",
Expand Down Expand Up @@ -252,5 +344,57 @@ describe("JavaScript/TypeScript Parser", () => {
`export default {\n hello: "Bonjour",\n welcome: "Bonjour {name} !",\n "about.you": "Bonjour {name} ! Vous avez {age} ans",\n "scope.test": "Un domaine",\n "scope.more.test": "Un domaine",\n "scope.more.param": "Un domaine avec {param}",\n "scope.more.and.more.test": "Un domaine",\n "scope.more.stars#one": "1 étoile sur GitHub",\n "scope.more.stars#other": "{count} étoiles sur GitHub"\n} as const;\n`,
);
});

test("maintains same structure as example file", async () => {
const originalData = {
hello: {
world: "Hello World",
},
};

const input = {
"hello.world": "Bonjour le monde",
};

const result = await parser.serialize("fr", input, originalData);
expect(result).toBe(
`export default {\n hello: {\n world: "Bonjour le monde"\n }\n} as const;\n`,
);
});

test("handles complex interpolation patterns", async () => {
const input = `{
message: "You have {count} item{count, plural, one{} other{s}} in your cart",
price: "Total: {currency}{amount, number, .00}"
}`;
const result = await parser.parse(input);
expect(result).toEqual({
message:
"You have {count} item{count, plural, one{} other{s}} in your cart",
price: "Total: {currency}{amount, number, .00}",
});
});

test("handles object spread syntax", async () => {
const input = `{
...commonTranslations,
specific: "value"
}`;
await expect(parser.parse(input)).rejects.toThrow();
});

test("handles special characters in keys", async () => {
const input = `{
"special-key": "value",
"key_with_underscore": "test",
"123numeric": "number"
}`;
const result = await parser.parse(input);
expect(result).toEqual({
"special-key": "value",
key_with_underscore: "test",
"123numeric": "number",
});
});
});
});
1 change: 1 addition & 0 deletions packages/cli/src/parsers/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export interface Parser {
locale: string,
data: Record<string, string>,
originalData?: string | Record<string, unknown>,
sourceData?: string | Record<string, unknown>,
): Promise<string>;
}
102 changes: 98 additions & 4 deletions packages/cli/src/parsers/formats/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,110 @@ export class JavaScriptParser extends BaseParser {
}

async serialize(
locale: string,
_: string,
data: Record<string, string>,
originalData?: string | Record<string, unknown>,
sourceData?: string | Record<string, unknown>,
): Promise<string> {
// Flatten any nested objects in the input data
const flatData = this.flattenObject(data);
const content = this.formatFlatObject(flatData);
let content: string;

if (sourceData) {
// If we have source data, use its format as a template
const isNestedFormat =
typeof sourceData === "string"
? this.isNestedObjectFormat(sourceData)
: this.hasNestedObjects(sourceData);

content = isNestedFormat
? this.formatNestedObject(data)
: this.formatFlatObject(data);
} else if (originalData) {
// Fall back to original data format if source not available
const isNestedFormat =
typeof originalData === "string"
? this.isNestedObjectFormat(originalData)
: this.hasNestedObjects(originalData);

content = isNestedFormat
? this.formatNestedObject(data)
: this.formatFlatObject(data);
} else {
// Default to flat object with dot notation
content = this.formatFlatObject(data);
}

return this.wrapInExport(content);
}

private isNestedObjectFormat(data: string): boolean {
try {
const cleanInput = this.preprocessInput(data);
const parsed = this.evaluateJavaScript(cleanInput);
return this.hasNestedObjects(parsed);
} catch {
return false;
}
}

private hasNestedObjects(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return false;

for (const value of Object.values(obj)) {
if (typeof value === "object" && value !== null) {
return true;
}
}
return false;
}

private formatNestedObject(data: Record<string, string>): string {
if (Object.keys(data).length === 0) {
return "{}";
}

// Group by common prefixes
const groups: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const parts = key.split(".");
let current = groups;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
const lastPart = parts[parts.length - 1];
current[lastPart] = value;
}

return this.formatObjectRecursive(groups);
}

private formatObjectRecursive(
obj: Record<string, unknown>,
level = 1,
): string {
if (Object.keys(obj).length === 0) {
return "{}";
}

const indent = " ".repeat(level);
const entries = Object.entries(obj).map(([key, value]) => {
const formattedKey = this.needsQuotes(key) ? `"${key}"` : key;
const formattedValue =
typeof value === "object" && value !== null
? this.formatObjectRecursive(
value as Record<string, unknown>,
level + 1,
)
: `"${String(value).replace(/"/g, '\\"')}"`;
return `${indent}${formattedKey}: ${formattedValue}`;
});

return `{\n${entries.join(",\n")}\n${" ".repeat(level - 1)}}`;
}

private formatFlatObject(obj: Record<string, string>): string {
if (Object.keys(obj).length === 0) {
return "{}";
Expand Down

0 comments on commit b06a3ce

Please sign in to comment.