はじめに
- バージョン ... 0.31
- SHA-1 ハッシュ ... 7519f1f6e356a5521404116aba57009d123d6572
- VitePress のディレクトリ構成
- src/client/app と src/client/theme-default のつなぎ込み
- Markdown ファイルと src/client/app, Vue インスタンス本体のつなぎ込み
- Vite と VitePress のつなぎ込み
- dev 編 <-- いまココです。
- build 編(未着手)
概要
◯ Vite と VitePress のつなぎこみ
Vite 向けの VitePress のプラグインを噛ませつなぎこみを行なっています。Vite はプラグインが多段に組まれていています。後述します。また、以下のスライドの中に図示されている箇所があるので、雰囲気が、つかめるかもしれません。
具体的に VitePress のプラグインを噛ませているのは Step 2 になります。ここでは主に VitePress のプラグインを見ていきます。
◯ 通信
例えば、いま http://127.0.0.1:3000/hello.html
にアクセスしたとします。Chrome のデベロッパツールを開いて確認するとわかりやすいかもしれません。動画作りました。
<div id="app"></div>
<script type="module" src="/@app/index.js"></script>
/@app/index.js
が呼び出されます。これは /src/client/app/index.js
です。
パス変換のロジックが記述されている箇所は、以下をご確認ください。resolver のあたりは、あまりよくわかっていません。
/src/client/app/index.js
は Vue.js のインスタンスを作成して、そのインスタンスをマウントしていることが確認できます。
if (inBrowser) {
// 1) Vue.js のインスタンスを作成
const { app, router } = createApp()
// 2) そのインスタンスをマウント
// wait unitl page component is fetched before mounting
router.go().then(() => {
app.mount('#app')
})
}
さらに router.go の中を覗くと location.href に遷移していることがわかります。
function go(href?: string) {
// location.href で対象のコンポーネントを読み込みます。
href = href || (inBrowser ? location.href : '/')
if (inBrowser) {
// save scroll position before changing url
history.replaceState({ scrollPosition: window.scrollY }, document.title)
history.pushState(null, '', href)
}
return loadPage(href)
}
ルータの実装については、以下の記事で詳細を後述します。
Step 1. VitePress - エントリーポイント
yarn dev, yarn build を実行すると、ここから起動します。
//
// こっちが実行されます。dev
//
if (!command || command === 'dev') {
const port = argv.port || 3000
const root = command === 'dev' && argv._[1]
if (root) {
argv.root = root
}
require('../dist/node') // <--- ../src/node を参照します。
.createServer(argv)
.then((server) => {
server.listen(port, () => {
console.log(`listening at http://localhost:${port}`)
})
})
.catch((err) => {
console.error(chalk.red(`failed to start server. error:\n`), err)
})
//
// こっちじゃない。build
//
} else if (command === 'build') {
require('../dist/node')
.build(argv)
.catch((err) => {
console.error(chalk.red(`build error:\n`), err)
})
} else {
console.log(chalk.red(`unknown command "${command}".`))
}
Step 2. VitePress - createServer
Step 1 の createServer
は、以下のように定義されています。Step 1 の実引数 argv
は、Step 2 の仮引数 options
に代入されます。VitePress は Vite のプラグインとして、実装されていることがわかります。
export async function createServer(options: ServerConfig = {}) {
const config = await resolveConfig(options.root)
return createViteServer({ // `vite` から import した `createServer` です。
...options, // Step 1 のコマンドライン引数 `argv` をアンパックしています。
plugins: [createVitePressPlugin(config)], // ポイント ... これが VitePress の本体です。
resolvers: [config.resolver] // resolvers は、後述します。
})
}
createViteServer
は vite
から import した createServer
です。
import {
createServer as createViteServer, // <--- ココ
cachedRead,
ServerConfig,
ServerPlugin
} from 'vite'
また、ここでのポイントは VitePress の本体に当たる部分が Vite Server の Plugin として渡されていることです。これは追って見ていきます。
plugins: [createVitePressPlugin(config)], // ポイント ... これが VitePress の本体です。
◯ resolveConfig
resolveConfig って、何してるのかな?と思ったのですが、サイトのメタデータを作成していました。resolve というよりは create なイメージでした。
export async function resolveConfig(
root: string = process.cwd()
): Promise<SiteConfig> {
const site = await resolveSiteData(root)
// resolve theme path
const userThemeDir = resolve(root, 'theme')
const themeDir = (await exists(userThemeDir))
? userThemeDir
: path.join(__dirname, '../lib/theme-default')
const config: SiteConfig = {
root,
site,
themeDir,
pages: await globby(['**.md'], { cwd: root, ignore: ['node_modules'] }),
configPath: resolve(root, 'config.js'),
publicDir: resolve(root, 'public'),
outDir: resolve(root, 'dist'),
tempDir: path.resolve(APP_PATH, 'temp'),
resolver: createResolver(themeDir)
}
return config
}
また、どこで md ファイルを収集しているか?疑問だったのですが、ここで config
の page
プロパティに注目すると globby を使って Markdown ファイルのパスを集めていることが確認できます。
Step 3. VitePress - createVitePressPlugin
2 つの watcher.on
と 1 つの app.use
をしています。watcher.on
はホットリロードの対象を登録しています。app.use
は Koa の middleware を登録しています。app
が Vue の app ではなく Koa の app
であることに留意してください。
function createVitePressPlugin({
configPath,
site: initialSiteData
}: SiteConfig): ServerPlugin {
return ({ app, root, watcher, resolver }) => {
const markdownToVue = createMarkdownToVueRenderFn(root)
//
// 記事となる Markdown ファイルの変更を監視登録しています。
//
// hot reload .md files as .vue files
watcher.on('change', async (file) => {
//
// 後述 1
//
})
//
// 設定ファイルの変更を監視登録しています。
//
// hot reload handling for siteData
// the data is stringified twice so it is sent to the client as a string
// it is then parsed on the client via JSON.parse() which is faster than
// parsing the object literal as JavaScript.
let siteData = initialSiteData
let stringifiedData = JSON.stringify(JSON.stringify(initialSiteData))
watcher.add(configPath)
watcher.on('change', async (file) => {
//
// 後述 2
//
})
// inject Koa middleware
app.use(async (ctx, next) => {
//
// 後述 3
//
})
}
}
◯ 後述 1
Markdown ファイルの更新を監視するコードが書かれています。こんなに繊細に書かれているとは思いませんでした。もっとざっくりとディレクトリ配下は全部監視!みたいなのかと思っていました...orz
// hot reload .md files as .vue files
watcher.on('change', async (file) => {
if (file.endsWith('.md')) {
debugHmr(`reloading ${file}`)
//
// cachedRead
// キャッシュを読み込みます。
// キャッシュってなんだろうと思ってコードを追ってみたのですが、
// このキャッシュは、メモリからのキャッシュです。
// ファイルが読み込まれていなければ fs.open() します。
//
const content = await cachedRead(null, file)
// Vite では timestamp をキャッシュを更新するか、
// しないかの判断に使うことがあるそうです。
// このコードブロックの末尾を確認すると雰囲気がわかります。
// watcher.handleVueReload(file, timestamp, vueSrc)
const timestamp = Date.now()
//
// Markdown ファイルが Vue の SFC の template に変換されます。
//
const { pageData, vueSrc } = markdownToVue(
content,
file,
timestamp,
// do not inject pageData on HMR
// it leads to vite to think <script> has changed and reloads the
// component instead of re-rendering.
// pageData needs separate HMR logic anyway (see below)
false
)
// notify the client to update page data
watcher.send({
type: 'custom',
id: 'vitepress:pageData',
customData: {
path: resolver.fileToRequest(file),
pageData
},
timestamp: Date.now()
})
// reload the content component
watcher.handleVueReload(file, timestamp, vueSrc)
}
})
◯ 後述 2
省略
◯ 後述 3 - inject Koa middleware とは
上記コードのコメントに "inject Koa middleware" という文言が見えます。Koa の middleware は、簡単に言えばフックです。HTTP リクエストをフックして処理を追加します。
app.use(async (ctx, next) => { ... }
に着目していきます。ctx には HTTP リクエストの内容が、next には次に控えている middleware を呼び出す callback 関数が代入されています。await next() すると次の middleware に操作が移り、return されると戻って来ます。
Vite の middleware 間の関係は、わかっていません。await next()
にかかる挙動は、以下 Developers.IO の記事がわかりやすいです。
- koa v2 によるAPIアプリケーションの実装例 - Developers.IO
- Why do we await next when using koa routers? - Stackoverflow
- Mastering Koa Middleware - Netscape
"inject Koa middleware" 以下の内容を抜粋しました。3種類のファイルをフックして、処理を加えていることがわかります。
// inject Koa middleware
app.use(async (ctx, next) => {
//
// 1) @siteDate の場合
// サイトのタイトルなどのメタデータが保存されています。
//
// serve siteData (which is a virtual file)
if (ctx.path === SITE_DATA_REQUEST_PATH) {
ctx.type = 'js'
ctx.body = `export default ${stringifiedData}`
debug(ctx.url)
return
}
//
// 2) Markdown ファイルの場合
// 例えば、動画の最後でダウンロードされた hello.md ファイルが該当します。
//
// handle .md -> vue transforms
if (ctx.path.endsWith('.md')) {
//
// /index.md -> docs/index.md に変換します。
// resolver については、後述します。
const file = resolver.requestToFile(ctx.path)
// cachedRead
// ctx.body にファイルの中身が設定されます。
//
await cachedRead(ctx, file)
// let vite know this is supposed to be treated as vue file
ctx.vue = true
const { vueSrc, pageData } = markdownToVue(
ctx.body,
file,
ctx.lastModified.getTime(),
false
)
ctx.body = vueSrc
debug(ctx.url, ctx.status)
await next()
// make sure this is the main <script> block
if (!ctx.query.type) {
// inject pageData to generated script
ctx.body += `\nexport const __pageData = ${JSON.stringify(
JSON.stringify(pageData)
)}`
}
return
}
await next()
//
// 3) 動画の最初にダウンロードされた hello.html
// 全て index.html が返されます。
// router.go で hello.md を表示しています。
// SPA のような動作をしています。
//
// serve our index.html after vite history fallback
if (ctx.url.endsWith('.html')) {
await cachedRead(ctx, path.join(APP_PATH, 'index.html'))
ctx.status = 200
}
})
TODO:
cachedRead
, cachedRead
といった文言が見え、Markdown ファイルを読んでいるのは、わかるが、どこで app と繋がっているかわからないので、調べる。
ここから Vite のコードを追いかけます。
Step 4. Vite - createServer
Vite のコードを読むと大量の Vite のプラグインが登録されていることを確認できます。登録の順番は、引数で追加したもの -> Vite が最初からつけているもの、となっているようです。
-
koa
のインスタンスを作りサーバを走らせる。 -
chokidar
で hot reload を有効にする。 - 何をしているのか、わからない。
- 引数で渡された plugins を実行する。
import { vuePlugin } from './serverPluginVue' // <--- 後述します。
// ... 中略
const internalPlugins: Plugin[] = [
hmrPlugin,
moduleRewritePlugin,
moduleResolvePlugin,
vuePlugin, // <--- 後述します。
esbuildPlugin,
jsonPlugin,
cssPlugin,
assetPathPlugin,
serveStaticPlugin
]
export function createServer(config: ServerConfig = {}): Server {
const {
root = process.cwd(),
plugins = [],
resolvers = [],
jsx = {}
} = config
//
// 1) app を生成して create サーバに渡す。
// ここでの app は Vue のインスタンスではなく、
// Koa のインスタンスを参照していることに留意したい。
//
const app = new Koa()
const server = http.createServer(app.callback())
//
// 2) `/node_modules/` 以外に hot reload がかかるように登録している。
//
const watcher = chokidar.watch(root, {
ignored: [/node_modules/]
}) as HMRWatcher
//
// 3) 何をしているのか、わからない。
//
const resolver = createResolver(root, resolvers)
//
// 4) createServer を実行したタイミングで
// plugins を実行していることがわかる。
//
const context = {
root,
app,
server,
watcher,
resolver,
jsxConfig: {
jsxFactory: jsx.factory,
jsxFragment: jsx.fragment
}
}
;[...plugins, ...internalPlugins].forEach((m) => m(context))
return server
}
以下のディレクトリの配下に internalPlugins が保存されています。一覧すると雰囲気がつかめるかもしれません。
Step 5. Vite - serverPluginVue
serverPluginVue を覗くと compileSFCTemplate
, compileSFCStyle
という文言が見えます。ここで SFC, Single File Component を変換, compile しているようです。
if (query.type === 'template') {
const templateBlock = descriptor.template!
if (templateBlock.src) {
filename = await resolveSrcImport(templateBlock, ctx, resolver)
}
// ... 中略
return etagCacheCheck(ctx)
}
if (query.type === 'style') {
const index = Number(query.index)
const styleBlock = descriptor.styles[index]
if (styleBlock.src) {
filename = await resolveSrcImport(styleBlock, ctx, resolver)
}
const id = hash_sum(publicPath)
const result = await compileSFCStyle(
root,
styleBlock,
index,
filename,
publicPath
)
ctx.type = 'js'
ctx.body = codegenCss(`${id}-${index}`, result.code, result.modules)
return etagCacheCheck(ctx)
}
ここから resolver を追いかけます。
Step 6. VitePress - resolver
VitePress の中で resolve, resolver という単語は、色々な意味で使われますが、Vite 本体に渡す resolver について見て行きます。Vite 本体に渡す resolver は HTTP リクエストのパスを、ファイルシステムのどのパスに対応づけるかを記述します。
例えば、以下のように docs ディレクトリを指定した場合...
$ npx vitepress dev docs
HTTP リクエストは以下のように変換されて欲しいです。
/ -> /docs/index.md
/hello -> /docs/hello.md
/hello/nihao -> /docs/hello/nihao.md
該当箇所を引用しつつ resolvers に何が代入されているかを追いかけに行きます。
export async function createServer(options: ServerConfig = {}) {
const config = await resolveConfig(options.root) // <--- ここに注目してください。
return createViteServer({
...options,
plugins: [createVitePressPlugin(config)],
resolvers: [config.resolver] // <--- ここに注目してください。
})
}
config を作成している resolveConfig を見て行きます。
export async function resolveConfig(
root: string = process.cwd()
): Promise<SiteConfig> {
const site = await resolveSiteData(root)
// resolve theme path
const userThemeDir = resolve(root, 'theme')
const themeDir = (await exists(userThemeDir))
? userThemeDir
: path.join(__dirname, '../lib/theme-default')
const config: SiteConfig = {
root,
site,
themeDir,
pages: await globby(['**.md'], { cwd: root, ignore: ['node_modules'] }),
configPath: resolve(root, 'config.js'),
publicDir: resolve(root, 'public'),
outDir: resolve(root, 'dist'),
tempDir: path.resolve(APP_PATH, 'temp'),
resolver: createResolver(themeDir) // <--- ここに注目してください。
}
return config
}
resolver.js 全体を貼り付けます。
import path from 'path'
import { Resolver } from 'vite'
export const APP_PATH = path.join(__dirname, '../client/app')
// special virtual file
// we can't directly import '/@siteData' becase
// - it's not an actual file so we can't use tsconfig paths to redirect it
// - TS doesn't allow shimming a module that starts with '/'
export const SITE_DATA_ID = '@siteData'
export const SITE_DATA_REQUEST_PATH = '/' + SITE_DATA_ID
// これは vite に渡されるパスリゾルバで、/@app や /@theme で始まる
// カスタムリクエストを解決することができます。
// また、vite HMR が正しい更新通知をクライアントに送信できるように、
// ファイルのパスをパブリックにサーブされたパスにマップする必要があります。
// this is a path resolver that is passed to vite
// 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(themeDir: string): Resolver {
return {
alias: {
// このようにして /@app/ /@theme/ などがある理由は、
// Vue のインスタンスや theme の SFC が個別にダウンロードされるからです。
// (うまい説明が思いつきません... orz)
// デベロッパツールのネットワークを開き、
// @app や @theme へのリクエストの中身を見ると雰囲気がわかるかもしれません。
'/@app/': APP_PATH,
'/@theme/': themeDir,
vitepress: '/@app/exports.js',
[SITE_DATA_ID]: SITE_DATA_REQUEST_PATH
},
//
// HTTP リクエストから -> サーバのファイルパスへの変換
//
requestToFile(publicPath) {
if (publicPath === SITE_DATA_REQUEST_PATH) {
return SITE_DATA_REQUEST_PATH
}
},
//
// サーバのファイルパスから -> HTTP リクエストへの変換
//
fileToRequest(filePath) {
if (filePath === SITE_DATA_REQUEST_PATH) {
return SITE_DATA_REQUEST_PATH
}
}
// ここでなぜ requestToFile, fileToRequest が
// このような書き方をされているのか、まだつかめていません。
}
}
Step 7. Vite - resolver
次は Vite の resolver を見て行きます。Step 6 の createViteServer を思い出してください。
export function createServer(config: ServerConfig = {}): Server {
const {
root = process.cwd(),
plugins = [],
resolvers = [], // <--- ここに注目してください。
jsx = {}
} = config
const app = new Koa()
const server = http.createServer(app.callback())
const watcher = chokidar.watch(root, {
ignored: [/node_modules/]
}) as HMRWatcher
const resolver = createResolver(root, resolvers) // <--- ここに注目してください。
const context = {
root,
app,
server,
watcher,
resolver, // <--- ここに注目してください。
jsxConfig: {
jsxFactory: jsx.factory,
jsxFragment: jsx.fragment
}
}
;[...plugins, ...internalPlugins].forEach((m) => m(context))
return server
}
const resolver = createResolver(root, resolvers)
を見ていきます。引数に注目します。
root
にはドキュメント、マークダウンファイルの保存されたディレクトリの root が代入されます。例えば docs です。
resolvers
には、VitePress から呼び出す時は、Step6 で見てきた resolvers が代入されています。コードを見ると、カスタムの resolver が該当しなければデフォルトの resolver で解決するということをしているようです。
export function createResolver(
root: string,
resolvers: Resolver[]
): InternalResolver {
return {
requestToFile: (publicPath) => {
let resolved: string | undefined
for (const r of resolvers) {
const filepath = r.requestToFile(publicPath, root)
if (filepath) {
resolved = filepath
break
}
}
if (!resolved) {
resolved = defaultRequestToFile(publicPath, root)
}
resolved = resolveExt(resolved)
return resolved
},
fileToRequest: (filePath) => {
for (const r of resolvers) {
const request = r.fileToRequest(filePath, root)
if (request) return request
}
return defaultFileToRequest(filePath, root)
},
idToRequest: (id: string) => {
for (const r of resolvers) {
const request = r.idToRequest && r.idToRequest(id)
if (request) return request
}
return defaultIdToRequest(id)
}
}
}
おわりに
以上になります。ありがとうございました。build 編は、まだ未着手です。