void-plugin-hono-jsx 0.0.0-bootstrap.0 → 0.1.0-alpha.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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0-alpha.0 (release candidate; unpublished)
4
+
5
+ - Accept the upstream esbuild development-server advisory GHSA-67mh-4wv8-2f99 as a documented limitation of the pinned alpha baseline, without dependency overrides. New unreviewed advisories and publisher-tooling findings remain blocked by the release gates.
6
+ - Verify Void 0.26.1 production deferred errors remain generic while development preserves diagnostic messages.
7
+ - Record the macOS development transport limitation after an undefined-action POST; the adapter does not repair this upstream edge case.
8
+
9
+ Initial experimental Hono JSX Pages adapter for Void 0.26.1.
10
+
11
+ - Server-rendered Pages, client-only pages, Void navigation and persistent layouts.
12
+ - Loader/action forms, head metadata and single deferred loader props.
13
+ - Load-policy islands, static output, development document reload and local ISR.
14
+ - Exact framework peers: Hono 4.13.13, Void 0.26.1 and Vite 8.3.3; Node >=24.21.0.
15
+
16
+ Browser mounting redraws server content. Managed edge deployment is unverified.
17
+ See the package guide for unsupported modes and the pinned compatibility policy.
18
+ This entry records the candidate scope; it does not claim an npm publication.
package/README.md CHANGED
@@ -1,3 +1,142 @@
1
- # Registration-only staging candidate
1
+ # Experimental Hono Pages adapter for Void
2
2
 
3
- This candidate establishes package ownership for Trusted Publisher setup. It contains no adapter code and must not be approved. The adapter release will be staged separately as 0.1.0-alpha.0 / next.
3
+ This experimental package integrates Hono JSX with Void Pages. The package name is `void-plugin-hono-jsx`. The initial `0.1.0-alpha.0` candidate targets the `next` dist-tag. It is unpublished and distributed under the [MIT license](LICENSE). Publication remains a separate maintainer decision. It is not an official Void adapter.
4
+
5
+ ## Supported versions
6
+
7
+ | Dependency | Verified version |
8
+ | --- | --- |
9
+ | Node.js | 24.21.0 or newer |
10
+ | Void | 0.26.1 |
11
+ | Hono | 4.13.13 |
12
+ | Vite | 8.3.3 |
13
+
14
+ Framework packages are exact peer dependencies. Other versions have not been validated; configuration fails with an install command when Void, Hono or Vite differs from this baseline.
15
+
16
+ ## Install the validation tarball
17
+
18
+ In the repository, run `npm ci`, `npm run build:package`, then `npm pack ./packages/hono-pages`. Install the resulting tarball in your project alongside the supported peer versions:
19
+
20
+ ```sh
21
+ npm install ./void-plugin-hono-jsx-0.1.0-alpha.0.tgz hono@4.13.13
22
+ npm install --save-dev void@0.26.1 vite@8.3.3 typescript@7.0.2
23
+ ```
24
+
25
+ ## Configure
26
+
27
+ ```ts
28
+ // vite.config.ts
29
+ import { defineConfig } from 'vite';
30
+ import { voidPlugin } from 'void';
31
+ import { voidHonoPages } from 'void-plugin-hono-jsx/plugin';
32
+
33
+ export default defineConfig({ plugins: [voidPlugin(), voidHonoPages()] });
34
+ ```
35
+
36
+ Configure project settings in `void.config.ts` using `defineConfig` from `void/config`. Run Vite from that project's root. `voidHonoPages()` includes the Hono JSX transform, so do not register a separate transform. Configure charset, viewport and favicon through Void's `head` settings.
37
+
38
+ Use `jsx: "react-jsx"` and `jsxImportSource: "hono/jsx"` in TypeScript. The plugin chooses the server or DOM factory for application JSX; the plugin resolves package runtime exports using Vite's actual consumer environment. Cloudflare worker environments may enable the browser condition, so that condition alone cannot select the renderer.
39
+
40
+ ## Runtime exports
41
+
42
+ ```tsx
43
+ import { Link, useRouter, useShared, useParams, useErrors } from 'void-plugin-hono-jsx/pages';
44
+ import { useForm } from 'void-plugin-hono-jsx/form';
45
+ ```
46
+
47
+ `Link` and `useRouter` use Void's shared navigation protocol. Ordinary GET visits reset page state; refresh preserves it. Root/nested/named layouts retain state while they remain in the active chain. Shared data, route params and field errors are available in SSR and browser components. `useForm` supports all mutation methods, named actions, validation, duplicate suppression and cancellation. Cancellation does not undo server writes.
48
+
49
+ Head resolution and escaping follow Void. The adapter synchronizes document attributes and inert script metadata; navigation does not execute newly inserted scripts. Raw `script.innerHTML` is authored content. Serialize dynamic JSON-LD and escape `<` before assigning it.
50
+
51
+ ## Static output and prerendering
52
+
53
+ Set `output: 'static'` in `void.config.ts`, then run `vite build`. Void collects known page routes and dynamic `getPrerenderPaths()` results and generates HTML plus `/_void/pages/*.json` under `dist/client`. The Hono adapter supplies its renderer and enables the shared router's static-data mode. It adds no route crawler.
54
+
55
+ ```ts
56
+ // pages/users/[id].server.ts
57
+ import { defineHandler } from 'void';
58
+ export const prerender = true;
59
+ export const getPrerenderPaths = () => [{ id: 'alice' }, { id: 'bob' }];
60
+ export const loader = defineHandler((c) => ({ id: c.req.param('id') }));
61
+ ```
62
+
63
+ Known routes auto-prerender in static output. `prerender = true` explicitly selects a route; `prerender = false` excludes it. Excluded routes and undeclared dynamic paths have no generated HTML or page-data file and require a server to work. Serve `dist/client` with clean URL mapping such as `/users/alice` to `users/alice.html`, including the generated assets and `/_void/pages` JSON files. Direct requests, reload and counters use the generated output; Pages links fetch the static JSON and keep the document.
64
+
65
+ Actions and named actions require a server. The plugin rejects them during static discovery. Deferred props are unsupported in static output and produce an explicit renderer error. Void 0.26.1 reports failed prerender paths as warnings, continues the build, and omits that path's HTML; verify the expected file set before release. The consumer check exercises both diagnostics and rejects any unexpected prerender failures in the supported fixture.
66
+
67
+ `fixtures/static` demonstrates loader-backed counters, declared dynamic paths and true/false overrides. `scripts/check-static-consumer.mjs`, called by `npm run check:consumer`, builds with Void's own collection and serves only generated files. ISR, CDN configuration and managed deployment are separate follow-up work.
68
+
69
+ ## Deferred loader props
70
+
71
+ Return `defer(() => slowOperation())` from a Void loader. The public Hono `Deferred<T>` type is Void's `DeferredState<T>` discriminated union. Read its readonly `loading`, `error` and `value` fields:
72
+
73
+ ```tsx
74
+ import type { Deferred } from 'void-plugin-hono-jsx/pages';
75
+ function Result({ slow }: { slow: Deferred<string> }) {
76
+ if (slow.loading) return <p>Loading</p>;
77
+ if (slow.error) return <p role="alert">{slow.error.message}</p>;
78
+ return <p>{slow.value}</p>;
79
+ }
80
+ ```
81
+
82
+ The server renders immediate props and a loading state before the resolver completes. Deferred pages load the Pages entry asynchronously, so Hono can mount while the initial HTML stream is open. Initial HTML callbacks and SPA NDJSON responses use Void's existing protocol and deferred registry. During initial component imports, the adapter buffers early completions in Void's existing queue and drains them when the registry is ready.
83
+
84
+ A resolved value or error updates the current page without resetting page-local state or surviving layouts. Updates from a pending page are ignored until that page commits. Void aborts and disposes old navigation resources; the adapter additionally accepts only updates belonging to the rendered props object.
85
+
86
+ Void 0.26.1 forwards ordinary deferred resolver error messages in development; production streams use `Internal Server Error`. Deliberate HTTP exception messages may be forwarded in production. The adapter turns the received message into an `Error` and exposes it through `error`; server stack traces are not included. Author deliberate HTTP exception messages suitable for display to users. This follows the verified streaming error behavior of the pinned Void version.
87
+
88
+ The verified contract covers single deferred props during GET document requests and SPA navigation. Grouped deferred props, deferred prefetching, mutation-driven deferred refresh and deferred island pages are excluded. The ordinary Pages action APIs remain documented under runtime exports.
89
+
90
+ ## Load-policy islands
91
+
92
+ Use a `.island.tsx` or `.island.jsx` page for static server content and import an interactive component using Void's import attribute:
93
+
94
+ ```tsx
95
+ import Counter from '../components/Counter' with { island: 'load' };
96
+ export const layout = false;
97
+ export default function Page() {
98
+ return <main><p>Static server content</p><Counter initialCount={9} message="Hello" /></main>;
99
+ }
100
+ ```
101
+
102
+ Place the counter outside the pages directory. It can use Hono hooks and event handlers normally. The adapter renders its initial HTML inside Void's `data-island`, `data-hydrate` and `data-props` markers, then uses `initIslands` to load and mount only interactive components. Props must be JSON-serializable values. Hono escapes the JSON attribute; parsed browser props match the server values. Mounting redraws the island contents. It does not reuse the server DOM.
103
+
104
+ A static-only island page emits no client script or module preload tags. Static page modules stay outside the normal Pages browser manifest. A project containing only static island pages builds empty client entry modules with no framework code. Void's own automatic prerender policy still applies to eligible island pages. Full static output and explicit prerender configuration follow the static output contract below.
105
+
106
+ Only the `load` policy, a single default relative TSX/JSX import, and attributes directly in the island page are supported. Other policies and malformed imports fail during discovery. Island layouts, nested attributed imports and island form helpers are unsupported. Direct imports of `void-plugin-hono-jsx/form` in an island page or component fail with a diagnostic; `useForm` also requires a full Pages provider at runtime. Use ordinary document links in static island pages. Pages navigation falls back to a document request for island routes. Static layouts remain server content and do not retain interactive state.
107
+
108
+ ## Experimental limitations
109
+
110
+ The virtual renderer/client entries follow version-specific conventions observed in Void's official adapter rather than a documented third-party registration API. Browser mounting redraws the SSR page; DOM reuse is not guaranteed. Local ISR uses Void's native direct-worker KV/Cache API runtime; managed edge behavior remains unverified. Unsupported modes fail clearly. Package publication and repository visibility are separate maintainer decisions.
111
+
112
+ ## Known upstream dependency advisory
113
+
114
+ The initial alpha accepts [GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) as a known limitation on the pinned peer baseline. The reviewed audits on 2026-10-11 reported Moderate findings propagated across multiple package entries from this single esbuild advisory; package-entry counts can differ between audit reports. The affected esbuild 0.18.20 is installed through Void 0.26.1 → drizzle-kit 0.31.11 → @esbuild-kit/esm-loader 2.6.5 → @esbuild-kit/core-utils 3.3.2.
115
+
116
+ The advisory concerns esbuild's own development server. The adapter uses Vite/Oxc for JSX and TypeScript for package compilation; the inspected upstream loader uses esbuild transforms. No esbuild serve invocation was found in the adapter or these loader modules, but other upstream commands and consumer configurations have not been exhaustively assessed. Avoid the affected esbuild serve feature. This is an accepted audit finding, not a claim of overall security or an audit-zero result.
117
+
118
+ The package adds no overrides and does not force changes to Void's internal dependencies. Consumers install the framework peers and their dependencies even though this archive contains no dependency implementation. Follow compatible upstream fixes; a peer upgrade requires renewed validation. New unreviewed advisories continue to block release preparation policy.
119
+
120
+ ## Verification
121
+
122
+ `npm run check:consumer` packs the package, audits its file list, copies a fixture into a temporary directory, installs locked peer dependencies and the tarball, and checks types, development rendering, production build, preview rendering and browser interaction. The consumer uses only the documented package exports and does not import repository source files.
123
+
124
+ Repository: https://github.com/ts-76/void-plugin-hono-jsx
125
+
126
+ ### Development reload and compatibility
127
+
128
+ Page and companion-loader edits reload the document and reset page/layout state. This is not state-preserving HMR. Added and removed page files update Void's route manifest without restarting the server. The adapter clears its discovery cache and uses Void's existing structural reload notifications; sending a second worker reload can mix SSR module instances during a request.
129
+
130
+ In development, the SSR renderer eagerly imports server-rendered page and layout modules with their providers. This keeps in-flight requests within the same module generation during a reload. Server-side module initialization therefore happens when the renderer loads, including pages not yet visited. Browser-only page components are excluded from this server graph. Production keeps lazy component imports. The packed-consumer regression also sends parallel SSR requests during six simultaneous page/loader revisions and requires every response to succeed.
131
+
132
+ The packed-consumer check edits a page, changes its loader value, adds and removes a route, checks counter interaction after reloading, and asserts that the server stays running. It also verifies actionable diagnostics for unverified Void, Hono and Vite versions. Development and production preview are exercised on the exact baseline above; no additional framework versions are claimed. The active CI workflow covers that baseline on Node 24.21.0. It also runs `npm run check:source` for navigation, persistent layouts, action forms and head metadata in development and production preview. Inspect the GitHub Actions result on the commit being reviewed.
133
+
134
+ ## Local ISR
135
+
136
+ For server output, set `routing.revalidate` in `void.config.ts` and override a page with `export const revalidate` in its companion server module. The adapter uses Void's existing cache runtime. The packed-consumer check verifies fresh and stale HTML, background regeneration, page/global/zero policies, retained valid HTML after regeneration failure, safe props, asset references and browser interaction.
137
+
138
+ The fixture binds local `ISR_CACHE` storage for Void production preview. Real deployments require Void's cache bindings; the fixture's placeholder ID and test control API are for local checks only. Managed edge, cross-data-center invalidation and production KV behavior remain unverified. ISR requires a worker/cache runtime; static output is served as generated files. See the repository `docs/isr.md` and [Void revalidation guide](https://void.cloud/guide/edge/revalidation).
139
+
140
+ ## Known local development limitation
141
+
142
+ On the tested macOS Void/Miniflare development runtime, a POST to an undefined named action can reset the next request on a reused connection. Declared-action browser behavior is verified; this transport edge case remains unresolved upstream. Managed runtime behavior for the edge case is unverified. Review this limitation before adopting the initial alpha.
@@ -0,0 +1,55 @@
1
+ # Third-party notices
2
+
3
+ The renderer/client entry conventions and provider integration were adapted from the published `@void/react` 0.26.0 adapter and Void 0.26.0. Both distributions include the following MIT notice. Hono is a peer dependency; its notice is included for reference. The project is separately licensed under MIT; these notices preserve upstream copyright attribution.
4
+
5
+ ## Void and @void/react
6
+
7
+ Source distributions: `void@0.26.0` and `@void/react@0.26.0`, published npm packages. The adapter imports framework APIs rather than bundling the framework.
8
+
9
+ MIT License
10
+
11
+ Copyright (c) 2026-present, Cloudflare, Inc.
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+
31
+ ## Hono
32
+
33
+ Source distribution: `hono@4.13.13`, published npm package. Hono is required as an exact peer dependency.
34
+
35
+ MIT License
36
+
37
+ Copyright (c) 2021 - present, Yusuke Wada and Hono contributors
38
+
39
+ Permission is hereby granted, free of charge, to any person obtaining a copy
40
+ of this software and associated documentation files (the "Software"), to deal
41
+ in the Software without restriction, including without limitation the rights
42
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
43
+ copies of the Software, and to permit persons to whom the Software is
44
+ furnished to do so, subject to the following conditions:
45
+
46
+ The above copyright notice and this permission notice shall be included in all
47
+ copies or substantial portions of the Software.
48
+
49
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
50
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
51
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
52
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
53
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
54
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
55
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ import { type VoidRouterConfig } from 'void/pages-client';
2
+ type Manifest = Pick<VoidRouterConfig, 'components' | 'layoutTree' | 'routeMeta' | 'matchRoute' | 'staticPageData' | 'staticPageDataFastPath'>;
3
+ export declare function startHonoPages(manifest: Manifest): Promise<void>;
4
+ export {};
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/dom/jsx-runtime";
2
+ import { render } from 'hono/jsx/dom';
3
+ import { jsx } from 'hono/jsx/dom/jsx-runtime';
4
+ import { useLayoutEffect, useState } from 'hono/jsx';
5
+ import { createVoidRouter } from 'void/pages-client';
6
+ import { parseCacheFor } from 'void/pages-prefetch';
7
+ import { PageContext, RouterContext } from "void-plugin-hono-jsx/pages";
8
+ import { createDocumentHead } from './document-head.js';
9
+ import { createDeferred, resolveDeferred, rejectDeferred, isLoading, bufferInitialDeferred } from './deferred.js';
10
+ function pageComponent(component) {
11
+ if (typeof component !== 'function')
12
+ throw new Error('Hono Pages: a page must export a component function.');
13
+ return component;
14
+ }
15
+ export async function startHonoPages(manifest) {
16
+ const root = document.getElementById('app');
17
+ if (!root)
18
+ throw new Error('Hono Pages: app root is missing.');
19
+ let update;
20
+ let revision = 0;
21
+ let applyDocumentHead;
22
+ let renderedProps;
23
+ let updateDeferred;
24
+ const initialization = createVoidRouter({
25
+ ...manifest,
26
+ adapter: {
27
+ createDeferred, resolveDeferred, rejectDeferred, isLoading,
28
+ onDeferredUpdate(props) {
29
+ if (props === renderedProps)
30
+ updateDeferred?.({ ...props });
31
+ },
32
+ async applyUpdate(page, component, layouts, options) {
33
+ layouts.forEach(pageComponent);
34
+ pageComponent(component);
35
+ if (!update)
36
+ throw new Error('Hono Pages: page rendering has not started.');
37
+ if (!options.preserveState)
38
+ revision++;
39
+ renderedProps = page.props;
40
+ update({ page: { ...page, props: { ...page.props } }, component, layouts, revision });
41
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
42
+ applyDocumentHead?.(page.head);
43
+ },
44
+ },
45
+ viewTransitions: false,
46
+ prefetchHoverDelay: 75,
47
+ prefetchDefaultCacheFor: parseCacheFor('30s'),
48
+ cacheVisitedPageData: false,
49
+ onNavigationChange(state) { root.dataset.navigation = state.state; },
50
+ });
51
+ const releaseBuffer = bufferInitialDeferred();
52
+ const navigation = await initialization.finally(releaseBuffer);
53
+ renderedProps = navigation.initialPageData.props;
54
+ applyDocumentHead = createDocumentHead(navigation.initialPageData.head);
55
+ function App() {
56
+ const [frame, setFrame] = useState({ page: { ...navigation.initialPageData, props: { ...navigation.initialPageData.props } }, component: navigation.initialComponent, layouts: navigation.initialLayouts, revision });
57
+ const [failed, setFailed] = useState(false);
58
+ useLayoutEffect(() => {
59
+ updateDeferred = (props) => setFrame((frame) => ({ ...frame, page: { ...frame.page, props } }));
60
+ update = (next) => { setFailed(false); setFrame(next); };
61
+ const onError = () => setFailed(true);
62
+ window.addEventListener('hono-pages:navigation-error', onError);
63
+ return () => { update = undefined; updateDeferred = undefined; window.removeEventListener('hono-pages:navigation-error', onError); };
64
+ }, []);
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
+ }
70
+ return _jsx(RouterContext.Provider, { value: navigation.router, children: _jsxs(PageContext.Provider, { value: frame.page, children: [failed && _jsx("p", { role: "alert", children: "The page could not be loaded. Please try again." }), content] }) });
71
+ }
72
+ render(_jsx(App, {}), root);
73
+ navigation.start();
74
+ navigation.commitInitialRender();
75
+ root.dataset.navigation = 'idle';
76
+ root.dataset.honoPages = 'mounted';
77
+ }
@@ -0,0 +1,13 @@
1
+ import type { DeferredState } from 'void';
2
+ export declare function createDeferred(): DeferredState<never>;
3
+ export declare function resolveDeferred(_ref: unknown, value: unknown): DeferredState<unknown>;
4
+ export declare function rejectDeferred(_ref: unknown, message: string): DeferredState<never>;
5
+ export declare function isLoading(ref: unknown): boolean;
6
+ declare global {
7
+ interface Window {
8
+ __resolveDeferred?: (group: string, data: unknown) => void;
9
+ __rejectDeferred?: (group: string, error: string) => void;
10
+ __deferredQueue?: Array<[string, unknown] | [string, 0, string]>;
11
+ }
12
+ }
13
+ export declare function bufferInitialDeferred(): () => void;
@@ -0,0 +1,22 @@
1
+ export function createDeferred() {
2
+ return { loading: true, value: null, error: null };
3
+ }
4
+ export function resolveDeferred(_ref, value) {
5
+ return { loading: false, value, error: null };
6
+ }
7
+ export function rejectDeferred(_ref, message) {
8
+ return { loading: false, value: null, error: new Error(message) };
9
+ }
10
+ export function isLoading(ref) {
11
+ return typeof ref === 'object' && ref !== null && 'loading' in ref && ref.loading === true;
12
+ }
13
+ export function bufferInitialDeferred() {
14
+ const resolve = window.__resolveDeferred;
15
+ const reject = window.__rejectDeferred;
16
+ window.__resolveDeferred = (group, data) => (window.__deferredQueue ??= []).push([group, data]);
17
+ window.__rejectDeferred = (group, error) => (window.__deferredQueue ??= []).push([group, 0, error]);
18
+ return () => {
19
+ window.__resolveDeferred = resolve;
20
+ window.__rejectDeferred = reject;
21
+ };
22
+ }
@@ -0,0 +1,3 @@
1
+ import type { HeadDescriptor } from 'void';
2
+ /** Complements Void's title/meta/link updates without executing navigation scripts. */
3
+ export declare function createDocumentHead(initial: HeadDescriptor | undefined): (head: HeadDescriptor | undefined) => void;
@@ -0,0 +1,44 @@
1
+ import { renderHeadToString } from 'void/pages-head';
2
+ function scriptSignature(script) {
3
+ return JSON.stringify([Array.from(script.attributes, ({ name, value }) => [name, value]).sort(), script.textContent]);
4
+ }
5
+ function scriptElements(head) {
6
+ const template = document.createElement('template');
7
+ template.innerHTML = renderHeadToString({ script: head?.script });
8
+ return Array.from(template.content.querySelectorAll('script'));
9
+ }
10
+ /** Complements Void's title/meta/link updates without executing navigation scripts. */
11
+ export function createDocumentHead(initial) {
12
+ let htmlKeys = Object.keys(initial?.htmlAttrs ?? {});
13
+ let bodyKeys = Object.keys(initial?.bodyAttrs ?? {});
14
+ let scripts = [];
15
+ const existing = Array.from(document.head.querySelectorAll('script'));
16
+ for (const desired of scriptElements(initial)) {
17
+ const index = existing.findIndex((script) => scriptSignature(script) === scriptSignature(desired));
18
+ if (index >= 0)
19
+ scripts.push(...existing.splice(index, 1));
20
+ }
21
+ function attributes(element, previous, next = {}) {
22
+ for (const key of previous)
23
+ if (!(key in next))
24
+ element.removeAttribute(key);
25
+ for (const [key, value] of Object.entries(next))
26
+ element.setAttribute(key, value);
27
+ return Object.keys(next);
28
+ }
29
+ return (head) => {
30
+ htmlKeys = attributes(document.documentElement, htmlKeys, head?.htmlAttrs);
31
+ bodyKeys = attributes(document.body, bodyKeys, head?.bodyAttrs);
32
+ const remaining = [...scripts];
33
+ scripts = scriptElements(head).map((desired) => {
34
+ const index = remaining.findIndex((script) => scriptSignature(script) === scriptSignature(desired));
35
+ if (index >= 0)
36
+ return remaining.splice(index, 1)[0];
37
+ // Template-parsed scripts remain inert when inserted. Head changes are metadata, not script execution.
38
+ document.head.appendChild(desired);
39
+ return desired;
40
+ });
41
+ for (const script of remaining)
42
+ script.remove();
43
+ };
44
+ }
@@ -0,0 +1,16 @@
1
+ import { type FormMethod } from 'void/pages-client';
2
+ export declare function useForm<T extends Record<string, unknown>>(initialData: T): {
3
+ data: T;
4
+ setData: (newState: T | ((currentState: T) => T)) => void;
5
+ errors: Record<string, string>;
6
+ pending: boolean;
7
+ wasSuccessful: boolean;
8
+ error: Error | null;
9
+ submit: (method: FormMethod, url: string) => Promise<void>;
10
+ cancel: () => void;
11
+ reset(): void;
12
+ post: (url: string) => Promise<void>;
13
+ put: (url: string) => Promise<void>;
14
+ patch: (url: string) => Promise<void>;
15
+ delete: (url: string) => Promise<void>;
16
+ };
@@ -0,0 +1,80 @@
1
+ import { useContext, useRef, useState } from 'hono/jsx';
2
+ import { isAbortError, submitAction } from 'void/pages-client';
3
+ import { RouterContext, useErrors, useRouter } from "void-plugin-hono-jsx/pages";
4
+ function fieldErrors(error) {
5
+ const body = error.body;
6
+ if (typeof body !== 'object' || body === null || !('errors' in body) || typeof body.errors !== 'object' || body.errors === null)
7
+ return {};
8
+ return Object.fromEntries(Object.entries(body.errors).filter((entry) => typeof entry[1] === 'string'));
9
+ }
10
+ export function useForm(initialData) {
11
+ if (!useContext(RouterContext))
12
+ throw new Error('Hono Pages: useForm requires the full Pages renderer; island form helpers are unsupported.');
13
+ const router = useRouter();
14
+ const initialErrors = useErrors();
15
+ const [data, updateData] = useState(initialData);
16
+ const [errors, setErrors] = useState(initialErrors);
17
+ const [pending, setPending] = useState(false);
18
+ const [wasSuccessful, setSuccessful] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ const active = useRef(false);
21
+ const generation = useRef(0);
22
+ const setData = (next) => {
23
+ updateData(next);
24
+ setSuccessful(false);
25
+ setErrors({});
26
+ setError(null);
27
+ };
28
+ async function submit(method, url) {
29
+ if (active.current)
30
+ return;
31
+ active.current = true;
32
+ const id = ++generation.current;
33
+ setPending(true);
34
+ setSuccessful(false);
35
+ setError(null);
36
+ setErrors({});
37
+ try {
38
+ const result = await submitAction(router, url, { method: method.toUpperCase(), data, preserveScroll: true });
39
+ if (id !== generation.current)
40
+ return;
41
+ if (result.ok)
42
+ setSuccessful(true);
43
+ else {
44
+ setError(result.error);
45
+ setErrors(fieldErrors(result.error));
46
+ }
47
+ }
48
+ catch (failure) {
49
+ if (id === generation.current && !isAbortError(failure)) {
50
+ setError(failure instanceof Error ? failure : new Error(String(failure)));
51
+ }
52
+ }
53
+ finally {
54
+ if (id === generation.current) {
55
+ active.current = false;
56
+ setPending(false);
57
+ }
58
+ }
59
+ }
60
+ function cancel() {
61
+ if (!active.current)
62
+ return;
63
+ const id = ++generation.current;
64
+ active.current = false;
65
+ setPending(false);
66
+ setSuccessful(false);
67
+ void router.refresh().catch((failure) => {
68
+ if (id === generation.current)
69
+ setError(failure instanceof Error ? failure : new Error(String(failure)));
70
+ });
71
+ }
72
+ return {
73
+ data, setData, errors, pending, wasSuccessful, error, submit, cancel,
74
+ reset() { setData(initialData); },
75
+ post: (url) => submit('post', url),
76
+ put: (url) => submit('put', url),
77
+ patch: (url) => submit('patch', url),
78
+ delete: (url) => submit('delete', url),
79
+ };
80
+ }
@@ -0,0 +1,17 @@
1
+ import { type JSX } from 'hono/jsx';
2
+ import type { VoidRouter } from 'void/pages-client';
3
+ import type { PageObject } from 'void/pages-protocol';
4
+ export type { DeferredState as Deferred } from 'void';
5
+ export declare const RouterContext: import("hono/jsx").Context<VoidRouter | null>;
6
+ export declare const PageContext: import("hono/jsx").Context<PageObject | null>;
7
+ export declare function useShared(): Record<string, unknown>;
8
+ export declare function useParams(): Record<string, string>;
9
+ export declare function useErrors(): Record<string, string>;
10
+ export declare function useRouter(): VoidRouter;
11
+ type LinkProps = Omit<JSX.IntrinsicElements['a'], 'href' | 'onClick'> & {
12
+ href: string;
13
+ reloadDocument?: boolean;
14
+ replace?: boolean;
15
+ onClick?: (event: MouseEvent) => void;
16
+ };
17
+ export declare function Link({ href, reloadDocument, replace, onClick, ...attributes }: LinkProps): import("hono/jsx/dom/jsx-dev-runtime").JSX.Element;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx } from "hono/jsx/dom/jsx-runtime";
2
+ import { createContext, useContext } from 'hono/jsx';
3
+ export const RouterContext = createContext(null);
4
+ export const PageContext = createContext(null);
5
+ function usePage() {
6
+ const page = useContext(PageContext);
7
+ if (!page)
8
+ throw new Error('Hono Pages: page data requires a page provider.');
9
+ return page;
10
+ }
11
+ export function useShared() { return usePage().shared; }
12
+ export function useParams() { return usePage().params; }
13
+ export function useErrors() { return usePage().errors ?? {}; }
14
+ export function useRouter() {
15
+ const router = useContext(RouterContext);
16
+ if (!router)
17
+ throw new Error('Hono Pages: useRouter requires a page provider.');
18
+ return router;
19
+ }
20
+ export function Link({ href, reloadDocument, replace, onClick, ...attributes }) {
21
+ const router = useRouter();
22
+ return _jsx("a", { ...attributes, href: href, onClick: (event) => {
23
+ onClick?.(event);
24
+ if (event.defaultPrevented || reloadDocument || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
25
+ return;
26
+ if (attributes.download !== undefined || (attributes.target && attributes.target !== '_self') || attributes.rel?.split(/\s+/).includes('external'))
27
+ return;
28
+ const url = new URL(href, window.location.href);
29
+ if (url.origin !== window.location.origin || !['http:', 'https:'].includes(url.protocol))
30
+ return;
31
+ event.preventDefault();
32
+ void router.visit(url, { replace }).catch((error) => {
33
+ window.dispatchEvent(new CustomEvent('hono-pages:navigation-error', { detail: error }));
34
+ });
35
+ } });
36
+ }
@@ -0,0 +1 @@
1
+ export declare function assertSupportedFrameworks(): void;
@@ -0,0 +1,29 @@
1
+ import { createRequire } from 'node:module';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ const supported = { void: '0.26.1', hono: '4.13.13', vite: '8.3.3' };
5
+ export function assertSupportedFrameworks() {
6
+ const require = createRequire(import.meta.url);
7
+ for (const [name, expected] of Object.entries(supported)) {
8
+ let directory = dirname(require.resolve(name));
9
+ let version;
10
+ while (true) {
11
+ const path = join(directory, 'package.json');
12
+ if (existsSync(path)) {
13
+ const metadata = JSON.parse(readFileSync(path, 'utf8'));
14
+ if (typeof metadata === 'object' && metadata !== null && 'name' in metadata && metadata.name === name) {
15
+ if (!('version' in metadata) || typeof metadata.version !== 'string')
16
+ throw new Error(`Hono Pages: ${name} package version is missing.`);
17
+ version = metadata.version;
18
+ break;
19
+ }
20
+ }
21
+ const parent = dirname(directory);
22
+ if (parent === directory)
23
+ throw new Error(`Hono Pages: cannot locate ${name} package metadata.`);
24
+ directory = parent;
25
+ }
26
+ if (version !== expected)
27
+ throw new Error(`Hono Pages: ${name} ${version} is unverified; install ${name}@${expected}. The adapter requires void@0.26.1, hono@4.13.13 and vite@8.3.3.`);
28
+ }
29
+ }
@@ -0,0 +1,3 @@
1
+ import { type Plugin } from 'vite';
2
+ /** Narrow adapter for Void's load-on-page-load island convention. */
3
+ export declare function honoIslands(): Plugin;
@@ -0,0 +1,112 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { normalizePath, parseSync } from 'vite';
4
+ import { scanPages, resolvePagesDir } from 'void/pages';
5
+ import { findIslandImports, editIslandSource, resolveSpecifier, computePagesNeedingClientJS, ISLANDS_RENDERER_ID, ISLANDS_CLIENT_ENTRY_ID } from 'void/pages-islands-plugin';
6
+ /** Narrow adapter for Void's load-on-page-load island convention. */
7
+ export function honoIslands() {
8
+ const root = process.cwd();
9
+ const pagesDir = resolvePagesDir(root);
10
+ const islands = new Map();
11
+ const pageFiles = new Set();
12
+ let scan;
13
+ function islandImports(code, filename) {
14
+ const found = findIslandImports(code, filename);
15
+ const attributed = parseSync(filename, code).program.body.filter((node) => node.type === 'ImportDeclaration' && node.attributes?.some(({ key }) => ('name' in key && key.name === 'island') || ('value' in key && key.value === 'island')));
16
+ if (attributed.length !== found.length)
17
+ throw new Error(`Hono islands: use one default import with exactly one island: 'load' attribute in ${filename}.`);
18
+ return found;
19
+ }
20
+ function imports(code, filename) {
21
+ const found = islandImports(code, filename);
22
+ for (const entry of found) {
23
+ if (entry.hydrate !== 'load')
24
+ throw new Error(`Hono islands: unsupported policy '${entry.hydrate}' in ${filename}; use island: 'load'.`);
25
+ if (!entry.specifier.startsWith('.'))
26
+ throw new Error(`Hono islands: use a relative TSX/JSX component import in ${filename}.`);
27
+ const path = resolveSpecifier(entry.specifier, join(filename, '..'), ['.tsx', '.jsx']);
28
+ if (!path || !/\.[jt]sx$/.test(path))
29
+ throw new Error(`Hono islands: cannot resolve TSX/JSX component ${entry.specifier} in ${filename}.`);
30
+ const canonical = realpathSync(path);
31
+ const id = normalizePath(relative(root, canonical));
32
+ islands.set(id, canonical);
33
+ }
34
+ return found;
35
+ }
36
+ async function rescan() {
37
+ scan = await scanPages(root, { pagesDir });
38
+ islands.clear();
39
+ pageFiles.clear();
40
+ for (const page of scan.pages.filter((page) => page.island)) {
41
+ const filename = realpathSync(join(pagesDir, page.componentPath));
42
+ pageFiles.add(filename);
43
+ imports(readFileSync(filename, 'utf8'), filename);
44
+ }
45
+ }
46
+ return {
47
+ name: 'void-hono:islands',
48
+ enforce: 'pre',
49
+ buildStart: rescan,
50
+ async hotUpdate({ file }) {
51
+ if (normalizePath(file).startsWith(normalizePath(pagesDir) + '/'))
52
+ await rescan();
53
+ },
54
+ config() {
55
+ return { build: { rollupOptions: { input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID } } } };
56
+ },
57
+ resolveId(id) {
58
+ if (id === ISLANDS_RENDERER_ID || id === ISLANDS_CLIENT_ENTRY_ID)
59
+ return `\0${id}`;
60
+ },
61
+ transform(code, id) {
62
+ const filename = id.split('?')[0];
63
+ if (!/\.[jt]sx$/.test(filename) || filename.includes('/node_modules/'))
64
+ return;
65
+ const canonical = realpathSync(filename);
66
+ if ((pageFiles.has(canonical) || [...islands.values()].includes(canonical)) && parseSync(filename, code).program.body.some((node) => node.type === 'ImportDeclaration' && node.source.value === 'void-plugin-hono-jsx/form')) {
67
+ throw new Error('Hono islands: form helpers are unsupported; use a full Pages component.');
68
+ }
69
+ const found = islandImports(code, filename);
70
+ if (!found.length)
71
+ return;
72
+ if (!pageFiles.has(realpathSync(filename)))
73
+ throw new Error('Hono islands: attributed imports are supported only directly in .island.tsx/.island.jsx pages.');
74
+ imports(code, filename);
75
+ return editIslandSource(code, found.map((entry) => {
76
+ const path = resolveSpecifier(entry.specifier, join(filename, '..'), ['.tsx', '.jsx']);
77
+ if (!path)
78
+ throw new Error(`Hono islands: missing component ${entry.specifier}.`);
79
+ const islandId = normalizePath(relative(root, realpathSync(path)));
80
+ const component = `__honoIslandComponent${entry.start}`;
81
+ return { start: entry.start, end: entry.end, text: `import ${component} from ${entry.specifierRaw};
82
+ const ${entry.importName} = (props) => __honoIslandJsx('div', { 'data-island': ${JSON.stringify(islandId)}, 'data-hydrate': 'load', 'data-props': JSON.stringify(props), children: __honoIslandJsx(${component}, props) });` };
83
+ }), { prepend: "import { jsx as __honoIslandJsx } from 'hono/jsx/jsx-runtime';\n" });
84
+ },
85
+ load(id) {
86
+ if (id === `\0${ISLANDS_RENDERER_ID}`) {
87
+ if (!scan)
88
+ throw new Error('Hono islands: page discovery has not completed.');
89
+ const interactive = [...computePagesNeedingClientJS(scan, pagesDir, null)];
90
+ return `import { renderPage } from 'virtual:void-pages-renderer';
91
+ const interactive = new Set(${JSON.stringify(interactive)});
92
+ export function renderIslandPage(page, assets) {
93
+ if (page.deferred?.length || page.deferredKeys) throw new Error('Hono islands: deferred props are unsupported.');
94
+ return renderPage(page, interactive.has(page.component) ? assets : { ...assets, preloads: '', body: '' });
95
+ }`;
96
+ }
97
+ if (id === `\0${ISLANDS_CLIENT_ENTRY_ID}`) {
98
+ if (!islands.size)
99
+ return 'export {};';
100
+ const loaders = [...islands].map(([name, path]) => `${JSON.stringify(name)}: () => import(${JSON.stringify(path)})`).join(',\n');
101
+ return `import { initIslands } from 'void/pages-client';
102
+ import { jsx } from 'hono/jsx/dom/jsx-runtime';
103
+ import { render } from 'hono/jsx/dom';
104
+ initIslands({ ${loaders} }, (element, module, props) => {
105
+ if (typeof module.default !== 'function') throw new Error('Hono islands: a component must export a default function.');
106
+ render(jsx(module.default, props), element);
107
+ element.setAttribute('data-hono-island', 'mounted');
108
+ });`;
109
+ }
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,3 @@
1
+ import { type Plugin } from 'vite';
2
+ /** JSX factories differ by Vite environment; hooks can be shared via hono/jsx. */
3
+ export declare function honoJsx(): Plugin;
@@ -0,0 +1,20 @@
1
+ import { transformWithOxc } from 'vite';
2
+ /** JSX factories differ by Vite environment; hooks can be shared via hono/jsx. */
3
+ export function honoJsx() {
4
+ return {
5
+ name: 'poc:hono-jsx',
6
+ enforce: 'pre',
7
+ async transform(code, id) {
8
+ const filename = id.split('?')[0];
9
+ if (!/\.[jt]sx$/.test(filename) || filename.includes('/node_modules/'))
10
+ return;
11
+ return transformWithOxc(code, filename, {
12
+ jsx: {
13
+ runtime: 'automatic',
14
+ importSource: this.environment.config.consumer === 'client' ? 'hono/jsx/dom' : 'hono/jsx',
15
+ development: this.environment.mode === 'dev',
16
+ },
17
+ });
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from 'vite';
2
+ /** Experimental Pages contract verified against Void 0.26.1. */
3
+ export declare function voidHonoPages(): Plugin[];
@@ -0,0 +1,148 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { normalizePath } from 'vite';
3
+ import { pagesRuntimePath, clientRuntimePath, publicRuntimeModules } from './runtime-paths.js';
4
+ import { generateComponentManifest, resolvePagesDir, resolveStaticPageDataBuildOptions, scanPages } from 'void/pages';
5
+ import { honoJsx } from './hono-jsx.js';
6
+ import { honoIslands } from './hono-islands.js';
7
+ import { assertSupportedFrameworks } from './compatibility.js';
8
+ const rendererId = 'virtual:void-pages-renderer';
9
+ const clientId = 'virtual:void-pages-client';
10
+ /** Experimental Pages contract verified against Void 0.26.1. */
11
+ export function voidHonoPages() {
12
+ const root = process.cwd();
13
+ const pagesDir = resolvePagesDir(root);
14
+ const staticData = resolveStaticPageDataBuildOptions(root);
15
+ let scan;
16
+ async function rescan() {
17
+ scan = await scanPages(root, { pagesDir });
18
+ for (const layout of [...scan.layouts, ...scan.namedLayouts]) {
19
+ if (layout.island || !/\.[jt]sx$/.test(layout.filePath))
20
+ throw new Error('Hono Pages: only standard TSX/JSX layouts are supported.');
21
+ }
22
+ for (const page of scan.pages) {
23
+ if (!/\.[jt]sx$/.test(page.componentPath)) {
24
+ throw new Error(`Hono Pages: only standard TSX/JSX pages are supported: ${page.componentPath}.`);
25
+ }
26
+ if (staticData.enabled && page.methods.some((method) => method === 'action' || method === 'actions')) {
27
+ throw new Error(`Hono Pages: actions require a server and cannot be used with static output: ${page.componentPath}.`);
28
+ }
29
+ }
30
+ return scan;
31
+ }
32
+ return [honoIslands(), honoJsx(), {
33
+ name: 'void-hono:pages',
34
+ enforce: 'pre',
35
+ config() {
36
+ assertSupportedFrameworks();
37
+ return {
38
+ resolve: { dedupe: ['hono'] },
39
+ build: { manifest: true, rollupOptions: { input: { 'pages-client': clientId } } },
40
+ };
41
+ },
42
+ configEnvironment() {
43
+ const modules = Object.keys(publicRuntimeModules);
44
+ const packages = [...new Set(modules.map((id) => id.slice(0, id.lastIndexOf('/'))))];
45
+ return { optimizeDeps: { exclude: ['hono', ...modules] }, resolve: { noExternal: ['hono', ...packages] } };
46
+ },
47
+ async buildStart() {
48
+ await rescan();
49
+ },
50
+ configureServer(server) {
51
+ const clear = (file) => {
52
+ if (normalizePath(file).startsWith(normalizePath(pagesDir) + '/'))
53
+ scan = undefined;
54
+ };
55
+ server.watcher.on('add', clear);
56
+ server.watcher.on('unlink', clear);
57
+ server.httpServer?.once('close', () => { server.watcher.off('add', clear); server.watcher.off('unlink', clear); });
58
+ },
59
+ async hotUpdate({ file, type, modules, timestamp }) {
60
+ if (!normalizePath(file).startsWith(normalizePath(pagesDir) + '/'))
61
+ return;
62
+ await rescan();
63
+ if (type !== 'update')
64
+ return [];
65
+ const invalidated = new Set();
66
+ for (const module of modules)
67
+ this.environment.moduleGraph.invalidateModule(module, invalidated, timestamp, true);
68
+ for (const id of [rendererId, clientId]) {
69
+ const module = this.environment.moduleGraph.getModuleById(`\0${id}`);
70
+ if (module)
71
+ this.environment.moduleGraph.invalidateModule(module, invalidated, timestamp, true);
72
+ }
73
+ this.environment.hot.send({ type: 'full-reload' });
74
+ return [];
75
+ },
76
+ resolveId(id) {
77
+ if (id === rendererId || id === clientId)
78
+ return `\0${id}`;
79
+ if (id === pagesRuntimePath || id === clientRuntimePath)
80
+ return { id: realpathSync(id), external: false };
81
+ const runtime = publicRuntimeModules[id];
82
+ if (runtime)
83
+ return { id: realpathSync(this.environment.config.consumer === 'client' ? runtime.client : runtime.server), external: false };
84
+ },
85
+ async load(id) {
86
+ if (id !== `\0${rendererId}` && id !== `\0${clientId}`)
87
+ return;
88
+ const currentScan = scan ?? await rescan();
89
+ const serverSnapshot = id === `\0${rendererId}` && this.environment.mode === 'dev';
90
+ const manifestScan = id === `\0${clientId}`
91
+ ? { ...currentScan, pages: currentScan.pages.filter((page) => !page.island) }
92
+ : serverSnapshot ? { ...currentScan, pages: currentScan.pages.filter((page) => page.renderMode !== 'client') } : currentScan;
93
+ let manifest = generateComponentManifest(manifestScan, pagesDir);
94
+ if (serverSnapshot) {
95
+ // A request can retain the old renderer while the worker module runner
96
+ // reloads. Capture components with its providers instead of importing
97
+ // a new generation into an in-flight render. Browser-only pages remain
98
+ // absent from this server graph; production keeps lazy imports.
99
+ const imports = [];
100
+ manifest = manifest.replace(/\(\) => import\(("(?:[^"\\]|\\.)*")\)/g, (_, path) => {
101
+ const name = `__honoPageSnapshot${imports.length}`;
102
+ imports.push(`import * as ${name} from ${path};`);
103
+ return `() => ${name}`;
104
+ });
105
+ manifest = imports.join('\n') + '\n' + manifest;
106
+ }
107
+ if (id === `\0${clientId}` && !currentScan.pages.some((page) => !page.island))
108
+ return 'export {};';
109
+ if (id === `\0${rendererId}`) {
110
+ return `
111
+ import { jsx } from 'hono/jsx';
112
+ import { createSsrRouter } from 'void/pages-client';
113
+ import { PageContext, RouterContext } from ${JSON.stringify(pagesRuntimePath)};
114
+ import { serializePageData } from 'void/pages-server';
115
+ import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from 'void/pages-head';
116
+ ${manifest}
117
+ export async function renderPage(pageObj, assetTags) {
118
+ if (${JSON.stringify(staticData.enabled)} && pageObj.deferredKeys) throw new Error('Hono Pages: deferred props cannot be statically prerendered; remove defer().');
119
+ const load = components[pageObj.component];
120
+ if (!load) throw new Error('Hono Pages: unknown page component ' + pageObj.component);
121
+ const { default: Page } = await load();
122
+ if (typeof Page !== 'function') throw new Error('Hono Pages: a page must export a component function.');
123
+ let content = jsx(Page, pageObj.props);
124
+ const ids = layoutTree[pageObj.component] ?? [];
125
+ const layouts = await Promise.all(ids.map(async (id) => (await components[id]()).default));
126
+ for (const Layout of layouts.reverse()) {
127
+ if (typeof Layout !== 'function') throw new Error('Hono Pages: a layout must export a component function.');
128
+ content = jsx(Layout, { children: content });
129
+ }
130
+ const markup = await jsx(RouterContext.Provider, { value: createSsrRouter(pageObj.url, pageObj.params), children: jsx(PageContext.Provider, { value: pageObj, children: content }) }).toString();
131
+ const head = pageObj.head;
132
+ const body = pageObj.deferredKeys ? assetTags.body.replaceAll('<script type="module"', '<script async type="module"') : assetTags.body;
133
+ return '<!doctype html><html' + (head ? renderHtmlAttrs(head) : '') + '><head>'
134
+ + (head ? renderHeadToString(head) : '') + assetTags.css + assetTags.preloads
135
+ + '</head><body' + (head ? renderBodyAttrs(head) : '') + '>'
136
+ + '<script id="__VOID_PAGE_DATA__" type="application/json">' + serializePageData(pageObj) + '</script>'
137
+ + '<div id="app">' + markup + '</div>' + body + '</body></html>';
138
+ }
139
+ `;
140
+ }
141
+ return `
142
+ import { startHonoPages } from ${JSON.stringify(clientRuntimePath)};
143
+ ${manifest}
144
+ await startHonoPages({ components, layoutTree, routeMeta, matchRoute, staticPageData: ${JSON.stringify(staticData.enabled)}, staticPageDataFastPath: ${JSON.stringify(staticData.fastPath)} });
145
+ `;
146
+ },
147
+ }];
148
+ }
@@ -0,0 +1,6 @@
1
+ export declare const pagesRuntimePath: string;
2
+ export declare const clientRuntimePath: string;
3
+ export declare const publicRuntimeModules: Record<string, {
4
+ server: string;
5
+ client: string;
6
+ }>;
@@ -0,0 +1,7 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ export const pagesRuntimePath = fileURLToPath(new URL('../server/pages.js', import.meta.url));
3
+ export const clientRuntimePath = fileURLToPath(new URL('../browser/client.js', import.meta.url));
4
+ export const publicRuntimeModules = {
5
+ "void-plugin-hono-jsx/pages": { server: fileURLToPath(new URL('../server/pages.js', import.meta.url)), client: fileURLToPath(new URL('../browser/pages.js', import.meta.url)) },
6
+ "void-plugin-hono-jsx/form": { server: fileURLToPath(new URL('../server/form.js', import.meta.url)), client: fileURLToPath(new URL('../browser/form.js', import.meta.url)) },
7
+ };
@@ -0,0 +1,16 @@
1
+ import { type FormMethod } from 'void/pages-client';
2
+ export declare function useForm<T extends Record<string, unknown>>(initialData: T): {
3
+ data: T;
4
+ setData: (newState: T | ((currentState: T) => T)) => void;
5
+ errors: Record<string, string>;
6
+ pending: boolean;
7
+ wasSuccessful: boolean;
8
+ error: Error | null;
9
+ submit: (method: FormMethod, url: string) => Promise<void>;
10
+ cancel: () => void;
11
+ reset(): void;
12
+ post: (url: string) => Promise<void>;
13
+ put: (url: string) => Promise<void>;
14
+ patch: (url: string) => Promise<void>;
15
+ delete: (url: string) => Promise<void>;
16
+ };
@@ -0,0 +1,80 @@
1
+ import { useContext, useRef, useState } from 'hono/jsx';
2
+ import { isAbortError, submitAction } from 'void/pages-client';
3
+ import { RouterContext, useErrors, useRouter } from "void-plugin-hono-jsx/pages";
4
+ function fieldErrors(error) {
5
+ const body = error.body;
6
+ if (typeof body !== 'object' || body === null || !('errors' in body) || typeof body.errors !== 'object' || body.errors === null)
7
+ return {};
8
+ return Object.fromEntries(Object.entries(body.errors).filter((entry) => typeof entry[1] === 'string'));
9
+ }
10
+ export function useForm(initialData) {
11
+ if (!useContext(RouterContext))
12
+ throw new Error('Hono Pages: useForm requires the full Pages renderer; island form helpers are unsupported.');
13
+ const router = useRouter();
14
+ const initialErrors = useErrors();
15
+ const [data, updateData] = useState(initialData);
16
+ const [errors, setErrors] = useState(initialErrors);
17
+ const [pending, setPending] = useState(false);
18
+ const [wasSuccessful, setSuccessful] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ const active = useRef(false);
21
+ const generation = useRef(0);
22
+ const setData = (next) => {
23
+ updateData(next);
24
+ setSuccessful(false);
25
+ setErrors({});
26
+ setError(null);
27
+ };
28
+ async function submit(method, url) {
29
+ if (active.current)
30
+ return;
31
+ active.current = true;
32
+ const id = ++generation.current;
33
+ setPending(true);
34
+ setSuccessful(false);
35
+ setError(null);
36
+ setErrors({});
37
+ try {
38
+ const result = await submitAction(router, url, { method: method.toUpperCase(), data, preserveScroll: true });
39
+ if (id !== generation.current)
40
+ return;
41
+ if (result.ok)
42
+ setSuccessful(true);
43
+ else {
44
+ setError(result.error);
45
+ setErrors(fieldErrors(result.error));
46
+ }
47
+ }
48
+ catch (failure) {
49
+ if (id === generation.current && !isAbortError(failure)) {
50
+ setError(failure instanceof Error ? failure : new Error(String(failure)));
51
+ }
52
+ }
53
+ finally {
54
+ if (id === generation.current) {
55
+ active.current = false;
56
+ setPending(false);
57
+ }
58
+ }
59
+ }
60
+ function cancel() {
61
+ if (!active.current)
62
+ return;
63
+ const id = ++generation.current;
64
+ active.current = false;
65
+ setPending(false);
66
+ setSuccessful(false);
67
+ void router.refresh().catch((failure) => {
68
+ if (id === generation.current)
69
+ setError(failure instanceof Error ? failure : new Error(String(failure)));
70
+ });
71
+ }
72
+ return {
73
+ data, setData, errors, pending, wasSuccessful, error, submit, cancel,
74
+ reset() { setData(initialData); },
75
+ post: (url) => submit('post', url),
76
+ put: (url) => submit('put', url),
77
+ patch: (url) => submit('patch', url),
78
+ delete: (url) => submit('delete', url),
79
+ };
80
+ }
@@ -0,0 +1,17 @@
1
+ import { type JSX } from 'hono/jsx';
2
+ import type { VoidRouter } from 'void/pages-client';
3
+ import type { PageObject } from 'void/pages-protocol';
4
+ export type { DeferredState as Deferred } from 'void';
5
+ export declare const RouterContext: import("hono/jsx").Context<VoidRouter | null>;
6
+ export declare const PageContext: import("hono/jsx").Context<PageObject | null>;
7
+ export declare function useShared(): Record<string, unknown>;
8
+ export declare function useParams(): Record<string, string>;
9
+ export declare function useErrors(): Record<string, string>;
10
+ export declare function useRouter(): VoidRouter;
11
+ type LinkProps = Omit<JSX.IntrinsicElements['a'], 'href' | 'onClick'> & {
12
+ href: string;
13
+ reloadDocument?: boolean;
14
+ replace?: boolean;
15
+ onClick?: (event: MouseEvent) => void;
16
+ };
17
+ export declare function Link({ href, reloadDocument, replace, onClick, ...attributes }: LinkProps): import("hono/jsx/jsx-dev-runtime").JSX.Element;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
+ import { createContext, useContext } from 'hono/jsx';
3
+ export const RouterContext = createContext(null);
4
+ export const PageContext = createContext(null);
5
+ function usePage() {
6
+ const page = useContext(PageContext);
7
+ if (!page)
8
+ throw new Error('Hono Pages: page data requires a page provider.');
9
+ return page;
10
+ }
11
+ export function useShared() { return usePage().shared; }
12
+ export function useParams() { return usePage().params; }
13
+ export function useErrors() { return usePage().errors ?? {}; }
14
+ export function useRouter() {
15
+ const router = useContext(RouterContext);
16
+ if (!router)
17
+ throw new Error('Hono Pages: useRouter requires a page provider.');
18
+ return router;
19
+ }
20
+ export function Link({ href, reloadDocument, replace, onClick, ...attributes }) {
21
+ const router = useRouter();
22
+ return _jsx("a", { ...attributes, href: href, onClick: (event) => {
23
+ onClick?.(event);
24
+ if (event.defaultPrevented || reloadDocument || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
25
+ return;
26
+ if (attributes.download !== undefined || (attributes.target && attributes.target !== '_self') || attributes.rel?.split(/\s+/).includes('external'))
27
+ return;
28
+ const url = new URL(href, window.location.href);
29
+ if (url.origin !== window.location.origin || !['http:', 'https:'].includes(url.protocol))
30
+ return;
31
+ event.preventDefault();
32
+ void router.visit(url, { replace }).catch((error) => {
33
+ window.dispatchEvent(new CustomEvent('hono-pages:navigation-error', { detail: error }));
34
+ });
35
+ } });
36
+ }
package/package.json CHANGED
@@ -1,20 +1,58 @@
1
1
  {
2
2
  "name": "void-plugin-hono-jsx",
3
- "version": "0.0.0-bootstrap.0",
3
+ "version": "0.1.0-alpha.0",
4
4
  "private": false,
5
- "description": "Registration-only staging candidate; not the adapter release",
5
+ "description": "Experimental Hono JSX Pages adapter for Void 0.26.1",
6
+ "type": "module",
6
7
  "license": "MIT",
8
+ "engines": {
9
+ "node": ">=24.21.0"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "THIRD_PARTY_NOTICES.md",
15
+ "LICENSE",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "exports": {
19
+ "./plugin": {
20
+ "types": "./dist/plugins/hono-pages.d.ts",
21
+ "import": "./dist/plugins/hono-pages.js"
22
+ },
23
+ "./pages": {
24
+ "types": "./dist/server/pages.d.ts",
25
+ "default": "./dist/server/pages.js"
26
+ },
27
+ "./form": {
28
+ "types": "./dist/server/form.d.ts",
29
+ "default": "./dist/server/form.js"
30
+ }
31
+ },
32
+ "peerDependencies": {
33
+ "hono": "4.13.13",
34
+ "void": "0.26.1",
35
+ "vite": "8.3.3"
36
+ },
7
37
  "repository": {
8
38
  "type": "git",
9
- "url": "https://github.com/ts-76/void-plugin-hono-jsx.git"
39
+ "url": "https://github.com/ts-76/void-plugin-hono-jsx.git",
40
+ "directory": "packages/hono-pages"
10
41
  },
11
42
  "publishConfig": {
12
- "registry": "https://registry.npmjs.org/",
13
43
  "access": "public",
14
- "tag": "bootstrap"
44
+ "registry": "https://registry.npmjs.org/",
45
+ "tag": "next"
15
46
  },
16
- "files": [
17
- "README.md",
18
- "LICENSE"
47
+ "bugs": {
48
+ "url": "https://github.com/ts-76/void-plugin-hono-jsx/issues"
49
+ },
50
+ "homepage": "https://github.com/ts-76/void-plugin-hono-jsx#readme",
51
+ "keywords": [
52
+ "void",
53
+ "hono",
54
+ "jsx",
55
+ "vite",
56
+ "ssr"
19
57
  ]
20
58
  }