diff --git a/.changeset/few-trains-pay.md b/.changeset/few-trains-pay.md new file mode 100644 index 000000000..15957e096 --- /dev/null +++ b/.changeset/few-trains-pay.md @@ -0,0 +1,5 @@ +--- +"lingo.dev": patch +--- + +transform object with numeric keys diff --git a/packages/cli/package.json b/packages/cli/package.json index 54292d56f..a5524ac66 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,7 @@ "dev": "tsup --watch", "build": "tsc --noEmit && tsup", "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf build" }, "keywords": [], diff --git a/packages/cli/src/cli/loaders/flat.spec.ts b/packages/cli/src/cli/loaders/flat.spec.ts new file mode 100644 index 000000000..b7a9d9b03 --- /dev/null +++ b/packages/cli/src/cli/loaders/flat.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { flatten } from "flat"; +import createFlatLoader, { + buildDenormalizedKeysMap, + denormalizeObjectKeys, + mapDenormalizedKeys, + normalizeObjectKeys, + OBJECT_NUMERIC_KEY_PREFIX, +} from "./flat"; + +describe("flat loader", () => { + describe("createFlatLoader", () => { + it("loads numeric object and array and preserves state", async () => { + const loader = createFlatLoader(); + loader.setDefaultLocale("en"); + await loader.pull("en", { + messages: { "1": "foo", "2": "bar" }, + years: ["January 13, 2025", "February 14, 2025"], + }); + await loader.pull("en", {}); // run again to ensure state is preserved + const output = await loader.push("en", { + "messages/1": "foo", + "messages/2": "bar", + "years/0": "January 13, 2025", + "years/1": "February 14, 2025", + }); + expect(output).toEqual({ + messages: { "1": "foo", "2": "bar" }, + years: ["January 13, 2025", "February 14, 2025"], + }); + }); + }); + + describe("helper functions", () => { + const inputObj = { + messages: { + "1": "a", + "2": "b", + }, + }; + const inputArray = { + messages: ["a", "b", "c"], + }; + + describe("denormalizeObjectKeys", () => { + it("should denormalize object keys", () => { + const output = denormalizeObjectKeys(inputObj); + expect(output).toEqual({ + messages: { + [`${OBJECT_NUMERIC_KEY_PREFIX}1`]: "a", + [`${OBJECT_NUMERIC_KEY_PREFIX}2`]: "b", + }, + }); + }); + + it("should preserve array", () => { + const output = denormalizeObjectKeys(inputArray); + expect(output).toEqual({ + messages: ["a", "b", "c"], + }); + }); + }); + + describe("buildDenormalizedKeysMap", () => { + it("should build normalized keys map", () => { + const denormalized: Record = flatten(denormalizeObjectKeys(inputObj), { delimiter: "/" }); + const output = buildDenormalizedKeysMap(denormalized); + expect(output).toEqual({ + "messages/1": `messages/${OBJECT_NUMERIC_KEY_PREFIX}1`, + "messages/2": `messages/${OBJECT_NUMERIC_KEY_PREFIX}2`, + }); + }); + + it("should build keys map array", () => { + const denormalized: Record = flatten(denormalizeObjectKeys(inputArray), { delimiter: "/" }); + const output = buildDenormalizedKeysMap(denormalized); + expect(output).toEqual({ + "messages/0": "messages/0", + "messages/1": "messages/1", + "messages/2": "messages/2", + }); + }); + }); + + describe("normalizeObjectKeys", () => { + it("should normalize denormalized object keys", () => { + const output = normalizeObjectKeys(denormalizeObjectKeys(inputObj)); + expect(output).toEqual(inputObj); + }); + + it("should process array keys", () => { + const output = normalizeObjectKeys(denormalizeObjectKeys(inputArray)); + expect(output).toEqual(inputArray); + }); + }); + + describe("mapDeormalizedKeys", () => { + it("should map normalized keys", () => { + const denormalized: Record = flatten(denormalizeObjectKeys(inputObj), { delimiter: "/" }); + const keyMap = buildDenormalizedKeysMap(denormalized); + const flattened: Record = flatten(inputObj, { delimiter: "/" }); + const mapped = mapDenormalizedKeys(flattened, keyMap); + expect(mapped).toEqual(denormalized); + }); + + it("should map array", () => { + const denormalized: Record = flatten(denormalizeObjectKeys(inputArray), { delimiter: "/" }); + const keyMap = buildDenormalizedKeysMap(denormalized); + const flattened: Record = flatten(inputArray, { delimiter: "/" }); + const mapped = mapDenormalizedKeys(flattened, keyMap); + expect(mapped).toEqual(denormalized); + }); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/flat.ts b/packages/cli/src/cli/loaders/flat.ts index eae3efca6..65687cb9b 100644 --- a/packages/cli/src/cli/loaders/flat.ts +++ b/packages/cli/src/cli/loaders/flat.ts @@ -1,24 +1,92 @@ import { flatten, unflatten } from "flat"; import { ILoader } from "./_types"; import { createLoader } from "./_utils"; +import _ from "lodash"; + +export const OBJECT_NUMERIC_KEY_PREFIX = "__lingodotdev__obj__"; export default function createFlatLoader(): ILoader, Record> { + let denormalizedKeysMap: Record = {}; + return createLoader({ pull: async (locale, input) => { - return flatten(input || {}, { + const denormalized = denormalizeObjectKeys(input || {}); + const flattened: Record = flatten(denormalized, { delimiter: "/", transformKey(key) { return encodeURIComponent(String(key)); }, }); + denormalizedKeysMap = { ...denormalizedKeysMap, ...buildDenormalizedKeysMap(flattened) }; + const normalized = normalizeObjectKeys(flattened); + return normalized; }, push: async (locale, data) => { - return unflatten(data || {}, { + const denormalized = mapDenormalizedKeys(data, denormalizedKeysMap); + const unflattened: Record = unflatten(denormalized || {}, { delimiter: "/", transformKey(key) { return decodeURIComponent(String(key)); }, }); + const normalized = normalizeObjectKeys(unflattened); + return normalized; }, }); } + +export function buildDenormalizedKeysMap(obj: Record) { + if (!obj) return {}; + + return Object.keys(obj).reduce( + (acc, key) => { + if (key) { + const normalizedKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, ""); + acc[normalizedKey] = key; + } + return acc; + }, + {} as Record, + ); +} + +export function mapDenormalizedKeys(obj: Record, denormalizedKeysMap: Record) { + return Object.keys(obj).reduce( + (acc, key) => { + const denormalizedKey = denormalizedKeysMap[key]; + acc[denormalizedKey] = obj[key]; + return acc; + }, + {} as Record, + ); +} + +export function denormalizeObjectKeys(obj: Record): Record { + if (_.isObject(obj) && !_.isArray(obj)) { + return _.transform( + obj, + (result, value, key) => { + const newKey = !isNaN(Number(key)) ? `${OBJECT_NUMERIC_KEY_PREFIX}${key}` : key; + result[newKey] = _.isObject(value) ? denormalizeObjectKeys(value) : value; + }, + {} as Record, + ); + } else { + return obj; + } +} + +export function normalizeObjectKeys(obj: Record): Record { + if (_.isObject(obj) && !_.isArray(obj)) { + return _.transform( + obj, + (result, value, key) => { + const newKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, ""); + result[newKey] = _.isObject(value) ? normalizeObjectKeys(value) : value; + }, + {} as Record, + ); + } else { + return obj; + } +} diff --git a/packages/cli/src/cli/loaders/index.spec.ts b/packages/cli/src/cli/loaders/index.spec.ts index 430b41303..69b340624 100644 --- a/packages/cli/src/cli/loaders/index.spec.ts +++ b/packages/cli/src/cli/loaders/index.spec.ts @@ -332,6 +332,42 @@ describe("bucket loaders", () => { expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" }); }); + + it("should save json data with numeric keys", async () => { + setupFileMocks(); + + const input = { messages: { "1": "foo", "2": "bar", "3": "bar" } }; + const payload = { "messages/1": "foo", "messages/2": "bar", "messages/3": "bar" }; + const expectedOutput = JSON.stringify(input, null, 2); + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json"); + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" }); + }); + + it("should save json data with array", async () => { + setupFileMocks(); + + const input = { messages: ["foo", "bar"] }; + const payload = { "messages/0": "foo", "messages/1": "bar" }; + const expectedOutput = `{\n "messages\": [\"foo\", \"bar\"]\n}`; + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json"); + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" }); + }); }); describe("markdown bucket loader", () => {