@stati/core 1.0.0 → 1.2.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 +217 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +24 -2
- package/dist/core/build.d.ts +9 -2
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +200 -46
- package/dist/core/dev.d.ts +21 -0
- package/dist/core/dev.d.ts.map +1 -0
- package/dist/core/dev.js +371 -0
- 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 +243 -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 +23 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +172 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import fse from 'fs-extra';
|
|
2
|
+
const { readFile, writeFile, pathExists, ensureDir } = fse;
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
/**
|
|
5
|
+
* Path to the cache manifest file within the cache directory
|
|
6
|
+
*/
|
|
7
|
+
const MANIFEST_FILENAME = 'manifest.json';
|
|
8
|
+
/**
|
|
9
|
+
* Loads the ISG cache manifest from the cache directory.
|
|
10
|
+
* Returns null if no manifest exists or if it's corrupted.
|
|
11
|
+
* Provides detailed error reporting for different failure scenarios.
|
|
12
|
+
*
|
|
13
|
+
* @param cacheDir - Path to the .stati cache directory
|
|
14
|
+
* @returns Promise resolving to the cache manifest or null
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const manifest = await loadCacheManifest('.stati');
|
|
19
|
+
* if (manifest) {
|
|
20
|
+
* console.log(`Found ${Object.keys(manifest.entries).length} cached entries`);
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export async function loadCacheManifest(cacheDir) {
|
|
25
|
+
const manifestPath = join(cacheDir, MANIFEST_FILENAME);
|
|
26
|
+
try {
|
|
27
|
+
if (!(await pathExists(manifestPath))) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const manifestContent = await readFile(manifestPath, 'utf-8');
|
|
31
|
+
// Handle empty files
|
|
32
|
+
if (!manifestContent.trim()) {
|
|
33
|
+
console.warn('Cache manifest is empty, creating new cache');
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
let manifest;
|
|
37
|
+
try {
|
|
38
|
+
manifest = JSON.parse(manifestContent);
|
|
39
|
+
}
|
|
40
|
+
catch (parseError) {
|
|
41
|
+
console.warn(`Cache manifest contains invalid JSON, ignoring existing cache. ` +
|
|
42
|
+
`Error: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Detailed validation of manifest structure
|
|
46
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
47
|
+
console.warn('Cache manifest is not an object, ignoring existing cache');
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const manifestObj = manifest;
|
|
51
|
+
if (!manifestObj.entries) {
|
|
52
|
+
console.warn('Cache manifest missing "entries" field, ignoring existing cache');
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (typeof manifestObj.entries !== 'object' || Array.isArray(manifestObj.entries)) {
|
|
56
|
+
console.warn('Cache manifest "entries" field is not an object, ignoring existing cache');
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Validate individual cache entries
|
|
60
|
+
const entries = manifestObj.entries;
|
|
61
|
+
const validatedEntries = {};
|
|
62
|
+
let invalidEntryCount = 0;
|
|
63
|
+
for (const [path, entry] of Object.entries(entries)) {
|
|
64
|
+
if (validateCacheEntry(entry, path)) {
|
|
65
|
+
validatedEntries[path] = entry;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
invalidEntryCount++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (invalidEntryCount > 0) {
|
|
72
|
+
console.warn(`Removed ${invalidEntryCount} invalid cache entries`);
|
|
73
|
+
}
|
|
74
|
+
return { entries: validatedEntries };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const nodeError = error;
|
|
78
|
+
if (nodeError.code === 'ENOENT') {
|
|
79
|
+
return null; // File doesn't exist, this is normal
|
|
80
|
+
}
|
|
81
|
+
if (nodeError.code === 'EACCES') {
|
|
82
|
+
console.error(`Permission denied reading cache manifest at ${manifestPath}. ` +
|
|
83
|
+
`Please check file permissions or run with appropriate privileges.`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (nodeError.code === 'EMFILE' || nodeError.code === 'ENFILE') {
|
|
87
|
+
console.error(`Too many open files when reading cache manifest. ` +
|
|
88
|
+
`Consider reducing concurrent operations or increasing file descriptor limits.`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
console.warn(`Failed to load cache manifest: ${error instanceof Error ? error.message : String(error)}. ` +
|
|
92
|
+
`Starting with fresh cache.`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Saves the ISG cache manifest to the cache directory.
|
|
98
|
+
* Creates the cache directory if it doesn't exist.
|
|
99
|
+
* Provides detailed error handling for common file system issues.
|
|
100
|
+
*
|
|
101
|
+
* @param cacheDir - Path to the .stati cache directory
|
|
102
|
+
* @param manifest - The cache manifest to save
|
|
103
|
+
* @throws {Error} If the manifest cannot be saved
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const manifest: CacheManifest = { entries: {} };
|
|
108
|
+
* await saveCacheManifest('.stati', manifest);
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export async function saveCacheManifest(cacheDir, manifest) {
|
|
112
|
+
const manifestPath = join(cacheDir, MANIFEST_FILENAME);
|
|
113
|
+
try {
|
|
114
|
+
// Ensure cache directory exists
|
|
115
|
+
await ensureDir(cacheDir);
|
|
116
|
+
// Save manifest with pretty formatting for debugging
|
|
117
|
+
const manifestContent = JSON.stringify(manifest, null, 2);
|
|
118
|
+
await writeFile(manifestPath, manifestContent, 'utf-8');
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const nodeError = error;
|
|
122
|
+
if (nodeError.code === 'EACCES') {
|
|
123
|
+
throw new Error(`Permission denied saving cache manifest to ${manifestPath}. ` +
|
|
124
|
+
`Please check directory permissions or run with appropriate privileges.`);
|
|
125
|
+
}
|
|
126
|
+
if (nodeError.code === 'ENOSPC') {
|
|
127
|
+
throw new Error(`No space left on device when saving cache manifest to ${manifestPath}. ` +
|
|
128
|
+
`Please free up disk space and try again.`);
|
|
129
|
+
}
|
|
130
|
+
if (nodeError.code === 'EMFILE' || nodeError.code === 'ENFILE') {
|
|
131
|
+
throw new Error(`Too many open files when saving cache manifest. ` +
|
|
132
|
+
`Consider reducing concurrent operations or increasing file descriptor limits.`);
|
|
133
|
+
}
|
|
134
|
+
if (nodeError.code === 'ENOTDIR') {
|
|
135
|
+
throw new Error(`Cache directory path ${cacheDir} is not a directory. ` +
|
|
136
|
+
`Please remove the conflicting file and try again.`);
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Failed to save cache manifest to ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Creates an empty cache manifest with no entries.
|
|
143
|
+
*
|
|
144
|
+
* @returns A new empty cache manifest
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```typescript
|
|
148
|
+
* const manifest = createEmptyManifest();
|
|
149
|
+
* console.log(Object.keys(manifest.entries).length); // 0
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function createEmptyManifest() {
|
|
153
|
+
return {
|
|
154
|
+
entries: {},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Validates a cache entry object to ensure it has the required structure.
|
|
159
|
+
* Used when loading cache manifest to filter out corrupted entries.
|
|
160
|
+
*
|
|
161
|
+
* @param entry - The cache entry to validate
|
|
162
|
+
* @param path - The path key for error context
|
|
163
|
+
* @returns True if the entry is valid, false otherwise
|
|
164
|
+
*/
|
|
165
|
+
function validateCacheEntry(entry, path) {
|
|
166
|
+
if (!entry || typeof entry !== 'object') {
|
|
167
|
+
console.warn(`Invalid cache entry for ${path}: not an object`);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
const entryObj = entry;
|
|
171
|
+
// Check required fields
|
|
172
|
+
const requiredFields = ['path', 'inputsHash', 'deps', 'tags', 'renderedAt', 'ttlSeconds'];
|
|
173
|
+
for (const field of requiredFields) {
|
|
174
|
+
if (!(field in entryObj)) {
|
|
175
|
+
console.warn(`Invalid cache entry for ${path}: missing required field "${field}"`);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Validate field types
|
|
180
|
+
if (typeof entryObj.path !== 'string') {
|
|
181
|
+
console.warn(`Invalid cache entry for ${path}: "path" must be a string`);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (typeof entryObj.inputsHash !== 'string') {
|
|
185
|
+
console.warn(`Invalid cache entry for ${path}: "inputsHash" must be a string`);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (!Array.isArray(entryObj.deps)) {
|
|
189
|
+
console.warn(`Invalid cache entry for ${path}: "deps" must be an array`);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (!Array.isArray(entryObj.tags)) {
|
|
193
|
+
console.warn(`Invalid cache entry for ${path}: "tags" must be an array`);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
if (typeof entryObj.renderedAt !== 'string') {
|
|
197
|
+
console.warn(`Invalid cache entry for ${path}: "renderedAt" must be a string`);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
if (typeof entryObj.ttlSeconds !== 'number') {
|
|
201
|
+
console.warn(`Invalid cache entry for ${path}: "ttlSeconds" must be a number`);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
// Validate optional fields if present
|
|
205
|
+
if (entryObj.publishedAt !== undefined && typeof entryObj.publishedAt !== 'string') {
|
|
206
|
+
console.warn(`Invalid cache entry for ${path}: "publishedAt" must be a string if present`);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (entryObj.maxAgeCapDays !== undefined && typeof entryObj.maxAgeCapDays !== 'number') {
|
|
210
|
+
console.warn(`Invalid cache entry for ${path}: "maxAgeCapDays" must be a number if present`);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
// Validate that deps and tags arrays contain strings
|
|
214
|
+
if (!entryObj.deps.every((dep) => typeof dep === 'string')) {
|
|
215
|
+
console.warn(`Invalid cache entry for ${path}: all "deps" must be strings`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (!entryObj.tags.every((tag) => typeof tag === 'string')) {
|
|
219
|
+
console.warn(`Invalid cache entry for ${path}: all "tags" must be strings`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
// Validate date format for renderedAt
|
|
223
|
+
if (isNaN(new Date(entryObj.renderedAt).getTime())) {
|
|
224
|
+
console.warn(`Invalid cache entry for ${path}: "renderedAt" is not a valid date`);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
// Validate date format for publishedAt if present
|
|
228
|
+
if (entryObj.publishedAt && isNaN(new Date(entryObj.publishedAt).getTime())) {
|
|
229
|
+
console.warn(`Invalid cache entry for ${path}: "publishedAt" is not a valid date`);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { PageModel, ISGConfig, AgingRule, CacheEntry } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Safely gets the current UTC time with drift protection.
|
|
4
|
+
* Ensures all ISG operations use consistent UTC time.
|
|
5
|
+
*
|
|
6
|
+
* @param providedDate - Optional date to use instead of system time (for testing)
|
|
7
|
+
* @returns UTC Date object
|
|
8
|
+
*/
|
|
9
|
+
export declare function getSafeCurrentTime(providedDate?: Date): Date;
|
|
10
|
+
/**
|
|
11
|
+
* Safely parses and normalizes a date string to UTC.
|
|
12
|
+
* Handles various date formats and timezone issues.
|
|
13
|
+
*
|
|
14
|
+
* @param dateStr - Date string to parse
|
|
15
|
+
* @param context - Context for error messages
|
|
16
|
+
* @returns Parsed UTC date or null if invalid
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseSafeDate(dateStr: string, context?: string): Date | null;
|
|
19
|
+
/**
|
|
20
|
+
* Computes the effective TTL for a page based on configuration and page-specific overrides.
|
|
21
|
+
* Takes into account aging rules and front-matter overrides.
|
|
22
|
+
* Uses safe time handling to avoid timezone issues.
|
|
23
|
+
*
|
|
24
|
+
* @param page - The page model
|
|
25
|
+
* @param isgConfig - ISG configuration
|
|
26
|
+
* @param currentTime - Optional current time (for testing)
|
|
27
|
+
* @returns Effective TTL in seconds
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const ttl = computeEffectiveTTL(page, config.isg);
|
|
32
|
+
* console.log(`Page will be cached for ${ttl} seconds`);
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function computeEffectiveTTL(page: PageModel, isgConfig: ISGConfig, currentTime?: Date): number;
|
|
36
|
+
/**
|
|
37
|
+
* Computes the next rebuild date for a page based on TTL and aging rules.
|
|
38
|
+
* Returns null if the page is frozen (beyond max age cap).
|
|
39
|
+
* Uses safe time handling with clock drift protection.
|
|
40
|
+
*
|
|
41
|
+
* @param options - Configuration options
|
|
42
|
+
* @param options.now - Current date/time
|
|
43
|
+
* @param options.publishedAt - When the content was originally published
|
|
44
|
+
* @param options.ttlSeconds - TTL for this page in seconds
|
|
45
|
+
* @param options.maxAgeCapDays - Maximum age cap in days
|
|
46
|
+
* @returns Next rebuild date, or null if frozen
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const nextRebuild = computeNextRebuildAt({
|
|
51
|
+
* now: new Date(),
|
|
52
|
+
* publishedAt: new Date('2024-01-01'),
|
|
53
|
+
* ttlSeconds: 3600,
|
|
54
|
+
* maxAgeCapDays: 365
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function computeNextRebuildAt(options: {
|
|
59
|
+
now: Date;
|
|
60
|
+
publishedAt?: Date;
|
|
61
|
+
ttlSeconds: number;
|
|
62
|
+
maxAgeCapDays?: number;
|
|
63
|
+
}): Date | null;
|
|
64
|
+
/**
|
|
65
|
+
* Determines if a page is frozen (beyond its max age cap).
|
|
66
|
+
* Frozen pages are not rebuilt unless their content changes.
|
|
67
|
+
* Uses safe time handling to avoid timezone issues.
|
|
68
|
+
*
|
|
69
|
+
* @param entry - Cache entry for the page
|
|
70
|
+
* @param now - Current date/time
|
|
71
|
+
* @returns True if the page is frozen
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* if (isPageFrozen(cacheEntry, new Date())) {
|
|
76
|
+
* console.log('Page is frozen, will not rebuild based on TTL');
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export declare function isPageFrozen(entry: CacheEntry, now: Date): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Applies aging rules to determine the appropriate TTL for content based on its age.
|
|
83
|
+
* Aging rules allow older content to be cached longer.
|
|
84
|
+
*
|
|
85
|
+
* @param publishedAt - When the content was originally published
|
|
86
|
+
* @param agingRules - Array of aging rules (sorted by untilDays ascending)
|
|
87
|
+
* @param defaultTTL - Default TTL to use if no rules match
|
|
88
|
+
* @param now - Current date/time
|
|
89
|
+
* @returns Appropriate TTL in seconds
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* const rules = [
|
|
94
|
+
* { untilDays: 7, ttlSeconds: 3600 }, // 1 hour for week-old content
|
|
95
|
+
* { untilDays: 30, ttlSeconds: 86400 } // 1 day for month-old content
|
|
96
|
+
* ];
|
|
97
|
+
* const ttl = applyAgingRules(publishedAt, rules, 1800, new Date());
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export declare function applyAgingRules(publishedAt: Date, agingRules: AgingRule[], defaultTTL: number, now: Date): number;
|
|
101
|
+
//# sourceMappingURL=ttl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ttl.d.ts","sourceRoot":"","sources":["../../../src/core/isg/ttl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAQlF;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,YAAY,CAAC,EAAE,IAAI,GAAG,IAAI,CAW5D;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAqB5E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,SAAS,EACf,SAAS,EAAE,SAAS,EACpB,WAAW,CAAC,EAAE,IAAI,GACjB,MAAM,CAkBR;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAC5C,GAAG,EAAE,IAAI,CAAC;IACV,WAAW,CAAC,EAAE,IAAI,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GAAG,IAAI,GAAG,IAAI,CAqBd;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAiBlE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAC7B,WAAW,EAAE,IAAI,EACjB,UAAU,EAAE,SAAS,EAAE,EACvB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,IAAI,GACR,MAAM,CAyBR"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clock drift tolerance in milliseconds.
|
|
3
|
+
* Accounts for small differences between system clocks.
|
|
4
|
+
*/
|
|
5
|
+
const CLOCK_DRIFT_TOLERANCE_MS = 30000; // 30 seconds
|
|
6
|
+
/**
|
|
7
|
+
* Safely gets the current UTC time with drift protection.
|
|
8
|
+
* Ensures all ISG operations use consistent UTC time.
|
|
9
|
+
*
|
|
10
|
+
* @param providedDate - Optional date to use instead of system time (for testing)
|
|
11
|
+
* @returns UTC Date object
|
|
12
|
+
*/
|
|
13
|
+
export function getSafeCurrentTime(providedDate) {
|
|
14
|
+
const now = providedDate || new Date();
|
|
15
|
+
// Ensure we're working with valid dates
|
|
16
|
+
if (isNaN(now.getTime())) {
|
|
17
|
+
console.warn('Invalid system date detected, using fallback date');
|
|
18
|
+
return new Date('2025-01-01T00:00:00.000Z'); // Fallback to known good date
|
|
19
|
+
}
|
|
20
|
+
// Normalize to UTC to avoid timezone issues
|
|
21
|
+
return new Date(now.toISOString());
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Safely parses and normalizes a date string to UTC.
|
|
25
|
+
* Handles various date formats and timezone issues.
|
|
26
|
+
*
|
|
27
|
+
* @param dateStr - Date string to parse
|
|
28
|
+
* @param context - Context for error messages
|
|
29
|
+
* @returns Parsed UTC date or null if invalid
|
|
30
|
+
*/
|
|
31
|
+
export function parseSafeDate(dateStr, context) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = new Date(dateStr);
|
|
34
|
+
if (isNaN(parsed.getTime())) {
|
|
35
|
+
if (context) {
|
|
36
|
+
console.warn(`Invalid date "${dateStr}" in ${context}, ignoring`);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// Normalize to UTC
|
|
41
|
+
return new Date(parsed.toISOString());
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (context) {
|
|
45
|
+
console.warn(`Failed to parse date "${dateStr}" in ${context}: ${error instanceof Error ? error.message : String(error)}`);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Computes the effective TTL for a page based on configuration and page-specific overrides.
|
|
52
|
+
* Takes into account aging rules and front-matter overrides.
|
|
53
|
+
* Uses safe time handling to avoid timezone issues.
|
|
54
|
+
*
|
|
55
|
+
* @param page - The page model
|
|
56
|
+
* @param isgConfig - ISG configuration
|
|
57
|
+
* @param currentTime - Optional current time (for testing)
|
|
58
|
+
* @returns Effective TTL in seconds
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const ttl = computeEffectiveTTL(page, config.isg);
|
|
63
|
+
* console.log(`Page will be cached for ${ttl} seconds`);
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function computeEffectiveTTL(page, isgConfig, currentTime) {
|
|
67
|
+
// Check for page-specific TTL override in front matter
|
|
68
|
+
if (typeof page.frontMatter.ttlSeconds === 'number' && page.frontMatter.ttlSeconds > 0) {
|
|
69
|
+
return page.frontMatter.ttlSeconds;
|
|
70
|
+
}
|
|
71
|
+
// Get publishedAt date for aging calculations
|
|
72
|
+
const publishedAt = getPublishedDate(page);
|
|
73
|
+
const now = getSafeCurrentTime(currentTime);
|
|
74
|
+
// Apply aging rules if we have a published date and aging rules configured
|
|
75
|
+
if (publishedAt && isgConfig.aging && isgConfig.aging.length > 0) {
|
|
76
|
+
const defaultTTL = isgConfig.ttlSeconds ?? 21600; // 6 hours default
|
|
77
|
+
return applyAgingRules(publishedAt, isgConfig.aging, defaultTTL, now);
|
|
78
|
+
}
|
|
79
|
+
// Fall back to default TTL
|
|
80
|
+
return isgConfig.ttlSeconds ?? 21600; // 6 hours default
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Computes the next rebuild date for a page based on TTL and aging rules.
|
|
84
|
+
* Returns null if the page is frozen (beyond max age cap).
|
|
85
|
+
* Uses safe time handling with clock drift protection.
|
|
86
|
+
*
|
|
87
|
+
* @param options - Configuration options
|
|
88
|
+
* @param options.now - Current date/time
|
|
89
|
+
* @param options.publishedAt - When the content was originally published
|
|
90
|
+
* @param options.ttlSeconds - TTL for this page in seconds
|
|
91
|
+
* @param options.maxAgeCapDays - Maximum age cap in days
|
|
92
|
+
* @returns Next rebuild date, or null if frozen
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* const nextRebuild = computeNextRebuildAt({
|
|
97
|
+
* now: new Date(),
|
|
98
|
+
* publishedAt: new Date('2024-01-01'),
|
|
99
|
+
* ttlSeconds: 3600,
|
|
100
|
+
* maxAgeCapDays: 365
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function computeNextRebuildAt(options) {
|
|
105
|
+
const { publishedAt, ttlSeconds, maxAgeCapDays } = options;
|
|
106
|
+
// Normalize the provided time to UTC
|
|
107
|
+
const now = getSafeCurrentTime(options.now);
|
|
108
|
+
// If there's a max age cap and published date, check if content is frozen
|
|
109
|
+
if (maxAgeCapDays && publishedAt) {
|
|
110
|
+
const normalizedPublishedAt = getSafeCurrentTime(publishedAt);
|
|
111
|
+
const maxAgeMs = maxAgeCapDays * 24 * 60 * 60 * 1000;
|
|
112
|
+
const ageMs = now.getTime() - normalizedPublishedAt.getTime();
|
|
113
|
+
if (ageMs > maxAgeMs) {
|
|
114
|
+
// Content is frozen, no rebuild needed
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Add clock drift tolerance to prevent rebuild loops
|
|
119
|
+
const nextRebuildTime = now.getTime() + ttlSeconds * 1000 + CLOCK_DRIFT_TOLERANCE_MS;
|
|
120
|
+
return new Date(nextRebuildTime);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Determines if a page is frozen (beyond its max age cap).
|
|
124
|
+
* Frozen pages are not rebuilt unless their content changes.
|
|
125
|
+
* Uses safe time handling to avoid timezone issues.
|
|
126
|
+
*
|
|
127
|
+
* @param entry - Cache entry for the page
|
|
128
|
+
* @param now - Current date/time
|
|
129
|
+
* @returns True if the page is frozen
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```typescript
|
|
133
|
+
* if (isPageFrozen(cacheEntry, new Date())) {
|
|
134
|
+
* console.log('Page is frozen, will not rebuild based on TTL');
|
|
135
|
+
* }
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function isPageFrozen(entry, now) {
|
|
139
|
+
if (!entry.maxAgeCapDays || !entry.publishedAt) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const safeNow = getSafeCurrentTime(now);
|
|
143
|
+
const publishedAt = parseSafeDate(entry.publishedAt, `cache entry for ${entry.path}`);
|
|
144
|
+
if (!publishedAt) {
|
|
145
|
+
// If we can't parse the published date, don't freeze
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const maxAgeMs = entry.maxAgeCapDays * 24 * 60 * 60 * 1000;
|
|
149
|
+
const ageMs = safeNow.getTime() - publishedAt.getTime();
|
|
150
|
+
return ageMs > maxAgeMs;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Applies aging rules to determine the appropriate TTL for content based on its age.
|
|
154
|
+
* Aging rules allow older content to be cached longer.
|
|
155
|
+
*
|
|
156
|
+
* @param publishedAt - When the content was originally published
|
|
157
|
+
* @param agingRules - Array of aging rules (sorted by untilDays ascending)
|
|
158
|
+
* @param defaultTTL - Default TTL to use if no rules match
|
|
159
|
+
* @param now - Current date/time
|
|
160
|
+
* @returns Appropriate TTL in seconds
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* const rules = [
|
|
165
|
+
* { untilDays: 7, ttlSeconds: 3600 }, // 1 hour for week-old content
|
|
166
|
+
* { untilDays: 30, ttlSeconds: 86400 } // 1 day for month-old content
|
|
167
|
+
* ];
|
|
168
|
+
* const ttl = applyAgingRules(publishedAt, rules, 1800, new Date());
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function applyAgingRules(publishedAt, agingRules, defaultTTL, now) {
|
|
172
|
+
const ageMs = now.getTime() - publishedAt.getTime();
|
|
173
|
+
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
|
174
|
+
// Sort rules by untilDays in ascending order to apply the most specific rule
|
|
175
|
+
const sortedRules = [...agingRules].sort((a, b) => a.untilDays - b.untilDays);
|
|
176
|
+
// Find the most specific rule that applies
|
|
177
|
+
let applicableRule = null;
|
|
178
|
+
for (const rule of sortedRules) {
|
|
179
|
+
if (ageDays <= rule.untilDays) {
|
|
180
|
+
applicableRule = rule;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// If no rule applies, use the last (highest untilDays) rule if age exceeds all rules
|
|
185
|
+
if (!applicableRule && sortedRules.length > 0) {
|
|
186
|
+
const lastRule = sortedRules[sortedRules.length - 1];
|
|
187
|
+
if (lastRule && ageDays > lastRule.untilDays) {
|
|
188
|
+
applicableRule = lastRule;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return applicableRule ? applicableRule.ttlSeconds : defaultTTL;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Helper function to extract published date from page front matter.
|
|
195
|
+
* Supports various date formats and field names.
|
|
196
|
+
* Uses safe date parsing to handle timezone issues.
|
|
197
|
+
*/
|
|
198
|
+
function getPublishedDate(page) {
|
|
199
|
+
const frontMatter = page.frontMatter;
|
|
200
|
+
// Try common field names for published date
|
|
201
|
+
const dateFields = ['publishedAt', 'published', 'date', 'createdAt'];
|
|
202
|
+
for (const field of dateFields) {
|
|
203
|
+
const value = frontMatter[field];
|
|
204
|
+
if (value) {
|
|
205
|
+
// Handle string dates
|
|
206
|
+
if (typeof value === 'string') {
|
|
207
|
+
const safeDate = parseSafeDate(value, `front-matter field "${field}" in ${page.sourcePath}`);
|
|
208
|
+
if (safeDate) {
|
|
209
|
+
return safeDate;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Handle Date objects
|
|
213
|
+
if (value instanceof Date) {
|
|
214
|
+
const safeDate = getSafeCurrentTime(value);
|
|
215
|
+
if (safeDate && !isNaN(safeDate.getTime())) {
|
|
216
|
+
return safeDate;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ISGConfig } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Error codes for ISG validation failures.
|
|
4
|
+
* These provide structured error identification for better debugging.
|
|
5
|
+
*/
|
|
6
|
+
export declare enum ISGValidationError {
|
|
7
|
+
INVALID_TTL = "ISG_INVALID_TTL",
|
|
8
|
+
INVALID_MAX_AGE_CAP = "ISG_INVALID_MAX_AGE_CAP",
|
|
9
|
+
INVALID_AGING_RULE = "ISG_INVALID_AGING_RULE",
|
|
10
|
+
DUPLICATE_AGING_RULE = "ISG_DUPLICATE_AGING_RULE",
|
|
11
|
+
UNSORTED_AGING_RULES = "ISG_UNSORTED_AGING_RULES",
|
|
12
|
+
AGING_RULE_EXCEEDS_CAP = "ISG_AGING_RULE_EXCEEDS_CAP"
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Represents a validation error with context and actionable message.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ISGConfigurationError extends Error {
|
|
18
|
+
readonly code: ISGValidationError;
|
|
19
|
+
readonly field: string;
|
|
20
|
+
readonly value: unknown;
|
|
21
|
+
constructor(code: ISGValidationError, field: string, value: unknown, message: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validates ISG configuration and provides actionable error messages.
|
|
25
|
+
* Throws ISGConfigurationError for invalid configurations.
|
|
26
|
+
*
|
|
27
|
+
* @param config - ISG configuration to validate
|
|
28
|
+
* @throws {ISGConfigurationError} When configuration is invalid
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* try {
|
|
33
|
+
* validateISGConfig(userConfig.isg);
|
|
34
|
+
* } catch (error) {
|
|
35
|
+
* if (error instanceof ISGConfigurationError) {
|
|
36
|
+
* console.error(`${error.code}: ${error.message}`);
|
|
37
|
+
* console.error(`Field: ${error.field}, Value: ${error.value}`);
|
|
38
|
+
* }
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function validateISGConfig(config: ISGConfig): void;
|
|
43
|
+
/**
|
|
44
|
+
* Validates front-matter ISG overrides for a single page.
|
|
45
|
+
* Provides helpful error messages for invalid page-level configuration.
|
|
46
|
+
*
|
|
47
|
+
* @param frontMatter - Page front-matter object
|
|
48
|
+
* @param sourcePath - Path to source file for error context
|
|
49
|
+
* @throws {ISGConfigurationError} When front-matter overrides are invalid
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* try {
|
|
54
|
+
* validatePageISGOverrides(page.frontMatter, page.sourcePath);
|
|
55
|
+
* } catch (error) {
|
|
56
|
+
* console.error(`Error in ${page.sourcePath}: ${error.message}`);
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function validatePageISGOverrides(frontMatter: Record<string, unknown>, sourcePath: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Safely extracts numeric value from front-matter with validation.
|
|
63
|
+
* Used for TTL and max age cap overrides.
|
|
64
|
+
*
|
|
65
|
+
* @param value - Value from front-matter
|
|
66
|
+
* @param fieldName - Name of the field for error messages
|
|
67
|
+
* @param sourcePath - Source file path for error context
|
|
68
|
+
* @returns Validated number or undefined if not set
|
|
69
|
+
*/
|
|
70
|
+
export declare function extractNumericOverride(value: unknown, fieldName: string, sourcePath: string): number | undefined;
|
|
71
|
+
//# sourceMappingURL=validation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../../src/core/isg/validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAa,MAAM,gBAAgB,CAAC;AAE3D;;;GAGG;AACH,oBAAY,kBAAkB;IAC5B,WAAW,oBAAoB;IAC/B,mBAAmB,4BAA4B;IAC/C,kBAAkB,2BAA2B;IAC7C,oBAAoB,6BAA6B;IACjD,oBAAoB,6BAA6B;IACjD,sBAAsB,+BAA+B;CACtD;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;aAE5B,IAAI,EAAE,kBAAkB;aACxB,KAAK,EAAE,MAAM;aACb,KAAK,EAAE,OAAO;gBAFd,IAAI,EAAE,kBAAkB,EACxB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,OAAO,EAC9B,OAAO,EAAE,MAAM;CAKlB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAmBzD;AAmLD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpC,UAAU,EAAE,MAAM,GACjB,IAAI,CAwDN;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,SAAS,CAoCpB"}
|