diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts new file mode 100644 index 000000000000..b8733b76d9b3 --- /dev/null +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'vitest' +import { createMarkdownToVueRenderFn } from '../../../src/node/markdownToVue' +import type { SiteConfig } from '../../../src/node/siteConfig' + +describe('markdownToVue', () => { + test('marks generated markdown default export as vapor when vue.features.vapor is enabled', async () => { + const render = await createRenderFn() + + const { vueSrc } = await render('# Hello', `${process.cwd()}/index.md`, '') + + expect(vueSrc).toMatchInlineSnapshot(` + " + " + `) + }) + + test('keeps existing markdown script setup when marking default export as vapor', async () => { + const render = await createRenderFn() + + const { vueSrc } = await render( + ` + +# {{ label }}`, + `${process.cwd()}/guide/index.md`, + '' + ) + + expect(vueSrc).toMatchInlineSnapshot(` + " + + " + `) + }) + + test('marks existing markdown default export as vapor when vue.features.vapor is enabled', async () => { + const render = await createRenderFn() + + const { vueSrc } = await render( + ` + +# Hello`, + `${process.cwd()}/tutorial/index.md`, + '' + ) + + expect(vueSrc).toMatchInlineSnapshot(` + " + " + `) + }) +}) + +function createRenderFn() { + return createMarkdownToVueRenderFn( + process.cwd(), + { cache: false }, + '/', + false, + false, + createSiteConfig() + ) +} + +function createSiteConfig(): SiteConfig { + return { + __dirty: true, + pages: [], + dynamicRoutes: [], + rewrites: { map: {}, inv: {} }, + site: { + base: '/', + lang: 'en-US', + dir: 'ltr', + title: '', + description: '', + head: [], + appearance: true, + themeConfig: {}, + scrollOffset: 0, + locales: {}, + router: { prefetchLinks: true } + }, + vue: { features: { vapor: true } }, + ignoreDeadLinks: true + } as unknown as SiteConfig +} diff --git a/__tests__/unit/node/plugin.test.ts b/__tests__/unit/node/plugin.test.ts new file mode 100644 index 000000000000..bada8f1e8792 --- /dev/null +++ b/__tests__/unit/node/plugin.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test, vi } from 'vitest' +import type { Plugin, PluginOption, Rollup } from 'vite' +import { createVitePressPlugin } from '../../../src/node/plugin' +import type { SiteConfig } from '../../../src/node/siteConfig' + +describe('vitepress plugin', () => { + test('strips only markdown-owned vapor static template payloads in lean chunks', async () => { + const pageToHashMap: Record = {} + const plugins = await createVitePressPlugin( + createSiteConfig(), + false, + pageToHashMap + ) + const plugin = findPluginByName(plugins, 'vitepress') + const staticMarkersPlugin = findPluginByName( + plugins, + 'vitepress:static-markers' + ) + expect(staticMarkersPlugin.apply).toBe('build') + const code = [ + `const t0 = _template("", false, true)`, + `const t1 = _template("", false, true)`, + `const t2 = _template("", false, true)`, + `const t3 = _template("
", true)`, + `const t4 = _template("", false)` + ].join('\n') + const generateBundle = plugin.generateBundle + + if (!generateBundle || typeof generateBundle === 'function') { + throw new Error('vitepress plugin is missing expected build hooks') + } + + if (!staticMarkersPlugin.transform) { + throw new Error( + 'vitepress static marker plugin is missing transform hook' + ) + } + + const transformHandler = + typeof staticMarkersPlugin.transform === 'function' + ? staticMarkersPlugin.transform + : staticMarkersPlugin.transform.handler + const transformed = transformHandler.call( + {} as never, + code, + `${process.cwd()}/guide.md?vue&type=template` + ) + + expect(transformed).toContain( + `const t0 = _template("__VP_STATIC_START____VP_STATIC_END__", false, true)` + ) + expect(transformed).toContain( + `const t1 = _template("__VP_STATIC_START____VP_STATIC_END__", false, true)` + ) + expect(transformed).toContain( + `const t2 = _template("__VP_STATIC_START____VP_STATIC_END__", false, true)` + ) + expect(transformed).toContain(`const t3 = _template("
", true)`) + expect(transformed).toContain(`const t4 = _template("", false)`) + + const chunk = createPageChunk( + 'guide', + 'guide.abc123.js', + [ + transformed as string, + `const imported = _template("

imported component

", false, true)` + ].join('\n') + ) + + const bundle = { + [chunk.fileName]: chunk + } + const emitFile = vi.fn() + + generateBundle.handler.call( + { emitFile } as never, + {} as never, + bundle, + false + ) + + expect(pageToHashMap.guide).toBe('abc123') + expect(emitFile).toHaveBeenCalledTimes(1) + expect(emitFile.mock.calls[0][0]).toMatchObject({ + fileName: 'guide.abc123.lean.js' + }) + expect(emitFile.mock.calls[0][0].source).toContain( + `const t0 = _template("", false, true)` + ) + expect(emitFile.mock.calls[0][0].source).toContain( + `const t1 = _template("", false, true)` + ) + expect(emitFile.mock.calls[0][0].source).toContain( + `const t2 = _template("", false, true)` + ) + expect(emitFile.mock.calls[0][0].source).toContain( + `const t3 = _template("
", true)` + ) + expect(emitFile.mock.calls[0][0].source).toContain( + `const t4 = _template("", false)` + ) + expect(emitFile.mock.calls[0][0].source).toContain( + `const imported = _template("

imported component

", false, true)` + ) + expect(bundle[chunk.fileName].code).toContain( + `const t0 = _template("", false, true)` + ) + expect(bundle[chunk.fileName].code).toContain( + `const t1 = _template("", false, true)` + ) + expect(bundle[chunk.fileName].code).toContain( + `const t2 = _template("", false, true)` + ) + expect(bundle[chunk.fileName].code).not.toContain(`__VP_STATIC_`) + }) + + test('strips only markdown-owned vdom static vnode payloads in lean chunks', async () => { + const pageToHashMap: Record = {} + const siteConfig = createSiteConfig() + siteConfig.vue = {} + const plugins = await createVitePressPlugin( + siteConfig, + false, + pageToHashMap + ) + const plugin = findPluginByName(plugins, 'vitepress') + const staticMarkersPlugin = findPluginByName( + plugins, + 'vitepress:static-markers' + ) + const generateBundle = plugin.generateBundle + + if (!generateBundle || typeof generateBundle === 'function') { + throw new Error('vitepress plugin is missing expected build hooks') + } + + if (!staticMarkersPlugin.transform) { + throw new Error( + 'vitepress static marker plugin is missing transform hook' + ) + } + + const transformHandler = + typeof staticMarkersPlugin.transform === 'function' + ? staticMarkersPlugin.transform + : staticMarkersPlugin.transform.handler + const transformed = transformHandler.call( + {} as never, + `const t0 = createStaticVNode("

markdown

", 1)`, + `${process.cwd()}/guide.md?vue&type=template` + ) + + expect(transformed).toContain( + `const t0 = createStaticVNode("__VP_STATIC_START__

markdown

__VP_STATIC_END__", 1)` + ) + + const chunk = createPageChunk( + 'guide', + 'guide.abc123.js', + [ + transformed as string, + `const imported = createStaticVNode("

imported component

", 1)` + ].join('\n') + ) + const bundle = { + [chunk.fileName]: chunk + } + const emitFile = vi.fn() + + generateBundle.handler.call( + { emitFile } as never, + {} as never, + bundle, + false + ) + + expect(emitFile.mock.calls[0][0].source).toContain( + `const t0 = createStaticVNode("", 1)` + ) + expect(emitFile.mock.calls[0][0].source).toContain( + `const imported = createStaticVNode("

imported component

", 1)` + ) + expect(bundle[chunk.fileName].code).toContain( + `const t0 = createStaticVNode("

markdown

", 1)` + ) + expect(bundle[chunk.fileName].code).not.toContain(`__VP_STATIC_`) + }) +}) + +function createPageChunk( + name: string, + fileName: string, + code: string +): Rollup.OutputChunk & { facadeModuleId: string } { + return { + type: 'chunk', + code, + dynamicImports: [], + exports: [], + facadeModuleId: `${process.cwd()}/${name}.md`, + fileName, + implicitlyLoadedBefore: [], + importedBindings: {}, + imports: [], + isDynamicEntry: false, + isEntry: true, + isImplicitEntry: false, + map: null, + moduleIds: [], + modules: {}, + name, + preliminaryFileName: fileName, + referencedFiles: [], + sourcemapFileName: null + } as unknown as Rollup.OutputChunk & { facadeModuleId: string } +} + +function createSiteConfig(): SiteConfig { + return { + __dirty: true, + pages: [], + dynamicRoutes: [], + rewrites: { map: {}, inv: {} }, + site: { + base: '/', + lang: 'en-US', + dir: 'ltr', + title: '', + description: '', + head: [], + appearance: true, + themeConfig: {}, + scrollOffset: 0, + locales: {}, + router: { prefetchLinks: true } + }, + vue: { features: { vapor: true } }, + ignoreDeadLinks: true + } as unknown as SiteConfig +} + +function findPluginByName( + plugins: readonly PluginOption[], + name: string +): Plugin { + for (const plugin of plugins) { + if (Array.isArray(plugin)) { + const nested = findPluginByName(plugin, name) + if (nested) return nested + continue + } + + if ( + plugin && + typeof plugin === 'object' && + !('then' in plugin) && + 'name' in plugin && + plugin.name === name + ) { + return plugin + } + } + + throw new Error(`failed to find plugin: ${name}`) +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b809371393a5..eeeb1f8316c9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -150,6 +150,16 @@ export default defineConfig({ enableNativePlugin: true } }, + vue: { + // This enables interop between the VDOM and Vapor components. + vaporInterop: true, + features: { + // Force `.vue` and `.md` files to be compiled as Vapor components. + // Only `.vue` files using ` diff --git a/src/client/theme-default/components/VPNavBarSearch.vue b/src/client/theme-default/components/VPNavBarSearch.vue index e23d5cefa852..96240ebbcff6 100644 --- a/src/client/theme-default/components/VPNavBarSearch.vue +++ b/src/client/theme-default/components/VPNavBarSearch.vue @@ -1,19 +1,31 @@ ` - ) + if (vapor && defaultExportRE.test(tagSrc)) { + const defaultExportVar = `__VP_VAPOR_DEFAULT_EXPORT__` + tags[existingScriptIndex] = tagSrc + .replace(defaultExportRE, `$1const ${defaultExportVar} =`) + .replace( + scriptRE, + `${code} +${defaultExportVar}.__vapor = true +export default ${defaultExportVar}` + ) + } else { + const namedDefaultMatch = vapor + ? tagSrc.match(namedDefaultExportNameRE) + : null + tags[existingScriptIndex] = tagSrc.replace( + scriptRE, + code + + (namedDefaultMatch + ? `\n${namedDefaultMatch[1]}.__vapor = true` + : hasDefaultExport + ? `` + : defaultExportCode) + + `` + ) + } } else { tags.unshift( `` + }>${code}${defaultExportCode}` ) } diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 0dabfabc9375..7d300e3ad670 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -47,6 +47,8 @@ const hashRE = /\.([-\w]+)\.js$/ const staticInjectMarkerRE = /\bcreateStaticVNode\((?:(".*")|('.*')), (\d+)\)/g const staticStripRE = /['"`]__VP_STATIC_START__[^]*?__VP_STATIC_END__['"`]/g const staticRestoreRE = /__VP_STATIC_(START|END)__/g +const vaporStaticTemplateInjectMarkerRE = + /\b(_?template)\((?:(".*")|('.*')),\s*(true|false),\s*true\)/g // matches client-side js blocks in MPA mode. // in the future we may add different execution strategies like visible or @@ -65,6 +67,36 @@ const isPageChunk = ( const cleanUrl = (url: string): string => url.replace(/[?#].*$/s, '') +const isMarkdownModule = (id: string): boolean => + cleanUrl(normalizePath(id)).endsWith('.md') + +function injectStaticMarkers(code: string, isVaporMode: boolean): string { + if (isVaporMode) { + // Only compiler-marked pure-static template() calls are strip-safe for + // Vapor hydration. + return code.replace( + vaporStaticTemplateInjectMarkerRE, + (_, helper, str1, str2, root) => { + const str = str1 || str2 + const quote = str[0] + return `${helper}(${quote}__VP_STATIC_START__${str.slice(1, -1)}__VP_STATIC_END__${quote}, ${root}, true)` + } + ) + } + + // For markdown-owned compiled modules, inject marker for start/end of static strings. + // we do this here because in generateBundle the chunks would have been + // minified and we won't be able to safely locate the strings. + // Using a regexp relies on specific output from Vue compiler core, + // which is a reasonable trade-off considering the massive perf win over + // a full AST parse. + return code.replace(staticInjectMarkerRE, (_, str1, str2, flag) => { + const str = str1 || str2 + const quote = str[0] + return `createStaticVNode(${quote}__VP_STATIC_START__${str.slice(1, -1)}__VP_STATIC_END__${quote}, ${flag})` + }) +} + export async function createVitePressPlugin( siteConfig: SiteConfig, ssr = false, @@ -84,13 +116,15 @@ export async function createVitePressPlugin( cleanUrls } = siteConfig + const { vaporInterop, ...vuePluginOptions } = userVuePluginOptions ?? {} + const isVaporMode = !!vuePluginOptions.features?.vapor let markdownToVue: Awaited> // lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x const vuePlugin = await import('@vitejs/plugin-vue').then((r) => r.default({ include: /\.(?:vue|md)$/, - ...userVuePluginOptions + ...vuePluginOptions }) ) @@ -137,7 +171,9 @@ export async function createVitePressPlugin( !!site.themeConfig?.algolia, // legacy __CARBON__: !!site.themeConfig?.carbonAds, __ASSETS_DIR__: JSON.stringify(siteConfig.assetsDir), - __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: !!process.env.DEBUG + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: !!process.env.DEBUG, + __VAPOR__: isVaporMode, + __VAPOR_INTEROP__: !!vaporInterop }, optimizeDeps: { // force include vue to avoid duplicated copies when linked + optimized @@ -284,24 +320,6 @@ export async function createVitePressPlugin( } }, - renderChunk(code, chunk) { - if (!ssr && isPageChunk(chunk as Rollup.OutputChunk)) { - // For each page chunk, inject marker for start/end of static strings. - // we do this here because in generateBundle the chunks would have been - // minified and we won't be able to safely locate the strings. - // Using a regexp relies on specific output from Vue compiler core, - // which is a reasonable trade-off considering the massive perf win over - // a full AST parse. - code = code.replace(staticInjectMarkerRE, (_, str1, str2, flag) => { - const str = str1 || str2 - const quote = str[0] - return `createStaticVNode(${quote}__VP_STATIC_START__${str.slice(1, -1)}__VP_STATIC_END__${quote}, ${flag})` - }) - return code - } - return null - }, - generateBundle: { order: ssr ? null : 'post', handler(_options, bundle) { @@ -380,6 +398,20 @@ export async function createVitePressPlugin( } } + const staticMarkersPlugin: Plugin = { + name: 'vitepress:static-markers', + apply: 'build', + transform(code, id) { + if (ssr || !isMarkdownModule(id)) return null + + // Mark only the compiled .md module before bundling. A page chunk can + // also contain imported components that are not rendered during SSR, so + // stripping the whole chunk would remove HTML they need for later mount. + const transformed = injectStaticMarkers(code, isVaporMode) + return transformed === code ? null : transformed + } + } + const hmrFix: Plugin = { name: 'vitepress:hmr-fix', async hotUpdate({ file, modules: existingMods }) { @@ -407,6 +439,7 @@ export async function createVitePressPlugin( vitePressPlugin, rewritesPlugin(siteConfig), vuePlugin, + staticMarkersPlugin, hmrFix, webFontsPlugin(siteConfig.useWebFonts), ...(userViteConfig?.plugins || []), diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index a421ced3b584..500b4f344cb5 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -23,6 +23,13 @@ export type RawConfigExports = | Awaitable> | (() => Awaitable>) +export interface VitePressVuePluginOptions extends VuePluginOptions { + /** + * Install Vue's Vapor interop plugin for mixed VDOM and Vapor components. + */ + vaporInterop?: boolean +} + export interface TransformContext { page: string siteConfig: SiteConfig @@ -75,7 +82,7 @@ export interface UserConfig< /** * Options to pass on to `@vitejs/plugin-vue` */ - vue?: VuePluginOptions + vue?: VitePressVuePluginOptions /** * Vite config */