void-plugin-hono-jsx 0.1.0-alpha.2 → 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 (95) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +73 -20
  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/compatibility.d.ts +12 -1
  33. package/dist/plugins/compatibility.d.ts.map +1 -1
  34. package/dist/plugins/compatibility.js +65 -22
  35. package/dist/plugins/compatibility.js.map +1 -1
  36. package/dist/plugins/hono-islands.d.ts +1 -1
  37. package/dist/plugins/hono-islands.d.ts.map +1 -1
  38. package/dist/plugins/hono-islands.js +156 -24
  39. package/dist/plugins/hono-islands.js.map +1 -1
  40. package/dist/plugins/hono-markdown.d.ts +4 -0
  41. package/dist/plugins/hono-markdown.d.ts.map +1 -0
  42. package/dist/plugins/hono-markdown.js +91 -0
  43. package/dist/plugins/hono-markdown.js.map +1 -0
  44. package/dist/plugins/hono-pages.d.ts +10 -2
  45. package/dist/plugins/hono-pages.d.ts.map +1 -1
  46. package/dist/plugins/hono-pages.js +55 -18
  47. package/dist/plugins/hono-pages.js.map +1 -1
  48. package/dist/plugins/markdown-api.d.ts +10 -0
  49. package/dist/plugins/markdown-api.d.ts.map +1 -0
  50. package/dist/plugins/markdown-api.js +13 -0
  51. package/dist/plugins/markdown-api.js.map +1 -0
  52. package/dist/plugins/markdown-compiler.d.ts +43 -0
  53. package/dist/plugins/markdown-compiler.d.ts.map +1 -0
  54. package/dist/plugins/markdown-compiler.js +91 -0
  55. package/dist/plugins/markdown-compiler.js.map +1 -0
  56. package/dist/plugins/resolve-component.d.ts +3 -0
  57. package/dist/plugins/resolve-component.d.ts.map +1 -0
  58. package/dist/plugins/resolve-component.js +15 -0
  59. package/dist/plugins/resolve-component.js.map +1 -0
  60. package/dist/plugins/runtime-paths.d.ts +1 -0
  61. package/dist/plugins/runtime-paths.d.ts.map +1 -1
  62. package/dist/plugins/runtime-paths.js +2 -0
  63. package/dist/plugins/runtime-paths.js.map +1 -1
  64. package/dist/server/form-view.d.ts +10 -0
  65. package/dist/server/form-view.d.ts.map +1 -0
  66. package/dist/server/form-view.js +16 -0
  67. package/dist/server/form-view.js.map +1 -0
  68. package/dist/server/form.d.ts +28 -12
  69. package/dist/server/form.d.ts.map +1 -1
  70. package/dist/server/form.js +84 -23
  71. package/dist/server/form.js.map +1 -1
  72. package/dist/server/markdown.d.ts +15 -0
  73. package/dist/server/markdown.d.ts.map +1 -0
  74. package/dist/server/markdown.js +6 -0
  75. package/dist/server/markdown.js.map +1 -0
  76. package/dist/server/pages.d.ts +33 -3
  77. package/dist/server/pages.d.ts.map +1 -1
  78. package/dist/server/pages.js +76 -13
  79. package/dist/server/pages.js.map +1 -1
  80. package/package.json +24 -6
  81. package/src/plugins/compatibility.ts +56 -19
  82. package/src/plugins/hono-islands.ts +121 -23
  83. package/src/plugins/hono-markdown.ts +78 -0
  84. package/src/plugins/hono-pages.ts +54 -17
  85. package/src/plugins/markdown-api.ts +17 -0
  86. package/src/plugins/markdown-compiler.ts +77 -0
  87. package/src/plugins/resolve-component.ts +12 -0
  88. package/src/plugins/runtime-paths.ts +2 -0
  89. package/src/runtime/client.tsx +66 -24
  90. package/src/runtime/document-head.ts +20 -1
  91. package/src/runtime/form-view.tsx +23 -0
  92. package/src/runtime/form.ts +82 -41
  93. package/src/runtime/markdown.ts +7 -0
  94. package/src/runtime/navigation.ts +72 -0
  95. package/src/runtime/pages.tsx +77 -15
@@ -0,0 +1,77 @@
1
+ import MarkdownExit from 'markdown-exit';
2
+ import { parseSync } from 'vite';
3
+ import { parseDocument } from 'yaml';
4
+ import { findIslandImports, editIslandSource } from 'void/pages-islands-plugin';
5
+
6
+ const unescapeAttribute = new MarkdownExit().utils.unescapeAll;
7
+
8
+ export type Heading = { depth: number; slug: string; text: string };
9
+ export function extractMarkdown(source: string) {
10
+ const match = source.match(/^<script\b[^>]*>([\s\S]*?)<\/script>\s*\n?/);
11
+ const script = match?.[1] ?? '';
12
+ const body = match ? source.slice(match[0].length) : source;
13
+ const imports = findIslandImports(script, 'markdown-script.ts');
14
+ const attributed = parseSync('markdown-script.ts', script).program.body.filter(node => node.type === 'ImportDeclaration' && node.attributes?.some(({ key }) => ('name' in key && key.name === 'island') || ('value' in key && key.value === 'island')));
15
+ if (attributed.length !== imports.length) throw new Error('Hono Markdown: use one default component import with exactly one island attribute.');
16
+ const graphSource = imports.map(entry => script.slice(entry.start, entry.end)).join('\n');
17
+ const clientCode = editIslandSource(script, imports.map(entry => ({ start: entry.start, end: entry.end, text: '' }))).code.trim();
18
+ return { body, script, imports, graphSource, clientCode };
19
+ }
20
+
21
+ export function compileMarkdown(source: string) {
22
+ const extracted = extractMarkdown(source);
23
+ const match = extracted.body.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
24
+ let frontmatter: Record<string, unknown> = {};
25
+ if (match) {
26
+ const yaml = parseDocument(match[1], { uniqueKeys: true });
27
+ if (yaml.errors.length) throw new Error('Hono Markdown: invalid frontmatter: ' + yaml.errors.map(error => error.message).join('; '));
28
+ const value: unknown = yaml.toJS({ maxAliasCount: 50 });
29
+ if (value !== null && (typeof value !== 'object' || Array.isArray(value))) throw new Error('Hono Markdown: frontmatter must be a mapping.');
30
+ frontmatter = (value ?? {}) as Record<string, unknown>;
31
+ try { JSON.stringify(frontmatter); } catch { throw new Error('Hono Markdown: frontmatter must be JSON serializable.'); }
32
+ }
33
+ const content = match ? extracted.body.slice(match[0].length) : extracted.body;
34
+ const compiler = new MarkdownExit({ html: true, linkify: true });
35
+ const tokens = compiler.parse(content, {});
36
+ const headings: Heading[] = [];
37
+ const slugs = new Map<string, number>();
38
+ const authoredHtml = tokens.flatMap(token => token.type === 'html_block' || token.type === 'html_inline' ? [token.content] : (token.children ?? []).filter(child => child.type === 'html_inline').map(child => child.content)).join('\n');
39
+ const usedIds = new Set([...authoredHtml.matchAll(/\bid\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map(match => match[1] ?? match[2]));
40
+ for (let index = 0; index < tokens.length; index++) {
41
+ if (tokens[index].type !== 'heading_open') continue;
42
+ const inline = tokens[index + 1];
43
+ const text = inline.children?.map(token => token.type === 'text' || token.type === 'code_inline' ? token.content : token.type === 'image' ? token.content : '').join('') ?? inline.content;
44
+ const base = text.toLowerCase().replace(/[^\p{L}\p{N} _-]/gu, '').trim().replace(/\s+/g, '-') || 'heading';
45
+ let count = slugs.get(base) ?? 0;
46
+ let slug = count ? `${base}-${count}` : base;
47
+ while (usedIds.has(slug)) { count++; slug = `${base}-${count}`; }
48
+ slugs.set(base, count + 1); usedIds.add(slug);
49
+ tokens[index].attrSet('id', slug);
50
+ headings.push({ depth: Number(tokens[index].tag.slice(1)), slug, text });
51
+ }
52
+ return { ...extracted, frontmatter, headings, html: compiler.renderer.render(tokens, compiler.options, {}) };
53
+ }
54
+
55
+ // Component tag splitting follows @void/md 0.27.0's emit-shared convention.
56
+ // Hono raw fragments preserve enclosing HTML across the component boundaries.
57
+ export function renderChunks(html: string, names: Set<string>) {
58
+ const tags = /<([A-Z]\w*)\s*((?:[^>/"']|"[^"]*"|'[^']*')*)\/\s*>|<([A-Z]\w*)\s*((?:[^>/"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/\3\s*>/g;
59
+ const chunks: Array<{ html: string } | { name: string; props: Record<string, string | boolean> }> = [];
60
+ let cursor = 0;
61
+ for (const match of html.matchAll(tags)) {
62
+ const name = match[1] ?? match[3];
63
+ if (!names.has(name)) continue;
64
+ if (match[5]?.trim()) throw new Error('Hono Markdown: island tag children are unsupported; pass literal attributes to a self-closing tag.');
65
+ if (match.index > cursor) chunks.push({ html: html.slice(cursor, match.index) });
66
+ const props: Record<string, string | boolean> = Object.create(null);
67
+ const attrs = match[2] ?? match[4] ?? '';
68
+ const attrRe = /(\w[\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s]+)))?/g;
69
+ for (const attr of attrs.matchAll(attrRe)) {
70
+ const value = attr[2] ?? attr[3] ?? attr[4];
71
+ props[attr[1]] = value === undefined ? true : unescapeAttribute(value);
72
+ }
73
+ chunks.push({ name, props }); cursor = match.index + match[0].length;
74
+ }
75
+ if (cursor < html.length) chunks.push({ html: html.slice(cursor) });
76
+ return chunks;
77
+ }
@@ -0,0 +1,12 @@
1
+ import { statSync } from 'node:fs';
2
+ import { resolveSpecifier } from 'void/pages-islands-plugin';
3
+
4
+ /** Relative component imports also use Vite's directory/index convention. */
5
+ export function resolveComponent(specifier: string, importerDir: string, extensions: string[]) {
6
+ const target = resolveSpecifier(specifier, importerDir, extensions);
7
+ if (!target) return null;
8
+ if (statSync(target).isFile()) return target;
9
+ if (!statSync(target).isDirectory()) return null;
10
+ const index = resolveSpecifier('./index', target, extensions);
11
+ return index && statSync(index).isFile() ? index : null;
12
+ }
@@ -5,7 +5,9 @@ import { fileURLToPath } from 'node:url';
5
5
  const compiled = new URL(import.meta.url).pathname.endsWith('.js');
6
6
  export const pagesRuntimePath = fileURLToPath(new URL(compiled ? '../server/pages.js' : '../runtime/pages.tsx', import.meta.url));
7
7
  export const clientRuntimePath = fileURLToPath(new URL(compiled ? '../browser/client.js' : '../runtime/client.tsx', import.meta.url));
8
+ export const markdownRuntimePath = fileURLToPath(new URL(compiled ? '../server/markdown.js' : '../runtime/markdown.ts', import.meta.url));
8
9
  export const publicRuntimeModules: Record<string, { server: string; client: string }> = compiled ? {
10
+ 'void-plugin-hono-jsx/markdown': { server: markdownRuntimePath, client: fileURLToPath(new URL('../browser/markdown.js', import.meta.url)) },
9
11
  'void-plugin-hono-jsx/pages': { server: pagesRuntimePath, client: fileURLToPath(new URL('../browser/pages.js', import.meta.url)) },
10
12
  'void-plugin-hono-jsx/form': {
11
13
  server: fileURLToPath(new URL('../server/form.js', import.meta.url)),
@@ -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
+ }