# 「手搓系列 01」 从零搭建 Vue 文档站，学习从静态生成到语法解析

原文：https://blog.grtsinry43.com/posts/hands-on-vue-docs/

作者：grtsinry43

发布时间：2025-09-02T18:03:56Z

内容更新时间：2025-09-02T18:03:56Z

最近在实习，好久不更新了，休假回校之前，准备开一个新的合集 「手搓系列」，我们从头实现一些习以为常的前端轮子/技术栈/工具链，用手写的方式学习它的思路，同时得到些新的思考。

手搓的故事就从比较知名的文档网站 VitePress 开始吧，之后预计还有打包工具，ui 库，以及前端基建，干货多多，可以考虑长期追更（doge），自己写一遍既可以给大家讲原理，自己也能学习，两全其美，每一个部分都由“分析效果——规划实现——MVP 调通——难点解决——项目成品”组成

让我们开始吧

> [!NOTE]
>
> 受限于精力，业余时间实在不多，这个 demo 项目的样式，交互还在设计，完成之后会开源

## 分析效果

VitePress 文档站，一个知名的静态文档站点，被无数项目和开发者所采用，简洁精美，配置简单，深受开发者喜爱。

分析它的效果，首先是 yaml 驱动的元信息，md 中直接书写 Vue 组件，智能生成 siderbar 和 toc，主页内容的自定义，markdown 扩展语法，代码着色高亮等等

## 规划实现

### Vite 插件

我们将依托于 Vite 的强大能力，一步步手动实现这些特性

在 Vite 的构建中，我们可以编写插件来自定义 vite 处理文件的操作，严格来说，对于 vite 来说，它既不认识文件的扩展名，也看不懂里面的内容，之所以能够解析 vue/react 等等库，也就是因为插件的存在
Vite 插件（也就是 rollup）的类型定义是这样的

```typescript
interface Plugin$1<A = any> extends Rollup.Plugin<A> {
  hotUpdate?: ObjectHook<(this: MinimalPluginContext & {
    environment: DevEnvironment;
  }, options: HotUpdateOptions) => Array<EnvironmentModuleNode> | void | Promise<Array<EnvironmentModuleNode> | void>>;
  resolveId?: ObjectHook<(this: PluginContext, source: string, importer: string | undefined, options: {
    attributes: Record<string, string>;
    custom?: CustomPluginOptions;
    ssr?: boolean;
    isEntry: boolean;
  }) => Promise<ResolveIdResult> | ResolveIdResult, {
    filter?: {
      id?: StringFilter<RegExp>;
    };
  }>;
  load?: ObjectHook<(this: PluginContext, id: string, options?: {
    ssr?: boolean;
  }) => Promise<LoadResult> | LoadResult, {
    filter?: {
      id?: StringFilter;
    };
  }>;
  transform?: ObjectHook<(this: TransformPluginContext, code: string, id: string, options?: {
    ssr?: boolean;
  }) => Promise<Rollup.TransformResult> | Rollup.TransformResult, {
    filter?: {
      id?: StringFilter;
      code?: StringFilter;
    };
  }>;
  sharedDuringBuild?: boolean;
  perEnvironmentStartEndDuringDev?: boolean;
  enforce?: 'pre' | 'post';
  apply?: 'serve' | 'build' | ((this: void, config: UserConfig, env: ConfigEnv) => boolean);
  applyToEnvironment?: (environment: PartialEnvironment) => boolean | Promise<boolean> | PluginOption;
  config?: ObjectHook<(this: ConfigPluginContext, config: UserConfig, env: ConfigEnv) => Omit<UserConfig, 'plugins'> | null | void | Promise<Omit<UserConfig, 'plugins'> | null | void>>;
  configEnvironment?: ObjectHook<(this: ConfigPluginContext, name: string, config: EnvironmentOptions, env: ConfigEnv & {
    isSsrTargetWebworker?: boolean;
  }) => EnvironmentOptions | null | void | Promise<EnvironmentOptions | null | void>>;
  configResolved?: ObjectHook<(this: MinimalPluginContextWithoutEnvironment, config: ResolvedConfig) => void | Promise<void>>;
  configureServer?: ObjectHook<ServerHook>;
  configurePreviewServer?: ObjectHook<PreviewServerHook>;
  transformIndexHtml?: IndexHtmlTransform;
  buildApp?: ObjectHook<BuildAppHook>;
  handleHotUpdate?: ObjectHook<(this: MinimalPluginContextWithoutEnvironment, ctx: HmrContext) => Array<ModuleNode> | void | Promise<Array<ModuleNode> | void>>;
}
```

其中对我们来说最常用的就是 transform，id 就是文件名，code 就是代码内容，也就是相当于拿到每个文件，你告诉这个文件要怎么处理，处理完还给它就行
于是我们之前说到的特性，markdown 解析可以这里拿，元数据可以这里拿，甚至组件的渲染都可以这里自定义

当然别的特性也有大用，我们慢慢讲解

###  静态网站生成（SSG）

这名词听起来这么高大上，其实很好理解。

你应该了解过，也用过/实现过 SSR，也就是服务端渲染，这种方式利用 node runtime，在 node 中创建基础应用然后为客户端分发部分渲染好的字符串，由客户端加载 js 斌完成“水合”，进而提升了性能和 SEO 体验

但是这个时候又有新的问题出现了，SSR 对服务端的性能要求很高，或者是像一些静态的内容很少更新，无需或很少交互的内容/serverless，那么我们就让 node 渲染一次然后直接存起来不就好了，诶，于是你发明了 SSG，通过构建时候的一次渲染+客户端脚本水合，这使得你的应用极为轻量，因为你可以在构建阶段完成大部分的页面渲染。

### 技术选择

选择好了大概方向，那我们来决定用什么来写。

我们采用 Vite+Vue+Markdown-it+shiki+tailwind 手搓这个站点

其中如果是 react 的话其实绝大部分都采用 mdx，一步到位没有乐趣了

而 vue+markdown-it 刚好能发挥 vue sfc 的灵活性，以及自己掌控完全解析的高可玩性

shiki 没啥好说的，好用就是了，当然也可以 highlightjs。

## 从 MVP 开始

让我们从构建最小可行性产品开始

1. **项目初始化**：首先，我们用 Vite 创建一个基本的 Vue 项目作为我们的骨架。
2. **Markdown 解析**：然后，我们要让 Vite 能“看懂” `.md` 文件，并把它转换成 Vue 组件。
3. **路由系统**：接着，我们会建立一个简单的文件路由，让不同的 URL 能访问到对应的 Markdown 页面。
4. **静态站点生成 (SSG)**：最后，我们会编写一个构建脚本，把所有页面打包成最终的静态 HTML 文件。

```bash
pnpm create vite@latest
```

先建项目，没啥可说的，Vue3+ts 模板，然后装好依赖

### 第一个插件

首先我们要让 Vite 认识 `.md`，让我们在 `vite.config.js` 写下第一个插件

```typescript
import { defineConfig, Plugin  } from 'vite'
import vue from '@vitejs/plugin-vue'
import Markdown from 'markdown-it'

// 1. 初始化 markdown-it
const md = new Markdown()

// 2. 自定义插件
const markdownPlugin: Plugin = {
  // 插件名称
  name: 'vite-plugin-markdown-to-vue',
  // 这是一个预处理
  enforece: "pre",
  // transform 钩子
  transform(code: string, id: string) {
    // 检查文件扩展名是否是 .md
    if (id.endsWith('.md')) {
      // 使用 markdown-it 将 Markdown 文本转换为 HTML
      const html = md.render(code);

      // 3. 将 HTML 包装成一个 Vue 组件的模板字符串
      // 注意：这里需要使用 backticks (`) 包裹 HTML，并将其导出
      const vueComponent = `
        <template>
          <div class="markdown-body">
            ${html}
          </div>
        </template>
        
        <script lang="ts">
        </script>
      `;

      // 返回转换后的 Vue 组件代码
      return {
        code: vueComponent
      };
    }
  },
};


// https://vitejs.dev/config/
export default defineConfig({
  // 4. 在 Vite 中使用我们的插件
  plugins: [vue({
            // 这里不要忘记这里让 vue compiler 也要处理 .md
            include: [/\.vue$/, /\.md$/], 
        }), markdownPlugin],
})
```

是的，如你所见，就这么简单，使用 `md.render` 渲染成 html，组装成 sfc 之后直接给到下一步的 vue compiler 就可以了。

现在，我们直接创建一个 Markdown 文件，并在我们的 Vue 应用里使用它。

1. 在 `src` 文件夹下创建一个名为 `hello.md` 的文件，内容如下：

   Markdown

   ```
   # Hello, VitePress Clone!
   
   This is a paragraph rendered from Markdown.
   
   - Item 1
   - Item 2
   ```

2. 修改 `src/App.vue` 文件，清空原有内容，然后引入并使用这个 Markdown 文件：

   Code snippet

   ```
   <template>
     <HelloWorld />
   </template>
   
   <script setup>
   // 像导入一个普通的 Vue 组件一样导入 .md 文件
   import HelloWorld from './hello.md'
   </script>
   ```

诶，这样就渲染出来了，原汁原味的 html

注：这里引入 ide 会报错，建一个 `d.ts` 就行

```typescript
declare module '*.md' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}
```

### 内容目录与路由系统

为了方便书写内容，我们创建 `content/` 文件夹，现在开始构建路由

我们希望程序能自动扫描 `content/` 目录下的所有 `.md` 文件，并为它们生成对应的页面路由。例如，`content/hello.md` 应该能通过 `/hello` 这个网址访问到。

这个过程可以分为两大部分：

1. **扫描文件 (Node.js)**：我们需要在 Vite 的配置文件 (`vite.config.ts`) 里编写一段 Node.js 代码，用来读取 `content/` 目录下的所有文件，并生成一个路由配置列表。
2. **使用路由 (浏览器端)**：我们需要在 Vue 应用 (`src/` 目录) 中安装和配置 `vue-router`，让它使用我们上一步生成的路由列表来展示不同的页面。

先装 router

```bash
pnpm add vue-router
```

好玩的来了，构建工具在构建中可以生成一些“**虚拟模块** (Virtual Module)”。

听起来很高级，但原理很简单：我们将编写一个 Vite 插件，这个插件会创建一个 **只存在于内存中** 的“虚拟文件”。我们的 Vue 应用可以像导入普通文件一样导入这个虚拟文件，从而获取到我们动态生成的路由列表。

那就简单了，无外乎两步：

1. **在 Vite 插件中**：扫描 `content/` 目录，生成路由配置，并通过一个特殊的虚拟 ID (咱们这利用 `virtual:routes`) 来提供这些配置。
2. **在 Vue 应用中**：导入 `virtual:routes`，并用它来初始化 `vue-router`。

我们需要一个新的 Vite 插件。这次，之前说的其他钩子就有用了：`resolveId` 和 `load`。

- **`resolveId` 钩子**：当 Vite 看到 `import ... from 'virtual:routes'` 这样的语句时，它会问：“这个 'virtual: routes' 到底是什么东西？” 这个钩子就是用来回答这个问题的。我们会告诉 Vite：“是的，我认识这个 ID，你交给我来处理就行。”
- **`load` 钩子**：在 `resolveId` 确认了 ID 之后，Vite 就会调用 `load` 钩子，问：“好了，那这个模块的内容是什么？” 在这里，我们就会动态地生成代码并返回。

利用这个功能，让我们创建新的插件：

```typescript
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import Markdown from 'markdown-it'
import fs from 'fs'
import path from 'path'

const md = new Markdown()

// 之前的 markdownPlugin，不管它
const markdownPlugin: Plugin = { ... };

const routesPlugin: Plugin = {
    name: 'vite-plugin-virtual-routes',
    resolveId(id) {
        // 如果导入的 ID 是 'virtual: routes'，就告诉 Vite 我们要处理它
        if (id === 'virtual:routes') {
            return id;
        }
    },
    load(id) {
        // 确认是我们的虚拟模块
        if (id === 'virtual:routes') {
            // 1. 定义 content 文件夹的路径
            const contentDir = path.resolve(__dirname, 'content');
            // 2. 读取文件夹下的所有文件名
            const files = fs.readdirSync(contentDir);
            
            // 3. 为每个 .md 文件生成一个路由对象
            const routes = files.filter(file => file.endsWith('.md')).map(file => {
                const name = file.replace('.md', '');
                const path = name === 'index' ? '/' : `/${name}`;
                // 这里直接导入
                return `{
                    path: '${path}',
                    component: () => import('/content/${file}')
                }`;
            });

            // 4. 将路由对象数组转换成 JavaScript 代码字符串就 ok 了
            return `export const routes = [${routes.join(', ')}]`;
        }
    }
}


// https://vite.dev/config/
export default defineConfig({
    plugins: [
        vue({
            include: [/\.vue$/, /\.md$/],
        }),
        markdownPlugin,
        routesPlugin
    ],
})
```

同样为了 ts 更好推断，还要添加 d.ts

```typescript
declare module 'virtual:routes' {
  import type { RouteRecordRaw } from 'vue-router'
  export const routes: RouteRecordRaw[]
}
```

于是就可以直接引入

```typescript
import { routes } from 'virtual:routes'
```

创建路由 use router-view 不再赘述，前端开发写过无数次了，于是你就有了简单路由。

但是这还不够呀，文档一定会有多级路由存在的，那也简单，递归处理一下就好了呗

递归很简单，基本功了，直接上代码

```typescript
import fs from "fs";
import path from "path";
import type {Plugin} from "vite";

function generateRoutesFromDir(dir: string, basePath: string = '/') {
    const entries = fs.readdirSync(dir, {withFileTypes: true});
    const routes: Array<{ path: string; importPath: string }> = [];

    for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);

        if (entry.isDirectory()) {
            const newBasePath = path.posix.join(basePath, entry.name);
            const subRoutes = generateRoutesFromDir(fullPath, newBasePath);
            routes.push(...subRoutes);
        } else if (entry.isFile() && entry.name.endsWith('.md')) {
            const name = entry.name.replace('.md', '');
            const routePath = name === 'index' ? basePath : path.posix.join(basePath, name);

            const importPath = path.posix.join('/content', basePath, entry.name);

            routes.push({
                path: routePath,
                importPath: importPath,
            });
        }
    }
    return routes;
}


export function routesPlugin(): Plugin {
    return {
        name: 'vite-plugin-virtual-routes',
        resolveId(id) {
            if (id === 'virtual:routes') {
                return id;
            }
        },
        load(id) {
            if (id === 'virtual:routes') {
                const contentDir = path.resolve(__dirname, '../content');
                const routes = generateRoutesFromDir(contentDir);

                const routesString = routes.map(route => `{
                        path: '${route.path}',
                        component: () => import(/* @vite-ignore */ '${route.importPath}')
                    }`);

                return `export const routes = [${routesString.join(', ')}]`;
            }
        }
    }
}
```

到这里，你的应用便有了雏形，可以在不同页面之间导航了。

### 从 SSR 到 SSG

相信熟悉前端的你一定写过 SSR 的手动实现，思路就是维护两个 entry，一个 server，一个 client，使用打包工具分别处理，收到请求首先内存 router 切换，状态库注入，然后渲染发给客户端，

这里还是实现一次吧，毕竟懂了 SSR 就简单了。

首先让 router 在服务端内存维护，客户端历史记录维护。

```typescript
import { 
  createRouter as _createRouter, 
  createWebHistory, 
  createMemoryHistory 
} from 'vue-router'
import { routes } from 'virtual:routes'

export const createRouter = () => _createRouter({
  // Vite 会提供一个环境变量 import.meta.env.SSR
  // 在浏览器环境(SSR 为 false)，我们使用 history 模式
  // 在 Node.js 环境(SSR 为 true)，我们使用 memory 模式
  history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
  routes,
})
```

随后主应用使用这个额算是工厂函数

```typescript
import { createApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router' // <-- 1. 导入 createRouter 函数

const app = createApp(App)
const router = createRouter() // <-- 2. 调用函数创建实例
app.use(router)

// 在挂载应用之前，我们需要确保路由已经准备就绪
router.isReady().then(() => {
  app.mount('#app')
})
```

好，接下来是服务端入口，我们新建一个 `entry-ssr.ts`

```typescript
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router'

// 在 Node.js 环境中调用
export function createApp() {
  const app = createSSRApp(App)
  const router = createRouter()
  app.use(router)

  // 返回 app 和 router 实例
  return { app, router }
}
```

Vite 的强大又来了，它提供一个 `createServer` 可以方便创建服务端环境，而 `ssrLoadModule` 就是为加载 ssr 入口而生，优化常用使用场景确实方便于 webpack 维护三套配置。

用我们之前扫描到的所有路由，依次完成渲染

```typescript
// ssg.ts
import { build, createServer } from 'vite'
import path from 'path'
import fs from 'fs/promises'
import { renderToString } from 'vue/server-renderer'

  console.log('Building for client...');
  await build();
  console.log('Client build complete.');

  console.log('Starting SSR...');

  const server = await createServer({
    server: { middlewareMode: true },
    appType: 'custom',
  });

  try {
    const { createApp } = await server.ssrLoadModule('./src/entry-ssr.ts');
    const { routes } = await server.ssrLoadModule('virtual:routes');
    const routesToRender = routes.map((route: any) => route.path);
    console.log('Discovered routes to render:', routesToRender);

    for (const route of routesToRender) {
      const { app, router } = createApp();

      await router.push(route);
      await router.isReady();

      const html = await renderToString(app);
      
      // 我们暂时还只打印 HTML 片段
      console.log(`Rendered HTML for ${route}`);
    }
  } finally {
    await server.close();
  }

  console.log('SSR complete.');
})();
```

正如我们之前的思路，我们把这些存起来就行了，存哪里呢，诶，存在客户端的打包刚好

为了渲染好的内容能够精准插入，我们不妨给它标记下，编辑根 `index.html`

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
  </head>
  <body>
    <div id="app">
    <!-- app inject-->
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

```

我们添加了个注释，这样便于 SSR 拿到字符串之后替换
思路有了，相信对大家来说超级简单，直接最后的代码~

```typescript
import { build, createServer } from 'vite'
import path from 'path'
import fs from 'fs/promises'
import { renderToString } from 'vue/server-renderer'

import { fileURLToPath } from 'url'
import { dirname } from 'path'

    ;(async () => {
    const __dirname = dirname(fileURLToPath(import.meta.url));

    console.log('Building for client...');
    await build();
    console.log('Client build complete.');

    console.log('Starting SSG (Static Site Generation)...');

    const server = await createServer({
        server: { middlewareMode: true },
        appType: 'custom',
    });

    try {
        const { createApp } = await server.ssrLoadModule('./src/entry-ssr.ts');
        const { routes } = await server.ssrLoadModule('virtual:routes');
        const routesToRender = routes.map((route: any) => route.path);

        const template = await fs.readFile(path.resolve(__dirname, '../dist/index.html'), 'utf-8');

        for (const route of routesToRender) {
            const { app, router } = createApp();
            await router.push(route);
            await router.isReady();

            const appHtml = await renderToString(app);

            const finalHtml = template.replace(`<!-- app inject-->`, appHtml);

            // 计算输出路径, 确保“干净”的 URL (例如 /hello 对应 /hello/index.html)
            const dirPath = path.join(__dirname, '../dist', route);
            const filePath = path.join(dirPath, 'index.html');

            // 递归创建目录, 然后写入最终的 HTML 文件
            await fs.mkdir(dirPath, { recursive: true });
            await fs.writeFile(filePath, finalHtml);

            console.log(`✓ Pre-rendered: ${route}`);
        }
    } finally {
        await server.close();
    }

    console.log('SSG complete. Your static site is ready in the "dist" folder.');
})();
```

到这里，你的网站成功实现静态生成！（撒花）

在这里之后，你可以比如用 `gray-matter` 解析元数据，创建 meta 组件，这些思路差不多

## 难点解决

难题到这里开始了。

### shiki 代码着色

核心挑战：同步 vs. 异步

在集成 `shiki` 时，我们会遇到一个非常经典且重要的问题：`markdown-it` 的渲染过程是 **同步** 的，但 `shiki` 的高亮过程是 **异步** 的（因为它需要异步加载不同语言的语法文件）。

要解决这个矛盾，我们只需要顶层 `await` 来初始化 `shiki`，然后把它作为高亮引擎提供给 `markdown-it`。其实也就是等他加载完再继续

```bash
pnpm add shiki
```

```typescript
import MarkdownIt from 'markdown-it'
import {createHighlighter} from 'shiki'

const highlighter = await createHighlighter({
	themes: ['nord', 'github-light'],
	langs: ['ts', 'js', 'json', 'vue', 'css', 'html', 'md']
})

// Shiki 准备好之后, 我们才创建 markdown-it 实例
export const md = new MarkdownIt({
	highlight(code, lang) {
        if (!lang || !highlighter.getLoadedLanguages().includes(lang as any)) {
    return `<pre class="shiki"><code>${code}</code></pre>`;
        }
        return highlighter.codeToHtml(code, { lang })
    }

})
```

现在我们只需要用这里的 md 对象替换 vite 插件里面的就好啦，很轻松，并且由于只有构建时引入一次，也不会影响客户端代码大小。

### 自定义组件解析

还记得之前我们说过 Vue 在这里的优势是 sfc 嘛，我们不妨将整篇文章作为 sfc 组件，利用 `@vue/compiler-sfc` 一把梭哈

当然也不是那么暴力，整体的思路是

原始文档 → 正则替换组件 → Markdown 渲染 → SFC 组件 → 最终渲染

我们就从简单的 `<Alert/>` 入手，首先是写好正则：

```typescript
// 注册的自定义语法/组件映射
    const customSyntaxMap = new Map<RegExp, (...args: any[]) => string>([
        // Alert 组件支持
        [/:::\s*(info|warning|success|danger)(?:\s+(.+?))?\s*\n(.*?)\n:::/gs, (_match: string, type: string, title: string, content: string) => {
            const titleAttr = title ? ` title="${title.trim()}"` : ''
            return `<Alert type="${type}"${titleAttr}>${content.trim()}</Alert>`
        }],
    ])
```

于是我们可以直接在 transform 钩子中替换与渲染：

我直接在注释中讲解吧

```typescript
transform(code: string, id: string) {
            if (!id.endsWith('.md')) {
                return null
            }

    		// 提取前置信息和最终文本
            const { data: frontmatter, content: markdownContent } = matter(code)

            let processedContent = markdownContent
            const components = new Set<string>()

            // 处理自定义语法
            for (const [regex, replacer] of customSyntaxMap) {
                processedContent = processedContent.replace(regex, (...args) => {
                    const result = replacer(...args)
                    const componentMatch = result.match(/<([A-Z][a-zA-Z0-9]*)/)
                    if (componentMatch) {
                        components.add(componentMatch[1])
                    }
                    return result
                })
            }

            // 分割内容，也就是每段逐个处理
            const parts: Array<{ type: 'markdown' | 'component', content: string }> = []
            const componentRegex = /<([A-Z][a-zA-Z0-9]*)[^>]*>.*?<\/\1>/gs

            let lastIndex = 0
            let match

            while ((match = componentRegex.exec(processedContent)) !== null) {
                if (match.index > lastIndex) {
                    const markdownPart = processedContent.slice(lastIndex, match.index).trim()
                    if (markdownPart) {
                        parts.push({ type: 'markdown', content: markdownPart })
                    }
                }
                parts.push({ type: 'component', content: match[0] })
                lastIndex = match.index + match[0].length
            }

            if (lastIndex < processedContent.length) {
                const markdownPart = processedContent.slice(lastIndex).trim()
                if (markdownPart) {
                    parts.push({ type: 'markdown', content: markdownPart })
                }
            }

            // 生成组件导入，拼接到最后 sfc 用
            const componentImports = Array.from(components)
                .map(name => `import ${name} from '/src/components/${name}.vue'`)
                .join('\n')

            // 生成模板内容
            const templateContent = parts.map(part => {
                if (part.type === 'component') {
                    return part.content
                } else {
                    const htmlContent = md.render(part.content)
                    return `<div class="markdown-content">${htmlContent}</div>`
                }
            }).join('\n    ')

            // 直接生成带布局的完整页面
            const vueComponent = `<script setup lang="ts">
                import Layout from '/src/layouts/Layout.vue'
                // 这里引入注册组件
                ${componentImports}
                
                const frontmatter = ${JSON.stringify(frontmatter)}
                </script>
                
                <template>
                  <Layout>
                    <div class="page-content">
                      <h1 v-if="frontmatter?.title" class="page-title">{{frontmatter.title}}</h1>
                      <div class="markdown-body">
                        ${templateContent}
                      </div>
                    </div>
                  </Layout>
                </template>
                
                <style scoped>
                // 对应的样式
                </style>`


            return {
                code: vueComponent
            }
        }
```

到这里，「手搓系列 01」就告一段落了。我们一起从零构建了一个完整的静态文档站，从最基础的 Vite 插件，到核心的 SSG 渲染，再到代码高亮和自定义组件解析。这个过程不仅是为了得到一个能用的轮子，更是为了深入理解这些习以为常的技术栈背后，每个环节是如何协同工作的。

最终的项目我还在慢慢开发，涉及到每个组件写一遍，还有 UI 和功能，精力真的不够，等到做完第一时间更新和开源。

---

手写一遍，才能真正掌握它的精髓。

如果你对这个系列感兴趣，可以长期关注 RSS。在接下来的文章里，我们还会继续探索更多有趣的前端轮子，比如打包工具、UI 库和前端基建等。让我们动手继续敲下去
