@stati/core 1.28.0 → 1.29.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 (63) hide show
  1. package/README.md +6 -1
  2. package/dist/core/build.d.ts.map +1 -1
  3. package/dist/core/build.js +122 -34
  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 +11 -62
  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/templates.d.ts +3 -0
  31. package/dist/core/templates.d.ts.map +1 -1
  32. package/dist/core/templates.js +67 -127
  33. package/dist/core/utils/fs.utils.d.ts +8 -0
  34. package/dist/core/utils/fs.utils.d.ts.map +1 -1
  35. package/dist/core/utils/fs.utils.js +13 -1
  36. package/dist/core/utils/index.d.ts +3 -3
  37. package/dist/core/utils/index.d.ts.map +1 -1
  38. package/dist/core/utils/index.js +3 -3
  39. package/dist/core/utils/paths.utils.d.ts +15 -0
  40. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  41. package/dist/core/utils/paths.utils.js +20 -0
  42. package/dist/core/utils/template-discovery.utils.d.ts +25 -1
  43. package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
  44. package/dist/core/utils/template-discovery.utils.js +61 -11
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/rss/generator.d.ts.map +1 -1
  48. package/dist/rss/generator.js +5 -2
  49. package/dist/search/generator.d.ts +9 -0
  50. package/dist/search/generator.d.ts.map +1 -1
  51. package/dist/search/generator.js +20 -0
  52. package/dist/search/index.d.ts +1 -1
  53. package/dist/search/index.d.ts.map +1 -1
  54. package/dist/search/index.js +1 -1
  55. package/dist/types/config.d.ts +55 -0
  56. package/dist/types/config.d.ts.map +1 -1
  57. package/dist/types/content.d.ts +129 -1
  58. package/dist/types/content.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +2 -2
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/types/isg.d.ts +12 -0
  62. package/dist/types/isg.d.ts.map +1 -1
  63. 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":"AAuEA,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,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, 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, 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,31 +210,35 @@ 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 };
206
214
  }
207
215
  /**
208
216
  * Output file and ISG cache key of a page.
209
217
  */
210
218
  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
- }
219
+ const outputPath = join(outDir, getPageOutputFile(page.url));
221
220
  // Cache key is the output path relative to outDir
222
221
  const relativePath = relative(outDir, outputPath).replace(/\\/g, '/');
223
222
  const cacheKey = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
224
223
  return { outputPath, cacheKey };
225
224
  }
225
+ /**
226
+ * Hashes a value for the render inputs, or returns a value that never matches when it
227
+ * cannot be serialized, so the page rebuilds instead of keeping stale output.
228
+ */
229
+ function hashableData(value) {
230
+ try {
231
+ JSON.stringify(value);
232
+ return value ?? null;
233
+ }
234
+ catch {
235
+ return `unserializable:${Date.now()}:${Math.random()}`;
236
+ }
237
+ }
226
238
  /**
227
239
  * Processes pages with ISG caching logic.
228
240
  */
229
- async function processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder = noopMetricRecorder, searchIndexFilename, imageRuntime = null) {
241
+ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder = noopMetricRecorder, searchIndexFilename, imageRuntime = null, navigationHash = computeNavigationHash(navigation)) {
230
242
  let cacheHits = 0;
231
243
  let cacheMisses = 0;
232
244
  const searchablePages = [];
@@ -238,6 +250,18 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
238
250
  await config.hooks.beforeAll(buildContext);
239
251
  endHookBeforeAll();
240
252
  }
253
+ // Collection summaries, hashed after beforeAll because the hook can change front matter
254
+ const summaries = createSummaryHasher(pages, resolveSummaryFields(config));
255
+ // The pages of a listing share one array of page URLs, hashed once for all of them
256
+ const urlListHashes = new WeakMap();
257
+ const hashUrlList = (urls) => {
258
+ let hash = urlListHashes.get(urls);
259
+ if (hash === undefined) {
260
+ hash = computeContentHash('', { urls });
261
+ urlListHashes.set(urls, hash);
262
+ }
263
+ return hash;
264
+ };
241
265
  // Render each page with progress tracking and ISG
242
266
  if (logger.step) {
243
267
  logger.step(2, 3, 'Rendering pages');
@@ -273,18 +297,56 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
273
297
  searchIndexPath: `/${searchIndexFilename}`,
274
298
  }),
275
299
  };
276
- const renderInputsHash = computeContentHash('', {
300
+ // Collection the page sees as stati.collection; a page whose collection appears,
301
+ // disappears or changes rebuilds even when it never read it
302
+ const collectionPath = resolveCollectionPath(page, pages);
303
+ const staticRenderInputs = {
277
304
  ...assets,
278
305
  bundleAutoInject: config.typescript?.autoInject !== false,
279
306
  searchAutoInject: config.search?.autoInjectMetaTag !== false,
280
307
  // Omitted with images: false, so the hash matches builds without the image pipeline
281
308
  ...(imageFingerprint && { images: imageFingerprint }),
309
+ collection: collectionPath ?? null,
310
+ // A listing page depends on its own slice and on the page count, which every page
311
+ // shows in its numbered links
312
+ ...(page.pagination && {
313
+ pagination: {
314
+ pageNumber: page.pagination.pageNumber,
315
+ totalPages: page.pagination.totalPages,
316
+ urls: hashUrlList(page.pagination.urls),
317
+ items: summaries.pages(page.pagination.items),
318
+ },
319
+ }),
320
+ // A generated page depends on its own items only, not on the whole collection
321
+ ...(page.generated && {
322
+ generated: {
323
+ key: page.generated.key,
324
+ label: page.generated.label,
325
+ template: page.generated.template,
326
+ items: summaries.pages(page.generated.items),
327
+ data: hashableData(page.generated.data),
328
+ },
329
+ }),
330
+ };
331
+ // The data a render read: the collection summary and the navigation tree
332
+ const renderInputsHashFor = (reads) => computeContentHash('', {
333
+ ...staticRenderInputs,
334
+ ...(reads?.collection && {
335
+ collectionSummary: collectionPath !== undefined ? summaries.collection(collectionPath) : 'none',
336
+ }),
337
+ ...(reads?.nav && { navigation: navigationHash }),
282
338
  });
339
+ // Entries written before Stati recorded what pages read are rebuilt once to record it
340
+ const readsKnown = existingEntry !== undefined && hasRecordedReads(existingEntry);
341
+ const renderInputsHash = renderInputsHashFor(readsKnown
342
+ ? { collection: existingEntry.readsCollection, nav: existingEntry.readsNav }
343
+ : undefined);
283
344
  // Check if we should rebuild this page (considering ISG logic)
284
345
  const shouldRebuildStart = performance.now();
285
346
  const shouldRebuild = options.force ||
286
347
  config.isg?.enabled === false ||
287
348
  !(await pathExists(outputPath)) ||
349
+ (existingEntry !== undefined && !readsKnown) ||
288
350
  // A cached page whose generated images are gone is rebuilt to regenerate them
289
351
  (imageRuntime !== null && !imageRuntime.outputsExist(existingEntry?.images)) ||
290
352
  (await shouldRebuildPage(page, existingEntry, config, pages, buildTime, renderInputsHash));
@@ -300,7 +362,7 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
300
362
  recorder.recordPageTiming(page.url, 0, true);
301
363
  // Collect searchable page data for cached pages if search is enabled
302
364
  // Extract TOC without full HTML rendering for efficiency
303
- if (config.search?.enabled === true) {
365
+ if (config.search?.enabled === true && !isGeneratedPageExcludedFromSearch(page)) {
304
366
  const tocEnabled = config.markdown?.toc !== false;
305
367
  const toc = tocEnabled ? extractToc(page.content, md) : [];
306
368
  searchablePages.push({ page, toc, markdownContent: page.content });
@@ -357,10 +419,17 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
357
419
  if (config.typescript?.autoInject !== false && assets.bundlePaths.length > 0) {
358
420
  finalHtml = autoInjectBundles(finalHtml, assets.bundlePaths);
359
421
  }
360
- // Start the image jobs the final HTML needs and record its image files for the cache
361
- const renderedAssets = pageImages && imageRuntime
422
+ // Start the image jobs the final HTML needs and record its image files for the cache,
423
+ // along with what the render read, which decides the hash it is stored under
424
+ const committedImages = pageImages && imageRuntime
362
425
  ? imageRuntime.commitPage(pageImages, finalHtml, { outputPath, cacheKey })
363
426
  : undefined;
427
+ const renderedAssets = {
428
+ assetDeps: committedImages?.assetDeps ?? [],
429
+ images: committedImages?.images ?? [],
430
+ reads: renderResult.reads,
431
+ };
432
+ const renderedInputsHash = renderInputsHashFor(renderResult.reads);
364
433
  const renderTime = Math.round(performance.now() - startTime);
365
434
  // Record page timing for rendered pages (includes template count)
366
435
  recorder.recordPageTiming(page.url, renderTime, false, renderResult.templatesLoaded);
@@ -370,7 +439,13 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
370
439
  // In dev mode, batch writes for parallel execution to avoid Windows filesystem slowdown
371
440
  if (isDevMode) {
372
441
  pendingWrites.push({ outputPath, content: finalHtml });
373
- pendingCacheUpdates.push({ cacheKey, page, existingEntry, renderInputsHash, renderedAssets });
442
+ pendingCacheUpdates.push({
443
+ cacheKey,
444
+ page,
445
+ existingEntry,
446
+ renderInputsHash: renderedInputsHash,
447
+ renderedAssets,
448
+ });
374
449
  }
375
450
  else {
376
451
  // Production mode: write immediately
@@ -382,17 +457,17 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
382
457
  if (!options.skipCacheUpdate) {
383
458
  const cacheEntryStart = performance.now();
384
459
  if (existingEntry) {
385
- manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, pages, buildTime, false, renderInputsHash, renderedAssets);
460
+ manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, pages, buildTime, false, renderedInputsHash, renderedAssets);
386
461
  }
387
462
  else {
388
- manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderInputsHash, renderedAssets);
463
+ manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderedInputsHash, renderedAssets);
389
464
  }
390
465
  totalCacheEntryTime += performance.now() - cacheEntryStart;
391
466
  }
392
467
  }
393
468
  // Collect searchable page data if search is enabled
394
469
  // Uses TOC entries and markdown content instead of parsing rendered HTML
395
- if (config.search?.enabled === true) {
470
+ if (config.search?.enabled === true && !isGeneratedPageExcludedFromSearch(page)) {
396
471
  searchablePages.push({ page, toc, markdownContent: page.content });
397
472
  }
398
473
  // Run afterRender hook
@@ -567,14 +642,13 @@ async function buildInternal(options = {}) {
567
642
  console.log(); // Add spacing before content loading
568
643
  }
569
644
  const endContentSpan = recorder.startSpan('contentDiscoveryMs');
570
- const { pages, navigation, md, eta, navigationHash } = await loadContentAndBuildNavigation(config, options, logger);
645
+ const { pages, markdownFiles, navigation, md, eta, navigationHash } = await loadContentAndBuildNavigation(config, options, logger);
571
646
  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.
647
+ // Record content discovery counts. Pages include the ones generated from [param].md
648
+ // templates and pagination; Markdown files include the templates.
575
649
  recorder.increment('totalPages', pages.length);
576
- recorder.increment('markdownFilesProcessed', pages.length);
577
- // Store navigation hash in manifest for change detection in dev server
650
+ recorder.increment('markdownFilesProcessed', markdownFiles);
651
+ // Recorded in the manifest; pages that read the navigation also hash it into their inputs
578
652
  manifest.navigationHash = navigationHash;
579
653
  // Compile TypeScript if enabled
580
654
  // In dev mode, skip compilation since esbuild watcher handles it
@@ -626,9 +700,23 @@ async function buildInternal(options = {}) {
626
700
  }
627
701
  const endPageRenderSpan = recorder.startSpan('pageRenderingMs');
628
702
  const buildTime = new Date();
629
- const pageProcessingResult = await processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder, searchIndexFilename, imageRuntime);
703
+ const pageProcessingResult = await processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder, searchIndexFilename, imageRuntime, navigationHash);
630
704
  endPageRenderSpan();
631
705
  const { cacheHits, cacheMisses, searchablePages } = pageProcessingResult;
706
+ // Remove the output of pages that no longer exist, such as a deleted post or a tag no post
707
+ // uses anymore. Runs before static assets are copied, so a file in the static directory at
708
+ // the same path is copied afterwards.
709
+ const liveCacheKeys = new Set(pages.map((page) => getPageOutput(page, outDir).cacheKey));
710
+ const pruned = await pruneOrphanedPages(manifest, liveCacheKeys, outDir);
711
+ for (const failure of pruned.failed) {
712
+ logger.warning(`Could not remove the stale page ${failure.key}: ${failure.message}`);
713
+ }
714
+ if (pruned.removed.length > 0) {
715
+ const count = pruned.removed.length;
716
+ logger.info(count === 1
717
+ ? 'Removed 1 page that no longer exists'
718
+ : `Removed ${count} pages that no longer exist`);
719
+ }
632
720
  // Wait for image jobs; pages whose images failed are rewritten to show plain images
633
721
  let imageFailures = [];
634
722
  if (imageRuntime) {
@@ -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"}