aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/vue/src
diff options
context:
space:
mode:
authorGravatar github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 2025-06-05 14:25:23 +0000
committerGravatar github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 2025-06-05 14:25:23 +0000
commite586d7d704d475afe3373a1de6ae20d504f79d6d (patch)
tree7e3fa24807cebd48a86bd40f866d792181191ee9 /packages/integrations/vue/src
downloadastro-e586d7d704d475afe3373a1de6ae20d504f79d6d.tar.gz
astro-e586d7d704d475afe3373a1de6ae20d504f79d6d.tar.zst
astro-e586d7d704d475afe3373a1de6ae20d504f79d6d.zip
Sync from a8e1c0a7402940e0fc5beef669522b315052df1blatest
Diffstat (limited to 'packages/integrations/vue/src')
-rw-r--r--packages/integrations/vue/src/client.ts67
-rw-r--r--packages/integrations/vue/src/context.ts26
-rw-r--r--packages/integrations/vue/src/editor.cts65
-rw-r--r--packages/integrations/vue/src/index.ts181
-rw-r--r--packages/integrations/vue/src/server.ts52
-rw-r--r--packages/integrations/vue/src/static-html.ts33
-rw-r--r--packages/integrations/vue/src/types.ts4
7 files changed, 428 insertions, 0 deletions
diff --git a/packages/integrations/vue/src/client.ts b/packages/integrations/vue/src/client.ts
new file mode 100644
index 000000000..8f02d534e
--- /dev/null
+++ b/packages/integrations/vue/src/client.ts
@@ -0,0 +1,67 @@
+import { setup } from 'virtual:@astrojs/vue/app';
+import { Suspense, createApp, createSSRApp, h } from 'vue';
+import StaticHtml from './static-html.js';
+
+// keep track of already initialized apps, so we don't hydrate again for view transitions
+let appMap = new WeakMap<
+ HTMLElement,
+ { props: Record<string, any>; slots: Record<string, any>; component?: any }
+>();
+
+export default (element: HTMLElement) =>
+ async (
+ Component: any,
+ props: Record<string, any>,
+ slotted: Record<string, any>,
+ { client }: Record<string, string>,
+ ) => {
+ if (!element.hasAttribute('ssr')) return;
+
+ // Expose name on host component for Vue devtools
+ const name = Component.name ? `${Component.name} Host` : undefined;
+ const slots: Record<string, any> = {};
+ for (const [key, value] of Object.entries(slotted)) {
+ slots[key] = () => h(StaticHtml, { value, name: key === 'default' ? undefined : key });
+ }
+
+ const isHydrate = client !== 'only';
+ const bootstrap = isHydrate ? createSSRApp : createApp;
+
+ // keep a reference to the app, props and slots so we can update a running instance later
+ let appInstance = appMap.get(element);
+
+ if (!appInstance) {
+ appInstance = {
+ props,
+ slots,
+ };
+ const app = bootstrap({
+ name,
+ render() {
+ // At this point, appInstance has been set so it's safe to use a non-null assertion
+ let content = h(Component, appInstance!.props, appInstance!.slots);
+ appInstance!.component = this;
+ // related to https://github.com/withastro/astro/issues/6549
+ // if the component is async, wrap it in a Suspense component
+ if (isAsync(Component.setup)) {
+ content = h(Suspense, null, content);
+ }
+ return content;
+ },
+ });
+ app.config.idPrefix = element.getAttribute('prefix') ?? undefined;
+ await setup(app);
+ app.mount(element, isHydrate);
+ appMap.set(element, appInstance);
+ element.addEventListener('astro:unmount', () => app.unmount(), { once: true });
+ } else {
+ appInstance.props = props;
+ appInstance.slots = slots;
+ appInstance.component.$forceUpdate();
+ }
+ };
+
+function isAsync(fn: () => any) {
+ const constructor = fn?.constructor;
+ return constructor && constructor.name === 'AsyncFunction';
+}
diff --git a/packages/integrations/vue/src/context.ts b/packages/integrations/vue/src/context.ts
new file mode 100644
index 000000000..833755044
--- /dev/null
+++ b/packages/integrations/vue/src/context.ts
@@ -0,0 +1,26 @@
+import type { SSRResult } from 'astro';
+
+const contexts = new WeakMap<SSRResult, { currentIndex: number; readonly id: string }>();
+
+const ID_PREFIX = 's';
+
+function getContext(rendererContextResult: SSRResult) {
+ if (contexts.has(rendererContextResult)) {
+ return contexts.get(rendererContextResult);
+ }
+ const ctx = {
+ currentIndex: 0,
+ get id() {
+ return ID_PREFIX + this.currentIndex.toString();
+ },
+ };
+ contexts.set(rendererContextResult, ctx);
+ return ctx;
+}
+
+export function incrementId(rendererContextResult: SSRResult) {
+ const ctx = getContext(rendererContextResult)!;
+ const id = ctx.id;
+ ctx.currentIndex++;
+ return id;
+}
diff --git a/packages/integrations/vue/src/editor.cts b/packages/integrations/vue/src/editor.cts
new file mode 100644
index 000000000..96d8f8e71
--- /dev/null
+++ b/packages/integrations/vue/src/editor.cts
@@ -0,0 +1,65 @@
+import { parse } from '@vue/compiler-sfc';
+
+export function toTSX(code: string, className: string): string {
+ let result = `export default function ${className}__AstroComponent_(_props: Record<string, any>): any {}`;
+
+ // NOTE: As you can expect, using regexes for this is not exactly the most reliable way of doing things
+ // However, I couldn't figure out a way to do it using Vue's compiler, I tried looking at how Volar does it, but I
+ // didn't really understand everything happening there and it seemed to be pretty Volar-specific. I do believe
+ // someone more knowledgeable on Vue's internals could figure it out, but since this solution is good enough for most
+ // Vue components (and it's an improvement over, well, nothing), it's alright, I think
+ try {
+ const parsedResult = parse(code);
+
+ if (parsedResult.errors.length > 0) {
+ return `
+ let ${className}__AstroComponent_: Error
+ export default ${className}__AstroComponent_
+ `;
+ }
+
+ // Vue supports 2 type of script blocks: setup and non-setup
+ const regularScriptBlockContent = parsedResult.descriptor.script?.content ?? '';
+ const { scriptSetup } = parsedResult.descriptor;
+
+ if (scriptSetup) {
+ const codeWithoutComments = scriptSetup.content.replace(/\/\/.*|\/\*[\s\S]*?\*\//g, '');
+ const definePropsType = /defineProps<([\s\S]+?)>\s?\(\)/.exec(codeWithoutComments);
+ const propsGeneric = scriptSetup.attrs.generic;
+ const propsGenericType = propsGeneric ? `<${propsGeneric}>` : '';
+
+ if (definePropsType) {
+ result = `
+ ${regularScriptBlockContent}
+ ${scriptSetup.content}
+
+ export default function ${className}__AstroComponent_${propsGenericType}(_props: ${definePropsType[1]}): any {
+ <div></div>
+ }
+ `;
+ } else {
+ // TODO. Find a way to support generics when using defineProps without passing explicit types.
+ // Right now something like this `defineProps({ prop: { type: Array as PropType<T[]> } })`
+ // won't be correctly typed in Astro.
+ const defineProps = /defineProps\([\s\S]+?\)/.exec(codeWithoutComments);
+ if (defineProps) {
+ result = `
+ import { defineProps } from 'vue';
+
+ ${regularScriptBlockContent}
+
+ const Props = ${defineProps[0]}
+
+ export default function ${className}__AstroComponent_${propsGenericType}(_props: typeof Props): any {
+ <div></div>
+ }
+ `;
+ }
+ }
+ }
+ } catch {
+ return result;
+ }
+
+ return result;
+}
diff --git a/packages/integrations/vue/src/index.ts b/packages/integrations/vue/src/index.ts
new file mode 100644
index 000000000..66e5d269c
--- /dev/null
+++ b/packages/integrations/vue/src/index.ts
@@ -0,0 +1,181 @@
+import path from 'node:path';
+import type { Options as VueOptions } from '@vitejs/plugin-vue';
+import vue from '@vitejs/plugin-vue';
+import type { Options as VueJsxOptions } from '@vitejs/plugin-vue-jsx';
+import { MagicString } from '@vue/compiler-sfc';
+import type { AstroIntegration, AstroRenderer, ContainerRenderer, HookParameters } from 'astro';
+import type { Plugin, UserConfig } from 'vite';
+import type { VitePluginVueDevToolsOptions } from 'vite-plugin-vue-devtools';
+
+const VIRTUAL_MODULE_ID = 'virtual:@astrojs/vue/app';
+const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
+
+interface Options extends VueOptions {
+ jsx?: boolean | VueJsxOptions;
+ appEntrypoint?: string;
+ devtools?: boolean | Omit<VitePluginVueDevToolsOptions, 'appendTo'>;
+}
+
+function getRenderer(): AstroRenderer {
+ return {
+ name: '@astrojs/vue',
+ clientEntrypoint: '@astrojs/vue/client.js',
+ serverEntrypoint: '@astrojs/vue/server.js',
+ };
+}
+
+function getJsxRenderer(): AstroRenderer {
+ return {
+ name: '@astrojs/vue (jsx)',
+ clientEntrypoint: '@astrojs/vue/client.js',
+ serverEntrypoint: '@astrojs/vue/server.js',
+ };
+}
+
+export function getContainerRenderer(): ContainerRenderer {
+ return {
+ name: '@astrojs/vue',
+ serverEntrypoint: '@astrojs/vue/server.js',
+ };
+}
+
+function virtualAppEntrypoint(options?: Options): Plugin {
+ let isBuild: boolean;
+ let root: string;
+ let appEntrypoint: string | undefined;
+
+ return {
+ name: '@astrojs/vue/virtual-app',
+ config(_, { command }) {
+ isBuild = command === 'build';
+ },
+ configResolved(config) {
+ root = config.root;
+ if (options?.appEntrypoint) {
+ appEntrypoint = options.appEntrypoint.startsWith('.')
+ ? path.resolve(root, options.appEntrypoint)
+ : options.appEntrypoint;
+ }
+ },
+ resolveId(id: string) {
+ if (id == VIRTUAL_MODULE_ID) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ },
+ load(id: string) {
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
+ if (appEntrypoint) {
+ return `\
+import * as mod from ${JSON.stringify(appEntrypoint)};
+
+export const setup = async (app) => {
+ if ('default' in mod) {
+ await mod.default(app);
+ } else {
+ ${
+ !isBuild
+ ? `console.warn("[@astrojs/vue] appEntrypoint \`" + ${JSON.stringify(
+ appEntrypoint,
+ )} + "\` does not export a default function. Check out https://docs.astro.build/en/guides/integrations-guide/vue/#appentrypoint.");`
+ : ''
+ }
+ }
+}`;
+ }
+ return `export const setup = () => {};`;
+ }
+ },
+ // Ensure that Vue components reference appEntrypoint directly
+ // This allows Astro to associate global styles imported in this file
+ // with the pages they should be injected to
+ transform(code, id) {
+ if (!appEntrypoint) return;
+ if (id.endsWith('.vue')) {
+ const s = new MagicString(code);
+ s.prepend(`import ${JSON.stringify(appEntrypoint)};\n`);
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: 'boundary' }),
+ };
+ }
+ },
+ };
+}
+
+async function getViteConfiguration(
+ command: HookParameters<'astro:config:setup'>['command'],
+ options?: Options,
+): Promise<UserConfig> {
+ const vueOptions = {
+ ...options,
+ template: {
+ ...options?.template,
+ transformAssetUrls: false,
+ },
+ } satisfies VueOptions;
+
+ // The vue vite plugin may not manage to resolve it automatically
+ vueOptions.compiler ??= await import('vue/compiler-sfc');
+
+ const config: UserConfig = {
+ optimizeDeps: {
+ // We add `vue` here as `@vitejs/plugin-vue` doesn't add it and we want to prevent
+ // re-optimization if the `vue` import is only encountered later.
+ include: ['@astrojs/vue/client.js', 'vue'],
+ exclude: ['@astrojs/vue/server.js', VIRTUAL_MODULE_ID],
+ },
+ plugins: [vue(vueOptions), virtualAppEntrypoint(vueOptions)],
+ ssr: {
+ noExternal: ['vuetify', 'vueperslides', 'primevue'],
+ },
+ };
+
+ if (options?.jsx) {
+ const vueJsx = (await import('@vitejs/plugin-vue-jsx')).default;
+ const jsxOptions = typeof options.jsx === 'object' ? options.jsx : undefined;
+ config.plugins?.push(vueJsx(jsxOptions));
+ }
+
+ if (command === 'dev' && options?.devtools) {
+ const vueDevTools = (await import('vite-plugin-vue-devtools')).default;
+ const devToolsOptions = typeof options.devtools === 'object' ? options.devtools : {};
+ config.plugins?.push(
+ vueDevTools({
+ ...devToolsOptions,
+ appendTo: VIRTUAL_MODULE_ID,
+ }),
+ );
+ }
+
+ return config;
+}
+
+export default function (options?: Options): AstroIntegration {
+ return {
+ name: '@astrojs/vue',
+ hooks: {
+ 'astro:config:setup': async ({ addRenderer, updateConfig, command }) => {
+ addRenderer(getRenderer());
+ if (options?.jsx) {
+ addRenderer(getJsxRenderer());
+ }
+ updateConfig({ vite: await getViteConfiguration(command, options) });
+ },
+ 'astro:config:done': ({ logger, config }) => {
+ if (!options?.jsx) return;
+
+ const knownJsxRenderers = ['@astrojs/react', '@astrojs/preact', '@astrojs/solid-js'];
+ const enabledKnownJsxRenderers = config.integrations.filter((renderer) =>
+ knownJsxRenderers.includes(renderer.name),
+ );
+
+ // This error can only be thrown from here since Vue is an optional JSX renderer
+ if (enabledKnownJsxRenderers.length > 1 && !options?.include && !options?.exclude) {
+ logger.warn(
+ 'More than one JSX renderer is enabled. This will lead to unexpected behavior unless you set the `include` or `exclude` option. See https://docs.astro.build/en/guides/integrations-guide/solid-js/#combining-multiple-jsx-frameworks for more information.',
+ );
+ }
+ },
+ },
+ };
+}
diff --git a/packages/integrations/vue/src/server.ts b/packages/integrations/vue/src/server.ts
new file mode 100644
index 000000000..1aa104b0d
--- /dev/null
+++ b/packages/integrations/vue/src/server.ts
@@ -0,0 +1,52 @@
+import { setup } from 'virtual:@astrojs/vue/app';
+import type { AstroComponentMetadata, NamedSSRLoadedRendererValue } from 'astro';
+import { createSSRApp, h } from 'vue';
+import { renderToString } from 'vue/server-renderer';
+import { incrementId } from './context.js';
+import StaticHtml from './static-html.js';
+import type { RendererContext } from './types.js';
+
+async function check(Component: any) {
+ return !!Component['ssrRender'] || !!Component['__ssrInlineRender'];
+}
+
+async function renderToStaticMarkup(
+ this: RendererContext,
+ Component: any,
+ inputProps: Record<string, any>,
+ slotted: Record<string, any>,
+ metadata?: AstroComponentMetadata,
+) {
+ let prefix;
+ if (this && this.result) {
+ prefix = incrementId(this.result);
+ }
+ const attrs: Record<string, any> = { prefix };
+
+ const slots: Record<string, any> = {};
+ const props = { ...inputProps };
+ delete props.slot;
+ for (const [key, value] of Object.entries(slotted)) {
+ slots[key] = () =>
+ h(StaticHtml, {
+ value,
+ name: key === 'default' ? undefined : key,
+ // Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
+ hydrate: metadata?.astroStaticSlot ? !!metadata.hydrate : true,
+ });
+ }
+ const app = createSSRApp({ render: () => h(Component, props, slots) });
+ app.config.idPrefix = prefix;
+ await setup(app);
+ const html = await renderToString(app);
+ return { html, attrs };
+}
+
+const renderer: NamedSSRLoadedRendererValue = {
+ name: '@astrojs/vue',
+ check,
+ renderToStaticMarkup,
+ supportsAstroStaticSlot: true,
+};
+
+export default renderer;
diff --git a/packages/integrations/vue/src/static-html.ts b/packages/integrations/vue/src/static-html.ts
new file mode 100644
index 000000000..689b56a70
--- /dev/null
+++ b/packages/integrations/vue/src/static-html.ts
@@ -0,0 +1,33 @@
+import { defineComponent, h } from 'vue';
+
+/**
+ * Astro passes `children` as a string of HTML, so we need
+ * a wrapper `div` to render that content as VNodes.
+ *
+ * This is the Vue + JSX equivalent of using `<div v-html="value" />`
+ */
+const StaticHtml = defineComponent({
+ props: {
+ value: String,
+ name: String,
+ hydrate: {
+ type: Boolean,
+ default: true,
+ },
+ },
+ setup({ name, value, hydrate }) {
+ if (!value) return () => null;
+ let tagName = hydrate ? 'astro-slot' : 'astro-static-slot';
+ return () => h(tagName, { name, innerHTML: value });
+ },
+});
+
+/**
+ * Other frameworks have `shouldComponentUpdate` in order to signal
+ * that this subtree is entirely static and will not be updated
+ *
+ * Fortunately, Vue is smart enough to figure that out without any
+ * help from us, so this just works out of the box!
+ */
+
+export default StaticHtml;
diff --git a/packages/integrations/vue/src/types.ts b/packages/integrations/vue/src/types.ts
new file mode 100644
index 000000000..5dff5b0b4
--- /dev/null
+++ b/packages/integrations/vue/src/types.ts
@@ -0,0 +1,4 @@
+import type { SSRResult } from 'astro';
+export type RendererContext = {
+ result: SSRResult;
+};