@stati/core 1.26.2 → 1.27.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.
package/README.md CHANGED
@@ -63,20 +63,24 @@ This is my first page. It's just Markdown.
63
63
  **3. Build or develop:**
64
64
 
65
65
  ```typescript
66
- import { build, createDevServer } from '@stati/core';
66
+ import { build, createDevServer, setEnv } from '@stati/core';
67
67
 
68
68
  // Development with live reload
69
69
  const server = await createDevServer({
70
70
  port: 3000,
71
71
  open: true,
72
72
  });
73
+ await server.start();
73
74
 
74
- // Or build for production
75
+ // Or build for production (build() alone runs a development build)
76
+ setEnv('production');
75
77
  const stats = await build({
76
78
  clean: true,
77
79
  });
78
80
  ```
79
81
 
82
+ Most projects use the `stati` command from [`@stati/cli`](https://www.npmjs.com/package/@stati/cli) instead (`stati dev`, `stati build`), which sets the environment for you.
83
+
80
84
  That's it! Your site is ready.
81
85
 
82
86
  ---
@@ -111,7 +115,7 @@ my-site/
111
115
  ├── site/ # Your content (Markdown + templates)
112
116
  │ ├── index.md # Homepage → /
113
117
  │ └── blog/
114
- │ └── hello.md # Blog post → /blog/hello/
118
+ │ └── hello.md # Blog post → /blog/hello
115
119
  ├── public/ # Static assets (CSS, images)
116
120
  └── stati.config.js # Configuration (optional)
117
121
  ```
@@ -162,7 +166,7 @@ Customize layouts with [Eta templates](https://eta.js.org):
162
166
 
163
167
  Only rebuild what changed:
164
168
 
165
- - Smart caching based on file modification times
169
+ - Smart caching based on hashes of each page's content and templates
166
170
  - TTL-based refresh for dynamic content
167
171
  - Tag-based invalidation for related content
168
172
 
@@ -175,7 +179,7 @@ await invalidate('tag:blog');
175
179
  // Invalidate by path
176
180
  await invalidate('path:/posts');
177
181
 
178
- // Invalidate old content (3+ months)
182
+ // Invalidate pages rendered within the last 3 months
179
183
  await invalidate('age:3months');
180
184
  ```
181
185
 
@@ -183,10 +187,10 @@ await invalidate('age:3months');
183
187
 
184
188
  SEO optimization works automatically with zero configuration:
185
189
 
186
- - Meta tags (title, description, keywords)
190
+ - Meta tags (title, description, keywords from front matter)
187
191
  - Open Graph for social sharing
188
192
  - Twitter Cards
189
- - Structured data (JSON-LD)
193
+ - Structured data (JSON-LD) from `seo.structuredData` in front matter
190
194
 
191
195
  Enable sitemap and robots.txt with one config option.
192
196
 
@@ -217,7 +221,7 @@ This is all you need! Stati automatically enables:
217
221
 
218
222
  ### Extended Configuration (Common Options)
219
223
 
220
- Below are commonly used configuration options. This is **not a complete list** - see the [full configuration documentation](https://stati.build/configuration/) for all available options.
224
+ Below are commonly used configuration options. This is **not a complete list** - see the [full configuration documentation](https://stati.build/configuration/file/) for all available options.
221
225
 
222
226
  ```javascript
223
227
  import { defineConfig } from '@stati/core';
@@ -264,8 +268,8 @@ export default defineConfig({
264
268
  ttlSeconds: 86400, // Cache TTL: 24 hours (default: 21600 / 6 hours)
265
269
  maxAgeCapDays: 30, // Max age for aging rules (default: 365)
266
270
  aging: [
267
- { untilDays: 7, ttlSeconds: 86400 }, // 1 day cache for week-old content
268
- { untilDays: 30, ttlSeconds: 604800 }, // 1 week cache for month-old content
271
+ { untilDays: 7, ttlSeconds: 86400 }, // up to 7 days old: 1 day
272
+ { untilDays: 30, ttlSeconds: 604800 }, // up to 30 days old (and older): 1 week
269
273
  ],
270
274
  },
271
275
 
@@ -342,9 +346,9 @@ export default defineConfig({
342
346
 
343
347
  ### Build Functions
344
348
 
345
- #### `build(options?: BuildOptions): Promise<BuildStats>`
349
+ #### `build(options?: BuildOptions): Promise<BuildResult>`
346
350
 
347
- Build your static site for production.
351
+ Build your static site. `build()` uses Stati's current environment, which is `development` unless you call `setEnv('production')` first (the `stati build` command does). Sitemap, robots.txt and RSS are only generated in production.
348
352
 
349
353
  **Options:**
350
354
 
@@ -353,15 +357,18 @@ Build your static site for production.
353
357
  force?: boolean; // Force rebuild of all pages (ignores cache)
354
358
  clean?: boolean; // Clean output directory before build
355
359
  includeDrafts?: boolean; // Include draft pages in build
356
- configPath?: string; // Custom config file path
360
+ configPath?: string; // Config file; only its directory is used to find stati.config.*
361
+ metrics?: MetricsOptions; // Collect build metrics
362
+ logger?: Logger; // Custom logger for build output
357
363
  }
358
364
  ```
359
365
 
360
366
  **Example:**
361
367
 
362
368
  ```typescript
363
- import { build } from '@stati/core';
369
+ import { build, setEnv } from '@stati/core';
364
370
 
371
+ setEnv('production');
365
372
  const stats = await build({
366
373
  clean: true,
367
374
  force: false,
@@ -371,7 +378,7 @@ const stats = await build({
371
378
  console.log(`Built ${stats.totalPages} pages in ${stats.buildTimeMs}ms`);
372
379
  ```
373
380
 
374
- **Returns:** Build statistics including pages built, duration, and cache hits.
381
+ **Returns:** Build statistics (`totalPages`, `assetsCount`, `buildTimeMs`, `outputSizeBytes`, `cacheHits`, `cacheMisses`), plus `buildMetrics` when metrics are enabled.
375
382
 
376
383
  ---
377
384
 
@@ -386,10 +393,12 @@ Start a development server with live reload.
386
393
  port?: number; // Port number (default: 3000)
387
394
  host?: string; // Host address (default: 'localhost')
388
395
  open?: boolean; // Auto-open browser (default: false)
389
- configPath?: string; // Custom config file path
396
+ logger?: Logger; // Custom logger
390
397
  }
391
398
  ```
392
399
 
400
+ The config is always loaded from the current working directory.
401
+
393
402
  **Example:**
394
403
 
395
404
  ```typescript
@@ -399,11 +408,12 @@ const server = await createDevServer({
399
408
  port: 3000,
400
409
  open: true,
401
410
  });
411
+ await server.start();
402
412
 
403
- console.log(`Dev server running at http://localhost:3000`);
413
+ console.log(`Dev server running at ${server.url}`);
404
414
  ```
405
415
 
406
- **Returns:** Server instance with methods to stop and restart.
416
+ **Returns:** A server object with `start()`, `stop()` and `url`. Nothing listens until you call `start()`.
407
417
 
408
418
  ---
409
419
 
@@ -418,16 +428,19 @@ Start a production preview server to test your built site locally.
418
428
  port?: number; // Port number (default: 4000)
419
429
  host?: string; // Host address (default: 'localhost')
420
430
  open?: boolean; // Auto-open browser (default: false)
421
- configPath?: string; // Custom config file path
431
+ logger?: Logger; // Custom logger
422
432
  }
423
433
  ```
424
434
 
435
+ The config (used to find the output directory) is always loaded from the current working directory.
436
+
425
437
  **Example:**
426
438
 
427
439
  ```typescript
428
- import { createPreviewServer } from '@stati/core';
440
+ import { build, createPreviewServer, setEnv } from '@stati/core';
429
441
 
430
442
  // First build your site
443
+ setEnv('production');
431
444
  await build({ clean: true });
432
445
 
433
446
  // Then preview it
@@ -435,11 +448,12 @@ const server = await createPreviewServer({
435
448
  port: 4000,
436
449
  open: true,
437
450
  });
451
+ await server.start();
438
452
 
439
- console.log(`Preview server running at http://localhost:4000`);
453
+ console.log(`Preview server running at ${server.url}`);
440
454
  ```
441
455
 
442
- **Returns:** Server instance with methods to stop.
456
+ **Returns:** A server object with `start()`, `stop()` and `url`.
443
457
 
444
458
  **Note:** Unlike `createDevServer`, the preview server serves the static files from your `dist/` directory without live reload or rebuilding. This is useful for testing your production build locally before deployment.
445
459
 
@@ -452,9 +466,9 @@ Invalidate cache by tags, paths, patterns, or age.
452
466
  **Query Syntax:**
453
467
 
454
468
  - `tag:blog` - Invalidate pages with specific tag
455
- - `path:/posts` - Invalidate pages under path
456
- - `glob:/blog/**` - Invalidate by glob pattern
457
- - `age:3months` - Invalidate content younger than 3 months
469
+ - `path:/posts` - Invalidate cache entries whose key (output path, such as `/posts/hello.html`) starts with this prefix
470
+ - `glob:/blog/**` - Invalidate by glob pattern matched against the whole key
471
+ - `age:3months` - Invalidate pages rendered within the last 3 months
458
472
  - No query - Clear entire cache
459
473
 
460
474
  **Examples:**
@@ -468,7 +482,7 @@ await invalidate('tag:news');
468
482
  // Invalidate by path prefix
469
483
  await invalidate('path:/blog/2024/');
470
484
 
471
- // Invalidate old content
485
+ // Invalidate pages rendered within the last 6 months
472
486
  await invalidate('age:6months');
473
487
 
474
488
  // Multiple criteria (OR logic)
@@ -533,6 +547,7 @@ import type {
533
547
 
534
548
  // Build
535
549
  BuildOptions,
550
+ BuildResult,
536
551
  BuildContext,
537
552
  BuildStats,
538
553
 
@@ -551,7 +566,10 @@ import type {
551
566
  // Cache
552
567
  InvalidationResult,
553
568
  AgingRule,
554
- } from '@stati/core/types';
569
+ } from '@stati/core';
570
+
571
+ // Template and cache types are only exported from the types entry point
572
+ import type { TemplateContext, CacheManifest } from '@stati/core/types';
555
573
  ```
556
574
 
557
575
  > **Need more types?** This is a curated list of commonly used types. For the complete type reference including all SEO, sitemap, and configuration types, see the [API Types Documentation](https://stati.build/api/types/).
@@ -564,9 +582,9 @@ import type {
564
582
 
565
583
  Stati supports rich Markdown features out of the box:
566
584
 
567
- - **Front-matter** - YAML, TOML, or JSON metadata
585
+ - **Front-matter** - YAML (default) or JSON (`---json`) metadata
568
586
  - **Plugins** - Full markdown-it ecosystem compatibility
569
- - **Syntax highlighting** - Code blocks with language support
587
+ - **Syntax highlighting** - Code blocks get `language-*` classes; add a plugin such as `markdown-it-prism` to highlight them
570
588
  - **Custom rendering** - Configure parser behavior
571
589
  - **Draft mode** - Mark pages as `draft: true`
572
590
 
@@ -592,7 +610,7 @@ Powered by [Eta](https://eta.js.org) for fast, flexible templates:
592
610
  - **Partials** - Reusable components
593
611
  - **Custom filters** - Transform data in templates
594
612
  - **Type-safe helpers** - Access page data with autocomplete
595
- - **Hot reload** - See template changes instantly
613
+ - **Live reload** - The browser reloads when a template changes
596
614
 
597
615
  **Template structure:**
598
616
 
@@ -604,11 +622,11 @@ Powered by [Eta](https://eta.js.org) for fast, flexible templates:
604
622
  <title><%= stati.page.title %> - <%= stati.site.title %></title>
605
623
  </head>
606
624
  <body>
607
- <%~ include('_partials/header') %>
625
+ <%~ stati.partials.header %>
608
626
  <main>
609
627
  <%~ stati.content %>
610
628
  </main>
611
- <%~ include('_partials/footer') %>
629
+ <%~ stati.partials.footer %>
612
630
  </body>
613
631
  </html>
614
632
  ```
@@ -617,7 +635,7 @@ Powered by [Eta](https://eta.js.org) for fast, flexible templates:
617
635
 
618
636
  Automatic navigation hierarchy based on your file structure:
619
637
 
620
- - **Auto-generated tree** - Reflects directory structure
638
+ - **Auto-generated tree** - Built from your folders (each folder of pages is a top-level node)
621
639
  - **Breadcrumbs** - Parent-child relationships
622
640
  - **Custom ordering** - Use `order` in front-matter
623
641
  - **Index pages** - Special handling for `index.md`
@@ -654,7 +672,7 @@ Built for productivity:
654
672
 
655
673
  Smart caching for lightning-fast builds:
656
674
 
657
- - **Modification tracking** - Rebuild only changed files
675
+ - **Content hashing** - Rebuild pages whose content or templates changed
658
676
  - **Incremental builds** - Skip unchanged pages
659
677
  - **Tag invalidation** - Update related content together
660
678
  - **TTL-based refresh** - Control cache lifetime
@@ -667,9 +685,9 @@ export default defineConfig({
667
685
  isg: {
668
686
  ttlSeconds: 21600, // 6 hours default
669
687
  aging: [
670
- { untilDays: 7, ttlSeconds: 3600 }, // Recent: 1 hour
671
- { untilDays: 30, ttlSeconds: 86400 }, // Month: 1 day
672
- // Older content uses default TTL
688
+ { untilDays: 7, ttlSeconds: 3600 }, // up to 7 days old: 1 hour
689
+ { untilDays: 30, ttlSeconds: 86400 }, // up to 30 days old: 1 day
690
+ // Older content also uses the last rule (1 day)
673
691
  ],
674
692
  },
675
693
  });
@@ -708,8 +726,8 @@ export default defineConfig({
708
726
  },
709
727
  isg: {
710
728
  aging: [
711
- { untilDays: 30, ttlSeconds: 3600 }, // Fresh posts: 1 hour
712
- { untilDays: 365, ttlSeconds: 86400 }, // Year-old: 1 day
729
+ { untilDays: 30, ttlSeconds: 3600 }, // up to 30 days old: 1 hour
730
+ { untilDays: 365, ttlSeconds: 86400 }, // up to a year old (and older): 1 day
713
731
  ],
714
732
  },
715
733
  });
@@ -739,7 +757,7 @@ export default defineConfig({
739
757
  ## Requirements
740
758
 
741
759
  - **Node.js** ^22.12.0 || >=24
742
- - **npm** 11.5.1 or higher (or equivalent package manager)
760
+ - **A package manager**: npm, pnpm, yarn or bun
743
761
 
744
762
  ---
745
763
 
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../src/core/templates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAG1B,OAAO,KAAK,EACV,WAAW,EACX,SAAS,EACT,OAAO,EAEP,QAAQ,EACR,MAAM,EACP,MAAM,mBAAmB,CAAC;AAkB3B;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,eAAe,EAAE,MAAM,CAAC;CACzB;AAgND;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,GAAG,CAwB7D;AAED,wBAAsB,UAAU,CAC9B,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,EACnB,GAAG,EAAE,GAAG,EACR,UAAU,CAAC,EAAE,OAAO,EAAE,EACtB,QAAQ,CAAC,EAAE,SAAS,EAAE,EACtB,MAAM,CAAC,EAAE,OAAO,mBAAmB,EAAE,WAAW,EAChD,GAAG,CAAC,EAAE,QAAQ,EAAE,EAChB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,YAAY,CAAC,CAuMvB"}
1
+ {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../src/core/templates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAG1B,OAAO,KAAK,EACV,WAAW,EACX,SAAS,EACT,OAAO,EAEP,QAAQ,EACR,MAAM,EACP,MAAM,mBAAmB,CAAC;AAmB3B;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,eAAe,EAAE,MAAM,CAAC;CACzB;AAgND;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,GAAG,CA2B7D;AAED,wBAAsB,UAAU,CAC9B,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,EACnB,GAAG,EAAE,GAAG,EACR,UAAU,CAAC,EAAE,OAAO,EAAE,EACtB,QAAQ,CAAC,EAAE,SAAS,EAAE,EACtB,MAAM,CAAC,EAAE,OAAO,mBAAmB,EAAE,WAAW,EAChD,GAAG,CAAC,EAAE,QAAQ,EAAE,EAChB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,YAAY,CAAC,CA0MvB"}
@@ -3,7 +3,7 @@ import { join, dirname, relative, basename, posix } from 'node:path';
3
3
  import glob from 'fast-glob';
4
4
  import { createFallbackLogger } from './utils/index.js';
5
5
  import { TEMPLATE_EXTENSION } from '../constants.js';
6
- import { getStatiVersion, isCollectionIndexPage, discoverLayout, getCollectionPathForPage, resolveSrcDir, createTemplateError, createValidatingPartialsProxy, propValue, wrapPartialsAsCallable, createNavigationHelpers, } from './utils/index.js';
6
+ import { getStatiVersion, isCollectionIndexPage, discoverLayout, getCollectionPathForPage, resolveSrcDir, createTemplateError, createValidatingPartialsProxy, createValidatingContextProxy, propValue, wrapPartialsAsCallable, createNavigationHelpers, } from './utils/index.js';
7
7
  import { isDevelopment, isProduction } from '../env.js';
8
8
  import { generateSEO } from '../seo/index.js';
9
9
  /**
@@ -206,6 +206,9 @@ export function createTemplateEngine(config) {
206
206
  cache: useCache,
207
207
  cacheFilepaths: useCache,
208
208
  varName: 'stati',
209
+ // Debug mode wraps runtime errors with the template path and line number,
210
+ // which the dev error overlay uses to point at the failing line
211
+ debug: isDevelopment(),
209
212
  });
210
213
  if (isDevelopment()) {
211
214
  cachedTemplateEngine = { templateDir, eta };
@@ -274,6 +277,9 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
274
277
  generateSEO: (tags) => generateSEO({ page, config, site: config.site }, tags),
275
278
  propValue,
276
279
  assets, // TypeScript bundle assets (available when typescript.enabled is true)
280
+ // Set by callable partials invoked with props; always present so that
281
+ // stati.props?.x works under the dev-mode context validation
282
+ props: undefined,
277
283
  };
278
284
  // Render partials and store their content
279
285
  // Use multiple passes to allow partials to reference other partials
@@ -294,10 +300,10 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
294
300
  const combinedPartials = { ...renderedPartials, ...passRenderedPartials };
295
301
  // Wrap partials as callable before passing to validation proxy
296
302
  const callablePartials = wrapPartialsAsCallable(eta, combinedPartials, partialPaths, baseContext);
297
- const partialContext = {
303
+ const partialContext = createValidatingContextProxy({
298
304
  ...baseContext,
299
305
  partials: createValidatingPartialsProxy(callablePartials), // Include both previous and current pass partials with validation
300
- };
306
+ });
301
307
  const renderedContent = await eta.renderAsync(partialPath, partialContext);
302
308
  passRenderedPartials[partialName] = renderedContent;
303
309
  progressMade = true;
@@ -341,10 +347,10 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
341
347
  }
342
348
  // Wrap final rendered partials as callable before passing to layout context
343
349
  const callablePartials = wrapPartialsAsCallable(eta, renderedPartials, partialPaths, baseContext);
344
- const context = {
350
+ const context = createValidatingContextProxy({
345
351
  ...baseContext,
346
352
  partials: createValidatingPartialsProxy(callablePartials), // Add rendered partials with validation
347
- };
353
+ });
348
354
  try {
349
355
  if (!layoutPath) {
350
356
  log.warning('No layout template found, using fallback');
@@ -1 +1 @@
1
- {"version":3,"file":"callable-partials.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/callable-partials.utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAE1B;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;IAC1C,QAAQ,IAAI,MAAM,CAAC;IACnB,OAAO,IAAI,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,GAAG,EACR,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpC,eAAe,EAAE,MAAM,GACtB,eAAe,CAqDjB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACpC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CA8BjC"}
1
+ {"version":3,"file":"callable-partials.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/callable-partials.utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAK1B;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;IAC1C,QAAQ,IAAI,MAAM,CAAC;IACnB,OAAO,IAAI,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,GAAG,EACR,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpC,eAAe,EAAE,MAAM,GACtB,eAAe,CA0DjB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACpC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CA+BjC"}
@@ -1,3 +1,6 @@
1
+ import { isDevelopment } from '../../env.js';
2
+ import { createValidatingContextProxy } from './context-validation.utils.js';
3
+ import { createValidatingPartialsProxy } from './partial-validation.utils.js';
1
4
  /**
2
5
  * Creates a callable partial that can be used both as a value and as a function.
3
6
  * This enables both syntaxes:
@@ -34,16 +37,21 @@ export function makeCallablePartial(eta, partialPath, baseContext, renderedConte
34
37
  }
35
38
  // Props provided - re-render with merged context
36
39
  try {
37
- const mergedContext = {
40
+ const mergedContext = createValidatingContextProxy({
38
41
  ...baseContext,
39
42
  props, // Make props available as stati.props
40
- };
43
+ });
41
44
  // Render the partial with the merged context using renderAsync
42
45
  // This is a synchronous call despite the name when used with already-loaded templates
43
46
  const result = eta.render(partialPath, mergedContext);
44
47
  return result || '';
45
48
  }
46
49
  catch (error) {
50
+ // In development, let the error reach the calling template so it ends up
51
+ // in the error overlay instead of a hidden HTML comment
52
+ if (isDevelopment()) {
53
+ throw error;
54
+ }
47
55
  console.error(`Error rendering callable partial ${partialPath} with props:`, error);
48
56
  return `<!-- Error rendering partial with props: ${error instanceof Error ? error.message : String(error)} -->`;
49
57
  }
@@ -97,12 +105,13 @@ export function makeCallablePartial(eta, partialPath, baseContext, renderedConte
97
105
  export function wrapPartialsAsCallable(eta, partials, partialPaths, baseContext) {
98
106
  const callablePartials = {};
99
107
  // Create a context that will have partials updated to the callable versions
100
- // This enables nested callable partial calls
108
+ // This enables nested callable partial calls. The getter runs when a partial is
109
+ // rendered with props, after all callables exist, and wraps them in the same
110
+ // validating proxy that layouts and pre-rendered partials get.
101
111
  const contextWithCallablePartials = {
102
112
  ...baseContext,
103
- // The partials property will be updated after all callables are created
104
113
  get partials() {
105
- return callablePartials;
114
+ return createValidatingPartialsProxy(callablePartials);
106
115
  },
107
116
  };
108
117
  for (const [name, renderedContent] of Object.entries(partials)) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Checks whether a property access should bypass validation.
3
+ */
4
+ export declare function isPassthroughProperty(prop: string | symbol): boolean;
5
+ /**
6
+ * Thrown in development when a template reads a property that does not exist
7
+ * on the template context.
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.
11
+ * Use {@link parseUnknownPropertyMessage} to read them back.
12
+ */
13
+ export declare class UnknownTemplatePropertyError extends Error {
14
+ readonly propertyPath: string;
15
+ readonly suggestions: string[];
16
+ constructor(propertyPath: string, suggestions: string[], availableKeys: string[]);
17
+ }
18
+ /**
19
+ * Reads an {@link UnknownTemplatePropertyError} message back into a short message
20
+ * and a list of overlay suggestions. Returns `undefined` for any other message.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * parseUnknownPropertyMessage('Unknown property "stati.paeg". Did you mean "stati.page"?');
25
+ * // { message: 'Unknown property "stati.paeg"', suggestions: ['Did you mean "stati.page"?'] }
26
+ * ```
27
+ */
28
+ export declare function parseUnknownPropertyMessage(message: string): {
29
+ message: string;
30
+ suggestions: string[];
31
+ } | undefined;
32
+ /**
33
+ * Wraps the template context so that reading a property that does not exist
34
+ * throws an {@link UnknownTemplatePropertyError} with "did you mean" suggestions
35
+ * instead of silently rendering `undefined`. The error reaches the dev server's
36
+ * error overlay.
37
+ *
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.
41
+ *
42
+ * Only active in development; other environments get the context back unchanged.
43
+ */
44
+ export declare function createValidatingContextProxy<T extends object>(context: T): T;
45
+ //# sourceMappingURL=context-validation.utils.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,175 @@
1
+ import { isDevelopment } from '../../env.js';
2
+ import { findSimilarNames } from './similarity.utils.js';
3
+ /**
4
+ * Properties that must resolve normally even when they are not defined on the
5
+ * object, because the runtime, serializers, or test tooling probe for them.
6
+ */
7
+ const PASSTHROUGH_PROPERTIES = new Set([
8
+ 'length',
9
+ 'constructor',
10
+ 'then', // Promise detection
11
+ '$$typeof', // React inspection
12
+ 'nodeType', // DOM node detection
13
+ 'asymmetricMatch', // Jest/Vitest matcher
14
+ 'toJSON', // JSON serialization
15
+ ]);
16
+ /**
17
+ * Checks whether a property access should bypass validation.
18
+ */
19
+ export function isPassthroughProperty(prop) {
20
+ return typeof prop === 'symbol' || prop in Object.prototype || PASSTHROUGH_PROPERTIES.has(prop);
21
+ }
22
+ /**
23
+ * Every `SiteConfig` field. Optional fields that a site leaves unset are absent at
24
+ * runtime, so they are listed here to keep `stati.site.description` from being
25
+ * reported as unknown. Typed as a Record so a new `SiteConfig` field fails to compile
26
+ * until it is added.
27
+ */
28
+ const SITE_CONFIG_KEYS = {
29
+ title: true,
30
+ baseUrl: true,
31
+ defaultLocale: true,
32
+ description: true,
33
+ };
34
+ /**
35
+ * Context properties whose own keys form a closed set and are validated one level
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.
39
+ */
40
+ const VALIDATED_NESTED_PROPERTIES = {
41
+ site: Object.keys(SITE_CONFIG_KEYS),
42
+ nav: [],
43
+ collection: [],
44
+ generator: [],
45
+ };
46
+ /**
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`.
50
+ */
51
+ const CROSS_LEVEL_PROPERTIES = ['page', 'site', 'nav', 'partials'];
52
+ /**
53
+ * Thrown in development when a template reads a property that does not exist
54
+ * on the template context.
55
+ *
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.
58
+ * Use {@link parseUnknownPropertyMessage} to read them back.
59
+ */
60
+ export class UnknownTemplatePropertyError extends Error {
61
+ propertyPath;
62
+ suggestions;
63
+ constructor(propertyPath, suggestions, availableKeys) {
64
+ super(formatUnknownPropertyMessage(propertyPath, suggestions, availableKeys));
65
+ this.name = 'UnknownTemplatePropertyError';
66
+ this.propertyPath = propertyPath;
67
+ this.suggestions = suggestions;
68
+ }
69
+ }
70
+ function formatUnknownPropertyMessage(propertyPath, suggestions, availableKeys) {
71
+ const base = `Unknown property "${propertyPath}".`;
72
+ if (suggestions.length > 0) {
73
+ const quoted = suggestions.map((s) => `"${s}"`);
74
+ const list = quoted.length === 1 ? quoted[0] : `${quoted.slice(0, -1).join(', ')} or ${quoted.at(-1)}`;
75
+ return `${base} Did you mean ${list}?`;
76
+ }
77
+ return `${base} Available properties: ${availableKeys.join(', ')}.`;
78
+ }
79
+ /**
80
+ * Reads an {@link UnknownTemplatePropertyError} message back into a short message
81
+ * and a list of overlay suggestions. Returns `undefined` for any other message.
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * parseUnknownPropertyMessage('Unknown property "stati.paeg". Did you mean "stati.page"?');
86
+ * // { message: 'Unknown property "stati.paeg"', suggestions: ['Did you mean "stati.page"?'] }
87
+ * ```
88
+ */
89
+ export function parseUnknownPropertyMessage(message) {
90
+ const match = message.match(/^Unknown property "([^"]+)"\.(?: Did you mean (.+)\?| Available properties: (.*)\.)?$/);
91
+ if (!match) {
92
+ return undefined;
93
+ }
94
+ const [, propertyPath, didYouMean, available] = match;
95
+ const suggestions = [];
96
+ if (didYouMean) {
97
+ for (const [, name] of didYouMean.matchAll(/"([^"]+)"/g)) {
98
+ suggestions.push(`Did you mean "${name}"?`);
99
+ }
100
+ }
101
+ else if (available !== undefined) {
102
+ const parentPath = propertyPath.slice(0, propertyPath.lastIndexOf('.'));
103
+ suggestions.push(`Available properties on ${parentPath}: ${available || '(none)'}`);
104
+ }
105
+ return { message: `Unknown property "${propertyPath}"`, suggestions };
106
+ }
107
+ function listKeys(target, extraKeys) {
108
+ return [...new Set([...Object.keys(target), ...extraKeys])].sort();
109
+ }
110
+ function createValidatingObjectProxy(target, path, extraKeys) {
111
+ return new Proxy(target, {
112
+ get(obj, prop, receiver) {
113
+ if (prop in obj || isPassthroughProperty(prop) || extraKeys.includes(prop)) {
114
+ return Reflect.get(obj, prop, receiver);
115
+ }
116
+ const name = String(prop);
117
+ const availableKeys = listKeys(obj, extraKeys);
118
+ const suggestions = findSimilarNames(name, availableKeys).map((key) => `${path}.${key}`);
119
+ throw new UnknownTemplatePropertyError(`${path}.${name}`, suggestions, availableKeys);
120
+ },
121
+ });
122
+ }
123
+ /**
124
+ * Wraps the template context so that reading a property that does not exist
125
+ * throws an {@link UnknownTemplatePropertyError} with "did you mean" suggestions
126
+ * instead of silently rendering `undefined`. The error reaches the dev server's
127
+ * error overlay.
128
+ *
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.
132
+ *
133
+ * Only active in development; other environments get the context back unchanged.
134
+ */
135
+ export function createValidatingContextProxy(context) {
136
+ if (!isDevelopment()) {
137
+ return context;
138
+ }
139
+ const nestedProxies = new Map();
140
+ return new Proxy(context, {
141
+ get(obj, prop, receiver) {
142
+ if (prop in obj || isPassthroughProperty(prop)) {
143
+ const value = Reflect.get(obj, prop, receiver);
144
+ const nestedExtraKeys = typeof prop === 'string' && Object.hasOwn(VALIDATED_NESTED_PROPERTIES, prop)
145
+ ? VALIDATED_NESTED_PROPERTIES[prop]
146
+ : undefined;
147
+ if (nestedExtraKeys && typeof value === 'object' && value !== null) {
148
+ // Cache so that repeated reads return the same proxy (stati.nav === stati.nav)
149
+ let nested = nestedProxies.get(prop);
150
+ if (!nested) {
151
+ nested = createValidatingObjectProxy(value, `stati.${String(prop)}`, nestedExtraKeys);
152
+ nestedProxies.set(prop, nested);
153
+ }
154
+ return nested;
155
+ }
156
+ return value;
157
+ }
158
+ const name = String(prop);
159
+ const availableKeys = listKeys(obj, []);
160
+ // An exact match one level down (stati.title -> stati.page.title) is a
161
+ // stronger signal than a spelling match, so it goes first
162
+ const suggestions = [];
163
+ for (const parent of CROSS_LEVEL_PROPERTIES) {
164
+ const parentValue = obj[parent];
165
+ if (typeof parentValue === 'object' &&
166
+ parentValue !== null &&
167
+ Object.hasOwn(parentValue, name)) {
168
+ suggestions.push(`stati.${parent}.${name}`);
169
+ }
170
+ }
171
+ suggestions.push(...findSimilarNames(name, availableKeys).map((key) => `stati.${key}`));
172
+ throw new UnknownTemplatePropertyError(`stati.${name}`, suggestions.slice(0, 3), availableKeys);
173
+ },
174
+ });
175
+ }
@@ -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;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAwbnF;AA0DD;;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;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"}