@stati/core 1.20.3 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/config/loader.d.ts +4 -0
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/loader.js +49 -4
  4. package/dist/core/build.d.ts +6 -0
  5. package/dist/core/build.d.ts.map +1 -1
  6. package/dist/core/build.js +176 -66
  7. package/dist/core/dev.d.ts.map +1 -1
  8. package/dist/core/dev.js +100 -28
  9. package/dist/core/isg/builder.d.ts +4 -1
  10. package/dist/core/isg/builder.d.ts.map +1 -1
  11. package/dist/core/isg/builder.js +89 -2
  12. package/dist/core/isg/deps.d.ts +5 -0
  13. package/dist/core/isg/deps.d.ts.map +1 -1
  14. package/dist/core/isg/deps.js +38 -3
  15. package/dist/core/isg/dev-server-lock.d.ts +85 -0
  16. package/dist/core/isg/dev-server-lock.d.ts.map +1 -0
  17. package/dist/core/isg/dev-server-lock.js +248 -0
  18. package/dist/core/isg/hash.d.ts +4 -0
  19. package/dist/core/isg/hash.d.ts.map +1 -1
  20. package/dist/core/isg/hash.js +24 -1
  21. package/dist/core/isg/index.d.ts +3 -2
  22. package/dist/core/isg/index.d.ts.map +1 -1
  23. package/dist/core/isg/index.js +3 -2
  24. package/dist/core/markdown.d.ts +6 -0
  25. package/dist/core/markdown.d.ts.map +1 -1
  26. package/dist/core/markdown.js +23 -0
  27. package/dist/core/preview.js +1 -1
  28. package/dist/core/templates.js +5 -5
  29. package/dist/core/utils/bundle-matching.utils.d.ts +2 -0
  30. package/dist/core/utils/bundle-matching.utils.d.ts.map +1 -1
  31. package/dist/core/utils/index.d.ts +1 -1
  32. package/dist/core/utils/index.d.ts.map +1 -1
  33. package/dist/core/utils/index.js +1 -1
  34. package/dist/core/utils/logger.utils.d.ts.map +1 -1
  35. package/dist/core/utils/logger.utils.js +1 -0
  36. package/dist/core/utils/partial-validation.utils.js +2 -2
  37. package/dist/core/utils/paths.utils.d.ts +18 -0
  38. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  39. package/dist/core/utils/paths.utils.js +23 -0
  40. package/dist/core/utils/tailwind-inventory.utils.d.ts +1 -16
  41. package/dist/core/utils/tailwind-inventory.utils.d.ts.map +1 -1
  42. package/dist/core/utils/tailwind-inventory.utils.js +35 -3
  43. package/dist/core/utils/typescript.utils.d.ts +13 -0
  44. package/dist/core/utils/typescript.utils.d.ts.map +1 -1
  45. package/dist/core/utils/typescript.utils.js +82 -3
  46. package/dist/env.d.ts +45 -0
  47. package/dist/env.d.ts.map +1 -1
  48. package/dist/env.js +51 -0
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +2 -2
  52. package/dist/metrics/index.d.ts +1 -1
  53. package/dist/metrics/index.d.ts.map +1 -1
  54. package/dist/metrics/index.js +2 -0
  55. package/dist/metrics/recorder.d.ts.map +1 -1
  56. package/dist/metrics/types.d.ts +31 -0
  57. package/dist/metrics/types.d.ts.map +1 -1
  58. package/dist/metrics/utils/html-report.utils.d.ts +24 -0
  59. package/dist/metrics/utils/html-report.utils.d.ts.map +1 -0
  60. package/dist/metrics/utils/html-report.utils.js +1547 -0
  61. package/dist/metrics/utils/index.d.ts +1 -0
  62. package/dist/metrics/utils/index.d.ts.map +1 -1
  63. package/dist/metrics/utils/index.js +2 -0
  64. package/dist/metrics/utils/writer.utils.d.ts +6 -2
  65. package/dist/metrics/utils/writer.utils.d.ts.map +1 -1
  66. package/dist/metrics/utils/writer.utils.js +20 -4
  67. package/dist/search/generator.d.ts +1 -9
  68. package/dist/search/generator.d.ts.map +1 -1
  69. package/dist/search/generator.js +26 -2
  70. package/dist/seo/generator.d.ts.map +1 -1
  71. package/dist/seo/generator.js +1 -0
  72. package/dist/seo/utils/escape-and-validation.utils.d.ts.map +1 -1
  73. package/dist/seo/utils/escape-and-validation.utils.js +1 -16
  74. package/dist/types/logging.d.ts +31 -12
  75. package/dist/types/logging.d.ts.map +1 -1
  76. package/package.json +1 -1
package/dist/core/dev.js CHANGED
@@ -6,7 +6,7 @@ import chokidar from 'chokidar';
6
6
  import { build } from './build.js';
7
7
  import { invalidate } from './invalidate.js';
8
8
  import { loadConfig } from '../config/loader.js';
9
- import { loadCacheManifest, saveCacheManifest, computeNavigationHash } from './isg/index.js';
9
+ import { loadCacheManifest, saveCacheManifest, computeNavigationHash, DevServerLockManager, } from './isg/index.js';
10
10
  import { loadContent } from './content.js';
11
11
  import { buildNavigation } from './navigation.js';
12
12
  import { resolveDevPaths, resolveCacheDir, resolvePrettyUrl, createErrorOverlay, parseErrorDetails, TemplateError, createFallbackLogger, mergeServerOptions, createTypeScriptWatcher, normalizePathForComparison, isPathWithinDirectory, } from './utils/index.js';
@@ -38,7 +38,7 @@ async function loadDevConfig(configPath, logger) {
38
38
  async function performInitialBuild(configPath, logger, onError) {
39
39
  try {
40
40
  // Clear cache to ensure fresh build on dev server start
41
- logger.info?.('Clearing cache for fresh development build...');
41
+ logger.status('Clearing cache for fresh development build...');
42
42
  await invalidate();
43
43
  await build({
44
44
  logger,
@@ -87,15 +87,29 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
87
87
  const startTime = Date.now();
88
88
  // All changes being processed in this build (primary + batched)
89
89
  const allChanges = [{ path: changedPath, eventType }, ...batchedChanges];
90
- // Create a quiet logger for dev builds that suppresses verbose output
91
- const devLogger = {
92
- info: () => { }, // Suppress info messages
93
- success: () => { }, // Suppress success messages
94
- error: logger.error || (() => { }),
95
- warning: logger.warning || (() => { }),
96
- building: () => { }, // Suppress building messages
97
- processing: () => { }, // Suppress processing messages
98
- stats: () => { }, // Suppress stats messages
90
+ // Create a dev logger that shows progress for template rebuilds
91
+ // but suppresses other verbose output
92
+ const createDevLogger = (showProgress) => {
93
+ const baseLogger = {
94
+ info: () => { }, // Suppress info messages
95
+ success: () => { }, // Suppress success messages
96
+ error: logger.error || (() => { }),
97
+ warning: logger.warning || (() => { }),
98
+ status: () => { }, // Suppress status messages
99
+ building: () => { }, // Suppress building messages
100
+ processing: () => { }, // Suppress processing messages
101
+ stats: () => { }, // Suppress stats messages
102
+ };
103
+ // Enable progress tracking for template rebuilds (but not the full summary)
104
+ if (showProgress && logger.startProgress && logger.updateProgress && logger.endProgress) {
105
+ return {
106
+ ...baseLogger,
107
+ startProgress: logger.startProgress,
108
+ updateProgress: logger.updateProgress,
109
+ endProgress: logger.endProgress,
110
+ };
111
+ }
112
+ return baseLogger;
99
113
  };
100
114
  // Helper to check if a path is a static asset
101
115
  const normalizedStaticDir = staticDir.replace(/\\/g, '/');
@@ -105,16 +119,22 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
105
119
  };
106
120
  // Helper to get relative path
107
121
  const getRelativePath = (path) => path.replace(process.cwd(), '').replace(/\\/g, '/').replace(/^\//, '');
122
+ // Track template change result for better output
123
+ let templateChangeResult = null;
108
124
  try {
109
125
  // Check if the changed file is a template/partial
110
126
  if (changedPath.endsWith(TEMPLATE_EXTENSION) || changedPath.includes('_partials')) {
111
- await handleTemplateChange(changedPath, configPath, devLogger);
127
+ // Use progress-enabled logger for template changes (rebuilds multiple pages)
128
+ const devLogger = createDevLogger(true);
129
+ templateChangeResult = await handleTemplateChange(changedPath, configPath, devLogger);
112
130
  }
113
131
  else if (changedPath.endsWith('.md')) {
132
+ const devLogger = createDevLogger(false);
114
133
  await handleMarkdownChange(changedPath, configPath, devLogger);
115
134
  }
116
135
  else {
117
136
  // Static file changed - use normal rebuild
137
+ const devLogger = createDevLogger(false);
118
138
  await build({
119
139
  logger: devLogger,
120
140
  force: false,
@@ -137,7 +157,7 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
137
157
  });
138
158
  }
139
159
  const duration = Date.now() - startTime;
140
- // Log all files that were processed in this batch
160
+ // Log rebuild result with affected page info for template changes
141
161
  for (const change of allChanges) {
142
162
  const relativePath = getRelativePath(change.path);
143
163
  let action;
@@ -150,14 +170,22 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
150
170
  else {
151
171
  action = 'rebuilt';
152
172
  }
153
- logger.info?.(`⚡ ${relativePath} ${action}`);
173
+ // For template changes, include affected page count
174
+ if (templateChangeResult && templateChangeResult.affectedPages > 0) {
175
+ const { affectedPages, totalPages } = templateChangeResult;
176
+ logger.info?.(`▸ ${relativePath} ${action}`);
177
+ logger.info?.(` ${affectedPages}/${totalPages} pages rebuilt in ${duration}ms`);
178
+ }
179
+ else {
180
+ logger.info?.(`▸ ${relativePath} ${action}`);
181
+ logger.info?.(` Done in ${duration}ms`);
182
+ }
154
183
  }
155
- logger.info?.(` Done in ${duration}ms`);
156
184
  }
157
185
  catch (error) {
158
186
  const buildError = error instanceof Error ? error : new Error(String(error));
159
187
  const duration = Date.now() - startTime;
160
- logger.error?.(`❌ Rebuild failed after ${duration}ms: ${buildError.message}`);
188
+ logger.error?.( Rebuild failed after ${duration}ms: ${buildError.message}`);
161
189
  // Store the error for display in browser
162
190
  if (onError) {
163
191
  onError(buildError);
@@ -183,6 +211,8 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
183
211
  * Handles template/partial file changes by invalidating affected pages.
184
212
  * Uses proper path normalization to ensure reliable matching between
185
213
  * file watcher paths and cached dependency paths.
214
+ *
215
+ * @returns Object with affected and total page counts
186
216
  */
187
217
  async function handleTemplateChange(templatePath, configPath, logger) {
188
218
  const cacheDir = resolveCacheDir();
@@ -197,38 +227,50 @@ async function handleTemplateChange(templatePath, configPath, logger) {
197
227
  clean: false,
198
228
  ...(configPath && { configPath }),
199
229
  });
200
- return;
230
+ return { affectedPages: 0, totalPages: 0 };
201
231
  }
202
232
  // Normalize the changed template path to absolute POSIX format for reliable comparison
203
233
  // This handles cases where the watcher provides relative paths, Windows paths, or different
204
234
  // path representations than what's stored in the cache manifest
235
+ // NOTE: Only used for string comparison, never for file system operations
205
236
  const normalizedTemplatePath = normalizePathForComparison(templatePath);
237
+ // Get total page count from manifest
238
+ const totalPages = Object.keys(cacheManifest.entries).length;
206
239
  // Find pages that depend on this template
207
240
  let affectedPagesCount = 0;
208
241
  for (const [pagePath, entry] of Object.entries(cacheManifest.entries)) {
209
242
  // Check if any of the page's dependencies match the changed template
210
243
  const hasMatchingDep = entry.deps.some((dep) => {
211
- // Normalize the cached dependency path to the same format
244
+ // Normalize the cached dependency path to the same format for comparison only
212
245
  const normalizedDep = normalizePathForComparison(dep);
213
246
  // Direct path comparison - both paths are now in consistent format
214
247
  return normalizedDep === normalizedTemplatePath;
215
248
  });
216
249
  if (hasMatchingDep) {
217
250
  affectedPagesCount++;
218
- // Remove from cache to force rebuild
219
- delete cacheManifest.entries[pagePath];
251
+ // Mark entry as stale by invalidating its inputsHash
252
+ // This forces a rebuild while preserving deps for fast update optimization
253
+ cacheManifest.entries[pagePath] = {
254
+ ...entry,
255
+ inputsHash: 'STALE', // Forces rebuild in shouldRebuildPage
256
+ };
220
257
  }
221
258
  }
222
259
  if (affectedPagesCount > 0) {
223
260
  // Save updated cache manifest
224
261
  await saveCacheManifest(cacheDir, cacheManifest);
225
262
  // Perform incremental rebuild (only affected pages will be rebuilt)
263
+ // skipManifestSave: true because we already saved the manifest above
264
+ // skipAssetCopy: true because template changes don't affect static assets
226
265
  await build({
227
266
  logger,
228
267
  force: false,
229
268
  clean: false,
269
+ skipManifestSave: true,
270
+ skipAssetCopy: true,
230
271
  ...(configPath && { configPath }),
231
272
  });
273
+ return { affectedPages: affectedPagesCount, totalPages };
232
274
  }
233
275
  else {
234
276
  // If no affected pages were found but a template changed,
@@ -240,6 +282,7 @@ async function handleTemplateChange(templatePath, configPath, logger) {
240
282
  clean: false,
241
283
  ...(configPath && { configPath }),
242
284
  });
285
+ return { affectedPages: totalPages, totalPages };
243
286
  }
244
287
  }
245
288
  catch (_error) {
@@ -255,6 +298,8 @@ async function handleTemplateChange(templatePath, configPath, logger) {
255
298
  catch (fallbackError) {
256
299
  throw fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError));
257
300
  }
301
+ // Return zeros on error fallback
302
+ return { affectedPages: 0, totalPages: 0 };
258
303
  }
259
304
  }
260
305
  /**
@@ -286,7 +331,7 @@ async function handleMarkdownChange(_markdownPath, configPath, logger) {
286
331
  // Compare navigation hashes
287
332
  if (newNavigationHash !== cacheManifest.navigationHash) {
288
333
  // Navigation structure changed - clear cache and force full rebuild
289
- logger.info?.('📊 Navigation structure changed, performing full rebuild...');
334
+ logger.status('Navigation structure changed, performing full rebuild...');
290
335
  // Force rebuild bypasses ISG cache entirely
291
336
  await build({
292
337
  logger,
@@ -336,6 +381,9 @@ export async function createDevServer(options = {}) {
336
381
  },
337
382
  });
338
383
  setEnv('development');
384
+ // Create dev server lock manager
385
+ const cacheDir = resolveCacheDir();
386
+ const devLock = new DevServerLockManager(cacheDir);
339
387
  const url = `http://${host}:${port}`;
340
388
  let httpServer = null;
341
389
  let wsServer = null;
@@ -386,7 +434,7 @@ export async function createDevServer(options = {}) {
386
434
  ws.onmessage = function(event) {
387
435
  const data = JSON.parse(event.data);
388
436
  if (data.type === 'reload') {
389
- console.log(' Reloading page due to file changes...');
437
+ console.log(' Reloading page due to file changes...');
390
438
  window.location.reload();
391
439
  }
392
440
  };
@@ -531,18 +579,40 @@ export async function createDevServer(options = {}) {
531
579
  const devServer = {
532
580
  url,
533
581
  async start() {
582
+ // Acquire dev server lock to prevent multiple dev servers in the same directory
583
+ try {
584
+ await devLock.acquireLock();
585
+ }
586
+ catch (error) {
587
+ const message = error instanceof Error ? error.message : String(error);
588
+ logger.error?.(`Failed to start dev server:\n${message}`);
589
+ throw error;
590
+ }
534
591
  // Perform initial build
535
592
  await performInitialBuild(configPath, logger, setLastBuildError);
536
593
  // Create HTTP server
537
594
  httpServer = createServer(async (req, res) => {
538
595
  const requestPath = req.url || '/';
596
+ const requestStart = Date.now();
539
597
  // Handle WebSocket upgrade path
540
598
  if (requestPath === '/__ws') {
541
599
  return; // Let WebSocket server handle this
542
600
  }
543
- logger.processing?.(`${req.method} ${requestPath}`);
601
+ // Only log page requests, not static assets (files with extensions)
602
+ const hasFileExtension = requestPath.includes('.') && !requestPath.endsWith('.html');
603
+ if (!hasFileExtension) {
604
+ logger.processing?.(`${req.method} ${requestPath}`);
605
+ }
544
606
  try {
545
607
  const { content, mimeType, statusCode } = await serveFile(requestPath);
608
+ const responseTime = Date.now() - requestStart;
609
+ // Log slow responses with memory info
610
+ if (responseTime > 2000) {
611
+ const mem = process.memoryUsage();
612
+ const heapMB = Math.round(mem.heapUsed / 1024 / 1024);
613
+ const rssMB = Math.round(mem.rss / 1024 / 1024);
614
+ logger.warning?.(`Slow response: ${requestPath} took ${responseTime}ms (heap: ${heapMB}MB, rss: ${rssMB}MB)`);
615
+ }
546
616
  res.writeHead(statusCode, {
547
617
  'Content-Type': mimeType,
548
618
  'Access-Control-Allow-Origin': '*',
@@ -595,7 +665,7 @@ export async function createDevServer(options = {}) {
595
665
  outDir: config.outDir || DEFAULT_OUT_DIR,
596
666
  logger,
597
667
  onRebuild: (_results, compileTimeMs) => {
598
- logger.info?.(`⚡ TypeScript recompiled in ${compileTimeMs}ms`);
668
+ logger.info?.(`▸ TypeScript recompiled in ${compileTimeMs}ms`);
599
669
  // Broadcast reload to WebSocket clients
600
670
  if (wsServer) {
601
671
  wsServer.clients.forEach((client) => {
@@ -615,7 +685,7 @@ export async function createDevServer(options = {}) {
615
685
  console.log();
616
686
  logger.error?.(`TypeScript setup failed: ${tsError.message}`);
617
687
  logger.warning?.('──────────────────────────────────────────────────────────────');
618
- logger.warning?.('⚠️ TypeScript hot reload is DISABLED for this session.');
688
+ logger.warning?.('! TypeScript hot reload is DISABLED for this session.');
619
689
  logger.warning?.(" Dev server will continue, but TypeScript changes won't auto-reload.");
620
690
  logger.warning?.(' Fix your TypeScript configuration and restart the dev server.');
621
691
  logger.warning?.('──────────────────────────────────────────────────────────────');
@@ -638,7 +708,7 @@ export async function createDevServer(options = {}) {
638
708
  });
639
709
  cssWatcher.on('change', (path) => {
640
710
  const relativePath = path.replace(process.cwd(), '').replace(/\\/g, '/').replace(/^\//, '');
641
- logger.info?.(`⚡ ${relativePath} updated`);
711
+ logger.info?.(`▸ ${relativePath} updated`);
642
712
  // Just notify clients to reload - no rebuild needed since CSS was already compiled
643
713
  if (wsServer) {
644
714
  wsServer.clients.forEach((client) => {
@@ -660,9 +730,9 @@ export async function createDevServer(options = {}) {
660
730
  });
661
731
  logger.success?.(`Dev server running at ${url}`);
662
732
  logger.info?.(`\nServing:`);
663
- logger.info?.(` 📁 ${outDir}`);
733
+ logger.info?.(` ${outDir}`);
664
734
  logger.info?.('Watching:');
665
- watchPaths.forEach((path) => logger.info?.(` 📁 ${path}`));
735
+ watchPaths.forEach((path) => logger.info?.(` ${path}`));
666
736
  logger.info?.('');
667
737
  // Open browser if requested
668
738
  if (open) {
@@ -679,6 +749,8 @@ export async function createDevServer(options = {}) {
679
749
  if (isStopping)
680
750
  return;
681
751
  isStopping = true;
752
+ // Release dev server lock first to allow other servers to start
753
+ await devLock.releaseLock();
682
754
  if (watcher) {
683
755
  await watcher.close();
684
756
  watcher = null;
@@ -35,11 +35,14 @@ export declare function shouldRebuildPage(page: PageModel, entry: CacheEntry | u
35
35
  export declare function createCacheEntry(page: PageModel, config: StatiConfig, renderedAt: Date): Promise<CacheEntry>;
36
36
  /**
37
37
  * Updates an existing cache entry with new information after rebuilding.
38
+ * In dev mode, this is optimized to reuse the existing deps array since
39
+ * template structure rarely changes between incremental rebuilds.
38
40
  *
39
41
  * @param entry - Existing cache entry
40
42
  * @param page - The page model
41
43
  * @param config - Stati configuration
42
44
  * @param renderedAt - When the page was rendered
45
+ * @param fastUpdate - If true, skip expensive dep tracking (dev mode optimization)
43
46
  * @returns Updated cache entry
44
47
  *
45
48
  * @example
@@ -47,5 +50,5 @@ export declare function createCacheEntry(page: PageModel, config: StatiConfig, r
47
50
  * const updatedEntry = await updateCacheEntry(existingEntry, page, config, new Date());
48
51
  * ```
49
52
  */
50
- export declare function updateCacheEntry(entry: CacheEntry, page: PageModel, config: StatiConfig, renderedAt: Date): Promise<CacheEntry>;
53
+ export declare function updateCacheEntry(entry: CacheEntry, page: PageModel, config: StatiConfig, renderedAt: Date, fastUpdate?: boolean): Promise<CacheEntry>;
51
54
  //# sourceMappingURL=builder.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../../../src/core/isg/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAmE/E;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,MAAM,EAAE,WAAW,EACnB,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,OAAO,CAAC,CAgKlB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,GACf,OAAO,CAAC,UAAU,CAAC,CA2ErB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,GACf,OAAO,CAAC,UAAU,CAAC,CAUrB"}
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../../../src/core/isg/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAoE/E;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,MAAM,EAAE,WAAW,EACnB,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,OAAO,CAAC,CAgKlB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,GACf,OAAO,CAAC,UAAU,CAAC,CAqFrB;AAkDD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,EAChB,UAAU,UAAQ,GACjB,OAAO,CAAC,UAAU,CAAC,CA4CrB"}
@@ -2,6 +2,7 @@ import { computeContentHash, computeFileHash, computeInputsHash } from './hash.j
2
2
  import { trackTemplateDependencies } from './deps.js';
3
3
  import { computeEffectiveTTL, computeNextRebuildAt, isPageFrozen } from './ttl.js';
4
4
  import { validatePageISGOverrides, extractNumericOverride } from './validation.js';
5
+ import { performance } from 'node:perf_hooks';
5
6
  /**
6
7
  * Determines the output path for a page.
7
8
  */
@@ -214,13 +215,21 @@ export async function shouldRebuildPage(page, entry, config, now) {
214
215
  * ```
215
216
  */
216
217
  export async function createCacheEntry(page, config, renderedAt) {
218
+ const timings = {};
219
+ let start = performance.now();
217
220
  // Validate page-level ISG overrides first
218
221
  validatePageISGOverrides(page.frontMatter, page.sourcePath);
222
+ timings.validate = performance.now() - start;
219
223
  // Compute content hash
224
+ start = performance.now();
220
225
  const contentHash = computeContentHash(page.content, page.frontMatter);
226
+ timings.contentHash = performance.now() - start;
221
227
  // Track all template dependencies
228
+ start = performance.now();
222
229
  const deps = await trackTemplateDependencies(page, config);
230
+ timings.trackDeps = performance.now() - start;
223
231
  // Compute hashes for all dependencies
232
+ start = performance.now();
224
233
  const depsHashes = [];
225
234
  for (const dep of deps) {
226
235
  const depHash = await computeFileHash(dep);
@@ -228,6 +237,7 @@ export async function createCacheEntry(page, config, renderedAt) {
228
237
  depsHashes.push(depHash);
229
238
  }
230
239
  }
240
+ timings.depsHashes = performance.now() - start;
231
241
  const inputsHash = computeInputsHash(contentHash, depsHashes);
232
242
  // Extract tags from front matter
233
243
  let tags = [];
@@ -271,13 +281,60 @@ export async function createCacheEntry(page, config, renderedAt) {
271
281
  }
272
282
  return cacheEntry;
273
283
  }
284
+ /**
285
+ * Checks if the template structure has changed by comparing the layout file's
286
+ * modification time against when the cache entry was last rendered.
287
+ * This detects when new includes/extends have been added to templates.
288
+ *
289
+ * @param entry - The existing cache entry
290
+ * @returns True if template structure may have changed, false if structure is unchanged
291
+ */
292
+ async function hasTemplateStructureChanged(entry) {
293
+ try {
294
+ // If the entry has a layout in its deps, check if that layout file has been modified
295
+ // since the last render. Layout files are typically the first dep or contain the
296
+ // includes/extends declarations.
297
+ if (entry.deps.length === 0) {
298
+ return false;
299
+ }
300
+ // Get the timestamp of the last render
301
+ const lastRendered = new Date(entry.renderedAt).getTime();
302
+ // Import stat from node:fs/promises at runtime to avoid circular dependency issues
303
+ const { stat } = await import('node:fs/promises');
304
+ // Check if any template files (layout or partials) have been modified
305
+ // We only need to check the first few deps (usually layout and immediate includes)
306
+ // as a heuristic to detect structural changes without expensive full tracking
307
+ const depsToCheck = entry.deps.slice(0, 3);
308
+ for (const depPath of depsToCheck) {
309
+ try {
310
+ const stats = await stat(depPath);
311
+ if (stats.mtimeMs > lastRendered) {
312
+ // Template file was modified after last render - structure may have changed
313
+ return true;
314
+ }
315
+ }
316
+ catch (_error) {
317
+ // If we can't stat the file, assume it might have changed
318
+ return true;
319
+ }
320
+ }
321
+ return false;
322
+ }
323
+ catch (_error) {
324
+ // On any error, be conservative and assume structure changed
325
+ return true;
326
+ }
327
+ }
274
328
  /**
275
329
  * Updates an existing cache entry with new information after rebuilding.
330
+ * In dev mode, this is optimized to reuse the existing deps array since
331
+ * template structure rarely changes between incremental rebuilds.
276
332
  *
277
333
  * @param entry - Existing cache entry
278
334
  * @param page - The page model
279
335
  * @param config - Stati configuration
280
336
  * @param renderedAt - When the page was rendered
337
+ * @param fastUpdate - If true, skip expensive dep tracking (dev mode optimization)
281
338
  * @returns Updated cache entry
282
339
  *
283
340
  * @example
@@ -285,8 +342,38 @@ export async function createCacheEntry(page, config, renderedAt) {
285
342
  * const updatedEntry = await updateCacheEntry(existingEntry, page, config, new Date());
286
343
  * ```
287
344
  */
288
- export async function updateCacheEntry(entry, page, config, renderedAt) {
289
- // Create a new entry and preserve the original publishedAt if not overridden
345
+ export async function updateCacheEntry(entry, page, config, renderedAt, fastUpdate = false) {
346
+ // In fast update mode (dev), reuse existing deps to avoid expensive tracking
347
+ if (fastUpdate && entry.deps.length > 0) {
348
+ // Detect if template structure has changed (new includes/extends added)
349
+ // by checking if the layout file has been modified or if template content
350
+ // contains different dependency patterns
351
+ const structureChanged = await hasTemplateStructureChanged(entry);
352
+ if (structureChanged) {
353
+ // Template structure changed - fall through to full dependency tracking
354
+ // This ensures we pick up new includes/extends
355
+ }
356
+ else {
357
+ // Structure unchanged - safe to reuse existing deps
358
+ // Just update the content hash and timestamp, keep existing deps
359
+ const contentHash = computeContentHash(page.content, page.frontMatter);
360
+ // Compute hashes for existing dependencies (fast - files are cached)
361
+ const depsHashes = [];
362
+ for (const dep of entry.deps) {
363
+ const depHash = await computeFileHash(dep);
364
+ if (depHash) {
365
+ depsHashes.push(depHash);
366
+ }
367
+ }
368
+ const inputsHash = computeInputsHash(contentHash, depsHashes);
369
+ return {
370
+ ...entry,
371
+ inputsHash,
372
+ renderedAt: renderedAt.toISOString(),
373
+ };
374
+ }
375
+ }
376
+ // Full update - create new entry and preserve original publishedAt
290
377
  const newEntry = await createCacheEntry(page, config, renderedAt);
291
378
  // Preserve original publishedAt if no new one is specified
292
379
  if (!newEntry.publishedAt && entry.publishedAt) {
@@ -1,4 +1,9 @@
1
1
  import type { PageModel, StatiConfig } from '../../types/index.js';
2
+ /**
3
+ * Clears the template path and content caches.
4
+ * Should be called at the start of each build.
5
+ */
6
+ export declare function clearTemplatePathCache(): void;
2
7
  /**
3
8
  * Error thrown when a circular dependency is detected in templates.
4
9
  */
@@ -1 +1 @@
1
- {"version":3,"file":"deps.d.ts","sourceRoot":"","sources":["../../../src/core/isg/deps.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAGnE;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;aAE9B,eAAe,EAAE,MAAM,EAAE;gBAAzB,eAAe,EAAE,MAAM,EAAE,EACzC,OAAO,EAAE,MAAM;CAKlB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,EAAE,CAAC,CA+CnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CASxB"}
1
+ {"version":3,"file":"deps.d.ts","sourceRoot":"","sources":["../../../src/core/isg/deps.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAenE;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAG7C;AAED;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;aAE9B,eAAe,EAAE,MAAM,EAAE;gBAAzB,eAAe,EAAE,MAAM,EAAE,EACzC,OAAO,EAAE,MAAM;CAKlB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,EAAE,CAAC,CA+CnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CASxB"}
@@ -2,6 +2,24 @@ import { join, dirname, relative, posix } from 'node:path';
2
2
  import { pathExists, readFile, isCollectionIndexPage, discoverLayout, resolveSrcDir, } from '../utils/index.js';
3
3
  import glob from 'fast-glob';
4
4
  import { TEMPLATE_EXTENSION } from '../../constants.js';
5
+ /**
6
+ * Per-build cache for template path resolution (glob results).
7
+ * Cleared at the start of each build via clearTemplatePathCache().
8
+ */
9
+ const templatePathCache = new Map();
10
+ /**
11
+ * Per-build cache for template content reads.
12
+ * Cleared at the start of each build via clearTemplatePathCache().
13
+ */
14
+ const templateContentCache = new Map();
15
+ /**
16
+ * Clears the template path and content caches.
17
+ * Should be called at the start of each build.
18
+ */
19
+ export function clearTemplatePathCache() {
20
+ templatePathCache.clear();
21
+ templateContentCache.clear();
22
+ }
5
23
  /**
6
24
  * Error thrown when a circular dependency is detected in templates.
7
25
  */
@@ -132,8 +150,15 @@ async function collectTemplateDependencies(templatePath, srcDir, visited, curren
132
150
  currentPath.add(normalizedPath);
133
151
  visited.add(normalizedPath);
134
152
  try {
135
- // Read template content to find includes/extends
136
- const content = await readFile(templatePath, 'utf-8');
153
+ // Read template content to find includes/extends (use cache)
154
+ let content;
155
+ if (templateContentCache.has(normalizedPath)) {
156
+ content = templateContentCache.get(normalizedPath);
157
+ }
158
+ else {
159
+ content = await readFile(templatePath, 'utf-8');
160
+ templateContentCache.set(normalizedPath, content ?? null);
161
+ }
137
162
  if (!content) {
138
163
  return;
139
164
  }
@@ -274,12 +299,19 @@ async function parseTemplateDependencies(content, templatePath, srcDir) {
274
299
  * @returns Absolute path to template file, or null if not found
275
300
  */
276
301
  async function resolveTemplatePathInternal(templateRef, srcDir, currentDir) {
302
+ // Create cache key from all inputs
303
+ const cacheKey = `${templateRef}|${srcDir}|${currentDir ?? ''}`;
304
+ // Check cache first
305
+ if (templatePathCache.has(cacheKey)) {
306
+ return templatePathCache.get(cacheKey) ?? null;
307
+ }
277
308
  const templateName = templateRef.endsWith(TEMPLATE_EXTENSION)
278
309
  ? templateRef
279
310
  : `${templateRef}${TEMPLATE_EXTENSION}`;
280
311
  // Try absolute path from srcDir
281
312
  const absolutePath = join(srcDir, templateName);
282
313
  if (await pathExists(absolutePath)) {
314
+ templatePathCache.set(cacheKey, absolutePath);
283
315
  return absolutePath;
284
316
  }
285
317
  // Determine the starting directory for hierarchical search (relative to srcDir)
@@ -318,7 +350,9 @@ async function resolveTemplatePathInternal(templateRef, srcDir, currentDir) {
318
350
  const matches = await glob(pattern, { absolute: true });
319
351
  if (matches.length > 0) {
320
352
  // Normalize to POSIX format for consistent cross-platform path handling
321
- return matches[0].replace(/\\/g, '/');
353
+ const result = matches[0].replace(/\\/g, '/');
354
+ templatePathCache.set(cacheKey, result);
355
+ return result;
322
356
  }
323
357
  }
324
358
  catch {
@@ -326,5 +360,6 @@ async function resolveTemplatePathInternal(templateRef, srcDir, currentDir) {
326
360
  continue;
327
361
  }
328
362
  }
363
+ templatePathCache.set(cacheKey, null);
329
364
  return null;
330
365
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Dev server lock information stored in the lock file.
3
+ */
4
+ interface DevServerLock {
5
+ pid: number;
6
+ timestamp: string;
7
+ hostname?: string;
8
+ }
9
+ /**
10
+ * Manages dev server locking to prevent multiple dev servers from running in the same directory.
11
+ * Uses a simple file-based locking mechanism with process ID tracking.
12
+ */
13
+ export declare class DevServerLockManager {
14
+ private lockPath;
15
+ private isLocked;
16
+ private cleanupHandlersRegistered;
17
+ constructor(cacheDir: string);
18
+ /**
19
+ * Attempts to acquire a dev server lock.
20
+ * Throws an error if another dev server is already running.
21
+ *
22
+ * @throws {Error} When lock cannot be acquired
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const lockManager = new DevServerLockManager('.stati');
27
+ * try {
28
+ * await lockManager.acquireLock();
29
+ * // Start dev server
30
+ * } finally {
31
+ * await lockManager.releaseLock();
32
+ * }
33
+ * ```
34
+ */
35
+ acquireLock(): Promise<void>;
36
+ /**
37
+ * Releases the dev server lock if this process owns it.
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * await lockManager.releaseLock();
42
+ * ```
43
+ */
44
+ releaseLock(): Promise<void>;
45
+ /**
46
+ * Checks if a dev server lock is currently held by any process.
47
+ *
48
+ * @returns True if a lock exists and the owning process is running
49
+ */
50
+ isLockHeld(): Promise<boolean>;
51
+ /**
52
+ * Gets information about the current lock holder.
53
+ *
54
+ * @returns Lock information or null if no lock exists
55
+ */
56
+ getLockInfo(): Promise<DevServerLock | null>;
57
+ /**
58
+ * Force removes the lock file without checking ownership.
59
+ * Should only be used in error recovery scenarios.
60
+ */
61
+ private forceRemoveLock;
62
+ /**
63
+ * Creates a new lock file with current process information.
64
+ */
65
+ private createLockFile;
66
+ /**
67
+ * Reads and parses the lock file.
68
+ */
69
+ private readLockFile;
70
+ /**
71
+ * Checks if a process with the given PID is currently running.
72
+ */
73
+ private isProcessRunning;
74
+ /**
75
+ * Gets the hostname for lock identification.
76
+ */
77
+ private getHostname;
78
+ /**
79
+ * Registers process exit handlers to ensure lock is released.
80
+ * This prevents accidentally leaving the lock file behind.
81
+ */
82
+ private registerCleanupHandlers;
83
+ }
84
+ export {};
85
+ //# sourceMappingURL=dev-server-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev-server-lock.d.ts","sourceRoot":"","sources":["../../../src/core/isg/dev-server-lock.ts"],"names":[],"mappings":"AAYA;;GAEG;AACH,UAAU,aAAa;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,yBAAyB,CAAS;gBAE9B,QAAQ,EAAE,MAAM;IAI5B;;;;;;;;;;;;;;;;OAgBG;IACG,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAgDlC;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAqBlC;;;;OAIG;IACG,UAAU,IAAI,OAAO,CAAC,OAAO,CAAC;IAiBpC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAYlD;;;OAGG;YACW,eAAe;IAQ7B;;OAEG;YACW,cAAc;IAc5B;;OAEG;YACW,YAAY;IAY1B;;OAEG;YACW,gBAAgB;IAY9B;;OAEG;IACH,OAAO,CAAC,WAAW;IAQnB;;;OAGG;IACH,OAAO,CAAC,uBAAuB;CAkDhC"}