@hyperspan/plugin-preact 1.0.4 → 2.0.0-alpha.1
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 +17 -8
- package/src/index.test.ts +14 -6
- package/src/index.ts +108 -196
package/package.json
CHANGED
|
@@ -1,35 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/plugin-preact",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-alpha.1",
|
|
4
4
|
"description": "Hyperspan Plugin for Preact",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "src/index.ts",
|
|
6
7
|
"types": "src/index.ts",
|
|
7
8
|
"public": true,
|
|
8
9
|
"publishConfig": {
|
|
9
|
-
"access": "public"
|
|
10
|
+
"access": "public",
|
|
11
|
+
"tag": "alpha"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"default": "./src/index.ts"
|
|
17
|
+
}
|
|
10
18
|
},
|
|
11
19
|
"author": "Vance Lucas <vance@vancelucas.com>",
|
|
12
20
|
"license": "BSD-3-Clause",
|
|
13
|
-
"keywords": [],
|
|
14
21
|
"homepage": "https://www.hyperspan.dev/docs/clientjs/islands",
|
|
15
22
|
"bugs": {
|
|
16
23
|
"url": "https://github.com/vlucas/hyperspan/issues"
|
|
17
24
|
},
|
|
18
25
|
"scripts": {
|
|
19
|
-
"test": "
|
|
26
|
+
"test": "vitest run"
|
|
20
27
|
},
|
|
21
28
|
"repository": {
|
|
22
29
|
"type": "git",
|
|
23
30
|
"url": "git+https://github.com/vlucas/hyperspan.git"
|
|
24
31
|
},
|
|
25
32
|
"devDependencies": {
|
|
26
|
-
"@types/bun": "^1.3.8",
|
|
27
33
|
"prettier": "^3.8.1",
|
|
28
|
-
"typescript": "^5.9.3"
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vite": "^6.3.5",
|
|
36
|
+
"vitest": "^3.2.4"
|
|
29
37
|
},
|
|
30
38
|
"peerDependencies": {
|
|
31
|
-
"@hyperspan/html": "^
|
|
32
|
-
"@hyperspan/framework": "^
|
|
39
|
+
"@hyperspan/html": "^2.0.0-alpha.1",
|
|
40
|
+
"@hyperspan/framework": "^2.0.0-alpha.1",
|
|
41
|
+
"vite": "^6.0.0 || ^7.0.0"
|
|
33
42
|
},
|
|
34
43
|
"dependencies": {
|
|
35
44
|
"debug": "^4.4.3",
|
package/src/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test, describe, expect } from '
|
|
1
|
+
import { test, describe, expect } from 'vitest';
|
|
2
2
|
import { h } from 'preact';
|
|
3
3
|
import { buildIslandHtml, renderPreactSSR, renderPreactIsland } from './index';
|
|
4
4
|
|
|
@@ -91,9 +91,7 @@ describe('renderPreactSSR', () => {
|
|
|
91
91
|
|
|
92
92
|
describe('renderPreactIsland', () => {
|
|
93
93
|
test('throws when component has no __HS_ISLAND property', () => {
|
|
94
|
-
expect(() => renderPreactIsland(Hello, {})).toThrow(
|
|
95
|
-
'was not loaded with an island plugin'
|
|
96
|
-
);
|
|
94
|
+
expect(() => renderPreactIsland(Hello, {})).toThrow('was not loaded with an island plugin');
|
|
97
95
|
});
|
|
98
96
|
|
|
99
97
|
test('returns an html_safe object', () => {
|
|
@@ -179,10 +177,20 @@ describe('renderPreactIsland', () => {
|
|
|
179
177
|
(Hello as any).__HS_ISLAND = {
|
|
180
178
|
id: jsId,
|
|
181
179
|
render: (props: any, options: any = {}) =>
|
|
182
|
-
buildIslandHtml(
|
|
180
|
+
buildIslandHtml(
|
|
181
|
+
jsId,
|
|
182
|
+
'Hello',
|
|
183
|
+
'hello',
|
|
184
|
+
'console.log(1)',
|
|
185
|
+
renderPreactSSR(Hello, props),
|
|
186
|
+
options
|
|
187
|
+
),
|
|
183
188
|
};
|
|
184
189
|
|
|
185
|
-
const result = renderPreactIsland(Hello, { name: 'World' }, {
|
|
190
|
+
const result = renderPreactIsland(Hello, { name: 'World' }, {
|
|
191
|
+
ssr: true,
|
|
192
|
+
loading: 'lazy',
|
|
193
|
+
} as any);
|
|
186
194
|
expect(result.content).toContain('data-loading="lazy"');
|
|
187
195
|
expect(result.content).toContain('<template>');
|
|
188
196
|
expect(result.content).toContain('Hello World!');
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { resolve, basename, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
2
4
|
import { assetHash } from '@hyperspan/framework/utils';
|
|
3
|
-
import {
|
|
4
|
-
import { resolve } from 'node:path';
|
|
5
|
+
import { registerImport, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
|
|
5
6
|
import type { Hyperspan as HS } from '@hyperspan/framework';
|
|
6
7
|
import { html } from '@hyperspan/html';
|
|
7
8
|
import { h } from 'preact';
|
|
@@ -10,36 +11,6 @@ import debug from 'debug';
|
|
|
10
11
|
|
|
11
12
|
const log = debug('hyperspan:plugin-preact');
|
|
12
13
|
|
|
13
|
-
const CWD = process.cwd();
|
|
14
|
-
|
|
15
|
-
/** Dev: stable `[name].js` via Bun default. Prod: hashed filenames for caching. */
|
|
16
|
-
const ISLAND_JS_NAMING = IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined;
|
|
17
|
-
|
|
18
|
-
function islandBundleBaseName(outputPath: string): string {
|
|
19
|
-
return String(outputPath.split('/').reverse()[0]!.replace(/\.js$/i, ''));
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function pickEntryPointJsOutput(
|
|
23
|
-
outputs: ReadonlyArray<{ path: string; kind?: string }>,
|
|
24
|
-
entrySourcePath: string
|
|
25
|
-
): { path: string } {
|
|
26
|
-
const js = outputs.filter((o) => o.path.endsWith('.js'));
|
|
27
|
-
const entry = js.find((o) => o.kind === 'entry-point');
|
|
28
|
-
if (entry) return entry;
|
|
29
|
-
const sourceBase = entrySourcePath.split('/').pop()!.replace(/\.(tsx|ts|jsx|js)$/i, '');
|
|
30
|
-
const byName = js.find((o) => {
|
|
31
|
-
const b = islandBundleBaseName(o.path);
|
|
32
|
-
return b === sourceBase || b.startsWith(`${sourceBase}-`);
|
|
33
|
-
});
|
|
34
|
-
if (byName) return byName;
|
|
35
|
-
if (js[0]) return js[0];
|
|
36
|
-
throw new Error('[Hyperspan] Preact island build produced no JS output');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Build the island wrapper HTML: a div for SSR content + a module script tag for client hydration.
|
|
41
|
-
* Exported so it can be imported by generated island module code and used directly in tests.
|
|
42
|
-
*/
|
|
43
14
|
export function buildIslandHtml(
|
|
44
15
|
jsId: string,
|
|
45
16
|
componentName: string,
|
|
@@ -55,159 +26,94 @@ export function buildIslandHtml(
|
|
|
55
26
|
return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
|
|
56
27
|
}
|
|
57
28
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* Exported for direct use in tests and external tooling.
|
|
61
|
-
*/
|
|
62
|
-
export function renderPreactSSR(Component: any, props: any = {}): string {
|
|
63
|
-
return preactRenderToString(h(Component, props));
|
|
29
|
+
export function renderPreactSSR(Component: unknown, props: Record<string, unknown> = {}): string {
|
|
30
|
+
return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
|
|
64
31
|
}
|
|
65
32
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const outdir = resolve(CWD, config.publicDir, JS_ISLAND_PUBLIC_PATH.replace(/^\//, ''));
|
|
78
|
-
|
|
79
|
-
// Preact client JS is always production mode
|
|
80
|
-
process.env.NODE_ENV = 'production';
|
|
81
|
-
const result = await Bun.build({
|
|
82
|
-
entrypoints: [sourceFile],
|
|
83
|
-
outdir,
|
|
84
|
-
naming: ISLAND_JS_NAMING,
|
|
85
|
-
minify: true,
|
|
86
|
-
format: 'esm',
|
|
87
|
-
target: 'browser',
|
|
88
|
-
});
|
|
89
|
-
process.env.NODE_ENV = currentNodeEnv;
|
|
90
|
-
|
|
91
|
-
const preactEntry = pickEntryPointJsOutput(result.outputs, sourceFile);
|
|
92
|
-
const builtFileName = islandBundleBaseName(preactEntry.path);
|
|
93
|
-
const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/${builtFileName}.js`;
|
|
94
|
-
|
|
95
|
-
JS_IMPORT_MAP.set('preact', builtFilePath);
|
|
96
|
-
JS_IMPORT_MAP.set('preact/compat', builtFilePath);
|
|
97
|
-
JS_IMPORT_MAP.set('preact/hooks', builtFilePath);
|
|
98
|
-
JS_IMPORT_MAP.set('preact/jsx-runtime', builtFilePath);
|
|
99
|
-
JS_IMPORT_MAP.set('preact/jsx-dev-runtime', builtFilePath);
|
|
100
|
-
|
|
101
|
-
if (!JS_IMPORT_MAP.has('react')) {
|
|
102
|
-
JS_IMPORT_MAP.set('react', builtFilePath);
|
|
103
|
-
JS_IMPORT_MAP.set('react-dom', builtFilePath);
|
|
33
|
+
function extractDefaultExportName(code: string): string | null {
|
|
34
|
+
const patterns = [
|
|
35
|
+
/export\{([^\s]+) as default\}/,
|
|
36
|
+
/export default function\s+([^\s(]+)/,
|
|
37
|
+
/export default function\s*\(/,
|
|
38
|
+
/export default const\s+([^\s=]+)/,
|
|
39
|
+
/export default class\s+([^\s{]+)/,
|
|
40
|
+
];
|
|
41
|
+
for (const re of patterns) {
|
|
42
|
+
const m = code.match(re);
|
|
43
|
+
if (m?.[1]) return m[1];
|
|
104
44
|
}
|
|
45
|
+
if (/export default function/.test(code)) return 'DefaultComponent';
|
|
46
|
+
const anyMatch = code.match(/export default\s+([A-Za-z_$][\w$]*)/);
|
|
47
|
+
return anyMatch?.[1] ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isIslandComponent(id: string): boolean {
|
|
51
|
+
return (
|
|
52
|
+
(id.includes('/app/components/') || id.includes('\\app\\components\\')) &&
|
|
53
|
+
id.endsWith('.tsx') &&
|
|
54
|
+
!id.includes('node_modules')
|
|
55
|
+
);
|
|
105
56
|
}
|
|
106
57
|
|
|
107
58
|
/**
|
|
108
|
-
*
|
|
59
|
+
* Vite plugin for Preact islands.
|
|
109
60
|
*/
|
|
110
|
-
export function
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
log('plugin loaded');
|
|
114
|
-
// Ensure Preact can be loaded on the client
|
|
115
|
-
if (!JS_IMPORT_MAP.has('preact')) {
|
|
116
|
-
await copyPreactToPublicFolder(config);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Define a Bun plugin to handle .tsx files
|
|
120
|
-
await Bun.plugin({
|
|
121
|
-
name: 'Hyperspan Preact Loader',
|
|
122
|
-
async setup(build) {
|
|
123
|
-
// when a .tsx file is imported...
|
|
124
|
-
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
|
125
|
-
log('tsx file loaded', args.path);
|
|
126
|
-
const jsId = assetHash(args.path);
|
|
61
|
+
export function preactVitePlugin(): Plugin {
|
|
62
|
+
const preactClientPath = resolve(dirname(fileURLToPath(import.meta.url)), './preact-client.ts');
|
|
127
63
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
64
|
+
return {
|
|
65
|
+
name: 'hyperspan-preact',
|
|
66
|
+
enforce: 'pre',
|
|
131
67
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Look for the default export
|
|
168
|
-
const RE_EXPORT_DEFAULT = /export\{([^\s]+) as default\}/;
|
|
169
|
-
const RE_EXPORT_DEFAULT_FN = /export default function\s+([^\s]+)/;
|
|
170
|
-
const RE_EXPORT_DEFAULT_CONST = /export default const\s+([^\s]+)/;
|
|
171
|
-
const RE_EXPORT_DEFAULT_ANY = /export default\s+([^\s]+)/;
|
|
172
|
-
|
|
173
|
-
const exportedDefault = contents.match(RE_EXPORT_DEFAULT);
|
|
174
|
-
const exportedDefaultFn = contents.match(RE_EXPORT_DEFAULT_FN);
|
|
175
|
-
const exportedDefaultConst = contents.match(RE_EXPORT_DEFAULT_CONST);
|
|
176
|
-
const exportedDefaultAny = contents.match(RE_EXPORT_DEFAULT_ANY);
|
|
177
|
-
|
|
178
|
-
const componentName =
|
|
179
|
-
exportedDefault?.[1] ||
|
|
180
|
-
exportedDefaultFn?.[1] ||
|
|
181
|
-
exportedDefaultConst?.[1] ||
|
|
182
|
-
exportedDefaultAny?.[1];
|
|
183
|
-
|
|
184
|
-
if (!componentName) {
|
|
185
|
-
log('ERROR: no default export found', args.path);
|
|
186
|
-
throw new Error(
|
|
187
|
-
`No default export found in ${args.path}. Did you forget to export a component?`
|
|
188
|
-
);
|
|
189
|
-
}
|
|
68
|
+
config() {
|
|
69
|
+
return {
|
|
70
|
+
optimizeDeps: {
|
|
71
|
+
include: ['preact', 'preact/hooks', 'preact/jsx-runtime'],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
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
|
+
const clientUrl = '/islands/preact-client.js';
|
|
80
|
+
registerImport('preact', clientUrl);
|
|
81
|
+
registerImport('preact/hooks', clientUrl);
|
|
82
|
+
registerImport('preact/jsx-runtime', clientUrl);
|
|
83
|
+
registerImport('preact/jsx-dev-runtime', clientUrl);
|
|
84
|
+
registerImport('preact/compat', clientUrl);
|
|
85
|
+
registerImport('react', clientUrl);
|
|
86
|
+
registerImport('react-dom', clientUrl);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async transform(code, id) {
|
|
90
|
+
if (!isIslandComponent(id)) return;
|
|
91
|
+
|
|
92
|
+
log('transform island', id);
|
|
93
|
+
const jsId = assetHash(id);
|
|
94
|
+
const esmName = basename(id, '.tsx');
|
|
95
|
+
const componentName = extractDefaultExportName(code);
|
|
96
|
+
|
|
97
|
+
if (!componentName) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`No default export found in ${id}. Did you forget to export a Preact component?`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
190
102
|
|
|
191
|
-
|
|
192
|
-
|
|
103
|
+
const publicPath = `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`;
|
|
104
|
+
registerImport(esmName, publicPath);
|
|
193
105
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// A lot of this work actaully has to be done now, ahead of time, to ensure we use the same Preact instance to hydrate and render the component so there are no errors.
|
|
197
|
-
// So... we have to import the preact-render-to-string library to render the component to a string here, with simple functions to do that work and return HTML.
|
|
198
|
-
// All imports needed for this work are prefixed with __hs_ to avoid clashing with other imports in the module, as some of them may be duplicates.
|
|
199
|
-
// Finally, we need to export all of the functions that do this work in a special way so we don't change the default export or other functions in the module, so that only the Hyperspan renderIsland() function can use them.
|
|
200
|
-
const moduleCode = `// hyperspan:processed
|
|
106
|
+
const moduleCode = `// hyperspan:processed
|
|
107
|
+
import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';
|
|
201
108
|
import { render as __hs_renderToString } from 'preact-render-to-string';
|
|
202
109
|
import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
|
|
203
110
|
|
|
204
|
-
|
|
205
|
-
${contents}
|
|
111
|
+
${code}
|
|
206
112
|
|
|
207
|
-
// hyperspan:preact-plugin
|
|
208
113
|
function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
|
|
209
114
|
return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, options);
|
|
210
115
|
}
|
|
116
|
+
|
|
211
117
|
${componentName}.__HS_ISLAND = {
|
|
212
118
|
id: "${jsId}",
|
|
213
119
|
render: (props, options = {}) => {
|
|
@@ -215,47 +121,53 @@ ${componentName}.__HS_ISLAND = {
|
|
|
215
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}"));\`;
|
|
216
122
|
return __hs_renderIsland(jsContent, '', options);
|
|
217
123
|
}
|
|
218
|
-
|
|
219
124
|
const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
|
|
220
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}"));\`;
|
|
221
126
|
return __hs_renderIsland(jsContent, ssrContent, options);
|
|
222
|
-
|
|
223
127
|
}
|
|
224
|
-
}
|
|
128
|
+
};
|
|
225
129
|
`;
|
|
226
130
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
131
|
+
return { code: moduleCode, map: null };
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
buildStart() {
|
|
135
|
+
// emitFile is only available during production builds, not vite serve.
|
|
136
|
+
if (this.meta.watchMode) return;
|
|
137
|
+
try {
|
|
138
|
+
this.emitFile({
|
|
139
|
+
type: 'chunk',
|
|
140
|
+
id: preactClientPath,
|
|
141
|
+
fileName: 'islands/preact-client.js',
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// Serve mode — client is resolved via import map / Vite deps instead.
|
|
145
|
+
}
|
|
146
|
+
},
|
|
242
147
|
};
|
|
243
148
|
}
|
|
244
149
|
|
|
245
|
-
|
|
246
150
|
/**
|
|
247
|
-
*
|
|
151
|
+
* Hyperspan config plugin (no-op in v2 — use preactVitePlugin in vite.config.ts).
|
|
248
152
|
*/
|
|
249
|
-
export function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
153
|
+
export function preactPlugin(): HS.Plugin {
|
|
154
|
+
return () => {
|
|
155
|
+
log('preactPlugin loaded (Vite handles bundling in v2)');
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function renderPreactIsland(
|
|
160
|
+
Component: {
|
|
161
|
+
__HS_ISLAND?: { render: (props: unknown, options: unknown) => string };
|
|
162
|
+
name?: string;
|
|
163
|
+
},
|
|
164
|
+
props: Record<string, unknown> = {},
|
|
165
|
+
options: { ssr?: boolean; loading?: string } = { ssr: true }
|
|
166
|
+
) {
|
|
254
167
|
if (Component.__HS_ISLAND?.render) {
|
|
255
168
|
return html.raw(Component.__HS_ISLAND.render(props, options));
|
|
256
169
|
}
|
|
257
|
-
|
|
258
170
|
throw new Error(
|
|
259
|
-
`Module ${Component.name} was not loaded with an island plugin!
|
|
171
|
+
`Module ${Component.name} was not loaded with an island plugin! Add preactVitePlugin() to vite.config.ts.`
|
|
260
172
|
);
|
|
261
|
-
}
|
|
173
|
+
}
|