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("imported component
", false, true)` + ) + expect(bundle[chunk.fileName].code).toContain( + `const t0 = _template("", false, true)` + ) + expect(bundle[chunk.fileName].code).toContain( + `const t1 = _template("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