From a512fc79257f57bfb7215a918d089ee5ab0f0f34 Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 29 Apr 2020 15:34:18 -0400 Subject: [PATCH] ssr build --- lib/app/components/Content.js | 2 +- lib/app/composables/pageData.js | 17 +--- lib/app/exports.js | 2 +- lib/app/index.js | 130 +++++++++++++++------------- lib/app/{composables => }/router.js | 18 ++-- package.json | 5 +- src/build/build.ts | 15 +++- src/build/bundle.ts | 90 +++++++++++-------- src/build/render.ts | 17 ++++ src/{resolveConfig.ts => config.ts} | 43 +++++---- src/server.ts | 22 +++-- src/tsconfig.json | 2 +- src/utils/pathResolver.ts | 8 +- yarn.lock | 8 ++ 14 files changed, 220 insertions(+), 159 deletions(-) rename lib/app/{composables => }/router.js (92%) create mode 100644 src/build/render.ts rename src/{resolveConfig.ts => config.ts} (60%) diff --git a/lib/app/components/Content.js b/lib/app/components/Content.js index d0903e55..637a932a 100644 --- a/lib/app/components/Content.js +++ b/lib/app/components/Content.js @@ -1,5 +1,5 @@ import { h } from 'vue' -import { useRoute } from '../composables/router' +import { useRoute } from '../router' export const Content = { setup() { diff --git a/lib/app/composables/pageData.js b/lib/app/composables/pageData.js index 1cd1f24b..38f42b49 100644 --- a/lib/app/composables/pageData.js +++ b/lib/app/composables/pageData.js @@ -1,4 +1,4 @@ -import { ref, provide, inject } from 'vue' +import { inject } from 'vue' /** * @typedef {{ @@ -11,24 +11,15 @@ import { ref, provide, inject } from 'vue' /** * @type {import('vue').InjectionKey} */ -const pageDataSymbol = Symbol() - -export function initPageData() { - const data = ref() - provide(pageDataSymbol, data) - return data -} +export const pageDataSymbol = Symbol() /** * @returns {PageDataRef} */ export function usePageData() { const data = inject(pageDataSymbol) - if (__DEV__ && !data) { - throw new Error( - 'usePageData() is called without initPageData() in an ancestor component.' - ) + if (!data) { + throw new Error('usePageData() is called without provider.') } - // @ts-ignore return data } diff --git a/lib/app/exports.js b/lib/app/exports.js index 1a016399..42aa52c9 100644 --- a/lib/app/exports.js +++ b/lib/app/exports.js @@ -2,4 +2,4 @@ // so the user can do `import { usePageData } from 'vitepress'` export { useSiteData } from './composables/siteData' export { usePageData } from './composables/pageData' -export { useRouter, useRoute } from './composables/router' +export { useRouter, useRoute } from './router' diff --git a/lib/app/index.js b/lib/app/index.js index 700467a7..2bf6e59b 100644 --- a/lib/app/index.js +++ b/lib/app/index.js @@ -1,8 +1,8 @@ -import { createApp, h, readonly } from 'vue' +import { createApp as createClientApp, createSSRApp, ref, readonly } from 'vue' import { Content } from './components/Content' -import { initRouter } from './composables/router' +import { createRouter, RouterSymbol } from './router' import { useSiteData } from './composables/siteData' -import { initPageData, usePageData } from './composables/pageData' +import { usePageData, pageDataSymbol } from './composables/pageData' import Theme from '/@theme/index' import { hot } from '@hmr' @@ -10,72 +10,80 @@ const inBrowser = typeof window !== 'undefined' const NotFound = Theme.NotFound || (() => '404 Not Found') -const App = { - setup() { - const pageDataRef = initPageData() +export function createApp() { + const pageDataRef = ref() - initRouter((route) => { - let pagePath = route.path.replace(/\.html$/, '') - if (pagePath.endsWith('/')) { - pagePath += 'index' - } - if (__DEV__) { - // awlays force re-fetch content in dev - pagePath += `.md?t=${Date.now()}` - } else { - // in production, each .md file is built into a .md.js file following - // the path conversion scheme. - // /foo/bar.html -> /js/foo_bar.md.js - // TODO handle base - pagePath = `/js/${pagePath.slice(1).replace(/\//g, '_')}.md.js` + // hot reload pageData + if (__DEV__ && inBrowser) { + hot.on('vitepress:pageData', (data) => { + if ( + data.path.replace(/\.md$/, '') === + location.pathname.replace(/\.html$/, '') + ) { + pageDataRef.value = data.pageData } + }) + } - if (inBrowser) { - // in browser: native dynamic import - return import(pagePath).then((m) => { - pageDataRef.value = readonly(m.__pageData) - return m.default - }) - } else { - // SSR, sync require - return require(pagePath).default - } - }, NotFound) + const router = createRouter((route) => { + let pagePath = route.path.replace(/\.html$/, '') + if (pagePath.endsWith('/')) { + pagePath += 'index' + } + if (__DEV__) { + // awlays force re-fetch content in dev + pagePath += `.md?t=${Date.now()}` + } else { + // in production, each .md file is built into a .md.js file following + // the path conversion scheme. + // /foo/bar.html -> /js/foo_bar.md.js + // TODO handle base + pagePath = pagePath.slice(1).replace(/\//g, '_') + '.md.js' + } - if (__DEV__ && inBrowser) { - // hot reload pageData - hot.on('vitepress:pageData', (data) => { - if ( - data.path.replace(/\.md$/, '') === - location.pathname.replace(/\.html$/, '') - ) { - pageDataRef.value = data.pageData - } + if (inBrowser) { + // in browser: native dynamic import + // js files are stored in a sub directory + return import('./js/' + pagePath).then(page => { + pageDataRef.value = readonly(page.__pageData) + return page.default }) + } else { + // SSR, sync require + const page = require('./' + pagePath) + console.log('setting page data') + pageDataRef.value = page.__pageData + return page.default } + }, NotFound) - return () => h(Theme.Layout) - } -} + const app = __DEV__ + ? createClientApp(Theme.Layout) + : createSSRApp(Theme.Layout) -// TODO use createSSRApp in production -const app = createApp(App) + app.provide(RouterSymbol, router) + app.provide(pageDataSymbol, pageDataRef) -app.component('Content', Content) + app.component('Content', Content) -app.mixin({ - beforeCreate() { - const siteRef = useSiteData() - const pageRef = usePageData() - Object.defineProperties(this, { - $site: { - get: () => siteRef.value - }, - $page: { - get: () => pageRef.value - } - }) - } -}) + app.mixin({ + beforeCreate() { + const siteRef = useSiteData() + const pageRef = usePageData() + Object.defineProperties(this, { + $site: { + get: () => siteRef.value + }, + $page: { + get: () => pageRef.value + } + }) + } + }) + + return { app, router } +} -app.mount('#app') +if (inBrowser) { + createApp().app.mount('#app') +} diff --git a/lib/app/composables/router.js b/lib/app/router.js similarity index 92% rename from lib/app/composables/router.js rename to lib/app/router.js index d37ce9dd..fc67f5ee 100644 --- a/lib/app/composables/router.js +++ b/lib/app/router.js @@ -1,4 +1,4 @@ -import { reactive, provide, inject, nextTick, markRaw } from 'vue' +import { reactive, inject, nextTick, markRaw } from 'vue' /** * @typedef {import('vue').Component} Component @@ -17,7 +17,7 @@ import { reactive, provide, inject, nextTick, markRaw } from 'vue' /** * @type {import('vue').InjectionKey} */ -const RouterSymbol = Symbol() +export const RouterSymbol = Symbol() /** * @returns {Route} @@ -32,7 +32,7 @@ const getDefaultRoute = () => ({ * @param {Component} [fallbackComponent] * @returns {Router} */ -export function initRouter(loadComponent, fallbackComponent) { +export function createRouter(loadComponent, fallbackComponent) { const route = reactive(getDefaultRoute()) const inBrowser = typeof window !== 'undefined' @@ -159,9 +159,9 @@ export function initRouter(loadComponent, fallbackComponent) { go } - provide(RouterSymbol, router) - - loadPage(location.href) + if (inBrowser) { + loadPage(location.href) + } return router } @@ -171,10 +171,8 @@ export function initRouter(loadComponent, fallbackComponent) { */ export function useRouter() { const router = inject(RouterSymbol) - if (__DEV__ && !router) { - throw new Error( - 'useRouter() is called without initRouter() in an ancestor component.' - ) + if (!router) { + throw new Error('useRouter() is called without provider.') } // @ts-ignore return router diff --git a/package.json b/package.json index 213faa61..6e058ab5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "author": "Evan You", "license": "MIT", "dependencies": { + "@vue/compiler-sfc": "^3.0.0-beta.4", + "@vue/server-renderer": "^3.0.0-beta.4", "debug": "^4.1.1", "diacritics": "^1.3.0", "escape-html": "^1.0.3", @@ -36,7 +38,8 @@ "minimist": "^1.2.5", "prismjs": "^1.20.0", "slash": "^3.0.0", - "vite": "^0.6.0" + "vite": "^0.6.0", + "vue": "^3.0.0-beta.4" }, "devDependencies": { "@types/lru-cache": "^5.1.0", diff --git a/src/build/build.ts b/src/build/build.ts index 2da466ee..dc93f17e 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -1,11 +1,22 @@ +import { promises as fs } from 'fs' import { bundle } from './bundle' import { BuildOptions as ViteBuildOptions } from 'vite' +import { resolveConfig } from '../config' +import { renderPage } from './render' export type BuildOptions = Pick< ViteBuildOptions, 'root' | 'rollupInputOptions' | 'rollupOutputOptions' > -export async function build(options: BuildOptions = {}) { - await bundle(options) +export async function build(buildOptions: BuildOptions = {}) { + const siteConfig = await resolveConfig(buildOptions.root) + try { + const result = await bundle(siteConfig, buildOptions) + for (const page of siteConfig.pages) { + await renderPage(siteConfig, page, result) + } + } finally { + await fs.rmdir(siteConfig.tempDir, { recursive: true }) + } } diff --git a/src/build/bundle.ts b/src/build/bundle.ts index c435fce7..cf782d86 100644 --- a/src/build/bundle.ts +++ b/src/build/bundle.ts @@ -1,25 +1,23 @@ import path from 'path' -import globby from 'globby' import slash from 'slash' import { promises as fs } from 'fs' import { APP_PATH, createResolver } from '../utils/pathResolver' -import { build } from 'vite' +import { build, BuildOptions as ViteBuildOptions, BuildResult } from 'vite' import { BuildOptions } from './build' -import { resolveConfig } from '../resolveConfig' +import { SiteConfig } from '../config' import { Plugin } from 'rollup' import { createMarkdownToVueRenderFn } from '../markdownToVue' // bundles the VitePress app for both client AND server. -export async function bundle(options: BuildOptions) { - const root = options.root || process.cwd() - const config = await resolveConfig(root) - const resolver = createResolver(config.themePath) +export async function bundle( + config: SiteConfig, + options: BuildOptions +): Promise { + const root = config.root + const resolver = createResolver(config.themeDir) const markdownToVue = createMarkdownToVueRenderFn(root) - const { - rollupInputOptions = {}, - rollupOutputOptions = {} - } = options + const { rollupInputOptions = {}, rollupOutputOptions = {} } = options const VitePressPlugin: Plugin = { name: 'vitepress', @@ -32,17 +30,8 @@ export async function bundle(options: BuildOptions) { if (id === '/@siteData') { return `export default ${JSON.stringify(JSON.stringify(config.site))}` } - // generate facade module for .md files - // and request virtual .md.vue file - if (id.endsWith('.md')) { - return ( - `import Comp, { __pageData } from "${id + '.vue'}"\n` + - `export default Comp\n` + - `export { __pageData }` - ) - } // compile md into vue src for .md.vue virtual files - if (id.endsWith('.md.vue')) { + if (id.endsWith('.md')) { const filePath = id.replace(/\.vue$/, '') const content = await fs.readFile(filePath, 'utf-8') const lastUpdated = (await fs.stat(filePath)).mtimeMs @@ -76,34 +65,59 @@ export async function bundle(options: BuildOptions) { } } - const pages = ( - await globby(['**.md'], { cwd: root, ignore: ['node_modules'] }) - ).map((file) => path.resolve(root, file)) + // convert page files to absolute paths + const pages = config.pages.map(file => path.resolve(root, file)) - await build({ + // let rollup-plugin-vue compile .md files as well + const rollupPluginVueOptions = { + include: /\.(vue|md)$/ + } + + const sharedOptions: ViteBuildOptions = { ...options, cdn: false, silent: true, resolvers: [resolver], - srcRoots: [APP_PATH, config.themePath], + srcRoots: [APP_PATH, config.themeDir], cssFileName: 'css/style.css', + rollupPluginVueOptions, rollupInputOptions: { ...rollupInputOptions, input: [path.resolve(APP_PATH, 'index.js'), ...pages], plugins: [VitePressPlugin, ...(rollupInputOptions.plugins || [])] }, - rollupOutputOptions: [ - { - dir: path.resolve(root, '.vitepress/dist'), - ...rollupOutputOptions - }, - { - dir: path.resolve(root, '.vitepress/temp'), - ...rollupOutputOptions, - format: 'cjs', - exports: 'named' - } - ], + rollupOutputOptions: { + ...rollupOutputOptions, + dir: config.outDir + }, debug: !!process.env.DEBUG + } + + const clientResult = await build({ + ...sharedOptions, + rollupOutputOptions: { + ...rollupOutputOptions, + dir: config.outDir + } }) + + const serverResult = await build({ + ...sharedOptions, + rollupPluginVueOptions: { + ...rollupPluginVueOptions, + target: 'node' + }, + rollupInputOptions: { + ...sharedOptions.rollupInputOptions, + external: ['vue', '@vue/server-renderer'] + }, + rollupOutputOptions: { + ...rollupOutputOptions, + dir: config.tempDir, + format: 'cjs', + exports: 'named' + } + }) + + return [clientResult, serverResult] } diff --git a/src/build/render.ts b/src/build/render.ts new file mode 100644 index 00000000..281b2da6 --- /dev/null +++ b/src/build/render.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { SiteConfig } from '../config' +import { BuildResult } from 'vite' +import { renderToString } from '@vue/server-renderer' + +export async function renderPage( + config: SiteConfig, + page: string, // foo.md + result: BuildResult[] +) { + const { createApp } = require(path.join(config.tempDir, 'js/index.js')) + const { app, router } = createApp() + const routePath = `/${page.replace(/\.md$/, '')}` + router.go(routePath) + const html = await renderToString(app) + console.log(html) +} diff --git a/src/resolveConfig.ts b/src/config.ts similarity index 60% rename from src/resolveConfig.ts rename to src/config.ts index c2343355..063850f5 100644 --- a/src/resolveConfig.ts +++ b/src/config.ts @@ -1,7 +1,8 @@ import path from 'path' import chalk from 'chalk' +import globby from 'globby' import { promises as fs } from 'fs' -import { createResolver } from './utils/pathResolver' +import { createResolver, APP_PATH } from './utils/pathResolver' import { Resolver } from 'vite' const debug = require('debug')('vitepress:config') @@ -24,32 +25,44 @@ export interface SiteData { themeConfig: ThemeConfig } -export interface ResolvedConfig { +export interface SiteConfig { + root: string site: SiteData - themePath: string + configPath: string + themeDir: string + outDir: string + tempDir: string resolver: Resolver + pages: string[] } -export const getConfigPath = (root: string) => - path.join(root, '.vitepress/config.js') +const resolve = (root: string, file: string) => + path.join(root, `.vitepress`, file) -export async function resolveConfig(root: string): Promise { +export async function resolveConfig( + root: string = process.cwd() +): Promise { const site = await resolveSiteData(root) // resolve theme path - const userThemePath = path.join(root, '.vitepress/theme') - let themePath: string + const userThemeDir = resolve(root, 'theme') + let themeDir: string try { - await fs.stat(userThemePath) - themePath = userThemePath + await fs.stat(userThemeDir) + themeDir = userThemeDir } catch (e) { - themePath = path.join(__dirname, '../lib/theme-default') + themeDir = path.join(__dirname, '../lib/theme-default') } - const config: ResolvedConfig = { + const config: SiteConfig = { + root, site, - themePath, - resolver: createResolver(themePath) + themeDir, + pages: await globby(['**.md'], { cwd: root, ignore: ['node_modules'] }), + configPath: resolve(root, 'config.js'), + outDir: resolve(root, 'dist'), + tempDir: path.resolve(APP_PATH, 'temp'), + resolver: createResolver(themeDir) } return config @@ -57,7 +70,7 @@ export async function resolveConfig(root: string): Promise { export async function resolveSiteData(root: string): Promise { // load user config - const configPath = getConfigPath(root) + const configPath = resolve(root, 'config.js') let hasUserConfig = false try { await fs.stat(configPath) diff --git a/src/server.ts b/src/server.ts index 0d85ee3d..0b49a4c1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,19 +7,18 @@ import { } from 'vite' import { resolveConfig, - ResolvedConfig, - getConfigPath, + SiteConfig, resolveSiteData -} from './resolveConfig' +} from './config' import { createMarkdownToVueRenderFn } from './markdownToVue' import { APP_PATH, SITE_DATA_REQUEST_PATH } from './utils/pathResolver' const debug = require('debug')('vitepress:serve') const debugHmr = require('debug')('vitepress:hmr') -function createVitePressPlugin(config: ResolvedConfig): Plugin { +function createVitePressPlugin(config: SiteConfig): Plugin { const { - themePath, + themeDir, site: initialSiteData, resolver: vitepressResolver } = config @@ -33,9 +32,9 @@ function createVitePressPlugin(config: ResolvedConfig): Plugin { } // watch theme files if it's outside of project root - if (path.relative(root, themePath).startsWith('..')) { - debugHmr(`watching theme dir outside of project root: ${themePath}`) - watcher.add(themePath) + if (path.relative(root, themeDir).startsWith('..')) { + debugHmr(`watching theme dir outside of project root: ${themeDir}`) + watcher.add(themeDir) } // hot reload .md files as .vue files @@ -68,10 +67,9 @@ function createVitePressPlugin(config: ResolvedConfig): Plugin { // parsing the object literal as JavaScript. let siteData = initialSiteData let stringifiedData = JSON.stringify(JSON.stringify(initialSiteData)) - const configPath = getConfigPath(root) - watcher.add(configPath) + watcher.add(config.configPath) watcher.on('change', async (file) => { - if (file === configPath) { + if (file === config.configPath) { const newData = await resolveSiteData(root) stringifiedData = JSON.stringify(JSON.stringify(newData)) if (newData.base !== siteData.base) { @@ -129,7 +127,7 @@ function createVitePressPlugin(config: ResolvedConfig): Plugin { } export async function createServer(options: ServerConfig = {}) { - const config = await resolveConfig(options.root || process.cwd()) + const config = await resolveConfig(options.root) return createViteServer({ ...options, diff --git a/src/tsconfig.json b/src/tsconfig.json index cf969577..2b53777e 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "outDir": "../dist", "module": "commonjs", - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], "sourceMap": false, "target": "esnext", "moduleResolution": "node", diff --git a/src/utils/pathResolver.ts b/src/utils/pathResolver.ts index 8b578361..cbb3affa 100644 --- a/src/utils/pathResolver.ts +++ b/src/utils/pathResolver.ts @@ -11,14 +11,14 @@ export const SITE_DATA_REQUEST_PATH = '/@siteData' // so that we can resolve custom requests that start with /@app or /@theme // we also need to map file paths back to their public served paths so that // vite HMR can send the correct update notifications to the client. -export function createResolver(themePath: string): Resolver { +export function createResolver(themeDir: string): Resolver { return { requestToFile(publicPath) { if (publicPath.startsWith('/@app')) { return path.join(APP_PATH, publicPath.replace(/^\/@app\/?/, '')) } if (publicPath.startsWith('/@theme')) { - return path.join(themePath, publicPath.replace(/^\/@theme\/?/, '')) + return path.join(themeDir, publicPath.replace(/^\/@theme\/?/, '')) } if (publicPath === SITE_DATA_REQUEST_PATH) { return SITE_DATA_REQUEST_PATH @@ -28,8 +28,8 @@ export function createResolver(themePath: string): Resolver { if (filePath.startsWith(APP_PATH)) { return `/@app/${path.relative(APP_PATH, filePath)}` } - if (filePath.startsWith(themePath)) { - return `/@theme/${path.relative(themePath, filePath)}` + if (filePath.startsWith(themeDir)) { + return `/@theme/${path.relative(themeDir, filePath)}` } if (filePath === SITE_DATA_REQUEST_PATH) { return SITE_DATA_REQUEST_PATH diff --git a/yarn.lock b/yarn.lock index b757cffe..f13b0e53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -320,6 +320,14 @@ "@vue/shared" "3.0.0-beta.4" csstype "^2.6.8" +"@vue/server-renderer@^3.0.0-beta.4": + version "3.0.0-beta.4" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.0.0-beta.4.tgz#08bbd34f8917f01298cfb70a0b000349868f770b" + integrity sha512-CQGaKWV4UL4EjpquQHWZWlXBYgYcqFWIcEIHckJAMiZTsI0UEE77B/2KTQEBHmdNHhISRTl4+KHP3gf3+cJHcA== + dependencies: + "@vue/compiler-ssr" "3.0.0-beta.4" + "@vue/shared" "3.0.0-beta.4" + "@vue/shared@3.0.0-beta.4": version "3.0.0-beta.4" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.0-beta.4.tgz#2c1f18896d598549a9241641b406f0886a710494"