@hyperspan/plugin-preact 1.0.4 → 2.0.0-alpha.13
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 +19 -8
- package/src/index.test.ts +22 -6
- package/src/index.ts +194 -208
package/package.json
CHANGED
|
@@ -1,35 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/plugin-preact",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-alpha.13",
|
|
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
|
-
"@
|
|
33
|
+
"@hyperspan/vite-plugin": "^2.0.0-alpha.13",
|
|
27
34
|
"prettier": "^3.8.1",
|
|
28
|
-
"typescript": "^5.9.3"
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vite": "^8.2.1",
|
|
37
|
+
"vitest": "^4.1.10"
|
|
29
38
|
},
|
|
30
39
|
"peerDependencies": {
|
|
31
|
-
"@hyperspan/html": "^
|
|
32
|
-
"@hyperspan/framework": "^
|
|
40
|
+
"@hyperspan/html": "^2.0.0-alpha.13",
|
|
41
|
+
"@hyperspan/framework": "^2.0.0-alpha.13",
|
|
42
|
+
"@hyperspan/vite-plugin": "^2.0.0-alpha.13",
|
|
43
|
+
"vite": "^8.0.0"
|
|
33
44
|
},
|
|
34
45
|
"dependencies": {
|
|
35
46
|
"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
|
|
|
@@ -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,9 +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(
|
|
95
|
-
'was not loaded with an island plugin'
|
|
96
|
-
);
|
|
102
|
+
expect(() => renderPreactIsland(Hello, {})).toThrow(/not a Hyperspan island/i);
|
|
97
103
|
});
|
|
98
104
|
|
|
99
105
|
test('returns an html_safe object', () => {
|
|
@@ -179,10 +185,20 @@ describe('renderPreactIsland', () => {
|
|
|
179
185
|
(Hello as any).__HS_ISLAND = {
|
|
180
186
|
id: jsId,
|
|
181
187
|
render: (props: any, options: any = {}) =>
|
|
182
|
-
buildIslandHtml(
|
|
188
|
+
buildIslandHtml(
|
|
189
|
+
jsId,
|
|
190
|
+
'Hello',
|
|
191
|
+
'hello',
|
|
192
|
+
'console.log(1)',
|
|
193
|
+
renderPreactSSR(Hello, props),
|
|
194
|
+
options
|
|
195
|
+
),
|
|
183
196
|
};
|
|
184
197
|
|
|
185
|
-
const result = renderPreactIsland(Hello, { name: 'World' }, {
|
|
198
|
+
const result = renderPreactIsland(Hello, { name: 'World' }, {
|
|
199
|
+
ssr: true,
|
|
200
|
+
loading: 'lazy',
|
|
201
|
+
} as any);
|
|
186
202
|
expect(result.content).toContain('data-loading="lazy"');
|
|
187
203
|
expect(result.content).toContain('<template>');
|
|
188
204
|
expect(result.content).toContain('Hello World!');
|
package/src/index.ts
CHANGED
|
@@ -1,261 +1,247 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
2
4
|
import { assetHash } from '@hyperspan/framework/utils';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
+
import { registerImport, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
|
|
6
|
+
import { renderIsland } from '@hyperspan/framework';
|
|
5
7
|
import type { Hyperspan as HS } from '@hyperspan/framework';
|
|
6
8
|
import { html } from '@hyperspan/html';
|
|
7
9
|
import { h } from 'preact';
|
|
8
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';
|
|
9
18
|
import debug from 'debug';
|
|
10
19
|
|
|
11
20
|
const log = debug('hyperspan:plugin-preact');
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
export type IslandExportKind = 'default' | 'named';
|
|
14
23
|
|
|
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
24
|
export function buildIslandHtml(
|
|
44
25
|
jsId: string,
|
|
45
26
|
componentName: string,
|
|
46
27
|
esmName: string,
|
|
47
28
|
jsContent: string,
|
|
48
29
|
ssrContent: string,
|
|
49
|
-
options: { loading?: string } = {}
|
|
30
|
+
options: { loading?: string; exportKind?: IslandExportKind } = {}
|
|
50
31
|
): string {
|
|
51
|
-
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>`;
|
|
52
37
|
if (options.loading === 'lazy') {
|
|
53
38
|
return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
|
|
54
39
|
}
|
|
55
40
|
return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
|
|
56
41
|
}
|
|
57
42
|
|
|
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));
|
|
43
|
+
export function renderPreactSSR(Component: unknown, props: Record<string, unknown> = {}): string {
|
|
44
|
+
return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
|
|
64
45
|
}
|
|
65
46
|
|
|
66
|
-
|
|
67
|
-
|
|
47
|
+
type DiscoveredExport = { name: string; kind: IslandExportKind };
|
|
48
|
+
|
|
49
|
+
function extractDefaultExportName(code: string): string | null {
|
|
50
|
+
const patterns = [
|
|
51
|
+
/export\{([^\s]+) as default\}/,
|
|
52
|
+
/export default function\s+([^\s(]+)/,
|
|
53
|
+
/export default function\s*\(/,
|
|
54
|
+
/export default const\s+([^\s=]+)/,
|
|
55
|
+
/export default class\s+([^\s{]+)/,
|
|
56
|
+
];
|
|
57
|
+
for (const re of patterns) {
|
|
58
|
+
const m = code.match(re);
|
|
59
|
+
if (m?.[1]) return m[1];
|
|
60
|
+
}
|
|
61
|
+
if (/export default function/.test(code)) return 'DefaultComponent';
|
|
62
|
+
const anyMatch = code.match(/export default\s+([A-Za-z_$][\w$]*)/);
|
|
63
|
+
return anyMatch?.[1] ?? null;
|
|
64
|
+
}
|
|
68
65
|
|
|
69
|
-
|
|
66
|
+
function discoverPreactExports(code: string): DiscoveredExport[] {
|
|
67
|
+
const exports: DiscoveredExport[] = [];
|
|
68
|
+
const seen = new Set<string>();
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const currentNodeEnv = process.env.NODE_ENV || 'production';
|
|
76
|
-
const sourceFile = resolve(import.meta.dir, './preact-client.ts');
|
|
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);
|
|
70
|
+
const defaultName = extractDefaultExportName(code);
|
|
71
|
+
if (defaultName) {
|
|
72
|
+
exports.push({ name: defaultName, kind: 'default' });
|
|
73
|
+
seen.add(defaultName);
|
|
104
74
|
}
|
|
105
|
-
}
|
|
106
75
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
export
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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]);
|
|
117
86
|
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
118
89
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (PREACT_ISLAND_CACHE.has(jsId)) {
|
|
134
|
-
const hit = PREACT_ISLAND_CACHE.get(jsId)!;
|
|
135
|
-
JS_IMPORT_MAP.set(hit.esmName, `${JS_ISLAND_PUBLIC_PATH}/${hit.esmName}.js`);
|
|
136
|
-
log('tsx file cached', args.path);
|
|
137
|
-
return {
|
|
138
|
-
contents: hit.contents,
|
|
139
|
-
loader: 'js',
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
log('tsx file not cached, building...', args.path);
|
|
144
|
-
// We need to build the file to ensure we can ship it to the client with dependencies
|
|
145
|
-
// Ironic, right? Calling Bun.build() inside of a plugin that runs on Bun.build()?
|
|
146
|
-
const islandOutdir = resolve(CWD, config.publicDir, JS_ISLAND_PUBLIC_PATH.replace(/^\//, ''));
|
|
147
|
-
const result = await Bun.build({
|
|
148
|
-
entrypoints: [args.path],
|
|
149
|
-
outdir: islandOutdir,
|
|
150
|
-
naming: ISLAND_JS_NAMING,
|
|
151
|
-
external: Array.from(JS_IMPORT_MAP.keys()),
|
|
152
|
-
minify: true,
|
|
153
|
-
format: 'esm',
|
|
154
|
-
target: 'browser',
|
|
155
|
-
env: 'APP_PUBLIC_*',
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
const entryOut = pickEntryPointJsOutput(result.outputs, args.path);
|
|
159
|
-
const esmName = islandBundleBaseName(entryOut.path);
|
|
160
|
-
|
|
161
|
-
// Add output file to import map
|
|
162
|
-
JS_IMPORT_MAP.set(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
163
|
-
log('added to import map', esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
164
|
-
|
|
165
|
-
let contents = await Bun.file(entryOut.path).text();
|
|
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
|
-
}
|
|
190
|
-
|
|
191
|
-
// Add to contents so this is in the client JS as well
|
|
192
|
-
contents = `import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';${contents}`;
|
|
193
|
-
|
|
194
|
-
// Some _interesting_ work at play here...
|
|
195
|
-
// We have to modify the original file contents to add an __HS_PLUGIN export that the renderIsland() function can use to render the component.
|
|
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
|
|
201
|
-
import { render as __hs_renderToString } from 'preact-render-to-string';
|
|
202
|
-
import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
|
|
203
|
-
|
|
204
|
-
// Original file contents
|
|
205
|
-
${contents}
|
|
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
|
+
}
|
|
206
104
|
|
|
207
|
-
|
|
208
|
-
function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
|
|
209
|
-
return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, options);
|
|
105
|
+
return exports;
|
|
210
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 `
|
|
211
118
|
${componentName}.__HS_ISLAND = {
|
|
212
119
|
id: "${jsId}",
|
|
213
120
|
render: (props, options = {}) => {
|
|
121
|
+
const __hs_exportKind = ${JSON.stringify(exportKind)};
|
|
214
122
|
if (options.ssr === false) {
|
|
215
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}"));\`;
|
|
216
|
-
return
|
|
124
|
+
return __hs_renderIsland_${componentName}(jsContent, '', options);
|
|
217
125
|
}
|
|
218
|
-
|
|
219
126
|
const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
|
|
220
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}"));\`;
|
|
221
|
-
return
|
|
222
|
-
|
|
128
|
+
return __hs_renderIsland_${componentName}(jsContent, ssrContent, options);
|
|
223
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)} });
|
|
224
134
|
}
|
|
225
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;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Vite plugin for Preact islands.
|
|
150
|
+
*/
|
|
151
|
+
export function preactIslandPlugin(): Plugin {
|
|
152
|
+
const preactClientPath = resolve(dirname(fileURLToPath(import.meta.url)), './preact-client.ts');
|
|
226
153
|
|
|
227
|
-
|
|
154
|
+
return {
|
|
155
|
+
name: 'hyperspan-preact',
|
|
156
|
+
enforce: 'pre',
|
|
228
157
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
});
|
|
158
|
+
config() {
|
|
159
|
+
return {
|
|
160
|
+
optimizeDeps: {
|
|
161
|
+
include: ['preact', 'preact/hooks', 'preact/jsx-runtime'],
|
|
234
162
|
},
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
resolveId: islandPluginResolveId('preact', '.tsx'),
|
|
167
|
+
|
|
168
|
+
configureServer() {
|
|
169
|
+
const clientUrl = '/islands/preact-client.js';
|
|
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
|
+
);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async transform(code, id) {
|
|
185
|
+
if (!isIslandModule(id, 'preact', '.tsx')) return;
|
|
186
|
+
|
|
187
|
+
log('transform island', id);
|
|
188
|
+
const cleanId = splitIslandId(id).path;
|
|
189
|
+
const esmName = `island-${assetHash(cleanId)}`;
|
|
190
|
+
const exports = discoverPreactExports(code);
|
|
191
|
+
|
|
192
|
+
if (exports.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`No component exports found in ${cleanId}. Export a default or named Preact component and import with \`with { island: 'preact' }\`.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
registerImport(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
199
|
+
|
|
200
|
+
const attachments = exports
|
|
201
|
+
.map((exp) => buildIslandAttachment(exp, cleanId, esmName))
|
|
202
|
+
.join('\n');
|
|
203
|
+
|
|
204
|
+
const moduleCode = `// hyperspan:processed
|
|
205
|
+
import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';
|
|
206
|
+
import { render as __hs_renderToString } from 'preact-render-to-string';
|
|
207
|
+
import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
|
|
208
|
+
|
|
209
|
+
${code}
|
|
210
|
+
${attachments}
|
|
211
|
+
`;
|
|
212
|
+
|
|
213
|
+
return { code: moduleCode, map: null };
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
buildStart() {
|
|
217
|
+
if (this.meta.watchMode) return;
|
|
218
|
+
try {
|
|
219
|
+
this.emitFile({
|
|
220
|
+
type: 'chunk',
|
|
221
|
+
id: preactClientPath,
|
|
222
|
+
fileName: 'islands/preact-client.js',
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
// Serve mode — client is resolved via import map / Vite deps instead.
|
|
226
|
+
}
|
|
227
|
+
},
|
|
242
228
|
};
|
|
243
229
|
}
|
|
244
230
|
|
|
245
|
-
|
|
246
231
|
/**
|
|
247
|
-
*
|
|
232
|
+
* Hyperspan config plugin — register in hyperspan.config.ts plugins array.
|
|
248
233
|
*/
|
|
249
|
-
export function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return html.raw(Component.__HS_ISLAND.render(props, options));
|
|
256
|
-
}
|
|
234
|
+
export function preactPlugin(): HS.Plugin {
|
|
235
|
+
registerIslandPlugin('preact', { vitePlugin: preactIslandPlugin });
|
|
236
|
+
return () => {
|
|
237
|
+
log('preactPlugin loaded');
|
|
238
|
+
};
|
|
239
|
+
}
|
|
257
240
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
241
|
+
export function renderPreactIsland(
|
|
242
|
+
Component: Parameters<typeof renderIsland>[0],
|
|
243
|
+
props: Record<string, unknown> = {},
|
|
244
|
+
options: Parameters<typeof renderIsland>[2] = { ssr: true }
|
|
245
|
+
) {
|
|
246
|
+
return renderIsland(Component, props, options);
|
|
247
|
+
}
|