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