@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 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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Vue Integration Client Entrypoint
3
+ *
4
+ * Main export for client-side Vue integration functionality.
5
+ */
6
+
7
+ export { hydrate, getHydrationScript } from "./hydration.ts";
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
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ root: import.meta.dirname,
6
+ include: ['__tests__/**/*.test.ts'],
7
+ server: {
8
+ deps: {
9
+ inline: ['zod'],
10
+ },
11
+ },
12
+ },
13
+ });