@sigil-dev/grimoire 0.3.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/.grimoire/_routes.dom.js +4 -0
- package/.grimoire/_routes.hydrate.js +4 -0
- package/.grimoire/_routes.ts +4 -0
- package/.grimoire/tsconfig.generated.json +11 -0
- package/.grimoire/types/ambient.d.ts +6 -0
- package/.grimoire/types/api/hello/$types.d.ts +29 -0
- package/README.md +1 -0
- package/index.ts +22 -0
- package/package.json +36 -0
- package/public/__grimoire__/client.js +86 -0
- package/public/__grimoire__/hydrate.js +101 -0
- package/src/client-router.ts +77 -0
- package/src/client.ts +4 -0
- package/src/context.ts +10 -0
- package/src/cookie-utils.ts +66 -0
- package/src/enhance.ts +97 -0
- package/src/error.ts +52 -0
- package/src/fail.ts +41 -0
- package/src/head.ts +27 -0
- package/src/headers.ts +114 -0
- package/src/hooks.ts +93 -0
- package/src/hydrate.ts +22 -0
- package/src/manifest-gen.ts +26 -0
- package/src/plugins.ts +25 -0
- package/src/redirect.ts +35 -0
- package/src/renderer.ts +142 -0
- package/src/router.ts +94 -0
- package/src/scanner.ts +97 -0
- package/src/scope.ts +22 -0
- package/src/server.ts +318 -0
- package/src/ssrPlugin.ts +26 -0
- package/src/sync.ts +18 -0
- package/src/transform-routes.ts +90 -0
- package/src/typegen.ts +263 -0
- package/src/types.ts +85 -0
- package/src/vite-plugin.ts +72 -0
- package/test/context.test.ts +52 -0
- package/test/fail.test.ts +46 -0
- package/test/headers.test.ts +96 -0
- package/test/hydration.test.ts +119 -0
- package/test/middleware.test.ts +217 -0
- package/test/preload.ts +5 -0
- package/test/redirect-error.test.ts +112 -0
- package/test/rendering.test.ts +172 -0
- package/test/routing.test.ts +45 -0
- package/test/scanning.test.ts +55 -0
- package/test/scope.test.ts +164 -0
- package/test/server.test.ts +30 -0
- package/test/streaming.test.ts +132 -0
- package/test/transform-routes.test.ts +84 -0
- package/test/typegen.test.ts +652 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Auto-generated by @sigil-dev/grimoire — do not edit
|
|
2
|
+
|
|
3
|
+
export type Params = {};
|
|
4
|
+
|
|
5
|
+
export type PageData = Record<string, never>;
|
|
6
|
+
export type PageProps = { params: Params };
|
|
7
|
+
export type PageServerLoad = never;
|
|
8
|
+
export type Actions = Record<string, never>;
|
|
9
|
+
|
|
10
|
+
export type LayoutData = Record<string, never>;
|
|
11
|
+
export type LayoutProps = LayoutData & { params: Params; children?: unknown };
|
|
12
|
+
export type LayoutServerLoad = (ctx: {
|
|
13
|
+
params: Params;
|
|
14
|
+
request: Request;
|
|
15
|
+
url: URL;
|
|
16
|
+
locals: App.Locals;
|
|
17
|
+
}) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
18
|
+
|
|
19
|
+
type _SRV = typeof import("C:/Users/Cane1712/AppData/Local/Temp/grimoire-server-1780500535824/api/hello/+server.js");
|
|
20
|
+
type _HttpMethods = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
21
|
+
type _ServerKeys = Extract<keyof _SRV, _HttpMethods>;
|
|
22
|
+
export type ServerHandlers = {
|
|
23
|
+
[K in _ServerKeys]: (ctx: {
|
|
24
|
+
params: Params;
|
|
25
|
+
request: Request;
|
|
26
|
+
url: URL;
|
|
27
|
+
locals: App.Locals;
|
|
28
|
+
}) => Response | Promise<Response>;
|
|
29
|
+
};
|
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Grimoire Implementation
|
package/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type { RouteFile, RouteTree } from "./src/scanner";
|
|
2
|
+
export { scanRoutes } from "./src/scanner";
|
|
3
|
+
export { createServer } from "./src/server";
|
|
4
|
+
export { fail, isFailResult } from "./src/fail";
|
|
5
|
+
export type { FailResult } from "./src/fail";
|
|
6
|
+
export { redirect, isRedirectResult } from "./src/redirect";
|
|
7
|
+
export type { RedirectResult } from "./src/redirect";
|
|
8
|
+
export { error, isErrorResult } from "./src/error";
|
|
9
|
+
export type { ErrorResult } from "./src/error";
|
|
10
|
+
export type { Handle, RequestEvent, Cookies, CookieOptions, ResolveFunction } from "./src/hooks";
|
|
11
|
+
export { sequence, createHooks } from "./src/hooks";
|
|
12
|
+
export type { TypegenConfig } from "./src/typegen";
|
|
13
|
+
export { generateTypes } from "./src/typegen";
|
|
14
|
+
export type {
|
|
15
|
+
GrimoireConfig,
|
|
16
|
+
GrimoirePlugin,
|
|
17
|
+
LoadContext,
|
|
18
|
+
RenderContext,
|
|
19
|
+
RouteInfo,
|
|
20
|
+
TypedLoadContext,
|
|
21
|
+
} from "./src/types";
|
|
22
|
+
export { defineConfig } from "./src/types";
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sigil-dev/grimoire",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"version": "0.3.0",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/bun": "latest"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.ts",
|
|
12
|
+
"./client": "./src/enhance.ts",
|
|
13
|
+
"./headers": "./src/headers.ts",
|
|
14
|
+
"./hooks": "./src/hooks.ts",
|
|
15
|
+
"./vite": "./src/vite-plugin.ts"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"grimoire": "src/sync.ts"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"sync": "bun src/sync.ts",
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@babel/core": "^8.0.0-rc.6",
|
|
26
|
+
"@babel/plugin-syntax-jsx": "^8.0.0-rc.6",
|
|
27
|
+
"@babel/plugin-syntax-typescript": "^8.0.0-rc.6",
|
|
28
|
+
"@babel/types": "^8.0.0-rc.6",
|
|
29
|
+
"@sigil-dev/compiler": "0.3.0",
|
|
30
|
+
"@sigil-dev/runtime": "0.3.0",
|
|
31
|
+
"vite": "^8.0.16"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"typescript": "^5"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// .grimoire/_routes.dom.js
|
|
2
|
+
var routes = {};
|
|
3
|
+
|
|
4
|
+
// src/scope.ts
|
|
5
|
+
function withEffectScope(fn) {
|
|
6
|
+
const prev = globalThis.__sigilEffectScope;
|
|
7
|
+
const disposers = [];
|
|
8
|
+
globalThis.__sigilEffectScope = disposers;
|
|
9
|
+
try {
|
|
10
|
+
fn();
|
|
11
|
+
} finally {
|
|
12
|
+
globalThis.__sigilEffectScope = prev;
|
|
13
|
+
}
|
|
14
|
+
return () => {
|
|
15
|
+
for (const d of disposers)
|
|
16
|
+
d();
|
|
17
|
+
disposers.length = 0;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/client-router.ts
|
|
22
|
+
var routeMap = {};
|
|
23
|
+
var disposeCurrentPage = null;
|
|
24
|
+
async function navigate(path) {
|
|
25
|
+
const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
|
|
26
|
+
const json = await res.json();
|
|
27
|
+
const { data, params, pattern, head } = json;
|
|
28
|
+
const Page = routeMap[pattern];
|
|
29
|
+
if (!Page) {
|
|
30
|
+
window.location.href = path;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
disposeCurrentPage?.();
|
|
34
|
+
disposeCurrentPage = null;
|
|
35
|
+
globalThis.__nodes = [];
|
|
36
|
+
let node;
|
|
37
|
+
disposeCurrentPage = withEffectScope(() => {
|
|
38
|
+
node = Page({ ...data, params });
|
|
39
|
+
});
|
|
40
|
+
const slot = document.getElementById("grimoire-page");
|
|
41
|
+
if (!slot)
|
|
42
|
+
return;
|
|
43
|
+
slot.replaceChildren(node);
|
|
44
|
+
updateHead(head);
|
|
45
|
+
history.pushState({}, "", path);
|
|
46
|
+
currentPath = path;
|
|
47
|
+
window.scrollTo(0, 0);
|
|
48
|
+
}
|
|
49
|
+
var currentPath = location.pathname;
|
|
50
|
+
function initRouter(routes2, initialDispose) {
|
|
51
|
+
routeMap = routes2;
|
|
52
|
+
disposeCurrentPage = initialDispose ?? null;
|
|
53
|
+
document.addEventListener("click", handleClick);
|
|
54
|
+
window.addEventListener("popstate", () => {
|
|
55
|
+
if (location.pathname !== currentPath) {
|
|
56
|
+
currentPath = location.pathname;
|
|
57
|
+
navigate(location.pathname);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function updateHead(headHtml) {
|
|
62
|
+
document.querySelectorAll("[data-grimoire-head]").forEach((el) => el.remove());
|
|
63
|
+
if (!headHtml)
|
|
64
|
+
return;
|
|
65
|
+
const tmp = document.createElement("head");
|
|
66
|
+
tmp.innerHTML = headHtml;
|
|
67
|
+
for (const el of Array.from(tmp.children)) {
|
|
68
|
+
el.dataset.grimoireHead = "1";
|
|
69
|
+
document.head.appendChild(el);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function handleClick(e) {
|
|
73
|
+
const a = e.target.closest("a");
|
|
74
|
+
if (!a)
|
|
75
|
+
return;
|
|
76
|
+
const href = a.getAttribute("href");
|
|
77
|
+
if (!href)
|
|
78
|
+
return;
|
|
79
|
+
if (/^(https?:\/\/|\/\/|#|mailto:|tel:)/.test(href))
|
|
80
|
+
return;
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
navigate(href);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/client.ts
|
|
86
|
+
initRouter(routes);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// .grimoire/_routes.hydrate.js
|
|
2
|
+
var routes = {};
|
|
3
|
+
|
|
4
|
+
// src/scope.ts
|
|
5
|
+
function withEffectScope(fn) {
|
|
6
|
+
const prev = globalThis.__sigilEffectScope;
|
|
7
|
+
const disposers = [];
|
|
8
|
+
globalThis.__sigilEffectScope = disposers;
|
|
9
|
+
try {
|
|
10
|
+
fn();
|
|
11
|
+
} finally {
|
|
12
|
+
globalThis.__sigilEffectScope = prev;
|
|
13
|
+
}
|
|
14
|
+
return () => {
|
|
15
|
+
for (const d of disposers)
|
|
16
|
+
d();
|
|
17
|
+
disposers.length = 0;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/client-router.ts
|
|
22
|
+
var routeMap = {};
|
|
23
|
+
var disposeCurrentPage = null;
|
|
24
|
+
async function navigate(path) {
|
|
25
|
+
const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
|
|
26
|
+
const json = await res.json();
|
|
27
|
+
const { data, params, pattern, head } = json;
|
|
28
|
+
const Page = routeMap[pattern];
|
|
29
|
+
if (!Page) {
|
|
30
|
+
window.location.href = path;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
disposeCurrentPage?.();
|
|
34
|
+
disposeCurrentPage = null;
|
|
35
|
+
globalThis.__nodes = [];
|
|
36
|
+
let node;
|
|
37
|
+
disposeCurrentPage = withEffectScope(() => {
|
|
38
|
+
node = Page({ ...data, params });
|
|
39
|
+
});
|
|
40
|
+
const slot = document.getElementById("grimoire-page");
|
|
41
|
+
if (!slot)
|
|
42
|
+
return;
|
|
43
|
+
slot.replaceChildren(node);
|
|
44
|
+
updateHead(head);
|
|
45
|
+
history.pushState({}, "", path);
|
|
46
|
+
currentPath = path;
|
|
47
|
+
window.scrollTo(0, 0);
|
|
48
|
+
}
|
|
49
|
+
var currentPath = location.pathname;
|
|
50
|
+
function initRouter(routes2, initialDispose) {
|
|
51
|
+
routeMap = routes2;
|
|
52
|
+
disposeCurrentPage = initialDispose ?? null;
|
|
53
|
+
document.addEventListener("click", handleClick);
|
|
54
|
+
window.addEventListener("popstate", () => {
|
|
55
|
+
if (location.pathname !== currentPath) {
|
|
56
|
+
currentPath = location.pathname;
|
|
57
|
+
navigate(location.pathname);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function updateHead(headHtml) {
|
|
62
|
+
document.querySelectorAll("[data-grimoire-head]").forEach((el) => el.remove());
|
|
63
|
+
if (!headHtml)
|
|
64
|
+
return;
|
|
65
|
+
const tmp = document.createElement("head");
|
|
66
|
+
tmp.innerHTML = headHtml;
|
|
67
|
+
for (const el of Array.from(tmp.children)) {
|
|
68
|
+
el.dataset.grimoireHead = "1";
|
|
69
|
+
document.head.appendChild(el);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function handleClick(e) {
|
|
73
|
+
const a = e.target.closest("a");
|
|
74
|
+
if (!a)
|
|
75
|
+
return;
|
|
76
|
+
const href = a.getAttribute("href");
|
|
77
|
+
if (!href)
|
|
78
|
+
return;
|
|
79
|
+
if (/^(https?:\/\/|\/\/|#|mailto:|tel:)/.test(href))
|
|
80
|
+
return;
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
navigate(href);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/hydrate.ts
|
|
86
|
+
var stateEl = document.getElementById("__grimoire_state__");
|
|
87
|
+
var initialDispose;
|
|
88
|
+
if (stateEl) {
|
|
89
|
+
const state = JSON.parse(stateEl.textContent);
|
|
90
|
+
const Page = routes[state.pattern];
|
|
91
|
+
if (Page) {
|
|
92
|
+
const slot = document.getElementById("grimoire-page");
|
|
93
|
+
if (slot) {
|
|
94
|
+
globalThis.__nodes = Array.from(slot.childNodes);
|
|
95
|
+
initialDispose = withEffectScope(() => {
|
|
96
|
+
Page({ ...state.data, params: state.params });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
initRouter(routes, initialDispose);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { withEffectScope } from "./scope.ts";
|
|
2
|
+
|
|
3
|
+
let routeMap: Record<string, (props: any) => any> = {};
|
|
4
|
+
let disposeCurrentPage: (() => void) | null = null;
|
|
5
|
+
|
|
6
|
+
async function navigate(path: string) {
|
|
7
|
+
const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
|
|
8
|
+
const json = await res.json();
|
|
9
|
+
const { data, params, pattern, head } = json;
|
|
10
|
+
|
|
11
|
+
const Page = routeMap[pattern];
|
|
12
|
+
if (!Page) {
|
|
13
|
+
window.location.href = path;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Tear down all effects from the previous page before mounting the next one.
|
|
18
|
+
disposeCurrentPage?.();
|
|
19
|
+
disposeCurrentPage = null;
|
|
20
|
+
|
|
21
|
+
// SPA navigation has no SSR nodes to claim — start with an empty pool so
|
|
22
|
+
// the hydrate-compiled components create fresh DOM instead of recycling stale nodes.
|
|
23
|
+
(globalThis as any).__nodes = [];
|
|
24
|
+
|
|
25
|
+
let node: any;
|
|
26
|
+
disposeCurrentPage = withEffectScope(() => {
|
|
27
|
+
node = Page({ ...data, params });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const slot = document.getElementById("grimoire-page");
|
|
31
|
+
if (!slot) return;
|
|
32
|
+
slot.replaceChildren(node);
|
|
33
|
+
updateHead(head);
|
|
34
|
+
history.pushState({}, "", path);
|
|
35
|
+
currentPath = path;
|
|
36
|
+
window.scrollTo(0, 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let currentPath = location.pathname;
|
|
40
|
+
|
|
41
|
+
export function initRouter(
|
|
42
|
+
routes: Record<string, (props: any) => any>,
|
|
43
|
+
initialDispose?: () => void,
|
|
44
|
+
) {
|
|
45
|
+
routeMap = routes;
|
|
46
|
+
disposeCurrentPage = initialDispose ?? null;
|
|
47
|
+
document.addEventListener("click", handleClick);
|
|
48
|
+
window.addEventListener("popstate", () => {
|
|
49
|
+
if (location.pathname !== currentPath) {
|
|
50
|
+
currentPath = location.pathname;
|
|
51
|
+
navigate(location.pathname);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function updateHead(headHtml: string) {
|
|
57
|
+
document
|
|
58
|
+
.querySelectorAll("[data-grimoire-head]")
|
|
59
|
+
.forEach((el) => el.remove());
|
|
60
|
+
if (!headHtml) return;
|
|
61
|
+
const tmp = document.createElement("head");
|
|
62
|
+
tmp.innerHTML = headHtml;
|
|
63
|
+
for (const el of Array.from(tmp.children)) {
|
|
64
|
+
(el as HTMLElement).dataset.grimoireHead = "1";
|
|
65
|
+
document.head.appendChild(el);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleClick(e: MouseEvent) {
|
|
70
|
+
const a = (e.target as Element).closest("a");
|
|
71
|
+
if (!a) return;
|
|
72
|
+
const href = a.getAttribute("href");
|
|
73
|
+
if (!href) return;
|
|
74
|
+
if (/^(https?:\/\/|\/\/|#|mailto:|tel:)/.test(href)) return;
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
navigate(href);
|
|
77
|
+
}
|
package/src/client.ts
ADDED
package/src/context.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { __setStoreProvider } from "@sigil-dev/runtime";
|
|
3
|
+
|
|
4
|
+
const storage = new AsyncLocalStorage<Map<symbol, unknown>>();
|
|
5
|
+
|
|
6
|
+
__setStoreProvider(() => storage.getStore() ?? new Map());
|
|
7
|
+
|
|
8
|
+
export function runWithContext<T>(fn: () => Promise<T>): Promise<T> {
|
|
9
|
+
return storage.run(new Map(), fn);
|
|
10
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse cookies from a Cookie header string.
|
|
3
|
+
*/
|
|
4
|
+
export function parseCookies(cookieHeader: string): Map<string, string> {
|
|
5
|
+
const map = new Map<string, string>();
|
|
6
|
+
if (!cookieHeader) return map;
|
|
7
|
+
for (const pair of cookieHeader.split(";")) {
|
|
8
|
+
const [name, ...rest] = pair.split("=");
|
|
9
|
+
if (name) map.set(name.trim(), rest.join("=").trim());
|
|
10
|
+
}
|
|
11
|
+
return map;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a Set-Cookie header from name, value, and options.
|
|
16
|
+
*/
|
|
17
|
+
export function serializeCookie(
|
|
18
|
+
name: string,
|
|
19
|
+
value: string,
|
|
20
|
+
options?: {
|
|
21
|
+
path?: string;
|
|
22
|
+
domain?: string;
|
|
23
|
+
maxAge?: number;
|
|
24
|
+
expires?: Date;
|
|
25
|
+
httpOnly?: boolean;
|
|
26
|
+
secure?: boolean;
|
|
27
|
+
sameSite?: "strict" | "lax" | "none";
|
|
28
|
+
},
|
|
29
|
+
): string {
|
|
30
|
+
let cookie = `${name}=${encodeURIComponent(value)}`;
|
|
31
|
+
if (options?.path) cookie += `; Path=${options.path}`;
|
|
32
|
+
if (options?.domain) cookie += `; Domain=${options.domain}`;
|
|
33
|
+
if (options?.maxAge != null) cookie += `; Max-Age=${options.maxAge}`;
|
|
34
|
+
if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
|
|
35
|
+
if (options?.httpOnly) cookie += "; HttpOnly";
|
|
36
|
+
if (options?.secure) cookie += "; Secure";
|
|
37
|
+
if (options?.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
|
38
|
+
return cookie;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a Cookies helper from a Cookie header string.
|
|
43
|
+
*/
|
|
44
|
+
export function createCookies(
|
|
45
|
+
cookieHeader: string,
|
|
46
|
+
): import("./hooks").Cookies & { toHeaders(): string[] } {
|
|
47
|
+
const store = parseCookies(cookieHeader);
|
|
48
|
+
const pending: string[] = [];
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
get(name: string) {
|
|
52
|
+
return store.get(name);
|
|
53
|
+
},
|
|
54
|
+
set(name: string, value: string, options?: any) {
|
|
55
|
+
store.set(name, value);
|
|
56
|
+
pending.push(serializeCookie(name, value, options));
|
|
57
|
+
},
|
|
58
|
+
delete(name: string) {
|
|
59
|
+
store.delete(name);
|
|
60
|
+
pending.push(serializeCookie(name, "", { maxAge: 0 }));
|
|
61
|
+
},
|
|
62
|
+
toHeaders() {
|
|
63
|
+
return pending;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/enhance.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side form action handler.
|
|
3
|
+
* Wraps form submission with fetch, handles fail() responses, and updates state.
|
|
4
|
+
*
|
|
5
|
+
* Usage in +page.tsx:
|
|
6
|
+
*
|
|
7
|
+
* import { enhance } from "@sigil-dev/grimoire/client";
|
|
8
|
+
* import { createSignal } from "@sigil-dev/runtime";
|
|
9
|
+
*
|
|
10
|
+
* const [errors, setErrors] = createSignal({});
|
|
11
|
+
*
|
|
12
|
+
* <form use:enhance={{ action: "/api/login", onSuccess: () => { ... }, onFail: (data) => setErrors(data) }}>
|
|
13
|
+
* ...
|
|
14
|
+
* </form>
|
|
15
|
+
*/
|
|
16
|
+
export interface EnhanceOptions {
|
|
17
|
+
/** URL to submit to (defaults to form's action) */
|
|
18
|
+
action?: string;
|
|
19
|
+
/** Called on successful submission (303 redirect) */
|
|
20
|
+
onSuccess?: (result: { redirect: string }) => void;
|
|
21
|
+
/** Called when server returns fail() with validation data */
|
|
22
|
+
onFail?: (data: Record<string, unknown>) => void;
|
|
23
|
+
/** Called on network or unexpected errors */
|
|
24
|
+
onError?: (error: Error) => void;
|
|
25
|
+
/** Whether to reset the form on success (default: true) */
|
|
26
|
+
reset?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* use:enhance directive for progressive form enhancement.
|
|
31
|
+
* Attaches to a <form> element and intercepts submit events.
|
|
32
|
+
*
|
|
33
|
+
* Returns a cleanup function.
|
|
34
|
+
*/
|
|
35
|
+
export function enhance(
|
|
36
|
+
form: HTMLFormElement,
|
|
37
|
+
options: EnhanceOptions = {},
|
|
38
|
+
): () => void {
|
|
39
|
+
const { action, onSuccess, onFail, onError, reset = true } = options;
|
|
40
|
+
|
|
41
|
+
async function handleSubmit(e: Event) {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
|
|
44
|
+
const formData = new FormData(form);
|
|
45
|
+
const url = action ?? form.getAttribute("action") ?? window.location.pathname;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"X-Grimoire-Form": "1",
|
|
52
|
+
},
|
|
53
|
+
body: formData,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
57
|
+
|
|
58
|
+
if (contentType.includes("application/json")) {
|
|
59
|
+
const json = await res.json();
|
|
60
|
+
|
|
61
|
+
if (json.fail) {
|
|
62
|
+
// fail() response — validation errors
|
|
63
|
+
onFail?.(json.data);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Regular JSON response (API route)
|
|
68
|
+
onSuccess?.(json);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Non-JSON response (303 redirect or other)
|
|
73
|
+
if (res.status === 303) {
|
|
74
|
+
const location = res.headers.get("location") ?? "/";
|
|
75
|
+
if (reset) form.reset();
|
|
76
|
+
onSuccess?.({ redirect: location });
|
|
77
|
+
// Navigate to the redirect target
|
|
78
|
+
window.history.pushState({}, "", location);
|
|
79
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Other response
|
|
84
|
+
if (res.ok) {
|
|
85
|
+
if (reset) form.reset();
|
|
86
|
+
onSuccess?.({ redirect: window.location.pathname });
|
|
87
|
+
} else {
|
|
88
|
+
onError?.(new Error(`Form submission failed: ${res.status}`));
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
form.addEventListener("submit", handleSubmit);
|
|
96
|
+
return () => form.removeEventListener("submit", handleSubmit);
|
|
97
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throw an error from a load function or form action.
|
|
3
|
+
* The server catches it and returns an error response.
|
|
4
|
+
*
|
|
5
|
+
* Usage in +page.server.ts:
|
|
6
|
+
*
|
|
7
|
+
* import { error } from "@sigil-dev/grimoire";
|
|
8
|
+
*
|
|
9
|
+
* export async function load({ params }) {
|
|
10
|
+
* const item = await db.find(params.id);
|
|
11
|
+
* if (!item) throw error(404, "Not found");
|
|
12
|
+
* return { item };
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* export async function POST({ request }) {
|
|
16
|
+
* const session = await getSession(request);
|
|
17
|
+
* if (!session) throw error(401, "Unauthorized");
|
|
18
|
+
* // ...
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* The server:
|
|
22
|
+
* - Returns JSON { error: true, status, message } for API requests
|
|
23
|
+
* - Renders +error.tsx page if one exists for the route
|
|
24
|
+
* - Falls back to a plain text error response
|
|
25
|
+
*/
|
|
26
|
+
export interface ErrorResult {
|
|
27
|
+
__error: true;
|
|
28
|
+
status: number;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function error(status: number, message: string): never;
|
|
33
|
+
export function error(status: number, data: Record<string, unknown>): never;
|
|
34
|
+
export function error(
|
|
35
|
+
status: number,
|
|
36
|
+
messageOrData: string | Record<string, unknown>,
|
|
37
|
+
): never {
|
|
38
|
+
const message =
|
|
39
|
+
typeof messageOrData === "string"
|
|
40
|
+
? messageOrData
|
|
41
|
+
: JSON.stringify(messageOrData);
|
|
42
|
+
|
|
43
|
+
throw { __error: true, status, message } as ErrorResult;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isErrorResult(value: unknown): value is ErrorResult {
|
|
47
|
+
return (
|
|
48
|
+
typeof value === "object" &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
(value as any).__error === true
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/fail.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return validation errors from a server form action.
|
|
3
|
+
* Instead of redirecting, the server sends a JSON response with the error data.
|
|
4
|
+
*
|
|
5
|
+
* Usage in +page.server.ts:
|
|
6
|
+
*
|
|
7
|
+
* import { fail } from "@sigil-dev/grimoire";
|
|
8
|
+
*
|
|
9
|
+
* export async function POST({ request }) {
|
|
10
|
+
* const form = await request.formData();
|
|
11
|
+
* const name = form.get("name")?.toString() ?? "";
|
|
12
|
+
*
|
|
13
|
+
* if (!name) {
|
|
14
|
+
* return fail(400, { name, errors: { name: "Name is required" } });
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* // success — redirect
|
|
18
|
+
* return { redirect: "/dashboard" };
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
export function fail<T extends Record<string, unknown>>(
|
|
22
|
+
status: number,
|
|
23
|
+
data: T,
|
|
24
|
+
): FailResult<T> {
|
|
25
|
+
return { __fail: true as const, status, data };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FailResult<T = Record<string, unknown>> {
|
|
29
|
+
__fail: true;
|
|
30
|
+
status: number;
|
|
31
|
+
data: T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isFailResult(value: unknown): value is FailResult {
|
|
35
|
+
return (
|
|
36
|
+
typeof value === "object" &&
|
|
37
|
+
value !== null &&
|
|
38
|
+
"__fail" in value &&
|
|
39
|
+
(value as any).__fail === true
|
|
40
|
+
);
|
|
41
|
+
}
|