@hyperspan/plugin-preact 2.0.0-alpha.1 → 2.0.0-alpha.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 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.3",
4
4
  "description": "Hyperspan Plugin for Preact",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,14 +30,16 @@
30
30
  "url": "git+https://github.com/vlucas/hyperspan.git"
31
31
  },
32
32
  "devDependencies": {
33
+ "@hyperspan/vite-plugin": "^2.0.0-alpha.3",
33
34
  "prettier": "^3.8.1",
34
35
  "typescript": "^5.9.3",
35
36
  "vite": "^6.3.5",
36
37
  "vitest": "^3.2.4"
37
38
  },
38
39
  "peerDependencies": {
39
- "@hyperspan/html": "^2.0.0-alpha.1",
40
- "@hyperspan/framework": "^2.0.0-alpha.1",
40
+ "@hyperspan/html": "^2.0.0-alpha.3",
41
+ "@hyperspan/framework": "^2.0.0-alpha.3",
42
+ "@hyperspan/vite-plugin": "^2.0.0-alpha.3",
41
43
  "vite": "^6.0.0 || ^7.0.0"
42
44
  },
43
45
  "dependencies": {
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,38 @@
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
+ } from '@hyperspan/vite-plugin/islands';
10
17
  import debug from 'debug';
11
18
 
12
19
  const log = debug('hyperspan:plugin-preact');
13
20
 
21
+ export type IslandExportKind = 'default' | 'named';
22
+
14
23
  export function buildIslandHtml(
15
24
  jsId: string,
16
25
  componentName: string,
17
26
  esmName: string,
18
27
  jsContent: string,
19
28
  ssrContent: string,
20
- options: { loading?: string } = {}
29
+ options: { loading?: string; exportKind?: IslandExportKind } = {}
21
30
  ): string {
22
- const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">import ${componentName} from "${esmName}";${jsContent}</script>`;
31
+ const importStmt =
32
+ options.exportKind === 'named'
33
+ ? `import { ${componentName} } from "${esmName}";`
34
+ : `import ${componentName} from "${esmName}";`;
35
+ const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">${importStmt}${jsContent}</script>`;
23
36
  if (options.loading === 'lazy') {
24
37
  return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
25
38
  }
@@ -30,6 +43,8 @@ export function renderPreactSSR(Component: unknown, props: Record<string, unknow
30
43
  return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
31
44
  }
32
45
 
46
+ type DiscoveredExport = { name: string; kind: IslandExportKind };
47
+
33
48
  function extractDefaultExportName(code: string): string | null {
34
49
  const patterns = [
35
50
  /export\{([^\s]+) as default\}/,
@@ -47,18 +62,82 @@ function extractDefaultExportName(code: string): string | null {
47
62
  return anyMatch?.[1] ?? null;
48
63
  }
49
64
 
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
- );
65
+ function discoverPreactExports(code: string): DiscoveredExport[] {
66
+ const exports: DiscoveredExport[] = [];
67
+ const seen = new Set<string>();
68
+
69
+ const defaultName = extractDefaultExportName(code);
70
+ if (defaultName) {
71
+ exports.push({ name: defaultName, kind: 'default' });
72
+ seen.add(defaultName);
73
+ }
74
+
75
+ for (const re of [
76
+ /export\s+function\s+([A-Za-z_$][\w$]*)/g,
77
+ /export\s+const\s+([A-Za-z_$][\w$]*)/g,
78
+ /export\s+class\s+([A-Za-z_$][\w$]*)/g,
79
+ ]) {
80
+ let match: RegExpExecArray | null;
81
+ while ((match = re.exec(code)) !== null) {
82
+ if (!seen.has(match[1])) {
83
+ exports.push({ name: match[1], kind: 'named' });
84
+ seen.add(match[1]);
85
+ }
86
+ }
87
+ }
88
+
89
+ const exportBlock = /export\s*\{([^}]+)\}/g;
90
+ let block: RegExpExecArray | null;
91
+ while ((block = exportBlock.exec(code)) !== null) {
92
+ for (const part of block[1].split(',')) {
93
+ const trimmed = part.trim();
94
+ if (!trimmed || trimmed.startsWith('type ')) continue;
95
+ const asMatch = trimmed.match(/^(?:([\w$]+)\s+as\s+)?([\w$]+)$/);
96
+ const name = asMatch?.[2] ?? trimmed;
97
+ if (name && name !== 'default' && !seen.has(name)) {
98
+ exports.push({ name, kind: 'named' });
99
+ seen.add(name);
100
+ }
101
+ }
102
+ }
103
+
104
+ return exports;
105
+ }
106
+
107
+ function buildIslandAttachment(
108
+ exportInfo: DiscoveredExport,
109
+ cleanId: string,
110
+ esmName: string
111
+ ): string {
112
+ const { name: componentName, kind } = exportInfo;
113
+ const jsId = assetHash(`${cleanId}:${componentName}`);
114
+ const exportKind = kind;
115
+
116
+ return `
117
+ ${componentName}.__HS_ISLAND = {
118
+ id: "${jsId}",
119
+ render: (props, options = {}) => {
120
+ const __hs_exportKind = ${JSON.stringify(exportKind)};
121
+ if (options.ssr === false) {
122
+ const jsContent = \`import { h as __hs_h, render as __hs_render } from 'preact';__hs_render(__hs_h(${componentName}, \${JSON.stringify(props)}), document.getElementById("${jsId}"));\`;
123
+ return __hs_renderIsland_${componentName}(jsContent, '', options);
124
+ }
125
+ const ssrContent = __hs_renderToString(__hs_h(${componentName}, props));
126
+ const jsContent = \`import { h as __hs_h, hydrate as __hs_hydrate } from 'preact';__hs_hydrate(__hs_h(${componentName}, \${JSON.stringify(props)}), document.getElementById("${jsId}"));\`;
127
+ return __hs_renderIsland_${componentName}(jsContent, ssrContent, options);
128
+ }
129
+ };
130
+
131
+ function __hs_renderIsland_${componentName}(jsContent = '', ssrContent = '', options = {}) {
132
+ return __hs_buildIslandHtml("${jsId}", "${componentName}", "${esmName}", jsContent, ssrContent, { ...options, exportKind: ${JSON.stringify(exportKind)} });
133
+ }
134
+ `;
56
135
  }
57
136
 
58
137
  /**
59
138
  * Vite plugin for Preact islands.
60
139
  */
61
- export function preactVitePlugin(): Plugin {
140
+ export function preactIslandPlugin(): Plugin {
62
141
  const preactClientPath = resolve(dirname(fileURLToPath(import.meta.url)), './preact-client.ts');
63
142
 
64
143
  return {
@@ -73,9 +152,9 @@ export function preactVitePlugin(): Plugin {
73
152
  };
74
153
  },
75
154
 
155
+ resolveId: islandPluginResolveId('preact', '.tsx'),
156
+
76
157
  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
158
  const clientUrl = '/islands/preact-client.js';
80
159
  registerImport('preact', clientUrl);
81
160
  registerImport('preact/hooks', clientUrl);
@@ -87,21 +166,24 @@ export function preactVitePlugin(): Plugin {
87
166
  },
88
167
 
89
168
  async transform(code, id) {
90
- if (!isIslandComponent(id)) return;
169
+ if (!isIslandModule(id, 'preact', '.tsx')) return;
91
170
 
92
171
  log('transform island', id);
93
- const jsId = assetHash(id);
94
- const esmName = basename(id, '.tsx');
95
- const componentName = extractDefaultExportName(code);
172
+ const cleanId = splitIslandId(id).path;
173
+ const esmName = `island-${assetHash(cleanId)}`;
174
+ const exports = discoverPreactExports(code);
96
175
 
97
- if (!componentName) {
176
+ if (exports.length === 0) {
98
177
  throw new Error(
99
- `No default export found in ${id}. Did you forget to export a Preact component?`
178
+ `No component exports found in ${cleanId}. Export a default or named Preact component and import with \`with { island: 'preact' }\`.`
100
179
  );
101
180
  }
102
181
 
103
- const publicPath = `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`;
104
- registerImport(esmName, publicPath);
182
+ registerImport(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
183
+
184
+ const attachments = exports
185
+ .map((exp) => buildIslandAttachment(exp, cleanId, esmName))
186
+ .join('\n');
105
187
 
106
188
  const moduleCode = `// hyperspan:processed
107
189
  import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';
@@ -109,30 +191,13 @@ import { render as __hs_renderToString } from 'preact-render-to-string';
109
191
  import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
110
192
 
111
193
  ${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
- };
194
+ ${attachments}
129
195
  `;
130
196
 
131
197
  return { code: moduleCode, map: null };
132
198
  },
133
199
 
134
200
  buildStart() {
135
- // emitFile is only available during production builds, not vite serve.
136
201
  if (this.meta.watchMode) return;
137
202
  try {
138
203
  this.emitFile({
@@ -148,26 +213,19 @@ ${componentName}.__HS_ISLAND = {
148
213
  }
149
214
 
150
215
  /**
151
- * Hyperspan config plugin (no-op in v2 — use preactVitePlugin in vite.config.ts).
216
+ * Hyperspan config plugin — register in hyperspan.config.ts plugins array.
152
217
  */
153
218
  export function preactPlugin(): HS.Plugin {
219
+ registerIslandPlugin('preact', { vitePlugin: preactIslandPlugin });
154
220
  return () => {
155
- log('preactPlugin loaded (Vite handles bundling in v2)');
221
+ log('preactPlugin loaded');
156
222
  };
157
223
  }
158
224
 
159
225
  export function renderPreactIsland(
160
- Component: {
161
- __HS_ISLAND?: { render: (props: unknown, options: unknown) => string };
162
- name?: string;
163
- },
226
+ Component: Parameters<typeof renderIsland>[0],
164
227
  props: Record<string, unknown> = {},
165
- options: { ssr?: boolean; loading?: string } = { ssr: true }
228
+ options: Parameters<typeof renderIsland>[2] = { ssr: true }
166
229
  ) {
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
- );
230
+ return renderIsland(Component, props, options);
173
231
  }