@useavalon/vue 0.1.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/README.md +42 -0
- package/__tests__/config.test.ts +90 -0
- package/__tests__/css-extractor.test.ts +119 -0
- package/client/hydration.ts +124 -0
- package/client/index.ts +7 -0
- package/mod.ts +59 -0
- package/package.json +26 -0
- package/server/css-extractor.ts +175 -0
- package/server/renderer.ts +116 -0
- package/types.ts +80 -0
- package/vitest.config.ts +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @useavalon/vue
|
|
2
|
+
|
|
3
|
+
Vue 3 integration for [Avalon](https://useavalon.dev). Server-side rendering and client-side hydration for Vue Single File Components as islands.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Vue 3 with Composition API and `<script setup>`
|
|
8
|
+
- Server-side rendering via `@vue/server-renderer`
|
|
9
|
+
- Scoped CSS extraction from SFCs
|
|
10
|
+
- All hydration strategies (`on:client`, `on:visible`, `on:idle`, `on:interaction`)
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```vue
|
|
15
|
+
<!-- components/Counter.vue -->
|
|
16
|
+
<script setup>
|
|
17
|
+
import { ref } from 'vue';
|
|
18
|
+
const count = ref(0);
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<button @click="count++">Count: {{ count }}</button>
|
|
23
|
+
</template>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
// pages/index.tsx
|
|
28
|
+
import Counter from '../components/Counter.vue';
|
|
29
|
+
|
|
30
|
+
export default function Home() {
|
|
31
|
+
return <Counter island={{ condition: 'on:visible' }} />;
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Links
|
|
36
|
+
|
|
37
|
+
- [Documentation](https://useavalon.dev/docs/frameworks/vue)
|
|
38
|
+
- [GitHub](https://github.com/useAvalon/Avalon)
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { vueIntegration } from '../mod.ts';
|
|
3
|
+
|
|
4
|
+
describe('vueIntegration.config()', () => {
|
|
5
|
+
const cfg = vueIntegration.config();
|
|
6
|
+
|
|
7
|
+
it('returns "vue" as the integration name', () => {
|
|
8
|
+
expect(cfg.name).toBe('vue');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('declares .vue file extension', () => {
|
|
12
|
+
expect(cfg.fileExtensions).toEqual(['.vue']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('detection patterns - imports', () => {
|
|
16
|
+
const { imports } = cfg.detectionPatterns;
|
|
17
|
+
|
|
18
|
+
it('matches bare "vue" import', () => {
|
|
19
|
+
expect(imports.some((r) => r.test('vue'))).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('matches "vue/" subpath import', () => {
|
|
23
|
+
expect(imports.some((r) => r.test('vue/server-renderer'))).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('matches from "vue" import statement', () => {
|
|
27
|
+
const code = "import { ref } from 'vue'";
|
|
28
|
+
expect(imports.some((r) => r.test(code))).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not match unrelated imports', () => {
|
|
32
|
+
const code = "import { render } from 'react'";
|
|
33
|
+
expect(imports.some((r) => r.test(code))).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('detection patterns - content', () => {
|
|
38
|
+
const { content } = cfg.detectionPatterns;
|
|
39
|
+
|
|
40
|
+
it('matches <template> tag', () => {
|
|
41
|
+
expect(content.some((r) => r.test('<template>'))).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('matches <script setup> tag', () => {
|
|
45
|
+
expect(content.some((r) => r.test('<script setup>'))).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('matches <script lang="ts" setup> tag', () => {
|
|
49
|
+
expect(content.some((r) => r.test('<script lang="ts" setup>'))).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('matches defineComponent API', () => {
|
|
53
|
+
expect(content.some((r) => r.test('defineComponent({ })'))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('matches ref API', () => {
|
|
57
|
+
expect(content.some((r) => r.test('const count = ref(0)'))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('matches reactive API', () => {
|
|
61
|
+
expect(content.some((r) => r.test('const state = reactive({})'))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('matches computed API', () => {
|
|
65
|
+
expect(content.some((r) => r.test('const doubled = computed(() => count.value * 2)'))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not match unrelated content', () => {
|
|
69
|
+
expect(content.some((r) => r.test('const x = 42'))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('vueIntegration.getHydrationScript()', () => {
|
|
75
|
+
const script = vueIntegration.getHydrationScript();
|
|
76
|
+
|
|
77
|
+
it('returns a non-empty string', () => {
|
|
78
|
+
expect(script).toBeTruthy();
|
|
79
|
+
expect(typeof script).toBe('string');
|
|
80
|
+
expect(script.length).toBeGreaterThan(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('contains a query selector for data-framework="vue"', () => {
|
|
84
|
+
expect(script).toContain('data-framework="vue"');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('contains dynamic import logic', () => {
|
|
88
|
+
expect(script).toContain('import(');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { applyScopedCSS, applyScopeToHTML, generateScopeId } from '../server/css-extractor.ts';
|
|
3
|
+
|
|
4
|
+
describe('applyScopedCSS', () => {
|
|
5
|
+
it('appends scope attribute to a simple selector', () => {
|
|
6
|
+
const css = '.foo { color: red; }';
|
|
7
|
+
const result = applyScopedCSS(css, 'data-v-abc123');
|
|
8
|
+
expect(result).toContain('.foo[data-v-abc123]');
|
|
9
|
+
expect(result).toContain('color: red;');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('scopes multiple comma-separated selectors', () => {
|
|
13
|
+
const css = '.foo, .bar { margin: 0; }';
|
|
14
|
+
const result = applyScopedCSS(css, 'data-v-abc123');
|
|
15
|
+
expect(result).toContain('.foo[data-v-abc123]');
|
|
16
|
+
expect(result).toContain('.bar[data-v-abc123]');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('scopes element selectors', () => {
|
|
20
|
+
const css = 'h1 { font-size: 2em; }';
|
|
21
|
+
const result = applyScopedCSS(css, 'data-v-xyz');
|
|
22
|
+
expect(result).toContain('h1[data-v-xyz]');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('scopes compound selectors', () => {
|
|
26
|
+
const css = '.parent .child { display: flex; }';
|
|
27
|
+
const result = applyScopedCSS(css, 'data-v-s1');
|
|
28
|
+
expect(result).toContain('.parent .child[data-v-s1]');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('skips @media at-rules', () => {
|
|
32
|
+
const css = '@media (max-width: 600px) { .foo { color: red; } }';
|
|
33
|
+
const result = applyScopedCSS(css, 'data-v-abc');
|
|
34
|
+
// The @media rule itself should not be scoped
|
|
35
|
+
expect(result).toMatch(/@media\s*\(max-width:\s*600px\)/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('skips @keyframes at-rules', () => {
|
|
39
|
+
const css = '@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }';
|
|
40
|
+
const result = applyScopedCSS(css, 'data-v-abc');
|
|
41
|
+
expect(result).toContain('@keyframes fadeIn');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('skips @supports at-rules', () => {
|
|
45
|
+
const css = '@supports (display: grid) { .grid { display: grid; } }';
|
|
46
|
+
const result = applyScopedCSS(css, 'data-v-abc');
|
|
47
|
+
expect(result).toMatch(/@supports\s*\(display:\s*grid\)/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns empty string for empty CSS', () => {
|
|
51
|
+
expect(applyScopedCSS('', 'data-v-abc')).toBe('');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('applyScopeToHTML', () => {
|
|
56
|
+
it('adds scope attribute to opening tags', () => {
|
|
57
|
+
const html = '<div class="wrapper"><span>hello</span></div>';
|
|
58
|
+
const result = applyScopeToHTML(html, 'data-v-abc');
|
|
59
|
+
expect(result).toContain('<div class="wrapper" data-v-abc>');
|
|
60
|
+
expect(result).toContain('<span data-v-abc>');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('leaves closing tags unmodified', () => {
|
|
64
|
+
const html = '<div>text</div>';
|
|
65
|
+
const result = applyScopeToHTML(html, 'data-v-abc');
|
|
66
|
+
expect(result).toContain('</div>');
|
|
67
|
+
// Closing tag should not have the scope attribute
|
|
68
|
+
expect(result).not.toContain('</div data-v-abc>');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('leaves self-closing tags unmodified', () => {
|
|
72
|
+
const html = '<img src="a.png"/>';
|
|
73
|
+
const result = applyScopeToHTML(html, 'data-v-abc');
|
|
74
|
+
// Self-closing tags ending with / are skipped by the implementation
|
|
75
|
+
expect(result).toContain('<img src="a.png"/>');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('handles multiple opening tags', () => {
|
|
79
|
+
const html = '<ul><li>one</li><li>two</li></ul>';
|
|
80
|
+
const result = applyScopeToHTML(html, 'data-v-s1');
|
|
81
|
+
expect(result).toContain('<ul data-v-s1>');
|
|
82
|
+
expect(result).toContain('<li data-v-s1>');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns empty string for empty HTML', () => {
|
|
86
|
+
expect(applyScopeToHTML('', 'data-v-abc')).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('handles tags with attributes', () => {
|
|
90
|
+
const html = '<a href="/link" class="btn">click</a>';
|
|
91
|
+
const result = applyScopeToHTML(html, 'data-v-abc');
|
|
92
|
+
expect(result).toContain('<a href="/link" class="btn" data-v-abc>');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('generateScopeId', () => {
|
|
97
|
+
it('returns a string starting with "data-v-"', () => {
|
|
98
|
+
const id = generateScopeId('/components/Foo.vue');
|
|
99
|
+
expect(id).toMatch(/^data-v-/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns deterministic output for the same input', () => {
|
|
103
|
+
const a = generateScopeId('/components/Foo.vue');
|
|
104
|
+
const b = generateScopeId('/components/Foo.vue');
|
|
105
|
+
expect(a).toBe(b);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns different IDs for different paths', () => {
|
|
109
|
+
const a = generateScopeId('/components/Foo.vue');
|
|
110
|
+
const b = generateScopeId('/components/Bar.vue');
|
|
111
|
+
expect(a).not.toBe(b);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('produces alphanumeric hash portion', () => {
|
|
115
|
+
const id = generateScopeId('/src/App.vue');
|
|
116
|
+
const hash = id.replace('data-v-', '');
|
|
117
|
+
expect(hash).toMatch(/^[a-z0-9]+$/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Client Hydration
|
|
3
|
+
*
|
|
4
|
+
* Provides client-side hydration for Vue components.
|
|
5
|
+
* Attaches interactivity to server-rendered Vue components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createApp } from "vue";
|
|
9
|
+
import type { Component } from "vue";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hydrate a Vue component on the client
|
|
13
|
+
*
|
|
14
|
+
* Creates a Vue app instance and mounts it to the container element,
|
|
15
|
+
* hydrating the server-rendered HTML.
|
|
16
|
+
*
|
|
17
|
+
* @param container - DOM element containing server-rendered HTML
|
|
18
|
+
* @param component - Vue component to hydrate
|
|
19
|
+
* @param props - Component props
|
|
20
|
+
*/
|
|
21
|
+
export function hydrate(
|
|
22
|
+
container: Element,
|
|
23
|
+
component: unknown,
|
|
24
|
+
props: Record<string, unknown> = {},
|
|
25
|
+
): void {
|
|
26
|
+
try {
|
|
27
|
+
// Create Vue app with the component and props
|
|
28
|
+
// Type assertion needed because component is dynamically loaded
|
|
29
|
+
const app = createApp(component as Component, props);
|
|
30
|
+
|
|
31
|
+
// Mount and hydrate
|
|
32
|
+
app.mount(container, true);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error("Vue hydration failed:", error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the hydration script for Vue islands
|
|
41
|
+
*
|
|
42
|
+
* Returns a script that will be injected into the page to handle
|
|
43
|
+
* automatic hydration of Vue islands based on their conditions.
|
|
44
|
+
*
|
|
45
|
+
* @returns Hydration script as a string
|
|
46
|
+
*/
|
|
47
|
+
export function getHydrationScript(): string {
|
|
48
|
+
return `
|
|
49
|
+
import { createApp } from 'vue';
|
|
50
|
+
|
|
51
|
+
// Auto-hydrate all Vue islands
|
|
52
|
+
document.querySelectorAll('[data-framework="vue"]').forEach(async (el) => {
|
|
53
|
+
const src = el.getAttribute('data-src');
|
|
54
|
+
const propsJson = el.getAttribute('data-props') || '{}';
|
|
55
|
+
const condition = el.getAttribute('data-condition') || 'on:client';
|
|
56
|
+
|
|
57
|
+
// Check hydration condition
|
|
58
|
+
if (!shouldHydrate(el, condition)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Dynamic import the component
|
|
64
|
+
const module = await import(src);
|
|
65
|
+
const Component = module.default || module;
|
|
66
|
+
|
|
67
|
+
// Parse props
|
|
68
|
+
const props = JSON.parse(propsJson);
|
|
69
|
+
|
|
70
|
+
// Create and mount Vue app
|
|
71
|
+
const app = createApp(Component, props);
|
|
72
|
+
app.mount(el, true);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Failed to hydrate Vue island:', error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function shouldHydrate(element, condition) {
|
|
79
|
+
if (!condition || condition === 'on:client') {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (condition === 'on:visible') {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const observer = new IntersectionObserver((entries) => {
|
|
86
|
+
if (entries[0].isIntersecting) {
|
|
87
|
+
observer.disconnect();
|
|
88
|
+
resolve(true);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
observer.observe(element);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (condition === 'on:interaction') {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const events = ['click', 'mouseenter', 'focusin', 'touchstart'];
|
|
98
|
+
const handler = () => {
|
|
99
|
+
events.forEach(e => element.removeEventListener(e, handler));
|
|
100
|
+
resolve(true);
|
|
101
|
+
};
|
|
102
|
+
events.forEach(e => element.addEventListener(e, handler, { once: true }));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (condition === 'on:idle') {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
if ('requestIdleCallback' in window) {
|
|
109
|
+
requestIdleCallback(() => resolve(true));
|
|
110
|
+
} else {
|
|
111
|
+
setTimeout(() => resolve(true), 200);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (condition.startsWith('media:')) {
|
|
117
|
+
const query = condition.slice(6);
|
|
118
|
+
return window.matchMedia(query).matches;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
}
|
package/client/index.ts
ADDED
package/mod.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Integration for Avalon
|
|
3
|
+
*
|
|
4
|
+
* Provides Vue 3 support with SSR, hydration, and scoped CSS extraction.
|
|
5
|
+
* This integration enables Vue Single File Components (.vue) to work
|
|
6
|
+
* seamlessly with Avalon's islands architecture.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Plugin } from 'vite';
|
|
10
|
+
import type { Integration, IntegrationConfig } from '../core/types.ts';
|
|
11
|
+
import { render } from './server/renderer.ts';
|
|
12
|
+
import { getHydrationScript } from './client/hydration.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Vue integration instance
|
|
16
|
+
*
|
|
17
|
+
* Implements the standard Integration interface for Vue components.
|
|
18
|
+
*/
|
|
19
|
+
export const vueIntegration: Integration = {
|
|
20
|
+
name: 'vue',
|
|
21
|
+
version: '0.1.0',
|
|
22
|
+
|
|
23
|
+
render,
|
|
24
|
+
getHydrationScript,
|
|
25
|
+
|
|
26
|
+
config(): IntegrationConfig {
|
|
27
|
+
return {
|
|
28
|
+
name: 'vue',
|
|
29
|
+
fileExtensions: ['.vue'],
|
|
30
|
+
jsxImportSources: [],
|
|
31
|
+
detectionPatterns: {
|
|
32
|
+
imports: [/^vue$/, /^vue\//, /from\s+['"]vue['"]/],
|
|
33
|
+
content: [/<template>/, /<script.*setup>/, /\bdefineComponent\b/, /\bref\b/, /\breactive\b/, /\bcomputed\b/],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Provides the @vitejs/plugin-vue Vite plugin with avalon-island custom element configuration.
|
|
40
|
+
* This allows Vue components to work seamlessly with Avalon's islands architecture.
|
|
41
|
+
*/
|
|
42
|
+
async vitePlugin(): Promise<Plugin | Plugin[]> {
|
|
43
|
+
const { default: vue } = await import('@vitejs/plugin-vue');
|
|
44
|
+
return vue({
|
|
45
|
+
template: {
|
|
46
|
+
compilerOptions: {
|
|
47
|
+
// Treat avalon-island as a custom element so Vue doesn't try to resolve it
|
|
48
|
+
isCustomElement: (tag: string) => tag === 'avalon-island',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Re-export public API
|
|
56
|
+
export { render } from './server/renderer.ts';
|
|
57
|
+
export { hydrate, getHydrationScript } from './client/hydration.ts';
|
|
58
|
+
export { extractCSS, applyScopedCSS, generateScopeId } from './server/css-extractor.ts';
|
|
59
|
+
export type * from './types.ts';
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@useavalon/vue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vue integration for Avalon islands architecture",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/useAvalon/Avalon.git",
|
|
10
|
+
"directory": "packages/integrations/vue"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://useavalon.dev/docs/frameworks/vue",
|
|
13
|
+
"keywords": ["avalon", "islands", "vue", "ssr", "hydration"],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./mod.ts",
|
|
16
|
+
"./server": "./server/renderer.ts",
|
|
17
|
+
"./client": "./client/index.ts",
|
|
18
|
+
"./types": "./types.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": ["**/*.ts", "**/*.tsx", "README.md"],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"vue": "3.5.21",
|
|
23
|
+
"vite": "8.0.0",
|
|
24
|
+
"@vitejs/plugin-vue": "^5.2.4"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue CSS Extractor
|
|
3
|
+
*
|
|
4
|
+
* Utilities for extracting and processing CSS from Vue Single File Components.
|
|
5
|
+
* Handles both scoped and global styles with proper attribute application.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import type { CSSExtractionOptions, StyleBlock } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract CSS from Vue Single File Component
|
|
13
|
+
*
|
|
14
|
+
* Parses <style> blocks from .vue files and applies scoping if needed.
|
|
15
|
+
* Supports both scoped and global styles.
|
|
16
|
+
*
|
|
17
|
+
* @param src - Path to the Vue component file
|
|
18
|
+
* @param options - CSS extraction options
|
|
19
|
+
* @returns Extracted and processed CSS string
|
|
20
|
+
*/
|
|
21
|
+
export async function extractCSS(
|
|
22
|
+
src: string,
|
|
23
|
+
options: CSSExtractionOptions = {},
|
|
24
|
+
) {
|
|
25
|
+
// Try different path variations to find the Vue file
|
|
26
|
+
const pathVariations = [
|
|
27
|
+
// Standard framework paths
|
|
28
|
+
src.startsWith("/") ? `src${src}` : src,
|
|
29
|
+
src.replace("/islands/", "/src/islands/"),
|
|
30
|
+
// Remove leading slash variations
|
|
31
|
+
src.startsWith("/") ? src.substring(1) : src,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
let vueContent = "";
|
|
35
|
+
|
|
36
|
+
for (const path of pathVariations) {
|
|
37
|
+
try {
|
|
38
|
+
vueContent = await readFile(path, "utf-8");
|
|
39
|
+
break;
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!vueContent) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Vue file not found in any of the attempted paths: ${
|
|
48
|
+
pathVariations.join(", ")
|
|
49
|
+
}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extract all style blocks
|
|
54
|
+
const styleBlocks = extractStyleBlocks(vueContent);
|
|
55
|
+
|
|
56
|
+
if (styleBlocks.length === 0) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Generate scope ID if not provided
|
|
61
|
+
const scopeId = options.scopeId || generateScopeId(src);
|
|
62
|
+
|
|
63
|
+
// Process each style block
|
|
64
|
+
let componentCSS = "";
|
|
65
|
+
|
|
66
|
+
for (const block of styleBlocks) {
|
|
67
|
+
if (block.scoped) {
|
|
68
|
+
componentCSS += applyScopedCSS(block.content, scopeId);
|
|
69
|
+
} else {
|
|
70
|
+
componentCSS += block.content;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return componentCSS;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Apply scoped CSS transformation
|
|
79
|
+
*
|
|
80
|
+
* Adds scope attributes to CSS selectors for Vue's scoped styles.
|
|
81
|
+
* Skips at-rules like @media, @keyframes, etc.
|
|
82
|
+
*
|
|
83
|
+
* @param css - CSS content to scope
|
|
84
|
+
* @param scopeId - Scope identifier (e.g., "data-v-abc123")
|
|
85
|
+
* @returns Scoped CSS string
|
|
86
|
+
*/
|
|
87
|
+
export function applyScopedCSS(css: string, scopeId: string) {
|
|
88
|
+
return css.replace(/([^{}]+){/g, (match, selector) => {
|
|
89
|
+
const trimmedSelector = selector.trim();
|
|
90
|
+
|
|
91
|
+
// Skip at-rules (@media, @keyframes, @supports, etc.)
|
|
92
|
+
if (trimmedSelector.startsWith("@")) {
|
|
93
|
+
return match;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Add scope attribute to each selector
|
|
97
|
+
// Handle multiple selectors separated by commas
|
|
98
|
+
const scopedSelectors = trimmedSelector
|
|
99
|
+
.split(",")
|
|
100
|
+
.map((s: string) => `${s.trim()}[${scopeId}]`)
|
|
101
|
+
.join(", ");
|
|
102
|
+
|
|
103
|
+
return `${scopedSelectors} {`;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract style blocks from Vue SFC content
|
|
109
|
+
*
|
|
110
|
+
* Parses <style> tags and extracts their content and attributes.
|
|
111
|
+
*
|
|
112
|
+
* @param vueContent - Vue SFC file content
|
|
113
|
+
* @returns Array of style blocks with metadata
|
|
114
|
+
*/
|
|
115
|
+
function extractStyleBlocks(vueContent: string) {
|
|
116
|
+
const styleRegex = /<style([^>]*)>([\s\S]*?)<\/style>/gi;
|
|
117
|
+
const blocks: StyleBlock[] = [];
|
|
118
|
+
let match;
|
|
119
|
+
|
|
120
|
+
while ((match = styleRegex.exec(vueContent)) !== null) {
|
|
121
|
+
const attributes = match[1];
|
|
122
|
+
const content = match[2].trim();
|
|
123
|
+
const isScoped = attributes.includes("scoped");
|
|
124
|
+
|
|
125
|
+
blocks.push({
|
|
126
|
+
content,
|
|
127
|
+
scoped: isScoped,
|
|
128
|
+
attributes,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return blocks;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Generate a consistent scope ID for a component
|
|
137
|
+
*
|
|
138
|
+
* Creates a deterministic scope ID based on the component path.
|
|
139
|
+
* Format: "data-v-{hash}" where hash is derived from the path.
|
|
140
|
+
*
|
|
141
|
+
* @param src - Component source path
|
|
142
|
+
* @returns Scope ID string
|
|
143
|
+
*/
|
|
144
|
+
export function generateScopeId(src: string) {
|
|
145
|
+
// Remove special characters and convert to lowercase for consistency
|
|
146
|
+
const hash = src.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
|
147
|
+
return `data-v-${hash}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Apply scope attributes to HTML elements
|
|
152
|
+
*
|
|
153
|
+
* Adds scope attributes to HTML tags for matching with scoped CSS.
|
|
154
|
+
* Skips closing tags and self-closing tags.
|
|
155
|
+
*
|
|
156
|
+
* @param html - HTML string to process
|
|
157
|
+
* @param scopeId - Scope identifier
|
|
158
|
+
* @returns HTML with scope attributes
|
|
159
|
+
*/
|
|
160
|
+
export function applyScopeToHTML(html: string, scopeId: string) {
|
|
161
|
+
return html.replace(/<([a-zA-Z][^>]*?)>/g, (match, tagContent) => {
|
|
162
|
+
// Skip closing tags
|
|
163
|
+
if (tagContent.startsWith("/")) {
|
|
164
|
+
return match;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Skip self-closing tags (already have /)
|
|
168
|
+
if (tagContent.endsWith("/")) {
|
|
169
|
+
return match;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Add scope attribute
|
|
173
|
+
return `<${tagContent} ${scopeId}>`;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Server Renderer
|
|
3
|
+
*
|
|
4
|
+
* Provides server-side rendering capabilities for Vue components.
|
|
5
|
+
* Uses Vue's official SSR API with proper hydration support.
|
|
6
|
+
*
|
|
7
|
+
* Migrated from src/islands/renderers/vue-renderer.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createSSRApp } from 'vue';
|
|
11
|
+
import { renderToString as vueRenderToString } from 'vue/server-renderer';
|
|
12
|
+
import type { RenderParams, RenderResult } from '../../core/types.ts';
|
|
13
|
+
import { extractCSS, generateScopeId, applyScopeToHTML } from './css-extractor.ts';
|
|
14
|
+
import { toImportSpecifier } from '../../core/utils.ts';
|
|
15
|
+
import { resolveIslandPath } from '../../../avalon/src/islands/framework-detection.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render a Vue component to HTML string with SSR
|
|
19
|
+
*
|
|
20
|
+
* Creates a Vue SSR app instance and renders it to string.
|
|
21
|
+
* Extracts and applies scoped CSS from the component.
|
|
22
|
+
*
|
|
23
|
+
* Based on Vue.js SSR documentation and Astro's Vue integration:
|
|
24
|
+
* - Creates proper SSR app with createSSRApp
|
|
25
|
+
* - Wraps SSR HTML in a div with data-server-rendered="true"
|
|
26
|
+
* - Uses consistent container structure for client hydration
|
|
27
|
+
*
|
|
28
|
+
* @param params - Render parameters including component, props, and source path
|
|
29
|
+
* @returns Render result with HTML, CSS, and hydration data
|
|
30
|
+
*/
|
|
31
|
+
export async function render(params: RenderParams): Promise<RenderResult> {
|
|
32
|
+
const { component: _component, props = {}, src, condition = 'on:client', ssrOnly = false } = params;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const VueComponent = await loadComponent(src);
|
|
36
|
+
|
|
37
|
+
const app = createSSRApp(VueComponent as any, props);
|
|
38
|
+
const ssrHtml = await vueRenderToString(app);
|
|
39
|
+
|
|
40
|
+
let componentCSS = '';
|
|
41
|
+
let scopeId = '';
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
scopeId = generateScopeId(src);
|
|
45
|
+
componentCSS = await extractCSS(src, { scopeId });
|
|
46
|
+
} catch {
|
|
47
|
+
// CSS extraction failed, continue without CSS
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let finalHtml = ssrHtml;
|
|
51
|
+
if (componentCSS) {
|
|
52
|
+
finalHtml = applyScopeToHTML(ssrHtml, scopeId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
html: finalHtml,
|
|
57
|
+
css: componentCSS || undefined,
|
|
58
|
+
scopeId: scopeId || undefined,
|
|
59
|
+
hydrationData: { src, props, framework: 'vue', condition, ssrOnly },
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new Error(`Vue SSR rendering failed: ${error}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load a Vue component module
|
|
68
|
+
*
|
|
69
|
+
* Handles both development (via Vite) and production (pre-built) scenarios.
|
|
70
|
+
*
|
|
71
|
+
* @param src - Component source path
|
|
72
|
+
* @returns Vue component module
|
|
73
|
+
*/
|
|
74
|
+
async function loadComponent(src: string) {
|
|
75
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
76
|
+
|
|
77
|
+
if (isDev && (globalThis as any).__viteDevServer) {
|
|
78
|
+
// Development: use Vite's SSR module loading
|
|
79
|
+
|
|
80
|
+
const viteServer = (globalThis as any).__viteDevServer;
|
|
81
|
+
const resolvedPath = await resolveIslandPath(src);
|
|
82
|
+
const module = await viteServer.ssrLoadModule(resolvedPath);
|
|
83
|
+
return module.default || module;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Production: load from build output
|
|
87
|
+
const ssrPath = src.replace('/islands/', '/dist/ssr/islands/').replace('.vue', '.js');
|
|
88
|
+
|
|
89
|
+
const module = await import(
|
|
90
|
+
/* @vite-ignore */
|
|
91
|
+
toImportSpecifier(ssrPath)
|
|
92
|
+
);
|
|
93
|
+
return module.default || module;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get component metadata for debugging
|
|
98
|
+
*
|
|
99
|
+
* @param component - Vue component
|
|
100
|
+
* @returns Component metadata object
|
|
101
|
+
*/
|
|
102
|
+
export function getComponentMetadata(component: unknown) {
|
|
103
|
+
if (typeof component === 'object' && component !== null) {
|
|
104
|
+
return {
|
|
105
|
+
name: (component as { name?: string }).name || 'Anonymous',
|
|
106
|
+
type: 'component',
|
|
107
|
+
hasSetup: 'setup' in component,
|
|
108
|
+
hasTemplate: 'template' in component,
|
|
109
|
+
hasRender: 'render' in component,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
type: typeof component,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Integration Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions specific to the Vue integration package.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RenderParams, RenderResult } from "../core/types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Vue-specific render parameters
|
|
11
|
+
*/
|
|
12
|
+
export interface VueRenderParams extends RenderParams {
|
|
13
|
+
/**
|
|
14
|
+
* Vue app context for SSR
|
|
15
|
+
*/
|
|
16
|
+
context?: Map<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Vue-specific render result with CSS extraction
|
|
21
|
+
*/
|
|
22
|
+
export interface VueRenderResult extends RenderResult {
|
|
23
|
+
/**
|
|
24
|
+
* Extracted CSS from Vue SFC <style> blocks
|
|
25
|
+
*/
|
|
26
|
+
css?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Head content (e.g., meta tags, title)
|
|
30
|
+
*/
|
|
31
|
+
head?: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Scope ID for scoped styles
|
|
35
|
+
*/
|
|
36
|
+
scopeId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Vue component module structure
|
|
41
|
+
*/
|
|
42
|
+
export interface VueComponentModule {
|
|
43
|
+
default?: unknown;
|
|
44
|
+
[key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* CSS extraction options
|
|
49
|
+
*/
|
|
50
|
+
export interface CSSExtractionOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Whether to apply scoping to CSS
|
|
53
|
+
*/
|
|
54
|
+
scoped?: boolean;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Custom scope ID (generated if not provided)
|
|
58
|
+
*/
|
|
59
|
+
scopeId?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Style block metadata from Vue SFC
|
|
64
|
+
*/
|
|
65
|
+
export interface StyleBlock {
|
|
66
|
+
/**
|
|
67
|
+
* CSS content
|
|
68
|
+
*/
|
|
69
|
+
content: string;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether the style is scoped
|
|
73
|
+
*/
|
|
74
|
+
scoped: boolean;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Style attributes (e.g., lang, scoped)
|
|
78
|
+
*/
|
|
79
|
+
attributes: string;
|
|
80
|
+
}
|