void-plugin-hono-jsx 0.1.0-alpha.3 → 0.1.0-alpha.4
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/CHANGELOG.md +52 -0
- package/README.md +41 -12
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/dist/browser/client.d.ts +2 -1
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/client.js +76 -22
- package/dist/browser/client.js.map +1 -1
- package/dist/browser/document-head.d.ts +1 -1
- package/dist/browser/document-head.d.ts.map +1 -1
- package/dist/browser/document-head.js +22 -1
- package/dist/browser/document-head.js.map +1 -1
- package/dist/browser/form-view.d.ts +10 -0
- package/dist/browser/form-view.d.ts.map +1 -0
- package/dist/browser/form-view.js +16 -0
- package/dist/browser/form-view.js.map +1 -0
- package/dist/browser/form.d.ts +28 -12
- package/dist/browser/form.d.ts.map +1 -1
- package/dist/browser/form.js +84 -23
- package/dist/browser/form.js.map +1 -1
- package/dist/browser/markdown.d.ts +15 -0
- package/dist/browser/markdown.d.ts.map +1 -0
- package/dist/browser/markdown.js +6 -0
- package/dist/browser/markdown.js.map +1 -0
- package/dist/browser/navigation.d.ts +13 -0
- package/dist/browser/navigation.d.ts.map +1 -0
- package/dist/browser/navigation.js +83 -0
- package/dist/browser/navigation.js.map +1 -0
- package/dist/browser/pages.d.ts +33 -3
- package/dist/browser/pages.d.ts.map +1 -1
- package/dist/browser/pages.js +76 -13
- package/dist/browser/pages.js.map +1 -1
- package/dist/plugins/hono-islands.d.ts +1 -1
- package/dist/plugins/hono-islands.d.ts.map +1 -1
- package/dist/plugins/hono-islands.js +156 -24
- package/dist/plugins/hono-islands.js.map +1 -1
- package/dist/plugins/hono-markdown.d.ts +4 -0
- package/dist/plugins/hono-markdown.d.ts.map +1 -0
- package/dist/plugins/hono-markdown.js +91 -0
- package/dist/plugins/hono-markdown.js.map +1 -0
- package/dist/plugins/hono-pages.d.ts +9 -1
- package/dist/plugins/hono-pages.d.ts.map +1 -1
- package/dist/plugins/hono-pages.js +51 -15
- package/dist/plugins/hono-pages.js.map +1 -1
- package/dist/plugins/markdown-api.d.ts +10 -0
- package/dist/plugins/markdown-api.d.ts.map +1 -0
- package/dist/plugins/markdown-api.js +13 -0
- package/dist/plugins/markdown-api.js.map +1 -0
- package/dist/plugins/markdown-compiler.d.ts +43 -0
- package/dist/plugins/markdown-compiler.d.ts.map +1 -0
- package/dist/plugins/markdown-compiler.js +91 -0
- package/dist/plugins/markdown-compiler.js.map +1 -0
- package/dist/plugins/resolve-component.d.ts +3 -0
- package/dist/plugins/resolve-component.d.ts.map +1 -0
- package/dist/plugins/resolve-component.js +15 -0
- package/dist/plugins/resolve-component.js.map +1 -0
- package/dist/plugins/runtime-paths.d.ts +1 -0
- package/dist/plugins/runtime-paths.d.ts.map +1 -1
- package/dist/plugins/runtime-paths.js +2 -0
- package/dist/plugins/runtime-paths.js.map +1 -1
- package/dist/server/form-view.d.ts +10 -0
- package/dist/server/form-view.d.ts.map +1 -0
- package/dist/server/form-view.js +16 -0
- package/dist/server/form-view.js.map +1 -0
- package/dist/server/form.d.ts +28 -12
- package/dist/server/form.d.ts.map +1 -1
- package/dist/server/form.js +84 -23
- package/dist/server/form.js.map +1 -1
- package/dist/server/markdown.d.ts +15 -0
- package/dist/server/markdown.d.ts.map +1 -0
- package/dist/server/markdown.js +6 -0
- package/dist/server/markdown.js.map +1 -0
- package/dist/server/pages.d.ts +33 -3
- package/dist/server/pages.d.ts.map +1 -1
- package/dist/server/pages.js +76 -13
- package/dist/server/pages.js.map +1 -1
- package/package.json +21 -3
- package/src/plugins/hono-islands.ts +121 -23
- package/src/plugins/hono-markdown.ts +78 -0
- package/src/plugins/hono-pages.ts +50 -14
- package/src/plugins/markdown-api.ts +17 -0
- package/src/plugins/markdown-compiler.ts +77 -0
- package/src/plugins/resolve-component.ts +12 -0
- package/src/plugins/runtime-paths.ts +2 -0
- package/src/runtime/client.tsx +66 -24
- package/src/runtime/document-head.ts +20 -1
- package/src/runtime/form-view.tsx +23 -0
- package/src/runtime/form.ts +82 -41
- package/src/runtime/markdown.ts +7 -0
- package/src/runtime/navigation.ts +72 -0
- package/src/runtime/pages.tsx +77 -15
package/src/runtime/client.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { render } from 'hono/jsx/dom';
|
|
1
|
+
import { ErrorBoundary, render } from 'hono/jsx/dom';
|
|
2
2
|
import { jsx } from 'hono/jsx/dom/jsx-runtime';
|
|
3
3
|
import { useLayoutEffect, useState } from 'hono/jsx';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { idleNavigationState, type NavigationState, type VoidRouterConfig } from 'void/pages-client';
|
|
5
|
+
import { PageContext, RouterContext, NavigationContext, setActionRouter } from './pages.tsx';
|
|
6
|
+
import { createHonoRouter } from './navigation.ts';
|
|
7
7
|
import { createDocumentHead } from './document-head.ts';
|
|
8
8
|
import { createDeferred, resolveDeferred, rejectDeferred, isLoading, bufferInitialDeferred } from './deferred.ts';
|
|
9
9
|
|
|
10
|
+
type NavigationOptions = Pick<VoidRouterConfig, 'viewTransitions' | 'prefetchHoverDelay' | 'prefetchDefaultCacheFor'>;
|
|
11
|
+
|
|
10
12
|
type Manifest = Pick<VoidRouterConfig, 'components' | 'layoutTree' | 'routeMeta' | 'matchRoute' | 'staticPageData' | 'staticPageDataFastPath'>;
|
|
11
13
|
|
|
12
14
|
function pageComponent(component: unknown) {
|
|
@@ -14,16 +16,22 @@ function pageComponent(component: unknown) {
|
|
|
14
16
|
return component;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
export async function startHonoPages(manifest: Manifest) {
|
|
19
|
+
export async function startHonoPages(manifest: Manifest, options: NavigationOptions) {
|
|
18
20
|
const root = document.getElementById('app');
|
|
19
21
|
if (!root) throw new Error('Hono Pages: app root is missing.');
|
|
20
|
-
type Frame = { page: Awaited<ReturnType<typeof
|
|
22
|
+
type Frame = { page: Awaited<ReturnType<typeof createHonoRouter>>['initialPageData']; sourceProps: Record<string, unknown>; component: unknown; layouts: unknown[]; revision: number; commit: number };
|
|
21
23
|
let update: ((frame: Frame) => void) | undefined;
|
|
22
24
|
let revision = 0;
|
|
25
|
+
let commit = 0;
|
|
26
|
+
const pendingCommits = new Map<number, { resolve(): void; reject(error: unknown): void }>();
|
|
27
|
+
let committedFrame: Frame | undefined;
|
|
28
|
+
const failedFrames = new Set<number>();
|
|
29
|
+
let navigationState = idleNavigationState();
|
|
30
|
+
let updateNavigation: ((state: NavigationState) => void) | undefined;
|
|
23
31
|
let applyDocumentHead: ReturnType<typeof createDocumentHead> | undefined;
|
|
24
32
|
let renderedProps: Record<string, unknown> | undefined;
|
|
25
33
|
let updateDeferred: ((props: Record<string, unknown>) => void) | undefined;
|
|
26
|
-
const initialization =
|
|
34
|
+
const initialization = createHonoRouter({
|
|
27
35
|
...manifest,
|
|
28
36
|
adapter: {
|
|
29
37
|
createDeferred, resolveDeferred, rejectDeferred, isLoading,
|
|
@@ -36,42 +44,76 @@ export async function startHonoPages(manifest: Manifest) {
|
|
|
36
44
|
if (!update) throw new Error('Hono Pages: page rendering has not started.');
|
|
37
45
|
if (!options.preserveState) revision++;
|
|
38
46
|
renderedProps = page.props;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
const nextCommit = ++commit;
|
|
48
|
+
const committed = new Promise<void>((resolve, reject) => pendingCommits.set(nextCommit, { resolve, reject }));
|
|
49
|
+
update({ page: { ...page, props: { ...page.props } }, sourceProps: page.props, component, layouts, revision, commit: nextCommit });
|
|
50
|
+
await committed;
|
|
42
51
|
},
|
|
43
52
|
},
|
|
44
|
-
|
|
45
|
-
prefetchHoverDelay: 75,
|
|
46
|
-
prefetchDefaultCacheFor: parseCacheFor('30s'),
|
|
53
|
+
...options,
|
|
47
54
|
cacheVisitedPageData: false,
|
|
48
|
-
onNavigationChange(state) { root.dataset.navigation = state.state; },
|
|
55
|
+
onNavigationChange(state) { navigationState = state; updateNavigation?.(state); root.dataset.navigation = state.state; },
|
|
49
56
|
});
|
|
50
57
|
const releaseBuffer = bufferInitialDeferred();
|
|
51
58
|
const navigation = await initialization.finally(releaseBuffer);
|
|
59
|
+
setActionRouter(navigation.router);
|
|
52
60
|
renderedProps = navigation.initialPageData.props;
|
|
53
61
|
applyDocumentHead = createDocumentHead(navigation.initialPageData.head);
|
|
54
62
|
|
|
63
|
+
const initialFrame: Frame = { page: { ...navigation.initialPageData, props: { ...navigation.initialPageData.props } }, sourceProps: navigation.initialPageData.props, component: navigation.initialComponent, layouts: navigation.initialLayouts, revision, commit };
|
|
64
|
+
function PageFrame({ frame }: { frame: Frame }) {
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
if (failedFrames.has(frame.commit)) return;
|
|
67
|
+
if (frame.commit > 0) applyDocumentHead?.(frame.page.head);
|
|
68
|
+
committedFrame = frame;
|
|
69
|
+
for (const id of failedFrames) if (id < frame.commit) failedFrames.delete(id);
|
|
70
|
+
for (const [id, pending] of pendingCommits) if (id <= frame.commit) { pendingCommits.delete(id); pending.resolve(); }
|
|
71
|
+
}, [frame]);
|
|
72
|
+
let content = jsx(pageComponent(frame.component), frame.page.props, String(frame.revision));
|
|
73
|
+
const ids = manifest.layoutTree[frame.page.component] ?? [];
|
|
74
|
+
for (let index = frame.layouts.length - 1; index >= 0; index--) {
|
|
75
|
+
content = jsx(pageComponent(frame.layouts[index]), { children: content }, ids[index]);
|
|
76
|
+
}
|
|
77
|
+
return <PageContext.Provider value={frame.page}>{content}</PageContext.Provider>;
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
function App() {
|
|
56
|
-
const [frame, setFrame] = useState<Frame>(
|
|
81
|
+
const [frame, setFrame] = useState<Frame>(initialFrame);
|
|
82
|
+
const [pendingNavigation, setNavigation] = useState(navigationState);
|
|
57
83
|
const [failed, setFailed] = useState(false);
|
|
84
|
+
const [recovery, setRecovery] = useState(0);
|
|
58
85
|
useLayoutEffect(() => {
|
|
86
|
+
updateNavigation = setNavigation;
|
|
87
|
+
setNavigation(navigationState);
|
|
59
88
|
updateDeferred = (props) => setFrame((frame) => ({ ...frame, page: { ...frame.page, props } }));
|
|
60
89
|
update = (next) => { setFailed(false); setFrame(next); };
|
|
61
90
|
const onError = () => setFailed(true);
|
|
62
91
|
window.addEventListener('hono-pages:navigation-error', onError);
|
|
63
|
-
return () => { update = undefined; updateDeferred = undefined; window.removeEventListener('hono-pages:navigation-error', onError); };
|
|
92
|
+
return () => { for (const pending of pendingCommits.values()) pending.reject(new DOMException('Hono Pages: renderer unmounted.', 'AbortError')); pendingCommits.clear(); updateNavigation = undefined; update = undefined; updateDeferred = undefined; window.removeEventListener('hono-pages:navigation-error', onError); };
|
|
64
93
|
}, []);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
const renderFailed = (error: unknown) => {
|
|
95
|
+
if (failedFrames.has(frame.commit)) return;
|
|
96
|
+
failedFrames.add(frame.commit);
|
|
97
|
+
queueMicrotask(() => {
|
|
98
|
+
// A rejected child never reaches PageFrame's commit effect. Roll back
|
|
99
|
+
// outside the render traversal, then reject every coalesced waiter.
|
|
100
|
+
if (commit === frame.commit) {
|
|
101
|
+
if (committedFrame) { renderedProps = committedFrame.sourceProps; setFrame(committedFrame); }
|
|
102
|
+
// Discard the partially built Hono tree only after a render failure.
|
|
103
|
+
// Successful navigations keep the same boundary and layout state.
|
|
104
|
+
setRecovery(value => value + 1);
|
|
105
|
+
setFailed(true);
|
|
106
|
+
}
|
|
107
|
+
for (const [id, pending] of pendingCommits) if (id <= frame.commit) { pendingCommits.delete(id); pending.reject(error); }
|
|
108
|
+
});
|
|
109
|
+
};
|
|
70
110
|
return <RouterContext.Provider value={navigation.router}>
|
|
71
|
-
<
|
|
111
|
+
<NavigationContext.Provider value={pendingNavigation}>
|
|
72
112
|
{failed && <p role="alert">The page could not be loaded. Please try again.</p>}
|
|
73
|
-
{
|
|
74
|
-
|
|
113
|
+
<ErrorBoundary key={recovery} onError={renderFailed} fallback={<></>}>
|
|
114
|
+
<PageFrame frame={frame} />
|
|
115
|
+
</ErrorBoundary>
|
|
116
|
+
</NavigationContext.Provider>
|
|
75
117
|
</RouterContext.Provider>;
|
|
76
118
|
}
|
|
77
119
|
|
|
@@ -11,7 +11,11 @@ function scriptElements(head: HeadDescriptor | undefined) {
|
|
|
11
11
|
return Array.from(template.content.querySelectorAll('script'));
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
function metadataSignature(element: Element) {
|
|
15
|
+
return JSON.stringify([element.tagName, Array.from(element.attributes, ({ name, value }) => [name, value]).filter(([name]) => name !== 'data-void-head').sort()]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Synchronizes the snapshot before Void's idempotent post-render head commit. */
|
|
15
19
|
export function createDocumentHead(initial: HeadDescriptor | undefined) {
|
|
16
20
|
let htmlKeys = Object.keys(initial?.htmlAttrs ?? {});
|
|
17
21
|
let bodyKeys = Object.keys(initial?.bodyAttrs ?? {});
|
|
@@ -29,6 +33,21 @@ export function createDocumentHead(initial: HeadDescriptor | undefined) {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
return (head: HeadDescriptor | undefined) => {
|
|
36
|
+
if (head?.title !== undefined) document.title = head.title;
|
|
37
|
+
// Use Void's serializer and managed-tag marker so the router's subsequent
|
|
38
|
+
// commit reuses these nodes instead of inserting duplicate meta/link tags.
|
|
39
|
+
const template = document.createElement('template');
|
|
40
|
+
template.innerHTML = renderHeadToString({ meta: head?.meta, link: head?.link });
|
|
41
|
+
const metadata = Array.from(document.head.querySelectorAll('meta[data-void-head],link[data-void-head]'));
|
|
42
|
+
let cursor = document.head.querySelector('meta[data-void-head],link[data-void-head]') as Node | null;
|
|
43
|
+
for (const desired of Array.from(template.content.querySelectorAll('meta,link'))) {
|
|
44
|
+
const index = metadata.findIndex(element => metadataSignature(element) === metadataSignature(desired));
|
|
45
|
+
const element = index >= 0 ? metadata.splice(index, 1)[0] : desired;
|
|
46
|
+
element.setAttribute('data-void-head', '');
|
|
47
|
+
if (element !== cursor) document.head.insertBefore(element, cursor);
|
|
48
|
+
cursor = element.nextSibling;
|
|
49
|
+
}
|
|
50
|
+
for (const previous of metadata) previous.remove();
|
|
32
51
|
htmlKeys = attributes(document.documentElement, htmlKeys, head?.htmlAttrs);
|
|
33
52
|
bodyKeys = attributes(document.body, bodyKeys, head?.bodyAttrs);
|
|
34
53
|
const remaining = [...scripts];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState, type JSX, type Child } from 'hono/jsx';
|
|
2
|
+
import { formErrorMessage, formRetryMessage, formSubmissionBlocked, scheduleFormRetry, type FormMethod, type FormModel, type VoidActionError } from 'void/pages-client';
|
|
3
|
+
|
|
4
|
+
export type FormProps = Omit<JSX.IntrinsicElements['form'], 'action' | 'method' | 'onSubmit' | 'onError'> & {
|
|
5
|
+
form: FormModel<[string | FormData | undefined]>;
|
|
6
|
+
method?: FormMethod;
|
|
7
|
+
url?: string;
|
|
8
|
+
renderError?: (error: VoidActionError) => Child;
|
|
9
|
+
};
|
|
10
|
+
export function Form({ form, method = 'post', url, renderError, children, ...props }: FormProps) {
|
|
11
|
+
const [now, setNow] = useState(Date.now());
|
|
12
|
+
useEffect(() => scheduleFormRetry(form.error, () => setNow(Date.now())), [form.error]);
|
|
13
|
+
const message = formErrorMessage(form);
|
|
14
|
+
const retry = formRetryMessage(form.error);
|
|
15
|
+
const blocked = formSubmissionBlocked(form, now);
|
|
16
|
+
return <form {...props} aria-busy={form.pending} onSubmit={(event: Event) => {
|
|
17
|
+
event.preventDefault();
|
|
18
|
+
if (!blocked) void form[method](url ?? new FormData(event.currentTarget as HTMLFormElement));
|
|
19
|
+
}}>
|
|
20
|
+
{message && <div role="alert" data-void-form-error>{form.error && renderError ? renderError(form.error) : message}{retry && <p>{retry}</p>}</div>}
|
|
21
|
+
{children}
|
|
22
|
+
</form>;
|
|
23
|
+
}
|
package/src/runtime/form.ts
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
|
-
import { useContext, useRef, useState } from 'hono/jsx';
|
|
2
|
-
import { isAbortError, submitAction, VoidActionError, type FormMethod } from 'void/pages-client';
|
|
3
|
-
import {
|
|
1
|
+
import { useContext, useEffect, useRef, useState } from 'hono/jsx';
|
|
2
|
+
import { isAbortError, isEqualFormValue, interpolateRouteParams, submitAction, VoidActionError, type ActionResult, type FormMethod } from 'void/pages-client';
|
|
3
|
+
import type { ActionFormOptions, ActionUrl, ResolveActionBody } from 'void/routes';
|
|
4
|
+
import { RouterContext, useErrors, useRouter, action } from './pages.tsx';
|
|
5
|
+
export { Form, type FormProps } from './form-view.tsx';
|
|
6
|
+
export { action };
|
|
7
|
+
|
|
8
|
+
type Fields = Record<string, unknown>;
|
|
9
|
+
type Body<U extends string> = ResolveActionBody<U> extends Fields ? ResolveActionBody<U> : Fields;
|
|
10
|
+
type SetData<T> = { (next: T | ((current: T) => T)): void; <K extends keyof T & string>(field: K, value: T[K]): void };
|
|
11
|
+
export type HonoForm<T extends Fields> = {
|
|
12
|
+
data: T; setData: SetData<T>; errors: Record<string, string>; pending: boolean;
|
|
13
|
+
error: VoidActionError | null; hasChanges: boolean; wasSuccessful: boolean; recentlySuccessful: boolean;
|
|
14
|
+
submit(method: FormMethod, target?: string | FormData): Promise<void>; cancel(): void;
|
|
15
|
+
reset: { (): void; (...fields: Array<keyof T & string>): void }; clearErrors(...fields: Array<keyof T & string>): void; clearError(): void;
|
|
16
|
+
} & Record<FormMethod, (target?: string | FormData) => Promise<void>>;
|
|
17
|
+
|
|
18
|
+
function toFormError(failure: unknown, url: string): VoidActionError {
|
|
19
|
+
if (failure instanceof VoidActionError) return failure;
|
|
20
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
21
|
+
const error = new VoidActionError({ body: { message }, status: 0, statusText: 'Network Error', url });
|
|
22
|
+
error.message = message;
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
4
25
|
|
|
5
26
|
function fieldErrors(error: VoidActionError): Record<string, string> {
|
|
6
27
|
const body = error.body;
|
|
@@ -8,64 +29,84 @@ function fieldErrors(error: VoidActionError): Record<string, string> {
|
|
|
8
29
|
return Object.fromEntries(Object.entries(body.errors).filter((entry): entry is [string, string] => typeof entry[1] === 'string'));
|
|
9
30
|
}
|
|
10
31
|
|
|
11
|
-
export function useForm<T extends
|
|
12
|
-
|
|
32
|
+
export function useForm<T extends Fields>(initialData: T): HonoForm<T>;
|
|
33
|
+
export function useForm<U extends ActionUrl & string>(url: U, defaults: Body<U>, options?: ActionFormOptions<U>): HonoForm<Body<U>>;
|
|
34
|
+
export function useForm(initial: string | Fields, values?: Fields, options?: { params?: Record<string, string> }): HonoForm<Fields> {
|
|
35
|
+
if (!useContext(RouterContext)) throw new Error('Hono Pages: useForm requires the full Pages renderer; use useIslandForm for islands.');
|
|
13
36
|
const router = useRouter();
|
|
14
|
-
const
|
|
15
|
-
|
|
37
|
+
const bound = typeof initial === 'string' ? initial : undefined;
|
|
38
|
+
return useFormModel(typeof initial === 'string' ? values ?? {} : initial, useErrors(), async (method, url, data) => submitAction(router, interpolateRouteParams(url, options?.params), { method: method.toUpperCase(), data, preserveScroll: true }), () => router.refresh(), bound);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useIslandForm<T extends Fields>(defaults: T): HonoForm<T> {
|
|
42
|
+
const controller = useRef<AbortController | null>(null);
|
|
43
|
+
useEffect(() => () => controller.current?.abort(), []);
|
|
44
|
+
return useFormModel(defaults, {}, async (method, url, data) => {
|
|
45
|
+
const target = new URL(url, window.location.href);
|
|
46
|
+
if (target.origin !== window.location.origin || !['http:', 'https:'].includes(target.protocol)) throw new Error('Hono island form: actions must target the current origin.');
|
|
47
|
+
controller.current = new AbortController();
|
|
48
|
+
const response = await fetch(target, { method: method.toUpperCase(), headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: controller.current.signal });
|
|
49
|
+
if (response.redirected && response.ok) { window.location.assign(response.url); return { ok: true, pageData: undefined }; }
|
|
50
|
+
if (response.ok) { window.location.reload(); return { ok: true, pageData: undefined }; }
|
|
51
|
+
let body: unknown;
|
|
52
|
+
try { body = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.text(); } catch { body = null; }
|
|
53
|
+
return { ok: false, error: new VoidActionError({ body, status: response.status, statusText: response.statusText, url: target.href }) };
|
|
54
|
+
}, () => controller.current?.abort());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type FormTransport = (method: FormMethod, url: string, data: Fields) => Promise<ActionResult>;
|
|
58
|
+
function useFormModel<T extends Fields>(initial: T, initialErrors: Record<string, string>, send: FormTransport, abort: () => void | Promise<unknown>, bound?: string): HonoForm<T> {
|
|
59
|
+
const defaults = useRef({ ...initial });
|
|
60
|
+
const current = useRef({ ...defaults.current });
|
|
61
|
+
const [data, updateData] = useState(current.current);
|
|
16
62
|
const [errors, setErrors] = useState(initialErrors);
|
|
17
63
|
const [pending, setPending] = useState(false);
|
|
18
64
|
const [wasSuccessful, setSuccessful] = useState(false);
|
|
19
|
-
const [
|
|
65
|
+
const [recentlySuccessful, setRecent] = useState(false);
|
|
66
|
+
const [error, setError] = useState<VoidActionError | null>(null);
|
|
20
67
|
const active = useRef(false);
|
|
21
68
|
const generation = useRef(0);
|
|
69
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
70
|
+
useEffect(() => () => { ++generation.current; active.current = false; clearTimeout(timer.current); }, []);
|
|
22
71
|
|
|
23
|
-
const setData:
|
|
24
|
-
|
|
25
|
-
setSuccessful(false);
|
|
26
|
-
setErrors({});
|
|
27
|
-
setError(null);
|
|
72
|
+
const setData: SetData<T> = (next: T | ((current: T) => T) | string, value?: unknown) => {
|
|
73
|
+
current.current = typeof next === 'string' ? { ...current.current, [next]: value } as T : typeof next === 'function' ? next(current.current) : { ...next };
|
|
74
|
+
updateData(current.current); setSuccessful(false); setErrors({}); setError(null); setRecent(false); clearTimeout(timer.current);
|
|
28
75
|
};
|
|
29
|
-
|
|
30
|
-
|
|
76
|
+
async function submit(method: FormMethod, target?: string | FormData) {
|
|
77
|
+
const url = typeof target === 'string' ? target : bound;
|
|
78
|
+
if (!url) throw new Error('Hono Form: supply an action URL to useForm or submit.');
|
|
31
79
|
if (active.current) return;
|
|
32
|
-
active.current = true;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
setSuccessful(false);
|
|
36
|
-
setError(null);
|
|
37
|
-
setErrors({});
|
|
80
|
+
active.current = true; const id = ++generation.current;
|
|
81
|
+
setPending(true); setSuccessful(false); setRecent(false); clearTimeout(timer.current); setError(null); setErrors({});
|
|
82
|
+
const submitted = { ...current.current };
|
|
38
83
|
try {
|
|
39
|
-
const result = await
|
|
84
|
+
const result = await send(method, url, submitted);
|
|
40
85
|
if (id !== generation.current) return;
|
|
41
|
-
if (result.ok)
|
|
42
|
-
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
if (bound) defaults.current = submitted;
|
|
88
|
+
setSuccessful(true); setRecent(true);
|
|
89
|
+
timer.current = setTimeout(() => { if (id === generation.current) setRecent(false); }, 2000);
|
|
90
|
+
} else { setError(result.error); setErrors(fieldErrors(result.error)); }
|
|
43
91
|
} catch (failure) {
|
|
44
|
-
if (id === generation.current && !isAbortError(failure))
|
|
45
|
-
setError(failure instanceof Error ? failure : new Error(String(failure)));
|
|
46
|
-
}
|
|
92
|
+
if (id === generation.current && !isAbortError(failure)) setError(toFormError(failure, url));
|
|
47
93
|
} finally {
|
|
48
94
|
if (id === generation.current) { active.current = false; setPending(false); }
|
|
49
95
|
}
|
|
50
96
|
}
|
|
51
|
-
|
|
52
97
|
function cancel() {
|
|
53
98
|
if (!active.current) return;
|
|
54
|
-
const id = ++generation.current;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
setSuccessful(false);
|
|
58
|
-
void router.refresh().catch((failure: unknown) => {
|
|
59
|
-
if (id === generation.current) setError(failure instanceof Error ? failure : new Error(String(failure)));
|
|
99
|
+
const id = ++generation.current; active.current = false; setPending(false); setSuccessful(false); setRecent(false); clearTimeout(timer.current);
|
|
100
|
+
void Promise.resolve(abort()).catch((failure: unknown) => {
|
|
101
|
+
if (id === generation.current) setError(toFormError(failure, bound ?? 'island'));
|
|
60
102
|
});
|
|
61
103
|
}
|
|
62
|
-
|
|
63
104
|
return {
|
|
64
|
-
data, setData, errors, pending, wasSuccessful, error, submit, cancel,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
data, setData, errors, pending, wasSuccessful, recentlySuccessful, error, submit, cancel,
|
|
106
|
+
hasChanges: Object.keys({ ...defaults.current, ...data }).some((key) => !isEqualFormValue(defaults.current[key], data[key])),
|
|
107
|
+
reset(...fields: Array<string | MouseEvent>) { const keys = fields.filter((field): field is string => typeof field === 'string'); setData(keys.length ? { ...current.current, ...Object.fromEntries(keys.map((key) => [key, defaults.current[key]])) } as T : { ...defaults.current }); },
|
|
108
|
+
clearErrors(...fields) { setErrors((previous) => fields.length ? Object.fromEntries(Object.entries(previous).filter(([field]) => !fields.includes(field))) : {}); },
|
|
109
|
+
clearError() { setError(null); },
|
|
110
|
+
post: (target) => submit('post', target), put: (target) => submit('put', target), patch: (target) => submit('patch', target), delete: (target) => submit('delete', target),
|
|
70
111
|
};
|
|
71
112
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createContext, useContext } from 'hono/jsx';
|
|
2
|
+
export type MarkdownHeading = { depth: number; slug: string; text: string };
|
|
3
|
+
export type MarkdownContextValue = { frontmatter: Record<string, unknown>; headings: MarkdownHeading[]; props: Record<string, unknown> };
|
|
4
|
+
export const MarkdownContext = createContext<MarkdownContextValue>({ frontmatter: {}, headings: [], props: {} });
|
|
5
|
+
export function useFrontmatter<T = Record<string, unknown>>(): T { return useContext(MarkdownContext).frontmatter as T; }
|
|
6
|
+
export function useMarkdownHeadings(): MarkdownHeading[] { return useContext(MarkdownContext).headings; }
|
|
7
|
+
export function useMarkdownProps<T = Record<string, unknown>>(): T { return useContext(MarkdownContext).props as T; }
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createPageNavigationEngine, createRouterFacade, idleNavigationState, isAbortError, type PageNavigationResource, type VoidRouter, type VoidRouterConfig, type NavigationMethod } from 'void/pages-client';
|
|
2
|
+
|
|
3
|
+
/** DOM updates still complete when a native animation is skipped or replaced. */
|
|
4
|
+
export async function applyWithTransition(enabled: boolean, update: () => Promise<void>) {
|
|
5
|
+
if (!enabled || !document.startViewTransition) return update();
|
|
6
|
+
const transition = document.startViewTransition(update);
|
|
7
|
+
// ready rejects for expected animation skips; updateCallbackDone separately
|
|
8
|
+
// propagates rendering failures. Never patch the browser's native method.
|
|
9
|
+
void transition.ready.catch(() => {});
|
|
10
|
+
void transition.finished?.catch(() => {});
|
|
11
|
+
await transition.updateCallbackDone;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Hono render integration over Void's public navigation engine and facade. */
|
|
15
|
+
export async function createHonoRouter(config: VoidRouterConfig) {
|
|
16
|
+
const engine = await createPageNavigationEngine(config);
|
|
17
|
+
const currentUrl = () => window.location.pathname + window.location.search + window.location.hash;
|
|
18
|
+
const toUrl = (value: string | URL) => new URL(value, window.location.href);
|
|
19
|
+
const normalize = (value: string | URL) => {
|
|
20
|
+
const url = toUrl(value);
|
|
21
|
+
// Preserve foreign origins and schemes for the shared engine's guards.
|
|
22
|
+
return url.origin === window.location.origin && ['http:', 'https:'].includes(url.protocol)
|
|
23
|
+
? url.pathname + url.search + url.hash : url.href;
|
|
24
|
+
};
|
|
25
|
+
let routeUrl = currentUrl();
|
|
26
|
+
let routeParams = engine.initialPageData.params ?? {};
|
|
27
|
+
let active: number | null = null;
|
|
28
|
+
const setRoute = (url: string, params = routeParams) => { routeUrl = url; routeParams = params; config.onRouteChange?.(url); };
|
|
29
|
+
const assertCurrent = (resource: PageNavigationResource) => {
|
|
30
|
+
if (active !== resource.id) throw new DOMException('Hono Pages: stale navigation callback discarded.', 'AbortError');
|
|
31
|
+
};
|
|
32
|
+
async function apply(resource: PageNavigationResource) {
|
|
33
|
+
active = resource.id;
|
|
34
|
+
const previousParams = routeParams;
|
|
35
|
+
config.onNavigationChange?.({ state: resource.method === 'GET' ? 'loading' : 'submitting', location: toUrl(resource.fetchUrl + resource.hash), method: resource.method.toUpperCase() as NavigationMethod });
|
|
36
|
+
try {
|
|
37
|
+
const result = await resource.shell;
|
|
38
|
+
if (!result) return;
|
|
39
|
+
assertCurrent(resource);
|
|
40
|
+
setRoute(result.pageData.url || resource.fetchUrl + resource.hash, result.pageData.params ?? {});
|
|
41
|
+
await applyWithTransition(resource.options.viewTransition ?? config.viewTransitions, async () => {
|
|
42
|
+
assertCurrent(resource);
|
|
43
|
+
await config.adapter.applyUpdate(result.pageData, result.component, result.layouts, { preserveState: resource.options.preserveState ?? resource.method !== 'GET' });
|
|
44
|
+
});
|
|
45
|
+
assertCurrent(resource);
|
|
46
|
+
await resource.commit();
|
|
47
|
+
if (active === resource.id) setRoute(currentUrl(), result.pageData.params ?? {});
|
|
48
|
+
return result.pageData;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (active === resource.id) routeParams = previousParams;
|
|
51
|
+
if (resource.method === 'GET' && !resource.options._resolveOnShell && isAbortError(error)) return;
|
|
52
|
+
throw error;
|
|
53
|
+
} finally {
|
|
54
|
+
if (active === resource.id) { active = null; setRoute(currentUrl()); config.onNavigationChange?.(idleNavigationState()); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const raw: VoidRouter = {
|
|
58
|
+
_hoverDelay: engine._hoverDelay,
|
|
59
|
+
get url() { return toUrl(routeUrl); },
|
|
60
|
+
get path() { return this.url.pathname; },
|
|
61
|
+
get query() { return this.url.searchParams; },
|
|
62
|
+
get params() { return routeParams; },
|
|
63
|
+
visit(url, options = {}) { return apply(engine.prepareVisit(normalize(url), options)); },
|
|
64
|
+
refresh(options = {}) { return raw.visit(window.location.pathname + window.location.search, { preserveState: true, preserveScroll: true, ...options }); },
|
|
65
|
+
prefetch(url, options = {}) { return engine.prefetch(normalize(url), options); },
|
|
66
|
+
flush(url, method = 'GET') { engine.flush(normalize(url), method); },
|
|
67
|
+
flushAll() { engine.flushAll(); },
|
|
68
|
+
};
|
|
69
|
+
const router = createRouterFacade(raw, () => routeUrl, () => routeParams);
|
|
70
|
+
engine.setPopStateHandler((url, options) => { void router.visit(url, options).catch(error => window.dispatchEvent(new CustomEvent('hono-pages:navigation-error', { detail: error }))); });
|
|
71
|
+
return { router, initialPageData: engine.initialPageData, initialComponent: engine.initialComponent, initialLayouts: engine.initialLayouts, start: engine.start, commitInitialRender: engine.commitInitialRender };
|
|
72
|
+
}
|
package/src/runtime/pages.tsx
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { createContext, useContext, type JSX } from 'hono/jsx';
|
|
2
|
-
import type
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, type JSX } from 'hono/jsx';
|
|
2
|
+
import { createActionClient, mergeDataIntoHref, type NavigationState, type NavigationMethod, type VoidRouter } from 'void/pages-client';
|
|
3
|
+
import type { CacheForInput } from 'void/pages-prefetch';
|
|
3
4
|
import type { PageObject } from 'void/pages-protocol';
|
|
4
5
|
export type { DeferredState as Deferred } from 'void';
|
|
5
6
|
|
|
7
|
+
const actionClient = createActionClient();
|
|
8
|
+
export const action = actionClient.action;
|
|
9
|
+
export const setActionRouter = actionClient.setActionRouter;
|
|
10
|
+
|
|
6
11
|
export const RouterContext = createContext<VoidRouter | null>(null);
|
|
12
|
+
export const NavigationContext = createContext<NavigationState | null>(null);
|
|
7
13
|
export const PageContext = createContext<PageObject | null>(null);
|
|
8
14
|
|
|
9
15
|
function usePage() {
|
|
@@ -12,6 +18,12 @@ function usePage() {
|
|
|
12
18
|
return page;
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
export function useNavigation(): NavigationState {
|
|
22
|
+
const navigation = useContext(NavigationContext);
|
|
23
|
+
if (!navigation) throw new Error('Hono Pages: useNavigation requires a page provider.');
|
|
24
|
+
return navigation;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function useShared() { return usePage().shared; }
|
|
16
28
|
export function useParams() { return usePage().params; }
|
|
17
29
|
export function useErrors() { return usePage().errors ?? {}; }
|
|
@@ -22,24 +34,74 @@ export function useRouter(): VoidRouter {
|
|
|
22
34
|
return router;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
type
|
|
37
|
+
export type PrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible';
|
|
38
|
+
export type LinkProps = Omit<JSX.IntrinsicElements['a'], 'href' | 'onClick'> & {
|
|
26
39
|
href: string;
|
|
40
|
+
method?: NavigationMethod | Lowercase<NavigationMethod>;
|
|
41
|
+
data?: Record<string, unknown>;
|
|
42
|
+
preserveScroll?: boolean;
|
|
43
|
+
preserveState?: boolean;
|
|
27
44
|
reloadDocument?: boolean;
|
|
28
45
|
replace?: boolean;
|
|
46
|
+
viewTransition?: boolean;
|
|
47
|
+
prefetch?: boolean | PrefetchStrategy | ReadonlyArray<PrefetchStrategy>;
|
|
48
|
+
cacheFor?: CacheForInput;
|
|
49
|
+
onNavigate?: (event: { preventDefault(): void }) => void;
|
|
29
50
|
onClick?: (event: MouseEvent) => void;
|
|
30
51
|
};
|
|
31
52
|
|
|
32
|
-
export function Link({ href,
|
|
53
|
+
export function Link({ href, method = 'GET', data, preserveScroll, preserveState, reloadDocument, replace, viewTransition,
|
|
54
|
+
prefetch = false, cacheFor, onNavigate, onClick, onMouseEnter, onMouseLeave, onFocus, onMouseDown, onTouchStart,
|
|
55
|
+
...attributes }: LinkProps) {
|
|
33
56
|
const router = useRouter();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
const element = useRef<HTMLAnchorElement | HTMLButtonElement | null>(null);
|
|
58
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
59
|
+
const normalized = method.toUpperCase();
|
|
60
|
+
const get = normalized === 'GET';
|
|
61
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(normalized)) throw new Error('Hono Link: unsupported method.');
|
|
62
|
+
if (!get && (prefetch !== false || reloadDocument)) throw new Error('Hono Link: prefetch and reloadDocument only support GET links.');
|
|
63
|
+
const destination = get ? mergeDataIntoHref(href, data) : href;
|
|
64
|
+
const strategies = reloadDocument || prefetch === false ? [] : prefetch === true ? ['hover'] : typeof prefetch === 'string' ? [prefetch] : prefetch;
|
|
65
|
+
if (strategies.some((strategy) => !['hover', 'click', 'mount', 'visible'].includes(strategy))) throw new Error('Hono Link: unsupported prefetch strategy.');
|
|
66
|
+
const clear = () => { clearTimeout(timer.current); timer.current = undefined; };
|
|
67
|
+
const canHandle = () => {
|
|
68
|
+
const url = new URL(destination, window.location.href);
|
|
69
|
+
return !reloadDocument && attributes.download === undefined && (!attributes.target || attributes.target === '_self') &&
|
|
70
|
+
!attributes.rel?.split(/\s+/).includes('external') && url.origin === window.location.origin && ['http:', 'https:'].includes(url.protocol);
|
|
71
|
+
};
|
|
72
|
+
const report = (error: unknown) => window.dispatchEvent(new CustomEvent('hono-pages:navigation-error', { detail: error }));
|
|
73
|
+
const fetchAhead = () => {
|
|
74
|
+
if (canHandle()) void router.prefetch(destination, { cacheFor }).catch(report);
|
|
75
|
+
};
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (strategies.includes('mount')) fetchAhead();
|
|
78
|
+
let observer: IntersectionObserver | undefined;
|
|
79
|
+
if (strategies.includes('visible') && element.current) {
|
|
80
|
+
observer = new IntersectionObserver((entries) => {
|
|
81
|
+
if (entries.some((entry) => entry.isIntersecting)) { observer?.disconnect(); fetchAhead(); }
|
|
82
|
+
});
|
|
83
|
+
observer.observe(element.current);
|
|
84
|
+
}
|
|
85
|
+
return () => { clear(); observer?.disconnect(); };
|
|
86
|
+
}, [destination, prefetch, cacheFor, reloadDocument, router]);
|
|
87
|
+
const events = {
|
|
88
|
+
onClick(event: MouseEvent) {
|
|
89
|
+
onClick?.(event);
|
|
90
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || !canHandle()) return;
|
|
91
|
+
let cancelled = false;
|
|
92
|
+
onNavigate?.({ preventDefault() { cancelled = true; } });
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
if (!cancelled) void router.visit(new URL(destination, window.location.href), { method: normalized, data: get ? undefined : data, preserveScroll, preserveState, replace, viewTransition }).catch(report);
|
|
95
|
+
},
|
|
96
|
+
onMouseEnter(event: MouseEvent) {
|
|
97
|
+
onMouseEnter?.(event); clear();
|
|
98
|
+
if (!event.defaultPrevented && strategies.includes('hover')) timer.current = setTimeout(fetchAhead, router._hoverDelay);
|
|
99
|
+
},
|
|
100
|
+
onMouseLeave(event: MouseEvent) { onMouseLeave?.(event); clear(); },
|
|
101
|
+
onFocus(event: FocusEvent) { onFocus?.(event); if (!event.defaultPrevented && strategies.includes('hover')) fetchAhead(); },
|
|
102
|
+
onMouseDown(event: MouseEvent) { onMouseDown?.(event); if (!event.defaultPrevented && strategies.includes('click')) fetchAhead(); },
|
|
103
|
+
onTouchStart(event: TouchEvent) { onTouchStart?.(event); if (!event.defaultPrevented && strategies.includes('click')) fetchAhead(); },
|
|
104
|
+
};
|
|
105
|
+
if (get) return <a {...attributes} {...events} ref={element} href={destination} />;
|
|
106
|
+
return <button {...attributes} {...events} ref={element} type="button" />;
|
|
45
107
|
}
|