@hyperspan/plugin-vue 1.0.0
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/.prettierignore +1 -0
- package/package.json +41 -0
- package/src/index.test.ts +205 -0
- package/src/index.ts +320 -0
- package/src/types.d.ts +7 -0
- package/src/vue-client.ts +1 -0
- package/tsconfig.json +25 -0
package/.prettierignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
src/vue-client.ts
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyperspan/plugin-vue",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Hyperspan Plugin for Vue.js Client Islands",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"public": true,
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"author": "Vance Lucas <vance@vancelucas.com>",
|
|
12
|
+
"license": "BSD-3-Clause",
|
|
13
|
+
"keywords": [],
|
|
14
|
+
"homepage": "https://www.hyperspan.dev/docs/clientjs/islands",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/vlucas/hyperspan/issues"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bun test"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/vlucas/hyperspan.git"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/bun": "^1.3.8",
|
|
27
|
+
"prettier": "^3.8.1",
|
|
28
|
+
"typescript": "^5.9.3",
|
|
29
|
+
"vue": "^3.5.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@hyperspan/html": "^1.0.0",
|
|
33
|
+
"@hyperspan/framework": "^1.0.1"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@vue/compiler-sfc": "^3.5.0",
|
|
37
|
+
"@vue/server-renderer": "^3.5.0",
|
|
38
|
+
"debug": "^4.4.3",
|
|
39
|
+
"vue": "^3.5.30"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { test, describe, expect } from 'bun:test';
|
|
2
|
+
import { defineComponent, h } from 'vue';
|
|
3
|
+
import { buildIslandHtml, renderVueSSR, renderVueIsland } from './index';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Simple Vue components defined inline — no .vue compilation needed
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const Hello = defineComponent({
|
|
10
|
+
props: {
|
|
11
|
+
name: { type: String, default: 'World' },
|
|
12
|
+
count: { type: Number, default: 0 },
|
|
13
|
+
},
|
|
14
|
+
render() {
|
|
15
|
+
return h('div', { class: 'hello' }, [
|
|
16
|
+
h('h1', `Hello ${this.name}!`),
|
|
17
|
+
h('p', `Count: ${this.count}`),
|
|
18
|
+
]);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// buildIslandHtml
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe('buildIslandHtml', () => {
|
|
27
|
+
const jsId = 'vue-abc123';
|
|
28
|
+
const componentName = '__hs_vue_component';
|
|
29
|
+
const esmName = 'hello-component';
|
|
30
|
+
|
|
31
|
+
test('wraps SSR content in a div with the island id', () => {
|
|
32
|
+
const result = buildIslandHtml(jsId, componentName, esmName, '', '<p>SSR</p>');
|
|
33
|
+
expect(result).toContain(`<div id="${jsId}">`);
|
|
34
|
+
expect(result).toContain('<p>SSR</p>');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('includes a module script tag with the correct source id', () => {
|
|
38
|
+
const result = buildIslandHtml(jsId, componentName, esmName, 'console.log(1)', '');
|
|
39
|
+
expect(result).toContain(`<script type="module" id="${jsId}_script" data-source-id="${jsId}">`);
|
|
40
|
+
expect(result).toContain(`import ${componentName} from "${esmName}"`);
|
|
41
|
+
expect(result).toContain('console.log(1)');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('lazy loading wraps script in a template inside a hidden div', () => {
|
|
45
|
+
const result = buildIslandHtml(jsId, componentName, esmName, '', '<p>SSR</p>', {
|
|
46
|
+
loading: 'lazy',
|
|
47
|
+
});
|
|
48
|
+
expect(result).toContain('data-loading="lazy"');
|
|
49
|
+
expect(result).toContain('<template>');
|
|
50
|
+
expect(result).toContain('<p>SSR</p>');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('eager loading (default) does not include a template tag', () => {
|
|
54
|
+
const result = buildIslandHtml(jsId, componentName, esmName, '', '<p>SSR</p>');
|
|
55
|
+
expect(result).not.toContain('data-loading="lazy"');
|
|
56
|
+
expect(result).not.toContain('<template>');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// renderVueSSR
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe('renderVueSSR', () => {
|
|
65
|
+
test('renders a component to an HTML string', async () => {
|
|
66
|
+
const output = await renderVueSSR(Hello, { name: 'World' });
|
|
67
|
+
expect(output).toContain('<div');
|
|
68
|
+
expect(output).toContain('Hello World!');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('passes props to the component', async () => {
|
|
72
|
+
const output = await renderVueSSR(Hello, { name: 'Alice', count: 42 });
|
|
73
|
+
expect(output).toContain('Hello Alice!');
|
|
74
|
+
expect(output).toContain('Count: 42');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('uses component defaults when props are omitted', async () => {
|
|
78
|
+
const output = await renderVueSSR(Hello, {});
|
|
79
|
+
expect(output).toContain('Hello World!');
|
|
80
|
+
expect(output).toContain('Count: 0');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('returns a string', async () => {
|
|
84
|
+
const output = await renderVueSSR(Hello, {});
|
|
85
|
+
expect(typeof output).toBe('string');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('Vue SSR output contains data-v- hydration markers', async () => {
|
|
89
|
+
// Vue SSR adds data-v- attributes for hydration matching
|
|
90
|
+
const output = await renderVueSSR(Hello, { name: 'World' });
|
|
91
|
+
// Just verify the component's root element is present
|
|
92
|
+
expect(output).toMatch(/class="hello"/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// renderVueIsland
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('renderVueIsland', () => {
|
|
101
|
+
test('throws when component has no __HS_ISLAND property', async () => {
|
|
102
|
+
const Bare = defineComponent({ render: () => h('div') });
|
|
103
|
+
await expect(renderVueIsland(Bare, {})).rejects.toThrow('was not loaded with an island plugin');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('returns an html_safe object', async () => {
|
|
107
|
+
const jsId = 'vue-island-1';
|
|
108
|
+
const mockComponent = defineComponent({ render: () => h('span') }) as any;
|
|
109
|
+
mockComponent.__HS_ISLAND = {
|
|
110
|
+
id: jsId,
|
|
111
|
+
render: async (props: any, options: any = {}) => {
|
|
112
|
+
const ssrContent = await renderVueSSR(Hello, props);
|
|
113
|
+
return buildIslandHtml(jsId, '__hs_vue_component', 'hello-vue', '', ssrContent, options);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const result = await renderVueIsland(mockComponent, { name: 'World' });
|
|
118
|
+
expect(result).toHaveProperty('_kind', 'html_safe');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('island output contains SSR-rendered HTML', async () => {
|
|
122
|
+
const jsId = 'vue-island-2';
|
|
123
|
+
const mockComponent = defineComponent({ render: () => h('span') }) as any;
|
|
124
|
+
mockComponent.__HS_ISLAND = {
|
|
125
|
+
id: jsId,
|
|
126
|
+
render: async (props: any, options: any = {}) => {
|
|
127
|
+
const ssrContent = await renderVueSSR(Hello, props);
|
|
128
|
+
return buildIslandHtml(jsId, '__hs_vue_component', 'hello-vue', '', ssrContent, options);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const result = await renderVueIsland(mockComponent, { name: 'Vue', count: 9 });
|
|
133
|
+
expect(result.content).toContain('Hello Vue!');
|
|
134
|
+
expect(result.content).toContain('Count: 9');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('island output contains the wrapper div with the correct id', async () => {
|
|
138
|
+
const jsId = 'vue-island-3';
|
|
139
|
+
const mockComponent = defineComponent({ render: () => h('span') }) as any;
|
|
140
|
+
mockComponent.__HS_ISLAND = {
|
|
141
|
+
id: jsId,
|
|
142
|
+
render: async (props: any, options: any = {}) => {
|
|
143
|
+
const ssrContent = await renderVueSSR(Hello, props);
|
|
144
|
+
return buildIslandHtml(jsId, '__hs_vue_component', 'hello-vue', '', ssrContent, options);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const result = await renderVueIsland(mockComponent, { name: 'World' });
|
|
149
|
+
expect(result.content).toContain(`<div id="${jsId}">`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('island output contains a hydration script tag', async () => {
|
|
153
|
+
const jsId = 'vue-island-4';
|
|
154
|
+
const esmName = 'hello-vue';
|
|
155
|
+
const mockComponent = defineComponent({ render: () => h('span') }) as any;
|
|
156
|
+
mockComponent.__HS_ISLAND = {
|
|
157
|
+
id: jsId,
|
|
158
|
+
render: async (props: any, options: any = {}) => {
|
|
159
|
+
const ssrContent = await renderVueSSR(Hello, props);
|
|
160
|
+
const jsContent = `import { createSSRApp as __hs_createSSRApp } from 'vue';__hs_createSSRApp(__hs_vue_component, ${JSON.stringify(props)}).mount(document.getElementById("${jsId}"));`;
|
|
161
|
+
return buildIslandHtml(jsId, '__hs_vue_component', esmName, jsContent, ssrContent, options);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const result = await renderVueIsland(mockComponent, { name: 'World' });
|
|
166
|
+
expect(result.content).toContain('<script type="module"');
|
|
167
|
+
expect(result.content).toContain('createSSRApp');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('ssr: false produces no SSR content', async () => {
|
|
171
|
+
const jsId = 'vue-island-5';
|
|
172
|
+
const mockComponent = defineComponent({ render: () => h('span') }) as any;
|
|
173
|
+
mockComponent.__HS_ISLAND = {
|
|
174
|
+
id: jsId,
|
|
175
|
+
render: async (props: any, options: any = {}) => {
|
|
176
|
+
if (options.ssr === false) {
|
|
177
|
+
return buildIslandHtml(jsId, '__hs_vue_component', 'hello-vue', 'console.log("mount")', '', options);
|
|
178
|
+
}
|
|
179
|
+
const ssrContent = await renderVueSSR(Hello, props);
|
|
180
|
+
return buildIslandHtml(jsId, '__hs_vue_component', 'hello-vue', '', ssrContent, options);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const result = await renderVueIsland(mockComponent, { name: 'World' }, { ssr: false, loading: undefined });
|
|
185
|
+
expect(result.content).not.toContain('Hello World!');
|
|
186
|
+
expect(result.content).toContain(`<div id="${jsId}"></div>`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('loading: lazy wraps the script in a template', async () => {
|
|
190
|
+
const jsId = 'vue-island-6';
|
|
191
|
+
const mockComponent = defineComponent({ render: () => h('span') }) as any;
|
|
192
|
+
mockComponent.__HS_ISLAND = {
|
|
193
|
+
id: jsId,
|
|
194
|
+
render: async (props: any, options: any = {}) => {
|
|
195
|
+
const ssrContent = await renderVueSSR(Hello, props);
|
|
196
|
+
return buildIslandHtml(jsId, '__hs_vue_component', 'hello-vue', 'console.log(1)', ssrContent, options);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const result = await renderVueIsland(mockComponent, { name: 'World' }, { ssr: true, loading: 'lazy' } as any);
|
|
201
|
+
expect(result.content).toContain('data-loading="lazy"');
|
|
202
|
+
expect(result.content).toContain('<template>');
|
|
203
|
+
expect(result.content).toContain('Hello World!');
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { JS_IMPORT_MAP, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
|
|
2
|
+
import { assetHash } from '@hyperspan/framework/utils';
|
|
3
|
+
import { IS_PROD } from '@hyperspan/framework/server';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import type { Hyperspan as HS } from '@hyperspan/framework';
|
|
6
|
+
import { html } from '@hyperspan/html';
|
|
7
|
+
import debug from 'debug';
|
|
8
|
+
import { parse, compileScript, compileTemplate, rewriteDefault } from '@vue/compiler-sfc';
|
|
9
|
+
import './types.d';
|
|
10
|
+
|
|
11
|
+
const log = debug('hyperspan:plugin-vue');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the island wrapper HTML: a div for SSR content + a module script tag for client hydration.
|
|
15
|
+
* Exported so it can be imported by generated island module code and used directly in tests.
|
|
16
|
+
*/
|
|
17
|
+
export function buildIslandHtml(
|
|
18
|
+
jsId: string,
|
|
19
|
+
componentName: string,
|
|
20
|
+
esmName: string,
|
|
21
|
+
jsContent: string,
|
|
22
|
+
ssrContent: string,
|
|
23
|
+
options: { loading?: string } = {}
|
|
24
|
+
): string {
|
|
25
|
+
const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">import ${componentName} from "${esmName}";${jsContent}</script>`;
|
|
26
|
+
if (options.loading === 'lazy') {
|
|
27
|
+
return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
|
|
28
|
+
}
|
|
29
|
+
return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render a Vue component to an HTML string (SSR).
|
|
34
|
+
* Exported for direct use in tests and external tooling.
|
|
35
|
+
*/
|
|
36
|
+
export async function renderVueSSR(Component: any, props: any = {}): Promise<string> {
|
|
37
|
+
const { createSSRApp } = await import('vue');
|
|
38
|
+
const { renderToString } = await import('@vue/server-renderer');
|
|
39
|
+
const app = createSSRApp(Component, props);
|
|
40
|
+
return renderToString(app);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const VUE_ISLAND_CACHE = new Map<string, string>();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compile a Vue SFC to JavaScript using @vue/compiler-sfc.
|
|
47
|
+
* Returns the combined script + template JS code ready to execute.
|
|
48
|
+
*/
|
|
49
|
+
async function compileVueSFC(
|
|
50
|
+
source: string,
|
|
51
|
+
filepath: string,
|
|
52
|
+
id: string,
|
|
53
|
+
ssr: boolean
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const { descriptor, errors } = parse(source, { filename: filepath });
|
|
56
|
+
|
|
57
|
+
if (errors.length) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Vue SFC parse errors in ${filepath}:\n${errors.map((e) => e.message).join('\n')}`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Compile <script> / <script setup> block
|
|
64
|
+
let scriptCode = 'const __sfc__ = {};';
|
|
65
|
+
let bindingMetadata: Record<string, any> | undefined;
|
|
66
|
+
if (descriptor.script || descriptor.scriptSetup) {
|
|
67
|
+
const scriptResult = compileScript(descriptor, { id });
|
|
68
|
+
bindingMetadata = scriptResult.bindings;
|
|
69
|
+
// Rename `export default` to `const __sfc__ =` so we can augment it
|
|
70
|
+
scriptCode = rewriteDefault(scriptResult.content, '__sfc__');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Compile <template> block
|
|
74
|
+
let templateCode = '';
|
|
75
|
+
if (descriptor.template) {
|
|
76
|
+
const templateResult = compileTemplate({
|
|
77
|
+
source: descriptor.template.content,
|
|
78
|
+
filename: filepath,
|
|
79
|
+
id,
|
|
80
|
+
ssr,
|
|
81
|
+
scoped: descriptor.styles.some((s) => s.scoped),
|
|
82
|
+
// Pass binding metadata so the template compiler knows which vars are
|
|
83
|
+
// <script setup> bindings and generates $setup.x refs instead of _ctx.x
|
|
84
|
+
compilerOptions: bindingMetadata ? { bindingMetadata } : undefined,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (templateResult.errors.length) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Vue SFC template errors in ${filepath}:\n${templateResult.errors.map((e) => (typeof e === 'string' ? e : e.message)).join('\n')}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
templateCode = templateResult.code;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Combine: attach render/ssrRender to the component object, then export it
|
|
97
|
+
const renderKey = ssr ? 'ssrRender' : 'render';
|
|
98
|
+
return `${scriptCode}\n${templateCode}\n__sfc__.${renderKey} = ${renderKey};\nexport default __sfc__;\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build Vue client JS and copy to public folder
|
|
103
|
+
*/
|
|
104
|
+
async function copyVueToPublicFolder(config: HS.Config) {
|
|
105
|
+
const outdir = join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH);
|
|
106
|
+
const devOutputFile = join(outdir, 'vue-client.js');
|
|
107
|
+
|
|
108
|
+
// In dev mode, skip the build if the file already exists to avoid
|
|
109
|
+
// conflicts with bun --watch (building vue triggers watch restarts)
|
|
110
|
+
if (!IS_PROD && (await Bun.file(devOutputFile).exists())) {
|
|
111
|
+
const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/vue-client.js`;
|
|
112
|
+
JS_IMPORT_MAP.set('vue', builtFilePath);
|
|
113
|
+
JS_IMPORT_MAP.set('vue/dist/vue.esm-bundler.js', builtFilePath);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const currentNodeEnv = process.env.NODE_ENV || 'production';
|
|
118
|
+
const sourceFile = resolve(__dirname, './vue-client.ts');
|
|
119
|
+
|
|
120
|
+
// Vue client JS is always production mode
|
|
121
|
+
process.env.NODE_ENV = 'production';
|
|
122
|
+
const result = await Bun.build({
|
|
123
|
+
entrypoints: [sourceFile],
|
|
124
|
+
outdir,
|
|
125
|
+
naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
|
|
126
|
+
minify: true,
|
|
127
|
+
format: 'esm',
|
|
128
|
+
target: 'browser',
|
|
129
|
+
define: {
|
|
130
|
+
__VUE_OPTIONS_API__: 'true',
|
|
131
|
+
__VUE_PROD_DEVTOOLS__: 'false',
|
|
132
|
+
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
process.env.NODE_ENV = currentNodeEnv;
|
|
136
|
+
|
|
137
|
+
const builtFileName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
138
|
+
const builtFilePath = `${JS_ISLAND_PUBLIC_PATH}/${builtFileName}.js`;
|
|
139
|
+
|
|
140
|
+
JS_IMPORT_MAP.set('vue', builtFilePath);
|
|
141
|
+
JS_IMPORT_MAP.set('vue/dist/vue.esm-bundler.js', builtFilePath);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hyperspan Vue Plugin
|
|
146
|
+
*/
|
|
147
|
+
export function vuePlugin(): HS.Plugin {
|
|
148
|
+
return async (config: HS.Config) => {
|
|
149
|
+
try {
|
|
150
|
+
log('plugin loaded');
|
|
151
|
+
// Ensure Vue can be loaded on the client
|
|
152
|
+
if (!JS_IMPORT_MAP.has('vue')) {
|
|
153
|
+
await copyVueToPublicFolder(config);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Define a Bun plugin to handle .vue files
|
|
157
|
+
await Bun.plugin({
|
|
158
|
+
name: 'Hyperspan Vue Loader',
|
|
159
|
+
async setup(build) {
|
|
160
|
+
// when a .vue file is imported...
|
|
161
|
+
build.onLoad({ filter: /\.vue$/ }, async (args) => {
|
|
162
|
+
log('vue file loaded', args.path);
|
|
163
|
+
const jsId = assetHash(args.path);
|
|
164
|
+
|
|
165
|
+
// Cache: Avoid re-processing the same file
|
|
166
|
+
if (VUE_ISLAND_CACHE.has(jsId)) {
|
|
167
|
+
log('vue file cached', args.path);
|
|
168
|
+
return {
|
|
169
|
+
contents: VUE_ISLAND_CACHE.get(jsId) || '',
|
|
170
|
+
loader: 'js',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
log('vue file not cached, building...', args.path);
|
|
175
|
+
|
|
176
|
+
const source = await Bun.file(args.path).text();
|
|
177
|
+
|
|
178
|
+
// Compile the Vue SFC for server-side rendering
|
|
179
|
+
const ssrCode = await compileVueSFC(source, args.path, jsId, true);
|
|
180
|
+
|
|
181
|
+
const outdir = join('./', config.publicDir, JS_ISLAND_PUBLIC_PATH);
|
|
182
|
+
const baseName = args.path.split('/').pop()!.replace('.vue', '');
|
|
183
|
+
|
|
184
|
+
let esmName: string;
|
|
185
|
+
if (!IS_PROD) {
|
|
186
|
+
// In dev mode, write compiled JS directly — avoids nested Bun.build() inside
|
|
187
|
+
// Bun.plugin() onLoad which causes EISDIR conflicts under bun --watch.
|
|
188
|
+
// The browser import map resolves 'vue' to vue-client.js.
|
|
189
|
+
const clientCode = await compileVueSFC(source, args.path, jsId, false);
|
|
190
|
+
await Bun.write(join(outdir, `${baseName}.js`), clientCode);
|
|
191
|
+
esmName = baseName;
|
|
192
|
+
} else {
|
|
193
|
+
// Production: full bundle with tree-shaking and minification
|
|
194
|
+
const vueClientPlugin = {
|
|
195
|
+
name: 'vue-client-compiler',
|
|
196
|
+
setup(clientBuild: any) {
|
|
197
|
+
clientBuild.onLoad(
|
|
198
|
+
{ filter: /\.vue$/ },
|
|
199
|
+
async ({ path }: { path: string }) => {
|
|
200
|
+
const src = await Bun.file(path).text();
|
|
201
|
+
const clientId = assetHash(path);
|
|
202
|
+
const compiledCode = await compileVueSFC(src, path, clientId, false);
|
|
203
|
+
return { contents: compiledCode, loader: 'js' };
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const clientResult = await Bun.build({
|
|
210
|
+
entrypoints: [args.path],
|
|
211
|
+
outdir,
|
|
212
|
+
naming: '[dir]/[name]-[hash].[ext]',
|
|
213
|
+
external: Array.from(JS_IMPORT_MAP.keys()),
|
|
214
|
+
minify: true,
|
|
215
|
+
format: 'esm',
|
|
216
|
+
target: 'browser',
|
|
217
|
+
plugins: [vueClientPlugin],
|
|
218
|
+
env: 'APP_PUBLIC_*',
|
|
219
|
+
define: {
|
|
220
|
+
__VUE_OPTIONS_API__: 'true',
|
|
221
|
+
__VUE_PROD_DEVTOOLS__: 'false',
|
|
222
|
+
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
esmName = String(clientResult.outputs[0].path.split('/').reverse()[0]).replace(
|
|
227
|
+
'.js',
|
|
228
|
+
''
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
JS_IMPORT_MAP.set(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
233
|
+
log('added to import map', esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
|
|
234
|
+
|
|
235
|
+
// Use a fixed component identifier throughout — no name extraction needed
|
|
236
|
+
// since we control the SSR module code and always rename to __sfc__.
|
|
237
|
+
const componentName = '__hs_vue_component';
|
|
238
|
+
|
|
239
|
+
// Build the final module code.
|
|
240
|
+
// The SSR compiled code is included inline for server-side rendering via @vue/server-renderer.
|
|
241
|
+
// The client script imports the ESM bundle and uses Vue's createSSRApp for hydration.
|
|
242
|
+
// Note: render() is async because Vue's renderToString() returns a Promise.
|
|
243
|
+
const moduleCode = `// hyperspan:processed
|
|
244
|
+
function __hs_buildIslandHtml(jsId, componentName, esmName, jsContent, ssrContent, options) {
|
|
245
|
+
options = options || {};
|
|
246
|
+
const scriptTag = \`<script type="module" id="\${jsId}_script" data-source-id="\${jsId}">import \${componentName} from "\${esmName}";\${jsContent}</script>\`;
|
|
247
|
+
if (options.loading === 'lazy') {
|
|
248
|
+
return \`<div id="\${jsId}">\${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\\n\${scriptTag}</template></div>\`;
|
|
249
|
+
}
|
|
250
|
+
return \`<div id="\${jsId}">\${ssrContent}</div>\\n\${scriptTag}\`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Server-side compiled Vue component
|
|
254
|
+
${ssrCode}
|
|
255
|
+
const ${componentName} = __sfc__;
|
|
256
|
+
|
|
257
|
+
// hyperspan:vue-plugin
|
|
258
|
+
function __hs_renderIsland(jsContent = '', ssrContent = '', options = {}) {
|
|
259
|
+
return __hs_buildIslandHtml("${jsId}", "__hs_vue_component", "${esmName}", jsContent, ssrContent, options);
|
|
260
|
+
}
|
|
261
|
+
${componentName}.__HS_ISLAND = {
|
|
262
|
+
id: "${jsId}",
|
|
263
|
+
render: async (props, options = {}) => {
|
|
264
|
+
// Dynamic imports keep vue/server-renderer out of the static module graph
|
|
265
|
+
// so the CSS-extraction bundler does not try to bundle them.
|
|
266
|
+
const { createSSRApp: __hs_createSSRApp } = await import('vue');
|
|
267
|
+
const { renderToString: __hs_renderToString } = await import('@vue/server-renderer');
|
|
268
|
+
|
|
269
|
+
if (options.ssr === false) {
|
|
270
|
+
const jsContent = \`import { createApp as __hs_createApp } from 'vue';__hs_createApp(__hs_vue_component, \${JSON.stringify(props)}).mount(document.getElementById("${jsId}"));\`;
|
|
271
|
+
return __hs_renderIsland(jsContent, '', options);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const app = __hs_createSSRApp(${componentName}, props);
|
|
275
|
+
const ssrContent = await __hs_renderToString(app);
|
|
276
|
+
const jsContent = \`import { createSSRApp as __hs_createSSRApp } from 'vue';__hs_createSSRApp(__hs_vue_component, \${JSON.stringify(props)}).mount(document.getElementById("${jsId}"));\`;
|
|
277
|
+
return __hs_renderIsland(jsContent, ssrContent, options);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
VUE_ISLAND_CACHE.set(jsId, moduleCode);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
contents: moduleCode,
|
|
286
|
+
loader: 'js',
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
} catch (e) {
|
|
292
|
+
log('ERROR: plugin build error', e);
|
|
293
|
+
console.error('[Hyperspan] @hyperspan/plugin-vue build error');
|
|
294
|
+
console.error(e);
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Render a Vue island component.
|
|
302
|
+
* Returns a Promise because Vue's renderToString() is async.
|
|
303
|
+
*/
|
|
304
|
+
export async function renderVueIsland(
|
|
305
|
+
Component: any,
|
|
306
|
+
props: any = {},
|
|
307
|
+
options = {
|
|
308
|
+
ssr: true,
|
|
309
|
+
loading: undefined,
|
|
310
|
+
}
|
|
311
|
+
) {
|
|
312
|
+
// Render island with its own logic
|
|
313
|
+
if (Component.__HS_ISLAND?.render) {
|
|
314
|
+
return html.raw(await Component.__HS_ISLAND.render(props, options));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
throw new Error(
|
|
318
|
+
`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?`
|
|
319
|
+
);
|
|
320
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from 'vue';
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compileOnSave": true,
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"target": "es2019",
|
|
7
|
+
"lib": ["ESNext", "dom", "dom.iterable"],
|
|
8
|
+
"module": "esnext",
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"moduleDetection": "force",
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"composite": true,
|
|
15
|
+
"strict": true,
|
|
16
|
+
"declaration": true,
|
|
17
|
+
"sourceMap": true,
|
|
18
|
+
"downlevelIteration": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"allowSyntheticDefaultImports": true,
|
|
21
|
+
"forceConsistentCasingInFileNames": true,
|
|
22
|
+
"allowJs": true
|
|
23
|
+
},
|
|
24
|
+
"exclude": ["node_modules", "__tests__", "*.test.ts"]
|
|
25
|
+
}
|