@schalkneethling/miyagi-core 4.4.0 → 4.4.2

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.
@@ -22,10 +22,10 @@ import setViews from "./views.js";
22
22
  const SOCKET_PATH = "/__miyagi_ws";
23
23
  const sockets = new Set();
24
24
  const DEFAULT_RELOAD_MESSAGE = {
25
- type: "reload",
26
- scope: "iframe",
27
- reason: "change",
28
- paths: [],
25
+ type: "reload",
26
+ scope: "iframe",
27
+ reason: "change",
28
+ paths: [],
29
29
  };
30
30
  const RELOAD_SCOPES = new Set(["none", "iframe", "parent"]);
31
31
  let restartFileWatcher = null;
@@ -35,22 +35,22 @@ let restartFileWatcher = null;
35
35
  * @returns {number}
36
36
  */
37
37
  function getEventPriority(eventType) {
38
- // Higher value = stronger event for the same path during burst coalescing.
39
- // Example: if a file emits "change" then "unlink", we keep "unlink".
40
- switch (eventType) {
41
- case "unlinkDir":
42
- return 5;
43
- case "unlink":
44
- return 4;
45
- case "addDir":
46
- return 3;
47
- case "add":
48
- return 2;
49
- case "change":
50
- return 1;
51
- default:
52
- return 0;
53
- }
38
+ // Higher value = stronger event for the same path during burst coalescing.
39
+ // Example: if a file emits "change" then "unlink", we keep "unlink".
40
+ switch (eventType) {
41
+ case "unlinkDir":
42
+ return 5;
43
+ case "unlink":
44
+ return 4;
45
+ case "addDir":
46
+ return 3;
47
+ case "add":
48
+ return 2;
49
+ case "change":
50
+ return 1;
51
+ default:
52
+ return 0;
53
+ }
54
54
  }
55
55
 
56
56
  /**
@@ -58,8 +58,8 @@ function getEventPriority(eventType) {
58
58
  * @returns {string}
59
59
  */
60
60
  function normalizeRelativePath(inputPath) {
61
- const absolutePath = path.resolve(inputPath);
62
- return path.relative(process.cwd(), absolutePath);
61
+ const absolutePath = path.resolve(inputPath);
62
+ return path.relative(process.cwd(), absolutePath);
63
63
  }
64
64
 
65
65
  /**
@@ -67,11 +67,11 @@ function normalizeRelativePath(inputPath) {
67
67
  * @returns {string}
68
68
  */
69
69
  function resolveSourcePath(source) {
70
- if (path.isAbsolute(source.path)) {
71
- return source.path;
72
- }
70
+ if (path.isAbsolute(source.path)) {
71
+ return source.path;
72
+ }
73
73
 
74
- return path.resolve(process.cwd(), source.path);
74
+ return path.resolve(process.cwd(), source.path);
75
75
  }
76
76
 
77
77
  /**
@@ -79,27 +79,28 @@ function resolveSourcePath(source) {
79
79
  * @param {object} [payload]
80
80
  */
81
81
  function sendReload(scope, payload = {}) {
82
- const normalizedScope = RELOAD_SCOPES.has(scope) ? scope : "parent";
83
- const reloadEnabled = global.config.watch?.reload?.enabled ?? global.config.ui.reload;
84
-
85
- if (!reloadEnabled || normalizedScope === "none") {
86
- return;
87
- }
88
-
89
- const message = JSON.stringify({
90
- ...DEFAULT_RELOAD_MESSAGE,
91
- ...payload,
92
- scope: normalizedScope,
93
- });
94
-
95
- for (const ws of sockets) {
96
- if (ws.readyState !== 1) {
97
- sockets.delete(ws);
98
- continue;
99
- }
100
-
101
- ws.send(message);
102
- }
82
+ const normalizedScope = RELOAD_SCOPES.has(scope) ? scope : "parent";
83
+ const reloadEnabled =
84
+ global.config.watch?.reload?.enabled ?? global.config.ui.reload;
85
+
86
+ if (!reloadEnabled || normalizedScope === "none") {
87
+ return;
88
+ }
89
+
90
+ const message = JSON.stringify({
91
+ ...DEFAULT_RELOAD_MESSAGE,
92
+ ...payload,
93
+ scope: normalizedScope,
94
+ });
95
+
96
+ for (const ws of sockets) {
97
+ if (ws.readyState !== 1) {
98
+ sockets.delete(ws);
99
+ continue;
100
+ }
101
+
102
+ ws.send(message);
103
+ }
103
104
  }
104
105
 
105
106
  /**
@@ -109,19 +110,19 @@ function sendReload(scope, payload = {}) {
109
110
  * @returns {string}
110
111
  */
111
112
  function colorize(color, value, useColors) {
112
- if (!useColors) {
113
- return value;
114
- }
115
-
116
- const colors = {
117
- cyan: "\x1b[36m",
118
- green: "\x1b[32m",
119
- yellow: "\x1b[33m",
120
- grey: "\x1b[90m",
121
- reset: "\x1b[0m",
122
- };
123
-
124
- return `${colors[color] || ""}${value}${colors.reset}`;
113
+ if (!useColors) {
114
+ return value;
115
+ }
116
+
117
+ const colors = {
118
+ cyan: "\x1b[36m",
119
+ green: "\x1b[32m",
120
+ yellow: "\x1b[33m",
121
+ grey: "\x1b[90m",
122
+ reset: "\x1b[0m",
123
+ };
124
+
125
+ return `${colors[color] || ""}${value}${colors.reset}`;
125
126
  }
126
127
 
127
128
  /**
@@ -129,65 +130,67 @@ function colorize(color, value, useColors) {
129
130
  * @param {object} watchConfig
130
131
  */
131
132
  function printWatchReport(report, watchConfig) {
132
- const reportConfig = watchConfig?.report || {};
133
- if (!reportConfig.enabled || !reportConfig.onStart) {
134
- return;
135
- }
136
-
137
- if (reportConfig.format === "json") {
138
- console.info(JSON.stringify(report));
139
- return;
140
- }
141
-
142
- const useColors = reportConfig.useColors && process.stdout.isTTY;
143
-
144
- if (reportConfig.format === "summary") {
145
- console.info(
146
- `${colorize("cyan", "Watch report:", useColors)} backend=${report.backend
147
- } sources=${report.meta.sourceCount} ignores=${report.meta.ignoreCount}`,
148
- );
149
- return;
150
- }
151
-
152
- console.info(colorize("cyan", "\nWatch report", useColors));
153
- console.info(
154
- ` ${colorize("grey", "Watcher backend:", useColors)} ${colorize(
155
- "green",
156
- report.backend,
157
- useColors,
158
- )}`,
159
- );
160
- console.info(
161
- ` ${colorize("grey", "Diagnostics:", useColors)} sources=${report.meta.sourceCount
162
- }, ignores=${report.meta.ignoreCount}`,
163
- );
164
-
165
- if (watchConfig?.debug?.logResolvedSources) {
166
- console.info(` ${colorize("grey", "Resolved sources:", useColors)}`);
167
-
168
- for (const source of report.sources) {
169
- const stateLabel = source.exists ? "exists" : "missing";
170
- const stateColor = source.exists ? "green" : "yellow";
171
- console.info(
172
- ` - ${source.id} (${source.type}) ${source.resolvedPath} [${colorize(
173
- stateColor,
174
- stateLabel,
175
- useColors,
176
- )}]`,
177
- );
178
- }
179
- }
180
-
181
- console.info(` ${colorize("grey", "Ignored patterns:", useColors)}`);
182
- for (const pattern of report.ignore.patterns) {
183
- console.info(` - ${pattern}`);
184
- }
185
-
186
- console.info(` ${colorize("grey", "Reload rules:", useColors)}`);
187
- for (const [key, scope] of Object.entries(report.reload.rules)) {
188
- console.info(` - ${key}: ${scope}`);
189
- }
190
- console.info("");
133
+ const reportConfig = watchConfig?.report || {};
134
+ if (!reportConfig.enabled || !reportConfig.onStart) {
135
+ return;
136
+ }
137
+
138
+ if (reportConfig.format === "json") {
139
+ console.info(JSON.stringify(report));
140
+ return;
141
+ }
142
+
143
+ const useColors = reportConfig.useColors && process.stdout.isTTY;
144
+
145
+ if (reportConfig.format === "summary") {
146
+ console.info(
147
+ `${colorize("cyan", "Watch report:", useColors)} backend=${
148
+ report.backend
149
+ } sources=${report.meta.sourceCount} ignores=${report.meta.ignoreCount}`,
150
+ );
151
+ return;
152
+ }
153
+
154
+ console.info(colorize("cyan", "\nWatch report", useColors));
155
+ console.info(
156
+ ` ${colorize("grey", "Watcher backend:", useColors)} ${colorize(
157
+ "green",
158
+ report.backend,
159
+ useColors,
160
+ )}`,
161
+ );
162
+ console.info(
163
+ ` ${colorize("grey", "Diagnostics:", useColors)} sources=${
164
+ report.meta.sourceCount
165
+ }, ignores=${report.meta.ignoreCount}`,
166
+ );
167
+
168
+ if (watchConfig?.debug?.logResolvedSources) {
169
+ console.info(` ${colorize("grey", "Resolved sources:", useColors)}`);
170
+
171
+ for (const source of report.sources) {
172
+ const stateLabel = source.exists ? "exists" : "missing";
173
+ const stateColor = source.exists ? "green" : "yellow";
174
+ console.info(
175
+ ` - ${source.id} (${source.type}) ${source.resolvedPath} [${colorize(
176
+ stateColor,
177
+ stateLabel,
178
+ useColors,
179
+ )}]`,
180
+ );
181
+ }
182
+ }
183
+
184
+ console.info(` ${colorize("grey", "Ignored patterns:", useColors)}`);
185
+ for (const pattern of report.ignore.patterns) {
186
+ console.info(` - ${pattern}`);
187
+ }
188
+
189
+ console.info(` ${colorize("grey", "Reload rules:", useColors)}`);
190
+ for (const [key, scope] of Object.entries(report.reload.rules)) {
191
+ console.info(` - ${key}: ${scope}`);
192
+ }
193
+ console.info("");
191
194
  }
192
195
 
193
196
  /**
@@ -195,32 +198,32 @@ function printWatchReport(report, watchConfig) {
195
198
  * @returns {object[]}
196
199
  */
197
200
  function getExtensionSources(watchConfig) {
198
- const extensionSources = [];
199
-
200
- for (const extension of global.config.extensions) {
201
- const ext = Array.isArray(extension) ? extension[0] : extension;
202
- const opts =
203
- Array.isArray(extension) && extension[1] ? extension[1] : { locales: {} };
204
-
205
- if (!ext.extendWatcher) {
206
- continue;
207
- }
208
-
209
- const extensionWatch = ext.extendWatcher(opts);
210
- if (!extensionWatch?.folder || !extensionWatch?.lang) {
211
- continue;
212
- }
213
-
214
- extensionSources.push({
215
- id: `extension-${extensionWatch.lang}`,
216
- type: "dir",
217
- path: path.join(extensionWatch.folder, extensionWatch.lang),
218
- recursive: true,
219
- optional: true,
220
- });
221
- }
222
-
223
- return [...(watchConfig.sources || []), ...extensionSources];
201
+ const extensionSources = [];
202
+
203
+ for (const extension of global.config.extensions) {
204
+ const ext = Array.isArray(extension) ? extension[0] : extension;
205
+ const opts =
206
+ Array.isArray(extension) && extension[1] ? extension[1] : { locales: {} };
207
+
208
+ if (!ext.extendWatcher) {
209
+ continue;
210
+ }
211
+
212
+ const extensionWatch = ext.extendWatcher(opts);
213
+ if (!extensionWatch?.folder || !extensionWatch?.lang) {
214
+ continue;
215
+ }
216
+
217
+ extensionSources.push({
218
+ id: `extension-${extensionWatch.lang}`,
219
+ type: "dir",
220
+ path: path.join(extensionWatch.folder, extensionWatch.lang),
221
+ recursive: true,
222
+ optional: true,
223
+ });
224
+ }
225
+
226
+ return [...(watchConfig.sources || []), ...extensionSources];
224
227
  }
225
228
 
226
229
  /**
@@ -228,54 +231,54 @@ function getExtensionSources(watchConfig) {
228
231
  * @returns {object}
229
232
  */
230
233
  function resolveWatchTargets(watchConfig) {
231
- const sources = getExtensionSources(watchConfig);
232
- const ignorePatterns = [
233
- ...(watchConfig.ignore?.defaults ? ["node_modules/**", ".git/**"] : []),
234
- ...(watchConfig.ignore?.patterns || []),
235
- ];
236
- const reportSources = [];
237
- const targets = [];
238
-
239
- for (const source of sources) {
240
- if (!source || typeof source.path !== "string") continue;
241
- const resolvedPath = resolveSourcePath(source);
242
- const exists = fs.existsSync(resolvedPath);
243
-
244
- reportSources.push({
245
- id: source.id,
246
- type: source.type,
247
- inputPath: source.path,
248
- resolvedPath,
249
- exists,
250
- recursive: source.recursive !== false,
251
- optional: source.optional === true,
252
- ignored: anymatch(ignorePatterns, normalizeRelativePath(resolvedPath)),
253
- });
254
-
255
- if (exists) {
256
- targets.push(resolvedPath);
257
- }
258
- }
259
-
260
- return {
261
- targets: [...new Set(targets)].sort(),
262
- ignorePatterns,
263
- report: {
264
- backend: "chokidar",
265
- sources: reportSources.sort((a, b) => a.id.localeCompare(b.id)),
266
- ignore: {
267
- defaultsEnabled: Boolean(watchConfig.ignore?.defaults),
268
- patterns: [...new Set(ignorePatterns)].sort(),
269
- },
270
- reload: {
271
- rules: watchConfig.reload.rules,
272
- },
273
- meta: {
274
- sourceCount: reportSources.length,
275
- ignoreCount: [...new Set(ignorePatterns)].length,
276
- },
277
- },
278
- };
234
+ const sources = getExtensionSources(watchConfig);
235
+ const ignorePatterns = [
236
+ ...(watchConfig.ignore?.defaults ? ["node_modules/**", ".git/**"] : []),
237
+ ...(watchConfig.ignore?.patterns || []),
238
+ ];
239
+ const reportSources = [];
240
+ const targets = [];
241
+
242
+ for (const source of sources) {
243
+ if (!source || typeof source.path !== "string") continue;
244
+ const resolvedPath = resolveSourcePath(source);
245
+ const exists = fs.existsSync(resolvedPath);
246
+
247
+ reportSources.push({
248
+ id: source.id,
249
+ type: source.type,
250
+ inputPath: source.path,
251
+ resolvedPath,
252
+ exists,
253
+ recursive: source.recursive !== false,
254
+ optional: source.optional === true,
255
+ ignored: anymatch(ignorePatterns, normalizeRelativePath(resolvedPath)),
256
+ });
257
+
258
+ if (exists) {
259
+ targets.push(resolvedPath);
260
+ }
261
+ }
262
+
263
+ return {
264
+ targets: [...new Set(targets)].sort(),
265
+ ignorePatterns,
266
+ report: {
267
+ backend: "chokidar",
268
+ sources: reportSources.sort((a, b) => a.id.localeCompare(b.id)),
269
+ ignore: {
270
+ defaultsEnabled: Boolean(watchConfig.ignore?.defaults),
271
+ patterns: [...new Set(ignorePatterns)].sort(),
272
+ },
273
+ reload: {
274
+ rules: watchConfig.reload.rules,
275
+ },
276
+ meta: {
277
+ sourceCount: reportSources.length,
278
+ ignoreCount: [...new Set(ignorePatterns)].length,
279
+ },
280
+ },
281
+ };
279
282
  }
280
283
 
281
284
  /**
@@ -283,39 +286,39 @@ function resolveWatchTargets(watchConfig) {
283
286
  * @returns {Promise<object>}
284
287
  */
285
288
  async function updateFileContents(events) {
286
- const data = helpers.cloneDeep(global.state.fileContents);
287
-
288
- try {
289
- await Promise.all(
290
- events.map(async ({ changedPath, relativePath }) => {
291
- const fullPath = path.resolve(changedPath);
292
-
293
- if (
294
- fs.existsSync(fullPath) &&
295
- fs.lstatSync(fullPath).isFile() &&
296
- (helpers.fileIsTemplateFile(relativePath) ||
297
- helpers.fileIsDataFile(relativePath) ||
298
- helpers.fileIsDocumentationFile(relativePath) ||
299
- helpers.fileIsSchemaFile(relativePath))
300
- ) {
301
- try {
302
- const result = await readFile(fullPath);
303
- data[fullPath] = result;
304
- return Promise.resolve();
305
- } catch (err) {
306
- return Promise.reject(err.message);
307
- }
308
- } else {
309
- delete data[fullPath];
310
- return Promise.resolve();
311
- }
312
- }),
313
- );
314
-
315
- return data;
316
- } catch (err) {
317
- log("error", err);
318
- }
289
+ const data = helpers.cloneDeep(global.state.fileContents);
290
+
291
+ try {
292
+ await Promise.all(
293
+ events.map(async ({ changedPath, relativePath }) => {
294
+ const fullPath = path.resolve(changedPath);
295
+
296
+ if (
297
+ fs.existsSync(fullPath) &&
298
+ fs.lstatSync(fullPath).isFile() &&
299
+ (helpers.fileIsTemplateFile(relativePath) ||
300
+ helpers.fileIsDataFile(relativePath) ||
301
+ helpers.fileIsDocumentationFile(relativePath) ||
302
+ helpers.fileIsSchemaFile(relativePath))
303
+ ) {
304
+ try {
305
+ const result = await readFile(fullPath);
306
+ data[fullPath] = result;
307
+ return Promise.resolve();
308
+ } catch (err) {
309
+ return Promise.reject(err.message);
310
+ }
311
+ } else {
312
+ delete data[fullPath];
313
+ return Promise.resolve();
314
+ }
315
+ }),
316
+ );
317
+
318
+ return data;
319
+ } catch (err) {
320
+ log("error", err);
321
+ }
319
322
  }
320
323
 
321
324
  /**
@@ -323,7 +326,7 @@ async function updateFileContents(events) {
323
326
  * @returns {boolean}
324
327
  */
325
328
  function pathExistsAsDirectory(changedPath) {
326
- return fs.existsSync(changedPath) && fs.lstatSync(changedPath).isDirectory();
329
+ return fs.existsSync(changedPath) && fs.lstatSync(changedPath).isDirectory();
327
330
  }
328
331
 
329
332
  /**
@@ -331,248 +334,261 @@ function pathExistsAsDirectory(changedPath) {
331
334
  * @returns {Promise<void>}
332
335
  */
333
336
  async function handleFileChange(events) {
334
- for (const extension of global.config.extensions) {
335
- const ext = Array.isArray(extension) ? extension[0] : extension;
336
- const opts =
337
- Array.isArray(extension) && extension[1] ? extension[1] : { locales: {} };
338
-
339
- if (ext.callbacks?.fileChanged) {
340
- await ext.callbacks.fileChanged(opts);
341
- }
342
- }
343
-
344
- const watchRules = global.config.watch.reload.rules;
345
- const templateEvents = events.filter(({ relativePath }) =>
346
- helpers.fileIsTemplateFile(relativePath),
347
- );
348
- const dataEvents = events.filter(({ relativePath }) =>
349
- helpers.fileIsDataFile(relativePath),
350
- );
351
- const docEvents = events.filter(({ relativePath }) =>
352
- helpers.fileIsDocumentationFile(relativePath),
353
- );
354
- const schemaEvents = events.filter(({ relativePath }) =>
355
- helpers.fileIsSchemaFile(relativePath),
356
- );
357
- const componentAssetEvents = events.filter(({ relativePath }) =>
358
- helpers.fileIsAssetFile(relativePath),
359
- );
360
- const cssEvents = events.filter(({ relativePath }) => relativePath.endsWith(".css"));
361
- const jsEvents = events.filter(({ relativePath }) => relativePath.endsWith(".js"));
362
- const hasRemoveEvents = events.some(({ eventType }) =>
363
- ["unlink", "unlinkDir"].includes(eventType),
364
- );
365
- const hasDirectoryEvents = events.some(
366
- ({ changedPath, eventType }) =>
367
- ["addDir", "unlinkDir"].includes(eventType) || pathExistsAsDirectory(changedPath),
368
- );
369
-
370
- const configFilePath = global.config.userFileName
371
- ? path.resolve(process.cwd(), global.config.userFileName)
372
- : null;
373
- const hasConfigFileEvent =
374
- configFilePath &&
375
- events.some(({ changedPath }) => path.resolve(changedPath) === configFilePath);
376
-
377
- if (hasConfigFileEvent && global.config.watch.configFile.enabled) {
378
- await configurationFileUpdated();
379
- sendReload("parent", {
380
- reason: "config",
381
- paths: [global.config.userFileName],
382
- });
383
- return;
384
- }
385
-
386
- if (
387
- hasDirectoryEvents &&
388
- !hasRemoveEvents &&
389
- templateEvents.length === 0 &&
390
- dataEvents.length === 0 &&
391
- docEvents.length === 0 &&
392
- schemaEvents.length === 0 &&
393
- componentAssetEvents.length === 0 &&
394
- cssEvents.length === 0 &&
395
- jsEvents.length === 0
396
- ) {
397
- await setState({
398
- sourceTree: true,
399
- fileContents: true,
400
- menu: true,
401
- partials: true,
402
- });
403
-
404
- sendReload(watchRules.unknown, {
405
- reason: "directory",
406
- paths: events.map(({ relativePath }) => relativePath),
407
- });
408
- log("success", `${t("updatingDone")}\n`);
409
- return;
410
- }
411
-
412
- if (hasRemoveEvents) {
413
- await setState({
414
- sourceTree: true,
415
- fileContents: await updateFileContents(events),
416
- menu: true,
417
- partials: true,
418
- });
419
-
420
- sendReload("parent", {
421
- reason: "remove",
422
- paths: events.map(({ relativePath }) => relativePath),
423
- });
424
- log("success", `${t("updatingDone")}\n`);
425
- return;
426
- }
427
-
428
- if (templateEvents.length > 0) {
429
- const allTemplatePathsExistAsPartials = templateEvents.every(({ relativePath }) => {
430
- const shortPath = relativePath.replace(
431
- path.join(global.config.components.folder, "/"),
432
- "",
433
- );
434
- return Object.keys(global.state.partials).includes(shortPath);
435
- });
436
-
437
- if (allTemplatePathsExistAsPartials) {
438
- await setState({
439
- fileContents: await updateFileContents(templateEvents),
440
- });
441
-
442
- sendReload(watchRules.template, {
443
- reason: "template",
444
- paths: templateEvents.map(({ relativePath }) => relativePath),
445
- });
446
- } else {
447
- await setState({
448
- fileContents: await updateFileContents(templateEvents),
449
- sourceTree: true,
450
- menu: true,
451
- partials: true,
452
- });
453
-
454
- sendReload("parent", {
455
- reason: "template-added",
456
- paths: templateEvents.map(({ relativePath }) => relativePath),
457
- });
458
- }
459
-
460
- log("success", `${t("updatingDone")}\n`);
461
- return;
462
- }
463
-
464
- if (dataEvents.length > 0) {
465
- const hasBeenAdded = dataEvents.some(
466
- ({ eventType, changedPath }) =>
467
- eventType === "add" ||
468
- !Object.keys(global.state.fileContents).includes(path.resolve(changedPath)),
469
- );
470
-
471
- await setState({
472
- fileContents: await updateFileContents(dataEvents),
473
- sourceTree: hasBeenAdded,
474
- menu: true,
475
- });
476
-
477
- sendReload(watchRules.data, {
478
- reason: hasBeenAdded ? "data-added" : "data",
479
- paths: dataEvents.map(({ relativePath }) => relativePath),
480
- });
481
- log("success", `${t("updatingDone")}\n`);
482
- return;
483
- }
484
-
485
- if (docEvents.length > 0) {
486
- const hasBeenAdded = docEvents.some(
487
- ({ eventType, changedPath }) =>
488
- eventType === "add" ||
489
- !Object.keys(global.state.fileContents).includes(path.resolve(changedPath)),
490
- );
491
-
492
- await setState({
493
- fileContents: await updateFileContents(docEvents),
494
- sourceTree: hasBeenAdded,
495
- menu: hasBeenAdded,
496
- });
497
-
498
- sendReload(watchRules.docs, {
499
- reason: hasBeenAdded ? "docs-added" : "docs",
500
- paths: docEvents.map(({ relativePath }) => relativePath),
501
- });
502
- log("success", `${t("updatingDone")}\n`);
503
- return;
504
- }
505
-
506
- if (schemaEvents.length > 0) {
507
- await setState({
508
- fileContents: await updateFileContents(schemaEvents),
509
- });
510
-
511
- sendReload(watchRules.schema, {
512
- reason: "schema",
513
- paths: schemaEvents.map(({ relativePath }) => relativePath),
514
- });
515
- log("success", `${t("updatingDone")}\n`);
516
- return;
517
- }
518
-
519
- if (componentAssetEvents.length > 0) {
520
- sendReload(watchRules.componentAsset, {
521
- reason: "component-asset",
522
- paths: componentAssetEvents.map(({ relativePath }) => relativePath),
523
- });
524
- log("success", `${t("updatingDone")}\n`);
525
- return;
526
- }
527
-
528
- if (cssEvents.length > 0) {
529
- if (
530
- cssEvents.some(({ relativePath }) =>
531
- global.config.assets.customProperties.files.includes(relativePath),
532
- )
533
- ) {
534
- await setState({
535
- css: true,
536
- });
537
- } else {
538
- await setState({
539
- menu: true,
540
- });
541
- }
542
-
543
- sendReload(watchRules.globalCss, {
544
- reason: "css",
545
- paths: cssEvents.map(({ relativePath }) => relativePath),
546
- });
547
- log("success", `${t("updatingDone")}\n`);
548
- return;
549
- }
550
-
551
- if (jsEvents.length > 0) {
552
- await setState({
553
- menu: true,
554
- });
555
-
556
- sendReload(watchRules.globalJs, {
557
- reason: "js",
558
- paths: jsEvents.map(({ relativePath }) => relativePath),
559
- });
560
- log("success", `${t("updatingDone")}\n`);
561
- return;
562
- }
563
-
564
- await setState({
565
- sourceTree: true,
566
- fileContents: true,
567
- menu: true,
568
- partials: true,
569
- });
570
-
571
- sendReload(watchRules.unknown, {
572
- reason: "unknown",
573
- paths: events.map(({ relativePath }) => relativePath),
574
- });
575
- log("success", `${t("updatingDone")}\n`);
337
+ for (const extension of global.config.extensions) {
338
+ const ext = Array.isArray(extension) ? extension[0] : extension;
339
+ const opts =
340
+ Array.isArray(extension) && extension[1] ? extension[1] : { locales: {} };
341
+
342
+ if (ext.callbacks?.fileChanged) {
343
+ await ext.callbacks.fileChanged(opts);
344
+ }
345
+ }
346
+
347
+ const watchRules = global.config.watch.reload.rules;
348
+ const templateEvents = events.filter(({ relativePath }) =>
349
+ helpers.fileIsTemplateFile(relativePath),
350
+ );
351
+ const dataEvents = events.filter(({ relativePath }) =>
352
+ helpers.fileIsDataFile(relativePath),
353
+ );
354
+ const docEvents = events.filter(({ relativePath }) =>
355
+ helpers.fileIsDocumentationFile(relativePath),
356
+ );
357
+ const schemaEvents = events.filter(({ relativePath }) =>
358
+ helpers.fileIsSchemaFile(relativePath),
359
+ );
360
+ const componentAssetEvents = events.filter(({ relativePath }) =>
361
+ helpers.fileIsAssetFile(relativePath),
362
+ );
363
+ const cssEvents = events.filter(({ relativePath }) =>
364
+ relativePath.endsWith(".css"),
365
+ );
366
+ const jsEvents = events.filter(({ relativePath }) =>
367
+ relativePath.endsWith(".js"),
368
+ );
369
+ const hasRemoveEvents = events.some(({ eventType }) =>
370
+ ["unlink", "unlinkDir"].includes(eventType),
371
+ );
372
+ const hasDirectoryEvents = events.some(
373
+ ({ changedPath, eventType }) =>
374
+ ["addDir", "unlinkDir"].includes(eventType) ||
375
+ pathExistsAsDirectory(changedPath),
376
+ );
377
+
378
+ const configFilePath = global.config.userFileName
379
+ ? path.resolve(process.cwd(), global.config.userFileName)
380
+ : null;
381
+ const hasConfigFileEvent =
382
+ configFilePath &&
383
+ events.some(
384
+ ({ changedPath }) => path.resolve(changedPath) === configFilePath,
385
+ );
386
+
387
+ if (hasConfigFileEvent && global.config.watch.configFile.enabled) {
388
+ await configurationFileUpdated();
389
+ sendReload("parent", {
390
+ reason: "config",
391
+ paths: [global.config.userFileName],
392
+ });
393
+ return;
394
+ }
395
+
396
+ if (
397
+ hasDirectoryEvents &&
398
+ !hasRemoveEvents &&
399
+ templateEvents.length === 0 &&
400
+ dataEvents.length === 0 &&
401
+ docEvents.length === 0 &&
402
+ schemaEvents.length === 0 &&
403
+ componentAssetEvents.length === 0 &&
404
+ cssEvents.length === 0 &&
405
+ jsEvents.length === 0
406
+ ) {
407
+ await setState({
408
+ sourceTree: true,
409
+ fileContents: true,
410
+ menu: true,
411
+ partials: true,
412
+ });
413
+
414
+ sendReload(watchRules.unknown, {
415
+ reason: "directory",
416
+ paths: events.map(({ relativePath }) => relativePath),
417
+ });
418
+ log("success", `${t("updatingDone")}\n`);
419
+ return;
420
+ }
421
+
422
+ if (hasRemoveEvents) {
423
+ await setState({
424
+ sourceTree: true,
425
+ fileContents: await updateFileContents(events),
426
+ menu: true,
427
+ partials: true,
428
+ });
429
+
430
+ sendReload("parent", {
431
+ reason: "remove",
432
+ paths: events.map(({ relativePath }) => relativePath),
433
+ });
434
+ log("success", `${t("updatingDone")}\n`);
435
+ return;
436
+ }
437
+
438
+ if (templateEvents.length > 0) {
439
+ const allTemplatePathsExistAsPartials = templateEvents.every(
440
+ ({ relativePath }) => {
441
+ const shortPath = relativePath.replace(
442
+ path.join(global.config.components.folder, "/"),
443
+ "",
444
+ );
445
+ return Object.keys(global.state.partials).includes(shortPath);
446
+ },
447
+ );
448
+
449
+ if (allTemplatePathsExistAsPartials) {
450
+ await setState({
451
+ fileContents: await updateFileContents(templateEvents),
452
+ });
453
+
454
+ sendReload(watchRules.template, {
455
+ reason: "template",
456
+ paths: templateEvents.map(({ relativePath }) => relativePath),
457
+ });
458
+ } else {
459
+ await setState({
460
+ fileContents: await updateFileContents(templateEvents),
461
+ sourceTree: true,
462
+ menu: true,
463
+ partials: true,
464
+ });
465
+
466
+ sendReload("parent", {
467
+ reason: "template-added",
468
+ paths: templateEvents.map(({ relativePath }) => relativePath),
469
+ });
470
+ }
471
+
472
+ log("success", `${t("updatingDone")}\n`);
473
+ return;
474
+ }
475
+
476
+ if (dataEvents.length > 0) {
477
+ const hasBeenAdded = dataEvents.some(
478
+ ({ eventType, changedPath }) =>
479
+ eventType === "add" ||
480
+ !Object.keys(global.state.fileContents).includes(
481
+ path.resolve(changedPath),
482
+ ),
483
+ );
484
+
485
+ await setState({
486
+ fileContents: await updateFileContents(dataEvents),
487
+ sourceTree: hasBeenAdded,
488
+ menu: true,
489
+ });
490
+
491
+ sendReload(watchRules.data, {
492
+ reason: hasBeenAdded ? "data-added" : "data",
493
+ paths: dataEvents.map(({ relativePath }) => relativePath),
494
+ });
495
+ log("success", `${t("updatingDone")}\n`);
496
+ return;
497
+ }
498
+
499
+ if (docEvents.length > 0) {
500
+ const hasBeenAdded = docEvents.some(
501
+ ({ eventType, changedPath }) =>
502
+ eventType === "add" ||
503
+ !Object.keys(global.state.fileContents).includes(
504
+ path.resolve(changedPath),
505
+ ),
506
+ );
507
+
508
+ await setState({
509
+ fileContents: await updateFileContents(docEvents),
510
+ sourceTree: hasBeenAdded,
511
+ menu: hasBeenAdded,
512
+ });
513
+
514
+ sendReload(watchRules.docs, {
515
+ reason: hasBeenAdded ? "docs-added" : "docs",
516
+ paths: docEvents.map(({ relativePath }) => relativePath),
517
+ });
518
+ log("success", `${t("updatingDone")}\n`);
519
+ return;
520
+ }
521
+
522
+ if (schemaEvents.length > 0) {
523
+ await setState({
524
+ fileContents: await updateFileContents(schemaEvents),
525
+ });
526
+
527
+ sendReload(watchRules.schema, {
528
+ reason: "schema",
529
+ paths: schemaEvents.map(({ relativePath }) => relativePath),
530
+ });
531
+ log("success", `${t("updatingDone")}\n`);
532
+ return;
533
+ }
534
+
535
+ if (componentAssetEvents.length > 0) {
536
+ sendReload(watchRules.componentAsset, {
537
+ reason: "component-asset",
538
+ paths: componentAssetEvents.map(({ relativePath }) => relativePath),
539
+ });
540
+ log("success", `${t("updatingDone")}\n`);
541
+ return;
542
+ }
543
+
544
+ if (cssEvents.length > 0) {
545
+ if (
546
+ cssEvents.some(({ relativePath }) =>
547
+ global.config.assets.customProperties.files.includes(relativePath),
548
+ )
549
+ ) {
550
+ await setState({
551
+ css: true,
552
+ });
553
+ } else {
554
+ await setState({
555
+ menu: true,
556
+ });
557
+ }
558
+
559
+ sendReload(watchRules.globalCss, {
560
+ reason: "css",
561
+ paths: cssEvents.map(({ relativePath }) => relativePath),
562
+ });
563
+ log("success", `${t("updatingDone")}\n`);
564
+ return;
565
+ }
566
+
567
+ if (jsEvents.length > 0) {
568
+ await setState({
569
+ menu: true,
570
+ });
571
+
572
+ sendReload(watchRules.globalJs, {
573
+ reason: "js",
574
+ paths: jsEvents.map(({ relativePath }) => relativePath),
575
+ });
576
+ log("success", `${t("updatingDone")}\n`);
577
+ return;
578
+ }
579
+
580
+ await setState({
581
+ sourceTree: true,
582
+ fileContents: true,
583
+ menu: true,
584
+ partials: true,
585
+ });
586
+
587
+ sendReload(watchRules.unknown, {
588
+ reason: "unknown",
589
+ paths: events.map(({ relativePath }) => relativePath),
590
+ });
591
+ log("success", `${t("updatingDone")}\n`);
576
592
  }
577
593
 
578
594
  /**
@@ -580,218 +596,235 @@ async function handleFileChange(events) {
580
596
  * @returns {Array<{ eventType: string, changedPath: string, relativePath: string }>}
581
597
  */
582
598
  function snapshotPendingEvents(pendingByPath) {
583
- return [...pendingByPath.values()]
584
- // Process highest-priority events first to keep structural changes deterministic.
585
- .sort((a, b) => getEventPriority(b.eventType) - getEventPriority(a.eventType))
586
- .map((entry) => ({
587
- eventType: entry.eventType,
588
- changedPath: entry.changedPath,
589
- relativePath: normalizeRelativePath(entry.changedPath),
590
- }));
599
+ return (
600
+ [...pendingByPath.values()]
601
+ // Process highest-priority events first to keep structural changes deterministic.
602
+ .sort(
603
+ (a, b) => getEventPriority(b.eventType) - getEventPriority(a.eventType),
604
+ )
605
+ .map((entry) => ({
606
+ eventType: entry.eventType,
607
+ changedPath: entry.changedPath,
608
+ relativePath: normalizeRelativePath(entry.changedPath),
609
+ }))
610
+ );
591
611
  }
592
612
 
593
613
  /**
594
614
  * @param {object} server
595
615
  */
596
616
  export default function Watcher(server) {
597
- const wss = new WebSocketServer({ noServer: true });
598
- let watcher;
599
- let debounceTimer = null;
600
- let isProcessing = false;
601
- const pendingByPath = new Map();
602
-
603
- const startHeartbeat = () => {
604
- const heartbeatConfig = global.config.watch?.socket?.heartbeat;
605
- if (!heartbeatConfig?.enabled) {
606
- return null;
607
- }
608
-
609
- return setInterval(() => {
610
- for (const ws of sockets) {
611
- if (ws.readyState !== 1) {
612
- sockets.delete(ws);
613
- continue;
614
- }
615
-
616
- ws.ping();
617
- }
618
- }, heartbeatConfig.intervalMs);
619
- };
620
-
621
- const heartbeatInterval = startHeartbeat();
622
-
623
- const processPendingEvents = async () => {
624
- if (isProcessing) {
625
- return;
626
- }
627
-
628
- if (pendingByPath.size === 0) {
629
- return;
630
- }
631
-
632
- isProcessing = true;
633
- try {
634
- log("info", t("updatingStarted"));
635
- const events = snapshotPendingEvents(pendingByPath);
636
- pendingByPath.clear();
637
- await handleFileChange(events);
638
- } catch (error) {
639
- log("error", "Error while processing file changes.", error);
640
- } finally {
641
- isProcessing = false;
642
- if (pendingByPath.size > 0) {
643
- void processPendingEvents();
644
- }
645
- }
646
- };
647
-
648
- const enqueueEvent = (eventType, changedPath) => {
649
- const absolutePath = path.resolve(changedPath);
650
- const existing = pendingByPath.get(absolutePath);
651
- const shouldReplace =
652
- !existing ||
653
- getEventPriority(eventType) >= getEventPriority(existing.eventType);
654
-
655
- if (shouldReplace) {
656
- // Keep only the strongest event per path inside the coalescing window.
657
- pendingByPath.set(absolutePath, {
658
- eventType,
659
- changedPath: absolutePath,
660
- });
661
- }
662
-
663
- const watchBehavior = global.config.watch?.behavior || {};
664
- const delay = Math.max(
665
- watchBehavior.debounceMs || 0,
666
- watchBehavior.coalesceWindowMs || 0,
667
- );
668
-
669
- if (debounceTimer) {
670
- clearTimeout(debounceTimer);
671
- }
672
-
673
- debounceTimer = setTimeout(() => {
674
- debounceTimer = null;
675
- void processPendingEvents();
676
- }, delay);
677
- };
678
-
679
- const setupFileWatcher = () => {
680
- const watchConfig = global.config.watch || {};
681
- if (watchConfig.enabled === false) {
682
- return;
683
- }
684
-
685
- if (watcher) {
686
- void watcher.close();
687
- }
688
-
689
- const { targets, ignorePatterns, report } = resolveWatchTargets(watchConfig);
690
- const awaitWriteFinish = watchConfig.behavior?.awaitWriteFinish || {};
691
-
692
- printWatchReport(report, watchConfig);
693
-
694
- if (targets.length === 0) {
695
- log("error", t("watchingFilesFailed"));
696
- return;
697
- }
698
-
699
- watcher = chokidar.watch(targets, {
700
- ignoreInitial: true,
701
- persistent: true,
702
- ignored(changedPath) {
703
- const relativePath = normalizeRelativePath(changedPath);
704
- return anymatch(ignorePatterns, relativePath);
705
- },
706
- awaitWriteFinish: awaitWriteFinish.enabled
707
- ? {
708
- stabilityThreshold: awaitWriteFinish.stabilityThresholdMs,
709
- pollInterval: awaitWriteFinish.pollIntervalMs,
710
- }
711
- : false,
712
- });
713
-
714
- watcher.on("all", (eventType, changedPath) => {
715
- const watchDebug = global.config.watch?.debug || {};
716
- if (watchDebug.logEvents) {
717
- log("info", `watch:event=${eventType} path=${changedPath}`);
718
- }
719
-
720
- enqueueEvent(eventType, changedPath);
721
- });
722
-
723
- watcher.on("error", (error) => {
724
- log("error", t("watchingFilesFailed"), error);
725
- });
726
- };
727
- restartFileWatcher = setupFileWatcher;
728
-
729
- wss.on("connection", function open(ws) {
730
- sockets.add(ws);
731
-
732
- ws.on("close", () => {
733
- sockets.delete(ws);
734
- });
735
-
736
- ws.on("error", () => {
737
- sockets.delete(ws);
738
- });
739
- });
740
-
741
- server.on("upgrade", (request, socket, head) => {
742
- const requestUrl = new URL(
743
- request.url || "/",
744
- `http://${request.headers.host || "localhost"}`,
745
- );
746
-
747
- if (requestUrl.pathname !== SOCKET_PATH) {
748
- socket.destroy();
749
- return;
750
- }
751
-
752
- wss.handleUpgrade(request, socket, head, (ws) => {
753
- wss.emit("connection", ws, request);
754
- });
755
- });
756
-
757
- setupFileWatcher();
758
-
759
- server.on("close", () => {
760
- if (heartbeatInterval) {
761
- clearInterval(heartbeatInterval);
762
- }
763
-
764
- if (watcher) {
765
- void watcher.close();
766
- }
767
- });
617
+ const wss = new WebSocketServer({ noServer: true });
618
+ let watcher;
619
+ let debounceTimer = null;
620
+ let isProcessing = false;
621
+ let watcherStartedAt = null;
622
+ const pendingByPath = new Map();
623
+
624
+ const startHeartbeat = () => {
625
+ const heartbeatConfig = global.config.watch?.socket?.heartbeat;
626
+ if (!heartbeatConfig?.enabled) {
627
+ return null;
628
+ }
629
+
630
+ return setInterval(() => {
631
+ for (const ws of sockets) {
632
+ if (ws.readyState !== 1) {
633
+ sockets.delete(ws);
634
+ continue;
635
+ }
636
+
637
+ ws.ping();
638
+ }
639
+ }, heartbeatConfig.intervalMs);
640
+ };
641
+
642
+ const heartbeatInterval = startHeartbeat();
643
+
644
+ const processPendingEvents = async () => {
645
+ if (isProcessing) {
646
+ return;
647
+ }
648
+
649
+ if (pendingByPath.size === 0) {
650
+ return;
651
+ }
652
+
653
+ isProcessing = true;
654
+ try {
655
+ log("info", t("updatingStarted"));
656
+ const events = snapshotPendingEvents(pendingByPath);
657
+ pendingByPath.clear();
658
+ await handleFileChange(events);
659
+ } catch (error) {
660
+ log("error", "Error while processing file changes.", error);
661
+ } finally {
662
+ isProcessing = false;
663
+ if (pendingByPath.size > 0) {
664
+ void processPendingEvents();
665
+ }
666
+ }
667
+ };
668
+
669
+ const enqueueEvent = (eventType, changedPath) => {
670
+ const graceMs = global.config.watch?.behavior?.startupGraceMs ?? 0;
671
+ if (
672
+ watcherStartedAt &&
673
+ graceMs > 0 &&
674
+ Date.now() - watcherStartedAt < graceMs
675
+ ) {
676
+ return;
677
+ }
678
+
679
+ const absolutePath = path.resolve(changedPath);
680
+ const existing = pendingByPath.get(absolutePath);
681
+ const shouldReplace =
682
+ !existing ||
683
+ getEventPriority(eventType) >= getEventPriority(existing.eventType);
684
+
685
+ if (shouldReplace) {
686
+ // Keep only the strongest event per path inside the coalescing window.
687
+ pendingByPath.set(absolutePath, {
688
+ eventType,
689
+ changedPath: absolutePath,
690
+ });
691
+ }
692
+
693
+ const watchBehavior = global.config.watch?.behavior || {};
694
+ const delay = Math.max(
695
+ watchBehavior.debounceMs || 0,
696
+ watchBehavior.coalesceWindowMs || 0,
697
+ );
698
+
699
+ if (debounceTimer) {
700
+ clearTimeout(debounceTimer);
701
+ }
702
+
703
+ debounceTimer = setTimeout(() => {
704
+ debounceTimer = null;
705
+ void processPendingEvents();
706
+ }, delay);
707
+ };
708
+
709
+ const setupFileWatcher = () => {
710
+ const watchConfig = global.config.watch || {};
711
+ if (watchConfig.enabled === false) {
712
+ return;
713
+ }
714
+
715
+ if (watcher) {
716
+ void watcher.close();
717
+ }
718
+
719
+ const { targets, ignorePatterns, report } =
720
+ resolveWatchTargets(watchConfig);
721
+ const awaitWriteFinish = watchConfig.behavior?.awaitWriteFinish || {};
722
+
723
+ printWatchReport(report, watchConfig);
724
+
725
+ if (targets.length === 0) {
726
+ log("error", t("watchingFilesFailed"));
727
+ return;
728
+ }
729
+
730
+ watcher = chokidar.watch(targets, {
731
+ ignoreInitial: true,
732
+ persistent: true,
733
+ ignored(changedPath) {
734
+ const relativePath = normalizeRelativePath(changedPath);
735
+ return anymatch(ignorePatterns, relativePath);
736
+ },
737
+ awaitWriteFinish: awaitWriteFinish.enabled
738
+ ? {
739
+ stabilityThreshold: awaitWriteFinish.stabilityThresholdMs,
740
+ pollInterval: awaitWriteFinish.pollIntervalMs,
741
+ }
742
+ : false,
743
+ });
744
+
745
+ watcher.on("all", (eventType, changedPath) => {
746
+ const watchDebug = global.config.watch?.debug || {};
747
+ if (watchDebug.logEvents) {
748
+ log("info", `watch:event=${eventType} path=${changedPath}`);
749
+ }
750
+
751
+ enqueueEvent(eventType, changedPath);
752
+ });
753
+
754
+ watcher.on("error", (error) => {
755
+ log("error", t("watchingFilesFailed"), error);
756
+ });
757
+
758
+ watcherStartedAt = Date.now();
759
+ };
760
+ restartFileWatcher = setupFileWatcher;
761
+
762
+ wss.on("connection", function open(ws) {
763
+ sockets.add(ws);
764
+
765
+ ws.on("close", () => {
766
+ sockets.delete(ws);
767
+ });
768
+
769
+ ws.on("error", () => {
770
+ sockets.delete(ws);
771
+ });
772
+ });
773
+
774
+ server.on("upgrade", (request, socket, head) => {
775
+ const requestUrl = new URL(
776
+ request.url || "/",
777
+ `http://${request.headers.host || "localhost"}`,
778
+ );
779
+
780
+ if (requestUrl.pathname !== SOCKET_PATH) {
781
+ socket.destroy();
782
+ return;
783
+ }
784
+
785
+ wss.handleUpgrade(request, socket, head, (ws) => {
786
+ wss.emit("connection", ws, request);
787
+ });
788
+ });
789
+
790
+ setupFileWatcher();
791
+
792
+ server.on("close", () => {
793
+ if (heartbeatInterval) {
794
+ clearInterval(heartbeatInterval);
795
+ }
796
+
797
+ if (watcher) {
798
+ void watcher.close();
799
+ }
800
+ });
768
801
  }
769
802
 
770
803
  /**
771
804
  * @returns {Promise<void>}
772
805
  */
773
806
  async function configurationFileUpdated() {
774
- log("info", t("updatingConfiguration"));
775
-
776
- const config = await getConfig(yargs.argv);
777
-
778
- if (config) {
779
- global.config = config;
780
- if (restartFileWatcher) {
781
- restartFileWatcher();
782
- }
783
- await setEngines();
784
- await setState({
785
- sourceTree: true,
786
- fileContents: true,
787
- menu: true,
788
- partials: true,
789
- css: true,
790
- });
791
-
792
- setStatic();
793
- setViews();
794
-
795
- log("success", `${t("updatingConfigurationDone")}\n`);
796
- }
807
+ log("info", t("updatingConfiguration"));
808
+
809
+ const config = await getConfig(yargs.argv);
810
+
811
+ if (config) {
812
+ global.config = config;
813
+ if (restartFileWatcher) {
814
+ restartFileWatcher();
815
+ }
816
+ await setEngines();
817
+ await setState({
818
+ sourceTree: true,
819
+ fileContents: true,
820
+ menu: true,
821
+ partials: true,
822
+ css: true,
823
+ });
824
+
825
+ setStatic();
826
+ setViews();
827
+
828
+ log("success", `${t("updatingConfigurationDone")}\n`);
829
+ }
797
830
  }