@forgebook/sveltekit 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +145 -2
- package/dist/src/app/env.d.ts +8 -0
- package/dist/src/app/env.js +15 -0
- package/dist/src/app/forms.d.ts +68 -0
- package/dist/src/app/forms.js +125 -0
- package/dist/src/app/manifest.d.ts +21 -0
- package/dist/src/app/manifest.js +17 -0
- package/dist/src/app/navigation.d.ts +97 -0
- package/dist/src/app/navigation.js +147 -0
- package/dist/src/app/paths.d.ts +32 -0
- package/dist/src/app/paths.js +56 -0
- package/dist/src/app/state.d.ts +27 -0
- package/dist/src/app/state.js +60 -0
- package/dist/src/app/stores.d.ts +31 -0
- package/dist/src/app/stores.js +36 -0
- package/dist/src/appSettings.d.ts +44 -0
- package/dist/src/appSettings.js +55 -0
- package/dist/src/kitParameters.d.ts +272 -0
- package/dist/src/kitParameters.js +291 -0
- package/dist/src/kitRenderer.d.ts +3 -0
- package/dist/src/kitRenderer.js +254 -0
- package/dist/src/kitStore.d.ts +90 -0
- package/dist/src/kitStore.js +301 -0
- package/dist/src/mountFrame.d.ts +14 -0
- package/dist/src/mountFrame.js +15 -0
- package/dist/src/routeIds.d.ts +44 -0
- package/dist/src/routeIds.js +150 -0
- package/dist/src/routeOrder.d.ts +18 -0
- package/dist/src/routeOrder.js +187 -0
- package/dist/src/stories.d.ts +48 -0
- package/dist/src/stories.js +11 -0
- package/dist/src/storyCode.d.ts +3 -0
- package/dist/src/storyCode.js +9 -0
- package/dist/src/storyPage.d.ts +35 -0
- package/dist/src/storyPage.js +19 -0
- package/dist/src/vite/envModules.d.ts +67 -0
- package/dist/src/vite/envModules.js +181 -0
- package/dist/src/vite/extractDocs.d.ts +14 -0
- package/dist/src/vite/extractDocs.js +20 -0
- package/dist/src/vite/kitPlugin.d.ts +28 -0
- package/dist/src/vite/kitPlugin.js +241 -0
- package/dist/src/vite/kitProject.d.ts +27 -0
- package/dist/src/vite/kitProject.js +176 -0
- package/dist/src/vite/projectFiles.d.ts +14 -0
- package/dist/src/vite/projectFiles.js +70 -0
- package/dist/src/vite/sveltekit.d.ts +26 -0
- package/dist/src/vite/sveltekit.js +39 -0
- package/package.json +113 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgebook's renderer for a SvelteKit project: Svelte's renderer, each story given its page (`$app/state`), its
|
|
3
|
+
* page's data loaded by its `load` before it is drawn, and its links kept in the story.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* A story's page (`parameters.sveltekit`) lives in a store made when the story is mounted, so a story drawn again with
|
|
7
|
+
* other args keeps where its links took it, and one that starts over starts at its own page. A story that gives its
|
|
8
|
+
* page data (`page.data`, `serverData` or `load`) or an action's answer (`forms.result`) is a page's: its component
|
|
9
|
+
* gets `data`, `form` and `params`, as SvelteKit
|
|
10
|
+
* draws a `+page.svelte`, unless its args give them. Every link the story draws is caught, so the frame stays on its
|
|
11
|
+
* story: one of the app moves the story's page, one an `hrefs` entry names calls it instead, and each is listed among
|
|
12
|
+
* the story's events. Everything else is Svelte's renderer (`@forgebook/svelte/renderer`), which this one wraps.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import { svelteRenderer } from "@forgebook/svelte/renderer";
|
|
17
|
+
import { hrefCallbacksOf } from "./kitParameters.js";
|
|
18
|
+
import { createKitStore, errorOfThrown, setCurrentStore } from "./kitStore.js";
|
|
19
|
+
/** Whether a value is a plain object. */
|
|
20
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
/** An error as the frame takes it: SvelteKit's `error(404, "…")` written with its status. */
|
|
22
|
+
const toError = (error) => {
|
|
23
|
+
if (error instanceof Error)
|
|
24
|
+
return error;
|
|
25
|
+
const read = errorOfThrown(error);
|
|
26
|
+
return new Error(`${read.status ?? 500}: ${read.message}`);
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The address a load fetches: in a page, as the frame's `fetch` reads it, so the catalogue's mocks answer a path; on a
|
|
30
|
+
* server, which has no page to read a path from, from the story's page, as SvelteKit's `fetch` does.
|
|
31
|
+
*
|
|
32
|
+
* @param input - what the load fetches.
|
|
33
|
+
* @param pageUrl - the story's page.
|
|
34
|
+
*/
|
|
35
|
+
const pageRequest = (input, pageUrl) => typeof location === "undefined" ? new URL(input, pageUrl).href : input;
|
|
36
|
+
/**
|
|
37
|
+
* The loader of a story's page data: its loads in order, the layouts' first, each `parent()` the data above it.
|
|
38
|
+
*
|
|
39
|
+
* @param kit - the story's settings.
|
|
40
|
+
* @param store - the story's page, which records what the loads call.
|
|
41
|
+
* @returns the loader, or none for a story that gives no load and no server data.
|
|
42
|
+
*/
|
|
43
|
+
const loaderOf = (kit, store) => {
|
|
44
|
+
if (kit.loads.length === 0 && kit.serverData === null)
|
|
45
|
+
return undefined;
|
|
46
|
+
return async (at) => {
|
|
47
|
+
let merged = kit.page.data;
|
|
48
|
+
if (kit.loads.length === 0)
|
|
49
|
+
return { ...merged, ...kit.serverData };
|
|
50
|
+
for (const [index, load] of kit.loads.entries()) {
|
|
51
|
+
const above = merged;
|
|
52
|
+
const isPage = index === kit.loads.length - 1;
|
|
53
|
+
const result = await load({
|
|
54
|
+
params: at.params,
|
|
55
|
+
url: at.url,
|
|
56
|
+
route: at.route,
|
|
57
|
+
fetch: (input, init) => globalThis.fetch(typeof input === "string" ? pageRequest(input, at.url) : input, init),
|
|
58
|
+
// the page's server data is the page's load's; a layout's server load is given by `page.data`
|
|
59
|
+
data: isPage ? kit.serverData : null,
|
|
60
|
+
parent: async () => above,
|
|
61
|
+
depends: (...dependencies) => store.record("depends", dependencies),
|
|
62
|
+
setHeaders: () => undefined,
|
|
63
|
+
untrack: (read) => read()
|
|
64
|
+
});
|
|
65
|
+
merged = { ...merged, ...(isRecord(result) ? result : {}) };
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The drawing Svelte's renderer gets: a page's component given its data, form and params, unless its args give them.
|
|
72
|
+
*
|
|
73
|
+
* @param drawing - the drawing the frame gave.
|
|
74
|
+
* @param store - the story's page.
|
|
75
|
+
*/
|
|
76
|
+
const withPage = (drawing, store) => {
|
|
77
|
+
if (!store.kit.isPage)
|
|
78
|
+
return drawing;
|
|
79
|
+
const page = store.page();
|
|
80
|
+
return {
|
|
81
|
+
...drawing,
|
|
82
|
+
args: {
|
|
83
|
+
...("data" in drawing.args ? {} : { data: page.data }),
|
|
84
|
+
...("form" in drawing.args ? {} : { form: page.form }),
|
|
85
|
+
// SvelteKit 3 gives a page its route's params too
|
|
86
|
+
...("params" in drawing.args ? {} : { params: page.params }),
|
|
87
|
+
...drawing.args
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The href of the link an event went through, as written: an `<a>` (HTML or SVG) or an `<area>`; none without one.
|
|
93
|
+
*
|
|
94
|
+
* @param event - the click.
|
|
95
|
+
*/
|
|
96
|
+
const linkHref = (event) => {
|
|
97
|
+
for (const target of event.composedPath())
|
|
98
|
+
if (target instanceof Element && (target.localName === "a" || target.localName === "area"))
|
|
99
|
+
return target.getAttribute("href") ?? target.getAttribute("xlink:href");
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* The address a form sent without `use:enhance` goes to: a GET form's action with its values as the search.
|
|
104
|
+
*
|
|
105
|
+
* @param form - the form.
|
|
106
|
+
* @param submitter - the button that sent it, or none.
|
|
107
|
+
* @param pageUrl - the story's page, the action's base.
|
|
108
|
+
*/
|
|
109
|
+
const formAddress = (form, submitter, pageUrl) => {
|
|
110
|
+
const action = new URL(submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? "", pageUrl);
|
|
111
|
+
const values = new FormData(form);
|
|
112
|
+
action.search = new URLSearchParams([...values].map(([name, value]) => [
|
|
113
|
+
name,
|
|
114
|
+
typeof value === "string" ? value : value.name
|
|
115
|
+
])).toString();
|
|
116
|
+
return action;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Keeps a story in its frame: a link of the app moves the story's page, one an `hrefs` entry names calls it, one
|
|
120
|
+
* outside the app goes nowhere; a form sent without `use:enhance` is recorded, and a GET form moves the page as the
|
|
121
|
+
* browser would; each is recorded.
|
|
122
|
+
*
|
|
123
|
+
* @param canvas - the story's canvas.
|
|
124
|
+
* @param store - the story's page.
|
|
125
|
+
* @returns what stops it.
|
|
126
|
+
*/
|
|
127
|
+
const keepInStory = (canvas, store) => {
|
|
128
|
+
const onClick = (event) => {
|
|
129
|
+
if (event.defaultPrevented)
|
|
130
|
+
return;
|
|
131
|
+
const href = linkHref(event);
|
|
132
|
+
if (href === null)
|
|
133
|
+
return;
|
|
134
|
+
// the frame's address names the story: a link never takes the frame away from it
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
store.record("link", [href]);
|
|
137
|
+
const matched = hrefCallbacksOf(store.kit.hrefs, href);
|
|
138
|
+
for (const callback of matched)
|
|
139
|
+
callback(href, event);
|
|
140
|
+
if (matched.length === 0)
|
|
141
|
+
void store.navigate("link", href);
|
|
142
|
+
};
|
|
143
|
+
const onSubmit = (event) => {
|
|
144
|
+
// `use:enhance` handles its own form first
|
|
145
|
+
if (event.defaultPrevented || !(event.target instanceof HTMLFormElement))
|
|
146
|
+
return;
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
const form = event.target;
|
|
149
|
+
const method = (event.submitter?.getAttribute("formmethod") ?? form.method).toUpperCase();
|
|
150
|
+
const address = formAddress(form, event.submitter, store.page().url);
|
|
151
|
+
store.record("submit", [
|
|
152
|
+
method,
|
|
153
|
+
method === "GET" ? `${address.pathname}${address.search}` : address.pathname
|
|
154
|
+
]);
|
|
155
|
+
if (method === "GET")
|
|
156
|
+
void store.navigate("form", address);
|
|
157
|
+
};
|
|
158
|
+
canvas.addEventListener("click", onClick);
|
|
159
|
+
canvas.addEventListener("submit", onSubmit);
|
|
160
|
+
return () => {
|
|
161
|
+
canvas.removeEventListener("click", onClick);
|
|
162
|
+
canvas.removeEventListener("submit", onSubmit);
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
/** The page of each story drawn now, by its parameters, and how many of its drawings use it. */
|
|
166
|
+
const SHARED = new WeakMap();
|
|
167
|
+
/**
|
|
168
|
+
* The page of a story about to be drawn, made the frame's current one, with its loader: the one its other drawings use
|
|
169
|
+
* when it is drawn several times at once (a matrix's cells), since one frame shows one page; a new one otherwise.
|
|
170
|
+
*
|
|
171
|
+
* @param drawing - the drawing the frame gave.
|
|
172
|
+
* @returns the page, and what lets it go when the drawing is taken away.
|
|
173
|
+
*/
|
|
174
|
+
const storeOf = (drawing) => {
|
|
175
|
+
const key = drawing.story.parameters;
|
|
176
|
+
const shared = SHARED.get(key);
|
|
177
|
+
const entry = shared !== undefined && shared.mounts > 0
|
|
178
|
+
? shared
|
|
179
|
+
: { store: createKitStore(key, drawing.onEvent), mounts: 0 };
|
|
180
|
+
if (entry !== shared) {
|
|
181
|
+
entry.store.setLoader(loaderOf(entry.store.kit, entry.store));
|
|
182
|
+
SHARED.set(key, entry);
|
|
183
|
+
}
|
|
184
|
+
entry.mounts += 1;
|
|
185
|
+
setCurrentStore(entry.store);
|
|
186
|
+
return {
|
|
187
|
+
store: entry.store,
|
|
188
|
+
release: () => {
|
|
189
|
+
entry.mounts -= 1;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
/** Svelte's renderer for a SvelteKit project. */
|
|
194
|
+
export const kitRenderer = {
|
|
195
|
+
mount: (canvas, first, onError) => {
|
|
196
|
+
const { store, release } = storeOf(first);
|
|
197
|
+
let latest = first;
|
|
198
|
+
let inner;
|
|
199
|
+
let isGone = false;
|
|
200
|
+
const stopKeeping = keepInStory(canvas, store);
|
|
201
|
+
// an error a load throws, then or after a move, is shown in place of the story
|
|
202
|
+
store.setErrorHandler((error) => onError(toError(error)));
|
|
203
|
+
let shown = store.page();
|
|
204
|
+
// a page's component follows its data, form and params, as SvelteKit gives them again after a move or an action
|
|
205
|
+
const stopData = store.subscribe(() => {
|
|
206
|
+
const now = store.page();
|
|
207
|
+
if (now.data === shown.data && now.form === shown.form && now.params === shown.params)
|
|
208
|
+
return;
|
|
209
|
+
shown = now;
|
|
210
|
+
if (store.kit.isPage)
|
|
211
|
+
inner?.update(withPage(latest, store));
|
|
212
|
+
});
|
|
213
|
+
const draw = () => {
|
|
214
|
+
const mounted = svelteRenderer.mount(canvas, withPage(latest, store), onError);
|
|
215
|
+
inner = mounted;
|
|
216
|
+
return mounted;
|
|
217
|
+
};
|
|
218
|
+
// a page without loads is drawn at once, as any Svelte story; one with loads once they are done
|
|
219
|
+
const drawn = store.kit.loads.length === 0
|
|
220
|
+
? draw().drawn
|
|
221
|
+
: store
|
|
222
|
+
.reload()
|
|
223
|
+
.then(async (isLoaded) => isLoaded && !isGone ? draw().drawn : new Promise(() => undefined));
|
|
224
|
+
return {
|
|
225
|
+
drawn,
|
|
226
|
+
update: (next) => {
|
|
227
|
+
latest = next;
|
|
228
|
+
inner?.update(withPage(next, store));
|
|
229
|
+
},
|
|
230
|
+
unmount: () => {
|
|
231
|
+
isGone = true;
|
|
232
|
+
stopKeeping();
|
|
233
|
+
stopData();
|
|
234
|
+
release();
|
|
235
|
+
inner?.unmount();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
renderToString: async (drawing) => {
|
|
240
|
+
const { store, release } = storeOf(drawing);
|
|
241
|
+
try {
|
|
242
|
+
let failure;
|
|
243
|
+
store.setErrorHandler((error) => {
|
|
244
|
+
failure = error;
|
|
245
|
+
});
|
|
246
|
+
if (store.kit.loads.length > 0 && !(await store.reload()))
|
|
247
|
+
throw toError(failure);
|
|
248
|
+
return await svelteRenderer.renderToString(withPage(drawing, store));
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
release();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type KitError, type KitNavigation, type KitPage, type NavigationType, type StoryKit } from "./kitParameters.ts";
|
|
2
|
+
/** A call a story made to SvelteKit: its function (`goto`) and what it was called with. */
|
|
3
|
+
export type KitCall = {
|
|
4
|
+
/** The function, as the catalogue lists it (`goto`, `invalidateAll`, `link`). */
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** What it was called with. */
|
|
7
|
+
readonly values: readonly unknown[];
|
|
8
|
+
};
|
|
9
|
+
/** Tells the catalogue about a call: the drawing's `onEvent`, when the catalogue gives one. */
|
|
10
|
+
export type CallReport = (name: string, values: readonly unknown[]) => void;
|
|
11
|
+
/** Loads the page's data for an address: the story's loads, run by the renderer. */
|
|
12
|
+
export type DataLoader = (page: Pick<KitPage, "url" | "params" | "route">) => Promise<Readonly<Record<string, unknown>>>;
|
|
13
|
+
/** A `beforeNavigate` callback's argument: the navigation, and how to stop it. */
|
|
14
|
+
export type BeforeNavigation = KitNavigation & {
|
|
15
|
+
/** Stops the navigation. */
|
|
16
|
+
readonly cancel: () => void;
|
|
17
|
+
};
|
|
18
|
+
/** What a navigation gives the new page. */
|
|
19
|
+
export type NavigateOptions = {
|
|
20
|
+
/** The page's new state (`App.PageState`). */
|
|
21
|
+
readonly state?: Readonly<Record<string, unknown>>;
|
|
22
|
+
};
|
|
23
|
+
/** A story's SvelteKit page. */
|
|
24
|
+
export type KitStore = {
|
|
25
|
+
/** The story's settings. */
|
|
26
|
+
readonly kit: StoryKit;
|
|
27
|
+
/** The page, as it is now; read in an effect, the effect follows it. */
|
|
28
|
+
readonly page: () => KitPage;
|
|
29
|
+
/** The navigation under way, or the story's own; `null` when none. */
|
|
30
|
+
readonly navigating: () => Partial<KitNavigation> | null;
|
|
31
|
+
/** Whether a new version is out. */
|
|
32
|
+
readonly updated: () => boolean;
|
|
33
|
+
/** Calls a listener whenever the page changes; returns what stops it. */
|
|
34
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
35
|
+
/** Changes some of the page's fields (`form`, `status`, `error`, `state`, `data`). */
|
|
36
|
+
readonly setPage: (fields: Partial<KitPage>) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Goes to an href from the page: a navigation of the app, or none for one outside it (another origin, or outside
|
|
39
|
+
* the base path), one a callback cancelled, or one a later navigation overtook.
|
|
40
|
+
*
|
|
41
|
+
* @returns whether the page moved.
|
|
42
|
+
*/
|
|
43
|
+
readonly navigate: (type: NavigationType, href: string | URL, options?: NavigateOptions) => Promise<boolean>;
|
|
44
|
+
/** The data the story's loads give an href, without going there, as `preloadData` does; the page's without loads. */
|
|
45
|
+
readonly dataAt: (href: string | URL) => Promise<Readonly<Record<string, unknown>>>;
|
|
46
|
+
/**
|
|
47
|
+
* Loads the page's data again, as `invalidate` and `refreshAll` do: a redirect it throws moves the page, another
|
|
48
|
+
* error goes to the error handler.
|
|
49
|
+
*
|
|
50
|
+
* @returns whether the data was loaded.
|
|
51
|
+
*/
|
|
52
|
+
readonly reload: () => Promise<boolean>;
|
|
53
|
+
/** Sets what hears an error a load threw: the frame, which shows it in place of the story. */
|
|
54
|
+
readonly setErrorHandler: (handler: ((error: unknown) => void) | undefined) => void;
|
|
55
|
+
/** Sets what loads the page's data, once the renderer has the story's loads. */
|
|
56
|
+
readonly setLoader: (loader: DataLoader | undefined) => void;
|
|
57
|
+
/** Registers a `beforeNavigate` callback; returns what removes it. */
|
|
58
|
+
readonly beforeNavigate: (callback: (navigation: BeforeNavigation) => void) => () => void;
|
|
59
|
+
/** Registers an `onNavigate` callback; returns what removes it. */
|
|
60
|
+
readonly onNavigate: (callback: (navigation: KitNavigation) => unknown) => () => void;
|
|
61
|
+
/** Registers an `afterNavigate` callback; returns what removes it. */
|
|
62
|
+
readonly afterNavigate: (callback: (navigation: KitNavigation) => void) => () => void;
|
|
63
|
+
/** The navigation `afterNavigate` sees as the page is drawn. */
|
|
64
|
+
readonly enter: () => KitNavigation;
|
|
65
|
+
/** Records a call and tells the catalogue. */
|
|
66
|
+
readonly record: (name: string, values: readonly unknown[]) => void;
|
|
67
|
+
/** The calls the story made, in order. */
|
|
68
|
+
readonly calls: () => readonly KitCall[];
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The page's error for what a load threw: SvelteKit's `error(404, "…")` with its message, anything else as it is.
|
|
72
|
+
*
|
|
73
|
+
* @param thrown - what the load threw.
|
|
74
|
+
*/
|
|
75
|
+
export declare const errorOfThrown: (thrown: unknown) => KitError;
|
|
76
|
+
/**
|
|
77
|
+
* A story's SvelteKit page.
|
|
78
|
+
*
|
|
79
|
+
* @param parameters - the story's parameters, which give its page.
|
|
80
|
+
* @param onEvent - tells the catalogue about each call (the drawing's `onEvent`); none outside a catalogue.
|
|
81
|
+
*/
|
|
82
|
+
export declare const createKitStore: (parameters: Readonly<Record<string, unknown>>, onEvent: CallReport | undefined) => KitStore;
|
|
83
|
+
/**
|
|
84
|
+
* Makes a store the frame's current one: the story drawn last.
|
|
85
|
+
*
|
|
86
|
+
* @param store - the store.
|
|
87
|
+
*/
|
|
88
|
+
export declare const setCurrentStore: (store: KitStore) => void;
|
|
89
|
+
/** The frame's current store: the story drawn last, or an idle one at `/`. */
|
|
90
|
+
export declare const currentStore: () => KitStore;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A story's SvelteKit page while it is drawn: the page its components read, where its links and `goto` calls take it,
|
|
3
|
+
* the navigation callbacks its components registered, and the calls it made, told to the catalogue.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* The page really moves: a link clicked in a story changes `page.url`, its route and its params (matched against the
|
|
7
|
+
* project's routes), runs `beforeNavigate`, `onNavigate` and `afterNavigate`, and loads the page's data again when the
|
|
8
|
+
* story gives a `load`. The frame's own address never changes (it names the story).
|
|
9
|
+
*
|
|
10
|
+
* Svelte follows the page through `createSubscriber`, so `$app/state` reads it with no rune of its own, and
|
|
11
|
+
* `$app/stores` subscribes to the same changes. One frame draws one story, so the store of the story drawn last is the
|
|
12
|
+
* frame's current one; it sits on a global symbol, so two copies of this module (one pre-bundled, one from the sources)
|
|
13
|
+
* share it.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import { createSubscriber } from "svelte/reactivity";
|
|
18
|
+
import { appSettings, pageRouteIds } from "./appSettings.js";
|
|
19
|
+
import { routedPage, storyKitOf } from "./kitParameters.js";
|
|
20
|
+
/**
|
|
21
|
+
* Where a page is, as a navigation names it.
|
|
22
|
+
*
|
|
23
|
+
* @param page - the page.
|
|
24
|
+
*/
|
|
25
|
+
const targetOf = (page) => ({
|
|
26
|
+
params: page.params,
|
|
27
|
+
route: page.route,
|
|
28
|
+
url: page.url
|
|
29
|
+
});
|
|
30
|
+
/** How many redirects the page's loads may throw in a row, as a browser follows them. */
|
|
31
|
+
const MOST_REDIRECTS = 20;
|
|
32
|
+
/** Whether a value is a plain object. */
|
|
33
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
/**
|
|
35
|
+
* The address a redirect a load threw goes to (SvelteKit's `redirect(303, "/login")`), or none.
|
|
36
|
+
*
|
|
37
|
+
* @param thrown - what the load threw.
|
|
38
|
+
*/
|
|
39
|
+
const redirectOf = (thrown) => isRecord(thrown) &&
|
|
40
|
+
typeof thrown["status"] === "number" &&
|
|
41
|
+
thrown["status"] >= 300 &&
|
|
42
|
+
thrown["status"] < 400 &&
|
|
43
|
+
typeof thrown["location"] === "string"
|
|
44
|
+
? thrown["location"]
|
|
45
|
+
: undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The page's error for what a load threw: SvelteKit's `error(404, "…")` with its message, anything else as it is.
|
|
48
|
+
*
|
|
49
|
+
* @param thrown - what the load threw.
|
|
50
|
+
*/
|
|
51
|
+
export const errorOfThrown = (thrown) => {
|
|
52
|
+
if (isRecord(thrown) && isRecord(thrown["body"]) && typeof thrown["body"]["message"] === "string")
|
|
53
|
+
return { message: thrown["body"]["message"], status: statusOfThrown(thrown) };
|
|
54
|
+
return {
|
|
55
|
+
message: thrown instanceof Error ? thrown.message : String(thrown),
|
|
56
|
+
status: statusOfThrown(thrown)
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The status of what a load threw: SvelteKit's error's, or 500.
|
|
61
|
+
*
|
|
62
|
+
* @param thrown - what the load threw.
|
|
63
|
+
*/
|
|
64
|
+
const statusOfThrown = (thrown) => isRecord(thrown) && typeof thrown["status"] === "number" ? thrown["status"] : 500;
|
|
65
|
+
/**
|
|
66
|
+
* Whether a path is under the base path, so the app's.
|
|
67
|
+
*
|
|
68
|
+
* @param pathname - the path.
|
|
69
|
+
* @param base - the base path, or empty.
|
|
70
|
+
*/
|
|
71
|
+
const isInBase = (pathname, base) => base === "" || pathname === base || pathname.startsWith(`${base}/`);
|
|
72
|
+
/**
|
|
73
|
+
* A navigation from a page to another, once every `beforeNavigate` callback let it start.
|
|
74
|
+
*
|
|
75
|
+
* @param type - how it started.
|
|
76
|
+
* @param from - the page it leaves.
|
|
77
|
+
* @param to - where it goes.
|
|
78
|
+
* @param before - the `beforeNavigate` callbacks.
|
|
79
|
+
* @returns the navigation and what says it is done, or none when a callback cancelled it.
|
|
80
|
+
*/
|
|
81
|
+
const startNavigation = (type, from, to, before) => {
|
|
82
|
+
let isCancelled = false;
|
|
83
|
+
let complete = () => undefined;
|
|
84
|
+
const navigation = {
|
|
85
|
+
from: targetOf(from),
|
|
86
|
+
to: targetOf(to),
|
|
87
|
+
type,
|
|
88
|
+
willUnload: false,
|
|
89
|
+
complete: new Promise((resolve) => {
|
|
90
|
+
complete = resolve;
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
const cancel = () => {
|
|
94
|
+
isCancelled = true;
|
|
95
|
+
};
|
|
96
|
+
for (const callback of [...before])
|
|
97
|
+
callback({ ...navigation, cancel });
|
|
98
|
+
return isCancelled ? undefined : { navigation, complete };
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Adds a callback to a set.
|
|
102
|
+
*
|
|
103
|
+
* @param set - the set.
|
|
104
|
+
* @param callback - the callback.
|
|
105
|
+
* @returns what removes it.
|
|
106
|
+
*/
|
|
107
|
+
const registered = (set, callback) => {
|
|
108
|
+
set.add(callback);
|
|
109
|
+
return () => {
|
|
110
|
+
set.delete(callback);
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* A story's SvelteKit page.
|
|
115
|
+
*
|
|
116
|
+
* @param parameters - the story's parameters, which give its page.
|
|
117
|
+
* @param onEvent - tells the catalogue about each call (the drawing's `onEvent`); none outside a catalogue.
|
|
118
|
+
*/
|
|
119
|
+
export const createKitStore = (parameters, onEvent) => {
|
|
120
|
+
const { base } = appSettings();
|
|
121
|
+
const kit = storyKitOf(parameters, pageRouteIds(), base);
|
|
122
|
+
let page = kit.page;
|
|
123
|
+
let navigating = kit.navigating;
|
|
124
|
+
const listeners = new Set();
|
|
125
|
+
const callbacks = { before: new Set(), on: new Set(), after: new Set() };
|
|
126
|
+
const made = [];
|
|
127
|
+
let loader;
|
|
128
|
+
const changed = () => {
|
|
129
|
+
for (const listener of [...listeners])
|
|
130
|
+
listener();
|
|
131
|
+
};
|
|
132
|
+
const subscribe = (listener) => {
|
|
133
|
+
listeners.add(listener);
|
|
134
|
+
return () => {
|
|
135
|
+
listeners.delete(listener);
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
const track = createSubscriber((update) => subscribe(update));
|
|
139
|
+
/** The last navigation or reload started: one that ends after a later one started is dropped, as SvelteKit's is. */
|
|
140
|
+
let latest = 0;
|
|
141
|
+
/**
|
|
142
|
+
* What a load that threw does: a redirect moves the page (as many times as a browser follows), anything else is
|
|
143
|
+
* the page's error, shown by the catalogue.
|
|
144
|
+
*
|
|
145
|
+
* @returns whether the page moved.
|
|
146
|
+
*/
|
|
147
|
+
const failed = async (thrown, redirects) => {
|
|
148
|
+
const location = redirectOf(thrown);
|
|
149
|
+
if (location !== undefined && redirects < MOST_REDIRECTS) {
|
|
150
|
+
record("redirect", [location]);
|
|
151
|
+
return navigate("goto", location, {}, redirects + 1);
|
|
152
|
+
}
|
|
153
|
+
const error = location === undefined ? thrown : new Error(`Too many redirects, the last to ${location}`);
|
|
154
|
+
page = { ...page, error: errorOfThrown(error), status: statusOfThrown(error) };
|
|
155
|
+
changed();
|
|
156
|
+
onError?.(error);
|
|
157
|
+
return false;
|
|
158
|
+
};
|
|
159
|
+
const record = (name, values) => {
|
|
160
|
+
made.push({ name, values });
|
|
161
|
+
onEvent?.(name, values);
|
|
162
|
+
};
|
|
163
|
+
let onError;
|
|
164
|
+
/** A new navigation or reload: the later one wins. */
|
|
165
|
+
const nextToken = () => {
|
|
166
|
+
latest += 1;
|
|
167
|
+
return latest;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Moves the page once its data is loaded: the `onNavigate` callbacks run, the page moves, then the `afterNavigate`
|
|
171
|
+
* ones run, what an `onNavigate` callback returned with them.
|
|
172
|
+
*
|
|
173
|
+
* @returns whether the page moved: not when a later navigation overtook this one.
|
|
174
|
+
*/
|
|
175
|
+
const move = async (token, to, navigation, options) => {
|
|
176
|
+
// a load reads the address and the params, so every move loads the data again, as SvelteKit's does
|
|
177
|
+
const data = loader === undefined ? page.data : await loader(to);
|
|
178
|
+
if (token !== latest)
|
|
179
|
+
return false;
|
|
180
|
+
const returned = await Promise.all([...callbacks.on].map((callback) => callback(navigation)));
|
|
181
|
+
if (token !== latest)
|
|
182
|
+
return false;
|
|
183
|
+
page = { ...page, ...to, data, state: options.state ?? {}, form: null, error: null, status: 200 };
|
|
184
|
+
navigating = null;
|
|
185
|
+
changed();
|
|
186
|
+
for (const callback of [...callbacks.after])
|
|
187
|
+
callback(navigation);
|
|
188
|
+
for (const after of returned)
|
|
189
|
+
if (typeof after === "function")
|
|
190
|
+
Reflect.apply(after, undefined, [navigation]);
|
|
191
|
+
return true;
|
|
192
|
+
};
|
|
193
|
+
/** Goes to an href: the `beforeNavigate` callbacks may cancel it, then the page's data is loaded and it moves. */
|
|
194
|
+
const navigate = async (type, href, options = {}, redirects = 0) => {
|
|
195
|
+
const url = new URL(href, page.url);
|
|
196
|
+
// another origin, or a path outside the base path, is not the app's: SvelteKit leaves it to the browser
|
|
197
|
+
if (url.origin !== page.url.origin || !isInBase(url.pathname, base))
|
|
198
|
+
return false;
|
|
199
|
+
const to = routedPage(url, pageRouteIds(), base);
|
|
200
|
+
const started = startNavigation(type, page, to, callbacks.before);
|
|
201
|
+
if (started === undefined)
|
|
202
|
+
return false;
|
|
203
|
+
const token = nextToken();
|
|
204
|
+
navigating = started.navigation;
|
|
205
|
+
changed();
|
|
206
|
+
try {
|
|
207
|
+
return await move(token, to, started.navigation, options);
|
|
208
|
+
}
|
|
209
|
+
catch (thrown) {
|
|
210
|
+
if (token !== latest)
|
|
211
|
+
return false;
|
|
212
|
+
navigating = null;
|
|
213
|
+
changed();
|
|
214
|
+
return failed(thrown, redirects);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
started.complete();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
kit,
|
|
222
|
+
page: () => {
|
|
223
|
+
track();
|
|
224
|
+
return page;
|
|
225
|
+
},
|
|
226
|
+
navigating: () => {
|
|
227
|
+
track();
|
|
228
|
+
return navigating;
|
|
229
|
+
},
|
|
230
|
+
updated: () => kit.updated,
|
|
231
|
+
subscribe,
|
|
232
|
+
setPage: (fields) => {
|
|
233
|
+
page = { ...page, ...fields };
|
|
234
|
+
changed();
|
|
235
|
+
},
|
|
236
|
+
navigate,
|
|
237
|
+
dataAt: async (href) => loader === undefined ? page.data : loader(routedPage(new URL(href, page.url), pageRouteIds(), base)),
|
|
238
|
+
reload: async () => {
|
|
239
|
+
if (loader === undefined)
|
|
240
|
+
return true;
|
|
241
|
+
const token = nextToken();
|
|
242
|
+
try {
|
|
243
|
+
const data = await loader(page);
|
|
244
|
+
if (token !== latest)
|
|
245
|
+
return false;
|
|
246
|
+
page = { ...page, data };
|
|
247
|
+
changed();
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
catch (thrown) {
|
|
251
|
+
return token === latest ? failed(thrown, 0) : false;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
setLoader: (next) => {
|
|
255
|
+
loader = next;
|
|
256
|
+
},
|
|
257
|
+
beforeNavigate: (callback) => registered(callbacks.before, callback),
|
|
258
|
+
onNavigate: (callback) => registered(callbacks.on, callback),
|
|
259
|
+
afterNavigate: (callback) => registered(callbacks.after, callback),
|
|
260
|
+
enter: () => ({
|
|
261
|
+
from: null,
|
|
262
|
+
to: targetOf(page),
|
|
263
|
+
type: "enter",
|
|
264
|
+
willUnload: false,
|
|
265
|
+
complete: Promise.resolve(),
|
|
266
|
+
...kit.navigation.afterNavigate
|
|
267
|
+
}),
|
|
268
|
+
record,
|
|
269
|
+
setErrorHandler: (handler) => {
|
|
270
|
+
onError = handler;
|
|
271
|
+
},
|
|
272
|
+
calls: () => [...made]
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
/** Where the frame's current store is kept: one key for every copy of this module. */
|
|
276
|
+
const CURRENT_KEY = Symbol.for("forgebook.sveltekit.page-store");
|
|
277
|
+
/** The store a frame has before any story is drawn, and outside a catalogue: `/`, no data. */
|
|
278
|
+
let idleStore;
|
|
279
|
+
/**
|
|
280
|
+
* Makes a store the frame's current one: the story drawn last.
|
|
281
|
+
*
|
|
282
|
+
* @param store - the store.
|
|
283
|
+
*/
|
|
284
|
+
export const setCurrentStore = (store) => {
|
|
285
|
+
Reflect.set(globalThis, CURRENT_KEY, store);
|
|
286
|
+
};
|
|
287
|
+
/** Whether a value is a store this module made. */
|
|
288
|
+
const isKitStore = (value) => typeof value === "object" &&
|
|
289
|
+
value !== null &&
|
|
290
|
+
"page" in value &&
|
|
291
|
+
typeof value.page === "function" &&
|
|
292
|
+
"navigate" in value &&
|
|
293
|
+
typeof value.navigate === "function";
|
|
294
|
+
/** The frame's current store: the story drawn last, or an idle one at `/`. */
|
|
295
|
+
export const currentStore = () => {
|
|
296
|
+
const store = Reflect.get(globalThis, CURRENT_KEY);
|
|
297
|
+
if (isKitStore(store))
|
|
298
|
+
return store;
|
|
299
|
+
idleStore ??= createKitStore({}, undefined);
|
|
300
|
+
return idleStore;
|
|
301
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame page of a SvelteKit catalogue: Forgebook's frame, drawing its stories with Svelte and each story's
|
|
3
|
+
* SvelteKit page. Called by the page the plugin generates from the catalogue's configuration.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { type FrameConfig } from "forgebook/frame";
|
|
8
|
+
/**
|
|
9
|
+
* Shows the story the address names in the given element, drawn with Svelte for a SvelteKit project.
|
|
10
|
+
*
|
|
11
|
+
* @param container - the element the story renders into.
|
|
12
|
+
* @param config - the sources and the languages, as the shell's configuration gives them.
|
|
13
|
+
*/
|
|
14
|
+
export declare const mountFrame: (container: HTMLElement, config: Omit<FrameConfig, "renderer">) => Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame page of a SvelteKit catalogue: Forgebook's frame, drawing its stories with Svelte and each story's
|
|
3
|
+
* SvelteKit page. Called by the page the plugin generates from the catalogue's configuration.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { mountFrame as mountForgebookFrame } from "forgebook/frame";
|
|
8
|
+
import { kitRenderer } from "./kitRenderer.js";
|
|
9
|
+
/**
|
|
10
|
+
* Shows the story the address names in the given element, drawn with Svelte for a SvelteKit project.
|
|
11
|
+
*
|
|
12
|
+
* @param container - the element the story renders into.
|
|
13
|
+
* @param config - the sources and the languages, as the shell's configuration gives them.
|
|
14
|
+
*/
|
|
15
|
+
export const mountFrame = (container, config) => mountForgebookFrame(container, { ...config, renderer: kitRenderer });
|