@messagevisor/catalog 0.0.1 → 0.1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/assets/index-CfGbXx4X.css +1 -0
  4. package/dist/assets/index-r8ugP5JL.js +73 -0
  5. package/dist/favicon.png +0 -0
  6. package/dist/index.html +14 -0
  7. package/dist/logo-text.png +0 -0
  8. package/lib/index.d.ts +1 -0
  9. package/lib/index.js +18 -0
  10. package/lib/index.js.map +1 -0
  11. package/lib/node/formatExamplePreview.d.ts +10 -0
  12. package/lib/node/formatExamplePreview.js +79 -0
  13. package/lib/node/formatExamplePreview.js.map +1 -0
  14. package/lib/node/index.d.ts +191 -0
  15. package/lib/node/index.js +1645 -0
  16. package/lib/node/index.js.map +1 -0
  17. package/package.json +59 -13
  18. package/src/App.tsx +73 -0
  19. package/src/api.spec.ts +42 -0
  20. package/src/api.ts +87 -0
  21. package/src/catalogBrandAssets.ts +8 -0
  22. package/src/components/details/ConditionTree.tsx +146 -0
  23. package/src/components/details/FieldGrid.tsx +16 -0
  24. package/src/components/details/GroupSegmentTree.tsx +73 -0
  25. package/src/components/details/MarkdownContent.tsx +23 -0
  26. package/src/components/details/TranslationsTable.tsx +263 -0
  27. package/src/components/details/UsageLinks.tsx +29 -0
  28. package/src/components/history/HistoryTimeline.tsx +122 -0
  29. package/src/components/layout/AppShell.tsx +338 -0
  30. package/src/components/layout/PageHeader.tsx +13 -0
  31. package/src/components/layout/Tabs.tsx +35 -0
  32. package/src/components/lists/EntityList.tsx +451 -0
  33. package/src/components/ui/Badge.tsx +21 -0
  34. package/src/components/ui/Button.tsx +12 -0
  35. package/src/components/ui/Card.tsx +9 -0
  36. package/src/components/ui/CodeBlock.tsx +7 -0
  37. package/src/components/ui/EmptyState.tsx +8 -0
  38. package/src/components/ui/Input.tsx +12 -0
  39. package/src/components/ui/LabelValueBadge.tsx +55 -0
  40. package/src/config.ts +2 -0
  41. package/src/context/CatalogContext.tsx +50 -0
  42. package/src/entityTypes.ts +49 -0
  43. package/src/index.ts +1 -0
  44. package/src/main.tsx +28 -0
  45. package/src/node/formatExamplePreview.ts +85 -0
  46. package/src/node/index.spec.ts +713 -0
  47. package/src/node/index.ts +2007 -0
  48. package/src/pages/EntityDetailPage.tsx +3345 -0
  49. package/src/pages/HistoryPage.tsx +26 -0
  50. package/src/pages/HomePage.tsx +21 -0
  51. package/src/pages/ListPage.tsx +59 -0
  52. package/src/styles.css +95 -0
  53. package/src/theme.ts +36 -0
  54. package/src/types.ts +127 -0
  55. package/src/utils/formatCatalogTimestamp.ts +77 -0
  56. package/src/utils/hashTranslationValue.spec.ts +20 -0
  57. package/src/utils/hashTranslationValue.ts +22 -0
  58. package/src/utils/searchQuery.ts +46 -0
@@ -0,0 +1,2007 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import * as childProcess from "child_process";
3
+ import * as fs from "fs";
4
+ import * as http from "http";
5
+ import * as path from "path";
6
+
7
+ import type {
8
+ Attribute,
9
+ Condition,
10
+ FormatPresets,
11
+ GroupSegment,
12
+ Locale,
13
+ Message,
14
+ Override,
15
+ Target,
16
+ Segment,
17
+ } from "@messagevisor/types";
18
+
19
+ import { attachFormatExamplePreviews } from "./formatExamplePreview";
20
+
21
+ export interface CatalogPluginParsedOptions {
22
+ _: string[];
23
+ [key: string]: any;
24
+ }
25
+
26
+ export interface CatalogPluginHandlerOptions {
27
+ rootDirectoryPath: string;
28
+ projectConfig: any;
29
+ datasource: any;
30
+ parsed: CatalogPluginParsedOptions;
31
+ }
32
+
33
+ export interface CatalogPlugin {
34
+ command: string;
35
+ handler: (options: CatalogPluginHandlerOptions) => Promise<void | boolean>;
36
+ examples: {
37
+ command: string;
38
+ description: string;
39
+ }[];
40
+ }
41
+
42
+ export interface CatalogRuntime {
43
+ mergeFormats: (parent?: FormatPresets, child?: FormatPresets) => FormatPresets | undefined;
44
+ resolveFormats: (
45
+ localeKey: string,
46
+ locales: Record<string, Locale>,
47
+ target?: Target,
48
+ ) => FormatPresets | undefined;
49
+ getProjectSetExecutions: (
50
+ projectConfig: any,
51
+ datasource: any,
52
+ selectedSet?: string,
53
+ ) => Promise<Array<{ set: string; projectConfig: any; datasource: any }>>;
54
+ resolveExamples: (
55
+ projectConfig: any,
56
+ datasource: any,
57
+ options?: {
58
+ set?: string;
59
+ locale?: string;
60
+ message?: string;
61
+ exampleIndex?: number | string;
62
+ matrixIndex?: number | string;
63
+ descriptionPattern?: string | RegExp;
64
+ translationPattern?: string | RegExp;
65
+ onlyMessages?: boolean;
66
+ onlyLocales?: boolean;
67
+ },
68
+ ) => Promise<{
69
+ locales: CatalogEvaluatedLocaleExample[];
70
+ messages: CatalogEvaluatedMessageExample[];
71
+ }>;
72
+ findDuplicateTranslations: (
73
+ projectConfig: any,
74
+ datasource: any,
75
+ ) => Promise<CatalogDuplicateTranslationsResult>;
76
+ }
77
+
78
+ export const CATALOG_SCHEMA_VERSION = "1";
79
+ export const CATALOG_HISTORY_PAGE_SIZE = 50;
80
+
81
+ type CatalogEntityType = "locale" | "message" | "attribute" | "segment" | "target";
82
+ export type CatalogGitProvider = "github" | "gitlab" | "bitbucket";
83
+ export type CatalogDevEditorId = "cursor" | "vscode";
84
+
85
+ export interface CatalogDevEditor {
86
+ id: CatalogDevEditorId;
87
+ label: string;
88
+ icon: CatalogDevEditorId;
89
+ }
90
+
91
+ interface CatalogHistoryEntity {
92
+ type: CatalogEntityType | "test";
93
+ key: string;
94
+ set?: string;
95
+ }
96
+
97
+ interface CatalogHistoryEntry {
98
+ commit: string;
99
+ author: string;
100
+ timestamp: string;
101
+ entities: CatalogHistoryEntity[];
102
+ }
103
+
104
+ interface CatalogLastModified {
105
+ commit: string;
106
+ author: string;
107
+ timestamp: string;
108
+ }
109
+
110
+ interface CatalogEntitySummary {
111
+ key: string;
112
+ description?: string;
113
+ archived?: boolean;
114
+ deprecated?: boolean;
115
+ targets?: string[];
116
+ messageCount?: number;
117
+ locales?: string[];
118
+ overrideLocales?: string[];
119
+ lastModified?: CatalogLastModified;
120
+ href: string;
121
+ }
122
+
123
+ type CatalogValueSource = "direct" | "inherited" | "target" | "missing";
124
+
125
+ interface CatalogFormatRow {
126
+ path: string;
127
+ value: unknown;
128
+ source: CatalogValueSource;
129
+ from?: string;
130
+ examplePreview?: string;
131
+ }
132
+
133
+ interface CatalogTranslationRow {
134
+ locale: string;
135
+ value: string;
136
+ source: CatalogValueSource;
137
+ from?: string;
138
+ }
139
+
140
+ interface CatalogEvaluatedMessageExample {
141
+ set?: string;
142
+ message: string;
143
+ locale: string;
144
+ exampleIndex: number;
145
+ matrixIndex?: number;
146
+ description?: string;
147
+ values?: Record<string, unknown>;
148
+ context?: Record<string, unknown>;
149
+ formats?: FormatPresets;
150
+ currency?: string;
151
+ timeZone?: string;
152
+ evaluatedTranslation: unknown;
153
+ }
154
+
155
+ interface CatalogEvaluatedLocaleExample {
156
+ set?: string;
157
+ locale: string;
158
+ sourceLocale: string;
159
+ exampleIndex: number;
160
+ matrixIndex?: number;
161
+ description?: string;
162
+ rawMessage?: string;
163
+ message?: string;
164
+ originalTranslation?: string;
165
+ values?: Record<string, unknown>;
166
+ context?: Record<string, unknown>;
167
+ formats?: FormatPresets;
168
+ currency?: string;
169
+ timeZone?: string;
170
+ evaluatedTranslation: unknown;
171
+ }
172
+
173
+ interface CatalogDuplicateTranslationSource {
174
+ messageKey: string;
175
+ locale: string;
176
+ }
177
+
178
+ interface CatalogDuplicateTranslationValue {
179
+ value: string;
180
+ messageKeys: string[];
181
+ sources: CatalogDuplicateTranslationSource[];
182
+ }
183
+
184
+ interface CatalogDuplicateTranslationsLocaleResult {
185
+ locale: string;
186
+ duplicateValues: CatalogDuplicateTranslationValue[];
187
+ }
188
+
189
+ interface CatalogDuplicateTranslationsSetResult {
190
+ set: string | null;
191
+ locales: CatalogDuplicateTranslationsLocaleResult[];
192
+ }
193
+
194
+ interface CatalogDuplicateTranslationsResult {
195
+ summary: {
196
+ sets: number;
197
+ locales: number;
198
+ duplicateValues: number;
199
+ duplicateMessageKeys: number;
200
+ };
201
+ results: CatalogDuplicateTranslationsSetResult[];
202
+ }
203
+
204
+ interface CatalogLocaleDuplicatesFile {
205
+ locale: string;
206
+ summary: {
207
+ duplicateValues: number;
208
+ duplicateMessageKeys: number;
209
+ };
210
+ duplicateValues: CatalogDuplicateTranslationValue[];
211
+ }
212
+
213
+ interface CatalogSetIndex {
214
+ set: string;
215
+ counts: Record<CatalogEntityType, number>;
216
+ entities: Record<CatalogEntityType, CatalogEntitySummary[]>;
217
+ }
218
+
219
+ export interface CatalogExportOptions {
220
+ outDir?: string;
221
+ copyAssets?: boolean;
222
+ browserRouter?: boolean;
223
+ dev?: boolean;
224
+ devEditors?: CatalogDevEditor[];
225
+ }
226
+
227
+ export interface CatalogServeOptions {
228
+ outDir?: string;
229
+ port?: number | string;
230
+ browserRouter?: boolean;
231
+ liveReload?: boolean;
232
+ }
233
+
234
+ export interface CatalogServerHandle {
235
+ close: () => Promise<void>;
236
+ triggerReload: () => void;
237
+ }
238
+
239
+ interface CatalogBuildContext {
240
+ rootDirectoryPath: string;
241
+ repositoryRootDirectoryPath: string;
242
+ outputDirectoryPath: string;
243
+ dataDirectoryPath: string;
244
+ fullHistory: CatalogHistoryEntry[];
245
+ runtime: CatalogRuntime;
246
+ devEditors: CatalogDevEditor[];
247
+ duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult>;
248
+ }
249
+
250
+ interface SourceFileInfo {
251
+ sourcePath: string;
252
+ absolutePath: string;
253
+ }
254
+
255
+ interface EntityPathInfo {
256
+ type: CatalogEntityType | "test";
257
+ key: string;
258
+ set?: string;
259
+ }
260
+
261
+ function toPosixPath(value: string) {
262
+ return value.split(path.sep).join("/");
263
+ }
264
+
265
+ function getRealPath(value: string) {
266
+ try {
267
+ return fs.realpathSync.native(value);
268
+ } catch (_error) {
269
+ return value;
270
+ }
271
+ }
272
+
273
+ function encodeKey(key: string) {
274
+ return encodeURIComponent(key);
275
+ }
276
+
277
+ function matchesPattern(key: string, patterns?: string[]) {
278
+ if (!patterns || patterns.length === 0) {
279
+ return false;
280
+ }
281
+
282
+ return patterns.some((pattern) => {
283
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
284
+ return new RegExp(`^${escaped}$`).test(key);
285
+ });
286
+ }
287
+
288
+ async function readAll<T>(keys: string[], read: (key: string) => Promise<T>) {
289
+ const result: Record<string, T> = {};
290
+
291
+ for (const key of keys) {
292
+ result[key] = await read(key);
293
+ }
294
+
295
+ return result;
296
+ }
297
+
298
+ function sortStrings(values: string[]) {
299
+ return Array.from(new Set(values)).sort();
300
+ }
301
+
302
+ function deepClone<T>(value: T): T {
303
+ return JSON.parse(JSON.stringify(value));
304
+ }
305
+
306
+ function getLocaleDirections(locales: Record<string, Locale>) {
307
+ return Object.fromEntries(
308
+ Object.entries(locales).map(([localeKey, locale]) => [localeKey, locale.direction]),
309
+ );
310
+ }
311
+
312
+ function collectAttributeKeysFromConditions(
313
+ condition: Condition | Condition[] | "*" | undefined,
314
+ result: Set<string>,
315
+ ) {
316
+ if (!condition || condition === "*") {
317
+ return;
318
+ }
319
+
320
+ if (Array.isArray(condition)) {
321
+ for (const item of condition) {
322
+ collectAttributeKeysFromConditions(item, result);
323
+ }
324
+
325
+ return;
326
+ }
327
+
328
+ if (typeof condition === "string") {
329
+ return;
330
+ }
331
+
332
+ if ("attribute" in condition) {
333
+ result.add(condition.attribute);
334
+ return;
335
+ }
336
+
337
+ if ("and" in condition) {
338
+ collectAttributeKeysFromConditions(condition.and, result);
339
+ }
340
+
341
+ if ("or" in condition) {
342
+ collectAttributeKeysFromConditions(condition.or, result);
343
+ }
344
+
345
+ if ("not" in condition) {
346
+ collectAttributeKeysFromConditions(condition.not, result);
347
+ }
348
+ }
349
+
350
+ function collectSegmentKeys(
351
+ segments: GroupSegment | GroupSegment[] | "*" | undefined,
352
+ result: Set<string>,
353
+ ) {
354
+ if (!segments || segments === "*") {
355
+ return;
356
+ }
357
+
358
+ if (typeof segments === "string") {
359
+ result.add(segments);
360
+ return;
361
+ }
362
+
363
+ if (Array.isArray(segments)) {
364
+ for (const segment of segments) {
365
+ collectSegmentKeys(segment, result);
366
+ }
367
+
368
+ return;
369
+ }
370
+
371
+ if ("and" in segments) {
372
+ collectSegmentKeys(segments.and, result);
373
+ }
374
+
375
+ if ("or" in segments) {
376
+ collectSegmentKeys(segments.or, result);
377
+ }
378
+
379
+ if ("not" in segments) {
380
+ collectSegmentKeys(segments.not, result);
381
+ }
382
+ }
383
+
384
+ function getTargetMessageKeys(target: Target, messageKeys: string[]) {
385
+ const includeMessages = target.includeMessages?.length ? target.includeMessages : ["*"];
386
+ const excludeMessages = target.excludeMessages || [];
387
+
388
+ return messageKeys
389
+ .filter(
390
+ (messageKey) =>
391
+ matchesPattern(messageKey, includeMessages) && !matchesPattern(messageKey, excludeMessages),
392
+ )
393
+ .sort();
394
+ }
395
+
396
+ function getLastModified(
397
+ history: CatalogHistoryEntry[],
398
+ type: CatalogEntityType,
399
+ key: string,
400
+ set?: string,
401
+ ): CatalogLastModified | undefined {
402
+ const entry = history.find((candidate) =>
403
+ candidate.entities.some(
404
+ (entity) =>
405
+ entity.type === type &&
406
+ entity.key === key &&
407
+ (typeof set === "undefined" || entity.set === set),
408
+ ),
409
+ );
410
+
411
+ if (!entry) {
412
+ return undefined;
413
+ }
414
+
415
+ return {
416
+ commit: entry.commit,
417
+ author: entry.author,
418
+ timestamp: entry.timestamp,
419
+ };
420
+ }
421
+
422
+ function getEntitySummary(
423
+ entity: Locale | Message | Attribute | Segment | Target,
424
+ type: CatalogEntityType,
425
+ key: string,
426
+ history: CatalogHistoryEntry[],
427
+ set?: string,
428
+ extra: Partial<CatalogEntitySummary> = {},
429
+ ): CatalogEntitySummary {
430
+ return {
431
+ key,
432
+ description: (entity as any).description,
433
+ archived: (entity as any).archived,
434
+ deprecated: (entity as any).deprecated,
435
+ ...extra,
436
+ lastModified: getLastModified(history, type, key, set),
437
+ href: `entities/${type}/${encodeKey(key)}.json`,
438
+ };
439
+ }
440
+
441
+ function getPathValue(value: unknown, segments: string[]): unknown {
442
+ let current = value as any;
443
+
444
+ for (const segment of segments) {
445
+ if (!current || typeof current !== "object" || !(segment in current)) {
446
+ return undefined;
447
+ }
448
+
449
+ current = current[segment];
450
+ }
451
+
452
+ return current;
453
+ }
454
+
455
+ function flattenObjectRows(value: unknown, prefix = ""): { path: string; value: unknown }[] {
456
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
457
+ return prefix ? [{ path: prefix, value }] : [];
458
+ }
459
+
460
+ const rows: { path: string; value: unknown }[] = [];
461
+
462
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
463
+ const childPath = prefix ? `${prefix}.${key}` : key;
464
+ const childValue = (value as Record<string, unknown>)[key];
465
+
466
+ if (childValue && typeof childValue === "object" && !Array.isArray(childValue)) {
467
+ rows.push(...flattenObjectRows(childValue, childPath));
468
+ } else {
469
+ rows.push({ path: childPath, value: childValue });
470
+ }
471
+ }
472
+
473
+ return rows;
474
+ }
475
+
476
+ function resolveLocaleChain(
477
+ localeKey: string,
478
+ locales: Record<string, Locale>,
479
+ field: "inheritFormatsFrom" | "inheritTranslationsFrom",
480
+ ) {
481
+ const chain: string[] = [];
482
+ const seen = new Set<string>();
483
+ let currentKey: string | undefined = localeKey;
484
+
485
+ while (currentKey && !seen.has(currentKey)) {
486
+ seen.add(currentKey);
487
+ chain.unshift(currentKey);
488
+ currentKey = locales[currentKey]?.[field];
489
+ }
490
+
491
+ return chain;
492
+ }
493
+
494
+ function getLocaleFormatSource(
495
+ localeKey: string,
496
+ locales: Record<string, Locale>,
497
+ formatPath: string,
498
+ ): Pick<CatalogFormatRow, "source" | "from"> {
499
+ const pathSegments = formatPath.split(".");
500
+
501
+ if (typeof getPathValue(locales[localeKey]?.formats, pathSegments) !== "undefined") {
502
+ return { source: "direct" };
503
+ }
504
+
505
+ const chain = resolveLocaleChain(localeKey, locales, "inheritFormatsFrom").reverse();
506
+
507
+ for (const candidate of chain) {
508
+ if (
509
+ candidate !== localeKey &&
510
+ typeof getPathValue(locales[candidate]?.formats, pathSegments) !== "undefined"
511
+ ) {
512
+ return { source: "inherited", from: candidate };
513
+ }
514
+ }
515
+
516
+ return { source: "missing" };
517
+ }
518
+
519
+ function getFormatRows(
520
+ runtime: CatalogRuntime,
521
+ localeKey: string,
522
+ locales: Record<string, Locale>,
523
+ target?: Target,
524
+ ): CatalogFormatRow[] {
525
+ const computedFormats = runtime.resolveFormats(localeKey, locales, target) || {};
526
+
527
+ const rows = flattenObjectRows(computedFormats).map((row) => {
528
+ if (
529
+ target &&
530
+ typeof getPathValue(target.formats?.[localeKey], row.path.split(".")) !== "undefined"
531
+ ) {
532
+ return { ...row, source: "target" as const, from: "target" };
533
+ }
534
+
535
+ return {
536
+ ...row,
537
+ ...getLocaleFormatSource(localeKey, locales, row.path),
538
+ };
539
+ });
540
+
541
+ return attachFormatExamplePreviews(localeKey, computedFormats, rows);
542
+ }
543
+
544
+ function resolveTranslationRow(
545
+ translations: Record<string, string> | undefined,
546
+ localeKey: string,
547
+ locales: Record<string, Locale>,
548
+ ): CatalogTranslationRow {
549
+ if (typeof translations?.[localeKey] !== "undefined") {
550
+ return {
551
+ locale: localeKey,
552
+ value: translations[localeKey],
553
+ source: "direct",
554
+ };
555
+ }
556
+
557
+ const chain = resolveLocaleChain(localeKey, locales, "inheritTranslationsFrom").reverse();
558
+
559
+ for (const candidate of chain) {
560
+ if (candidate !== localeKey && typeof translations?.[candidate] !== "undefined") {
561
+ return {
562
+ locale: localeKey,
563
+ value: translations[candidate],
564
+ source: "inherited",
565
+ from: candidate,
566
+ };
567
+ }
568
+ }
569
+
570
+ return {
571
+ locale: localeKey,
572
+ value: "",
573
+ source: "missing",
574
+ };
575
+ }
576
+
577
+ function getDuplicateSetKey(set: string | null | undefined) {
578
+ return set || "root";
579
+ }
580
+
581
+ function toLocaleDuplicatesFile(
582
+ localeKey: string,
583
+ duplicatesByLocale: Record<string, CatalogDuplicateTranslationsLocaleResult>,
584
+ ): CatalogLocaleDuplicatesFile {
585
+ const duplicateValues = duplicatesByLocale[localeKey]?.duplicateValues || [];
586
+
587
+ return {
588
+ locale: localeKey,
589
+ summary: {
590
+ duplicateValues: duplicateValues.length,
591
+ duplicateMessageKeys: duplicateValues.reduce(
592
+ (sum, duplicate) => sum + duplicate.messageKeys.length,
593
+ 0,
594
+ ),
595
+ },
596
+ duplicateValues,
597
+ };
598
+ }
599
+
600
+ async function writeJson(filePath: string, content: unknown) {
601
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
602
+ await fs.promises.writeFile(filePath, JSON.stringify(content, null, 2));
603
+ }
604
+
605
+ function getEntityDirectoryPaths(config: any): Record<CatalogEntityType | "test", string> {
606
+ return {
607
+ locale: config.localesDirectoryPath,
608
+ message: config.messagesDirectoryPath,
609
+ attribute: config.attributesDirectoryPath,
610
+ segment: config.segmentsDirectoryPath,
611
+ target: config.targetsDirectoryPath,
612
+ test: config.testsDirectoryPath,
613
+ };
614
+ }
615
+
616
+ function getKeyFromRelativeEntityPath(
617
+ relativePath: string,
618
+ extension: string,
619
+ namespaceCharacter: string,
620
+ ) {
621
+ const withoutExtension = relativePath.slice(0, -extension.length);
622
+ const parts = withoutExtension.split(path.sep);
623
+ const last = parts[parts.length - 1];
624
+
625
+ if (last.endsWith(".spec")) {
626
+ parts[parts.length - 1] = last.slice(0, -".spec".length);
627
+ }
628
+
629
+ return parts.join(namespaceCharacter);
630
+ }
631
+
632
+ function getEntityInfoFromRelativePath(
633
+ rootDirectoryPath: string,
634
+ projectConfig: any,
635
+ relativePath: string,
636
+ ): EntityPathInfo | undefined {
637
+ const absolutePath = path.join(rootDirectoryPath, relativePath);
638
+ const extension = `.${(projectConfig.parser as any).extension}`;
639
+ const configs = projectConfig.sets
640
+ ? fs.existsSync(projectConfig.setsDirectoryPath)
641
+ ? fs
642
+ .readdirSync(projectConfig.setsDirectoryPath, { withFileTypes: true })
643
+ .filter((entry) => entry.isDirectory())
644
+ .map((entry) => ({
645
+ set: entry.name,
646
+ directories: getEntityDirectoryPaths({
647
+ ...projectConfig,
648
+ localesDirectoryPath: path.join(
649
+ projectConfig.setsDirectoryPath,
650
+ entry.name,
651
+ "locales",
652
+ ),
653
+ messagesDirectoryPath: path.join(
654
+ projectConfig.setsDirectoryPath,
655
+ entry.name,
656
+ "messages",
657
+ ),
658
+ attributesDirectoryPath: path.join(
659
+ projectConfig.setsDirectoryPath,
660
+ entry.name,
661
+ "attributes",
662
+ ),
663
+ segmentsDirectoryPath: path.join(
664
+ projectConfig.setsDirectoryPath,
665
+ entry.name,
666
+ "segments",
667
+ ),
668
+ targetsDirectoryPath: path.join(
669
+ projectConfig.setsDirectoryPath,
670
+ entry.name,
671
+ "targets",
672
+ ),
673
+ testsDirectoryPath: path.join(projectConfig.setsDirectoryPath, entry.name, "tests"),
674
+ }),
675
+ }))
676
+ : []
677
+ : [{ set: undefined, directories: getEntityDirectoryPaths(projectConfig) }];
678
+
679
+ for (const config of configs) {
680
+ for (const type of Object.keys(config.directories) as (CatalogEntityType | "test")[]) {
681
+ const directoryPath = config.directories[type];
682
+
683
+ if (
684
+ absolutePath === directoryPath ||
685
+ !absolutePath.startsWith(`${directoryPath}${path.sep}`) ||
686
+ !absolutePath.endsWith(extension)
687
+ ) {
688
+ continue;
689
+ }
690
+
691
+ return {
692
+ type,
693
+ key: getKeyFromRelativeEntityPath(
694
+ path.relative(directoryPath, absolutePath),
695
+ extension,
696
+ projectConfig.namespaceCharacter,
697
+ ),
698
+ set: config.set,
699
+ };
700
+ }
701
+ }
702
+
703
+ return undefined;
704
+ }
705
+
706
+ function runGit(rootDirectoryPath: string, args: string[]) {
707
+ return childProcess.execFileSync("git", ["-C", rootDirectoryPath, ...args], {
708
+ encoding: "utf8",
709
+ stdio: ["ignore", "pipe", "ignore"],
710
+ });
711
+ }
712
+
713
+ function isExecutableFile(filePath: string) {
714
+ try {
715
+ const stat = fs.statSync(filePath);
716
+ if (!stat.isFile()) {
717
+ return false;
718
+ }
719
+
720
+ if (process.platform === "win32") {
721
+ return true;
722
+ }
723
+
724
+ fs.accessSync(filePath, fs.constants.X_OK);
725
+ return true;
726
+ } catch (_error) {
727
+ return false;
728
+ }
729
+ }
730
+
731
+ function hasCommandInPath(command: string) {
732
+ const pathEntries = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
733
+ const extensions =
734
+ process.platform === "win32"
735
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
736
+ : [""];
737
+
738
+ return pathEntries.some((entry) =>
739
+ extensions.some((extension) => isExecutableFile(path.join(entry, `${command}${extension}`))),
740
+ );
741
+ }
742
+
743
+ function hasKnownEditorInstall(editor: CatalogDevEditorId) {
744
+ if (hasCommandInPath(editor === "cursor" ? "cursor" : "code")) {
745
+ return true;
746
+ }
747
+
748
+ if (process.platform === "darwin") {
749
+ const appName = editor === "cursor" ? "Cursor.app" : "Visual Studio Code.app";
750
+
751
+ return [
752
+ path.join("/Applications", appName),
753
+ path.join(process.env.HOME || "", "Applications", appName),
754
+ ].some((appPath) => fs.existsSync(appPath));
755
+ }
756
+
757
+ if (process.platform === "win32") {
758
+ const localAppData = process.env.LOCALAPPDATA || "";
759
+ const programFiles = process.env.ProgramFiles || "";
760
+ const programFilesX86 = process.env["ProgramFiles(x86)"] || "";
761
+ const candidates =
762
+ editor === "cursor"
763
+ ? [
764
+ path.join(localAppData, "Programs", "Cursor", "Cursor.exe"),
765
+ path.join(programFiles, "Cursor", "Cursor.exe"),
766
+ path.join(programFilesX86, "Cursor", "Cursor.exe"),
767
+ ]
768
+ : [
769
+ path.join(localAppData, "Programs", "Microsoft VS Code", "Code.exe"),
770
+ path.join(programFiles, "Microsoft VS Code", "Code.exe"),
771
+ path.join(programFilesX86, "Microsoft VS Code", "Code.exe"),
772
+ ];
773
+
774
+ return candidates.some((candidate) => fs.existsSync(candidate));
775
+ }
776
+
777
+ return false;
778
+ }
779
+
780
+ function detectDevEditors(): CatalogDevEditor[] {
781
+ const editors: CatalogDevEditor[] = [];
782
+
783
+ if (hasKnownEditorInstall("cursor")) {
784
+ editors.push({ id: "cursor", label: "Cursor", icon: "cursor" });
785
+ }
786
+
787
+ if (hasKnownEditorInstall("vscode")) {
788
+ editors.push({ id: "vscode", label: "VS Code", icon: "vscode" });
789
+ }
790
+
791
+ return editors;
792
+ }
793
+
794
+ function getGitHistory(rootDirectoryPath: string, projectConfig: any): CatalogHistoryEntry[] {
795
+ try {
796
+ const pathPatterns = projectConfig.sets
797
+ ? [path.relative(rootDirectoryPath, projectConfig.setsDirectoryPath)]
798
+ : [
799
+ path.relative(rootDirectoryPath, projectConfig.localesDirectoryPath),
800
+ path.relative(rootDirectoryPath, projectConfig.messagesDirectoryPath),
801
+ path.relative(rootDirectoryPath, projectConfig.attributesDirectoryPath),
802
+ path.relative(rootDirectoryPath, projectConfig.segmentsDirectoryPath),
803
+ path.relative(rootDirectoryPath, projectConfig.targetsDirectoryPath),
804
+ path.relative(rootDirectoryPath, projectConfig.testsDirectoryPath),
805
+ ];
806
+ const raw = runGit(rootDirectoryPath, [
807
+ "log",
808
+ "--name-only",
809
+ "--pretty=format:%h|%an|%aI",
810
+ "--relative",
811
+ "--no-merges",
812
+ "--",
813
+ ...pathPatterns,
814
+ ]);
815
+ const blocks = raw.split("\n\n");
816
+ const history: CatalogHistoryEntry[] = [];
817
+
818
+ for (const block of blocks) {
819
+ if (!block.trim()) {
820
+ continue;
821
+ }
822
+
823
+ const lines = block.split("\n").filter(Boolean);
824
+ const [commit, author, timestamp] = lines[0].split("|");
825
+ const entities = lines
826
+ .slice(1)
827
+ .map((line) => getEntityInfoFromRelativePath(rootDirectoryPath, projectConfig, line))
828
+ .filter(Boolean) as CatalogHistoryEntity[];
829
+ const filteredEntities = entities.filter((entity) => entity.type !== "test");
830
+
831
+ if (filteredEntities.length > 0) {
832
+ history.push({ commit, author, timestamp, entities: filteredEntities });
833
+ }
834
+ }
835
+
836
+ return history;
837
+ } catch (_error) {
838
+ return [];
839
+ }
840
+ }
841
+
842
+ function getCurrentBranch(rootDirectoryPath: string) {
843
+ try {
844
+ return runGit(rootDirectoryPath, ["symbolic-ref", "--short", "HEAD"]).trim() || "HEAD";
845
+ } catch (_error) {
846
+ return "HEAD";
847
+ }
848
+ }
849
+
850
+ function getRepositoryRootDirectoryPath(rootDirectoryPath: string) {
851
+ try {
852
+ return (
853
+ getRealPath(runGit(rootDirectoryPath, ["rev-parse", "--show-toplevel"]).trim()) ||
854
+ getRealPath(rootDirectoryPath)
855
+ );
856
+ } catch (_error) {
857
+ return getRealPath(rootDirectoryPath);
858
+ }
859
+ }
860
+
861
+ function getOwnerAndRepoFromGitRemote(origin: string, host: string) {
862
+ const escapedHost = host.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
863
+ const match = origin.match(new RegExp(`${escapedHost}[:/]([^/]+)/(.+?)(?:\\.git)?$`));
864
+
865
+ if (!match) {
866
+ return undefined;
867
+ }
868
+
869
+ return {
870
+ owner: match[1],
871
+ repo: match[2],
872
+ };
873
+ }
874
+
875
+ function getRepoLinks(rootDirectoryPath: string) {
876
+ try {
877
+ const origin = runGit(rootDirectoryPath, ["config", "--get", "remote.origin.url"]).trim();
878
+ const branch = encodeURI(getCurrentBranch(rootDirectoryPath));
879
+ const providers: Record<
880
+ CatalogGitProvider,
881
+ {
882
+ host: string;
883
+ repository: (owner: string, repo: string) => string;
884
+ source: (owner: string, repo: string) => string;
885
+ commit: (owner: string, repo: string) => string;
886
+ }
887
+ > = {
888
+ github: {
889
+ host: "github.com",
890
+ repository: (owner, repo) => `https://github.com/${owner}/${repo}`,
891
+ source: (owner, repo) => `https://github.com/${owner}/${repo}/blob/${branch}/{{path}}`,
892
+ commit: (owner, repo) => `https://github.com/${owner}/${repo}/commit/{{hash}}`,
893
+ },
894
+ gitlab: {
895
+ host: "gitlab.com",
896
+ repository: (owner, repo) => `https://gitlab.com/${owner}/${repo}`,
897
+ source: (owner, repo) => `https://gitlab.com/${owner}/${repo}/-/blob/${branch}/{{path}}`,
898
+ commit: (owner, repo) => `https://gitlab.com/${owner}/${repo}/-/commit/{{hash}}`,
899
+ },
900
+ bitbucket: {
901
+ host: "bitbucket.org",
902
+ repository: (owner, repo) => `https://bitbucket.org/${owner}/${repo}`,
903
+ source: (owner, repo) => `https://bitbucket.org/${owner}/${repo}/src/${branch}/{{path}}`,
904
+ commit: (owner, repo) => `https://bitbucket.org/${owner}/${repo}/commits/{{hash}}`,
905
+ },
906
+ };
907
+
908
+ for (const provider of Object.keys(providers) as CatalogGitProvider[]) {
909
+ const config = providers[provider];
910
+ const details = getOwnerAndRepoFromGitRemote(origin, config.host);
911
+
912
+ if (details) {
913
+ return {
914
+ provider,
915
+ repository: config.repository(details.owner, details.repo),
916
+ source: config.source(details.owner, details.repo),
917
+ commit: config.commit(details.owner, details.repo),
918
+ };
919
+ }
920
+ }
921
+ } catch (_error) {
922
+ return undefined;
923
+ }
924
+ }
925
+
926
+ function chunkHistory(history: CatalogHistoryEntry[], pageSize = CATALOG_HISTORY_PAGE_SIZE) {
927
+ const pages: CatalogHistoryEntry[][] = [];
928
+
929
+ for (let index = 0; index < history.length; index += pageSize) {
930
+ pages.push(history.slice(index, index + pageSize));
931
+ }
932
+
933
+ return pages.length > 0 ? pages : [[]];
934
+ }
935
+
936
+ async function writeHistoryPages(directoryPath: string, history: CatalogHistoryEntry[]) {
937
+ const pages = chunkHistory(history);
938
+
939
+ for (let index = 0; index < pages.length; index++) {
940
+ await writeJson(path.join(directoryPath, `page-${index + 1}.json`), {
941
+ page: index + 1,
942
+ pageSize: CATALOG_HISTORY_PAGE_SIZE,
943
+ totalPages: pages.length,
944
+ entries: pages[index],
945
+ });
946
+ }
947
+ }
948
+
949
+ function filterHistoryForEntity(
950
+ history: CatalogHistoryEntry[],
951
+ type: CatalogEntityType,
952
+ key: string,
953
+ set?: string,
954
+ ) {
955
+ return history.filter((entry) =>
956
+ entry.entities.some(
957
+ (entity) =>
958
+ entity.type === type &&
959
+ entity.key === key &&
960
+ (typeof set === "undefined" || entity.set === set),
961
+ ),
962
+ );
963
+ }
964
+
965
+ function filterHistoryForSet(history: CatalogHistoryEntry[], set: string) {
966
+ return history.filter((entry) => entry.entities.some((entity) => entity.set === set));
967
+ }
968
+
969
+ function getSourceFileInfo(
970
+ repositoryRootDirectoryPath: string,
971
+ rootDirectoryPath: string,
972
+ projectConfig: any,
973
+ type: CatalogEntityType,
974
+ key: string,
975
+ ): SourceFileInfo {
976
+ const directoryByType: Record<CatalogEntityType, string> = {
977
+ locale: projectConfig.localesDirectoryPath,
978
+ message: projectConfig.messagesDirectoryPath,
979
+ attribute: projectConfig.attributesDirectoryPath,
980
+ segment: projectConfig.segmentsDirectoryPath,
981
+ target: projectConfig.targetsDirectoryPath,
982
+ };
983
+ const extension = `.${(projectConfig.parser as any).extension}`;
984
+ const filePath = path.resolve(
985
+ path.resolve(
986
+ rootDirectoryPath,
987
+ directoryByType[type],
988
+ ...key.split(projectConfig.namespaceCharacter),
989
+ ) + extension,
990
+ );
991
+ const absolutePath = getRealPath(filePath);
992
+
993
+ return {
994
+ sourcePath: toPosixPath(path.relative(repositoryRootDirectoryPath, absolutePath)),
995
+ absolutePath,
996
+ };
997
+ }
998
+
999
+ function encodeEditorPath(filePath: string) {
1000
+ return encodeURI(filePath.split(path.sep).join("/")).replace(/#/g, "%23").replace(/\?/g, "%3F");
1001
+ }
1002
+
1003
+ function getEditorUri(editor: CatalogDevEditorId, filePath: string) {
1004
+ return `${editor === "cursor" ? "cursor" : "vscode"}://file/${encodeEditorPath(filePath)}`;
1005
+ }
1006
+
1007
+ function getEditorLinks(editors: CatalogDevEditor[], sourceFileInfo: SourceFileInfo) {
1008
+ if (editors.length === 0) {
1009
+ return undefined;
1010
+ }
1011
+
1012
+ return Object.fromEntries(
1013
+ editors.map((editor) => [editor.id, getEditorUri(editor.id, sourceFileInfo.absolutePath)]),
1014
+ );
1015
+ }
1016
+
1017
+ async function buildSetCatalog(
1018
+ context: CatalogBuildContext,
1019
+ set: string,
1020
+ projectConfig: any,
1021
+ datasource: any,
1022
+ outputRelativeDirectory: string,
1023
+ ) {
1024
+ const outputDirectoryPath = path.join(context.dataDirectoryPath, outputRelativeDirectory);
1025
+ const [localeKeys, messageKeys, attributeKeys, segmentKeys, targetKeys] = await Promise.all([
1026
+ datasource.listLocales(),
1027
+ datasource.listMessages(),
1028
+ datasource.listAttributes(),
1029
+ datasource.listSegments(),
1030
+ datasource.listTargets(),
1031
+ ]);
1032
+ const [locales, messages, attributes, segments, targets] = await Promise.all([
1033
+ readAll<Locale>(localeKeys, (key) => datasource.readLocale(key)),
1034
+ readAll<Message>(messageKeys, (key) => datasource.readMessage(key)),
1035
+ readAll<Attribute>(attributeKeys, (key) => datasource.readAttribute(key)),
1036
+ readAll<Segment>(segmentKeys, (key) => datasource.readSegment(key)),
1037
+ readAll<Target>(targetKeys, (key) => datasource.readTarget(key)),
1038
+ ]);
1039
+ const messageTargets: Record<string, string[]> = {};
1040
+ const targetMessages: Record<string, string[]> = {};
1041
+ const localeTargets: Record<string, Set<string>> = {};
1042
+ const attributeTargets: Record<string, Set<string>> = {};
1043
+ const segmentTargets: Record<string, Set<string>> = {};
1044
+ const attributesUsedInSegments: Record<string, Set<string>> = {};
1045
+ const attributesUsedInMessages: Record<string, Set<string>> = {};
1046
+ const segmentsUsedInMessages: Record<string, Set<string>> = {};
1047
+
1048
+ for (const targetKey of targetKeys) {
1049
+ targetMessages[targetKey] = getTargetMessageKeys(targets[targetKey], messageKeys);
1050
+ const targetLocaleKeys = targets[targetKey].locales?.length
1051
+ ? targets[targetKey].locales
1052
+ : localeKeys;
1053
+
1054
+ for (const localeKey of targetLocaleKeys) {
1055
+ if (!localeTargets[localeKey]) {
1056
+ localeTargets[localeKey] = new Set();
1057
+ }
1058
+
1059
+ localeTargets[localeKey].add(targetKey);
1060
+ }
1061
+
1062
+ for (const messageKey of targetMessages[targetKey]) {
1063
+ if (!messageTargets[messageKey]) {
1064
+ messageTargets[messageKey] = [];
1065
+ }
1066
+
1067
+ messageTargets[messageKey].push(targetKey);
1068
+ }
1069
+ }
1070
+
1071
+ for (const segmentKey of segmentKeys) {
1072
+ const usedAttributes = new Set<string>();
1073
+ collectAttributeKeysFromConditions(segments[segmentKey].conditions, usedAttributes);
1074
+
1075
+ for (const attributeKey of Array.from(usedAttributes)) {
1076
+ if (!attributesUsedInSegments[attributeKey]) {
1077
+ attributesUsedInSegments[attributeKey] = new Set();
1078
+ }
1079
+
1080
+ attributesUsedInSegments[attributeKey].add(segmentKey);
1081
+ }
1082
+ }
1083
+
1084
+ for (const messageKey of messageKeys) {
1085
+ const message = messages[messageKey];
1086
+ const targetsForMessage = messageTargets[messageKey] || [];
1087
+
1088
+ for (const override of message.overrides || []) {
1089
+ const usedAttributes = new Set<string>();
1090
+ const usedSegments = new Set<string>();
1091
+ collectAttributeKeysFromConditions(override.conditions, usedAttributes);
1092
+ collectSegmentKeys(override.segments, usedSegments);
1093
+
1094
+ for (const attributeKey of Array.from(usedAttributes)) {
1095
+ if (!attributesUsedInMessages[attributeKey]) {
1096
+ attributesUsedInMessages[attributeKey] = new Set();
1097
+ }
1098
+
1099
+ attributesUsedInMessages[attributeKey].add(messageKey);
1100
+
1101
+ for (const targetKey of targetsForMessage) {
1102
+ if (!attributeTargets[attributeKey]) {
1103
+ attributeTargets[attributeKey] = new Set();
1104
+ }
1105
+
1106
+ attributeTargets[attributeKey].add(targetKey);
1107
+ }
1108
+ }
1109
+
1110
+ for (const segmentKey of Array.from(usedSegments)) {
1111
+ if (!segmentsUsedInMessages[segmentKey]) {
1112
+ segmentsUsedInMessages[segmentKey] = new Set();
1113
+ }
1114
+
1115
+ segmentsUsedInMessages[segmentKey].add(messageKey);
1116
+
1117
+ for (const targetKey of targetsForMessage) {
1118
+ if (!segmentTargets[segmentKey]) {
1119
+ segmentTargets[segmentKey] = new Set();
1120
+ }
1121
+
1122
+ segmentTargets[segmentKey].add(targetKey);
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ for (const attributeKey of Object.keys(attributesUsedInSegments)) {
1129
+ for (const segmentKey of Array.from(attributesUsedInSegments[attributeKey])) {
1130
+ for (const targetKey of Array.from(segmentTargets[segmentKey] || [])) {
1131
+ if (!attributeTargets[attributeKey]) {
1132
+ attributeTargets[attributeKey] = new Set();
1133
+ }
1134
+
1135
+ attributeTargets[attributeKey].add(targetKey);
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ const history = set ? filterHistoryForSet(context.fullHistory, set) : context.fullHistory;
1141
+ const localeDirections = getLocaleDirections(locales);
1142
+ const duplicateResult = context.duplicateResultsBySet[getDuplicateSetKey(set)] || {
1143
+ set: set || null,
1144
+ locales: [],
1145
+ };
1146
+ const duplicatesByLocale = Object.fromEntries(
1147
+ duplicateResult.locales.map((entry) => [entry.locale, entry]),
1148
+ );
1149
+ const index: CatalogSetIndex = {
1150
+ set,
1151
+ counts: {
1152
+ locale: localeKeys.length,
1153
+ message: messageKeys.length,
1154
+ attribute: attributeKeys.length,
1155
+ segment: segmentKeys.length,
1156
+ target: targetKeys.length,
1157
+ },
1158
+ entities: {
1159
+ locale: [],
1160
+ message: [],
1161
+ attribute: [],
1162
+ segment: [],
1163
+ target: [],
1164
+ },
1165
+ };
1166
+
1167
+ await writeHistoryPages(path.join(outputDirectoryPath, "history"), history);
1168
+
1169
+ const evaluatedMessageExamplesByKey = (
1170
+ await context.runtime.resolveExamples(projectConfig, datasource, {
1171
+ onlyMessages: true,
1172
+ })
1173
+ ).messages.reduce<Record<string, CatalogEvaluatedMessageExample[]>>((accumulator, example) => {
1174
+ if (!accumulator[example.message]) {
1175
+ accumulator[example.message] = [];
1176
+ }
1177
+
1178
+ accumulator[example.message].push(example);
1179
+ return accumulator;
1180
+ }, {});
1181
+
1182
+ const evaluatedLocaleExamplesByKey = (
1183
+ await context.runtime.resolveExamples(projectConfig, datasource, {
1184
+ onlyLocales: true,
1185
+ })
1186
+ ).locales.reduce<Record<string, CatalogEvaluatedLocaleExample[]>>((accumulator, example) => {
1187
+ const originalTranslation = example.message
1188
+ ? resolveTranslationRow(messages[example.message]?.translations, example.locale, locales)
1189
+ .value
1190
+ : undefined;
1191
+
1192
+ if (!accumulator[example.locale]) {
1193
+ accumulator[example.locale] = [];
1194
+ }
1195
+
1196
+ accumulator[example.locale].push({
1197
+ ...example,
1198
+ originalTranslation: originalTranslation || undefined,
1199
+ });
1200
+ return accumulator;
1201
+ }, {});
1202
+
1203
+ for (const localeKey of localeKeys) {
1204
+ const locale = locales[localeKey];
1205
+ const sourceFileInfo = getSourceFileInfo(
1206
+ context.repositoryRootDirectoryPath,
1207
+ context.rootDirectoryPath,
1208
+ projectConfig,
1209
+ "locale",
1210
+ localeKey,
1211
+ );
1212
+ const detail = {
1213
+ type: "locale",
1214
+ key: localeKey,
1215
+ entity: locale,
1216
+ sourcePath: sourceFileInfo.sourcePath,
1217
+ editLinks: getEditorLinks(context.devEditors, sourceFileInfo),
1218
+ baseFormats: locale.formats || {},
1219
+ computedFormats: context.runtime.resolveFormats(localeKey, locales),
1220
+ formatRows: getFormatRows(context.runtime, localeKey, locales),
1221
+ evaluatedExamples: evaluatedLocaleExamplesByKey[localeKey] || [],
1222
+ targetFormats: Object.fromEntries(
1223
+ targetKeys.map((targetKey) => [
1224
+ targetKey,
1225
+ context.runtime.resolveFormats(localeKey, locales, targets[targetKey]),
1226
+ ]),
1227
+ ),
1228
+ lastModified: getLastModified(context.fullHistory, "locale", localeKey, set || undefined),
1229
+ };
1230
+
1231
+ index.entities.locale.push(
1232
+ getEntitySummary(locale, "locale", localeKey, context.fullHistory, set || undefined, {
1233
+ targets: sortStrings(Array.from(localeTargets[localeKey] || [])),
1234
+ }),
1235
+ );
1236
+ await writeJson(
1237
+ path.join(outputDirectoryPath, "entities", "locale", `${encodeKey(localeKey)}.json`),
1238
+ detail,
1239
+ );
1240
+ await writeJson(
1241
+ path.join(outputDirectoryPath, "duplicates", "locales", `${encodeKey(localeKey)}.json`),
1242
+ toLocaleDuplicatesFile(localeKey, duplicatesByLocale),
1243
+ );
1244
+ await writeHistoryPages(
1245
+ path.join(outputDirectoryPath, "history", "locale", encodeKey(localeKey)),
1246
+ filterHistoryForEntity(context.fullHistory, "locale", localeKey, set || undefined),
1247
+ );
1248
+ }
1249
+
1250
+ // translationShards[3charPrefix][messageKey] = Set<lowercased value>
1251
+ const translationShards: Record<string, Record<string, Set<string>>> = {};
1252
+
1253
+ function addToTranslationShard(msgKey: string, value: string) {
1254
+ if (!value || value.length < 3) return;
1255
+ const lower = value.toLowerCase();
1256
+ const seenSubs = new Set<string>();
1257
+ for (let i = 0; i <= lower.length - 3; i++) {
1258
+ const sub = lower.slice(i, i + 3);
1259
+ if (seenSubs.has(sub)) continue;
1260
+ seenSubs.add(sub);
1261
+ const filename = Buffer.from(sub, "utf8").toString("hex");
1262
+ if (!translationShards[filename]) translationShards[filename] = {};
1263
+ if (!translationShards[filename][msgKey]) translationShards[filename][msgKey] = new Set();
1264
+ translationShards[filename][msgKey].add(lower);
1265
+ }
1266
+ }
1267
+
1268
+ for (const messageKey of messageKeys) {
1269
+ const message = messages[messageKey];
1270
+ const overrides = (message.overrides || []).map((override: Override) => {
1271
+ const attributes = new Set<string>();
1272
+ const overrideSegments = new Set<string>();
1273
+ collectAttributeKeysFromConditions(override.conditions, attributes);
1274
+ collectSegmentKeys(override.segments, overrideSegments);
1275
+
1276
+ return {
1277
+ ...override,
1278
+ usedAttributes: sortStrings(Array.from(attributes)),
1279
+ usedSegments: sortStrings(Array.from(overrideSegments)),
1280
+ };
1281
+ });
1282
+ const sourceFileInfo = getSourceFileInfo(
1283
+ context.repositoryRootDirectoryPath,
1284
+ context.rootDirectoryPath,
1285
+ projectConfig,
1286
+ "message",
1287
+ messageKey,
1288
+ );
1289
+ const detail = {
1290
+ type: "message",
1291
+ key: messageKey,
1292
+ entity: { ...message, overrides },
1293
+ sourcePath: sourceFileInfo.sourcePath,
1294
+ editLinks: getEditorLinks(context.devEditors, sourceFileInfo),
1295
+ targets: sortStrings(messageTargets[messageKey] || []),
1296
+ localeKeys,
1297
+ localeDirections,
1298
+ translations: localeKeys.map((localeKey) =>
1299
+ resolveTranslationRow(message.translations, localeKey, locales),
1300
+ ),
1301
+ evaluatedExamples: evaluatedMessageExamplesByKey[messageKey] || [],
1302
+ overrideTranslations: overrides.map((override) => ({
1303
+ key: override.key,
1304
+ rows: localeKeys.map((localeKey) =>
1305
+ resolveTranslationRow(override.translations, localeKey, locales),
1306
+ ),
1307
+ })),
1308
+ lastModified: getLastModified(context.fullHistory, "message", messageKey, set || undefined),
1309
+ };
1310
+
1311
+ const directLocales = localeKeys.filter(
1312
+ (lk) => message.translations && typeof message.translations[lk] === "string",
1313
+ );
1314
+ const overrideLocalesSet = new Set<string>();
1315
+ for (const override of overrides) {
1316
+ for (const lk of Object.keys(override.translations || {})) {
1317
+ overrideLocalesSet.add(lk);
1318
+ }
1319
+ }
1320
+ const overrideLocalesList = sortStrings(Array.from(overrideLocalesSet));
1321
+
1322
+ // Build translation shards (direct + inherited + override, all locales combined)
1323
+ for (const localeKey of localeKeys) {
1324
+ const row = resolveTranslationRow(message.translations, localeKey, locales);
1325
+ if (row.source !== "missing" && row.value) {
1326
+ addToTranslationShard(messageKey, row.value);
1327
+ }
1328
+ for (const override of overrides) {
1329
+ const overrideRow = resolveTranslationRow(override.translations, localeKey, locales);
1330
+ if (overrideRow.source !== "missing" && overrideRow.value) {
1331
+ addToTranslationShard(messageKey, overrideRow.value);
1332
+ }
1333
+ }
1334
+ }
1335
+
1336
+ index.entities.message.push(
1337
+ getEntitySummary(message, "message", messageKey, context.fullHistory, set || undefined, {
1338
+ targets: sortStrings(messageTargets[messageKey] || []),
1339
+ ...(directLocales.length > 0 ? { locales: sortStrings(directLocales) } : {}),
1340
+ ...(overrideLocalesList.length > 0 ? { overrideLocales: overrideLocalesList } : {}),
1341
+ }),
1342
+ );
1343
+ await writeJson(
1344
+ path.join(outputDirectoryPath, "entities", "message", `${encodeKey(messageKey)}.json`),
1345
+ detail,
1346
+ );
1347
+ await writeHistoryPages(
1348
+ path.join(outputDirectoryPath, "history", "message", encodeKey(messageKey)),
1349
+ filterHistoryForEntity(context.fullHistory, "message", messageKey, set || undefined),
1350
+ );
1351
+ }
1352
+
1353
+ for (const [prefix, messageMap] of Object.entries(translationShards)) {
1354
+ const shardData: Record<string, string[]> = {};
1355
+ for (const [msgKey, valueSet] of Object.entries(messageMap)) {
1356
+ shardData[msgKey] = Array.from(valueSet);
1357
+ }
1358
+ await writeJson(path.join(outputDirectoryPath, "translations", `${prefix}.json`), shardData);
1359
+ }
1360
+
1361
+ for (const attributeKey of attributeKeys) {
1362
+ const attribute = attributes[attributeKey];
1363
+ const sourceFileInfo = getSourceFileInfo(
1364
+ context.repositoryRootDirectoryPath,
1365
+ context.rootDirectoryPath,
1366
+ projectConfig,
1367
+ "attribute",
1368
+ attributeKey,
1369
+ );
1370
+ const detail = {
1371
+ type: "attribute",
1372
+ key: attributeKey,
1373
+ entity: attribute,
1374
+ sourcePath: sourceFileInfo.sourcePath,
1375
+ editLinks: getEditorLinks(context.devEditors, sourceFileInfo),
1376
+ usage: {
1377
+ segments: sortStrings(Array.from(attributesUsedInSegments[attributeKey] || [])),
1378
+ messages: sortStrings(Array.from(attributesUsedInMessages[attributeKey] || [])),
1379
+ },
1380
+ lastModified: getLastModified(
1381
+ context.fullHistory,
1382
+ "attribute",
1383
+ attributeKey,
1384
+ set || undefined,
1385
+ ),
1386
+ };
1387
+
1388
+ index.entities.attribute.push(
1389
+ getEntitySummary(
1390
+ attribute,
1391
+ "attribute",
1392
+ attributeKey,
1393
+ context.fullHistory,
1394
+ set || undefined,
1395
+ {
1396
+ targets: sortStrings(Array.from(attributeTargets[attributeKey] || [])),
1397
+ },
1398
+ ),
1399
+ );
1400
+ await writeJson(
1401
+ path.join(outputDirectoryPath, "entities", "attribute", `${encodeKey(attributeKey)}.json`),
1402
+ detail,
1403
+ );
1404
+ await writeHistoryPages(
1405
+ path.join(outputDirectoryPath, "history", "attribute", encodeKey(attributeKey)),
1406
+ filterHistoryForEntity(context.fullHistory, "attribute", attributeKey, set || undefined),
1407
+ );
1408
+ }
1409
+
1410
+ for (const segmentKey of segmentKeys) {
1411
+ const segment = segments[segmentKey];
1412
+ const usedAttributes = new Set<string>();
1413
+ collectAttributeKeysFromConditions(segment.conditions, usedAttributes);
1414
+ const sourceFileInfo = getSourceFileInfo(
1415
+ context.repositoryRootDirectoryPath,
1416
+ context.rootDirectoryPath,
1417
+ projectConfig,
1418
+ "segment",
1419
+ segmentKey,
1420
+ );
1421
+ const detail = {
1422
+ type: "segment",
1423
+ key: segmentKey,
1424
+ entity: segment,
1425
+ sourcePath: sourceFileInfo.sourcePath,
1426
+ editLinks: getEditorLinks(context.devEditors, sourceFileInfo),
1427
+ usage: {
1428
+ attributes: sortStrings(Array.from(usedAttributes)),
1429
+ messages: sortStrings(Array.from(segmentsUsedInMessages[segmentKey] || [])),
1430
+ },
1431
+ lastModified: getLastModified(context.fullHistory, "segment", segmentKey, set || undefined),
1432
+ };
1433
+
1434
+ index.entities.segment.push(
1435
+ getEntitySummary(segment, "segment", segmentKey, context.fullHistory, set || undefined, {
1436
+ targets: sortStrings(Array.from(segmentTargets[segmentKey] || [])),
1437
+ }),
1438
+ );
1439
+ await writeJson(
1440
+ path.join(outputDirectoryPath, "entities", "segment", `${encodeKey(segmentKey)}.json`),
1441
+ detail,
1442
+ );
1443
+ await writeHistoryPages(
1444
+ path.join(outputDirectoryPath, "history", "segment", encodeKey(segmentKey)),
1445
+ filterHistoryForEntity(context.fullHistory, "segment", segmentKey, set || undefined),
1446
+ );
1447
+ }
1448
+
1449
+ for (const targetKey of targetKeys) {
1450
+ const target = targets[targetKey];
1451
+ const targetLocaleKeys = target.locales?.length ? target.locales : localeKeys;
1452
+ const formatsByLocale: Record<string, FormatPresets | undefined> = {};
1453
+ const formatRowsByLocale: Record<string, CatalogFormatRow[]> = {};
1454
+
1455
+ for (const localeKey of targetLocaleKeys) {
1456
+ formatsByLocale[localeKey] = context.runtime.resolveFormats(localeKey, locales, target);
1457
+ formatRowsByLocale[localeKey] = getFormatRows(context.runtime, localeKey, locales, target);
1458
+ }
1459
+
1460
+ const sourceFileInfo = getSourceFileInfo(
1461
+ context.repositoryRootDirectoryPath,
1462
+ context.rootDirectoryPath,
1463
+ projectConfig,
1464
+ "target",
1465
+ targetKey,
1466
+ );
1467
+ const detail = {
1468
+ type: "target",
1469
+ key: targetKey,
1470
+ entity: target,
1471
+ sourcePath: sourceFileInfo.sourcePath,
1472
+ editLinks: getEditorLinks(context.devEditors, sourceFileInfo),
1473
+ locales: targetLocaleKeys,
1474
+ formatsByLocale,
1475
+ formatRowsByLocale,
1476
+ messages: targetMessages[targetKey],
1477
+ lastModified: getLastModified(context.fullHistory, "target", targetKey, set || undefined),
1478
+ };
1479
+
1480
+ index.entities.target.push(
1481
+ getEntitySummary(target, "target", targetKey, context.fullHistory, set || undefined, {
1482
+ messageCount: targetMessages[targetKey].length,
1483
+ }),
1484
+ );
1485
+ await writeJson(
1486
+ path.join(outputDirectoryPath, "entities", "target", `${encodeKey(targetKey)}.json`),
1487
+ detail,
1488
+ );
1489
+ await writeHistoryPages(
1490
+ path.join(outputDirectoryPath, "history", "target", encodeKey(targetKey)),
1491
+ filterHistoryForEntity(context.fullHistory, "target", targetKey, set || undefined),
1492
+ );
1493
+ }
1494
+
1495
+ for (const type of Object.keys(index.entities) as CatalogEntityType[]) {
1496
+ index.entities[type].sort((a, b) => a.key.localeCompare(b.key));
1497
+ }
1498
+
1499
+ await writeJson(path.join(outputDirectoryPath, "index.json"), index);
1500
+
1501
+ return index;
1502
+ }
1503
+
1504
+ async function copyCatalogAssets(outputDirectoryPath: string) {
1505
+ let packageJsonPath: string;
1506
+
1507
+ try {
1508
+ packageJsonPath = require.resolve("@messagevisor/catalog/package.json");
1509
+ } catch (_error) {
1510
+ throw new Error(
1511
+ "Unable to resolve @messagevisor/catalog. Run npm install from the repository root.",
1512
+ );
1513
+ }
1514
+
1515
+ const distPath = path.join(path.dirname(packageJsonPath), "dist");
1516
+
1517
+ if (!fs.existsSync(distPath)) {
1518
+ throw new Error(
1519
+ "Catalog UI bundle not found. Run `npm run build --workspace @messagevisor/catalog` first.",
1520
+ );
1521
+ }
1522
+
1523
+ await fs.promises.cp(distPath, outputDirectoryPath, { recursive: true });
1524
+ }
1525
+
1526
+ export async function exportCatalog(
1527
+ runtime: CatalogRuntime,
1528
+ rootDirectoryPath: string,
1529
+ projectConfig: any,
1530
+ datasource: any,
1531
+ options: CatalogExportOptions = {},
1532
+ ) {
1533
+ const outputDirectoryPath = options.outDir
1534
+ ? path.resolve(rootDirectoryPath, options.outDir)
1535
+ : projectConfig.catalogDirectoryPath;
1536
+ const dataDirectoryPath = path.join(outputDirectoryPath, "data");
1537
+
1538
+ await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
1539
+ await fs.promises.mkdir(dataDirectoryPath, { recursive: true });
1540
+
1541
+ if (options.copyAssets !== false) {
1542
+ await copyCatalogAssets(outputDirectoryPath);
1543
+ }
1544
+
1545
+ const devEditors = options.dev ? options.devEditors || detectDevEditors() : [];
1546
+ const fullHistory = getGitHistory(rootDirectoryPath, projectConfig);
1547
+ const duplicateTranslations = await runtime.findDuplicateTranslations(projectConfig, datasource);
1548
+ const duplicateResultsBySet = Object.fromEntries(
1549
+ duplicateTranslations.results.map((result) => [getDuplicateSetKey(result.set), result]),
1550
+ );
1551
+ const context: CatalogBuildContext = {
1552
+ rootDirectoryPath,
1553
+ repositoryRootDirectoryPath: getRepositoryRootDirectoryPath(rootDirectoryPath),
1554
+ outputDirectoryPath,
1555
+ dataDirectoryPath,
1556
+ fullHistory,
1557
+ runtime,
1558
+ devEditors,
1559
+ duplicateResultsBySet,
1560
+ };
1561
+ const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
1562
+ const setIndexes: Record<string, CatalogSetIndex> = {};
1563
+
1564
+ await writeHistoryPages(path.join(dataDirectoryPath, "project", "history"), fullHistory);
1565
+
1566
+ for (const execution of executions) {
1567
+ const outputRelativeDirectory = projectConfig.sets ? path.join("sets", execution.set) : "root";
1568
+ setIndexes[execution.set || "root"] = await buildSetCatalog(
1569
+ context,
1570
+ execution.set,
1571
+ execution.projectConfig,
1572
+ execution.datasource,
1573
+ outputRelativeDirectory,
1574
+ );
1575
+ }
1576
+
1577
+ const manifest = {
1578
+ schemaVersion: CATALOG_SCHEMA_VERSION,
1579
+ generatedAt: new Date().toISOString(),
1580
+ router: options.browserRouter === false ? "hash" : "browser",
1581
+ sets: projectConfig.sets,
1582
+ setKeys: projectConfig.sets ? executions.map((execution) => execution.set) : [],
1583
+ dev: options.dev ? { editors: devEditors } : undefined,
1584
+ links: getRepoLinks(rootDirectoryPath),
1585
+ paths: {
1586
+ projectHistory: "data/project/history/page-1.json",
1587
+ root: projectConfig.sets ? undefined : "data/root/index.json",
1588
+ sets: projectConfig.sets
1589
+ ? Object.fromEntries(
1590
+ executions.map((execution) => [
1591
+ execution.set,
1592
+ `data/sets/${encodeURIComponent(execution.set)}/index.json`,
1593
+ ]),
1594
+ )
1595
+ : undefined,
1596
+ },
1597
+ counts: Object.fromEntries(Object.keys(setIndexes).map((key) => [key, setIndexes[key].counts])),
1598
+ };
1599
+
1600
+ await writeJson(path.join(dataDirectoryPath, "manifest.json"), manifest);
1601
+
1602
+ console.log(`Catalog exported to ${outputDirectoryPath}`);
1603
+
1604
+ return {
1605
+ outputDirectoryPath,
1606
+ manifest,
1607
+ };
1608
+ }
1609
+
1610
+ function getContentType(filePath: string) {
1611
+ const extension = path.extname(filePath);
1612
+
1613
+ switch (extension) {
1614
+ case ".js":
1615
+ return "text/javascript";
1616
+ case ".css":
1617
+ return "text/css";
1618
+ case ".json":
1619
+ return "application/json";
1620
+ case ".png":
1621
+ return "image/png";
1622
+ case ".svg":
1623
+ return "image/svg+xml";
1624
+ case ".ico":
1625
+ return "image/x-icon";
1626
+ default:
1627
+ return "text/html";
1628
+ }
1629
+ }
1630
+
1631
+ function getCatalogLiveReloadClientScript() {
1632
+ return [
1633
+ "<script>",
1634
+ "(() => {",
1635
+ ' const source = new EventSource("/__messagevisor_catalog_reload");',
1636
+ ' source.addEventListener("reload", () => window.location.reload());',
1637
+ " source.onerror = () => {",
1638
+ " source.close();",
1639
+ " setTimeout(() => window.location.reload(), 1000);",
1640
+ " };",
1641
+ "})();",
1642
+ "</script>",
1643
+ ].join("");
1644
+ }
1645
+
1646
+ function injectCatalogLiveReloadClient(html: string) {
1647
+ const script = getCatalogLiveReloadClientScript();
1648
+
1649
+ if (html.includes("</body>")) {
1650
+ return html.replace("</body>", `${script}</body>`);
1651
+ }
1652
+
1653
+ return `${html}${script}`;
1654
+ }
1655
+
1656
+ function createProjectWatcher(
1657
+ rootDirectoryPath: string,
1658
+ ignoredDirectoryPaths: string[],
1659
+ onChange: (changedPath: string) => void,
1660
+ ) {
1661
+ function shouldIgnore(targetPath: string) {
1662
+ const resolvedTargetPath = path.resolve(targetPath);
1663
+
1664
+ return ignoredDirectoryPaths.some((ignoredDirectoryPath) => {
1665
+ const resolvedIgnoredPath = path.resolve(ignoredDirectoryPath);
1666
+
1667
+ return (
1668
+ resolvedTargetPath === resolvedIgnoredPath ||
1669
+ resolvedTargetPath.startsWith(`${resolvedIgnoredPath}${path.sep}`)
1670
+ );
1671
+ });
1672
+ }
1673
+
1674
+ function collectSnapshotEntries(directoryPath: string, snapshotEntries: string[]) {
1675
+ if (shouldIgnore(directoryPath)) {
1676
+ return;
1677
+ }
1678
+
1679
+ let entries: fs.Dirent[] = [];
1680
+
1681
+ try {
1682
+ entries = fs.readdirSync(directoryPath, { withFileTypes: true });
1683
+ } catch {
1684
+ return;
1685
+ }
1686
+
1687
+ for (const entry of entries) {
1688
+ const entryPath = path.join(directoryPath, entry.name);
1689
+
1690
+ if (shouldIgnore(entryPath)) {
1691
+ continue;
1692
+ }
1693
+
1694
+ if (entry.isDirectory()) {
1695
+ collectSnapshotEntries(entryPath, snapshotEntries);
1696
+ continue;
1697
+ }
1698
+
1699
+ if (!entry.isFile()) {
1700
+ continue;
1701
+ }
1702
+
1703
+ try {
1704
+ const stat = fs.statSync(entryPath);
1705
+ const relativePath = path.relative(rootDirectoryPath, entryPath);
1706
+ snapshotEntries.push(`${relativePath}:${stat.size}:${stat.mtimeMs}`);
1707
+ } catch {
1708
+ // Ignore transient editor save races.
1709
+ }
1710
+ }
1711
+ }
1712
+
1713
+ function createSnapshot() {
1714
+ const snapshotEntries: string[] = [];
1715
+ collectSnapshotEntries(rootDirectoryPath, snapshotEntries);
1716
+ snapshotEntries.sort();
1717
+ return snapshotEntries.join("|");
1718
+ }
1719
+
1720
+ let previousSnapshot = createSnapshot();
1721
+ const interval = setInterval(() => {
1722
+ const nextSnapshot = createSnapshot();
1723
+
1724
+ if (nextSnapshot === previousSnapshot) {
1725
+ return;
1726
+ }
1727
+
1728
+ previousSnapshot = nextSnapshot;
1729
+ onChange(rootDirectoryPath);
1730
+ }, 250);
1731
+
1732
+ return () => {
1733
+ clearInterval(interval);
1734
+ };
1735
+ }
1736
+
1737
+ export async function serveCatalog(
1738
+ runtime: CatalogRuntime,
1739
+ rootDirectoryPath: string,
1740
+ projectConfig: any,
1741
+ datasource: any,
1742
+ options: CatalogServeOptions = {},
1743
+ ): Promise<CatalogServerHandle> {
1744
+ const outputDirectoryPath = options.outDir
1745
+ ? path.resolve(rootDirectoryPath, options.outDir)
1746
+ : projectConfig.catalogDirectoryPath;
1747
+
1748
+ if (!fs.existsSync(outputDirectoryPath)) {
1749
+ await exportCatalog(runtime, rootDirectoryPath, projectConfig, datasource, {
1750
+ outDir: outputDirectoryPath,
1751
+ browserRouter: options.browserRouter,
1752
+ });
1753
+ }
1754
+
1755
+ const port = Number(options.port || 3000);
1756
+ const liveReloadClients = new Set<http.ServerResponse>();
1757
+
1758
+ function triggerReload() {
1759
+ liveReloadClients.forEach((client) => {
1760
+ client.write("event: reload\n");
1761
+ client.write("data: reload\n\n");
1762
+ });
1763
+ }
1764
+
1765
+ const server = http.createServer((request, response) => {
1766
+ const requestedUrl = decodeURIComponent((request.url || "/").split("?")[0]);
1767
+
1768
+ if (options.liveReload && requestedUrl === "/__messagevisor_catalog_reload") {
1769
+ response.writeHead(200, {
1770
+ "Content-Type": "text/event-stream",
1771
+ "Cache-Control": "no-cache, no-transform",
1772
+ Connection: "keep-alive",
1773
+ });
1774
+ response.write("\n");
1775
+ liveReloadClients.add(response);
1776
+
1777
+ request.on("close", () => {
1778
+ liveReloadClients.delete(response);
1779
+ });
1780
+
1781
+ return;
1782
+ }
1783
+
1784
+ const requestedPath = requestedUrl === "/" ? "/index.html" : requestedUrl;
1785
+ const filePath = path.join(outputDirectoryPath, requestedPath);
1786
+ const safeFilePath = filePath.startsWith(outputDirectoryPath)
1787
+ ? filePath
1788
+ : path.join(outputDirectoryPath, "index.html");
1789
+
1790
+ fs.readFile(safeFilePath, (error, content) => {
1791
+ if (!error) {
1792
+ if (options.liveReload && path.basename(safeFilePath) === "index.html") {
1793
+ const html = injectCatalogLiveReloadClient(content.toString("utf8"));
1794
+ response.writeHead(200, { "Content-Type": "text/html" });
1795
+ response.end(html);
1796
+ return;
1797
+ }
1798
+
1799
+ response.writeHead(200, { "Content-Type": getContentType(safeFilePath) });
1800
+ response.end(content);
1801
+ return;
1802
+ }
1803
+
1804
+ if (
1805
+ requestedPath.startsWith("/assets/") ||
1806
+ requestedPath.startsWith("/data/") ||
1807
+ requestedPath === "/favicon.ico"
1808
+ ) {
1809
+ response.writeHead(404, { "Content-Type": "text/plain" });
1810
+ response.end("404 Not Found");
1811
+ return;
1812
+ }
1813
+
1814
+ fs.readFile(path.join(outputDirectoryPath, "index.html"), (indexError, indexContent) => {
1815
+ if (indexError) {
1816
+ response.writeHead(500, { "Content-Type": "text/plain" });
1817
+ response.end("Catalog index.html not found.");
1818
+ return;
1819
+ }
1820
+
1821
+ if (options.liveReload) {
1822
+ response.writeHead(200, { "Content-Type": "text/html" });
1823
+ response.end(injectCatalogLiveReloadClient(indexContent.toString("utf8")));
1824
+ return;
1825
+ }
1826
+
1827
+ response.writeHead(200, { "Content-Type": "text/html" });
1828
+ response.end(indexContent);
1829
+ });
1830
+ });
1831
+ });
1832
+
1833
+ server.on("error", (error) => {
1834
+ console.error(`Unable to serve catalog on http://127.0.0.1:${port}/`);
1835
+ console.error(error);
1836
+ process.exitCode = 1;
1837
+ });
1838
+
1839
+ server.listen(port, "127.0.0.1", () => {
1840
+ console.log(`Catalog running at http://127.0.0.1:${port}/`);
1841
+ });
1842
+
1843
+ return {
1844
+ close: () =>
1845
+ new Promise<void>((resolve, reject) => {
1846
+ server.close((error) => {
1847
+ if (error) {
1848
+ reject(error);
1849
+ return;
1850
+ }
1851
+
1852
+ resolve();
1853
+ });
1854
+ }),
1855
+ triggerReload,
1856
+ };
1857
+ }
1858
+
1859
+ export function createCatalogApi(runtime: CatalogRuntime) {
1860
+ return {
1861
+ exportCatalog: (
1862
+ rootDirectoryPath: string,
1863
+ projectConfig: any,
1864
+ datasource: any,
1865
+ options: CatalogExportOptions = {},
1866
+ ) => exportCatalog(runtime, rootDirectoryPath, projectConfig, datasource, options),
1867
+ serveCatalog: (
1868
+ rootDirectoryPath: string,
1869
+ projectConfig: any,
1870
+ datasource: any,
1871
+ options: CatalogServeOptions = {},
1872
+ ) => serveCatalog(runtime, rootDirectoryPath, projectConfig, datasource, options),
1873
+ };
1874
+ }
1875
+
1876
+ export function createCatalogPlugin(
1877
+ runtime: CatalogRuntime,
1878
+ api: ReturnType<typeof createCatalogApi> = createCatalogApi(runtime),
1879
+ ): CatalogPlugin {
1880
+ return {
1881
+ command: "catalog [subcommand]",
1882
+ handler: async ({ rootDirectoryPath, projectConfig, datasource, parsed }) => {
1883
+ const allowedSubcommands = ["export", "serve"];
1884
+ const browserRouter = !(parsed.hashRouter || parsed["hash-router"]);
1885
+
1886
+ if (!parsed.subcommand) {
1887
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
1888
+ outDir: parsed.outDir,
1889
+ copyAssets: !parsed.noAssets,
1890
+ browserRouter,
1891
+ dev: true,
1892
+ });
1893
+ const server = await api.serveCatalog(rootDirectoryPath, projectConfig, datasource, {
1894
+ outDir: parsed.outDir,
1895
+ port: parsed.port || parsed.p,
1896
+ browserRouter,
1897
+ liveReload: true,
1898
+ });
1899
+
1900
+ const outputDirectoryPath = parsed.outDir
1901
+ ? path.resolve(rootDirectoryPath, parsed.outDir)
1902
+ : projectConfig.catalogDirectoryPath;
1903
+ const ignoredDirectoryPaths = [
1904
+ path.join(rootDirectoryPath, ".git"),
1905
+ path.join(rootDirectoryPath, "node_modules"),
1906
+ path.join(rootDirectoryPath, ".messagevisor"),
1907
+ path.join(rootDirectoryPath, "datafiles"),
1908
+ path.join(rootDirectoryPath, "catalog"),
1909
+ path.join(rootDirectoryPath, "exports"),
1910
+ path.join(rootDirectoryPath, "imports"),
1911
+ outputDirectoryPath,
1912
+ ];
1913
+ let exportInFlight = false;
1914
+ let exportQueued = false;
1915
+ let queuedReason: string | null = null;
1916
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
1917
+
1918
+ const runExportAndReload = async (reason: string) => {
1919
+ if (exportInFlight) {
1920
+ exportQueued = true;
1921
+ queuedReason = queuedReason || reason;
1922
+ return;
1923
+ }
1924
+
1925
+ exportInFlight = true;
1926
+ console.log(`\n[catalog] Re-exporting because ${reason}`);
1927
+
1928
+ try {
1929
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
1930
+ outDir: parsed.outDir,
1931
+ copyAssets: !parsed.noAssets,
1932
+ browserRouter,
1933
+ dev: true,
1934
+ });
1935
+ server.triggerReload();
1936
+ } catch (error) {
1937
+ console.error("[catalog] Export failed during watch mode");
1938
+ console.error(error);
1939
+ } finally {
1940
+ exportInFlight = false;
1941
+
1942
+ if (exportQueued) {
1943
+ const nextReason = queuedReason || "more project changes";
1944
+ exportQueued = false;
1945
+ queuedReason = null;
1946
+ void runExportAndReload(nextReason);
1947
+ }
1948
+ }
1949
+ };
1950
+
1951
+ const stopWatchingProject = createProjectWatcher(
1952
+ rootDirectoryPath,
1953
+ ignoredDirectoryPaths,
1954
+ (changedPath) => {
1955
+ const reason = `project change in ${path.relative(rootDirectoryPath, changedPath) || "."}`;
1956
+
1957
+ if (debounceTimer) {
1958
+ clearTimeout(debounceTimer);
1959
+ }
1960
+ debounceTimer = setTimeout(() => {
1961
+ debounceTimer = null;
1962
+ void runExportAndReload(reason);
1963
+ }, 150);
1964
+ },
1965
+ );
1966
+
1967
+ process.on("exit", stopWatchingProject);
1968
+ return;
1969
+ }
1970
+
1971
+ if (allowedSubcommands.indexOf(parsed.subcommand) === -1) {
1972
+ console.log("Please specify a subcommand: `export` or `serve`");
1973
+ return false;
1974
+ }
1975
+
1976
+ if (parsed.subcommand === "export") {
1977
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
1978
+ outDir: parsed.outDir,
1979
+ copyAssets: !parsed.noAssets,
1980
+ browserRouter,
1981
+ });
1982
+ }
1983
+
1984
+ if (parsed.subcommand === "serve") {
1985
+ await api.serveCatalog(rootDirectoryPath, projectConfig, datasource, {
1986
+ outDir: parsed.outDir,
1987
+ port: parsed.port || parsed.p,
1988
+ browserRouter,
1989
+ });
1990
+ }
1991
+ },
1992
+ examples: [
1993
+ {
1994
+ command: "catalog",
1995
+ description: "generate and serve the static catalog locally",
1996
+ },
1997
+ {
1998
+ command: "catalog export",
1999
+ description: "generate static catalog with project data",
2000
+ },
2001
+ {
2002
+ command: "catalog serve",
2003
+ description: "serve the generated catalog locally",
2004
+ },
2005
+ ],
2006
+ };
2007
+ }