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