@stati/core 1.21.0 → 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 (65) 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 +134 -24
  7. package/dist/core/dev.d.ts.map +1 -1
  8. package/dist/core/dev.js +86 -19
  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/templates.js +5 -5
  28. package/dist/core/utils/index.d.ts +1 -1
  29. package/dist/core/utils/index.d.ts.map +1 -1
  30. package/dist/core/utils/index.js +1 -1
  31. package/dist/core/utils/partial-validation.utils.js +2 -2
  32. package/dist/core/utils/paths.utils.d.ts +18 -0
  33. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  34. package/dist/core/utils/paths.utils.js +23 -0
  35. package/dist/core/utils/tailwind-inventory.utils.d.ts +1 -16
  36. package/dist/core/utils/tailwind-inventory.utils.d.ts.map +1 -1
  37. package/dist/core/utils/tailwind-inventory.utils.js +35 -3
  38. package/dist/core/utils/typescript.utils.d.ts +9 -0
  39. package/dist/core/utils/typescript.utils.d.ts.map +1 -1
  40. package/dist/core/utils/typescript.utils.js +41 -0
  41. package/dist/env.d.ts +45 -0
  42. package/dist/env.d.ts.map +1 -1
  43. package/dist/env.js +51 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/metrics/index.d.ts +1 -1
  48. package/dist/metrics/index.d.ts.map +1 -1
  49. package/dist/metrics/index.js +2 -0
  50. package/dist/metrics/recorder.d.ts.map +1 -1
  51. package/dist/metrics/types.d.ts +31 -0
  52. package/dist/metrics/types.d.ts.map +1 -1
  53. package/dist/metrics/utils/html-report.utils.d.ts +24 -0
  54. package/dist/metrics/utils/html-report.utils.d.ts.map +1 -0
  55. package/dist/metrics/utils/html-report.utils.js +1547 -0
  56. package/dist/metrics/utils/index.d.ts +1 -0
  57. package/dist/metrics/utils/index.d.ts.map +1 -1
  58. package/dist/metrics/utils/index.js +2 -0
  59. package/dist/metrics/utils/writer.utils.d.ts +6 -2
  60. package/dist/metrics/utils/writer.utils.d.ts.map +1 -1
  61. package/dist/metrics/utils/writer.utils.js +20 -4
  62. package/dist/search/generator.d.ts +1 -9
  63. package/dist/search/generator.d.ts.map +1 -1
  64. package/dist/search/generator.js +26 -2
  65. 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';
@@ -87,16 +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
- status: () => { }, // Suppress status messages
97
- building: () => { }, // Suppress building messages
98
- processing: () => { }, // Suppress processing messages
99
- 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;
100
113
  };
101
114
  // Helper to check if a path is a static asset
102
115
  const normalizedStaticDir = staticDir.replace(/\\/g, '/');
@@ -106,16 +119,22 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
106
119
  };
107
120
  // Helper to get relative path
108
121
  const getRelativePath = (path) => path.replace(process.cwd(), '').replace(/\\/g, '/').replace(/^\//, '');
122
+ // Track template change result for better output
123
+ let templateChangeResult = null;
109
124
  try {
110
125
  // Check if the changed file is a template/partial
111
126
  if (changedPath.endsWith(TEMPLATE_EXTENSION) || changedPath.includes('_partials')) {
112
- 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);
113
130
  }
114
131
  else if (changedPath.endsWith('.md')) {
132
+ const devLogger = createDevLogger(false);
115
133
  await handleMarkdownChange(changedPath, configPath, devLogger);
116
134
  }
117
135
  else {
118
136
  // Static file changed - use normal rebuild
137
+ const devLogger = createDevLogger(false);
119
138
  await build({
120
139
  logger: devLogger,
121
140
  force: false,
@@ -138,7 +157,7 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
138
157
  });
139
158
  }
140
159
  const duration = Date.now() - startTime;
141
- // Log all files that were processed in this batch
160
+ // Log rebuild result with affected page info for template changes
142
161
  for (const change of allChanges) {
143
162
  const relativePath = getRelativePath(change.path);
144
163
  let action;
@@ -151,9 +170,17 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
151
170
  else {
152
171
  action = 'rebuilt';
153
172
  }
154
- 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
+ }
155
183
  }
156
- logger.info?.(` Done in ${duration}ms`);
157
184
  }
158
185
  catch (error) {
159
186
  const buildError = error instanceof Error ? error : new Error(String(error));
@@ -184,6 +211,8 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
184
211
  * Handles template/partial file changes by invalidating affected pages.
185
212
  * Uses proper path normalization to ensure reliable matching between
186
213
  * file watcher paths and cached dependency paths.
214
+ *
215
+ * @returns Object with affected and total page counts
187
216
  */
188
217
  async function handleTemplateChange(templatePath, configPath, logger) {
189
218
  const cacheDir = resolveCacheDir();
@@ -198,38 +227,50 @@ async function handleTemplateChange(templatePath, configPath, logger) {
198
227
  clean: false,
199
228
  ...(configPath && { configPath }),
200
229
  });
201
- return;
230
+ return { affectedPages: 0, totalPages: 0 };
202
231
  }
203
232
  // Normalize the changed template path to absolute POSIX format for reliable comparison
204
233
  // This handles cases where the watcher provides relative paths, Windows paths, or different
205
234
  // path representations than what's stored in the cache manifest
235
+ // NOTE: Only used for string comparison, never for file system operations
206
236
  const normalizedTemplatePath = normalizePathForComparison(templatePath);
237
+ // Get total page count from manifest
238
+ const totalPages = Object.keys(cacheManifest.entries).length;
207
239
  // Find pages that depend on this template
208
240
  let affectedPagesCount = 0;
209
241
  for (const [pagePath, entry] of Object.entries(cacheManifest.entries)) {
210
242
  // Check if any of the page's dependencies match the changed template
211
243
  const hasMatchingDep = entry.deps.some((dep) => {
212
- // Normalize the cached dependency path to the same format
244
+ // Normalize the cached dependency path to the same format for comparison only
213
245
  const normalizedDep = normalizePathForComparison(dep);
214
246
  // Direct path comparison - both paths are now in consistent format
215
247
  return normalizedDep === normalizedTemplatePath;
216
248
  });
217
249
  if (hasMatchingDep) {
218
250
  affectedPagesCount++;
219
- // Remove from cache to force rebuild
220
- 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
+ };
221
257
  }
222
258
  }
223
259
  if (affectedPagesCount > 0) {
224
260
  // Save updated cache manifest
225
261
  await saveCacheManifest(cacheDir, cacheManifest);
226
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
227
265
  await build({
228
266
  logger,
229
267
  force: false,
230
268
  clean: false,
269
+ skipManifestSave: true,
270
+ skipAssetCopy: true,
231
271
  ...(configPath && { configPath }),
232
272
  });
273
+ return { affectedPages: affectedPagesCount, totalPages };
233
274
  }
234
275
  else {
235
276
  // If no affected pages were found but a template changed,
@@ -241,6 +282,7 @@ async function handleTemplateChange(templatePath, configPath, logger) {
241
282
  clean: false,
242
283
  ...(configPath && { configPath }),
243
284
  });
285
+ return { affectedPages: totalPages, totalPages };
244
286
  }
245
287
  }
246
288
  catch (_error) {
@@ -256,6 +298,8 @@ async function handleTemplateChange(templatePath, configPath, logger) {
256
298
  catch (fallbackError) {
257
299
  throw fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError));
258
300
  }
301
+ // Return zeros on error fallback
302
+ return { affectedPages: 0, totalPages: 0 };
259
303
  }
260
304
  }
261
305
  /**
@@ -337,6 +381,9 @@ export async function createDevServer(options = {}) {
337
381
  },
338
382
  });
339
383
  setEnv('development');
384
+ // Create dev server lock manager
385
+ const cacheDir = resolveCacheDir();
386
+ const devLock = new DevServerLockManager(cacheDir);
340
387
  const url = `http://${host}:${port}`;
341
388
  let httpServer = null;
342
389
  let wsServer = null;
@@ -532,11 +579,21 @@ export async function createDevServer(options = {}) {
532
579
  const devServer = {
533
580
  url,
534
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
+ }
535
591
  // Perform initial build
536
592
  await performInitialBuild(configPath, logger, setLastBuildError);
537
593
  // Create HTTP server
538
594
  httpServer = createServer(async (req, res) => {
539
595
  const requestPath = req.url || '/';
596
+ const requestStart = Date.now();
540
597
  // Handle WebSocket upgrade path
541
598
  if (requestPath === '/__ws') {
542
599
  return; // Let WebSocket server handle this
@@ -548,6 +605,14 @@ export async function createDevServer(options = {}) {
548
605
  }
549
606
  try {
550
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
+ }
551
616
  res.writeHead(statusCode, {
552
617
  'Content-Type': mimeType,
553
618
  'Access-Control-Allow-Origin': '*',
@@ -684,6 +749,8 @@ export async function createDevServer(options = {}) {
684
749
  if (isStopping)
685
750
  return;
686
751
  isStopping = true;
752
+ // Release dev server lock first to allow other servers to start
753
+ await devLock.releaseLock();
687
754
  if (watcher) {
688
755
  await watcher.close();
689
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"}