what-framework-cli 0.1.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.
- package/package.json +31 -0
- package/src/cli.js +888 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "what-framework-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "What Framework CLI - Dev server, build, and deployment tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"what": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/cli.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cli",
|
|
18
|
+
"dev-server",
|
|
19
|
+
"build-tool",
|
|
20
|
+
"what-framework"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"what-framework": "^0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/aspect/what-fw.git"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// What Framework - CLI
|
|
4
|
+
// Commands: dev, build, preview, generate
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync, copyFileSync, realpathSync } from 'fs';
|
|
7
|
+
import { join, resolve, relative, extname, basename, normalize } from 'path';
|
|
8
|
+
import { createServer } from 'http';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
import { gzipSync } from 'zlib';
|
|
12
|
+
|
|
13
|
+
// Security: Prevent path traversal attacks
|
|
14
|
+
function safePath(base, userPath) {
|
|
15
|
+
try {
|
|
16
|
+
// Reject paths that contain .. segments (path traversal attempt)
|
|
17
|
+
const normalized = normalize(userPath);
|
|
18
|
+
if (normalized.startsWith('..') || normalized.includes('/..') || normalized.includes('\\..')) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get the real base path (resolve symlinks)
|
|
23
|
+
const realBase = realpathSync(base);
|
|
24
|
+
|
|
25
|
+
// Resolve the user path against the base
|
|
26
|
+
const resolved = resolve(realBase, normalized);
|
|
27
|
+
|
|
28
|
+
// Double-check: ensure resolved path is within base
|
|
29
|
+
if (!resolved.startsWith(realBase + '/') && resolved !== realBase) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return resolved;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Simple WebSocket implementation using native Node.js APIs (no external deps)
|
|
40
|
+
class SimpleWebSocketServer {
|
|
41
|
+
constructor({ server }) {
|
|
42
|
+
this.clients = new Set();
|
|
43
|
+
server.on('upgrade', (req, socket, head) => {
|
|
44
|
+
if (req.headers.upgrade?.toLowerCase() !== 'websocket') return;
|
|
45
|
+
|
|
46
|
+
const key = req.headers['sec-websocket-key'];
|
|
47
|
+
const accept = createHash('sha1')
|
|
48
|
+
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
49
|
+
.digest('base64');
|
|
50
|
+
|
|
51
|
+
socket.write([
|
|
52
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
53
|
+
'Upgrade: websocket',
|
|
54
|
+
'Connection: Upgrade',
|
|
55
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
56
|
+
'', ''
|
|
57
|
+
].join('\r\n'));
|
|
58
|
+
|
|
59
|
+
const client = new SimpleWebSocket(socket);
|
|
60
|
+
this.clients.add(client);
|
|
61
|
+
client.onclose = () => this.clients.delete(client);
|
|
62
|
+
this.onconnection?.(client);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
on(event, handler) {
|
|
66
|
+
if (event === 'connection') this.onconnection = handler;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class SimpleWebSocket {
|
|
71
|
+
constructor(socket) {
|
|
72
|
+
this.socket = socket;
|
|
73
|
+
this.socket.on('close', () => this.onclose?.());
|
|
74
|
+
this.socket.on('error', () => this.onclose?.());
|
|
75
|
+
this.socket.on('data', (data) => this._handleData(data));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_handleData(buffer) {
|
|
79
|
+
// Simple WebSocket frame parsing (text frames only)
|
|
80
|
+
try {
|
|
81
|
+
const firstByte = buffer[0];
|
|
82
|
+
const opcode = firstByte & 0x0f;
|
|
83
|
+
if (opcode === 0x08) { this.socket.end(); return; } // Close frame
|
|
84
|
+
|
|
85
|
+
const secondByte = buffer[1];
|
|
86
|
+
let payloadLength = secondByte & 0x7f;
|
|
87
|
+
let offset = 2;
|
|
88
|
+
|
|
89
|
+
if (payloadLength === 126) {
|
|
90
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
91
|
+
offset = 4;
|
|
92
|
+
} else if (payloadLength === 127) {
|
|
93
|
+
payloadLength = Number(buffer.readBigUInt64BE(2));
|
|
94
|
+
offset = 10;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const masked = (secondByte & 0x80) !== 0;
|
|
98
|
+
let maskKey;
|
|
99
|
+
if (masked) {
|
|
100
|
+
maskKey = buffer.slice(offset, offset + 4);
|
|
101
|
+
offset += 4;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let payload = buffer.slice(offset, offset + payloadLength);
|
|
105
|
+
if (masked) {
|
|
106
|
+
for (let i = 0; i < payload.length; i++) {
|
|
107
|
+
payload[i] ^= maskKey[i % 4];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (opcode === 0x01) { // Text frame
|
|
112
|
+
this.onmessage?.({ data: payload.toString('utf8') });
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
send(data) {
|
|
118
|
+
try {
|
|
119
|
+
const payload = Buffer.from(data, 'utf8');
|
|
120
|
+
const length = payload.length;
|
|
121
|
+
let header;
|
|
122
|
+
|
|
123
|
+
if (length < 126) {
|
|
124
|
+
header = Buffer.alloc(2);
|
|
125
|
+
header[0] = 0x81; // FIN + text opcode
|
|
126
|
+
header[1] = length;
|
|
127
|
+
} else if (length < 65536) {
|
|
128
|
+
header = Buffer.alloc(4);
|
|
129
|
+
header[0] = 0x81;
|
|
130
|
+
header[1] = 126;
|
|
131
|
+
header.writeUInt16BE(length, 2);
|
|
132
|
+
} else {
|
|
133
|
+
header = Buffer.alloc(10);
|
|
134
|
+
header[0] = 0x81;
|
|
135
|
+
header[1] = 127;
|
|
136
|
+
header.writeBigUInt64BE(BigInt(length), 2);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.socket.write(Buffer.concat([header, payload]));
|
|
140
|
+
} catch (e) {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
close() {
|
|
144
|
+
try {
|
|
145
|
+
const closeFrame = Buffer.from([0x88, 0x00]);
|
|
146
|
+
this.socket.write(closeFrame);
|
|
147
|
+
this.socket.end();
|
|
148
|
+
} catch (e) {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
153
|
+
const cwd = process.cwd();
|
|
154
|
+
|
|
155
|
+
const args = process.argv.slice(2);
|
|
156
|
+
const command = args[0];
|
|
157
|
+
|
|
158
|
+
const commands = { dev, build, preview, generate, init };
|
|
159
|
+
|
|
160
|
+
if (!command || !commands[command]) {
|
|
161
|
+
console.log(`
|
|
162
|
+
what - The closest framework to vanilla JS
|
|
163
|
+
|
|
164
|
+
Usage: what <command>
|
|
165
|
+
|
|
166
|
+
Commands:
|
|
167
|
+
dev Start dev server with HMR
|
|
168
|
+
build Production build
|
|
169
|
+
preview Preview production build
|
|
170
|
+
generate Static site generation
|
|
171
|
+
init Create a new project
|
|
172
|
+
|
|
173
|
+
Options:
|
|
174
|
+
--port Dev server port (default: 3000)
|
|
175
|
+
--host Dev server host (default: localhost)
|
|
176
|
+
`);
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
commands[command]();
|
|
181
|
+
|
|
182
|
+
// --- Dev Server ---
|
|
183
|
+
|
|
184
|
+
async function dev() {
|
|
185
|
+
const port = getFlag('--port', 3000);
|
|
186
|
+
const host = getFlag('--host', 'localhost');
|
|
187
|
+
const config = loadConfig();
|
|
188
|
+
|
|
189
|
+
const server = createServer(async (req, res) => {
|
|
190
|
+
const url = new URL(req.url, `http://${host}:${port}`);
|
|
191
|
+
let pathname = url.pathname;
|
|
192
|
+
|
|
193
|
+
// Handle server actions
|
|
194
|
+
if (pathname === '/__what_action' && req.method === 'POST') {
|
|
195
|
+
const actionId = req.headers['x-what-action'];
|
|
196
|
+
let body = '';
|
|
197
|
+
req.on('data', chunk => body += chunk);
|
|
198
|
+
req.on('end', async () => {
|
|
199
|
+
try {
|
|
200
|
+
const { args } = JSON.parse(body);
|
|
201
|
+
// In production, this would call the registered action
|
|
202
|
+
// For dev, we'll return a placeholder response
|
|
203
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
204
|
+
res.end(JSON.stringify({
|
|
205
|
+
_action: actionId,
|
|
206
|
+
_dev: true,
|
|
207
|
+
message: 'Server actions require production build with action registration',
|
|
208
|
+
}));
|
|
209
|
+
} catch (e) {
|
|
210
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
211
|
+
res.end(JSON.stringify({ message: e.message }));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Serve framework modules
|
|
218
|
+
if (pathname.startsWith('/@what/')) {
|
|
219
|
+
const modName = pathname.slice(7);
|
|
220
|
+
const modPath = resolveFrameworkModule(modName);
|
|
221
|
+
if (modPath) {
|
|
222
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
223
|
+
res.end(readFileSync(modPath, 'utf-8'));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Serve static files from public/
|
|
229
|
+
const publicDir = join(cwd, 'public');
|
|
230
|
+
const publicPath = existsSync(publicDir) ? safePath(publicDir, pathname) : null;
|
|
231
|
+
if (publicPath && existsSync(publicPath) && statSync(publicPath).isFile()) {
|
|
232
|
+
serveFile(res, publicPath);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Serve source files (JS, CSS) with transforms
|
|
237
|
+
const srcDir = join(cwd, 'src');
|
|
238
|
+
const srcPath = existsSync(srcDir) ? safePath(srcDir, pathname) : null;
|
|
239
|
+
if (srcPath && existsSync(srcPath) && statSync(srcPath).isFile()) {
|
|
240
|
+
const ext = extname(srcPath);
|
|
241
|
+
if (ext === '.js' || ext === '.mjs') {
|
|
242
|
+
res.writeHead(200, {
|
|
243
|
+
'Content-Type': 'application/javascript',
|
|
244
|
+
'Cache-Control': 'no-cache',
|
|
245
|
+
});
|
|
246
|
+
let code = readFileSync(srcPath, 'utf-8');
|
|
247
|
+
// Transform bare imports to /@what/ paths
|
|
248
|
+
code = transformImports(code);
|
|
249
|
+
res.end(code);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
serveFile(res, srcPath);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Try pages directory for route matching
|
|
257
|
+
const page = resolvePageFile(pathname, config);
|
|
258
|
+
if (page) {
|
|
259
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
260
|
+
res.end(await renderDevPage(page, pathname, config));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// SPA fallback: serve index.html for all routes
|
|
265
|
+
const indexPath = join(cwd, 'src', 'index.html');
|
|
266
|
+
if (existsSync(indexPath)) {
|
|
267
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
268
|
+
let html = readFileSync(indexPath, 'utf-8');
|
|
269
|
+
html = injectDevClient(html);
|
|
270
|
+
res.end(html);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
res.writeHead(404);
|
|
275
|
+
res.end('Not found');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// WebSocket server for HMR (zero dependencies)
|
|
279
|
+
const wsClients = new Set();
|
|
280
|
+
|
|
281
|
+
server.listen(port, host, () => {
|
|
282
|
+
console.log(`\n what dev server\n`);
|
|
283
|
+
console.log(` Local: http://${host}:${port}`);
|
|
284
|
+
console.log(` Mode: ${config.mode || 'hybrid'}`);
|
|
285
|
+
console.log(` Pages: ${config.pagesDir || 'src/pages'}`);
|
|
286
|
+
console.log(` HMR: WebSocket (instant reload)\n`);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Initialize WebSocket server
|
|
290
|
+
const wss = new SimpleWebSocketServer({ server });
|
|
291
|
+
wss.on('connection', (ws) => {
|
|
292
|
+
wsClients.add(ws);
|
|
293
|
+
ws.onclose = () => wsClients.delete(ws);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Watch for file changes with instant WebSocket notification
|
|
297
|
+
if (config.hmr !== false) {
|
|
298
|
+
watchFiles(cwd, (changedFiles) => {
|
|
299
|
+
const message = JSON.stringify({
|
|
300
|
+
type: 'update',
|
|
301
|
+
files: changedFiles,
|
|
302
|
+
timestamp: Date.now(),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Notify all connected clients instantly
|
|
306
|
+
for (const client of wsClients) {
|
|
307
|
+
try {
|
|
308
|
+
client.send(message);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
wsClients.delete(client);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- Build ---
|
|
318
|
+
|
|
319
|
+
async function build() {
|
|
320
|
+
const config = loadConfig();
|
|
321
|
+
const outDir = join(cwd, config.outDir || 'dist');
|
|
322
|
+
const useHash = config.hash !== false;
|
|
323
|
+
const hashManifest = {};
|
|
324
|
+
|
|
325
|
+
console.log('\n what build\n');
|
|
326
|
+
if (useHash) console.log(' Hash: Enabled (cache busting)\n');
|
|
327
|
+
|
|
328
|
+
mkdirSync(outDir, { recursive: true });
|
|
329
|
+
|
|
330
|
+
// Collect all source files
|
|
331
|
+
const srcDir = join(cwd, 'src');
|
|
332
|
+
const files = collectFiles(srcDir);
|
|
333
|
+
|
|
334
|
+
let totalSize = 0;
|
|
335
|
+
let gzipSize = 0;
|
|
336
|
+
|
|
337
|
+
for (const file of files) {
|
|
338
|
+
const rel = relative(srcDir, file);
|
|
339
|
+
const ext = extname(file);
|
|
340
|
+
let outPath = join(outDir, rel);
|
|
341
|
+
|
|
342
|
+
mkdirSync(join(outDir, relative(srcDir, join(file, '..'))), { recursive: true });
|
|
343
|
+
|
|
344
|
+
if (ext === '.js' || ext === '.mjs') {
|
|
345
|
+
let code = readFileSync(file, 'utf-8');
|
|
346
|
+
code = transformImports(code);
|
|
347
|
+
code = minifyJS(code);
|
|
348
|
+
|
|
349
|
+
// Add content hash to filename
|
|
350
|
+
if (useHash && !rel.includes('index')) {
|
|
351
|
+
const hash = contentHash(code);
|
|
352
|
+
const hashedName = addHash(rel, hash);
|
|
353
|
+
outPath = join(outDir, hashedName);
|
|
354
|
+
hashManifest[rel] = hashedName;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
writeFileSync(outPath, code);
|
|
358
|
+
totalSize += code.length;
|
|
359
|
+
|
|
360
|
+
// Create gzipped version
|
|
361
|
+
const gzipped = gzipSync(code);
|
|
362
|
+
writeFileSync(outPath + '.gz', gzipped);
|
|
363
|
+
gzipSize += gzipped.length;
|
|
364
|
+
} else if (ext === '.html') {
|
|
365
|
+
let html = readFileSync(file, 'utf-8');
|
|
366
|
+
html = minifyHTML(html);
|
|
367
|
+
|
|
368
|
+
// Replace references with hashed versions
|
|
369
|
+
if (useHash) {
|
|
370
|
+
for (const [original, hashed] of Object.entries(hashManifest)) {
|
|
371
|
+
html = html.replace(new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), hashed);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
writeFileSync(outPath, html);
|
|
375
|
+
totalSize += html.length;
|
|
376
|
+
} else {
|
|
377
|
+
copyFileSync(file, outPath);
|
|
378
|
+
totalSize += statSync(file).size;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Copy public dir
|
|
383
|
+
const publicDir = join(cwd, 'public');
|
|
384
|
+
if (existsSync(publicDir)) {
|
|
385
|
+
const pubFiles = collectFiles(publicDir);
|
|
386
|
+
for (const file of pubFiles) {
|
|
387
|
+
const rel = relative(publicDir, file);
|
|
388
|
+
const outPath = join(outDir, rel);
|
|
389
|
+
mkdirSync(join(outDir, relative(publicDir, join(file, '..'))), { recursive: true });
|
|
390
|
+
copyFileSync(file, outPath);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Bundle the framework runtime
|
|
395
|
+
bundleRuntime(outDir, useHash, hashManifest);
|
|
396
|
+
|
|
397
|
+
// Write manifest for production use
|
|
398
|
+
if (useHash && Object.keys(hashManifest).length > 0) {
|
|
399
|
+
writeFileSync(
|
|
400
|
+
join(outDir, 'manifest.json'),
|
|
401
|
+
JSON.stringify(hashManifest, null, 2)
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
console.log(` Output: ${relative(cwd, outDir)}/`);
|
|
406
|
+
console.log(` Size: ${formatSize(totalSize)} (${formatSize(gzipSize)} gzip)`);
|
|
407
|
+
console.log(` Files: ${files.length}`);
|
|
408
|
+
if (useHash) {
|
|
409
|
+
console.log(` Hashed: ${Object.keys(hashManifest).length} files`);
|
|
410
|
+
}
|
|
411
|
+
console.log();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// --- Preview ---
|
|
415
|
+
|
|
416
|
+
function preview() {
|
|
417
|
+
const config = loadConfig();
|
|
418
|
+
const outDir = join(cwd, config.outDir || 'dist');
|
|
419
|
+
const port = getFlag('--port', 4000);
|
|
420
|
+
|
|
421
|
+
if (!existsSync(outDir)) {
|
|
422
|
+
console.error(' No build found. Run `what build` first.');
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const server = createServer((req, res) => {
|
|
427
|
+
let pathname = new URL(req.url, `http://localhost:${port}`).pathname;
|
|
428
|
+
if (pathname === '/') pathname = '/index.html';
|
|
429
|
+
|
|
430
|
+
// Security: Prevent path traversal
|
|
431
|
+
const filePath = safePath(outDir, pathname);
|
|
432
|
+
if (filePath && existsSync(filePath) && statSync(filePath).isFile()) {
|
|
433
|
+
serveFile(res, filePath);
|
|
434
|
+
} else {
|
|
435
|
+
// SPA fallback
|
|
436
|
+
const indexPath = join(outDir, 'index.html');
|
|
437
|
+
if (existsSync(indexPath)) {
|
|
438
|
+
serveFile(res, indexPath);
|
|
439
|
+
} else {
|
|
440
|
+
res.writeHead(404);
|
|
441
|
+
res.end('Not found');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
server.listen(port, () => {
|
|
447
|
+
console.log(`\n what preview\n`);
|
|
448
|
+
console.log(` Local: http://localhost:${port}\n`);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- Static Generation ---
|
|
453
|
+
|
|
454
|
+
async function generate() {
|
|
455
|
+
const config = loadConfig();
|
|
456
|
+
const outDir = join(cwd, config.outDir || 'dist');
|
|
457
|
+
|
|
458
|
+
console.log('\n what generate (SSG)\n');
|
|
459
|
+
|
|
460
|
+
// First do a normal build
|
|
461
|
+
await build();
|
|
462
|
+
|
|
463
|
+
// Then pre-render all pages
|
|
464
|
+
const pagesDir = join(cwd, config.pagesDir || 'src/pages');
|
|
465
|
+
if (existsSync(pagesDir)) {
|
|
466
|
+
const pages = collectFiles(pagesDir).filter(f => extname(f) === '.js');
|
|
467
|
+
for (const page of pages) {
|
|
468
|
+
const route = fileToRoute(relative(pagesDir, page));
|
|
469
|
+
console.log(` Pre-rendering: ${route}`);
|
|
470
|
+
// In full impl: import page, call renderToString, write HTML
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
console.log('\n Static generation complete.\n');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- Init ---
|
|
478
|
+
|
|
479
|
+
function init() {
|
|
480
|
+
const name = args[1] || 'my-what-app';
|
|
481
|
+
const dir = join(cwd, name);
|
|
482
|
+
|
|
483
|
+
if (existsSync(dir)) {
|
|
484
|
+
console.error(` Directory "${name}" already exists.`);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
console.log(`\n Creating ${name}...\n`);
|
|
489
|
+
|
|
490
|
+
mkdirSync(join(dir, 'src/pages'), { recursive: true });
|
|
491
|
+
mkdirSync(join(dir, 'src/components'), { recursive: true });
|
|
492
|
+
mkdirSync(join(dir, 'public'), { recursive: true });
|
|
493
|
+
|
|
494
|
+
writeFileSync(join(dir, 'what.config.js'), `export default {
|
|
495
|
+
mode: 'hybrid', // 'static' | 'server' | 'client' | 'hybrid'
|
|
496
|
+
};
|
|
497
|
+
`);
|
|
498
|
+
|
|
499
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
|
500
|
+
name,
|
|
501
|
+
private: true,
|
|
502
|
+
type: 'module',
|
|
503
|
+
scripts: {
|
|
504
|
+
dev: 'what dev',
|
|
505
|
+
build: 'what build',
|
|
506
|
+
preview: 'what preview',
|
|
507
|
+
generate: 'what generate',
|
|
508
|
+
},
|
|
509
|
+
dependencies: {
|
|
510
|
+
'what-fw': '^0.1.0',
|
|
511
|
+
},
|
|
512
|
+
}, null, 2));
|
|
513
|
+
|
|
514
|
+
console.log(` Done! Next steps:\n`);
|
|
515
|
+
console.log(` cd ${name}`);
|
|
516
|
+
console.log(` npm install`);
|
|
517
|
+
console.log(` npm run dev\n`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// --- Helpers ---
|
|
521
|
+
|
|
522
|
+
function getFlag(name, defaultValue) {
|
|
523
|
+
const idx = args.indexOf(name);
|
|
524
|
+
if (idx === -1) return defaultValue;
|
|
525
|
+
const val = args[idx + 1];
|
|
526
|
+
return typeof defaultValue === 'number' ? Number(val) : val;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function loadConfig() {
|
|
530
|
+
const configPath = join(cwd, 'what.config.js');
|
|
531
|
+
// Simple sync config load (no dynamic import in this context)
|
|
532
|
+
if (existsSync(configPath)) {
|
|
533
|
+
try {
|
|
534
|
+
// Read and extract basic config
|
|
535
|
+
const src = readFileSync(configPath, 'utf-8');
|
|
536
|
+
const match = src.match(/export default\s*(\{[\s\S]*?\})/);
|
|
537
|
+
if (match) {
|
|
538
|
+
return new Function(`return ${match[1]}`)();
|
|
539
|
+
}
|
|
540
|
+
} catch (e) { /* use defaults */ }
|
|
541
|
+
}
|
|
542
|
+
return { mode: 'hybrid', pagesDir: 'src/pages', outDir: 'dist' };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function collectFiles(dir) {
|
|
546
|
+
const files = [];
|
|
547
|
+
if (!existsSync(dir)) return files;
|
|
548
|
+
for (const entry of readdirSync(dir)) {
|
|
549
|
+
if (entry.startsWith('.')) continue;
|
|
550
|
+
const full = join(dir, entry);
|
|
551
|
+
if (statSync(full).isDirectory()) {
|
|
552
|
+
files.push(...collectFiles(full));
|
|
553
|
+
} else {
|
|
554
|
+
files.push(full);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return files;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function resolvePageFile(pathname, config) {
|
|
561
|
+
const pagesDir = join(cwd, config.pagesDir || 'src/pages');
|
|
562
|
+
if (!existsSync(pagesDir)) return null;
|
|
563
|
+
|
|
564
|
+
// Try exact match
|
|
565
|
+
const exact = join(pagesDir, pathname + '.js');
|
|
566
|
+
if (existsSync(exact)) return exact;
|
|
567
|
+
|
|
568
|
+
// Try index
|
|
569
|
+
const index = join(pagesDir, pathname, 'index.js');
|
|
570
|
+
if (existsSync(index)) return index;
|
|
571
|
+
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function fileToRoute(filepath) {
|
|
576
|
+
return '/' + filepath
|
|
577
|
+
.replace(/\.js$/, '')
|
|
578
|
+
.replace(/\/index$/, '')
|
|
579
|
+
.replace(/\[(\w+)\]/g, ':$1')
|
|
580
|
+
.replace(/\[\.\.\.(\w+)\]/g, '*');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function renderDevPage(pagePath, pathname, config) {
|
|
584
|
+
const route = relative(join(cwd, config.pagesDir || 'src/pages'), pagePath);
|
|
585
|
+
return `<!DOCTYPE html>
|
|
586
|
+
<html lang="en">
|
|
587
|
+
<head>
|
|
588
|
+
<meta charset="UTF-8">
|
|
589
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
590
|
+
<title>What App</title>
|
|
591
|
+
</head>
|
|
592
|
+
<body>
|
|
593
|
+
<div id="app"></div>
|
|
594
|
+
<script type="module">
|
|
595
|
+
import { mount } from '/@what/core.js';
|
|
596
|
+
const mod = await import('/pages/${route}');
|
|
597
|
+
const Page = mod.default || mod;
|
|
598
|
+
mount(Page(), '#app');
|
|
599
|
+
</script>
|
|
600
|
+
</body>
|
|
601
|
+
</html>`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function injectDevClient(html) {
|
|
605
|
+
const devScript = `<script type="module">
|
|
606
|
+
// What HMR client - WebSocket with polling fallback
|
|
607
|
+
const wsUrl = 'ws://' + location.host;
|
|
608
|
+
let ws = null;
|
|
609
|
+
let reconnectTimer = null;
|
|
610
|
+
let reconnectAttempts = 0;
|
|
611
|
+
const maxReconnectAttempts = 10;
|
|
612
|
+
|
|
613
|
+
function connect() {
|
|
614
|
+
try {
|
|
615
|
+
ws = new WebSocket(wsUrl);
|
|
616
|
+
|
|
617
|
+
ws.onopen = () => {
|
|
618
|
+
console.log('[what] HMR connected');
|
|
619
|
+
reconnectAttempts = 0;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
ws.onmessage = (event) => {
|
|
623
|
+
try {
|
|
624
|
+
const data = JSON.parse(event.data);
|
|
625
|
+
if (data.type === 'update') {
|
|
626
|
+
console.log('[what] Files changed:', data.files.join(', '));
|
|
627
|
+
// Smart reload: check if we can hot-swap or need full reload
|
|
628
|
+
const needsFullReload = data.files.some(f =>
|
|
629
|
+
f.endsWith('.html') || f.includes('/pages/') || f.includes('index.')
|
|
630
|
+
);
|
|
631
|
+
if (needsFullReload) {
|
|
632
|
+
location.reload();
|
|
633
|
+
} else {
|
|
634
|
+
// For CSS and some JS, we could do hot updates
|
|
635
|
+
// For now, reload but this is where HMR logic would go
|
|
636
|
+
location.reload();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} catch (e) {}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
ws.onclose = () => {
|
|
643
|
+
ws = null;
|
|
644
|
+
scheduleReconnect();
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
ws.onerror = () => {
|
|
648
|
+
ws?.close();
|
|
649
|
+
};
|
|
650
|
+
} catch (e) {
|
|
651
|
+
scheduleReconnect();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function scheduleReconnect() {
|
|
656
|
+
if (reconnectTimer || reconnectAttempts >= maxReconnectAttempts) return;
|
|
657
|
+
reconnectAttempts++;
|
|
658
|
+
const delay = Math.min(1000 * Math.pow(1.5, reconnectAttempts), 10000);
|
|
659
|
+
reconnectTimer = setTimeout(() => {
|
|
660
|
+
reconnectTimer = null;
|
|
661
|
+
connect();
|
|
662
|
+
}, delay);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Initial connection
|
|
666
|
+
connect();
|
|
667
|
+
|
|
668
|
+
// Fallback: if no WebSocket update in 5s, poll
|
|
669
|
+
let lastActivity = Date.now();
|
|
670
|
+
setInterval(() => {
|
|
671
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
672
|
+
lastActivity = Date.now();
|
|
673
|
+
} else if (Date.now() - lastActivity > 5000) {
|
|
674
|
+
// Polling fallback
|
|
675
|
+
fetch('/__what_hmr?t=' + Date.now()).then(r => r.json()).then(data => {
|
|
676
|
+
if (data.reload) location.reload();
|
|
677
|
+
}).catch(() => {});
|
|
678
|
+
}
|
|
679
|
+
}, 2000);
|
|
680
|
+
</script>`;
|
|
681
|
+
return html.replace('</body>', devScript + '\n</body>');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function resolveFrameworkModule(name) {
|
|
685
|
+
const whatDir = resolve(__dirname, '../../what/src');
|
|
686
|
+
const coreDir = resolve(__dirname, '../../core/src');
|
|
687
|
+
const routerDir = resolve(__dirname, '../../router/src');
|
|
688
|
+
const serverDir = resolve(__dirname, '../../server/src');
|
|
689
|
+
|
|
690
|
+
const map = {
|
|
691
|
+
'core.js': join(whatDir, 'index.js'),
|
|
692
|
+
'reactive.js': join(coreDir, 'reactive.js'),
|
|
693
|
+
'router.js': join(whatDir, 'router.js'),
|
|
694
|
+
'server.js': join(whatDir, 'server.js'),
|
|
695
|
+
'islands.js': join(serverDir, 'islands.js'),
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
return map[name] || null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function transformImports(code) {
|
|
702
|
+
// Transform: import { x } from 'what' -> from '/@what/core.js'
|
|
703
|
+
return code
|
|
704
|
+
.replace(/from\s+['"]what['"]/g, "from '/@what/core.js'")
|
|
705
|
+
.replace(/from\s+['"]what\/router['"]/g, "from '/@what/router.js'")
|
|
706
|
+
.replace(/from\s+['"]what\/server['"]/g, "from '/@what/islands.js'");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function minifyJS(code) {
|
|
710
|
+
// Lightweight minification: strip comments, collapse whitespace
|
|
711
|
+
return code
|
|
712
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
|
713
|
+
.replace(/\/\/[^\n]*/g, '') // line comments
|
|
714
|
+
.replace(/^\s+/gm, '') // leading whitespace
|
|
715
|
+
.replace(/\n\s*\n/g, '\n') // empty lines
|
|
716
|
+
.trim();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function minifyHTML(html) {
|
|
720
|
+
return html
|
|
721
|
+
.replace(/<!--[\s\S]*?-->/g, '') // comments
|
|
722
|
+
.replace(/\s{2,}/g, ' ') // collapse whitespace
|
|
723
|
+
.replace(/>\s+</g, '><') // between tags
|
|
724
|
+
.trim();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function contentHash(content) {
|
|
728
|
+
return createHash('md5').update(content).digest('hex').slice(0, 8);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function addHash(filename, hash) {
|
|
732
|
+
const ext = extname(filename);
|
|
733
|
+
const base = filename.slice(0, -ext.length);
|
|
734
|
+
return `${base}.${hash}${ext}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function bundleRuntime(outDir, useHash = false, hashManifest = {}) {
|
|
738
|
+
// Copy framework runtime into output for production
|
|
739
|
+
const whatDir = resolve(__dirname, '../../what/src');
|
|
740
|
+
const coreDir = resolve(__dirname, '../../core/src');
|
|
741
|
+
const routerDir = resolve(__dirname, '../../router/src');
|
|
742
|
+
const serverDir = resolve(__dirname, '../../server/src');
|
|
743
|
+
const runtimeDir = join(outDir, '@what');
|
|
744
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
745
|
+
|
|
746
|
+
// Core modules
|
|
747
|
+
const coreModules = [
|
|
748
|
+
'reactive.js', 'h.js', 'dom.js', 'hooks.js',
|
|
749
|
+
'components.js', 'store.js', 'helpers.js', 'scheduler.js',
|
|
750
|
+
'animation.js', 'a11y.js', 'skeleton.js', 'data.js', 'form.js'
|
|
751
|
+
];
|
|
752
|
+
|
|
753
|
+
// Bundle main entry point
|
|
754
|
+
const whatFiles = [
|
|
755
|
+
{ src: join(whatDir, 'index.js'), out: 'core.js' },
|
|
756
|
+
{ src: join(whatDir, 'router.js'), out: 'router.js' },
|
|
757
|
+
{ src: join(whatDir, 'server.js'), out: 'server.js' },
|
|
758
|
+
];
|
|
759
|
+
|
|
760
|
+
for (const { src, out } of whatFiles) {
|
|
761
|
+
if (existsSync(src)) {
|
|
762
|
+
let code = readFileSync(src, 'utf-8');
|
|
763
|
+
code = minifyJS(code);
|
|
764
|
+
let outName = out;
|
|
765
|
+
|
|
766
|
+
if (useHash) {
|
|
767
|
+
const hash = contentHash(code);
|
|
768
|
+
const hashedName = addHash(outName, hash);
|
|
769
|
+
hashManifest[`@what/${outName}`] = `@what/${hashedName}`;
|
|
770
|
+
outName = hashedName;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
writeFileSync(join(runtimeDir, outName), code);
|
|
774
|
+
const gzipped = gzipSync(code);
|
|
775
|
+
writeFileSync(join(runtimeDir, outName + '.gz'), gzipped);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Bundle core modules
|
|
780
|
+
for (const mod of coreModules) {
|
|
781
|
+
const src = join(coreDir, mod);
|
|
782
|
+
if (existsSync(src)) {
|
|
783
|
+
let code = readFileSync(src, 'utf-8');
|
|
784
|
+
code = minifyJS(code);
|
|
785
|
+
let outName = mod;
|
|
786
|
+
|
|
787
|
+
if (useHash) {
|
|
788
|
+
const hash = contentHash(code);
|
|
789
|
+
const hashedName = addHash(outName, hash);
|
|
790
|
+
hashManifest[`@what/${outName}`] = `@what/${hashedName}`;
|
|
791
|
+
outName = hashedName;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
writeFileSync(join(runtimeDir, outName), code);
|
|
795
|
+
const gzipped = gzipSync(code);
|
|
796
|
+
writeFileSync(join(runtimeDir, outName + '.gz'), gzipped);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Bundle router
|
|
801
|
+
const routerSrc = join(routerDir, 'index.js');
|
|
802
|
+
if (existsSync(routerSrc)) {
|
|
803
|
+
let code = readFileSync(routerSrc, 'utf-8');
|
|
804
|
+
code = minifyJS(code);
|
|
805
|
+
writeFileSync(join(runtimeDir, 'router-impl.js'), code);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Bundle islands
|
|
809
|
+
const islandsSrc = join(serverDir, 'islands.js');
|
|
810
|
+
if (existsSync(islandsSrc)) {
|
|
811
|
+
let code = readFileSync(islandsSrc, 'utf-8');
|
|
812
|
+
code = minifyJS(code);
|
|
813
|
+
writeFileSync(join(runtimeDir, 'islands.js'), code);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function serveFile(res, filepath) {
|
|
818
|
+
const ext = extname(filepath);
|
|
819
|
+
const types = {
|
|
820
|
+
'.html': 'text/html',
|
|
821
|
+
'.js': 'application/javascript',
|
|
822
|
+
'.mjs': 'application/javascript',
|
|
823
|
+
'.css': 'text/css',
|
|
824
|
+
'.json': 'application/json',
|
|
825
|
+
'.png': 'image/png',
|
|
826
|
+
'.jpg': 'image/jpeg',
|
|
827
|
+
'.svg': 'image/svg+xml',
|
|
828
|
+
'.ico': 'image/x-icon',
|
|
829
|
+
'.woff2': 'font/woff2',
|
|
830
|
+
'.woff': 'font/woff',
|
|
831
|
+
};
|
|
832
|
+
res.writeHead(200, {
|
|
833
|
+
'Content-Type': types[ext] || 'application/octet-stream',
|
|
834
|
+
'Cache-Control': 'no-cache',
|
|
835
|
+
});
|
|
836
|
+
res.end(readFileSync(filepath));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function formatSize(bytes) {
|
|
840
|
+
if (bytes < 1024) return bytes + ' B';
|
|
841
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
|
|
842
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function watchFiles(dir, onChange) {
|
|
846
|
+
// Simple polling watcher with change tracking — no native deps
|
|
847
|
+
const files = new Map();
|
|
848
|
+
let initialized = false;
|
|
849
|
+
|
|
850
|
+
function scan() {
|
|
851
|
+
const current = collectFiles(join(dir, 'src'));
|
|
852
|
+
const changedFiles = [];
|
|
853
|
+
|
|
854
|
+
for (const f of current) {
|
|
855
|
+
try {
|
|
856
|
+
const mtime = statSync(f).mtimeMs;
|
|
857
|
+
if (files.get(f) !== mtime) {
|
|
858
|
+
if (initialized) {
|
|
859
|
+
changedFiles.push(relative(dir, f));
|
|
860
|
+
}
|
|
861
|
+
files.set(f, mtime);
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
// File was deleted during scan
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Detect deleted files
|
|
869
|
+
for (const [f] of files) {
|
|
870
|
+
if (!current.includes(f)) {
|
|
871
|
+
files.delete(f);
|
|
872
|
+
if (initialized) {
|
|
873
|
+
changedFiles.push(relative(dir, f) + ' (deleted)');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (changedFiles.length > 0) {
|
|
879
|
+
onChange(changedFiles);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
initialized = true;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
scan();
|
|
886
|
+
// Poll every 100ms for more responsive HMR
|
|
887
|
+
setInterval(scan, 100);
|
|
888
|
+
}
|