Skip to content

Commit 1f1e33f

Browse files
authored
feat(cli): allow wildcards when matching lockedKeys, ignoredKeys, injectLocale (#1029)
* feat(cli): lockedKeys with wildcards * feat(cli): injectLocale with wildcards * feat(cli): ignoredKeys with wildcards * fix(cli): ensure correct key order even with JSON arrays
1 parent d14b162 commit 1f1e33f

File tree

9 files changed

+311
-8
lines changed

9 files changed

+311
-8
lines changed

.changeset/chatty-cameras-occur.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+
allow wildcards when matching lockedKeys, ignoredKeys, injectLocale

packages/cli/src/cli/loaders/ensure-key-order.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,41 @@ describe("ensure-key-order loader", () => {
1919
expect(result).toEqual({ a: 11, b: 22, c: 33 });
2020
});
2121

22+
it("should reorder keys in objects of nested arrays to match original input order on push", async () => {
23+
const originalInput = [
24+
{ a: 1, b: 2, c: 3 },
25+
{ a: 4, b: 5, c: 6 },
26+
{
27+
values: [
28+
{ a: 7, b: 8, c: 9 },
29+
{ a: 10, b: 11, c: 12 },
30+
],
31+
},
32+
];
33+
await loader.pull("en", originalInput);
34+
const data = [
35+
{ b: 22, a: 11, c: 33 },
36+
{ b: 55, c: 66, a: 44 },
37+
{
38+
values: [
39+
{ b: 88, c: 99, a: 77 },
40+
{ c: 122, b: 111, a: 100 },
41+
],
42+
},
43+
];
44+
const result = await loader.push("en", data);
45+
expect(result).toEqual([
46+
{ a: 11, b: 22, c: 33 },
47+
{ a: 44, b: 55, c: 66 },
48+
{
49+
values: [
50+
{ a: 77, b: 88, c: 99 },
51+
{ a: 100, b: 111, c: 122 },
52+
],
53+
},
54+
]);
55+
});
56+
2257
it("should reorder falsy keys to match original input order on push", async () => {
2358
const originalInput = {
2459
a: 1,

packages/cli/src/cli/loaders/ensure-key-order.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ function reorderKeys(
2323
data: Record<string, any>,
2424
originalInput: Record<string, any>,
2525
): Record<string, any> {
26+
if (_.isArray(originalInput) && _.isArray(data)) {
27+
// If both are arrays, recursively reorder keys in each element
28+
return data.map((item, idx) => reorderKeys(item, originalInput[idx] ?? {}));
29+
}
2630
if (!_.isObject(data) || _.isArray(data) || _.isDate(data)) {
2731
return data;
2832
}

packages/cli/src/cli/loaders/ignored-keys.spec.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const defaultLocale = "en";
66
const targetLocale = "es";
77

88
// Common ignored keys list used across tests
9-
const IGNORED_KEYS = ["meta", "todo"];
9+
const IGNORED_KEYS = ["meta", "todo", "pages/*/title"];
1010

1111
/**
1212
* Creates a fresh loader instance with the default locale already set.
@@ -83,4 +83,100 @@ describe("ignored-keys loader", () => {
8383
todo: "todo es",
8484
});
8585
});
86+
87+
it("should omit keys matching wildcard patterns when pulling the default locale", async () => {
88+
const loader = createLoader();
89+
const input = {
90+
greeting: "hello",
91+
meta: "some meta information",
92+
"pages/0/title": "Title 0",
93+
"pages/0/content": "Content 0",
94+
"pages/foo/title": "Foo Title",
95+
"pages/foo/content": "Foo Content",
96+
"pages/bar/notitle": "No Title",
97+
"pages/bar/content": "No Content",
98+
};
99+
const result = await loader.pull(defaultLocale, input);
100+
expect(result).toEqual({
101+
greeting: "hello",
102+
"pages/0/content": "Content 0",
103+
"pages/foo/content": "Foo Content",
104+
"pages/bar/notitle": "No Title",
105+
"pages/bar/content": "No Content",
106+
});
107+
});
108+
109+
it("should omit keys matching wildcard patterns when pulling a target locale", async () => {
110+
const loader = createLoader();
111+
await loader.pull(defaultLocale, {
112+
greeting: "hello",
113+
meta: "meta en",
114+
"pages/0/title": "Title 0",
115+
"pages/0/content": "Content 0",
116+
"pages/foo/title": "Foo Title",
117+
"pages/foo/content": "Foo Content",
118+
"pages/bar/notitle": "No Title",
119+
"pages/bar/content": "No Content",
120+
});
121+
const targetInput = {
122+
greeting: "hola",
123+
meta: "meta es",
124+
"pages/0/title": "Title 0",
125+
"pages/0/content": "Contenido 0",
126+
"pages/foo/title": "Foo Title",
127+
"pages/foo/content": "Contenido Foo",
128+
"pages/bar/notitle": "No Title",
129+
"pages/bar/content": "No Content",
130+
};
131+
const result = await loader.pull(targetLocale, targetInput);
132+
expect(result).toEqual({
133+
greeting: "hola",
134+
"pages/0/content": "Contenido 0",
135+
"pages/foo/content": "Contenido Foo",
136+
"pages/bar/notitle": "No Title",
137+
"pages/bar/content": "No Content",
138+
});
139+
});
140+
141+
it("should merge wildcard-ignored keys back when pushing a target locale", async () => {
142+
const loader = createLoader();
143+
await loader.pull(defaultLocale, {
144+
greeting: "hello",
145+
meta: "meta en",
146+
"pages/0/title": "Title 0",
147+
"pages/0/content": "Content 0",
148+
"pages/foo/title": "Foo Title",
149+
"pages/foo/content": "Foo Content",
150+
"pages/bar/notitle": "No Title",
151+
"pages/bar/content": "No Content",
152+
});
153+
await loader.pull(targetLocale, {
154+
greeting: "hola",
155+
meta: "meta es",
156+
"pages/0/title": "Título 0",
157+
"pages/0/content": "Contenido 0",
158+
"pages/foo/title": "Título Foo",
159+
"pages/foo/content": "Contenido Foo",
160+
"pages/bar/notitle": "No Título",
161+
"pages/bar/content": "Contenido Bar",
162+
});
163+
const dataToPush = {
164+
greeting: "hola",
165+
"pages/0/content": "Contenido Nuveo",
166+
"pages/foo/content": "Contenido Nuevo Foo",
167+
"pages/bar/notitle": "No Título",
168+
"pages/bar/content": "Contenido Nuevo Bar",
169+
};
170+
const pushResult = await loader.push(targetLocale, dataToPush);
171+
expect(pushResult).toEqual({
172+
greeting: "hola",
173+
meta: "meta es",
174+
"pages/0/title": "Título 0",
175+
"pages/0/content": "Contenido Nuveo",
176+
"pages/foo/title": "Título Foo",
177+
"pages/foo/content": "Contenido Nuevo Foo",
178+
"pages/bar/notitle": "No Título",
179+
"pages/bar/content": "Contenido Nuevo Bar",
180+
});
181+
});
86182
});
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
11
import { ILoader } from "./_types";
22
import { createLoader } from "./_utils";
33
import _ from "lodash";
4+
import { minimatch } from "minimatch";
45

56
export default function createIgnoredKeysLoader(
67
ignoredKeys: string[],
78
): ILoader<Record<string, any>, Record<string, any>> {
89
return createLoader({
910
pull: async (locale, data) => {
10-
const result = _.chain(data).omit(ignoredKeys).value();
11+
const result = _.omitBy(data, (value, key) =>
12+
_isIgnoredKey(key, ignoredKeys),
13+
);
1114
return result;
1215
},
1316
push: async (locale, data, originalInput, originalLocale, pullInput) => {
14-
const result = _.merge({}, data, _.pick(pullInput, ignoredKeys));
17+
const ignoredSubObject = _.pickBy(pullInput, (value, key) =>
18+
_isIgnoredKey(key, ignoredKeys),
19+
);
20+
const result = _.merge({}, data, ignoredSubObject);
1521
return result;
1622
},
1723
});
1824
}
25+
26+
function _isIgnoredKey(key: string, ignoredKeys: string[]) {
27+
return ignoredKeys.some(
28+
(ignoredKey) => key.startsWith(ignoredKey) || minimatch(key, ignoredKey),
29+
);
30+
}

packages/cli/src/cli/loaders/inject-locale.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,35 @@ describe("createInjectLocaleLoader", () => {
4646
const result = await loader.pull(locale, data);
4747
expect(result).toEqual({});
4848
});
49+
50+
it("should omit keys matching wildcard pattern where value matches locale", async () => {
51+
const loader = createInjectLocaleLoader([
52+
"pages.*.locale",
53+
"meta/*/lang",
54+
]);
55+
loader.setDefaultLocale(locale);
56+
const data = {
57+
pages: {
58+
foo: { locale: "en", value: 1 },
59+
bar: { locale: "en", value: 2 },
60+
baz: { locale: "fr", value: 3 },
61+
},
62+
other: 42,
63+
"meta/a/lang": "en",
64+
"meta/b/lang": "fr",
65+
"meta/c/lang": "en",
66+
};
67+
const result = await loader.pull(locale, data);
68+
expect(result).toEqual({
69+
pages: {
70+
foo: { value: 1 },
71+
bar: { value: 2 },
72+
baz: { locale: "fr", value: 3 },
73+
},
74+
other: 42,
75+
"meta/b/lang": "fr",
76+
});
77+
});
4978
});
5079

5180
describe("push", () => {
@@ -137,5 +166,43 @@ describe("createInjectLocaleLoader", () => {
137166
await loader.pull(originalLocale, originalInput);
138167
const data = { value: 2, meta: { other: 2 } };
139168
});
169+
170+
it("should set wildcard-matched keys to new locale if they matched originalLocale", async () => {
171+
const loader = createInjectLocaleLoader([
172+
"pages.*.locale",
173+
"meta/*/lang",
174+
]);
175+
loader.setDefaultLocale(originalLocale);
176+
const originalInput = {
177+
pages: {
178+
foo: { locale: "en", value: 1 },
179+
bar: { locale: "en", value: 2 },
180+
baz: { locale: "fr", value: 3 },
181+
},
182+
"meta/a/lang": "en",
183+
"meta/b/lang": "fr",
184+
"meta/c/lang": "en",
185+
};
186+
await loader.pull(originalLocale, originalInput);
187+
const data = {
188+
pages: {
189+
foo: { value: 10 },
190+
bar: { value: 20 },
191+
baz: { locale: "fr", value: 30 },
192+
},
193+
"meta/b/lang": "fr",
194+
};
195+
const result = await loader.push("de", data);
196+
expect(result).toEqual({
197+
pages: {
198+
foo: { locale: "de", value: 10 },
199+
bar: { locale: "de", value: 20 },
200+
baz: { locale: "fr", value: 30 },
201+
},
202+
"meta/a/lang": "de",
203+
"meta/b/lang": "fr",
204+
"meta/c/lang": "de",
205+
});
206+
});
140207
});
141208
});

packages/cli/src/cli/loaders/inject-locale.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import _ from "lodash";
22
import { ILoader } from "./_types";
33
import { createLoader } from "./_utils";
4+
import { minimatch } from "minimatch";
45

56
export default function createInjectLocaleLoader(
67
injectLocaleKeys?: string[],
@@ -39,7 +40,30 @@ function _getKeysWithLocales(
3940
injectLocaleKeys: string[],
4041
locale: string,
4142
) {
42-
return injectLocaleKeys.filter((key) => {
43-
return _.get(data, key) === locale;
43+
const allKeys = _getAllKeys(data);
44+
return allKeys.filter((key) => {
45+
return (
46+
injectLocaleKeys.some((pattern) => minimatch(key, pattern)) &&
47+
_.get(data, key) === locale
48+
);
4449
});
4550
}
51+
52+
// Helper to get all deep keys in lodash path style (e.g., 'a.b.c')
53+
function _getAllKeys(obj: Record<string, any>, prefix = ""): string[] {
54+
let keys: string[] = [];
55+
for (const key in obj) {
56+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
57+
const path = prefix ? `${prefix}.${key}` : key;
58+
if (
59+
typeof obj[key] === "object" &&
60+
obj[key] !== null &&
61+
!Array.isArray(obj[key])
62+
) {
63+
keys = keys.concat(_getAllKeys(obj[key], path));
64+
} else {
65+
keys.push(path);
66+
}
67+
}
68+
return keys;
69+
}

0 commit comments

Comments
 (0)