@stati/core 1.27.2 → 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 (136) hide show
  1. package/README.md +6 -1
  2. package/dist/config/errors.d.ts +10 -0
  3. package/dist/config/errors.d.ts.map +1 -0
  4. package/dist/config/errors.js +10 -0
  5. package/dist/config/loader.d.ts.map +1 -1
  6. package/dist/config/loader.js +15 -2
  7. package/dist/constants.d.ts +26 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/constants.js +23 -0
  10. package/dist/core/build.d.ts.map +1 -1
  11. package/dist/core/build.js +201 -41
  12. package/dist/core/collections.d.ts +139 -0
  13. package/dist/core/collections.d.ts.map +1 -0
  14. package/dist/core/collections.js +342 -0
  15. package/dist/core/content.d.ts.map +1 -1
  16. package/dist/core/content.js +2 -1
  17. package/dist/core/dev.d.ts.map +1 -1
  18. package/dist/core/dev.js +11 -62
  19. package/dist/core/expand.d.ts +86 -0
  20. package/dist/core/expand.d.ts.map +1 -0
  21. package/dist/core/expand.js +484 -0
  22. package/dist/core/isg/builder.d.ts +27 -2
  23. package/dist/core/isg/builder.d.ts.map +1 -1
  24. package/dist/core/isg/builder.js +83 -11
  25. package/dist/core/isg/deps.d.ts +9 -0
  26. package/dist/core/isg/deps.d.ts.map +1 -1
  27. package/dist/core/isg/deps.js +30 -1
  28. package/dist/core/isg/hash.d.ts +14 -2
  29. package/dist/core/isg/hash.d.ts.map +1 -1
  30. package/dist/core/isg/hash.js +30 -2
  31. package/dist/core/isg/index.d.ts +4 -2
  32. package/dist/core/isg/index.d.ts.map +1 -1
  33. package/dist/core/isg/index.js +4 -2
  34. package/dist/core/isg/manifest.js +14 -0
  35. package/dist/core/isg/prune.d.ts +32 -0
  36. package/dist/core/isg/prune.d.ts.map +1 -0
  37. package/dist/core/isg/prune.js +113 -0
  38. package/dist/core/markdown.d.ts +10 -1
  39. package/dist/core/markdown.d.ts.map +1 -1
  40. package/dist/core/markdown.js +13 -3
  41. package/dist/core/navigation.d.ts +4 -2
  42. package/dist/core/navigation.d.ts.map +1 -1
  43. package/dist/core/navigation.js +17 -2
  44. package/dist/core/templates.d.ts +15 -1
  45. package/dist/core/templates.d.ts.map +1 -1
  46. package/dist/core/templates.js +121 -126
  47. package/dist/core/utils/fs.utils.d.ts +29 -1
  48. package/dist/core/utils/fs.utils.d.ts.map +1 -1
  49. package/dist/core/utils/fs.utils.js +74 -1
  50. package/dist/core/utils/index.d.ts +3 -3
  51. package/dist/core/utils/index.d.ts.map +1 -1
  52. package/dist/core/utils/index.js +3 -3
  53. package/dist/core/utils/paths.utils.d.ts +15 -0
  54. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  55. package/dist/core/utils/paths.utils.js +20 -0
  56. package/dist/core/utils/template-discovery.utils.d.ts +25 -1
  57. package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
  58. package/dist/core/utils/template-discovery.utils.js +61 -11
  59. package/dist/images/config.d.ts +52 -0
  60. package/dist/images/config.d.ts.map +1 -0
  61. package/dist/images/config.js +200 -0
  62. package/dist/images/index.d.ts +15 -0
  63. package/dist/images/index.d.ts.map +1 -0
  64. package/dist/images/index.js +11 -0
  65. package/dist/images/manifest.d.ts +36 -0
  66. package/dist/images/manifest.d.ts.map +1 -0
  67. package/dist/images/manifest.js +69 -0
  68. package/dist/images/markdown-rule.d.ts +26 -0
  69. package/dist/images/markdown-rule.d.ts.map +1 -0
  70. package/dist/images/markdown-rule.js +60 -0
  71. package/dist/images/markup.d.ts +39 -0
  72. package/dist/images/markup.d.ts.map +1 -0
  73. package/dist/images/markup.js +72 -0
  74. package/dist/images/og.d.ts +52 -0
  75. package/dist/images/og.d.ts.map +1 -0
  76. package/dist/images/og.js +165 -0
  77. package/dist/images/plan.d.ts +101 -0
  78. package/dist/images/plan.d.ts.map +1 -0
  79. package/dist/images/plan.js +128 -0
  80. package/dist/images/provider.d.ts +35 -0
  81. package/dist/images/provider.d.ts.map +1 -0
  82. package/dist/images/provider.js +70 -0
  83. package/dist/images/resolve.d.ts +45 -0
  84. package/dist/images/resolve.d.ts.map +1 -0
  85. package/dist/images/resolve.js +51 -0
  86. package/dist/images/runtime.d.ts +258 -0
  87. package/dist/images/runtime.d.ts.map +1 -0
  88. package/dist/images/runtime.js +659 -0
  89. package/dist/images/sources.d.ts +47 -0
  90. package/dist/images/sources.d.ts.map +1 -0
  91. package/dist/images/sources.js +97 -0
  92. package/dist/images/utils/image-probe.utils.d.ts +30 -0
  93. package/dist/images/utils/image-probe.utils.d.ts.map +1 -0
  94. package/dist/images/utils/image-probe.utils.js +307 -0
  95. package/dist/images/utils/index.d.ts +7 -0
  96. package/dist/images/utils/index.d.ts.map +1 -0
  97. package/dist/images/utils/index.js +5 -0
  98. package/dist/index.d.ts +2 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/metrics/noop.d.ts.map +1 -1
  101. package/dist/metrics/noop.js +3 -0
  102. package/dist/metrics/recorder.d.ts.map +1 -1
  103. package/dist/metrics/recorder.js +3 -0
  104. package/dist/metrics/types.d.ts +8 -0
  105. package/dist/metrics/types.d.ts.map +1 -1
  106. package/dist/rss/generator.d.ts.map +1 -1
  107. package/dist/rss/generator.js +5 -2
  108. package/dist/search/generator.d.ts +9 -0
  109. package/dist/search/generator.d.ts.map +1 -1
  110. package/dist/search/generator.js +20 -0
  111. package/dist/search/index.d.ts +1 -1
  112. package/dist/search/index.d.ts.map +1 -1
  113. package/dist/search/index.js +1 -1
  114. package/dist/seo/auto-inject.d.ts +3 -0
  115. package/dist/seo/auto-inject.d.ts.map +1 -1
  116. package/dist/seo/auto-inject.js +1 -0
  117. package/dist/seo/generator.d.ts +24 -0
  118. package/dist/seo/generator.d.ts.map +1 -1
  119. package/dist/seo/generator.js +65 -1
  120. package/dist/seo/index.d.ts +1 -1
  121. package/dist/seo/index.d.ts.map +1 -1
  122. package/dist/seo/index.js +1 -1
  123. package/dist/types/config.d.ts +60 -0
  124. package/dist/types/config.d.ts.map +1 -1
  125. package/dist/types/content.d.ts +150 -3
  126. package/dist/types/content.d.ts.map +1 -1
  127. package/dist/types/images.d.ts +185 -0
  128. package/dist/types/images.d.ts.map +1 -0
  129. package/dist/types/images.js +8 -0
  130. package/dist/types/index.d.ts +3 -2
  131. package/dist/types/index.d.ts.map +1 -1
  132. package/dist/types/isg.d.ts +19 -0
  133. package/dist/types/isg.d.ts.map +1 -1
  134. package/dist/types/seo.d.ts +9 -0
  135. package/dist/types/seo.d.ts.map +1 -1
  136. 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:
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Error thrown when the Stati configuration is invalid.
3
+ * `loadConfig()` always rethrows it, because the build cannot proceed with a broken config.
4
+ */
5
+ export declare class ConfigValidationError extends Error {
6
+ constructor(message: string, options?: {
7
+ cause?: unknown;
8
+ });
9
+ }
10
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/config/errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAI3D"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Error thrown when the Stati configuration is invalid.
3
+ * `loadConfig()` always rethrows it, because the build cannot proceed with a broken config.
4
+ */
5
+ export class ConfigValidationError extends Error {
6
+ constructor(message, options) {
7
+ super(message, options);
8
+ this.name = 'ConfigValidationError';
9
+ }
10
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AA6BrD;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC;AAqBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAExD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,UAAU,CAAC,GAAG,GAAE,MAAsB,GAAG,OAAO,CAAC,WAAW,CAAC,CA8GlF"}
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AA+BrD;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC;AAqBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAExD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,UAAU,CAAC,GAAG,GAAE,MAAsB,GAAG,OAAO,CAAC,WAAW,CAAC,CA8HlF"}
@@ -2,6 +2,8 @@ import { existsSync, statSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { validateISGConfig, ISGConfigurationError } from '../core/isg/validation.js';
5
+ import { validateImagesConfig } from '../images/config.js';
6
+ import { ConfigValidationError } from './errors.js';
5
7
  import { compileStatiConfig, cleanupCompiledConfig } from '../core/utils/index.js';
6
8
  import { DEFAULT_SRC_DIR, DEFAULT_OUT_DIR, DEFAULT_STATIC_DIR, DEFAULT_SITE_TITLE, DEFAULT_DEV_BASE_URL, DEFAULT_TTL_SECONDS, DEFAULT_MAX_AGE_CAP_DAYS, CONFIG_FILE_PATTERNS, } from '../constants.js';
7
9
  /**
@@ -139,6 +141,16 @@ export async function loadConfig(cwd = process.cwd()) {
139
141
  }
140
142
  throw error; // Re-throw non-ISG errors
141
143
  }
144
+ // Validate image configuration
145
+ try {
146
+ validateImagesConfig(mergedConfig.images);
147
+ }
148
+ catch (error) {
149
+ if (error instanceof ConfigValidationError) {
150
+ throw new ConfigValidationError(`Invalid images configuration in ${configPath}:\n${error.message}`, { cause: error });
151
+ }
152
+ throw error;
153
+ }
142
154
  // Cache the loaded config with its modification time
143
155
  try {
144
156
  const mtime = statSync(resolvedConfigPath).mtimeMs;
@@ -154,8 +166,9 @@ export async function loadConfig(cwd = process.cwd()) {
154
166
  return mergedConfig;
155
167
  }
156
168
  catch (error) {
157
- if (error instanceof Error && error.message.includes('Invalid ISG configuration')) {
158
- // ISG validation errors should bubble up with context
169
+ if (error instanceof ConfigValidationError ||
170
+ (error instanceof Error && error.message.includes('Invalid ISG configuration'))) {
171
+ // Validation errors bubble up with context: the build cannot proceed with an invalid config
159
172
  throw error;
160
173
  }
161
174
  console.error(`Error loading config from ${configPath}:`, error);
@@ -82,4 +82,30 @@ export declare const DEFAULT_BUNDLES: readonly [{
82
82
  readonly entryPoint: "main.ts";
83
83
  readonly bundleName: "main";
84
84
  }];
85
+ /** Output directory for generated and copied images, relative to the build output */
86
+ export declare const IMAGES_OUTPUT_DIR = "_img";
87
+ /** Subdirectory of {@link IMAGES_OUTPUT_DIR} for generated Open Graph images */
88
+ export declare const OG_IMAGES_SUBDIR = "og";
89
+ /** Cache subdirectory (inside `.stati`) holding the images manifest */
90
+ export declare const IMAGES_CACHE_SUBDIR = "images";
91
+ /** Package that implements responsive images and Open Graph image generation */
92
+ export declare const IMAGES_PACKAGE_NAME = "@stati/images";
93
+ /** Default candidate widths for responsive images */
94
+ export declare const DEFAULT_IMAGE_WIDTHS: readonly number[];
95
+ /** Default modern formats for responsive images */
96
+ export declare const DEFAULT_IMAGE_FORMATS: readonly ('avif' | 'webp')[];
97
+ /** Default encoder quality per format; PNG stays lossless unless configured */
98
+ export declare const DEFAULT_IMAGE_QUALITY: {
99
+ readonly avif: 50;
100
+ readonly webp: 75;
101
+ readonly jpeg: 80;
102
+ };
103
+ /** Default `sizes` attribute for responsive images */
104
+ export declare const DEFAULT_IMAGE_SIZES = "100vw";
105
+ /** Default Open Graph image template name */
106
+ export declare const DEFAULT_OG_TEMPLATE = "og.eta";
107
+ /** Default Open Graph image width in pixels */
108
+ export declare const DEFAULT_OG_WIDTH = 1200;
109
+ /** Default Open Graph image height in pixels */
110
+ export declare const DEFAULT_OG_HEIGHT = 630;
85
111
  //# sourceMappingURL=constants.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,iDAAiD;AACjD,eAAO,MAAM,eAAe,SAAS,CAAC;AAEtC,+CAA+C;AAC/C,eAAO,MAAM,eAAe,SAAS,CAAC;AAEtC,sCAAsC;AACtC,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAE3C,iCAAiC;AACjC,eAAO,MAAM,cAAc,WAAW,CAAC;AAEvC,4DAA4D;AAC5D,eAAO,MAAM,YAAY,UAAU,CAAC;AAEpC,kCAAkC;AAClC,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAIjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,iCAAkC,CAAC;AAEtE,mCAAmC;AACnC,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C,2CAA2C;AAC3C,eAAO,MAAM,oBAAoB,UAEhC,CAAC;AAIF,sCAAsC;AACtC,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC,kCAAkC;AAClC,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC,sCAAsC;AACtC,eAAO,MAAM,gBAAgB,cAAc,CAAC;AAE5C,0CAA0C;AAC1C,eAAO,MAAM,oBAAoB,0BAAmD,CAAC;AAIrF,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,QAAQ,CAAC;AAEzC,+DAA+D;AAC/D,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C,qEAAqE;AACrE,eAAO,MAAM,wBAAwB,QAAQ,CAAC;AAI9C,4BAA4B;AAC5B,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC,0BAA0B;AAC1B,eAAO,MAAM,gBAAgB,QAA0B,CAAC;AAExD,yBAAyB;AACzB,eAAO,MAAM,eAAe,QAAwB,CAAC;AAErD,0BAA0B;AAC1B,eAAO,MAAM,gBAAgB,QAAsB,CAAC;AAEpD,8BAA8B;AAC9B,eAAO,MAAM,oBAAoB,QAAyB,CAAC;AAI3D,oDAAoD;AACpD,eAAO,MAAM,aAAa,QAAyB,CAAC;AAEpD,iDAAiD;AACjD,eAAO,MAAM,cAAc,QAA0B,CAAC;AAEtD,6CAA6C;AAC7C,eAAO,MAAM,UAAU,QAAmB,CAAC;AAE3C,iDAAiD;AACjD,eAAO,MAAM,WAAW,QAAuB,CAAC;AAEhD,uCAAuC;AACvC,eAAO,MAAM,WAAW,QAAsB,CAAC;AAE/C,2CAA2C;AAC3C,eAAO,MAAM,SAAS,QAAkB,CAAC;AAEzC,6CAA6C;AAC7C,eAAO,MAAM,UAAU,QAAmB,CAAC;AAI3C,kCAAkC;AAClC,eAAO,MAAM,kBAAkB,SAAS,CAAC;AAEzC,8BAA8B;AAC9B,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AAExC,+BAA+B;AAC/B,eAAO,MAAM,eAAe,eAAgC,CAAC;AAE7D,mEAAmE;AACnE,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAIvC,yBAAyB;AACzB,eAAO,MAAM,kBAAkB,kBAAkB,CAAC;AAElD,8CAA8C;AAC9C,eAAO,MAAM,cAAc,UAAU,CAAC;AAItC,0CAA0C;AAC1C,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AAExC,qEAAqE;AACrE,eAAO,MAAM,kBAAkB,YAAY,CAAC;AAE5C;;;GAGG;AACH,eAAO,MAAM,eAAe;;;EAA2D,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"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"}
package/dist/constants.js CHANGED
@@ -88,3 +88,26 @@ export const DEFAULT_TS_OUT_DIR = '_assets';
88
88
  * Creates a single global bundle from 'main.ts' entry point.
89
89
  */
90
90
  export const DEFAULT_BUNDLES = [{ entryPoint: 'main.ts', bundleName: 'main' }];
91
+ // === Image Constants ===
92
+ /** Output directory for generated and copied images, relative to the build output */
93
+ export const IMAGES_OUTPUT_DIR = '_img';
94
+ /** Subdirectory of {@link IMAGES_OUTPUT_DIR} for generated Open Graph images */
95
+ export const OG_IMAGES_SUBDIR = 'og';
96
+ /** Cache subdirectory (inside `.stati`) holding the images manifest */
97
+ export const IMAGES_CACHE_SUBDIR = 'images';
98
+ /** Package that implements responsive images and Open Graph image generation */
99
+ export const IMAGES_PACKAGE_NAME = '@stati/images';
100
+ /** Default candidate widths for responsive images */
101
+ export const DEFAULT_IMAGE_WIDTHS = [480, 960, 1440];
102
+ /** Default modern formats for responsive images */
103
+ export const DEFAULT_IMAGE_FORMATS = ['avif', 'webp'];
104
+ /** Default encoder quality per format; PNG stays lossless unless configured */
105
+ export const DEFAULT_IMAGE_QUALITY = { avif: 50, webp: 75, jpeg: 80 };
106
+ /** Default `sizes` attribute for responsive images */
107
+ export const DEFAULT_IMAGE_SIZES = '100vw';
108
+ /** Default Open Graph image template name */
109
+ export const DEFAULT_OG_TEMPLATE = 'og.eta';
110
+ /** Default Open Graph image width in pixels */
111
+ export const DEFAULT_OG_WIDTH = 1200;
112
+ /** Default Open Graph image height in pixels */
113
+ export const DEFAULT_OG_HEIGHT = 630;
@@ -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;AAE3B,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,17 +1,20 @@
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';
17
+ import { ImageRuntime } from '../images/runtime.js';
15
18
  import { createMetricRecorder, noopMetricRecorder } from '../metrics/index.js';
16
19
  /**
17
20
  * Recursively calculates the total size of a directory in bytes.
@@ -179,12 +182,18 @@ async function setupCacheAndManifest(cacheDir) {
179
182
  return { manifest };
180
183
  }
181
184
  /**
182
- * Loads content and builds navigation.
185
+ * Loads content, expands generated and paginated pages, and builds navigation.
183
186
  */
184
187
  async function loadContentAndBuildNavigation(config, options, logger) {
185
- // Load all content
186
- const pages = await loadContent(config, options.includeDrafts);
187
- 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
+ }
188
197
  // Build navigation from pages
189
198
  if (logger.step) {
190
199
  console.log(); // Add spacing before navigation step
@@ -201,12 +210,35 @@ async function loadContentAndBuildNavigation(config, options, logger) {
201
210
  // Create processors
202
211
  const md = await createMarkdownProcessor(config);
203
212
  const eta = createTemplateEngine(config);
204
- return { pages, navigation, md, eta, navigationHash };
213
+ return { pages, markdownFiles: loaded.length, navigation, md, eta, navigationHash };
214
+ }
215
+ /**
216
+ * Output file and ISG cache key of a page.
217
+ */
218
+ function getPageOutput(page, outDir) {
219
+ const outputPath = join(outDir, getPageOutputFile(page.url));
220
+ // Cache key is the output path relative to outDir
221
+ const relativePath = relative(outDir, outputPath).replace(/\\/g, '/');
222
+ const cacheKey = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
223
+ return { outputPath, cacheKey };
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
+ }
205
237
  }
206
238
  /**
207
239
  * Processes pages with ISG caching logic.
208
240
  */
209
- async function processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder = noopMetricRecorder, searchIndexFilename) {
241
+ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder = noopMetricRecorder, searchIndexFilename, imageRuntime = null, navigationHash = computeNavigationHash(navigation)) {
210
242
  let cacheHits = 0;
211
243
  let cacheMisses = 0;
212
244
  const searchablePages = [];
@@ -218,6 +250,18 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
218
250
  await config.hooks.beforeAll(buildContext);
219
251
  endHookBeforeAll();
220
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
+ };
221
265
  // Render each page with progress tracking and ISG
222
266
  if (logger.step) {
223
267
  logger.step(2, 3, 'Rendering pages');
@@ -236,24 +280,14 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
236
280
  const isDevMode = isDevelopment();
237
281
  const pendingWrites = [];
238
282
  const pendingCacheUpdates = [];
283
+ // Image settings that change generated HTML, part of every page's render inputs
284
+ const imageFingerprint = imageRuntime?.fingerprint();
239
285
  for (let i = 0; i < pages.length; i++) {
240
286
  const page = pages[i];
241
287
  if (!page)
242
288
  continue; // Safety check
243
- // Determine output path
244
- let outputPath;
245
- if (page.url === '/') {
246
- outputPath = join(outDir, 'index.html');
247
- }
248
- else if (page.url.endsWith('/')) {
249
- outputPath = join(outDir, page.url, 'index.html');
250
- }
251
- else {
252
- outputPath = join(outDir, `${page.url}.html`);
253
- }
254
- // Get cache key (use output path relative to outDir)
255
- const relativePath = relative(outDir, outputPath).replace(/\\/g, '/');
256
- const cacheKey = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
289
+ // Determine output path and cache key
290
+ const { outputPath, cacheKey } = getPageOutput(page, outDir);
257
291
  const existingEntry = manifest.entries[cacheKey];
258
292
  const bundlePaths = getBundlePathsForPage(page.url, compiledBundles);
259
293
  const assets = {
@@ -263,16 +297,58 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
263
297
  searchIndexPath: `/${searchIndexFilename}`,
264
298
  }),
265
299
  };
266
- 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 = {
267
304
  ...assets,
268
305
  bundleAutoInject: config.typescript?.autoInject !== false,
269
306
  searchAutoInject: config.search?.autoInjectMetaTag !== false,
307
+ // Omitted with images: false, so the hash matches builds without the image pipeline
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 }),
270
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);
271
344
  // Check if we should rebuild this page (considering ISG logic)
272
345
  const shouldRebuildStart = performance.now();
273
346
  const shouldRebuild = options.force ||
274
347
  config.isg?.enabled === false ||
275
348
  !(await pathExists(outputPath)) ||
349
+ (existingEntry !== undefined && !readsKnown) ||
350
+ // A cached page whose generated images are gone is rebuilt to regenerate them
351
+ (imageRuntime !== null && !imageRuntime.outputsExist(existingEntry?.images)) ||
276
352
  (await shouldRebuildPage(page, existingEntry, config, pages, buildTime, renderInputsHash));
277
353
  const shouldRebuildTime = performance.now() - shouldRebuildStart;
278
354
  totalShouldRebuildTime += shouldRebuildTime;
@@ -286,7 +362,7 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
286
362
  recorder.recordPageTiming(page.url, 0, true);
287
363
  // Collect searchable page data for cached pages if search is enabled
288
364
  // Extract TOC without full HTML rendering for efficiency
289
- if (config.search?.enabled === true) {
365
+ if (config.search?.enabled === true && !isGeneratedPageExcludedFromSearch(page)) {
290
366
  const tocEnabled = config.markdown?.toc !== false;
291
367
  const toc = tocEnabled ? extractToc(page.content, md) : [];
292
368
  searchablePages.push({ page, toc, markdownContent: page.content });
@@ -302,12 +378,16 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
302
378
  await config.hooks.beforeRender({ page, config });
303
379
  recorder.addToPhase('hookBeforeRenderTotalMs', performance.now() - hookStart);
304
380
  }
381
+ // Image state for this page: markup, dependencies and generated files
382
+ const pageImages = imageRuntime?.createPageContext(page);
305
383
  // Render markdown to HTML with TOC extraction
306
384
  const tocEnabled = config.markdown?.toc !== false;
307
- const { html: htmlContent, toc } = renderMarkdown(page.content, md, tocEnabled);
385
+ const { html: htmlContent, toc } = renderMarkdown(page.content, md, tocEnabled, {
386
+ images: pageImages,
387
+ });
308
388
  // Render with template
309
389
  const renderPageStart = performance.now();
310
- const renderResult = await renderPage(page, htmlContent, config, eta, navigation, pages, assets, toc, logger);
390
+ const renderResult = await renderPage(page, htmlContent, config, eta, navigation, pages, assets, toc, logger, { images: pageImages });
311
391
  const renderPageTime = performance.now() - renderPageStart;
312
392
  totalRenderTime += renderPageTime;
313
393
  let finalHtml = renderResult.html;
@@ -324,6 +404,9 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
324
404
  if (config.seo?.debug !== undefined) {
325
405
  injectOptions.debug = config.seo.debug;
326
406
  }
407
+ if (renderResult.ogImage) {
408
+ injectOptions.generatedImage = renderResult.ogImage;
409
+ }
327
410
  finalHtml = autoInjectSEO(finalHtml, injectOptions);
328
411
  }
329
412
  // Auto-inject search index meta tag if enabled (default: true)
@@ -336,6 +419,17 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
336
419
  if (config.typescript?.autoInject !== false && assets.bundlePaths.length > 0) {
337
420
  finalHtml = autoInjectBundles(finalHtml, assets.bundlePaths);
338
421
  }
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
425
+ ? imageRuntime.commitPage(pageImages, finalHtml, { outputPath, cacheKey })
426
+ : undefined;
427
+ const renderedAssets = {
428
+ assetDeps: committedImages?.assetDeps ?? [],
429
+ images: committedImages?.images ?? [],
430
+ reads: renderResult.reads,
431
+ };
432
+ const renderedInputsHash = renderInputsHashFor(renderResult.reads);
339
433
  const renderTime = Math.round(performance.now() - startTime);
340
434
  // Record page timing for rendered pages (includes template count)
341
435
  recorder.recordPageTiming(page.url, renderTime, false, renderResult.templatesLoaded);
@@ -345,7 +439,13 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
345
439
  // In dev mode, batch writes for parallel execution to avoid Windows filesystem slowdown
346
440
  if (isDevMode) {
347
441
  pendingWrites.push({ outputPath, content: finalHtml });
348
- pendingCacheUpdates.push({ cacheKey, page, existingEntry, renderInputsHash });
442
+ pendingCacheUpdates.push({
443
+ cacheKey,
444
+ page,
445
+ existingEntry,
446
+ renderInputsHash: renderedInputsHash,
447
+ renderedAssets,
448
+ });
349
449
  }
350
450
  else {
351
451
  // Production mode: write immediately
@@ -357,17 +457,17 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
357
457
  if (!options.skipCacheUpdate) {
358
458
  const cacheEntryStart = performance.now();
359
459
  if (existingEntry) {
360
- manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, pages, buildTime, false, renderInputsHash);
460
+ manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, pages, buildTime, false, renderedInputsHash, renderedAssets);
361
461
  }
362
462
  else {
363
- manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderInputsHash);
463
+ manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderedInputsHash, renderedAssets);
364
464
  }
365
465
  totalCacheEntryTime += performance.now() - cacheEntryStart;
366
466
  }
367
467
  }
368
468
  // Collect searchable page data if search is enabled
369
469
  // Uses TOC entries and markdown content instead of parsing rendered HTML
370
- if (config.search?.enabled === true) {
470
+ if (config.search?.enabled === true && !isGeneratedPageExcludedFromSearch(page)) {
371
471
  searchablePages.push({ page, toc, markdownContent: page.content });
372
472
  }
373
473
  // Run afterRender hook
@@ -390,14 +490,14 @@ async function processPagesWithCache(pages, manifest, config, outDir, md, eta, n
390
490
  // This avoids expensive template dependency tracking on each incremental rebuild
391
491
  if (!options.skipCacheUpdate) {
392
492
  const cacheEntryStart = performance.now();
393
- for (const { cacheKey, page, existingEntry, renderInputsHash } of pendingCacheUpdates) {
493
+ for (const { cacheKey, page, existingEntry, renderInputsHash, renderedAssets, } of pendingCacheUpdates) {
394
494
  if (existingEntry) {
395
495
  // Fast update: reuse existing deps, just update hashes and timestamp
396
496
  manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, pages, buildTime, true, // fastUpdate = true in dev mode
397
- renderInputsHash);
497
+ renderInputsHash, renderedAssets);
398
498
  }
399
499
  else {
400
- manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderInputsHash);
500
+ manifest.entries[cacheKey] = await createCacheEntry(page, config, pages, buildTime, renderInputsHash, renderedAssets);
401
501
  }
402
502
  }
403
503
  totalCacheEntryTime = performance.now() - cacheEntryStart;
@@ -501,6 +601,14 @@ async function buildInternal(options = {}) {
501
601
  await remove(cacheDir);
502
602
  }
503
603
  await ensureDir(outDir);
604
+ // Image pipeline; null with images: false. Fails here, before any page renders, when a
605
+ // feature needs @stati/images and it is missing or incompatible.
606
+ const imageRuntime = await ImageRuntime.create(config, {
607
+ outDir,
608
+ cacheDir,
609
+ logger,
610
+ development: isDevelopment(),
611
+ });
504
612
  // Enable Tailwind class inventory tracking only if Tailwind is detected
505
613
  const tailwindInitStart = performance.now();
506
614
  const hasTailwind = await isTailwindUsed();
@@ -534,14 +642,13 @@ async function buildInternal(options = {}) {
534
642
  console.log(); // Add spacing before content loading
535
643
  }
536
644
  const endContentSpan = recorder.startSpan('contentDiscoveryMs');
537
- const { pages, navigation, md, eta, navigationHash } = await loadContentAndBuildNavigation(config, options, logger);
645
+ const { pages, markdownFiles, navigation, md, eta, navigationHash } = await loadContentAndBuildNavigation(config, options, logger);
538
646
  endContentSpan();
539
- // Record content discovery counts.
540
- // Both totalPages and markdownFilesProcessed are incremented by pages.length
541
- // 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.
542
649
  recorder.increment('totalPages', pages.length);
543
- recorder.increment('markdownFilesProcessed', pages.length);
544
- // 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
545
652
  manifest.navigationHash = navigationHash;
546
653
  // Compile TypeScript if enabled
547
654
  // In dev mode, skip compilation since esbuild watcher handles it
@@ -593,9 +700,40 @@ async function buildInternal(options = {}) {
593
700
  }
594
701
  const endPageRenderSpan = recorder.startSpan('pageRenderingMs');
595
702
  const buildTime = new Date();
596
- const pageProcessingResult = await processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder, searchIndexFilename);
703
+ const pageProcessingResult = await processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger, compiledBundles, recorder, searchIndexFilename, imageRuntime, navigationHash);
597
704
  endPageRenderSpan();
598
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
+ }
720
+ // Wait for image jobs; pages whose images failed are rewritten to show plain images
721
+ let imageFailures = [];
722
+ if (imageRuntime) {
723
+ const endImageSpan = recorder.startSpan('imageSettleMs');
724
+ const settled = await imageRuntime.settle(manifest);
725
+ await imageRuntime.save();
726
+ endImageSpan();
727
+ imageFailures = settled.failures;
728
+ const { generated, cached, failed } = imageRuntime.stats;
729
+ recorder.increment('imagesGenerated', generated);
730
+ recorder.increment('imagesCached', cached);
731
+ recorder.increment('imagesFailed', failed);
732
+ if (generated > 0 || failed > 0) {
733
+ logger.info('');
734
+ logger.info(`Images: ${generated} generated, ${cached} reused${failed > 0 ? `, ${failed} failed` : ''}`);
735
+ }
736
+ }
599
737
  // Generate search index if enabled
600
738
  // Uses markdown content and TOC entries
601
739
  let searchIndexMetadata;
@@ -638,6 +776,13 @@ async function buildInternal(options = {}) {
638
776
  await saveCacheManifest(cacheDir, manifest);
639
777
  }
640
778
  endManifestSaveSpan();
779
+ // In the dev server a failed Open Graph image goes to the error overlay, like a template error
780
+ const ogFailures = imageFailures.filter((failure) => failure.og);
781
+ if (isDevMode && ogFailures.length > 0) {
782
+ throw new Error(ogFailures
783
+ .map((failure) => `Open Graph image failed for ${failure.label}: ${failure.message}`)
784
+ .join('\n'));
785
+ }
641
786
  // Copy static assets and count them (skip for template-only changes in dev mode)
642
787
  const endAssetSpan = recorder.startSpan('assetCopyMs');
643
788
  let assetsCount = 0;
@@ -646,6 +791,21 @@ async function buildInternal(options = {}) {
646
791
  }
647
792
  endAssetSpan();
648
793
  recorder.increment('assetsCopied', assetsCount);
794
+ // Delete generated images no page of this build uses. Production only: dev builds skip
795
+ // responsive and Open Graph images by default and must not delete them.
796
+ if (imageRuntime && getEnv() === 'production') {
797
+ const liveImages = new Set();
798
+ const liveSources = new Set();
799
+ for (const page of pages) {
800
+ const entry = manifest.entries[getPageOutput(page, outDir).cacheKey];
801
+ entry?.images?.forEach((image) => liveImages.add(image));
802
+ entry?.assetDeps?.forEach((source) => liveSources.add(source));
803
+ }
804
+ const removed = await imageRuntime.prune(liveImages, liveSources);
805
+ if (removed > 0) {
806
+ logger.info(`Removed ${removed} unused generated image${removed === 1 ? '' : 's'}`);
807
+ }
808
+ }
649
809
  // Get current environment
650
810
  const currentEnv = getEnv();
651
811
  // Generate sitemap if enabled (only in production mode)