blumenjs 0.2.1 → 0.2.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/dist/cli/blumen.js +875 -62
- package/dist/cli/commands/build.js +47 -6
- package/dist/templates/app/client/entry.tsx +5 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +19 -5
- package/dist/templates/app/shared/RouterContext.tsx +4 -1
- package/dist/templates/go-server/main.go +107 -4
- package/dist/templates/go-server/middleware.go +1 -1
- package/dist/templates/node-ssr/server.ts +222 -2
- package/dist/templates/scripts/generate-routes.ts +141 -9
- package/package.json +16 -4
package/dist/cli/blumen.js
CHANGED
|
@@ -68,7 +68,7 @@ async function select(question, options) {
|
|
|
68
68
|
input: process.stdin,
|
|
69
69
|
output: process.stdout
|
|
70
70
|
});
|
|
71
|
-
return new Promise((
|
|
71
|
+
return new Promise((resolve6) => {
|
|
72
72
|
console.log(`
|
|
73
73
|
${c.bold}${question}${c.reset}`);
|
|
74
74
|
options.forEach((opt, i) => {
|
|
@@ -79,9 +79,9 @@ async function select(question, options) {
|
|
|
79
79
|
rl.close();
|
|
80
80
|
const idx = parseInt(answer, 10) - 1;
|
|
81
81
|
if (idx >= 0 && idx < options.length) {
|
|
82
|
-
|
|
82
|
+
resolve6(options[idx]);
|
|
83
83
|
} else {
|
|
84
|
-
|
|
84
|
+
resolve6(options[0]);
|
|
85
85
|
}
|
|
86
86
|
});
|
|
87
87
|
});
|
|
@@ -93,14 +93,14 @@ async function confirm(question, defaultYes = true) {
|
|
|
93
93
|
output: process.stdout
|
|
94
94
|
});
|
|
95
95
|
const hint = defaultYes ? "Y/n" : "y/N";
|
|
96
|
-
return new Promise((
|
|
96
|
+
return new Promise((resolve6) => {
|
|
97
97
|
rl.question(` ${c.bold}${question}${c.reset} ${c.dim}(${hint})${c.reset} `, (answer) => {
|
|
98
98
|
rl.close();
|
|
99
99
|
const a = answer.trim().toLowerCase();
|
|
100
100
|
if (a === "")
|
|
101
|
-
|
|
101
|
+
resolve6(defaultYes);
|
|
102
102
|
else
|
|
103
|
-
|
|
103
|
+
resolve6(a === "y" || a === "yes");
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
106
|
}
|
|
@@ -321,6 +321,39 @@ async function dev() {
|
|
|
321
321
|
|
|
322
322
|
// cli/commands/build.ts
|
|
323
323
|
import { execSync as execSync3 } from "child_process";
|
|
324
|
+
import * as fs2 from "fs";
|
|
325
|
+
import * as path2 from "path";
|
|
326
|
+
function generateChunkManifest() {
|
|
327
|
+
const jsDir = path2.resolve("static/js");
|
|
328
|
+
const chunksDir = path2.resolve("static/js/chunks");
|
|
329
|
+
const manifest = {};
|
|
330
|
+
if (fs2.existsSync(jsDir)) {
|
|
331
|
+
for (const file of fs2.readdirSync(jsDir)) {
|
|
332
|
+
if (!file.endsWith(".js") || file.endsWith(".map"))
|
|
333
|
+
continue;
|
|
334
|
+
const match = file.match(/^([^.]+)\.[a-f0-9]+\.js$/);
|
|
335
|
+
if (match) {
|
|
336
|
+
manifest[match[1]] = `/static/js/${file}`;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (fs2.existsSync(chunksDir)) {
|
|
341
|
+
for (const file of fs2.readdirSync(chunksDir)) {
|
|
342
|
+
if (!file.endsWith(".js") || file.endsWith(".map"))
|
|
343
|
+
continue;
|
|
344
|
+
const match = file.match(/^([^.]+)\.[a-f0-9]+\.js$/);
|
|
345
|
+
if (match) {
|
|
346
|
+
manifest[match[1]] = `/static/js/chunks/${file}`;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
fs2.mkdirSync("dist", { recursive: true });
|
|
351
|
+
fs2.writeFileSync("dist/chunk-manifest.json", JSON.stringify(manifest, null, 2));
|
|
352
|
+
const coreChunks = ["runtime", "vendor", "framework", "main"].filter((k) => manifest[k]);
|
|
353
|
+
const pageChunks = Object.keys(manifest).filter((k) => k.startsWith("page-"));
|
|
354
|
+
log.info(` Core chunks: ${coreChunks.join(", ")} (${coreChunks.length})`);
|
|
355
|
+
log.info(` Page chunks: ${pageChunks.length} page(s)`);
|
|
356
|
+
}
|
|
324
357
|
async function build(args = []) {
|
|
325
358
|
banner();
|
|
326
359
|
const analyze = args.includes("--analyze");
|
|
@@ -335,10 +368,14 @@ async function build(args = []) {
|
|
|
335
368
|
cmd: "npx tsx scripts/generate-routes.ts"
|
|
336
369
|
},
|
|
337
370
|
{
|
|
338
|
-
label: "Building client bundle",
|
|
371
|
+
label: "Building client bundle (code splitting)",
|
|
339
372
|
cmd: "npx webpack --mode production",
|
|
340
373
|
env: analyze ? { BLUMEN_ANALYZE: "1" } : void 0
|
|
341
374
|
},
|
|
375
|
+
{
|
|
376
|
+
label: "Generating chunk manifest",
|
|
377
|
+
fn: generateChunkManifest
|
|
378
|
+
},
|
|
342
379
|
{
|
|
343
380
|
label: "Building SSR server",
|
|
344
381
|
cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external --alias:@=./app"
|
|
@@ -355,11 +392,15 @@ async function build(args = []) {
|
|
|
355
392
|
const step = steps[i];
|
|
356
393
|
log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
|
|
357
394
|
try {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
395
|
+
if (step.fn) {
|
|
396
|
+
step.fn();
|
|
397
|
+
} else if (step.cmd) {
|
|
398
|
+
execSync3(step.cmd, {
|
|
399
|
+
stdio: "inherit",
|
|
400
|
+
cwd: process.cwd(),
|
|
401
|
+
env: { ...process.env, ...step.env || {} }
|
|
402
|
+
});
|
|
403
|
+
}
|
|
363
404
|
log.success(step.label);
|
|
364
405
|
} catch {
|
|
365
406
|
if (step.optional) {
|
|
@@ -385,10 +426,10 @@ async function build(args = []) {
|
|
|
385
426
|
|
|
386
427
|
// cli/commands/start.ts
|
|
387
428
|
import { spawn as spawn2 } from "child_process";
|
|
388
|
-
import * as
|
|
429
|
+
import * as fs3 from "fs";
|
|
389
430
|
async function start() {
|
|
390
431
|
banner();
|
|
391
|
-
if (!
|
|
432
|
+
if (!fs3.existsSync("dist/ssr-server.js")) {
|
|
392
433
|
log.error("Production build not found.");
|
|
393
434
|
log.info(
|
|
394
435
|
`Run ${c.bold}blumen build${c.reset} first to create a production build.`
|
|
@@ -473,23 +514,23 @@ async function start() {
|
|
|
473
514
|
}
|
|
474
515
|
|
|
475
516
|
// cli/commands/create.ts
|
|
476
|
-
import * as
|
|
477
|
-
import * as
|
|
517
|
+
import * as fs4 from "fs";
|
|
518
|
+
import * as path3 from "path";
|
|
478
519
|
import { execSync as execSync4 } from "child_process";
|
|
479
520
|
function getFrameworkRoot() {
|
|
480
|
-
const cliEntry =
|
|
481
|
-
const cliDir =
|
|
482
|
-
return
|
|
521
|
+
const cliEntry = fs4.realpathSync(process.argv[1]);
|
|
522
|
+
const cliDir = path3.dirname(cliEntry);
|
|
523
|
+
return path3.resolve(cliDir, "..");
|
|
483
524
|
}
|
|
484
525
|
function readProjectFile(relativePath) {
|
|
485
526
|
const root = getFrameworkRoot();
|
|
486
|
-
const bundledPath =
|
|
487
|
-
if (
|
|
488
|
-
return
|
|
527
|
+
const bundledPath = path3.join(root, "templates", relativePath);
|
|
528
|
+
if (fs4.existsSync(bundledPath)) {
|
|
529
|
+
return fs4.readFileSync(bundledPath, "utf-8");
|
|
489
530
|
}
|
|
490
|
-
const sourcePath =
|
|
491
|
-
if (
|
|
492
|
-
return
|
|
531
|
+
const sourcePath = path3.join(root, relativePath);
|
|
532
|
+
if (fs4.existsSync(sourcePath)) {
|
|
533
|
+
return fs4.readFileSync(sourcePath, "utf-8");
|
|
493
534
|
}
|
|
494
535
|
throw new Error(`Template file not found: ${relativePath}`);
|
|
495
536
|
}
|
|
@@ -754,9 +795,9 @@ static/js/bundle.js
|
|
|
754
795
|
.DS_Store
|
|
755
796
|
`;
|
|
756
797
|
function writeFile(base, relPath, content) {
|
|
757
|
-
const fullPath =
|
|
758
|
-
|
|
759
|
-
|
|
798
|
+
const fullPath = path3.join(base, relPath);
|
|
799
|
+
fs4.mkdirSync(path3.dirname(fullPath), { recursive: true });
|
|
800
|
+
fs4.writeFileSync(fullPath, content, "utf-8");
|
|
760
801
|
}
|
|
761
802
|
var TEMPLATE_MAP = {
|
|
762
803
|
starter: {
|
|
@@ -825,8 +866,8 @@ async function create(projectName) {
|
|
|
825
866
|
);
|
|
826
867
|
process.exit(1);
|
|
827
868
|
}
|
|
828
|
-
const projectDir =
|
|
829
|
-
if (
|
|
869
|
+
const projectDir = path3.resolve(process.cwd(), projectName);
|
|
870
|
+
if (fs4.existsSync(projectDir)) {
|
|
830
871
|
log.error(`Directory ${c.bold}${projectName}${c.reset} already exists.`);
|
|
831
872
|
process.exit(1);
|
|
832
873
|
}
|
|
@@ -895,8 +936,8 @@ async function create(projectName) {
|
|
|
895
936
|
|
|
896
937
|
// cli/commands/deploy.ts
|
|
897
938
|
import { execSync as execSync5, spawnSync } from "child_process";
|
|
898
|
-
import * as
|
|
899
|
-
import * as
|
|
939
|
+
import * as fs5 from "fs";
|
|
940
|
+
import * as path4 from "path";
|
|
900
941
|
var PLATFORMS = [
|
|
901
942
|
{ name: "Docker (any cloud)", ok: true, note: "Best option \u2014 multi-stage Dockerfile included" },
|
|
902
943
|
{ name: "Railway", ok: true, note: "Auto-detects Dockerfile, deploy with railway up" },
|
|
@@ -916,7 +957,7 @@ function hasCommand(cmd) {
|
|
|
916
957
|
}
|
|
917
958
|
}
|
|
918
959
|
function ensureDockerfile() {
|
|
919
|
-
if (!
|
|
960
|
+
if (!fs5.existsSync("Dockerfile")) {
|
|
920
961
|
log.error("No Dockerfile found in the current directory.");
|
|
921
962
|
log.info(
|
|
922
963
|
`Run ${c.bold}blumen create${c.reset} to scaffold a project with Docker support.`
|
|
@@ -934,7 +975,7 @@ async function deployDocker() {
|
|
|
934
975
|
);
|
|
935
976
|
process.exit(1);
|
|
936
977
|
}
|
|
937
|
-
const projectName =
|
|
978
|
+
const projectName = path4.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
938
979
|
const imageName = `blumen-${projectName}`;
|
|
939
980
|
log.step(`Building image: ${c.bold}${imageName}${c.reset}...`);
|
|
940
981
|
divider();
|
|
@@ -976,8 +1017,8 @@ async function deployDocker() {
|
|
|
976
1017
|
async function deployFly() {
|
|
977
1018
|
log.info("Fly.io Deployment\n");
|
|
978
1019
|
ensureDockerfile();
|
|
979
|
-
const projectName =
|
|
980
|
-
if (!
|
|
1020
|
+
const projectName = path4.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1021
|
+
if (!fs5.existsSync("fly.toml")) {
|
|
981
1022
|
log.step("Generating fly.toml...");
|
|
982
1023
|
const flyConfig = `# Fly.io configuration for Blumen app
|
|
983
1024
|
# Deploy with: fly deploy
|
|
@@ -1007,7 +1048,7 @@ primary_region = "iad"
|
|
|
1007
1048
|
cpu_kind = "shared"
|
|
1008
1049
|
cpus = 1
|
|
1009
1050
|
`;
|
|
1010
|
-
|
|
1051
|
+
fs5.writeFileSync("fly.toml", flyConfig);
|
|
1011
1052
|
log.success("fly.toml created");
|
|
1012
1053
|
} else {
|
|
1013
1054
|
log.info("fly.toml already exists, skipping generation.");
|
|
@@ -1048,7 +1089,7 @@ primary_region = "iad"
|
|
|
1048
1089
|
async function deployRailway() {
|
|
1049
1090
|
log.info("Railway Deployment\n");
|
|
1050
1091
|
ensureDockerfile();
|
|
1051
|
-
if (!
|
|
1092
|
+
if (!fs5.existsSync("railway.toml")) {
|
|
1052
1093
|
log.step("Generating railway.toml...");
|
|
1053
1094
|
const railwayConfig = `# Railway configuration for Blumen app
|
|
1054
1095
|
# Deploy with: railway up
|
|
@@ -1064,7 +1105,7 @@ async function deployRailway() {
|
|
|
1064
1105
|
restartPolicyType = "ON_FAILURE"
|
|
1065
1106
|
restartPolicyMaxRetries = 5
|
|
1066
1107
|
`;
|
|
1067
|
-
|
|
1108
|
+
fs5.writeFileSync("railway.toml", railwayConfig);
|
|
1068
1109
|
log.success("railway.toml created");
|
|
1069
1110
|
} else {
|
|
1070
1111
|
log.info("railway.toml already exists, skipping generation.");
|
|
@@ -1159,12 +1200,12 @@ async function deploy(subcommand) {
|
|
|
1159
1200
|
}
|
|
1160
1201
|
|
|
1161
1202
|
// cli/commands/fonts.ts
|
|
1162
|
-
import * as
|
|
1163
|
-
import * as
|
|
1203
|
+
import * as fs6 from "fs";
|
|
1204
|
+
import * as path5 from "path";
|
|
1164
1205
|
import * as https from "https";
|
|
1165
|
-
var FONTS_DIR =
|
|
1166
|
-
var FONTS_CSS =
|
|
1167
|
-
var FONTS_CONFIG =
|
|
1206
|
+
var FONTS_DIR = path5.resolve("static/fonts");
|
|
1207
|
+
var FONTS_CSS = path5.resolve("static/css/fonts.css");
|
|
1208
|
+
var FONTS_CONFIG = path5.resolve("blumen.fonts.json");
|
|
1168
1209
|
var DEFAULT_MANIFEST = {
|
|
1169
1210
|
fonts: []
|
|
1170
1211
|
};
|
|
@@ -1182,13 +1223,13 @@ var FALLBACK_MAP = {
|
|
|
1182
1223
|
"JetBrains Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "103%" }
|
|
1183
1224
|
};
|
|
1184
1225
|
function downloadFile(url, dest) {
|
|
1185
|
-
return new Promise((
|
|
1186
|
-
const file =
|
|
1226
|
+
return new Promise((resolve6, reject) => {
|
|
1227
|
+
const file = fs6.createWriteStream(dest);
|
|
1187
1228
|
https.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (response) => {
|
|
1188
1229
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
|
1189
1230
|
const redirectUrl = response.headers.location;
|
|
1190
1231
|
if (redirectUrl) {
|
|
1191
|
-
downloadFile(redirectUrl, dest).then(
|
|
1232
|
+
downloadFile(redirectUrl, dest).then(resolve6).catch(reject);
|
|
1192
1233
|
return;
|
|
1193
1234
|
}
|
|
1194
1235
|
}
|
|
@@ -1199,17 +1240,17 @@ function downloadFile(url, dest) {
|
|
|
1199
1240
|
response.pipe(file);
|
|
1200
1241
|
file.on("finish", () => {
|
|
1201
1242
|
file.close();
|
|
1202
|
-
|
|
1243
|
+
resolve6();
|
|
1203
1244
|
});
|
|
1204
1245
|
}).on("error", (err) => {
|
|
1205
|
-
|
|
1246
|
+
fs6.unlink(dest, () => {
|
|
1206
1247
|
});
|
|
1207
1248
|
reject(err);
|
|
1208
1249
|
});
|
|
1209
1250
|
});
|
|
1210
1251
|
}
|
|
1211
1252
|
function fetchGoogleFontsCSS(family, weights, subsets) {
|
|
1212
|
-
return new Promise((
|
|
1253
|
+
return new Promise((resolve6, reject) => {
|
|
1213
1254
|
const weightStr = weights.join(";");
|
|
1214
1255
|
const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weightStr}&display=swap&subset=${subsets.join(",")}`;
|
|
1215
1256
|
https.get(url, {
|
|
@@ -1222,7 +1263,7 @@ function fetchGoogleFontsCSS(family, weights, subsets) {
|
|
|
1222
1263
|
response.on("data", (chunk) => {
|
|
1223
1264
|
data += chunk.toString();
|
|
1224
1265
|
});
|
|
1225
|
-
response.on("end", () =>
|
|
1266
|
+
response.on("end", () => resolve6(data));
|
|
1226
1267
|
}).on("error", reject);
|
|
1227
1268
|
});
|
|
1228
1269
|
}
|
|
@@ -1292,11 +1333,11 @@ async function processFont(config) {
|
|
|
1292
1333
|
const ext = font.format === "woff2" ? "woff2" : "woff";
|
|
1293
1334
|
const safeName = config.family.replace(/\s+/g, "-").toLowerCase();
|
|
1294
1335
|
const fileName = `${safeName}-${font.weight}-${font.style}.${ext}`;
|
|
1295
|
-
const filePath =
|
|
1296
|
-
if (!
|
|
1336
|
+
const filePath = path5.join(FONTS_DIR, fileName);
|
|
1337
|
+
if (!fs6.existsSync(filePath)) {
|
|
1297
1338
|
try {
|
|
1298
1339
|
await downloadFile(font.url, filePath);
|
|
1299
|
-
const size =
|
|
1340
|
+
const size = fs6.statSync(filePath).size;
|
|
1300
1341
|
console.log(` Downloaded: ${fileName} (${(size / 1024).toFixed(1)} KB)`);
|
|
1301
1342
|
} catch (err) {
|
|
1302
1343
|
console.error(` Failed to download ${fileName}: ${err}`);
|
|
@@ -1317,13 +1358,13 @@ async function processFont(config) {
|
|
|
1317
1358
|
return generatedCSS;
|
|
1318
1359
|
}
|
|
1319
1360
|
function loadManifest() {
|
|
1320
|
-
if (
|
|
1321
|
-
return JSON.parse(
|
|
1361
|
+
if (fs6.existsSync(FONTS_CONFIG)) {
|
|
1362
|
+
return JSON.parse(fs6.readFileSync(FONTS_CONFIG, "utf-8"));
|
|
1322
1363
|
}
|
|
1323
1364
|
return { ...DEFAULT_MANIFEST };
|
|
1324
1365
|
}
|
|
1325
1366
|
function saveManifest(manifest) {
|
|
1326
|
-
|
|
1367
|
+
fs6.writeFileSync(FONTS_CONFIG, JSON.stringify(manifest, null, 2), "utf-8");
|
|
1327
1368
|
}
|
|
1328
1369
|
async function fontsCommand(args) {
|
|
1329
1370
|
console.log("\n \u{1F338} Blumen Font Optimizer\n");
|
|
@@ -1365,8 +1406,8 @@ async function fontsCommand(args) {
|
|
|
1365
1406
|
console.log(' blumen fonts --add "Roboto"');
|
|
1366
1407
|
return;
|
|
1367
1408
|
}
|
|
1368
|
-
|
|
1369
|
-
|
|
1409
|
+
fs6.mkdirSync(FONTS_DIR, { recursive: true });
|
|
1410
|
+
fs6.mkdirSync(path5.dirname(FONTS_CSS), { recursive: true });
|
|
1370
1411
|
let allCSS = `/* Auto-generated by Blumen Font Optimizer */
|
|
1371
1412
|
/* Do not edit manually - run 'blumen fonts' to regenerate */
|
|
1372
1413
|
|
|
@@ -1375,10 +1416,10 @@ async function fontsCommand(args) {
|
|
|
1375
1416
|
const css = await processFont(fontConfig);
|
|
1376
1417
|
allCSS += css + "\n";
|
|
1377
1418
|
}
|
|
1378
|
-
|
|
1419
|
+
fs6.writeFileSync(FONTS_CSS, allCSS, "utf-8");
|
|
1379
1420
|
console.log(`
|
|
1380
|
-
\u2705 Font CSS written to: ${
|
|
1381
|
-
console.log(` \u2705 Font files saved to: ${
|
|
1421
|
+
\u2705 Font CSS written to: ${path5.relative(process.cwd(), FONTS_CSS)}`);
|
|
1422
|
+
console.log(` \u2705 Font files saved to: ${path5.relative(process.cwd(), FONTS_DIR)}/`);
|
|
1382
1423
|
console.log(`
|
|
1383
1424
|
To use these fonts, import the CSS in your _app.tsx or _document.tsx:`);
|
|
1384
1425
|
console.log(` import '../../static/css/fonts.css';`);
|
|
@@ -1388,6 +1429,748 @@ async function fontsCommand(args) {
|
|
|
1388
1429
|
console.log();
|
|
1389
1430
|
}
|
|
1390
1431
|
|
|
1432
|
+
// cli/commands/audit.ts
|
|
1433
|
+
import { execSync as execSync6 } from "child_process";
|
|
1434
|
+
function severityColor(severity) {
|
|
1435
|
+
switch (severity) {
|
|
1436
|
+
case "critical":
|
|
1437
|
+
return c.red;
|
|
1438
|
+
case "high":
|
|
1439
|
+
return c.red;
|
|
1440
|
+
case "moderate":
|
|
1441
|
+
return c.yellow;
|
|
1442
|
+
case "low":
|
|
1443
|
+
return c.dim;
|
|
1444
|
+
default:
|
|
1445
|
+
return c.dim;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
function severityIcon(severity) {
|
|
1449
|
+
switch (severity) {
|
|
1450
|
+
case "critical":
|
|
1451
|
+
return "\u{1F534}";
|
|
1452
|
+
case "high":
|
|
1453
|
+
return "\u{1F7E0}";
|
|
1454
|
+
case "moderate":
|
|
1455
|
+
return "\u{1F7E1}";
|
|
1456
|
+
case "low":
|
|
1457
|
+
return "\u{1F535}";
|
|
1458
|
+
default:
|
|
1459
|
+
return "\u26AA";
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
async function audit(args = []) {
|
|
1463
|
+
banner();
|
|
1464
|
+
const fix = args.includes("--fix");
|
|
1465
|
+
const ci = args.includes("--ci");
|
|
1466
|
+
const jsonOutput = args.includes("--json");
|
|
1467
|
+
if (fix) {
|
|
1468
|
+
log.info("Attempting to fix vulnerabilities...");
|
|
1469
|
+
log.blank();
|
|
1470
|
+
try {
|
|
1471
|
+
execSync6("npm audit fix", {
|
|
1472
|
+
stdio: "inherit",
|
|
1473
|
+
cwd: process.cwd()
|
|
1474
|
+
});
|
|
1475
|
+
log.blank();
|
|
1476
|
+
log.success("Audit fix completed.");
|
|
1477
|
+
} catch {
|
|
1478
|
+
log.warn("Some vulnerabilities could not be fixed automatically.");
|
|
1479
|
+
log.info(`Run ${c.bold}npm audit fix --force${c.reset} to force-fix (may include breaking changes).`);
|
|
1480
|
+
}
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (jsonOutput) {
|
|
1484
|
+
try {
|
|
1485
|
+
execSync6("npm audit --json", {
|
|
1486
|
+
stdio: "inherit",
|
|
1487
|
+
cwd: process.cwd()
|
|
1488
|
+
});
|
|
1489
|
+
} catch {
|
|
1490
|
+
process.exit(1);
|
|
1491
|
+
}
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
log.info("Scanning dependencies for known vulnerabilities...");
|
|
1495
|
+
log.blank();
|
|
1496
|
+
let auditOutput;
|
|
1497
|
+
let hasVulnerabilities = false;
|
|
1498
|
+
try {
|
|
1499
|
+
auditOutput = execSync6("npm audit --json 2>/dev/null", {
|
|
1500
|
+
cwd: process.cwd(),
|
|
1501
|
+
encoding: "utf-8"
|
|
1502
|
+
});
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
auditOutput = err.stdout || "{}";
|
|
1505
|
+
hasVulnerabilities = true;
|
|
1506
|
+
}
|
|
1507
|
+
try {
|
|
1508
|
+
const report = JSON.parse(auditOutput);
|
|
1509
|
+
const meta = report.metadata || {};
|
|
1510
|
+
const vulns = meta.vulnerabilities || {
|
|
1511
|
+
info: 0,
|
|
1512
|
+
low: 0,
|
|
1513
|
+
moderate: 0,
|
|
1514
|
+
high: 0,
|
|
1515
|
+
critical: 0,
|
|
1516
|
+
total: 0
|
|
1517
|
+
};
|
|
1518
|
+
const deps = meta.dependencies || {};
|
|
1519
|
+
const total = vulns.total || vulns.critical + vulns.high + vulns.moderate + vulns.low + (vulns.info || 0);
|
|
1520
|
+
divider();
|
|
1521
|
+
log.blank();
|
|
1522
|
+
if (total === 0) {
|
|
1523
|
+
log.success(`${c.bold}No vulnerabilities found!${c.reset} \u2728`);
|
|
1524
|
+
log.blank();
|
|
1525
|
+
if (deps.total) {
|
|
1526
|
+
log.info(`Scanned ${c.bold}${deps.total}${c.reset} dependencies.`);
|
|
1527
|
+
}
|
|
1528
|
+
} else {
|
|
1529
|
+
log.warn(`${c.bold}${total} vulnerabilit${total === 1 ? "y" : "ies"} found${c.reset}`);
|
|
1530
|
+
log.blank();
|
|
1531
|
+
const levels = [
|
|
1532
|
+
{ name: "critical", count: vulns.critical },
|
|
1533
|
+
{ name: "high", count: vulns.high },
|
|
1534
|
+
{ name: "moderate", count: vulns.moderate },
|
|
1535
|
+
{ name: "low", count: vulns.low }
|
|
1536
|
+
];
|
|
1537
|
+
for (const level of levels) {
|
|
1538
|
+
if (level.count > 0) {
|
|
1539
|
+
const color = severityColor(level.name);
|
|
1540
|
+
const icon = severityIcon(level.name);
|
|
1541
|
+
console.log(` ${icon} ${color}${level.count} ${level.name}${c.reset}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
log.blank();
|
|
1545
|
+
if (deps.total) {
|
|
1546
|
+
log.info(`Scanned ${c.bold}${deps.total}${c.reset} dependencies.`);
|
|
1547
|
+
}
|
|
1548
|
+
log.info(`Run ${c.bold}blumen audit --fix${c.reset} to attempt automatic fixes.`);
|
|
1549
|
+
}
|
|
1550
|
+
log.blank();
|
|
1551
|
+
divider();
|
|
1552
|
+
log.blank();
|
|
1553
|
+
if (ci && (vulns.critical > 0 || vulns.high > 0)) {
|
|
1554
|
+
log.error("CI check failed: high or critical vulnerabilities detected.");
|
|
1555
|
+
process.exit(1);
|
|
1556
|
+
}
|
|
1557
|
+
} catch {
|
|
1558
|
+
log.info("Detailed report:");
|
|
1559
|
+
log.blank();
|
|
1560
|
+
try {
|
|
1561
|
+
execSync6("npm audit", {
|
|
1562
|
+
stdio: "inherit",
|
|
1563
|
+
cwd: process.cwd()
|
|
1564
|
+
});
|
|
1565
|
+
} catch {
|
|
1566
|
+
if (ci) {
|
|
1567
|
+
process.exit(1);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// cli/commands/test.ts
|
|
1574
|
+
import { execSync as execSync7 } from "child_process";
|
|
1575
|
+
async function test(args = []) {
|
|
1576
|
+
banner();
|
|
1577
|
+
const watch = args.includes("--watch");
|
|
1578
|
+
const coverage = args.includes("--coverage");
|
|
1579
|
+
const ui = args.includes("--ui");
|
|
1580
|
+
const pattern = args.filter((a) => !a.startsWith("--")).join(" ");
|
|
1581
|
+
let cmd = "npx vitest run";
|
|
1582
|
+
if (watch) {
|
|
1583
|
+
cmd = "npx vitest";
|
|
1584
|
+
log.info("Starting test watcher...");
|
|
1585
|
+
} else if (ui) {
|
|
1586
|
+
cmd = "npx vitest --ui";
|
|
1587
|
+
log.info("Opening Vitest UI...");
|
|
1588
|
+
} else if (coverage) {
|
|
1589
|
+
cmd = "npx vitest run --coverage";
|
|
1590
|
+
log.info("Running tests with coverage...");
|
|
1591
|
+
} else {
|
|
1592
|
+
log.info("Running tests...");
|
|
1593
|
+
}
|
|
1594
|
+
if (pattern) {
|
|
1595
|
+
cmd += ` ${pattern}`;
|
|
1596
|
+
}
|
|
1597
|
+
log.blank();
|
|
1598
|
+
divider();
|
|
1599
|
+
log.blank();
|
|
1600
|
+
try {
|
|
1601
|
+
execSync7(cmd, {
|
|
1602
|
+
stdio: "inherit",
|
|
1603
|
+
cwd: process.cwd(),
|
|
1604
|
+
env: {
|
|
1605
|
+
...process.env,
|
|
1606
|
+
NODE_ENV: "test"
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
log.blank();
|
|
1610
|
+
divider();
|
|
1611
|
+
log.blank();
|
|
1612
|
+
log.success("All tests passed! \u2728");
|
|
1613
|
+
log.blank();
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
log.blank();
|
|
1616
|
+
divider();
|
|
1617
|
+
log.blank();
|
|
1618
|
+
if (err.status) {
|
|
1619
|
+
log.error(`Tests failed with exit code ${err.status}`);
|
|
1620
|
+
process.exit(err.status);
|
|
1621
|
+
} else {
|
|
1622
|
+
log.error("Test runner encountered an error");
|
|
1623
|
+
process.exit(1);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// cli/commands/bench.ts
|
|
1629
|
+
import * as http from "http";
|
|
1630
|
+
function percentile(sorted, p) {
|
|
1631
|
+
const idx = Math.ceil(p / 100 * sorted.length) - 1;
|
|
1632
|
+
return sorted[Math.max(0, idx)];
|
|
1633
|
+
}
|
|
1634
|
+
function makeRequest(url) {
|
|
1635
|
+
return new Promise((resolve6, reject) => {
|
|
1636
|
+
const start2 = performance.now();
|
|
1637
|
+
const req = http.get(url, (res) => {
|
|
1638
|
+
let body = "";
|
|
1639
|
+
res.on("data", (chunk) => {
|
|
1640
|
+
body += chunk;
|
|
1641
|
+
});
|
|
1642
|
+
res.on("end", () => {
|
|
1643
|
+
resolve6({
|
|
1644
|
+
latency: performance.now() - start2,
|
|
1645
|
+
status: res.statusCode || 0,
|
|
1646
|
+
cacheHeader: res.headers["x-blumen-cache"] || ""
|
|
1647
|
+
});
|
|
1648
|
+
});
|
|
1649
|
+
});
|
|
1650
|
+
req.on("error", reject);
|
|
1651
|
+
req.setTimeout(1e4, () => {
|
|
1652
|
+
req.destroy();
|
|
1653
|
+
reject(new Error("Timeout"));
|
|
1654
|
+
});
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
async function runBenchmark(url, totalRequests, concurrency) {
|
|
1658
|
+
const latencies = [];
|
|
1659
|
+
let successful = 0;
|
|
1660
|
+
let failed = 0;
|
|
1661
|
+
let cacheHits = 0;
|
|
1662
|
+
let cacheMisses = 0;
|
|
1663
|
+
let streamed = 0;
|
|
1664
|
+
let completed = 0;
|
|
1665
|
+
const start2 = performance.now();
|
|
1666
|
+
for (let i = 0; i < totalRequests; i += concurrency) {
|
|
1667
|
+
const batchSize = Math.min(concurrency, totalRequests - i);
|
|
1668
|
+
const batch = Array.from(
|
|
1669
|
+
{ length: batchSize },
|
|
1670
|
+
() => makeRequest(url).then((result) => {
|
|
1671
|
+
latencies.push(result.latency);
|
|
1672
|
+
if (result.status >= 200 && result.status < 400) {
|
|
1673
|
+
successful++;
|
|
1674
|
+
} else {
|
|
1675
|
+
failed++;
|
|
1676
|
+
}
|
|
1677
|
+
if (result.cacheHeader === "HIT" || result.cacheHeader === "STALE")
|
|
1678
|
+
cacheHits++;
|
|
1679
|
+
else if (result.cacheHeader === "MISS")
|
|
1680
|
+
cacheMisses++;
|
|
1681
|
+
else if (result.cacheHeader === "STREAM")
|
|
1682
|
+
streamed++;
|
|
1683
|
+
completed++;
|
|
1684
|
+
}).catch(() => {
|
|
1685
|
+
failed++;
|
|
1686
|
+
completed++;
|
|
1687
|
+
})
|
|
1688
|
+
);
|
|
1689
|
+
await Promise.all(batch);
|
|
1690
|
+
const pct = Math.round(completed / totalRequests * 100);
|
|
1691
|
+
process.stdout.write(`\r \u25CF Progress: ${pct}% (${completed}/${totalRequests})`);
|
|
1692
|
+
}
|
|
1693
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
1694
|
+
const totalTimeMs = performance.now() - start2;
|
|
1695
|
+
const sorted = latencies.sort((a, b) => a - b);
|
|
1696
|
+
return {
|
|
1697
|
+
url,
|
|
1698
|
+
totalRequests,
|
|
1699
|
+
successfulRequests: successful,
|
|
1700
|
+
failedRequests: failed,
|
|
1701
|
+
totalTimeMs,
|
|
1702
|
+
requestsPerSec: successful / totalTimeMs * 1e3,
|
|
1703
|
+
latencies: sorted,
|
|
1704
|
+
p50: sorted.length ? percentile(sorted, 50) : 0,
|
|
1705
|
+
p95: sorted.length ? percentile(sorted, 95) : 0,
|
|
1706
|
+
p99: sorted.length ? percentile(sorted, 99) : 0,
|
|
1707
|
+
min: sorted.length ? sorted[0] : 0,
|
|
1708
|
+
max: sorted.length ? sorted[sorted.length - 1] : 0,
|
|
1709
|
+
avg: sorted.length ? sorted.reduce((a, b) => a + b, 0) / sorted.length : 0,
|
|
1710
|
+
cacheHits,
|
|
1711
|
+
cacheMisses,
|
|
1712
|
+
streamed
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
function formatMs(ms) {
|
|
1716
|
+
if (ms < 1)
|
|
1717
|
+
return `${(ms * 1e3).toFixed(0)}\xB5s`;
|
|
1718
|
+
if (ms < 1e3)
|
|
1719
|
+
return `${ms.toFixed(1)}ms`;
|
|
1720
|
+
return `${(ms / 1e3).toFixed(2)}s`;
|
|
1721
|
+
}
|
|
1722
|
+
function printResult(result) {
|
|
1723
|
+
console.log(` ${c.bold}URL${c.reset} ${result.url}`);
|
|
1724
|
+
console.log(` ${c.bold}Requests${c.reset} ${result.successfulRequests}/${result.totalRequests} successful${result.failedRequests > 0 ? ` (${result.failedRequests} failed)` : ""}`);
|
|
1725
|
+
console.log(` ${c.bold}Duration${c.reset} ${formatMs(result.totalTimeMs)}`);
|
|
1726
|
+
console.log(` ${c.bold}Throughput${c.reset} ${c.green}${result.requestsPerSec.toFixed(1)} req/sec${c.reset}`);
|
|
1727
|
+
console.log("");
|
|
1728
|
+
console.log(` ${c.bold}Latency${c.reset}`);
|
|
1729
|
+
console.log(` min ${formatMs(result.min)}`);
|
|
1730
|
+
console.log(` avg ${formatMs(result.avg)}`);
|
|
1731
|
+
console.log(` p50 ${formatMs(result.p50)}`);
|
|
1732
|
+
console.log(` p95 ${c.yellow}${formatMs(result.p95)}${c.reset}`);
|
|
1733
|
+
console.log(` p99 ${c.yellow}${formatMs(result.p99)}${c.reset}`);
|
|
1734
|
+
console.log(` max ${formatMs(result.max)}`);
|
|
1735
|
+
if (result.cacheHits > 0 || result.cacheMisses > 0 || result.streamed > 0) {
|
|
1736
|
+
console.log("");
|
|
1737
|
+
console.log(` ${c.bold}Cache${c.reset}`);
|
|
1738
|
+
if (result.cacheHits > 0)
|
|
1739
|
+
console.log(` HIT ${result.cacheHits} (${(result.cacheHits / result.totalRequests * 100).toFixed(0)}%)`);
|
|
1740
|
+
if (result.cacheMisses > 0)
|
|
1741
|
+
console.log(` MISS ${result.cacheMisses}`);
|
|
1742
|
+
if (result.streamed > 0)
|
|
1743
|
+
console.log(` STREAM ${result.streamed}`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
async function bench(args = []) {
|
|
1747
|
+
banner();
|
|
1748
|
+
let baseUrl = "http://localhost:3000";
|
|
1749
|
+
let totalRequests = 200;
|
|
1750
|
+
let concurrency = 10;
|
|
1751
|
+
for (let i = 0; i < args.length; i++) {
|
|
1752
|
+
if (args[i] === "--url" && args[i + 1]) {
|
|
1753
|
+
baseUrl = args[++i];
|
|
1754
|
+
} else if (args[i] === "--requests" && args[i + 1]) {
|
|
1755
|
+
totalRequests = parseInt(args[++i], 10);
|
|
1756
|
+
} else if (args[i] === "--concurrency" && args[i + 1]) {
|
|
1757
|
+
concurrency = parseInt(args[++i], 10);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
log.info(`Benchmarking ${c.bold}${baseUrl}${c.reset}`);
|
|
1761
|
+
log.info(`${totalRequests} requests, ${concurrency} concurrent connections`);
|
|
1762
|
+
log.blank();
|
|
1763
|
+
divider();
|
|
1764
|
+
const endpoints = [
|
|
1765
|
+
{ name: "Homepage (SSR)", path: "/" },
|
|
1766
|
+
{ name: "About (SSR)", path: "/about" },
|
|
1767
|
+
{ name: "Static Asset", path: "/static/js/runtime.js" }
|
|
1768
|
+
];
|
|
1769
|
+
for (const endpoint of endpoints) {
|
|
1770
|
+
const url = `${baseUrl}${endpoint.path}`;
|
|
1771
|
+
log.blank();
|
|
1772
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 ${endpoint.name}${c.reset}`);
|
|
1773
|
+
log.blank();
|
|
1774
|
+
try {
|
|
1775
|
+
const result = await runBenchmark(url, totalRequests, concurrency);
|
|
1776
|
+
printResult(result);
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
log.error(` Failed to benchmark ${url}: ${err.message}`);
|
|
1779
|
+
log.info(` Make sure the server is running (${c.bold}blumen dev${c.reset} or ${c.bold}blumen start${c.reset})`);
|
|
1780
|
+
}
|
|
1781
|
+
log.blank();
|
|
1782
|
+
divider();
|
|
1783
|
+
}
|
|
1784
|
+
log.blank();
|
|
1785
|
+
log.success("Benchmark complete! \u2728");
|
|
1786
|
+
log.blank();
|
|
1787
|
+
log.info(`For production load testing, use k6:`);
|
|
1788
|
+
log.info(` ${c.bold}k6 run benchmarks/k6-load-test.js${c.reset}`);
|
|
1789
|
+
log.blank();
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// cli/commands/export.ts
|
|
1793
|
+
import { execSync as execSync8, spawn as spawn3 } from "child_process";
|
|
1794
|
+
import * as fs7 from "fs";
|
|
1795
|
+
import * as path6 from "path";
|
|
1796
|
+
import * as http2 from "http";
|
|
1797
|
+
async function renderPage(route, ssrUrl) {
|
|
1798
|
+
const body = JSON.stringify({
|
|
1799
|
+
path: route,
|
|
1800
|
+
query: {},
|
|
1801
|
+
params: {}
|
|
1802
|
+
});
|
|
1803
|
+
return new Promise((resolve6, reject) => {
|
|
1804
|
+
const req = http2.request(ssrUrl, {
|
|
1805
|
+
method: "POST",
|
|
1806
|
+
headers: {
|
|
1807
|
+
"Content-Type": "application/json",
|
|
1808
|
+
"Content-Length": Buffer.byteLength(body)
|
|
1809
|
+
}
|
|
1810
|
+
}, (res) => {
|
|
1811
|
+
let data = "";
|
|
1812
|
+
res.on("data", (chunk) => data += chunk);
|
|
1813
|
+
res.on("end", () => {
|
|
1814
|
+
try {
|
|
1815
|
+
const json = JSON.parse(data);
|
|
1816
|
+
if (json.html) {
|
|
1817
|
+
resolve6(json.html);
|
|
1818
|
+
} else {
|
|
1819
|
+
reject(new Error("SSR response missing html field"));
|
|
1820
|
+
}
|
|
1821
|
+
} catch {
|
|
1822
|
+
reject(new Error(`Invalid SSR response: ${data.slice(0, 100)}`));
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
req.on("error", (err) => reject(new Error(`SSR server unreachable: ${err.message}`)));
|
|
1827
|
+
req.setTimeout(15e3, () => {
|
|
1828
|
+
req.destroy();
|
|
1829
|
+
reject(new Error("SSR timeout"));
|
|
1830
|
+
});
|
|
1831
|
+
req.write(body);
|
|
1832
|
+
req.end();
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
function discoverRoutes() {
|
|
1836
|
+
const pagesDir = path6.resolve("app/pages");
|
|
1837
|
+
const routes = [];
|
|
1838
|
+
function scan(dir, prefix) {
|
|
1839
|
+
if (!fs7.existsSync(dir))
|
|
1840
|
+
return;
|
|
1841
|
+
const entries = fs7.readdirSync(dir, { withFileTypes: true });
|
|
1842
|
+
for (const entry of entries) {
|
|
1843
|
+
if (entry.name.startsWith("_") || entry.name.startsWith("."))
|
|
1844
|
+
continue;
|
|
1845
|
+
if (entry.isDirectory()) {
|
|
1846
|
+
scan(path6.join(dir, entry.name), `${prefix}/${entry.name.toLowerCase()}`);
|
|
1847
|
+
} else if (entry.name.endsWith(".tsx") && !entry.name.startsWith("NotFound")) {
|
|
1848
|
+
const name = entry.name.replace(".tsx", "");
|
|
1849
|
+
if (name.toLowerCase() === "home") {
|
|
1850
|
+
routes.push("/");
|
|
1851
|
+
} else if (name === "index") {
|
|
1852
|
+
routes.push(prefix || "/");
|
|
1853
|
+
} else if (!name.includes("[")) {
|
|
1854
|
+
routes.push(`${prefix}/${name.toLowerCase()}`);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
scan(pagesDir, "");
|
|
1860
|
+
return routes;
|
|
1861
|
+
}
|
|
1862
|
+
async function waitForServer(url, maxWaitMs = 15e3) {
|
|
1863
|
+
const start2 = Date.now();
|
|
1864
|
+
while (Date.now() - start2 < maxWaitMs) {
|
|
1865
|
+
try {
|
|
1866
|
+
await new Promise((resolve6, reject) => {
|
|
1867
|
+
const req = http2.get(url, () => resolve6());
|
|
1868
|
+
req.on("error", reject);
|
|
1869
|
+
req.setTimeout(1e3, () => {
|
|
1870
|
+
req.destroy();
|
|
1871
|
+
reject(new Error("timeout"));
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
return true;
|
|
1875
|
+
} catch {
|
|
1876
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return false;
|
|
1880
|
+
}
|
|
1881
|
+
async function exportSite(args = []) {
|
|
1882
|
+
banner();
|
|
1883
|
+
let outDir = "dist/export";
|
|
1884
|
+
for (let i = 0; i < args.length; i++) {
|
|
1885
|
+
if (args[i] === "--out" && args[i + 1]) {
|
|
1886
|
+
outDir = args[++i];
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
log.info("Exporting static site...");
|
|
1890
|
+
log.blank();
|
|
1891
|
+
log.info("Step 1/4: Building production bundle...");
|
|
1892
|
+
try {
|
|
1893
|
+
execSync8("npx tsx cli/blumen.ts build", { stdio: "inherit", cwd: process.cwd() });
|
|
1894
|
+
} catch {
|
|
1895
|
+
log.error("Build failed. Fix errors and try again.");
|
|
1896
|
+
process.exit(1);
|
|
1897
|
+
}
|
|
1898
|
+
log.blank();
|
|
1899
|
+
log.info("Step 2/4: Starting SSR server for pre-rendering...");
|
|
1900
|
+
const ssrProcess = spawn3("node", ["dist/ssr-server.js"], {
|
|
1901
|
+
cwd: process.cwd(),
|
|
1902
|
+
env: { ...process.env, NODE_ENV: "production", PORT: "4001" },
|
|
1903
|
+
stdio: "pipe"
|
|
1904
|
+
});
|
|
1905
|
+
const ssrUrl = "http://localhost:4001/render";
|
|
1906
|
+
const serverReady = await waitForServer("http://localhost:4001/health", 15e3);
|
|
1907
|
+
if (!serverReady) {
|
|
1908
|
+
log.warn("SSR server health check failed, attempting rendering anyway...");
|
|
1909
|
+
}
|
|
1910
|
+
log.blank();
|
|
1911
|
+
log.info("Step 3/4: Pre-rendering pages...");
|
|
1912
|
+
const routes = discoverRoutes();
|
|
1913
|
+
const outputDir = path6.resolve(outDir);
|
|
1914
|
+
fs7.mkdirSync(outputDir, { recursive: true });
|
|
1915
|
+
let success = 0;
|
|
1916
|
+
let failed = 0;
|
|
1917
|
+
for (const route of routes) {
|
|
1918
|
+
try {
|
|
1919
|
+
const html = await renderPage(route, ssrUrl);
|
|
1920
|
+
const filePath = route === "/" ? path6.join(outputDir, "index.html") : path6.join(outputDir, route.slice(1), "index.html");
|
|
1921
|
+
fs7.mkdirSync(path6.dirname(filePath), { recursive: true });
|
|
1922
|
+
fs7.writeFileSync(filePath, html, "utf-8");
|
|
1923
|
+
console.log(` ${c.green}\u2713${c.reset} ${route} \u2192 ${path6.relative(process.cwd(), filePath)}`);
|
|
1924
|
+
success++;
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
console.log(` ${c.red}\u2717${c.reset} ${route}: ${err.message}`);
|
|
1927
|
+
failed++;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
ssrProcess.kill("SIGTERM");
|
|
1931
|
+
log.blank();
|
|
1932
|
+
log.info("Step 4/4: Copying static assets...");
|
|
1933
|
+
const staticDir = path6.resolve("static");
|
|
1934
|
+
if (fs7.existsSync(staticDir)) {
|
|
1935
|
+
copyDir(staticDir, path6.join(outputDir, "static"));
|
|
1936
|
+
log.success("Static assets copied.");
|
|
1937
|
+
}
|
|
1938
|
+
const distJs = path6.resolve("dist/client");
|
|
1939
|
+
if (fs7.existsSync(distJs)) {
|
|
1940
|
+
copyDir(distJs, path6.join(outputDir, "static/js"));
|
|
1941
|
+
}
|
|
1942
|
+
log.blank();
|
|
1943
|
+
divider();
|
|
1944
|
+
log.blank();
|
|
1945
|
+
log.success(`Static export complete! \u2728`);
|
|
1946
|
+
log.info(`${success} page(s) exported${failed > 0 ? `, ${failed} failed` : ""}`);
|
|
1947
|
+
log.info(`Output: ${c.bold}${outDir}/${c.reset}`);
|
|
1948
|
+
log.blank();
|
|
1949
|
+
log.info(`Deploy anywhere:`);
|
|
1950
|
+
log.info(` ${c.dim}GitHub Pages:${c.reset} push ${outDir}/ to gh-pages branch`);
|
|
1951
|
+
log.info(` ${c.dim}Netlify:${c.reset} set publish directory to ${outDir}/`);
|
|
1952
|
+
log.info(` ${c.dim}S3:${c.reset} aws s3 sync ${outDir}/ s3://my-bucket`);
|
|
1953
|
+
log.info(` ${c.dim}Local:${c.reset} npx serve ${outDir}/`);
|
|
1954
|
+
log.blank();
|
|
1955
|
+
}
|
|
1956
|
+
function copyDir(src, dest) {
|
|
1957
|
+
fs7.mkdirSync(dest, { recursive: true });
|
|
1958
|
+
const entries = fs7.readdirSync(src, { withFileTypes: true });
|
|
1959
|
+
for (const entry of entries) {
|
|
1960
|
+
const srcPath = path6.join(src, entry.name);
|
|
1961
|
+
const destPath = path6.join(dest, entry.name);
|
|
1962
|
+
if (entry.isDirectory()) {
|
|
1963
|
+
copyDir(srcPath, destPath);
|
|
1964
|
+
} else {
|
|
1965
|
+
fs7.copyFileSync(srcPath, destPath);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// cli/commands/migrate.ts
|
|
1971
|
+
import * as fs8 from "fs";
|
|
1972
|
+
import * as path7 from "path";
|
|
1973
|
+
function isNextJsProject(dir) {
|
|
1974
|
+
return fs8.existsSync(path7.join(dir, "next.config.js")) || fs8.existsSync(path7.join(dir, "next.config.mjs")) || fs8.existsSync(path7.join(dir, "next.config.ts")) || fs8.existsSync(path7.join(dir, "pages")) || fs8.existsSync(path7.join(dir, "src/pages"));
|
|
1975
|
+
}
|
|
1976
|
+
function findPagesDir(dir) {
|
|
1977
|
+
if (fs8.existsSync(path7.join(dir, "pages")))
|
|
1978
|
+
return path7.join(dir, "pages");
|
|
1979
|
+
if (fs8.existsSync(path7.join(dir, "src/pages")))
|
|
1980
|
+
return path7.join(dir, "src/pages");
|
|
1981
|
+
if (fs8.existsSync(path7.join(dir, "app")))
|
|
1982
|
+
return path7.join(dir, "app");
|
|
1983
|
+
return null;
|
|
1984
|
+
}
|
|
1985
|
+
function getAllTsxFiles(dir) {
|
|
1986
|
+
const results = [];
|
|
1987
|
+
if (!fs8.existsSync(dir))
|
|
1988
|
+
return results;
|
|
1989
|
+
const entries = fs8.readdirSync(dir, { withFileTypes: true });
|
|
1990
|
+
for (const entry of entries) {
|
|
1991
|
+
const fullPath = path7.join(dir, entry.name);
|
|
1992
|
+
if (entry.isDirectory()) {
|
|
1993
|
+
results.push(...getAllTsxFiles(fullPath));
|
|
1994
|
+
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
1995
|
+
results.push(fullPath);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return results;
|
|
1999
|
+
}
|
|
2000
|
+
function rewriteImports(content, depth) {
|
|
2001
|
+
const prefix = "../".repeat(depth) || "./";
|
|
2002
|
+
const sharedPrefix = `${prefix}../shared`;
|
|
2003
|
+
content = content.replace(
|
|
2004
|
+
/import\s+(?:Link|{[^}]*})\s+from\s+['"]next\/link['"]/g,
|
|
2005
|
+
`import { Link } from "${sharedPrefix}/Link"`
|
|
2006
|
+
);
|
|
2007
|
+
content = content.replace(
|
|
2008
|
+
/import\s+(?:Head|{[^}]*})\s+from\s+['"]next\/head['"]/g,
|
|
2009
|
+
`import { BlumenHead } from "${sharedPrefix}/BlumenHead"`
|
|
2010
|
+
);
|
|
2011
|
+
content = content.replace(
|
|
2012
|
+
/import\s+(?:Image|{[^}]*})\s+from\s+['"]next\/image['"]/g,
|
|
2013
|
+
`// TODO: Replace next/image with standard <img> or BlumenImage
|
|
2014
|
+
// import Image from "next/image"`
|
|
2015
|
+
);
|
|
2016
|
+
content = content.replace(
|
|
2017
|
+
/import\s+(?:{[^}]*})\s+from\s+['"]next\/router['"]/g,
|
|
2018
|
+
`import { useRouter } from "${sharedPrefix}/RouterContext"`
|
|
2019
|
+
);
|
|
2020
|
+
return content;
|
|
2021
|
+
}
|
|
2022
|
+
function rewriteDataFetching(content) {
|
|
2023
|
+
const changes = [];
|
|
2024
|
+
if (content.includes("getServerSideProps")) {
|
|
2025
|
+
content = content.replace(/getServerSideProps/g, "getServerProps");
|
|
2026
|
+
changes.push("getServerSideProps \u2192 getServerProps");
|
|
2027
|
+
}
|
|
2028
|
+
if (content.includes("getStaticProps") && !content.includes("force-static")) {
|
|
2029
|
+
content += `
|
|
2030
|
+
|
|
2031
|
+
// Mark this page for static generation
|
|
2032
|
+
export const dynamic = 'force-static';
|
|
2033
|
+
`;
|
|
2034
|
+
changes.push("Added export const dynamic = 'force-static'");
|
|
2035
|
+
}
|
|
2036
|
+
content = content.replace(/GetServerSidePropsContext/g, "BlumenContext");
|
|
2037
|
+
if (content.includes("BlumenContext") && !content.includes("import")) {
|
|
2038
|
+
changes.push("GetServerSidePropsContext \u2192 BlumenContext");
|
|
2039
|
+
}
|
|
2040
|
+
return { content, changes };
|
|
2041
|
+
}
|
|
2042
|
+
async function migrate(args = []) {
|
|
2043
|
+
banner();
|
|
2044
|
+
const dryRun = args.includes("--dry-run");
|
|
2045
|
+
const sourceDir = args.find((a) => !a.startsWith("--")) || ".";
|
|
2046
|
+
const absDir = path7.resolve(sourceDir);
|
|
2047
|
+
log.info(`Analyzing ${c.bold}${absDir}${c.reset} for Next.js project...`);
|
|
2048
|
+
log.blank();
|
|
2049
|
+
if (!isNextJsProject(absDir)) {
|
|
2050
|
+
log.error("No Next.js project detected.");
|
|
2051
|
+
log.info("Expected: next.config.js/mjs/ts or pages/ directory");
|
|
2052
|
+
process.exit(1);
|
|
2053
|
+
}
|
|
2054
|
+
log.success("Next.js project detected!");
|
|
2055
|
+
log.blank();
|
|
2056
|
+
const result = {
|
|
2057
|
+
moved: [],
|
|
2058
|
+
rewritten: [],
|
|
2059
|
+
warnings: [],
|
|
2060
|
+
errors: []
|
|
2061
|
+
};
|
|
2062
|
+
const pagesDir = findPagesDir(absDir);
|
|
2063
|
+
if (!pagesDir) {
|
|
2064
|
+
log.error("Could not find pages/ or src/pages/ directory");
|
|
2065
|
+
process.exit(1);
|
|
2066
|
+
}
|
|
2067
|
+
const isAppRouter = pagesDir.endsWith("/app");
|
|
2068
|
+
if (isAppRouter) {
|
|
2069
|
+
result.warnings.push("Next.js App Router detected. Migration from App Router requires manual review.");
|
|
2070
|
+
}
|
|
2071
|
+
log.info(`Pages directory: ${c.bold}${path7.relative(absDir, pagesDir)}/${c.reset}`);
|
|
2072
|
+
log.blank();
|
|
2073
|
+
divider();
|
|
2074
|
+
log.blank();
|
|
2075
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 1: Analyzing files${c.reset}`);
|
|
2076
|
+
log.blank();
|
|
2077
|
+
const files = getAllTsxFiles(pagesDir);
|
|
2078
|
+
console.log(` Found ${files.length} source file(s)`);
|
|
2079
|
+
log.blank();
|
|
2080
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 2: Rewriting imports${c.reset}`);
|
|
2081
|
+
log.blank();
|
|
2082
|
+
for (const file of files) {
|
|
2083
|
+
const relPath = path7.relative(pagesDir, file);
|
|
2084
|
+
const depth = relPath.split("/").length;
|
|
2085
|
+
let content = fs8.readFileSync(file, "utf-8");
|
|
2086
|
+
let modified = false;
|
|
2087
|
+
const fileChanges = [];
|
|
2088
|
+
const newContent = rewriteImports(content, depth);
|
|
2089
|
+
if (newContent !== content) {
|
|
2090
|
+
content = newContent;
|
|
2091
|
+
modified = true;
|
|
2092
|
+
fileChanges.push("Import rewrites");
|
|
2093
|
+
}
|
|
2094
|
+
const { content: dfContent, changes } = rewriteDataFetching(content);
|
|
2095
|
+
if (changes.length > 0) {
|
|
2096
|
+
content = dfContent;
|
|
2097
|
+
modified = true;
|
|
2098
|
+
fileChanges.push(...changes);
|
|
2099
|
+
}
|
|
2100
|
+
if (modified) {
|
|
2101
|
+
if (!dryRun) {
|
|
2102
|
+
fs8.writeFileSync(file, content, "utf-8");
|
|
2103
|
+
}
|
|
2104
|
+
console.log(` ${c.green}\u2713${c.reset} ${relPath}: ${fileChanges.join(", ")}`);
|
|
2105
|
+
result.rewritten.push(relPath);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
if (result.rewritten.length === 0) {
|
|
2109
|
+
console.log(` ${c.dim}No import rewrites needed${c.reset}`);
|
|
2110
|
+
}
|
|
2111
|
+
log.blank();
|
|
2112
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 3: File structure mapping${c.reset}`);
|
|
2113
|
+
log.blank();
|
|
2114
|
+
const mappings = [
|
|
2115
|
+
["pages/_app.tsx", "\u2192", "app/shared/_app.tsx"],
|
|
2116
|
+
["pages/_document.tsx", "\u2192", "app/shared/_document.tsx"],
|
|
2117
|
+
["pages/index.tsx", "\u2192", "app/pages/Home.tsx"],
|
|
2118
|
+
["pages/[slug].tsx", "\u2192", "app/pages/[slug].tsx"],
|
|
2119
|
+
["pages/api/", "\u2192", "app/api/ (Blumen API routes)"],
|
|
2120
|
+
["public/", "\u2192", "static/"],
|
|
2121
|
+
["styles/", "\u2192", "app/shared/styles/"]
|
|
2122
|
+
];
|
|
2123
|
+
for (const [from, arrow, to] of mappings) {
|
|
2124
|
+
console.log(` ${c.dim}${from}${c.reset} ${arrow} ${c.bold}${to}${c.reset}`);
|
|
2125
|
+
}
|
|
2126
|
+
log.blank();
|
|
2127
|
+
divider();
|
|
2128
|
+
log.blank();
|
|
2129
|
+
console.log(` ${c.bold}Migration Summary${c.reset}`);
|
|
2130
|
+
log.blank();
|
|
2131
|
+
const summaryTable = [
|
|
2132
|
+
["Files analyzed", `${files.length}`],
|
|
2133
|
+
["Imports rewritten", `${result.rewritten.length}`],
|
|
2134
|
+
["Warnings", `${result.warnings.length}`],
|
|
2135
|
+
["Mode", dryRun ? "Dry run (no changes)" : "Applied"]
|
|
2136
|
+
];
|
|
2137
|
+
for (const [label, value] of summaryTable) {
|
|
2138
|
+
console.log(` ${c.dim}${label}:${c.reset} ${value}`);
|
|
2139
|
+
}
|
|
2140
|
+
log.blank();
|
|
2141
|
+
if (result.warnings.length > 0) {
|
|
2142
|
+
console.log(` ${c.yellow}${c.bold}Warnings:${c.reset}`);
|
|
2143
|
+
for (const w of result.warnings) {
|
|
2144
|
+
console.log(` ${c.yellow}\u26A0${c.reset} ${w}`);
|
|
2145
|
+
}
|
|
2146
|
+
log.blank();
|
|
2147
|
+
}
|
|
2148
|
+
console.log(` ${c.bold}Next Steps${c.reset}`);
|
|
2149
|
+
log.blank();
|
|
2150
|
+
console.log(` 1. Move your page files to ${c.bold}app/pages/${c.reset}`);
|
|
2151
|
+
console.log(` 2. Move ${c.bold}_app.tsx${c.reset} to ${c.bold}app/shared/_app.tsx${c.reset}`);
|
|
2152
|
+
console.log(` 3. Move ${c.bold}public/${c.reset} to ${c.bold}static/${c.reset}`);
|
|
2153
|
+
console.log(` 4. Replace ${c.bold}next/image${c.reset} usage with standard ${c.bold}<img>${c.reset} or ${c.bold}<BlumenImage>${c.reset}`);
|
|
2154
|
+
console.log(` 5. Run ${c.bold}blumen dev${c.reset} to test`);
|
|
2155
|
+
log.blank();
|
|
2156
|
+
console.log(` ${c.bold}API Comparison${c.reset}`);
|
|
2157
|
+
log.blank();
|
|
2158
|
+
const comparison = [
|
|
2159
|
+
["next/link", "Link from shared/Link", "\u2713 Auto-converted"],
|
|
2160
|
+
["next/head", "BlumenHead", "\u2713 Auto-converted"],
|
|
2161
|
+
["next/router", "useRouter from RouterContext", "\u2713 Auto-converted"],
|
|
2162
|
+
["getServerSideProps", "getServerProps", "\u2713 Auto-converted"],
|
|
2163
|
+
["getStaticProps", "getStaticProps", "\u2713 Same API + force-static"],
|
|
2164
|
+
["next/image", "<img> or <BlumenImage>", "\u26A0 Manual review"],
|
|
2165
|
+
["API Routes (pages/api)", "app/api/ handlers", "\u26A0 Manual migration"],
|
|
2166
|
+
["Middleware", "Go middleware", "\u26A0 Manual migration"]
|
|
2167
|
+
];
|
|
2168
|
+
for (const [next, blumen, status] of comparison) {
|
|
2169
|
+
console.log(` ${c.dim}${next.padEnd(25)}${c.reset}\u2192 ${blumen.padEnd(30)} ${status}`);
|
|
2170
|
+
}
|
|
2171
|
+
log.blank();
|
|
2172
|
+
}
|
|
2173
|
+
|
|
1391
2174
|
// cli/blumen.ts
|
|
1392
2175
|
async function main() {
|
|
1393
2176
|
const command = process.argv[2];
|
|
@@ -1412,6 +2195,21 @@ async function main() {
|
|
|
1412
2195
|
console.log(
|
|
1413
2196
|
` fonts Download and optimize fonts`
|
|
1414
2197
|
);
|
|
2198
|
+
console.log(
|
|
2199
|
+
` audit Scan dependencies for vulnerabilities`
|
|
2200
|
+
);
|
|
2201
|
+
console.log(
|
|
2202
|
+
` test Run unit and component tests`
|
|
2203
|
+
);
|
|
2204
|
+
console.log(
|
|
2205
|
+
` bench Run performance benchmarks`
|
|
2206
|
+
);
|
|
2207
|
+
console.log(
|
|
2208
|
+
` export Export as a fully static site`
|
|
2209
|
+
);
|
|
2210
|
+
console.log(
|
|
2211
|
+
` migrate Migrate a Next.js project to Blumen`
|
|
2212
|
+
);
|
|
1415
2213
|
console.log("");
|
|
1416
2214
|
console.log(` ${c.bold}Options${c.reset}`);
|
|
1417
2215
|
console.log(` --help Show this help message`);
|
|
@@ -1442,6 +2240,21 @@ async function main() {
|
|
|
1442
2240
|
case "fonts":
|
|
1443
2241
|
await fontsCommand(process.argv.slice(3));
|
|
1444
2242
|
break;
|
|
2243
|
+
case "audit":
|
|
2244
|
+
await audit(process.argv.slice(3));
|
|
2245
|
+
break;
|
|
2246
|
+
case "test":
|
|
2247
|
+
await test(process.argv.slice(3));
|
|
2248
|
+
break;
|
|
2249
|
+
case "bench":
|
|
2250
|
+
await bench(process.argv.slice(3));
|
|
2251
|
+
break;
|
|
2252
|
+
case "export":
|
|
2253
|
+
await exportSite(process.argv.slice(3));
|
|
2254
|
+
break;
|
|
2255
|
+
case "migrate":
|
|
2256
|
+
await migrate(process.argv.slice(3));
|
|
2257
|
+
break;
|
|
1445
2258
|
default:
|
|
1446
2259
|
log.error(
|
|
1447
2260
|
`Unknown command: ${c.bold}${command}${c.reset}`
|