@messagevisor/catalog 0.7.0 → 0.9.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.
package/src/node/index.ts CHANGED
@@ -341,6 +341,8 @@ export interface CatalogExportOptions {
341
341
  devEditors?: CatalogDevEditor[];
342
342
  withTranslationSearch?: boolean;
343
343
  withDuplicates?: boolean;
344
+ devSession?: CatalogDevSession;
345
+ preserveAssets?: boolean;
344
346
  }
345
347
 
346
348
  export interface CatalogServeOptions {
@@ -371,6 +373,22 @@ interface CatalogBuildContext {
371
373
  writer: CatalogJsonWriter;
372
374
  }
373
375
 
376
+ interface CatalogDevSession {
377
+ outputDirectoryPath: string;
378
+ devEditors: CatalogDevEditor[];
379
+ historyIndex: CatalogHistoryIndex;
380
+ links: ReturnType<typeof getRepoLinks>;
381
+ repositoryRootDirectoryPath: string;
382
+ repositorySourceRootDirectoryPath: string;
383
+ }
384
+
385
+ interface CatalogDevRebuildRequest {
386
+ kind: "full" | "set" | "message";
387
+ reason: string;
388
+ set?: string;
389
+ messageKeys?: string[];
390
+ }
391
+
374
392
  interface SourceFileInfo {
375
393
  sourcePath: string;
376
394
  absolutePath: string;
@@ -630,7 +648,7 @@ function getLocaleFormatSource(
630
648
  locales: Record<string, Locale>,
631
649
  formatPath: string,
632
650
  ): Pick<CatalogFormatRow, "source" | "from"> {
633
- const pathSegments = formatPath.split(".");
651
+ const pathSegments = getFormatStylePathSegments(formatPath);
634
652
 
635
653
  if (typeof getPathValue(locales[localeKey]?.formats, pathSegments) !== "undefined") {
636
654
  return { source: "direct" };
@@ -650,6 +668,11 @@ function getLocaleFormatSource(
650
668
  return { source: "missing" };
651
669
  }
652
670
 
671
+ function getFormatStylePathSegments(formatPath: string): string[] {
672
+ const pathSegments = formatPath.split(".").filter(Boolean);
673
+ return pathSegments.length > 2 ? pathSegments.slice(0, 2) : pathSegments;
674
+ }
675
+
653
676
  function getFormatRows(
654
677
  runtime: CatalogRuntime,
655
678
  localeKey: string,
@@ -661,7 +684,8 @@ function getFormatRows(
661
684
  const rows = flattenObjectRows(computedFormats).map((row) => {
662
685
  if (
663
686
  target &&
664
- typeof getPathValue(target.formats?.[localeKey], row.path.split(".")) !== "undefined"
687
+ typeof getPathValue(target.formats?.[localeKey], getFormatStylePathSegments(row.path)) !==
688
+ "undefined"
665
689
  ) {
666
690
  return { ...row, source: "target" as const, from: "target" };
667
691
  }
@@ -1977,6 +2001,455 @@ async function copyCatalogAssets(outputDirectoryPath: string) {
1977
2001
  await fs.promises.cp(distPath, outputDirectoryPath, { recursive: true });
1978
2002
  }
1979
2003
 
2004
+ async function createCatalogDevSession(
2005
+ rootDirectoryPath: string,
2006
+ projectConfig: any,
2007
+ options: { outDir?: string; devEditors?: CatalogDevEditor[] } = {},
2008
+ ): Promise<CatalogDevSession> {
2009
+ const outputDirectoryPath = options.outDir
2010
+ ? path.resolve(rootDirectoryPath, options.outDir)
2011
+ : projectConfig.catalogDirectoryPath;
2012
+
2013
+ return {
2014
+ outputDirectoryPath,
2015
+ devEditors: options.devEditors || detectDevEditors(),
2016
+ historyIndex: await getGitHistoryIndex(rootDirectoryPath, projectConfig),
2017
+ links: getRepoLinks(rootDirectoryPath),
2018
+ repositoryRootDirectoryPath: getRepositoryRootDirectoryPath(rootDirectoryPath),
2019
+ repositorySourceRootDirectoryPath: getRepositorySourceRootDirectoryPath(rootDirectoryPath),
2020
+ };
2021
+ }
2022
+
2023
+ async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
2024
+ try {
2025
+ return JSON.parse(await fs.promises.readFile(filePath, "utf8")) as T;
2026
+ } catch (_error) {
2027
+ return undefined;
2028
+ }
2029
+ }
2030
+
2031
+ function getOutputRelativeDirectory(projectConfig: any, set?: string) {
2032
+ return projectConfig.sets ? path.join("sets", set || "") : "root";
2033
+ }
2034
+
2035
+ function getDataOutputDirectoryPath(session: CatalogDevSession, projectConfig: any, set?: string) {
2036
+ return path.join(
2037
+ session.outputDirectoryPath,
2038
+ "data",
2039
+ getOutputRelativeDirectory(projectConfig, set),
2040
+ );
2041
+ }
2042
+
2043
+ function getEntityKeyFromChangedPath(
2044
+ rootDirectoryPath: string,
2045
+ projectConfig: any,
2046
+ changedPath: string,
2047
+ ): EntityPathInfo | undefined {
2048
+ const relativePath = path.relative(rootDirectoryPath, changedPath);
2049
+
2050
+ if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
2051
+ return undefined;
2052
+ }
2053
+
2054
+ return getEntityInfoFromRelativePath(rootDirectoryPath, projectConfig, relativePath);
2055
+ }
2056
+
2057
+ function getChangedPathSummary(rootDirectoryPath: string, changedPaths: string[]) {
2058
+ return changedPaths
2059
+ .slice(0, 3)
2060
+ .map((changedPath) => formatCatalogPath(rootDirectoryPath, changedPath))
2061
+ .join(", ");
2062
+ }
2063
+
2064
+ function classifyCatalogDevChanges(
2065
+ rootDirectoryPath: string,
2066
+ projectConfig: any,
2067
+ changedPaths: string[],
2068
+ options: { withTranslationSearch: boolean; withDuplicates: boolean },
2069
+ ): CatalogDevRebuildRequest {
2070
+ const reason = getChangedPathSummary(rootDirectoryPath, changedPaths) || "project changes";
2071
+ const infos = changedPaths.map((changedPath) =>
2072
+ getEntityKeyFromChangedPath(rootDirectoryPath, projectConfig, changedPath),
2073
+ );
2074
+
2075
+ if (infos.length === 0 || infos.some((info) => !info)) {
2076
+ return { kind: "full", reason };
2077
+ }
2078
+
2079
+ const sets = new Set(infos.map((info) => info?.set || ""));
2080
+ const types = new Set(infos.map((info) => info?.type));
2081
+
2082
+ if (sets.size > 1) {
2083
+ return { kind: "full", reason };
2084
+ }
2085
+
2086
+ const set = Array.from(sets)[0] || undefined;
2087
+
2088
+ if (
2089
+ types.size === 1 &&
2090
+ types.has("message") &&
2091
+ !options.withTranslationSearch &&
2092
+ !options.withDuplicates
2093
+ ) {
2094
+ return {
2095
+ kind: "message",
2096
+ reason,
2097
+ set,
2098
+ messageKeys: sortStrings(infos.map((info) => info?.key || "").filter(Boolean)),
2099
+ };
2100
+ }
2101
+
2102
+ if (
2103
+ projectConfig.sets &&
2104
+ set &&
2105
+ types.size > 0 &&
2106
+ !types.has("test") &&
2107
+ !options.withTranslationSearch &&
2108
+ !options.withDuplicates
2109
+ ) {
2110
+ return { kind: "set", reason, set };
2111
+ }
2112
+
2113
+ return { kind: "full", reason };
2114
+ }
2115
+
2116
+ async function writeCatalogManifest(
2117
+ writer: CatalogJsonWriter,
2118
+ rootDirectoryPath: string,
2119
+ projectConfig: any,
2120
+ session: CatalogDevSession,
2121
+ options: {
2122
+ browserRouter: boolean;
2123
+ withTranslationSearch: boolean;
2124
+ withDuplicates: boolean;
2125
+ setIndexes: Record<string, CatalogSetIndex>;
2126
+ executions: Array<{ set: string; projectConfig: any; datasource: any }>;
2127
+ },
2128
+ ) {
2129
+ const manifest = {
2130
+ schemaVersion: CATALOG_SCHEMA_VERSION,
2131
+ generatedAt: new Date().toISOString(),
2132
+ router: options.browserRouter === false ? "hash" : "browser",
2133
+ sets: projectConfig.sets,
2134
+ setKeys: projectConfig.sets ? options.executions.map((execution) => execution.set) : [],
2135
+ dev: { editors: session.devEditors },
2136
+ features: {
2137
+ translationSearch: options.withTranslationSearch,
2138
+ duplicates: options.withDuplicates,
2139
+ },
2140
+ links: session.links,
2141
+ paths: {
2142
+ projectHistory: "data/project/history/page-1.json",
2143
+ root: projectConfig.sets ? undefined : "data/root/index.json",
2144
+ sets: projectConfig.sets
2145
+ ? Object.fromEntries(
2146
+ options.executions.map((execution) => [
2147
+ execution.set,
2148
+ `data/sets/${encodeURIComponent(execution.set)}/index.json`,
2149
+ ]),
2150
+ )
2151
+ : undefined,
2152
+ },
2153
+ counts: Object.fromEntries(
2154
+ Object.keys(options.setIndexes).map((key) => [key, options.setIndexes[key].counts]),
2155
+ ),
2156
+ };
2157
+
2158
+ await writer.write(path.join(session.outputDirectoryPath, "data", "manifest.json"), manifest);
2159
+ return manifest;
2160
+ }
2161
+
2162
+ function getMessageRelationshipFingerprint(message: Message) {
2163
+ const attributes = new Set<string>();
2164
+ const segments = new Set<string>();
2165
+
2166
+ for (const override of message.overrides || []) {
2167
+ collectAttributeKeysFromConditions(override.conditions, attributes);
2168
+ collectSegmentKeys(override.segments, segments);
2169
+ }
2170
+
2171
+ return {
2172
+ attributes: sortStrings(Array.from(attributes)),
2173
+ segments: sortStrings(Array.from(segments)),
2174
+ };
2175
+ }
2176
+
2177
+ function sameStringList(left: string[] = [], right: string[] = []) {
2178
+ if (left.length !== right.length) {
2179
+ return false;
2180
+ }
2181
+
2182
+ return left.every((value, index) => value === right[index]);
2183
+ }
2184
+
2185
+ function summarizeMessage(
2186
+ message: Message,
2187
+ messageKey: string,
2188
+ historyIndex: CatalogHistoryIndex,
2189
+ set: string | undefined,
2190
+ targets: string[],
2191
+ ) {
2192
+ const directLocales = Object.keys(message.translations || {});
2193
+ const overrideLocalesSet = new Set<string>();
2194
+
2195
+ for (const override of message.overrides || []) {
2196
+ for (const localeKey of Object.keys(override.translations || {})) {
2197
+ overrideLocalesSet.add(localeKey);
2198
+ }
2199
+ }
2200
+
2201
+ const overrideLocales = sortStrings(Array.from(overrideLocalesSet));
2202
+
2203
+ return getEntitySummary(message, "message", messageKey, historyIndex, set, {
2204
+ targets,
2205
+ ...(directLocales.length > 0 ? { locales: sortStrings(directLocales) } : {}),
2206
+ ...(overrideLocales.length > 0 ? { overrideLocales } : {}),
2207
+ });
2208
+ }
2209
+
2210
+ async function tryRebuildCatalogMessage(
2211
+ runtime: CatalogRuntime,
2212
+ rootDirectoryPath: string,
2213
+ rootProjectConfig: any,
2214
+ projectConfig: any,
2215
+ datasource: any,
2216
+ session: CatalogDevSession,
2217
+ request: CatalogDevRebuildRequest,
2218
+ ) {
2219
+ if (request.kind !== "message" || !request.messageKeys || request.messageKeys.length === 0) {
2220
+ return false;
2221
+ }
2222
+
2223
+ const dataDirectoryPath = getDataOutputDirectoryPath(session, rootProjectConfig, request.set);
2224
+ const indexPath = path.join(dataDirectoryPath, "index.json");
2225
+ const index = await readJsonFile<CatalogSetIndex>(indexPath);
2226
+
2227
+ if (!index) {
2228
+ return false;
2229
+ }
2230
+
2231
+ const [localeKeys, messageKeys, targetKeys] = (await Promise.all([
2232
+ datasource.listLocales(),
2233
+ datasource.listMessages(),
2234
+ datasource.listTargets(),
2235
+ ])) as [string[], string[], string[]];
2236
+ const messageKeySet = new Set(messageKeys);
2237
+
2238
+ if (request.messageKeys.some((messageKey) => !messageKeySet.has(messageKey))) {
2239
+ return false;
2240
+ }
2241
+
2242
+ const [locales, targets] = await Promise.all([
2243
+ readAll<Locale>(localeKeys, (key) => datasource.readLocale(key)),
2244
+ readAll<Target>(targetKeys, (key) => datasource.readTarget(key)),
2245
+ ]);
2246
+ const localeDirections = getLocaleDirections(locales);
2247
+ const targetMessages = Object.fromEntries(
2248
+ targetKeys.map((targetKey) => [
2249
+ targetKey,
2250
+ getTargetMessageKeys(targets[targetKey], messageKeys),
2251
+ ]),
2252
+ ) as Record<string, string[]>;
2253
+ const writer = new CatalogJsonWriter();
2254
+
2255
+ for (const messageKey of request.messageKeys) {
2256
+ const oldDetailPath = path.join(
2257
+ dataDirectoryPath,
2258
+ "entities",
2259
+ "message",
2260
+ `${encodeKey(messageKey)}.json`,
2261
+ );
2262
+ const oldDetail = await readJsonFile<any>(oldDetailPath);
2263
+
2264
+ if (!oldDetail) {
2265
+ return false;
2266
+ }
2267
+
2268
+ const message = await datasource.readMessage(messageKey);
2269
+ const messageTargets = sortStrings(
2270
+ targetKeys.filter((targetKey) => targetMessages[targetKey].includes(messageKey)),
2271
+ );
2272
+
2273
+ if (!sameStringList(sortStrings(oldDetail.targets || []), messageTargets)) {
2274
+ return false;
2275
+ }
2276
+
2277
+ const oldRelationshipFingerprint = getMessageRelationshipFingerprint(oldDetail.entity || {});
2278
+ const nextRelationshipFingerprint = getMessageRelationshipFingerprint(message);
2279
+
2280
+ if (
2281
+ !sameStringList(
2282
+ oldRelationshipFingerprint.attributes,
2283
+ nextRelationshipFingerprint.attributes,
2284
+ ) ||
2285
+ !sameStringList(oldRelationshipFingerprint.segments, nextRelationshipFingerprint.segments)
2286
+ ) {
2287
+ return false;
2288
+ }
2289
+
2290
+ const examples = await runtime.resolveExamples(projectConfig, datasource, {
2291
+ set: request.set,
2292
+ message: messageKey,
2293
+ onlyMessages: true,
2294
+ });
2295
+ const overrides = (message.overrides || []).map((override: Override) => {
2296
+ const attributes = new Set<string>();
2297
+ const overrideSegments = new Set<string>();
2298
+ collectAttributeKeysFromConditions(override.conditions, attributes);
2299
+ collectSegmentKeys(override.segments, overrideSegments);
2300
+
2301
+ return {
2302
+ ...override,
2303
+ usedAttributes: sortStrings(Array.from(attributes)),
2304
+ usedSegments: sortStrings(Array.from(overrideSegments)),
2305
+ };
2306
+ });
2307
+ const sourceFileInfo = getSourceFileInfo(
2308
+ session.repositorySourceRootDirectoryPath,
2309
+ rootDirectoryPath,
2310
+ projectConfig,
2311
+ "message",
2312
+ messageKey,
2313
+ { resolveAbsolutePath: session.devEditors.length > 0 },
2314
+ );
2315
+ const detail = {
2316
+ type: "message",
2317
+ key: messageKey,
2318
+ entity: { ...message, overrides },
2319
+ sourcePath: sourceFileInfo.sourcePath,
2320
+ editLinks: getEditorLinks(session.devEditors, sourceFileInfo),
2321
+ targets: messageTargets,
2322
+ localeKeys,
2323
+ localeDirections,
2324
+ translations: localeKeys.map((localeKey) =>
2325
+ resolveTranslationRow(message.translations, localeKey, locales),
2326
+ ),
2327
+ evaluatedExamples: examples.messages,
2328
+ overrideTranslations: overrides.map((override) => ({
2329
+ key: override.key,
2330
+ rows: localeKeys.map((localeKey) =>
2331
+ resolveTranslationRow(override.translations, localeKey, locales),
2332
+ ),
2333
+ })),
2334
+ lastModified: getLastModified(session.historyIndex, "message", messageKey, request.set),
2335
+ };
2336
+
2337
+ await writer.write(oldDetailPath, detail);
2338
+
2339
+ await writeHistoryPages(
2340
+ writer,
2341
+ path.join(dataDirectoryPath, "history", "message", encodeKey(messageKey)),
2342
+ getHistoryForEntity(session.historyIndex, "message", messageKey, request.set),
2343
+ { skipEmpty: true },
2344
+ );
2345
+
2346
+ const nextSummary = summarizeMessage(
2347
+ message,
2348
+ messageKey,
2349
+ session.historyIndex,
2350
+ request.set,
2351
+ messageTargets,
2352
+ );
2353
+ const existingSummaryIndex = index.entities.message.findIndex(
2354
+ (entry) => entry.key === messageKey,
2355
+ );
2356
+
2357
+ if (existingSummaryIndex === -1) {
2358
+ index.entities.message.push(nextSummary);
2359
+ } else {
2360
+ index.entities.message[existingSummaryIndex] = nextSummary;
2361
+ }
2362
+ }
2363
+
2364
+ index.entities.message.sort((left, right) => left.key.localeCompare(right.key));
2365
+ index.counts.message = messageKeys.length;
2366
+ await writer.write(indexPath, index);
2367
+
2368
+ return true;
2369
+ }
2370
+
2371
+ async function rebuildCatalogSetForDev(
2372
+ runtime: CatalogRuntime,
2373
+ rootDirectoryPath: string,
2374
+ projectConfig: any,
2375
+ datasource: any,
2376
+ session: CatalogDevSession,
2377
+ options: {
2378
+ set?: string;
2379
+ browserRouter: boolean;
2380
+ withTranslationSearch: boolean;
2381
+ withDuplicates: boolean;
2382
+ },
2383
+ ) {
2384
+ const writer = new CatalogJsonWriter();
2385
+ const progress = new CatalogProgressReporter(rootDirectoryPath, session.outputDirectoryPath);
2386
+ const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
2387
+ const setIndexes: Record<string, CatalogSetIndex> = {};
2388
+ const existingIndexes = await Promise.all(
2389
+ executions.map(async (execution) => {
2390
+ const indexPath = path.join(
2391
+ session.outputDirectoryPath,
2392
+ "data",
2393
+ getOutputRelativeDirectory(projectConfig, execution.set),
2394
+ "index.json",
2395
+ );
2396
+ return [execution.set || "root", await readJsonFile<CatalogSetIndex>(indexPath)] as const;
2397
+ }),
2398
+ );
2399
+
2400
+ for (const [key, index] of existingIndexes) {
2401
+ if (index) {
2402
+ setIndexes[key] = index;
2403
+ }
2404
+ }
2405
+
2406
+ const execution = executions.find((item) => (item.set || undefined) === options.set);
2407
+
2408
+ if (!execution) {
2409
+ return false;
2410
+ }
2411
+
2412
+ const outputRelativeDirectory = getOutputRelativeDirectory(projectConfig, execution.set);
2413
+ await fs.promises.rm(path.join(session.outputDirectoryPath, "data", outputRelativeDirectory), {
2414
+ recursive: true,
2415
+ force: true,
2416
+ });
2417
+
2418
+ const context: CatalogBuildContext = {
2419
+ rootDirectoryPath,
2420
+ repositoryRootDirectoryPath: session.repositoryRootDirectoryPath,
2421
+ repositorySourceRootDirectoryPath: session.repositorySourceRootDirectoryPath,
2422
+ outputDirectoryPath: session.outputDirectoryPath,
2423
+ dataDirectoryPath: path.join(session.outputDirectoryPath, "data"),
2424
+ historyIndex: session.historyIndex,
2425
+ runtime,
2426
+ devEditors: session.devEditors,
2427
+ duplicateResultsBySet: {},
2428
+ withTranslationSearch: options.withTranslationSearch,
2429
+ withDuplicates: options.withDuplicates,
2430
+ progress,
2431
+ writer,
2432
+ };
2433
+
2434
+ setIndexes[execution.set || "root"] = await buildSetCatalog(
2435
+ context,
2436
+ execution.set,
2437
+ execution.projectConfig,
2438
+ execution.datasource,
2439
+ outputRelativeDirectory,
2440
+ );
2441
+
2442
+ await writeCatalogManifest(writer, rootDirectoryPath, projectConfig, session, {
2443
+ browserRouter: options.browserRouter,
2444
+ withTranslationSearch: options.withTranslationSearch,
2445
+ withDuplicates: options.withDuplicates,
2446
+ setIndexes,
2447
+ executions,
2448
+ });
2449
+
2450
+ return true;
2451
+ }
2452
+
1980
2453
  export async function exportCatalog(
1981
2454
  runtime: CatalogRuntime,
1982
2455
  rootDirectoryPath: string,
@@ -2003,7 +2476,11 @@ export async function exportCatalog(
2003
2476
  });
2004
2477
 
2005
2478
  let stepStartedAt = progress.step("Preparing output directory");
2006
- await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
2479
+ if (options.preserveAssets) {
2480
+ await fs.promises.rm(dataDirectoryPath, { recursive: true, force: true });
2481
+ } else {
2482
+ await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
2483
+ }
2007
2484
  await fs.promises.mkdir(dataDirectoryPath, { recursive: true });
2008
2485
  progress.done(stepStartedAt);
2009
2486
 
@@ -2013,13 +2490,17 @@ export async function exportCatalog(
2013
2490
  progress.done(stepStartedAt);
2014
2491
  }
2015
2492
 
2016
- const devEditors = options.dev ? options.devEditors || detectDevEditors() : [];
2493
+ const devEditors = options.dev
2494
+ ? options.devSession?.devEditors || options.devEditors || detectDevEditors()
2495
+ : [];
2017
2496
  stepStartedAt = progress.step("Reading Git history");
2018
- const historyIndex = await getGitHistoryIndex(rootDirectoryPath, projectConfig);
2497
+ const historyIndex =
2498
+ options.devSession?.historyIndex ||
2499
+ (await getGitHistoryIndex(rootDirectoryPath, projectConfig));
2019
2500
  progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "commit")})`);
2020
2501
 
2021
2502
  stepStartedAt = progress.step("Resolving repository links");
2022
- const links = getRepoLinks(rootDirectoryPath);
2503
+ const links = options.devSession?.links || getRepoLinks(rootDirectoryPath);
2023
2504
  progress.done(stepStartedAt);
2024
2505
 
2025
2506
  let duplicateResultsBySet: Record<string, CatalogDuplicateTranslationsSetResult> = {};
@@ -2050,8 +2531,12 @@ export async function exportCatalog(
2050
2531
 
2051
2532
  const context: CatalogBuildContext = {
2052
2533
  rootDirectoryPath,
2053
- repositoryRootDirectoryPath: getRepositoryRootDirectoryPath(rootDirectoryPath),
2054
- repositorySourceRootDirectoryPath: getRepositorySourceRootDirectoryPath(rootDirectoryPath),
2534
+ repositoryRootDirectoryPath:
2535
+ options.devSession?.repositoryRootDirectoryPath ||
2536
+ getRepositoryRootDirectoryPath(rootDirectoryPath),
2537
+ repositorySourceRootDirectoryPath:
2538
+ options.devSession?.repositorySourceRootDirectoryPath ||
2539
+ getRepositorySourceRootDirectoryPath(rootDirectoryPath),
2055
2540
  outputDirectoryPath,
2056
2541
  dataDirectoryPath,
2057
2542
  historyIndex,
@@ -2177,11 +2662,34 @@ function injectCatalogLiveReloadClient(html: string) {
2177
2662
  return `${html}${script}`;
2178
2663
  }
2179
2664
 
2180
- function createProjectWatcher(
2665
+ function getCatalogInputWatchPaths(rootDirectoryPath: string, projectConfig: any) {
2666
+ const paths = [path.join(rootDirectoryPath, "messagevisor.config.js")];
2667
+
2668
+ if (projectConfig.sets) {
2669
+ paths.push(projectConfig.setsDirectoryPath);
2670
+ return paths;
2671
+ }
2672
+
2673
+ paths.push(
2674
+ projectConfig.localesDirectoryPath,
2675
+ projectConfig.messagesDirectoryPath,
2676
+ projectConfig.attributesDirectoryPath,
2677
+ projectConfig.segmentsDirectoryPath,
2678
+ projectConfig.targetsDirectoryPath,
2679
+ projectConfig.testsDirectoryPath,
2680
+ );
2681
+
2682
+ return paths.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
2683
+ }
2684
+
2685
+ function createCatalogInputWatcher(
2181
2686
  rootDirectoryPath: string,
2687
+ projectConfig: any,
2182
2688
  ignoredDirectoryPaths: string[],
2183
- onChange: (changedPath: string) => void,
2689
+ onChange: (changedPaths: string[]) => void,
2184
2690
  ) {
2691
+ const watchPaths = getCatalogInputWatchPaths(rootDirectoryPath, projectConfig);
2692
+
2185
2693
  function shouldIgnore(targetPath: string) {
2186
2694
  const resolvedTargetPath = path.resolve(targetPath);
2187
2695
 
@@ -2195,7 +2703,24 @@ function createProjectWatcher(
2195
2703
  });
2196
2704
  }
2197
2705
 
2198
- function collectSnapshotEntries(directoryPath: string, snapshotEntries: string[]) {
2706
+ function shouldWatch(targetPath: string) {
2707
+ const resolvedTargetPath = path.resolve(targetPath);
2708
+
2709
+ if (shouldIgnore(resolvedTargetPath)) {
2710
+ return false;
2711
+ }
2712
+
2713
+ return watchPaths.some((watchPath) => {
2714
+ const resolvedWatchPath = path.resolve(watchPath);
2715
+
2716
+ return (
2717
+ resolvedTargetPath === resolvedWatchPath ||
2718
+ resolvedTargetPath.startsWith(`${resolvedWatchPath}${path.sep}`)
2719
+ );
2720
+ });
2721
+ }
2722
+
2723
+ function collectSnapshotEntries(directoryPath: string, snapshotEntries: Map<string, string>) {
2199
2724
  if (shouldIgnore(directoryPath)) {
2200
2725
  return;
2201
2726
  }
@@ -2226,8 +2751,7 @@ function createProjectWatcher(
2226
2751
 
2227
2752
  try {
2228
2753
  const stat = fs.statSync(entryPath);
2229
- const relativePath = path.relative(rootDirectoryPath, entryPath);
2230
- snapshotEntries.push(`${relativePath}:${stat.size}:${stat.mtimeMs}`);
2754
+ snapshotEntries.set(entryPath, `${stat.size}:${stat.mtimeMs}`);
2231
2755
  } catch {
2232
2756
  // Ignore transient editor save races.
2233
2757
  }
@@ -2235,26 +2759,108 @@ function createProjectWatcher(
2235
2759
  }
2236
2760
 
2237
2761
  function createSnapshot() {
2238
- const snapshotEntries: string[] = [];
2239
- collectSnapshotEntries(rootDirectoryPath, snapshotEntries);
2240
- snapshotEntries.sort();
2241
- return snapshotEntries.join("|");
2762
+ const snapshotEntries = new Map<string, string>();
2763
+
2764
+ for (const watchPath of watchPaths) {
2765
+ if (!fs.existsSync(watchPath)) {
2766
+ continue;
2767
+ }
2768
+
2769
+ const stat = fs.statSync(watchPath);
2770
+
2771
+ if (stat.isFile()) {
2772
+ snapshotEntries.set(watchPath, `${stat.size}:${stat.mtimeMs}`);
2773
+ continue;
2774
+ }
2775
+
2776
+ collectSnapshotEntries(watchPath, snapshotEntries);
2777
+ }
2778
+
2779
+ return snapshotEntries;
2242
2780
  }
2243
2781
 
2244
- let previousSnapshot = createSnapshot();
2245
- const interval = setInterval(() => {
2246
- const nextSnapshot = createSnapshot();
2782
+ function getSnapshotChanges(previous: Map<string, string>, next: Map<string, string>) {
2783
+ const changedPaths = new Set<string>();
2247
2784
 
2248
- if (nextSnapshot === previousSnapshot) {
2249
- return;
2785
+ for (const [filePath, signature] of Array.from(next.entries())) {
2786
+ if (previous.get(filePath) !== signature) {
2787
+ changedPaths.add(filePath);
2788
+ }
2789
+ }
2790
+
2791
+ for (const filePath of Array.from(previous.keys())) {
2792
+ if (!next.has(filePath)) {
2793
+ changedPaths.add(filePath);
2794
+ }
2250
2795
  }
2251
2796
 
2252
- previousSnapshot = nextSnapshot;
2253
- onChange(rootDirectoryPath);
2254
- }, 250);
2797
+ return Array.from(changedPaths);
2798
+ }
2799
+
2800
+ function createPollingWatcher() {
2801
+ let previousSnapshot = createSnapshot();
2802
+ const interval = setInterval(() => {
2803
+ const nextSnapshot = createSnapshot();
2804
+ const changedPaths = getSnapshotChanges(previousSnapshot, nextSnapshot).filter(shouldWatch);
2805
+
2806
+ previousSnapshot = nextSnapshot;
2807
+
2808
+ if (changedPaths.length === 0) {
2809
+ return;
2810
+ }
2811
+
2812
+ onChange(changedPaths);
2813
+ }, 1000);
2814
+
2815
+ return () => {
2816
+ clearInterval(interval);
2817
+ };
2818
+ }
2819
+
2820
+ const watchers: fs.FSWatcher[] = [];
2821
+ let nativeWatcherFailed = false;
2822
+
2823
+ for (const watchPath of watchPaths) {
2824
+ if (!fs.existsSync(watchPath)) {
2825
+ continue;
2826
+ }
2827
+
2828
+ try {
2829
+ const stat = fs.statSync(watchPath);
2830
+ const directoryPath = stat.isDirectory() ? watchPath : path.dirname(watchPath);
2831
+ const watcher = fs.watch(
2832
+ directoryPath,
2833
+ { recursive: stat.isDirectory() },
2834
+ (_eventType, filename) => {
2835
+ const changedPath = filename
2836
+ ? path.resolve(directoryPath, filename.toString())
2837
+ : directoryPath;
2838
+
2839
+ if (shouldWatch(changedPath)) {
2840
+ onChange([changedPath]);
2841
+ }
2842
+ },
2843
+ );
2844
+
2845
+ watchers.push(watcher);
2846
+ } catch (_error) {
2847
+ nativeWatcherFailed = true;
2848
+ break;
2849
+ }
2850
+ }
2851
+
2852
+ if (nativeWatcherFailed || watchers.length === 0) {
2853
+ for (const watcher of watchers) {
2854
+ watcher.close();
2855
+ }
2856
+
2857
+ return createPollingWatcher();
2858
+ }
2255
2859
 
2256
2860
  return () => {
2257
- clearInterval(interval);
2861
+ for (const watcher of watchers) {
2862
+ watcher.close();
2863
+ }
2258
2864
  };
2259
2865
  }
2260
2866
 
@@ -2405,6 +3011,11 @@ function isWithDuplicatesEnabled(parsed: CatalogPluginParsedOptions) {
2405
3011
  return parsed.withDuplicates === true || parsed["with-duplicates"] === true;
2406
3012
  }
2407
3013
 
3014
+ export const __catalogDevInternals = {
3015
+ classifyCatalogDevChanges,
3016
+ getCatalogInputWatchPaths,
3017
+ };
3018
+
2408
3019
  export function createCatalogPlugin(
2409
3020
  runtime: CatalogRuntime,
2410
3021
  api: ReturnType<typeof createCatalogApi> = createCatalogApi(runtime),
@@ -2418,11 +3029,18 @@ export function createCatalogPlugin(
2418
3029
  const withDuplicates = isWithDuplicatesEnabled(parsed);
2419
3030
 
2420
3031
  if (!parsed.subcommand) {
3032
+ const outputDirectoryPath = parsed.outDir
3033
+ ? path.resolve(rootDirectoryPath, parsed.outDir)
3034
+ : projectConfig.catalogDirectoryPath;
3035
+ const devSession = await createCatalogDevSession(rootDirectoryPath, projectConfig, {
3036
+ outDir: parsed.outDir,
3037
+ });
2421
3038
  await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
2422
3039
  outDir: parsed.outDir,
2423
3040
  copyAssets: !parsed.noAssets,
2424
3041
  browserRouter,
2425
3042
  dev: true,
3043
+ devSession,
2426
3044
  withTranslationSearch,
2427
3045
  withDuplicates,
2428
3046
  });
@@ -2433,9 +3051,6 @@ export function createCatalogPlugin(
2433
3051
  liveReload: true,
2434
3052
  });
2435
3053
 
2436
- const outputDirectoryPath = parsed.outDir
2437
- ? path.resolve(rootDirectoryPath, parsed.outDir)
2438
- : projectConfig.catalogDirectoryPath;
2439
3054
  const ignoredDirectoryPaths = [
2440
3055
  path.join(rootDirectoryPath, ".git"),
2441
3056
  path.join(rootDirectoryPath, "node_modules"),
@@ -2447,29 +3062,77 @@ export function createCatalogPlugin(
2447
3062
  outputDirectoryPath,
2448
3063
  ];
2449
3064
  let exportInFlight = false;
2450
- let exportQueued = false;
2451
- let queuedReason: string | null = null;
3065
+ let queuedChanges: string[] = [];
3066
+ let pendingChanges: string[] = [];
2452
3067
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
2453
3068
 
2454
- const runExportAndReload = async (reason: string) => {
3069
+ const runRebuildAndReload = async (changedPaths: string[]) => {
2455
3070
  if (exportInFlight) {
2456
- exportQueued = true;
2457
- queuedReason = queuedReason || reason;
3071
+ queuedChanges.push(...changedPaths);
2458
3072
  return;
2459
3073
  }
2460
3074
 
2461
3075
  exportInFlight = true;
2462
- console.log(`\n[catalog] Re-exporting because ${reason}`);
2463
-
2464
- try {
2465
- await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
2466
- outDir: parsed.outDir,
2467
- copyAssets: !parsed.noAssets,
2468
- browserRouter,
2469
- dev: true,
3076
+ const request = classifyCatalogDevChanges(
3077
+ rootDirectoryPath,
3078
+ projectConfig,
3079
+ changedPaths,
3080
+ {
2470
3081
  withTranslationSearch,
2471
3082
  withDuplicates,
2472
- });
3083
+ },
3084
+ );
3085
+ console.log(`\n[catalog] Rebuilding (${request.kind}) because ${request.reason}`);
3086
+
3087
+ try {
3088
+ let handled = false;
3089
+
3090
+ if (request.kind === "message") {
3091
+ const [execution] = await runtime.getProjectSetExecutions(
3092
+ projectConfig,
3093
+ datasource,
3094
+ request.set,
3095
+ );
3096
+ handled = await tryRebuildCatalogMessage(
3097
+ runtime,
3098
+ rootDirectoryPath,
3099
+ projectConfig,
3100
+ execution.projectConfig,
3101
+ execution.datasource,
3102
+ devSession,
3103
+ request,
3104
+ );
3105
+ }
3106
+
3107
+ if (!handled && request.kind === "set" && request.set) {
3108
+ handled = await rebuildCatalogSetForDev(
3109
+ runtime,
3110
+ rootDirectoryPath,
3111
+ projectConfig,
3112
+ datasource,
3113
+ devSession,
3114
+ {
3115
+ set: request.set,
3116
+ browserRouter,
3117
+ withTranslationSearch,
3118
+ withDuplicates,
3119
+ },
3120
+ );
3121
+ }
3122
+
3123
+ if (!handled) {
3124
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
3125
+ outDir: parsed.outDir,
3126
+ copyAssets: false,
3127
+ preserveAssets: true,
3128
+ browserRouter,
3129
+ dev: true,
3130
+ devSession,
3131
+ withTranslationSearch,
3132
+ withDuplicates,
3133
+ });
3134
+ }
3135
+
2473
3136
  server.triggerReload();
2474
3137
  } catch (error) {
2475
3138
  console.error("[catalog] Export failed during watch mode");
@@ -2477,28 +3140,30 @@ export function createCatalogPlugin(
2477
3140
  } finally {
2478
3141
  exportInFlight = false;
2479
3142
 
2480
- if (exportQueued) {
2481
- const nextReason = queuedReason || "more project changes";
2482
- exportQueued = false;
2483
- queuedReason = null;
2484
- void runExportAndReload(nextReason);
3143
+ if (queuedChanges.length > 0) {
3144
+ const nextChanges = queuedChanges;
3145
+ queuedChanges = [];
3146
+ void runRebuildAndReload(nextChanges);
2485
3147
  }
2486
3148
  }
2487
3149
  };
2488
3150
 
2489
- const stopWatchingProject = createProjectWatcher(
3151
+ const stopWatchingProject = createCatalogInputWatcher(
2490
3152
  rootDirectoryPath,
3153
+ projectConfig,
2491
3154
  ignoredDirectoryPaths,
2492
- (changedPath) => {
2493
- const reason = `project change in ${path.relative(rootDirectoryPath, changedPath) || "."}`;
3155
+ (changedPaths) => {
3156
+ pendingChanges.push(...changedPaths);
2494
3157
 
2495
3158
  if (debounceTimer) {
2496
3159
  clearTimeout(debounceTimer);
2497
3160
  }
2498
3161
  debounceTimer = setTimeout(() => {
3162
+ const nextChanges = Array.from(new Set(pendingChanges));
3163
+ pendingChanges = [];
2499
3164
  debounceTimer = null;
2500
- void runExportAndReload(reason);
2501
- }, 150);
3165
+ void runRebuildAndReload(nextChanges);
3166
+ }, 250);
2502
3167
  },
2503
3168
  );
2504
3169