@hyperspan/plugin-preact 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 +5 -3
- package/src/index.test.ts +9 -1
- package/src/index.ts +109 -51
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/plugin-preact",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.2",
|
|
4
4
|
"description": "Hyperspan Plugin for Preact",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -30,14 +30,16 @@
|
|
|
30
30
|
"url": "git+https://github.com/vlucas/hyperspan.git"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
+
"@hyperspan/vite-plugin": "^2.0.0-alpha.2",
|
|
33
34
|
"prettier": "^3.8.1",
|
|
34
35
|
"typescript": "^5.9.3",
|
|
35
36
|
"vite": "^6.3.5",
|
|
36
37
|
"vitest": "^3.2.4"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
|
-
"@hyperspan/html": "^2.0.0-alpha.
|
|
40
|
-
"@hyperspan/framework": "^2.0.0-alpha.
|
|
40
|
+
"@hyperspan/html": "^2.0.0-alpha.2",
|
|
41
|
+
"@hyperspan/framework": "^2.0.0-alpha.2",
|
|
42
|
+
"@hyperspan/vite-plugin": "^2.0.0-alpha.2",
|
|
41
43
|
"vite": "^6.0.0 || ^7.0.0"
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|
package/src/index.test.ts
CHANGED
|
@@ -49,6 +49,14 @@ describe('buildIslandHtml', () => {
|
|
|
49
49
|
expect(result).not.toContain('data-loading="lazy"');
|
|
50
50
|
expect(result).not.toContain('<template>');
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
test('named export uses brace import syntax', () => {
|
|
54
|
+
const result = buildIslandHtml(jsId, componentName, esmName, '', '<p>SSR</p>', {
|
|
55
|
+
exportKind: 'named',
|
|
56
|
+
});
|
|
57
|
+
expect(result).toContain(`import { ${componentName} } from "${esmName}"`);
|
|
58
|
+
expect(result).not.toContain(`import ${componentName} from "${esmName}"`);
|
|
59
|
+
});
|
|
52
60
|
});
|
|
53
61
|
|
|
54
62
|
// ---------------------------------------------------------------------------
|
|
@@ -91,7 +99,7 @@ describe('renderPreactSSR', () => {
|
|
|
91
99
|
|
|
92
100
|
describe('renderPreactIsland', () => {
|
|
93
101
|
test('throws when component has no __HS_ISLAND property', () => {
|
|
94
|
-
expect(() => renderPreactIsland(Hello, {})).toThrow(
|
|
102
|
+
expect(() => renderPreactIsland(Hello, {})).toThrow(/not a Hyperspan island/i);
|
|
95
103
|
});
|
|
96
104
|
|
|
97
105
|
test('returns an html_safe object', () => {
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
import { resolve,
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { assetHash } from '@hyperspan/framework/utils';
|
|
5
5
|
import { registerImport, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
|
|
6
|
+
import { renderIsland } from '@hyperspan/framework';
|
|
6
7
|
import type { Hyperspan as HS } from '@hyperspan/framework';
|
|
7
8
|
import { html } from '@hyperspan/html';
|
|
8
9
|
import { h } from 'preact';
|
|
9
10
|
import { render as preactRenderToString } from 'preact-render-to-string';
|
|
11
|
+
import {
|
|
12
|
+
registerIslandPlugin,
|
|
13
|
+
isIslandModule,
|
|
14
|
+
islandPluginResolveId,
|
|
15
|
+
splitIslandId,
|
|
16
|
+
} from '@hyperspan/vite-plugin/islands';
|
|
10
17
|
import debug from 'debug';
|
|
11
18
|
|
|
12
19
|
const log = debug('hyperspan:plugin-preact');
|
|
13
20
|
|
|
21
|
+
export type IslandExportKind = 'default' | 'named';
|
|
22
|
+
|
|
14
23
|
export function buildIslandHtml(
|
|
15
24
|
jsId: string,
|
|
16
25
|
componentName: string,
|
|
17
26
|
esmName: string,
|
|
18
27
|
jsContent: string,
|
|
19
28
|
ssrContent: string,
|
|
20
|
-
options: { loading?: string } = {}
|
|
29
|
+
options: { loading?: string; exportKind?: IslandExportKind } = {}
|
|
21
30
|
): string {
|
|
22
|
-
const
|
|
31
|
+
const importStmt =
|
|
32
|
+
options.exportKind === 'named'
|
|
33
|
+
? `import { ${componentName} } from "${esmName}";`
|
|
34
|
+
: `import ${componentName} from "${esmName}";`;
|
|
35
|
+
const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">${importStmt}${jsContent}</script>`;
|
|
23
36
|
if (options.loading === 'lazy') {
|
|
24
37
|
return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
|
|
25
38
|
}
|
|
@@ -30,6 +43,8 @@ export function renderPreactSSR(Component: unknown, props: Record<string, unknow
|
|
|
30
43
|
return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
|
|
31
44
|
}
|
|
32
45
|
|
|
46
|
+
type DiscoveredExport = { name: string; kind: IslandExportKind };
|
|
47
|
+
|
|
33
48
|
function extractDefaultExportName(code: string): string | null {
|
|
34
49
|
const patterns = [
|
|
35
50
|
/export\{([^\s]+) as default\}/,
|
|
@@ -47,18 +62,82 @@ function extractDefaultExportName(code: string): string | null {
|
|
|
47
62
|
return anyMatch?.[1] ?? null;
|
|
48
63
|
}
|
|
49
64
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
)
|
|
65
|
+
function discoverPreactExports(code: string): DiscoveredExport[] {
|
|
66
|
+
const exports: DiscoveredExport[] = [];
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
|
|
69
|
+
const defaultName = extractDefaultExportName(code);
|
|
70
|
+
if (defaultName) {
|
|
71
|
+
exports.push({ name: defaultName, kind: 'default' });
|
|
72
|
+
seen.add(defaultName);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const re of [
|
|
76
|
+
/export\s+function\s+([A-Za-z_$][\w$]*)/g,
|
|
77
|
+
/export\s+const\s+([A-Za-z_$][\w$]*)/g,
|
|
78
|
+
/export\s+class\s+([A-Za-z_$][\w$]*)/g,
|
|
79
|
+
]) {
|
|
80
|
+
let match: RegExpExecArray | null;
|
|
81
|
+
while ((match = re.exec(code)) !== null) {
|
|
82
|
+
if (!seen.has(match[1])) {
|
|
83
|
+
exports.push({ name: match[1], kind: 'named' });
|
|
84
|
+
seen.add(match[1]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const exportBlock = /export\s*\{([^}]+)\}/g;
|
|
90
|
+
let block: RegExpExecArray | null;
|
|
91
|
+
while ((block = exportBlock.exec(code)) !== null) {
|
|
92
|
+
for (const part of block[1].split(',')) {
|
|
93
|
+
const trimmed = part.trim();
|
|
94
|
+
if (!trimmed || trimmed.startsWith('type ')) continue;
|
|
95
|
+
const asMatch = trimmed.match(/^(?:([\w$]+)\s+as\s+)?([\w$]+)$/);
|
|
96
|
+
const name = asMatch?.[2] ?? trimmed;
|
|
97
|
+
if (name && name !== 'default' && !seen.has(name)) {
|
|
98
|
+
exports.push({ name, kind: 'named' });
|
|
99
|
+
seen.add(name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return exports;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildIslandAttachment(
|
|
108
|
+
exportInfo: DiscoveredExport,
|
|
109
|
+
cleanId: string,
|
|
110
|
+
esmName: string
|
|
111
|
+
): string {
|
|
112
|
+
const { name: componentName, kind } = exportInfo;
|
|
113
|
+
const jsId = assetHash(`${cleanId}:${componentName}`);
|
|
114
|
+
const exportKind = kind;
|
|
115
|
+
|
|
116
|
+
return `
|
|
117
|
+
${componentName}.__HS_ISLAND = {
|
|
118
|
+
id: "${jsId}",
|
|
119
|
+
render: (props, options = {}) => {
|
|
120
|
+
const __hs_exportKind = ${JSON.stringify(exportKind)};
|
|
121
|
+
if (options.ssr === false) {
|
|
122
|
+
const jsContent = \`import { h as __hs_h, render as __hs_render } from 'preact';__hs_render(__hs_h(${componentName}, \${JSON.stringify(props)}), document.getElementById("${jsId}"));\`;
|
|
123
|
+
return __hs_renderIsland_${componentName}(jsContent, '', options);
|
|
124
|
+
}
|
|
125
|
+
const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
|
|
126
|
+
const jsContent = \`import { h as __hs_h, hydrate as __hs_hydrate } from 'preact';__hs_hydrate(__hs_h(${componentName}, \${JSON.stringify(props)}), document.getElementById("${jsId}"));\`;
|
|
127
|
+
return __hs_renderIsland_${componentName}(jsContent, ssrContent, options);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function __hs_renderIsland_${componentName}(jsContent = '', ssrContent = '', options = {}) {
|
|
132
|
+
return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, { ...options, exportKind: ${JSON.stringify(exportKind)} });
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
56
135
|
}
|
|
57
136
|
|
|
58
137
|
/**
|
|
59
138
|
* Vite plugin for Preact islands.
|
|
60
139
|
*/
|
|
61
|
-
export function
|
|
140
|
+
export function preactIslandPlugin(): Plugin {
|
|
62
141
|
const preactClientPath = resolve(dirname(fileURLToPath(import.meta.url)), './preact-client.ts');
|
|
63
142
|
|
|
64
143
|
return {
|
|
@@ -73,9 +152,9 @@ export function preactVitePlugin(): Plugin {
|
|
|
73
152
|
};
|
|
74
153
|
},
|
|
75
154
|
|
|
155
|
+
resolveId: islandPluginResolveId('preact', '.tsx'),
|
|
156
|
+
|
|
76
157
|
configureServer() {
|
|
77
|
-
// In Vite serve, Preact is resolved from node_modules / optimizeDeps.
|
|
78
|
-
// Import-map aliases for the browser bundle are set during production build.
|
|
79
158
|
const clientUrl = '/islands/preact-client.js';
|
|
80
159
|
registerImport('preact', clientUrl);
|
|
81
160
|
registerImport('preact/hooks', clientUrl);
|
|
@@ -87,21 +166,24 @@ export function preactVitePlugin(): Plugin {
|
|
|
87
166
|
},
|
|
88
167
|
|
|
89
168
|
async transform(code, id) {
|
|
90
|
-
if (!
|
|
169
|
+
if (!isIslandModule(id, 'preact', '.tsx')) return;
|
|
91
170
|
|
|
92
171
|
log('transform island', id);
|
|
93
|
-
const
|
|
94
|
-
const esmName =
|
|
95
|
-
const
|
|
172
|
+
const cleanId = splitIslandId(id).path;
|
|
173
|
+
const esmName = `island-${assetHash(cleanId)}`;
|
|
174
|
+
const exports = discoverPreactExports(code);
|
|
96
175
|
|
|
97
|
-
if (
|
|
176
|
+
if (exports.length === 0) {
|
|
98
177
|
throw new Error(
|
|
99
|
-
`No
|
|
178
|
+
`No component exports found in ${cleanId}. Export a default or named Preact component and import with \`with { island: 'preact' }\`.`
|
|
100
179
|
);
|
|
101
180
|
}
|
|
102
181
|
|
|
103
|
-
|
|
104
|
-
|
|
182
|
+
registerImport(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
183
|
+
|
|
184
|
+
const attachments = exports
|
|
185
|
+
.map((exp) => buildIslandAttachment(exp, cleanId, esmName))
|
|
186
|
+
.join('\n');
|
|
105
187
|
|
|
106
188
|
const moduleCode = `// hyperspan:processed
|
|
107
189
|
import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';
|
|
@@ -109,30 +191,13 @@ import { render as __hs_renderToString } from 'preact-render-to-string';
|
|
|
109
191
|
import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
|
|
110
192
|
|
|
111
193
|
${code}
|
|
112
|
-
|
|
113
|
-
function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
|
|
114
|
-
return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, options);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
${componentName}.__HS_ISLAND = {
|
|
118
|
-
id: "${jsId}",
|
|
119
|
-
render: (props, options = {}) => {
|
|
120
|
-
if (options.ssr === false) {
|
|
121
|
-
const jsContent = \`import { h as __hs_h, render as __hs_render } from 'preact';__hs_render(__hs_h(${componentName}, \${JSON.stringify(props)}), document.getElementById("${jsId}"));\`;
|
|
122
|
-
return __hs_renderIsland(jsContent, '', options);
|
|
123
|
-
}
|
|
124
|
-
const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
|
|
125
|
-
const jsContent = \`import { h as __hs_h, hydrate as __hs_hydrate } from 'preact';__hs_hydrate(__hs_h(${componentName}, \${JSON.stringify(props)}), document.getElementById("${jsId}"));\`;
|
|
126
|
-
return __hs_renderIsland(jsContent, ssrContent, options);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
194
|
+
${attachments}
|
|
129
195
|
`;
|
|
130
196
|
|
|
131
197
|
return { code: moduleCode, map: null };
|
|
132
198
|
},
|
|
133
199
|
|
|
134
200
|
buildStart() {
|
|
135
|
-
// emitFile is only available during production builds, not vite serve.
|
|
136
201
|
if (this.meta.watchMode) return;
|
|
137
202
|
try {
|
|
138
203
|
this.emitFile({
|
|
@@ -148,26 +213,19 @@ ${componentName}.__HS_ISLAND = {
|
|
|
148
213
|
}
|
|
149
214
|
|
|
150
215
|
/**
|
|
151
|
-
* Hyperspan config plugin
|
|
216
|
+
* Hyperspan config plugin — register in hyperspan.config.ts plugins array.
|
|
152
217
|
*/
|
|
153
218
|
export function preactPlugin(): HS.Plugin {
|
|
219
|
+
registerIslandPlugin('preact', { vitePlugin: preactIslandPlugin });
|
|
154
220
|
return () => {
|
|
155
|
-
log('preactPlugin loaded
|
|
221
|
+
log('preactPlugin loaded');
|
|
156
222
|
};
|
|
157
223
|
}
|
|
158
224
|
|
|
159
225
|
export function renderPreactIsland(
|
|
160
|
-
Component:
|
|
161
|
-
__HS_ISLAND?: { render: (props: unknown, options: unknown) => string };
|
|
162
|
-
name?: string;
|
|
163
|
-
},
|
|
226
|
+
Component: Parameters<typeof renderIsland>[0],
|
|
164
227
|
props: Record<string, unknown> = {},
|
|
165
|
-
options:
|
|
228
|
+
options: Parameters<typeof renderIsland>[2] = { ssr: true }
|
|
166
229
|
) {
|
|
167
|
-
|
|
168
|
-
return html.raw(Component.__HS_ISLAND.render(props, options));
|
|
169
|
-
}
|
|
170
|
-
throw new Error(
|
|
171
|
-
`Module ${Component.name} was not loaded with an island plugin! Add preactVitePlugin() to vite.config.ts.`
|
|
172
|
-
);
|
|
230
|
+
return renderIsland(Component, props, options);
|
|
173
231
|
}
|