Skip to content

Commit fe922a4

Browse files
feat: key locking support (#627)
* feat: add config 1.6 * feat: add key locking support * test: added tests for key lockin * fix: cache restore support in locked keys mdoe * test: upd test * chore: cleanup
1 parent 4f8c7c5 commit fe922a4

7 files changed

Lines changed: 356 additions & 29 deletions

File tree

.changeset/sixty-houses-marry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@lingo.dev/_spec": minor
3+
"lingo.dev": minor
4+
---
5+
6+
add support for json/yaml key locking

packages/cli/src/cli/cmd/i18n.ts

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,16 @@ export default new Command()
124124
for (const bucket of buckets) {
125125
for (const bucketPath of bucket.paths) {
126126
const sourceLocale = resolveOverriddenLocale(i18nConfig!.locale.source, bucketPath.delimiter);
127-
const bucketLoader = createBucketLoader(bucket.type, bucketPath.pathPattern, {
128-
isCacheRestore: false,
129-
defaultLocale: sourceLocale,
130-
injectLocale: bucket.injectLocale,
131-
});
127+
const bucketLoader = createBucketLoader(
128+
bucket.type,
129+
bucketPath.pathPattern,
130+
{
131+
isCacheRestore: false,
132+
defaultLocale: sourceLocale,
133+
injectLocale: bucket.injectLocale,
134+
},
135+
bucket.lockedKeys,
136+
);
132137
bucketLoader.setDefaultLocale(sourceLocale);
133138
await bucketLoader.init();
134139

@@ -216,11 +221,16 @@ export default new Command()
216221
bucketOra.info(`Processing path: ${bucketPath.pathPattern}`);
217222

218223
const sourceLocale = resolveOverriddenLocale(i18nConfig!.locale.source, bucketPath.delimiter);
219-
const bucketLoader = createBucketLoader(bucket.type, bucketPath.pathPattern, {
220-
isCacheRestore: true,
221-
defaultLocale: sourceLocale,
222-
injectLocale: bucket.injectLocale,
223-
});
224+
const bucketLoader = createBucketLoader(
225+
bucket.type,
226+
bucketPath.pathPattern,
227+
{
228+
isCacheRestore: true,
229+
defaultLocale: sourceLocale,
230+
injectLocale: bucket.injectLocale,
231+
},
232+
bucket.lockedKeys,
233+
);
224234
bucketLoader.setDefaultLocale(sourceLocale);
225235
await bucketLoader.init();
226236
const sourceData = await bucketLoader.pull(sourceLocale);
@@ -264,12 +274,17 @@ export default new Command()
264274
for (const bucketPath of bucket.paths) {
265275
const sourceLocale = resolveOverriddenLocale(i18nConfig!.locale.source, bucketPath.delimiter);
266276

267-
const bucketLoader = createBucketLoader(bucket.type, bucketPath.pathPattern, {
268-
isCacheRestore: false,
269-
defaultLocale: sourceLocale,
270-
returnUnlocalizedKeys: true,
271-
injectLocale: bucket.injectLocale,
272-
});
277+
const bucketLoader = createBucketLoader(
278+
bucket.type,
279+
bucketPath.pathPattern,
280+
{
281+
isCacheRestore: false,
282+
defaultLocale: sourceLocale,
283+
returnUnlocalizedKeys: true,
284+
injectLocale: bucket.injectLocale,
285+
},
286+
bucket.lockedKeys,
287+
);
273288
bucketLoader.setDefaultLocale(sourceLocale);
274289
await bucketLoader.init();
275290

@@ -346,11 +361,16 @@ export default new Command()
346361

347362
const sourceLocale = resolveOverriddenLocale(i18nConfig!.locale.source, bucketPath.delimiter);
348363

349-
const bucketLoader = createBucketLoader(bucket.type, bucketPath.pathPattern, {
350-
isCacheRestore: false,
351-
defaultLocale: sourceLocale,
352-
injectLocale: bucket.injectLocale,
353-
});
364+
const bucketLoader = createBucketLoader(
365+
bucket.type,
366+
bucketPath.pathPattern,
367+
{
368+
isCacheRestore: false,
369+
defaultLocale: sourceLocale,
370+
injectLocale: bucket.injectLocale,
371+
},
372+
bucket.lockedKeys,
373+
);
354374
bucketLoader.setDefaultLocale(sourceLocale);
355375
await bucketLoader.init();
356376
let sourceData = await bucketLoader.pull(sourceLocale);

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

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,65 @@ describe("bucket loaders", () => {
444444
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
445445
});
446446

447+
it("should respect locked keys during cache restoration", async () => {
448+
setupFileMocks();
449+
450+
const input = {
451+
"button.title": "Submit",
452+
"button.description": "Extra field not in payload",
453+
"locked.key": "Should not change",
454+
nested: {
455+
locked: "This is locked",
456+
unlocked: "This can change",
457+
extra: "This should be removed in cache restore",
458+
},
459+
};
460+
const payload = {
461+
"button.title": "Enviar",
462+
"locked.key": "This should not be applied",
463+
"nested/locked": "This should not be applied either",
464+
"nested/unlocked": "Este puede cambiar",
465+
};
466+
467+
mockFileOperations(JSON.stringify(input));
468+
469+
const jsonLoader = createBucketLoader(
470+
"json",
471+
"i18n/[locale].json",
472+
{ isCacheRestore: true, defaultLocale: "en" },
473+
["locked.key", "nested/locked"],
474+
);
475+
476+
jsonLoader.setDefaultLocale("en");
477+
await jsonLoader.pull("en");
478+
479+
await jsonLoader.push("es", payload);
480+
481+
expect(fs.writeFile).toHaveBeenCalled();
482+
const writeFileCall = (fs.writeFile as any).mock.calls[0];
483+
const writtenContent = JSON.parse(writeFileCall[1]);
484+
485+
// During cache restoration, only keys in the payload should be included
486+
// but locked keys should still be preserved
487+
expect(Object.keys(writtenContent)).toContain("button.title");
488+
expect(Object.keys(writtenContent)).toContain("locked.key");
489+
expect(writtenContent["locked.key"]).toBe("Should not change");
490+
491+
// Fields not in the payload should be removed in cache restoration
492+
expect(Object.keys(writtenContent)).not.toContain("button.description");
493+
494+
// Nested keys should follow the same pattern
495+
expect(writtenContent.nested).toHaveProperty("unlocked", "Este puede cambiar");
496+
expect(writtenContent.nested).toHaveProperty("locked", "This is locked");
497+
expect(writtenContent.nested).not.toHaveProperty("extra");
498+
499+
// Only locked keys and payload keys should be present
500+
expect(Object.keys(writtenContent)).toEqual(expect.arrayContaining(["button.title", "locked.key", "nested"]));
501+
expect(Object.keys(writtenContent)).toHaveLength(3);
502+
expect(Object.keys(writtenContent.nested)).toEqual(expect.arrayContaining(["locked", "unlocked"]));
503+
expect(Object.keys(writtenContent.nested)).toHaveLength(2);
504+
});
505+
447506
it("should load and save json data for paths with multiple locales", async () => {
448507
setupFileMocks();
449508

@@ -507,6 +566,186 @@ describe("bucket loaders", () => {
507566
});
508567
});
509568

569+
describe("locked keys functionality", () => {
570+
it("should respect locked keys for JSON format", async () => {
571+
setupFileMocks();
572+
573+
const input = {
574+
"button.title": "Submit",
575+
"button.description": "Submit description",
576+
"locked.key": "Should not change",
577+
nested: {
578+
locked: "This is locked",
579+
unlocked: "This can change",
580+
},
581+
};
582+
const payload = {
583+
"button.title": "Enviar",
584+
"button.description": "Descripción de envío",
585+
"locked.key": "This should not be applied",
586+
"nested/locked": "This should not be applied either",
587+
"nested/unlocked": "Este puede cambiar",
588+
};
589+
590+
mockFileOperations(JSON.stringify(input));
591+
592+
const jsonLoader = createBucketLoader(
593+
"json",
594+
"i18n/[locale].json",
595+
{ isCacheRestore: false, defaultLocale: "en" },
596+
["locked.key", "nested/locked"],
597+
);
598+
599+
jsonLoader.setDefaultLocale("en");
600+
await jsonLoader.pull("en");
601+
602+
await jsonLoader.push("es", payload);
603+
604+
expect(fs.writeFile).toHaveBeenCalled();
605+
const writeFileCall = (fs.writeFile as any).mock.calls[0];
606+
const writtenContent = JSON.parse(writeFileCall[1]);
607+
608+
// Check that locked keys retain their original values
609+
expect(writtenContent["locked.key"]).toBe("Should not change");
610+
expect(writtenContent.nested.locked).toBe("This is locked");
611+
612+
// Check that unlocked keys are updated
613+
expect(writtenContent["button.title"]).toBe("Enviar");
614+
expect(writtenContent["button.description"]).toBe("Descripción de envío");
615+
expect(writtenContent.nested.unlocked).toBe("Este puede cambiar");
616+
});
617+
618+
it("should respect locked keys during cache restoration", async () => {
619+
setupFileMocks();
620+
621+
const input = {
622+
"button.title": "Submit",
623+
"locked.key": "Should not change",
624+
nested: {
625+
locked: "This is locked",
626+
unlocked: "This can change",
627+
},
628+
};
629+
const payload = {
630+
"button.title": "Enviar",
631+
"locked.key": "This should not be applied",
632+
"nested/locked": "This should not be applied either",
633+
"nested/unlocked": "Este puede cambiar",
634+
};
635+
636+
mockFileOperations(JSON.stringify(input));
637+
638+
const jsonLoader = createBucketLoader(
639+
"json",
640+
"i18n/[locale].json",
641+
{ isCacheRestore: true, defaultLocale: "en" },
642+
["locked.key", "nested/locked"],
643+
);
644+
645+
jsonLoader.setDefaultLocale("en");
646+
await jsonLoader.pull("en");
647+
648+
await jsonLoader.push("es", payload);
649+
650+
expect(fs.writeFile).toHaveBeenCalled();
651+
const writeFileCall = (fs.writeFile as any).mock.calls[0];
652+
const writtenContent = JSON.parse(writeFileCall[1]);
653+
654+
expect(Object.keys(writtenContent)).toContain("button.title");
655+
expect(writtenContent["locked.key"]).toBe("Should not change");
656+
expect(writtenContent.nested).toHaveProperty("unlocked", "Este puede cambiar");
657+
expect(writtenContent.nested).toHaveProperty("locked", "This is locked");
658+
});
659+
660+
it("should handle deeply nested locked keys", async () => {
661+
setupFileMocks();
662+
663+
const input = {
664+
level1: {
665+
level2: {
666+
level3: {
667+
locked: "This is locked deep",
668+
unlocked: "This can change",
669+
},
670+
},
671+
},
672+
};
673+
const payload = {
674+
"level1/level2/level3/locked": "This should not be applied",
675+
"level1/level2/level3/unlocked": "This should change",
676+
};
677+
678+
mockFileOperations(JSON.stringify(input));
679+
680+
const jsonLoader = createBucketLoader(
681+
"json",
682+
"i18n/[locale].json",
683+
{ isCacheRestore: false, defaultLocale: "en" },
684+
["level1/level2/level3/locked"],
685+
);
686+
687+
jsonLoader.setDefaultLocale("en");
688+
await jsonLoader.pull("en");
689+
690+
await jsonLoader.push("es", payload);
691+
692+
expect(fs.writeFile).toHaveBeenCalled();
693+
const writeFileCall = (fs.writeFile as any).mock.calls[0];
694+
const writtenContent = JSON.parse(writeFileCall[1]);
695+
696+
// Check that deeply nested locked key retains its original value
697+
expect(writtenContent.level1.level2.level3.locked).toBe("This is locked deep");
698+
699+
// Check that unlocked key is updated
700+
expect(writtenContent.level1.level2.level3.unlocked).toBe("This should change");
701+
});
702+
703+
it("should lock keys that are arrays", async () => {
704+
setupFileMocks();
705+
706+
const input = {
707+
messages: ["first", "second", "third"],
708+
unlocked: ["can", "be", "changed"],
709+
};
710+
const payload = {
711+
"messages/0": "should not change",
712+
"messages/1": "should not change either",
713+
"messages/2": "should definitely not change",
714+
"unlocked/0": "should",
715+
"unlocked/1": "definitely",
716+
"unlocked/2": "change",
717+
};
718+
719+
mockFileOperations(JSON.stringify(input));
720+
721+
const jsonLoader = createBucketLoader(
722+
"json",
723+
"i18n/[locale].json",
724+
{ isCacheRestore: false, defaultLocale: "en" },
725+
["messages/0", "messages/1", "messages/2"],
726+
);
727+
728+
jsonLoader.setDefaultLocale("en");
729+
await jsonLoader.pull("en");
730+
731+
await jsonLoader.push("es", payload);
732+
733+
expect(fs.writeFile).toHaveBeenCalled();
734+
const writeFileCall = (fs.writeFile as any).mock.calls[0];
735+
const writtenContent = JSON.parse(writeFileCall[1]);
736+
737+
// Check that locked array elements retain their original values
738+
expect(writtenContent.messages[0]).toBe("first");
739+
expect(writtenContent.messages[1]).toBe("second");
740+
expect(writtenContent.messages[2]).toBe("third");
741+
742+
// Check that unlocked array elements are updated
743+
expect(writtenContent.unlocked[0]).toBe("should");
744+
expect(writtenContent.unlocked[1]).toBe("definitely");
745+
expect(writtenContent.unlocked[2]).toBe("change");
746+
});
747+
});
748+
510749
describe("markdown bucket loader", () => {
511750
it("should load markdown data", async () => {
512751
setupFileMocks();

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import createPlutilJsonTextLoader from "./plutil-json-loader";
3030
import createPhpLoader from "./php";
3131
import createVueJsonLoader from "./vue-json";
3232
import createInjectLocaleLoader from "./inject-locale";
33+
import createLockedKeysLoader from "./locked-keys";
3334

3435
type BucketLoaderOptions = {
3536
isCacheRestore: boolean;
@@ -42,6 +43,7 @@ export default function createBucketLoader(
4243
bucketType: Z.infer<typeof bucketTypeSchema>,
4344
bucketPathPattern: string,
4445
options: BucketLoaderOptions,
46+
lockedKeys?: string[],
4547
): ILoader<void, Record<string, string>> {
4648
switch (bucketType) {
4749
default:
@@ -77,6 +79,7 @@ export default function createBucketLoader(
7779
createJsonLoader(),
7880
createInjectLocaleLoader(options.injectLocale),
7981
createFlatLoader(),
82+
createLockedKeysLoader(lockedKeys || [], options.isCacheRestore),
8083
createSyncLoader(),
8184
createUnlocalizableLoader(options.isCacheRestore, options.returnUnlocalizedKeys),
8285
);
@@ -136,6 +139,7 @@ export default function createBucketLoader(
136139
createPrettierLoader({ parser: "yaml", bucketPathPattern }),
137140
createYamlLoader(),
138141
createFlatLoader(),
142+
createLockedKeysLoader(lockedKeys || [], options.isCacheRestore),
139143
createSyncLoader(),
140144
createUnlocalizableLoader(options.isCacheRestore, options.returnUnlocalizedKeys),
141145
);

0 commit comments

Comments
 (0)