@stati/core 1.29.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 -11
  37. package/dist/core/templates.d.ts.map +1 -1
  38. package/dist/core/templates.js +115 -196
  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, 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,60 +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), with `/` or `\` separators
54
- * @param config - Stati configuration
55
- * @returns Object mapping partial names to their file paths
56
- */
57
- export async function discoverPartials(pagePath, config) {
58
- const srcDir = resolveSrcDir(config);
59
- const partials = {};
60
- // Get the directory of the current page. Windows paths use backslashes, so convert them
61
- // first, or the folders between the root and the page's own folder are skipped.
62
- const pageDir = posix.dirname(pagePath.replace(/\\/g, '/'));
63
- const pathSegments = pageDir === '.' ? [] : pageDir.split('/');
64
- // Scan from root to current directory (least specific first)
65
- // This allows more specific partials to override less specific ones
66
- const dirsToScan = [];
67
- if (pathSegments.length > 0) {
68
- // Add root directory first, then parent directories, then current
69
- dirsToScan.push(''); // Root directory first
70
- // Add directories from root to current
71
- for (let i = 1; i <= pathSegments.length; i++) {
72
- dirsToScan.push(pathSegments.slice(0, i).join('/'));
73
- }
74
- }
75
- else {
76
- dirsToScan.push(''); // Root directory only
77
- }
78
- for (const dir of dirsToScan) {
79
- const searchDir = dir ? join(srcDir, dir) : srcDir;
80
- // Find all underscore folders in this directory level
81
- const globPattern = join(searchDir, '_*/').replace(/\\/g, '/');
82
- const underscoreFolders = await glob(globPattern, {
83
- absolute: true,
84
- onlyDirectories: true,
85
- });
86
- // Scan each underscore folder for .eta files
87
- for (const folderPath of underscoreFolders) {
88
- const etaFiles = await glob('**/*.eta', {
89
- cwd: folderPath,
90
- absolute: false,
91
- });
92
- for (const etaFile of etaFiles) {
93
- const partialName = basename(etaFile, TEMPLATE_EXTENSION);
94
- const fullPath = join(folderPath, etaFile);
95
- // Get relative path from srcDir to the partial file
96
- const relativePath = relative(srcDir, fullPath);
97
- partials[partialName] = posix.normalize(relativePath);
98
- }
99
- }
100
- }
101
- return partials;
102
- }
103
49
  /**
104
50
  * Cached template engine for dev mode to avoid re-creating on each rebuild.
105
51
  *
@@ -118,6 +64,12 @@ let cachedTemplateEngine = null;
118
64
  export function clearTemplateEngineCache() {
119
65
  cachedTemplateEngine = null;
120
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
+ */
121
73
  export function createTemplateEngine(config) {
122
74
  const templateDir = resolveSrcDir(config);
123
75
  // In dev mode, reuse a single cached engine across rebuilds to avoid
@@ -125,8 +77,8 @@ export function createTemplateEngine(config) {
125
77
  if (isDevelopment() && cachedTemplateEngine && cachedTemplateEngine.templateDir === templateDir) {
126
78
  return cachedTemplateEngine.eta;
127
79
  }
128
- // Enable the template cache in dev too so partials/layouts compile once and are
129
- // 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().
130
82
  const useCache = isDevelopment() || isProduction();
131
83
  const eta = new Eta({
132
84
  views: templateDir,
@@ -136,33 +88,40 @@ export function createTemplateEngine(config) {
136
88
  // Debug mode wraps runtime errors with the template path and line number,
137
89
  // which the dev error overlay uses to point at the failing line
138
90
  debug: isDevelopment(),
91
+ plugins: [createStatiEtaPlugin()],
139
92
  });
93
+ installEtaPlugin(eta);
140
94
  if (isDevelopment()) {
141
95
  cachedTemplateEngine = { templateDir, eta };
142
96
  }
143
97
  return eta;
144
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
+ */
145
105
  export async function renderPage(page, body, config, eta, navigation, allPages, assets, toc, logger, extras) {
146
106
  const log = logger || createFallbackLogger();
147
- const pageImages = extras?.images;
148
- // The generated Open Graph image is known once the og template has rendered, after the
149
- // 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.
150
115
  const ogState = {
151
116
  image: undefined,
152
117
  pending: pageImages?.wantsOg() ?? false,
153
118
  };
154
- // Discover partials for this page's directory hierarchy
155
- const srcDir = resolveSrcDir(config);
156
- const relativePath = relative(srcDir, page.sourcePath);
157
- const partialPaths = await discoverPartials(relativePath, config);
158
- // Count templates: partials + layout (if found)
159
- const partialsCount = Object.keys(partialPaths).length;
160
119
  // What this render reads from shared data, so the cache can rebuild the page when it changes
161
120
  const reads = { collection: false, nav: false };
162
121
  // The page's collection: its own on index pages, the one it belongs to on other pages, or
163
122
  // the one its front matter names. Collections are computed once per page list.
164
123
  let collectionData;
165
- const isIndexPage = allPages && isCollectionIndexPage(page, allPages);
124
+ const isIndexPage = allPages !== undefined && isCollectionIndexPage(page, allPages);
166
125
  if (allPages) {
167
126
  const collectionPath = resolveCollectionPath(page, allPages);
168
127
  const data = collectionPath !== undefined
@@ -172,11 +131,13 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
172
131
  collectionData = createCollectionView(data, reads);
173
132
  }
174
133
  }
175
- // Discover the appropriate layout using hierarchical layout.eta convention
176
- // Pass isIndexPage flag to enable index.eta lookup for aggregation pages
177
- const layoutPath = await discoverLayout(relativePath, config, page.frontMatter.layout, isIndexPage);
178
- // Calculate total templates loaded (partials + layout if present)
179
- 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);
180
141
  // Create navigation helpers
181
142
  const navTree = navigation || [];
182
143
  const navHelpers = createNavigationHelpers(navTree, page);
@@ -204,24 +165,22 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
204
165
  return currentNavNode;
205
166
  },
206
167
  });
207
- // Create base context for partial rendering
208
- 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 = {
209
171
  site: config.site,
210
172
  page: pageContext,
211
173
  content: body,
212
- nav: createNavView(navHelpers, reads), // Replace navigation with nav helpers
213
- collection: collectionData, // Add collection data
214
- // Set on paginated listings and on pages generated from [param].md templates. Placed before
215
- // 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
216
177
  pagination: page.pagination ? copyPaginationData(page.pagination) : undefined,
217
178
  generated: page.generated ? copyGeneratedData(page.generated) : undefined,
218
- slugify,
219
- // Add custom filters to context
220
- ...(config.eta?.filters || {}),
179
+ // A copy, so a template that replaces a helper does not change it for later pages
180
+ helpers: { ...config.helpers },
221
181
  generator: {
222
182
  version: getStatiVersion(),
223
183
  },
224
- // Stati utilities object with helper functions
225
184
  generateSEO: (tags) => generateSEO({
226
185
  page,
227
186
  config,
@@ -230,141 +189,101 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
230
189
  deferGeneratedImage: ogState.pending,
231
190
  }, tags),
232
191
  propValue,
192
+ slugify,
233
193
  // Image with dimensions, lazy loading and, with images.responsive, srcset variants
234
194
  image: (src, options) => pageImages ? pageImages.renderHelperImage(src, options) : renderPlainImage(src, options),
235
195
  assets, // TypeScript bundle assets (available when typescript.enabled is true)
236
- // Set by callable partials invoked with props; always present so that
237
- // stati.props?.x works under the dev-mode context validation
238
- props: undefined,
239
196
  // Set while the Open Graph template renders: { width, height }
240
197
  og: undefined,
241
198
  // The page's generated Open Graph image, once rendered: { url, path, width, height, alt }
242
199
  ogImage: undefined,
243
200
  };
244
- // Render partials and store their content
245
- // Use multiple passes to allow partials to reference other partials
246
- const renderedPartials = {};
247
- const maxPasses = 3; // Prevent infinite loops
248
- for (let pass = 0; pass < maxPasses; pass++) {
249
- // Each pass renders all partials to ensure they have access to all previously rendered ones
250
- // TODO: Optimize by tracking which partials changed or have dependencies
251
- const partialsToRender = { ...partialPaths };
252
- if (Object.keys(partialsToRender).length === 0) {
253
- break;
254
- }
255
- let progressMade = false;
256
- const passRenderedPartials = {};
257
- for (const [partialName, partialPath] of Object.entries(partialsToRender)) {
258
- try {
259
- // Create context with all previously rendered partials available
260
- const combinedPartials = { ...renderedPartials, ...passRenderedPartials };
261
- // Wrap partials as callable before passing to validation proxy
262
- const callablePartials = wrapPartialsAsCallable(eta, combinedPartials, partialPaths, baseContext);
263
- const partialContext = createValidatingContextProxy({
264
- ...baseContext,
265
- partials: createValidatingPartialsProxy(callablePartials), // Include both previous and current pass partials with validation
266
- });
267
- const renderedContent = await eta.renderAsync(partialPath, partialContext);
268
- passRenderedPartials[partialName] = renderedContent;
269
- progressMade = true;
270
- }
271
- catch (error) {
272
- // If this is the last pass, log the error and create placeholder
273
- if (pass === maxPasses - 1) {
274
- log.warning(`Failed to render partial ${partialName} at ${partialPath}: ${error instanceof Error ? error.message : String(error)}`);
275
- // In development mode, throw enhanced template error for partials too
276
- if (isDevelopment()) {
277
- const templateError = createTemplateError(error instanceof Error ? error : new Error(String(error)), partialPath);
278
- throw templateError;
279
- }
280
- passRenderedPartials[partialName] = `<!-- Error rendering partial: ${partialName} -->`;
281
- progressMade = true;
282
- }
283
- // Otherwise, use existing content if available, or skip for retry
284
- else if (renderedPartials[partialName]) {
285
- passRenderedPartials[partialName] = renderedPartials[partialName];
286
- }
287
- // For failed partials on non-last pass, still count as progress to allow retries
288
- else {
289
- progressMade = true;
290
- }
291
- }
292
- }
293
- // Update the rendered partials with this pass's results
294
- Object.assign(renderedPartials, passRenderedPartials);
295
- // If no progress was made, break to avoid infinite loop
296
- if (!progressMade) {
297
- break;
298
- }
299
- // If this is pass 0, always do at least one more pass to allow interdependencies
300
- if (pass === 0) {
301
- continue;
302
- }
303
- // For subsequent passes, only continue if we're not at max passes yet
304
- if (pass >= maxPasses - 1) {
305
- break;
306
- }
307
- }
308
- // 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
309
202
  // generated image is known before the layout renders its SEO tags
310
203
  if (pageImages && ogState.pending) {
311
204
  // Copy the page's property descriptors so that navNode stays a getter: spreading would
312
205
  // read it and count every page with a card as reading the navigation
313
- const ogPage = Object.defineProperties({}, Object.getOwnPropertyDescriptors(baseContext.page));
206
+ const ogPage = Object.defineProperties({}, Object.getOwnPropertyDescriptors(pageContext));
314
207
  ogPage.content = '';
315
- const ogBaseContext = {
316
- ...baseContext,
317
- content: '',
318
- page: ogPage,
319
- og: pageImages.ogSize(),
208
+ const ogBase = {
209
+ id: 'og',
210
+ values: { ...values, content: '', page: ogPage, og: pageImages.ogSize() },
320
211
  };
321
- const ogContext = createValidatingContextProxy({
322
- ...ogBaseContext,
323
- partials: createValidatingPartialsProxy(wrapPartialsAsCallable(eta, renderedPartials, partialPaths, ogBaseContext)),
324
- });
325
- ogState.image = (await pageImages.renderOg(ogContext, eta)) ?? undefined;
212
+ ogState.image = (await pageImages.renderOg(createOgRenderer(scope, ogBase))) ?? undefined;
326
213
  ogState.pending = false;
327
- baseContext.ogImage = ogState.image;
214
+ values.ogImage = ogState.image;
328
215
  }
329
- // Wrap final rendered partials as callable before passing to layout context
330
- const callablePartials = wrapPartialsAsCallable(eta, renderedPartials, partialPaths, baseContext);
331
- const context = createValidatingContextProxy({
332
- ...baseContext,
333
- 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()],
334
225
  });
226
+ if (!layout.file) {
227
+ log.warning('No layout template found, using fallback');
228
+ return result(createFallbackHtml(page, body));
229
+ }
335
230
  try {
336
- if (!layoutPath) {
337
- log.warning('No layout template found, using fallback');
338
- return {
339
- html: createFallbackHtml(page, body),
340
- templatesLoaded,
341
- ogImage: ogState.image,
342
- reads,
343
- };
344
- }
345
- const html = await eta.renderAsync(layoutPath, context);
346
- return {
347
- html: resolveGeneratedImagePlaceholders(html, ogState.image),
348
- templatesLoaded,
349
- ogImage: ogState.image,
350
- reads,
351
- };
231
+ const html = await scope.renderAsync({ id: 'page', values }, { file: layout.file });
232
+ return result(resolveGeneratedImagePlaceholders(html, ogState.image));
352
233
  }
353
234
  catch (error) {
354
- log.error(`Error rendering layout ${layoutPath || 'unknown'}: ${error instanceof Error ? error.message : String(error)}`);
355
- // 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
356
238
  if (isDevelopment()) {
357
- 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}`);
358
243
  throw templateError;
359
244
  }
360
- return {
361
- html: createFallbackHtml(page, body),
362
- templatesLoaded,
363
- ogImage: ogState.image,
364
- reads,
365
- };
245
+ log.error(`Error rendering ${scope.display(failed)} for ${page.url}: ${failure.message}`);
246
+ return result(createFallbackHtml(page, body));
366
247
  }
367
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
+ }
368
287
  function createFallbackHtml(page, body) {
369
288
  const title = page.frontMatter.title || 'Untitled';
370
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"}