@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
@@ -0,0 +1,143 @@
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 { isDevelopment } from '../env.js';
11
+ import { TemplateUsageError } from './utils/index.js';
12
+ import { getRenderScope } from './template-render.js';
13
+ /**
14
+ * Eta's declaration of its `layout()` function. The plugin checks for it: when Eta generates
15
+ * different code, creating the engine fails instead of templates silently getting Eta's
16
+ * functions back.
17
+ */
18
+ export const ETA_LAYOUT_DECLARATION = 'function layout(path, data) {\n __eta.layout = path;\n __eta.layoutData = data;\n}';
19
+ /** Eta's template functions that Stati replaces, and the code that declares each one. */
20
+ const ETA_FUNCTIONS = [
21
+ { name: 'include', declaration: 'let include = ' },
22
+ { name: 'includeAsync', declaration: 'let includeAsync = ' },
23
+ { name: 'layout', declaration: ETA_LAYOUT_DECLARATION },
24
+ { name: 'block', declaration: 'function block(' },
25
+ { name: 'blockAsync', declaration: 'async function blockAsync(' },
26
+ ];
27
+ /** Marks the start of the template's own code in the compiled function. */
28
+ const BODY_START = '/*@stati:template-body@*/';
29
+ /** Property of the engine through which compiled templates report a blocked call. */
30
+ const BLOCKED_CALL_HANDLER = '__statiBlockedEtaCall';
31
+ /**
32
+ * Creates the plugin. The replacements go at the start of the template's code rather than
33
+ * next to Eta's declarations, because with `debug` on Eta declares `block()` inside a `try`
34
+ * block, where a replacement made outside it would be shadowed. The template's code then runs
35
+ * in a block of its own, so it can still declare names such as `block` or `output`, which
36
+ * would otherwise clash with Eta's declarations.
37
+ */
38
+ export function createStatiEtaPlugin() {
39
+ return {
40
+ processAST(ast) {
41
+ const lineNo = ast.reduce((line, node) => typeof node === 'object' && node.lineNo !== undefined ? node.lineNo : line, 1);
42
+ const start = { t: 'e', val: BODY_START, lineNo: 1 };
43
+ const end = { t: 'e', val: '}', lineNo };
44
+ return [start, ...ast, end];
45
+ },
46
+ processFnString(fnString) {
47
+ if (!fnString.includes(ETA_LAYOUT_DECLARATION) || !fnString.includes(BODY_START)) {
48
+ throw new Error("Stati could not find Eta's layout() function in a compiled template, so it cannot block Eta's own include(), layout() and block(). This Eta version is not supported; install the version @stati/core depends on.");
49
+ }
50
+ // The names are the identifiers listed in ETA_FUNCTIONS, so they need no escaping
51
+ const replacements = ETA_FUNCTIONS.filter(({ declaration }) => fnString.includes(declaration))
52
+ .map(({ name }) => `${name} = (...__statiArgs) => this.${BLOCKED_CALL_HANDLER}('${name}', __statiArgs, options && options.filepath);`)
53
+ .join(' ');
54
+ // With debug on, Eta marks each tag's line with `__eta.line=N` and no semicolon, so a tag
55
+ // that starts with `(`, `[` or a template literal would continue that statement
56
+ return fnString
57
+ .replace(BODY_START, `${replacements} {`)
58
+ .replace(/__eta\.line=(\d+)\n/g, '__eta.line=$1;\n');
59
+ },
60
+ };
61
+ }
62
+ /**
63
+ * Installs the handler that the replaced functions call, and compiles an empty template so
64
+ * that an Eta version the plugin does not match fails here rather than on the first page.
65
+ *
66
+ * @throws {Error} When Eta's generated code lacks the declarations the plugin replaces
67
+ */
68
+ export function installEtaPlugin(eta) {
69
+ eta[BLOCKED_CALL_HANDLER] = handleBlockedCall;
70
+ eta.compileToString('', { async: false });
71
+ }
72
+ /**
73
+ * The Stati call to suggest for a name passed to one of Eta's functions. It keeps Eta's
74
+ * resolution: a name without a leading `/`, `./` or `../` is relative to the calling template.
75
+ */
76
+ function suggestedName(name) {
77
+ if (typeof name !== 'string') {
78
+ return 'path';
79
+ }
80
+ let path = name.replace(/\\/g, '/');
81
+ if (path.endsWith('.eta')) {
82
+ path = path.slice(0, -'.eta'.length);
83
+ }
84
+ if (!path.startsWith('/') && !path.startsWith('./') && !path.startsWith('../')) {
85
+ path = `./${path}`;
86
+ }
87
+ // Escaped for the single-quoted string of the suggested call
88
+ return path.replace(/[\\']/g, '\\$&');
89
+ }
90
+ /**
91
+ * The message for a blocked call, and the replacement to suggest.
92
+ */
93
+ export function describeBlockedCall(name, args) {
94
+ const withData = args.length > 1 && args[1] !== undefined;
95
+ const target = suggestedName(args[0]);
96
+ switch (name) {
97
+ case 'include':
98
+ case 'includeAsync': {
99
+ const suggestion = `stati.include('${target}'${withData ? ', data' : ''})`;
100
+ const sync = name === 'includeAsync'
101
+ ? '; templates rendered by stati.include() render synchronously'
102
+ : '';
103
+ return {
104
+ message: `Eta's ${name}() is not available in Stati templates. Use ${suggestion} instead${sync}.`,
105
+ suggestion: `Use ${suggestion}`,
106
+ };
107
+ }
108
+ case 'layout': {
109
+ const suggestion = `stati.layout('${target}'${withData ? ', data' : ''})`;
110
+ return {
111
+ message: `Eta's layout() is not available in Stati templates. Use ${suggestion} instead; the data arrives as stati.props.`,
112
+ suggestion: `Use ${suggestion}`,
113
+ };
114
+ }
115
+ case 'block':
116
+ case 'blockAsync':
117
+ return {
118
+ message: `Eta's ${name}() is not available in Stati templates. Pass the content to the layout as a prop instead, such as stati.layout('base', { sidebar: html }).`,
119
+ suggestion: 'Pass the content to the layout as a prop',
120
+ };
121
+ }
122
+ }
123
+ /**
124
+ * Called by a compiled template for one of Eta's blocked functions. Development throws, so the
125
+ * error overlay points at the call; production logs the error and the call renders nothing.
126
+ */
127
+ function handleBlockedCall(name, args, filepath) {
128
+ const { message, suggestion } = describeBlockedCall(name, args);
129
+ if (isDevelopment()) {
130
+ throw new TemplateUsageError(message, [suggestion]);
131
+ }
132
+ const scope = getRenderScope();
133
+ const where = filepath ? `${scope ? scope.displayPath(filepath) : filepath}: ` : '';
134
+ const page = scope ? ` (page ${scope.page.url})` : '';
135
+ const text = `${where}${message}${page}`;
136
+ if (scope) {
137
+ scope.logger.error(text);
138
+ }
139
+ else {
140
+ console.error(text);
141
+ }
142
+ return '';
143
+ }
@@ -1,8 +1,10 @@
1
- import type { PageModel, CacheEntry, StatiConfig } from '../../types/index.js';
1
+ import type { PageModel, CacheEntry, StatiConfig, TemplateLookup } from '../../types/index.js';
2
2
  import type { PageReads } from '../collections.js';
3
+ import { TemplateIndex } from '../template-index.js';
3
4
  /**
4
- * What a render discovered: the source images a page displays (`assetDeps`), the generated
5
- * images it references (`images`) and the shared data it read (`reads`).
5
+ * What a render discovered: the templates it used (`templates`, `templateLookups`), the source
6
+ * images a page displays (`assetDeps`), the generated images it references (`images`) and the
7
+ * shared data it read (`reads`).
6
8
  */
7
9
  export interface RenderedAssets {
8
10
  /** Absolute paths of files whose content affects the page, such as source images */
@@ -11,6 +13,10 @@ export interface RenderedAssets {
11
13
  images: readonly string[];
12
14
  /** Whether the render read the collection and the navigation tree */
13
15
  reads?: PageReads | undefined;
16
+ /** Template files the render used, absolute POSIX paths, the page's layout first */
17
+ templates?: readonly string[] | undefined;
18
+ /** Template names the render looked up, with what each resolved to */
19
+ templateLookups?: readonly TemplateLookup[] | undefined;
14
20
  }
15
21
  /**
16
22
  * Whether a cache entry records what its page read while rendering. Entries written before
@@ -22,10 +28,23 @@ export interface RenderedAssets {
22
28
  * @returns True when `readsCollection` and `readsNav` are both recorded
23
29
  */
24
30
  export declare function hasRecordedReads(entry: CacheEntry): boolean;
31
+ /**
32
+ * Whether a cache entry's templates and name lookups were recorded while its page rendered.
33
+ * An entry without them cannot tell when a template change affects its page, so the page is
34
+ * rebuilt once to record them.
35
+ *
36
+ * @param entry - The cache entry
37
+ * @returns True when `templatesRecorded` is set
38
+ */
39
+ export declare function hasRecordedTemplates(entry: CacheEntry): boolean;
25
40
  /**
26
41
  * Determines if a page should be rebuilt based on ISG logic.
27
- * Checks content changes, dependency changes, TTL expiration, and freeze status.
28
- * Handles edge cases like missing dates, corrupted cache entries, and dependency errors.
42
+ * Checks content changes, the templates the last render recorded, the page's layout, TTL
43
+ * expiration, and freeze status. Handles edge cases like missing dates and corrupted entries.
44
+ *
45
+ * A cached page is stale when a template it rendered changed or no longer exists, when its
46
+ * layout would now be a different file, or when a template name it looked up would now
47
+ * resolve differently (checked against the template index, without reading files).
29
48
  *
30
49
  * @param page - The page model to check
31
50
  * @param entry - Existing cache entry, or undefined if not cached
@@ -33,6 +52,7 @@ export declare function hasRecordedReads(entry: CacheEntry): boolean;
33
52
  * @param allPages - All pages in the site, used to resolve the same layout as rendering
34
53
  * @param now - Current date/time
35
54
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
55
+ * @param templateIndex - The build's template index; read from the source directory when omitted
36
56
  * @returns True if the page should be rebuilt
37
57
  *
38
58
  * @example
@@ -43,43 +63,41 @@ export declare function hasRecordedReads(entry: CacheEntry): boolean;
43
63
  * }
44
64
  * ```
45
65
  */
46
- export declare function shouldRebuildPage(page: PageModel, entry: CacheEntry | undefined, config: StatiConfig, allPages: PageModel[], now: Date, renderInputsHash?: string): Promise<boolean>;
66
+ export declare function shouldRebuildPage(page: PageModel, entry: CacheEntry | undefined, config: StatiConfig, allPages: PageModel[], now: Date, renderInputsHash?: string, templateIndex?: TemplateIndex): Promise<boolean>;
47
67
  /**
48
- * Creates a new cache entry for a page after it has been rendered.
68
+ * Creates a new cache entry for a page after it has been rendered, with the templates and name
69
+ * lookups the render recorded (`rendered`).
49
70
  *
50
71
  * @param page - The page model
51
72
  * @param config - Stati configuration
52
- * @param allPages - All pages in the site, used to resolve the same layout as rendering
53
73
  * @param renderedAt - When the page was rendered
54
74
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
55
- * @param rendered - Files the render discovered, such as images
75
+ * @param rendered - What the render used: templates, lookups, images and shared data. Without
76
+ * recorded templates the entry is not marked `templatesRecorded` and the page rebuilds next time.
56
77
  * @returns New cache entry
57
78
  *
58
79
  * @example
59
80
  * ```typescript
60
- * const entry = await createCacheEntry(page, config, pages, new Date());
81
+ * const entry = await createCacheEntry(page, config, new Date(), renderInputsHash, rendered);
61
82
  * ```
62
83
  */
63
- export declare function createCacheEntry(page: PageModel, config: StatiConfig, allPages: PageModel[], renderedAt: Date, renderInputsHash?: string, rendered?: RenderedAssets): Promise<CacheEntry>;
84
+ export declare function createCacheEntry(page: PageModel, config: StatiConfig, renderedAt: Date, renderInputsHash?: string, rendered?: RenderedAssets): Promise<CacheEntry>;
64
85
  /**
65
- * Updates an existing cache entry with new information after rebuilding.
66
- * In dev mode, this is optimized to reuse the existing deps array since
67
- * template structure rarely changes between incremental rebuilds.
86
+ * Updates an existing cache entry after its page rendered again: a new entry from this
87
+ * render, keeping the original publish date when the page no longer sets one.
68
88
  *
69
89
  * @param entry - Existing cache entry
70
90
  * @param page - The page model
71
91
  * @param config - Stati configuration
72
- * @param allPages - All pages in the site, used to resolve the same layout as rendering
73
92
  * @param renderedAt - When the page was rendered
74
- * @param fastUpdate - If true, skip expensive dep tracking (dev mode optimization)
75
93
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
76
- * @param rendered - Files the render discovered, such as images
94
+ * @param rendered - What the render used: templates, lookups, images and shared data
77
95
  * @returns Updated cache entry
78
96
  *
79
97
  * @example
80
98
  * ```typescript
81
- * const updatedEntry = await updateCacheEntry(existingEntry, page, config, pages, new Date());
99
+ * const updatedEntry = await updateCacheEntry(existingEntry, page, config, new Date(), hash, rendered);
82
100
  * ```
83
101
  */
84
- export declare function updateCacheEntry(entry: CacheEntry, page: PageModel, config: StatiConfig, allPages: PageModel[], renderedAt: Date, fastUpdate?: boolean, renderInputsHash?: string, rendered?: RenderedAssets): Promise<CacheEntry>;
102
+ export declare function updateCacheEntry(entry: CacheEntry, page: PageModel, config: StatiConfig, renderedAt: Date, renderInputsHash?: string, rendered?: RenderedAssets): Promise<CacheEntry>;
85
103
  //# sourceMappingURL=builder.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../../../src/core/isg/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAsFnD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,oFAAoF;IACpF,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,kFAAkF;IAClF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAE3D;AAuDD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,SAAS,EAAE,EACrB,GAAG,EAAE,IAAI,EACT,gBAAgB,CAAC,EAAE,MAAM,GACxB,OAAO,CAAC,OAAO,CAAC,CAuKlB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,SAAS,EAAE,EACrB,UAAU,EAAE,IAAI,EAChB,gBAAgB,CAAC,EAAE,MAAM,EACzB,QAAQ,CAAC,EAAE,cAAc,GACxB,OAAO,CAAC,UAAU,CAAC,CAmFrB;AAkDD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,SAAS,EAAE,EACrB,UAAU,EAAE,IAAI,EAChB,UAAU,UAAQ,EAClB,gBAAgB,CAAC,EAAE,MAAM,EACzB,QAAQ,CAAC,EAAE,cAAc,GACxB,OAAO,CAAC,UAAU,CAAC,CAiErB"}
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../../../src/core/isg/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC/F,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAWnD,OAAO,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AA+E3E;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,oFAAoF;IACpF,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,kFAAkF;IAClF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC9B,oFAAoF;IACpF,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IAC1C,sEAAsE;IACtE,eAAe,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,SAAS,CAAC;CACzD;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAE3D;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAE/D;AAuDD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,SAAS,EAAE,EACrB,GAAG,EAAE,IAAI,EACT,gBAAgB,CAAC,EAAE,MAAM,EACzB,aAAa,CAAC,EAAE,aAAa,GAC5B,OAAO,CAAC,OAAO,CAAC,CA+JlB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,EAChB,gBAAgB,CAAC,EAAE,MAAM,EACzB,QAAQ,CAAC,EAAE,cAAc,GACxB,OAAO,CAAC,UAAU,CAAC,CA0ErB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,EAChB,gBAAgB,CAAC,EAAE,MAAM,EACzB,QAAQ,CAAC,EAAE,cAAc,GACxB,OAAO,CAAC,UAAU,CAAC,CASrB"}
@@ -1,9 +1,9 @@
1
1
  import { computeContentHash, computeFileHash, computeBinaryFileHash, computeInputsHash, } from './hash.js';
2
- import { trackTemplateDependencies, resolveLayoutDependency } from './deps.js';
2
+ import { resolveLayoutDependency } from './deps.js';
3
3
  import { computeEffectiveTTL, computeNextRebuildAt, isPageFrozen } from './ttl.js';
4
4
  import { validatePageISGOverrides, extractNumericOverride } from './validation.js';
5
- import { getFrontMatterDate } from '../utils/index.js';
6
- import { performance } from 'node:perf_hooks';
5
+ import { getFrontMatterDate, normalizePathForComparison, resolveSrcDir } from '../utils/index.js';
6
+ import { TemplateIndex, isTemplateLookupList } from '../template-index.js';
7
7
  /**
8
8
  * Determines the output path for a page.
9
9
  */
@@ -60,11 +60,14 @@ function isValidCacheEntry(entry) {
60
60
  return false;
61
61
  }
62
62
  }
63
- for (const flag of [entry.readsCollection, entry.readsNav]) {
63
+ for (const flag of [entry.readsCollection, entry.readsNav, entry.templatesRecorded]) {
64
64
  if (flag !== undefined && typeof flag !== 'boolean') {
65
65
  return false;
66
66
  }
67
67
  }
68
+ if (entry.templateLookups !== undefined && !isTemplateLookupList(entry.templateLookups)) {
69
+ return false;
70
+ }
68
71
  return true;
69
72
  }
70
73
  /**
@@ -79,6 +82,17 @@ function isValidCacheEntry(entry) {
79
82
  export function hasRecordedReads(entry) {
80
83
  return typeof entry.readsCollection === 'boolean' && typeof entry.readsNav === 'boolean';
81
84
  }
85
+ /**
86
+ * Whether a cache entry's templates and name lookups were recorded while its page rendered.
87
+ * An entry without them cannot tell when a template change affects its page, so the page is
88
+ * rebuilt once to record them.
89
+ *
90
+ * @param entry - The cache entry
91
+ * @returns True when `templatesRecorded` is set
92
+ */
93
+ export function hasRecordedTemplates(entry) {
94
+ return entry.templatesRecorded === true;
95
+ }
82
96
  /**
83
97
  * Tags a page's cache entry carries for `stati invalidate "tag:…"`: its front matter tags,
84
98
  * plus the key and label of a generated page, so `tag:css` also refreshes `/tags/css`.
@@ -125,8 +139,12 @@ function withRenderedAssets(entry, rendered) {
125
139
  }
126
140
  /**
127
141
  * Determines if a page should be rebuilt based on ISG logic.
128
- * Checks content changes, dependency changes, TTL expiration, and freeze status.
129
- * Handles edge cases like missing dates, corrupted cache entries, and dependency errors.
142
+ * Checks content changes, the templates the last render recorded, the page's layout, TTL
143
+ * expiration, and freeze status. Handles edge cases like missing dates and corrupted entries.
144
+ *
145
+ * A cached page is stale when a template it rendered changed or no longer exists, when its
146
+ * layout would now be a different file, or when a template name it looked up would now
147
+ * resolve differently (checked against the template index, without reading files).
130
148
  *
131
149
  * @param page - The page model to check
132
150
  * @param entry - Existing cache entry, or undefined if not cached
@@ -134,6 +152,7 @@ function withRenderedAssets(entry, rendered) {
134
152
  * @param allPages - All pages in the site, used to resolve the same layout as rendering
135
153
  * @param now - Current date/time
136
154
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
155
+ * @param templateIndex - The build's template index; read from the source directory when omitted
137
156
  * @returns True if the page should be rebuilt
138
157
  *
139
158
  * @example
@@ -144,7 +163,7 @@ function withRenderedAssets(entry, rendered) {
144
163
  * }
145
164
  * ```
146
165
  */
147
- export async function shouldRebuildPage(page, entry, config, allPages, now, renderInputsHash) {
166
+ export async function shouldRebuildPage(page, entry, config, allPages, now, renderInputsHash, templateIndex) {
148
167
  if (config.isg?.enabled === false) {
149
168
  return true;
150
169
  }
@@ -158,46 +177,37 @@ export async function shouldRebuildPage(page, entry, config, allPages, now, rend
158
177
  console.warn(`Invalid cache entry for ${page.url}, forcing rebuild`);
159
178
  return true;
160
179
  }
161
- // Check if inputs (content + dependencies) have changed
162
- const currentContentHash = computeContentHash(page.content, page.frontMatter);
163
- // Track dependencies with error handling
164
- let deps;
165
- try {
166
- deps = await trackTemplateDependencies(page, config, allPages);
180
+ // Entries from before templates were recorded while rendering rebuild once to record them
181
+ if (!hasRecordedTemplates(entry)) {
182
+ return true;
167
183
  }
168
- catch (error) {
169
- if (error instanceof Error && error.name === 'CircularDependencyError') {
170
- console.error(`Circular dependency detected for ${page.url}: ${error.message}`);
171
- throw error; // Re-throw circular dependency errors as they're fatal
172
- }
173
- // For other dependency errors, log warning and assume dependencies changed
174
- console.warn(`Failed to track dependencies for ${page.url}: ${error instanceof Error ? error.message : String(error)}`);
175
- console.warn('Assuming dependencies changed, forcing rebuild');
184
+ const index = templateIndex ?? (await TemplateIndex.load(resolveSrcDir(config)));
185
+ // The page can switch layouts without a template file changing, for example when a
186
+ // collection gains its first child page. deps[0] is the layout. A page without a layout
187
+ // renders Stati's fallback HTML; it always rebuilds, so a new layout.eta takes effect.
188
+ // Compared in normalized form: the drive letter's case follows the shell that ran the build.
189
+ const layout = resolveLayoutDependency(page, allPages, index);
190
+ const recordedLayout = entry.deps[0];
191
+ if (layout === null ||
192
+ recordedLayout === undefined ||
193
+ normalizePathForComparison(layout) !== normalizePathForComparison(recordedLayout)) {
176
194
  return true;
177
195
  }
178
- // Compute hashes for all dependencies with error handling
196
+ // A name the page looked up would now find another template, such as a section override
197
+ // added at the same path, or a file that stati.include.has() did not find
198
+ if (index.lookupsChanged(entry.templateLookups)) {
199
+ return true;
200
+ }
201
+ // Check if inputs (content + dependencies) have changed
202
+ const currentContentHash = computeContentHash(page.content, page.frontMatter);
203
+ // Hash every template the page rendered; one that no longer exists rebuilds the page
179
204
  const depsHashes = [];
180
- let dependencyErrors = 0;
181
- for (const dep of deps) {
182
- try {
183
- const depHash = await computeFileHash(dep);
184
- if (depHash) {
185
- depsHashes.push(depHash);
186
- }
187
- else {
188
- dependencyErrors++;
189
- console.warn(`Missing dependency file: ${dep} (used by ${page.url})`);
190
- }
191
- }
192
- catch (error) {
193
- dependencyErrors++;
194
- console.warn(`Failed to hash dependency ${dep}: ${error instanceof Error ? error.message : String(error)}`);
205
+ for (const dep of entry.deps) {
206
+ const depHash = await computeFileHash(dep);
207
+ if (!depHash) {
208
+ return true;
195
209
  }
196
- }
197
- // If we had dependency errors, force rebuild to be safe
198
- if (dependencyErrors > 0) {
199
- console.warn(`${dependencyErrors} dependency errors for ${page.url}, forcing rebuild`);
200
- return true;
210
+ depsHashes.push(depHash);
201
211
  }
202
212
  // Files found during the last render, such as images, are hashed by content
203
213
  depsHashes.push(...(await hashAssetDeps(entry.assetDeps)));
@@ -276,37 +286,28 @@ export async function shouldRebuildPage(page, entry, config, allPages, now, rend
276
286
  }
277
287
  }
278
288
  /**
279
- * Creates a new cache entry for a page after it has been rendered.
289
+ * Creates a new cache entry for a page after it has been rendered, with the templates and name
290
+ * lookups the render recorded (`rendered`).
280
291
  *
281
292
  * @param page - The page model
282
293
  * @param config - Stati configuration
283
- * @param allPages - All pages in the site, used to resolve the same layout as rendering
284
294
  * @param renderedAt - When the page was rendered
285
295
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
286
- * @param rendered - Files the render discovered, such as images
296
+ * @param rendered - What the render used: templates, lookups, images and shared data. Without
297
+ * recorded templates the entry is not marked `templatesRecorded` and the page rebuilds next time.
287
298
  * @returns New cache entry
288
299
  *
289
300
  * @example
290
301
  * ```typescript
291
- * const entry = await createCacheEntry(page, config, pages, new Date());
302
+ * const entry = await createCacheEntry(page, config, new Date(), renderInputsHash, rendered);
292
303
  * ```
293
304
  */
294
- export async function createCacheEntry(page, config, allPages, renderedAt, renderInputsHash, rendered) {
295
- const timings = {};
296
- let start = performance.now();
305
+ export async function createCacheEntry(page, config, renderedAt, renderInputsHash, rendered) {
297
306
  // Validate page-level ISG overrides first
298
307
  validatePageISGOverrides(page.frontMatter, page.sourcePath);
299
- timings.validate = performance.now() - start;
300
- // Compute content hash
301
- start = performance.now();
302
308
  const contentHash = computeContentHash(page.content, page.frontMatter);
303
- timings.contentHash = performance.now() - start;
304
- // Track all template dependencies
305
- start = performance.now();
306
- const deps = await trackTemplateDependencies(page, config, allPages);
307
- timings.trackDeps = performance.now() - start;
308
- // Compute hashes for all dependencies
309
- start = performance.now();
309
+ // The templates the render used, the layout first
310
+ const deps = [...(rendered?.templates ?? [])];
310
311
  const depsHashes = [];
311
312
  for (const dep of deps) {
312
313
  const depHash = await computeFileHash(dep);
@@ -315,7 +316,6 @@ export async function createCacheEntry(page, config, allPages, renderedAt, rende
315
316
  }
316
317
  }
317
318
  depsHashes.push(...(await hashAssetDeps(rendered?.assetDeps)));
318
- timings.depsHashes = performance.now() - start;
319
319
  const inputsHash = computeInputsHash(contentHash, depsHashes, renderInputsHash);
320
320
  // Extract tags from front matter
321
321
  const tags = getCacheTags(page);
@@ -343,6 +343,10 @@ export async function createCacheEntry(page, config, allPages, renderedAt, rende
343
343
  path: outputPath,
344
344
  inputsHash,
345
345
  deps,
346
+ ...(rendered?.templates && {
347
+ templateLookups: [...(rendered.templateLookups ?? [])],
348
+ templatesRecorded: true,
349
+ }),
346
350
  tags,
347
351
  renderedAt: renderedAt.toISOString(),
348
352
  ttlSeconds,
@@ -357,111 +361,24 @@ export async function createCacheEntry(page, config, allPages, renderedAt, rende
357
361
  return withRenderedAssets(cacheEntry, rendered);
358
362
  }
359
363
  /**
360
- * Checks if the template structure has changed by comparing the layout file's
361
- * modification time against when the cache entry was last rendered.
362
- * This detects when new includes/extends have been added to templates.
363
- *
364
- * @param entry - The existing cache entry
365
- * @returns True if template structure may have changed, false if structure is unchanged
366
- */
367
- async function hasTemplateStructureChanged(entry) {
368
- try {
369
- // If the entry has a layout in its deps, check if that layout file has been modified
370
- // since the last render. Layout files are typically the first dep or contain the
371
- // includes/extends declarations.
372
- if (entry.deps.length === 0) {
373
- return false;
374
- }
375
- // Get the timestamp of the last render
376
- const lastRendered = new Date(entry.renderedAt).getTime();
377
- // Import stat from node:fs/promises at runtime to avoid circular dependency issues
378
- const { stat } = await import('node:fs/promises');
379
- // Check if any template files (layout or partials) have been modified
380
- // We only need to check the first few deps (usually layout and immediate includes)
381
- // as a heuristic to detect structural changes without expensive full tracking
382
- const depsToCheck = entry.deps.slice(0, 3);
383
- for (const depPath of depsToCheck) {
384
- try {
385
- const stats = await stat(depPath);
386
- if (stats.mtimeMs > lastRendered) {
387
- // Template file was modified after last render - structure may have changed
388
- return true;
389
- }
390
- }
391
- catch (_error) {
392
- // If we can't stat the file, assume it might have changed
393
- return true;
394
- }
395
- }
396
- return false;
397
- }
398
- catch (_error) {
399
- // On any error, be conservative and assume structure changed
400
- return true;
401
- }
402
- }
403
- /**
404
- * Updates an existing cache entry with new information after rebuilding.
405
- * In dev mode, this is optimized to reuse the existing deps array since
406
- * template structure rarely changes between incremental rebuilds.
364
+ * Updates an existing cache entry after its page rendered again: a new entry from this
365
+ * render, keeping the original publish date when the page no longer sets one.
407
366
  *
408
367
  * @param entry - Existing cache entry
409
368
  * @param page - The page model
410
369
  * @param config - Stati configuration
411
- * @param allPages - All pages in the site, used to resolve the same layout as rendering
412
370
  * @param renderedAt - When the page was rendered
413
- * @param fastUpdate - If true, skip expensive dep tracking (dev mode optimization)
414
371
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
415
- * @param rendered - Files the render discovered, such as images
372
+ * @param rendered - What the render used: templates, lookups, images and shared data
416
373
  * @returns Updated cache entry
417
374
  *
418
375
  * @example
419
376
  * ```typescript
420
- * const updatedEntry = await updateCacheEntry(existingEntry, page, config, pages, new Date());
377
+ * const updatedEntry = await updateCacheEntry(existingEntry, page, config, new Date(), hash, rendered);
421
378
  * ```
422
379
  */
423
- export async function updateCacheEntry(entry, page, config, allPages, renderedAt, fastUpdate = false, renderInputsHash, rendered) {
424
- // In fast update mode (dev), reuse existing deps to avoid expensive tracking
425
- if (fastUpdate && entry.deps.length > 0) {
426
- // Detect if template structure has changed (new includes/extends added)
427
- // by checking if the layout file has been modified or if template content
428
- // contains different dependency patterns
429
- const structureChanged = await hasTemplateStructureChanged(entry);
430
- // The page can also switch layouts without any template file changing, for example
431
- // when a collection gains its first child page or a front matter layout is edited.
432
- // deps[0] is always the layout, so compare it against the layout used now.
433
- const layoutChanged = !structureChanged &&
434
- (await resolveLayoutDependency(page, config, allPages)) !== entry.deps[0];
435
- if (structureChanged || layoutChanged) {
436
- // Template structure or layout changed - fall through to full dependency tracking
437
- // This ensures we pick up new includes/extends and the newly selected layout
438
- }
439
- else {
440
- // Structure unchanged - safe to reuse existing deps
441
- // Just update the content hash and timestamp, keep existing deps
442
- const contentHash = computeContentHash(page.content, page.frontMatter);
443
- // Compute hashes for existing dependencies (fast - files are cached)
444
- const depsHashes = [];
445
- for (const dep of entry.deps) {
446
- const depHash = await computeFileHash(dep);
447
- if (depHash) {
448
- depsHashes.push(depHash);
449
- }
450
- }
451
- // Asset deps come from this render, not from the previous entry
452
- depsHashes.push(...(await hashAssetDeps(rendered?.assetDeps)));
453
- const inputsHash = computeInputsHash(contentHash, depsHashes, renderInputsHash);
454
- return withRenderedAssets({
455
- ...entry,
456
- inputsHash,
457
- // Front matter tags, and a generated page's label, can change without a layout change
458
- tags: getCacheTags(page),
459
- renderedAt: renderedAt.toISOString(),
460
- }, rendered);
461
- }
462
- }
463
- // Full update - create new entry and preserve original publishedAt
464
- const newEntry = await createCacheEntry(page, config, allPages, renderedAt, renderInputsHash, rendered);
380
+ export async function updateCacheEntry(entry, page, config, renderedAt, renderInputsHash, rendered) {
381
+ const newEntry = await createCacheEntry(page, config, renderedAt, renderInputsHash, rendered);
465
382
  // Preserve original publishedAt if no new one is specified
466
383
  if (!newEntry.publishedAt && entry.publishedAt) {
467
384
  newEntry.publishedAt = entry.publishedAt;