Skip to content

Commit afbb978

Browse files
authored
feat(cli): add json-dictionary loader support (#1031)
1 parent eadbe94 commit afbb978

6 files changed

Lines changed: 323 additions & 1 deletion

File tree

.changeset/proud-vans-tell.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@lingo.dev/_spec": patch
3+
"lingo.dev": patch
4+
---
5+
6+
add json-dictionary loader support

packages/cli/src/cli/loaders/index.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,6 +2777,71 @@ Línea 3`;
27772777
);
27782778
});
27792779
});
2780+
2781+
describe("json-dictionary bucket loader", () => {
2782+
it("should add target locale keys only where source locale keys exist", async () => {
2783+
setupFileMocks();
2784+
const input = {
2785+
title: { en: "I am a title" },
2786+
logoPosition: "right",
2787+
pages: [
2788+
{
2789+
name: "Welcome to my world",
2790+
elements: [
2791+
{
2792+
title: { en: "I am an element title" },
2793+
description: { en: "I am an element description" },
2794+
},
2795+
],
2796+
},
2797+
],
2798+
};
2799+
mockFileOperations(JSON.stringify(input));
2800+
const loader = createBucketLoader(
2801+
"json-dictionary",
2802+
"i18n/[locale].json",
2803+
{
2804+
defaultLocale: "en",
2805+
},
2806+
);
2807+
loader.setDefaultLocale("en");
2808+
await loader.pull("en");
2809+
await loader.push("es", {
2810+
title: "Yo soy un titulo",
2811+
"pages/0/elements/0/title": "Yo soy un elemento de titulo",
2812+
"pages/0/elements/0/description": "Yo soy una descripcion de elemento",
2813+
});
2814+
const expectedOutput = `{
2815+
"title": {
2816+
"en": "I am a title",
2817+
"es": "Yo soy un titulo"
2818+
},
2819+
"logoPosition": "right",
2820+
"pages": [
2821+
{
2822+
"name": "Welcome to my world",
2823+
"elements": [
2824+
{
2825+
"title": {
2826+
"en": "I am an element title",
2827+
"es": "Yo soy un elemento de titulo"
2828+
},
2829+
"description": {
2830+
"en": "I am an element description",
2831+
"es": "Yo soy una descripcion de elemento"
2832+
}
2833+
}
2834+
]
2835+
}
2836+
]
2837+
}`;
2838+
expect(fs.writeFile).toHaveBeenCalledWith(
2839+
"i18n/es.json",
2840+
expectedOutput,
2841+
{ encoding: "utf-8", flag: "w" },
2842+
);
2843+
});
2844+
});
27802845
});
27812846

27822847
function setupFileMocks() {

packages/cli/src/cli/loaders/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ import createIgnoredKeysLoader from "./ignored-keys";
4242
import createEjsLoader from "./ejs";
4343
import createEnsureKeyOrderLoader from "./ensure-key-order";
4444
import createTxtLoader from "./txt";
45+
import createJsonKeysLoader from "./json-dictionary";
4546

4647
type BucketLoaderOptions = {
4748
returnUnlocalizedKeys?: boolean;
4849
defaultLocale: string;
4950
injectLocale?: string[];
51+
targetLocale?: string;
5052
};
5153

5254
export default function createBucketLoader(
@@ -56,7 +58,7 @@ export default function createBucketLoader(
5658
lockedKeys?: string[],
5759
lockedPatterns?: string[],
5860
ignoredKeys?: string[],
59-
): ILoader<void, Record<string, string>> {
61+
): ILoader<void, Record<string, any>> {
6062
switch (bucketType) {
6163
default:
6264
throw new Error(`Unsupported bucket type: ${bucketType}`);
@@ -286,5 +288,18 @@ export default function createBucketLoader(
286288
createSyncLoader(),
287289
createUnlocalizableLoader(options.returnUnlocalizedKeys),
288290
);
291+
case "json-dictionary":
292+
return composeLoaders(
293+
createTextFileLoader(bucketPathPattern),
294+
createPrettierLoader({ parser: "json", bucketPathPattern }),
295+
createJsonLoader(),
296+
createJsonKeysLoader(),
297+
createEnsureKeyOrderLoader(),
298+
createFlatLoader(),
299+
createInjectLocaleLoader(options.injectLocale),
300+
createLockedKeysLoader(lockedKeys || []),
301+
createSyncLoader(),
302+
createUnlocalizableLoader(options.returnUnlocalizedKeys),
303+
);
289304
}
290305
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { forEach } from "lodash";
2+
import createJsonKeysLoader from "./json-dictionary";
3+
import { describe, it, expect } from "vitest";
4+
5+
describe("json-dictionary loader", () => {
6+
const input = {
7+
title: {
8+
en: "I am a title",
9+
},
10+
logoPosition: "right",
11+
pages: [
12+
{
13+
name: "Welcome to my world",
14+
elements: [
15+
{
16+
title: {
17+
en: "I am an element title",
18+
},
19+
description: {
20+
en: "I am an element description",
21+
},
22+
},
23+
],
24+
},
25+
],
26+
};
27+
28+
it("should return nested object of only translatable keys on pull", async () => {
29+
const loader = createJsonKeysLoader();
30+
loader.setDefaultLocale("en");
31+
const pulled = await loader.pull("en", input);
32+
expect(pulled).toEqual({
33+
title: "I am a title",
34+
pages: [
35+
{
36+
elements: [
37+
{
38+
title: "I am an element title",
39+
description: "I am an element description",
40+
},
41+
],
42+
},
43+
],
44+
});
45+
});
46+
47+
it("should add target locale keys only where source locale keys exist on push", async () => {
48+
const loader = createJsonKeysLoader();
49+
loader.setDefaultLocale("en");
50+
const pulled = await loader.pull("en", input);
51+
const output = await loader.push("es", {
52+
title: "Yo soy un titulo",
53+
logoPosition: "right",
54+
pages: [
55+
{
56+
name: "Welcome to my world",
57+
elements: [
58+
{
59+
title: "Yo soy un elemento de titulo",
60+
description: "Yo soy una descripcion de elemento",
61+
},
62+
],
63+
},
64+
],
65+
});
66+
expect(output).toEqual({
67+
title: { en: "I am a title", es: "Yo soy un titulo" },
68+
logoPosition: "right",
69+
pages: [
70+
{
71+
name: "Welcome to my world",
72+
elements: [
73+
{
74+
title: {
75+
en: "I am an element title",
76+
es: "Yo soy un elemento de titulo",
77+
},
78+
description: {
79+
en: "I am an element description",
80+
es: "Yo soy una descripcion de elemento",
81+
},
82+
},
83+
],
84+
},
85+
],
86+
});
87+
});
88+
89+
it("should correctly order locale keys on push", async () => {
90+
const loader = createJsonKeysLoader();
91+
loader.setDefaultLocale("en");
92+
const pulled = await loader.pull("en", {
93+
data: {
94+
en: "foo1",
95+
es: "foo2",
96+
de: "foo3",
97+
},
98+
});
99+
const output = await loader.push("fr", { data: "foo4" });
100+
expect(Object.keys(output.data)).toEqual(["en", "de", "es", "fr"]);
101+
});
102+
103+
it("should not add target locale keys to non-object values", async () => {
104+
const loader = createJsonKeysLoader();
105+
loader.setDefaultLocale("en");
106+
const data = { foo: 123, bar: true, baz: null };
107+
const pulled = await loader.pull("en", data);
108+
expect(pulled).toEqual({});
109+
const output = await loader.push("es", pulled);
110+
expect(output).toEqual({
111+
foo: 123,
112+
bar: true,
113+
baz: null,
114+
});
115+
});
116+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import _ from "lodash";
2+
import { ILoader } from "./_types";
3+
import { createLoader } from "./_utils";
4+
5+
export default function createJsonDictionaryLoader(): ILoader<
6+
Record<string, any>,
7+
Record<string, any>
8+
> {
9+
return createLoader({
10+
pull: async (locale, input) => {
11+
return extractTranslatables(input, locale);
12+
},
13+
push: async (locale, data, originalInput, originalLocale) => {
14+
if (!originalInput) {
15+
throw new Error("Error while parsing json-dictionary bucket");
16+
}
17+
const input = _.cloneDeep(originalInput);
18+
19+
// set the translation under the target locale key, use value from data (which is now a string)
20+
function walk(obj: any, dataNode: any, path: string[] = []) {
21+
if (Array.isArray(obj) && Array.isArray(dataNode)) {
22+
obj.forEach((item, idx) =>
23+
walk(item, dataNode[idx], [...path, String(idx)]),
24+
);
25+
} else if (
26+
obj &&
27+
typeof obj === "object" &&
28+
dataNode &&
29+
typeof dataNode === "object" &&
30+
!Array.isArray(dataNode)
31+
) {
32+
for (const key of Object.keys(obj)) {
33+
if (dataNode.hasOwnProperty(key)) {
34+
walk(obj[key], dataNode[key], [...path, key]);
35+
}
36+
}
37+
} else if (
38+
obj &&
39+
typeof obj === "object" &&
40+
!Array.isArray(obj) &&
41+
typeof dataNode === "string"
42+
) {
43+
// dataNode is the new string for the target locale
44+
setNestedLocale(input, path, locale, dataNode, originalLocale);
45+
}
46+
}
47+
walk(originalInput, data);
48+
49+
return input;
50+
},
51+
});
52+
}
53+
54+
// extract all keys that match locale from object
55+
function extractTranslatables(obj: any, locale: string): any {
56+
if (Array.isArray(obj)) {
57+
return obj.map((item) => extractTranslatables(item, locale));
58+
} else if (isTranslatableObject(obj, locale)) {
59+
return obj[locale];
60+
} else if (obj && typeof obj === "object") {
61+
const result: any = {};
62+
for (const key of Object.keys(obj)) {
63+
const value = extractTranslatables(obj[key], locale);
64+
if (
65+
(typeof value === "object" &&
66+
value !== null &&
67+
Object.keys(value).length > 0) ||
68+
(Array.isArray(value) && value.length > 0) ||
69+
(typeof value === "string" && value.length > 0)
70+
) {
71+
result[key] = value;
72+
}
73+
}
74+
return result;
75+
}
76+
return undefined;
77+
}
78+
79+
function isTranslatableObject(obj: any, locale: string): boolean {
80+
return (
81+
obj &&
82+
typeof obj === "object" &&
83+
!Array.isArray(obj) &&
84+
Object.prototype.hasOwnProperty.call(obj, locale)
85+
);
86+
}
87+
88+
function setNestedLocale(
89+
obj: any,
90+
path: string[],
91+
locale: string,
92+
value: string,
93+
originalLocale: string,
94+
) {
95+
let curr = obj;
96+
for (let i = 0; i < path.length - 1; i++) {
97+
const key = path[i];
98+
if (!(key in curr)) curr[key] = {};
99+
curr = curr[key];
100+
}
101+
const last = path[path.length - 1];
102+
if (curr[last] && typeof curr[last] === "object") {
103+
curr[last][locale] = value;
104+
// Reorder keys: source locale first, then others alphabetically
105+
if (originalLocale && curr[last][originalLocale]) {
106+
const entries = Object.entries(curr[last]);
107+
const first = entries.filter(([k]) => k === originalLocale);
108+
const rest = entries
109+
.filter(([k]) => k !== originalLocale)
110+
.sort(([a], [b]) => a.localeCompare(b));
111+
const ordered = [...first, ...rest];
112+
const reordered: Record<string, string> = {};
113+
for (const [k, v] of ordered) {
114+
reordered[k] = v as string;
115+
}
116+
curr[last] = reordered;
117+
}
118+
}
119+
}

packages/spec/src/formats.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const bucketTypes = [
2727
"vue-json",
2828
"typescript",
2929
"txt",
30+
"json-dictionary",
3031
] as const;
3132

3233
export const bucketTypeSchema = Z.enum(bucketTypes);

0 commit comments

Comments
 (0)