MacrosMeng's Material 3 Expressive Blog
Warning
本项目含有大量未经审查的 AI 生成代码。
一个基于 VitePress 2 的博客:带有 Material 3 Expressive 视觉层,博客列表、RSS 与 SEO 由插件生成,自定义首页通过 createContentLoader 读取同一份 frontmatter。
- VitePress 2、Vue 3、TypeScript、pnpm、Node 22+
@binarynoir/vitepress-blog:文章识别、博客索引、筛选、作者信息、文章导航、RSS、Open Graph / Twitter 卡片verve-tokens+ Material Color Utilities:M3 Expressive token 层(Tonal Spot 配色)- Fontsource:Google Sans Flex Variable + Noto Sans SC Variable
material-symbols:Material 图标字体markdown-it-mathjax3@4(VitePress 2 的 peer 依赖范围)用于 LaTeXmarkdown-it-github-alerts:> [!WARNING]形式的 GFM alerts- Shiki
catppuccin-mocha:代码块 - GFM 由 VitePress 默认 Markdown 管线提供
本仓库适合直接复制,而不是作为 npm 包安装。每个由模板创建的博客都拥有独立的内容、主题和部署流程。模板包含完整的 M3E 主题、示例文章、本地一言数据和 GitHub Pages workflow。
本项目使用 MPL 2.0 开源,示例文章使用 CC-BY 4.0 公开开放。你不需要将你的博客内容也使用 MPL 2.0 开放。
- VitePress 2 站点配置和完整的 M3E 主题
- 示例博客列表、文章、关于页和自定义首页
- 本地一言 JSON
pnpm锁文件和 Node 22 引擎声明scripts/verify.mjs,构建后冒烟检查- 项目站点部署所需的 base 路径工具
发布模板或创建新博客之前:
- Node.js
22或更高版本 - pnpm
10或更高版本 - Git
- GitHub 账号
- 可选:自定义域名
检查版本:
node --version
pnpm --version
git --version-
在模板仓库页面点击:
Use this template → Create a new repository -
填写仓库名,例如
my-blog,然后创建。如果想要干净的历史记录,不要同时初始化 README。 -
克隆新仓库:
git clone git@github.com:YOUR_NAME/my-blog.git cd my-blog -
安装依赖:
pnpm install
模板自带
pnpm-lock.yaml;日常开发请使用pnpm,不要使用 npm。 -
修改
.vitepress/config.ts顶部的站点级配置:const siteUrl = "https://username.github.io"; const siteTitle = "My Blog"; const siteDescription = "Notes on code, design and everything between."; // 根站点 / 自定义域名:"/"。 // GitHub Pages 项目站点:"/my-blog/"。 const base = process.env.VP_BASE || "/";
siteUrl必须是绝对源地址,不包含仓库路径。博客插件会按siteUrl + base + 页面路径生成 RSS、sitemap 和社交卡片的地址。 -
编辑同一文件中的
m3eConfig。至少需要替换:profile:名称、handle、身份、简介、头像、技能、CTAsocials:GitHub / Bilibili / Email / RSS 链接和 handlehome.hero:Hero 文案、按钮,以及右侧三个卡片home.latest:文章数量和各区块文案home.about:About 区块文案和链接footer:版权起始年份、ICP、页脚链接和标签oneLiner:本地 JSON 数据源和离线兜底文案
-
调整
themeConfig.nav和themeConfig.socialLinks的行为:nav定义导航栏中的页面。- 导航栏社交图标由带
navIcon的m3eConfig.socials条目自动生成。 themeConfig.logo指向 favicon 或品牌标识。
-
替换示例内容:
docs/blog/*.md 替换或删除示例文章 docs/blog/index.md 保留 blog-index frontmatter,修改简介 docs/about.md 替换为你的个人介绍 docs/public/avatar.svg 你的头像 docs/public/favicon.svg 你的 favicon docs/public/covers/* 文章封面 docs/public/hitokoto.json 你的一言数据关于页不能包含
blog:字段,它被有意排除在博客文章之外。 -
如果使用许可证卡片,为每篇文章添加 license 字段:
license: CC-BY-4.0 # licenseUrl: https://example.com/license
-
如果不想使用默认的 Material 蓝紫色,修改主题种子色:
// scripts/generate-theme.mjs const seed = "#6750A4";
然后重新生成 tokens:
pnpm theme
-
启动开发服务器:
pnpm dev
-
在本地检查站点,至少访问:
/:自定义首页/blog/:生成的列表、筛选和 RSS- 任意一篇文章:作者信息、上一篇/下一篇、许可证卡片
/about:Profile Card 和社交链接/blog/markdown-and-math:MathJax、GFM alerts 和代码块
-
运行生产构建和冒烟检查:
pnpm build pnpm verify
-
提交你的站点内容:
git add . git commit -m "Customize blog" git push
-
在新仓库中打开:
Settings → Pages -
将 Source 设为 GitHub Actions。
-
如果默认分支不是
main,修改:on: push: branches: [main]
-
选择正确的 URL / base 组合:
部署目标 siteUrlbase/VP_BASE用户站点: username.github.iohttps://username.github.io/或留空项目站点: username.github.io/my-bloghttps://username.github.io/my-blog/自定义域名: blog.example.comhttps://blog.example.com/或留空 -
如果是项目站点,添加仓库 Actions 变量:
Settings → Secrets and variables → Actions → Variables Name: VP_BASE Value: /my-blog/workflow 会把
VP_BASE传入构建。自定义组件中的内部链接和 public 图片通过.vitepress/theme/utils/url.ts里的resolveHref()/resolveAsset()处理,因此会自动带上相同的 base 路径。 -
如果使用自定义域名,在 Settings → Pages → Custom domain 中设置,并在
siteUrl中使用完整的自定义域名,同时保持base: "/"。如果 GitHub 要求CNAME文件,在docs/public/CNAME中只写入域名:blog.example.com -
首次 workflow 运行成功后,部署地址会出现在:
- Actions → Deploy to GitHub Pages
- Settings → Pages
| 现象 | 处理方式 |
|---|---|
| 部署后 CSS、图片或字体 404 | base 与仓库路径不一致。设置 VP_BASE=/repo/ 或修改 base。 |
| 首页正常,但文章链接 404 | 检查 siteUrl 和 base;siteUrl 是源地址,base 包含仓库路径。 |
| Feed 或 OG 图片地址指向了错误的主机 | 修改 siteUrl 后重新构建。 |
Actions 中 pnpm install --frozen-lockfile 失败 |
本地修改依赖后执行 pnpm install,提交 pnpm-lock.yaml 并推送。 |
| 部署任务因权限失败 | 将 Pages 的 Source 设为 GitHub Actions,并保留 workflow 中的 pages: write / id-token: write 权限。 |
| 关于页出现在博客列表中 | 删除它的 blog: frontmatter 字段。 |
| 首页文章卡片作者显示错误 | 检查文章 author 字段是否匹配 m3eConfig.authors,或直接使用显示名称/对象。 |
| 修改主题后没有生效 | 执行 pnpm theme,然后重启 pnpm dev;tokens.css 是生成文件。 |
| 只显示 VitePress 默认主题 | 确认新仓库复制了 .vitepress/theme/,而不是只复制了 docs/。 |
由模板生成的仓库是独立副本。如果之后改进了模板,并希望把改动同步到已有博客,可以把模板添加为第二个 remote,然后有意地合并:
git remote add template git@github.com:YOUR_NAME/vitepress-m3e-blog-template.git
git fetch template
git merge template/main --allow-unrelated-histories重点检查 .vitepress/config.ts、m3eConfig、docs/ 和静态资源中的冲突。不要把模板的示例内容盲目合并进已经发布的博客。
- 替换
siteUrl、siteTitle、siteDescription和base - 替换
m3eConfig.profile - 替换
m3eConfig.socials - 替换
m3eConfig.home.hero.stats为自己的三个卡片 - 替换
m3eConfig.footer的链接、ICP 和版权设置 - 替换
docs/blog/*.md示例文章 - 替换
docs/about.md - 替换
docs/public/avatar.svg、favicon.svg、covers/和hitokoto.json - 更新
themeConfig.nav - 选择主题种子色并执行
pnpm theme - 添加项目许可证
- 使用 GitHub Actions 启用 Pages
- 执行
pnpm build和pnpm verify
M33EBlog/
├─ docs/
│ ├─ index.md # layout: m3e-home
│ ├─ about.md # 无 `blog:` 字段 —— 不是文章
│ ├─ blog/
│ │ ├─ index.md # blog-index: { scope: site }
│ │ ├─ hello-m3e.md
│ │ ├─ markdown-and-math.md
│ │ └─ content-pipeline.md
│ └─ public/
│ ├─ avatar.svg
│ ├─ favicon.svg
│ ├─ hitokoto.json # 本地一言数据源
│ └─ covers/{m3e,math,rss}.svg
├─ .vitepress/
│ ├─ config.ts # withBlog + markdown + MathJax + nav
│ └─ theme/
│ ├─ index.ts # theme-without-fonts、字体、图标、useBlog()
│ ├─ Layout.vue # m3e-home 切换 + LicenseCard/SiteFooter 插槽
│ ├─ layouts/M3EHome.vue # hero + 一言 + 文章流 + about 卡片
│ ├─ components/
│ │ ├─ M3Button.vue
│ │ ├─ PostCard.vue
│ │ ├─ ProfileCard.vue
│ │ ├─ SocialLinks.vue
│ │ ├─ OneLiner.vue
│ │ ├─ LicenseCard.vue
│ │ └─ SiteFooter.vue
│ ├─ composables/
│ │ └─ useM3EConfig.ts # 读取 themeConfig.m3e 并合并默认值
│ ├─ config/
│ │ └─ defaults.ts # 默认内容 + 深度合并
│ ├─ data/
│ │ └─ homePosts.data.ts # createContentLoader('blog/**/*.md')
│ ├─ types/
│ │ ├─ post.ts
│ │ └─ site-config.ts # M3EConfig + DeepPartial
│ ├─ utils/
│ │ ├─ date.ts
│ │ └─ url.ts # 感知 base 的 resolveHref/resolveAsset
│ └─ styles/
│ ├─ tokens.css # 生成文件,不要手动编辑
│ ├─ theme.css # M3 ↔ VitePress 桥接、排版、导航
│ └─ blog.css # 插件 CSS 变量/类名皮肤
├─ .github/workflows/
│ └─ deploy.yml # GitHub Pages 部署
├─ scripts/
│ ├─ generate-theme.mjs # seed #6750A4 → tokens.css
│ └─ verify.mjs # 构建后冒烟检查
├─ package.json
└─ pnpm-lock.yaml
theme/index.ts 引入 vitepress/theme-without-fonts。这是默认主题不会打包 Inter 的原因。字体由 Fontsource 自托管,并在 theme.css 中指定:
--vp-font-family-base:
"Google Sans Flex Variable", "Noto Sans SC Variable", "Noto Sans SC",
"PingFang SC", "Microsoft YaHei", sans-serif;withBlog() 包装 VitePress 配置,并与 markdown.config、transformHead、transformPageData、buildEnd 和 Vite 插件组合。我们不重新实现其中任何一项。生成的博客列表是普通 Markdown/HTML,因此 blog.css 只重映射其公开的 --vp-blog-* 变量,并为文档化的 vp-blog-* 类名换肤。
docs/index.md 使用 layout: m3e-home,Layout.vue 切换到 layouts/M3EHome.vue。其中 data/homePosts.data.ts 调用:
createContentLoader("blog/**/*.md", { transform });它会规范化 title、date、summary、image、author、tags、category 和 pinned,字段名与插件读取的一致。作者 key 在渲染时由 PostCard 通过 m3e.authors 解析,因此首页文章流和插件卡片都会显示配置中的作者名。首页使用的图片应写成 public 路径(/covers/foo.svg)或绝对 URL:插件可以复制相对路径的 frontmatter 图片,但自定义 loader 在构建时无法得知 Vite 生成的哈希文件名。
自定义首页仍然需要真实的 VitePress 导航栏,包括移动端全屏菜单、溢出菜单、搜索和外观切换。Layout.vue 复用内部的 VPNav 组件,其他页面使用完整的默认 DefaultTheme.Layout。没有重写任何导航逻辑。CSS 只映射导航栏的表面色、高度、背景模糊变量,并调整视觉状态。
风险 / 假设: 引入 vitepress/dist/client/theme-default/components/VPNav.vue 属于内部路径。VitePress 版本在 package.json 中固定;升级 VitePress 时需要重新检查该引入。
原计划使用 doc-footer-before,但 VitePress 会把该插槽包在 VPDocFooter 里,而 VPDocFooter 只有在存在 editLink、lastUpdated 或 VitePress 上一篇/下一篇时才会渲染。博客插件用自己的 .vp-blog-post-nav 替换了 VitePress 的上一篇/下一篇,因此文章页上的页脚(以及其中的许可证插槽)会消失。这一结论已对照已安装的 VitePress 源码验证。
因此许可证卡片通过无条件渲染的 doc-after 插槽注入。它仍然位于每篇文章底部、插件的新旧文章导航之后。这是对最初方案的刻意调整,并不表示插件提供了新的插槽。
about.md 没有 blog: 字段。它使用文档布局,设置 aside: false / outline: false,渲染 ProfileCard 和独立的 SocialLinks 行,永远不会出现在 feed 或生成的博客列表中。
博客页和文章页会获得每博客一份的 RSS feed(/blog/feed.xml)、<link rel="alternate">、Open Graph 和 Twitter 卡片标签。不存在第二套 feed 实现。
- 配色:种子色
#6750A4(Material 蓝紫色),由 Material Color Utilities 通过verve-tokens生成,使用 Tonal Spot 方案。它比早期的 expressive 方案彩度更低;secondary/tertiary 角色是柔和的紫色/粉色,而不是绿色。 - 所有组件颜色使用
--md-sys-color-*;圆角使用--md-sys-shape-corner-*;间距使用生成的--md-sys-spacing-*比例(space100 = 8dp,并包含 2/4/6/10 与 12/20 的嵌套步进)。 - 动效使用
--md-sys-motion-*token。形状变形使用 effects 弹簧(damping 1.0,无回弹),因为圆角插值低于零时会被钳制成直角;空间弹簧仍用于 transform。别名为--m3e-shape-morph-duration/--m3e-shape-morph-easing。 - 卡片基础圆角为 12dp(
medium),首页特色卡片使用 expressive 的 20dp;按钮触控目标为 40/48/56px,默认全圆角,按下时变为 8/12/16dp。 - 图标使用 Material Symbols(outlined),通过
material-symbols自托管。 - 生成的
tokens.css会提交到仓库。pnpm theme会重新生成它。verve-tokens@0.1.0依赖的 Material Color Utilities ESM 构建使用了无扩展名 import,因此生成脚本通过tsx运行(Node 原生 ESM loader 会拒绝这种写法)。
创建 docs/blog/my-post.md:
---
title: My post
description: 显示在文档 head 中,并作为 summary 的兜底值。
blog:
date: 2026-11-01 10:00
summary: 用于卡片、feed 和社交卡片的一两句话。
image: /covers/my-cover.svg # public 路径或绝对 URL
imageAlt: 封面描述
author: meng # m3e.authors 中的 key,也可以是显示名称/对象
tags: [VitePress, Design]
category: Engineering
scope: site # 同时出现在主博客列表
pinned: false
cover: false # 在作者信息下方显示封面
license: CC-BY-4.0
---首页会自动收录。插件列表也会自动收录。只有当 license 是 LicenseCard.vue 中映射的 key(或任意自定义字符串并附带 licenseUrl)时,许可证卡片才会显示。
所有自定义站点内容都放在 .vitepress/config.ts 的 m3eConfig 对象中。它的类型是 DeepPartial<M3EConfig>,因此只需覆盖需要的字段;resolveM3EConfig() 会把它与 .vitepress/theme/config/defaults.ts 中的默认值深度合并。VitePress 已经拥有的值(标题、描述、站点 URL、logo、导航、社交链接)不在这里重复。
const m3eConfig = {
profile: {
name: "Meng",
handle: "M33E",
avatar: "/avatar.svg",
skills: ["VitePress", "Vue"],
cta: { label: "认识我", href: "/about" },
},
socials: [
{
label: "GitHub",
href: "https://github.com/",
icon: "code", // 关于页/页脚使用 Material Symbols
navIcon: "github", // 可选:导航栏使用 Simple Icons
},
],
home: {
hero: {
eyebrow: "M33E · NOTES ON CRAFT",
titleLead: "把想法写成",
titleAccent: "可以运行的东西",
titleTail: "。",
description: "前端工程、设计系统与 Material 3 Expressive。",
actions: [...],
stats: [
{ icon: "edit_note", value: "{postCount}", label: "篇公开笔记" },
{
icon: "interests",
value: "M3E",
label: "Material 3 Expressive",
tone: "tertiary",
},
{
icon: "rss_feed",
value: "RSS 已开启",
label: "订阅全部文章",
href: "/blog/feed.xml",
tone: "surface",
},
],
},
latest: { limit: 7, ... },
about: { ... },
},
footer: {
since: 2024,
icp: "",
links: [...],
},
oneLiner: {
source: "/hitokoto.json",
fallback: { text: "慢慢来,比较快。", from: "M33E" },
},
} satisfies DeepPartial<M3EConfig>;profile:头像、名称、身份、简介、技能和紧凑卡片 CTAsocials:关于页和页脚数据;同时生成导航栏使用的themeConfig.socialLinksauthors:供博客插件和首页卡片作者解析使用home.hero:文案、CTA 按钮和右侧三个卡片。{postCount}会在运行时替换home.latest:eyebrow、标题、文章数量、操作按钮、空状态home.about:eyebrow、标题、正文、操作按钮footer:起始年份、ICP、链接、社交标签、built-with 文案和版权后缀oneLiner:本地文件路径、标签和离线兜底
以下值有意不在 m3eConfig 中重复:
| 值 | VitePress 来源 |
|---|---|
| 站点标题 / 描述 | title、description |
| 站点 URL | sitemap.hostname(同一个值会传给博客插件) |
| 页脚品牌名 / 描述 | site.title、site.description |
| 页脚 Logo | themeConfig.logo |
| 导航栏链接 | themeConfig.nav |
| 导航栏社交图标 | 由 m3e.socials 派生到 themeConfig.socialLinks |
| 目录、搜索、外观、文档页脚 | 原生 themeConfig 选项 |
保留完整的 socials 数组,是因为 VitePress 的 socialLinks 只支持 icon / link / ariaLabel;关于页和页脚还需要显示名称、handle 和 Material Symbols 图标。themeConfig.socialLinks 由它生成,因此导航栏数据没有被配置两次。
修改 scripts/generate-theme.mjs 中的 seed 或 variant(当前为 tonalSpot),然后执行:
pnpm theme生成的 tokens.css 同时包含亮色和暗色 M3 角色,以及 M3 间距比例。无需编辑任何组件文件。可以先用 Material Theme Builder 预览种子色效果。
.vitepress/config.ts 中把 markdown.theme 设为 Shiki 的 catppuccin-mocha。代码块外框的官方 Mocha 颜色值以 --m3e-code-* 的形式放在 theme.css 中(这是唯一的非 M3 颜色常量,因为语法主题是外部调色板)。
markdown-it-github-alerts 已启用,使用中文标题和 Material Symbols 图标:
> [!NOTE]
> 需要记住的信息。
> [!TIP]
> 有用的建议。
> [!WARNING]
> 有风险的操作。
> [!CAUTION]
> 破坏性操作。VitePress 的 ::: tip / ::: warning / ::: danger / ::: details 容器使用同一套 callout 样式。warning/caution 映射到 M3 的 error 配色,因为 M3 没有专门的 warning 角色。
文档布局使用比 VitePress 默认更窄的边距和更宽的正文列:
--m3e-doc-container-max: 76rem--m3e-doc-content-max: 56rem- 桌面端行内 padding:24px;移动端:20px
如果希望阅读宽度更宽或更窄,在 .vitepress/theme/styles/theme.css 中修改这些变量。表格和代码块在窄屏上横向滚动,而不是撑宽页面。
Fontsource 的引入位于 .vitepress/theme/index.ts。更新引入语句以及 styles/theme.css 中的 --vp-font-family-base / --vp-font-family-mono 字体栈。保持使用 vitepress/theme-without-fonts,确保 Inter 永远不会被打包。
- Hero 文案、两个 CTA 按钮和右侧三个卡片:
.vitepress/config.ts中的m3eConfig.home.hero - 文章数量:
m3eConfig.home.latest.limit - Latest/About 区块标题和操作按钮:
m3eConfig.home.latest/m3eConfig.home.about - About 简介:
<ProfileCard compact />,数据来自m3eConfig.profile - 一言数据:
m3eConfig.oneLiner.source,默认docs/public/hitokoto.json;组件会请求这个本地文件,并保留内置兜底。没有外部 quote API。
编辑 docs/public/hitokoto.json:
{
"quotes": [
{
"text": "慢慢来,比较快。",
"from": "M33E"
}
]
}OneLiner.vue 通过 withBase() 请求 m3eConfig.oneLiner.source,随机选择一条,并在点击刷新按钮时避免重复当前内容。如果文件缺失,配置的兜底文案会一直显示。不会请求任何外部 API。
当前实现的是复杂页脚 SiteFooter,通过 Layout.vue 的 layout-bottom 注入。若要改用 VitePress 的简单页脚:
- 从
Layout.vue中移除#layout-bottom下的<SiteFooter />(以及M3EHome.vue中的<SiteFooter />)。 - 在
themeConfig中添加:
footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2024–2026 M33E Blog'
}不要同时启用两者,否则页面会显示两个页脚。
桌面端导航和移动端导航都是默认主题。在 themeConfig.nav 中增删项目;溢出到 ⋯ 菜单、全屏汉堡菜单、搜索、外观和社交链接都由 VitePress 处理。theme.css 只修改表面色、高度、背景模糊、悬停/按下胶囊形状和移动端行形状。
只使用 CSS。编辑 styles/blog.css:
- 插件变量:
--vp-blog-card-*、--vp-blog-tag-*、--vp-blog-accent - 类名:
.vp-blog-list、.vp-blog-card、.vp-blog-browse、.vp-blog-byline、.vp-blog-post-nav
新旧文章组件的 DOM 由插件负责。可以重新设计视觉样式,但不能在不 fork 插件的情况下用 Vue 组件替换它。“全部文章”链接同样来自插件。
pnpm build
pnpm verify
npm run build # 同样可用;脚本内部调用 pnpm
pnpm preview --port 4173pnpm verify 检查:
- 首页使用
m3e-home,渲染 hero/一言/PostCard/about 简介/页脚 - 默认导航栏/汉堡菜单存在
- 关于页不在 feed 中,也没有生成的博客列表
- 博客索引包含插件卡片、browse 面板和 RSS 链接
- 每个页面都链接主 feed
- 文章 head 包含 OG/Twitter 标签
- 文章包含作者信息 + 插件文章导航 + 许可证卡片
- MathJax 输出存在
- GitHub alerts 使用 Material Symbols 渲染,代码块使用 Catppuccin Mocha
- 一言由本地
hitokoto.json提供,没有 hitokoto 请求 /blog/feed.xml存在,并包含三篇文章- CSS
@font-face声明了 Google Sans Flex Variable、Noto Sans SC Variable 和 Material Symbols,且没有 Inter - 生成的 token 文件使用 Tonal Spot,并暴露间距比例
手动检查:
- 390px 视口:汉堡菜单打开全屏导航;Escape/关闭后焦点返回;三个导航项可见。
- 1440px:导航项保持在导航栏上;桌面导航栏有磨砂效果;hero 和文章网格没有横向溢出。
- 切换外观:
.dark会替换 M3 角色;--md-sys-color-primary变为暗色方案的值。 - 打开
/blog/markdown-and-math:行内公式和块级公式正常渲染,构建输出和浏览器控制台没有Failed to resolve component: mjx-*警告。 - 打开
/about:Profile Card + 一行社交链接;没有博客卡片。
- 不虚构插件 API 或插槽。
- 不声称插件的生成列表或新旧文章 DOM 可以被 Vue 组件替换。只做 CSS 换肤。
- 首页不调用插件列表组件;它通过
createContentLoader读取同一份 frontmatter。 - 组件样式中没有硬编码颜色、圆角、间距或弹簧时长。调色板值只存在于生成的 token 文件中;间距使用
--md-sys-spacing-*。唯一的外部调色板常量是 Catppuccin Mocha 代码块颜色。插画 SVG 使用调色板值,因为外部 SVG 无法读取页面 CSS 变量。 - 不 fork
@binarynoir/vitepress-blog。只有在必须结构性修改文章导航 DOM,或首页必须共享插件内部数据管线时,才值得 fork。