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
|
@@ -15,19 +15,22 @@
|
|
|
15
15
|
* - index.tsx → "/" (index route for folders)
|
|
16
16
|
* - NotFound.tsx → skipped (reserved 404)
|
|
17
17
|
* - _app.tsx → skipped (reserved prefix)
|
|
18
|
+
* - _layout.tsx → layout metadata (not a route, metadata cascades to children)
|
|
18
19
|
*
|
|
19
20
|
* Usage: npx tsx scripts/generate-routes.ts
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import * as fs from "fs";
|
|
23
24
|
import * as path from "path";
|
|
25
|
+
import * as ts from "typescript";
|
|
26
|
+
import { generateAPIRoutes } from "./generate-api-routes";
|
|
24
27
|
|
|
25
28
|
const PAGES_DIR = path.resolve("app/pages");
|
|
26
29
|
const SSR_OUTPUT = path.resolve("node-ssr/generated-routes.ts");
|
|
27
30
|
const CLIENT_OUTPUT = path.resolve("app/client/generated-routes.ts");
|
|
28
31
|
|
|
29
32
|
// Reserved filenames that should not become routes
|
|
30
|
-
const RESERVED = new Set(["NotFound", "NotFound.tsx"]);
|
|
33
|
+
const RESERVED = new Set(["NotFound", "NotFound.tsx", "loading.tsx"]);
|
|
31
34
|
|
|
32
35
|
interface RouteEntry {
|
|
33
36
|
/** The URL path, e.g. "/" or "/users/[id]" */
|
|
@@ -42,6 +45,81 @@ interface RouteEntry {
|
|
|
42
45
|
keys: string[];
|
|
43
46
|
/** Whether this page exports a getServerProps function */
|
|
44
47
|
hasGetServerProps: boolean;
|
|
48
|
+
/** Whether this page exports a static metadata object */
|
|
49
|
+
hasMetadata: boolean;
|
|
50
|
+
/** Whether this page exports a generateMetadata function */
|
|
51
|
+
hasGenerateMetadata: boolean;
|
|
52
|
+
/** Whether a loading.tsx file exists in the same directory */
|
|
53
|
+
hasLoading: boolean;
|
|
54
|
+
/** Import path for the loading component (if hasLoading) */
|
|
55
|
+
loadingImportPath?: string;
|
|
56
|
+
/** Whether this page exports dynamic = 'force-static' for SSG */
|
|
57
|
+
isStatic: boolean;
|
|
58
|
+
/** Whether this page exports a getStaticProps function */
|
|
59
|
+
hasGetStaticProps: boolean;
|
|
60
|
+
/** Whether this page exports a getStaticPaths function */
|
|
61
|
+
hasGetStaticPaths: boolean;
|
|
62
|
+
/** Whether this page starts with 'use client' directive */
|
|
63
|
+
isClientComponent: boolean;
|
|
64
|
+
/** Whether this page exports runtime = 'edge' */
|
|
65
|
+
isEdge: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface LayoutEntry {
|
|
69
|
+
/** Directory path relative to PAGES_DIR, e.g. "" (root) or "docs" */
|
|
70
|
+
dirPath: string;
|
|
71
|
+
/** The URL prefix this layout applies to, e.g. "/" or "/docs" */
|
|
72
|
+
routePrefix: string;
|
|
73
|
+
/** Import path for the layout file (without extension) */
|
|
74
|
+
importPath: string;
|
|
75
|
+
/** Safe identifier for imports */
|
|
76
|
+
layoutId: string;
|
|
77
|
+
/** Whether this layout exports a static metadata object */
|
|
78
|
+
hasMetadata: boolean;
|
|
79
|
+
/** Whether this layout exports a generateMetadata function */
|
|
80
|
+
hasGenerateMetadata: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a file exports a named identifier at the top level.
|
|
85
|
+
* Uses TypeScript AST to avoid false positives from strings/comments.
|
|
86
|
+
*/
|
|
87
|
+
function hasNamedExport(filePath: string, content: string, exportName: string): boolean {
|
|
88
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
89
|
+
let found = false;
|
|
90
|
+
|
|
91
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
92
|
+
// 1. Function declaration: export [async] function name() {}
|
|
93
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
94
|
+
const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
95
|
+
if (isExported && node.name?.text === exportName) {
|
|
96
|
+
found = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 2. Variable declaration: export const name = ...
|
|
100
|
+
else if (ts.isVariableStatement(node)) {
|
|
101
|
+
const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
102
|
+
if (isExported) {
|
|
103
|
+
for (const decl of node.declarationList.declarations) {
|
|
104
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === exportName) {
|
|
105
|
+
found = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 3. Export declaration: export { name } or export { x as name }
|
|
111
|
+
else if (ts.isExportDeclaration(node)) {
|
|
112
|
+
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
113
|
+
for (const element of node.exportClause.elements) {
|
|
114
|
+
if (element.name.text === exportName) {
|
|
115
|
+
found = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return found;
|
|
45
123
|
}
|
|
46
124
|
|
|
47
125
|
function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
|
|
@@ -89,9 +167,31 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
|
|
|
89
167
|
patternStr = `^${patternStr}$`;
|
|
90
168
|
}
|
|
91
169
|
|
|
92
|
-
// Check if the page exports getServerProps
|
|
170
|
+
// Check if the page exports getServerProps, metadata, or generateMetadata
|
|
93
171
|
const fileContent = fs.readFileSync(fullPath, "utf-8");
|
|
94
|
-
const hasGetServerProps =
|
|
172
|
+
const hasGetServerProps = hasNamedExport(fullPath, fileContent, "getServerProps");
|
|
173
|
+
const hasGetStaticProps = hasNamedExport(fullPath, fileContent, "getStaticProps");
|
|
174
|
+
const hasGetStaticPaths = hasNamedExport(fullPath, fileContent, "getStaticPaths");
|
|
175
|
+
const hasMetadata = hasNamedExport(fullPath, fileContent, "metadata");
|
|
176
|
+
const hasGenerateMetadata = hasNamedExport(fullPath, fileContent, "generateMetadata");
|
|
177
|
+
const hasDynamic = hasNamedExport(fullPath, fileContent, "dynamic");
|
|
178
|
+
// Pages with getStaticProps are automatically static
|
|
179
|
+
const isStatic = hasGetStaticProps || (hasDynamic && fileContent.includes("force-static"));
|
|
180
|
+
|
|
181
|
+
// Detect 'use client' directive at the top of the file
|
|
182
|
+
const isClientComponent = /^\s*['"]use client['"]/.test(fileContent);
|
|
183
|
+
|
|
184
|
+
// Detect runtime = 'edge' for edge functions
|
|
185
|
+
const hasRuntime = hasNamedExport(fullPath, fileContent, "runtime");
|
|
186
|
+
const isEdge = hasRuntime && fileContent.includes("edge");
|
|
187
|
+
|
|
188
|
+
// Check for a loading.tsx file in the same directory
|
|
189
|
+
const pageDir = path.dirname(fullPath);
|
|
190
|
+
const loadingFile = path.join(pageDir, "loading.tsx");
|
|
191
|
+
const hasLoading = fs.existsSync(loadingFile);
|
|
192
|
+
const loadingImportPath = hasLoading
|
|
193
|
+
? path.relative(baseDir, loadingFile).replace(".tsx", "").replace(/\\/g, "/")
|
|
194
|
+
: undefined;
|
|
95
195
|
|
|
96
196
|
results.push({
|
|
97
197
|
route: routePath,
|
|
@@ -99,11 +199,65 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
|
|
|
99
199
|
importPath: relPath.replace(".tsx", "").replace(/\\/g, "/"),
|
|
100
200
|
patternStr,
|
|
101
201
|
keys,
|
|
102
|
-
hasGetServerProps
|
|
202
|
+
hasGetServerProps,
|
|
203
|
+
hasMetadata,
|
|
204
|
+
hasGenerateMetadata,
|
|
205
|
+
hasLoading,
|
|
206
|
+
loadingImportPath,
|
|
207
|
+
isStatic,
|
|
208
|
+
hasGetStaticProps,
|
|
209
|
+
hasGetStaticPaths,
|
|
210
|
+
isClientComponent,
|
|
211
|
+
isEdge,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Scan for _layout.tsx files in the pages directory tree.
|
|
221
|
+
* These files can export metadata/generateMetadata that cascades to child pages.
|
|
222
|
+
*/
|
|
223
|
+
function scanLayouts(dir: string, baseDir: string = dir): LayoutEntry[] {
|
|
224
|
+
let results: LayoutEntry[] = [];
|
|
225
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
226
|
+
|
|
227
|
+
// Check for _layout.tsx in this directory
|
|
228
|
+
const layoutFile = path.join(dir, "_layout.tsx");
|
|
229
|
+
if (fs.existsSync(layoutFile)) {
|
|
230
|
+
const relDir = path.relative(baseDir, dir).replace(/\\/g, "/");
|
|
231
|
+
const routePrefix = relDir === "" ? "/" : "/" + relDir.toLowerCase();
|
|
232
|
+
const layoutId = relDir === "" ? "Layout_root" : "Layout_" + relDir.replace(/[^a-zA-Z0-9]/g, "_");
|
|
233
|
+
const importPath = relDir === "" ? "_layout" : relDir + "/_layout";
|
|
234
|
+
|
|
235
|
+
const content = fs.readFileSync(layoutFile, "utf-8");
|
|
236
|
+
const hasMetadata = hasNamedExport(layoutFile, content, "metadata");
|
|
237
|
+
const hasGenerateMetadata = hasNamedExport(layoutFile, content, "generateMetadata");
|
|
238
|
+
|
|
239
|
+
if (hasMetadata || hasGenerateMetadata) {
|
|
240
|
+
results.push({
|
|
241
|
+
dirPath: relDir,
|
|
242
|
+
routePrefix,
|
|
243
|
+
importPath,
|
|
244
|
+
layoutId,
|
|
245
|
+
hasMetadata,
|
|
246
|
+
hasGenerateMetadata,
|
|
103
247
|
});
|
|
104
248
|
}
|
|
105
249
|
}
|
|
106
250
|
|
|
251
|
+
// Recurse into subdirectories
|
|
252
|
+
for (const item of items) {
|
|
253
|
+
if (item.isDirectory() && !item.name.startsWith("_")) {
|
|
254
|
+
results = results.concat(scanLayouts(path.join(dir, item.name), baseDir));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Sort by depth (root first, then deeper segments)
|
|
259
|
+
results.sort((a, b) => a.dirPath.split("/").length - b.dirPath.split("/").length);
|
|
260
|
+
|
|
107
261
|
return results;
|
|
108
262
|
}
|
|
109
263
|
|
|
@@ -125,8 +279,21 @@ function discoverPages(): RouteEntry[] {
|
|
|
125
279
|
return routes;
|
|
126
280
|
}
|
|
127
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Generate a webpack chunk name for a route entry.
|
|
284
|
+
* Produces clean names like "page-home", "page-docs", "page-users-id".
|
|
285
|
+
*/
|
|
286
|
+
function getChunkName(r: RouteEntry): string {
|
|
287
|
+
return "page-" + r.importPath
|
|
288
|
+
.replace(/\/index$/, "") // "docs/index" → "docs"
|
|
289
|
+
.replace(/\[([^\]]+)\]/g, "$1") // "[id]" → "id"
|
|
290
|
+
.replace(/\//g, "-") // "dashboard/settings" → "dashboard-settings"
|
|
291
|
+
.toLowerCase();
|
|
292
|
+
}
|
|
293
|
+
|
|
128
294
|
function generateRouteFile(
|
|
129
295
|
routes: RouteEntry[],
|
|
296
|
+
layouts: LayoutEntry[],
|
|
130
297
|
importPathPrefix: string,
|
|
131
298
|
isServer: boolean,
|
|
132
299
|
): string {
|
|
@@ -140,11 +307,18 @@ function generateRouteFile(
|
|
|
140
307
|
"",
|
|
141
308
|
];
|
|
142
309
|
|
|
143
|
-
// Import
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
310
|
+
// Import page components
|
|
311
|
+
// Server: static imports (SSR needs all components loaded)
|
|
312
|
+
// Client: React.lazy() dynamic imports for code splitting
|
|
313
|
+
const imports = isServer
|
|
314
|
+
? routes.map(
|
|
315
|
+
(r) =>
|
|
316
|
+
`import ${r.componentId} from "${importPathPrefix}/${r.importPath}";`,
|
|
317
|
+
)
|
|
318
|
+
: routes.map((r) => {
|
|
319
|
+
const chunkName = getChunkName(r);
|
|
320
|
+
return `const ${r.componentId} = React.lazy(() => import(/* webpackChunkName: "${chunkName}" */ "${importPathPrefix}/${r.importPath}"));`;
|
|
321
|
+
});
|
|
148
322
|
|
|
149
323
|
// For SSR: import getServerProps from pages that export it
|
|
150
324
|
const gspImports: string[] = [];
|
|
@@ -158,6 +332,69 @@ function generateRouteFile(
|
|
|
158
332
|
}
|
|
159
333
|
}
|
|
160
334
|
|
|
335
|
+
// For SSR: import getStaticProps from pages that export it
|
|
336
|
+
// getStaticProps works like getServerProps but the page is pre-rendered at build time
|
|
337
|
+
const staticPropsImports: string[] = [];
|
|
338
|
+
const staticPropsRoutes = routes.filter(r => r.hasGetStaticProps);
|
|
339
|
+
if (isServer && staticPropsRoutes.length > 0) {
|
|
340
|
+
for (const r of staticPropsRoutes) {
|
|
341
|
+
const spId = "staticProps_" + r.componentId.replace("Page_", "");
|
|
342
|
+
staticPropsImports.push(
|
|
343
|
+
`import { getStaticProps as ${spId} } from "${importPathPrefix}/${r.importPath}";`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// For SSR: import getStaticPaths from pages that export it (dynamic SSG)
|
|
349
|
+
const staticPathsImports: string[] = [];
|
|
350
|
+
const staticPathsRoutes = routes.filter(r => r.hasGetStaticPaths);
|
|
351
|
+
if (isServer && staticPathsRoutes.length > 0) {
|
|
352
|
+
for (const r of staticPathsRoutes) {
|
|
353
|
+
const pathsId = "staticPaths_" + r.componentId.replace("Page_", "");
|
|
354
|
+
staticPathsImports.push(
|
|
355
|
+
`import { getStaticPaths as ${pathsId} } from "${importPathPrefix}/${r.importPath}";`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// For SSR: import metadata and generateMetadata from pages that export them
|
|
361
|
+
const metaImports: string[] = [];
|
|
362
|
+
const metaRoutes = routes.filter(r => r.hasMetadata);
|
|
363
|
+
const genMetaRoutes = routes.filter(r => r.hasGenerateMetadata);
|
|
364
|
+
if (isServer) {
|
|
365
|
+
for (const r of metaRoutes) {
|
|
366
|
+
const metaId = "meta_" + r.componentId.replace("Page_", "");
|
|
367
|
+
metaImports.push(
|
|
368
|
+
`import { metadata as ${metaId} } from "${importPathPrefix}/${r.importPath}";`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
for (const r of genMetaRoutes) {
|
|
372
|
+
const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
|
|
373
|
+
metaImports.push(
|
|
374
|
+
`import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${r.importPath}";`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// For SSR: import metadata and generateMetadata from layout files
|
|
380
|
+
const layoutImports: string[] = [];
|
|
381
|
+
if (isServer) {
|
|
382
|
+
for (const l of layouts) {
|
|
383
|
+
if (l.hasMetadata) {
|
|
384
|
+
const metaId = "layoutMeta_" + l.layoutId;
|
|
385
|
+
layoutImports.push(
|
|
386
|
+
`import { metadata as ${metaId} } from "${importPathPrefix}/${l.importPath}";`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (l.hasGenerateMetadata) {
|
|
390
|
+
const genMetaId = "layoutGenMeta_" + l.layoutId;
|
|
391
|
+
layoutImports.push(
|
|
392
|
+
`import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${l.importPath}";`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
161
398
|
const hasApp = fs.existsSync(path.join(PAGES_DIR, "_app.tsx"));
|
|
162
399
|
const hasDoc = fs.existsSync(path.join(PAGES_DIR, "_document.tsx"));
|
|
163
400
|
|
|
@@ -172,35 +409,182 @@ function generateRouteFile(
|
|
|
172
409
|
: `import { DefaultDocument } from "${importPathPrefix}/../shared/DefaultDocument";\nexport const Document = DefaultDocument;`;
|
|
173
410
|
}
|
|
174
411
|
|
|
412
|
+
// Import loading components (client-side only)
|
|
413
|
+
const loadingImports: string[] = [];
|
|
414
|
+
const loadingRoutes = routes.filter(r => r.hasLoading);
|
|
415
|
+
if (!isServer && loadingRoutes.length > 0) {
|
|
416
|
+
for (const r of loadingRoutes) {
|
|
417
|
+
const loadingId = "Loading_" + r.componentId.replace("Page_", "");
|
|
418
|
+
loadingImports.push(
|
|
419
|
+
`import ${loadingId} from "${importPathPrefix}/${r.loadingImportPath}";`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
175
424
|
const routeObjects = routes.map(r => {
|
|
176
425
|
const keysArray = r.keys.length > 0 ? `["${r.keys.join('", "')}"]` : "[]";
|
|
426
|
+
const loadingId = r.hasLoading ? "Loading_" + r.componentId.replace("Page_", "") : null;
|
|
427
|
+
const loadingLine = !isServer && loadingId ? `,\n\t\tloading: ${loadingId}` : "";
|
|
177
428
|
return `\t{
|
|
178
429
|
\t\tpath: "${r.route}",
|
|
179
430
|
\t\tpattern: new RegExp("${r.patternStr.replace(/\\/g, '\\\\')}"),
|
|
180
431
|
\t\tkeys: ${keysArray},
|
|
181
|
-
\t\tcomponent: ${r.componentId}
|
|
432
|
+
\t\tcomponent: ${r.componentId}${loadingLine}
|
|
182
433
|
\t}`;
|
|
183
434
|
});
|
|
184
435
|
|
|
185
436
|
// Generate serverPropsMap for SSR routes
|
|
186
437
|
let serverPropsMapStr = "";
|
|
187
|
-
if (isServer && gspRoutes.length > 0) {
|
|
438
|
+
if (isServer && (gspRoutes.length > 0 || staticPropsRoutes.length > 0)) {
|
|
439
|
+
// Merge getServerProps + getStaticProps into one map
|
|
440
|
+
// getStaticProps pages are treated identically at render time
|
|
188
441
|
const entries = gspRoutes.map(r => {
|
|
189
442
|
const gspId = "gsp_" + r.componentId.replace("Page_", "");
|
|
190
443
|
return `\t"${r.route}": ${gspId},`;
|
|
191
444
|
});
|
|
445
|
+
const staticEntries = staticPropsRoutes.map(r => {
|
|
446
|
+
const spId = "staticProps_" + r.componentId.replace("Page_", "");
|
|
447
|
+
return `\t"${r.route}": ${spId},`;
|
|
448
|
+
});
|
|
192
449
|
serverPropsMapStr = [
|
|
193
450
|
"",
|
|
194
|
-
"// Map of routes that export getServerProps",
|
|
451
|
+
"// Map of routes that export getServerProps or getStaticProps",
|
|
195
452
|
"// Used by the SSR server to run data fetching before rendering",
|
|
196
453
|
"export const serverPropsMap: Record<string, Function> = {",
|
|
197
454
|
...entries,
|
|
455
|
+
...staticEntries,
|
|
198
456
|
"};",
|
|
199
457
|
].join("\n");
|
|
200
458
|
} else if (isServer) {
|
|
201
459
|
serverPropsMapStr = "\nexport const serverPropsMap: Record<string, Function> = {};";
|
|
202
460
|
}
|
|
203
461
|
|
|
462
|
+
// Generate staticPropsMap (for ISR — tracks which pages use getStaticProps)
|
|
463
|
+
let staticMapsStr = "";
|
|
464
|
+
if (isServer) {
|
|
465
|
+
if (staticPropsRoutes.length > 0) {
|
|
466
|
+
const entries = staticPropsRoutes.map(r => {
|
|
467
|
+
const spId = "staticProps_" + r.componentId.replace("Page_", "");
|
|
468
|
+
return `\t"${r.route}": ${spId},`;
|
|
469
|
+
});
|
|
470
|
+
staticMapsStr += [
|
|
471
|
+
"",
|
|
472
|
+
"// Map of routes with getStaticProps (for ISR revalidation)",
|
|
473
|
+
"export const staticPropsMap: Record<string, Function> = {",
|
|
474
|
+
...entries,
|
|
475
|
+
"};",
|
|
476
|
+
].join("\n");
|
|
477
|
+
} else {
|
|
478
|
+
staticMapsStr += "\nexport const staticPropsMap: Record<string, Function> = {};";
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (staticPathsRoutes.length > 0) {
|
|
482
|
+
const entries = staticPathsRoutes.map(r => {
|
|
483
|
+
const pathsId = "staticPaths_" + r.componentId.replace("Page_", "");
|
|
484
|
+
return `\t"${r.route}": ${pathsId},`;
|
|
485
|
+
});
|
|
486
|
+
staticMapsStr += [
|
|
487
|
+
"",
|
|
488
|
+
"// Map of routes with getStaticPaths (for dynamic SSG)",
|
|
489
|
+
"export const staticPathsMap: Record<string, Function> = {",
|
|
490
|
+
...entries,
|
|
491
|
+
"};",
|
|
492
|
+
].join("\n");
|
|
493
|
+
} else {
|
|
494
|
+
staticMapsStr += "\nexport const staticPathsMap: Record<string, Function> = {};";
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Generate metadataMap and generateMetadataMap for SSR routes
|
|
499
|
+
let metadataMapStr = "";
|
|
500
|
+
if (isServer) {
|
|
501
|
+
// Static metadata map
|
|
502
|
+
if (metaRoutes.length > 0) {
|
|
503
|
+
const entries = metaRoutes.map(r => {
|
|
504
|
+
const metaId = "meta_" + r.componentId.replace("Page_", "");
|
|
505
|
+
return `\t"${r.route}": ${metaId},`;
|
|
506
|
+
});
|
|
507
|
+
metadataMapStr += [
|
|
508
|
+
"",
|
|
509
|
+
"// Map of routes with static metadata exports",
|
|
510
|
+
"export const metadataMap: Record<string, any> = {",
|
|
511
|
+
...entries,
|
|
512
|
+
"};",
|
|
513
|
+
].join("\n");
|
|
514
|
+
} else {
|
|
515
|
+
metadataMapStr += "\nexport const metadataMap: Record<string, any> = {};";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Dynamic generateMetadata map
|
|
519
|
+
if (genMetaRoutes.length > 0) {
|
|
520
|
+
const entries = genMetaRoutes.map(r => {
|
|
521
|
+
const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
|
|
522
|
+
return `\t"${r.route}": ${genMetaId},`;
|
|
523
|
+
});
|
|
524
|
+
metadataMapStr += [
|
|
525
|
+
"",
|
|
526
|
+
"// Map of routes with dynamic generateMetadata exports",
|
|
527
|
+
"export const generateMetadataMap: Record<string, Function> = {",
|
|
528
|
+
...entries,
|
|
529
|
+
"};",
|
|
530
|
+
].join("\n");
|
|
531
|
+
} else {
|
|
532
|
+
metadataMapStr += "\nexport const generateMetadataMap: Record<string, Function> = {};";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Layout static metadata map
|
|
536
|
+
const layoutMetaEntries = layouts.filter(l => l.hasMetadata);
|
|
537
|
+
if (layoutMetaEntries.length > 0) {
|
|
538
|
+
const entries = layoutMetaEntries.map(l => {
|
|
539
|
+
const metaId = "layoutMeta_" + l.layoutId;
|
|
540
|
+
return `\t"${l.routePrefix}": ${metaId},`;
|
|
541
|
+
});
|
|
542
|
+
metadataMapStr += [
|
|
543
|
+
"",
|
|
544
|
+
"// Layout metadata — cascades to all child pages in the directory",
|
|
545
|
+
"export const layoutMetadataMap: Record<string, any> = {",
|
|
546
|
+
...entries,
|
|
547
|
+
"};",
|
|
548
|
+
].join("\n");
|
|
549
|
+
} else {
|
|
550
|
+
metadataMapStr += "\nexport const layoutMetadataMap: Record<string, any> = {};";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Layout dynamic generateMetadata map
|
|
554
|
+
const layoutGenMetaEntries = layouts.filter(l => l.hasGenerateMetadata);
|
|
555
|
+
if (layoutGenMetaEntries.length > 0) {
|
|
556
|
+
const entries = layoutGenMetaEntries.map(l => {
|
|
557
|
+
const genMetaId = "layoutGenMeta_" + l.layoutId;
|
|
558
|
+
return `\t"${l.routePrefix}": ${genMetaId},`;
|
|
559
|
+
});
|
|
560
|
+
metadataMapStr += [
|
|
561
|
+
"",
|
|
562
|
+
"// Layout dynamic metadata — cascades to all child pages",
|
|
563
|
+
"export const layoutGenerateMetadataMap: Record<string, Function> = {",
|
|
564
|
+
...entries,
|
|
565
|
+
"};",
|
|
566
|
+
].join("\n");
|
|
567
|
+
} else {
|
|
568
|
+
metadataMapStr += "\nexport const layoutGenerateMetadataMap: Record<string, Function> = {};";
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Generate route-to-chunk name map (client-side only, for prefetching)
|
|
573
|
+
let routeChunkMapStr = "";
|
|
574
|
+
if (!isServer) {
|
|
575
|
+
const entries = routes.map(r => {
|
|
576
|
+
const chunkName = getChunkName(r);
|
|
577
|
+
return `\t"${r.route}": "${chunkName}",`;
|
|
578
|
+
});
|
|
579
|
+
routeChunkMapStr = [
|
|
580
|
+
"",
|
|
581
|
+
"// Map of route paths to their webpack chunk names (for prefetching)",
|
|
582
|
+
"export const routeChunkMap: Record<string, string> = {",
|
|
583
|
+
...entries,
|
|
584
|
+
"};",
|
|
585
|
+
].join("\n");
|
|
586
|
+
}
|
|
587
|
+
|
|
204
588
|
const map = [
|
|
205
589
|
"",
|
|
206
590
|
"export interface RouteDef {",
|
|
@@ -208,6 +592,7 @@ function generateRouteFile(
|
|
|
208
592
|
"\tpattern: RegExp;",
|
|
209
593
|
"\tkeys: string[];",
|
|
210
594
|
"\tcomponent: React.ComponentType<any>;",
|
|
595
|
+
"\tloading?: React.ComponentType<any>;",
|
|
211
596
|
"}",
|
|
212
597
|
"",
|
|
213
598
|
"export const routes: RouteDef[] = [",
|
|
@@ -217,10 +602,13 @@ function generateRouteFile(
|
|
|
217
602
|
appImport,
|
|
218
603
|
docImport,
|
|
219
604
|
serverPropsMapStr,
|
|
605
|
+
staticMapsStr,
|
|
606
|
+
metadataMapStr,
|
|
607
|
+
routeChunkMapStr,
|
|
220
608
|
"",
|
|
221
609
|
];
|
|
222
610
|
|
|
223
|
-
return [...header, ...imports, ...gspImports, ...map].join("\n");
|
|
611
|
+
return [...header, ...imports, ...gspImports, ...staticPropsImports, ...staticPathsImports, ...metaImports, ...layoutImports, ...loadingImports, ...map].join("\n");
|
|
224
612
|
}
|
|
225
613
|
|
|
226
614
|
function main() {
|
|
@@ -228,32 +616,84 @@ function main() {
|
|
|
228
616
|
console.log(` Scanning: ${PAGES_DIR}`);
|
|
229
617
|
|
|
230
618
|
const routes = discoverPages();
|
|
619
|
+
const layouts = scanLayouts(PAGES_DIR);
|
|
231
620
|
|
|
232
621
|
if (routes.length === 0) {
|
|
233
622
|
console.error(" ❌ No pages found!");
|
|
234
623
|
process.exit(1);
|
|
235
624
|
}
|
|
236
625
|
|
|
626
|
+
// Print detected layouts
|
|
627
|
+
if (layouts.length > 0) {
|
|
628
|
+
console.log(` Found ${layouts.length} layout(s) with metadata:`);
|
|
629
|
+
for (const l of layouts) {
|
|
630
|
+
const tags: string[] = [];
|
|
631
|
+
if (l.hasMetadata) tags.push("metadata");
|
|
632
|
+
if (l.hasGenerateMetadata) tags.push("generateMetadata");
|
|
633
|
+
const scope = l.routePrefix === "/" ? "(all pages)" : `(${l.routePrefix}/*)` ;
|
|
634
|
+
console.log(` 📐 ${l.importPath}.tsx [${tags.join(", ")}] ${scope}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
237
638
|
console.log(` Found ${routes.length} route(s):`);
|
|
238
639
|
for (const r of routes) {
|
|
239
|
-
const
|
|
240
|
-
|
|
640
|
+
const tags: string[] = [];
|
|
641
|
+
if (r.hasGetServerProps) tags.push("getServerProps");
|
|
642
|
+
if (r.hasGetStaticProps) tags.push("getStaticProps");
|
|
643
|
+
if (r.hasMetadata) tags.push("metadata");
|
|
644
|
+
if (r.hasGenerateMetadata) tags.push("generateMetadata");
|
|
645
|
+
if (r.hasLoading) tags.push("loading");
|
|
646
|
+
if (r.isStatic) tags.push("ssg");
|
|
647
|
+
if (r.isClientComponent) tags.push("use client");
|
|
648
|
+
if (r.isEdge) tags.push("edge");
|
|
649
|
+
const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
|
|
650
|
+
console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${tagStr}`);
|
|
241
651
|
}
|
|
242
652
|
|
|
243
653
|
// Generate SSR routes (node-ssr/ → ../app/pages)
|
|
244
|
-
const ssrContent = generateRouteFile(routes, "../app/pages", true);
|
|
654
|
+
const ssrContent = generateRouteFile(routes, layouts, "../app/pages", true);
|
|
245
655
|
fs.mkdirSync(path.dirname(SSR_OUTPUT), { recursive: true });
|
|
246
656
|
fs.writeFileSync(SSR_OUTPUT, ssrContent, "utf-8");
|
|
247
657
|
console.log(` ✅ Written: ${path.relative(process.cwd(), SSR_OUTPUT)}`);
|
|
248
658
|
|
|
249
659
|
// Generate Client routes (app/client/ → ../pages)
|
|
250
|
-
const clientContent = generateRouteFile(routes, "../pages", false);
|
|
660
|
+
const clientContent = generateRouteFile(routes, layouts, "../pages", false);
|
|
251
661
|
fs.mkdirSync(path.dirname(CLIENT_OUTPUT), { recursive: true });
|
|
252
662
|
fs.writeFileSync(CLIENT_OUTPUT, clientContent, "utf-8");
|
|
253
663
|
console.log(
|
|
254
664
|
` ✅ Written: ${path.relative(process.cwd(), CLIENT_OUTPUT)}`,
|
|
255
665
|
);
|
|
256
666
|
|
|
667
|
+
// Generate API routes (app/api/ → node-ssr/generated-api-routes.ts)
|
|
668
|
+
console.log(" → Scanning API routes...");
|
|
669
|
+
const apiRoutes = generateAPIRoutes();
|
|
670
|
+
if (apiRoutes.length > 0) {
|
|
671
|
+
console.log(` Found ${apiRoutes.length} API route(s):`);
|
|
672
|
+
for (const r of apiRoutes) {
|
|
673
|
+
console.log(` ${r.methods.join("|").padEnd(20)} ${r.route}`);
|
|
674
|
+
}
|
|
675
|
+
console.log(` ✅ Written: node-ssr/generated-api-routes.ts`);
|
|
676
|
+
} else {
|
|
677
|
+
console.log(" No API routes found (app/api/ is empty or missing)");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Write SSG manifest for the build step
|
|
681
|
+
const ssgRoutes = routes.filter(r => r.isStatic);
|
|
682
|
+
if (ssgRoutes.length > 0) {
|
|
683
|
+
const ssgManifest = {
|
|
684
|
+
pages: ssgRoutes.map(r => ({
|
|
685
|
+
route: r.route,
|
|
686
|
+
componentId: r.componentId,
|
|
687
|
+
hasGetServerProps: r.hasGetServerProps,
|
|
688
|
+
hasGetStaticProps: r.hasGetStaticProps,
|
|
689
|
+
hasGetStaticPaths: r.hasGetStaticPaths,
|
|
690
|
+
}))
|
|
691
|
+
};
|
|
692
|
+
fs.mkdirSync("dist", { recursive: true });
|
|
693
|
+
fs.writeFileSync("dist/ssg-manifest.json", JSON.stringify(ssgManifest, null, 2));
|
|
694
|
+
console.log(` 📄 SSG: ${ssgRoutes.length} page(s) will be pre-rendered at build time`);
|
|
695
|
+
}
|
|
696
|
+
|
|
257
697
|
console.log(" 🎉 Routes generated successfully!");
|
|
258
698
|
}
|
|
259
699
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blumenjs",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
"dev:go": "go run go-server/main.go go-server/image.go go-server/cache.go",
|
|
30
30
|
"build:client": "webpack --mode production",
|
|
31
31
|
"build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external",
|
|
32
|
-
"clean": "rm -rf dist static/js/bundle.js"
|
|
32
|
+
"clean": "rm -rf dist static/js/bundle.js",
|
|
33
|
+
"test": "tsx cli/blumen.ts test",
|
|
34
|
+
"test:coverage": "tsx cli/blumen.ts test --coverage",
|
|
35
|
+
"bench": "tsx cli/blumen.ts bench"
|
|
33
36
|
},
|
|
34
37
|
"keywords": [
|
|
35
38
|
"react",
|
|
@@ -49,9 +52,11 @@
|
|
|
49
52
|
},
|
|
50
53
|
"homepage": "",
|
|
51
54
|
"dependencies": {
|
|
52
|
-
"lucide-react": "^1.14.0"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
"lucide-react": "^1.14.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"react": "^18.2.0 || ^19.0.0",
|
|
59
|
+
"react-dom": "^18.2.0 || ^19.0.0"
|
|
55
60
|
},
|
|
56
61
|
"devDependencies": {
|
|
57
62
|
"@babel/core": "^7.29.0",
|
|
@@ -59,19 +64,28 @@
|
|
|
59
64
|
"@babel/preset-react": "^7.28.5",
|
|
60
65
|
"@babel/preset-typescript": "^7.28.5",
|
|
61
66
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
|
67
|
+
"@testing-library/dom": "^10.4.1",
|
|
68
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
69
|
+
"@testing-library/react": "^16.3.2",
|
|
62
70
|
"@types/node": "^20.10.0",
|
|
63
|
-
"@types/react": "^18.
|
|
64
|
-
"@types/react-dom": "^18.
|
|
71
|
+
"@types/react": "^18.3.29",
|
|
72
|
+
"@types/react-dom": "^18.3.7",
|
|
73
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
74
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
65
75
|
"babel-loader": "^10.1.1",
|
|
66
76
|
"concurrently": "^8.2.2",
|
|
67
77
|
"css-loader": "^7.1.4",
|
|
68
78
|
"esbuild": "^0.19.0",
|
|
79
|
+
"jsdom": "^29.1.1",
|
|
69
80
|
"mini-css-extract-plugin": "^2.10.2",
|
|
81
|
+
"react": "^19.2.6",
|
|
82
|
+
"react-dom": "^19.2.6",
|
|
70
83
|
"react-refresh": "^0.18.0",
|
|
71
84
|
"style-loader": "^4.0.0",
|
|
72
85
|
"ts-loader": "^9.5.1",
|
|
73
86
|
"tsx": "^4.6.0",
|
|
74
87
|
"typescript": "^5.3.0",
|
|
88
|
+
"vitest": "^4.1.7",
|
|
75
89
|
"webpack": "^5.89.0",
|
|
76
90
|
"webpack-bundle-analyzer": "^5.3.0",
|
|
77
91
|
"webpack-cli": "^5.1.4",
|