@hyperspan/plugin-preact 1.0.2 → 1.0.3
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/index.test.ts +190 -0
- package/src/index.ts +75 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/plugin-preact",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Hyperspan Plugin for Preact",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"author": "Vance Lucas <vance@vancelucas.com>",
|
|
12
12
|
"license": "BSD-3-Clause",
|
|
13
13
|
"keywords": [],
|
|
14
|
-
"homepage": "https://www.hyperspan.dev/docs/islands
|
|
14
|
+
"homepage": "https://www.hyperspan.dev/docs/clientjs/islands",
|
|
15
15
|
"bugs": {
|
|
16
16
|
"url": "https://github.com/vlucas/hyperspan/issues"
|
|
17
17
|
},
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { test, describe, expect } from 'bun:test';
|
|
2
|
+
import { h } from 'preact';
|
|
3
|
+
import { buildIslandHtml, renderPreactSSR, renderPreactIsland } from './index';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Simple Preact components defined inline — no .tsx compilation needed
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function Hello({ name = 'World', count = 0 }: { name?: string; count?: number }) {
|
|
10
|
+
return h('div', { class: 'hello' }, [
|
|
11
|
+
h('h1', null, `Hello ${name}!`),
|
|
12
|
+
h('p', null, `Count: ${count}`),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// buildIslandHtml
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('buildIslandHtml', () => {
|
|
21
|
+
const jsId = 'abc123';
|
|
22
|
+
const componentName = 'Hello';
|
|
23
|
+
const esmName = 'hello-component';
|
|
24
|
+
|
|
25
|
+
test('wraps SSR content in a div with the island id', () => {
|
|
26
|
+
const result = buildIslandHtml(jsId, componentName, esmName, '', '<p>SSR</p>');
|
|
27
|
+
expect(result).toContain(`<div id="${jsId}">`);
|
|
28
|
+
expect(result).toContain('<p>SSR</p>');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('includes a module script tag with the correct source id', () => {
|
|
32
|
+
const result = buildIslandHtml(jsId, componentName, esmName, 'console.log(1)', '');
|
|
33
|
+
expect(result).toContain(`<script type="module" id="${jsId}_script" data-source-id="${jsId}">`);
|
|
34
|
+
expect(result).toContain(`import ${componentName} from "${esmName}"`);
|
|
35
|
+
expect(result).toContain('console.log(1)');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('lazy loading wraps script in a template inside a hidden div', () => {
|
|
39
|
+
const result = buildIslandHtml(jsId, componentName, esmName, '', '<p>SSR</p>', {
|
|
40
|
+
loading: 'lazy',
|
|
41
|
+
});
|
|
42
|
+
expect(result).toContain('data-loading="lazy"');
|
|
43
|
+
expect(result).toContain('<template>');
|
|
44
|
+
expect(result).toContain('<p>SSR</p>');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('eager loading (default) does not include a template tag', () => {
|
|
48
|
+
const result = buildIslandHtml(jsId, componentName, esmName, '', '<p>SSR</p>');
|
|
49
|
+
expect(result).not.toContain('data-loading="lazy"');
|
|
50
|
+
expect(result).not.toContain('<template>');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// renderPreactSSR
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('renderPreactSSR', () => {
|
|
59
|
+
test('renders a component to an HTML string', () => {
|
|
60
|
+
const output = renderPreactSSR(Hello, { name: 'World' });
|
|
61
|
+
expect(output).toContain('<div');
|
|
62
|
+
expect(output).toContain('Hello World!');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('passes props to the component', () => {
|
|
66
|
+
const output = renderPreactSSR(Hello, { name: 'Alice', count: 42 });
|
|
67
|
+
expect(output).toContain('Hello Alice!');
|
|
68
|
+
expect(output).toContain('Count: 42');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('uses component defaults when props are omitted', () => {
|
|
72
|
+
const output = renderPreactSSR(Hello, {});
|
|
73
|
+
expect(output).toContain('Hello World!');
|
|
74
|
+
expect(output).toContain('Count: 0');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('returns a string', () => {
|
|
78
|
+
const output = renderPreactSSR(Hello, {});
|
|
79
|
+
expect(typeof output).toBe('string');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('escapes dangerous characters in prop values', () => {
|
|
83
|
+
const output = renderPreactSSR(Hello, { name: '<script>alert(1)</script>' });
|
|
84
|
+
expect(output).not.toContain('<script>alert');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// renderPreactIsland
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe('renderPreactIsland', () => {
|
|
93
|
+
test('throws when component has no __HS_ISLAND property', () => {
|
|
94
|
+
expect(() => renderPreactIsland(Hello, {})).toThrow(
|
|
95
|
+
'was not loaded with an island plugin'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('returns an html_safe object', () => {
|
|
100
|
+
const jsId = 'island-test-1';
|
|
101
|
+
const esmName = 'hello-ssr';
|
|
102
|
+
(Hello as any).__HS_ISLAND = {
|
|
103
|
+
id: jsId,
|
|
104
|
+
render: (props: any, options: any = {}) => {
|
|
105
|
+
const ssrContent = renderPreactSSR(Hello, props);
|
|
106
|
+
const jsContent = `import { h as __hs_h, hydrate as __hs_hydrate } from 'preact';__hs_hydrate(__hs_h(Hello, ${JSON.stringify(props)}), document.getElementById("${jsId}"));`;
|
|
107
|
+
return buildIslandHtml(jsId, 'Hello', esmName, jsContent, ssrContent, options);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = renderPreactIsland(Hello, { name: 'World' });
|
|
112
|
+
expect(result).toHaveProperty('_kind', 'html_safe');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('island output contains SSR-rendered HTML', () => {
|
|
116
|
+
const jsId = 'island-test-2';
|
|
117
|
+
const esmName = 'hello-ssr';
|
|
118
|
+
(Hello as any).__HS_ISLAND = {
|
|
119
|
+
id: jsId,
|
|
120
|
+
render: (props: any, options: any = {}) => {
|
|
121
|
+
const ssrContent = renderPreactSSR(Hello, props);
|
|
122
|
+
const jsContent = '';
|
|
123
|
+
return buildIslandHtml(jsId, 'Hello', esmName, jsContent, ssrContent, options);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const result = renderPreactIsland(Hello, { name: 'Alice', count: 7 });
|
|
128
|
+
expect(result.content).toContain('Hello Alice!');
|
|
129
|
+
expect(result.content).toContain('Count: 7');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('island output contains the wrapper div with the correct id', () => {
|
|
133
|
+
const jsId = 'island-test-3';
|
|
134
|
+
(Hello as any).__HS_ISLAND = {
|
|
135
|
+
id: jsId,
|
|
136
|
+
render: (props: any, options: any = {}) =>
|
|
137
|
+
buildIslandHtml(jsId, 'Hello', 'hello', '', renderPreactSSR(Hello, props), options),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const result = renderPreactIsland(Hello, { name: 'Bob' });
|
|
141
|
+
expect(result.content).toContain(`<div id="${jsId}">`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('island output contains a hydration script tag', () => {
|
|
145
|
+
const jsId = 'island-test-4';
|
|
146
|
+
(Hello as any).__HS_ISLAND = {
|
|
147
|
+
id: jsId,
|
|
148
|
+
render: (props: any, options: any = {}) => {
|
|
149
|
+
const ssrContent = renderPreactSSR(Hello, props);
|
|
150
|
+
const jsContent = `import { h as __hs_h, hydrate as __hs_hydrate } from 'preact';__hs_hydrate(__hs_h(Hello, ${JSON.stringify(props)}), document.getElementById("${jsId}"));`;
|
|
151
|
+
return buildIslandHtml(jsId, 'Hello', 'hello', jsContent, ssrContent, options);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const result = renderPreactIsland(Hello, { name: 'World' });
|
|
156
|
+
expect(result.content).toContain('<script type="module"');
|
|
157
|
+
expect(result.content).toContain('hydrate');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('ssr: false produces no SSR content', () => {
|
|
161
|
+
const jsId = 'island-test-5';
|
|
162
|
+
(Hello as any).__HS_ISLAND = {
|
|
163
|
+
id: jsId,
|
|
164
|
+
render: (props: any, options: any = {}) => {
|
|
165
|
+
if (options.ssr === false) {
|
|
166
|
+
return buildIslandHtml(jsId, 'Hello', 'hello', 'console.log("mount")', '', options);
|
|
167
|
+
}
|
|
168
|
+
return buildIslandHtml(jsId, 'Hello', 'hello', '', renderPreactSSR(Hello, props), options);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const result = renderPreactIsland(Hello, { name: 'World' }, { ssr: false, loading: undefined });
|
|
173
|
+
expect(result.content).not.toContain('Hello World!');
|
|
174
|
+
expect(result.content).toContain(`<div id="${jsId}"></div>`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('loading: lazy wraps the script in a template', () => {
|
|
178
|
+
const jsId = 'island-test-6';
|
|
179
|
+
(Hello as any).__HS_ISLAND = {
|
|
180
|
+
id: jsId,
|
|
181
|
+
render: (props: any, options: any = {}) =>
|
|
182
|
+
buildIslandHtml(jsId, 'Hello', 'hello', 'console.log(1)', renderPreactSSR(Hello, props), options),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const result = renderPreactIsland(Hello, { name: 'World' }, { ssr: true, loading: 'lazy' } as any);
|
|
186
|
+
expect(result.content).toContain('data-loading="lazy"');
|
|
187
|
+
expect(result.content).toContain('<template>');
|
|
188
|
+
expect(result.content).toContain('Hello World!');
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,67 @@ import { IS_PROD } from '@hyperspan/framework/server';
|
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import type { Hyperspan as HS } from '@hyperspan/framework';
|
|
6
6
|
import { html } from '@hyperspan/html';
|
|
7
|
+
import { h } from 'preact';
|
|
8
|
+
import { render as preactRenderToString } from 'preact-render-to-string';
|
|
7
9
|
import debug from 'debug';
|
|
8
10
|
|
|
9
11
|
const log = debug('hyperspan:plugin-preact');
|
|
10
12
|
|
|
13
|
+
/** Dev: stable `[name].js` via Bun default. Prod: hashed filenames for caching. */
|
|
14
|
+
const ISLAND_JS_NAMING = IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined;
|
|
15
|
+
|
|
16
|
+
function islandBundleBaseName(outputPath: string): string {
|
|
17
|
+
return String(outputPath.split('/').reverse()[0]!.replace(/\.js$/i, ''));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pickEntryPointJsOutput(
|
|
21
|
+
outputs: ReadonlyArray<{ path: string; kind?: string }>,
|
|
22
|
+
entrySourcePath: string
|
|
23
|
+
): { path: string } {
|
|
24
|
+
const js = outputs.filter((o) => o.path.endsWith('.js'));
|
|
25
|
+
const entry = js.find((o) => o.kind === 'entry-point');
|
|
26
|
+
if (entry) return entry;
|
|
27
|
+
const sourceBase = entrySourcePath.split('/').pop()!.replace(/\.(tsx|ts|jsx|js)$/i, '');
|
|
28
|
+
const byName = js.find((o) => {
|
|
29
|
+
const b = islandBundleBaseName(o.path);
|
|
30
|
+
return b === sourceBase || b.startsWith(`${sourceBase}-`);
|
|
31
|
+
});
|
|
32
|
+
if (byName) return byName;
|
|
33
|
+
if (js[0]) return js[0];
|
|
34
|
+
throw new Error('[Hyperspan] Preact island build produced no JS output');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the island wrapper HTML: a div for SSR content + a module script tag for client hydration.
|
|
39
|
+
* Exported so it can be imported by generated island module code and used directly in tests.
|
|
40
|
+
*/
|
|
41
|
+
export function buildIslandHtml(
|
|
42
|
+
jsId: string,
|
|
43
|
+
componentName: string,
|
|
44
|
+
esmName: string,
|
|
45
|
+
jsContent: string,
|
|
46
|
+
ssrContent: string,
|
|
47
|
+
options: { loading?: string } = {}
|
|
48
|
+
): string {
|
|
49
|
+
const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">import ${componentName} from "${esmName}";${jsContent}</script>`;
|
|
50
|
+
if (options.loading === 'lazy') {
|
|
51
|
+
return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
|
|
52
|
+
}
|
|
53
|
+
return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render a Preact component to an HTML string (SSR).
|
|
58
|
+
* Exported for direct use in tests and external tooling.
|
|
59
|
+
*/
|
|
60
|
+
export function renderPreactSSR(Component: any, props: any = {}): string {
|
|
61
|
+
return preactRenderToString(h(Component, props));
|
|
62
|
+
}
|
|
63
|
+
|
|
11
64
|
// External ESM = https://esm.sh/preact@10.26.4/compat
|
|
12
|
-
|
|
65
|
+
type PreactIslandCacheEntry = { contents: string; esmName: string };
|
|
66
|
+
|
|
67
|
+
const PREACT_ISLAND_CACHE = new Map<string, PreactIslandCacheEntry>();
|
|
13
68
|
|
|
14
69
|
/**
|
|
15
70
|
* Build Preact client JS and copy to public folder
|
|
@@ -23,14 +78,15 @@ async function copyPreactToPublicFolder(config: HS.Config) {
|
|
|
23
78
|
const result = await Bun.build({
|
|
24
79
|
entrypoints: [sourceFile],
|
|
25
80
|
outdir: join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH),
|
|
26
|
-
naming:
|
|
81
|
+
naming: ISLAND_JS_NAMING,
|
|
27
82
|
minify: true,
|
|
28
83
|
format: 'esm',
|
|
29
84
|
target: 'browser',
|
|
30
85
|
});
|
|
31
86
|
process.env.NODE_ENV = currentNodeEnv;
|
|
32
87
|
|
|
33
|
-
const
|
|
88
|
+
const preactEntry = pickEntryPointJsOutput(result.outputs, sourceFile);
|
|
89
|
+
const builtFileName = islandBundleBaseName(preactEntry.path);
|
|
34
90
|
const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/${builtFileName}.js`;
|
|
35
91
|
|
|
36
92
|
JS_IMPORT_MAP.set('preact', builtFilePath);
|
|
@@ -51,7 +107,7 @@ async function copyPreactToPublicFolder(config: HS.Config) {
|
|
|
51
107
|
export function preactPlugin(): HS.Plugin {
|
|
52
108
|
return async (config: HS.Config) => {
|
|
53
109
|
try {
|
|
54
|
-
log('
|
|
110
|
+
log('plugin loaded');
|
|
55
111
|
// Ensure Preact can be loaded on the client
|
|
56
112
|
if (!JS_IMPORT_MAP.has('preact')) {
|
|
57
113
|
await copyPreactToPublicFolder(config);
|
|
@@ -66,11 +122,17 @@ export function preactPlugin(): HS.Plugin {
|
|
|
66
122
|
log('tsx file loaded', args.path);
|
|
67
123
|
const jsId = assetHash(args.path);
|
|
68
124
|
|
|
125
|
+
if (!JS_IMPORT_MAP.has('preact')) {
|
|
126
|
+
await copyPreactToPublicFolder(config);
|
|
127
|
+
}
|
|
128
|
+
|
|
69
129
|
// Cache: Avoid re-processing the same file
|
|
70
130
|
if (PREACT_ISLAND_CACHE.has(jsId)) {
|
|
131
|
+
const hit = PREACT_ISLAND_CACHE.get(jsId)!;
|
|
132
|
+
JS_IMPORT_MAP.set(hit.esmName, `${JS_ISLAND_PUBLIC_PATH}/${hit.esmName}.js`);
|
|
71
133
|
log('tsx file cached', args.path);
|
|
72
134
|
return {
|
|
73
|
-
contents:
|
|
135
|
+
contents: hit.contents,
|
|
74
136
|
loader: 'js',
|
|
75
137
|
};
|
|
76
138
|
}
|
|
@@ -81,7 +143,7 @@ export function preactPlugin(): HS.Plugin {
|
|
|
81
143
|
const result = await Bun.build({
|
|
82
144
|
entrypoints: [args.path],
|
|
83
145
|
outdir: join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH),
|
|
84
|
-
naming:
|
|
146
|
+
naming: ISLAND_JS_NAMING,
|
|
85
147
|
external: Array.from(JS_IMPORT_MAP.keys()),
|
|
86
148
|
minify: true,
|
|
87
149
|
format: 'esm',
|
|
@@ -89,12 +151,14 @@ export function preactPlugin(): HS.Plugin {
|
|
|
89
151
|
env: 'APP_PUBLIC_*',
|
|
90
152
|
});
|
|
91
153
|
|
|
154
|
+
const entryOut = pickEntryPointJsOutput(result.outputs, args.path);
|
|
155
|
+
const esmName = islandBundleBaseName(entryOut.path);
|
|
156
|
+
|
|
92
157
|
// Add output file to import map
|
|
93
|
-
const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
94
158
|
JS_IMPORT_MAP.set(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
95
159
|
log('added to import map', esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
96
160
|
|
|
97
|
-
let contents = await
|
|
161
|
+
let contents = await Bun.file(entryOut.path).text();
|
|
98
162
|
|
|
99
163
|
// Look for the default export
|
|
100
164
|
const RE_EXPORT_DEFAULT = /export\{([^\s]+) as default\}/;
|
|
@@ -120,8 +184,6 @@ export function preactPlugin(): HS.Plugin {
|
|
|
120
184
|
);
|
|
121
185
|
}
|
|
122
186
|
|
|
123
|
-
log('component name', componentName);
|
|
124
|
-
|
|
125
187
|
// Add to contents so this is in the client JS as well
|
|
126
188
|
contents = `import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';${contents}`;
|
|
127
189
|
|
|
@@ -133,18 +195,14 @@ export function preactPlugin(): HS.Plugin {
|
|
|
133
195
|
// 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.
|
|
134
196
|
const moduleCode = `// hyperspan:processed
|
|
135
197
|
import { render as __hs_renderToString } from 'preact-render-to-string';
|
|
198
|
+
import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
|
|
136
199
|
|
|
137
200
|
// Original file contents
|
|
138
201
|
${contents}
|
|
139
202
|
|
|
140
203
|
// hyperspan:preact-plugin
|
|
141
204
|
function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
|
|
142
|
-
|
|
143
|
-
if (options.loading === 'lazy') {
|
|
144
|
-
return \`<div id="${jsId}">\${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n\${scriptTag}</template></div>\`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return \`<div id="${jsId}">\${ssrContent}</div>\n\${scriptTag}\`;
|
|
205
|
+
return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, options);
|
|
148
206
|
}
|
|
149
207
|
${componentName}.__HS_ISLAND = {
|
|
150
208
|
id: "${jsId}",
|
|
@@ -162,7 +220,7 @@ ${componentName}.__HS_ISLAND = {
|
|
|
162
220
|
}
|
|
163
221
|
`;
|
|
164
222
|
|
|
165
|
-
PREACT_ISLAND_CACHE.set(jsId, moduleCode);
|
|
223
|
+
PREACT_ISLAND_CACHE.set(jsId, { contents: moduleCode, esmName });
|
|
166
224
|
|
|
167
225
|
return {
|
|
168
226
|
contents: moduleCode,
|