@messagevisor/catalog 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,6 +46,25 @@ async function pathExists(root: string, relativePath: string) {
46
46
  }
47
47
  }
48
48
 
49
+ function stripAnsi(value: string) {
50
+ return value.replace(/\x1b\[[0-9;]*m/g, "").replace(/%s/g, "");
51
+ }
52
+
53
+ async function captureConsoleLog(callback: () => Promise<void>) {
54
+ const logs: string[] = [];
55
+ const spy = jest.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
56
+ logs.push(args.map(String).join(" "));
57
+ });
58
+
59
+ try {
60
+ await callback();
61
+ } finally {
62
+ spy.mockRestore();
63
+ }
64
+
65
+ return stripAnsi(logs.join("\n"));
66
+ }
67
+
49
68
  async function createProject() {
50
69
  const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
51
70
  const interpolationModulePath = path.join(
@@ -230,14 +249,6 @@ describe("catalog", function () {
230
249
  const manifest = await readJson<any>(root, "catalog-out/data/manifest.json");
231
250
  const index = await readJson<any>(root, "catalog-out/data/root/index.json");
232
251
  const locale = await readJson<any>(root, "catalog-out/data/root/entities/locale/en-US.json");
233
- const localeDuplicates = await readJson<any>(
234
- root,
235
- "catalog-out/data/root/duplicates/locales/en-US.json",
236
- );
237
- const emptyLocaleDuplicates = await readJson<any>(
238
- root,
239
- "catalog-out/data/root/duplicates/locales/nl.json",
240
- );
241
252
  const message = await readJson<any>(
242
253
  root,
243
254
  "catalog-out/data/root/entities/message/common.welcome.json",
@@ -253,11 +264,20 @@ describe("catalog", function () {
253
264
  expect(manifest.sets).toBe(false);
254
265
  expect(manifest.router).toBe("browser");
255
266
  expect(manifest.dev).toBeUndefined();
256
- expect(manifest.features).toEqual({ translationSearch: false });
267
+ expect(manifest.features).toEqual({ translationSearch: false, duplicates: false });
257
268
  expect(manifest.paths.root).toBe("data/root/index.json");
258
269
  await expect(pathExists(root, "catalog-out/data/root/translations/77656c.json")).resolves.toBe(
259
270
  false,
260
271
  );
272
+ await expect(
273
+ pathExists(root, "catalog-out/data/root/duplicates/locales/en-US.json"),
274
+ ).resolves.toBe(false);
275
+ await expect(
276
+ pathExists(root, "catalog-out/data/root/history/message/common.welcome/page-1.json"),
277
+ ).resolves.toBe(false);
278
+ await expect(
279
+ pathExists(root, "catalog-out/data/root/history/message/common.draft/page-1.json"),
280
+ ).resolves.toBe(false);
261
281
  expect(index.counts.message).toBe(2);
262
282
  expect(
263
283
  index.entities.message.find((entry: any) => entry.key === "common.welcome").targets,
@@ -321,31 +341,6 @@ describe("catalog", function () {
321
341
  ]),
322
342
  );
323
343
  expect(locale.targetFormats.web.number.money.minimumFractionDigits).toBe(2);
324
- expect(localeDuplicates).toEqual({
325
- locale: "en-US",
326
- summary: {
327
- duplicateValues: 1,
328
- duplicateMessageKeys: 2,
329
- },
330
- duplicateValues: [
331
- {
332
- value: "Welcome",
333
- messageKeys: ["common.draft", "common.welcome"],
334
- sources: [
335
- { messageKey: "common.draft", locale: "en" },
336
- { messageKey: "common.welcome", locale: "en" },
337
- ],
338
- },
339
- ],
340
- });
341
- expect(emptyLocaleDuplicates).toEqual({
342
- locale: "nl",
343
- summary: {
344
- duplicateValues: 0,
345
- duplicateMessageKeys: 0,
346
- },
347
- duplicateValues: [],
348
- });
349
344
  expect(target.formatRowsByLocale["en-US"]).toEqual(
350
345
  expect.arrayContaining([
351
346
  expect.objectContaining({
@@ -403,6 +398,56 @@ describe("catalog", function () {
403
398
  expect(history.entries).toEqual([]);
404
399
  });
405
400
 
401
+ it("exports locale duplicate reports only when opted in", async function () {
402
+ const root = await createProject();
403
+ roots.push(root);
404
+ const projectConfig = getProjectConfig(root);
405
+ const datasource = new Datasource(projectConfig, root);
406
+
407
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
408
+ outDir: "catalog-out",
409
+ copyAssets: false,
410
+ withDuplicates: true,
411
+ });
412
+
413
+ const manifest = await readJson<any>(root, "catalog-out/data/manifest.json");
414
+ const localeDuplicates = await readJson<any>(
415
+ root,
416
+ "catalog-out/data/root/duplicates/locales/en-US.json",
417
+ );
418
+ const emptyLocaleDuplicates = await readJson<any>(
419
+ root,
420
+ "catalog-out/data/root/duplicates/locales/nl.json",
421
+ );
422
+
423
+ expect(manifest.features).toEqual({ translationSearch: false, duplicates: true });
424
+ expect(localeDuplicates).toEqual({
425
+ locale: "en-US",
426
+ summary: {
427
+ duplicateValues: 1,
428
+ duplicateMessageKeys: 2,
429
+ },
430
+ duplicateValues: [
431
+ {
432
+ value: "Welcome",
433
+ messageKeys: ["common.draft", "common.welcome"],
434
+ sources: [
435
+ { messageKey: "common.draft", locale: "en" },
436
+ { messageKey: "common.welcome", locale: "en" },
437
+ ],
438
+ },
439
+ ],
440
+ });
441
+ expect(emptyLocaleDuplicates).toEqual({
442
+ locale: "nl",
443
+ summary: {
444
+ duplicateValues: 0,
445
+ duplicateMessageKeys: 0,
446
+ },
447
+ duplicateValues: [],
448
+ });
449
+ });
450
+
406
451
  it("exports translation search shards only when opted in", async function () {
407
452
  const root = await createProject();
408
453
  roots.push(root);
@@ -418,11 +463,121 @@ describe("catalog", function () {
418
463
  const manifest = await readJson<any>(root, "catalog-out/data/manifest.json");
419
464
  const shard = await readJson<any>(root, "catalog-out/data/root/translations/77656c.json");
420
465
 
421
- expect(manifest.features).toEqual({ translationSearch: true });
466
+ expect(manifest.features).toEqual({ translationSearch: true, duplicates: false });
422
467
  expect(shard["common.welcome"]).toEqual(expect.arrayContaining(["welcome", "welcome pro"]));
423
468
  expect(shard["common.draft"]).toEqual(["welcome"]);
424
469
  });
425
470
 
471
+ it("prints progress output for default catalog export", async function () {
472
+ const root = await createProject();
473
+ roots.push(root);
474
+ const projectConfig = getProjectConfig(root);
475
+ const datasource = new Datasource(projectConfig, root);
476
+
477
+ const output = await captureConsoleLog(async () => {
478
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
479
+ outDir: "catalog-out",
480
+ copyAssets: false,
481
+ });
482
+ });
483
+
484
+ expect(output).toContain("Generating Messagevisor catalog");
485
+ expect(output).toContain("Output: catalog-out");
486
+ expect(output).toContain("Router: browser");
487
+ expect(output).toContain("Features: none");
488
+ expect(output).toContain("Preparing output directory");
489
+ expect(output).toContain("Reading Git history");
490
+ expect(output).toContain("Discovering project sets");
491
+ expect(output).toContain("Writing project history");
492
+ expect(output).toContain("Root catalog");
493
+ expect(output).toContain("Processing entities");
494
+ expect(output).toContain("Writing messages");
495
+ expect(output).toContain("Writing message details");
496
+ expect(output).toContain("Writing message history pages");
497
+ expect(output).toContain("Writing manifest");
498
+ expect(output).toContain("Catalog exported to catalog-out");
499
+ expect(output).toContain("Time:");
500
+ expect(output).not.toContain("Scanning duplicate translations");
501
+ expect(output).not.toContain("Building translation search shards");
502
+ });
503
+
504
+ it("prints optional catalog progress only when feature work is enabled", async function () {
505
+ const root = await createProject();
506
+ roots.push(root);
507
+ const projectConfig = getProjectConfig(root);
508
+ const datasource = new Datasource(projectConfig, root);
509
+
510
+ const output = await captureConsoleLog(async () => {
511
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
512
+ outDir: "catalog-out",
513
+ copyAssets: false,
514
+ withDuplicates: true,
515
+ withTranslationSearch: true,
516
+ });
517
+ });
518
+
519
+ expect(output).toContain("Features: translation search, duplicates");
520
+ expect(output).toContain("Scanning duplicate translations");
521
+ expect(output).toContain("Writing duplicate reports");
522
+ expect(output).toContain("Building translation search shards");
523
+ });
524
+
525
+ it("exports many messages deterministically without empty history files", async function () {
526
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
527
+ roots.push(root);
528
+
529
+ await writeFile(root, "messagevisor.config.js", "module.exports = {};\n");
530
+ await writeFile(root, "locales/en.yml", "description: English\n");
531
+
532
+ const messageCount = 1200;
533
+ await Promise.all(
534
+ Array.from({ length: messageCount }, (_, index) => {
535
+ const key = String(index).padStart(4, "0");
536
+ return writeFile(
537
+ root,
538
+ `messages/bulk/${key}.yml`,
539
+ `description: Bulk ${key}\ntranslations:\n en: Bulk ${key}\n`,
540
+ );
541
+ }),
542
+ );
543
+
544
+ const projectConfig = getProjectConfig(root);
545
+ const datasource = new Datasource(projectConfig, root);
546
+
547
+ const output = await captureConsoleLog(async () => {
548
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
549
+ outDir: "catalog-out",
550
+ copyAssets: false,
551
+ });
552
+ });
553
+
554
+ const index = await readJson<any>(root, "catalog-out/data/root/index.json");
555
+ const firstMessage = await readJson<any>(
556
+ root,
557
+ "catalog-out/data/root/entities/message/bulk.0000.json",
558
+ );
559
+ const lastMessage = await readJson<any>(
560
+ root,
561
+ "catalog-out/data/root/entities/message/bulk.1199.json",
562
+ );
563
+
564
+ expect(index.counts.message).toBe(messageCount);
565
+ expect(index.entities.message.slice(0, 3).map((entry: any) => entry.key)).toEqual([
566
+ "bulk.0000",
567
+ "bulk.0001",
568
+ "bulk.0002",
569
+ ]);
570
+ expect(firstMessage.sourcePath).toBe("messages/bulk/0000.yml");
571
+ expect(lastMessage.translations).toEqual([
572
+ { locale: "en", value: "Bulk 1199", source: "direct" },
573
+ ]);
574
+ await expect(
575
+ pathExists(root, "catalog-out/data/root/history/message/bulk.0000/page-1.json"),
576
+ ).resolves.toBe(false);
577
+ expect(output).toContain("1200 messages");
578
+ expect(output).toContain("1200 empty histories skipped");
579
+ });
580
+
426
581
  it("streams Git history into project, entity, and last-modified catalog data", async function () {
427
582
  const root = await createProject();
428
583
  roots.push(root);
@@ -492,9 +647,12 @@ describe("catalog", function () {
492
647
  ]),
493
648
  );
494
649
  expect(messageHistory.entries).toHaveLength(2);
495
- expect(spacedMessageHistory.entries[0].entities).toEqual(
496
- expect.arrayContaining([{ type: "message", key: "common.with space" }]),
497
- );
650
+ expect(messageHistory.entries[0].entities).toEqual([
651
+ { type: "message", key: "common.welcome" },
652
+ ]);
653
+ expect(spacedMessageHistory.entries[0].entities).toEqual([
654
+ { type: "message", key: "common.with space" },
655
+ ]);
498
656
  expect(message.lastModified).toMatchObject({
499
657
  author: "Catalog Tester",
500
658
  commit: projectHistory.entries[0].commit,
@@ -506,6 +664,67 @@ describe("catalog", function () {
506
664
  });
507
665
  });
508
666
 
667
+ it("keeps large commit entity lists out of per-entity history files", async function () {
668
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
669
+ roots.push(root);
670
+
671
+ await writeFile(root, "messagevisor.config.js", "module.exports = {};\n");
672
+ await writeFile(root, "locales/en.yml", "description: English\n");
673
+
674
+ const messageCount = 1200;
675
+ await Promise.all(
676
+ Array.from({ length: messageCount }, (_, index) => {
677
+ const key = String(index).padStart(4, "0");
678
+ return writeFile(
679
+ root,
680
+ `messages/bulk/${key}.yml`,
681
+ `description: Bulk ${key}\ntranslations:\n en: Bulk ${key}\n`,
682
+ );
683
+ }),
684
+ );
685
+
686
+ git(root, ["init"]);
687
+ git(root, ["add", "."]);
688
+ gitCommit(root, "large message import");
689
+
690
+ const projectConfig = getProjectConfig(root);
691
+ const datasource = new Datasource(projectConfig, root);
692
+
693
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
694
+ outDir: "catalog-out",
695
+ copyAssets: false,
696
+ });
697
+
698
+ const projectHistory = await readJson<any>(
699
+ root,
700
+ "catalog-out/data/project/history/page-1.json",
701
+ );
702
+ const firstMessageHistory = await readJson<any>(
703
+ root,
704
+ "catalog-out/data/root/history/message/bulk.0000/page-1.json",
705
+ );
706
+ const lastMessageHistory = await readJson<any>(
707
+ root,
708
+ "catalog-out/data/root/history/message/bulk.1199/page-1.json",
709
+ );
710
+
711
+ expect(projectHistory.entries[0].entities).toHaveLength(messageCount + 1);
712
+ expect(projectHistory.entries[0].entities).toEqual(
713
+ expect.arrayContaining([
714
+ { type: "locale", key: "en" },
715
+ { type: "message", key: "bulk.0000" },
716
+ { type: "message", key: "bulk.1199" },
717
+ ]),
718
+ );
719
+ expect(firstMessageHistory.entries[0]).toMatchObject({
720
+ commit: projectHistory.entries[0].commit,
721
+ author: projectHistory.entries[0].author,
722
+ timestamp: projectHistory.entries[0].timestamp,
723
+ entities: [{ type: "message", key: "bulk.0000" }],
724
+ });
725
+ expect(lastMessageHistory.entries[0].entities).toEqual([{ type: "message", key: "bulk.1199" }]);
726
+ });
727
+
509
728
  it("exports branch-aware repository links and hash router mode when requested", async function () {
510
729
  const root = await createProject();
511
730
  roots.push(root);
@@ -700,22 +919,41 @@ describe("catalog", function () {
700
919
  const admin = await readJson<any>(root, "catalog-out/data/sets/admin/index.json");
701
920
 
702
921
  expect(manifest.sets).toBe(true);
703
- expect(manifest.features).toEqual({ translationSearch: false });
922
+ expect(manifest.features).toEqual({ translationSearch: false, duplicates: false });
704
923
  expect(manifest.setKeys).toEqual(["admin", "storefront"]);
705
924
  await expect(
706
925
  pathExists(root, "catalog-out/data/sets/storefront/translations/73746f.json"),
707
926
  ).resolves.toBe(false);
927
+ await expect(
928
+ pathExists(root, "catalog-out/data/sets/storefront/duplicates/locales/en.json"),
929
+ ).resolves.toBe(false);
930
+
931
+ expect(storefront.counts.message).toBe(2);
932
+ expect(admin.counts.message).toBe(2);
933
+ await expect(
934
+ readJson<any>(root, "catalog-out/data/sets/storefront/entities/message/common.welcome.json"),
935
+ ).resolves.toMatchObject({
936
+ key: "common.welcome",
937
+ entity: { translations: { en: "storefront" } },
938
+ });
939
+
940
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
941
+ outDir: "catalog-with-duplicates",
942
+ copyAssets: false,
943
+ withDuplicates: true,
944
+ });
945
+
946
+ const optInManifest = await readJson<any>(root, "catalog-with-duplicates/data/manifest.json");
708
947
  const storefrontDuplicates = await readJson<any>(
709
948
  root,
710
- "catalog-out/data/sets/storefront/duplicates/locales/en.json",
949
+ "catalog-with-duplicates/data/sets/storefront/duplicates/locales/en.json",
711
950
  );
712
951
  const adminDuplicates = await readJson<any>(
713
952
  root,
714
- "catalog-out/data/sets/admin/duplicates/locales/en.json",
953
+ "catalog-with-duplicates/data/sets/admin/duplicates/locales/en.json",
715
954
  );
716
955
 
717
- expect(storefront.counts.message).toBe(2);
718
- expect(admin.counts.message).toBe(2);
956
+ expect(optInManifest.features).toEqual({ translationSearch: false, duplicates: true });
719
957
  expect(storefrontDuplicates.duplicateValues).toEqual([
720
958
  {
721
959
  value: "storefront",
@@ -736,12 +974,38 @@ describe("catalog", function () {
736
974
  ],
737
975
  },
738
976
  ]);
739
- await expect(
740
- readJson<any>(root, "catalog-out/data/sets/storefront/entities/message/common.welcome.json"),
741
- ).resolves.toMatchObject({
742
- key: "common.welcome",
743
- entity: { translations: { en: "storefront" } },
977
+ });
978
+
979
+ it("prints set names while exporting set project catalogs", async function () {
980
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-catalog-"));
981
+ roots.push(root);
982
+
983
+ await writeFile(root, "messagevisor.config.js", "module.exports = { sets: true };\n");
984
+
985
+ for (const set of ["storefront", "admin"]) {
986
+ await writeFile(root, `sets/${set}/locales/en.yml`, "description: English\n");
987
+ await writeFile(
988
+ root,
989
+ `sets/${set}/messages/common/welcome.yml`,
990
+ `description: Welcome\ntranslations:\n en: ${set}\n`,
991
+ );
992
+ }
993
+
994
+ const projectConfig = getProjectConfig(root);
995
+ const datasource = new Datasource(projectConfig, root);
996
+
997
+ const output = await captureConsoleLog(async () => {
998
+ await catalogApi.exportCatalog(root, projectConfig, datasource, {
999
+ outDir: "catalog-out",
1000
+ copyAssets: false,
1001
+ });
744
1002
  });
1003
+
1004
+ expect(output).toContain("Sets: enabled");
1005
+ expect(output).toContain("Discovering project sets");
1006
+ expect(output).toContain('Set "admin"');
1007
+ expect(output).toContain('Set "storefront"');
1008
+ expect(output).toContain("Processing entities");
745
1009
  });
746
1010
 
747
1011
  it("exports set translation search shards when opted in", async function () {
@@ -778,7 +1042,7 @@ describe("catalog", function () {
778
1042
  "catalog-out/data/sets/admin/translations/61646d.json",
779
1043
  );
780
1044
 
781
- expect(manifest.features).toEqual({ translationSearch: true });
1045
+ expect(manifest.features).toEqual({ translationSearch: true, duplicates: false });
782
1046
  expect(storefrontShard).toEqual({ "common.welcome": ["storefront"] });
783
1047
  expect(adminShard).toEqual({ "common.welcome": ["admin"] });
784
1048
  });
@@ -988,6 +1252,19 @@ describe("catalog plugin", function () {
988
1252
  );
989
1253
  });
990
1254
 
1255
+ it("forwards duplicates option for dev catalog mode", async function () {
1256
+ const { handler } = createPlugin();
1257
+
1258
+ await handler({ _: ["catalog"], withDuplicates: true });
1259
+
1260
+ expect(exportMock).toHaveBeenLastCalledWith(
1261
+ expect.any(String),
1262
+ expect.any(Object),
1263
+ expect.any(Object),
1264
+ expect.objectContaining({ withDuplicates: true, dev: true }),
1265
+ );
1266
+ });
1267
+
991
1268
  it("forwards translation search option for export subcommand", async function () {
992
1269
  const { handler } = createPlugin();
993
1270
 
@@ -1005,16 +1282,45 @@ describe("catalog plugin", function () {
1005
1282
  );
1006
1283
  });
1007
1284
 
1285
+ it("forwards duplicates option for export subcommand", async function () {
1286
+ const { handler } = createPlugin();
1287
+
1288
+ await handler({
1289
+ _: ["catalog", "export"],
1290
+ subcommand: "export",
1291
+ "with-duplicates": true,
1292
+ });
1293
+
1294
+ expect(exportMock).toHaveBeenLastCalledWith(
1295
+ expect.any(String),
1296
+ expect.any(Object),
1297
+ expect.any(Object),
1298
+ expect.objectContaining({ withDuplicates: true }),
1299
+ );
1300
+ });
1301
+
1008
1302
  it("forwards long and short port options for serve subcommand", async function () {
1009
1303
  const { handler } = createPlugin();
1010
1304
 
1011
- await handler({ _: ["catalog", "serve"], subcommand: "serve", port: 3103 });
1305
+ await handler({
1306
+ _: ["catalog", "serve"],
1307
+ subcommand: "serve",
1308
+ port: 3103,
1309
+ "with-duplicates": true,
1310
+ "with-translation-search": true,
1311
+ });
1012
1312
  expect(serveMock).toHaveBeenLastCalledWith(
1013
1313
  expect.any(String),
1014
1314
  expect.any(Object),
1015
1315
  expect.any(Object),
1016
1316
  expect.not.objectContaining({ withTranslationSearch: true }),
1017
1317
  );
1318
+ expect(serveMock).toHaveBeenLastCalledWith(
1319
+ expect.any(String),
1320
+ expect.any(Object),
1321
+ expect.any(Object),
1322
+ expect.not.objectContaining({ withDuplicates: true }),
1323
+ );
1018
1324
  expect(serveMock).toHaveBeenLastCalledWith(
1019
1325
  expect.any(String),
1020
1326
  expect.any(Object),
@@ -1043,4 +1349,17 @@ describe("catalog plugin", function () {
1043
1349
  expect.objectContaining({ port: undefined }),
1044
1350
  );
1045
1351
  });
1352
+
1353
+ it("does not print catalog generation progress for serve subcommand", async function () {
1354
+ const { handler } = createPlugin();
1355
+
1356
+ const output = await captureConsoleLog(async () => {
1357
+ await handler({ _: ["catalog", "serve"], subcommand: "serve" });
1358
+ });
1359
+
1360
+ expect(output).not.toContain("Generating Messagevisor catalog");
1361
+ expect(output).not.toContain("Processing entities");
1362
+ expect(serveMock).toHaveBeenCalledTimes(1);
1363
+ expect(exportMock).not.toHaveBeenCalled();
1364
+ });
1046
1365
  });