blumenjs 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/blumen.js +875 -62
- package/dist/cli/commands/audit.js +204 -0
- package/dist/cli/commands/bench.js +227 -0
- package/dist/cli/commands/build.js +47 -6
- package/dist/cli/commands/export.js +241 -0
- package/dist/cli/commands/migrate.js +267 -0
- package/dist/cli/commands/test.js +118 -0
- 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/actions.go +147 -0
- package/dist/templates/go-server/main.go +107 -4
- package/dist/templates/go-server/middleware.go +1 -1
- package/dist/templates/go-server/redirects.go +203 -0
- package/dist/templates/go-server/ssg.go +230 -0
- package/dist/templates/node-ssr/server.ts +222 -2
- package/dist/templates/scripts/generate-routes.ts +141 -9
- package/package.json +16 -4
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// cli/commands/export.ts
|
|
2
|
+
import { execSync, spawn } from "child_process";
|
|
3
|
+
import * as fs2 from "fs";
|
|
4
|
+
import * as path2 from "path";
|
|
5
|
+
import * as http from "http";
|
|
6
|
+
|
|
7
|
+
// cli/utils.ts
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
var c = {
|
|
12
|
+
reset: "\x1B[0m",
|
|
13
|
+
bold: "\x1B[1m",
|
|
14
|
+
dim: "\x1B[2m",
|
|
15
|
+
red: "\x1B[31m",
|
|
16
|
+
green: "\x1B[32m",
|
|
17
|
+
yellow: "\x1B[33m",
|
|
18
|
+
blue: "\x1B[34m",
|
|
19
|
+
magenta: "\x1B[35m",
|
|
20
|
+
cyan: "\x1B[36m",
|
|
21
|
+
white: "\x1B[37m",
|
|
22
|
+
gray: "\x1B[90m"
|
|
23
|
+
};
|
|
24
|
+
var log = {
|
|
25
|
+
info: (msg) => console.log(` ${c.magenta}\u25CF${c.reset} ${msg}`),
|
|
26
|
+
success: (msg) => console.log(` ${c.green}\u2713${c.reset} ${msg}`),
|
|
27
|
+
error: (msg) => console.error(` ${c.red}\u2717${c.reset} ${msg}`),
|
|
28
|
+
warn: (msg) => console.log(` ${c.yellow}\u26A0${c.reset} ${msg}`),
|
|
29
|
+
step: (msg) => console.log(` ${c.dim}\u2192${c.reset} ${msg}`),
|
|
30
|
+
blank: () => console.log("")
|
|
31
|
+
};
|
|
32
|
+
function getVersion() {
|
|
33
|
+
try {
|
|
34
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
35
|
+
let dir = path.dirname(thisFile);
|
|
36
|
+
for (let i = 0; i < 5; i++) {
|
|
37
|
+
const pkgFile = path.join(dir, "package.json");
|
|
38
|
+
if (fs.existsSync(pkgFile)) {
|
|
39
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf-8"));
|
|
40
|
+
if (pkg.name === "blumenjs" || pkg.name === "go-react-ssr") {
|
|
41
|
+
return pkg.version;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
dir = path.dirname(dir);
|
|
45
|
+
}
|
|
46
|
+
return "0.0.0";
|
|
47
|
+
} catch {
|
|
48
|
+
return "0.0.0";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function banner() {
|
|
52
|
+
const version = getVersion();
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log(
|
|
55
|
+
` ${c.magenta}${c.bold}\u{1F338} Blumen${c.reset} ${c.dim}v${version}${c.reset}`
|
|
56
|
+
);
|
|
57
|
+
console.log(
|
|
58
|
+
` ${c.dim}The React framework powered by Go${c.reset}`
|
|
59
|
+
);
|
|
60
|
+
console.log("");
|
|
61
|
+
}
|
|
62
|
+
function divider() {
|
|
63
|
+
console.log(` ${c.dim}${"\u2500".repeat(48)}${c.reset}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// cli/commands/export.ts
|
|
67
|
+
async function renderPage(route, ssrUrl) {
|
|
68
|
+
const body = JSON.stringify({
|
|
69
|
+
path: route,
|
|
70
|
+
query: {},
|
|
71
|
+
params: {}
|
|
72
|
+
});
|
|
73
|
+
return new Promise((resolve2, reject) => {
|
|
74
|
+
const req = http.request(ssrUrl, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"Content-Length": Buffer.byteLength(body)
|
|
79
|
+
}
|
|
80
|
+
}, (res) => {
|
|
81
|
+
let data = "";
|
|
82
|
+
res.on("data", (chunk) => data += chunk);
|
|
83
|
+
res.on("end", () => {
|
|
84
|
+
try {
|
|
85
|
+
const json = JSON.parse(data);
|
|
86
|
+
if (json.html) {
|
|
87
|
+
resolve2(json.html);
|
|
88
|
+
} else {
|
|
89
|
+
reject(new Error("SSR response missing html field"));
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
reject(new Error(`Invalid SSR response: ${data.slice(0, 100)}`));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
req.on("error", (err) => reject(new Error(`SSR server unreachable: ${err.message}`)));
|
|
97
|
+
req.setTimeout(15e3, () => {
|
|
98
|
+
req.destroy();
|
|
99
|
+
reject(new Error("SSR timeout"));
|
|
100
|
+
});
|
|
101
|
+
req.write(body);
|
|
102
|
+
req.end();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function discoverRoutes() {
|
|
106
|
+
const pagesDir = path2.resolve("app/pages");
|
|
107
|
+
const routes = [];
|
|
108
|
+
function scan(dir, prefix) {
|
|
109
|
+
if (!fs2.existsSync(dir))
|
|
110
|
+
return;
|
|
111
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.name.startsWith("_") || entry.name.startsWith("."))
|
|
114
|
+
continue;
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
scan(path2.join(dir, entry.name), `${prefix}/${entry.name.toLowerCase()}`);
|
|
117
|
+
} else if (entry.name.endsWith(".tsx") && !entry.name.startsWith("NotFound")) {
|
|
118
|
+
const name = entry.name.replace(".tsx", "");
|
|
119
|
+
if (name.toLowerCase() === "home") {
|
|
120
|
+
routes.push("/");
|
|
121
|
+
} else if (name === "index") {
|
|
122
|
+
routes.push(prefix || "/");
|
|
123
|
+
} else if (!name.includes("[")) {
|
|
124
|
+
routes.push(`${prefix}/${name.toLowerCase()}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
scan(pagesDir, "");
|
|
130
|
+
return routes;
|
|
131
|
+
}
|
|
132
|
+
async function waitForServer(url, maxWaitMs = 15e3) {
|
|
133
|
+
const start = Date.now();
|
|
134
|
+
while (Date.now() - start < maxWaitMs) {
|
|
135
|
+
try {
|
|
136
|
+
await new Promise((resolve2, reject) => {
|
|
137
|
+
const req = http.get(url, () => resolve2());
|
|
138
|
+
req.on("error", reject);
|
|
139
|
+
req.setTimeout(1e3, () => {
|
|
140
|
+
req.destroy();
|
|
141
|
+
reject(new Error("timeout"));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
async function exportSite(args = []) {
|
|
152
|
+
banner();
|
|
153
|
+
let outDir = "dist/export";
|
|
154
|
+
for (let i = 0; i < args.length; i++) {
|
|
155
|
+
if (args[i] === "--out" && args[i + 1]) {
|
|
156
|
+
outDir = args[++i];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
log.info("Exporting static site...");
|
|
160
|
+
log.blank();
|
|
161
|
+
log.info("Step 1/4: Building production bundle...");
|
|
162
|
+
try {
|
|
163
|
+
execSync("npx tsx cli/blumen.ts build", { stdio: "inherit", cwd: process.cwd() });
|
|
164
|
+
} catch {
|
|
165
|
+
log.error("Build failed. Fix errors and try again.");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
log.blank();
|
|
169
|
+
log.info("Step 2/4: Starting SSR server for pre-rendering...");
|
|
170
|
+
const ssrProcess = spawn("node", ["dist/ssr-server.js"], {
|
|
171
|
+
cwd: process.cwd(),
|
|
172
|
+
env: { ...process.env, NODE_ENV: "production", PORT: "4001" },
|
|
173
|
+
stdio: "pipe"
|
|
174
|
+
});
|
|
175
|
+
const ssrUrl = "http://localhost:4001/render";
|
|
176
|
+
const serverReady = await waitForServer("http://localhost:4001/health", 15e3);
|
|
177
|
+
if (!serverReady) {
|
|
178
|
+
log.warn("SSR server health check failed, attempting rendering anyway...");
|
|
179
|
+
}
|
|
180
|
+
log.blank();
|
|
181
|
+
log.info("Step 3/4: Pre-rendering pages...");
|
|
182
|
+
const routes = discoverRoutes();
|
|
183
|
+
const outputDir = path2.resolve(outDir);
|
|
184
|
+
fs2.mkdirSync(outputDir, { recursive: true });
|
|
185
|
+
let success = 0;
|
|
186
|
+
let failed = 0;
|
|
187
|
+
for (const route of routes) {
|
|
188
|
+
try {
|
|
189
|
+
const html = await renderPage(route, ssrUrl);
|
|
190
|
+
const filePath = route === "/" ? path2.join(outputDir, "index.html") : path2.join(outputDir, route.slice(1), "index.html");
|
|
191
|
+
fs2.mkdirSync(path2.dirname(filePath), { recursive: true });
|
|
192
|
+
fs2.writeFileSync(filePath, html, "utf-8");
|
|
193
|
+
console.log(` ${c.green}\u2713${c.reset} ${route} \u2192 ${path2.relative(process.cwd(), filePath)}`);
|
|
194
|
+
success++;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.log(` ${c.red}\u2717${c.reset} ${route}: ${err.message}`);
|
|
197
|
+
failed++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
ssrProcess.kill("SIGTERM");
|
|
201
|
+
log.blank();
|
|
202
|
+
log.info("Step 4/4: Copying static assets...");
|
|
203
|
+
const staticDir = path2.resolve("static");
|
|
204
|
+
if (fs2.existsSync(staticDir)) {
|
|
205
|
+
copyDir(staticDir, path2.join(outputDir, "static"));
|
|
206
|
+
log.success("Static assets copied.");
|
|
207
|
+
}
|
|
208
|
+
const distJs = path2.resolve("dist/client");
|
|
209
|
+
if (fs2.existsSync(distJs)) {
|
|
210
|
+
copyDir(distJs, path2.join(outputDir, "static/js"));
|
|
211
|
+
}
|
|
212
|
+
log.blank();
|
|
213
|
+
divider();
|
|
214
|
+
log.blank();
|
|
215
|
+
log.success(`Static export complete! \u2728`);
|
|
216
|
+
log.info(`${success} page(s) exported${failed > 0 ? `, ${failed} failed` : ""}`);
|
|
217
|
+
log.info(`Output: ${c.bold}${outDir}/${c.reset}`);
|
|
218
|
+
log.blank();
|
|
219
|
+
log.info(`Deploy anywhere:`);
|
|
220
|
+
log.info(` ${c.dim}GitHub Pages:${c.reset} push ${outDir}/ to gh-pages branch`);
|
|
221
|
+
log.info(` ${c.dim}Netlify:${c.reset} set publish directory to ${outDir}/`);
|
|
222
|
+
log.info(` ${c.dim}S3:${c.reset} aws s3 sync ${outDir}/ s3://my-bucket`);
|
|
223
|
+
log.info(` ${c.dim}Local:${c.reset} npx serve ${outDir}/`);
|
|
224
|
+
log.blank();
|
|
225
|
+
}
|
|
226
|
+
function copyDir(src, dest) {
|
|
227
|
+
fs2.mkdirSync(dest, { recursive: true });
|
|
228
|
+
const entries = fs2.readdirSync(src, { withFileTypes: true });
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
const srcPath = path2.join(src, entry.name);
|
|
231
|
+
const destPath = path2.join(dest, entry.name);
|
|
232
|
+
if (entry.isDirectory()) {
|
|
233
|
+
copyDir(srcPath, destPath);
|
|
234
|
+
} else {
|
|
235
|
+
fs2.copyFileSync(srcPath, destPath);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
exportSite
|
|
241
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// cli/commands/migrate.ts
|
|
2
|
+
import * as fs2 from "fs";
|
|
3
|
+
import * as path2 from "path";
|
|
4
|
+
|
|
5
|
+
// cli/utils.ts
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
var c = {
|
|
10
|
+
reset: "\x1B[0m",
|
|
11
|
+
bold: "\x1B[1m",
|
|
12
|
+
dim: "\x1B[2m",
|
|
13
|
+
red: "\x1B[31m",
|
|
14
|
+
green: "\x1B[32m",
|
|
15
|
+
yellow: "\x1B[33m",
|
|
16
|
+
blue: "\x1B[34m",
|
|
17
|
+
magenta: "\x1B[35m",
|
|
18
|
+
cyan: "\x1B[36m",
|
|
19
|
+
white: "\x1B[37m",
|
|
20
|
+
gray: "\x1B[90m"
|
|
21
|
+
};
|
|
22
|
+
var log = {
|
|
23
|
+
info: (msg) => console.log(` ${c.magenta}\u25CF${c.reset} ${msg}`),
|
|
24
|
+
success: (msg) => console.log(` ${c.green}\u2713${c.reset} ${msg}`),
|
|
25
|
+
error: (msg) => console.error(` ${c.red}\u2717${c.reset} ${msg}`),
|
|
26
|
+
warn: (msg) => console.log(` ${c.yellow}\u26A0${c.reset} ${msg}`),
|
|
27
|
+
step: (msg) => console.log(` ${c.dim}\u2192${c.reset} ${msg}`),
|
|
28
|
+
blank: () => console.log("")
|
|
29
|
+
};
|
|
30
|
+
function getVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
33
|
+
let dir = path.dirname(thisFile);
|
|
34
|
+
for (let i = 0; i < 5; i++) {
|
|
35
|
+
const pkgFile = path.join(dir, "package.json");
|
|
36
|
+
if (fs.existsSync(pkgFile)) {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf-8"));
|
|
38
|
+
if (pkg.name === "blumenjs" || pkg.name === "go-react-ssr") {
|
|
39
|
+
return pkg.version;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
dir = path.dirname(dir);
|
|
43
|
+
}
|
|
44
|
+
return "0.0.0";
|
|
45
|
+
} catch {
|
|
46
|
+
return "0.0.0";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function banner() {
|
|
50
|
+
const version = getVersion();
|
|
51
|
+
console.log("");
|
|
52
|
+
console.log(
|
|
53
|
+
` ${c.magenta}${c.bold}\u{1F338} Blumen${c.reset} ${c.dim}v${version}${c.reset}`
|
|
54
|
+
);
|
|
55
|
+
console.log(
|
|
56
|
+
` ${c.dim}The React framework powered by Go${c.reset}`
|
|
57
|
+
);
|
|
58
|
+
console.log("");
|
|
59
|
+
}
|
|
60
|
+
function divider() {
|
|
61
|
+
console.log(` ${c.dim}${"\u2500".repeat(48)}${c.reset}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// cli/commands/migrate.ts
|
|
65
|
+
function isNextJsProject(dir) {
|
|
66
|
+
return fs2.existsSync(path2.join(dir, "next.config.js")) || fs2.existsSync(path2.join(dir, "next.config.mjs")) || fs2.existsSync(path2.join(dir, "next.config.ts")) || fs2.existsSync(path2.join(dir, "pages")) || fs2.existsSync(path2.join(dir, "src/pages"));
|
|
67
|
+
}
|
|
68
|
+
function findPagesDir(dir) {
|
|
69
|
+
if (fs2.existsSync(path2.join(dir, "pages")))
|
|
70
|
+
return path2.join(dir, "pages");
|
|
71
|
+
if (fs2.existsSync(path2.join(dir, "src/pages")))
|
|
72
|
+
return path2.join(dir, "src/pages");
|
|
73
|
+
if (fs2.existsSync(path2.join(dir, "app")))
|
|
74
|
+
return path2.join(dir, "app");
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function getAllTsxFiles(dir) {
|
|
78
|
+
const results = [];
|
|
79
|
+
if (!fs2.existsSync(dir))
|
|
80
|
+
return results;
|
|
81
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const fullPath = path2.join(dir, entry.name);
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
results.push(...getAllTsxFiles(fullPath));
|
|
86
|
+
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
87
|
+
results.push(fullPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
function rewriteImports(content, depth) {
|
|
93
|
+
const prefix = "../".repeat(depth) || "./";
|
|
94
|
+
const sharedPrefix = `${prefix}../shared`;
|
|
95
|
+
content = content.replace(
|
|
96
|
+
/import\s+(?:Link|{[^}]*})\s+from\s+['"]next\/link['"]/g,
|
|
97
|
+
`import { Link } from "${sharedPrefix}/Link"`
|
|
98
|
+
);
|
|
99
|
+
content = content.replace(
|
|
100
|
+
/import\s+(?:Head|{[^}]*})\s+from\s+['"]next\/head['"]/g,
|
|
101
|
+
`import { BlumenHead } from "${sharedPrefix}/BlumenHead"`
|
|
102
|
+
);
|
|
103
|
+
content = content.replace(
|
|
104
|
+
/import\s+(?:Image|{[^}]*})\s+from\s+['"]next\/image['"]/g,
|
|
105
|
+
`// TODO: Replace next/image with standard <img> or BlumenImage
|
|
106
|
+
// import Image from "next/image"`
|
|
107
|
+
);
|
|
108
|
+
content = content.replace(
|
|
109
|
+
/import\s+(?:{[^}]*})\s+from\s+['"]next\/router['"]/g,
|
|
110
|
+
`import { useRouter } from "${sharedPrefix}/RouterContext"`
|
|
111
|
+
);
|
|
112
|
+
return content;
|
|
113
|
+
}
|
|
114
|
+
function rewriteDataFetching(content) {
|
|
115
|
+
const changes = [];
|
|
116
|
+
if (content.includes("getServerSideProps")) {
|
|
117
|
+
content = content.replace(/getServerSideProps/g, "getServerProps");
|
|
118
|
+
changes.push("getServerSideProps \u2192 getServerProps");
|
|
119
|
+
}
|
|
120
|
+
if (content.includes("getStaticProps") && !content.includes("force-static")) {
|
|
121
|
+
content += `
|
|
122
|
+
|
|
123
|
+
// Mark this page for static generation
|
|
124
|
+
export const dynamic = 'force-static';
|
|
125
|
+
`;
|
|
126
|
+
changes.push("Added export const dynamic = 'force-static'");
|
|
127
|
+
}
|
|
128
|
+
content = content.replace(/GetServerSidePropsContext/g, "BlumenContext");
|
|
129
|
+
if (content.includes("BlumenContext") && !content.includes("import")) {
|
|
130
|
+
changes.push("GetServerSidePropsContext \u2192 BlumenContext");
|
|
131
|
+
}
|
|
132
|
+
return { content, changes };
|
|
133
|
+
}
|
|
134
|
+
async function migrate(args = []) {
|
|
135
|
+
banner();
|
|
136
|
+
const dryRun = args.includes("--dry-run");
|
|
137
|
+
const sourceDir = args.find((a) => !a.startsWith("--")) || ".";
|
|
138
|
+
const absDir = path2.resolve(sourceDir);
|
|
139
|
+
log.info(`Analyzing ${c.bold}${absDir}${c.reset} for Next.js project...`);
|
|
140
|
+
log.blank();
|
|
141
|
+
if (!isNextJsProject(absDir)) {
|
|
142
|
+
log.error("No Next.js project detected.");
|
|
143
|
+
log.info("Expected: next.config.js/mjs/ts or pages/ directory");
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
log.success("Next.js project detected!");
|
|
147
|
+
log.blank();
|
|
148
|
+
const result = {
|
|
149
|
+
moved: [],
|
|
150
|
+
rewritten: [],
|
|
151
|
+
warnings: [],
|
|
152
|
+
errors: []
|
|
153
|
+
};
|
|
154
|
+
const pagesDir = findPagesDir(absDir);
|
|
155
|
+
if (!pagesDir) {
|
|
156
|
+
log.error("Could not find pages/ or src/pages/ directory");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
const isAppRouter = pagesDir.endsWith("/app");
|
|
160
|
+
if (isAppRouter) {
|
|
161
|
+
result.warnings.push("Next.js App Router detected. Migration from App Router requires manual review.");
|
|
162
|
+
}
|
|
163
|
+
log.info(`Pages directory: ${c.bold}${path2.relative(absDir, pagesDir)}/${c.reset}`);
|
|
164
|
+
log.blank();
|
|
165
|
+
divider();
|
|
166
|
+
log.blank();
|
|
167
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 1: Analyzing files${c.reset}`);
|
|
168
|
+
log.blank();
|
|
169
|
+
const files = getAllTsxFiles(pagesDir);
|
|
170
|
+
console.log(` Found ${files.length} source file(s)`);
|
|
171
|
+
log.blank();
|
|
172
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 2: Rewriting imports${c.reset}`);
|
|
173
|
+
log.blank();
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const relPath = path2.relative(pagesDir, file);
|
|
176
|
+
const depth = relPath.split("/").length;
|
|
177
|
+
let content = fs2.readFileSync(file, "utf-8");
|
|
178
|
+
let modified = false;
|
|
179
|
+
const fileChanges = [];
|
|
180
|
+
const newContent = rewriteImports(content, depth);
|
|
181
|
+
if (newContent !== content) {
|
|
182
|
+
content = newContent;
|
|
183
|
+
modified = true;
|
|
184
|
+
fileChanges.push("Import rewrites");
|
|
185
|
+
}
|
|
186
|
+
const { content: dfContent, changes } = rewriteDataFetching(content);
|
|
187
|
+
if (changes.length > 0) {
|
|
188
|
+
content = dfContent;
|
|
189
|
+
modified = true;
|
|
190
|
+
fileChanges.push(...changes);
|
|
191
|
+
}
|
|
192
|
+
if (modified) {
|
|
193
|
+
if (!dryRun) {
|
|
194
|
+
fs2.writeFileSync(file, content, "utf-8");
|
|
195
|
+
}
|
|
196
|
+
console.log(` ${c.green}\u2713${c.reset} ${relPath}: ${fileChanges.join(", ")}`);
|
|
197
|
+
result.rewritten.push(relPath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (result.rewritten.length === 0) {
|
|
201
|
+
console.log(` ${c.dim}No import rewrites needed${c.reset}`);
|
|
202
|
+
}
|
|
203
|
+
log.blank();
|
|
204
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 3: File structure mapping${c.reset}`);
|
|
205
|
+
log.blank();
|
|
206
|
+
const mappings = [
|
|
207
|
+
["pages/_app.tsx", "\u2192", "app/shared/_app.tsx"],
|
|
208
|
+
["pages/_document.tsx", "\u2192", "app/shared/_document.tsx"],
|
|
209
|
+
["pages/index.tsx", "\u2192", "app/pages/Home.tsx"],
|
|
210
|
+
["pages/[slug].tsx", "\u2192", "app/pages/[slug].tsx"],
|
|
211
|
+
["pages/api/", "\u2192", "app/api/ (Blumen API routes)"],
|
|
212
|
+
["public/", "\u2192", "static/"],
|
|
213
|
+
["styles/", "\u2192", "app/shared/styles/"]
|
|
214
|
+
];
|
|
215
|
+
for (const [from, arrow, to] of mappings) {
|
|
216
|
+
console.log(` ${c.dim}${from}${c.reset} ${arrow} ${c.bold}${to}${c.reset}`);
|
|
217
|
+
}
|
|
218
|
+
log.blank();
|
|
219
|
+
divider();
|
|
220
|
+
log.blank();
|
|
221
|
+
console.log(` ${c.bold}Migration Summary${c.reset}`);
|
|
222
|
+
log.blank();
|
|
223
|
+
const summaryTable = [
|
|
224
|
+
["Files analyzed", `${files.length}`],
|
|
225
|
+
["Imports rewritten", `${result.rewritten.length}`],
|
|
226
|
+
["Warnings", `${result.warnings.length}`],
|
|
227
|
+
["Mode", dryRun ? "Dry run (no changes)" : "Applied"]
|
|
228
|
+
];
|
|
229
|
+
for (const [label, value] of summaryTable) {
|
|
230
|
+
console.log(` ${c.dim}${label}:${c.reset} ${value}`);
|
|
231
|
+
}
|
|
232
|
+
log.blank();
|
|
233
|
+
if (result.warnings.length > 0) {
|
|
234
|
+
console.log(` ${c.yellow}${c.bold}Warnings:${c.reset}`);
|
|
235
|
+
for (const w of result.warnings) {
|
|
236
|
+
console.log(` ${c.yellow}\u26A0${c.reset} ${w}`);
|
|
237
|
+
}
|
|
238
|
+
log.blank();
|
|
239
|
+
}
|
|
240
|
+
console.log(` ${c.bold}Next Steps${c.reset}`);
|
|
241
|
+
log.blank();
|
|
242
|
+
console.log(` 1. Move your page files to ${c.bold}app/pages/${c.reset}`);
|
|
243
|
+
console.log(` 2. Move ${c.bold}_app.tsx${c.reset} to ${c.bold}app/shared/_app.tsx${c.reset}`);
|
|
244
|
+
console.log(` 3. Move ${c.bold}public/${c.reset} to ${c.bold}static/${c.reset}`);
|
|
245
|
+
console.log(` 4. Replace ${c.bold}next/image${c.reset} usage with standard ${c.bold}<img>${c.reset} or ${c.bold}<BlumenImage>${c.reset}`);
|
|
246
|
+
console.log(` 5. Run ${c.bold}blumen dev${c.reset} to test`);
|
|
247
|
+
log.blank();
|
|
248
|
+
console.log(` ${c.bold}API Comparison${c.reset}`);
|
|
249
|
+
log.blank();
|
|
250
|
+
const comparison = [
|
|
251
|
+
["next/link", "Link from shared/Link", "\u2713 Auto-converted"],
|
|
252
|
+
["next/head", "BlumenHead", "\u2713 Auto-converted"],
|
|
253
|
+
["next/router", "useRouter from RouterContext", "\u2713 Auto-converted"],
|
|
254
|
+
["getServerSideProps", "getServerProps", "\u2713 Auto-converted"],
|
|
255
|
+
["getStaticProps", "getStaticProps", "\u2713 Same API + force-static"],
|
|
256
|
+
["next/image", "<img> or <BlumenImage>", "\u26A0 Manual review"],
|
|
257
|
+
["API Routes (pages/api)", "app/api/ handlers", "\u26A0 Manual migration"],
|
|
258
|
+
["Middleware", "Go middleware", "\u26A0 Manual migration"]
|
|
259
|
+
];
|
|
260
|
+
for (const [next, blumen, status] of comparison) {
|
|
261
|
+
console.log(` ${c.dim}${next.padEnd(25)}${c.reset}\u2192 ${blumen.padEnd(30)} ${status}`);
|
|
262
|
+
}
|
|
263
|
+
log.blank();
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
migrate
|
|
267
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// cli/commands/test.ts
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
|
|
4
|
+
// cli/utils.ts
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
var c = {
|
|
9
|
+
reset: "\x1B[0m",
|
|
10
|
+
bold: "\x1B[1m",
|
|
11
|
+
dim: "\x1B[2m",
|
|
12
|
+
red: "\x1B[31m",
|
|
13
|
+
green: "\x1B[32m",
|
|
14
|
+
yellow: "\x1B[33m",
|
|
15
|
+
blue: "\x1B[34m",
|
|
16
|
+
magenta: "\x1B[35m",
|
|
17
|
+
cyan: "\x1B[36m",
|
|
18
|
+
white: "\x1B[37m",
|
|
19
|
+
gray: "\x1B[90m"
|
|
20
|
+
};
|
|
21
|
+
var log = {
|
|
22
|
+
info: (msg) => console.log(` ${c.magenta}\u25CF${c.reset} ${msg}`),
|
|
23
|
+
success: (msg) => console.log(` ${c.green}\u2713${c.reset} ${msg}`),
|
|
24
|
+
error: (msg) => console.error(` ${c.red}\u2717${c.reset} ${msg}`),
|
|
25
|
+
warn: (msg) => console.log(` ${c.yellow}\u26A0${c.reset} ${msg}`),
|
|
26
|
+
step: (msg) => console.log(` ${c.dim}\u2192${c.reset} ${msg}`),
|
|
27
|
+
blank: () => console.log("")
|
|
28
|
+
};
|
|
29
|
+
function getVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
32
|
+
let dir = path.dirname(thisFile);
|
|
33
|
+
for (let i = 0; i < 5; i++) {
|
|
34
|
+
const pkgFile = path.join(dir, "package.json");
|
|
35
|
+
if (fs.existsSync(pkgFile)) {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf-8"));
|
|
37
|
+
if (pkg.name === "blumenjs" || pkg.name === "go-react-ssr") {
|
|
38
|
+
return pkg.version;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
dir = path.dirname(dir);
|
|
42
|
+
}
|
|
43
|
+
return "0.0.0";
|
|
44
|
+
} catch {
|
|
45
|
+
return "0.0.0";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function banner() {
|
|
49
|
+
const version = getVersion();
|
|
50
|
+
console.log("");
|
|
51
|
+
console.log(
|
|
52
|
+
` ${c.magenta}${c.bold}\u{1F338} Blumen${c.reset} ${c.dim}v${version}${c.reset}`
|
|
53
|
+
);
|
|
54
|
+
console.log(
|
|
55
|
+
` ${c.dim}The React framework powered by Go${c.reset}`
|
|
56
|
+
);
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
function divider() {
|
|
60
|
+
console.log(` ${c.dim}${"\u2500".repeat(48)}${c.reset}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// cli/commands/test.ts
|
|
64
|
+
async function test(args = []) {
|
|
65
|
+
banner();
|
|
66
|
+
const watch = args.includes("--watch");
|
|
67
|
+
const coverage = args.includes("--coverage");
|
|
68
|
+
const ui = args.includes("--ui");
|
|
69
|
+
const pattern = args.filter((a) => !a.startsWith("--")).join(" ");
|
|
70
|
+
let cmd = "npx vitest run";
|
|
71
|
+
if (watch) {
|
|
72
|
+
cmd = "npx vitest";
|
|
73
|
+
log.info("Starting test watcher...");
|
|
74
|
+
} else if (ui) {
|
|
75
|
+
cmd = "npx vitest --ui";
|
|
76
|
+
log.info("Opening Vitest UI...");
|
|
77
|
+
} else if (coverage) {
|
|
78
|
+
cmd = "npx vitest run --coverage";
|
|
79
|
+
log.info("Running tests with coverage...");
|
|
80
|
+
} else {
|
|
81
|
+
log.info("Running tests...");
|
|
82
|
+
}
|
|
83
|
+
if (pattern) {
|
|
84
|
+
cmd += ` ${pattern}`;
|
|
85
|
+
}
|
|
86
|
+
log.blank();
|
|
87
|
+
divider();
|
|
88
|
+
log.blank();
|
|
89
|
+
try {
|
|
90
|
+
execSync(cmd, {
|
|
91
|
+
stdio: "inherit",
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
env: {
|
|
94
|
+
...process.env,
|
|
95
|
+
NODE_ENV: "test"
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
log.blank();
|
|
99
|
+
divider();
|
|
100
|
+
log.blank();
|
|
101
|
+
log.success("All tests passed! \u2728");
|
|
102
|
+
log.blank();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.blank();
|
|
105
|
+
divider();
|
|
106
|
+
log.blank();
|
|
107
|
+
if (err.status) {
|
|
108
|
+
log.error(`Tests failed with exit code ${err.status}`);
|
|
109
|
+
process.exit(err.status);
|
|
110
|
+
} else {
|
|
111
|
+
log.error("Test runner encountered an error");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export {
|
|
117
|
+
test
|
|
118
|
+
};
|
|
@@ -3,9 +3,13 @@ import { hydrateRoot } from "react-dom/client";
|
|
|
3
3
|
import NotFoundPage from "../pages/NotFound";
|
|
4
4
|
|
|
5
5
|
// Auto-generated route map (run `npm run routes` to regenerate)
|
|
6
|
-
import { routes, App } from "./generated-routes";
|
|
6
|
+
import { routes, App, routeChunkMap } from "./generated-routes";
|
|
7
7
|
import { RouterProvider } from "../shared/RouterContext";
|
|
8
8
|
|
|
9
|
+
// Expose chunk map globally for the prefetch system
|
|
10
|
+
// (prefetchCache.ts reads this to know which JS chunk to preload on hover)
|
|
11
|
+
(window as any).__BLUMEN_CHUNK_MAP__ = routeChunkMap;
|
|
12
|
+
|
|
9
13
|
function init() {
|
|
10
14
|
const container = document.getElementById("root");
|
|
11
15
|
if (!container) {
|
|
@@ -19,12 +19,19 @@ function sanitizeForHydration(data: any): string {
|
|
|
19
19
|
}
|
|
20
20
|
// HMR: In development, load the client bundle from Webpack Dev Server
|
|
21
21
|
// so the HMR runtime + React Fast Refresh are active.
|
|
22
|
+
// With code splitting, WDS serves multiple chunks instead of one bundle.js.
|
|
22
23
|
const isDev = process.env.NODE_ENV === "development";
|
|
23
|
-
const
|
|
24
|
-
? "http://localhost:3100/static/js/bundle.js"
|
|
25
|
-
: "/static/js/bundle.js";
|
|
24
|
+
const WDS_BASE = "http://localhost:3100/static/js";
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
// Dev mode core scripts — loaded in dependency order
|
|
27
|
+
const DEV_SCRIPTS = [
|
|
28
|
+
`${WDS_BASE}/runtime.js`,
|
|
29
|
+
`${WDS_BASE}/vendor.js`,
|
|
30
|
+
`${WDS_BASE}/framework.js`,
|
|
31
|
+
`${WDS_BASE}/main.js`,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function DefaultDocument({ children, initialProps, metadata, scripts }: any) {
|
|
28
35
|
const css = `
|
|
29
36
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
30
37
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #e2e8f0; background: #1a1025; }
|
|
@@ -114,7 +121,14 @@ export function DefaultDocument({ children, initialProps, metadata }: any) {
|
|
|
114
121
|
__html: sanitizeForHydration(initialProps)
|
|
115
122
|
}}
|
|
116
123
|
/>
|
|
117
|
-
|
|
124
|
+
{isDev
|
|
125
|
+
? DEV_SCRIPTS.map((src, i) => (
|
|
126
|
+
<script key={i} src={src} defer></script>
|
|
127
|
+
))
|
|
128
|
+
: (scripts || ['/static/js/main.js']).map((src: string, i: number) => (
|
|
129
|
+
<script key={i} src={src} defer></script>
|
|
130
|
+
))
|
|
131
|
+
}
|
|
118
132
|
</body>
|
|
119
133
|
</html>
|
|
120
134
|
);
|