blumenjs 0.2.3 → 0.2.5

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.
@@ -842,6 +842,7 @@ function getTemplateFiles(projectName, template) {
842
842
  ["node-ssr/server.ts", readProjectFile("node-ssr/server.ts")],
843
843
  ["go-server/main.go", readProjectFile("go-server/main.go")],
844
844
  ["scripts/generate-routes.ts", readProjectFile("scripts/generate-routes.ts")],
845
+ ["scripts/generate-api-routes.ts", readProjectFile("scripts/generate-api-routes.ts")],
845
846
  // Placeholder
846
847
  ["static/js/.gitkeep", ""],
847
848
  // Docker support (production deployment)
@@ -526,6 +526,7 @@ function getTemplateFiles(projectName, template) {
526
526
  ["node-ssr/server.ts", readProjectFile("node-ssr/server.ts")],
527
527
  ["go-server/main.go", readProjectFile("go-server/main.go")],
528
528
  ["scripts/generate-routes.ts", readProjectFile("scripts/generate-routes.ts")],
529
+ ["scripts/generate-api-routes.ts", readProjectFile("scripts/generate-api-routes.ts")],
529
530
  // Placeholder
530
531
  ["static/js/.gitkeep", ""],
531
532
  // Docker support (production deployment)
@@ -212,6 +212,14 @@ export function RouterProvider({
212
212
  return () => window.removeEventListener("popstate", onPopState);
213
213
  }, []);
214
214
 
215
+ // Track whether hydration is complete. During hydration, we skip
216
+ // Suspense because the lazy chunk hasn't loaded yet and Suspense
217
+ // would show a fallback that mismatches the server HTML.
218
+ const [hydrated, setHydrated] = useState(false);
219
+ useEffect(() => {
220
+ setHydrated(true);
221
+ }, []);
222
+
215
223
  // ── Render ──────────────────────────────────────────────────
216
224
  const contextValue: RouterContextValue = { path, params, navigate };
217
225
 
@@ -220,6 +228,19 @@ export function RouterProvider({
220
228
  // 2. Otherwise — show the page component
221
229
  const LoadingComp = loadingComponent;
222
230
 
231
+ // During initial hydration, render App directly (no Suspense/ErrorBoundary)
232
+ // to match the SSR output exactly. After hydration, wrap with Suspense
233
+ // for lazy-loaded navigation.
234
+ const pageContent = hydrated ? (
235
+ <BlumenErrorBoundary>
236
+ <Suspense fallback={LoadingComp ? <LoadingComp /> : <DefaultLoading />}>
237
+ <App Component={PageComponent} pageProps={pageProps} />
238
+ </Suspense>
239
+ </BlumenErrorBoundary>
240
+ ) : (
241
+ <App Component={PageComponent} pageProps={pageProps} />
242
+ );
243
+
223
244
  return (
224
245
  <RouterContext.Provider value={contextValue}>
225
246
  {isLoading && LoadingComp ? (
@@ -230,11 +251,7 @@ export function RouterProvider({
230
251
  <div
231
252
  className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
232
253
  >
233
- <BlumenErrorBoundary>
234
- <Suspense fallback={LoadingComp ? <LoadingComp /> : <DefaultLoading />}>
235
- <App Component={PageComponent} pageProps={pageProps} />
236
- </Suspense>
237
- </BlumenErrorBoundary>
254
+ {pageContent}
238
255
  </div>
239
256
  )}
240
257
  </RouterContext.Provider>
@@ -342,8 +342,9 @@ async function handleRender(
342
342
  };
343
343
 
344
344
  // Render React to HTML
345
- // Wrap in the same page-transition div that the client-side
346
- // RouterProvider renders, so hydration trees match exactly.
345
+ // Must match the client's INITIAL hydration tree exactly:
346
+ // div.page-transition > App
347
+ // (Client adds Suspense/ErrorBoundary AFTER hydration via useEffect)
347
348
  const appElement = React.createElement(
348
349
  "div",
349
350
  { className: "page-transition page-transition-active" },
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Blumen API Route Generator
3
+ *
4
+ * Scans app/api/ recursively for route.ts files and generates:
5
+ * node-ssr/generated-api-routes.ts — API route map for the SSR server
6
+ *
7
+ * Convention:
8
+ * - app/api/users/route.ts → /api/users
9
+ * - app/api/users/[id]/route.ts → /api/users/[id]
10
+ * - Only `route.ts` files are scanned (not .tsx — API routes don't render)
11
+ * - Exported function names determine allowed HTTP methods:
12
+ * GET, POST, PUT, DELETE, PATCH
13
+ *
14
+ * Usage: npx tsx scripts/generate-api-routes.ts
15
+ * (Also called automatically by generate-routes.ts)
16
+ */
17
+
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import * as ts from "typescript";
21
+
22
+ const API_DIR = path.resolve("app/api");
23
+ const API_OUTPUT = path.resolve("node-ssr/generated-api-routes.ts");
24
+
25
+ const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
26
+
27
+ interface APIRouteEntry {
28
+ /** The URL path, e.g. "/api/users" or "/api/users/[id]" */
29
+ route: string;
30
+ /** Safe identifier for imports */
31
+ routeId: string;
32
+ /** Import path relative to the api dir */
33
+ importPath: string;
34
+ /** Regex string for matching the route */
35
+ patternStr: string;
36
+ /** Extracted parameter keys */
37
+ keys: string[];
38
+ /** HTTP methods exported by this route */
39
+ methods: string[];
40
+ }
41
+
42
+ /**
43
+ * Check if a file exports a named identifier at the top level.
44
+ * Uses TypeScript AST to avoid false positives from strings/comments.
45
+ */
46
+ function hasNamedExport(filePath: string, content: string, exportName: string): boolean {
47
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
48
+ let found = false;
49
+
50
+ ts.forEachChild(sourceFile, (node) => {
51
+ if (ts.isFunctionDeclaration(node)) {
52
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
53
+ if (isExported && node.name?.text === exportName) found = true;
54
+ } else if (ts.isVariableStatement(node)) {
55
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
56
+ if (isExported) {
57
+ for (const decl of node.declarationList.declarations) {
58
+ if (ts.isIdentifier(decl.name) && decl.name.text === exportName) found = true;
59
+ }
60
+ }
61
+ } else if (ts.isExportDeclaration(node)) {
62
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
63
+ for (const element of node.exportClause.elements) {
64
+ if (element.name.text === exportName) found = true;
65
+ }
66
+ }
67
+ }
68
+ });
69
+
70
+ return found;
71
+ }
72
+
73
+ function scanAPIDir(dir: string, baseDir: string = dir): APIRouteEntry[] {
74
+ if (!fs.existsSync(dir)) return [];
75
+
76
+ let results: APIRouteEntry[] = [];
77
+ const items = fs.readdirSync(dir, { withFileTypes: true });
78
+
79
+ for (const item of items) {
80
+ const fullPath = path.join(dir, item.name);
81
+
82
+ if (item.isDirectory()) {
83
+ results = results.concat(scanAPIDir(fullPath, baseDir));
84
+ } else if (item.isFile() && item.name === "route.ts") {
85
+ const relDir = path.relative(baseDir, dir).replace(/\\/g, "/");
86
+ const routePath = "/api" + (relDir ? "/" + relDir.toLowerCase() : "");
87
+
88
+ // Generate safe identifier
89
+ const routeId = "api_" + (relDir || "root").replace(/[^a-zA-Z0-9]/g, "_");
90
+
91
+ // Parse bracket syntax for dynamic parameters
92
+ const keys: string[] = [];
93
+ let patternStr = routePath.replace(/\[([^\]]+)\]/g, (_, key) => {
94
+ keys.push(key);
95
+ return "([^/]+)";
96
+ });
97
+ patternStr = `^${patternStr}$`;
98
+
99
+ // Detect exported HTTP method handlers
100
+ const content = fs.readFileSync(fullPath, "utf-8");
101
+ const methods: string[] = [];
102
+ for (const method of HTTP_METHODS) {
103
+ if (hasNamedExport(fullPath, content, method)) {
104
+ methods.push(method);
105
+ }
106
+ }
107
+
108
+ if (methods.length === 0) {
109
+ console.warn(` ⚠️ ${routePath} — no HTTP method exports found, skipping`);
110
+ continue;
111
+ }
112
+
113
+ const importPath = relDir ? relDir + "/route" : "route";
114
+
115
+ results.push({
116
+ route: routePath,
117
+ routeId,
118
+ importPath,
119
+ patternStr,
120
+ keys,
121
+ methods,
122
+ });
123
+ }
124
+ }
125
+
126
+ // Sort: specific routes before dynamic ones
127
+ results.sort((a, b) => {
128
+ const aIsDynamic = a.route.includes("[");
129
+ const bIsDynamic = b.route.includes("[");
130
+ if (aIsDynamic && !bIsDynamic) return 1;
131
+ if (!aIsDynamic && bIsDynamic) return -1;
132
+ return a.route.localeCompare(b.route);
133
+ });
134
+
135
+ return results;
136
+ }
137
+
138
+ function generateAPIRouteFile(routes: APIRouteEntry[]): string {
139
+ const header = [
140
+ "// ┌─────────────────────────────────────────────┐",
141
+ "// │ AUTO-GENERATED — DO NOT EDIT │",
142
+ "// │ Run `npm run routes` to regenerate │",
143
+ "// └─────────────────────────────────────────────┘",
144
+ "",
145
+ ];
146
+
147
+ // Generate imports
148
+ const imports: string[] = [];
149
+ for (const r of routes) {
150
+ const namedImports = r.methods
151
+ .map(m => `${m} as ${m.toLowerCase()}_${r.routeId}`)
152
+ .join(", ");
153
+ imports.push(
154
+ `import { ${namedImports} } from "../app/api/${r.importPath}";`,
155
+ );
156
+ }
157
+
158
+ // Generate type and route array
159
+ const body = [
160
+ "",
161
+ "export interface APIRouteDef {",
162
+ "\tpath: string;",
163
+ "\tpattern: RegExp;",
164
+ "\tkeys: string[];",
165
+ "\thandlers: Record<string, Function>;",
166
+ "}",
167
+ "",
168
+ "export const apiRoutes: APIRouteDef[] = [",
169
+ ];
170
+
171
+ for (const r of routes) {
172
+ const handlersObj = r.methods
173
+ .map(m => `${m}: ${m.toLowerCase()}_${r.routeId}`)
174
+ .join(", ");
175
+ body.push(`\t{`);
176
+ body.push(`\t\tpath: "${r.route}",`);
177
+ body.push(`\t\tpattern: new RegExp("${r.patternStr.replace(/\\/g, "\\\\")}"),`);
178
+ body.push(`\t\tkeys: ${r.keys.length > 0 ? `["${r.keys.join('", "')}"]` : "[]"},`);
179
+ body.push(`\t\thandlers: { ${handlersObj} }`);
180
+ body.push(`\t},`);
181
+ }
182
+
183
+ body.push("];");
184
+ body.push("");
185
+
186
+ return [...header, ...imports, ...body].join("\n");
187
+ }
188
+
189
+ export function generateAPIRoutes(): APIRouteEntry[] {
190
+ if (!fs.existsSync(API_DIR)) {
191
+ // No app/api/ directory — generate empty file
192
+ const emptyFile = [
193
+ "// No API routes found (app/api/ directory does not exist)",
194
+ "",
195
+ "export interface APIRouteDef {",
196
+ "\tpath: string;",
197
+ "\tpattern: RegExp;",
198
+ "\tkeys: string[];",
199
+ "\thandlers: Record<string, Function>;",
200
+ "}",
201
+ "",
202
+ "export const apiRoutes: APIRouteDef[] = [];",
203
+ "",
204
+ ].join("\n");
205
+ fs.mkdirSync(path.dirname(API_OUTPUT), { recursive: true });
206
+ fs.writeFileSync(API_OUTPUT, emptyFile, "utf-8");
207
+ return [];
208
+ }
209
+
210
+ const routes = scanAPIDir(API_DIR);
211
+
212
+ const content = generateAPIRouteFile(routes);
213
+ fs.mkdirSync(path.dirname(API_OUTPUT), { recursive: true });
214
+ fs.writeFileSync(API_OUTPUT, content, "utf-8");
215
+
216
+ return routes;
217
+ }
218
+
219
+ // Allow standalone execution
220
+ if (process.argv[1]?.endsWith("generate-api-routes.ts")) {
221
+ console.log("🌸 Blumen API Route Generator");
222
+ console.log(` Scanning: ${API_DIR}`);
223
+
224
+ const routes = generateAPIRoutes();
225
+
226
+ if (routes.length === 0) {
227
+ console.log(" No API routes found.");
228
+ } else {
229
+ console.log(` Found ${routes.length} API route(s):`);
230
+ for (const r of routes) {
231
+ console.log(` ${r.methods.join("|").padEnd(20)} ${r.route}`);
232
+ }
233
+ }
234
+
235
+ console.log(` ✅ Written: ${path.relative(process.cwd(), API_OUTPUT)}`);
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blumenjs",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
5
5
  "type": "module",
6
6
  "bin": {