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
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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);
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -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.
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
<
|
|
60
|
-
<
|
|
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:
|
|
121
|
+
__html: sanitizeForHydration(initialProps)
|
|
70
122
|
}}
|
|
71
123
|
/>
|
|
72
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
171
|
-
className=
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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;
|