blumenjs 0.2.0 → 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 +890 -67
- package/dist/cli/commands/build.js +60 -9
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +5 -1
- package/dist/templates/app/pages/BlumenStarter.tsx +1 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +67 -8
- package/dist/templates/app/shared/Link.tsx +60 -6
- package/dist/templates/app/shared/RouterContext.tsx +83 -20
- package/dist/templates/app/shared/router.ts +2 -1
- package/dist/templates/go-server/main.go +294 -4
- package/dist/templates/go-server/middleware.go +546 -0
- package/dist/templates/go-server/websocket.go +430 -0
- package/dist/templates/node-ssr/server.ts +467 -3
- package/dist/templates/scripts/generate-routes.ts +457 -17
- package/package.json +21 -7
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
|
}
|
|
@@ -243,7 +243,7 @@ async function dev() {
|
|
|
243
243
|
label: " go",
|
|
244
244
|
color: c.green,
|
|
245
245
|
cmd: "go",
|
|
246
|
-
args: ["run", "go-server/
|
|
246
|
+
args: ["run", "./go-server/"],
|
|
247
247
|
readyPattern: /Go server starting/
|
|
248
248
|
}
|
|
249
249
|
];
|
|
@@ -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,13 +368,23 @@ 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
|
-
cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external"
|
|
381
|
+
cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external --alias:@=./app"
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
label: "Pre-rendering static pages (SSG)",
|
|
385
|
+
cmd: "npx tsx scripts/ssg-prerender.ts",
|
|
386
|
+
optional: true
|
|
387
|
+
// Don't fail if no SSG pages exist
|
|
345
388
|
}
|
|
346
389
|
];
|
|
347
390
|
const startTime = Date.now();
|
|
@@ -349,15 +392,23 @@ async function build(args = []) {
|
|
|
349
392
|
const step = steps[i];
|
|
350
393
|
log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
|
|
351
394
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
+
}
|
|
357
404
|
log.success(step.label);
|
|
358
405
|
} catch {
|
|
359
|
-
|
|
360
|
-
|
|
406
|
+
if (step.optional) {
|
|
407
|
+
log.success(step.label);
|
|
408
|
+
} else {
|
|
409
|
+
log.error(`Failed: ${step.label}`);
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
361
412
|
}
|
|
362
413
|
}
|
|
363
414
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
@@ -375,10 +426,10 @@ async function build(args = []) {
|
|
|
375
426
|
|
|
376
427
|
// cli/commands/start.ts
|
|
377
428
|
import { spawn as spawn2 } from "child_process";
|
|
378
|
-
import * as
|
|
429
|
+
import * as fs3 from "fs";
|
|
379
430
|
async function start() {
|
|
380
431
|
banner();
|
|
381
|
-
if (!
|
|
432
|
+
if (!fs3.existsSync("dist/ssr-server.js")) {
|
|
382
433
|
log.error("Production build not found.");
|
|
383
434
|
log.info(
|
|
384
435
|
`Run ${c.bold}blumen build${c.reset} first to create a production build.`
|
|
@@ -401,7 +452,7 @@ async function start() {
|
|
|
401
452
|
label: " go",
|
|
402
453
|
color: c.green,
|
|
403
454
|
cmd: "go",
|
|
404
|
-
args: ["run", "go-server/
|
|
455
|
+
args: ["run", "./go-server/"],
|
|
405
456
|
readyPattern: /Go server starting/
|
|
406
457
|
}
|
|
407
458
|
];
|
|
@@ -463,23 +514,23 @@ async function start() {
|
|
|
463
514
|
}
|
|
464
515
|
|
|
465
516
|
// cli/commands/create.ts
|
|
466
|
-
import * as
|
|
467
|
-
import * as
|
|
517
|
+
import * as fs4 from "fs";
|
|
518
|
+
import * as path3 from "path";
|
|
468
519
|
import { execSync as execSync4 } from "child_process";
|
|
469
520
|
function getFrameworkRoot() {
|
|
470
|
-
const cliEntry =
|
|
471
|
-
const cliDir =
|
|
472
|
-
return
|
|
521
|
+
const cliEntry = fs4.realpathSync(process.argv[1]);
|
|
522
|
+
const cliDir = path3.dirname(cliEntry);
|
|
523
|
+
return path3.resolve(cliDir, "..");
|
|
473
524
|
}
|
|
474
525
|
function readProjectFile(relativePath) {
|
|
475
526
|
const root = getFrameworkRoot();
|
|
476
|
-
const bundledPath =
|
|
477
|
-
if (
|
|
478
|
-
return
|
|
527
|
+
const bundledPath = path3.join(root, "templates", relativePath);
|
|
528
|
+
if (fs4.existsSync(bundledPath)) {
|
|
529
|
+
return fs4.readFileSync(bundledPath, "utf-8");
|
|
479
530
|
}
|
|
480
|
-
const sourcePath =
|
|
481
|
-
if (
|
|
482
|
-
return
|
|
531
|
+
const sourcePath = path3.join(root, relativePath);
|
|
532
|
+
if (fs4.existsSync(sourcePath)) {
|
|
533
|
+
return fs4.readFileSync(sourcePath, "utf-8");
|
|
483
534
|
}
|
|
484
535
|
throw new Error(`Template file not found: ${relativePath}`);
|
|
485
536
|
}
|
|
@@ -744,9 +795,9 @@ static/js/bundle.js
|
|
|
744
795
|
.DS_Store
|
|
745
796
|
`;
|
|
746
797
|
function writeFile(base, relPath, content) {
|
|
747
|
-
const fullPath =
|
|
748
|
-
|
|
749
|
-
|
|
798
|
+
const fullPath = path3.join(base, relPath);
|
|
799
|
+
fs4.mkdirSync(path3.dirname(fullPath), { recursive: true });
|
|
800
|
+
fs4.writeFileSync(fullPath, content, "utf-8");
|
|
750
801
|
}
|
|
751
802
|
var TEMPLATE_MAP = {
|
|
752
803
|
starter: {
|
|
@@ -815,8 +866,8 @@ async function create(projectName) {
|
|
|
815
866
|
);
|
|
816
867
|
process.exit(1);
|
|
817
868
|
}
|
|
818
|
-
const projectDir =
|
|
819
|
-
if (
|
|
869
|
+
const projectDir = path3.resolve(process.cwd(), projectName);
|
|
870
|
+
if (fs4.existsSync(projectDir)) {
|
|
820
871
|
log.error(`Directory ${c.bold}${projectName}${c.reset} already exists.`);
|
|
821
872
|
process.exit(1);
|
|
822
873
|
}
|
|
@@ -885,8 +936,8 @@ async function create(projectName) {
|
|
|
885
936
|
|
|
886
937
|
// cli/commands/deploy.ts
|
|
887
938
|
import { execSync as execSync5, spawnSync } from "child_process";
|
|
888
|
-
import * as
|
|
889
|
-
import * as
|
|
939
|
+
import * as fs5 from "fs";
|
|
940
|
+
import * as path4 from "path";
|
|
890
941
|
var PLATFORMS = [
|
|
891
942
|
{ name: "Docker (any cloud)", ok: true, note: "Best option \u2014 multi-stage Dockerfile included" },
|
|
892
943
|
{ name: "Railway", ok: true, note: "Auto-detects Dockerfile, deploy with railway up" },
|
|
@@ -906,7 +957,7 @@ function hasCommand(cmd) {
|
|
|
906
957
|
}
|
|
907
958
|
}
|
|
908
959
|
function ensureDockerfile() {
|
|
909
|
-
if (!
|
|
960
|
+
if (!fs5.existsSync("Dockerfile")) {
|
|
910
961
|
log.error("No Dockerfile found in the current directory.");
|
|
911
962
|
log.info(
|
|
912
963
|
`Run ${c.bold}blumen create${c.reset} to scaffold a project with Docker support.`
|
|
@@ -924,7 +975,7 @@ async function deployDocker() {
|
|
|
924
975
|
);
|
|
925
976
|
process.exit(1);
|
|
926
977
|
}
|
|
927
|
-
const projectName =
|
|
978
|
+
const projectName = path4.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
928
979
|
const imageName = `blumen-${projectName}`;
|
|
929
980
|
log.step(`Building image: ${c.bold}${imageName}${c.reset}...`);
|
|
930
981
|
divider();
|
|
@@ -966,8 +1017,8 @@ async function deployDocker() {
|
|
|
966
1017
|
async function deployFly() {
|
|
967
1018
|
log.info("Fly.io Deployment\n");
|
|
968
1019
|
ensureDockerfile();
|
|
969
|
-
const projectName =
|
|
970
|
-
if (!
|
|
1020
|
+
const projectName = path4.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1021
|
+
if (!fs5.existsSync("fly.toml")) {
|
|
971
1022
|
log.step("Generating fly.toml...");
|
|
972
1023
|
const flyConfig = `# Fly.io configuration for Blumen app
|
|
973
1024
|
# Deploy with: fly deploy
|
|
@@ -997,7 +1048,7 @@ primary_region = "iad"
|
|
|
997
1048
|
cpu_kind = "shared"
|
|
998
1049
|
cpus = 1
|
|
999
1050
|
`;
|
|
1000
|
-
|
|
1051
|
+
fs5.writeFileSync("fly.toml", flyConfig);
|
|
1001
1052
|
log.success("fly.toml created");
|
|
1002
1053
|
} else {
|
|
1003
1054
|
log.info("fly.toml already exists, skipping generation.");
|
|
@@ -1038,7 +1089,7 @@ primary_region = "iad"
|
|
|
1038
1089
|
async function deployRailway() {
|
|
1039
1090
|
log.info("Railway Deployment\n");
|
|
1040
1091
|
ensureDockerfile();
|
|
1041
|
-
if (!
|
|
1092
|
+
if (!fs5.existsSync("railway.toml")) {
|
|
1042
1093
|
log.step("Generating railway.toml...");
|
|
1043
1094
|
const railwayConfig = `# Railway configuration for Blumen app
|
|
1044
1095
|
# Deploy with: railway up
|
|
@@ -1054,7 +1105,7 @@ async function deployRailway() {
|
|
|
1054
1105
|
restartPolicyType = "ON_FAILURE"
|
|
1055
1106
|
restartPolicyMaxRetries = 5
|
|
1056
1107
|
`;
|
|
1057
|
-
|
|
1108
|
+
fs5.writeFileSync("railway.toml", railwayConfig);
|
|
1058
1109
|
log.success("railway.toml created");
|
|
1059
1110
|
} else {
|
|
1060
1111
|
log.info("railway.toml already exists, skipping generation.");
|
|
@@ -1149,12 +1200,12 @@ async function deploy(subcommand) {
|
|
|
1149
1200
|
}
|
|
1150
1201
|
|
|
1151
1202
|
// cli/commands/fonts.ts
|
|
1152
|
-
import * as
|
|
1153
|
-
import * as
|
|
1203
|
+
import * as fs6 from "fs";
|
|
1204
|
+
import * as path5 from "path";
|
|
1154
1205
|
import * as https from "https";
|
|
1155
|
-
var FONTS_DIR =
|
|
1156
|
-
var FONTS_CSS =
|
|
1157
|
-
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");
|
|
1158
1209
|
var DEFAULT_MANIFEST = {
|
|
1159
1210
|
fonts: []
|
|
1160
1211
|
};
|
|
@@ -1172,13 +1223,13 @@ var FALLBACK_MAP = {
|
|
|
1172
1223
|
"JetBrains Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "103%" }
|
|
1173
1224
|
};
|
|
1174
1225
|
function downloadFile(url, dest) {
|
|
1175
|
-
return new Promise((
|
|
1176
|
-
const file =
|
|
1226
|
+
return new Promise((resolve6, reject) => {
|
|
1227
|
+
const file = fs6.createWriteStream(dest);
|
|
1177
1228
|
https.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (response) => {
|
|
1178
1229
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
|
1179
1230
|
const redirectUrl = response.headers.location;
|
|
1180
1231
|
if (redirectUrl) {
|
|
1181
|
-
downloadFile(redirectUrl, dest).then(
|
|
1232
|
+
downloadFile(redirectUrl, dest).then(resolve6).catch(reject);
|
|
1182
1233
|
return;
|
|
1183
1234
|
}
|
|
1184
1235
|
}
|
|
@@ -1189,17 +1240,17 @@ function downloadFile(url, dest) {
|
|
|
1189
1240
|
response.pipe(file);
|
|
1190
1241
|
file.on("finish", () => {
|
|
1191
1242
|
file.close();
|
|
1192
|
-
|
|
1243
|
+
resolve6();
|
|
1193
1244
|
});
|
|
1194
1245
|
}).on("error", (err) => {
|
|
1195
|
-
|
|
1246
|
+
fs6.unlink(dest, () => {
|
|
1196
1247
|
});
|
|
1197
1248
|
reject(err);
|
|
1198
1249
|
});
|
|
1199
1250
|
});
|
|
1200
1251
|
}
|
|
1201
1252
|
function fetchGoogleFontsCSS(family, weights, subsets) {
|
|
1202
|
-
return new Promise((
|
|
1253
|
+
return new Promise((resolve6, reject) => {
|
|
1203
1254
|
const weightStr = weights.join(";");
|
|
1204
1255
|
const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weightStr}&display=swap&subset=${subsets.join(",")}`;
|
|
1205
1256
|
https.get(url, {
|
|
@@ -1212,7 +1263,7 @@ function fetchGoogleFontsCSS(family, weights, subsets) {
|
|
|
1212
1263
|
response.on("data", (chunk) => {
|
|
1213
1264
|
data += chunk.toString();
|
|
1214
1265
|
});
|
|
1215
|
-
response.on("end", () =>
|
|
1266
|
+
response.on("end", () => resolve6(data));
|
|
1216
1267
|
}).on("error", reject);
|
|
1217
1268
|
});
|
|
1218
1269
|
}
|
|
@@ -1282,11 +1333,11 @@ async function processFont(config) {
|
|
|
1282
1333
|
const ext = font.format === "woff2" ? "woff2" : "woff";
|
|
1283
1334
|
const safeName = config.family.replace(/\s+/g, "-").toLowerCase();
|
|
1284
1335
|
const fileName = `${safeName}-${font.weight}-${font.style}.${ext}`;
|
|
1285
|
-
const filePath =
|
|
1286
|
-
if (!
|
|
1336
|
+
const filePath = path5.join(FONTS_DIR, fileName);
|
|
1337
|
+
if (!fs6.existsSync(filePath)) {
|
|
1287
1338
|
try {
|
|
1288
1339
|
await downloadFile(font.url, filePath);
|
|
1289
|
-
const size =
|
|
1340
|
+
const size = fs6.statSync(filePath).size;
|
|
1290
1341
|
console.log(` Downloaded: ${fileName} (${(size / 1024).toFixed(1)} KB)`);
|
|
1291
1342
|
} catch (err) {
|
|
1292
1343
|
console.error(` Failed to download ${fileName}: ${err}`);
|
|
@@ -1307,13 +1358,13 @@ async function processFont(config) {
|
|
|
1307
1358
|
return generatedCSS;
|
|
1308
1359
|
}
|
|
1309
1360
|
function loadManifest() {
|
|
1310
|
-
if (
|
|
1311
|
-
return JSON.parse(
|
|
1361
|
+
if (fs6.existsSync(FONTS_CONFIG)) {
|
|
1362
|
+
return JSON.parse(fs6.readFileSync(FONTS_CONFIG, "utf-8"));
|
|
1312
1363
|
}
|
|
1313
1364
|
return { ...DEFAULT_MANIFEST };
|
|
1314
1365
|
}
|
|
1315
1366
|
function saveManifest(manifest) {
|
|
1316
|
-
|
|
1367
|
+
fs6.writeFileSync(FONTS_CONFIG, JSON.stringify(manifest, null, 2), "utf-8");
|
|
1317
1368
|
}
|
|
1318
1369
|
async function fontsCommand(args) {
|
|
1319
1370
|
console.log("\n \u{1F338} Blumen Font Optimizer\n");
|
|
@@ -1355,8 +1406,8 @@ async function fontsCommand(args) {
|
|
|
1355
1406
|
console.log(' blumen fonts --add "Roboto"');
|
|
1356
1407
|
return;
|
|
1357
1408
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1409
|
+
fs6.mkdirSync(FONTS_DIR, { recursive: true });
|
|
1410
|
+
fs6.mkdirSync(path5.dirname(FONTS_CSS), { recursive: true });
|
|
1360
1411
|
let allCSS = `/* Auto-generated by Blumen Font Optimizer */
|
|
1361
1412
|
/* Do not edit manually - run 'blumen fonts' to regenerate */
|
|
1362
1413
|
|
|
@@ -1365,10 +1416,10 @@ async function fontsCommand(args) {
|
|
|
1365
1416
|
const css = await processFont(fontConfig);
|
|
1366
1417
|
allCSS += css + "\n";
|
|
1367
1418
|
}
|
|
1368
|
-
|
|
1419
|
+
fs6.writeFileSync(FONTS_CSS, allCSS, "utf-8");
|
|
1369
1420
|
console.log(`
|
|
1370
|
-
\u2705 Font CSS written to: ${
|
|
1371
|
-
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)}/`);
|
|
1372
1423
|
console.log(`
|
|
1373
1424
|
To use these fonts, import the CSS in your _app.tsx or _document.tsx:`);
|
|
1374
1425
|
console.log(` import '../../static/css/fonts.css';`);
|
|
@@ -1378,6 +1429,748 @@ async function fontsCommand(args) {
|
|
|
1378
1429
|
console.log();
|
|
1379
1430
|
}
|
|
1380
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
|
+
|
|
1381
2174
|
// cli/blumen.ts
|
|
1382
2175
|
async function main() {
|
|
1383
2176
|
const command = process.argv[2];
|
|
@@ -1402,6 +2195,21 @@ async function main() {
|
|
|
1402
2195
|
console.log(
|
|
1403
2196
|
` fonts Download and optimize fonts`
|
|
1404
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
|
+
);
|
|
1405
2213
|
console.log("");
|
|
1406
2214
|
console.log(` ${c.bold}Options${c.reset}`);
|
|
1407
2215
|
console.log(` --help Show this help message`);
|
|
@@ -1432,6 +2240,21 @@ async function main() {
|
|
|
1432
2240
|
case "fonts":
|
|
1433
2241
|
await fontsCommand(process.argv.slice(3));
|
|
1434
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;
|
|
1435
2258
|
default:
|
|
1436
2259
|
log.error(
|
|
1437
2260
|
`Unknown command: ${c.bold}${command}${c.reset}`
|