@stati/core 1.26.1 → 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 +59 -41
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +11 -5
- package/dist/core/utils/callable-partials.utils.d.ts.map +1 -1
- package/dist/core/utils/callable-partials.utils.js +14 -5
- package/dist/core/utils/context-validation.utils.d.ts +45 -0
- package/dist/core/utils/context-validation.utils.d.ts.map +1 -0
- package/dist/core/utils/context-validation.utils.js +175 -0
- package/dist/core/utils/error-overlay.utils.d.ts.map +1 -1
- package/dist/core/utils/error-overlay.utils.js +747 -312
- package/dist/core/utils/index.d.ts +2 -0
- package/dist/core/utils/index.d.ts.map +1 -1
- package/dist/core/utils/index.js +4 -0
- package/dist/core/utils/partial-validation.utils.d.ts.map +1 -1
- package/dist/core/utils/partial-validation.utils.js +30 -81
- package/dist/core/utils/similarity.utils.d.ts +34 -0
- package/dist/core/utils/similarity.utils.d.ts.map +1 -0
- package/dist/core/utils/similarity.utils.js +73 -0
- package/dist/core/utils/template-errors.utils.d.ts +3 -1
- package/dist/core/utils/template-errors.utils.d.ts.map +1 -1
- package/dist/core/utils/template-errors.utils.js +68 -8
- package/package.json +4 -1
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
|
|
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
|
|
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 }, //
|
|
268
|
-
{ untilDays: 30, ttlSeconds: 604800 }, //
|
|
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<
|
|
349
|
+
#### `build(options?: BuildOptions): Promise<BuildResult>`
|
|
346
350
|
|
|
347
|
-
Build your static site
|
|
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; //
|
|
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
|
|
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
|
-
|
|
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
|
|
413
|
+
console.log(`Dev server running at ${server.url}`);
|
|
404
414
|
```
|
|
405
415
|
|
|
406
|
-
**Returns:**
|
|
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
|
-
|
|
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
|
|
453
|
+
console.log(`Preview server running at ${server.url}`);
|
|
440
454
|
```
|
|
441
455
|
|
|
442
|
-
**Returns:**
|
|
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
|
|
456
|
-
- `glob:/blog/**` - Invalidate by glob pattern
|
|
457
|
-
- `age:3months` - Invalidate
|
|
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
|
|
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
|
|
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
|
|
585
|
+
- **Front-matter** - YAML (default) or JSON (`---json`) metadata
|
|
568
586
|
- **Plugins** - Full markdown-it ecosystem compatibility
|
|
569
|
-
- **Syntax highlighting** - Code blocks
|
|
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
|
-
- **
|
|
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
|
-
<%~
|
|
625
|
+
<%~ stati.partials.header %>
|
|
608
626
|
<main>
|
|
609
627
|
<%~ stati.content %>
|
|
610
628
|
</main>
|
|
611
|
-
<%~
|
|
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** -
|
|
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
|
-
- **
|
|
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 }, //
|
|
671
|
-
{ untilDays: 30, ttlSeconds: 86400 }, //
|
|
672
|
-
// Older content uses
|
|
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 }, //
|
|
712
|
-
{ untilDays: 365, ttlSeconds: 86400 }, //
|
|
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
|
});
|
|
@@ -738,8 +756,8 @@ export default defineConfig({
|
|
|
738
756
|
|
|
739
757
|
## Requirements
|
|
740
758
|
|
|
741
|
-
- **Node.js** >=
|
|
742
|
-
- **
|
|
759
|
+
- **Node.js** ^22.12.0 || >=24
|
|
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;
|
|
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"}
|
package/dist/core/templates.js
CHANGED
|
@@ -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;
|
|
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;
|
|
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"}
|