@stati/core 1.29.1 → 2.0.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 (104) hide show
  1. package/README.md +10 -13
  2. package/dist/config/loader.d.ts +9 -0
  3. package/dist/config/loader.d.ts.map +1 -1
  4. package/dist/config/loader.js +77 -1
  5. package/dist/core/build.d.ts.map +1 -1
  6. package/dist/core/build.js +39 -29
  7. package/dist/core/content.d.ts.map +1 -1
  8. package/dist/core/content.js +9 -7
  9. package/dist/core/dev.d.ts.map +1 -1
  10. package/dist/core/dev.js +156 -197
  11. package/dist/core/eta-plugin.d.ts +60 -0
  12. package/dist/core/eta-plugin.d.ts.map +1 -0
  13. package/dist/core/eta-plugin.js +143 -0
  14. package/dist/core/isg/builder.d.ts +37 -19
  15. package/dist/core/isg/builder.d.ts.map +1 -1
  16. package/dist/core/isg/builder.js +70 -153
  17. package/dist/core/isg/deps.d.ts +8 -70
  18. package/dist/core/isg/deps.d.ts.map +1 -1
  19. package/dist/core/isg/deps.js +10 -401
  20. package/dist/core/isg/hash.d.ts.map +1 -1
  21. package/dist/core/isg/hash.js +2 -1
  22. package/dist/core/isg/index.d.ts +2 -2
  23. package/dist/core/isg/index.d.ts.map +1 -1
  24. package/dist/core/isg/index.js +3 -3
  25. package/dist/core/isg/manifest.d.ts.map +1 -1
  26. package/dist/core/isg/manifest.js +6 -1
  27. package/dist/core/markdown.d.ts +1 -1
  28. package/dist/core/markdown.d.ts.map +1 -1
  29. package/dist/core/markdown.js +3 -2
  30. package/dist/core/template-index.d.ts +209 -0
  31. package/dist/core/template-index.d.ts.map +1 -0
  32. package/dist/core/template-index.js +484 -0
  33. package/dist/core/template-render.d.ts +137 -0
  34. package/dist/core/template-render.d.ts.map +1 -0
  35. package/dist/core/template-render.js +444 -0
  36. package/dist/core/templates.d.ts +29 -2
  37. package/dist/core/templates.d.ts.map +1 -1
  38. package/dist/core/templates.js +115 -195
  39. package/dist/core/utils/context-validation.utils.d.ts +10 -6
  40. package/dist/core/utils/context-validation.utils.d.ts.map +1 -1
  41. package/dist/core/utils/context-validation.utils.js +39 -16
  42. package/dist/core/utils/date.utils.d.ts +1 -1
  43. package/dist/core/utils/date.utils.js +1 -1
  44. package/dist/core/utils/dev-css-watcher.utils.d.ts.map +1 -1
  45. package/dist/core/utils/dev-css-watcher.utils.js +3 -2
  46. package/dist/core/utils/error-overlay.utils.d.ts.map +1 -1
  47. package/dist/core/utils/error-overlay.utils.js +7 -45
  48. package/dist/core/utils/front-matter.utils.d.ts +50 -0
  49. package/dist/core/utils/front-matter.utils.d.ts.map +1 -0
  50. package/dist/core/utils/front-matter.utils.js +157 -0
  51. package/dist/core/utils/index.d.ts +4 -5
  52. package/dist/core/utils/index.d.ts.map +1 -1
  53. package/dist/core/utils/index.js +5 -7
  54. package/dist/core/utils/paths.utils.d.ts +3 -3
  55. package/dist/core/utils/paths.utils.js +6 -6
  56. package/dist/core/utils/tailwind-inventory.utils.d.ts +2 -2
  57. package/dist/core/utils/tailwind-inventory.utils.d.ts.map +1 -1
  58. package/dist/core/utils/tailwind-inventory.utils.js +15 -6
  59. package/dist/core/utils/template-discovery.utils.d.ts +3 -14
  60. package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
  61. package/dist/core/utils/template-discovery.utils.js +2 -62
  62. package/dist/core/utils/template-errors.utils.d.ts +14 -1
  63. package/dist/core/utils/template-errors.utils.d.ts.map +1 -1
  64. package/dist/core/utils/template-errors.utils.js +45 -4
  65. package/dist/core/utils/theme.utils.d.ts +17 -0
  66. package/dist/core/utils/theme.utils.d.ts.map +1 -0
  67. package/dist/core/utils/theme.utils.js +59 -0
  68. package/dist/images/index.d.ts +1 -2
  69. package/dist/images/index.d.ts.map +1 -1
  70. package/dist/images/index.js +0 -1
  71. package/dist/images/markdown-rule.d.ts +1 -2
  72. package/dist/images/markdown-rule.d.ts.map +1 -1
  73. package/dist/images/og.d.ts +2 -15
  74. package/dist/images/og.d.ts.map +1 -1
  75. package/dist/images/og.js +4 -47
  76. package/dist/images/runtime.d.ts +24 -5
  77. package/dist/images/runtime.d.ts.map +1 -1
  78. package/dist/images/runtime.js +14 -12
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/metrics/noop.d.ts.map +1 -1
  82. package/dist/metrics/noop.js +4 -1
  83. package/dist/metrics/recorder.d.ts.map +1 -1
  84. package/dist/metrics/recorder.js +11 -8
  85. package/dist/metrics/types.d.ts +31 -5
  86. package/dist/metrics/types.d.ts.map +1 -1
  87. package/dist/metrics/utils/html-report.utils.d.ts +2 -1
  88. package/dist/metrics/utils/html-report.utils.d.ts.map +1 -1
  89. package/dist/metrics/utils/html-report.utils.js +1115 -1136
  90. package/dist/types/config.d.ts +18 -6
  91. package/dist/types/config.d.ts.map +1 -1
  92. package/dist/types/content.d.ts +83 -16
  93. package/dist/types/content.d.ts.map +1 -1
  94. package/dist/types/index.d.ts +4 -3
  95. package/dist/types/index.d.ts.map +1 -1
  96. package/dist/types/isg.d.ts +48 -2
  97. package/dist/types/isg.d.ts.map +1 -1
  98. package/package.json +6 -6
  99. package/dist/core/utils/callable-partials.utils.d.ts +0 -60
  100. package/dist/core/utils/callable-partials.utils.d.ts.map +0 -1
  101. package/dist/core/utils/callable-partials.utils.js +0 -127
  102. package/dist/core/utils/partial-validation.utils.d.ts +0 -9
  103. package/dist/core/utils/partial-validation.utils.d.ts.map +0 -1
  104. package/dist/core/utils/partial-validation.utils.js +0 -107
@@ -1,12 +1,12 @@
1
1
  import { Eta } from 'eta';
2
- import { join, dirname, relative, basename, posix } from 'node:path';
3
- import glob from 'fast-glob';
4
- import { createFallbackLogger } from './utils/index.js';
5
- import { TEMPLATE_EXTENSION } from '../constants.js';
6
- import { getStatiVersion, isCollectionIndexPage, discoverLayout, resolveSrcDir, createTemplateError, createValidatingPartialsProxy, createValidatingContextProxy, propValue, slugify, wrapPartialsAsCallable, createNavigationHelpers, } from './utils/index.js';
2
+ import { join, relative } from 'node:path';
3
+ import { createFallbackLogger, getStatiVersion, isCollectionIndexPage, resolveSrcDir, createTemplateError, propValue, slugify, createNavigationHelpers, } from './utils/index.js';
7
4
  import { getCollections, resolveCollectionPath, createCollectionView, copyPaginationData, copyGeneratedData, } from './collections.js';
8
5
  import { isDevelopment, isProduction } from '../env.js';
9
6
  import { generateSEO, resolveGeneratedImagePlaceholders } from '../seo/index.js';
7
+ import { TemplateIndex } from './template-index.js';
8
+ import { RenderScope, failingTemplateOf, runInRenderScope, } from './template-render.js';
9
+ import { createStatiEtaPlugin, installEtaPlugin } from './eta-plugin.js';
10
10
  /**
11
11
  * `stati.image()` when images are turned off: a plain `<img>`, attributes escaped.
12
12
  */
@@ -46,59 +46,6 @@ function createNavView(helpers, reads) {
46
46
  }
47
47
  return view;
48
48
  }
49
- /**
50
- * Discovers partials in the hierarchy for a given page path.
51
- * Scans all parent directories for folders starting with underscore.
52
- *
53
- * @param pagePath - The path to the page (relative to srcDir)
54
- * @param config - Stati configuration
55
- * @returns Object mapping partial names to their file paths
56
- */
57
- async function discoverPartials(pagePath, config) {
58
- const srcDir = resolveSrcDir(config);
59
- const partials = {};
60
- // Get the directory of the current page
61
- const pageDir = dirname(pagePath);
62
- const pathSegments = pageDir === '.' ? [] : pageDir.split('/');
63
- // Scan from root to current directory (least specific first)
64
- // This allows more specific partials to override less specific ones
65
- const dirsToScan = [];
66
- if (pathSegments.length > 0) {
67
- // Add root directory first, then parent directories, then current
68
- dirsToScan.push(''); // Root directory first
69
- // Add directories from root to current
70
- for (let i = 1; i <= pathSegments.length; i++) {
71
- dirsToScan.push(pathSegments.slice(0, i).join('/'));
72
- }
73
- }
74
- else {
75
- dirsToScan.push(''); // Root directory only
76
- }
77
- for (const dir of dirsToScan) {
78
- const searchDir = dir ? join(srcDir, dir) : srcDir;
79
- // Find all underscore folders in this directory level
80
- const globPattern = join(searchDir, '_*/').replace(/\\/g, '/');
81
- const underscoreFolders = await glob(globPattern, {
82
- absolute: true,
83
- onlyDirectories: true,
84
- });
85
- // Scan each underscore folder for .eta files
86
- for (const folderPath of underscoreFolders) {
87
- const etaFiles = await glob('**/*.eta', {
88
- cwd: folderPath,
89
- absolute: false,
90
- });
91
- for (const etaFile of etaFiles) {
92
- const partialName = basename(etaFile, TEMPLATE_EXTENSION);
93
- const fullPath = join(folderPath, etaFile);
94
- // Get relative path from srcDir to the partial file
95
- const relativePath = relative(srcDir, fullPath);
96
- partials[partialName] = posix.normalize(relativePath);
97
- }
98
- }
99
- }
100
- return partials;
101
- }
102
49
  /**
103
50
  * Cached template engine for dev mode to avoid re-creating on each rebuild.
104
51
  *
@@ -117,6 +64,12 @@ let cachedTemplateEngine = null;
117
64
  export function clearTemplateEngineCache() {
118
65
  cachedTemplateEngine = null;
119
66
  }
67
+ /**
68
+ * Creates the template engine: Eta with `stati` as its data variable and Stati's plugin,
69
+ * which replaces Eta's own `include()`, `layout()` and `block()` in templates.
70
+ *
71
+ * @throws {Error} When the installed Eta generates code the plugin does not recognize
72
+ */
120
73
  export function createTemplateEngine(config) {
121
74
  const templateDir = resolveSrcDir(config);
122
75
  // In dev mode, reuse a single cached engine across rebuilds to avoid
@@ -124,8 +77,8 @@ export function createTemplateEngine(config) {
124
77
  if (isDevelopment() && cachedTemplateEngine && cachedTemplateEngine.templateDir === templateDir) {
125
78
  return cachedTemplateEngine.eta;
126
79
  }
127
- // Enable the template cache in dev too so partials/layouts compile once and are
128
- // reused across rebuilds; template edits clear this cache via clearTemplateEngineCache().
80
+ // Enable the template cache in dev too so templates compile once and are reused
81
+ // across rebuilds; template edits clear this cache via clearTemplateEngineCache().
129
82
  const useCache = isDevelopment() || isProduction();
130
83
  const eta = new Eta({
131
84
  views: templateDir,
@@ -135,33 +88,40 @@ export function createTemplateEngine(config) {
135
88
  // Debug mode wraps runtime errors with the template path and line number,
136
89
  // which the dev error overlay uses to point at the failing line
137
90
  debug: isDevelopment(),
91
+ plugins: [createStatiEtaPlugin()],
138
92
  });
93
+ installEtaPlugin(eta);
139
94
  if (isDevelopment()) {
140
95
  cachedTemplateEngine = { templateDir, eta };
141
96
  }
142
97
  return eta;
143
98
  }
99
+ /**
100
+ * Renders a page: its layout (chosen by front matter `layout:` or discovery), the parents the
101
+ * layout names with `stati.layout()`, and every template they include, plus the page's Open
102
+ * Graph template when it gets a generated image. Records every template and name lookup for
103
+ * the ISG cache.
104
+ */
144
105
  export async function renderPage(page, body, config, eta, navigation, allPages, assets, toc, logger, extras) {
145
106
  const log = logger || createFallbackLogger();
146
- const pageImages = extras?.images;
147
- // The generated Open Graph image is known once the og template has rendered, after the
148
- // partials. Until then stati.generateSEO() emits placeholders for its tags.
107
+ const index = extras?.templateIndex ?? (await TemplateIndex.load(resolveSrcDir(config)));
108
+ const scope = new RenderScope({ page, eta, index, logger: log, development: isDevelopment() });
109
+ return runInRenderScope(scope, () => renderPageInScope(scope, body, config, navigation, allPages, assets, toc, extras?.images));
110
+ }
111
+ async function renderPageInScope(scope, body, config, navigation, allPages, assets, toc, pageImages) {
112
+ const { page, logger: log, index } = scope;
113
+ // The generated Open Graph image is known once the og template has rendered, before the
114
+ // layout. Until then stati.generateSEO() emits placeholders for its tags.
149
115
  const ogState = {
150
116
  image: undefined,
151
117
  pending: pageImages?.wantsOg() ?? false,
152
118
  };
153
- // Discover partials for this page's directory hierarchy
154
- const srcDir = resolveSrcDir(config);
155
- const relativePath = relative(srcDir, page.sourcePath);
156
- const partialPaths = await discoverPartials(relativePath, config);
157
- // Count templates: partials + layout (if found)
158
- const partialsCount = Object.keys(partialPaths).length;
159
119
  // What this render reads from shared data, so the cache can rebuild the page when it changes
160
120
  const reads = { collection: false, nav: false };
161
121
  // The page's collection: its own on index pages, the one it belongs to on other pages, or
162
122
  // the one its front matter names. Collections are computed once per page list.
163
123
  let collectionData;
164
- const isIndexPage = allPages && isCollectionIndexPage(page, allPages);
124
+ const isIndexPage = allPages !== undefined && isCollectionIndexPage(page, allPages);
165
125
  if (allPages) {
166
126
  const collectionPath = resolveCollectionPath(page, allPages);
167
127
  const data = collectionPath !== undefined
@@ -171,11 +131,13 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
171
131
  collectionData = createCollectionView(data, reads);
172
132
  }
173
133
  }
174
- // Discover the appropriate layout using hierarchical layout.eta convention
175
- // Pass isIndexPage flag to enable index.eta lookup for aggregation pages
176
- const layoutPath = await discoverLayout(relativePath, config, page.frontMatter.layout, isIndexPage);
177
- // Calculate total templates loaded (partials + layout if present)
178
- const templatesLoaded = partialsCount + (layoutPath ? 1 : 0);
134
+ // The layout: front matter `layout:` by the name lookup, otherwise layout.eta discovery
135
+ // (index.eta first on index pages)
136
+ const layout = index.resolvePageLayout(scope.pageFile, page.frontMatter.layout, isIndexPage);
137
+ for (const lookup of layout.lookups) {
138
+ scope.record(lookup);
139
+ }
140
+ reportFrontMatterLayout(scope, layout);
179
141
  // Create navigation helpers
180
142
  const navTree = navigation || [];
181
143
  const navHelpers = createNavigationHelpers(navTree, page);
@@ -203,24 +165,22 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
203
165
  return currentNavNode;
204
166
  },
205
167
  });
206
- // Create base context for partial rendering
207
- const baseContext = {
168
+ // Values every template of the page shares. Each render adds props, body, include and layout.
169
+ // The site's helpers live under `helpers`; the root holds only Stati's own API.
170
+ const values = {
208
171
  site: config.site,
209
172
  page: pageContext,
210
173
  content: body,
211
- nav: createNavView(navHelpers, reads), // Replace navigation with nav helpers
212
- collection: collectionData, // Add collection data
213
- // Set on paginated listings and on pages generated from [param].md templates. Placed before
214
- // the filters, like slugify, so that an existing filter with the same name keeps working.
174
+ nav: createNavView(navHelpers, reads),
175
+ collection: collectionData,
176
+ // Set on paginated listings and on pages generated from [param].md templates
215
177
  pagination: page.pagination ? copyPaginationData(page.pagination) : undefined,
216
178
  generated: page.generated ? copyGeneratedData(page.generated) : undefined,
217
- slugify,
218
- // Add custom filters to context
219
- ...(config.eta?.filters || {}),
179
+ // A copy, so a template that replaces a helper does not change it for later pages
180
+ helpers: { ...config.helpers },
220
181
  generator: {
221
182
  version: getStatiVersion(),
222
183
  },
223
- // Stati utilities object with helper functions
224
184
  generateSEO: (tags) => generateSEO({
225
185
  page,
226
186
  config,
@@ -229,141 +189,101 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
229
189
  deferGeneratedImage: ogState.pending,
230
190
  }, tags),
231
191
  propValue,
192
+ slugify,
232
193
  // Image with dimensions, lazy loading and, with images.responsive, srcset variants
233
194
  image: (src, options) => pageImages ? pageImages.renderHelperImage(src, options) : renderPlainImage(src, options),
234
195
  assets, // TypeScript bundle assets (available when typescript.enabled is true)
235
- // Set by callable partials invoked with props; always present so that
236
- // stati.props?.x works under the dev-mode context validation
237
- props: undefined,
238
196
  // Set while the Open Graph template renders: { width, height }
239
197
  og: undefined,
240
198
  // The page's generated Open Graph image, once rendered: { url, path, width, height, alt }
241
199
  ogImage: undefined,
242
200
  };
243
- // Render partials and store their content
244
- // Use multiple passes to allow partials to reference other partials
245
- const renderedPartials = {};
246
- const maxPasses = 3; // Prevent infinite loops
247
- for (let pass = 0; pass < maxPasses; pass++) {
248
- // Each pass renders all partials to ensure they have access to all previously rendered ones
249
- // TODO: Optimize by tracking which partials changed or have dependencies
250
- const partialsToRender = { ...partialPaths };
251
- if (Object.keys(partialsToRender).length === 0) {
252
- break;
253
- }
254
- let progressMade = false;
255
- const passRenderedPartials = {};
256
- for (const [partialName, partialPath] of Object.entries(partialsToRender)) {
257
- try {
258
- // Create context with all previously rendered partials available
259
- const combinedPartials = { ...renderedPartials, ...passRenderedPartials };
260
- // Wrap partials as callable before passing to validation proxy
261
- const callablePartials = wrapPartialsAsCallable(eta, combinedPartials, partialPaths, baseContext);
262
- const partialContext = createValidatingContextProxy({
263
- ...baseContext,
264
- partials: createValidatingPartialsProxy(callablePartials), // Include both previous and current pass partials with validation
265
- });
266
- const renderedContent = await eta.renderAsync(partialPath, partialContext);
267
- passRenderedPartials[partialName] = renderedContent;
268
- progressMade = true;
269
- }
270
- catch (error) {
271
- // If this is the last pass, log the error and create placeholder
272
- if (pass === maxPasses - 1) {
273
- log.warning(`Failed to render partial ${partialName} at ${partialPath}: ${error instanceof Error ? error.message : String(error)}`);
274
- // In development mode, throw enhanced template error for partials too
275
- if (isDevelopment()) {
276
- const templateError = createTemplateError(error instanceof Error ? error : new Error(String(error)), partialPath);
277
- throw templateError;
278
- }
279
- passRenderedPartials[partialName] = `<!-- Error rendering partial: ${partialName} -->`;
280
- progressMade = true;
281
- }
282
- // Otherwise, use existing content if available, or skip for retry
283
- else if (renderedPartials[partialName]) {
284
- passRenderedPartials[partialName] = renderedPartials[partialName];
285
- }
286
- // For failed partials on non-last pass, still count as progress to allow retries
287
- else {
288
- progressMade = true;
289
- }
290
- }
291
- }
292
- // Update the rendered partials with this pass's results
293
- Object.assign(renderedPartials, passRenderedPartials);
294
- // If no progress was made, break to avoid infinite loop
295
- if (!progressMade) {
296
- break;
297
- }
298
- // If this is pass 0, always do at least one more pass to allow interdependencies
299
- if (pass === 0) {
300
- continue;
301
- }
302
- // For subsequent passes, only continue if we're not at max passes yet
303
- if (pass >= maxPasses - 1) {
304
- break;
305
- }
306
- }
307
- // Render the Open Graph template with the layout's context, minus the page body, so the
201
+ // Render the Open Graph template with the layout's values, minus the page body, so the
308
202
  // generated image is known before the layout renders its SEO tags
309
203
  if (pageImages && ogState.pending) {
310
204
  // Copy the page's property descriptors so that navNode stays a getter: spreading would
311
205
  // read it and count every page with a card as reading the navigation
312
- const ogPage = Object.defineProperties({}, Object.getOwnPropertyDescriptors(baseContext.page));
206
+ const ogPage = Object.defineProperties({}, Object.getOwnPropertyDescriptors(pageContext));
313
207
  ogPage.content = '';
314
- const ogBaseContext = {
315
- ...baseContext,
316
- content: '',
317
- page: ogPage,
318
- og: pageImages.ogSize(),
208
+ const ogBase = {
209
+ id: 'og',
210
+ values: { ...values, content: '', page: ogPage, og: pageImages.ogSize() },
319
211
  };
320
- const ogContext = createValidatingContextProxy({
321
- ...ogBaseContext,
322
- partials: createValidatingPartialsProxy(wrapPartialsAsCallable(eta, renderedPartials, partialPaths, ogBaseContext)),
323
- });
324
- ogState.image = (await pageImages.renderOg(ogContext, eta)) ?? undefined;
212
+ ogState.image = (await pageImages.renderOg(createOgRenderer(scope, ogBase))) ?? undefined;
325
213
  ogState.pending = false;
326
- baseContext.ogImage = ogState.image;
214
+ values.ogImage = ogState.image;
327
215
  }
328
- // Wrap final rendered partials as callable before passing to layout context
329
- const callablePartials = wrapPartialsAsCallable(eta, renderedPartials, partialPaths, baseContext);
330
- const context = createValidatingContextProxy({
331
- ...baseContext,
332
- partials: createValidatingPartialsProxy(callablePartials), // Add rendered partials with validation
216
+ const result = (html) => ({
217
+ html,
218
+ templatesLoaded: scope.templates.size,
219
+ templateRenders: scope.renders,
220
+ includeCacheHits: scope.includeCacheHits,
221
+ ogImage: ogState.image,
222
+ reads,
223
+ templates: scope.templateFiles(layout.file),
224
+ templateLookups: [...scope.lookups.values()],
333
225
  });
226
+ if (!layout.file) {
227
+ log.warning('No layout template found, using fallback');
228
+ return result(createFallbackHtml(page, body));
229
+ }
334
230
  try {
335
- if (!layoutPath) {
336
- log.warning('No layout template found, using fallback');
337
- return {
338
- html: createFallbackHtml(page, body),
339
- templatesLoaded,
340
- ogImage: ogState.image,
341
- reads,
342
- };
343
- }
344
- const html = await eta.renderAsync(layoutPath, context);
345
- return {
346
- html: resolveGeneratedImagePlaceholders(html, ogState.image),
347
- templatesLoaded,
348
- ogImage: ogState.image,
349
- reads,
350
- };
231
+ const html = await scope.renderAsync({ id: 'page', values }, { file: layout.file });
232
+ return result(resolveGeneratedImagePlaceholders(html, ogState.image));
351
233
  }
352
234
  catch (error) {
353
- log.error(`Error rendering layout ${layoutPath || 'unknown'}: ${error instanceof Error ? error.message : String(error)}`);
354
- // In development mode, throw enhanced template error for better debugging
235
+ const failure = error instanceof Error ? error : new Error(String(error));
236
+ const failed = failingTemplateOf(failure) ?? layout.file;
237
+ // In development, throw the error for the overlay; it points at the innermost template
355
238
  if (isDevelopment()) {
356
- const templateError = createTemplateError(error instanceof Error ? error : new Error(String(error)), layoutPath || undefined);
239
+ const failedPath = join(index.srcDir, failed);
240
+ const templateError = createTemplateError(failure, failedPath);
241
+ const where = scope.displayPath(templateError.filePath ?? failedPath);
242
+ log.error(`Error rendering ${where} for ${page.url}: ${templateError.message}`);
357
243
  throw templateError;
358
244
  }
359
- return {
360
- html: createFallbackHtml(page, body),
361
- templatesLoaded,
362
- ogImage: ogState.image,
363
- reads,
364
- };
245
+ log.error(`Error rendering ${scope.display(failed)} for ${page.url}: ${failure.message}`);
246
+ return result(createFallbackHtml(page, body));
365
247
  }
366
248
  }
249
+ /**
250
+ * Warns about a front matter `layout:` value that names no template, with the templates it
251
+ * could have meant. Layout discovery picks the page's layout instead.
252
+ */
253
+ function reportFrontMatterLayout(scope, layout) {
254
+ const unresolved = layout.unresolvedFrontMatter;
255
+ if (!unresolved) {
256
+ return;
257
+ }
258
+ const page = scope.displayPath(scope.page.sourcePath);
259
+ const used = layout.file === null ? 'the fallback HTML' : scope.display(layout.file);
260
+ const didYouMean = unresolved.suggestions.length > 0
261
+ ? ` Did you mean ${unresolved.suggestions.map((name) => `"${name}"`).join(', ')}?`
262
+ : '';
263
+ scope.logger.warning(`${page}: front matter layout ${JSON.stringify(unresolved.value)} ${unresolved.problem}; using ${used}.${didYouMean}`);
264
+ }
265
+ /**
266
+ * Lets the image pipeline find the page's Open Graph template through the template index,
267
+ * recording the lookup, and render it through the render scope, so it can use
268
+ * `stati.include()` and `stati.layout()` like any template.
269
+ */
270
+ function createOgRenderer(scope, base) {
271
+ return {
272
+ find(templateName) {
273
+ const name = templateName.replace(/\.eta$/, '');
274
+ const file = scope.index.lookup(name, scope.pageFolder);
275
+ scope.record({ name, from: scope.pageFolder, file });
276
+ return file === null ? null : join(scope.index.srcDir, file);
277
+ },
278
+ render(template) {
279
+ return 'path' in template
280
+ ? scope.renderAsync(base, {
281
+ file: relative(scope.index.srcDir, template.path).replace(/\\/g, '/'),
282
+ })
283
+ : scope.renderAsync(base, { source: template.source });
284
+ },
285
+ };
286
+ }
367
287
  function createFallbackHtml(page, body) {
368
288
  const title = page.frontMatter.title || 'Untitled';
369
289
  const description = page.frontMatter.description || '';
@@ -6,14 +6,18 @@ export declare function isPassthroughProperty(prop: string | symbol): boolean;
6
6
  * Thrown in development when a template reads a property that does not exist
7
7
  * on the template context.
8
8
  *
9
- * The suggestions are encoded in the message because Eta's debug mode rethrows
10
- * runtime errors as a new error that only keeps the original message and name.
9
+ * The suggestions are encoded in the message, which Eta's debug mode copies into
10
+ * the error it rethrows for each template the error passes through.
11
11
  * Use {@link parseUnknownPropertyMessage} to read them back.
12
12
  */
13
13
  export declare class UnknownTemplatePropertyError extends Error {
14
14
  readonly propertyPath: string;
15
15
  readonly suggestions: string[];
16
- constructor(propertyPath: string, suggestions: string[], availableKeys: string[]);
16
+ /**
17
+ * @param hint - Explanation shown instead of the suggestions, for a property that only some
18
+ * templates have
19
+ */
20
+ constructor(propertyPath: string, suggestions: string[], availableKeys: string[], hint?: string);
17
21
  }
18
22
  /**
19
23
  * Reads an {@link UnknownTemplatePropertyError} message back into a short message
@@ -35,9 +39,9 @@ export declare function parseUnknownPropertyMessage(message: string): {
35
39
  * instead of silently rendering `undefined`. The error reaches the dev server's
36
40
  * error overlay.
37
41
  *
38
- * Validates the top-level `stati.*` keys (including custom filters, which are
39
- * spread onto the context) and one level down for `site`, `nav`, `collection`,
40
- * and `generator`. Frontmatter under `stati.page` is not validated.
42
+ * Validates the top-level `stati.*` keys and one level down for `site`, `nav`,
43
+ * `collection`, `generator` and `helpers`, so a misspelt helper is caught too.
44
+ * Frontmatter under `stati.page` is not validated.
41
45
  *
42
46
  * Only active in development; other environments get the context back unchanged.
43
47
  */
@@ -1 +1 @@
1
- {"version":3,"file":"context-validation.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/context-validation.utils.ts"],"names":[],"mappings":"AAkBA;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAEpE;AAmCD;;;;;;;GAOG;AACH,qBAAa,4BAA6B,SAAQ,KAAK;IACrD,SAAgB,YAAY,EAAE,MAAM,CAAC;IACrC,SAAgB,WAAW,EAAE,MAAM,EAAE,CAAC;gBAE1B,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,MAAM,EAAE;CAMjF;AAiBD;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,GACd;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,SAAS,CAoBxD;AAyBD;;;;;;;;;;;GAWG;AACH,wBAAgB,4BAA4B,CAAC,CAAC,SAAS,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAqD5E"}
1
+ {"version":3,"file":"context-validation.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/context-validation.utils.ts"],"names":[],"mappings":"AAkBA;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAEpE;AA2CD;;;;;;;GAOG;AACH,qBAAa,4BAA6B,SAAQ,KAAK;IACrD,SAAgB,YAAY,EAAE,MAAM,CAAC;IACrC,SAAgB,WAAW,EAAE,MAAM,EAAE,CAAC;IAEtC;;;OAGG;gBACS,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,MAAM;CAMhG;AAqBD;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,GACd;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,SAAS,CAsBxD;AAyBD;;;;;;;;;;;GAWG;AACH,wBAAgB,4BAA4B,CAAC,CAAC,SAAS,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CA4D5E"}
@@ -34,41 +34,55 @@ const SITE_CONFIG_KEYS = {
34
34
  /**
35
35
  * Context properties whose own keys form a closed set and are validated one level
36
36
  * down, with keys that may be legitimately absent at runtime.
37
- * `page` is left out because frontmatter keys differ from page to page, and
38
- * `partials` already has its own validating proxy.
37
+ * `page` is left out because frontmatter keys differ from page to page.
39
38
  */
40
39
  const VALIDATED_NESTED_PROPERTIES = {
41
40
  site: Object.keys(SITE_CONFIG_KEYS),
42
41
  nav: [],
43
42
  collection: [],
44
43
  generator: [],
44
+ helpers: [],
45
45
  };
46
46
  /**
47
47
  * Context properties searched for an exact match when a key is missing from the
48
- * root, so that `stati.title` can suggest `stati.page.title` and `stati.header`
49
- * can suggest `stati.partials.header`.
48
+ * root, so that `stati.title` can suggest `stati.page.title` and `stati.formatDate`
49
+ * can suggest `stati.helpers.formatDate`.
50
50
  */
51
- const CROSS_LEVEL_PROPERTIES = ['page', 'site', 'nav', 'partials'];
51
+ const CROSS_LEVEL_PROPERTIES = ['page', 'site', 'nav', 'helpers'];
52
+ /**
53
+ * Explanations for root properties that exist only in some templates, shown instead of
54
+ * spelling suggestions when a template reads one where it is absent.
55
+ */
56
+ const ABSENT_PROPERTY_HINTS = {
57
+ body: "stati.body is set only in templates rendered by stati.layout() or stati.include(); the page's Markdown is stati.content",
58
+ };
52
59
  /**
53
60
  * Thrown in development when a template reads a property that does not exist
54
61
  * on the template context.
55
62
  *
56
- * The suggestions are encoded in the message because Eta's debug mode rethrows
57
- * runtime errors as a new error that only keeps the original message and name.
63
+ * The suggestions are encoded in the message, which Eta's debug mode copies into
64
+ * the error it rethrows for each template the error passes through.
58
65
  * Use {@link parseUnknownPropertyMessage} to read them back.
59
66
  */
60
67
  export class UnknownTemplatePropertyError extends Error {
61
68
  propertyPath;
62
69
  suggestions;
63
- constructor(propertyPath, suggestions, availableKeys) {
64
- super(formatUnknownPropertyMessage(propertyPath, suggestions, availableKeys));
70
+ /**
71
+ * @param hint - Explanation shown instead of the suggestions, for a property that only some
72
+ * templates have
73
+ */
74
+ constructor(propertyPath, suggestions, availableKeys, hint) {
75
+ super(formatUnknownPropertyMessage(propertyPath, suggestions, availableKeys, hint));
65
76
  this.name = 'UnknownTemplatePropertyError';
66
77
  this.propertyPath = propertyPath;
67
- this.suggestions = suggestions;
78
+ this.suggestions = hint ? [hint] : suggestions;
68
79
  }
69
80
  }
70
- function formatUnknownPropertyMessage(propertyPath, suggestions, availableKeys) {
81
+ function formatUnknownPropertyMessage(propertyPath, suggestions, availableKeys, hint) {
71
82
  const base = `Unknown property "${propertyPath}".`;
83
+ if (hint) {
84
+ return `${base} ${hint}.`;
85
+ }
72
86
  if (suggestions.length > 0) {
73
87
  const quoted = suggestions.map((s) => `"${s}"`);
74
88
  const list = quoted.length === 1 ? quoted[0] : `${quoted.slice(0, -1).join(', ')} or ${quoted.at(-1)}`;
@@ -87,11 +101,11 @@ function formatUnknownPropertyMessage(propertyPath, suggestions, availableKeys)
87
101
  * ```
88
102
  */
89
103
  export function parseUnknownPropertyMessage(message) {
90
- const match = message.match(/^Unknown property "([^"]+)"\.(?: Did you mean (.+)\?| Available properties: (.*)\.)?$/);
104
+ const match = message.match(/^Unknown property "([^"]+)"\.(?: Did you mean (.+)\?| Available properties: (.*)\.| (.+)\.)?$/);
91
105
  if (!match) {
92
106
  return undefined;
93
107
  }
94
- const [, propertyPath, didYouMean, available] = match;
108
+ const [, propertyPath, didYouMean, available, hint] = match;
95
109
  const suggestions = [];
96
110
  if (didYouMean) {
97
111
  for (const [, name] of didYouMean.matchAll(/"([^"]+)"/g)) {
@@ -102,6 +116,9 @@ export function parseUnknownPropertyMessage(message) {
102
116
  const parentPath = propertyPath.slice(0, propertyPath.lastIndexOf('.'));
103
117
  suggestions.push(`Available properties on ${parentPath}: ${available || '(none)'}`);
104
118
  }
119
+ else if (hint) {
120
+ suggestions.push(hint);
121
+ }
105
122
  return { message: `Unknown property "${propertyPath}"`, suggestions };
106
123
  }
107
124
  function listKeys(target, extraKeys) {
@@ -126,9 +143,9 @@ function createValidatingObjectProxy(target, path, extraKeys) {
126
143
  * instead of silently rendering `undefined`. The error reaches the dev server's
127
144
  * error overlay.
128
145
  *
129
- * Validates the top-level `stati.*` keys (including custom filters, which are
130
- * spread onto the context) and one level down for `site`, `nav`, `collection`,
131
- * and `generator`. Frontmatter under `stati.page` is not validated.
146
+ * Validates the top-level `stati.*` keys and one level down for `site`, `nav`,
147
+ * `collection`, `generator` and `helpers`, so a misspelt helper is caught too.
148
+ * Frontmatter under `stati.page` is not validated.
132
149
  *
133
150
  * Only active in development; other environments get the context back unchanged.
134
151
  */
@@ -157,6 +174,12 @@ export function createValidatingContextProxy(context) {
157
174
  }
158
175
  const name = String(prop);
159
176
  const availableKeys = listKeys(obj, []);
177
+ const hint = Object.hasOwn(ABSENT_PROPERTY_HINTS, name)
178
+ ? ABSENT_PROPERTY_HINTS[name]
179
+ : undefined;
180
+ if (hint) {
181
+ throw new UnknownTemplatePropertyError(`stati.${name}`, [], availableKeys, hint);
182
+ }
160
183
  // An exact match one level down (stati.title -> stati.page.title) is a
161
184
  // stronger signal than a spelling match, so it goes first
162
185
  const suggestions = [];
@@ -12,7 +12,7 @@ export declare const PUBLISHED_DATE_FIELDS: readonly ["publishedAt", "published"
12
12
  *
13
13
  * Checks {@link PUBLISHED_DATE_FIELDS} in order and returns the first valid value.
14
14
  * Accepts strings (quoted YAML dates) and `Date` objects (unquoted YAML dates,
15
- * which gray-matter parses into `Date`). Missing, `null` and empty values are skipped;
15
+ * which YAML parses into `Date`). Missing, `null` and empty values are skipped;
16
16
  * other invalid values are reported through `onInvalid` and skipped, so a bad
17
17
  * `publishedAt` does not hide a valid `date`.
18
18
  *
@@ -12,7 +12,7 @@ export const PUBLISHED_DATE_FIELDS = ['publishedAt', 'published', 'date', 'creat
12
12
  *
13
13
  * Checks {@link PUBLISHED_DATE_FIELDS} in order and returns the first valid value.
14
14
  * Accepts strings (quoted YAML dates) and `Date` objects (unquoted YAML dates,
15
- * which gray-matter parses into `Date`). Missing, `null` and empty values are skipped;
15
+ * which YAML parses into `Date`). Missing, `null` and empty values are skipped;
16
16
  * other invalid values are reported through `onInvalid` and skipped, so a bad
17
17
  * `publishedAt` does not hide a valid `date`.
18
18
  *
@@ -1 +1 @@
1
- {"version":3,"file":"dev-css-watcher.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/dev-css-watcher.utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,IAAI,CAAC;AAC1C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAGnD;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAa9E;AA0BD,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,eAAe,GAAG,IAAI,CAAC;IACjC,eAAe,EAAE,OAAO,CAAC;IACzB,UAAU,EAAE,SAAS,GAAG,IAAI,CAAC;IAC7B,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,6BAA6B,GACpC,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAgC3B"}
1
+ {"version":3,"file":"dev-css-watcher.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/dev-css-watcher.utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,IAAI,CAAC;AAC1C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAGnD;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAc9E;AA0BD,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,eAAe,GAAG,IAAI,CAAC;IACjC,eAAe,EAAE,OAAO,CAAC;IACzB,UAAU,EAAE,SAAS,GAAG,IAAI,CAAC;IAC7B,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,6BAA6B,GACpC,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAgC3B"}
@@ -1,5 +1,5 @@
1
1
  import chokidar from 'chokidar';
2
- import fg from 'fast-glob';
2
+ import { glob } from 'tinyglobby';
3
3
  import { pathExists } from './fs.utils.js';
4
4
  /**
5
5
  * Returns concrete CSS files currently present in outDir.
@@ -11,11 +11,12 @@ export async function discoverOutputCssFiles(outDir) {
11
11
  if (!(await pathExists(outDir))) {
12
12
  return [];
13
13
  }
14
- const cssFiles = await fg('**/*.css', {
14
+ const cssFiles = await glob('**/*.css', {
15
15
  cwd: outDir,
16
16
  absolute: true,
17
17
  onlyFiles: true,
18
18
  dot: false,
19
+ expandDirectories: false,
19
20
  });
20
21
  return cssFiles;
21
22
  }
@@ -1 +1 @@
1
- {"version":3,"file":"error-overlay.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/error-overlay.utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE;QACR,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AA0sBD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAiJnF;AAsFD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,EAC1B,QAAQ,CAAC,EAAE,MAAM,GAChB,YAAY,CAkCd"}
1
+ {"version":3,"file":"error-overlay.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/error-overlay.utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE;QACR,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AA6pBD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAiJnF;AAsFD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,EAC1B,QAAQ,CAAC,EAAE,MAAM,GAChB,YAAY,CAkCd"}