@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 @@
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"}
@@ -0,0 +1,371 @@
1
+ import { createServer } from 'http';
2
+ import { join, extname } from 'path';
3
+ import { posix } from 'path';
4
+ import { readFile, stat } from 'fs/promises';
5
+ import { WebSocketServer } from 'ws';
6
+ import chokidar from 'chokidar';
7
+ import { build } from './build.js';
8
+ import { loadConfig } from '../config/loader.js';
9
+ import { loadCacheManifest, saveCacheManifest } from './isg/manifest.js';
10
+ /**
11
+ * Creates and configures a development server with live reload functionality.
12
+ *
13
+ * @param options - Development server configuration options
14
+ * @returns Promise resolving to a DevServer instance
15
+ */
16
+ export async function createDevServer(options = {}) {
17
+ const { port = 3000, host = 'localhost', open = false, configPath, logger = {
18
+ info: (msg) => console.log(msg),
19
+ success: (msg) => console.log(msg),
20
+ error: (msg) => console.error(msg),
21
+ warning: (msg) => console.warn(msg),
22
+ building: (msg) => console.log(msg),
23
+ processing: (msg) => console.log(msg),
24
+ stats: (msg) => console.log(msg),
25
+ }, } = options;
26
+ const url = `http://${host}:${port}`;
27
+ let httpServer = null;
28
+ let wsServer = null;
29
+ let watcher = null;
30
+ let config;
31
+ let isBuilding = false;
32
+ // Load configuration
33
+ try {
34
+ if (configPath) {
35
+ // For custom config path, we need to change to that directory temporarily
36
+ // This is a limitation of the current loadConfig implementation
37
+ logger.info?.(`Loading config from: ${configPath}`);
38
+ }
39
+ config = await loadConfig(process.cwd());
40
+ }
41
+ catch (error) {
42
+ logger.error?.(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`);
43
+ throw error;
44
+ }
45
+ const outDir = join(process.cwd(), config.outDir || 'dist');
46
+ const srcDir = join(process.cwd(), config.srcDir || 'site');
47
+ const staticDir = join(process.cwd(), config.staticDir || 'public');
48
+ /**
49
+ * Performs an initial build to ensure dist/ exists
50
+ */
51
+ async function initialBuild() {
52
+ try {
53
+ await build({
54
+ logger,
55
+ force: false,
56
+ clean: false,
57
+ ...(configPath && { configPath }),
58
+ });
59
+ }
60
+ catch (error) {
61
+ logger.error?.(`Initial build failed: ${error instanceof Error ? error.message : String(error)}`);
62
+ throw error;
63
+ }
64
+ }
65
+ /**
66
+ * Performs incremental rebuild when files change, using ISG logic for smart rebuilds
67
+ */
68
+ async function incrementalRebuild(changedPath) {
69
+ if (isBuilding) {
70
+ logger.info?.('⏳ Build in progress, skipping...');
71
+ return;
72
+ }
73
+ isBuilding = true;
74
+ try {
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
+ }
90
+ // Notify all connected clients to reload
91
+ if (wsServer) {
92
+ wsServer.clients.forEach((client) => {
93
+ const ws = client;
94
+ if (ws.readyState === 1) {
95
+ // WebSocket.OPEN
96
+ ws.send(JSON.stringify({ type: 'reload' }));
97
+ }
98
+ });
99
+ }
100
+ logger.success?.('Rebuild complete');
101
+ }
102
+ catch (error) {
103
+ logger.error?.(`Rebuild failed: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ finally {
106
+ isBuilding = false;
107
+ }
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
+ }
165
+ /**
166
+ * Gets MIME type for a file based on its extension
167
+ */
168
+ function getMimeType(filePath) {
169
+ const ext = extname(filePath).toLowerCase();
170
+ const mimeTypes = {
171
+ '.html': 'text/html',
172
+ '.js': 'application/javascript',
173
+ '.css': 'text/css',
174
+ '.json': 'application/json',
175
+ '.png': 'image/png',
176
+ '.jpg': 'image/jpeg',
177
+ '.jpeg': 'image/jpeg',
178
+ '.gif': 'image/gif',
179
+ '.svg': 'image/svg+xml',
180
+ '.ico': 'image/x-icon',
181
+ '.webp': 'image/webp',
182
+ '.woff': 'font/woff',
183
+ '.woff2': 'font/woff2',
184
+ '.ttf': 'font/ttf',
185
+ '.eot': 'application/vnd.ms-fontobject',
186
+ };
187
+ return mimeTypes[ext] || 'application/octet-stream';
188
+ }
189
+ /**
190
+ * Injects live reload script into HTML responses
191
+ */
192
+ function injectLiveReloadScript(html) {
193
+ const script = `
194
+ <script>
195
+ (function() {
196
+ const ws = new WebSocket('ws://${host}:${port}/__ws');
197
+ ws.onmessage = function(event) {
198
+ const data = JSON.parse(event.data);
199
+ if (data.type === 'reload') {
200
+ console.log('Reloading page due to file changes...');
201
+ window.location.reload();
202
+ }
203
+ };
204
+ ws.onopen = function() {
205
+ console.log('Connected to Stati dev server');
206
+ };
207
+ ws.onclose = function() {
208
+ console.log('Lost connection to Stati dev server');
209
+ // Try to reconnect after a delay
210
+ setTimeout(() => window.location.reload(), 1000);
211
+ };
212
+ })();
213
+ </script>`;
214
+ // Insert before closing </body> tag, or at the end if no </body>
215
+ if (html.includes('</body>')) {
216
+ return html.replace('</body>', `${script}\n</body>`);
217
+ }
218
+ else {
219
+ return html + script;
220
+ }
221
+ }
222
+ /**
223
+ * Serves files from the dist directory
224
+ */
225
+ async function serveFile(requestPath) {
226
+ let filePath = join(outDir, requestPath === '/' ? 'index.html' : requestPath);
227
+ try {
228
+ const stats = await stat(filePath);
229
+ if (stats.isDirectory()) {
230
+ // Try to serve index.html from directory
231
+ const indexPath = join(filePath, 'index.html');
232
+ try {
233
+ await stat(indexPath);
234
+ filePath = indexPath;
235
+ }
236
+ catch {
237
+ return {
238
+ content: '404 - Directory listing not available',
239
+ mimeType: 'text/plain',
240
+ statusCode: 404,
241
+ };
242
+ }
243
+ }
244
+ const mimeType = getMimeType(filePath);
245
+ const content = await readFile(filePath);
246
+ // Inject live reload script into HTML files
247
+ if (mimeType === 'text/html') {
248
+ const html = content.toString('utf-8');
249
+ const injectedHtml = injectLiveReloadScript(html);
250
+ return {
251
+ content: injectedHtml,
252
+ mimeType,
253
+ statusCode: 200,
254
+ };
255
+ }
256
+ return {
257
+ content,
258
+ mimeType,
259
+ statusCode: 200,
260
+ };
261
+ }
262
+ catch {
263
+ // File not found
264
+ return {
265
+ content: '404 - File not found',
266
+ mimeType: 'text/plain',
267
+ statusCode: 404,
268
+ };
269
+ }
270
+ }
271
+ const devServer = {
272
+ url,
273
+ async start() {
274
+ // Perform initial build
275
+ await initialBuild();
276
+ // Create HTTP server
277
+ httpServer = createServer(async (req, res) => {
278
+ const requestPath = req.url || '/';
279
+ // Handle WebSocket upgrade path
280
+ if (requestPath === '/__ws') {
281
+ return; // Let WebSocket server handle this
282
+ }
283
+ logger.processing?.(`${req.method} ${requestPath}`);
284
+ try {
285
+ const { content, mimeType, statusCode } = await serveFile(requestPath);
286
+ res.writeHead(statusCode, {
287
+ 'Content-Type': mimeType,
288
+ 'Access-Control-Allow-Origin': '*',
289
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
290
+ });
291
+ res.end(content);
292
+ }
293
+ catch (error) {
294
+ logger.error?.(`Server error: ${error instanceof Error ? error.message : String(error)}`);
295
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
296
+ res.end('500 - Internal Server Error');
297
+ }
298
+ });
299
+ // Create WebSocket server for live reload
300
+ wsServer = new WebSocketServer({
301
+ server: httpServer,
302
+ path: '/__ws',
303
+ });
304
+ wsServer.on('connection', (ws) => {
305
+ logger.info?.('🔗 Browser connected for live reload');
306
+ const websocket = ws;
307
+ websocket.on('close', () => {
308
+ logger.info?.('Browser disconnected from live reload');
309
+ });
310
+ });
311
+ // Start HTTP server
312
+ await new Promise((resolve, reject) => {
313
+ httpServer.listen(port, host, () => {
314
+ resolve();
315
+ });
316
+ httpServer.on('error', (error) => {
317
+ reject(error);
318
+ });
319
+ });
320
+ // Set up file watching
321
+ const watchPaths = [srcDir, staticDir].filter(Boolean);
322
+ watcher = chokidar.watch(watchPaths, {
323
+ ignored: /(^|[/\\])\../, // ignore dotfiles
324
+ persistent: true,
325
+ ignoreInitial: true,
326
+ });
327
+ watcher.on('change', (path) => {
328
+ void incrementalRebuild(path);
329
+ });
330
+ watcher.on('add', (path) => {
331
+ void incrementalRebuild(path);
332
+ });
333
+ watcher.on('unlink', (path) => {
334
+ void incrementalRebuild(path);
335
+ });
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}`));
341
+ // Open browser if requested
342
+ if (open) {
343
+ try {
344
+ const { default: openBrowser } = await import('open');
345
+ await openBrowser(url);
346
+ }
347
+ catch {
348
+ logger.info?.('Could not open browser automatically');
349
+ }
350
+ }
351
+ },
352
+ async stop() {
353
+ if (watcher) {
354
+ await watcher.close();
355
+ watcher = null;
356
+ }
357
+ if (wsServer) {
358
+ wsServer.close();
359
+ wsServer = null;
360
+ }
361
+ if (httpServer) {
362
+ await new Promise((resolve) => {
363
+ httpServer.close(() => resolve());
364
+ });
365
+ httpServer = null;
366
+ }
367
+ logger.info?.('🛑 Dev server stopped');
368
+ },
369
+ };
370
+ return devServer;
371
+ }
@@ -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"}