arcanajs 2.5.3 → 2.6.0
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/framework/cli/templates.js +2 -0
- package/framework/lib/shared/components/Link.js +9 -6
- package/framework/lib/shared/components/Page.d.ts +3 -3
- package/framework/lib/shared/components/Page.js +1 -1
- package/framework/lib/shared/context/PageContext.js +1 -0
- package/framework/lib/shared/context/RouterContext.d.ts +1 -0
- package/framework/lib/shared/core/ArcanaJSApp.d.ts +11 -5
- package/framework/lib/shared/core/ArcanaJSApp.js +72 -31
- package/framework/templates/package.json +1 -1
- package/framework/templates/src/server/controllers/HomeController.ts +13 -1
- package/framework/templates/src/server/index.ts +7 -4
- package/framework/templates/src/types/HomePageData.ts +11 -0
- package/framework/templates/src/views/HomePage.tsx +26 -1
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ exports.configFiles = [
|
|
|
9
9
|
{ src: "postcss.config.js", dest: "postcss.config.js" },
|
|
10
10
|
// types
|
|
11
11
|
{ src: "src/arcanajs.d.ts", dest: "src/arcanajs.d.ts" },
|
|
12
|
+
{ src: "src/types/HomePageData.ts", dest: "src/types/HomePageData.ts" },
|
|
12
13
|
// client
|
|
13
14
|
{ src: "src/client/globals.css", dest: "src/client/globals.css" },
|
|
14
15
|
{ src: "src/client/index.tsx", dest: "src/client/index.tsx" },
|
|
@@ -54,6 +55,7 @@ exports.requiredDirs = [
|
|
|
54
55
|
"src/client",
|
|
55
56
|
"src/db",
|
|
56
57
|
"src/server",
|
|
58
|
+
"src/types",
|
|
57
59
|
"src/server/routes",
|
|
58
60
|
"src/server/controllers",
|
|
59
61
|
"src/views",
|
|
@@ -4,17 +4,20 @@ exports.Link = void 0;
|
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
5
|
const useRouter_1 = require("../hooks/useRouter");
|
|
6
6
|
const Link = ({ href, children, prefetch = false, ...props }) => {
|
|
7
|
-
const { navigateTo } = (0, useRouter_1.useRouter)();
|
|
7
|
+
const { navigateTo, navigateToAsync } = (0, useRouter_1.useRouter)();
|
|
8
8
|
const isExternal = /^https?:\/\//.test(href);
|
|
9
|
-
const handleClick = (e) => {
|
|
9
|
+
const handleClick = async (e) => {
|
|
10
10
|
e.preventDefault();
|
|
11
|
-
if (
|
|
12
|
-
navigateTo(href);
|
|
13
|
-
}
|
|
14
|
-
else {
|
|
11
|
+
if (isExternal) {
|
|
15
12
|
// Open external links in a new tab
|
|
16
13
|
window.open(href, "_blank", "noopener,noreferrer");
|
|
17
14
|
}
|
|
15
|
+
else if (navigateToAsync) {
|
|
16
|
+
await navigateToAsync(href);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
navigateTo(href);
|
|
20
|
+
}
|
|
18
21
|
};
|
|
19
22
|
const handleMouseEnter = () => {
|
|
20
23
|
if (prefetch && !isExternal) {
|
|
@@ -4,7 +4,7 @@ exports.Page = void 0;
|
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
5
|
const PageContext_1 = require("../context/PageContext");
|
|
6
6
|
const Head_1 = require("./Head");
|
|
7
|
-
const Page = ({ data, title, children }) => {
|
|
7
|
+
const Page = ({ data, title, children, }) => {
|
|
8
8
|
return ((0, jsx_runtime_1.jsxs)(PageContext_1.PageContext.Provider, { value: data, children: [title && ((0, jsx_runtime_1.jsx)(Head_1.Head, { children: (0, jsx_runtime_1.jsx)("title", { children: title }) })), children] }));
|
|
9
9
|
};
|
|
10
10
|
exports.Page = Page;
|
|
@@ -2,4 +2,5 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PageContext = void 0;
|
|
4
4
|
const createSingletonContext_1 = require("../utils/createSingletonContext");
|
|
5
|
+
// PageContext holds the page data (may be null before hydration).
|
|
5
6
|
exports.PageContext = (0, createSingletonContext_1.createSingletonContext)("PageContext", null);
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
export interface ArcanaJSAppProps {
|
|
2
|
+
export interface ArcanaJSAppProps<TData = any, TParams extends Record<string, string> = Record<string, string>> {
|
|
3
3
|
initialPage: string;
|
|
4
|
-
initialData:
|
|
5
|
-
initialParams?:
|
|
4
|
+
initialData: TData;
|
|
5
|
+
initialParams?: TParams;
|
|
6
6
|
initialUrl?: string;
|
|
7
7
|
csrfToken?: string;
|
|
8
|
-
views: Record<string, React.
|
|
8
|
+
views: Record<string, React.ComponentType<{
|
|
9
|
+
data: TData;
|
|
10
|
+
navigateTo: (url: string) => Promise<void>;
|
|
11
|
+
params: TParams;
|
|
12
|
+
}>>;
|
|
9
13
|
layout?: React.FC<{
|
|
10
14
|
children: React.ReactNode;
|
|
11
15
|
}>;
|
|
12
16
|
onNavigate?: (url: string) => void;
|
|
17
|
+
/** Maximum number of entries to keep in the navigation cache */
|
|
18
|
+
cacheLimit?: number;
|
|
13
19
|
}
|
|
14
|
-
export declare const ArcanaJSApp:
|
|
20
|
+
export declare const ArcanaJSApp: <TData = any, TParams extends Record<string, string> = Record<string, string>>(props: ArcanaJSAppProps<TData, TParams>) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -38,15 +38,18 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
38
38
|
const react_1 = __importStar(require("react"));
|
|
39
39
|
const Page_1 = require("../components/Page");
|
|
40
40
|
const RouterContext_1 = require("../context/RouterContext");
|
|
41
|
-
const ArcanaJSApp = (
|
|
41
|
+
const ArcanaJSApp = (props) => {
|
|
42
|
+
const { initialPage, initialData, initialParams = {}, initialUrl, csrfToken, views, layout: Layout, onNavigate, cacheLimit = 50, } = props;
|
|
42
43
|
const [page, setPage] = (0, react_1.useState)(initialPage);
|
|
43
44
|
const [data, setData] = (0, react_1.useState)(initialData);
|
|
44
45
|
const [params, setParams] = (0, react_1.useState)(initialParams);
|
|
45
46
|
const [url, setUrl] = (0, react_1.useState)(initialUrl ||
|
|
46
47
|
(typeof window !== "undefined" ? window.location.pathname : "/"));
|
|
47
48
|
const [isNavigating, setIsNavigating] = (0, react_1.useState)(false);
|
|
48
|
-
// Navigation cache to store previously visited pages
|
|
49
|
+
// Navigation cache to store previously visited pages (LRU via Map ordering)
|
|
49
50
|
const navigationCache = react_1.default.useRef(new Map());
|
|
51
|
+
// Abort controller for in-flight navigation fetch
|
|
52
|
+
const currentAbort = react_1.default.useRef(null);
|
|
50
53
|
(0, react_1.useEffect)(() => {
|
|
51
54
|
if (typeof window !== "undefined" && !window.history.state) {
|
|
52
55
|
window.history.replaceState({ page: initialPage, data: initialData, params: initialParams }, "", window.location.href);
|
|
@@ -59,36 +62,62 @@ const ArcanaJSApp = ({ initialPage, initialData, initialParams = {}, initialUrl,
|
|
|
59
62
|
setUrl(window.location.pathname);
|
|
60
63
|
}
|
|
61
64
|
else {
|
|
62
|
-
|
|
65
|
+
// Try to fetch the page state instead of hard reload
|
|
66
|
+
const path = window.location.pathname;
|
|
67
|
+
void navigateTo(path).catch(() => {
|
|
68
|
+
window.location.reload();
|
|
69
|
+
});
|
|
63
70
|
}
|
|
64
71
|
};
|
|
65
72
|
window.addEventListener("popstate", handlePopState);
|
|
66
|
-
return () =>
|
|
73
|
+
return () => {
|
|
74
|
+
window.removeEventListener("popstate", handlePopState);
|
|
75
|
+
currentAbort.current?.abort();
|
|
76
|
+
};
|
|
67
77
|
}, []);
|
|
78
|
+
const setCache = (key, value) => {
|
|
79
|
+
const map = navigationCache.current;
|
|
80
|
+
if (map.has(key))
|
|
81
|
+
map.delete(key);
|
|
82
|
+
map.set(key, value);
|
|
83
|
+
if (map.size > cacheLimit) {
|
|
84
|
+
const firstKey = map.keys().next().value;
|
|
85
|
+
if (firstKey !== undefined)
|
|
86
|
+
map.delete(firstKey);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
68
89
|
const navigateTo = async (newUrl) => {
|
|
69
90
|
// Check cache first for instant navigation
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
const map = navigationCache.current;
|
|
92
|
+
if (map.has(newUrl)) {
|
|
93
|
+
const cached = map.get(newUrl);
|
|
72
94
|
setPage(cached.page);
|
|
73
95
|
setData(cached.data);
|
|
74
96
|
setParams(cached.params || {});
|
|
75
97
|
setUrl(newUrl);
|
|
76
98
|
window.history.pushState(cached, "", newUrl);
|
|
77
|
-
// Scroll to top
|
|
78
99
|
if (typeof window !== "undefined") {
|
|
79
|
-
|
|
100
|
+
try {
|
|
101
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// ignore in non-browser env or when smooth not supported
|
|
105
|
+
}
|
|
80
106
|
}
|
|
81
|
-
if (onNavigate)
|
|
107
|
+
if (onNavigate)
|
|
82
108
|
onNavigate(newUrl);
|
|
83
|
-
}
|
|
84
109
|
return;
|
|
85
110
|
}
|
|
86
111
|
setIsNavigating(true);
|
|
112
|
+
// Abort previous request
|
|
113
|
+
currentAbort.current?.abort();
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
currentAbort.current = controller;
|
|
87
116
|
try {
|
|
88
117
|
const response = await fetch(newUrl, {
|
|
89
118
|
headers: { "X-ArcanaJS-Request": "true" },
|
|
90
|
-
// prevent caching in dev navigation
|
|
91
119
|
cache: "no-store",
|
|
120
|
+
signal: controller.signal,
|
|
92
121
|
});
|
|
93
122
|
if (!response.ok) {
|
|
94
123
|
if (response.status === 404) {
|
|
@@ -99,49 +128,61 @@ const ArcanaJSApp = ({ initialPage, initialData, initialParams = {}, initialUrl,
|
|
|
99
128
|
}
|
|
100
129
|
throw new Error(`Navigation failed: ${response.status} ${response.statusText}`);
|
|
101
130
|
}
|
|
102
|
-
// Ensure server returned JSON. If not, fallback to full navigation reload
|
|
103
131
|
const contentType = response.headers.get("content-type") || "";
|
|
104
132
|
if (!contentType.includes("application/json")) {
|
|
105
|
-
// The server returned HTML (or something else) instead of JSON.
|
|
106
|
-
// Do a full reload so the browser displays the correct page instead
|
|
107
|
-
// of trying to parse HTML as JSON (which causes the SyntaxError).
|
|
108
133
|
window.location.href = newUrl;
|
|
109
134
|
return;
|
|
110
135
|
}
|
|
111
136
|
const json = await response.json();
|
|
112
|
-
|
|
113
|
-
navigationCache.current.set(newUrl, {
|
|
137
|
+
const payload = {
|
|
114
138
|
page: json.page,
|
|
115
139
|
data: json.data,
|
|
116
|
-
params: json.params,
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
params: (json.params || {}),
|
|
141
|
+
};
|
|
142
|
+
setCache(newUrl, payload);
|
|
143
|
+
window.history.pushState({ page: payload.page, data: payload.data, params: payload.params }, "", newUrl);
|
|
144
|
+
setPage(payload.page);
|
|
145
|
+
setData(payload.data);
|
|
146
|
+
setParams(payload.params || {});
|
|
122
147
|
setUrl(newUrl);
|
|
123
|
-
// Scroll to top after navigation
|
|
124
148
|
if (typeof window !== "undefined") {
|
|
125
|
-
|
|
149
|
+
try {
|
|
150
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// ignore
|
|
154
|
+
}
|
|
126
155
|
}
|
|
127
|
-
if (onNavigate)
|
|
156
|
+
if (onNavigate)
|
|
128
157
|
onNavigate(newUrl);
|
|
129
|
-
}
|
|
130
158
|
}
|
|
131
|
-
catch (
|
|
132
|
-
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (err?.name === "AbortError")
|
|
161
|
+
return;
|
|
162
|
+
console.error("Navigation failed", err);
|
|
163
|
+
throw err;
|
|
133
164
|
}
|
|
134
165
|
finally {
|
|
166
|
+
// Clear abort controller if it's still the one we set
|
|
167
|
+
if (currentAbort.current === controller)
|
|
168
|
+
currentAbort.current = null;
|
|
135
169
|
setIsNavigating(false);
|
|
136
170
|
}
|
|
137
171
|
};
|
|
138
172
|
const renderPage = () => {
|
|
139
|
-
const Component = views[page] ||
|
|
173
|
+
const Component = (views[page] ||
|
|
174
|
+
views["NotFoundPage"] ||
|
|
175
|
+
(() => (0, jsx_runtime_1.jsx)("div", { children: "404 Not Found" })));
|
|
140
176
|
return ((0, jsx_runtime_1.jsx)(Page_1.Page, { data: data, children: (0, jsx_runtime_1.jsx)(Component, { data: data, navigateTo: navigateTo, params: params }) }));
|
|
141
177
|
};
|
|
142
178
|
const content = renderPage();
|
|
143
179
|
return ((0, jsx_runtime_1.jsx)(RouterContext_1.RouterProvider, { value: {
|
|
144
|
-
|
|
180
|
+
// keep backward-compatible wrapper that doesn't return a promise
|
|
181
|
+
navigateTo: (...args) => {
|
|
182
|
+
void navigateTo(args[0]);
|
|
183
|
+
},
|
|
184
|
+
// new async API consumers can use `navigateToAsync` when available
|
|
185
|
+
navigateToAsync: navigateTo,
|
|
145
186
|
currentPage: page,
|
|
146
187
|
currentUrl: url,
|
|
147
188
|
params,
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { Request, Response } from "arcanajs/server";
|
|
2
|
+
import type { HomePageData } from "../../types/HomePageData";
|
|
2
3
|
|
|
3
4
|
export default class HomeController {
|
|
4
5
|
home(_req: Request, res: Response) {
|
|
5
|
-
|
|
6
|
+
// Provide example page data to demonstrate passing data to the view
|
|
7
|
+
const data: HomePageData = {
|
|
8
|
+
welcome: "Welcome to ArcanaJS",
|
|
9
|
+
subtitle: "A modern React framework with server-side rendering",
|
|
10
|
+
time: new Date().toISOString(),
|
|
11
|
+
posts: [
|
|
12
|
+
{ id: 1, title: "Getting started with ArcanaJS" },
|
|
13
|
+
{ id: 2, title: "Building fast apps with SSR" },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
res.renderPage("HomePage", data);
|
|
6
18
|
}
|
|
7
19
|
}
|
|
@@ -20,10 +20,13 @@ const server = new ArcanaJSServer({
|
|
|
20
20
|
// Example: provide a dbConnect function that returns the DB client/connection.
|
|
21
21
|
// You can connect to MySQL/Postgres/MongoDB here and return the client.
|
|
22
22
|
// dbConnect: async () => {
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
23
|
+
// const { MongoClient } = await import("mongodb");
|
|
24
|
+
// const url = process.env.MONGO_URL || "mongodb://localhost:27017";
|
|
25
|
+
// const dbName = process.env.MONGO_DB || "mydb";
|
|
26
|
+
// const client = new MongoClient(url);
|
|
27
|
+
// await client.connect();
|
|
28
|
+
// const db = client.db(dbName);
|
|
29
|
+
// return { client, db };
|
|
27
30
|
// },
|
|
28
31
|
// Or use one of the provided DB templates (uncomment one):
|
|
29
32
|
// dbConnect: mongoDb,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { Body, Head, Page } from "arcanajs";
|
|
1
|
+
import { Body, Head, Page, usePage } from "arcanajs";
|
|
2
|
+
import type { HomePageData } from "../types/HomePageData";
|
|
2
3
|
|
|
3
4
|
export default function HomePage() {
|
|
5
|
+
const pageData = usePage<HomePageData | null>();
|
|
6
|
+
|
|
4
7
|
return (
|
|
5
8
|
<Page>
|
|
6
9
|
<Head>
|
|
@@ -83,6 +86,28 @@ export default function HomePage() {
|
|
|
83
86
|
Tailwind CSS v4 support. Build fast, beautiful applications
|
|
84
87
|
with zero configuration.
|
|
85
88
|
</p>
|
|
89
|
+
{pageData && (
|
|
90
|
+
<div className="text-sm text-gray-400 mb-6">
|
|
91
|
+
<div className="font-semibold text-white">
|
|
92
|
+
{pageData.welcome ?? "Welcome"}
|
|
93
|
+
</div>
|
|
94
|
+
<div className="text-gray-400 text-sm">
|
|
95
|
+
{pageData.subtitle}
|
|
96
|
+
</div>
|
|
97
|
+
<div className="text-xs text-gray-500 mt-1">
|
|
98
|
+
Server time: {pageData.time}
|
|
99
|
+
</div>
|
|
100
|
+
{Array.isArray(pageData.posts) && (
|
|
101
|
+
<ul className="mt-3 text-left list-disc list-inside text-gray-300">
|
|
102
|
+
{pageData.posts.map((p) => (
|
|
103
|
+
<li key={p.id} className="truncate">
|
|
104
|
+
{p.title}
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
86
111
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
87
112
|
<button className="btn-primary px-8 py-3.5 text-lg font-semibold rounded-xl inline-flex items-center justify-center gap-2">
|
|
88
113
|
Get Started
|
package/package.json
CHANGED