@hyperspan/plugin-preact 1.0.2 → 1.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/plugin-preact",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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-architecture",
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
@@ -1,36 +1,95 @@
1
1
  import { JS_IMPORT_MAP, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
2
2
  import { assetHash } from '@hyperspan/framework/utils';
3
3
  import { IS_PROD } from '@hyperspan/framework/server';
4
- import { join, resolve } from 'node:path';
4
+ import { 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
+ 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
+ export function buildIslandHtml(
44
+ jsId: string,
45
+ componentName: string,
46
+ esmName: string,
47
+ jsContent: string,
48
+ ssrContent: string,
49
+ options: { loading?: string } = {}
50
+ ): string {
51
+ const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">import ${componentName} from "${esmName}";${jsContent}</script>`;
52
+ if (options.loading === 'lazy') {
53
+ return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
54
+ }
55
+ return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
56
+ }
57
+
58
+ /**
59
+ * Render a Preact component to an HTML string (SSR).
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));
64
+ }
65
+
11
66
  // External ESM = https://esm.sh/preact@10.26.4/compat
12
- const PREACT_ISLAND_CACHE = new Map<string, string>();
67
+ type PreactIslandCacheEntry = { contents: string; esmName: string };
68
+
69
+ const PREACT_ISLAND_CACHE = new Map<string, PreactIslandCacheEntry>();
13
70
 
14
71
  /**
15
72
  * Build Preact client JS and copy to public folder
16
73
  */
17
74
  async function copyPreactToPublicFolder(config: HS.Config) {
18
75
  const currentNodeEnv = process.env.NODE_ENV || 'production';
19
- const sourceFile = resolve(__dirname, './preact-client.ts');
76
+ const sourceFile = resolve(import.meta.dir, './preact-client.ts');
77
+ const outdir = resolve(CWD, config.publicDir, JS_ISLAND_PUBLIC_PATH.replace(/^\//, ''));
20
78
 
21
79
  // Preact client JS is always production mode
22
80
  process.env.NODE_ENV = 'production';
23
81
  const result = await Bun.build({
24
82
  entrypoints: [sourceFile],
25
- outdir: join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH),
26
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
83
+ outdir,
84
+ naming: ISLAND_JS_NAMING,
27
85
  minify: true,
28
86
  format: 'esm',
29
87
  target: 'browser',
30
88
  });
31
89
  process.env.NODE_ENV = currentNodeEnv;
32
90
 
33
- const builtFileName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
91
+ const preactEntry = pickEntryPointJsOutput(result.outputs, sourceFile);
92
+ const builtFileName = islandBundleBaseName(preactEntry.path);
34
93
  const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/${builtFileName}.js`;
35
94
 
36
95
  JS_IMPORT_MAP.set('preact', builtFilePath);
@@ -51,7 +110,7 @@ async function copyPreactToPublicFolder(config: HS.Config) {
51
110
  export function preactPlugin(): HS.Plugin {
52
111
  return async (config: HS.Config) => {
53
112
  try {
54
- log('plguin loaded');
113
+ log('plugin loaded');
55
114
  // Ensure Preact can be loaded on the client
56
115
  if (!JS_IMPORT_MAP.has('preact')) {
57
116
  await copyPreactToPublicFolder(config);
@@ -66,11 +125,17 @@ export function preactPlugin(): HS.Plugin {
66
125
  log('tsx file loaded', args.path);
67
126
  const jsId = assetHash(args.path);
68
127
 
128
+ if (!JS_IMPORT_MAP.has('preact')) {
129
+ await copyPreactToPublicFolder(config);
130
+ }
131
+
69
132
  // Cache: Avoid re-processing the same file
70
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`);
71
136
  log('tsx file cached', args.path);
72
137
  return {
73
- contents: PREACT_ISLAND_CACHE.get(jsId) || '',
138
+ contents: hit.contents,
74
139
  loader: 'js',
75
140
  };
76
141
  }
@@ -78,10 +143,11 @@ export function preactPlugin(): HS.Plugin {
78
143
  log('tsx file not cached, building...', args.path);
79
144
  // We need to build the file to ensure we can ship it to the client with dependencies
80
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(/^\//, ''));
81
147
  const result = await Bun.build({
82
148
  entrypoints: [args.path],
83
- outdir: join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH),
84
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
149
+ outdir: islandOutdir,
150
+ naming: ISLAND_JS_NAMING,
85
151
  external: Array.from(JS_IMPORT_MAP.keys()),
86
152
  minify: true,
87
153
  format: 'esm',
@@ -89,12 +155,14 @@ export function preactPlugin(): HS.Plugin {
89
155
  env: 'APP_PUBLIC_*',
90
156
  });
91
157
 
158
+ const entryOut = pickEntryPointJsOutput(result.outputs, args.path);
159
+ const esmName = islandBundleBaseName(entryOut.path);
160
+
92
161
  // Add output file to import map
93
- const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
94
162
  JS_IMPORT_MAP.set(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
95
163
  log('added to import map', esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
96
164
 
97
- let contents = await result.outputs[0].text();
165
+ let contents = await Bun.file(entryOut.path).text();
98
166
 
99
167
  // Look for the default export
100
168
  const RE_EXPORT_DEFAULT = /export\{([^\s]+) as default\}/;
@@ -120,8 +188,6 @@ export function preactPlugin(): HS.Plugin {
120
188
  );
121
189
  }
122
190
 
123
- log('component name', componentName);
124
-
125
191
  // Add to contents so this is in the client JS as well
126
192
  contents = `import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';${contents}`;
127
193
 
@@ -133,18 +199,14 @@ export function preactPlugin(): HS.Plugin {
133
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.
134
200
  const moduleCode = `// hyperspan:processed
135
201
  import { render as __hs_renderToString } from 'preact-render-to-string';
202
+ import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
136
203
 
137
204
  // Original file contents
138
205
  ${contents}
139
206
 
140
207
  // hyperspan:preact-plugin
141
208
  function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
142
- const scriptTag = \`<script type="module" id="${jsId}_script" data-source-id="${jsId}">import ${componentName} from "${esmName}";\${jsContent}</script>\`;
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}\`;
209
+ return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, options);
148
210
  }
149
211
  ${componentName}.__HS_ISLAND = {
150
212
  id: "${jsId}",
@@ -162,7 +224,7 @@ ${componentName}.__HS_ISLAND = {
162
224
  }
163
225
  `;
164
226
 
165
- PREACT_ISLAND_CACHE.set(jsId, moduleCode);
227
+ PREACT_ISLAND_CACHE.set(jsId, { contents: moduleCode, esmName });
166
228
 
167
229
  return {
168
230
  contents: moduleCode,
@@ -0,0 +1,9 @@
1
+ import { h } from 'preact';
2
+
3
+ export default function MinimalIsland(props: { name?: string; count?: number }) {
4
+ return h(
5
+ 'div',
6
+ { className: 'minimal-island-fixture' },
7
+ `Hello ${props.name ?? '?'} (${props.count ?? 0})`
8
+ );
9
+ }
@@ -0,0 +1,8 @@
1
+ import { h } from 'preact';
2
+
3
+ /** Top-level DOM access (fires as soon as this module is evaluated, before any render). */
4
+ void document.documentElement;
5
+
6
+ export default function UsesDocumentIsland(_props: Record<string, never>) {
7
+ return h('div', { 'data-test': 'uses-document-island' }, 'fixture');
8
+ }
@@ -0,0 +1,6 @@
1
+ 'use strict';
2
+
3
+ /** Top-level browser global access (fires as soon as this module is evaluated). */
4
+ void window.__hyperspan_fixture__;
5
+
6
+ export const marker = 'uses-window-vanilla';