@hyperspan/plugin-preact 1.0.3 → 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 CHANGED
@@ -1,35 +1,44 @@
1
1
  {
2
2
  "name": "@hyperspan/plugin-preact",
3
- "version": "1.0.3",
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": "bun 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": "^1.0.0",
32
- "@hyperspan/framework": "^1.0.1"
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 'bun:test';
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(jsId, 'Hello', 'hello', 'console.log(1)', renderPreactSSR(Hello, props), options),
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' }, { ssr: true, loading: 'lazy' } as any);
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 { JS_IMPORT_MAP, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
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 { IS_PROD } from '@hyperspan/framework/server';
4
- import { join, 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,34 +11,6 @@ import debug from 'debug';
10
11
 
11
12
  const log = debug('hyperspan:plugin-preact');
12
13
 
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
14
  export function buildIslandHtml(
42
15
  jsId: string,
43
16
  componentName: string,
@@ -53,157 +26,94 @@ export function buildIslandHtml(
53
26
  return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
54
27
  }
55
28
 
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));
29
+ export function renderPreactSSR(Component: unknown, props: Record<string, unknown> = {}): string {
30
+ return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
62
31
  }
63
32
 
64
- // External ESM = https://esm.sh/preact@10.26.4/compat
65
- type PreactIslandCacheEntry = { contents: string; esmName: string };
66
-
67
- const PREACT_ISLAND_CACHE = new Map<string, PreactIslandCacheEntry>();
68
-
69
- /**
70
- * Build Preact client JS and copy to public folder
71
- */
72
- async function copyPreactToPublicFolder(config: HS.Config) {
73
- const currentNodeEnv = process.env.NODE_ENV || 'production';
74
- const sourceFile = resolve(__dirname, './preact-client.ts');
75
-
76
- // Preact client JS is always production mode
77
- process.env.NODE_ENV = 'production';
78
- const result = await Bun.build({
79
- entrypoints: [sourceFile],
80
- outdir: join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH),
81
- naming: ISLAND_JS_NAMING,
82
- minify: true,
83
- format: 'esm',
84
- target: 'browser',
85
- });
86
- process.env.NODE_ENV = currentNodeEnv;
87
-
88
- const preactEntry = pickEntryPointJsOutput(result.outputs, sourceFile);
89
- const builtFileName = islandBundleBaseName(preactEntry.path);
90
- const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/${builtFileName}.js`;
91
-
92
- JS_IMPORT_MAP.set('preact', builtFilePath);
93
- JS_IMPORT_MAP.set('preact/compat', builtFilePath);
94
- JS_IMPORT_MAP.set('preact/hooks', builtFilePath);
95
- JS_IMPORT_MAP.set('preact/jsx-runtime', builtFilePath);
96
- JS_IMPORT_MAP.set('preact/jsx-dev-runtime', builtFilePath);
97
-
98
- if (!JS_IMPORT_MAP.has('react')) {
99
- JS_IMPORT_MAP.set('react', builtFilePath);
100
- 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];
101
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
+ );
102
56
  }
103
57
 
104
58
  /**
105
- * Hyperspan Preact Plugin
59
+ * Vite plugin for Preact islands.
106
60
  */
107
- export function preactPlugin(): HS.Plugin {
108
- return async (config: HS.Config) => {
109
- try {
110
- log('plugin loaded');
111
- // Ensure Preact can be loaded on the client
112
- if (!JS_IMPORT_MAP.has('preact')) {
113
- await copyPreactToPublicFolder(config);
114
- }
115
-
116
- // Define a Bun plugin to handle .tsx files
117
- await Bun.plugin({
118
- name: 'Hyperspan Preact Loader',
119
- async setup(build) {
120
- // when a .tsx file is imported...
121
- build.onLoad({ filter: /\.tsx$/ }, async (args) => {
122
- log('tsx file loaded', args.path);
123
- const jsId = assetHash(args.path);
124
-
125
- if (!JS_IMPORT_MAP.has('preact')) {
126
- await copyPreactToPublicFolder(config);
127
- }
61
+ export function preactVitePlugin(): Plugin {
62
+ const preactClientPath = resolve(dirname(fileURLToPath(import.meta.url)), './preact-client.ts');
128
63
 
129
- // Cache: Avoid re-processing the same file
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`);
133
- log('tsx file cached', args.path);
134
- return {
135
- contents: hit.contents,
136
- loader: 'js',
137
- };
138
- }
64
+ return {
65
+ name: 'hyperspan-preact',
66
+ enforce: 'pre',
139
67
 
140
- log('tsx file not cached, building...', args.path);
141
- // We need to build the file to ensure we can ship it to the client with dependencies
142
- // Ironic, right? Calling Bun.build() inside of a plugin that runs on Bun.build()?
143
- const result = await Bun.build({
144
- entrypoints: [args.path],
145
- outdir: join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH),
146
- naming: ISLAND_JS_NAMING,
147
- external: Array.from(JS_IMPORT_MAP.keys()),
148
- minify: true,
149
- format: 'esm',
150
- target: 'browser',
151
- env: 'APP_PUBLIC_*',
152
- });
153
-
154
- const entryOut = pickEntryPointJsOutput(result.outputs, args.path);
155
- const esmName = islandBundleBaseName(entryOut.path);
156
-
157
- // Add output file to import map
158
- JS_IMPORT_MAP.set(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
159
- log('added to import map', esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
160
-
161
- let contents = await Bun.file(entryOut.path).text();
162
-
163
- // Look for the default export
164
- const RE_EXPORT_DEFAULT = /export\{([^\s]+) as default\}/;
165
- const RE_EXPORT_DEFAULT_FN = /export default function\s+([^\s]+)/;
166
- const RE_EXPORT_DEFAULT_CONST = /export default const\s+([^\s]+)/;
167
- const RE_EXPORT_DEFAULT_ANY = /export default\s+([^\s]+)/;
168
-
169
- const exportedDefault = contents.match(RE_EXPORT_DEFAULT);
170
- const exportedDefaultFn = contents.match(RE_EXPORT_DEFAULT_FN);
171
- const exportedDefaultConst = contents.match(RE_EXPORT_DEFAULT_CONST);
172
- const exportedDefaultAny = contents.match(RE_EXPORT_DEFAULT_ANY);
173
-
174
- const componentName =
175
- exportedDefault?.[1] ||
176
- exportedDefaultFn?.[1] ||
177
- exportedDefaultConst?.[1] ||
178
- exportedDefaultAny?.[1];
179
-
180
- if (!componentName) {
181
- log('ERROR: no default export found', args.path);
182
- throw new Error(
183
- `No default export found in ${args.path}. Did you forget to export a component?`
184
- );
185
- }
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
+ }
186
102
 
187
- // Add to contents so this is in the client JS as well
188
- contents = `import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';${contents}`;
103
+ const publicPath = `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`;
104
+ registerImport(esmName, publicPath);
189
105
 
190
- // Some _interesting_ work at play here...
191
- // We have to modify the original file contents to add an __HS_PLUGIN export that the renderIsland() function can use to render the component.
192
- // 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.
193
- // 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.
194
- // 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.
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.
196
- 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';
197
108
  import { render as __hs_renderToString } from 'preact-render-to-string';
198
109
  import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
199
110
 
200
- // Original file contents
201
- ${contents}
111
+ ${code}
202
112
 
203
- // hyperspan:preact-plugin
204
113
  function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
205
114
  return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, options);
206
115
  }
116
+
207
117
  ${componentName}.__HS_ISLAND = {
208
118
  id: "${jsId}",
209
119
  render: (props, options = {}) => {
@@ -211,47 +121,53 @@ ${componentName}.__HS_ISLAND = {
211
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}"));\`;
212
122
  return __hs_renderIsland(jsContent, '', options);
213
123
  }
214
-
215
124
  const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
216
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}"));\`;
217
126
  return __hs_renderIsland(jsContent, ssrContent, options);
218
-
219
127
  }
220
- }
128
+ };
221
129
  `;
222
130
 
223
- PREACT_ISLAND_CACHE.set(jsId, { contents: moduleCode, esmName });
224
-
225
- return {
226
- contents: moduleCode,
227
- loader: 'js',
228
- };
229
- });
230
- },
231
- });
232
- } catch (e) {
233
- log('ERROR: plugin build error', e);
234
- console.error('[Hyperspan] @hyperspan/plugin-preact build error');
235
- console.error(e);
236
- throw e;
237
- }
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
+ },
238
147
  };
239
148
  }
240
149
 
241
-
242
150
  /**
243
- * Render a Preact island component
151
+ * Hyperspan config plugin (no-op in v2 — use preactVitePlugin in vite.config.ts).
244
152
  */
245
- export function renderPreactIsland(Component: any, props: any = {}, options = {
246
- ssr: true,
247
- loading: undefined,
248
- }) {
249
- // Render island with its own logic
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
+ ) {
250
167
  if (Component.__HS_ISLAND?.render) {
251
168
  return html.raw(Component.__HS_ISLAND.render(props, options));
252
169
  }
253
-
254
170
  throw new Error(
255
- `Module ${Component.name} was not loaded with an island plugin! Did you forget to install an island plugin and add it to the 'plugins' option in your hyperspan.config.ts file?`
171
+ `Module ${Component.name} was not loaded with an island plugin! Add preactVitePlugin() to vite.config.ts.`
256
172
  );
257
- }
173
+ }
@@ -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';