@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
package/dist/core/dev.js CHANGED
@@ -6,11 +6,12 @@ import { build } from './build.js';
6
6
  import { invalidate } from './invalidate.js';
7
7
  import { loadConfig } from '../config/loader.js';
8
8
  import { clearTemplateEngineCache } from './templates.js';
9
+ import { startTemplateIndexSession, endTemplateIndexSession, getCachedTemplateIndex, toTemplateFile, } from './template-index.js';
9
10
  import { clearMarkdownProcessorCache } from './markdown.js';
10
- import { loadCacheManifest, saveCacheManifest, DevServerLockManager } from './isg/index.js';
11
- import { resolveDevPaths, resolveCacheDir, resolvePrettyUrl, resolveRequestFile, resolveNotFoundPage, getMimeType, parseRequestPath, createErrorOverlay, parseErrorDetails, TemplateError, createFallbackLogger, mergeServerOptions, createTypeScriptWatcher, isEnabledEnvFlag, refreshCssOutputWatcher, normalizePathForComparison, assertPortAvailable, toPortUnavailableError, } from './utils/index.js';
11
+ import { loadCacheManifest, saveCacheManifest, hasRecordedTemplates, DevServerLockManager, } from './isg/index.js';
12
+ import { resolveDevPaths, resolveCacheDir, resolvePrettyUrl, resolveRequestFile, resolveNotFoundPage, getMimeType, parseRequestPath, createErrorOverlay, parseErrorDetails, TemplateError, FrontMatterError, createFallbackLogger, mergeServerOptions, createTypeScriptWatcher, isEnabledEnvFlag, refreshCssOutputWatcher, normalizePathForComparison, assertPortAvailable, toPortUnavailableError, } from './utils/index.js';
12
13
  import { setEnv, getEnv } from '../env.js';
13
- import { DEFAULT_DEV_PORT, DEFAULT_DEV_HOST, TEMPLATE_EXTENSION, DEFAULT_OUT_DIR, } from '../constants.js';
14
+ import { DEFAULT_DEV_PORT, DEFAULT_DEV_HOST, DEFAULT_OUT_DIR } from '../constants.js';
14
15
  /**
15
16
  * Loads and validates configuration for the dev server.
16
17
  */
@@ -67,6 +68,7 @@ async function performInitialBuild(configPath, logger, onError) {
67
68
  * @param eventType - The type of change: 'add', 'change', or 'unlink'
68
69
  * @param configPath - Optional path to the stati config file
69
70
  * @param staticDir - The static assets directory path
71
+ * @param srcDir - The source directory path, whose `.eta` files are templates
70
72
  * @param logger - Logger instance for output
71
73
  * @param wsServer - WebSocket server for live reload notifications
72
74
  * @param isBuildingRef - Mutable reference tracking whether a build is currently in progress
@@ -75,9 +77,11 @@ async function performInitialBuild(configPath, logger, onError) {
75
77
  * ensuring each file is only processed once with its most recent event type.
76
78
  * @param onError - Optional callback invoked when build errors occur
77
79
  * @param onBuildEnd - Optional callback invoked after each build attempt finishes (success or failure)
80
+ * @param hasBuildError - Whether the last build failed, so a template change rebuilds even when
81
+ * no recorded page uses the template
78
82
  * @param batchedChanges - Array of changes to process in a single batch (used for pending queue processing)
79
83
  */
80
- async function performIncrementalRebuild(changedPath, eventType, configPath, staticDir, logger, wsServer, isBuildingRef, pendingChangesRef, onError, onBuildEnd, batchedChanges = []) {
84
+ async function performIncrementalRebuild(changedPath, eventType, configPath, staticDir, srcDir, logger, wsServer, isBuildingRef, pendingChangesRef, onError, onBuildEnd, hasBuildError, batchedChanges = []) {
81
85
  // If a build is in progress, queue this change for processing after the current build
82
86
  if (isBuildingRef.value) {
83
87
  pendingChangesRef.changes.set(changedPath, eventType);
@@ -121,45 +125,51 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
121
125
  const getRelativePath = (path) => path.replace(process.cwd(), '').replace(/\\/g, '/').replace(/^\//, '');
122
126
  // Track template change result for better output
123
127
  let templateChangeResult = null;
128
+ // Templates are .eta files in the source directory; other files are content or static assets
129
+ const isTemplate = (path) => toTemplateFile(srcDir, path) !== null;
130
+ const templateChanges = allChanges.filter((change) => isTemplate(change.path));
131
+ const otherChanges = allChanges.filter((change) => !isTemplate(change.path));
124
132
  try {
125
- // Check if the changed file is a template/partial
126
- if (changedPath.endsWith(TEMPLATE_EXTENSION) || changedPath.includes('_partials')) {
127
- // Use progress-enabled logger for template changes (rebuilds multiple pages)
128
- const devLogger = createDevLogger(true);
129
- templateChangeResult = await handleTemplateChange(changedPath, configPath, devLogger);
133
+ let rebuild = 'none';
134
+ if (templateChanges.length > 0) {
135
+ templateChangeResult = await markTemplateChanges(templateChanges, srcDir, hasBuildError?.() ?? false);
136
+ rebuild = templateChangeResult.rebuild;
130
137
  }
131
- else if (changedPath.endsWith('.md')) {
132
- const devLogger = createDevLogger(false);
133
- await handleMarkdownChange(changedPath, configPath, devLogger);
138
+ // Markdown and static files: ISG decides what re-renders, such as the pages that list a
139
+ // changed page through a collection; the build also removes a deleted page's output
140
+ if (otherChanges.length > 0 && rebuild === 'none') {
141
+ rebuild = 'incremental';
134
142
  }
135
- else {
136
- // Static file changed - use normal rebuild
137
- const devLogger = createDevLogger(false);
143
+ if (rebuild !== 'none') {
138
144
  await build({
139
- logger: devLogger,
140
- force: false,
145
+ // Progress output for template changes, which can rebuild many pages
146
+ logger: createDevLogger(templateChanges.length > 0),
147
+ force: rebuild === 'full',
141
148
  clean: false,
149
+ // Template changes don't affect static assets
150
+ ...(otherChanges.length === 0 && { skipAssetCopy: true }),
142
151
  ...(configPath && { configPath }),
143
152
  });
144
- }
145
- // Clear any previous errors on successful build
146
- if (onError) {
147
- onError(null);
148
- }
149
- // Notify all connected clients to reload
150
- if (wsServer) {
151
- wsServer.clients.forEach((client) => {
152
- const ws = client;
153
- if (ws.readyState === 1) {
154
- // WebSocket.OPEN
155
- ws.send(JSON.stringify({ type: 'reload' }));
156
- }
157
- });
153
+ // Clear any previous errors on successful build
154
+ if (onError) {
155
+ onError(null);
156
+ }
157
+ // Notify all connected clients to reload
158
+ if (wsServer) {
159
+ wsServer.clients.forEach((client) => {
160
+ const ws = client;
161
+ if (ws.readyState === 1) {
162
+ // WebSocket.OPEN
163
+ ws.send(JSON.stringify({ type: 'reload' }));
164
+ }
165
+ });
166
+ }
158
167
  }
159
168
  const duration = Date.now() - startTime;
160
169
  // Log rebuild result with affected page info for template changes
161
170
  for (const change of allChanges) {
162
171
  const relativePath = getRelativePath(change.path);
172
+ const template = templateChanges.includes(change);
163
173
  let action;
164
174
  if (change.eventType === 'unlink') {
165
175
  action = 'deleted';
@@ -167,17 +177,30 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
167
177
  else if (isStaticAsset(change.path)) {
168
178
  action = 'copied';
169
179
  }
180
+ else if (template) {
181
+ action = change.eventType === 'add' ? 'added' : 'changed';
182
+ }
170
183
  else {
171
184
  action = 'rebuilt';
172
185
  }
186
+ logger.info?.(`▸ ${relativePath} ${action}`);
173
187
  // For template changes, include affected page count
174
- if (templateChangeResult && templateChangeResult.affectedPages > 0) {
188
+ if (template && templateChangeResult && rebuild !== 'none') {
175
189
  const { affectedPages, totalPages } = templateChangeResult;
176
- logger.info?.(`▸ ${relativePath} ${action}`);
177
- logger.info?.(` ${affectedPages}/${totalPages} pages rebuilt in ${duration}ms`);
190
+ if (templateChangeResult.rebuild === 'full') {
191
+ logger.info?.(` All pages rebuilt in ${duration}ms`);
192
+ }
193
+ else if (affectedPages > 0) {
194
+ logger.info?.(` ${affectedPages}/${totalPages} pages rebuilt in ${duration}ms`);
195
+ }
196
+ else {
197
+ logger.info?.(` Done in ${duration}ms`);
198
+ }
199
+ }
200
+ else if (template) {
201
+ logger.info?.(change.eventType === 'unlink' ? ' No page used it' : ' No page uses it');
178
202
  }
179
203
  else {
180
- logger.info?.(`▸ ${relativePath} ${action}`);
181
204
  logger.info?.(` Done in ${duration}ms`);
182
205
  }
183
206
  }
@@ -206,127 +229,119 @@ async function performIncrementalRebuild(changedPath, eventType, configPath, sta
206
229
  // Get all pending changes - the build will process all of them at once
207
230
  const pendingChanges = Array.from(pendingChangesRef.changes.entries()).map(([path, evtType]) => ({ path, eventType: evtType }));
208
231
  pendingChangesRef.changes.clear();
209
- // Trigger another rebuild with all batched changes
210
- // The build() call will copy all static assets and rebuild all affected pages
232
+ // Trigger another rebuild with all batched changes: every template change in the batch
233
+ // updates the template index and marks its pages, then one build runs
211
234
  const [first, ...remaining] = pendingChanges;
212
235
  if (first) {
213
- void performIncrementalRebuild(first.path, first.eventType, configPath, staticDir, logger, wsServer, isBuildingRef, pendingChangesRef, onError, onBuildEnd, remaining);
236
+ void performIncrementalRebuild(first.path, first.eventType, configPath, staticDir, srcDir, logger, wsServer, isBuildingRef, pendingChangesRef, onError, onBuildEnd, hasBuildError, remaining);
214
237
  }
215
238
  }
216
239
  }
217
240
  }
218
241
  /**
219
- * Handles template/partial file changes by invalidating affected pages.
220
- * Uses proper path normalization to ensure reliable matching between
221
- * file watcher paths and cached dependency paths.
242
+ * Applies template changes before a rebuild. Pages record the templates they rendered and the
243
+ * names they looked up, so for each changed `.eta` file:
244
+ * - change: the pages that rendered it rebuild; when none did, no page uses it.
245
+ * - add: the template index learns the file, and the pages whose lookups now find it rebuild,
246
+ * such as the pages a new section override applies to.
247
+ * - unlink: the index forgets the file, and the pages that rendered or found it rebuild.
248
+ *
249
+ * Affected entries are marked stale in the saved manifest. Entries without recorded templates
250
+ * cannot say which pages use a file, so they get a forced full build.
222
251
  *
223
- * @returns Object with affected and total page counts
252
+ * @param lastBuildFailed - Whether the previous build failed; then a build runs even when no
253
+ * recorded page uses the file, so the error clears once the template that broke it is fixed
224
254
  */
225
- async function handleTemplateChange(templatePath, configPath, logger) {
226
- const cacheDir = resolveCacheDir();
255
+ async function markTemplateChanges(changes, srcDir, lastBuildFailed) {
256
+ // Drop the cached Eta engine so the changed templates are recompiled on the next build.
257
+ // Markdown-only edits keep reusing it.
258
+ clearTemplateEngineCache();
259
+ // Keep the template index that dev builds reuse in step with the files
260
+ const index = getCachedTemplateIndex(srcDir);
261
+ for (const change of changes) {
262
+ const file = toTemplateFile(srcDir, change.path);
263
+ if (index && file) {
264
+ if (change.eventType === 'add') {
265
+ index.add(file);
266
+ }
267
+ else if (change.eventType === 'unlink') {
268
+ index.remove(file);
269
+ }
270
+ }
271
+ }
227
272
  try {
228
- // Drop the cached Eta engine so the changed template (and any compiled partials)
229
- // are recompiled on the next build. This keeps rebuilds correct while still
230
- // reusing the engine across the common markdown-only edit path.
231
- clearTemplateEngineCache();
232
- // Load existing cache manifest
273
+ const cacheDir = resolveCacheDir();
233
274
  const cacheManifest = await loadCacheManifest(cacheDir);
234
275
  if (!cacheManifest) {
235
- // No cache exists, perform full rebuild
236
- await build({
237
- logger,
238
- force: false,
239
- clean: false,
240
- ...(configPath && { configPath }),
241
- });
242
- return { affectedPages: 0, totalPages: 0 };
276
+ return { affectedPages: 0, totalPages: 0, rebuild: 'incremental' };
243
277
  }
244
- // Normalize the changed template path to absolute POSIX format for reliable comparison
245
- // This handles cases where the watcher provides relative paths, Windows paths, or different
246
- // path representations than what's stored in the cache manifest
247
- // NOTE: Only used for string comparison, never for file system operations
248
- const normalizedTemplatePath = normalizePathForComparison(templatePath);
249
- // Get total page count from manifest
250
- const totalPages = Object.keys(cacheManifest.entries).length;
251
- // Find pages that depend on this template
252
- let affectedPagesCount = 0;
253
- for (const [pagePath, entry] of Object.entries(cacheManifest.entries)) {
254
- // Check if any of the page's dependencies match the changed template
255
- const hasMatchingDep = entry.deps.some((dep) => {
256
- // Normalize the cached dependency path to the same format for comparison only
257
- const normalizedDep = normalizePathForComparison(dep);
258
- // Direct path comparison - both paths are now in consistent format
259
- return normalizedDep === normalizedTemplatePath;
260
- });
261
- if (hasMatchingDep) {
262
- affectedPagesCount++;
263
- // Mark entry as stale by invalidating its inputsHash
264
- // This forces a rebuild while preserving deps for fast update optimization
265
- cacheManifest.entries[pagePath] = {
266
- ...entry,
267
- inputsHash: 'STALE', // Forces rebuild in shouldRebuildPage
268
- };
278
+ const entries = Object.entries(cacheManifest.entries);
279
+ const totalPages = entries.length;
280
+ if (entries.some(([, entry]) => !hasRecordedTemplates(entry))) {
281
+ return { affectedPages: totalPages, totalPages, rebuild: 'full' };
282
+ }
283
+ // Normalized for comparison only: watcher paths and manifest paths can differ in form
284
+ const changedFiles = new Set(changes.map((change) => normalizePathForComparison(change.path)));
285
+ const filesAddedOrRemoved = changes.some((change) => change.eventType !== 'change');
286
+ let affectedPages = 0;
287
+ for (const [pagePath, entry] of entries) {
288
+ const renderedChangedFile = entry.deps.some((dep) => changedFiles.has(normalizePathForComparison(dep)));
289
+ const lookupsChanged = filesAddedOrRemoved && index !== null && index.lookupsChanged(entry.templateLookups);
290
+ if (renderedChangedFile || lookupsChanged) {
291
+ affectedPages++;
292
+ // Forces a rebuild in shouldRebuildPage
293
+ cacheManifest.entries[pagePath] = { ...entry, inputsHash: 'STALE' };
269
294
  }
270
295
  }
271
- if (affectedPagesCount > 0) {
272
- // Save updated cache manifest
296
+ if (affectedPages > 0) {
273
297
  await saveCacheManifest(cacheDir, cacheManifest);
274
- // Perform incremental rebuild (only affected pages will be rebuilt)
275
- // skipManifestSave: true because we already saved the manifest above
276
- // skipAssetCopy: true because template changes don't affect static assets
277
- await build({
278
- logger,
279
- force: false,
280
- clean: false,
281
- skipManifestSave: true,
282
- skipAssetCopy: true,
283
- ...(configPath && { configPath }),
284
- });
285
- return { affectedPages: affectedPagesCount, totalPages };
298
+ return { affectedPages, totalPages, rebuild: 'incremental' };
286
299
  }
287
- else {
288
- // If no affected pages were found but a template changed,
289
- // force a full rebuild to ensure changes are reflected
290
- // This can happen if dependency tracking missed something
291
- await build({
292
- logger,
293
- force: true,
294
- clean: false,
295
- ...(configPath && { configPath }),
296
- });
297
- return { affectedPages: totalPages, totalPages };
300
+ // Before the session has an index, the build's own checks find the pages a new or deleted
301
+ // file affects
302
+ if ((filesAddedOrRemoved && index === null) || lastBuildFailed) {
303
+ return { affectedPages: 0, totalPages, rebuild: 'incremental' };
298
304
  }
305
+ return { affectedPages: 0, totalPages, rebuild: 'none' };
299
306
  }
300
- catch (_error) {
301
- try {
302
- // Fallback to full rebuild
303
- await build({
304
- logger,
305
- force: false,
306
- clean: false,
307
- ...(configPath && { configPath }),
308
- });
309
- }
310
- catch (fallbackError) {
311
- throw fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError));
312
- }
313
- // Return zeros on error fallback
314
- return { affectedPages: 0, totalPages: 0 };
307
+ catch {
308
+ // Let the build's own checks decide
309
+ return { affectedPages: 0, totalPages: 0, rebuild: 'incremental' };
315
310
  }
316
311
  }
317
312
  /**
318
- * Handles markdown file changes (added, edited or deleted pages) with an incremental build.
319
- * ISG decides what re-renders: the changed page, the pages that list it through a collection,
320
- * and the pages that read the navigation when the change altered it. A deleted page's output
321
- * is removed by the build.
313
+ * The error overlay's details for a failed build: template errors point at the template and
314
+ * front matter errors at the Markdown file; other errors are classified by their message.
322
315
  */
323
- async function handleMarkdownChange(_markdownPath, configPath, logger) {
324
- await build({
325
- logger,
326
- force: false,
327
- clean: false,
328
- ...(configPath && { configPath }),
329
- });
316
+ async function describeBuildError(error) {
317
+ if (error instanceof TemplateError) {
318
+ return error.toErrorDetails();
319
+ }
320
+ if (error instanceof FrontMatterError) {
321
+ // The file and line are the Markdown file's, not a stack frame's
322
+ const { line: _line, column: _column, ...details } = parseErrorDetails(error, 'markdown', error.filePath);
323
+ return {
324
+ ...details,
325
+ ...(error.line !== undefined && { line: error.line }),
326
+ ...(error.column !== undefined && { column: error.column }),
327
+ };
328
+ }
329
+ const message = error.message.toLowerCase();
330
+ const name = error.name ? error.name.toLowerCase() : '';
331
+ const errorType = message.includes('template') ||
332
+ message.includes('eta') ||
333
+ message.includes('layout') ||
334
+ name.includes('template')
335
+ ? 'template'
336
+ : message.includes('front-matter') ||
337
+ message.includes('yaml') ||
338
+ name.includes('yaml') ||
339
+ name.includes('yamlexception')
340
+ ? 'markdown'
341
+ : message.includes('config') || name.includes('config')
342
+ ? 'config'
343
+ : 'build';
344
+ return parseErrorDetails(error, errorType);
330
345
  }
331
346
  export async function createDevServer(options = {}) {
332
347
  const logger = options.logger ?? createFallbackLogger();
@@ -356,6 +371,7 @@ export async function createDevServer(options = {}) {
356
371
  const setLastBuildError = (error) => {
357
372
  lastBuildError = error;
358
373
  };
374
+ const hasLastBuildError = () => lastBuildError !== null;
359
375
  let watcher = null;
360
376
  let cssWatcher = null;
361
377
  let tsWatchers = [];
@@ -405,31 +421,7 @@ export async function createDevServer(options = {}) {
405
421
  // If there's a build error, show error overlay for HTML requests
406
422
  if (errorToShow &&
407
423
  (requestPath === '/' || requestPath.endsWith('.html') || !requestPath.includes('.'))) {
408
- let errorDetails;
409
- // Use enhanced error details for TemplateError instances
410
- if (errorToShow instanceof TemplateError) {
411
- errorDetails = await errorToShow.toErrorDetails();
412
- }
413
- else {
414
- // Determine error type based on error message
415
- const message = errorToShow.message.toLowerCase();
416
- const name = errorToShow.name ? errorToShow.name.toLowerCase() : '';
417
- const errorType = message.includes('template') ||
418
- message.includes('eta') ||
419
- message.includes('layout') ||
420
- name.includes('template')
421
- ? 'template'
422
- : message.includes('front-matter') ||
423
- message.includes('yaml') ||
424
- name.includes('yaml') ||
425
- name.includes('yamlexception')
426
- ? 'markdown'
427
- : message.includes('config') || name.includes('config')
428
- ? 'config'
429
- : 'build';
430
- errorDetails = parseErrorDetails(errorToShow, errorType);
431
- }
432
- const errorHtml = createErrorOverlay(errorDetails, requestPath);
424
+ const errorHtml = createErrorOverlay(await describeBuildError(errorToShow), requestPath);
433
425
  return {
434
426
  content: errorHtml,
435
427
  mimeType: 'text/html',
@@ -448,41 +440,6 @@ export async function createDevServer(options = {}) {
448
440
  // Use the shared pretty URL resolver
449
441
  const { filePath, found } = await resolvePrettyUrl(outDir, requestPath, requestFile);
450
442
  if (!found || !filePath) {
451
- // If we have a build error and this is an HTML request, show error overlay instead of 404
452
- const errorToShow = lastBuildError;
453
- if (errorToShow &&
454
- (requestPath === '/' || requestPath.endsWith('.html') || !requestPath.includes('.'))) {
455
- // Use enhanced error details for TemplateError instances
456
- let errorDetails;
457
- if (errorToShow instanceof TemplateError) {
458
- errorDetails = await errorToShow.toErrorDetails();
459
- }
460
- else {
461
- // Determine error type based on error message
462
- const message = errorToShow.message.toLowerCase();
463
- const name = errorToShow.name ? errorToShow.name.toLowerCase() : '';
464
- const errorType = message.includes('template') ||
465
- message.includes('eta') ||
466
- message.includes('layout') ||
467
- name.includes('template')
468
- ? 'template'
469
- : message.includes('front-matter') ||
470
- message.includes('yaml') ||
471
- name.includes('yaml') ||
472
- name.includes('yamlexception')
473
- ? 'markdown'
474
- : message.includes('config') || name.includes('config')
475
- ? 'config'
476
- : 'build';
477
- errorDetails = parseErrorDetails(errorToShow, errorType);
478
- }
479
- const errorHtml = createErrorOverlay(errorDetails, requestPath);
480
- return {
481
- content: errorHtml,
482
- mimeType: 'text/html',
483
- statusCode: 500,
484
- };
485
- }
486
443
  // Serve the site's 404 page, as static hosts do, with live reload so it updates on edits
487
444
  const notFoundPage = await resolveNotFoundPage(outDir);
488
445
  if (notFoundPage) {
@@ -582,7 +539,8 @@ export async function createDevServer(options = {}) {
582
539
  console.log();
583
540
  }
584
541
  }
585
- // Perform initial build
542
+ // Rebuilds reuse one template index, which the file watcher keeps current
543
+ startTemplateIndexSession(srcDir);
586
544
  await performInitialBuild(configPath, logger, setLastBuildError);
587
545
  // Create HTTP server
588
546
  httpServer = createServer(async (req, res) => {
@@ -687,13 +645,13 @@ export async function createDevServer(options = {}) {
687
645
  }
688
646
  }
689
647
  watcher.on('change', (path) => {
690
- void performIncrementalRebuild(path, 'change', configPath, staticDir, logger, disableWsReload ? null : wsServer, isBuildingRef, pendingChangesRef, setLastBuildError, disableCssWatcher ? undefined : refreshCssWatcher);
648
+ void performIncrementalRebuild(path, 'change', configPath, staticDir, srcDir, logger, disableWsReload ? null : wsServer, isBuildingRef, pendingChangesRef, setLastBuildError, disableCssWatcher ? undefined : refreshCssWatcher, hasLastBuildError);
691
649
  });
692
650
  watcher.on('add', (path) => {
693
- void performIncrementalRebuild(path, 'add', configPath, staticDir, logger, disableWsReload ? null : wsServer, isBuildingRef, pendingChangesRef, setLastBuildError, disableCssWatcher ? undefined : refreshCssWatcher);
651
+ void performIncrementalRebuild(path, 'add', configPath, staticDir, srcDir, logger, disableWsReload ? null : wsServer, isBuildingRef, pendingChangesRef, setLastBuildError, disableCssWatcher ? undefined : refreshCssWatcher, hasLastBuildError);
694
652
  });
695
653
  watcher.on('unlink', (path) => {
696
- void performIncrementalRebuild(path, 'unlink', configPath, staticDir, logger, disableWsReload ? null : wsServer, isBuildingRef, pendingChangesRef, setLastBuildError, disableCssWatcher ? undefined : refreshCssWatcher);
654
+ void performIncrementalRebuild(path, 'unlink', configPath, staticDir, srcDir, logger, disableWsReload ? null : wsServer, isBuildingRef, pendingChangesRef, setLastBuildError, disableCssWatcher ? undefined : refreshCssWatcher, hasLastBuildError);
697
655
  });
698
656
  logger.success?.(`Dev server running at ${url}`);
699
657
  logger.info?.(`\nServing:`);
@@ -757,6 +715,7 @@ export async function createDevServer(options = {}) {
757
715
  }
758
716
  // Release cached dev-session engines so they can be garbage collected
759
717
  clearTemplateEngineCache();
718
+ endTemplateIndexSession(srcDir);
760
719
  clearMarkdownProcessorCache();
761
720
  logger.info?.('Dev server stopped');
762
721
  },
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Stati's Eta plugin. Eta compiles every template with functions of its own: `include()`,
3
+ * `includeAsync()`, `layout()`, `block()` and `blockAsync()`. They resolve names their own way,
4
+ * skip validation and the template index, and a template reached through Eta's `layout()`
5
+ * inherits its child's `stati`. Stati templates compose with `stati.include()` and
6
+ * `stati.layout()`, so the plugin replaces Eta's functions in each compiled template with ones
7
+ * that report the Stati call to use.
8
+ * @module core/eta-plugin
9
+ */
10
+ import type { Eta, EtaConfig } from 'eta';
11
+ type EtaPlugin = EtaConfig['plugins'][number];
12
+ /**
13
+ * Eta's declaration of its `layout()` function. The plugin checks for it: when Eta generates
14
+ * different code, creating the engine fails instead of templates silently getting Eta's
15
+ * functions back.
16
+ */
17
+ export declare const ETA_LAYOUT_DECLARATION = "function layout(path, data) {\n __eta.layout = path;\n __eta.layoutData = data;\n}";
18
+ /** Eta's template functions that Stati replaces, and the code that declares each one. */
19
+ declare const ETA_FUNCTIONS: readonly [{
20
+ readonly name: "include";
21
+ readonly declaration: "let include = ";
22
+ }, {
23
+ readonly name: "includeAsync";
24
+ readonly declaration: "let includeAsync = ";
25
+ }, {
26
+ readonly name: "layout";
27
+ readonly declaration: "function layout(path, data) {\n __eta.layout = path;\n __eta.layoutData = data;\n}";
28
+ }, {
29
+ readonly name: "block";
30
+ readonly declaration: "function block(";
31
+ }, {
32
+ readonly name: "blockAsync";
33
+ readonly declaration: "async function blockAsync(";
34
+ }];
35
+ /** One of Eta's template functions that Stati blocks. */
36
+ export type BlockedEtaFunction = (typeof ETA_FUNCTIONS)[number]['name'];
37
+ /**
38
+ * Creates the plugin. The replacements go at the start of the template's code rather than
39
+ * next to Eta's declarations, because with `debug` on Eta declares `block()` inside a `try`
40
+ * block, where a replacement made outside it would be shadowed. The template's code then runs
41
+ * in a block of its own, so it can still declare names such as `block` or `output`, which
42
+ * would otherwise clash with Eta's declarations.
43
+ */
44
+ export declare function createStatiEtaPlugin(): EtaPlugin;
45
+ /**
46
+ * Installs the handler that the replaced functions call, and compiles an empty template so
47
+ * that an Eta version the plugin does not match fails here rather than on the first page.
48
+ *
49
+ * @throws {Error} When Eta's generated code lacks the declarations the plugin replaces
50
+ */
51
+ export declare function installEtaPlugin(eta: Eta): void;
52
+ /**
53
+ * The message for a blocked call, and the replacement to suggest.
54
+ */
55
+ export declare function describeBlockedCall(name: BlockedEtaFunction, args: readonly unknown[]): {
56
+ message: string;
57
+ suggestion: string;
58
+ };
59
+ export {};
60
+ //# sourceMappingURL=eta-plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eta-plugin.d.ts","sourceRoot":"","sources":["../../src/core/eta-plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AAK1C,KAAK,SAAS,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC;AAG9C;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,yFACqD,CAAC;AAEzF,yFAAyF;AACzF,QAAA,MAAM,aAAa;;;;;;;;;;;;;;;EAMT,CAAC;AAEX,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAUxE;;;;;;GAMG;AACH,wBAAgB,oBAAoB,IAAI,SAAS,CAgChD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,GAAG,GAAG,IAAI,CAG/C;AAqBD;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,kBAAkB,EACxB,IAAI,EAAE,SAAS,OAAO,EAAE,GACvB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CA8BzC"}