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.
Files changed (90) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +41 -12
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/dist/browser/client.d.ts +2 -1
  5. package/dist/browser/client.d.ts.map +1 -1
  6. package/dist/browser/client.js +76 -22
  7. package/dist/browser/client.js.map +1 -1
  8. package/dist/browser/document-head.d.ts +1 -1
  9. package/dist/browser/document-head.d.ts.map +1 -1
  10. package/dist/browser/document-head.js +22 -1
  11. package/dist/browser/document-head.js.map +1 -1
  12. package/dist/browser/form-view.d.ts +10 -0
  13. package/dist/browser/form-view.d.ts.map +1 -0
  14. package/dist/browser/form-view.js +16 -0
  15. package/dist/browser/form-view.js.map +1 -0
  16. package/dist/browser/form.d.ts +28 -12
  17. package/dist/browser/form.d.ts.map +1 -1
  18. package/dist/browser/form.js +84 -23
  19. package/dist/browser/form.js.map +1 -1
  20. package/dist/browser/markdown.d.ts +15 -0
  21. package/dist/browser/markdown.d.ts.map +1 -0
  22. package/dist/browser/markdown.js +6 -0
  23. package/dist/browser/markdown.js.map +1 -0
  24. package/dist/browser/navigation.d.ts +13 -0
  25. package/dist/browser/navigation.d.ts.map +1 -0
  26. package/dist/browser/navigation.js +83 -0
  27. package/dist/browser/navigation.js.map +1 -0
  28. package/dist/browser/pages.d.ts +33 -3
  29. package/dist/browser/pages.d.ts.map +1 -1
  30. package/dist/browser/pages.js +76 -13
  31. package/dist/browser/pages.js.map +1 -1
  32. package/dist/plugins/hono-islands.d.ts +1 -1
  33. package/dist/plugins/hono-islands.d.ts.map +1 -1
  34. package/dist/plugins/hono-islands.js +156 -24
  35. package/dist/plugins/hono-islands.js.map +1 -1
  36. package/dist/plugins/hono-markdown.d.ts +4 -0
  37. package/dist/plugins/hono-markdown.d.ts.map +1 -0
  38. package/dist/plugins/hono-markdown.js +91 -0
  39. package/dist/plugins/hono-markdown.js.map +1 -0
  40. package/dist/plugins/hono-pages.d.ts +9 -1
  41. package/dist/plugins/hono-pages.d.ts.map +1 -1
  42. package/dist/plugins/hono-pages.js +51 -15
  43. package/dist/plugins/hono-pages.js.map +1 -1
  44. package/dist/plugins/markdown-api.d.ts +10 -0
  45. package/dist/plugins/markdown-api.d.ts.map +1 -0
  46. package/dist/plugins/markdown-api.js +13 -0
  47. package/dist/plugins/markdown-api.js.map +1 -0
  48. package/dist/plugins/markdown-compiler.d.ts +43 -0
  49. package/dist/plugins/markdown-compiler.d.ts.map +1 -0
  50. package/dist/plugins/markdown-compiler.js +91 -0
  51. package/dist/plugins/markdown-compiler.js.map +1 -0
  52. package/dist/plugins/resolve-component.d.ts +3 -0
  53. package/dist/plugins/resolve-component.d.ts.map +1 -0
  54. package/dist/plugins/resolve-component.js +15 -0
  55. package/dist/plugins/resolve-component.js.map +1 -0
  56. package/dist/plugins/runtime-paths.d.ts +1 -0
  57. package/dist/plugins/runtime-paths.d.ts.map +1 -1
  58. package/dist/plugins/runtime-paths.js +2 -0
  59. package/dist/plugins/runtime-paths.js.map +1 -1
  60. package/dist/server/form-view.d.ts +10 -0
  61. package/dist/server/form-view.d.ts.map +1 -0
  62. package/dist/server/form-view.js +16 -0
  63. package/dist/server/form-view.js.map +1 -0
  64. package/dist/server/form.d.ts +28 -12
  65. package/dist/server/form.d.ts.map +1 -1
  66. package/dist/server/form.js +84 -23
  67. package/dist/server/form.js.map +1 -1
  68. package/dist/server/markdown.d.ts +15 -0
  69. package/dist/server/markdown.d.ts.map +1 -0
  70. package/dist/server/markdown.js +6 -0
  71. package/dist/server/markdown.js.map +1 -0
  72. package/dist/server/pages.d.ts +33 -3
  73. package/dist/server/pages.d.ts.map +1 -1
  74. package/dist/server/pages.js +76 -13
  75. package/dist/server/pages.js.map +1 -1
  76. package/package.json +21 -3
  77. package/src/plugins/hono-islands.ts +121 -23
  78. package/src/plugins/hono-markdown.ts +78 -0
  79. package/src/plugins/hono-pages.ts +50 -14
  80. package/src/plugins/markdown-api.ts +17 -0
  81. package/src/plugins/markdown-compiler.ts +77 -0
  82. package/src/plugins/resolve-component.ts +12 -0
  83. package/src/plugins/runtime-paths.ts +2 -0
  84. package/src/runtime/client.tsx +66 -24
  85. package/src/runtime/document-head.ts +20 -1
  86. package/src/runtime/form-view.tsx +23 -0
  87. package/src/runtime/form.ts +82 -41
  88. package/src/runtime/markdown.ts +7 -0
  89. package/src/runtime/navigation.ts +72 -0
  90. package/src/runtime/pages.tsx +77 -15
@@ -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 { createVoidRouter, type VoidRouterConfig } from 'void/pages-client';
5
- import { parseCacheFor } from 'void/pages-prefetch';
6
- import { PageContext, RouterContext } from './pages.tsx';
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 createVoidRouter>>['initialPageData']; component: unknown; layouts: unknown[]; revision: number };
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 = createVoidRouter({
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
- update({ page: { ...page, props: { ...page.props } }, component, layouts, revision });
40
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
41
- applyDocumentHead?.(page.head);
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
- viewTransitions: false,
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>({ page: { ...navigation.initialPageData, props: { ...navigation.initialPageData.props } }, component: navigation.initialComponent, layouts: navigation.initialLayouts, revision });
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
- let content = jsx(pageComponent(frame.component), frame.page.props, String(frame.revision));
66
- const ids = manifest.layoutTree[frame.page.component] ?? [];
67
- for (let index = frame.layouts.length - 1; index >= 0; index--) {
68
- content = jsx(pageComponent(frame.layouts[index]), { children: content }, ids[index]);
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
- <PageContext.Provider value={frame.page}>
111
+ <NavigationContext.Provider value={pendingNavigation}>
72
112
  {failed && <p role="alert">The page could not be loaded. Please try again.</p>}
73
- {content}
74
- </PageContext.Provider>
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
- /** Complements Void's title/meta/link updates without executing navigation scripts. */
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
+ }
@@ -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 { RouterContext, useErrors, useRouter } from './pages.tsx';
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 Record<string, unknown>>(initialData: T) {
12
- if (!useContext(RouterContext)) throw new Error('Hono Pages: useForm requires the full Pages renderer; island form helpers are unsupported.');
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 initialErrors = useErrors();
15
- const [data, updateData] = useState(initialData);
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 [error, setError] = useState<Error | null>(null);
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: typeof updateData = (next) => {
24
- updateData(next);
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
- async function submit(method: FormMethod, url: string) {
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
- const id = ++generation.current;
34
- setPending(true);
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 submitAction(router, url, { method: method.toUpperCase(), data, preserveScroll: true });
84
+ const result = await send(method, url, submitted);
40
85
  if (id !== generation.current) return;
41
- if (result.ok) setSuccessful(true);
42
- else { setError(result.error); setErrors(fieldErrors(result.error)); }
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
- active.current = false;
56
- setPending(false);
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
- reset() { setData(initialData); },
66
- post: (url: string) => submit('post', url),
67
- put: (url: string) => submit('put', url),
68
- patch: (url: string) => submit('patch', url),
69
- delete: (url: string) => submit('delete', url),
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
+ }
@@ -1,9 +1,15 @@
1
- import { createContext, useContext, type JSX } from 'hono/jsx';
2
- import type { VoidRouter } from 'void/pages-client';
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 LinkProps = Omit<JSX.IntrinsicElements['a'], 'href' | 'onClick'> & {
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, reloadDocument, replace, onClick, ...attributes }: LinkProps) {
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
- return <a {...attributes} href={href} onClick={(event: MouseEvent) => {
35
- onClick?.(event);
36
- if (event.defaultPrevented || reloadDocument || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
37
- if (attributes.download !== undefined || (attributes.target && attributes.target !== '_self') || attributes.rel?.split(/\s+/).includes('external')) return;
38
- const url = new URL(href, window.location.href);
39
- if (url.origin !== window.location.origin || !['http:', 'https:'].includes(url.protocol)) return;
40
- event.preventDefault();
41
- void router.visit(url, { replace }).catch((error: unknown) => {
42
- window.dispatchEvent(new CustomEvent('hono-pages:navigation-error', { detail: error }));
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
  }