diff --git a/.changeset/dry-moose-join.md b/.changeset/dry-moose-join.md new file mode 100644 index 000000000..b90fac4ee --- /dev/null +++ b/.changeset/dry-moose-join.md @@ -0,0 +1,29 @@ +--- +'@astrojs/vue': minor +--- + +Add support for the `appEntrypoint` option, which accepts a root-relative path to an app entrypoint. The default export of this file should be a function that accepts a Vue `App` instance prior to rendering. This opens up the ability to extend the `App` instance with [custom Vue plugins](https://vuejs.org/guide/reusability/plugins.html). + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import vue from '@astrojs/vue'; + +export default defineConfig({ + integrations: [ + vue({ + appEntrypoint: '/src/pages/_app' + }) + ] +}) +``` + +```js +// src/pages/_app.ts +import type { App } from 'vue'; +import i18nPlugin from '../plugins/i18n' + +export default function setup(app: App) { + app.use(i18nPlugin, { /* options */ }) +} +``` diff --git a/packages/integrations/vue/README.md b/packages/integrations/vue/README.md index 75c861ce3..c1c199b81 100644 --- a/packages/integrations/vue/README.md +++ b/packages/integrations/vue/README.md @@ -95,6 +95,36 @@ export default { } ``` +### appEntrypoint + +You can extend the Vue `app` instance setting the `appEntrypoint` option to a root-relative import specifier (for example, `appEntrypoint: "/src/pages/_app"`). + +The default export of this file should be a function that accepts a Vue `App` instance prior to rendering, allowing the use of [custom Vue plugins](https://vuejs.org/guide/reusability/plugins.html), `app.use`, and other customizations for advanced use cases. + +__`astro.config.mjs`__ + +```js +import { defineConfig } from 'astro/config'; +import vue from '@astrojs/vue'; + +export default defineConfig({ + integrations: [ + vue({ appEntrypoint: '/src/pages/_app' }) + ], +}); +``` + +__`src/pages/_app.ts`__ + +```js +import type { App } from 'vue'; +import i18nPlugin from 'my-vue-i18n-plugin'; + +export default (app: App) => { + app.use(i18nPlugin); +} +``` + ### jsx You can use Vue JSX by setting `jsx: true`. diff --git a/packages/integrations/vue/client.js b/packages/integrations/vue/client.js index 648a69658..faf1ac63a 100644 --- a/packages/integrations/vue/client.js +++ b/packages/integrations/vue/client.js @@ -1,8 +1,9 @@ import { h, createSSRApp, createApp } from 'vue'; +import { setup } from 'virtual:@astrojs/vue/app' import StaticHtml from './static-html.js'; export default (element) => - (Component, props, slotted, { client }) => { + async (Component, props, slotted, { client }) => { delete props['class']; if (!element.hasAttribute('ssr')) return; @@ -14,9 +15,11 @@ export default (element) => } if (client === 'only') { const app = createApp({ name, render: () => h(Component, props, slots) }); + await setup(app); app.mount(element, false); } else { const app = createSSRApp({ name, render: () => h(Component, props, slots) }); + await setup(app); app.mount(element, true); } }; diff --git a/packages/integrations/vue/package.json b/packages/integrations/vue/package.json index 7f80497b4..c5a932a3a 100644 --- a/packages/integrations/vue/package.json +++ b/packages/integrations/vue/package.json @@ -30,7 +30,8 @@ "scripts": { "build": "astro-scripts build \"src/index.ts\" && astro-scripts build \"src/editor.cts\" --force-cjs --no-clean-dist && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\" && astro-scripts build \"src/editor.cts\" --force-cjs --no-clean-dist", - "dev": "astro-scripts dev \"src/**/*.ts\"" + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "mocha --timeout 20000" }, "dependencies": { "@vitejs/plugin-vue": "^3.0.0", @@ -39,8 +40,12 @@ "@vue/compiler-sfc": "^3.2.39" }, "devDependencies": { + "@types/chai": "^4.3.3", "astro": "workspace:*", "astro-scripts": "workspace:*", + "chai": "^4.3.6", + "linkedom": "^0.14.17", + "mocha": "^9.2.2", "vite": "^3.0.0", "vue": "^3.2.37" }, diff --git a/packages/integrations/vue/server.js b/packages/integrations/vue/server.js index 8d4c6df9e..831b46a1d 100644 --- a/packages/integrations/vue/server.js +++ b/packages/integrations/vue/server.js @@ -1,5 +1,6 @@ import { h, createSSRApp } from 'vue'; import { renderToString } from 'vue/server-renderer'; +import { setup } from 'virtual:@astrojs/vue/app' import StaticHtml from './static-html.js'; function check(Component) { @@ -12,6 +13,7 @@ async function renderToStaticMarkup(Component, props, slotted) { slots[key] = () => h(StaticHtml, { value, name: key === 'default' ? undefined : key }); } const app = createSSRApp({ render: () => h(Component, props, slots) }); + await setup(app); const html = await renderToString(app); return { html }; } diff --git a/packages/integrations/vue/src/index.ts b/packages/integrations/vue/src/index.ts index e8ca19025..2af3062bf 100644 --- a/packages/integrations/vue/src/index.ts +++ b/packages/integrations/vue/src/index.ts @@ -6,6 +6,7 @@ import type { UserConfig } from 'vite'; interface Options extends VueOptions { jsx?: boolean | VueJsxOptions; + appEntrypoint?: string; } function getRenderer(): AstroRenderer { @@ -31,13 +32,34 @@ function getJsxRenderer(): AstroRenderer { }; } +function virtualAppEntrypoint(options?: Options) { + const virtualModuleId = 'virtual:@astrojs/vue/app'; + const resolvedVirtualModuleId = '\0' + virtualModuleId; + return { + name: '@astrojs/vue/virtual-app', + resolveId(id: string) { + if (id == virtualModuleId) { + return resolvedVirtualModuleId; + } + }, + load(id: string) { + if (id === resolvedVirtualModuleId) { + if (options?.appEntrypoint) { + return `export { default as setup } from "${options.appEntrypoint}";`; + } + return `export const setup = () => {};`; + } + }, + }; +} + async function getViteConfiguration(options?: Options): Promise { const config: UserConfig = { optimizeDeps: { include: ['@astrojs/vue/client.js', 'vue'], - exclude: ['@astrojs/vue/server.js'], + exclude: ['@astrojs/vue/server.js', 'virtual:@astrojs/vue/app'] }, - plugins: [vue(options)], + plugins: [vue(options), virtualAppEntrypoint(options)], ssr: { external: ['@vue/server-renderer'], noExternal: ['vueperslides'], diff --git a/packages/integrations/vue/test/app-entrypoint.test.js b/packages/integrations/vue/test/app-entrypoint.test.js new file mode 100644 index 000000000..1b53fbd21 --- /dev/null +++ b/packages/integrations/vue/test/app-entrypoint.test.js @@ -0,0 +1,34 @@ +import { loadFixture } from './test-utils.js'; +import { expect } from 'chai'; +import { parseHTML } from 'linkedom'; + +describe('App Entrypoint', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint/' + }); + await fixture.build(); + }); + + it('loads during SSR', async () => { + const data = await fixture.readFile('/index.html') + const { document } = parseHTML(data); + const bar = document.querySelector('#foo > #bar'); + expect(bar).not.to.be.undefined; + expect(bar.textContent).to.eq('works'); + }); + + it('setup included in renderer bundle', async () => { + const data = await fixture.readFile('/index.html') + const { document } = parseHTML(data); + const island = document.querySelector('astro-island'); + const client = island.getAttribute('renderer-url'); + expect(client).not.to.be.undefined; + + const js = await fixture.readFile(client); + expect(js).to.match(/\w+\.component\(\"Bar\"/gm) + }); +}); diff --git a/packages/integrations/vue/test/fixtures/app-entrypoint/astro.config.mjs b/packages/integrations/vue/test/fixtures/app-entrypoint/astro.config.mjs new file mode 100644 index 000000000..0bf5fd95d --- /dev/null +++ b/packages/integrations/vue/test/fixtures/app-entrypoint/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; +import vue from '@astrojs/vue'; + +export default defineConfig({ + integrations: [vue({ + appEntrypoint: '/src/pages/_app' + })] +}) diff --git a/packages/integrations/vue/test/fixtures/app-entrypoint/package.json b/packages/integrations/vue/test/fixtures/app-entrypoint/package.json new file mode 100644 index 000000000..3cb7d419b --- /dev/null +++ b/packages/integrations/vue/test/fixtures/app-entrypoint/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/vue-app-entrypoint", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/vue": "workspace:*" + } +} diff --git a/packages/integrations/vue/test/fixtures/app-entrypoint/src/components/Bar.vue b/packages/integrations/vue/test/fixtures/app-entrypoint/src/components/Bar.vue new file mode 100644 index 000000000..9e690ea06 --- /dev/null +++ b/packages/integrations/vue/test/fixtures/app-entrypoint/src/components/Bar.vue @@ -0,0 +1,3 @@ + diff --git a/packages/integrations/vue/test/fixtures/app-entrypoint/src/components/Foo.vue b/packages/integrations/vue/test/fixtures/app-entrypoint/src/components/Foo.vue new file mode 100644 index 000000000..3e648808c --- /dev/null +++ b/packages/integrations/vue/test/fixtures/app-entrypoint/src/components/Foo.vue @@ -0,0 +1,5 @@ + diff --git a/packages/integrations/vue/test/fixtures/app-entrypoint/src/pages/_app.ts b/packages/integrations/vue/test/fixtures/app-entrypoint/src/pages/_app.ts new file mode 100644 index 000000000..bbda85382 --- /dev/null +++ b/packages/integrations/vue/test/fixtures/app-entrypoint/src/pages/_app.ts @@ -0,0 +1,6 @@ +import type { App } from 'vue' +import Bar from '../components/Bar.vue' + +export default function setup(app: App) { + app.component('Bar', Bar); +} diff --git a/packages/integrations/vue/test/fixtures/app-entrypoint/src/pages/index.astro b/packages/integrations/vue/test/fixtures/app-entrypoint/src/pages/index.astro new file mode 100644 index 000000000..3240cbe0f --- /dev/null +++ b/packages/integrations/vue/test/fixtures/app-entrypoint/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import Foo from '../components/Foo.vue'; +--- + + + + Vue App Entrypoint + + + + + diff --git a/packages/integrations/vue/test/test-utils.js b/packages/integrations/vue/test/test-utils.js new file mode 100644 index 000000000..2475944be --- /dev/null +++ b/packages/integrations/vue/test/test-utils.js @@ -0,0 +1,17 @@ +import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; + +/** + * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture + */ + +export function loadFixture(inlineConfig) { + if (!inlineConfig || !inlineConfig.root) + throw new Error("Must provide { root: './fixtures/...' }"); + + // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath + // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` + return baseLoadFixture({ + ...inlineConfig, + root: new URL(inlineConfig.root, import.meta.url).toString(), + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed9380ab..03b392a86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3142,12 +3142,16 @@ importers: packages/integrations/vue: specifiers: + '@types/chai': ^4.3.3 '@vitejs/plugin-vue': ^3.0.0 '@vitejs/plugin-vue-jsx': ^2.0.1 '@vue/babel-plugin-jsx': ^1.1.1 '@vue/compiler-sfc': ^3.2.39 astro: workspace:* astro-scripts: workspace:* + chai: ^4.3.6 + linkedom: ^0.14.17 + mocha: ^9.2.2 vite: ^3.0.0 vue: ^3.2.37 dependencies: @@ -3156,11 +3160,23 @@ importers: '@vue/babel-plugin-jsx': 1.1.1 '@vue/compiler-sfc': 3.2.40 devDependencies: + '@types/chai': 4.3.3 astro: link:../../astro astro-scripts: link:../../../scripts + chai: 4.3.6 + linkedom: 0.14.17 + mocha: 9.2.2 vite: 3.1.8 vue: 3.2.40 + packages/integrations/vue/test/fixtures/app-entrypoint: + specifiers: + '@astrojs/vue': workspace:* + astro: workspace:* + dependencies: + '@astrojs/vue': link:../../.. + astro: link:../../../../../astro + packages/markdown/component: specifiers: '@types/mocha': ^9.1.1