@stati/core 1.1.0 → 1.3.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 +19 -7
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +24 -2
- package/dist/core/build.d.ts +21 -15
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +141 -42
- package/dist/core/dev.d.ts.map +1 -1
- package/dist/core/dev.js +84 -18
- package/dist/core/invalidate.d.ts +67 -1
- package/dist/core/invalidate.d.ts.map +1 -1
- package/dist/core/invalidate.js +321 -4
- package/dist/core/isg/build-lock.d.ts +116 -0
- package/dist/core/isg/build-lock.d.ts.map +1 -0
- package/dist/core/isg/build-lock.js +245 -0
- package/dist/core/isg/builder.d.ts +51 -0
- package/dist/core/isg/builder.d.ts.map +1 -0
- package/dist/core/isg/builder.js +321 -0
- package/dist/core/isg/deps.d.ts +63 -0
- package/dist/core/isg/deps.d.ts.map +1 -0
- package/dist/core/isg/deps.js +332 -0
- package/dist/core/isg/hash.d.ts +48 -0
- package/dist/core/isg/hash.d.ts.map +1 -0
- package/dist/core/isg/hash.js +82 -0
- package/dist/core/isg/manifest.d.ts +47 -0
- package/dist/core/isg/manifest.d.ts.map +1 -0
- package/dist/core/isg/manifest.js +233 -0
- package/dist/core/isg/ttl.d.ts +101 -0
- package/dist/core/isg/ttl.d.ts.map +1 -0
- package/dist/core/isg/ttl.js +222 -0
- package/dist/core/isg/validation.d.ts +71 -0
- package/dist/core/isg/validation.d.ts.map +1 -0
- package/dist/core/isg/validation.js +226 -0
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +110 -20
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fse from 'fs-extra';
|
|
2
|
+
const { writeFile, readFile, pathExists, remove, ensureDir } = fse;
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { hostname } from 'os';
|
|
5
|
+
/**
|
|
6
|
+
* Manages build process locking to prevent concurrent Stati builds from corrupting cache.
|
|
7
|
+
* Uses a simple file-based locking mechanism with process ID tracking.
|
|
8
|
+
*/
|
|
9
|
+
export class BuildLockManager {
|
|
10
|
+
lockPath;
|
|
11
|
+
isLocked = false;
|
|
12
|
+
constructor(cacheDir) {
|
|
13
|
+
this.lockPath = join(cacheDir, '.build-lock');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Attempts to acquire a build lock.
|
|
17
|
+
* Throws an error if another build process is already running.
|
|
18
|
+
*
|
|
19
|
+
* @param options - Lock acquisition options
|
|
20
|
+
* @param options.force - Force acquire lock even if another process holds it
|
|
21
|
+
* @param options.timeout - Maximum time to wait for lock in milliseconds
|
|
22
|
+
* @throws {Error} When lock cannot be acquired
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const lockManager = new BuildLockManager('.stati');
|
|
27
|
+
* try {
|
|
28
|
+
* await lockManager.acquireLock();
|
|
29
|
+
* // Proceed with build
|
|
30
|
+
* } finally {
|
|
31
|
+
* await lockManager.releaseLock();
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
async acquireLock(options = {}) {
|
|
36
|
+
const { force = false, timeout = 30000 } = options;
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
while (Date.now() - startTime < timeout) {
|
|
39
|
+
try {
|
|
40
|
+
// Check if lock file exists
|
|
41
|
+
if (await pathExists(this.lockPath)) {
|
|
42
|
+
const existingLock = await this.readLockFile();
|
|
43
|
+
if (existingLock && !force) {
|
|
44
|
+
// Check if the process is still running
|
|
45
|
+
if (await this.isProcessRunning(existingLock.pid)) {
|
|
46
|
+
// Wait a bit and try again
|
|
47
|
+
await this.sleep(1000);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Process is dead, remove stale lock
|
|
52
|
+
console.warn(`Removing stale build lock (PID ${existingLock.pid} no longer running)`);
|
|
53
|
+
await this.forceRemoveLock();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (force) {
|
|
57
|
+
console.warn('Force acquiring build lock, removing existing lock');
|
|
58
|
+
await this.forceRemoveLock();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Try to create the lock
|
|
62
|
+
await this.createLockFile();
|
|
63
|
+
this.isLocked = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const nodeError = error;
|
|
68
|
+
if (nodeError.code === 'EEXIST') {
|
|
69
|
+
// Another process created the lock between our check and creation
|
|
70
|
+
await this.sleep(1000);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Failed to acquire build lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Build lock acquisition timed out after ${timeout}ms. ` +
|
|
77
|
+
`Another Stati build process may be running. Use --force to override.`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Releases the build lock if this process owns it.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* await lockManager.releaseLock();
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
async releaseLock() {
|
|
88
|
+
if (!this.isLocked) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
// Verify we still own the lock before removing it
|
|
93
|
+
const currentLock = await this.readLockFile();
|
|
94
|
+
if (currentLock && currentLock.pid === process.pid) {
|
|
95
|
+
await remove(this.lockPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Don't throw on release errors, just warn
|
|
100
|
+
console.warn(`Warning: Failed to release build lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
this.isLocked = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Checks if a build lock is currently held by any process.
|
|
108
|
+
*
|
|
109
|
+
* @returns True if a lock exists and the owning process is running
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```typescript
|
|
113
|
+
* if (await lockManager.isLocked()) {
|
|
114
|
+
* console.log('Another build is in progress');
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
async isLockHeld() {
|
|
119
|
+
try {
|
|
120
|
+
if (!(await pathExists(this.lockPath))) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const lock = await this.readLockFile();
|
|
124
|
+
if (!lock) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return await this.isProcessRunning(lock.pid);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Gets information about the current lock holder.
|
|
135
|
+
*
|
|
136
|
+
* @returns Lock information or null if no lock exists
|
|
137
|
+
*/
|
|
138
|
+
async getLockInfo() {
|
|
139
|
+
try {
|
|
140
|
+
if (!(await pathExists(this.lockPath))) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return await this.readLockFile();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Force removes the lock file without checking ownership.
|
|
151
|
+
* Should only be used in error recovery scenarios.
|
|
152
|
+
*/
|
|
153
|
+
async forceRemoveLock() {
|
|
154
|
+
try {
|
|
155
|
+
await remove(this.lockPath);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Ignore errors when force removing
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Creates a new lock file with current process information.
|
|
163
|
+
*/
|
|
164
|
+
async createLockFile() {
|
|
165
|
+
const lockInfo = {
|
|
166
|
+
pid: process.pid,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
hostname: this.getHostname(),
|
|
169
|
+
};
|
|
170
|
+
// Ensure the cache directory exists before creating the lock file
|
|
171
|
+
await ensureDir(dirname(this.lockPath));
|
|
172
|
+
// Use 'wx' flag to create file exclusively (fails if exists)
|
|
173
|
+
await writeFile(this.lockPath, JSON.stringify(lockInfo, null, 2), { flag: 'wx' });
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Reads and parses the lock file.
|
|
177
|
+
*/
|
|
178
|
+
async readLockFile() {
|
|
179
|
+
try {
|
|
180
|
+
const content = await readFile(this.lockPath, 'utf-8');
|
|
181
|
+
return JSON.parse(content);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Checks if a process with the given PID is currently running.
|
|
189
|
+
*/
|
|
190
|
+
async isProcessRunning(pid) {
|
|
191
|
+
try {
|
|
192
|
+
// On Unix systems, sending signal 0 checks if process exists without affecting it
|
|
193
|
+
process.kill(pid, 0);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
const nodeError = error;
|
|
198
|
+
// ESRCH means process doesn't exist
|
|
199
|
+
return nodeError.code !== 'ESRCH';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Gets the hostname for lock identification.
|
|
204
|
+
*/
|
|
205
|
+
getHostname() {
|
|
206
|
+
try {
|
|
207
|
+
return hostname();
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return 'unknown';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Simple sleep utility for polling delays.
|
|
215
|
+
*/
|
|
216
|
+
sleep(ms) {
|
|
217
|
+
return new Promise((resolve) => global.setTimeout(resolve, ms));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Convenience function to safely execute a build with automatic lock management.
|
|
222
|
+
*
|
|
223
|
+
* @param cacheDir - Path to the cache directory
|
|
224
|
+
* @param buildFn - Function to execute while holding the lock
|
|
225
|
+
* @param options - Lock options
|
|
226
|
+
* @returns Result of the build function
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```typescript
|
|
230
|
+
* const result = await withBuildLock('.stati', async () => {
|
|
231
|
+
* // Your build logic here
|
|
232
|
+
* return await performBuild();
|
|
233
|
+
* });
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
export async function withBuildLock(cacheDir, buildFn, options = {}) {
|
|
237
|
+
const lockManager = new BuildLockManager(cacheDir);
|
|
238
|
+
try {
|
|
239
|
+
await lockManager.acquireLock(options);
|
|
240
|
+
return await buildFn();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await lockManager.releaseLock();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PageModel, CacheEntry, StatiConfig } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Determines if a page should be rebuilt based on ISG logic.
|
|
4
|
+
* Checks content changes, dependency changes, TTL expiration, and freeze status.
|
|
5
|
+
* Handles edge cases like missing dates, corrupted cache entries, and dependency errors.
|
|
6
|
+
*
|
|
7
|
+
* @param page - The page model to check
|
|
8
|
+
* @param entry - Existing cache entry, or undefined if not cached
|
|
9
|
+
* @param config - Stati configuration
|
|
10
|
+
* @param now - Current date/time
|
|
11
|
+
* @returns True if the page should be rebuilt
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const shouldRebuild = await shouldRebuildPage(page, cacheEntry, config, new Date());
|
|
16
|
+
* if (shouldRebuild) {
|
|
17
|
+
* console.log('Page needs rebuilding');
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function shouldRebuildPage(page: PageModel, entry: CacheEntry | undefined, config: StatiConfig, now: Date): Promise<boolean>;
|
|
22
|
+
/**
|
|
23
|
+
* Creates a new cache entry for a page after it has been rendered.
|
|
24
|
+
*
|
|
25
|
+
* @param page - The page model
|
|
26
|
+
* @param config - Stati configuration
|
|
27
|
+
* @param renderedAt - When the page was rendered
|
|
28
|
+
* @returns New cache entry
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const entry = await createCacheEntry(page, config, new Date());
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function createCacheEntry(page: PageModel, config: StatiConfig, renderedAt: Date): Promise<CacheEntry>;
|
|
36
|
+
/**
|
|
37
|
+
* Updates an existing cache entry with new information after rebuilding.
|
|
38
|
+
*
|
|
39
|
+
* @param entry - Existing cache entry
|
|
40
|
+
* @param page - The page model
|
|
41
|
+
* @param config - Stati configuration
|
|
42
|
+
* @param renderedAt - When the page was rendered
|
|
43
|
+
* @returns Updated cache entry
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* const updatedEntry = await updateCacheEntry(existingEntry, page, config, new Date());
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export declare function updateCacheEntry(entry: CacheEntry, page: PageModel, config: StatiConfig, renderedAt: Date): Promise<CacheEntry>;
|
|
51
|
+
//# sourceMappingURL=builder.d.ts.map
|
|
@@ -0,0 +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,gBAAgB,CAAC;AAmEzE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,MAAM,EAAE,WAAW,EACnB,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,OAAO,CAAC,CAgKlB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,GACf,OAAO,CAAC,UAAU,CAAC,CA2ErB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,IAAI,GACf,OAAO,CAAC,UAAU,CAAC,CAUrB"}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { computeContentHash, computeFileHash, computeInputsHash } from './hash.js';
|
|
2
|
+
import { trackTemplateDependencies } from './deps.js';
|
|
3
|
+
import { computeEffectiveTTL, computeNextRebuildAt, isPageFrozen } from './ttl.js';
|
|
4
|
+
import { validatePageISGOverrides, extractNumericOverride } from './validation.js';
|
|
5
|
+
/**
|
|
6
|
+
* Determines the output path for a page.
|
|
7
|
+
*/
|
|
8
|
+
function getOutputPath(page) {
|
|
9
|
+
if (page.url === '/') {
|
|
10
|
+
return '/index.html';
|
|
11
|
+
}
|
|
12
|
+
else if (page.url.endsWith('/')) {
|
|
13
|
+
return `${page.url}index.html`;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
return `${page.url}.html`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validates a cache entry structure to ensure it has all required fields.
|
|
21
|
+
* Used to detect corrupted cache entries that should trigger a rebuild.
|
|
22
|
+
*/
|
|
23
|
+
function isValidCacheEntry(entry) {
|
|
24
|
+
if (!entry || typeof entry !== 'object') {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
// Check required string fields
|
|
28
|
+
const requiredStringFields = ['path', 'inputsHash', 'renderedAt'];
|
|
29
|
+
for (const field of requiredStringFields) {
|
|
30
|
+
if (typeof entry[field] !== 'string') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Check required number fields
|
|
35
|
+
if (typeof entry.ttlSeconds !== 'number' || !Number.isFinite(entry.ttlSeconds)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// Check required array fields
|
|
39
|
+
if (!Array.isArray(entry.deps) || !Array.isArray(entry.tags)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// Check that arrays contain only strings
|
|
43
|
+
if (!entry.deps.every((dep) => typeof dep === 'string')) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (!entry.tags.every((tag) => typeof tag === 'string')) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// Check optional fields
|
|
50
|
+
if (entry.publishedAt !== undefined && typeof entry.publishedAt !== 'string') {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (entry.maxAgeCapDays !== undefined && typeof entry.maxAgeCapDays !== 'number') {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Determines if a page should be rebuilt based on ISG logic.
|
|
60
|
+
* Checks content changes, dependency changes, TTL expiration, and freeze status.
|
|
61
|
+
* Handles edge cases like missing dates, corrupted cache entries, and dependency errors.
|
|
62
|
+
*
|
|
63
|
+
* @param page - The page model to check
|
|
64
|
+
* @param entry - Existing cache entry, or undefined if not cached
|
|
65
|
+
* @param config - Stati configuration
|
|
66
|
+
* @param now - Current date/time
|
|
67
|
+
* @returns True if the page should be rebuilt
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const shouldRebuild = await shouldRebuildPage(page, cacheEntry, config, new Date());
|
|
72
|
+
* if (shouldRebuild) {
|
|
73
|
+
* console.log('Page needs rebuilding');
|
|
74
|
+
* }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export async function shouldRebuildPage(page, entry, config, now) {
|
|
78
|
+
// Always rebuild if no cache entry exists
|
|
79
|
+
if (!entry) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
// Validate the cache entry structure
|
|
84
|
+
if (!isValidCacheEntry(entry)) {
|
|
85
|
+
console.warn(`Invalid cache entry for ${page.url}, forcing rebuild`);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
// Check if inputs (content + dependencies) have changed
|
|
89
|
+
const currentContentHash = computeContentHash(page.content, page.frontMatter);
|
|
90
|
+
// Track dependencies with error handling
|
|
91
|
+
let deps;
|
|
92
|
+
try {
|
|
93
|
+
deps = await trackTemplateDependencies(page, config);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof Error && error.name === 'CircularDependencyError') {
|
|
97
|
+
console.error(`Circular dependency detected for ${page.url}: ${error.message}`);
|
|
98
|
+
throw error; // Re-throw circular dependency errors as they're fatal
|
|
99
|
+
}
|
|
100
|
+
// For other dependency errors, log warning and assume dependencies changed
|
|
101
|
+
console.warn(`Failed to track dependencies for ${page.url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
console.warn('Assuming dependencies changed, forcing rebuild');
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
// Compute hashes for all dependencies with error handling
|
|
106
|
+
const depsHashes = [];
|
|
107
|
+
let dependencyErrors = 0;
|
|
108
|
+
for (const dep of deps) {
|
|
109
|
+
try {
|
|
110
|
+
const depHash = await computeFileHash(dep);
|
|
111
|
+
if (depHash) {
|
|
112
|
+
depsHashes.push(depHash);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
dependencyErrors++;
|
|
116
|
+
console.warn(`Missing dependency file: ${dep} (used by ${page.url})`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
dependencyErrors++;
|
|
121
|
+
console.warn(`Failed to hash dependency ${dep}: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// If we had dependency errors, force rebuild to be safe
|
|
125
|
+
if (dependencyErrors > 0) {
|
|
126
|
+
console.warn(`${dependencyErrors} dependency errors for ${page.url}, forcing rebuild`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
const currentInputsHash = computeInputsHash(currentContentHash, depsHashes);
|
|
130
|
+
// If inputs changed, always rebuild
|
|
131
|
+
if (currentInputsHash !== entry.inputsHash) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
// If page is frozen (beyond max age cap), don't rebuild based on TTL
|
|
135
|
+
if (isPageFrozen(entry, now)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// Check if TTL has expired with safe date handling
|
|
139
|
+
let renderedAt;
|
|
140
|
+
try {
|
|
141
|
+
renderedAt = new Date(entry.renderedAt);
|
|
142
|
+
if (isNaN(renderedAt.getTime())) {
|
|
143
|
+
console.warn(`Invalid renderedAt date in cache entry for ${page.url}, forcing rebuild`);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.warn(`Failed to parse renderedAt for ${page.url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
// Handle edge case: invalid TTL values
|
|
152
|
+
if (typeof entry.ttlSeconds !== 'number' ||
|
|
153
|
+
entry.ttlSeconds < 0 ||
|
|
154
|
+
!Number.isFinite(entry.ttlSeconds)) {
|
|
155
|
+
console.warn(`Invalid TTL value in cache entry for ${page.url}: ${entry.ttlSeconds}, forcing rebuild`);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const computeOptions = {
|
|
159
|
+
now: renderedAt,
|
|
160
|
+
ttlSeconds: entry.ttlSeconds,
|
|
161
|
+
};
|
|
162
|
+
// Handle publishedAt edge cases
|
|
163
|
+
if (entry.publishedAt) {
|
|
164
|
+
try {
|
|
165
|
+
const publishedDate = new Date(entry.publishedAt);
|
|
166
|
+
if (!isNaN(publishedDate.getTime())) {
|
|
167
|
+
computeOptions.publishedAt = publishedDate;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
console.warn(`Invalid publishedAt date in cache entry for ${page.url}, ignoring for TTL calculation`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
console.warn(`Failed to parse publishedAt for ${page.url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Handle maxAgeCapDays edge cases
|
|
178
|
+
if (entry.maxAgeCapDays !== undefined) {
|
|
179
|
+
if (typeof entry.maxAgeCapDays === 'number' &&
|
|
180
|
+
entry.maxAgeCapDays > 0 &&
|
|
181
|
+
Number.isFinite(entry.maxAgeCapDays)) {
|
|
182
|
+
computeOptions.maxAgeCapDays = entry.maxAgeCapDays;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
console.warn(`Invalid maxAgeCapDays in cache entry for ${page.url}: ${entry.maxAgeCapDays}, ignoring`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const nextRebuildAt = computeNextRebuildAt(computeOptions);
|
|
189
|
+
// If no next rebuild time (frozen), don't rebuild
|
|
190
|
+
if (!nextRebuildAt) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
// Rebuild if TTL has expired
|
|
194
|
+
return now >= nextRebuildAt;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
// For any unexpected errors, log and force rebuild to be safe
|
|
198
|
+
console.warn(`Error checking rebuild status for ${page.url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
|
+
console.warn('Forcing rebuild due to error');
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Creates a new cache entry for a page after it has been rendered.
|
|
205
|
+
*
|
|
206
|
+
* @param page - The page model
|
|
207
|
+
* @param config - Stati configuration
|
|
208
|
+
* @param renderedAt - When the page was rendered
|
|
209
|
+
* @returns New cache entry
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```typescript
|
|
213
|
+
* const entry = await createCacheEntry(page, config, new Date());
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
export async function createCacheEntry(page, config, renderedAt) {
|
|
217
|
+
// Validate page-level ISG overrides first
|
|
218
|
+
validatePageISGOverrides(page.frontMatter, page.sourcePath);
|
|
219
|
+
// Compute content hash
|
|
220
|
+
const contentHash = computeContentHash(page.content, page.frontMatter);
|
|
221
|
+
// Track all template dependencies
|
|
222
|
+
const deps = await trackTemplateDependencies(page, config);
|
|
223
|
+
// Compute hashes for all dependencies
|
|
224
|
+
const depsHashes = [];
|
|
225
|
+
for (const dep of deps) {
|
|
226
|
+
const depHash = await computeFileHash(dep);
|
|
227
|
+
if (depHash) {
|
|
228
|
+
depsHashes.push(depHash);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const inputsHash = computeInputsHash(contentHash, depsHashes);
|
|
232
|
+
// Extract tags from front matter
|
|
233
|
+
let tags = [];
|
|
234
|
+
if (Array.isArray(page.frontMatter.tags)) {
|
|
235
|
+
tags = page.frontMatter.tags.filter((tag) => typeof tag === 'string');
|
|
236
|
+
}
|
|
237
|
+
// Get published date
|
|
238
|
+
const publishedAt = getPublishedDateISO(page);
|
|
239
|
+
// Compute effective TTL
|
|
240
|
+
const isgConfig = config.isg || {};
|
|
241
|
+
const ttlSeconds = computeEffectiveTTL(page, isgConfig);
|
|
242
|
+
// Get max age cap from front matter or config using safe extraction
|
|
243
|
+
let maxAgeCapDays = isgConfig.maxAgeCapDays;
|
|
244
|
+
try {
|
|
245
|
+
const frontMatterMaxAge = extractNumericOverride(page.frontMatter.maxAgeCapDays, 'maxAgeCapDays', page.sourcePath);
|
|
246
|
+
if (frontMatterMaxAge !== undefined) {
|
|
247
|
+
maxAgeCapDays = frontMatterMaxAge;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
// Log validation error but continue with default value
|
|
252
|
+
console.warn(`ISG validation warning for ${page.sourcePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
253
|
+
}
|
|
254
|
+
// Determine output path
|
|
255
|
+
const outputPath = getOutputPath(page);
|
|
256
|
+
// Build cache entry with proper optional handling
|
|
257
|
+
const cacheEntry = {
|
|
258
|
+
path: outputPath,
|
|
259
|
+
inputsHash,
|
|
260
|
+
deps,
|
|
261
|
+
tags,
|
|
262
|
+
renderedAt: renderedAt.toISOString(),
|
|
263
|
+
ttlSeconds,
|
|
264
|
+
};
|
|
265
|
+
// Add optional fields only if they exist
|
|
266
|
+
if (publishedAt) {
|
|
267
|
+
cacheEntry.publishedAt = publishedAt;
|
|
268
|
+
}
|
|
269
|
+
if (maxAgeCapDays !== undefined) {
|
|
270
|
+
cacheEntry.maxAgeCapDays = maxAgeCapDays;
|
|
271
|
+
}
|
|
272
|
+
return cacheEntry;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Updates an existing cache entry with new information after rebuilding.
|
|
276
|
+
*
|
|
277
|
+
* @param entry - Existing cache entry
|
|
278
|
+
* @param page - The page model
|
|
279
|
+
* @param config - Stati configuration
|
|
280
|
+
* @param renderedAt - When the page was rendered
|
|
281
|
+
* @returns Updated cache entry
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* ```typescript
|
|
285
|
+
* const updatedEntry = await updateCacheEntry(existingEntry, page, config, new Date());
|
|
286
|
+
* ```
|
|
287
|
+
*/
|
|
288
|
+
export async function updateCacheEntry(entry, page, config, renderedAt) {
|
|
289
|
+
// Create a new entry and preserve the original publishedAt if not overridden
|
|
290
|
+
const newEntry = await createCacheEntry(page, config, renderedAt);
|
|
291
|
+
// Preserve original publishedAt if no new one is specified
|
|
292
|
+
if (!newEntry.publishedAt && entry.publishedAt) {
|
|
293
|
+
newEntry.publishedAt = entry.publishedAt;
|
|
294
|
+
}
|
|
295
|
+
return newEntry;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Helper function to get published date as ISO string from page front matter.
|
|
299
|
+
*/
|
|
300
|
+
function getPublishedDateISO(page) {
|
|
301
|
+
const frontMatter = page.frontMatter;
|
|
302
|
+
// Try common field names for published date
|
|
303
|
+
const dateFields = ['publishedAt', 'published', 'date', 'createdAt'];
|
|
304
|
+
for (const field of dateFields) {
|
|
305
|
+
const value = frontMatter[field];
|
|
306
|
+
if (value) {
|
|
307
|
+
// Handle string dates
|
|
308
|
+
if (typeof value === 'string') {
|
|
309
|
+
const date = new Date(value);
|
|
310
|
+
if (!isNaN(date.getTime())) {
|
|
311
|
+
return date.toISOString();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Handle Date objects
|
|
315
|
+
if (value instanceof Date && !isNaN(value.getTime())) {
|
|
316
|
+
return value.toISOString();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { PageModel, StatiConfig } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Error thrown when a circular dependency is detected in templates.
|
|
4
|
+
*/
|
|
5
|
+
export declare class CircularDependencyError extends Error {
|
|
6
|
+
readonly dependencyChain: string[];
|
|
7
|
+
constructor(dependencyChain: string[], message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Tracks all template dependencies for a given page.
|
|
11
|
+
* This includes the layout file and all accessible partials.
|
|
12
|
+
* Includes circular dependency detection.
|
|
13
|
+
*
|
|
14
|
+
* @param page - The page model to track dependencies for
|
|
15
|
+
* @param config - Stati configuration
|
|
16
|
+
* @returns Array of absolute paths to dependency files
|
|
17
|
+
* @throws {CircularDependencyError} When circular dependencies are detected
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* try {
|
|
22
|
+
* const deps = await trackTemplateDependencies(page, config);
|
|
23
|
+
* console.log(`Page depends on ${deps.length} template files`);
|
|
24
|
+
* } catch (error) {
|
|
25
|
+
* if (error instanceof CircularDependencyError) {
|
|
26
|
+
* console.error(`Circular dependency: ${error.dependencyChain.join(' -> ')}`);
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function trackTemplateDependencies(page: PageModel, config: StatiConfig): Promise<string[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Finds all partial dependencies for a given page path.
|
|
34
|
+
* Searches up the directory hierarchy for _* folders containing .eta files.
|
|
35
|
+
*
|
|
36
|
+
* @param pagePath - Relative path to the page from srcDir
|
|
37
|
+
* @param config - Stati configuration
|
|
38
|
+
* @returns Array of absolute paths to partial files
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const partials = await findPartialDependencies('blog/post.md', config);
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function findPartialDependencies(pagePath: string, config: StatiConfig): Promise<string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Resolves a template name to its file path.
|
|
48
|
+
* Used for explicit layout specifications in front matter.
|
|
49
|
+
*
|
|
50
|
+
* @param layout - Layout name (without .eta extension)
|
|
51
|
+
* @param config - Stati configuration
|
|
52
|
+
* @returns Absolute path to template file, or null if not found
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* const templatePath = await resolveTemplatePath('post', config);
|
|
57
|
+
* if (templatePath) {
|
|
58
|
+
* console.log(`Found template at: ${templatePath}`);
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveTemplatePath(layout: string, config: StatiConfig): Promise<string | null>;
|
|
63
|
+
//# sourceMappingURL=deps.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deps.d.ts","sourceRoot":"","sources":["../../../src/core/isg/deps.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE7D;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;aAE9B,eAAe,EAAE,MAAM,EAAE;gBAAzB,eAAe,EAAE,MAAM,EAAE,EACzC,OAAO,EAAE,MAAM;CAKlB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,EAAE,CAAC,CAoCnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,EAAE,CAAC,CAiDnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CASxB"}
|