void-plugin-hono-jsx 0.1.0-alpha.3 → 0.1.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +41 -12
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/dist/browser/client.d.ts +2 -1
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/client.js +76 -22
- package/dist/browser/client.js.map +1 -1
- package/dist/browser/document-head.d.ts +1 -1
- package/dist/browser/document-head.d.ts.map +1 -1
- package/dist/browser/document-head.js +22 -1
- package/dist/browser/document-head.js.map +1 -1
- package/dist/browser/form-view.d.ts +10 -0
- package/dist/browser/form-view.d.ts.map +1 -0
- package/dist/browser/form-view.js +16 -0
- package/dist/browser/form-view.js.map +1 -0
- package/dist/browser/form.d.ts +28 -12
- package/dist/browser/form.d.ts.map +1 -1
- package/dist/browser/form.js +84 -23
- package/dist/browser/form.js.map +1 -1
- package/dist/browser/markdown.d.ts +15 -0
- package/dist/browser/markdown.d.ts.map +1 -0
- package/dist/browser/markdown.js +6 -0
- package/dist/browser/markdown.js.map +1 -0
- package/dist/browser/navigation.d.ts +13 -0
- package/dist/browser/navigation.d.ts.map +1 -0
- package/dist/browser/navigation.js +83 -0
- package/dist/browser/navigation.js.map +1 -0
- package/dist/browser/pages.d.ts +33 -3
- package/dist/browser/pages.d.ts.map +1 -1
- package/dist/browser/pages.js +76 -13
- package/dist/browser/pages.js.map +1 -1
- package/dist/plugins/hono-islands.d.ts +1 -1
- package/dist/plugins/hono-islands.d.ts.map +1 -1
- package/dist/plugins/hono-islands.js +156 -24
- package/dist/plugins/hono-islands.js.map +1 -1
- package/dist/plugins/hono-markdown.d.ts +4 -0
- package/dist/plugins/hono-markdown.d.ts.map +1 -0
- package/dist/plugins/hono-markdown.js +91 -0
- package/dist/plugins/hono-markdown.js.map +1 -0
- package/dist/plugins/hono-pages.d.ts +9 -1
- package/dist/plugins/hono-pages.d.ts.map +1 -1
- package/dist/plugins/hono-pages.js +51 -15
- package/dist/plugins/hono-pages.js.map +1 -1
- package/dist/plugins/markdown-api.d.ts +10 -0
- package/dist/plugins/markdown-api.d.ts.map +1 -0
- package/dist/plugins/markdown-api.js +13 -0
- package/dist/plugins/markdown-api.js.map +1 -0
- package/dist/plugins/markdown-compiler.d.ts +43 -0
- package/dist/plugins/markdown-compiler.d.ts.map +1 -0
- package/dist/plugins/markdown-compiler.js +91 -0
- package/dist/plugins/markdown-compiler.js.map +1 -0
- package/dist/plugins/resolve-component.d.ts +3 -0
- package/dist/plugins/resolve-component.d.ts.map +1 -0
- package/dist/plugins/resolve-component.js +15 -0
- package/dist/plugins/resolve-component.js.map +1 -0
- package/dist/plugins/runtime-paths.d.ts +1 -0
- package/dist/plugins/runtime-paths.d.ts.map +1 -1
- package/dist/plugins/runtime-paths.js +2 -0
- package/dist/plugins/runtime-paths.js.map +1 -1
- package/dist/server/form-view.d.ts +10 -0
- package/dist/server/form-view.d.ts.map +1 -0
- package/dist/server/form-view.js +16 -0
- package/dist/server/form-view.js.map +1 -0
- package/dist/server/form.d.ts +28 -12
- package/dist/server/form.d.ts.map +1 -1
- package/dist/server/form.js +84 -23
- package/dist/server/form.js.map +1 -1
- package/dist/server/markdown.d.ts +15 -0
- package/dist/server/markdown.d.ts.map +1 -0
- package/dist/server/markdown.js +6 -0
- package/dist/server/markdown.js.map +1 -0
- package/dist/server/pages.d.ts +33 -3
- package/dist/server/pages.d.ts.map +1 -1
- package/dist/server/pages.js +76 -13
- package/dist/server/pages.js.map +1 -1
- package/package.json +21 -3
- package/src/plugins/hono-islands.ts +121 -23
- package/src/plugins/hono-markdown.ts +78 -0
- package/src/plugins/hono-pages.ts +50 -14
- package/src/plugins/markdown-api.ts +17 -0
- package/src/plugins/markdown-compiler.ts +77 -0
- package/src/plugins/resolve-component.ts +12 -0
- package/src/plugins/runtime-paths.ts +2 -0
- package/src/runtime/client.tsx +66 -24
- package/src/runtime/document-head.ts +20 -1
- package/src/runtime/form-view.tsx +23 -0
- package/src/runtime/form.ts +82 -41
- package/src/runtime/markdown.ts +7 -0
- package/src/runtime/navigation.ts +72 -0
- package/src/runtime/pages.tsx +77 -15
|
@@ -1,30 +1,44 @@
|
|
|
1
1
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
import { normalizePath, parseSync, type Plugin } from 'vite';
|
|
4
|
-
import { scanPages, resolvePagesDir, type PageScanResult } from 'void/pages';
|
|
5
|
-
import { findIslandImports, editIslandSource,
|
|
4
|
+
import { scanPages, resolveEffectiveLayoutChain, resolvePagesDir, type PageScanResult } from 'void/pages';
|
|
5
|
+
import { findIslandImports, editIslandSource, computePagesNeedingClientJS, ISLANDS_RENDERER_ID, ISLANDS_CLIENT_ENTRY_ID } from 'void/pages-islands-plugin';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { resolveComponent } from './resolve-component.ts';
|
|
8
|
+
|
|
9
|
+
import { markdownApi, markdownDiagnostic, type HonoMarkdownApi } from './markdown-api.ts';
|
|
10
|
+
|
|
11
|
+
/** Hono mounting through Void's shared island scheduler. */
|
|
8
12
|
export function honoIslands(): Plugin {
|
|
9
13
|
const root = process.cwd();
|
|
10
14
|
const pagesDir = resolvePagesDir(root);
|
|
11
15
|
const islands = new Map<string, string>();
|
|
12
16
|
const pageFiles = new Set<string>();
|
|
17
|
+
const graph = new Map<string, Set<string>>();
|
|
18
|
+
const directlyInteractive = new Set<string>();
|
|
19
|
+
const interactivePages = new Set<string>();
|
|
20
|
+
const scriptOnlyPages = new Set<string>();
|
|
13
21
|
let scan: PageScanResult | undefined;
|
|
22
|
+
let markdown: HonoMarkdownApi | undefined;
|
|
14
23
|
|
|
15
24
|
function islandImports(code: string, filename: string) {
|
|
16
25
|
const found = findIslandImports(code, filename);
|
|
17
26
|
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')));
|
|
18
|
-
if (attributed.length !== found.length) throw new Error(`Hono islands: use one default import with exactly one island
|
|
27
|
+
if (attributed.length !== found.length) throw new Error(`Hono islands: use one default import with exactly one island attribute using load, visible, idle or media:query in ${filename}.`);
|
|
19
28
|
return found;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
function imports(code: string, filename: string) {
|
|
23
32
|
const found = islandImports(code, filename);
|
|
24
33
|
for (const entry of found) {
|
|
25
|
-
if (
|
|
34
|
+
if (!['load', 'visible', 'idle'].includes(entry.hydrate)) {
|
|
35
|
+
const query = entry.hydrate.startsWith('media:') ? entry.hydrate.slice(6).trim() : '';
|
|
36
|
+
let balance = 0;
|
|
37
|
+
for (const character of query) { if (character === '(') balance++; if (character === ')') balance--; if (balance < 0) break; }
|
|
38
|
+
if (!query || balance !== 0 || /[{};]/.test(query)) throw new Error(`Hono islands: unsupported policy or invalid media query '${entry.hydrate}' in ${filename}.`);
|
|
39
|
+
}
|
|
26
40
|
if (!entry.specifier.startsWith('.')) throw new Error(`Hono islands: use a relative TSX/JSX component import in ${filename}.`);
|
|
27
|
-
const path =
|
|
41
|
+
const path = resolveComponent(entry.specifier, join(filename, '..'), ['.tsx', '.jsx']);
|
|
28
42
|
if (!path || !/\.[jt]sx$/.test(path)) throw new Error(`Hono islands: cannot resolve TSX/JSX component ${entry.specifier} in ${filename}.`);
|
|
29
43
|
const canonical = realpathSync(path);
|
|
30
44
|
const id = normalizePath(relative(root, canonical));
|
|
@@ -35,21 +49,79 @@ export function honoIslands(): Plugin {
|
|
|
35
49
|
|
|
36
50
|
async function rescan() {
|
|
37
51
|
scan = await scanPages(root, { pagesDir });
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
// Never pass Markdown text to the JavaScript static-graph parser.
|
|
53
|
+
for (const file of [...scan.pages.map(page => page.componentPath), ...scan.layouts.map(layout => layout.filePath), ...scan.namedLayouts.map(layout => layout.filePath)]) {
|
|
54
|
+
if (/\.md$/.test(file) && !markdown) throw new Error(`${markdownDiagnostic} ${file}`);
|
|
55
|
+
}
|
|
56
|
+
islands.clear(); pageFiles.clear(); graph.clear(); directlyInteractive.clear(); interactivePages.clear(); scriptOnlyPages.clear();
|
|
57
|
+
const visit = (filename: string) => {
|
|
58
|
+
const canonical = realpathSync(filename);
|
|
59
|
+
if (graph.has(canonical)) return;
|
|
60
|
+
const dependencies = new Set<string>(); graph.set(canonical, dependencies); pageFiles.add(canonical);
|
|
61
|
+
const code = canonical.endsWith('.md') && markdown ? markdown.getGraphSource(canonical) : readFileSync(canonical, 'utf8');
|
|
62
|
+
const found = imports(code, canonical);
|
|
63
|
+
if (found.length) {
|
|
64
|
+
if (!/\.[jt]sx$/.test(canonical) && !(canonical.endsWith('.md') && markdown)) throw new Error('Hono islands: attributed imports require a static TSX/JSX module.');
|
|
65
|
+
directlyInteractive.add(canonical);
|
|
66
|
+
}
|
|
67
|
+
for (const node of parseSync(canonical, code).program.body) {
|
|
68
|
+
const source = node.type === 'ImportDeclaration' ? node.source : node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration' ? node.source : undefined;
|
|
69
|
+
const typeOnly = node.type === 'ImportDeclaration' ? node.importKind === 'type' || (node.specifiers.length > 0 && node.specifiers.every((entry) => entry.type === 'ImportSpecifier' && entry.importKind === 'type')) : (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && node.exportKind === 'type';
|
|
70
|
+
if (!source || typeOnly || !source.value.startsWith('.') || found.some((entry) => entry.specifier === source.value)) continue;
|
|
71
|
+
const target = resolveComponent(source.value, join(canonical, '..'), ['.tsx', '.jsx', '.ts', '.js']);
|
|
72
|
+
if (!target || !/\.[jt]sx?$/.test(target)) continue;
|
|
73
|
+
dependencies.add(realpathSync(target)); visit(target);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
};
|
|
77
|
+
for (const entry of [...scan.pages.filter((page) => page.island).map((page) => page.componentPath), ...scan.layouts.filter((layout) => layout.island).map((layout) => layout.filePath), ...scan.namedLayouts.filter((layout) => layout.island).map((layout) => layout.filePath)]) visit(join(pagesDir, entry));
|
|
78
|
+
const validateInteractive = (filename: string, seen = new Set<string>()) => {
|
|
79
|
+
const canonical = realpathSync(filename);
|
|
80
|
+
if (seen.has(canonical)) return; seen.add(canonical);
|
|
81
|
+
const code = canonical.endsWith('.md') && markdown ? markdown.getGraphSource(canonical) : readFileSync(canonical, 'utf8');
|
|
82
|
+
if (islandImports(code, canonical).length) throw new Error('Hono islands: attributed imports inside an interactive component are unsupported; use ordinary component imports there.');
|
|
83
|
+
for (const node of parseSync(canonical, code).program.body) {
|
|
84
|
+
const source = node.type === 'ImportDeclaration' ? node.source : node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration' ? node.source : undefined;
|
|
85
|
+
const typeOnly = node.type === 'ImportDeclaration' ? node.importKind === 'type' || (node.specifiers.length > 0 && node.specifiers.every((entry) => entry.type === 'ImportSpecifier' && entry.importKind === 'type')) : (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && node.exportKind === 'type';
|
|
86
|
+
if (!source || typeOnly || !source.value.startsWith('.')) continue;
|
|
87
|
+
const target = resolveComponent(source.value, join(canonical, '..'), ['.tsx', '.jsx', '.ts', '.js']);
|
|
88
|
+
if (target && /\.[jt]sx?$/.test(target)) validateInteractive(target, seen);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
for (const target of islands.values()) validateInteractive(target);
|
|
92
|
+
for (const page of scan.pages.filter((page) => !page.island)) {
|
|
93
|
+
for (const file of [page.componentPath, ...resolveEffectiveLayoutChain(page, scan.layouts, scan.namedLayouts).map((layout) => layout.filePath)]) validateInteractive(join(pagesDir, file));
|
|
94
|
+
}
|
|
95
|
+
const needsJS = new Set(directlyInteractive);
|
|
96
|
+
let changed = true;
|
|
97
|
+
while (changed) {
|
|
98
|
+
changed = false;
|
|
99
|
+
for (const [file, dependencies] of graph) if (!needsJS.has(file) && [...dependencies].some((target) => needsJS.has(target))) { needsJS.add(file); changed = true; }
|
|
100
|
+
}
|
|
40
101
|
for (const page of scan.pages.filter((page) => page.island)) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
102
|
+
const files = [page.componentPath, ...resolveEffectiveLayoutChain(page, scan.layouts, scan.namedLayouts).map((layout) => layout.filePath)];
|
|
103
|
+
const hasIslands = files.some(file => needsJS.has(realpathSync(join(pagesDir, file))));
|
|
104
|
+
const hasScript = markdown?.getClientScripts().has(page.componentId);
|
|
105
|
+
if (hasIslands || hasScript) interactivePages.add(page.componentId);
|
|
106
|
+
if (hasScript && !hasIslands) scriptOnlyPages.add(page.componentId);
|
|
44
107
|
}
|
|
45
108
|
}
|
|
46
109
|
|
|
47
110
|
return {
|
|
48
111
|
name: 'void-hono:islands',
|
|
49
112
|
enforce: 'pre',
|
|
113
|
+
configResolved(config) { markdown = markdownApi(config.plugins); },
|
|
50
114
|
buildStart: rescan,
|
|
51
|
-
async hotUpdate({ file }) {
|
|
52
|
-
|
|
115
|
+
async hotUpdate({ file, modules, timestamp }) {
|
|
116
|
+
const inPages = normalizePath(file).startsWith(normalizePath(pagesDir) + '/');
|
|
117
|
+
if (!inPages && ![...pageFiles, ...islands.values()].some((path) => normalizePath(path) === normalizePath(file))) return;
|
|
118
|
+
await rescan();
|
|
119
|
+
const invalidated = new Set<(typeof modules)[number]>();
|
|
120
|
+
for (const id of [ISLANDS_RENDERER_ID, ISLANDS_CLIENT_ENTRY_ID, 'virtual:void-pages-renderer']) {
|
|
121
|
+
const module = this.environment.moduleGraph.getModuleById(`\0${id}`);
|
|
122
|
+
if (module) this.environment.moduleGraph.invalidateModule(module, invalidated, timestamp, true);
|
|
123
|
+
}
|
|
124
|
+
if (!inPages && this.environment.config.consumer === 'client') this.environment.hot.send({ type: 'full-reload' });
|
|
53
125
|
},
|
|
54
126
|
config() {
|
|
55
127
|
return { build: { rollupOptions: { input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID } } } };
|
|
@@ -61,42 +133,68 @@ export function honoIslands(): Plugin {
|
|
|
61
133
|
const filename = id.split('?')[0];
|
|
62
134
|
if (!/\.[jt]sx$/.test(filename) || filename.includes('/node_modules/')) return;
|
|
63
135
|
const canonical = realpathSync(filename);
|
|
64
|
-
if ((pageFiles.has(canonical) || [...islands.values()].includes(canonical)) && parseSync(filename, code).program.body.some((node) => node.type === 'ImportDeclaration' && node.
|
|
65
|
-
throw new Error('Hono islands:
|
|
136
|
+
if ((pageFiles.has(canonical) || [...islands.values()].includes(canonical)) && parseSync(filename, code).program.body.some((node) => node.type === 'ImportDeclaration' && node.importKind !== 'type' && ['void-plugin-hono-jsx/form', 'void-plugin-hono-jsx/pages'].includes(node.source.value) && node.specifiers.some((entry) => entry.type !== 'ImportSpecifier' ? node.source.value.endsWith('/form') : entry.importKind !== 'type' && ['useForm', 'action', 'setActionRouter'].includes('name' in entry.imported ? entry.imported.name : entry.imported.value)))) {
|
|
137
|
+
throw new Error('Hono islands: useForm/action require full Pages; use named useIslandForm/Form imports for islands.');
|
|
66
138
|
}
|
|
67
139
|
const found = islandImports(code, filename);
|
|
68
140
|
if (!found.length) return;
|
|
69
|
-
if (!pageFiles.has(
|
|
141
|
+
if (!pageFiles.has(canonical)) throw new Error('Hono islands: attributed imports require a static island page, layout or component graph; use ordinary imports inside interactive components.');
|
|
70
142
|
imports(code, filename);
|
|
143
|
+
// Void requests static modules in the client environment to collect CSS.
|
|
144
|
+
// Actual interactive entry graphs were validated above; strip attributes
|
|
145
|
+
// for that traversal rather than generating server-only marker wrappers.
|
|
146
|
+
if (this.environment?.config.consumer === 'client') return editIslandSource(code, found.map((entry) => ({ start: entry.start, end: entry.end, text: `import ${entry.importName} from ${entry.specifierRaw};` })));
|
|
71
147
|
return editIslandSource(code, found.map((entry) => {
|
|
72
|
-
const path =
|
|
148
|
+
const path = resolveComponent(entry.specifier, join(filename, '..'), ['.tsx', '.jsx']);
|
|
73
149
|
if (!path) throw new Error(`Hono islands: missing component ${entry.specifier}.`);
|
|
74
150
|
const islandId = normalizePath(relative(root, realpathSync(path)));
|
|
75
151
|
const component = `__honoIslandComponent${entry.start}`;
|
|
76
152
|
return { start: entry.start, end: entry.end, text: `import ${component} from ${entry.specifierRaw};
|
|
77
|
-
const ${entry.importName} = (props) => __honoIslandJsx('div', { 'data-island': ${JSON.stringify(islandId)}, 'data-hydrate':
|
|
153
|
+
const ${entry.importName} = (props) => __honoIslandJsx('div', { 'data-island': ${JSON.stringify(islandId)}, 'data-hydrate': ${JSON.stringify(entry.hydrate)}, 'data-props': JSON.stringify(props), children: __honoIslandJsx(${component}, props) });` };
|
|
78
154
|
}), { prepend: "import { jsx as __honoIslandJsx } from 'hono/jsx/jsx-runtime';\n" });
|
|
79
155
|
},
|
|
80
156
|
load(id) {
|
|
81
157
|
if (id === `\0${ISLANDS_RENDERER_ID}`) {
|
|
82
158
|
if (!scan) throw new Error('Hono islands: page discovery has not completed.');
|
|
83
|
-
const interactive = [...computePagesNeedingClientJS(scan, pagesDir, null)];
|
|
159
|
+
const interactive = [...new Set([...computePagesNeedingClientJS(scan, pagesDir, null), ...interactivePages])];
|
|
84
160
|
return `import { renderPage } from 'virtual:void-pages-renderer';
|
|
85
161
|
const interactive = new Set(${JSON.stringify(interactive)});
|
|
162
|
+
const scriptOnly = new Set(${JSON.stringify([...scriptOnlyPages])});
|
|
163
|
+
const markdownScripts = new Set(${JSON.stringify([...(markdown?.getClientScripts().keys() ?? [])])});
|
|
86
164
|
export function renderIslandPage(page, assets) {
|
|
87
165
|
if (page.deferred?.length || page.deferredKeys) throw new Error('Hono islands: deferred props are unsupported.');
|
|
88
|
-
|
|
166
|
+
if (!markdownScripts.has(page.component)) assets = { ...assets,
|
|
167
|
+
body: assets.body.replace(/<script\\b[^>]*src=["'][^"']*(?:void-hono-markdown-client|markdown-client-)[^"']*["'][^>]*>\\s*<\\/script>/g, ''),
|
|
168
|
+
preloads: assets.preloads.replace(/<link\\b[^>]*href=["'][^"']*markdown-client-[^"']*["'][^>]*>/g, ''),
|
|
169
|
+
};
|
|
170
|
+
if (!interactive.has(page.component)) return renderPage(page, { ...assets, preloads: '', body: '' });
|
|
171
|
+
if (scriptOnly.has(page.component)) {
|
|
172
|
+
// Keep the Markdown entry; its dynamic import loads only this page's code.
|
|
173
|
+
// Framework preloads and the island scheduler have no work on this chain.
|
|
174
|
+
const body = assets.body.replace(/<script\\b[^>]*src=["'][^"']*(?:void-islands-client|islands-client-)[^"']*["'][^>]*>\\s*<\\/script>/g, '');
|
|
175
|
+
return renderPage(page, { ...assets, preloads: '', body });
|
|
176
|
+
}
|
|
177
|
+
return renderPage(page, assets);
|
|
89
178
|
}`;
|
|
90
179
|
}
|
|
91
180
|
if (id === `\0${ISLANDS_CLIENT_ENTRY_ID}`) {
|
|
92
|
-
if (!islands.size) return 'export {};';
|
|
181
|
+
if (!islands.size || !interactivePages.size) return 'export {};';
|
|
93
182
|
const loaders = [...islands].map(([name, path]) => `${JSON.stringify(name)}: () => import(${JSON.stringify(path)})`).join(',\n');
|
|
94
|
-
return
|
|
183
|
+
return `${markdown ? "import { MarkdownContext } from 'void-plugin-hono-jsx/markdown';" : ''}
|
|
184
|
+
import { initIslands } from 'void/pages-client';
|
|
95
185
|
import { jsx } from 'hono/jsx/dom/jsx-runtime';
|
|
96
186
|
import { render } from 'hono/jsx/dom';
|
|
187
|
+
for (const element of document.querySelectorAll('[data-hydrate^="media:"]')) {
|
|
188
|
+
const query = element.getAttribute('data-hydrate').slice(6).trim();
|
|
189
|
+
if (matchMedia(query).media === 'not all' && query !== 'not all') throw new Error('Hono islands: invalid or unsupported media query ' + query);
|
|
190
|
+
}
|
|
97
191
|
initIslands({ ${loaders} }, (element, module, props) => {
|
|
98
192
|
if (typeof module.default !== 'function') throw new Error('Hono islands: a component must export a default function.');
|
|
99
|
-
|
|
193
|
+
let content = jsx(module.default, props);
|
|
194
|
+
${markdown ? `const metadata = JSON.parse(document.getElementById('app')?.getAttribute('data-hono-markdown') || '{}');
|
|
195
|
+
const page = JSON.parse(document.getElementById('__VOID_PAGE_DATA__')?.textContent || '{}');
|
|
196
|
+
content = jsx(MarkdownContext.Provider, { value: { frontmatter: metadata.frontmatter ?? {}, headings: metadata.headings ?? [], props: page.props ?? {} }, children: content });` : ''}
|
|
197
|
+
render(content, element);
|
|
100
198
|
element.setAttribute('data-hono-island', 'mounted');
|
|
101
199
|
});`;
|
|
102
200
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, relative, dirname } from 'node:path';
|
|
3
|
+
import { normalizePath, type Plugin } from 'vite';
|
|
4
|
+
import { resolvePagesDir } from 'void/pages';
|
|
5
|
+
import { resolveComponent } from './resolve-component.ts';
|
|
6
|
+
import { compileMarkdown, extractMarkdown, renderChunks } from './markdown-compiler.ts';
|
|
7
|
+
import type { HonoMarkdownApi } from './markdown-api.ts';
|
|
8
|
+
|
|
9
|
+
const clientId = 'virtual:void-hono-markdown-client';
|
|
10
|
+
const scriptQuery = '?hono-markdown-script';
|
|
11
|
+
const literal = (value: unknown) => `JSON.parse(${JSON.stringify(JSON.stringify(value))})`;
|
|
12
|
+
|
|
13
|
+
/** Optional authored Markdown compiler; independent of @void/md private files. */
|
|
14
|
+
export function voidHonoMarkdown(): Plugin {
|
|
15
|
+
let root = process.cwd();
|
|
16
|
+
let pagesDir = resolvePagesDir(root);
|
|
17
|
+
const scripts = new Map<string, string>();
|
|
18
|
+
const files = new Map<string, string>();
|
|
19
|
+
function rescan() {
|
|
20
|
+
scripts.clear(); files.clear();
|
|
21
|
+
if (!existsSync(pagesDir)) return;
|
|
22
|
+
for (const entry of readdirSync(pagesDir, { recursive: true, withFileTypes: true })) {
|
|
23
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
24
|
+
const file = join(entry.parentPath, entry.name);
|
|
25
|
+
const id = normalizePath(relative(pagesDir, file)).replace(/\.md$/, '').replace(/\.island$/, '').replace(/\/index$/, '').replace(/^index$/, '') || 'index';
|
|
26
|
+
const { clientCode } = extractMarkdown(readFileSync(file, 'utf8'));
|
|
27
|
+
if (clientCode) { scripts.set(id, clientCode); files.set(id, file); }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const api: HonoMarkdownApi = { adapter: 'hono', clientEntryId: clientId, getClientScripts() { rescan(); return scripts; }, getGraphSource(file) { return extractMarkdown(readFileSync(file, 'utf8')).graphSource; } };
|
|
31
|
+
return {
|
|
32
|
+
// Void's Markdown entry/asset convention is shared with @void/md.
|
|
33
|
+
name: 'void-md', enforce: 'pre', api,
|
|
34
|
+
config() { return { build: { rollupOptions: { input: { 'markdown-client': clientId } } } }; },
|
|
35
|
+
configResolved(config) { if (!config.plugins.some(plugin => plugin.name === 'void-hono:pages')) throw new Error('Hono Markdown: configure voidHonoJsx() before voidHonoMarkdown().'); root = realpathSync(config.root); pagesDir = resolvePagesDir(root); rescan(); },
|
|
36
|
+
buildStart() { rescan(); },
|
|
37
|
+
hotUpdate({ file, timestamp }) {
|
|
38
|
+
if (!file.endsWith('.md') || !normalizePath(file).startsWith(normalizePath(pagesDir) + '/')) return;
|
|
39
|
+
rescan();
|
|
40
|
+
const entry = this.environment.moduleGraph.getModuleById(`\0${clientId}`);
|
|
41
|
+
if (entry) this.environment.moduleGraph.invalidateModule(entry, new Set(), timestamp, true);
|
|
42
|
+
},
|
|
43
|
+
resolveId(id) { if (id === clientId) return `\0${id}`; },
|
|
44
|
+
load(id) {
|
|
45
|
+
if (id.endsWith(scriptQuery)) return extractMarkdown(readFileSync(id.slice(0, -scriptQuery.length), 'utf8')).clientCode || 'export {};';
|
|
46
|
+
if (id !== `\0${clientId}`) return;
|
|
47
|
+
rescan();
|
|
48
|
+
if (!files.size) return 'export {};';
|
|
49
|
+
return `const scripts = {${[...files].map(([id, file]) => `${JSON.stringify(id)}: () => import(${JSON.stringify(normalizePath(file) + scriptQuery)})`).join(',')}};
|
|
50
|
+
const id = document.getElementById('app')?.getAttribute('data-md-script');
|
|
51
|
+
if (id && scripts[id]) await scripts[id]();`;
|
|
52
|
+
},
|
|
53
|
+
transform: {
|
|
54
|
+
filter: { id: /\.md$/ },
|
|
55
|
+
handler(source, id) {
|
|
56
|
+
const result = compileMarkdown(source);
|
|
57
|
+
const imports = result.imports.map((entry, index) => {
|
|
58
|
+
const target = resolveComponent(entry.specifier, dirname(id), ['.tsx', '.jsx']);
|
|
59
|
+
if (!target || !entry.specifier.startsWith('.')) throw new Error(`Hono Markdown: cannot resolve relative TSX/JSX island ${entry.specifier} in ${id}.`);
|
|
60
|
+
return { ...entry, alias: `__island${index}`, islandId: normalizePath(relative(root, realpathSync(target))) };
|
|
61
|
+
});
|
|
62
|
+
const chunks = renderChunks(result.html, new Set(imports.map(entry => entry.importName)));
|
|
63
|
+
const children = chunks.map(chunk => {
|
|
64
|
+
if ('html' in chunk) return `raw(${JSON.stringify(chunk.html)})`;
|
|
65
|
+
const entry = imports.find(entry => entry.importName === chunk.name)!;
|
|
66
|
+
const props = literal(chunk.props);
|
|
67
|
+
return `jsx('div', { 'data-island': ${JSON.stringify(entry.islandId)}, 'data-hydrate': ${JSON.stringify(entry.hydrate)}, 'data-props': JSON.stringify(${props}), children: jsx(${entry.alias}, ${props}) })`;
|
|
68
|
+
});
|
|
69
|
+
return { code: `import { jsx, Fragment } from 'hono/jsx/jsx-runtime';
|
|
70
|
+
import { raw } from 'hono/html';
|
|
71
|
+
${imports.map(entry => `import ${entry.alias} from ${JSON.stringify(entry.specifier)};`).join('\n')}
|
|
72
|
+
export const frontmatter = ${literal(result.frontmatter)};
|
|
73
|
+
export const headings = ${literal(result.headings)};
|
|
74
|
+
export default function MarkdownPage() { return jsx(Fragment, { children: [${children.join(',')}] }); }`, map: null };
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -1,32 +1,57 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { normalizePath } from 'vite';
|
|
4
|
-
import { pagesRuntimePath, clientRuntimePath, publicRuntimeModules } from './runtime-paths.ts';
|
|
4
|
+
import { pagesRuntimePath, clientRuntimePath, markdownRuntimePath, publicRuntimeModules } from './runtime-paths.ts';
|
|
5
5
|
import { generateComponentManifest, resolvePagesDir, resolveStaticPageDataBuildOptions, scanPages, type PageScanResult } from 'void/pages';
|
|
6
6
|
import type { Plugin } from 'vite';
|
|
7
|
+
import { parseCacheFor, type CacheForInput } from 'void/pages-prefetch';
|
|
7
8
|
import { honoJsx } from './hono-jsx.ts';
|
|
8
9
|
import { honoIslands } from './hono-islands.ts';
|
|
10
|
+
import { markdownApi, markdownDiagnostic, type HonoMarkdownApi } from './markdown-api.ts';
|
|
9
11
|
import { assertSupportedFrameworks } from './compatibility.ts';
|
|
10
12
|
|
|
11
13
|
const rendererId = 'virtual:void-pages-renderer';
|
|
12
14
|
const clientId = 'virtual:void-pages-client';
|
|
13
15
|
|
|
16
|
+
function assertComponentFile(file: string, kind: 'page' | 'layout', markdown = false) {
|
|
17
|
+
if (/\.md$/.test(file)) { if (markdown && kind === 'page') return; if (markdown) throw new Error('Hono Markdown: layouts must remain TSX/JSX: ' + file); throw new Error(`${markdownDiagnostic} ${kind}: ${file}`); }
|
|
18
|
+
if (!/\.[jt]sx$/.test(file)) throw new Error(`Hono Pages: only standard TSX/JSX ${kind}s are supported: ${file}.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export type HonoJsxOptions = {
|
|
23
|
+
viewTransitions?: boolean;
|
|
24
|
+
prefetch?: { hoverDelay?: number; cacheFor?: CacheForInput };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function clientOptions(options: HonoJsxOptions) {
|
|
28
|
+
const viewTransitions = options.viewTransitions ?? false;
|
|
29
|
+
const prefetchHoverDelay = options.prefetch?.hoverDelay ?? 75;
|
|
30
|
+
if (typeof viewTransitions !== 'boolean') throw new Error('Hono Pages: viewTransitions must be a boolean.');
|
|
31
|
+
if (!Number.isFinite(prefetchHoverDelay) || prefetchHoverDelay < 0) throw new Error('Hono Pages: prefetch.hoverDelay must be a finite non-negative number.');
|
|
32
|
+
const prefetchDefaultCacheFor = parseCacheFor(options.prefetch?.cacheFor ?? '30s');
|
|
33
|
+
if (![prefetchDefaultCacheFor.stale, prefetchDefaultCacheFor.expires].every(value => Number.isFinite(value) && value >= 0) || prefetchDefaultCacheFor.stale > prefetchDefaultCacheFor.expires) {
|
|
34
|
+
throw new Error('Hono Pages: prefetch.cacheFor must have finite non-negative durations, with stale <= expires.');
|
|
35
|
+
}
|
|
36
|
+
return { viewTransitions, prefetchHoverDelay, prefetchDefaultCacheFor };
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
/** Experimental Pages contract verified against Void 0.26.1 and 0.27.0. */
|
|
15
|
-
export function
|
|
40
|
+
export function voidHonoJsx(options: HonoJsxOptions = {}): Plugin[] {
|
|
41
|
+
const navigationOptions = clientOptions(options);
|
|
16
42
|
const root = process.cwd();
|
|
17
43
|
const pagesDir = resolvePagesDir(root);
|
|
18
44
|
const staticData = resolveStaticPageDataBuildOptions(root);
|
|
19
45
|
let scan: PageScanResult | undefined;
|
|
46
|
+
let markdown: HonoMarkdownApi | undefined;
|
|
20
47
|
|
|
21
48
|
async function rescan() {
|
|
22
49
|
scan = await scanPages(root, { pagesDir });
|
|
23
50
|
for (const layout of [...scan.layouts, ...scan.namedLayouts]) {
|
|
24
|
-
|
|
51
|
+
assertComponentFile(layout.filePath, 'layout');
|
|
25
52
|
}
|
|
26
53
|
for (const page of scan.pages) {
|
|
27
|
-
|
|
28
|
-
throw new Error(`Hono Pages: only standard TSX/JSX pages are supported: ${page.componentPath}.`);
|
|
29
|
-
}
|
|
54
|
+
assertComponentFile(page.componentPath, 'page', Boolean(markdown));
|
|
30
55
|
if (staticData.enabled && page.methods.some((method) => method === 'action' || method === 'actions')) {
|
|
31
56
|
throw new Error(`Hono Pages: actions require a server and cannot be used with static output: ${page.componentPath}.`);
|
|
32
57
|
}
|
|
@@ -44,6 +69,12 @@ export function voidHonoPages(): Plugin[] {
|
|
|
44
69
|
build: { manifest: true, rollupOptions: { input: { 'pages-client': clientId } } },
|
|
45
70
|
};
|
|
46
71
|
},
|
|
72
|
+
configResolved: {
|
|
73
|
+
order: 'pre',
|
|
74
|
+
handler(config) {
|
|
75
|
+
markdown = markdownApi(config.plugins);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
47
78
|
configEnvironment() {
|
|
48
79
|
const modules = Object.keys(publicRuntimeModules);
|
|
49
80
|
const packages = [...new Set(modules.map((id) => id.slice(0, id.lastIndexOf('/'))))];
|
|
@@ -75,7 +106,7 @@ export function voidHonoPages(): Plugin[] {
|
|
|
75
106
|
},
|
|
76
107
|
resolveId(id) {
|
|
77
108
|
if (id === rendererId || id === clientId) return `\0${id}`;
|
|
78
|
-
if (id === pagesRuntimePath || id === clientRuntimePath) return { id: realpathSync(id), external: false };
|
|
109
|
+
if (id === pagesRuntimePath || id === clientRuntimePath || id === markdownRuntimePath) return { id: realpathSync(id), external: false };
|
|
79
110
|
const runtime = publicRuntimeModules[id];
|
|
80
111
|
if (runtime) return { id: realpathSync(this.environment.config.consumer === 'client' ? runtime.client : runtime.server), external: false };
|
|
81
112
|
},
|
|
@@ -84,7 +115,7 @@ export function voidHonoPages(): Plugin[] {
|
|
|
84
115
|
const currentScan = scan ?? await rescan();
|
|
85
116
|
const serverSnapshot = id === `\0${rendererId}` && this.environment.mode === 'dev';
|
|
86
117
|
const manifestScan = id === `\0${clientId}`
|
|
87
|
-
? { ...currentScan, pages: currentScan.pages.filter((page) => !page.island) }
|
|
118
|
+
? { ...currentScan, pages: currentScan.pages.filter((page) => !page.island), layouts: currentScan.layouts.filter((layout) => !layout.island), namedLayouts: currentScan.namedLayouts.filter((layout) => !layout.island) }
|
|
88
119
|
: serverSnapshot ? { ...currentScan, pages: currentScan.pages.filter((page) => page.renderMode !== 'client') } : currentScan;
|
|
89
120
|
let manifest = generateComponentManifest(manifestScan, pagesDir);
|
|
90
121
|
if (serverSnapshot) {
|
|
@@ -102,10 +133,13 @@ export function voidHonoPages(): Plugin[] {
|
|
|
102
133
|
}
|
|
103
134
|
if (id === `\0${clientId}` && !currentScan.pages.some((page) => !page.island)) return 'export {};';
|
|
104
135
|
if (id === `\0${rendererId}`) {
|
|
136
|
+
const markdownScriptIds = [...(markdown?.getClientScripts().keys() ?? [])];
|
|
105
137
|
return `
|
|
106
138
|
import { jsx } from 'hono/jsx';
|
|
107
|
-
import {
|
|
108
|
-
|
|
139
|
+
${markdown ? `import { MarkdownContext } from ${JSON.stringify(markdownRuntimePath)};` : ''}
|
|
140
|
+
const markdownScriptIds = new Set(${JSON.stringify(markdownScriptIds)});
|
|
141
|
+
import { createSsrRouter, idleNavigationState } from 'void/pages-client';
|
|
142
|
+
import { PageContext, RouterContext, NavigationContext } from ${JSON.stringify(pagesRuntimePath)};
|
|
109
143
|
import { serializePageData } from 'void/pages-server';
|
|
110
144
|
import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from 'void/pages-head';
|
|
111
145
|
${manifest}
|
|
@@ -113,7 +147,7 @@ export async function renderPage(pageObj, assetTags) {
|
|
|
113
147
|
if (${JSON.stringify(staticData.enabled)} && pageObj.deferredKeys) throw new Error('Hono Pages: deferred props cannot be statically prerendered; remove defer().');
|
|
114
148
|
const load = components[pageObj.component];
|
|
115
149
|
if (!load) throw new Error('Hono Pages: unknown page component ' + pageObj.component);
|
|
116
|
-
const { default: Page } = await load();
|
|
150
|
+
const { default: Page, frontmatter = {}, headings = [] } = await load();
|
|
117
151
|
if (typeof Page !== 'function') throw new Error('Hono Pages: a page must export a component function.');
|
|
118
152
|
let content = jsx(Page, pageObj.props);
|
|
119
153
|
const ids = layoutTree[pageObj.component] ?? [];
|
|
@@ -122,21 +156,23 @@ export async function renderPage(pageObj, assetTags) {
|
|
|
122
156
|
if (typeof Layout !== 'function') throw new Error('Hono Pages: a layout must export a component function.');
|
|
123
157
|
content = jsx(Layout, { children: content });
|
|
124
158
|
}
|
|
125
|
-
|
|
159
|
+
${markdown ? 'content = jsx(MarkdownContext.Provider, { value: { frontmatter, headings, props: pageObj.props }, children: content });' : ''}
|
|
160
|
+
const markup = await jsx(RouterContext.Provider, { value: createSsrRouter(pageObj.url, pageObj.params), children: jsx(PageContext.Provider, { value: pageObj, children: jsx(NavigationContext.Provider, { value: idleNavigationState(), children: content }) }) }).toString();
|
|
161
|
+
const appHtml = await jsx('div', { id: 'app', 'data-md-script': markdownScriptIds.has(pageObj.component) ? pageObj.component : undefined, ${markdown ? "'data-hono-markdown': JSON.stringify({ frontmatter, headings })," : ''} dangerouslySetInnerHTML: { __html: markup } }).toString();
|
|
126
162
|
const head = pageObj.head;
|
|
127
163
|
const body = pageObj.deferredKeys ? assetTags.body.replaceAll('<script type="module"', '<script async type="module"') : assetTags.body;
|
|
128
164
|
return '<!doctype html><html' + (head ? renderHtmlAttrs(head) : '') + '><head>'
|
|
129
165
|
+ (head ? renderHeadToString(head) : '') + assetTags.css + assetTags.preloads
|
|
130
166
|
+ '</head><body' + (head ? renderBodyAttrs(head) : '') + '>'
|
|
131
167
|
+ '<script id="__VOID_PAGE_DATA__" type="application/json">' + serializePageData(pageObj) + '</script>'
|
|
132
|
-
+
|
|
168
|
+
+ appHtml + body + '</body></html>';
|
|
133
169
|
}
|
|
134
170
|
`;
|
|
135
171
|
}
|
|
136
172
|
return `
|
|
137
173
|
import { startHonoPages } from ${JSON.stringify(clientRuntimePath)};
|
|
138
174
|
${manifest}
|
|
139
|
-
await startHonoPages({ components, layoutTree, routeMeta, matchRoute, staticPageData: ${JSON.stringify(staticData.enabled)}, staticPageDataFastPath: ${JSON.stringify(staticData.fastPath)} });
|
|
175
|
+
await startHonoPages({ components, layoutTree, routeMeta, matchRoute, staticPageData: ${JSON.stringify(staticData.enabled)}, staticPageDataFastPath: ${JSON.stringify(staticData.fastPath)} }, ${JSON.stringify(navigationOptions)});
|
|
140
176
|
`;
|
|
141
177
|
},
|
|
142
178
|
}];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
export const markdownDiagnostic = 'Hono Pages: Markdown requires voidHonoMarkdown() from void-plugin-hono-jsx/markdown/plugin and its optional parser dependencies. See docs/markdown.md.';
|
|
4
|
+
export type HonoMarkdownApi = {
|
|
5
|
+
adapter: 'hono';
|
|
6
|
+
clientEntryId: string;
|
|
7
|
+
getClientScripts(): Map<string, string>;
|
|
8
|
+
getGraphSource(file: string): string;
|
|
9
|
+
};
|
|
10
|
+
export function markdownApi(plugins: readonly Plugin[]): HonoMarkdownApi | undefined {
|
|
11
|
+
const markdown = plugins.filter(plugin => plugin.name === 'void-md');
|
|
12
|
+
if (markdown.length > 1) throw new Error('Hono Pages: configure only one Markdown compiler.');
|
|
13
|
+
const plugin = markdown[0];
|
|
14
|
+
if (!plugin) return;
|
|
15
|
+
if (plugin.api?.adapter !== 'hono') throw new Error('Hono Pages: use voidHonoMarkdown(), not @void/md/plugin; the official compiler does not emit Hono components.');
|
|
16
|
+
return plugin.api as HonoMarkdownApi;
|
|
17
|
+
}
|
|
@@ -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)),
|