@kenjura/ursa 0.87.1 → 0.89.1
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/CHANGELOG.md +796 -737
- package/bin/ursa.js +7 -1
- package/meta/templates/default-template/index.html +1 -0
- package/package.json +1 -1
- package/src/dev.js +8 -4
- package/src/helper/__test__/hiddenPaths.test.js +106 -0
- package/src/helper/__test__/portUtils.test.js +100 -0
- package/src/helper/__test__/staticAssets.test.js +47 -0
- package/src/helper/automenu.js +43 -3
- package/src/helper/build/autoIndex.js +10 -5
- package/src/helper/contentHash.js +19 -0
- package/src/helper/hiddenPaths.js +62 -0
- package/src/helper/portUtils.js +71 -22
- package/src/helper/staticAssets.js +31 -0
- package/src/jobs/generate.js +1771 -1714
- package/src/serve.js +907 -906
package/src/serve.js
CHANGED
|
@@ -1,906 +1,907 @@
|
|
|
1
|
-
import express from "express";
|
|
2
|
-
import compression from "compression";
|
|
3
|
-
import watch from "node-watch";
|
|
4
|
-
import { generate, regenerateAffectedDocuments, clearWatchCache, clearScriptCache, clearStyleCache } from "./jobs/generate.js";
|
|
5
|
-
import { join, resolve, dirname, basename } from "path";
|
|
6
|
-
import fs from "fs";
|
|
7
|
-
import { promises } from "fs";
|
|
8
|
-
import { copy as copyDir, outputFile } from "fs-extra";
|
|
9
|
-
import { processImage } from "./helper/imageProcessor.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* @
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* @param {string}
|
|
65
|
-
* @
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* @param {string}
|
|
79
|
-
* @
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* @param {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
*
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* @param {string}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
let
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
*
|
|
166
|
-
* @
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
let
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
indicatorEl
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
el
|
|
190
|
-
el.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
el
|
|
196
|
-
el.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
*
|
|
282
|
-
* @param {string}
|
|
283
|
-
* @param {string}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
await
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Copy a single static file to the output directory
|
|
307
|
-
* For images, also generates a preview version and updates the imageMap cache
|
|
308
|
-
* @param {string} filePath - Absolute path to the static file
|
|
309
|
-
* @param {string} sourceDir - Source directory root
|
|
310
|
-
* @param {string} outputDir - Output directory root
|
|
311
|
-
*/
|
|
312
|
-
async function copyStaticFile(filePath, sourceDir, outputDir) {
|
|
313
|
-
const startTime = Date.now();
|
|
314
|
-
const relativePath = filePath.replace(sourceDir, '');
|
|
315
|
-
const relativeDir = dirname(relativePath);
|
|
316
|
-
const absoluteOutputDir = join(outputDir, relativeDir);
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
// Check if this is an image that needs preview processing
|
|
320
|
-
if (IMAGE_EXTENSIONS.test(filePath)) {
|
|
321
|
-
const result = await processImage(filePath, absoluteOutputDir, relativeDir);
|
|
322
|
-
const elapsed = Date.now() - startTime;
|
|
323
|
-
|
|
324
|
-
// Update the watchModeCache.imageMap so regenerated documents can use the new image
|
|
325
|
-
if (result && watchModeCache.imageMap) {
|
|
326
|
-
// The key is the absolute URL path (e.g., /campaigns/ABS/img/map.jpg)
|
|
327
|
-
const imageKey = result.original;
|
|
328
|
-
watchModeCache.imageMap.set(imageKey, result);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (result && result.preview !== result.original) {
|
|
332
|
-
return { success: true, message: `Processed ${relativePath} with preview in ${elapsed}ms` };
|
|
333
|
-
}
|
|
334
|
-
return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// For non-image files, just copy
|
|
338
|
-
const outputPath = join(outputDir, relativePath);
|
|
339
|
-
await mkdir(dirname(outputPath), { recursive: true });
|
|
340
|
-
await copyFile(filePath, outputPath);
|
|
341
|
-
const elapsed = Date.now() - startTime;
|
|
342
|
-
return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
|
|
343
|
-
} catch (e) {
|
|
344
|
-
return { success: false, message: `Error copying static file: ${e.message}` };
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Configurable serve function for CLI and library use
|
|
350
|
-
*/
|
|
351
|
-
export async function serve({
|
|
352
|
-
_source,
|
|
353
|
-
_meta,
|
|
354
|
-
_output,
|
|
355
|
-
port = 8080,
|
|
356
|
-
_whitelist = null,
|
|
357
|
-
_clean = false,
|
|
358
|
-
_exclude = null
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
port
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
console.log(
|
|
374
|
-
console.log("
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
console.log("
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
console.log("
|
|
412
|
-
console.log("
|
|
413
|
-
console.log("
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
debounceTimer
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
!
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
let
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
await
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
plan
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
reconcileResult.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
try { await promises.unlink(join(ursaDir, '
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
console.error(genError
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
console.log(`
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
for
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
console.error(error
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
*
|
|
753
|
-
* @param {
|
|
754
|
-
* @
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
level
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
res.setHeader('X-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
url
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
res.
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
console.log(
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
*
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
if (/\/
|
|
878
|
-
if (
|
|
879
|
-
if (
|
|
880
|
-
if (/\/
|
|
881
|
-
return
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
}
|
|
1
|
+
import express from "express";
|
|
2
|
+
import compression from "compression";
|
|
3
|
+
import watch from "node-watch";
|
|
4
|
+
import { generate, regenerateAffectedDocuments, clearWatchCache, clearScriptCache, clearStyleCache } from "./jobs/generate.js";
|
|
5
|
+
import { join, resolve, dirname, basename } from "path";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import { promises } from "fs";
|
|
8
|
+
import { copy as copyDir, outputFile } from "fs-extra";
|
|
9
|
+
import { processImage } from "./helper/imageProcessor.js";
|
|
10
|
+
import { STATIC_ASSET_EXTENSIONS, IMAGE_EXTENSIONS } from "./helper/staticAssets.js";
|
|
11
|
+
import { watchModeCache } from "./helper/build/watchCache.js";
|
|
12
|
+
import { dependencyTracker } from "./helper/dependencyTracker.js";
|
|
13
|
+
import { bundleMetaTemplateAssets, clearMetaBundleCache } from "./helper/assetBundler.js";
|
|
14
|
+
import { getTemplates, copyMetaAssets } from "./helper/build/templates.js";
|
|
15
|
+
import { isInsideTemplatesFolder, reconcileByTemplate } from "./helper/documentTemplates.js";
|
|
16
|
+
import { WebSocketServer } from "ws";
|
|
17
|
+
import { createServer } from "http";
|
|
18
|
+
import { resolvePort } from "./helper/portUtils.js";
|
|
19
|
+
const { readdir, mkdir, readFile, copyFile } = promises;
|
|
20
|
+
|
|
21
|
+
// WebSocket server for hot reloading
|
|
22
|
+
let wss = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Map of WebSocket client → current page URL path (e.g. '/campaigns/abs/index.html')
|
|
26
|
+
* Updated when clients send { type: 'url', url: '...' } messages.
|
|
27
|
+
*/
|
|
28
|
+
const clientUrls = new Map();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get URL paths that connected WebSocket clients are currently viewing.
|
|
32
|
+
* @returns {string[]} Array of unique URL paths
|
|
33
|
+
*/
|
|
34
|
+
function getClientViewedUrls() {
|
|
35
|
+
const urls = new Set();
|
|
36
|
+
for (const [client, url] of clientUrls) {
|
|
37
|
+
if (client.readyState === 1 && url) urls.add(url);
|
|
38
|
+
}
|
|
39
|
+
return [...urls];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a URL path for comparison.
|
|
44
|
+
* Converts /path/index.html → /path/, /path.html → /path.html
|
|
45
|
+
* Strips trailing whitespace. Ensures leading /.
|
|
46
|
+
* @param {string} url
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function normalizeUrl(url) {
|
|
50
|
+
if (!url) return '/';
|
|
51
|
+
let u = url.trim();
|
|
52
|
+
if (!u.startsWith('/')) u = '/' + u;
|
|
53
|
+
// /foo/index.html → /foo/
|
|
54
|
+
if (u.endsWith('/index.html')) u = u.slice(0, -10);
|
|
55
|
+
// Ensure trailing slash for directory-like paths (no extension)
|
|
56
|
+
if (!u.includes('.') && !u.endsWith('/')) u = u + '/';
|
|
57
|
+
return u;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert a source file path to the URL path it would produce,
|
|
62
|
+
* normalized for comparison with client URLs.
|
|
63
|
+
* e.g. /Users/.../docs/campaigns/abs/index.mdx → /campaigns/abs/
|
|
64
|
+
* @param {string} docPath - Absolute source path
|
|
65
|
+
* @param {string} sourceDir - Absolute source directory (with trailing slash)
|
|
66
|
+
* @returns {string} Normalized URL path
|
|
67
|
+
*/
|
|
68
|
+
function docPathToUrl(docPath, sourceDir) {
|
|
69
|
+
const normalizedSource = sourceDir.endsWith('/') ? sourceDir : sourceDir + '/';
|
|
70
|
+
const rawUrl = '/' + docPath.replace(normalizedSource, '').replace(/\.(md|mdx|txt|yml|yaml)$/, '.html');
|
|
71
|
+
return normalizeUrl(rawUrl);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Return all URL paths a source file maps to. Most files map to a single URL,
|
|
76
|
+
* but folder-named files (e.g. aletheia/aletheia.md) are promoted to
|
|
77
|
+
* <folder>/index.html during the build, so they also serve the folder URL.
|
|
78
|
+
* @param {string} docPath - Absolute source path
|
|
79
|
+
* @param {string} sourceDir - Absolute source directory (with trailing slash)
|
|
80
|
+
* @returns {string[]} Normalized URL paths
|
|
81
|
+
*/
|
|
82
|
+
function docPathToUrls(docPath, sourceDir) {
|
|
83
|
+
const urls = [docPathToUrl(docPath, sourceDir)];
|
|
84
|
+
const ext = docPath.match(/\.(md|mdx|txt|yml|yaml)$/);
|
|
85
|
+
if (ext) {
|
|
86
|
+
const base = basename(docPath, ext[0]);
|
|
87
|
+
const parent = basename(dirname(docPath));
|
|
88
|
+
if (base && parent && base === parent) {
|
|
89
|
+
// Folder-named file → also serves the folder's index URL
|
|
90
|
+
const folderUrl = normalizeUrl('/' + dirname(docPath).replace(
|
|
91
|
+
sourceDir.endsWith('/') ? sourceDir : sourceDir + '/', '') + '/');
|
|
92
|
+
if (!urls.includes(folderUrl)) urls.push(folderUrl);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return urls;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Broadcast a message to all connected WebSocket clients.
|
|
100
|
+
* @param {object} messageObj - Object to JSON.stringify and send
|
|
101
|
+
*/
|
|
102
|
+
function broadcastMessage(messageObj) {
|
|
103
|
+
if (!wss) return;
|
|
104
|
+
const message = JSON.stringify(messageObj);
|
|
105
|
+
wss.clients.forEach(client => {
|
|
106
|
+
if (client.readyState === 1) client.send(message);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Send a message only to clients viewing a specific set of URLs.
|
|
112
|
+
* Compares using normalizeUrl for consistent matching.
|
|
113
|
+
* @param {object} messageObj - Object to send
|
|
114
|
+
* @param {Set<string>} urls - Set of normalized URL paths to match against
|
|
115
|
+
*/
|
|
116
|
+
function sendToClientsViewing(messageObj, urls) {
|
|
117
|
+
if (!wss) return;
|
|
118
|
+
const message = JSON.stringify(messageObj);
|
|
119
|
+
for (const [client, clientUrl] of clientUrls) {
|
|
120
|
+
if (client.readyState === 1 && clientUrl && urls.has(normalizeUrl(clientUrl))) {
|
|
121
|
+
client.send(message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Broadcast a reload message to all connected clients
|
|
128
|
+
* @param {string} [changedFile] - Optional path of the changed file
|
|
129
|
+
*/
|
|
130
|
+
function broadcastReload(changedFile = null) {
|
|
131
|
+
broadcastMessage({ type: 'reload', file: changedFile, timestamp: Date.now() });
|
|
132
|
+
const clientCount = wss ? wss.clients.size : 0;
|
|
133
|
+
if (clientCount > 0) {
|
|
134
|
+
console.log(`🔄 Hot reload: notified ${clientCount} browser${clientCount > 1 ? 's' : ''}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Send reload only to clients viewing the given URL paths.
|
|
140
|
+
* Other clients get 'update-no-affect' to clear their loading indicator.
|
|
141
|
+
* @param {Set<string>} affectedUrls - URL paths that were regenerated
|
|
142
|
+
* @param {string} [changedFile] - Source file that changed
|
|
143
|
+
*/
|
|
144
|
+
function reloadAffectedClients(affectedUrls, changedFile = null) {
|
|
145
|
+
if (!wss) return;
|
|
146
|
+
let reloaded = 0;
|
|
147
|
+
let cleared = 0;
|
|
148
|
+
for (const [client, clientUrl] of clientUrls) {
|
|
149
|
+
if (client.readyState !== 1) continue;
|
|
150
|
+
const normalized = normalizeUrl(clientUrl);
|
|
151
|
+
if (normalized && affectedUrls.has(normalized)) {
|
|
152
|
+
client.send(JSON.stringify({ type: 'reload', file: changedFile, timestamp: Date.now() }));
|
|
153
|
+
reloaded++;
|
|
154
|
+
} else {
|
|
155
|
+
client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
|
|
156
|
+
cleared++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (reloaded > 0) {
|
|
160
|
+
console.log(`🔄 Hot reload: ${reloaded} affected client${reloaded > 1 ? 's' : ''} reloaded${cleared > 0 ? `, ${cleared} unaffected` : ''}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Generate the hot reload client script
|
|
166
|
+
* @param {number} wsPort - WebSocket server port
|
|
167
|
+
* @returns {string} JavaScript code to inject
|
|
168
|
+
*/
|
|
169
|
+
function getHotReloadScript(wsPort) {
|
|
170
|
+
return `
|
|
171
|
+
<!-- Ursa Hot Reload -->
|
|
172
|
+
<script>
|
|
173
|
+
(function() {
|
|
174
|
+
const wsUrl = 'ws://' + window.location.hostname + ':${wsPort}';
|
|
175
|
+
let ws;
|
|
176
|
+
let reconnectAttempts = 0;
|
|
177
|
+
const maxReconnectAttempts = 10;
|
|
178
|
+
const reconnectDelay = 1000;
|
|
179
|
+
|
|
180
|
+
// Loading indicator management
|
|
181
|
+
let indicatorEl = null;
|
|
182
|
+
function getIndicator() {
|
|
183
|
+
if (indicatorEl) return indicatorEl;
|
|
184
|
+
indicatorEl = document.getElementById('ursa-update-indicator');
|
|
185
|
+
return indicatorEl;
|
|
186
|
+
}
|
|
187
|
+
function showIndicator(color) {
|
|
188
|
+
const el = getIndicator();
|
|
189
|
+
if (!el) return;
|
|
190
|
+
el.style.display = 'flex';
|
|
191
|
+
el.className = 'ursa-update-indicator ursa-update-' + color;
|
|
192
|
+
}
|
|
193
|
+
function hideIndicator() {
|
|
194
|
+
const el = getIndicator();
|
|
195
|
+
if (!el) return;
|
|
196
|
+
el.style.display = 'none';
|
|
197
|
+
el.className = 'ursa-update-indicator';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sendUrl() {
|
|
201
|
+
if (ws && ws.readyState === 1) {
|
|
202
|
+
ws.send(JSON.stringify({ type: 'url', url: window.location.pathname }));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function connect() {
|
|
207
|
+
ws = new WebSocket(wsUrl);
|
|
208
|
+
|
|
209
|
+
ws.onopen = function() {
|
|
210
|
+
console.log('[Ursa] Hot reload connected');
|
|
211
|
+
reconnectAttempts = 0;
|
|
212
|
+
sendUrl();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
ws.onmessage = function(event) {
|
|
216
|
+
try {
|
|
217
|
+
const data = JSON.parse(event.data);
|
|
218
|
+
switch (data.type) {
|
|
219
|
+
case 'reload':
|
|
220
|
+
hideIndicator();
|
|
221
|
+
console.log('[Ursa] Reloading page...');
|
|
222
|
+
window.location.reload();
|
|
223
|
+
break;
|
|
224
|
+
case 'update-start':
|
|
225
|
+
showIndicator('gray');
|
|
226
|
+
break;
|
|
227
|
+
case 'update-affects-you':
|
|
228
|
+
showIndicator('green');
|
|
229
|
+
break;
|
|
230
|
+
case 'update-no-affect':
|
|
231
|
+
hideIndicator();
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error('[Ursa] Hot reload error:', e);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
ws.onclose = function() {
|
|
240
|
+
if (reconnectAttempts < maxReconnectAttempts) {
|
|
241
|
+
reconnectAttempts++;
|
|
242
|
+
console.log('[Ursa] Hot reload disconnected, reconnecting... (' + reconnectAttempts + '/' + maxReconnectAttempts + ')');
|
|
243
|
+
setTimeout(connect, reconnectDelay);
|
|
244
|
+
} else {
|
|
245
|
+
console.log('[Ursa] Hot reload: max reconnect attempts reached');
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
ws.onerror = function(error) {
|
|
250
|
+
console.error('[Ursa] Hot reload WebSocket error');
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
connect();
|
|
255
|
+
|
|
256
|
+
// Track navigation (SPA-style or hash changes)
|
|
257
|
+
window.addEventListener('popstate', sendUrl);
|
|
258
|
+
// Also re-send on page visibility change (e.g. tab switch)
|
|
259
|
+
document.addEventListener('visibilitychange', function() {
|
|
260
|
+
if (!document.hidden) sendUrl();
|
|
261
|
+
});
|
|
262
|
+
})();
|
|
263
|
+
</script>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Lock for preventing concurrent regenerations
|
|
268
|
+
let isRegenerating = false;
|
|
269
|
+
|
|
270
|
+
// Debounce state for file change batching
|
|
271
|
+
const DEBOUNCE_MS = 500; // Wait 500ms of quiet before starting regeneration
|
|
272
|
+
let pendingChanges = []; // { evt, name, watcher: 'source'|'meta' }
|
|
273
|
+
let debounceTimer = null;
|
|
274
|
+
|
|
275
|
+
// Changes that arrived while a regeneration pass was in flight.
|
|
276
|
+
// They are processed as the next batch when the current pass finishes —
|
|
277
|
+
// never dropped (passes run sequentially, single-writer).
|
|
278
|
+
let queuedDuringRegeneration = [];
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Copy a single CSS file to the output directory
|
|
282
|
+
* @param {string} cssPath - Absolute path to the CSS file
|
|
283
|
+
* @param {string} sourceDir - Source directory root
|
|
284
|
+
* @param {string} outputDir - Output directory root
|
|
285
|
+
*/
|
|
286
|
+
async function copyCssFile(cssPath, sourceDir, outputDir) {
|
|
287
|
+
const startTime = Date.now();
|
|
288
|
+
const relativePath = cssPath.replace(sourceDir, '');
|
|
289
|
+
const outputPath = join(outputDir, relativePath);
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const content = await readFile(cssPath, 'utf8');
|
|
293
|
+
await outputFile(outputPath, content);
|
|
294
|
+
const elapsed = Date.now() - startTime;
|
|
295
|
+
return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return { success: false, message: `Error copying CSS: ${e.message}` };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Shared with generate so the two cannot drift apart again — that drift is
|
|
302
|
+
// exactly how fonts and video came to work in dev and 404 in production.
|
|
303
|
+
const STATIC_FILE_EXTENSIONS = STATIC_ASSET_EXTENSIONS;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Copy a single static file to the output directory
|
|
307
|
+
* For images, also generates a preview version and updates the imageMap cache
|
|
308
|
+
* @param {string} filePath - Absolute path to the static file
|
|
309
|
+
* @param {string} sourceDir - Source directory root
|
|
310
|
+
* @param {string} outputDir - Output directory root
|
|
311
|
+
*/
|
|
312
|
+
async function copyStaticFile(filePath, sourceDir, outputDir) {
|
|
313
|
+
const startTime = Date.now();
|
|
314
|
+
const relativePath = filePath.replace(sourceDir, '');
|
|
315
|
+
const relativeDir = dirname(relativePath);
|
|
316
|
+
const absoluteOutputDir = join(outputDir, relativeDir);
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
// Check if this is an image that needs preview processing
|
|
320
|
+
if (IMAGE_EXTENSIONS.test(filePath)) {
|
|
321
|
+
const result = await processImage(filePath, absoluteOutputDir, relativeDir);
|
|
322
|
+
const elapsed = Date.now() - startTime;
|
|
323
|
+
|
|
324
|
+
// Update the watchModeCache.imageMap so regenerated documents can use the new image
|
|
325
|
+
if (result && watchModeCache.imageMap) {
|
|
326
|
+
// The key is the absolute URL path (e.g., /campaigns/ABS/img/map.jpg)
|
|
327
|
+
const imageKey = result.original;
|
|
328
|
+
watchModeCache.imageMap.set(imageKey, result);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (result && result.preview !== result.original) {
|
|
332
|
+
return { success: true, message: `Processed ${relativePath} with preview in ${elapsed}ms` };
|
|
333
|
+
}
|
|
334
|
+
return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// For non-image files, just copy
|
|
338
|
+
const outputPath = join(outputDir, relativePath);
|
|
339
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
340
|
+
await copyFile(filePath, outputPath);
|
|
341
|
+
const elapsed = Date.now() - startTime;
|
|
342
|
+
return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
|
|
343
|
+
} catch (e) {
|
|
344
|
+
return { success: false, message: `Error copying static file: ${e.message}` };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Configurable serve function for CLI and library use
|
|
350
|
+
*/
|
|
351
|
+
export async function serve({
|
|
352
|
+
_source,
|
|
353
|
+
_meta,
|
|
354
|
+
_output,
|
|
355
|
+
port = 8080,
|
|
356
|
+
_whitelist = null,
|
|
357
|
+
_clean = false,
|
|
358
|
+
_exclude = null,
|
|
359
|
+
strictPort = false
|
|
360
|
+
} = {}) {
|
|
361
|
+
const sourceDir = resolve(_source);
|
|
362
|
+
const metaDir = resolve(_meta);
|
|
363
|
+
const outputDir = resolve(_output);
|
|
364
|
+
|
|
365
|
+
console.log({ source: sourceDir, meta: metaDir, output: outputDir, port, whitelist: _whitelist, exclude: _exclude, clean: _clean });
|
|
366
|
+
|
|
367
|
+
// Resolve port (prompt user if occupied)
|
|
368
|
+
port = await resolvePort(port, { strict: strictPort });
|
|
369
|
+
|
|
370
|
+
// Ensure output directory exists and start server immediately
|
|
371
|
+
await mkdir(outputDir, { recursive: true });
|
|
372
|
+
serveFiles(outputDir, port);
|
|
373
|
+
console.log(`🚀 Development server running at http://localhost:${port}`);
|
|
374
|
+
console.log("📁 Serving files from:", outputDir);
|
|
375
|
+
console.log("⏳ Generating site in background (deferred image + search index processing)...\n");
|
|
376
|
+
|
|
377
|
+
// Initial generation with deferred image and search index processing for faster startup
|
|
378
|
+
// This also initializes the watch cache for fast single-file updates
|
|
379
|
+
generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean, _deferImages: true, _deferSearchIndex: true })
|
|
380
|
+
.then(async (result) => {
|
|
381
|
+
console.log("\n✅ Initial HTML generation complete. Fast single-file regeneration enabled.");
|
|
382
|
+
console.log(" Note: Images/search may be incomplete until background processing completes.\n");
|
|
383
|
+
|
|
384
|
+
// Wait for deferred processing to complete in parallel
|
|
385
|
+
const promises = [];
|
|
386
|
+
|
|
387
|
+
if (result && result.deferredImageProcessing) {
|
|
388
|
+
promises.push(
|
|
389
|
+
result.deferredImageProcessing
|
|
390
|
+
.then(() => console.log("\n✅ Image preview generation complete."))
|
|
391
|
+
.catch(error => console.error("Error during image processing:", error.message))
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (result && result.deferredSearchIndex) {
|
|
396
|
+
promises.push(
|
|
397
|
+
result.deferredSearchIndex
|
|
398
|
+
.then(() => console.log("✅ Search index generation complete."))
|
|
399
|
+
.catch(error => console.error("Error during search index generation:", error.message))
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await Promise.all(promises);
|
|
404
|
+
if (promises.length > 0) {
|
|
405
|
+
console.log("\n✅ Full site ready.\n");
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
.catch((error) => console.error("Error during initial generation:", error.message));
|
|
409
|
+
|
|
410
|
+
// Watch for changes
|
|
411
|
+
console.log("👀 Watching for changes in:");
|
|
412
|
+
console.log(" Source:", sourceDir, "(fast single-file mode)");
|
|
413
|
+
console.log(" Meta:", metaDir, "(full rebuild)");
|
|
414
|
+
console.log("\nPress Ctrl+C to stop the server\n");
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Queue a file change for debounced batch processing.
|
|
418
|
+
* Sends 'update-start' to all clients on the first change in a batch.
|
|
419
|
+
* Resets the 500ms debounce timer on each subsequent change.
|
|
420
|
+
*/
|
|
421
|
+
function queueChange(evt, name, watcher) {
|
|
422
|
+
// Send 'update-start' immediately on first change in a batch
|
|
423
|
+
if (pendingChanges.length === 0) {
|
|
424
|
+
broadcastMessage({ type: 'update-start', timestamp: Date.now() });
|
|
425
|
+
}
|
|
426
|
+
pendingChanges.push({ evt, name, watcher });
|
|
427
|
+
|
|
428
|
+
// Reset debounce timer
|
|
429
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
430
|
+
debounceTimer = setTimeout(() => {
|
|
431
|
+
debounceTimer = null;
|
|
432
|
+
const batch = pendingChanges.splice(0);
|
|
433
|
+
processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude);
|
|
434
|
+
}, DEBOUNCE_MS);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Process a batch of accumulated file changes.
|
|
439
|
+
* Categorizes changes, handles immediate operations (copies), then
|
|
440
|
+
* regenerates affected documents with priority ordering.
|
|
441
|
+
*/
|
|
442
|
+
async function processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude) {
|
|
443
|
+
if (isRegenerating) {
|
|
444
|
+
// Never drop changes: accumulate them and process when the current
|
|
445
|
+
// pass finishes (clients keep their update-start indicator until then)
|
|
446
|
+
queuedDuringRegeneration.push(...batch);
|
|
447
|
+
console.log(`⏳ Regeneration in progress — queued ${batch.length} change(s) for the next pass`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
isRegenerating = true;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
// Categorize changes
|
|
454
|
+
const metaChanges = batch.filter(c => c.watcher === 'meta');
|
|
455
|
+
const metaStaticChanges = metaChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
|
|
456
|
+
const sourceChanges = batch.filter(c => c.watcher === 'source');
|
|
457
|
+
|
|
458
|
+
const cssChanges = sourceChanges.filter(c => c.name?.endsWith('.css'));
|
|
459
|
+
const scriptJsChanges = sourceChanges.filter(c => c.name && basename(c.name) === 'script.js');
|
|
460
|
+
const staticChanges = sourceChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
|
|
461
|
+
const menuConfigChanges = sourceChanges.filter(c => {
|
|
462
|
+
if (!c.name) return false;
|
|
463
|
+
return c.name.includes('_menu') || c.name.includes('menu.') || c.name.includes('_config') || c.name.includes('.ursa');
|
|
464
|
+
});
|
|
465
|
+
const articleChanges = sourceChanges.filter(c => c.name && /\.(md|mdx|txt|yml)$/.test(c.name))
|
|
466
|
+
.filter(c => !menuConfigChanges.some(m => m.name === c.name)); // exclude menu files already handled
|
|
467
|
+
const otherSourceChanges = sourceChanges.filter(c =>
|
|
468
|
+
!cssChanges.includes(c) && !scriptJsChanges.includes(c) && !staticChanges.includes(c) &&
|
|
469
|
+
!menuConfigChanges.includes(c) && !articleChanges.includes(c)
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const allNames = batch.map(c => c.name).filter(Boolean);
|
|
473
|
+
const uniqueNames = [...new Set(allNames)];
|
|
474
|
+
console.log(`\n📦 Processing batch: ${uniqueNames.length} file(s) changed`);
|
|
475
|
+
for (const n of uniqueNames) console.log(` ${n}`);
|
|
476
|
+
|
|
477
|
+
// Track whether we need a full rebuild (menu/config change, or unknown meta change)
|
|
478
|
+
let needsFullRebuild = false;
|
|
479
|
+
let fullRebuildReason = '';
|
|
480
|
+
// Collect all document paths that need regeneration (for selective rebuild)
|
|
481
|
+
const affectedDocPaths = new Set();
|
|
482
|
+
|
|
483
|
+
// --- 1) Handle static file copies (immediate, no rebuild) ---
|
|
484
|
+
for (const change of staticChanges) {
|
|
485
|
+
const { evt, name } = change;
|
|
486
|
+
if (evt === 'remove') {
|
|
487
|
+
const relativePath = name.replace(sourceDir, '');
|
|
488
|
+
const outputPath = join(outputDir, relativePath);
|
|
489
|
+
try { await promises.unlink(outputPath); console.log(`🗑️ Removed static: ${relativePath}`); } catch {}
|
|
490
|
+
} else {
|
|
491
|
+
const result = await copyStaticFile(name, sourceDir + '/', outputDir + '/');
|
|
492
|
+
if (result.success) console.log(`✅ ${result.message}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// --- 2) Handle CSS copies + gather affected docs ---
|
|
497
|
+
for (const change of cssChanges) {
|
|
498
|
+
const result = await copyCssFile(change.name, sourceDir + '/', outputDir + '/');
|
|
499
|
+
if (result.success) console.log(`✅ ${result.message}`);
|
|
500
|
+
// Clear CSS bundle cache so affected documents will regenerate bundles
|
|
501
|
+
clearStyleCache();
|
|
502
|
+
if (watchModeCache.isInitialized) {
|
|
503
|
+
const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
|
|
504
|
+
if (plan.requiresFullRebuild) {
|
|
505
|
+
needsFullRebuild = true;
|
|
506
|
+
fullRebuildReason = plan.reason;
|
|
507
|
+
} else {
|
|
508
|
+
plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// --- 3) Handle script.js copies + gather affected docs ---
|
|
514
|
+
for (const change of scriptJsChanges) {
|
|
515
|
+
const relativePath = change.name.replace(sourceDir + '/', '').replace(sourceDir, '');
|
|
516
|
+
const outputPath = join(outputDir, relativePath);
|
|
517
|
+
const content = await readFile(change.name, 'utf8');
|
|
518
|
+
await outputFile(outputPath, content);
|
|
519
|
+
console.log(`✅ Copied ${relativePath}`);
|
|
520
|
+
// Clear script bundle cache so affected documents will regenerate bundles
|
|
521
|
+
clearScriptCache();
|
|
522
|
+
if (watchModeCache.isInitialized) {
|
|
523
|
+
const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
|
|
524
|
+
plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- 4) Handle meta changes ---
|
|
529
|
+
if (metaChanges.length > 0) {
|
|
530
|
+
console.log(`🎨 Processing ${metaChanges.length} meta change(s)`);
|
|
531
|
+
const pub = join(outputDir, 'public');
|
|
532
|
+
clearMetaBundleCache();
|
|
533
|
+
await copyMetaAssets(metaDir, pub);
|
|
534
|
+
const freshTemplates = await getTemplates(metaDir);
|
|
535
|
+
const bundledTemplates = await bundleMetaTemplateAssets(freshTemplates, metaDir, pub, { minify: true, sourcemap: false });
|
|
536
|
+
console.log('🔄 Reloaded and re-bundled meta templates');
|
|
537
|
+
|
|
538
|
+
if (watchModeCache.isInitialized) {
|
|
539
|
+
watchModeCache.templates = bundledTemplates;
|
|
540
|
+
// Check each meta change for its invalidation plan
|
|
541
|
+
for (const change of metaChanges) {
|
|
542
|
+
const plan = dependencyTracker.getMetaInvalidationPlan(change.name, metaDir);
|
|
543
|
+
if (plan.requiresFullRebuild) {
|
|
544
|
+
needsFullRebuild = true;
|
|
545
|
+
fullRebuildReason = plan.reason;
|
|
546
|
+
} else {
|
|
547
|
+
plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} else {
|
|
551
|
+
needsFullRebuild = true;
|
|
552
|
+
fullRebuildReason = 'Cache not initialized';
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// --- 5) Handle menu/config changes → force full rebuild ---
|
|
557
|
+
if (menuConfigChanges.length > 0) {
|
|
558
|
+
needsFullRebuild = true;
|
|
559
|
+
fullRebuildReason = `Menu/config change: ${menuConfigChanges.map(c => basename(c.name)).join(', ')}`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// --- 5.5) Handle document template changes ---
|
|
563
|
+
// If a _templates/*.md file changed, reconcile all documents using that template
|
|
564
|
+
// and add the affected instance documents to the regeneration set.
|
|
565
|
+
const templateChanges = articleChanges.filter(c => c.name && isInsideTemplatesFolder(c.name));
|
|
566
|
+
if (templateChanges.length > 0 && watchModeCache.isInitialized) {
|
|
567
|
+
const allArticles = watchModeCache.allArticlePaths || [];
|
|
568
|
+
for (const change of templateChanges) {
|
|
569
|
+
console.log(`📄 Document template changed: ${basename(change.name)}`);
|
|
570
|
+
const reconcileResult = await reconcileByTemplate(change.name, allArticles, sourceDir);
|
|
571
|
+
if (reconcileResult.updated > 0 || reconcileResult.conflicts > 0) {
|
|
572
|
+
console.log(` ${reconcileResult.updated} auto-merged, ${reconcileResult.conflicts} conflicts`);
|
|
573
|
+
reconcileResult.affectedPaths.forEach(p => affectedDocPaths.add(p));
|
|
574
|
+
}
|
|
575
|
+
if (reconcileResult.conflicts > 0) {
|
|
576
|
+
for (const msg of reconcileResult.messages) {
|
|
577
|
+
if (msg.includes('Conflict')) console.warn(` ⚠️ ${msg}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// --- 6) Handle article changes via fast single-file regen ---
|
|
584
|
+
// Deduplicate articles (same file may appear multiple times in rapid saves)
|
|
585
|
+
// Exclude _templates files from direct article regeneration (they aren't rendered)
|
|
586
|
+
const uniqueArticles = [...new Set(articleChanges.map(c => c.name))]
|
|
587
|
+
.filter(name => !isInsideTemplatesFolder(name));
|
|
588
|
+
for (const articlePath of uniqueArticles) {
|
|
589
|
+
affectedDocPaths.add(articlePath);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- 7) Handle other source changes → full rebuild ---
|
|
593
|
+
if (otherSourceChanges.length > 0) {
|
|
594
|
+
needsFullRebuild = true;
|
|
595
|
+
fullRebuildReason = `Non-standard source change: ${otherSourceChanges.map(c => basename(c.name || 'unknown')).join(', ')}`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// --- 8) Execute rebuild ---
|
|
599
|
+
if (needsFullRebuild) {
|
|
600
|
+
console.log(`📦 Full rebuild required: ${fullRebuildReason}`);
|
|
601
|
+
// Delete on-disk caches on EVERY full-rebuild path (not just menu/config):
|
|
602
|
+
// the content-hash skip only looks at article markdown, so without this a
|
|
603
|
+
// rebuild after a template/meta change would skip every unchanged article
|
|
604
|
+
// and leave stale HTML (see docs/changes/serve-logic.md, root cause #2)
|
|
605
|
+
const ursaDir = join(sourceDir, '.ursa');
|
|
606
|
+
try { await promises.unlink(join(ursaDir, 'content-hashes.json')); } catch {}
|
|
607
|
+
try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
|
|
608
|
+
clearWatchCache();
|
|
609
|
+
try {
|
|
610
|
+
const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _deferImages: true, _deferSearchIndex: true });
|
|
611
|
+
console.log("HTML regeneration complete.");
|
|
612
|
+
if (result?.deferredImageProcessing) {
|
|
613
|
+
result.deferredImageProcessing.then(() => console.log("Image preview generation complete.")).catch(e => console.error("Image processing error:", e.message));
|
|
614
|
+
}
|
|
615
|
+
if (result?.deferredSearchIndex) {
|
|
616
|
+
result.deferredSearchIndex.then(() => console.log("Search index generation complete.")).catch(e => console.error("Search index error:", e.message));
|
|
617
|
+
}
|
|
618
|
+
// Full rebuild: reload all clients
|
|
619
|
+
broadcastReload(uniqueNames[0]);
|
|
620
|
+
} catch (genError) {
|
|
621
|
+
console.error(`❌ Full rebuild failed:`, genError);
|
|
622
|
+
console.error(genError.stack);
|
|
623
|
+
// Still reload — fresh content may be partially written, better than stale
|
|
624
|
+
broadcastReload(uniqueNames[0]);
|
|
625
|
+
}
|
|
626
|
+
} else if (affectedDocPaths.size > 0) {
|
|
627
|
+
// Selective rebuild with priority ordering
|
|
628
|
+
const docPathsArray = [...affectedDocPaths];
|
|
629
|
+
|
|
630
|
+
// Determine which URLs clients are viewing, map to source paths for priority
|
|
631
|
+
const viewedUrls = getClientViewedUrls().map(normalizeUrl);
|
|
632
|
+
const priorityPaths = [];
|
|
633
|
+
const affectedUrlSet = new Set();
|
|
634
|
+
|
|
635
|
+
for (const docPath of docPathsArray) {
|
|
636
|
+
const urls = docPathToUrls(docPath, sourceDir + '/');
|
|
637
|
+
for (const url of urls) {
|
|
638
|
+
affectedUrlSet.add(url);
|
|
639
|
+
if (viewedUrls.includes(url) && !priorityPaths.includes(docPath)) {
|
|
640
|
+
priorityPaths.push(docPath);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
console.log(`🔀 Selective rebuild: ${docPathsArray.length} docs, ${priorityPaths.length} priority`);
|
|
646
|
+
if (priorityPaths.length > 0) {
|
|
647
|
+
console.log(` Priority: ${priorityPaths.map(p => basename(p)).join(', ')}`);
|
|
648
|
+
}
|
|
649
|
+
console.log(` Client URLs: ${viewedUrls.join(', ') || '(none)'}`);
|
|
650
|
+
console.log(` Affected URLs: ${[...affectedUrlSet].slice(0, 5).join(', ')}${affectedUrlSet.size > 5 ? ` +${affectedUrlSet.size - 5} more` : ''}`);
|
|
651
|
+
|
|
652
|
+
// Notify clients whether the change affects them
|
|
653
|
+
if (affectedUrlSet.size > 0) {
|
|
654
|
+
sendToClientsViewing({ type: 'update-affects-you', timestamp: Date.now() }, affectedUrlSet);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const regenResult = await regenerateAffectedDocuments(docPathsArray, {
|
|
658
|
+
_source: sourceDir, _meta: metaDir, _output: outputDir,
|
|
659
|
+
reason: `batch: ${uniqueNames.map(n => basename(n)).join(', ')}`,
|
|
660
|
+
priorityPaths,
|
|
661
|
+
onPriorityComplete: ({ regenerated, failed, priorityDocs }) => {
|
|
662
|
+
if (regenerated > 0) {
|
|
663
|
+
// Immediately reload clients whose pages are now ready
|
|
664
|
+
const readyUrls = new Set(priorityDocs.flatMap(p => docPathToUrls(p, sourceDir + '/')));
|
|
665
|
+
console.log(`⚡ Priority complete: ${regenerated} OK, ${failed} failed → reloading clients`);
|
|
666
|
+
reloadAffectedClients(readyUrls, uniqueNames[0]);
|
|
667
|
+
} else if (failed > 0) {
|
|
668
|
+
console.warn(`⚠️ Priority regen failed for all ${failed} docs — not reloading yet`);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// After all remaining docs are done, reload any remaining affected clients
|
|
674
|
+
// (non-priority clients that weren't reloaded during onPriorityComplete)
|
|
675
|
+
const priorityUrlSet = new Set(priorityPaths.flatMap(p => docPathToUrls(p, sourceDir + '/')));
|
|
676
|
+
const remainingUrls = new Set([...affectedUrlSet].filter(u => !priorityUrlSet.has(u)));
|
|
677
|
+
if (remainingUrls.size > 0) {
|
|
678
|
+
reloadAffectedClients(remainingUrls, uniqueNames[0]);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// If priority docs all failed, try reloading anyway now that remaining are done
|
|
682
|
+
if (priorityPaths.length > 0 && regenResult.regenerated > 0) {
|
|
683
|
+
const failedPriorityUrls = new Set();
|
|
684
|
+
// Check if any priority was among the failed — reload all affected as fallback
|
|
685
|
+
for (const pp of priorityPaths) {
|
|
686
|
+
failedPriorityUrls.add(docPathToUrl(pp, sourceDir + '/'));
|
|
687
|
+
}
|
|
688
|
+
// If regeneration succeeded overall, make sure all priority clients got reloaded
|
|
689
|
+
for (const [client, clientUrl] of clientUrls) {
|
|
690
|
+
if (client.readyState === 1 && clientUrl && failedPriorityUrls.has(normalizeUrl(clientUrl))) {
|
|
691
|
+
// Client might not have been reloaded if their specific doc failed but others succeeded
|
|
692
|
+
// The onPriorityComplete callback should have handled this, this is a safety net
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Clear indicator for clients not affected at all
|
|
698
|
+
for (const [client, clientUrl] of clientUrls) {
|
|
699
|
+
if (client.readyState === 1 && clientUrl && !affectedUrlSet.has(normalizeUrl(clientUrl))) {
|
|
700
|
+
client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
// No documents affected (e.g. static-only changes) — reload all clients
|
|
705
|
+
// (meta static assets were re-copied to output/public in step 4)
|
|
706
|
+
if (staticChanges.length > 0 || metaStaticChanges.length > 0) {
|
|
707
|
+
broadcastReload(uniqueNames[0]);
|
|
708
|
+
} else {
|
|
709
|
+
// Nothing to do — clear indicators
|
|
710
|
+
broadcastMessage({ type: 'update-no-affect', timestamp: Date.now() });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} catch (error) {
|
|
714
|
+
console.error(`❌ Error during batch processing:`, error);
|
|
715
|
+
console.error(error.stack);
|
|
716
|
+
// Reload clients as fallback — stale content with a reload is better than a stuck spinner
|
|
717
|
+
broadcastReload();
|
|
718
|
+
} finally {
|
|
719
|
+
isRegenerating = false;
|
|
720
|
+
// Process changes that arrived during this pass (sequentially, never dropped)
|
|
721
|
+
if (queuedDuringRegeneration.length > 0) {
|
|
722
|
+
const nextBatch = queuedDuringRegeneration.splice(0);
|
|
723
|
+
console.log(`▶️ Processing ${nextBatch.length} change(s) queued during the last pass`);
|
|
724
|
+
setImmediate(() => processChangeBatch(nextBatch, sourceDir, metaDir, outputDir, _whitelist, _exclude));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Meta changes: queue for debounced batch processing.
|
|
730
|
+
// Includes static asset extensions (images, fonts, media, PDFs) so that
|
|
731
|
+
// replacing e.g. a PNG or font in meta/ re-runs copyMetaAssets + re-bundling
|
|
732
|
+
// instead of being invisible to the watcher.
|
|
733
|
+
watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml|jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i }, (evt, name) => {
|
|
734
|
+
queueChange(evt, name, 'meta');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// Source changes: queue for debounced batch processing
|
|
738
|
+
watch(sourceDir, {
|
|
739
|
+
recursive: true,
|
|
740
|
+
filter: (f, skip) => {
|
|
741
|
+
// Skip .ursa folder (contains hash cache that gets updated during generation)
|
|
742
|
+
if (/[\/\\]\.ursa[\/\\]?/.test(f)) return skip;
|
|
743
|
+
// Watch article files, config files, and static assets
|
|
744
|
+
return /\.(js|json|css|html|md|mdx|txt|yml|yaml|tsx|ts|jsx|jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i.test(f);
|
|
745
|
+
}
|
|
746
|
+
}, (evt, name) => {
|
|
747
|
+
queueChange(evt, name, 'source');
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Start HTTP server to serve static files with hot reload support
|
|
753
|
+
* @param {string} outputDir - Directory to serve files from
|
|
754
|
+
* @param {number} port - HTTP server port
|
|
755
|
+
* @returns {object} Object containing httpServer and wsPort
|
|
756
|
+
*/
|
|
757
|
+
function serveFiles(outputDir, port = 8080) {
|
|
758
|
+
const app = express();
|
|
759
|
+
const wsPort = port + 1; // WebSocket on port+1
|
|
760
|
+
|
|
761
|
+
// Enable gzip compression for all responses
|
|
762
|
+
// This significantly reduces transfer size for JSON and HTML files
|
|
763
|
+
app.use(compression({
|
|
764
|
+
// Compress everything over 1KB
|
|
765
|
+
threshold: 1024,
|
|
766
|
+
// Use default compression level (good balance of speed vs size)
|
|
767
|
+
level: 6
|
|
768
|
+
}));
|
|
769
|
+
|
|
770
|
+
// Add ursa-version and doc-version headers to all JSON responses
|
|
771
|
+
// (per-document JSON, directory index arrays, and public/*.json index files)
|
|
772
|
+
app.use((req, res, next) => {
|
|
773
|
+
if (req.path.endsWith('.json')) {
|
|
774
|
+
const meta = watchModeCache.ursaMetadata || {};
|
|
775
|
+
res.setHeader('X-ursa-version', meta.ursaVersion || 'unknown');
|
|
776
|
+
res.setHeader('X-doc-version', meta.docVersion || 'unknown');
|
|
777
|
+
}
|
|
778
|
+
next();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Middleware to inject hot reload script into HTML responses
|
|
782
|
+
app.use(async (req, res, next) => {
|
|
783
|
+
// Only intercept HTML requests
|
|
784
|
+
const url = req.url;
|
|
785
|
+
const isHtmlRequest = url.endsWith('.html') ||
|
|
786
|
+
url.endsWith('/') ||
|
|
787
|
+
!url.includes('.') ||
|
|
788
|
+
url === '/';
|
|
789
|
+
|
|
790
|
+
if (!isHtmlRequest) {
|
|
791
|
+
return next();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Determine the file path
|
|
795
|
+
let filePath;
|
|
796
|
+
if (url === '/' || url.endsWith('/')) {
|
|
797
|
+
filePath = join(outputDir, url, 'index.html');
|
|
798
|
+
} else if (url.endsWith('.html')) {
|
|
799
|
+
filePath = join(outputDir, url);
|
|
800
|
+
} else {
|
|
801
|
+
// Try adding .html extension
|
|
802
|
+
filePath = join(outputDir, url + '.html');
|
|
803
|
+
if (!fs.existsSync(filePath)) {
|
|
804
|
+
filePath = join(outputDir, url, 'index.html');
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
if (fs.existsSync(filePath)) {
|
|
810
|
+
let html = await readFile(filePath, 'utf8');
|
|
811
|
+
// Inject hot reload script before </body>
|
|
812
|
+
const hotReloadScript = getHotReloadScript(wsPort);
|
|
813
|
+
if (html.includes('</body>')) {
|
|
814
|
+
html = html.replace('</body>', hotReloadScript + '</body>');
|
|
815
|
+
} else {
|
|
816
|
+
html += hotReloadScript;
|
|
817
|
+
}
|
|
818
|
+
res.setHeader('Content-Type', 'text/html');
|
|
819
|
+
res.send(html);
|
|
820
|
+
} else {
|
|
821
|
+
next();
|
|
822
|
+
}
|
|
823
|
+
} catch (error) {
|
|
824
|
+
next();
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// Fallback static file serving for non-HTML files
|
|
829
|
+
app.use(
|
|
830
|
+
express.static(outputDir, { extensions: ["html"], index: "index.html" })
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
// Create HTTP server
|
|
834
|
+
const httpServer = createServer(app);
|
|
835
|
+
|
|
836
|
+
// Create WebSocket server for hot reload
|
|
837
|
+
wss = new WebSocketServer({ port: wsPort });
|
|
838
|
+
|
|
839
|
+
wss.on('connection', (ws) => {
|
|
840
|
+
// Send a ping to keep connection alive
|
|
841
|
+
const pingInterval = setInterval(() => {
|
|
842
|
+
if (ws.readyState === 1) {
|
|
843
|
+
ws.ping();
|
|
844
|
+
}
|
|
845
|
+
}, 30000);
|
|
846
|
+
|
|
847
|
+
// Handle messages from the client (URL tracking)
|
|
848
|
+
ws.on('message', (data) => {
|
|
849
|
+
try {
|
|
850
|
+
const msg = JSON.parse(data.toString());
|
|
851
|
+
if (msg.type === 'url' && msg.url) {
|
|
852
|
+
clientUrls.set(ws, msg.url);
|
|
853
|
+
}
|
|
854
|
+
} catch (e) { /* ignore non-JSON messages */ }
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
ws.on('close', () => {
|
|
858
|
+
clearInterval(pingInterval);
|
|
859
|
+
clientUrls.delete(ws);
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
httpServer.listen(port, () => {
|
|
864
|
+
console.log(`🌐 Server listening on port ${port}`);
|
|
865
|
+
console.log(`🔥 Hot reload WebSocket on port ${wsPort}`);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
return { httpServer, wsPort };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* we're only interested in meta (and maybe, in the future, source)
|
|
873
|
+
* for src changes, we need the node process to restart
|
|
874
|
+
*/
|
|
875
|
+
function filter(filename, skip) {
|
|
876
|
+
// console.log("testing ", filename);
|
|
877
|
+
if (/\/build/.test(filename)) return skip;
|
|
878
|
+
if (/\/node_modules/.test(filename)) return skip;
|
|
879
|
+
if (/\.git/.test(filename)) return skip;
|
|
880
|
+
if (/\/src/.test(filename)) return skip;
|
|
881
|
+
if (/\/meta/.test(filename)) return true;
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Default serve function for backward compatibility (only run when executed directly)
|
|
886
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
887
|
+
const source = resolve(process.env.SOURCE ?? join(process.cwd(), "source"));
|
|
888
|
+
const meta = resolve(process.env.META ?? join(process.cwd(), "meta"));
|
|
889
|
+
const output = resolve(process.env.OUTPUT ?? join(process.cwd(), "build"));
|
|
890
|
+
|
|
891
|
+
console.log({ source, meta, output });
|
|
892
|
+
|
|
893
|
+
await generate({ _source: source, _meta: meta, _output: output });
|
|
894
|
+
console.log("done generating. now serving...");
|
|
895
|
+
|
|
896
|
+
serveFiles(output);
|
|
897
|
+
|
|
898
|
+
watch(meta, { recursive: true }, async (evt, name) => {
|
|
899
|
+
console.log("meta files changed! generating output");
|
|
900
|
+
await generate({ _source: source, _meta: meta, _output: output });
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
watch(source, { recursive: true }, async (evt, name) => {
|
|
904
|
+
console.log("source files changed! generating output");
|
|
905
|
+
await generate({ _source: source, _meta: meta, _output: output });
|
|
906
|
+
});
|
|
907
|
+
}
|