@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
package/README.md CHANGED
@@ -107,18 +107,30 @@ const server = await createDevServer(config, {
107
107
  });
108
108
  ```
109
109
 
110
- #### `invalidate(options: InvalidateOptions): Promise<void>`
110
+ #### `invalidate(query?: string): Promise<InvalidationResult>`
111
111
 
112
- Invalidate cache by tags or paths.
112
+ Invalidate cache by tags, paths, patterns, or age.
113
113
 
114
114
  ```typescript
115
115
  import { invalidate } from '@stati/core';
116
116
 
117
- await invalidate({
118
- tags: ['blog', 'posts'],
119
- paths: ['/blog', '/about'],
120
- config: './stati.config.js',
121
- });
117
+ // Invalidate by tag
118
+ await invalidate('tag:blog');
119
+
120
+ // Invalidate by path prefix
121
+ await invalidate('path:/posts');
122
+
123
+ // Invalidate by glob pattern
124
+ await invalidate('glob:/blog/**');
125
+
126
+ // Invalidate content younger than 3 months
127
+ await invalidate('age:3months');
128
+
129
+ // Multiple criteria (OR logic)
130
+ await invalidate('tag:blog age:1month');
131
+
132
+ // Clear entire cache
133
+ await invalidate();
122
134
  ```
123
135
 
124
136
  ### Configuration
@@ -1 +1 @@
1
- {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAqB/C;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,UAAU,CAAC,GAAG,GAAE,MAAsB,GAAG,OAAO,CAAC,WAAW,CAAC,CAkClF"}
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAsB/C;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,UAAU,CAAC,GAAG,GAAE,MAAsB,GAAG,OAAO,CAAC,WAAW,CAAC,CA2DlF"}
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { join, resolve } from 'path';
3
3
  import { pathToFileURL } from 'url';
4
+ import { validateISGConfig, ISGConfigurationError } from '../core/isg/validation.js';
4
5
  /**
5
6
  * Default configuration values for Stati.
6
7
  * Used as fallback when no configuration file is found.
@@ -59,15 +60,36 @@ export async function loadConfig(cwd = process.cwd()) {
59
60
  const configUrl = pathToFileURL(resolve(configPath)).href;
60
61
  const module = await import(configUrl);
61
62
  const userConfig = module.default || module;
62
- return {
63
+ const mergedConfig = {
63
64
  ...DEFAULT_CONFIG,
64
65
  ...userConfig,
65
66
  site: { ...DEFAULT_CONFIG.site, ...userConfig.site },
66
67
  isg: { ...DEFAULT_CONFIG.isg, ...userConfig.isg },
67
68
  };
69
+ // Validate ISG configuration
70
+ try {
71
+ if (mergedConfig.isg) {
72
+ validateISGConfig(mergedConfig.isg);
73
+ }
74
+ }
75
+ catch (error) {
76
+ if (error instanceof ISGConfigurationError) {
77
+ throw new Error(`Invalid ISG configuration in ${configPath}:\n` +
78
+ `${error.code}: ${error.message}\n` +
79
+ `Field: ${error.field}, Value: ${JSON.stringify(error.value)}\n\n` +
80
+ `Please check your stati.config.ts file and correct the ISG configuration.`);
81
+ }
82
+ throw error; // Re-throw non-ISG errors
83
+ }
84
+ return mergedConfig;
68
85
  }
69
86
  catch (error) {
70
- console.error('Error loading config:', error);
87
+ if (error instanceof Error && error.message.includes('Invalid ISG configuration')) {
88
+ // ISG validation errors should bubble up with context
89
+ throw error;
90
+ }
91
+ console.error(`Error loading config from ${configPath}:`, error);
92
+ console.error('Falling back to default configuration.');
71
93
  return DEFAULT_CONFIG;
72
94
  }
73
95
  }
@@ -30,6 +30,7 @@ export interface BuildOptions {
30
30
  /**
31
31
  * Builds the static site by processing content files and generating HTML pages.
32
32
  * This is the main entry point for Stati's build process.
33
+ * Uses build locking to prevent concurrent builds from corrupting cache.
33
34
  *
34
35
  * @param options - Build configuration options
35
36
  *
@@ -51,6 +52,7 @@ export interface BuildOptions {
51
52
  * @throws {Error} When configuration loading fails
52
53
  * @throws {Error} When content processing fails
53
54
  * @throws {Error} When template rendering fails
55
+ * @throws {Error} When build lock cannot be acquired
54
56
  */
55
57
  export declare function build(options?: BuildOptions): Promise<BuildStats>;
56
58
  //# sourceMappingURL=build.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/core/build.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAgB,UAAU,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qCAAqC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA2HD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,KAAK,CAAC,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,CAwI3E"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/core/build.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAgB,UAAU,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qCAAqC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA2HD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,KAAK,CAAC,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,CAQ3E"}
@@ -1,11 +1,15 @@
1
1
  import fse from 'fs-extra';
2
2
  const { ensureDir, writeFile, remove, pathExists, stat, readdir, copyFile } = fse;
3
- import { join, dirname } from 'path';
3
+ import { join, dirname, relative } from 'path';
4
+ import { posix } from 'path';
4
5
  import { loadConfig } from '../config/loader.js';
5
6
  import { loadContent } from './content.js';
6
7
  import { createMarkdownProcessor, renderMarkdown } from './markdown.js';
7
8
  import { createTemplateEngine, renderPage } from './templates.js';
8
9
  import { buildNavigation } from './navigation.js';
10
+ import { loadCacheManifest, saveCacheManifest } from './isg/manifest.js';
11
+ import { shouldRebuildPage, createCacheEntry, updateCacheEntry } from './isg/builder.js';
12
+ import { withBuildLock } from './isg/build-lock.js';
9
13
  /**
10
14
  * Recursively calculates the total size of a directory in bytes.
11
15
  * Used for build statistics.
@@ -50,7 +54,7 @@ async function copyStaticAssetsWithLogging(sourceDir, destDir, logger, basePath
50
54
  for (const item of items) {
51
55
  const sourcePath = join(sourceDir, item.name);
52
56
  const destPath = join(destDir, basePath, item.name);
53
- const relativePath = join(basePath, item.name).replace(/\\/g, '/');
57
+ const relativePath = posix.normalize(posix.join(basePath, item.name));
54
58
  if (item.isDirectory()) {
55
59
  // Recursively copy directories
56
60
  await ensureDir(destPath);
@@ -92,17 +96,17 @@ const defaultLogger = {
92
96
  function formatBuildStats(stats) {
93
97
  const sizeKB = (stats.outputSizeBytes / 1024).toFixed(1);
94
98
  const timeSeconds = (stats.buildTimeMs / 1000).toFixed(2);
95
- let output = `📊 Build Statistics:
99
+ let output = `Build Statistics:
96
100
  ┌─────────────────────────────────────────┐
97
- │ âąī¸ Build time: ${timeSeconds}s`.padEnd(41) + '│';
101
+ │ Build time: ${timeSeconds}s`.padEnd(41) + '│';
98
102
  output += `\n│ 📄 Pages built: ${stats.totalPages}`.padEnd(42) + '│';
99
103
  output += `\n│ đŸ“Ļ Assets copied: ${stats.assetsCount}`.padEnd(42) + '│';
100
- output += `\n│ 💾 Output size: ${sizeKB} KB`.padEnd(42) + '│';
104
+ output += `\n│ Output size: ${sizeKB} KB`.padEnd(42) + '│';
101
105
  if (stats.cacheHits !== undefined && stats.cacheMisses !== undefined) {
102
106
  const totalCacheRequests = stats.cacheHits + stats.cacheMisses;
103
107
  const hitRate = totalCacheRequests > 0 ? ((stats.cacheHits / totalCacheRequests) * 100).toFixed(1) : '0';
104
108
  output +=
105
- `\n│ đŸŽ¯ Cache hits: ${stats.cacheHits}/${totalCacheRequests} (${hitRate}%)`.padEnd(42) + '│';
109
+ `\n│ Cache hits: ${stats.cacheHits}/${totalCacheRequests} (${hitRate}%)`.padEnd(42) + '│';
106
110
  }
107
111
  output += '\n└─────────────────────────────────────────┘';
108
112
  return output;
@@ -110,6 +114,7 @@ function formatBuildStats(stats) {
110
114
  /**
111
115
  * Builds the static site by processing content files and generating HTML pages.
112
116
  * This is the main entry point for Stati's build process.
117
+ * Uses build locking to prevent concurrent builds from corrupting cache.
113
118
  *
114
119
  * @param options - Build configuration options
115
120
  *
@@ -131,8 +136,21 @@ function formatBuildStats(stats) {
131
136
  * @throws {Error} When configuration loading fails
132
137
  * @throws {Error} When content processing fails
133
138
  * @throws {Error} When template rendering fails
139
+ * @throws {Error} When build lock cannot be acquired
134
140
  */
135
141
  export async function build(options = {}) {
142
+ const cacheDir = join(process.cwd(), '.stati');
143
+ // Use build lock to prevent concurrent builds, with force option to override
144
+ return await withBuildLock(cacheDir, () => buildInternal(options), {
145
+ force: Boolean(options.force || options.clean), // Allow force if user explicitly requests it
146
+ timeout: 60000, // 1 minute timeout
147
+ });
148
+ }
149
+ /**
150
+ * Internal build implementation without locking.
151
+ * Separated for cleaner error handling and testing.
152
+ */
153
+ async function buildInternal(options = {}) {
136
154
  const buildStartTime = Date.now();
137
155
  const logger = options.logger || defaultLogger;
138
156
  logger.building('Building your site...');
@@ -143,9 +161,22 @@ export async function build(options = {}) {
143
161
  // Create .stati cache directory
144
162
  const cacheDir = join(process.cwd(), '.stati');
145
163
  await ensureDir(cacheDir);
164
+ // Load cache manifest for ISG
165
+ let cacheManifest = await loadCacheManifest(cacheDir);
166
+ // If no cache manifest exists, create an empty one
167
+ if (!cacheManifest) {
168
+ cacheManifest = {
169
+ entries: {},
170
+ };
171
+ }
172
+ // At this point cacheManifest is guaranteed to be non-null
173
+ const manifest = cacheManifest;
174
+ // Initialize cache stats
175
+ let cacheHits = 0;
176
+ let cacheMisses = 0;
146
177
  // Clean output directory if requested
147
178
  if (options.clean) {
148
- logger.info('🧹 Cleaning output directory...');
179
+ logger.info('Cleaning output directory...');
149
180
  await remove(outDir);
150
181
  }
151
182
  await ensureDir(outDir);
@@ -158,7 +189,7 @@ export async function build(options = {}) {
158
189
  logger.step(1, 3, 'Building navigation');
159
190
  }
160
191
  const navigation = buildNavigation(pages);
161
- logger.info(`🧭 Built navigation with ${navigation.length} top-level items`);
192
+ logger.info(`Built navigation with ${navigation.length} top-level items`);
162
193
  // Display navigation tree if the logger supports it
163
194
  if (logger.navigationTree) {
164
195
  logger.navigationTree(navigation);
@@ -171,30 +202,28 @@ export async function build(options = {}) {
171
202
  if (config.hooks?.beforeAll) {
172
203
  await config.hooks.beforeAll(buildContext);
173
204
  }
174
- // Render each page with progress tracking
205
+ // Render each page with tree-based progress tracking and ISG
175
206
  if (logger.step) {
176
207
  logger.step(2, 3, 'Rendering pages');
177
208
  }
209
+ // Initialize rendering tree
210
+ if (logger.startRenderingTree) {
211
+ logger.startRenderingTree('Page Rendering Process');
212
+ }
213
+ const buildTime = new Date();
178
214
  for (let i = 0; i < pages.length; i++) {
179
215
  const page = pages[i];
180
216
  if (!page)
181
217
  continue; // Safety check
182
- // Show progress for page rendering
183
- if (logger.progress) {
184
- logger.progress(i + 1, pages.length, `Rendering ${page.url}`);
218
+ const pageId = `page-${i}`;
219
+ // Add page to rendering tree
220
+ if (logger.addTreeNode) {
221
+ logger.addTreeNode('root', pageId, page.url, 'running', { url: page.url });
185
222
  }
186
223
  else {
187
- logger.processing(`Building ${page.url}`);
224
+ logger.processing(`Checking ${page.url}`);
188
225
  }
189
- // Run beforeRender hook
190
- if (config.hooks?.beforeRender) {
191
- await config.hooks.beforeRender({ page, config });
192
- }
193
- // Render markdown to HTML
194
- const htmlContent = renderMarkdown(page.content, md);
195
- // Render with template
196
- const finalHtml = await renderPage(page, htmlContent, config, eta, navigation, pages);
197
- // Determine output path - fix the logic here
226
+ // Determine output path
198
227
  let outputPath;
199
228
  if (page.url === '/') {
200
229
  outputPath = join(outDir, 'index.html');
@@ -205,14 +234,78 @@ export async function build(options = {}) {
205
234
  else {
206
235
  outputPath = join(outDir, `${page.url}.html`);
207
236
  }
237
+ // Get cache key (use output path relative to outDir)
238
+ const relativePath = relative(outDir, outputPath).replace(/\\/g, '/');
239
+ const cacheKey = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
240
+ const existingEntry = manifest.entries[cacheKey];
241
+ // Check if we should rebuild this page (considering ISG logic)
242
+ const shouldRebuild = options.force || (await shouldRebuildPage(page, existingEntry, config, buildTime));
243
+ if (!shouldRebuild) {
244
+ // Cache hit - skip rendering
245
+ cacheHits++;
246
+ if (logger.updateTreeNode) {
247
+ logger.updateTreeNode(pageId, 'cached', { cacheHit: true, url: page.url });
248
+ }
249
+ else {
250
+ logger.processing(`📋 Cached ${page.url}`);
251
+ }
252
+ continue;
253
+ }
254
+ // Cache miss - need to rebuild
255
+ cacheMisses++;
256
+ const startTime = Date.now();
257
+ // Add rendering substeps to tree
258
+ const markdownId = `${pageId}-markdown`;
259
+ const templateId = `${pageId}-template`;
260
+ if (logger.addTreeNode) {
261
+ logger.addTreeNode(pageId, markdownId, 'Processing Markdown', 'running');
262
+ logger.addTreeNode(pageId, templateId, 'Applying Template', 'pending');
263
+ }
264
+ // Run beforeRender hook
265
+ if (config.hooks?.beforeRender) {
266
+ await config.hooks.beforeRender({ page, config });
267
+ }
268
+ // Render markdown to HTML
269
+ const htmlContent = renderMarkdown(page.content, md);
270
+ if (logger.updateTreeNode) {
271
+ logger.updateTreeNode(markdownId, 'completed');
272
+ logger.updateTreeNode(templateId, 'running');
273
+ }
274
+ // Render with template
275
+ const finalHtml = await renderPage(page, htmlContent, config, eta, navigation, pages);
276
+ const renderTime = Date.now() - startTime;
277
+ if (logger.updateTreeNode) {
278
+ logger.updateTreeNode(templateId, 'completed');
279
+ logger.updateTreeNode(pageId, 'completed', {
280
+ timing: renderTime,
281
+ url: page.url,
282
+ });
283
+ }
208
284
  // Ensure directory exists and write file
209
285
  await ensureDir(dirname(outputPath));
210
286
  await writeFile(outputPath, finalHtml, 'utf-8');
287
+ // Update cache manifest
288
+ if (existingEntry) {
289
+ manifest.entries[cacheKey] = await updateCacheEntry(existingEntry, page, config, buildTime);
290
+ }
291
+ else {
292
+ manifest.entries[cacheKey] = await createCacheEntry(page, config, buildTime);
293
+ }
211
294
  // Run afterRender hook
212
295
  if (config.hooks?.afterRender) {
213
296
  await config.hooks.afterRender({ page, config });
214
297
  }
215
298
  }
299
+ // Display final rendering tree and clear it
300
+ if (logger.showRenderingTree) {
301
+ console.log(); // Add spacing
302
+ logger.showRenderingTree();
303
+ if (logger.clearRenderingTree) {
304
+ logger.clearRenderingTree();
305
+ }
306
+ }
307
+ // Save updated cache manifest
308
+ await saveCacheManifest(cacheDir, manifest);
216
309
  // Copy static assets and count them
217
310
  let assetsCount = 0;
218
311
  const staticDir = join(process.cwd(), config.staticDir);
@@ -221,9 +314,9 @@ export async function build(options = {}) {
221
314
  if (logger.step) {
222
315
  logger.step(3, 3, 'Copying static assets');
223
316
  }
224
- logger.info(`đŸ“Ļ Copying static assets from ${config.staticDir}`);
317
+ logger.info(`Copying static assets from ${config.staticDir}`);
225
318
  assetsCount = await copyStaticAssetsWithLogging(staticDir, outDir, logger);
226
- logger.info(`đŸ“Ļ Copied ${assetsCount} static assets`);
319
+ logger.info(`Copied ${assetsCount} static assets`);
227
320
  }
228
321
  // Run afterAll hook
229
322
  if (config.hooks?.afterAll) {
@@ -236,9 +329,9 @@ export async function build(options = {}) {
236
329
  assetsCount,
237
330
  buildTimeMs: buildEndTime - buildStartTime,
238
331
  outputSizeBytes: await getDirectorySize(outDir),
239
- // Cache stats would be populated here when caching is implemented
240
- cacheHits: 0,
241
- cacheMisses: 0,
332
+ // Include ISG cache statistics
333
+ cacheHits,
334
+ cacheMisses,
242
335
  };
243
336
  console.log(); // Add spacing before statistics
244
337
  // Use table format if available, otherwise fall back to formatted string
@@ -1 +1 @@
1
- {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/core/dev.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,aAAa,CAAC;AAKvD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CAgVxF"}
1
+ {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/core/dev.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,aAAa,CAAC;AAMvD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CA0ZxF"}
package/dist/core/dev.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { createServer } from 'http';
2
2
  import { join, extname } from 'path';
3
+ import { posix } from 'path';
3
4
  import { readFile, stat } from 'fs/promises';
4
5
  import { WebSocketServer } from 'ws';
5
6
  import chokidar from 'chokidar';
6
7
  import { build } from './build.js';
7
8
  import { loadConfig } from '../config/loader.js';
9
+ import { loadCacheManifest, saveCacheManifest } from './isg/manifest.js';
8
10
  /**
9
11
  * Creates and configures a development server with live reload functionality.
10
12
  *
@@ -47,7 +49,6 @@ export async function createDevServer(options = {}) {
47
49
  * Performs an initial build to ensure dist/ exists
48
50
  */
49
51
  async function initialBuild() {
50
- logger.info?.('đŸ—ī¸ Performing initial build...');
51
52
  try {
52
53
  await build({
53
54
  logger,
@@ -55,7 +56,6 @@ export async function createDevServer(options = {}) {
55
56
  clean: false,
56
57
  ...(configPath && { configPath }),
57
58
  });
58
- logger.success?.('✅ Initial build complete');
59
59
  }
60
60
  catch (error) {
61
61
  logger.error?.(`Initial build failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -63,7 +63,7 @@ export async function createDevServer(options = {}) {
63
63
  }
64
64
  }
65
65
  /**
66
- * Performs incremental rebuild when files change
66
+ * Performs incremental rebuild when files change, using ISG logic for smart rebuilds
67
67
  */
68
68
  async function incrementalRebuild(changedPath) {
69
69
  if (isBuilding) {
@@ -71,14 +71,22 @@ export async function createDevServer(options = {}) {
71
71
  return;
72
72
  }
73
73
  isBuilding = true;
74
- logger.info?.(`🔄 Rebuilding due to change: ${changedPath}`);
75
74
  try {
76
- await build({
77
- logger,
78
- force: false,
79
- clean: false,
80
- ...(configPath && { configPath }),
81
- });
75
+ // Check if the changed file is a template/partial
76
+ if (changedPath.endsWith('.eta') || changedPath.includes('_partials')) {
77
+ logger.info?.(`🎨 Template changed: ${changedPath}`);
78
+ await handleTemplateChange(changedPath);
79
+ }
80
+ else {
81
+ // Content or static file changed - use normal rebuild
82
+ logger.info?.(`📄 Content changed: ${changedPath}`);
83
+ await build({
84
+ logger,
85
+ force: false,
86
+ clean: false,
87
+ ...(configPath && { configPath }),
88
+ });
89
+ }
82
90
  // Notify all connected clients to reload
83
91
  if (wsServer) {
84
92
  wsServer.clients.forEach((client) => {
@@ -89,7 +97,7 @@ export async function createDevServer(options = {}) {
89
97
  }
90
98
  });
91
99
  }
92
- logger.success?.('✅ Rebuild complete');
100
+ logger.success?.('Rebuild complete');
93
101
  }
94
102
  catch (error) {
95
103
  logger.error?.(`Rebuild failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -98,6 +106,62 @@ export async function createDevServer(options = {}) {
98
106
  isBuilding = false;
99
107
  }
100
108
  }
109
+ /**
110
+ * Handles template/partial file changes by invalidating affected pages
111
+ */
112
+ async function handleTemplateChange(templatePath) {
113
+ const cacheDir = join(process.cwd(), '.stati');
114
+ try {
115
+ // Load existing cache manifest
116
+ let cacheManifest = await loadCacheManifest(cacheDir);
117
+ if (!cacheManifest) {
118
+ // No cache exists, perform full rebuild
119
+ logger.info?.('No cache found, performing full rebuild...');
120
+ await build({
121
+ logger,
122
+ force: false,
123
+ clean: false,
124
+ ...(configPath && { configPath }),
125
+ });
126
+ return;
127
+ }
128
+ // Find pages that depend on this template
129
+ const affectedPages = [];
130
+ for (const [pagePath, entry] of Object.entries(cacheManifest.entries)) {
131
+ if (entry.deps.some((dep) => dep.includes(posix.normalize(templatePath.replace(/\\/g, '/'))))) {
132
+ affectedPages.push(pagePath);
133
+ // Remove from cache to force rebuild
134
+ delete cacheManifest.entries[pagePath];
135
+ }
136
+ }
137
+ if (affectedPages.length > 0) {
138
+ logger.info?.(`đŸŽ¯ Invalidating ${affectedPages.length} affected pages:`);
139
+ affectedPages.forEach((page) => logger.info?.(` 📄 ${page}`));
140
+ // Save updated cache manifest
141
+ await saveCacheManifest(cacheDir, cacheManifest);
142
+ // Perform incremental rebuild (only affected pages will be rebuilt)
143
+ await build({
144
+ logger,
145
+ force: false,
146
+ clean: false,
147
+ ...(configPath && { configPath }),
148
+ });
149
+ }
150
+ else {
151
+ logger.info?.('â„šī¸ No pages affected by template change');
152
+ }
153
+ }
154
+ catch (error) {
155
+ logger.warning?.(`Template dependency analysis failed, performing full rebuild: ${error instanceof Error ? error.message : String(error)}`);
156
+ // Fallback to full rebuild
157
+ await build({
158
+ logger,
159
+ force: false,
160
+ clean: false,
161
+ ...(configPath && { configPath }),
162
+ });
163
+ }
164
+ }
101
165
  /**
102
166
  * Gets MIME type for a file based on its extension
103
167
  */
@@ -133,15 +197,15 @@ export async function createDevServer(options = {}) {
133
197
  ws.onmessage = function(event) {
134
198
  const data = JSON.parse(event.data);
135
199
  if (data.type === 'reload') {
136
- console.log('🔄 Reloading page due to file changes...');
200
+ console.log('Reloading page due to file changes...');
137
201
  window.location.reload();
138
202
  }
139
203
  };
140
204
  ws.onopen = function() {
141
- console.log('🔗 Connected to Stati dev server');
205
+ console.log('Connected to Stati dev server');
142
206
  };
143
207
  ws.onclose = function() {
144
- console.log('❌ Lost connection to Stati dev server');
208
+ console.log('Lost connection to Stati dev server');
145
209
  // Try to reconnect after a delay
146
210
  setTimeout(() => window.location.reload(), 1000);
147
211
  };
@@ -241,7 +305,7 @@ export async function createDevServer(options = {}) {
241
305
  logger.info?.('🔗 Browser connected for live reload');
242
306
  const websocket = ws;
243
307
  websocket.on('close', () => {
244
- logger.info?.('❌ Browser disconnected from live reload');
308
+ logger.info?.('Browser disconnected from live reload');
245
309
  });
246
310
  });
247
311
  // Start HTTP server
@@ -269,9 +333,11 @@ export async function createDevServer(options = {}) {
269
333
  watcher.on('unlink', (path) => {
270
334
  void incrementalRebuild(path);
271
335
  });
272
- logger.success?.(`🚀 Dev server running at ${url}`);
273
- logger.info?.(`📁 Serving from: ${outDir}`);
274
- logger.info?.(`👀 Watching: ${watchPaths.join(', ')}`);
336
+ logger.success?.(`Dev server running at ${url}`);
337
+ logger.info?.(`\nServing from:`);
338
+ logger.info?.(` 📁 ${outDir}`);
339
+ logger.info?.('Watching:');
340
+ watchPaths.forEach((path) => logger.info?.(` 📁 ${path}`));
275
341
  // Open browser if requested
276
342
  if (open) {
277
343
  try {
@@ -1,2 +1,68 @@
1
- export declare function invalidate(query?: string): Promise<void>;
1
+ import type { CacheEntry } from '../types.js';
2
+ /**
3
+ * Invalidation result containing affected cache entries.
4
+ */
5
+ export interface InvalidationResult {
6
+ /** Number of cache entries invalidated */
7
+ invalidatedCount: number;
8
+ /** Paths of invalidated pages */
9
+ invalidatedPaths: string[];
10
+ /** Whether the entire cache was cleared */
11
+ clearedAll: boolean;
12
+ }
13
+ /**
14
+ * Parses an invalidation query string into individual query terms.
15
+ * Supports space-separated values and quoted strings.
16
+ *
17
+ * @param query - The query string to parse
18
+ * @returns Array of parsed query terms
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * parseInvalidationQuery('tag:blog path:/posts') // ['tag:blog', 'path:/posts']
23
+ * parseInvalidationQuery('"tag:my tag" path:"/my path"') // ['tag:my tag', 'path:/my path']
24
+ * ```
25
+ */
26
+ export declare function parseInvalidationQuery(query: string): string[];
27
+ /**
28
+ * Checks if a cache entry matches a specific invalidation term.
29
+ *
30
+ * @param entry - Cache entry to check
31
+ * @param path - The page path for this entry
32
+ * @param term - Invalidation term to match against
33
+ * @returns True if the entry matches the term
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * matchesInvalidationTerm(entry, '/blog/post-1', 'tag:blog') // true if entry has 'blog' tag
38
+ * matchesInvalidationTerm(entry, '/blog/post-1', 'path:/blog') // true (path prefix match)
39
+ * ```
40
+ */
41
+ export declare function matchesInvalidationTerm(entry: CacheEntry, path: string, term: string): boolean;
42
+ /**
43
+ * Invalidates cache entries based on a query string.
44
+ * Supports tag-based, path-based, pattern-based, and time-based invalidation.
45
+ *
46
+ * @param query - Invalidation query string, or undefined to clear all cache
47
+ * @returns Promise resolving to invalidation result
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * // Invalidate all pages with 'blog' tag
52
+ * await invalidate('tag:blog');
53
+ *
54
+ * // Invalidate specific path
55
+ * await invalidate('path:/about');
56
+ *
57
+ * // Invalidate content younger than 3 months
58
+ * await invalidate('age:3months');
59
+ *
60
+ * // Invalidate multiple criteria
61
+ * await invalidate('tag:blog age:1week');
62
+ *
63
+ * // Clear entire cache
64
+ * await invalidate();
65
+ * ```
66
+ */
67
+ export declare function invalidate(query?: string): Promise<InvalidationResult>;
2
68
  //# sourceMappingURL=invalidate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"invalidate.d.ts","sourceRoot":"","sources":["../../src/core/invalidate.ts"],"names":[],"mappings":"AAAA,wBAAsB,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9D"}
1
+ {"version":3,"file":"invalidate.d.ts","sourceRoot":"","sources":["../../src/core/invalidate.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,0CAA0C;IAC1C,gBAAgB,EAAE,MAAM,CAAC;IACzB,iCAAiC;IACjC,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,2CAA2C;IAC3C,UAAU,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAkC9D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAkC9F;AAiLD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAoD5E"}