blumenjs 0.1.7 → 0.2.1

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.
@@ -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]" */
@@ -40,6 +43,75 @@ interface RouteEntry {
40
43
  patternStr: string;
41
44
  /** Extracted parameter keys */
42
45
  keys: string[];
46
+ /** Whether this page exports a getServerProps function */
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
+ }
59
+
60
+ interface LayoutEntry {
61
+ /** Directory path relative to PAGES_DIR, e.g. "" (root) or "docs" */
62
+ dirPath: string;
63
+ /** The URL prefix this layout applies to, e.g. "/" or "/docs" */
64
+ routePrefix: string;
65
+ /** Import path for the layout file (without extension) */
66
+ importPath: string;
67
+ /** Safe identifier for imports */
68
+ layoutId: string;
69
+ /** Whether this layout exports a static metadata object */
70
+ hasMetadata: boolean;
71
+ /** Whether this layout exports a generateMetadata function */
72
+ hasGenerateMetadata: boolean;
73
+ }
74
+
75
+ /**
76
+ * Check if a file exports a named identifier at the top level.
77
+ * Uses TypeScript AST to avoid false positives from strings/comments.
78
+ */
79
+ function hasNamedExport(filePath: string, content: string, exportName: string): boolean {
80
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
81
+ let found = false;
82
+
83
+ ts.forEachChild(sourceFile, (node) => {
84
+ // 1. Function declaration: export [async] function name() {}
85
+ if (ts.isFunctionDeclaration(node)) {
86
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
87
+ if (isExported && node.name?.text === exportName) {
88
+ found = true;
89
+ }
90
+ }
91
+ // 2. Variable declaration: export const name = ...
92
+ else if (ts.isVariableStatement(node)) {
93
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
94
+ if (isExported) {
95
+ for (const decl of node.declarationList.declarations) {
96
+ if (ts.isIdentifier(decl.name) && decl.name.text === exportName) {
97
+ found = true;
98
+ }
99
+ }
100
+ }
101
+ }
102
+ // 3. Export declaration: export { name } or export { x as name }
103
+ else if (ts.isExportDeclaration(node)) {
104
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
105
+ for (const element of node.exportClause.elements) {
106
+ if (element.name.text === exportName) {
107
+ found = true;
108
+ }
109
+ }
110
+ }
111
+ }
112
+ });
113
+
114
+ return found;
43
115
  }
44
116
 
45
117
  function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
@@ -87,16 +159,83 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
87
159
  patternStr = `^${patternStr}$`;
88
160
  }
89
161
 
162
+ // Check if the page exports getServerProps, metadata, or generateMetadata
163
+ const fileContent = fs.readFileSync(fullPath, "utf-8");
164
+ const hasGetServerProps = hasNamedExport(fullPath, fileContent, "getServerProps");
165
+ const hasMetadata = hasNamedExport(fullPath, fileContent, "metadata");
166
+ const hasGenerateMetadata = hasNamedExport(fullPath, fileContent, "generateMetadata");
167
+ const hasDynamic = hasNamedExport(fullPath, fileContent, "dynamic");
168
+ const isStatic = hasDynamic && fileContent.includes("force-static");
169
+
170
+ // Check for a loading.tsx file in the same directory
171
+ const pageDir = path.dirname(fullPath);
172
+ const loadingFile = path.join(pageDir, "loading.tsx");
173
+ const hasLoading = fs.existsSync(loadingFile);
174
+ const loadingImportPath = hasLoading
175
+ ? path.relative(baseDir, loadingFile).replace(".tsx", "").replace(/\\/g, "/")
176
+ : undefined;
177
+
90
178
  results.push({
91
179
  route: routePath,
92
180
  componentId,
93
181
  importPath: relPath.replace(".tsx", "").replace(/\\/g, "/"),
94
182
  patternStr,
95
- keys
183
+ keys,
184
+ hasGetServerProps,
185
+ hasMetadata,
186
+ hasGenerateMetadata,
187
+ hasLoading,
188
+ loadingImportPath,
189
+ isStatic,
190
+ });
191
+ }
192
+ }
193
+
194
+ return results;
195
+ }
196
+
197
+ /**
198
+ * Scan for _layout.tsx files in the pages directory tree.
199
+ * These files can export metadata/generateMetadata that cascades to child pages.
200
+ */
201
+ function scanLayouts(dir: string, baseDir: string = dir): LayoutEntry[] {
202
+ let results: LayoutEntry[] = [];
203
+ const items = fs.readdirSync(dir, { withFileTypes: true });
204
+
205
+ // Check for _layout.tsx in this directory
206
+ const layoutFile = path.join(dir, "_layout.tsx");
207
+ if (fs.existsSync(layoutFile)) {
208
+ const relDir = path.relative(baseDir, dir).replace(/\\/g, "/");
209
+ const routePrefix = relDir === "" ? "/" : "/" + relDir.toLowerCase();
210
+ const layoutId = relDir === "" ? "Layout_root" : "Layout_" + relDir.replace(/[^a-zA-Z0-9]/g, "_");
211
+ const importPath = relDir === "" ? "_layout" : relDir + "/_layout";
212
+
213
+ const content = fs.readFileSync(layoutFile, "utf-8");
214
+ const hasMetadata = hasNamedExport(layoutFile, content, "metadata");
215
+ const hasGenerateMetadata = hasNamedExport(layoutFile, content, "generateMetadata");
216
+
217
+ if (hasMetadata || hasGenerateMetadata) {
218
+ results.push({
219
+ dirPath: relDir,
220
+ routePrefix,
221
+ importPath,
222
+ layoutId,
223
+ hasMetadata,
224
+ hasGenerateMetadata,
96
225
  });
97
226
  }
98
227
  }
99
228
 
229
+ // Recurse into subdirectories
230
+ for (const item of items) {
231
+ if (item.isDirectory() && !item.name.startsWith("_")) {
232
+ results = results.concat(scanLayouts(path.join(dir, item.name), baseDir));
233
+ }
234
+ }
235
+
236
+ // Sort by depth (root first, then deeper segments)
237
+ results.sort((a, b) => a.dirPath.split("/").length - b.dirPath.split("/").length);
238
+
100
239
  return results;
101
240
  }
102
241
 
@@ -120,6 +259,7 @@ function discoverPages(): RouteEntry[] {
120
259
 
121
260
  function generateRouteFile(
122
261
  routes: RouteEntry[],
262
+ layouts: LayoutEntry[],
123
263
  importPathPrefix: string,
124
264
  isServer: boolean,
125
265
  ): string {
@@ -139,6 +279,56 @@ function generateRouteFile(
139
279
  `import ${r.componentId} from "${importPathPrefix}/${r.importPath}";`,
140
280
  );
141
281
 
282
+ // For SSR: import getServerProps from pages that export it
283
+ const gspImports: string[] = [];
284
+ const gspRoutes = routes.filter(r => r.hasGetServerProps);
285
+ if (isServer && gspRoutes.length > 0) {
286
+ for (const r of gspRoutes) {
287
+ const gspId = "gsp_" + r.componentId.replace("Page_", "");
288
+ gspImports.push(
289
+ `import { getServerProps as ${gspId} } from "${importPathPrefix}/${r.importPath}";`,
290
+ );
291
+ }
292
+ }
293
+
294
+ // For SSR: import metadata and generateMetadata from pages that export them
295
+ const metaImports: string[] = [];
296
+ const metaRoutes = routes.filter(r => r.hasMetadata);
297
+ const genMetaRoutes = routes.filter(r => r.hasGenerateMetadata);
298
+ if (isServer) {
299
+ for (const r of metaRoutes) {
300
+ const metaId = "meta_" + r.componentId.replace("Page_", "");
301
+ metaImports.push(
302
+ `import { metadata as ${metaId} } from "${importPathPrefix}/${r.importPath}";`,
303
+ );
304
+ }
305
+ for (const r of genMetaRoutes) {
306
+ const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
307
+ metaImports.push(
308
+ `import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${r.importPath}";`,
309
+ );
310
+ }
311
+ }
312
+
313
+ // For SSR: import metadata and generateMetadata from layout files
314
+ const layoutImports: string[] = [];
315
+ if (isServer) {
316
+ for (const l of layouts) {
317
+ if (l.hasMetadata) {
318
+ const metaId = "layoutMeta_" + l.layoutId;
319
+ layoutImports.push(
320
+ `import { metadata as ${metaId} } from "${importPathPrefix}/${l.importPath}";`,
321
+ );
322
+ }
323
+ if (l.hasGenerateMetadata) {
324
+ const genMetaId = "layoutGenMeta_" + l.layoutId;
325
+ layoutImports.push(
326
+ `import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${l.importPath}";`,
327
+ );
328
+ }
329
+ }
330
+ }
331
+
142
332
  const hasApp = fs.existsSync(path.join(PAGES_DIR, "_app.tsx"));
143
333
  const hasDoc = fs.existsSync(path.join(PAGES_DIR, "_document.tsx"));
144
334
 
@@ -153,16 +343,123 @@ function generateRouteFile(
153
343
  : `import { DefaultDocument } from "${importPathPrefix}/../shared/DefaultDocument";\nexport const Document = DefaultDocument;`;
154
344
  }
155
345
 
346
+ // Import loading components (client-side only)
347
+ const loadingImports: string[] = [];
348
+ const loadingRoutes = routes.filter(r => r.hasLoading);
349
+ if (!isServer && loadingRoutes.length > 0) {
350
+ for (const r of loadingRoutes) {
351
+ const loadingId = "Loading_" + r.componentId.replace("Page_", "");
352
+ loadingImports.push(
353
+ `import ${loadingId} from "${importPathPrefix}/${r.loadingImportPath}";`,
354
+ );
355
+ }
356
+ }
357
+
156
358
  const routeObjects = routes.map(r => {
157
359
  const keysArray = r.keys.length > 0 ? `["${r.keys.join('", "')}"]` : "[]";
360
+ const loadingId = r.hasLoading ? "Loading_" + r.componentId.replace("Page_", "") : null;
361
+ const loadingLine = !isServer && loadingId ? `,\n\t\tloading: ${loadingId}` : "";
158
362
  return `\t{
159
363
  \t\tpath: "${r.route}",
160
364
  \t\tpattern: new RegExp("${r.patternStr.replace(/\\/g, '\\\\')}"),
161
365
  \t\tkeys: ${keysArray},
162
- \t\tcomponent: ${r.componentId}
366
+ \t\tcomponent: ${r.componentId}${loadingLine}
163
367
  \t}`;
164
368
  });
165
369
 
370
+ // Generate serverPropsMap for SSR routes
371
+ let serverPropsMapStr = "";
372
+ if (isServer && gspRoutes.length > 0) {
373
+ const entries = gspRoutes.map(r => {
374
+ const gspId = "gsp_" + r.componentId.replace("Page_", "");
375
+ return `\t"${r.route}": ${gspId},`;
376
+ });
377
+ serverPropsMapStr = [
378
+ "",
379
+ "// Map of routes that export getServerProps",
380
+ "// Used by the SSR server to run data fetching before rendering",
381
+ "export const serverPropsMap: Record<string, Function> = {",
382
+ ...entries,
383
+ "};",
384
+ ].join("\n");
385
+ } else if (isServer) {
386
+ serverPropsMapStr = "\nexport const serverPropsMap: Record<string, Function> = {};";
387
+ }
388
+
389
+ // Generate metadataMap and generateMetadataMap for SSR routes
390
+ let metadataMapStr = "";
391
+ if (isServer) {
392
+ // Static metadata map
393
+ if (metaRoutes.length > 0) {
394
+ const entries = metaRoutes.map(r => {
395
+ const metaId = "meta_" + r.componentId.replace("Page_", "");
396
+ return `\t"${r.route}": ${metaId},`;
397
+ });
398
+ metadataMapStr += [
399
+ "",
400
+ "// Map of routes with static metadata exports",
401
+ "export const metadataMap: Record<string, any> = {",
402
+ ...entries,
403
+ "};",
404
+ ].join("\n");
405
+ } else {
406
+ metadataMapStr += "\nexport const metadataMap: Record<string, any> = {};";
407
+ }
408
+
409
+ // Dynamic generateMetadata map
410
+ if (genMetaRoutes.length > 0) {
411
+ const entries = genMetaRoutes.map(r => {
412
+ const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
413
+ return `\t"${r.route}": ${genMetaId},`;
414
+ });
415
+ metadataMapStr += [
416
+ "",
417
+ "// Map of routes with dynamic generateMetadata exports",
418
+ "export const generateMetadataMap: Record<string, Function> = {",
419
+ ...entries,
420
+ "};",
421
+ ].join("\n");
422
+ } else {
423
+ metadataMapStr += "\nexport const generateMetadataMap: Record<string, Function> = {};";
424
+ }
425
+
426
+ // Layout static metadata map
427
+ const layoutMetaEntries = layouts.filter(l => l.hasMetadata);
428
+ if (layoutMetaEntries.length > 0) {
429
+ const entries = layoutMetaEntries.map(l => {
430
+ const metaId = "layoutMeta_" + l.layoutId;
431
+ return `\t"${l.routePrefix}": ${metaId},`;
432
+ });
433
+ metadataMapStr += [
434
+ "",
435
+ "// Layout metadata — cascades to all child pages in the directory",
436
+ "export const layoutMetadataMap: Record<string, any> = {",
437
+ ...entries,
438
+ "};",
439
+ ].join("\n");
440
+ } else {
441
+ metadataMapStr += "\nexport const layoutMetadataMap: Record<string, any> = {};";
442
+ }
443
+
444
+ // Layout dynamic generateMetadata map
445
+ const layoutGenMetaEntries = layouts.filter(l => l.hasGenerateMetadata);
446
+ if (layoutGenMetaEntries.length > 0) {
447
+ const entries = layoutGenMetaEntries.map(l => {
448
+ const genMetaId = "layoutGenMeta_" + l.layoutId;
449
+ return `\t"${l.routePrefix}": ${genMetaId},`;
450
+ });
451
+ metadataMapStr += [
452
+ "",
453
+ "// Layout dynamic metadata — cascades to all child pages",
454
+ "export const layoutGenerateMetadataMap: Record<string, Function> = {",
455
+ ...entries,
456
+ "};",
457
+ ].join("\n");
458
+ } else {
459
+ metadataMapStr += "\nexport const layoutGenerateMetadataMap: Record<string, Function> = {};";
460
+ }
461
+ }
462
+
166
463
  const map = [
167
464
  "",
168
465
  "export interface RouteDef {",
@@ -170,6 +467,7 @@ function generateRouteFile(
170
467
  "\tpattern: RegExp;",
171
468
  "\tkeys: string[];",
172
469
  "\tcomponent: React.ComponentType<any>;",
470
+ "\tloading?: React.ComponentType<any>;",
173
471
  "}",
174
472
  "",
175
473
  "export const routes: RouteDef[] = [",
@@ -178,10 +476,12 @@ function generateRouteFile(
178
476
  "",
179
477
  appImport,
180
478
  docImport,
479
+ serverPropsMapStr,
480
+ metadataMapStr,
181
481
  "",
182
482
  ];
183
483
 
184
- return [...header, ...imports, ...map].join("\n");
484
+ return [...header, ...imports, ...gspImports, ...metaImports, ...layoutImports, ...loadingImports, ...map].join("\n");
185
485
  }
186
486
 
187
487
  function main() {
@@ -189,31 +489,79 @@ function main() {
189
489
  console.log(` Scanning: ${PAGES_DIR}`);
190
490
 
191
491
  const routes = discoverPages();
492
+ const layouts = scanLayouts(PAGES_DIR);
192
493
 
193
494
  if (routes.length === 0) {
194
495
  console.error(" ❌ No pages found!");
195
496
  process.exit(1);
196
497
  }
197
498
 
499
+ // Print detected layouts
500
+ if (layouts.length > 0) {
501
+ console.log(` Found ${layouts.length} layout(s) with metadata:`);
502
+ for (const l of layouts) {
503
+ const tags: string[] = [];
504
+ if (l.hasMetadata) tags.push("metadata");
505
+ if (l.hasGenerateMetadata) tags.push("generateMetadata");
506
+ const scope = l.routePrefix === "/" ? "(all pages)" : `(${l.routePrefix}/*)` ;
507
+ console.log(` 📐 ${l.importPath}.tsx [${tags.join(", ")}] ${scope}`);
508
+ }
509
+ }
510
+
198
511
  console.log(` Found ${routes.length} route(s):`);
199
512
  for (const r of routes) {
200
- console.log(` ${r.route.padEnd(25)} ${r.importPath}.tsx`);
513
+ const tags: string[] = [];
514
+ if (r.hasGetServerProps) tags.push("getServerProps");
515
+ if (r.hasMetadata) tags.push("metadata");
516
+ if (r.hasGenerateMetadata) tags.push("generateMetadata");
517
+ if (r.hasLoading) tags.push("loading");
518
+ if (r.isStatic) tags.push("ssg");
519
+ const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
520
+ console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${tagStr}`);
201
521
  }
202
522
 
203
523
  // Generate SSR routes (node-ssr/ → ../app/pages)
204
- const ssrContent = generateRouteFile(routes, "../app/pages", true);
524
+ const ssrContent = generateRouteFile(routes, layouts, "../app/pages", true);
205
525
  fs.mkdirSync(path.dirname(SSR_OUTPUT), { recursive: true });
206
526
  fs.writeFileSync(SSR_OUTPUT, ssrContent, "utf-8");
207
527
  console.log(` ✅ Written: ${path.relative(process.cwd(), SSR_OUTPUT)}`);
208
528
 
209
529
  // Generate Client routes (app/client/ → ../pages)
210
- const clientContent = generateRouteFile(routes, "../pages", false);
530
+ const clientContent = generateRouteFile(routes, layouts, "../pages", false);
211
531
  fs.mkdirSync(path.dirname(CLIENT_OUTPUT), { recursive: true });
212
532
  fs.writeFileSync(CLIENT_OUTPUT, clientContent, "utf-8");
213
533
  console.log(
214
534
  ` ✅ Written: ${path.relative(process.cwd(), CLIENT_OUTPUT)}`,
215
535
  );
216
536
 
537
+ // Generate API routes (app/api/ → node-ssr/generated-api-routes.ts)
538
+ console.log(" → Scanning API routes...");
539
+ const apiRoutes = generateAPIRoutes();
540
+ if (apiRoutes.length > 0) {
541
+ console.log(` Found ${apiRoutes.length} API route(s):`);
542
+ for (const r of apiRoutes) {
543
+ console.log(` ${r.methods.join("|").padEnd(20)} ${r.route}`);
544
+ }
545
+ console.log(` ✅ Written: node-ssr/generated-api-routes.ts`);
546
+ } else {
547
+ console.log(" No API routes found (app/api/ is empty or missing)");
548
+ }
549
+
550
+ // Write SSG manifest for the build step
551
+ const ssgRoutes = routes.filter(r => r.isStatic);
552
+ if (ssgRoutes.length > 0) {
553
+ const ssgManifest = {
554
+ pages: ssgRoutes.map(r => ({
555
+ route: r.route,
556
+ componentId: r.componentId,
557
+ hasGetServerProps: r.hasGetServerProps,
558
+ }))
559
+ };
560
+ fs.mkdirSync("dist", { recursive: true });
561
+ fs.writeFileSync("dist/ssg-manifest.json", JSON.stringify(ssgManifest, null, 2));
562
+ console.log(` 📄 SSG: ${ssgRoutes.length} page(s) will be pre-rendered at build time`);
563
+ }
564
+
217
565
  console.log(" 🎉 Routes generated successfully!");
218
566
  }
219
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blumenjs",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
4
4
  "description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,9 +26,9 @@
26
26
  "dev:legacy": "npm run routes && concurrently \"npm run dev:client\" \"npm run dev:ssr\" \"npm run dev:go\"",
27
27
  "dev:client": "webpack serve --mode development",
28
28
  "dev:ssr": "NODE_ENV=development tsx watch node-ssr/server.ts",
29
- "dev:go": "go run go-server/main.go",
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
- "build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --external:react --external:react-dom",
31
+ "build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external",
32
32
  "clean": "rm -rf dist static/js/bundle.js"
33
33
  },
34
34
  "keywords": [
@@ -49,9 +49,11 @@
49
49
  },
50
50
  "homepage": "",
51
51
  "dependencies": {
52
- "lucide-react": "^1.14.0",
53
- "react": "^18.2.0",
54
- "react-dom": "^18.2.0"
52
+ "lucide-react": "^1.14.0"
53
+ },
54
+ "peerDependencies": {
55
+ "react": "^18.2.0 || ^19.0.0",
56
+ "react-dom": "^18.2.0 || ^19.0.0"
55
57
  },
56
58
  "devDependencies": {
57
59
  "@babel/core": "^7.29.0",
@@ -64,12 +66,16 @@
64
66
  "@types/react-dom": "^18.2.0",
65
67
  "babel-loader": "^10.1.1",
66
68
  "concurrently": "^8.2.2",
69
+ "css-loader": "^7.1.4",
67
70
  "esbuild": "^0.19.0",
71
+ "mini-css-extract-plugin": "^2.10.2",
68
72
  "react-refresh": "^0.18.0",
73
+ "style-loader": "^4.0.0",
69
74
  "ts-loader": "^9.5.1",
70
75
  "tsx": "^4.6.0",
71
76
  "typescript": "^5.3.0",
72
77
  "webpack": "^5.89.0",
78
+ "webpack-bundle-analyzer": "^5.3.0",
73
79
  "webpack-cli": "^5.1.4",
74
80
  "webpack-dev-server": "^5.2.3"
75
81
  },