blumenjs 0.2.2 → 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.
@@ -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
+ };
@@ -0,0 +1,147 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "log"
8
+ "net/http"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ // ─── Server Actions Handler ────────────────────────────────────
14
+ // Handles POST /_blumen/action requests from client components.
15
+ // Validates CSRF tokens, then forwards to the Node SSR server
16
+ // for execution.
17
+
18
+ // actionRequest is the JSON body from the client.
19
+ type actionRequest struct {
20
+ Action string `json:"action"`
21
+ Input interface{} `json:"input"`
22
+ }
23
+
24
+ // actionResponse is the response from the Node SSR server.
25
+ type actionResponse struct {
26
+ Success bool `json:"success"`
27
+ Data interface{} `json:"data,omitempty"`
28
+ Error string `json:"error,omitempty"`
29
+ }
30
+
31
+ // ActionHandler creates the HTTP handler for server actions.
32
+ func ActionHandler() http.HandlerFunc {
33
+ return func(w http.ResponseWriter, r *http.Request) {
34
+ // Only POST is allowed
35
+ if r.Method != http.MethodPost {
36
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
37
+ return
38
+ }
39
+
40
+ // ── CSRF Validation (double-submit cookie pattern) ────
41
+ // The client sends a token in the X-CSRF-Token header
42
+ // and also sets it as a _blumen_csrf cookie.
43
+ // We validate that they match.
44
+ headerToken := r.Header.Get("X-CSRF-Token")
45
+ if headerToken == "" {
46
+ w.Header().Set("Content-Type", "application/json")
47
+ w.WriteHeader(http.StatusForbidden)
48
+ json.NewEncoder(w).Encode(actionResponse{
49
+ Success: false,
50
+ Error: "Missing CSRF token",
51
+ })
52
+ return
53
+ }
54
+
55
+ // Get CSRF cookie
56
+ cookieToken := ""
57
+ if cookie, err := r.Cookie("_blumen_csrf"); err == nil {
58
+ cookieToken = cookie.Value
59
+ }
60
+
61
+ // Validate: header token must match cookie token
62
+ if cookieToken == "" || headerToken != cookieToken {
63
+ w.Header().Set("Content-Type", "application/json")
64
+ w.WriteHeader(http.StatusForbidden)
65
+ json.NewEncoder(w).Encode(actionResponse{
66
+ Success: false,
67
+ Error: "Invalid CSRF token",
68
+ })
69
+ return
70
+ }
71
+
72
+ // ── Parse request body ────────────────────────────────
73
+ body, err := io.ReadAll(r.Body)
74
+ if err != nil {
75
+ w.Header().Set("Content-Type", "application/json")
76
+ w.WriteHeader(http.StatusBadRequest)
77
+ json.NewEncoder(w).Encode(actionResponse{
78
+ Success: false,
79
+ Error: "Failed to read request body",
80
+ })
81
+ return
82
+ }
83
+ defer r.Body.Close()
84
+
85
+ var actionReq actionRequest
86
+ if err := json.Unmarshal(body, &actionReq); err != nil {
87
+ w.Header().Set("Content-Type", "application/json")
88
+ w.WriteHeader(http.StatusBadRequest)
89
+ json.NewEncoder(w).Encode(actionResponse{
90
+ Success: false,
91
+ Error: "Invalid request body",
92
+ })
93
+ return
94
+ }
95
+
96
+ if actionReq.Action == "" {
97
+ w.Header().Set("Content-Type", "application/json")
98
+ w.WriteHeader(http.StatusBadRequest)
99
+ json.NewEncoder(w).Encode(actionResponse{
100
+ Success: false,
101
+ Error: "Action name is required",
102
+ })
103
+ return
104
+ }
105
+
106
+ // ── Forward to Node SSR server ────────────────────────
107
+ nodeURL := fmt.Sprintf("http://localhost:4000/action")
108
+
109
+ nodeBody, _ := json.Marshal(map[string]interface{}{
110
+ "action": actionReq.Action,
111
+ "input": actionReq.Input,
112
+ })
113
+
114
+ nodeReq, err := http.NewRequest("POST", nodeURL, strings.NewReader(string(nodeBody)))
115
+ if err != nil {
116
+ log.Printf("Action proxy error: %v", err)
117
+ w.Header().Set("Content-Type", "application/json")
118
+ w.WriteHeader(http.StatusInternalServerError)
119
+ json.NewEncoder(w).Encode(actionResponse{
120
+ Success: false,
121
+ Error: "Failed to create action request",
122
+ })
123
+ return
124
+ }
125
+ nodeReq.Header.Set("Content-Type", "application/json")
126
+
127
+ client := &http.Client{Timeout: 30 * time.Second}
128
+ resp, err := client.Do(nodeReq)
129
+ if err != nil {
130
+ log.Printf("Action execution error: %v", err)
131
+ w.Header().Set("Content-Type", "application/json")
132
+ w.WriteHeader(http.StatusServiceUnavailable)
133
+ json.NewEncoder(w).Encode(actionResponse{
134
+ Success: false,
135
+ Error: "Action server unavailable",
136
+ })
137
+ return
138
+ }
139
+ defer resp.Body.Close()
140
+
141
+ // Forward the response from Node
142
+ respBody, _ := io.ReadAll(resp.Body)
143
+ w.Header().Set("Content-Type", "application/json")
144
+ w.WriteHeader(resp.StatusCode)
145
+ w.Write(respBody)
146
+ }
147
+ }