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.
@@ -1,5 +1,7 @@
1
1
  // cli/commands/build.ts
2
2
  import { execSync } from "child_process";
3
+ import * as fs2 from "fs";
4
+ import * as path2 from "path";
3
5
 
4
6
  // cli/utils.ts
5
7
  import * as fs from "fs";
@@ -61,6 +63,37 @@ function divider() {
61
63
  }
62
64
 
63
65
  // cli/commands/build.ts
66
+ function generateChunkManifest() {
67
+ const jsDir = path2.resolve("static/js");
68
+ const chunksDir = path2.resolve("static/js/chunks");
69
+ const manifest = {};
70
+ if (fs2.existsSync(jsDir)) {
71
+ for (const file of fs2.readdirSync(jsDir)) {
72
+ if (!file.endsWith(".js") || file.endsWith(".map"))
73
+ continue;
74
+ const match = file.match(/^([^.]+)\.[a-f0-9]+\.js$/);
75
+ if (match) {
76
+ manifest[match[1]] = `/static/js/${file}`;
77
+ }
78
+ }
79
+ }
80
+ if (fs2.existsSync(chunksDir)) {
81
+ for (const file of fs2.readdirSync(chunksDir)) {
82
+ if (!file.endsWith(".js") || file.endsWith(".map"))
83
+ continue;
84
+ const match = file.match(/^([^.]+)\.[a-f0-9]+\.js$/);
85
+ if (match) {
86
+ manifest[match[1]] = `/static/js/chunks/${file}`;
87
+ }
88
+ }
89
+ }
90
+ fs2.mkdirSync("dist", { recursive: true });
91
+ fs2.writeFileSync("dist/chunk-manifest.json", JSON.stringify(manifest, null, 2));
92
+ const coreChunks = ["runtime", "vendor", "framework", "main"].filter((k) => manifest[k]);
93
+ const pageChunks = Object.keys(manifest).filter((k) => k.startsWith("page-"));
94
+ log.info(` Core chunks: ${coreChunks.join(", ")} (${coreChunks.length})`);
95
+ log.info(` Page chunks: ${pageChunks.length} page(s)`);
96
+ }
64
97
  async function build(args = []) {
65
98
  banner();
66
99
  const analyze = args.includes("--analyze");
@@ -75,13 +108,23 @@ async function build(args = []) {
75
108
  cmd: "npx tsx scripts/generate-routes.ts"
76
109
  },
77
110
  {
78
- label: "Building client bundle",
111
+ label: "Building client bundle (code splitting)",
79
112
  cmd: "npx webpack --mode production",
80
113
  env: analyze ? { BLUMEN_ANALYZE: "1" } : void 0
81
114
  },
115
+ {
116
+ label: "Generating chunk manifest",
117
+ fn: generateChunkManifest
118
+ },
82
119
  {
83
120
  label: "Building SSR server",
84
- cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external"
121
+ cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external --alias:@=./app"
122
+ },
123
+ {
124
+ label: "Pre-rendering static pages (SSG)",
125
+ cmd: "npx tsx scripts/ssg-prerender.ts",
126
+ optional: true
127
+ // Don't fail if no SSG pages exist
85
128
  }
86
129
  ];
87
130
  const startTime = Date.now();
@@ -89,15 +132,23 @@ async function build(args = []) {
89
132
  const step = steps[i];
90
133
  log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
91
134
  try {
92
- execSync(step.cmd, {
93
- stdio: "inherit",
94
- cwd: process.cwd(),
95
- env: { ...process.env, ...step.env || {} }
96
- });
135
+ if (step.fn) {
136
+ step.fn();
137
+ } else if (step.cmd) {
138
+ execSync(step.cmd, {
139
+ stdio: "inherit",
140
+ cwd: process.cwd(),
141
+ env: { ...process.env, ...step.env || {} }
142
+ });
143
+ }
97
144
  log.success(step.label);
98
145
  } catch {
99
- log.error(`Failed: ${step.label}`);
100
- process.exit(1);
146
+ if (step.optional) {
147
+ log.success(step.label);
148
+ } else {
149
+ log.error(`Failed: ${step.label}`);
150
+ process.exit(1);
151
+ }
101
152
  }
102
153
  }
103
154
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -218,7 +218,7 @@ async function dev() {
218
218
  label: " go",
219
219
  color: c.green,
220
220
  cmd: "go",
221
- args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
221
+ args: ["run", "./go-server/"],
222
222
  readyPattern: /Go server starting/
223
223
  }
224
224
  ];
@@ -87,7 +87,7 @@ async function start() {
87
87
  label: " go",
88
88
  color: c.green,
89
89
  cmd: "go",
90
- args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
90
+ args: ["run", "./go-server/"],
91
91
  readyPattern: /Go server starting/
92
92
  }
93
93
  ];
@@ -3,9 +3,13 @@ import { hydrateRoot } from "react-dom/client";
3
3
  import NotFoundPage from "../pages/NotFound";
4
4
 
5
5
  // Auto-generated route map (run `npm run routes` to regenerate)
6
- import { routes, App } from "./generated-routes";
6
+ import { routes, App, routeChunkMap } from "./generated-routes";
7
7
  import { RouterProvider } from "../shared/RouterContext";
8
8
 
9
+ // Expose chunk map globally for the prefetch system
10
+ // (prefetchCache.ts reads this to know which JS chunk to preload on hover)
11
+ (window as any).__BLUMEN_CHUNK_MAP__ = routeChunkMap;
12
+
9
13
  function init() {
10
14
  const container = document.getElementById("root");
11
15
  if (!container) {
@@ -316,7 +316,7 @@ const HomePage: React.FC<HomeProps> = () => {
316
316
  <div className="blumen-content">
317
317
  <div className="blumen-badge">
318
318
  <span className="blumen-badge-dot" />
319
- Framework v0.1.0
319
+ Framework v0.2.0
320
320
  </div>
321
321
 
322
322
  <h1 className="blumen-logo">🌸 Blumen</h1>
@@ -1,13 +1,37 @@
1
1
  import React from "react";
2
2
 
3
+ /**
4
+ * Sanitize data for safe embedding in a <script type="application/json"> tag.
5
+ * Escapes all characters that could break out of the JSON context:
6
+ * - `<` → `\u003c` (prevents </script> injection)
7
+ * - `>` → `\u003e` (prevents HTML entity injection)
8
+ * - `&` → `\u0026` (prevents HTML entity injection)
9
+ * - U+2028 → `\u2028` (line separator - breaks JS in some contexts)
10
+ * - U+2029 → `\u2029` (paragraph separator - breaks JS in some contexts)
11
+ */
12
+ function sanitizeForHydration(data: any): string {
13
+ return JSON.stringify(data)
14
+ .replace(/</g, '\\u003c')
15
+ .replace(/>/g, '\\u003e')
16
+ .replace(/&/g, '\\u0026')
17
+ .replace(/\u2028/g, '\\u2028')
18
+ .replace(/\u2029/g, '\\u2029');
19
+ }
3
20
  // HMR: In development, load the client bundle from Webpack Dev Server
4
21
  // so the HMR runtime + React Fast Refresh are active.
22
+ // With code splitting, WDS serves multiple chunks instead of one bundle.js.
5
23
  const isDev = process.env.NODE_ENV === "development";
6
- const BUNDLE_SRC = isDev
7
- ? "http://localhost:3100/static/js/bundle.js"
8
- : "/static/js/bundle.js";
24
+ const WDS_BASE = "http://localhost:3100/static/js";
25
+
26
+ // Dev mode core scripts — loaded in dependency order
27
+ const DEV_SCRIPTS = [
28
+ `${WDS_BASE}/runtime.js`,
29
+ `${WDS_BASE}/vendor.js`,
30
+ `${WDS_BASE}/framework.js`,
31
+ `${WDS_BASE}/main.js`,
32
+ ];
9
33
 
10
- export function DefaultDocument({ children, initialProps }: any) {
34
+ export function DefaultDocument({ children, initialProps, metadata, scripts }: any) {
11
35
  const css = `
12
36
  * { margin: 0; padding: 0; box-sizing: border-box; }
13
37
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #e2e8f0; background: #1a1025; }
@@ -51,13 +75,41 @@ export function DefaultDocument({ children, initialProps }: any) {
51
75
  .page-transition-exit { opacity: 0; transition: opacity 150ms ease-out; }
52
76
  `;
53
77
 
78
+ // Resolve metadata with defaults
79
+ const meta = metadata || {};
80
+ const title = meta.title || "Blumen App";
81
+ const description = meta.description || "";
82
+ const og = meta.openGraph || {};
83
+ const tw = meta.twitter || {};
84
+
54
85
  return (
55
86
  <html lang="en">
56
87
  <head>
57
88
  <meta charSet="UTF-8" />
58
89
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
59
- <meta name="description" content="SSR Go + React - Minimal Production-Quality SSR Engine" />
60
- <title>SSR Go + React</title>
90
+ <title>{title}</title>
91
+ {description && <meta name="description" content={description} />}
92
+ {meta.keywords?.length && <meta name="keywords" content={meta.keywords.join(", ")} />}
93
+ {meta.robots && <meta name="robots" content={meta.robots} />}
94
+ {meta.canonical && <link rel="canonical" href={meta.canonical} />}
95
+ {/* Open Graph */}
96
+ {og.title && <meta property="og:title" content={og.title} />}
97
+ {og.description && <meta property="og:description" content={og.description} />}
98
+ {og.image && <meta property="og:image" content={og.image} />}
99
+ {og.url && <meta property="og:url" content={og.url} />}
100
+ {og.type && <meta property="og:type" content={og.type} />}
101
+ {og.siteName && <meta property="og:site_name" content={og.siteName} />}
102
+ {/* Twitter Card */}
103
+ {tw.card && <meta name="twitter:card" content={tw.card} />}
104
+ {tw.title && <meta name="twitter:title" content={tw.title} />}
105
+ {tw.description && <meta name="twitter:description" content={tw.description} />}
106
+ {tw.image && <meta name="twitter:image" content={tw.image} />}
107
+ {tw.creator && <meta name="twitter:creator" content={tw.creator} />}
108
+ {tw.site && <meta name="twitter:site" content={tw.site} />}
109
+ {/* Custom meta tags */}
110
+ {meta.other && Object.entries(meta.other).map(([name, content]: [string, any]) => (
111
+ <meta key={name} name={name} content={content} />
112
+ ))}
61
113
  <style dangerouslySetInnerHTML={{ __html: css }} />
62
114
  </head>
63
115
  <body>
@@ -66,10 +118,17 @@ export function DefaultDocument({ children, initialProps }: any) {
66
118
  id="ssr-props"
67
119
  type="application/json"
68
120
  dangerouslySetInnerHTML={{
69
- __html: JSON.stringify(initialProps).replace(/</g, '\\u003c')
121
+ __html: sanitizeForHydration(initialProps)
70
122
  }}
71
123
  />
72
- <script src={BUNDLE_SRC} defer></script>
124
+ {isDev
125
+ ? DEV_SCRIPTS.map((src, i) => (
126
+ <script key={i} src={src} defer></script>
127
+ ))
128
+ : (scripts || ['/static/js/main.js']).map((src: string, i: number) => (
129
+ <script key={i} src={src} defer></script>
130
+ ))
131
+ }
73
132
  </body>
74
133
  </html>
75
134
  );
@@ -5,21 +5,26 @@
5
5
  * External links, new-tab links, and modified clicks (ctrl/cmd)
6
6
  * are passed through to the browser as normal.
7
7
  *
8
- * During SSR (no RouterProvider), renders a plain <a> tag.
8
+ * Prefetching:
9
+ * - On hover: preloads page data so navigation is instant
10
+ * - On viewport: uses IntersectionObserver to prefetch when visible
11
+ * - Set prefetch={false} to disable for specific links
9
12
  *
10
- * @example
11
- * <Link href="/about" className="nav-link">About</Link>
13
+ * During SSR (no RouterProvider), renders a plain <a> tag.
12
14
  */
13
15
 
14
- import React, { useContext } from "react";
16
+ import React, { useContext, useRef, useEffect, useCallback } from "react";
15
17
 
16
18
  // Import the context directly to do a safe check without throwing
17
19
  // We need the raw context object, not the hook
18
20
  import { RouterContextRef, useRouter } from "./RouterContext";
21
+ import { prefetch as prefetchData } from "./prefetchCache";
19
22
 
20
23
  interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
21
24
  /** The destination path */
22
25
  href: string;
26
+ /** Enable/disable prefetching. Defaults to true. Set to false for rarely-visited links. */
27
+ prefetch?: boolean;
23
28
  }
24
29
 
25
30
  function isExternal(href: string): boolean {
@@ -30,9 +35,51 @@ function isModifiedClick(e: React.MouseEvent): boolean {
30
35
  return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0;
31
36
  }
32
37
 
33
- export function Link({ href, children, onClick, target, ...rest }: LinkProps) {
38
+ export function Link({ href, children, onClick, target, prefetch = true, ...rest }: LinkProps) {
34
39
  // Safe check: if we're in SSR (no provider), just render a plain <a>
35
40
  const ctx = useContext(RouterContextRef);
41
+ const anchorRef = useRef<HTMLAnchorElement>(null);
42
+ const hasPrefetched = useRef(false);
43
+
44
+ // Determine if this is a local link that can be prefetched
45
+ const isLocal = !isExternal(href) && target !== "_blank";
46
+
47
+ // ── Hover prefetch ───────────────────────────────────────────
48
+ const handleMouseEnter = useCallback(() => {
49
+ if (!isLocal || !prefetch || hasPrefetched.current) return;
50
+ hasPrefetched.current = true;
51
+ prefetchData(href);
52
+ }, [href, isLocal, prefetch]);
53
+
54
+ // ── Viewport prefetch (IntersectionObserver) ─────────────────
55
+ useEffect(() => {
56
+ if (!isLocal || !prefetch || !anchorRef.current) return;
57
+ if (typeof IntersectionObserver === "undefined") return;
58
+
59
+ const el = anchorRef.current;
60
+
61
+ const observer = new IntersectionObserver(
62
+ (entries) => {
63
+ for (const entry of entries) {
64
+ if (entry.isIntersecting && !hasPrefetched.current) {
65
+ hasPrefetched.current = true;
66
+ prefetchData(href);
67
+ observer.unobserve(el);
68
+ }
69
+ }
70
+ },
71
+ {
72
+ // Start prefetching when the link is within 200px of the viewport
73
+ rootMargin: "200px",
74
+ },
75
+ );
76
+
77
+ observer.observe(el);
78
+
79
+ return () => {
80
+ observer.unobserve(el);
81
+ };
82
+ }, [href, isLocal, prefetch]);
36
83
 
37
84
  if (!ctx) {
38
85
  // SSR fallback — no router available
@@ -64,7 +111,14 @@ export function Link({ href, children, onClick, target, ...rest }: LinkProps) {
64
111
  };
65
112
 
66
113
  return (
67
- <a href={href} onClick={handleClick} target={target} {...rest}>
114
+ <a
115
+ ref={anchorRef}
116
+ href={href}
117
+ onClick={handleClick}
118
+ onMouseEnter={handleMouseEnter}
119
+ target={target}
120
+ {...rest}
121
+ >
68
122
  {children}
69
123
  </a>
70
124
  );
@@ -14,9 +14,13 @@ import React, {
14
14
  useCallback,
15
15
  useEffect,
16
16
  useRef,
17
+ Suspense,
17
18
  } from "react";
18
19
  import { matchRoute, type RouteDef } from "./router";
19
20
  import { BlumenErrorBoundary } from "./ErrorBoundary";
21
+ import { applyMetadata } from "./BlumenHead";
22
+ import { consumeCached } from "./prefetchCache";
23
+ import DefaultLoading from "./DefaultLoading";
20
24
 
21
25
  // ── Context types ──────────────────────────────────────────────
22
26
 
@@ -74,6 +78,8 @@ export function RouterProvider({
74
78
  () => window.location.pathname,
75
79
  );
76
80
  const [transitioning, setTransitioning] = useState(false);
81
+ const [isLoading, setIsLoading] = useState(false);
82
+ const [loadingComponent, setLoadingComponent] = useState<React.ComponentType<any> | null>(null);
77
83
  const [dynamicProps, setDynamicProps] = useState<Record<string, any>>({});
78
84
 
79
85
  // Track whether we're on the very first render (SSR hydration).
@@ -110,25 +116,48 @@ export function RouterProvider({
110
116
  // Animate out → change route → animate in
111
117
  setTransitioning(true);
112
118
 
113
- // Fetch dynamic data from Go server
114
- const fetchPromise = fetch(to, {
115
- headers: { "X-Blumen-Data": "1" }
116
- }).then(res => res.ok ? res.json() : {}).catch(err => {
117
- console.error("Failed to fetch route data", err);
118
- return {};
119
- });
119
+ // Check prefetch cache first (populated by <Link> on hover)
120
+ const cached = consumeCached(to);
121
+
122
+ // Determine the loading component for the target route
123
+ const targetMatch = matchRoute(to, routes);
124
+ const targetLoading = targetMatch?.loading || null;
125
+
126
+ // If we don't have cached data and route has a loading component, show it
127
+ if (!cached && targetLoading) {
128
+ setLoadingComponent(() => targetLoading);
129
+ setIsLoading(true);
130
+ }
131
+
132
+ // Fetch dynamic data from Go server (or use cached data)
133
+ const fetchPromise = cached
134
+ ? Promise.resolve(cached)
135
+ : fetch(to, {
136
+ headers: { "X-Blumen-Data": "1" }
137
+ }).then(res => res.ok ? res.json() : {}).catch(err => {
138
+ console.error("Failed to fetch route data", err);
139
+ return {};
140
+ });
120
141
 
121
142
  // Allow the exit transition to play (150ms)
122
143
  const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
123
144
 
124
- Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
145
+ Promise.all([fetchPromise, delayPromise]).then(([newData]: any[]) => {
125
146
  setDynamicProps(newData);
147
+
148
+ // Update <head> metadata for the new page
149
+ if (newData?.metadata) {
150
+ applyMetadata(newData.metadata);
151
+ }
152
+
126
153
  window.history.pushState(null, "", to);
127
154
  setPath(to);
128
155
  window.scrollTo(0, 0);
129
156
 
130
- // Small delay for the enter transition
157
+ // Clear loading state and transition
131
158
  requestAnimationFrame(() => {
159
+ setIsLoading(false);
160
+ setLoadingComponent(null);
132
161
  setTransitioning(false);
133
162
  });
134
163
  });
@@ -142,17 +171,38 @@ export function RouterProvider({
142
171
  setTransitioning(true);
143
172
  const to = window.location.pathname;
144
173
 
145
- const fetchPromise = fetch(to, {
146
- headers: { "X-Blumen-Data": "1" }
147
- }).then(res => res.ok ? res.json() : {}).catch(() => ({}));
174
+ // Check prefetch cache for back/forward navigation
175
+ const cached = consumeCached(to);
176
+
177
+ // Show loading component if available
178
+ if (!cached) {
179
+ const targetMatch = matchRoute(to, routes);
180
+ if (targetMatch?.loading) {
181
+ setLoadingComponent(() => targetMatch.loading!);
182
+ setIsLoading(true);
183
+ }
184
+ }
185
+
186
+ const fetchPromise = cached
187
+ ? Promise.resolve(cached)
188
+ : fetch(to, {
189
+ headers: { "X-Blumen-Data": "1" }
190
+ }).then(res => res.ok ? res.json() : {}).catch(() => ({}));
148
191
 
149
192
  const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
150
193
 
151
- Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
194
+ Promise.all([fetchPromise, delayPromise]).then(([newData]: any[]) => {
152
195
  setDynamicProps(newData);
153
196
  setPath(to);
197
+
198
+ // Update <head> metadata for the new page
199
+ if (newData?.metadata) {
200
+ applyMetadata(newData.metadata);
201
+ }
154
202
 
155
203
  requestAnimationFrame(() => {
204
+ setIsLoading(false);
205
+ setLoadingComponent(null);
156
206
  setTransitioning(false);
157
207
  });
158
208
  });
@@ -165,15 +215,28 @@ export function RouterProvider({
165
215
  // ── Render ──────────────────────────────────────────────────
166
216
  const contextValue: RouterContextValue = { path, params, navigate };
167
217
 
218
+ // Determine what to render:
219
+ // 1. If loading — show the loading component
220
+ // 2. Otherwise — show the page component
221
+ const LoadingComp = loadingComponent;
222
+
168
223
  return (
169
224
  <RouterContext.Provider value={contextValue}>
170
- <div
171
- className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
172
- >
173
- <BlumenErrorBoundary>
174
- <App Component={PageComponent} pageProps={pageProps} />
175
- </BlumenErrorBoundary>
176
- </div>
225
+ {isLoading && LoadingComp ? (
226
+ <div className="page-transition page-transition-active">
227
+ <LoadingComp />
228
+ </div>
229
+ ) : (
230
+ <div
231
+ className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
232
+ >
233
+ <BlumenErrorBoundary>
234
+ <Suspense fallback={LoadingComp ? <LoadingComp /> : <DefaultLoading />}>
235
+ <App Component={PageComponent} pageProps={pageProps} />
236
+ </Suspense>
237
+ </BlumenErrorBoundary>
238
+ </div>
239
+ )}
177
240
  </RouterContext.Provider>
178
241
  );
179
242
  }
@@ -3,6 +3,7 @@ export interface RouteDef {
3
3
  pattern: RegExp;
4
4
  keys: string[];
5
5
  component: React.ComponentType<any>;
6
+ loading?: React.ComponentType<any>;
6
7
  }
7
8
 
8
9
  export function matchRoute(path: string, routes: RouteDef[]) {
@@ -16,7 +17,7 @@ export function matchRoute(path: string, routes: RouteDef[]) {
16
17
  route.keys.forEach((key: string, index: number) => {
17
18
  params[key] = match[index + 1];
18
19
  });
19
- return { component: route.component, params };
20
+ return { component: route.component, params, loading: route.loading };
20
21
  }
21
22
  }
22
23
  return null;