blumenjs 0.2.0 → 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.
@@ -243,7 +243,7 @@ async function dev() {
243
243
  label: " go",
244
244
  color: c.green,
245
245
  cmd: "go",
246
- args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
246
+ args: ["run", "./go-server/"],
247
247
  readyPattern: /Go server starting/
248
248
  }
249
249
  ];
@@ -341,7 +341,13 @@ async function build(args = []) {
341
341
  },
342
342
  {
343
343
  label: "Building SSR server",
344
- cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external"
344
+ cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external --alias:@=./app"
345
+ },
346
+ {
347
+ label: "Pre-rendering static pages (SSG)",
348
+ cmd: "npx tsx scripts/ssg-prerender.ts",
349
+ optional: true
350
+ // Don't fail if no SSG pages exist
345
351
  }
346
352
  ];
347
353
  const startTime = Date.now();
@@ -356,8 +362,12 @@ async function build(args = []) {
356
362
  });
357
363
  log.success(step.label);
358
364
  } catch {
359
- log.error(`Failed: ${step.label}`);
360
- process.exit(1);
365
+ if (step.optional) {
366
+ log.success(step.label);
367
+ } else {
368
+ log.error(`Failed: ${step.label}`);
369
+ process.exit(1);
370
+ }
361
371
  }
362
372
  }
363
373
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -401,7 +411,7 @@ async function start() {
401
411
  label: " go",
402
412
  color: c.green,
403
413
  cmd: "go",
404
- args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
414
+ args: ["run", "./go-server/"],
405
415
  readyPattern: /Go server starting/
406
416
  }
407
417
  ];
@@ -81,7 +81,13 @@ async function build(args = []) {
81
81
  },
82
82
  {
83
83
  label: "Building SSR server",
84
- cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external"
84
+ cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external --alias:@=./app"
85
+ },
86
+ {
87
+ label: "Pre-rendering static pages (SSG)",
88
+ cmd: "npx tsx scripts/ssg-prerender.ts",
89
+ optional: true
90
+ // Don't fail if no SSG pages exist
85
91
  }
86
92
  ];
87
93
  const startTime = Date.now();
@@ -96,8 +102,12 @@ async function build(args = []) {
96
102
  });
97
103
  log.success(step.label);
98
104
  } catch {
99
- log.error(`Failed: ${step.label}`);
100
- process.exit(1);
105
+ if (step.optional) {
106
+ log.success(step.label);
107
+ } else {
108
+ log.error(`Failed: ${step.label}`);
109
+ process.exit(1);
110
+ }
101
111
  }
102
112
  }
103
113
  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
  ];
@@ -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,5 +1,22 @@
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.
5
22
  const isDev = process.env.NODE_ENV === "development";
@@ -7,7 +24,7 @@ const BUNDLE_SRC = isDev
7
24
  ? "http://localhost:3100/static/js/bundle.js"
8
25
  : "/static/js/bundle.js";
9
26
 
10
- export function DefaultDocument({ children, initialProps }: any) {
27
+ export function DefaultDocument({ children, initialProps, metadata }: any) {
11
28
  const css = `
12
29
  * { margin: 0; padding: 0; box-sizing: border-box; }
13
30
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #e2e8f0; background: #1a1025; }
@@ -51,13 +68,41 @@ export function DefaultDocument({ children, initialProps }: any) {
51
68
  .page-transition-exit { opacity: 0; transition: opacity 150ms ease-out; }
52
69
  `;
53
70
 
71
+ // Resolve metadata with defaults
72
+ const meta = metadata || {};
73
+ const title = meta.title || "Blumen App";
74
+ const description = meta.description || "";
75
+ const og = meta.openGraph || {};
76
+ const tw = meta.twitter || {};
77
+
54
78
  return (
55
79
  <html lang="en">
56
80
  <head>
57
81
  <meta charSet="UTF-8" />
58
82
  <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>
83
+ <title>{title}</title>
84
+ {description && <meta name="description" content={description} />}
85
+ {meta.keywords?.length && <meta name="keywords" content={meta.keywords.join(", ")} />}
86
+ {meta.robots && <meta name="robots" content={meta.robots} />}
87
+ {meta.canonical && <link rel="canonical" href={meta.canonical} />}
88
+ {/* Open Graph */}
89
+ {og.title && <meta property="og:title" content={og.title} />}
90
+ {og.description && <meta property="og:description" content={og.description} />}
91
+ {og.image && <meta property="og:image" content={og.image} />}
92
+ {og.url && <meta property="og:url" content={og.url} />}
93
+ {og.type && <meta property="og:type" content={og.type} />}
94
+ {og.siteName && <meta property="og:site_name" content={og.siteName} />}
95
+ {/* Twitter Card */}
96
+ {tw.card && <meta name="twitter:card" content={tw.card} />}
97
+ {tw.title && <meta name="twitter:title" content={tw.title} />}
98
+ {tw.description && <meta name="twitter:description" content={tw.description} />}
99
+ {tw.image && <meta name="twitter:image" content={tw.image} />}
100
+ {tw.creator && <meta name="twitter:creator" content={tw.creator} />}
101
+ {tw.site && <meta name="twitter:site" content={tw.site} />}
102
+ {/* Custom meta tags */}
103
+ {meta.other && Object.entries(meta.other).map(([name, content]: [string, any]) => (
104
+ <meta key={name} name={name} content={content} />
105
+ ))}
61
106
  <style dangerouslySetInnerHTML={{ __html: css }} />
62
107
  </head>
63
108
  <body>
@@ -66,7 +111,7 @@ export function DefaultDocument({ children, initialProps }: any) {
66
111
  id="ssr-props"
67
112
  type="application/json"
68
113
  dangerouslySetInnerHTML={{
69
- __html: JSON.stringify(initialProps).replace(/</g, '\\u003c')
114
+ __html: sanitizeForHydration(initialProps)
70
115
  }}
71
116
  />
72
117
  <script src={BUNDLE_SRC} defer></script>
@@ -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
  );
@@ -17,6 +17,9 @@ import React, {
17
17
  } from "react";
18
18
  import { matchRoute, type RouteDef } from "./router";
19
19
  import { BlumenErrorBoundary } from "./ErrorBoundary";
20
+ import { applyMetadata } from "./BlumenHead";
21
+ import { consumeCached } from "./prefetchCache";
22
+ import DefaultLoading from "./DefaultLoading";
20
23
 
21
24
  // ── Context types ──────────────────────────────────────────────
22
25
 
@@ -74,6 +77,8 @@ export function RouterProvider({
74
77
  () => window.location.pathname,
75
78
  );
76
79
  const [transitioning, setTransitioning] = useState(false);
80
+ const [isLoading, setIsLoading] = useState(false);
81
+ const [loadingComponent, setLoadingComponent] = useState<React.ComponentType<any> | null>(null);
77
82
  const [dynamicProps, setDynamicProps] = useState<Record<string, any>>({});
78
83
 
79
84
  // Track whether we're on the very first render (SSR hydration).
@@ -110,25 +115,48 @@ export function RouterProvider({
110
115
  // Animate out → change route → animate in
111
116
  setTransitioning(true);
112
117
 
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
- });
118
+ // Check prefetch cache first (populated by <Link> on hover)
119
+ const cached = consumeCached(to);
120
+
121
+ // Determine the loading component for the target route
122
+ const targetMatch = matchRoute(to, routes);
123
+ const targetLoading = targetMatch?.loading || null;
124
+
125
+ // If we don't have cached data and route has a loading component, show it
126
+ if (!cached && targetLoading) {
127
+ setLoadingComponent(() => targetLoading);
128
+ setIsLoading(true);
129
+ }
130
+
131
+ // Fetch dynamic data from Go server (or use cached data)
132
+ const fetchPromise = cached
133
+ ? Promise.resolve(cached)
134
+ : fetch(to, {
135
+ headers: { "X-Blumen-Data": "1" }
136
+ }).then(res => res.ok ? res.json() : {}).catch(err => {
137
+ console.error("Failed to fetch route data", err);
138
+ return {};
139
+ });
120
140
 
121
141
  // Allow the exit transition to play (150ms)
122
142
  const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
123
143
 
124
- Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
144
+ Promise.all([fetchPromise, delayPromise]).then(([newData]: any[]) => {
125
145
  setDynamicProps(newData);
146
+
147
+ // Update <head> metadata for the new page
148
+ if (newData?.metadata) {
149
+ applyMetadata(newData.metadata);
150
+ }
151
+
126
152
  window.history.pushState(null, "", to);
127
153
  setPath(to);
128
154
  window.scrollTo(0, 0);
129
155
 
130
- // Small delay for the enter transition
156
+ // Clear loading state and transition
131
157
  requestAnimationFrame(() => {
158
+ setIsLoading(false);
159
+ setLoadingComponent(null);
132
160
  setTransitioning(false);
133
161
  });
134
162
  });
@@ -142,17 +170,38 @@ export function RouterProvider({
142
170
  setTransitioning(true);
143
171
  const to = window.location.pathname;
144
172
 
145
- const fetchPromise = fetch(to, {
146
- headers: { "X-Blumen-Data": "1" }
147
- }).then(res => res.ok ? res.json() : {}).catch(() => ({}));
173
+ // Check prefetch cache for back/forward navigation
174
+ const cached = consumeCached(to);
175
+
176
+ // Show loading component if available
177
+ if (!cached) {
178
+ const targetMatch = matchRoute(to, routes);
179
+ if (targetMatch?.loading) {
180
+ setLoadingComponent(() => targetMatch.loading!);
181
+ setIsLoading(true);
182
+ }
183
+ }
184
+
185
+ const fetchPromise = cached
186
+ ? Promise.resolve(cached)
187
+ : fetch(to, {
188
+ headers: { "X-Blumen-Data": "1" }
189
+ }).then(res => res.ok ? res.json() : {}).catch(() => ({}));
148
190
 
149
191
  const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
150
192
 
151
- Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
193
+ Promise.all([fetchPromise, delayPromise]).then(([newData]: any[]) => {
152
194
  setDynamicProps(newData);
153
195
  setPath(to);
196
+
197
+ // Update <head> metadata for the new page
198
+ if (newData?.metadata) {
199
+ applyMetadata(newData.metadata);
200
+ }
154
201
 
155
202
  requestAnimationFrame(() => {
203
+ setIsLoading(false);
204
+ setLoadingComponent(null);
156
205
  setTransitioning(false);
157
206
  });
158
207
  });
@@ -165,15 +214,26 @@ export function RouterProvider({
165
214
  // ── Render ──────────────────────────────────────────────────
166
215
  const contextValue: RouterContextValue = { path, params, navigate };
167
216
 
217
+ // Determine what to render:
218
+ // 1. If loading — show the loading component
219
+ // 2. Otherwise — show the page component
220
+ const LoadingComp = loadingComponent;
221
+
168
222
  return (
169
223
  <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>
224
+ {isLoading && LoadingComp ? (
225
+ <div className="page-transition page-transition-active">
226
+ <LoadingComp />
227
+ </div>
228
+ ) : (
229
+ <div
230
+ className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
231
+ >
232
+ <BlumenErrorBoundary>
233
+ <App Component={PageComponent} pageProps={pageProps} />
234
+ </BlumenErrorBoundary>
235
+ </div>
236
+ )}
177
237
  </RouterContext.Provider>
178
238
  );
179
239
  }
@@ -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;