bosia 0.2.2 → 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/README.md +39 -39
- package/package.json +56 -53
- package/src/ambient.d.ts +31 -0
- package/src/cli/add.ts +120 -114
- package/src/cli/build.ts +10 -10
- package/src/cli/create.ts +142 -137
- package/src/cli/dev.ts +8 -8
- package/src/cli/feat.ts +291 -132
- package/src/cli/index.ts +51 -42
- package/src/cli/registry.ts +136 -115
- package/src/cli/start.ts +17 -17
- package/src/cli/test.ts +25 -0
- package/src/core/build.ts +72 -56
- package/src/core/client/App.svelte +177 -153
- package/src/core/client/appState.svelte.ts +57 -0
- package/src/core/client/enhance.ts +112 -0
- package/src/core/client/hydrate.ts +97 -65
- package/src/core/client/prefetch.ts +101 -94
- package/src/core/client/router.svelte.ts +64 -51
- package/src/core/cookies.ts +70 -66
- package/src/core/cors.ts +44 -35
- package/src/core/csrf.ts +38 -38
- package/src/core/dedup.ts +17 -17
- package/src/core/dev.ts +165 -168
- package/src/core/env.ts +155 -128
- package/src/core/envCodegen.ts +73 -73
- package/src/core/errors.ts +48 -49
- package/src/core/hooks.ts +50 -50
- package/src/core/html.ts +192 -139
- package/src/core/matcher.ts +130 -121
- package/src/core/paths.ts +8 -10
- package/src/core/plugin.ts +113 -107
- package/src/core/prerender.ts +191 -118
- package/src/core/renderer.ts +359 -265
- package/src/core/routeFile.ts +140 -127
- package/src/core/routeTypes.ts +144 -83
- package/src/core/scanner.ts +125 -95
- package/src/core/server.ts +543 -370
- package/src/core/types.ts +25 -20
- package/src/lib/client.ts +12 -0
- package/src/lib/index.ts +8 -8
- package/src/lib/utils.ts +44 -30
- package/templates/default/.prettierignore +5 -0
- package/templates/default/.prettierrc.json +9 -0
- package/templates/default/README.md +5 -5
- package/templates/default/package.json +22 -18
- package/templates/default/src/app.css +80 -80
- package/templates/default/src/app.d.ts +3 -3
- package/templates/default/src/routes/+error.svelte +7 -10
- package/templates/default/src/routes/+layout.svelte +2 -2
- package/templates/default/src/routes/+page.svelte +31 -29
- package/templates/default/src/routes/about/+page.svelte +3 -3
- package/templates/default/tsconfig.json +20 -20
- package/templates/demo/.prettierignore +5 -0
- package/templates/demo/.prettierrc.json +9 -0
- package/templates/demo/README.md +9 -9
- package/templates/demo/package.json +22 -17
- package/templates/demo/src/app.css +80 -80
- package/templates/demo/src/app.d.ts +3 -3
- package/templates/demo/src/hooks.server.ts +9 -9
- package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
- package/templates/demo/src/routes/(public)/+page.svelte +96 -67
- package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
- package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
- package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
- package/templates/demo/src/routes/+error.svelte +10 -7
- package/templates/demo/src/routes/+layout.server.ts +4 -4
- package/templates/demo/src/routes/+layout.svelte +2 -2
- package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
- package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
- package/templates/demo/src/routes/api/hello/+server.ts +25 -25
- package/templates/demo/tsconfig.json +20 -20
- package/templates/todo/.prettierignore +5 -0
- package/templates/todo/.prettierrc.json +9 -0
- package/templates/todo/README.md +9 -9
- package/templates/todo/package.json +22 -17
- package/templates/todo/src/app.css +80 -80
- package/templates/todo/src/app.d.ts +7 -7
- package/templates/todo/src/hooks.server.ts +9 -9
- package/templates/todo/src/routes/+error.svelte +10 -7
- package/templates/todo/src/routes/+layout.server.ts +4 -4
- package/templates/todo/src/routes/+layout.svelte +2 -2
- package/templates/todo/src/routes/+page.svelte +44 -44
- package/templates/todo/template.json +1 -1
- package/templates/todo/tsconfig.json +20 -20
|
@@ -1,111 +1,125 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
2
|
+
import { router } from "./router.svelte.ts";
|
|
3
|
+
import { findMatch } from "../matcher.ts";
|
|
4
|
+
import { clientRoutes } from "bosia:routes";
|
|
5
|
+
import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
|
|
6
|
+
import { appState } from "./appState.svelte.ts";
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
ssrMode = false,
|
|
10
|
+
ssrPageComponent = null,
|
|
11
|
+
ssrLayoutComponents = [],
|
|
12
|
+
ssrPageData = {},
|
|
13
|
+
ssrLayoutData = [],
|
|
14
|
+
ssrFormData = null,
|
|
15
|
+
}: {
|
|
16
|
+
ssrMode?: boolean;
|
|
17
|
+
ssrPageComponent?: any;
|
|
18
|
+
ssrLayoutComponents?: any[];
|
|
19
|
+
ssrPageData?: Record<string, any>;
|
|
20
|
+
ssrLayoutData?: Record<string, any>[];
|
|
21
|
+
ssrFormData?: any;
|
|
22
|
+
} = $props();
|
|
23
|
+
|
|
24
|
+
let PageComponent = $state<any>(ssrPageComponent);
|
|
25
|
+
let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
|
|
26
|
+
// In SSR mode, render directly from props (server module singletons must
|
|
27
|
+
// not hold per-request state). On the client, read/write through `appState`
|
|
28
|
+
// so `use:enhance` and other helpers can update the same cells.
|
|
29
|
+
const pageData = $derived(ssrMode ? (ssrPageData ?? {}) : appState.pageData);
|
|
30
|
+
const layoutData = $derived(ssrMode ? (ssrLayoutData ?? []) : appState.layoutData);
|
|
31
|
+
const routeParams = $derived(ssrMode ? (ssrPageData?.params ?? {}) : appState.routeParams);
|
|
32
|
+
const formData = $derived(ssrMode ? ssrFormData : appState.form);
|
|
33
|
+
let navigating = $state(false);
|
|
34
|
+
let navDone = $state(false);
|
|
35
|
+
// Skip bar on the very first effect run (initial hydration — data already present)
|
|
36
|
+
let firstNav = true;
|
|
37
|
+
let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
|
|
39
|
+
$effect(() => {
|
|
40
|
+
if (ssrMode) return;
|
|
41
|
+
|
|
42
|
+
const path = router.currentRoute;
|
|
43
|
+
const pathname = path.split("?")[0].split("#")[0];
|
|
44
|
+
const match = findMatch(clientRoutes, pathname);
|
|
45
|
+
if (!match) return;
|
|
46
|
+
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
|
|
49
|
+
const isFirst = firstNav;
|
|
50
|
+
firstNav = false;
|
|
51
|
+
if (isFirst) return; // Initial hydration — data already in SSR props, no fetch needed
|
|
52
|
+
|
|
53
|
+
appState.form = null;
|
|
54
|
+
if (navDoneTimer) {
|
|
55
|
+
clearTimeout(navDoneTimer);
|
|
56
|
+
navDoneTimer = null;
|
|
57
|
+
}
|
|
58
|
+
navDone = false;
|
|
59
|
+
navigating = true;
|
|
60
|
+
|
|
61
|
+
// Load components + data in parallel, then update state atomically
|
|
62
|
+
// to avoid a flash of stale/empty data before the fetch completes.
|
|
63
|
+
const cached = match.route.hasServerData ? consumePrefetch(path) : null;
|
|
64
|
+
prefetchCache.clear(); // clear remaining entries on navigation — matches SvelteKit behavior
|
|
65
|
+
const dataFetch = cached
|
|
66
|
+
? Promise.resolve(cached)
|
|
67
|
+
: match.route.hasServerData
|
|
68
|
+
? fetch(dataUrl(path))
|
|
69
|
+
.then((r) => r.json())
|
|
70
|
+
.catch(() => null)
|
|
71
|
+
: Promise.resolve(null);
|
|
72
|
+
|
|
73
|
+
Promise.all([
|
|
74
|
+
match.route.page(),
|
|
75
|
+
Promise.all(match.route.layouts.map((l: any) => l())),
|
|
76
|
+
dataFetch,
|
|
77
|
+
]).then(([pageMod, layoutMods, result]: [any, any[], any]) => {
|
|
78
|
+
if (cancelled) return;
|
|
79
|
+
navigating = false;
|
|
80
|
+
navDone = true;
|
|
81
|
+
navDoneTimer = setTimeout(() => {
|
|
82
|
+
navDone = false;
|
|
83
|
+
}, 400);
|
|
84
|
+
if (result?.redirect) {
|
|
85
|
+
router.navigate(result.redirect);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (result?.error || (result === null && match.route.hasServerData)) {
|
|
89
|
+
// Data fetch failed (e.g. static hosting with no server) — full page load
|
|
90
|
+
window.location.href = path;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
PageComponent = pageMod.default;
|
|
94
|
+
layoutComponents = layoutMods.map((m: any) => m.default);
|
|
95
|
+
appState.pageData = result?.pageData ?? {};
|
|
96
|
+
appState.layoutData = result?.layoutData ?? [];
|
|
97
|
+
appState.routeParams = result?.pageData?.params ?? match.params;
|
|
98
|
+
|
|
99
|
+
// Scroll to top on forward navigation (not on popstate/back-forward)
|
|
100
|
+
if (router.isPush) window.scrollTo(0, 0);
|
|
101
|
+
|
|
102
|
+
// Update document title and meta description from server metadata
|
|
103
|
+
if (result?.metadata) {
|
|
104
|
+
if (result.metadata.title) document.title = result.metadata.title;
|
|
105
|
+
if (result.metadata.description) {
|
|
106
|
+
let meta = document.querySelector(
|
|
107
|
+
'meta[name="description"]',
|
|
108
|
+
) as HTMLMetaElement | null;
|
|
109
|
+
if (!meta) {
|
|
110
|
+
meta = document.createElement("meta");
|
|
111
|
+
meta.name = "description";
|
|
112
|
+
document.head.appendChild(meta);
|
|
113
|
+
}
|
|
114
|
+
meta.content = result.metadata.description;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
};
|
|
122
|
+
});
|
|
109
123
|
</script>
|
|
110
124
|
|
|
111
125
|
<!--
|
|
@@ -114,62 +128,72 @@
|
|
|
114
128
|
-->
|
|
115
129
|
|
|
116
130
|
{#if navigating}
|
|
117
|
-
|
|
131
|
+
<div class="bosia-bar loading"></div>
|
|
118
132
|
{:else if navDone}
|
|
119
|
-
|
|
133
|
+
<div class="bosia-bar done"></div>
|
|
120
134
|
{/if}
|
|
121
135
|
|
|
122
136
|
{#if layoutComponents.length > 0}
|
|
123
|
-
|
|
137
|
+
{@render renderLayout(0)}
|
|
124
138
|
{:else if PageComponent}
|
|
125
|
-
|
|
139
|
+
<PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
|
|
126
140
|
{:else}
|
|
127
|
-
|
|
141
|
+
<p>Loading...</p>
|
|
128
142
|
{/if}
|
|
129
143
|
|
|
130
144
|
{#snippet renderLayout(index: number)}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
{@const Layout = layoutComponents[index]}
|
|
146
|
+
{@const data = layoutData[index] ?? {}}
|
|
147
|
+
|
|
148
|
+
{#if index < layoutComponents.length - 1}
|
|
149
|
+
<Layout {data}>
|
|
150
|
+
{@render renderLayout(index + 1)}
|
|
151
|
+
</Layout>
|
|
152
|
+
{:else}
|
|
153
|
+
<Layout {data}>
|
|
154
|
+
{#if PageComponent}
|
|
155
|
+
<PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
|
|
156
|
+
{:else}
|
|
157
|
+
<p>Loading...</p>
|
|
158
|
+
{/if}
|
|
159
|
+
</Layout>
|
|
160
|
+
{/if}
|
|
147
161
|
{/snippet}
|
|
148
162
|
|
|
149
163
|
<style>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
164
|
+
.bosia-bar {
|
|
165
|
+
position: fixed;
|
|
166
|
+
top: 0;
|
|
167
|
+
left: 0;
|
|
168
|
+
height: 2px;
|
|
169
|
+
width: 100%;
|
|
170
|
+
background: var(--bosia-loading-color, #f73b27);
|
|
171
|
+
z-index: 9999;
|
|
172
|
+
pointer-events: none;
|
|
173
|
+
transform-origin: left center;
|
|
174
|
+
}
|
|
175
|
+
.bosia-bar.loading {
|
|
176
|
+
animation: bosia-load 8s cubic-bezier(0.02, 0.5, 0.5, 1) forwards;
|
|
177
|
+
}
|
|
178
|
+
.bosia-bar.done {
|
|
179
|
+
animation: bosia-done 0.35s ease forwards;
|
|
180
|
+
}
|
|
181
|
+
@keyframes bosia-load {
|
|
182
|
+
from {
|
|
183
|
+
transform: scaleX(0);
|
|
184
|
+
}
|
|
185
|
+
to {
|
|
186
|
+
transform: scaleX(0.85);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
@keyframes bosia-done {
|
|
190
|
+
from {
|
|
191
|
+
transform: scaleX(1);
|
|
192
|
+
opacity: 1;
|
|
193
|
+
}
|
|
194
|
+
to {
|
|
195
|
+
transform: scaleX(1);
|
|
196
|
+
opacity: 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
175
199
|
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ─── Shared App State ─────────────────────────────────────
|
|
2
|
+
// Singleton holding reactive cells that App.svelte renders from.
|
|
3
|
+
// Lives in a module so client-side helpers (e.g. `use:enhance`)
|
|
4
|
+
// can read and update the same state without going through props.
|
|
5
|
+
//
|
|
6
|
+
// Server-side: never touched. App.svelte's template branches on
|
|
7
|
+
// `ssrMode` and reads from `ssrXxx` props directly during SSR,
|
|
8
|
+
// so concurrent requests don't share these cells.
|
|
9
|
+
|
|
10
|
+
import { dataUrl } from "./prefetch.ts";
|
|
11
|
+
import { router } from "./router.svelte.ts";
|
|
12
|
+
|
|
13
|
+
class AppState {
|
|
14
|
+
pageData = $state<Record<string, any>>({});
|
|
15
|
+
layoutData = $state<Record<string, any>[]>([]);
|
|
16
|
+
routeParams = $state<Record<string, string>>({});
|
|
17
|
+
form = $state<any>(null);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const appState = new AppState();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Re-fetch loader data for the given path and apply to `appState`.
|
|
24
|
+
* Used by `use:enhance` after a successful action — mirrors SvelteKit's
|
|
25
|
+
* `invalidateAll` default. No-op if the fetch fails or returns an error.
|
|
26
|
+
*/
|
|
27
|
+
export async function refreshData(path: string): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(dataUrl(path));
|
|
30
|
+
if (!res.ok) return;
|
|
31
|
+
const result = await res.json();
|
|
32
|
+
if (result?.redirect) {
|
|
33
|
+
router.navigate(result.redirect);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (result?.error) return;
|
|
37
|
+
appState.pageData = result?.pageData ?? {};
|
|
38
|
+
appState.layoutData = result?.layoutData ?? [];
|
|
39
|
+
appState.routeParams = result?.pageData?.params ?? appState.routeParams;
|
|
40
|
+
if (result?.metadata) {
|
|
41
|
+
if (result.metadata.title) document.title = result.metadata.title;
|
|
42
|
+
if (result.metadata.description) {
|
|
43
|
+
let meta = document.querySelector(
|
|
44
|
+
'meta[name="description"]',
|
|
45
|
+
) as HTMLMetaElement | null;
|
|
46
|
+
if (!meta) {
|
|
47
|
+
meta = document.createElement("meta");
|
|
48
|
+
meta.name = "description";
|
|
49
|
+
document.head.appendChild(meta);
|
|
50
|
+
}
|
|
51
|
+
meta.content = result.metadata.description;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// best-effort — silently swallow
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// ─── use:enhance ──────────────────────────────────────────
|
|
2
|
+
// Progressive enhancement for `<form method="POST">`. Intercepts
|
|
3
|
+
// submission, POSTs via fetch with `x-bosia-action: 1`, parses the
|
|
4
|
+
// JSON action result, and updates shared form/page state without a
|
|
5
|
+
// full page reload. Falls back to native form submission when JS is
|
|
6
|
+
// disabled because nothing is wired up until this action runs.
|
|
7
|
+
|
|
8
|
+
import { appState, refreshData } from "./appState.svelte.ts";
|
|
9
|
+
import { router } from "./router.svelte.ts";
|
|
10
|
+
|
|
11
|
+
export type ActionResult =
|
|
12
|
+
| { type: "success"; status: number; data: any }
|
|
13
|
+
| { type: "failure"; status: number; data: any }
|
|
14
|
+
| { type: "redirect"; status: number; location: string }
|
|
15
|
+
| { type: "error"; status: number; message: string };
|
|
16
|
+
|
|
17
|
+
export type SubmitFunction = (input: {
|
|
18
|
+
formData: FormData;
|
|
19
|
+
formElement: HTMLFormElement;
|
|
20
|
+
action: URL;
|
|
21
|
+
cancel: () => void;
|
|
22
|
+
submitter: HTMLElement | null;
|
|
23
|
+
}) =>
|
|
24
|
+
| void
|
|
25
|
+
| ((opts: {
|
|
26
|
+
result: ActionResult;
|
|
27
|
+
formElement: HTMLFormElement;
|
|
28
|
+
update: (opts?: { reset?: boolean; invalidateAll?: boolean }) => Promise<void>;
|
|
29
|
+
}) => void | Promise<void>);
|
|
30
|
+
|
|
31
|
+
async function applyResult(
|
|
32
|
+
result: ActionResult,
|
|
33
|
+
form: HTMLFormElement,
|
|
34
|
+
opts: { reset?: boolean; invalidateAll?: boolean } = {},
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const reset = opts.reset !== false;
|
|
37
|
+
const invalidateAll = opts.invalidateAll !== false;
|
|
38
|
+
|
|
39
|
+
if (result.type === "redirect") {
|
|
40
|
+
router.navigate(result.location);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (result.type === "error") {
|
|
44
|
+
appState.form = { error: { message: result.message, status: result.status } };
|
|
45
|
+
console.warn(`[bosia] action error ${result.status}: ${result.message}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (result.type === "failure") {
|
|
49
|
+
appState.form = result.data;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// success
|
|
53
|
+
appState.form = result.data;
|
|
54
|
+
if (reset) form.reset();
|
|
55
|
+
if (invalidateAll) {
|
|
56
|
+
await refreshData(window.location.pathname + window.location.search);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function enhance(form: HTMLFormElement, submit?: SubmitFunction) {
|
|
61
|
+
async function handleSubmit(event: SubmitEvent) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
|
|
64
|
+
const submitter = (event.submitter as HTMLElement | null) ?? null;
|
|
65
|
+
const formData = new FormData(form, submitter as HTMLElement | undefined);
|
|
66
|
+
|
|
67
|
+
// Resolve action URL — preserve `?/actionName` if the submitter or form sets it
|
|
68
|
+
const actionAttr =
|
|
69
|
+
(submitter as HTMLButtonElement | HTMLInputElement | null)?.formAction ||
|
|
70
|
+
form.action ||
|
|
71
|
+
window.location.href;
|
|
72
|
+
const action = new URL(actionAttr, window.location.href);
|
|
73
|
+
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
const cancel = () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const callback = submit?.({ formData, formElement: form, action, cancel, submitter });
|
|
80
|
+
if (cancelled) return;
|
|
81
|
+
|
|
82
|
+
let result: ActionResult;
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(action, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: formData,
|
|
87
|
+
headers: { "x-bosia-action": "1", accept: "application/json" },
|
|
88
|
+
});
|
|
89
|
+
result = (await res.json()) as ActionResult;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const message = (err as Error)?.message ?? "Network error";
|
|
92
|
+
result = { type: "error", status: 0, message };
|
|
93
|
+
console.warn("[bosia] enhance: submission failed", err);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const update = (opts?: { reset?: boolean; invalidateAll?: boolean }) =>
|
|
97
|
+
applyResult(result, form, opts ?? {});
|
|
98
|
+
|
|
99
|
+
if (typeof callback === "function") {
|
|
100
|
+
await callback({ result, formElement: form, update });
|
|
101
|
+
} else {
|
|
102
|
+
await update();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
form.addEventListener("submit", handleSubmit);
|
|
107
|
+
return {
|
|
108
|
+
destroy() {
|
|
109
|
+
form.removeEventListener("submit", handleSubmit);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|