@hyperspan/framework 2.0.0-alpha.1 → 2.0.0-alpha.2
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/package.json +2 -2
- package/src/client/_hs/hyperspan-actions.client.ts +37 -45
- package/src/client/js.test.ts +77 -3
- package/src/client/js.ts +158 -25
- package/src/index.ts +3 -0
- package/src/island.test.ts +30 -0
- package/src/island.ts +37 -0
- package/src/server.ts +1 -1
- package/tsconfig.json +3 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.2",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.ts",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"vitest": "^3.2.4"
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"@hyperspan/html": "^2.0.0-alpha.
|
|
99
|
+
"@hyperspan/html": "^2.0.0-alpha.2",
|
|
100
100
|
"debug": "^4.4.3",
|
|
101
101
|
"isbot": "^5.1.32",
|
|
102
102
|
"zod": "^4.4.3"
|
|
@@ -59,62 +59,56 @@ function setActionLoading(hsActionTag: HTMLElement | null, loading: boolean) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const actionFormObserver = new MutationObserver((list) => {
|
|
63
|
-
list.forEach((mutation) => {
|
|
64
|
-
mutation.addedNodes.forEach((node) => {
|
|
65
|
-
if (node && ('closest' in node || node instanceof HTMLFormElement)) {
|
|
66
|
-
bindHSActionForm(
|
|
67
|
-
(node as HTMLElement).closest('hs-action') as HSAction,
|
|
68
|
-
node instanceof HTMLFormElement
|
|
69
|
-
? node
|
|
70
|
-
: ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
62
|
/**
|
|
78
|
-
* Server action component
|
|
63
|
+
* Server action component — holds the action URL; form submit is handled via delegation.
|
|
79
64
|
*/
|
|
80
|
-
class HSAction extends HTMLElement {
|
|
81
|
-
constructor() {
|
|
82
|
-
super();
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
connectedCallback() {
|
|
86
|
-
actionFormObserver.observe(this, { childList: true, subtree: true });
|
|
87
|
-
bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
65
|
+
class HSAction extends HTMLElement {}
|
|
90
66
|
window.customElements.define('hs-action', HSAction);
|
|
91
67
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*/
|
|
95
|
-
function
|
|
96
|
-
if (
|
|
68
|
+
let actionDelegationInitialized = false;
|
|
69
|
+
|
|
70
|
+
/** One document-level handler so morphs never leave forms without listeners or stack duplicates. */
|
|
71
|
+
function initHSActionFormDelegation() {
|
|
72
|
+
if (actionDelegationInitialized) {
|
|
97
73
|
return;
|
|
98
74
|
}
|
|
75
|
+
actionDelegationInitialized = true;
|
|
99
76
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
e
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
77
|
+
document.addEventListener(
|
|
78
|
+
'submit',
|
|
79
|
+
(e) => {
|
|
80
|
+
const form = e.target;
|
|
81
|
+
if (!(form instanceof HTMLFormElement)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hsAction = form.closest('hs-action');
|
|
86
|
+
if (!hsAction) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
|
|
92
|
+
const url = hsAction.getAttribute('url') || '';
|
|
93
|
+
if (url) {
|
|
94
|
+
form.setAttribute('action', url);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
formSubmitToRoute(form);
|
|
98
|
+
},
|
|
99
|
+
true
|
|
100
|
+
);
|
|
109
101
|
}
|
|
110
102
|
|
|
103
|
+
initHSActionFormDelegation();
|
|
104
|
+
|
|
111
105
|
/**
|
|
112
106
|
* Submit form data to route and replace contents with response
|
|
113
107
|
*/
|
|
114
|
-
|
|
115
|
-
function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
|
|
108
|
+
function formSubmitToRoute(form: HTMLFormElement) {
|
|
116
109
|
const formData = new FormData(form);
|
|
117
|
-
const
|
|
110
|
+
const hsActionTag = form.closest('hs-action') as HTMLElement | null;
|
|
111
|
+
const formUrl = hsActionTag?.getAttribute('url') || form.getAttribute('action') || '';
|
|
118
112
|
const method = form.getAttribute('method')?.toUpperCase() || 'POST';
|
|
119
113
|
const confirmMessage = form.getAttribute('data-confirm') || '';
|
|
120
114
|
const headers = {
|
|
@@ -129,7 +123,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
129
123
|
}
|
|
130
124
|
}
|
|
131
125
|
|
|
132
|
-
const hsActionTag = form.closest('hs-action') as HTMLElement | null;
|
|
133
126
|
const eventTarget: EventTarget = hsActionTag || document;
|
|
134
127
|
const fetchDetail: HS.ActionFetchDetail = {
|
|
135
128
|
form,
|
|
@@ -185,7 +178,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
185
178
|
|
|
186
179
|
dispatchActionEvent(eventTarget, HS_ACTION_AFTER_SWAP, swapDetail);
|
|
187
180
|
|
|
188
|
-
opts.afterResponse && opts.afterResponse();
|
|
189
181
|
lazyLoadScripts();
|
|
190
182
|
}
|
|
191
183
|
|
package/src/client/js.test.ts
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, test, beforeEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { render } from '@hyperspan/html';
|
|
6
|
+
import {
|
|
7
|
+
buildClientJS,
|
|
8
|
+
discoverClientExports,
|
|
9
|
+
extractExports,
|
|
10
|
+
getClientJSEntries,
|
|
11
|
+
resetClientJSEntriesForTests,
|
|
12
|
+
} from './js';
|
|
13
|
+
|
|
14
|
+
describe('buildClientJS', () => {
|
|
15
|
+
let clientFile: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
resetClientJSEntriesForTests();
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), 'hs-client-'));
|
|
20
|
+
clientFile = join(dir, 'picker.ts');
|
|
21
|
+
writeFileSync(
|
|
22
|
+
clientFile,
|
|
23
|
+
`export function mountPicker() {
|
|
24
|
+
document.getElementById('picker')?.classList.add('ready');
|
|
25
|
+
}
|
|
26
|
+
`
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('registers entry and returns stable esmName from path hash', async () => {
|
|
31
|
+
const result = await buildClientJS(clientFile);
|
|
32
|
+
|
|
33
|
+
expect(result.esmName).toMatch(/^client-[0-9a-f]{16}$/);
|
|
34
|
+
expect(result.publicPath).toBe(`/_hs/js/${result.esmName}.js`);
|
|
35
|
+
expect(getClientJSEntries()).toHaveLength(1);
|
|
36
|
+
expect(getClientJSEntries()[0].absPath).toBe(clientFile);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('renderScriptTag with no loader uses import map key', async () => {
|
|
40
|
+
const result = await buildClientJS(clientFile);
|
|
41
|
+
const tag = render(result.renderScriptTag());
|
|
42
|
+
|
|
43
|
+
expect(tag).toContain(`import "${result.esmName}"`);
|
|
44
|
+
expect(tag).toContain(`data-source-id="${result.assetHash}"`);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('renderScriptTag with function inlines bootstrap code', async () => {
|
|
48
|
+
const result = await buildClientJS(clientFile);
|
|
49
|
+
const tag = render(
|
|
50
|
+
result.renderScriptTag(({ mountPicker }: { mountPicker: () => void }) => {
|
|
51
|
+
mountPicker();
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(tag).toContain(`import {mountPicker} from "${result.esmName}"`);
|
|
56
|
+
expect(tag).toContain('mountPicker()');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('renderScriptTag with string inlines bootstrap code', async () => {
|
|
60
|
+
const result = await buildClientJS(clientFile);
|
|
61
|
+
const tag = render(result.renderScriptTag('({ mountPicker }) => mountPicker()'));
|
|
62
|
+
|
|
63
|
+
expect(tag).toContain('({ mountPicker }) => mountPicker()');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
3
66
|
|
|
4
67
|
describe('extractExports', () => {
|
|
5
68
|
test('extracts aliased export from bundled JS content', () => {
|
|
@@ -14,7 +77,7 @@ describe('extractExports', () => {
|
|
|
14
77
|
});
|
|
15
78
|
});
|
|
16
79
|
|
|
17
|
-
test('returns
|
|
80
|
+
test('returns namespace export when none found', () => {
|
|
18
81
|
const contents = 'function noop(){return 1}const value=2;';
|
|
19
82
|
|
|
20
83
|
const result = extractExports(contents);
|
|
@@ -25,3 +88,14 @@ describe('extractExports', () => {
|
|
|
25
88
|
});
|
|
26
89
|
});
|
|
27
90
|
});
|
|
91
|
+
|
|
92
|
+
describe('discoverClientExports', () => {
|
|
93
|
+
test('discovers named exports from source', () => {
|
|
94
|
+
const result = discoverClientExports(
|
|
95
|
+
`export function mountPicker() {}\nexport const VERSION = 1;`
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(result.exports).toBe('{mountPicker, VERSION}');
|
|
99
|
+
expect(result.fnArgs).toBe('{mountPicker, VERSION}');
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/client/js.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
1
3
|
import { html } from '@hyperspan/html';
|
|
4
|
+
import { assetHash as assetHashFn } from '../utils';
|
|
2
5
|
import { getImportMap, registerImport, resolveImport } from './manifest';
|
|
3
6
|
import type { Hyperspan as HS } from '../types';
|
|
4
7
|
|
|
@@ -7,6 +10,27 @@ export { registerImport, resolveImport, getImportMap } from './manifest';
|
|
|
7
10
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
8
11
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
9
12
|
|
|
13
|
+
export type ClientJSEntry = {
|
|
14
|
+
absPath: string;
|
|
15
|
+
assetHash: string;
|
|
16
|
+
esmName: string;
|
|
17
|
+
publicPath: string;
|
|
18
|
+
exports: string;
|
|
19
|
+
fnArgs: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
|
|
23
|
+
|
|
24
|
+
function getClientJSRegistry(): Map<string, ClientJSEntry> {
|
|
25
|
+
const globalRegistry = globalThis as {
|
|
26
|
+
[CLIENT_JS_REGISTRY]?: Map<string, ClientJSEntry>;
|
|
27
|
+
};
|
|
28
|
+
if (!globalRegistry[CLIENT_JS_REGISTRY]) {
|
|
29
|
+
globalRegistry[CLIENT_JS_REGISTRY] = new Map();
|
|
30
|
+
}
|
|
31
|
+
return globalRegistry[CLIENT_JS_REGISTRY];
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/**
|
|
11
35
|
* Backward-compatible import map (backed by asset manifest).
|
|
12
36
|
*/
|
|
@@ -35,40 +59,149 @@ export const JS_IMPORT_MAP = {
|
|
|
35
59
|
},
|
|
36
60
|
};
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Resolve import.meta.resolve() / file URL / absolute path to a filesystem path.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveClientModulePath(modulePathResolved: string): string {
|
|
66
|
+
if (modulePathResolved.startsWith('file://')) {
|
|
67
|
+
return fileURLToPath(modulePathResolved);
|
|
68
|
+
}
|
|
69
|
+
return modulePathResolved;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getClientJSEntries(): ClientJSEntry[] {
|
|
73
|
+
return [...getClientJSRegistry().values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | undefined {
|
|
77
|
+
return getClientJSRegistry().get(esmName);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resetClientJSEntriesForTests(): void {
|
|
81
|
+
getClientJSRegistry().clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function registerClientJSEntry(entry: ClientJSEntry): void {
|
|
85
|
+
getClientJSRegistry().set(entry.esmName, entry);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build (or look up) a client JS module and return a helper for rendering script tags.
|
|
90
|
+
*
|
|
91
|
+
* Never evaluates the module on the server — only registers the path for Vite to bundle
|
|
92
|
+
* and returns URLs / script-tag helpers for the browser.
|
|
93
|
+
*/
|
|
94
|
+
export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
|
|
95
|
+
const absPath = resolveClientModulePath(modulePathResolved);
|
|
96
|
+
const hash = assetHashFn(absPath);
|
|
97
|
+
const esmName = `client-${hash}`;
|
|
98
|
+
const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
|
|
99
|
+
|
|
100
|
+
let exports = '* as _module';
|
|
101
|
+
let fnArgs = '_module';
|
|
102
|
+
if (existsSync(absPath)) {
|
|
103
|
+
const source = readFileSync(absPath, 'utf-8');
|
|
104
|
+
const discovered = discoverClientExports(source);
|
|
105
|
+
exports = discovered.exports;
|
|
106
|
+
fnArgs = discovered.fnArgs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs });
|
|
110
|
+
registerImport(esmName, publicPath);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
assetHash: hash,
|
|
114
|
+
esmName,
|
|
115
|
+
publicPath,
|
|
116
|
+
renderScriptTag: (loadScript) => {
|
|
117
|
+
const t = typeof loadScript;
|
|
118
|
+
|
|
119
|
+
if (t === 'string') {
|
|
120
|
+
return html`
|
|
121
|
+
<script type="module" data-source-id="${hash}">
|
|
122
|
+
import ${exports} from '${esmName}';
|
|
123
|
+
(${html.raw(loadScript as string)})(${fnArgs});
|
|
124
|
+
</script>
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
if (t === 'function') {
|
|
128
|
+
return html`
|
|
129
|
+
<script type="module" data-source-id="${hash}">
|
|
130
|
+
import ${exports} from '${esmName}';
|
|
131
|
+
(${html.raw(functionToString(loadScript))})(${fnArgs});
|
|
132
|
+
</script>
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return html`
|
|
137
|
+
<script type="module" data-source-id="${hash}">
|
|
138
|
+
import '${esmName}';
|
|
139
|
+
</script>
|
|
140
|
+
`;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
39
144
|
|
|
40
145
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
146
|
+
* Discover export names from source or bundled client JS.
|
|
147
|
+
* Prefers bundled `export{...}` form, then common source export patterns.
|
|
43
148
|
*/
|
|
44
|
-
export
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
: _modulePathResolved.includes('actions')
|
|
49
|
-
? 'actions'
|
|
50
|
-
: 'scripts';
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
return getClientJSFromManifest(moduleId);
|
|
54
|
-
} catch {
|
|
55
|
-
const esmName = moduleId;
|
|
56
|
-
const publicPath = `${JS_PUBLIC_PATH}/hyperspan-${moduleId}.client.js`;
|
|
57
|
-
registerImport(esmName, publicPath);
|
|
58
|
-
return {
|
|
59
|
-
assetHash: moduleId,
|
|
60
|
-
esmName,
|
|
61
|
-
publicPath,
|
|
62
|
-
renderScriptTag: () => html`<script type="module" src="${publicPath}"></script>`,
|
|
63
|
-
};
|
|
149
|
+
export function discoverClientExports(contents: string): { exports: string; fnArgs: string } {
|
|
150
|
+
const bundled = extractExports(contents);
|
|
151
|
+
if (bundled.exports !== '* as _module') {
|
|
152
|
+
return bundled;
|
|
64
153
|
}
|
|
154
|
+
|
|
155
|
+
const names: string[] = [];
|
|
156
|
+
const seen = new Set<string>();
|
|
157
|
+
|
|
158
|
+
for (const re of [
|
|
159
|
+
/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
|
|
160
|
+
/export\s+const\s+([A-Za-z_$][\w$]*)/g,
|
|
161
|
+
/export\s+class\s+([A-Za-z_$][\w$]*)/g,
|
|
162
|
+
/export\s+let\s+([A-Za-z_$][\w$]*)/g,
|
|
163
|
+
]) {
|
|
164
|
+
let match: RegExpExecArray | null;
|
|
165
|
+
while ((match = re.exec(contents)) !== null) {
|
|
166
|
+
if (!seen.has(match[1])) {
|
|
167
|
+
names.push(match[1]);
|
|
168
|
+
seen.add(match[1]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const exportBlock = /export\s*\{([^}]+)\}/g;
|
|
174
|
+
let block: RegExpExecArray | null;
|
|
175
|
+
while ((block = exportBlock.exec(contents)) !== null) {
|
|
176
|
+
for (const part of block[1].split(',')) {
|
|
177
|
+
const trimmed = part.trim();
|
|
178
|
+
if (!trimmed || trimmed.startsWith('type ')) continue;
|
|
179
|
+
const asMatch = trimmed.match(/^(?:([\w$]+)\s+as\s+)?([\w$]+)$/);
|
|
180
|
+
const name = asMatch?.[2] ?? trimmed;
|
|
181
|
+
if (name && name !== 'default' && !seen.has(name)) {
|
|
182
|
+
names.push(name);
|
|
183
|
+
seen.add(name);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (names.length === 0) {
|
|
189
|
+
if (/export\s+default\b/.test(contents)) {
|
|
190
|
+
return { exports: 'DefaultExport', fnArgs: 'DefaultExport' };
|
|
191
|
+
}
|
|
192
|
+
return { exports: '* as _module', fnArgs: '_module' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const exports = `{${names.join(', ')}}`;
|
|
196
|
+
return { exports, fnArgs: exports };
|
|
65
197
|
}
|
|
66
198
|
|
|
67
199
|
/**
|
|
68
|
-
* Extract the exports from a client JS module
|
|
200
|
+
* Extract the exports from a client JS module (bundled `export{a as b}` form).
|
|
69
201
|
*/
|
|
70
202
|
export function extractExports(contents: string): { exports: string; fnArgs: string } {
|
|
71
|
-
const
|
|
203
|
+
const exportRegex = /export\{(.*)\}/g;
|
|
204
|
+
const exportLine = exportRegex.exec(contents);
|
|
72
205
|
let exports = '{}';
|
|
73
206
|
let fnArgs = '{}';
|
|
74
207
|
|
package/src/index.ts
CHANGED
|
@@ -19,5 +19,8 @@ export {
|
|
|
19
19
|
getImportMap,
|
|
20
20
|
registerImport,
|
|
21
21
|
} from './client/manifest';
|
|
22
|
+
export { buildClientJS } from './client/js';
|
|
22
23
|
export type { AssetManifest } from './client/manifest';
|
|
24
|
+
export { renderIsland } from './island';
|
|
25
|
+
export type { IslandRenderOptions } from './island';
|
|
23
26
|
export type { Hyperspan } from './types';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { renderIsland } from './island';
|
|
3
|
+
|
|
4
|
+
describe('renderIsland', () => {
|
|
5
|
+
test('throws when component has no __HS_ISLAND', () => {
|
|
6
|
+
expect(() => renderIsland({ name: 'Bare' } as never, {})).toThrow(/not a Hyperspan island/i);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('returns html.raw for sync island render', () => {
|
|
10
|
+
const component = {
|
|
11
|
+
name: 'Counter',
|
|
12
|
+
__HS_ISLAND: {
|
|
13
|
+
render: () => '<div id="x">1</div>',
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const result = renderIsland(component, { count: 1 });
|
|
17
|
+
expect(result).toHaveProperty('_kind', 'html_safe');
|
|
18
|
+
expect((result as { content: string }).content).toContain('<div id="x">1</div>');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('awaits async island render', async () => {
|
|
22
|
+
const component = {
|
|
23
|
+
__HS_ISLAND: {
|
|
24
|
+
render: async () => '<div>async</div>',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const result = await renderIsland(component, {});
|
|
28
|
+
expect(result.content).toContain('<div>async</div>');
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/island.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { html } from '@hyperspan/html';
|
|
2
|
+
|
|
3
|
+
type IslandComponent = {
|
|
4
|
+
__HS_ISLAND?: {
|
|
5
|
+
render: (props: unknown, options: unknown) => string | Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
name?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type IslandRenderOptions = {
|
|
11
|
+
ssr?: boolean;
|
|
12
|
+
loading?: 'lazy' | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render any Hyperspan island component (Preact, Svelte, Vue).
|
|
17
|
+
* Proxies to `Component.__HS_ISLAND.render` injected by the island Vite plugin.
|
|
18
|
+
*/
|
|
19
|
+
export function renderIsland(
|
|
20
|
+
Component: IslandComponent,
|
|
21
|
+
props: Record<string, unknown> = {},
|
|
22
|
+
options: IslandRenderOptions = { ssr: true }
|
|
23
|
+
) {
|
|
24
|
+
if (!Component?.__HS_ISLAND?.render) {
|
|
25
|
+
const label = Component?.name ?? 'Component';
|
|
26
|
+
throw new Error(
|
|
27
|
+
`${label} is not a Hyperspan island. Import it with \`with { island: 'preact' | 'svelte' | 'vue' }\` ` +
|
|
28
|
+
`and ensure the matching plugin is listed in hyperspan.config.ts plugins.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = Component.__HS_ISLAND.render(props, options);
|
|
33
|
+
if (result != null && typeof (result as Promise<string>).then === 'function') {
|
|
34
|
+
return (result as Promise<string>).then((s) => html.raw(s));
|
|
35
|
+
}
|
|
36
|
+
return html.raw(result as string);
|
|
37
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -541,7 +541,7 @@ export async function returnHTMLResponse(
|
|
|
541
541
|
if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
542
542
|
return new StreamResponse(
|
|
543
543
|
renderStream(routeContent as HSHtml, {
|
|
544
|
-
renderChunk: (chunk) => {
|
|
544
|
+
renderChunk: (chunk: { id: string; content: string }) => {
|
|
545
545
|
// Trailing <!--/hs:chunk--> marks the end of a streaming chunk boundary.
|
|
546
546
|
return html`
|
|
547
547
|
<template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
|
package/tsconfig.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compileOnSave": true,
|
|
3
|
+
"include": ["src/**/*.ts"],
|
|
3
4
|
"compilerOptions": {
|
|
4
5
|
"outDir": "dist",
|
|
5
6
|
"target": "ES2020",
|
|
@@ -10,7 +11,6 @@
|
|
|
10
11
|
"esModuleInterop": true,
|
|
11
12
|
"allowImportingTsExtensions": true,
|
|
12
13
|
"noEmit": true,
|
|
13
|
-
"composite": true,
|
|
14
14
|
"strict": true,
|
|
15
15
|
"declaration": true,
|
|
16
16
|
"sourceMap": true,
|
|
@@ -19,11 +19,7 @@
|
|
|
19
19
|
"allowSyntheticDefaultImports": true,
|
|
20
20
|
"forceConsistentCasingInFileNames": true,
|
|
21
21
|
"allowJs": true,
|
|
22
|
-
"baseUrl": "."
|
|
23
|
-
"paths": {
|
|
24
|
-
"@hyperspan/html": ["../html/src/html.ts"]
|
|
25
|
-
}
|
|
22
|
+
"baseUrl": "."
|
|
26
23
|
},
|
|
27
|
-
"
|
|
28
|
-
"exclude": ["node_modules", "__tests__", "*.test.ts"]
|
|
24
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
29
25
|
}
|