@hyperspan/plugin-preact 2.0.0-alpha.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/plugin-preact",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.13",
4
4
  "description": "Hyperspan Plugin for Preact",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,15 +30,17 @@
30
30
  "url": "git+https://github.com/vlucas/hyperspan.git"
31
31
  },
32
32
  "devDependencies": {
33
+ "@hyperspan/vite-plugin": "^2.0.0-alpha.13",
33
34
  "prettier": "^3.8.1",
34
35
  "typescript": "^5.9.3",
35
- "vite": "^6.3.5",
36
- "vitest": "^3.2.4"
36
+ "vite": "^8.2.1",
37
+ "vitest": "^4.1.10"
37
38
  },
38
39
  "peerDependencies": {
39
- "@hyperspan/html": "^2.0.0-alpha.1",
40
- "@hyperspan/framework": "^2.0.0-alpha.1",
41
- "vite": "^6.0.0 || ^7.0.0"
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"
42
44
  },
43
45
  "dependencies": {
44
46
  "debug": "^4.4.3",
package/src/index.test.ts CHANGED
@@ -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,7 +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('was not loaded with an island plugin');
102
+ expect(() => renderPreactIsland(Hello, {})).toThrow(/not a Hyperspan island/i);
95
103
  });
96
104
 
97
105
  test('returns an html_safe object', () => {
package/src/index.ts CHANGED
@@ -1,25 +1,39 @@
1
1
  import type { Plugin } from 'vite';
2
- import { resolve, basename, dirname } from 'node:path';
2
+ import { resolve, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { assetHash } from '@hyperspan/framework/utils';
5
5
  import { registerImport, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
6
+ import { renderIsland } from '@hyperspan/framework';
6
7
  import type { Hyperspan as HS } from '@hyperspan/framework';
7
8
  import { html } from '@hyperspan/html';
8
9
  import { h } from 'preact';
9
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';
10
18
  import debug from 'debug';
11
19
 
12
20
  const log = debug('hyperspan:plugin-preact');
13
21
 
22
+ export type IslandExportKind = 'default' | 'named';
23
+
14
24
  export function buildIslandHtml(
15
25
  jsId: string,
16
26
  componentName: string,
17
27
  esmName: string,
18
28
  jsContent: string,
19
29
  ssrContent: string,
20
- options: { loading?: string } = {}
30
+ options: { loading?: string; exportKind?: IslandExportKind } = {}
21
31
  ): string {
22
- const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">import ${componentName} from "${esmName}";${jsContent}</script>`;
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>`;
23
37
  if (options.loading === 'lazy') {
24
38
  return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
25
39
  }
@@ -30,6 +44,8 @@ export function renderPreactSSR(Component: unknown, props: Record<string, unknow
30
44
  return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
31
45
  }
32
46
 
47
+ type DiscoveredExport = { name: string; kind: IslandExportKind };
48
+
33
49
  function extractDefaultExportName(code: string): string | null {
34
50
  const patterns = [
35
51
  /export\{([^\s]+) as default\}/,
@@ -47,18 +63,92 @@ function extractDefaultExportName(code: string): string | null {
47
63
  return anyMatch?.[1] ?? null;
48
64
  }
49
65
 
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
- );
66
+ function discoverPreactExports(code: string): DiscoveredExport[] {
67
+ const exports: DiscoveredExport[] = [];
68
+ const seen = new Set<string>();
69
+
70
+ const defaultName = extractDefaultExportName(code);
71
+ if (defaultName) {
72
+ exports.push({ name: defaultName, kind: 'default' });
73
+ seen.add(defaultName);
74
+ }
75
+
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]);
86
+ }
87
+ }
88
+ }
89
+
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
+ }
104
+
105
+ return exports;
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 `
118
+ ${componentName}.__HS_ISLAND = {
119
+ id: "${jsId}",
120
+ render: (props, options = {}) => {
121
+ const __hs_exportKind = ${JSON.stringify(exportKind)};
122
+ if (options.ssr === false) {
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}"));\`;
124
+ return __hs_renderIsland_${componentName}(jsContent, '', options);
125
+ }
126
+ const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
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}"));\`;
128
+ return __hs_renderIsland_${componentName}(jsContent, ssrContent, options);
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)} });
56
134
  }
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;
57
147
 
58
148
  /**
59
149
  * Vite plugin for Preact islands.
60
150
  */
61
- export function preactVitePlugin(): Plugin {
151
+ export function preactIslandPlugin(): Plugin {
62
152
  const preactClientPath = resolve(dirname(fileURLToPath(import.meta.url)), './preact-client.ts');
63
153
 
64
154
  return {
@@ -73,35 +163,43 @@ export function preactVitePlugin(): Plugin {
73
163
  };
74
164
  },
75
165
 
166
+ resolveId: islandPluginResolveId('preact', '.tsx'),
167
+
76
168
  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
169
  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);
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
+ );
87
182
  },
88
183
 
89
184
  async transform(code, id) {
90
- if (!isIslandComponent(id)) return;
185
+ if (!isIslandModule(id, 'preact', '.tsx')) return;
91
186
 
92
187
  log('transform island', id);
93
- const jsId = assetHash(id);
94
- const esmName = basename(id, '.tsx');
95
- const componentName = extractDefaultExportName(code);
188
+ const cleanId = splitIslandId(id).path;
189
+ const esmName = `island-${assetHash(cleanId)}`;
190
+ const exports = discoverPreactExports(code);
96
191
 
97
- if (!componentName) {
192
+ if (exports.length === 0) {
98
193
  throw new Error(
99
- `No default export found in ${id}. Did you forget to export a Preact component?`
194
+ `No component exports found in ${cleanId}. Export a default or named Preact component and import with \`with { island: 'preact' }\`.`
100
195
  );
101
196
  }
102
197
 
103
- const publicPath = `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`;
104
- registerImport(esmName, publicPath);
198
+ registerImport(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
199
+
200
+ const attachments = exports
201
+ .map((exp) => buildIslandAttachment(exp, cleanId, esmName))
202
+ .join('\n');
105
203
 
106
204
  const moduleCode = `// hyperspan:processed
107
205
  import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';
@@ -109,30 +207,13 @@ import { render as __hs_renderToString } from 'preact-render-to-string';
109
207
  import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
110
208
 
111
209
  ${code}
112
-
113
- function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
114
- return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, options);
115
- }
116
-
117
- ${componentName}.__HS_ISLAND = {
118
- id: "${jsId}",
119
- render: (props, options = {}) => {
120
- if (options.ssr === false) {
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}"));\`;
122
- return __hs_renderIsland(jsContent, '', options);
123
- }
124
- const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
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}"));\`;
126
- return __hs_renderIsland(jsContent, ssrContent, options);
127
- }
128
- };
210
+ ${attachments}
129
211
  `;
130
212
 
131
213
  return { code: moduleCode, map: null };
132
214
  },
133
215
 
134
216
  buildStart() {
135
- // emitFile is only available during production builds, not vite serve.
136
217
  if (this.meta.watchMode) return;
137
218
  try {
138
219
  this.emitFile({
@@ -148,26 +229,19 @@ ${componentName}.__HS_ISLAND = {
148
229
  }
149
230
 
150
231
  /**
151
- * Hyperspan config plugin (no-op in v2 — use preactVitePlugin in vite.config.ts).
232
+ * Hyperspan config plugin — register in hyperspan.config.ts plugins array.
152
233
  */
153
234
  export function preactPlugin(): HS.Plugin {
235
+ registerIslandPlugin('preact', { vitePlugin: preactIslandPlugin });
154
236
  return () => {
155
- log('preactPlugin loaded (Vite handles bundling in v2)');
237
+ log('preactPlugin loaded');
156
238
  };
157
239
  }
158
240
 
159
241
  export function renderPreactIsland(
160
- Component: {
161
- __HS_ISLAND?: { render: (props: unknown, options: unknown) => string };
162
- name?: string;
163
- },
242
+ Component: Parameters<typeof renderIsland>[0],
164
243
  props: Record<string, unknown> = {},
165
- options: { ssr?: boolean; loading?: string } = { ssr: true }
244
+ options: Parameters<typeof renderIsland>[2] = { ssr: true }
166
245
  ) {
167
- if (Component.__HS_ISLAND?.render) {
168
- return html.raw(Component.__HS_ISLAND.render(props, options));
169
- }
170
- throw new Error(
171
- `Module ${Component.name} was not loaded with an island plugin! Add preactVitePlugin() to vite.config.ts.`
172
- );
246
+ return renderIsland(Component, props, options);
173
247
  }