pulse-js-framework 1.9.0 → 1.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/build.js CHANGED
@@ -22,6 +22,20 @@ export async function buildProject(args) {
22
22
  const outDir = join(root, 'dist');
23
23
  const timer = createTimer();
24
24
 
25
+ // Check for --ssg flag
26
+ const ssgIndex = args.indexOf('--ssg');
27
+ const isSSG = ssgIndex !== -1;
28
+ if (isSSG) {
29
+ args.splice(ssgIndex, 1);
30
+ }
31
+
32
+ // Check for --manifest flag
33
+ const manifestIndex = args.indexOf('--manifest');
34
+ const generateManifest = manifestIndex !== -1 || isSSG;
35
+ if (manifestIndex !== -1) {
36
+ args.splice(manifestIndex, 1);
37
+ }
38
+
25
39
  // Check if vite is available
26
40
  try {
27
41
  const viteConfig = join(root, 'vite.config.js');
@@ -91,6 +105,30 @@ export async function buildProject(args) {
91
105
  // Bundle runtime
92
106
  bundleRuntime(outDir);
93
107
 
108
+ // Generate build manifest
109
+ if (generateManifest) {
110
+ try {
111
+ const { generateBuildManifest } = await import('./ssg.js');
112
+ const manifest = generateBuildManifest(outDir);
113
+ const manifestPath = join(outDir, '.pulse-manifest.json');
114
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
115
+ log.info(' Generated: .pulse-manifest.json');
116
+ } catch (err) {
117
+ log.warn(` Warning: Could not generate manifest: ${err.message}`);
118
+ }
119
+ }
120
+
121
+ // Run SSG if requested
122
+ if (isSSG) {
123
+ log.info('\n Running static site generation...\n');
124
+ try {
125
+ const { runSSG } = await import('./ssg.js');
126
+ await runSSG(args);
127
+ } catch (err) {
128
+ log.warn(` SSG warning: ${err.message}`);
129
+ }
130
+ }
131
+
94
132
  const elapsed = timer.elapsed();
95
133
  log.success(`
96
134
  ✓ Build complete in ${formatDuration(elapsed)}
package/cli/index.js CHANGED
@@ -53,7 +53,8 @@ const commands = {
53
53
  scaffold: runScaffoldCmd,
54
54
  docs: runDocsCmd,
55
55
  release: runReleaseCmd,
56
- 'docs-test': runDocsTestCmd
56
+ 'docs-test': runDocsTestCmd,
57
+ ssg: runSSGCmd
57
58
  };
58
59
 
59
60
  // Command aliases for common typos
@@ -1017,6 +1018,16 @@ async function runBuild(args) {
1017
1018
  await buildProject(args);
1018
1019
  }
1019
1020
 
1021
+ /**
1022
+ * Run static site generation
1023
+ */
1024
+ async function runSSGCmd(args) {
1025
+ log.info('Running static site generation...');
1026
+
1027
+ const { runSSG } = await import('./ssg.js');
1028
+ await runSSG(args);
1029
+ }
1030
+
1020
1031
  /**
1021
1032
  * Preview production build
1022
1033
  */
package/cli/ssg.js ADDED
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Pulse Static Site Generation (SSG)
3
+ *
4
+ * Pre-renders routes to static HTML files at build time.
5
+ * Supports selective SSG (some routes static, others dynamic),
6
+ * incremental regeneration, and build manifest generation.
7
+ *
8
+ * @module pulse-js-framework/cli/ssg
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
12
+ import { join, dirname, resolve, sep } from 'path';
13
+ import { log } from './logger.js';
14
+ import { createTimer, createProgressBar, formatDuration, createSpinner } from './utils/cli-ui.js';
15
+
16
+ // ============================================================================
17
+ // Constants
18
+ // ============================================================================
19
+
20
+ const DEFAULT_SSG_OPTIONS = {
21
+ routes: [],
22
+ outDir: 'dist',
23
+ template: null,
24
+ concurrent: 4,
25
+ timeout: 10000,
26
+ trailingSlash: true,
27
+ fallback: '404.html'
28
+ };
29
+
30
+ // ============================================================================
31
+ // Route Discovery
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Discover routes from a Pulse router configuration or explicit list.
36
+ *
37
+ * @param {Object} options - Discovery options
38
+ * @param {string[]} [options.routes] - Explicit route list
39
+ * @param {string} [options.routerFile] - Path to router config file
40
+ * @param {string} [options.srcDir] - Source directory for page discovery
41
+ * @returns {string[]} Array of route paths to pre-render
42
+ */
43
+ export function discoverRoutes(options = {}) {
44
+ const { routes = [], srcDir } = options;
45
+
46
+ // If explicit routes provided, use them
47
+ if (routes.length > 0) {
48
+ return routes;
49
+ }
50
+
51
+ // Try to discover from pages directory (convention-based)
52
+ if (srcDir) {
53
+ const pagesDir = join(srcDir, 'pages');
54
+ if (existsSync(pagesDir)) {
55
+ return discoverPagesRoutes(pagesDir);
56
+ }
57
+ }
58
+
59
+ // Default: just the root route
60
+ return ['/'];
61
+ }
62
+
63
+ /**
64
+ * Discover routes from a pages directory (file-based routing).
65
+ * @param {string} pagesDir - Pages directory path
66
+ * @param {string} [prefix=''] - Route prefix
67
+ * @returns {string[]}
68
+ */
69
+ function discoverPagesRoutes(pagesDir, prefix = '') {
70
+ const routes = [];
71
+
72
+ try {
73
+ const entries = readdirSync(pagesDir);
74
+ for (const entry of entries) {
75
+ const fullPath = join(pagesDir, entry);
76
+ const stat = statSync(fullPath);
77
+
78
+ if (stat.isDirectory()) {
79
+ routes.push(...discoverPagesRoutes(fullPath, `${prefix}/${entry}`));
80
+ } else if (entry.endsWith('.pulse') || entry.endsWith('.js')) {
81
+ const name = entry.replace(/\.(pulse|js)$/, '');
82
+ if (name === 'index') {
83
+ routes.push(prefix || '/');
84
+ } else if (!name.startsWith('_') && !name.startsWith('[')) {
85
+ routes.push(`${prefix}/${name}`);
86
+ }
87
+ }
88
+ }
89
+ } catch {
90
+ // Directory might not exist
91
+ }
92
+
93
+ return routes;
94
+ }
95
+
96
+ // ============================================================================
97
+ // Static Site Generation
98
+ // ============================================================================
99
+
100
+ /**
101
+ * @typedef {Object} SSGOptions
102
+ * @property {string[]} [routes] - Routes to pre-render
103
+ * @property {string} [outDir='dist'] - Output directory
104
+ * @property {string} [template] - HTML template string or path
105
+ * @property {number} [concurrent=4] - Max concurrent renders
106
+ * @property {number} [timeout=10000] - Render timeout per route (ms)
107
+ * @property {boolean} [trailingSlash=true] - Add trailing slash to dirs
108
+ * @property {string} [fallback='404.html'] - Fallback page filename
109
+ * @property {Function} [getStaticPaths] - Dynamic route path generator
110
+ * @property {Function} [onPageGenerated] - Callback per page
111
+ */
112
+
113
+ /**
114
+ * @typedef {Object} SSGResult
115
+ * @property {number} totalRoutes - Number of routes rendered
116
+ * @property {number} successCount - Successful renders
117
+ * @property {number} errorCount - Failed renders
118
+ * @property {string[]} generatedFiles - List of generated file paths
119
+ * @property {Object[]} errors - Error details
120
+ * @property {number} duration - Total duration in ms
121
+ */
122
+
123
+ /**
124
+ * Generate static HTML files for all routes.
125
+ *
126
+ * @param {SSGOptions} options - SSG options
127
+ * @returns {Promise<SSGResult>}
128
+ */
129
+ export async function generateStaticSite(options = {}) {
130
+ const config = { ...DEFAULT_SSG_OPTIONS, ...options };
131
+ const timer = createTimer();
132
+ const result = {
133
+ totalRoutes: 0,
134
+ successCount: 0,
135
+ errorCount: 0,
136
+ generatedFiles: [],
137
+ errors: [],
138
+ duration: 0
139
+ };
140
+
141
+ const root = process.cwd();
142
+ const outDir = join(root, config.outDir);
143
+
144
+ // Discover routes
145
+ let routes = config.routes.length > 0
146
+ ? config.routes
147
+ : discoverRoutes({ srcDir: join(root, 'src') });
148
+
149
+ // Handle dynamic paths
150
+ if (config.getStaticPaths) {
151
+ const dynamicPaths = await config.getStaticPaths();
152
+ routes = [...new Set([...routes, ...dynamicPaths])];
153
+ }
154
+
155
+ result.totalRoutes = routes.length;
156
+
157
+ if (routes.length === 0) {
158
+ log.warn('No routes found for SSG. Add routes to pulse.config.js or use --routes flag.');
159
+ result.duration = timer.elapsed();
160
+ return result;
161
+ }
162
+
163
+ log.info(`\n Pre-rendering ${routes.length} route${routes.length > 1 ? 's' : ''}...\n`);
164
+
165
+ const progress = createProgressBar({
166
+ total: routes.length,
167
+ label: 'SSG',
168
+ width: 25
169
+ });
170
+
171
+ // Get HTML template
172
+ const template = config.template || getDefaultTemplate(root);
173
+
174
+ // Render routes with concurrency limit
175
+ const chunks = chunkArray(routes, config.concurrent);
176
+
177
+ for (const chunk of chunks) {
178
+ const promises = chunk.map(async (route) => {
179
+ try {
180
+ const html = await renderRoute(route, template, config);
181
+ const filePath = routeToFilePath(route, outDir, config.trailingSlash);
182
+
183
+ // Ensure directory exists
184
+ const dir = dirname(filePath);
185
+ if (!existsSync(dir)) {
186
+ mkdirSync(dir, { recursive: true });
187
+ }
188
+
189
+ writeFileSync(filePath, html, 'utf-8');
190
+ result.generatedFiles.push(filePath);
191
+ result.successCount++;
192
+
193
+ if (config.onPageGenerated) {
194
+ config.onPageGenerated({ route, filePath, html });
195
+ }
196
+ } catch (err) {
197
+ result.errors.push({ route, error: err.message });
198
+ result.errorCount++;
199
+ log.warn(` Failed to render ${route}: ${err.message}`);
200
+ }
201
+ progress.tick();
202
+ });
203
+
204
+ await Promise.all(promises);
205
+ }
206
+
207
+ progress.done();
208
+ result.duration = timer.elapsed();
209
+ return result;
210
+ }
211
+
212
+ /**
213
+ * Render a single route to HTML.
214
+ *
215
+ * @param {string} route - Route path
216
+ * @param {string} template - HTML template
217
+ * @param {Object} config - SSG config
218
+ * @returns {Promise<string>} Rendered HTML
219
+ */
220
+ async function renderRoute(route, template, config) {
221
+ // Dynamic import to avoid circular dependencies
222
+ const { renderToString, serializeState } = await import('../runtime/ssr.js');
223
+ const { setSSRMode } = await import('../runtime/ssr.js');
224
+
225
+ // Try to load the app module
226
+ let appFactory;
227
+ try {
228
+ const appModule = await import(join(process.cwd(), 'dist', 'assets', 'main.js'));
229
+ appFactory = appModule.default || appModule.App || appModule.createApp;
230
+ } catch {
231
+ // Fallback: render empty page
232
+ return template
233
+ .replace('<!--app-html-->', `<div data-ssg-route="${escapeRoute(route)}"></div>`)
234
+ .replace('<!--app-state-->', '');
235
+ }
236
+
237
+ if (!appFactory || typeof appFactory !== 'function') {
238
+ return template
239
+ .replace('<!--app-html-->', `<div data-ssg-route="${escapeRoute(route)}"></div>`)
240
+ .replace('<!--app-state-->', '');
241
+ }
242
+
243
+ const { html, state } = await renderToString(
244
+ () => appFactory({ route }),
245
+ { waitForAsync: true, timeout: config.timeout, serializeState: true }
246
+ );
247
+
248
+ // Inject into template
249
+ let page = template
250
+ .replace('<!--app-html-->', html)
251
+ .replace('<!--app-state-->',
252
+ state ? `<script>window.__PULSE_STATE__=${serializeState(state)};</script>` : ''
253
+ );
254
+
255
+ return page;
256
+ }
257
+
258
+ /**
259
+ * Get default HTML template from the project.
260
+ * @param {string} root - Project root
261
+ * @returns {string}
262
+ */
263
+ function getDefaultTemplate(root) {
264
+ const indexPath = join(root, 'dist', 'index.html');
265
+ if (existsSync(indexPath)) {
266
+ return readFileSync(indexPath, 'utf-8');
267
+ }
268
+
269
+ // Fallback template
270
+ return `<!DOCTYPE html>
271
+ <html lang="en">
272
+ <head>
273
+ <meta charset="UTF-8">
274
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
275
+ <title>Pulse App</title>
276
+ </head>
277
+ <body>
278
+ <div id="app"><!--app-html--></div>
279
+ <!--app-state-->
280
+ </body>
281
+ </html>`;
282
+ }
283
+
284
+ /**
285
+ * Escape a route string for safe embedding in HTML attributes.
286
+ * @param {string} route - Route path
287
+ * @returns {string} Escaped route
288
+ */
289
+ function escapeRoute(route) {
290
+ return String(route)
291
+ .replace(/&/g, '&amp;')
292
+ .replace(/"/g, '&quot;')
293
+ .replace(/</g, '&lt;')
294
+ .replace(/>/g, '&gt;');
295
+ }
296
+
297
+ /**
298
+ * Convert a route path to a file path.
299
+ * @param {string} route - Route path (e.g., '/about')
300
+ * @param {string} outDir - Output directory
301
+ * @param {boolean} trailingSlash - Whether to create index.html in directories
302
+ * @returns {string} File path
303
+ */
304
+ function routeToFilePath(route, outDir, trailingSlash) {
305
+ // Sanitize route: strip directory traversal sequences
306
+ const sanitized = route.replace(/\.\./g, '');
307
+ const normalized = sanitized === '/' ? '/index' : sanitized;
308
+
309
+ const resolvedOut = resolve(outDir);
310
+ const filePath = trailingSlash
311
+ ? resolve(outDir, normalized, 'index.html')
312
+ : resolve(outDir, `${normalized}.html`);
313
+
314
+ // Security: ensure output stays within outDir
315
+ if (!filePath.startsWith(resolvedOut + sep)) {
316
+ throw new Error(`Route "${route}" resolves outside output directory`);
317
+ }
318
+
319
+ return filePath;
320
+ }
321
+
322
+ /**
323
+ * Split array into chunks for concurrency control.
324
+ * @param {Array} arr - Array to chunk
325
+ * @param {number} size - Chunk size
326
+ * @returns {Array[]}
327
+ */
328
+ function chunkArray(arr, size) {
329
+ const chunks = [];
330
+ for (let i = 0; i < arr.length; i += size) {
331
+ chunks.push(arr.slice(i, i + size));
332
+ }
333
+ return chunks;
334
+ }
335
+
336
+ // ============================================================================
337
+ // Build Manifest Generation
338
+ // ============================================================================
339
+
340
+ /**
341
+ * @typedef {Object} ManifestEntry
342
+ * @property {string} entry - Entry file path
343
+ * @property {string[]} [css] - CSS file paths
344
+ * @property {boolean} [lazy] - Whether the route is lazy-loaded
345
+ * @property {string[]} [imports] - Import dependencies
346
+ */
347
+
348
+ /**
349
+ * Generate a build manifest from compiled output.
350
+ *
351
+ * @param {string} outDir - Build output directory
352
+ * @param {Object} [options] - Generation options
353
+ * @returns {Object} Build manifest
354
+ */
355
+ export function generateBuildManifest(outDir, options = {}) {
356
+ const { base = '/' } = options;
357
+ const manifest = {
358
+ base,
359
+ routes: {},
360
+ chunks: {},
361
+ generated: new Date().toISOString()
362
+ };
363
+
364
+ const assetsDir = join(outDir, 'assets');
365
+ if (!existsSync(assetsDir)) {
366
+ return manifest;
367
+ }
368
+
369
+ // Scan for JS and CSS files
370
+ try {
371
+ const files = readdirSync(assetsDir);
372
+
373
+ for (const file of files) {
374
+ if (file.endsWith('.js')) {
375
+ // Detect route-based chunks
376
+ const name = file.replace('.js', '');
377
+ if (name === 'main' || name === 'index') {
378
+ manifest.routes['/'] = {
379
+ entry: `/assets/${file}`,
380
+ css: findMatchingCSS(assetsDir, name, files)
381
+ };
382
+ } else {
383
+ manifest.routes[`/${name}`] = {
384
+ entry: `/assets/${file}`,
385
+ css: findMatchingCSS(assetsDir, name, files),
386
+ lazy: true
387
+ };
388
+ }
389
+ }
390
+ }
391
+ } catch {
392
+ // Output directory might not exist yet
393
+ }
394
+
395
+ return manifest;
396
+ }
397
+
398
+ /**
399
+ * Find matching CSS files for a JS chunk.
400
+ * @param {string} dir - Assets directory
401
+ * @param {string} name - Chunk name
402
+ * @param {string[]} files - All files in directory
403
+ * @returns {string[]}
404
+ */
405
+ function findMatchingCSS(dir, name, files) {
406
+ return files
407
+ .filter(f => f.endsWith('.css') && f.startsWith(name))
408
+ .map(f => `/assets/${f}`);
409
+ }
410
+
411
+ // ============================================================================
412
+ // CLI Integration
413
+ // ============================================================================
414
+
415
+ /**
416
+ * Run SSG from CLI arguments.
417
+ *
418
+ * @param {string[]} args - CLI arguments
419
+ * @returns {Promise<void>}
420
+ */
421
+ export async function runSSG(args) {
422
+ const timer = createTimer();
423
+ const spinner = createSpinner('Starting static site generation...');
424
+
425
+ // Parse CLI args
426
+ const options = parseSSGArgs(args);
427
+
428
+ try {
429
+ const result = await generateStaticSite(options);
430
+
431
+ spinner.success(`SSG complete in ${formatDuration(result.duration)}`);
432
+
433
+ log.success(`
434
+ Static Site Generation Complete
435
+ ─────────────────────────────────
436
+ Routes: ${result.totalRoutes}
437
+ Generated: ${result.successCount}
438
+ Errors: ${result.errorCount}
439
+ Duration: ${formatDuration(result.duration)}
440
+ `);
441
+
442
+ if (result.errors.length > 0) {
443
+ log.warn(' Failed routes:');
444
+ for (const { route, error } of result.errors) {
445
+ log.warn(` ${route}: ${error}`);
446
+ }
447
+ }
448
+ } catch (err) {
449
+ spinner.error(`SSG failed: ${err.message}`);
450
+ process.exit(1);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Parse SSG CLI arguments.
456
+ * @param {string[]} args - CLI arguments
457
+ * @returns {SSGOptions}
458
+ */
459
+ function parseSSGArgs(args) {
460
+ const options = {};
461
+
462
+ for (let i = 0; i < args.length; i++) {
463
+ const arg = args[i];
464
+
465
+ if (arg === '--routes' && args[i + 1]) {
466
+ options.routes = args[++i].split(',').map(r => r.trim());
467
+ } else if (arg === '--out-dir' && args[i + 1]) {
468
+ options.outDir = args[++i];
469
+ } else if (arg === '--concurrent' && args[i + 1]) {
470
+ options.concurrent = parseInt(args[++i], 10);
471
+ } else if (arg === '--timeout' && args[i + 1]) {
472
+ options.timeout = parseInt(args[++i], 10);
473
+ } else if (arg === '--no-trailing-slash') {
474
+ options.trailingSlash = false;
475
+ }
476
+ }
477
+
478
+ return options;
479
+ }
480
+
481
+ // ============================================================================
482
+ // Exports
483
+ // ============================================================================
484
+
485
+ export default {
486
+ discoverRoutes,
487
+ generateStaticSite,
488
+ generateBuildManifest,
489
+ runSSG
490
+ };
@@ -34,6 +34,10 @@ export const NodeType = {
34
34
  LiveDirective: 'LiveDirective',
35
35
  FocusTrapDirective: 'FocusTrapDirective',
36
36
 
37
+ // SSR directives
38
+ ClientDirective: 'ClientDirective',
39
+ ServerDirective: 'ServerDirective',
40
+
37
41
  Property: 'Property',
38
42
  ObjectLiteral: 'ObjectLiteral',
39
43
  ArrayLiteral: 'ArrayLiteral',
@@ -799,6 +803,14 @@ export class Parser {
799
803
  return this.parseSrOnlyDirective();
800
804
  }
801
805
 
806
+ // SSR directives
807
+ if (name === 'client') {
808
+ return new ASTNode(NodeType.ClientDirective, {});
809
+ }
810
+ if (name === 'server') {
811
+ return new ASTNode(NodeType.ServerDirective, {});
812
+ }
813
+
802
814
  // @model directive for two-way binding
803
815
  if (name === 'model') {
804
816
  return this.parseModelDirective(modifiers);
@@ -835,6 +847,14 @@ export class Parser {
835
847
  return this.parseSrOnlyDirective();
836
848
  }
837
849
 
850
+ // SSR directives
851
+ if (name === 'client') {
852
+ return new ASTNode(NodeType.ClientDirective, {});
853
+ }
854
+ if (name === 'server') {
855
+ return new ASTNode(NodeType.ServerDirective, {});
856
+ }
857
+
838
858
  // @model directive for two-way binding
839
859
  if (name === 'model') {
840
860
  return this.parseModelDirective(modifiers);