@stati/core 1.28.0 → 1.29.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.
Files changed (74) hide show
  1. package/README.md +6 -1
  2. package/dist/core/build.d.ts.map +1 -1
  3. package/dist/core/build.js +192 -71
  4. package/dist/core/collections.d.ts +139 -0
  5. package/dist/core/collections.d.ts.map +1 -0
  6. package/dist/core/collections.js +342 -0
  7. package/dist/core/content.d.ts.map +1 -1
  8. package/dist/core/content.js +2 -1
  9. package/dist/core/dev.d.ts.map +1 -1
  10. package/dist/core/dev.js +32 -93
  11. package/dist/core/expand.d.ts +86 -0
  12. package/dist/core/expand.d.ts.map +1 -0
  13. package/dist/core/expand.js +484 -0
  14. package/dist/core/isg/builder.d.ts +15 -2
  15. package/dist/core/isg/builder.d.ts.map +1 -1
  16. package/dist/core/isg/builder.js +44 -6
  17. package/dist/core/isg/hash.d.ts +4 -2
  18. package/dist/core/isg/hash.d.ts.map +1 -1
  19. package/dist/core/isg/hash.js +16 -2
  20. package/dist/core/isg/index.d.ts +2 -1
  21. package/dist/core/isg/index.d.ts.map +1 -1
  22. package/dist/core/isg/index.js +3 -1
  23. package/dist/core/isg/manifest.js +6 -0
  24. package/dist/core/isg/prune.d.ts +32 -0
  25. package/dist/core/isg/prune.d.ts.map +1 -0
  26. package/dist/core/isg/prune.js +113 -0
  27. package/dist/core/navigation.d.ts +4 -2
  28. package/dist/core/navigation.d.ts.map +1 -1
  29. package/dist/core/navigation.js +17 -2
  30. package/dist/core/preview.d.ts.map +1 -1
  31. package/dist/core/preview.js +21 -32
  32. package/dist/core/templates.d.ts +3 -0
  33. package/dist/core/templates.d.ts.map +1 -1
  34. package/dist/core/templates.js +67 -127
  35. package/dist/core/utils/bundle-matching.utils.d.ts +8 -8
  36. package/dist/core/utils/bundle-matching.utils.d.ts.map +1 -1
  37. package/dist/core/utils/bundle-matching.utils.js +10 -10
  38. package/dist/core/utils/fs.utils.d.ts +8 -0
  39. package/dist/core/utils/fs.utils.d.ts.map +1 -1
  40. package/dist/core/utils/fs.utils.js +13 -1
  41. package/dist/core/utils/index.d.ts +6 -6
  42. package/dist/core/utils/index.d.ts.map +1 -1
  43. package/dist/core/utils/index.js +5 -5
  44. package/dist/core/utils/paths.utils.d.ts +15 -0
  45. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  46. package/dist/core/utils/paths.utils.js +20 -0
  47. package/dist/core/utils/server.utils.d.ts +38 -3
  48. package/dist/core/utils/server.utils.d.ts.map +1 -1
  49. package/dist/core/utils/server.utils.js +132 -44
  50. package/dist/core/utils/template-discovery.utils.d.ts +25 -1
  51. package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
  52. package/dist/core/utils/template-discovery.utils.js +61 -11
  53. package/dist/core/utils/typescript.utils.d.ts +27 -0
  54. package/dist/core/utils/typescript.utils.d.ts.map +1 -1
  55. package/dist/core/utils/typescript.utils.js +34 -1
  56. package/dist/index.d.ts +1 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/rss/generator.d.ts.map +1 -1
  59. package/dist/rss/generator.js +5 -2
  60. package/dist/search/generator.d.ts +27 -7
  61. package/dist/search/generator.d.ts.map +1 -1
  62. package/dist/search/generator.js +55 -12
  63. package/dist/search/index.d.ts +1 -1
  64. package/dist/search/index.d.ts.map +1 -1
  65. package/dist/search/index.js +1 -1
  66. package/dist/types/config.d.ts +61 -4
  67. package/dist/types/config.d.ts.map +1 -1
  68. package/dist/types/content.d.ts +129 -1
  69. package/dist/types/content.d.ts.map +1 -1
  70. package/dist/types/index.d.ts +2 -2
  71. package/dist/types/index.d.ts.map +1 -1
  72. package/dist/types/isg.d.ts +12 -0
  73. package/dist/types/isg.d.ts.map +1 -1
  74. package/package.json +1 -1
package/README.md CHANGED
@@ -166,7 +166,8 @@ Customize layouts with [Eta templates](https://eta.js.org):
166
166
 
167
167
  Only rebuild what changed:
168
168
 
169
- - Smart caching based on hashes of each page's content and templates
169
+ - Smart caching based on hashes of each page's content and templates, and of the collection and navigation data it reads
170
+ - Removal of pages that no longer exist, such as deleted posts and unused tag pages
170
171
  - TTL-based refresh for dynamic content
171
172
  - Tag-based invalidation for related content
172
173
 
@@ -183,6 +184,10 @@ await invalidate('path:/posts');
183
184
  await invalidate('age:3months');
184
185
  ```
185
186
 
187
+ #### Collections, Pagination and Generated Pages
188
+
189
+ A folder with an `index.md` is a collection: templates read its pages as `stati.collection`, sorted by date and grouped by tag. `paginate: 10` in front matter splits a listing into `/blog`, `/blog/page/2` and so on (`stati.pagination`), and a `[tag].md` template becomes one page per tag (`stati.generated`). See [Collections and generated pages](https://stati.build/core-concepts/collections).
190
+
186
191
  #### Built-In SEO
187
192
 
188
193
  SEO optimization works automatically with zero configuration:
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/core/build.ts"],"names":[],"mappings":"AA2DA,OAAO,KAAK,EAEV,UAAU,EACV,MAAM,EASP,MAAM,mBAAmB,CAAC;AAK3B,OAAO,KAAK,EAAkB,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxE;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,gCAAgC;IAChC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,uCAAuC;IACvC,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,qCAAqC;IACrC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,8BAA8B;IAC9B,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,+BAA+B;IAC/B,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,iCAAiC;IACjC,OAAO,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACrC,uEAAuE;IACvE,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,sGAAsG;IACtG,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,mFAAmF;IACnF,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,WAAY,SAAQ,UAAU;IAC7C,wDAAwD;IACxD,YAAY,CAAC,EAAE,YAAY,CAAC;CAC7B;AAkGD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAsB,KAAK,CAAC,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAsB5E"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/core/build.ts"],"names":[],"mappings":"AAyEA,OAAO,KAAK,EAEV,UAAU,EACV,MAAM,EAQP,MAAM,mBAAmB,CAAC;AAK3B,OAAO,KAAK,EAAkB,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxE;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,gCAAgC;IAChC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,uCAAuC;IACvC,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,qCAAqC;IACrC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,8BAA8B;IAC9B,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,+BAA+B;IAC/B,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,iCAAiC;IACjC,OAAO,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACrC,uEAAuE;IACvE,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,sGAAsG;IACtG,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,mFAAmF;IACnF,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,WAAY,SAAQ,UAAU;IAC7C,wDAAwD;IACxD,YAAY,CAAC,EAAE,YAAY,CAAC;CAC7B;AAkGD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAsB,KAAK,CAAC,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAsB5E"}
@@ -1,15 +1,17 @@
1
- import { ensureDir, writeFile, remove, pathExists, stat, readdir, copyFile, resolveOutDir, resolveStaticDir, resolveCacheDir, enableInventoryTracking, disableInventoryTracking, clearInventory, writeTailwindClassInventory, getInventorySize, isTailwindUsed, loadPreviousInventory, compileTypeScript, detectExistingBundles, autoInjectBundles, getBundlePathsForPage, formatBytes, } from './utils/index.js';
1
+ import { ensureDir, writeFile, remove, pathExists, stat, readdir, copyFile, resolveOutDir, resolveStaticDir, resolveCacheDir, enableInventoryTracking, disableInventoryTracking, clearInventory, writeTailwindClassInventory, getInventorySize, isTailwindUsed, loadPreviousInventory, compileTypeScript, detectExistingBundles, removeStaleBundleFiles, autoInjectBundles, getBundlePathsForPage, formatBytes, isVirtualPage, getPageOutputFile, } from './utils/index.js';
2
2
  import { join, dirname, relative, posix } from 'node:path';
3
3
  import { performance } from 'node:perf_hooks';
4
4
  import { loadConfig } from '../config/loader.js';
5
5
  import { loadContent } from './content.js';
6
+ import { expandPages } from './expand.js';
7
+ import { createSummaryHasher, resolveCollectionPath, resolveSummaryFields, } from './collections.js';
6
8
  import { createMarkdownProcessor, renderMarkdown, extractToc } from './markdown.js';
7
9
  import { createTemplateEngine, renderPage } from './templates.js';
8
10
  import { buildNavigation } from './navigation.js';
9
- import { loadCacheManifest, saveCacheManifest, shouldRebuildPage, createCacheEntry, updateCacheEntry, withBuildLock, computeNavigationHash, computeContentHash, clearFileHashCache, clearTemplatePathCache, } from './isg/index.js';
11
+ import { loadCacheManifest, saveCacheManifest, shouldRebuildPage, createCacheEntry, updateCacheEntry, hasRecordedReads, pruneOrphanedPages, withBuildLock, computeNavigationHash, computeContentHash, clearFileHashCache, clearTemplatePathCache, } from './isg/index.js';
10
12
  import { generateSitemap, generateRobotsTxtFromConfig, autoInjectSEO, } from '../seo/index.js';
11
13
  import { generateRSSFeeds, validateRSSConfig } from '../rss/index.js';
12
- import { computeSearchIndexFilename, generateSearchIndex, writeSearchIndex, autoInjectSearchMeta, } from '../search/index.js';
14
+ import { computeSearchIndexFilename, generateSearchIndex, writeSearchIndex, removeStaleSearchIndexes, autoInjectSearchMeta, isGeneratedPageExcludedFromSearch, } from '../search/index.js';
13
15
  import { getEnv, isDevelopment } from '../env.js';
14
16
  import { DEFAULT_OUT_DIR } from '../constants.js';
15
17
  import { ImageRuntime } from '../images/runtime.js';
@@ -180,12 +182,18 @@ async function setupCacheAndManifest(cacheDir) {
180
182
  return { manifest };
181
183
  }
182
184
  /**
183
- * Loads content and builds navigation.
185
+ * Loads content, expands generated and paginated pages, and builds navigation.
184
186
  */
185
187
  async function loadContentAndBuildNavigation(config, options, logger) {
186
- // Load all content
187
- const pages = await loadContent(config, options.includeDrafts);
188
- logger.status(`Found ${pages.length} pages`);
188
+ // Load all content, then replace [param].md templates with their pages and add the
189
+ // pages of paginated listings. Navigation, SEO, sitemap and search see the result.
190
+ const loaded = await loadContent(config, options.includeDrafts);
191
+ const pages = await expandPages(loaded, config, logger);
192
+ const virtualCount = pages.filter((page) => isVirtualPage(page)).length;
193
+ logger.status(`Found ${pages.length - virtualCount} pages`);
194
+ if (virtualCount > 0) {
195
+ logger.status(`Generated ${virtualCount} more from templates and pagination`);
196
+ }
189
197
  // Build navigation from pages
190
198
  if (logger.step) {
191
199
  console.log(); // Add spacing before navigation step
@@ -202,34 +210,89 @@ async function loadContentAndBuildNavigation(config, options, logger) {
202
210
  // Create processors
203
211
  const md = await createMarkdownProcessor(config);
204
212
  const eta = createTemplateEngine(config);
205
- return { pages, navigation, md, eta, navigationHash };
213
+ return { pages, markdownFiles: loaded.length, navigation, md, eta, navigationHash };
214
+ }
215
+ /**
216
+ * Generates the search index from the page models and writes it before any page renders,
217
+ * returning the file name pages link to. Every page links to the index, and the name is part
218
+ * of each page's cache hash, so a production build names a hashed file after the index
219
+ * content: a build that leaves the indexed text alone keeps the name and its cached pages.
220
+ * Development keeps one name, so a content edit does not re-render every page. Writing the
221
+ * index first means it is not held in memory while the pages render.
222
+ *
223
+ * @returns The index file name, or undefined when search is disabled
224
+ */
225
+ async function writeSearchIndexBeforeRendering(pages, config, md, outDir, logger, recorder) {
226
+ if (config.search?.enabled !== true) {
227
+ return undefined;
228
+ }
229
+ const isDevMode = isDevelopment();
230
+ const endSearchIndexSpan = recorder.startSpan('searchIndexGenerationMs');
231
+ // TOC entries and markdown content, the same data a render produces, without rendering.
232
+ // Sorted by URL: content discovery returns files in a different order on each run, which
233
+ // would change the index content, and with it the name, on every build.
234
+ const tocEnabled = config.markdown?.toc !== false;
235
+ const byUrl = (a, b) => a.url.localeCompare(b.url) || a.sourcePath.localeCompare(b.sourcePath);
236
+ const searchablePages = pages
237
+ .filter((page) => !isGeneratedPageExcludedFromSearch(page))
238
+ .sort(byUrl)
239
+ .map((page) => ({
240
+ page,
241
+ toc: tocEnabled ? extractToc(page.content, md) : [],
242
+ markdownContent: page.content,
243
+ }));
244
+ const index = generateSearchIndex(searchablePages, config.search);
245
+ // A production hash covers the documents and schema version, not generatedAt, which
246
+ // changes on every build; serializing them is skipped when the name has no hash
247
+ const hashInput = isDevMode
248
+ ? 'dev'
249
+ : config.search.hashFilename !== false
250
+ ? JSON.stringify({ version: index.version, documents: index.documents })
251
+ : undefined;
252
+ const filename = computeSearchIndexFilename(config.search, hashInput);
253
+ endSearchIndexSpan();
254
+ // Pages still link to the name when nothing is searchable, but no file is written
255
+ if (searchablePages.length === 0) {
256
+ return filename;
257
+ }
258
+ logger.info('');
259
+ logger.info(`Generating search index to ${filename}`);
260
+ const writeStart = performance.now();
261
+ // In dev mode, skip write if content hasn't changed (template-only changes)
262
+ const metadata = await writeSearchIndex(index, outDir, filename, isDevMode);
263
+ recorder.recordPhase('searchIndexWriteMs', performance.now() - writeStart);
264
+ logger.success(`Generated search index with ${metadata.documentCount} documents`);
265
+ return filename;
206
266
  }
207
267
  /**
208
268
  * Output file and ISG cache key of a page.
209
269
  */
210
270
  function getPageOutput(page, outDir) {
211
- let outputPath;
212
- if (page.url === '/') {
213
- outputPath = join(outDir, 'index.html');
214
- }
215
- else if (page.url.endsWith('/')) {
216
- outputPath = join(outDir, page.url, 'index.html');
217
- }
218
- else {
219
- outputPath = join(outDir, `${page.url}.html`);
220
- }
271
+ const outputPath = join(outDir, getPageOutputFile(page.url));
221
272
  // Cache key is the output path relative to outDir
222
273
  const relativePath = relative(outDir, outputPath).replace(/\\/g, '/');
223
274
  const cacheKey = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
224
275
  return { outputPath, cacheKey };
225
276
  }
277
+ /**
278
+ * Hashes a value for the render inputs, or returns a value that never matches when it
279
+ * cannot be serialized, so the page rebuilds instead of keeping stale output.
280
+ */
281
+ function hashableData(value) {
282
+ try {
283
+ JSON.stringify(value);
284
+ return value ?? null;
285
+ }
286
+ catch {
287
+ return `unserializable:${Date.now()}:${Math.random()}`;
288
+ }
289
+ }
226
290
  /**
227
291
  * Processes pages with ISG caching logic.
228
292
  */
229
- async function processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder = noopMetricRecorder, searchIndexFilename, imageRuntime = null) {
293
+ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder = noopMetricRecorder, imageRuntime = null, navigationHash = computeNavigationHash(navigation)) {
230
294
  let cacheHits = 0;
231
295
  let cacheMisses = 0;
232
- const searchablePages = [];
233
296
  // Build context
234
297
  const buildContext = { config, pages };
235
298
  // Run beforeAll hook
@@ -238,6 +301,20 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
238
301
  await config.hooks.beforeAll(buildContext);
239
302
  endHookBeforeAll();
240
303
  }
304
+ // Collection summaries, hashed after beforeAll because the hook can change front matter
305
+ const summaries = createSummaryHasher(pages, resolveSummaryFields(config));
306
+ // Generated after beforeAll for the same reason; pages render with its file name
307
+ const searchIndexFilename = await writeSearchIndexBeforeRendering(pages, config, md, outDir, logger, recorder);
308
+ // The pages of a listing share one array of page URLs, hashed once for all of them
309
+ const urlListHashes = new WeakMap();
310
+ const hashUrlList = (urls) => {
311
+ let hash = urlListHashes.get(urls);
312
+ if (hash === undefined) {
313
+ hash = computeContentHash('', { urls });
314
+ urlListHashes.set(urls, hash);
315
+ }
316
+ return hash;
317
+ };
241
318
  // Render each page with progress tracking and ISG
242
319
  if (logger.step) {
243
320
  logger.step(2, 3, 'Rendering pages');
@@ -273,18 +350,56 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
273
350
  searchIndexPath: `/${searchIndexFilename}`,
274
351
  }),
275
352
  };
276
- const renderInputsHash = computeContentHash('', {
353
+ // Collection the page sees as stati.collection; a page whose collection appears,
354
+ // disappears or changes rebuilds even when it never read it
355
+ const collectionPath = resolveCollectionPath(page, pages);
356
+ const staticRenderInputs = {
277
357
  ...assets,
278
358
  bundleAutoInject: config.typescript?.autoInject !== false,
279
359
  searchAutoInject: config.search?.autoInjectMetaTag !== false,
280
360
  // Omitted with images: false, so the hash matches builds without the image pipeline
281
361
  ...(imageFingerprint && { images: imageFingerprint }),
362
+ collection: collectionPath ?? null,
363
+ // A listing page depends on its own slice and on the page count, which every page
364
+ // shows in its numbered links
365
+ ...(page.pagination && {
366
+ pagination: {
367
+ pageNumber: page.pagination.pageNumber,
368
+ totalPages: page.pagination.totalPages,
369
+ urls: hashUrlList(page.pagination.urls),
370
+ items: summaries.pages(page.pagination.items),
371
+ },
372
+ }),
373
+ // A generated page depends on its own items only, not on the whole collection
374
+ ...(page.generated && {
375
+ generated: {
376
+ key: page.generated.key,
377
+ label: page.generated.label,
378
+ template: page.generated.template,
379
+ items: summaries.pages(page.generated.items),
380
+ data: hashableData(page.generated.data),
381
+ },
382
+ }),
383
+ };
384
+ // The data a render read: the collection summary and the navigation tree
385
+ const renderInputsHashFor = (reads) => computeContentHash('', {
386
+ ...staticRenderInputs,
387
+ ...(reads?.collection && {
388
+ collectionSummary: collectionPath !== undefined ? summaries.collection(collectionPath) : 'none',
389
+ }),
390
+ ...(reads?.nav && { navigation: navigationHash }),
282
391
  });
392
+ // Entries written before Stati recorded what pages read are rebuilt once to record it
393
+ const readsKnown = existingEntry !== undefined && hasRecordedReads(existingEntry);
394
+ const renderInputsHash = renderInputsHashFor(readsKnown
395
+ ? { collection: existingEntry.readsCollection, nav: existingEntry.readsNav }
396
+ : undefined);
283
397
  // Check if we should rebuild this page (considering ISG logic)
284
398
  const shouldRebuildStart = performance.now();
285
399
  const shouldRebuild = options.force ||
286
400
  config.isg?.enabled === false ||
287
401
  !(await pathExists(outputPath)) ||
402
+ (existingEntry !== undefined && !readsKnown) ||
288
403
  // A cached page whose generated images are gone is rebuilt to regenerate them
289
404
  (imageRuntime !== null && !imageRuntime.outputsExist(existingEntry?.images)) ||
290
405
  (await shouldRebuildPage(page, existingEntry, config, pages, buildTime, renderInputsHash));
@@ -298,13 +413,6 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
298
413
  }
299
414
  // Record page timing for cached pages (0ms render time)
300
415
  recorder.recordPageTiming(page.url, 0, true);
301
- // Collect searchable page data for cached pages if search is enabled
302
- // Extract TOC without full HTML rendering for efficiency
303
- if (config.search?.enabled === true) {
304
- const tocEnabled = config.markdown?.toc !== false;
305
- const toc = tocEnabled ? extractToc(page.content, md) : [];
306
- searchablePages.push({ page, toc, markdownContent: page.content });
307
- }
308
416
  continue;
309
417
  }
310
418
  // Cache miss - need to rebuild
@@ -357,10 +465,17 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
357
465
  if (config.typescript?.autoInject !== false && assets.bundlePaths.length > 0) {
358
466
  finalHtml = autoInjectBundles(finalHtml, assets.bundlePaths);
359
467
  }
360
- // Start the image jobs the final HTML needs and record its image files for the cache
361
- const renderedAssets = pageImages && imageRuntime
468
+ // Start the image jobs the final HTML needs and record its image files for the cache,
469
+ // along with what the render read, which decides the hash it is stored under
470
+ const committedImages = pageImages && imageRuntime
362
471
  ? imageRuntime.commitPage(pageImages, finalHtml, { outputPath, cacheKey })
363
472
  : undefined;
473
+ const renderedAssets = {
474
+ assetDeps: committedImages?.assetDeps ?? [],
475
+ images: committedImages?.images ?? [],
476
+ reads: renderResult.reads,
477
+ };
478
+ const renderedInputsHash = renderInputsHashFor(renderResult.reads);
364
479
  const renderTime = Math.round(performance.now() - startTime);
365
480
  // Record page timing for rendered pages (includes template count)
366
481
  recorder.recordPageTiming(page.url, renderTime, false, renderResult.templatesLoaded);
@@ -370,7 +485,13 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
370
485
  // In dev mode, batch writes for parallel execution to avoid Windows filesystem slowdown
371
486
  if (isDevMode) {
372
487
  pendingWrites.push({ outputPath, content: finalHtml });
373
- pendingCacheUpdates.push({ cacheKey, page, existingEntry, renderInputsHash, renderedAssets });
488
+ pendingCacheUpdates.push({
489
+ cacheKey,
490
+ page,
491
+ existingEntry,
492
+ renderInputsHash: renderedInputsHash,
493
+ renderedAssets,
494
+ });
374
495
  }
375
496
  else {
376
497
  // Production mode: write immediately
@@ -382,19 +503,14 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
382
503
  if (!options.skipCacheUpdate) {
383
504
  const cacheEntryStart = performance.now();
384
505
  if (existingEntry) {
385
- manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, pages, buildTime, false, renderInputsHash, renderedAssets);
506
+ manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, pages, buildTime, false, renderedInputsHash, renderedAssets);
386
507
  }
387
508
  else {
388
- manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderInputsHash, renderedAssets);
509
+ manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderedInputsHash, renderedAssets);
389
510
  }
390
511
  totalCacheEntryTime += performance.now() - cacheEntryStart;
391
512
  }
392
513
  }
393
- // Collect searchable page data if search is enabled
394
- // Uses TOC entries and markdown content instead of parsing rendered HTML
395
- if (config.search?.enabled === true) {
396
- searchablePages.push({ page, toc, markdownContent: page.content });
397
- }
398
514
  // Run afterRender hook
399
515
  if (config.hooks?.afterRender) {
400
516
  const hookStart = performance.now();
@@ -440,7 +556,7 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
440
556
  logger.showRenderingSummary();
441
557
  }
442
558
  }
443
- return { cacheHits, cacheMisses, searchablePages };
559
+ return { cacheHits, cacheMisses, searchIndexFilename };
444
560
  }
445
561
  /**
446
562
  * Copies static assets and returns the count.
@@ -567,14 +683,13 @@ async function buildInternal(options = {}) {
567
683
  console.log(); // Add spacing before content loading
568
684
  }
569
685
  const endContentSpan = recorder.startSpan('contentDiscoveryMs');
570
- const { pages, navigation, md, eta, navigationHash } = await loadContentAndBuildNavigation(config, options, logger);
686
+ const { pages, markdownFiles, navigation, md, eta, navigationHash } = await loadContentAndBuildNavigation(config, options, logger);
571
687
  endContentSpan();
572
- // Record content discovery counts.
573
- // Both totalPages and markdownFilesProcessed are incremented by pages.length
574
- // because loadContent() only processes *.md files, so all pages are markdown files.
688
+ // Record content discovery counts. Pages include the ones generated from [param].md
689
+ // templates and pagination; Markdown files include the templates.
575
690
  recorder.increment('totalPages', pages.length);
576
- recorder.increment('markdownFilesProcessed', pages.length);
577
- // Store navigation hash in manifest for change detection in dev server
691
+ recorder.increment('markdownFilesProcessed', markdownFiles);
692
+ // Recorded in the manifest; pages that read the navigation also hash it into their inputs
578
693
  manifest.navigationHash = navigationHash;
579
694
  // Compile TypeScript if enabled
580
695
  // In dev mode, skip compilation since esbuild watcher handles it
@@ -613,22 +728,29 @@ async function buildInternal(options = {}) {
613
728
  });
614
729
  endTsSpan();
615
730
  }
616
- // Pre-compute search index filename if search is enabled
617
- // In dev mode, use a stable filename to simlplify testing and debugging
618
- let searchIndexFilename;
619
- if (config.search?.enabled) {
620
- const buildId = isDevMode ? 'dev' : buildStartTime.toString();
621
- searchIndexFilename = computeSearchIndexFilename(config.search, buildId);
622
- }
623
731
  // Process pages with ISG caching logic
624
732
  if (logger.step) {
625
733
  console.log(); // Add spacing before page processing
626
734
  }
627
735
  const endPageRenderSpan = recorder.startSpan('pageRenderingMs');
628
736
  const buildTime = new Date();
629
- const pageProcessingResult = await processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder, searchIndexFilename, imageRuntime);
737
+ const pageProcessingResult = await processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder, imageRuntime, navigationHash);
630
738
  endPageRenderSpan();
631
- const { cacheHits, cacheMisses, searchablePages } = pageProcessingResult;
739
+ const { cacheHits, cacheMisses, searchIndexFilename } = pageProcessingResult;
740
+ // Remove the output of pages that no longer exist, such as a deleted post or a tag no post
741
+ // uses anymore. Runs before static assets are copied, so a file in the static directory at
742
+ // the same path is copied afterwards.
743
+ const liveCacheKeys = new Set(pages.map((page) => getPageOutput(page, outDir).cacheKey));
744
+ const pruned = await pruneOrphanedPages(manifest, liveCacheKeys, outDir);
745
+ for (const failure of pruned.failed) {
746
+ logger.warning(`Could not remove the stale page ${failure.key}: ${failure.message}`);
747
+ }
748
+ if (pruned.removed.length > 0) {
749
+ const count = pruned.removed.length;
750
+ logger.info(count === 1
751
+ ? 'Removed 1 page that no longer exists'
752
+ : `Removed ${count} pages that no longer exist`);
753
+ }
632
754
  // Wait for image jobs; pages whose images failed are rewritten to show plain images
633
755
  let imageFailures = [];
634
756
  if (imageRuntime) {
@@ -646,23 +768,6 @@ async function buildInternal(options = {}) {
646
768
  logger.info(`Images: ${generated} generated, ${cached} reused${failed > 0 ? `, ${failed} failed` : ''}`);
647
769
  }
648
770
  }
649
- // Generate search index if enabled
650
- // Uses markdown content and TOC entries
651
- let searchIndexMetadata;
652
- if (config.search?.enabled && searchablePages.length > 0 && searchIndexFilename) {
653
- logger.info('');
654
- logger.info(`Generating search index to ${searchIndexFilename}`);
655
- const endSearchIndexSpan = recorder.startSpan('searchIndexGenerationMs');
656
- const searchIndex = generateSearchIndex(searchablePages, config.search);
657
- endSearchIndexSpan();
658
- const writeStart = performance.now();
659
- // In dev mode, skip write if content hasn't changed (template-only changes)
660
- const skipIfUnchanged = isDevMode;
661
- searchIndexMetadata = await writeSearchIndex(searchIndex, outDir, searchIndexFilename, skipIfUnchanged);
662
- const writeTime = performance.now() - writeStart;
663
- recorder.recordPhase('searchIndexWriteMs', writeTime);
664
- logger.success(`Generated search index with ${searchIndexMetadata.documentCount} documents`);
665
- }
666
771
  // Record page rendering counts
667
772
  recorder.increment('renderedPages', cacheMisses);
668
773
  recorder.increment('cachedPages', cacheHits);
@@ -695,6 +800,22 @@ async function buildInternal(options = {}) {
695
800
  .map((failure) => `Open Graph image failed for ${failure.label}: ${failure.message}`)
696
801
  .join('\n'));
697
802
  }
803
+ // Delete the bundles and search indexes earlier builds wrote under other names, now that
804
+ // every page links to this build's files. Before the static copy, so a file in the static
805
+ // directory with such a name is copied afterwards.
806
+ if (!isDevMode) {
807
+ if (config.typescript?.enabled) {
808
+ await removeStaleBundleFiles({
809
+ projectRoot: process.cwd(),
810
+ config: config.typescript,
811
+ outDir: config.outDir || DEFAULT_OUT_DIR,
812
+ compiled: compiledBundles,
813
+ });
814
+ }
815
+ if (config.search?.enabled && searchIndexFilename) {
816
+ await removeStaleSearchIndexes(outDir, config.search, searchIndexFilename);
817
+ }
818
+ }
698
819
  // Copy static assets and count them (skip for template-only changes in dev mode)
699
820
  const endAssetSpan = recorder.startSpan('assetCopyMs');
700
821
  let assetsCount = 0;
@@ -0,0 +1,139 @@
1
+ import type { PageModel, CollectionData, PaginationData, GeneratedPageData, StatiConfig } from '../types/index.js';
2
+ /**
3
+ * Front matter fields of the collection summary when `collections.summaryFields` is not set.
4
+ */
5
+ export declare const DEFAULT_SUMMARY_FIELDS: readonly string[];
6
+ /**
7
+ * The collections of a page list.
8
+ */
9
+ export interface CollectionIndex {
10
+ /** Collection data by the URL of its index page */
11
+ byPath: Map<string, CollectionData>;
12
+ }
13
+ /**
14
+ * What a render read from data shared between pages. Filled in while the page renders.
15
+ */
16
+ export interface PageReads {
17
+ /** The page read `stati.collection` */
18
+ collection: boolean;
19
+ /** The page read the navigation tree (`stati.nav` or `stati.page.navNode`) */
20
+ nav: boolean;
21
+ }
22
+ /**
23
+ * Returns the collections of a page list, computing them once per array.
24
+ *
25
+ * @param allPages - All pages of the build
26
+ * @returns The collections by index page URL
27
+ */
28
+ export declare function getCollections(allPages: PageModel[]): CollectionIndex;
29
+ /**
30
+ * Computes every collection of a page list in one pass.
31
+ *
32
+ * A collection belongs to a collection index page (see `isCollectionIndexPage`), or to a page
33
+ * loaded from an `index.md`, whose collection is empty while its folder holds no pages. The
34
+ * root collection holds the top-level pages; any other collection holds every page below its
35
+ * folder, at any depth. The index page itself, generated pages and pagination pages are
36
+ * never members.
37
+ *
38
+ * @param allPages - All pages of the build
39
+ * @returns The collections by index page URL
40
+ */
41
+ export declare function buildCollections(allPages: PageModel[]): CollectionIndex;
42
+ /**
43
+ * Sorts pages by publishedAt date (most recent first), falling back to alphabetical by title.
44
+ *
45
+ * @param pages - Pages to sort, in place
46
+ * @returns The sorted array
47
+ */
48
+ export declare function sortPagesByDate(pages: PageModel[]): PageModel[];
49
+ /**
50
+ * Finds the collection a `collection` front matter value names. Accepts the index page URL
51
+ * with or without a leading or trailing slash: `posts`, `/posts` and `/posts/` all name the
52
+ * collection of `site/posts/index.md`.
53
+ *
54
+ * @param index - The collections of the build
55
+ * @param value - The front matter value
56
+ * @returns The index page URL, or undefined when the value names no collection
57
+ */
58
+ export declare function findCollectionPath(index: CollectionIndex, value: unknown): string | undefined;
59
+ /**
60
+ * Finds the collection a page reads as `stati.collection`:
61
+ *
62
+ * 1. The collection its `collection` front matter field names, when that collection exists.
63
+ * 2. Its own collection, when the page is a collection index page or an `index.md`, even
64
+ * when that collection is empty.
65
+ * 3. Otherwise the nearest collection with pages that the page belongs to: `/blog/post` reads
66
+ * `/blog`, and a top-level page reads the root collection.
67
+ *
68
+ * Pagination pages after the first read the collection of the first page. Generated pages
69
+ * read only the collection their template names; expansion sets it in their front matter.
70
+ *
71
+ * @param page - The page
72
+ * @param allPages - All pages of the build
73
+ * @returns The index page URL of the collection, or undefined when the page has none
74
+ */
75
+ export declare function resolveCollectionPath(page: PageModel, allPages: PageModel[]): string | undefined;
76
+ /**
77
+ * Returns the front matter fields of the collection summary.
78
+ *
79
+ * @param config - Stati configuration
80
+ * @returns `collections.summaryFields`, or the default fields
81
+ */
82
+ export declare function resolveSummaryFields(config: StatiConfig): readonly string[];
83
+ /**
84
+ * Hashes what a listing shows of a list of pages: each page's URL, publish date and summary
85
+ * fields, in list order. The Markdown body only counts when `fields` includes `content`.
86
+ *
87
+ * @param pages - The listed pages
88
+ * @param fields - Front matter fields to include
89
+ * @returns A `sha256-…` hash
90
+ */
91
+ export declare function computePagesSummaryHash(pages: readonly PageModel[], fields: readonly string[]): string;
92
+ /**
93
+ * Collection summary hashes for one build, computed at most once per collection.
94
+ */
95
+ export interface SummaryHasher {
96
+ /** Summary hash of a collection, or `'none'` when there is no such collection */
97
+ collection(path: string): string;
98
+ /** Summary hash of a list of pages */
99
+ pages(pages: readonly PageModel[]): string;
100
+ }
101
+ /**
102
+ * Creates the summary hasher of a build. Call it after the `beforeAll` hook, which can change
103
+ * the front matter the summaries include.
104
+ *
105
+ * @param allPages - All pages of the build
106
+ * @param fields - Summary fields
107
+ * @returns The hasher
108
+ */
109
+ export declare function createSummaryHasher(allPages: PageModel[], fields: readonly string[]): SummaryHasher;
110
+ /**
111
+ * Wraps collection data for one render. Reading any of its properties sets `reads.collection`,
112
+ * so the cache knows the page depends on the collection. Arrays and objects are copied on
113
+ * first read, so a template that sorts or changes them cannot affect other pages, which share
114
+ * the collection data within a build.
115
+ *
116
+ * The properties are accessors on a plain object, not a Proxy, so spreading the template
117
+ * context (as callable partials do) passes the view along without reading it.
118
+ *
119
+ * @param data - The shared collection data
120
+ * @param reads - The render's read flags
121
+ * @returns The view for `stati.collection`
122
+ */
123
+ export declare function createCollectionView(data: CollectionData, reads: PageReads): CollectionData;
124
+ /**
125
+ * Copies a page's pagination data for `stati.pagination`, so a template that changes the
126
+ * lists cannot change what the cache hashes.
127
+ *
128
+ * @param data - The page's pagination data
129
+ * @returns A copy with its own arrays
130
+ */
131
+ export declare function copyPaginationData(data: PaginationData): PaginationData;
132
+ /**
133
+ * Copies a page's generated data for `stati.generated`.
134
+ *
135
+ * @param data - The page's generated data
136
+ * @returns A copy with its own items array
137
+ */
138
+ export declare function copyGeneratedData(data: GeneratedPageData): GeneratedPageData;
139
+ //# sourceMappingURL=collections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collections.d.ts","sourceRoot":"","sources":["../../src/core/collections.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,WAAW,EACZ,MAAM,mBAAmB,CAAC;AAU3B;;GAEG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAA8C,CAAC;AAEnG;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,UAAU,EAAE,OAAO,CAAC;IACpB,8EAA8E;IAC9E,GAAG,EAAE,OAAO,CAAC;CACd;AASD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,CAQrE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,CAwBvE;AAwFD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,SAAS,EAAE,CAc/D;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAc7F;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,MAAM,GAAG,SAAS,CAuBhG;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,MAAM,EAAE,CAM3E;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,SAAS,SAAS,EAAE,EAC3B,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,MAAM,CAWR;AAMD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,iFAAiF;IACjF,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACjC,sCAAsC;IACtC,KAAK,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG,MAAM,CAAC;CAC5C;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,SAAS,EAAE,EACrB,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,aAAa,CAcf;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,SAAS,GAAG,cAAc,CAwB3F;AAaD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CAEvE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,GAAG,iBAAiB,CAE5E"}