pulse-js-framework 1.9.0 → 1.9.2
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 +38 -0
- package/cli/index.js +12 -1
- package/cli/ssg.js +490 -0
- package/compiler/parser.js +20 -0
- package/compiler/transformer/view.js +80 -1
- package/package.json +17 -1
- package/runtime/ssr-mismatch.js +388 -0
- package/runtime/ssr-preload.js +228 -0
- package/runtime/ssr-serializer.js +3 -1
- package/runtime/ssr-stream.js +388 -0
- package/runtime/ssr.js +103 -3
- package/server/express.js +108 -0
- package/server/fastify.js +119 -0
- package/server/hono.js +107 -0
- package/server/index.js +208 -0
- package/server/utils.js +254 -0
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, '&')
|
|
292
|
+
.replace(/"/g, '"')
|
|
293
|
+
.replace(/</g, '<')
|
|
294
|
+
.replace(/>/g, '>');
|
|
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
|
+
};
|
package/compiler/parser.js
CHANGED
|
@@ -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);
|