Skip to content

Commit ba51b9b

Browse files
authored
fix: transform object with numeric keys (#469)
Flat loader remembers which sections were object and arrays on pull. It reconstructs original object on push. Loader preserves its local state between pulls.
1 parent 42ff17f commit ba51b9b

5 files changed

Lines changed: 227 additions & 2 deletions

File tree

.changeset/few-trains-pay.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+
transform object with numeric keys

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"dev": "tsup --watch",
3838
"build": "tsc --noEmit && tsup",
3939
"test": "vitest run",
40+
"test:watch": "vitest",
4041
"clean": "rm -rf build"
4142
},
4243
"keywords": [],
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it } from "vitest";
2+
import { flatten } from "flat";
3+
import createFlatLoader, {
4+
buildDenormalizedKeysMap,
5+
denormalizeObjectKeys,
6+
mapDenormalizedKeys,
7+
normalizeObjectKeys,
8+
OBJECT_NUMERIC_KEY_PREFIX,
9+
} from "./flat";
10+
11+
describe("flat loader", () => {
12+
describe("createFlatLoader", () => {
13+
it("loads numeric object and array and preserves state", async () => {
14+
const loader = createFlatLoader();
15+
loader.setDefaultLocale("en");
16+
await loader.pull("en", {
17+
messages: { "1": "foo", "2": "bar" },
18+
years: ["January 13, 2025", "February 14, 2025"],
19+
});
20+
await loader.pull("en", {}); // run again to ensure state is preserved
21+
const output = await loader.push("en", {
22+
"messages/1": "foo",
23+
"messages/2": "bar",
24+
"years/0": "January 13, 2025",
25+
"years/1": "February 14, 2025",
26+
});
27+
expect(output).toEqual({
28+
messages: { "1": "foo", "2": "bar" },
29+
years: ["January 13, 2025", "February 14, 2025"],
30+
});
31+
});
32+
});
33+
34+
describe("helper functions", () => {
35+
const inputObj = {
36+
messages: {
37+
"1": "a",
38+
"2": "b",
39+
},
40+
};
41+
const inputArray = {
42+
messages: ["a", "b", "c"],
43+
};
44+
45+
describe("denormalizeObjectKeys", () => {
46+
it("should denormalize object keys", () => {
47+
const output = denormalizeObjectKeys(inputObj);
48+
expect(output).toEqual({
49+
messages: {
50+
[`${OBJECT_NUMERIC_KEY_PREFIX}1`]: "a",
51+
[`${OBJECT_NUMERIC_KEY_PREFIX}2`]: "b",
52+
},
53+
});
54+
});
55+
56+
it("should preserve array", () => {
57+
const output = denormalizeObjectKeys(inputArray);
58+
expect(output).toEqual({
59+
messages: ["a", "b", "c"],
60+
});
61+
});
62+
});
63+
64+
describe("buildDenormalizedKeysMap", () => {
65+
it("should build normalized keys map", () => {
66+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputObj), { delimiter: "/" });
67+
const output = buildDenormalizedKeysMap(denormalized);
68+
expect(output).toEqual({
69+
"messages/1": `messages/${OBJECT_NUMERIC_KEY_PREFIX}1`,
70+
"messages/2": `messages/${OBJECT_NUMERIC_KEY_PREFIX}2`,
71+
});
72+
});
73+
74+
it("should build keys map array", () => {
75+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputArray), { delimiter: "/" });
76+
const output = buildDenormalizedKeysMap(denormalized);
77+
expect(output).toEqual({
78+
"messages/0": "messages/0",
79+
"messages/1": "messages/1",
80+
"messages/2": "messages/2",
81+
});
82+
});
83+
});
84+
85+
describe("normalizeObjectKeys", () => {
86+
it("should normalize denormalized object keys", () => {
87+
const output = normalizeObjectKeys(denormalizeObjectKeys(inputObj));
88+
expect(output).toEqual(inputObj);
89+
});
90+
91+
it("should process array keys", () => {
92+
const output = normalizeObjectKeys(denormalizeObjectKeys(inputArray));
93+
expect(output).toEqual(inputArray);
94+
});
95+
});
96+
97+
describe("mapDeormalizedKeys", () => {
98+
it("should map normalized keys", () => {
99+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputObj), { delimiter: "/" });
100+
const keyMap = buildDenormalizedKeysMap(denormalized);
101+
const flattened: Record<string, string> = flatten(inputObj, { delimiter: "/" });
102+
const mapped = mapDenormalizedKeys(flattened, keyMap);
103+
expect(mapped).toEqual(denormalized);
104+
});
105+
106+
it("should map array", () => {
107+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputArray), { delimiter: "/" });
108+
const keyMap = buildDenormalizedKeysMap(denormalized);
109+
const flattened: Record<string, string> = flatten(inputArray, { delimiter: "/" });
110+
const mapped = mapDenormalizedKeys(flattened, keyMap);
111+
expect(mapped).toEqual(denormalized);
112+
});
113+
});
114+
});
115+
});
Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,92 @@
11
import { flatten, unflatten } from "flat";
22
import { ILoader } from "./_types";
33
import { createLoader } from "./_utils";
4+
import _ from "lodash";
5+
6+
export const OBJECT_NUMERIC_KEY_PREFIX = "__lingodotdev__obj__";
47

58
export default function createFlatLoader(): ILoader<Record<string, any>, Record<string, string>> {
9+
let denormalizedKeysMap: Record<string, string> = {};
10+
611
return createLoader({
712
pull: async (locale, input) => {
8-
return flatten(input || {}, {
13+
const denormalized = denormalizeObjectKeys(input || {});
14+
const flattened: Record<string, string> = flatten(denormalized, {
915
delimiter: "/",
1016
transformKey(key) {
1117
return encodeURIComponent(String(key));
1218
},
1319
});
20+
denormalizedKeysMap = { ...denormalizedKeysMap, ...buildDenormalizedKeysMap(flattened) };
21+
const normalized = normalizeObjectKeys(flattened);
22+
return normalized;
1423
},
1524
push: async (locale, data) => {
16-
return unflatten(data || {}, {
25+
const denormalized = mapDenormalizedKeys(data, denormalizedKeysMap);
26+
const unflattened: Record<string, any> = unflatten(denormalized || {}, {
1727
delimiter: "/",
1828
transformKey(key) {
1929
return decodeURIComponent(String(key));
2030
},
2131
});
32+
const normalized = normalizeObjectKeys(unflattened);
33+
return normalized;
2234
},
2335
});
2436
}
37+
38+
export function buildDenormalizedKeysMap(obj: Record<string, string>) {
39+
if (!obj) return {};
40+
41+
return Object.keys(obj).reduce(
42+
(acc, key) => {
43+
if (key) {
44+
const normalizedKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, "");
45+
acc[normalizedKey] = key;
46+
}
47+
return acc;
48+
},
49+
{} as Record<string, string>,
50+
);
51+
}
52+
53+
export function mapDenormalizedKeys(obj: Record<string, any>, denormalizedKeysMap: Record<string, string>) {
54+
return Object.keys(obj).reduce(
55+
(acc, key) => {
56+
const denormalizedKey = denormalizedKeysMap[key];
57+
acc[denormalizedKey] = obj[key];
58+
return acc;
59+
},
60+
{} as Record<string, string>,
61+
);
62+
}
63+
64+
export function denormalizeObjectKeys(obj: Record<string, any>): Record<string, any> {
65+
if (_.isObject(obj) && !_.isArray(obj)) {
66+
return _.transform(
67+
obj,
68+
(result, value, key) => {
69+
const newKey = !isNaN(Number(key)) ? `${OBJECT_NUMERIC_KEY_PREFIX}${key}` : key;
70+
result[newKey] = _.isObject(value) ? denormalizeObjectKeys(value) : value;
71+
},
72+
{} as Record<string, any>,
73+
);
74+
} else {
75+
return obj;
76+
}
77+
}
78+
79+
export function normalizeObjectKeys(obj: Record<string, any>): Record<string, any> {
80+
if (_.isObject(obj) && !_.isArray(obj)) {
81+
return _.transform(
82+
obj,
83+
(result, value, key) => {
84+
const newKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, "");
85+
result[newKey] = _.isObject(value) ? normalizeObjectKeys(value) : value;
86+
},
87+
{} as Record<string, any>,
88+
);
89+
} else {
90+
return obj;
91+
}
92+
}

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,42 @@ describe("bucket loaders", () => {
332332

333333
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
334334
});
335+
336+
it("should save json data with numeric keys", async () => {
337+
setupFileMocks();
338+
339+
const input = { messages: { "1": "foo", "2": "bar", "3": "bar" } };
340+
const payload = { "messages/1": "foo", "messages/2": "bar", "messages/3": "bar" };
341+
const expectedOutput = JSON.stringify(input, null, 2);
342+
343+
mockFileOperations(JSON.stringify(input));
344+
345+
const jsonLoader = createBucketLoader("json", "i18n/[locale].json");
346+
jsonLoader.setDefaultLocale("en");
347+
await jsonLoader.pull("en");
348+
349+
await jsonLoader.push("es", payload);
350+
351+
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
352+
});
353+
354+
it("should save json data with array", async () => {
355+
setupFileMocks();
356+
357+
const input = { messages: ["foo", "bar"] };
358+
const payload = { "messages/0": "foo", "messages/1": "bar" };
359+
const expectedOutput = `{\n "messages\": [\"foo\", \"bar\"]\n}`;
360+
361+
mockFileOperations(JSON.stringify(input));
362+
363+
const jsonLoader = createBucketLoader("json", "i18n/[locale].json");
364+
jsonLoader.setDefaultLocale("en");
365+
await jsonLoader.pull("en");
366+
367+
await jsonLoader.push("es", payload);
368+
369+
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
370+
});
335371
});
336372

337373
describe("markdown bucket loader", () => {

0 commit comments

Comments
 (0)