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