@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.
Files changed (41) hide show
  1. package/README.md +217 -0
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/loader.js +24 -2
  4. package/dist/core/build.d.ts +9 -2
  5. package/dist/core/build.d.ts.map +1 -1
  6. package/dist/core/build.js +200 -46
  7. package/dist/core/dev.d.ts +21 -0
  8. package/dist/core/dev.d.ts.map +1 -0
  9. package/dist/core/dev.js +371 -0
  10. package/dist/core/invalidate.d.ts +67 -1
  11. package/dist/core/invalidate.d.ts.map +1 -1
  12. package/dist/core/invalidate.js +321 -4
  13. package/dist/core/isg/build-lock.d.ts +116 -0
  14. package/dist/core/isg/build-lock.d.ts.map +1 -0
  15. package/dist/core/isg/build-lock.js +243 -0
  16. package/dist/core/isg/builder.d.ts +51 -0
  17. package/dist/core/isg/builder.d.ts.map +1 -0
  18. package/dist/core/isg/builder.js +321 -0
  19. package/dist/core/isg/deps.d.ts +63 -0
  20. package/dist/core/isg/deps.d.ts.map +1 -0
  21. package/dist/core/isg/deps.js +332 -0
  22. package/dist/core/isg/hash.d.ts +48 -0
  23. package/dist/core/isg/hash.d.ts.map +1 -0
  24. package/dist/core/isg/hash.js +82 -0
  25. package/dist/core/isg/manifest.d.ts +47 -0
  26. package/dist/core/isg/manifest.d.ts.map +1 -0
  27. package/dist/core/isg/manifest.js +233 -0
  28. package/dist/core/isg/ttl.d.ts +101 -0
  29. package/dist/core/isg/ttl.d.ts.map +1 -0
  30. package/dist/core/isg/ttl.js +222 -0
  31. package/dist/core/isg/validation.d.ts +71 -0
  32. package/dist/core/isg/validation.d.ts.map +1 -0
  33. package/dist/core/isg/validation.js +226 -0
  34. package/dist/core/templates.d.ts.map +1 -1
  35. package/dist/core/templates.js +23 -5
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/types.d.ts +172 -0
  40. package/dist/types.d.ts.map +1 -1
  41. 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"}