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.
package/dist/cli/blumen.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
346
|
-
//
|
|
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
|
+
}
|