前置:本文用到的技术栈与部署方式已就绪 → Astro 7 + AstroPaper v6 主题 + Tailwind 4 + Pagefind 搜索 + KaTeX 公式 + Vercel 部署。基础设施由模板与配置管,写文章本身只需要 Markdown。
📌 文章页右侧已上线浮动目录,会自动从 h2/h3 生成;当前滚到的章节会高亮,桌面端常驻、平板/手机点击右下角按钮展开。
文件放哪#
src/content/posts/
├── astro/ ← 按主题分类(子文件夹名 = URL 段)
│ ├── content-collections.md
│ └── tailwind-v4.md
├── hexo/
│ └── vs-astro.md
├── setup/ ← 博客搭建相关文章
│ └── how-to-write-a-post.md
├── _drafts/ ← 下划线开头 = 草稿,不发布
│ └── wip-article.md
└── welcome.md ← 没分类的文章
| 情况 | 处理 |
|---|---|
| 文章主题明确 | 进对应子目录 |
| 文章跨多个主题 | 选最相关的,或放根目录 |
| 还没想好分类 | 先放根目录 src/content/posts/,以后再调整 |
| 草稿 / 半成品 | 必须放 _drafts/(下划线开头) |
目录名建议用英文或拼音。中文目录名也能用,但 URL 里会变成拼音或乱码。
文件名建议英文 + kebab-case:
# 推荐
touch src/content/posts/astro/my-new-feature.md
touch src/content/posts/setup/how-to-write-a-post.md
# 不推荐
touch src/content/posts/我的文章.md
frontmatter#
每个 .md 文件顶部必须有 YAML frontmatter(--- 之间):
---
title: "我的新文章标题"
description: "一两句简介,用于 SEO meta 和 RSS 摘要,必填。"
pubDatetime: 2026-10-08
tags: ["astro", "blog"]
featured: false
---
必填字段#
| 字段 | 类型 | 示例 |
|---|---|---|
title | string | "如何写一篇博客" |
description | string | "Astro 博客完整教程。" |
pubDatetime | Date | 2026-10-08(ISO 8601 格式) |
可选字段#
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
author | string | config.site.author | 文章作者 |
modDatetime | Date|null | null | 最后修改时间(显示「更新于」) |
tags | string[] | ["others"] | 标签数组,至少 1 个 |
featured | boolean | false | true 时进首页「精选」section |
ogImage | image|string | — | 自定义 OG 图,优先级高于全局 |
canonicalURL | string | — | SEO 规范 URL |
hideEditPost | boolean | false | 单独隐藏「编辑此页」 |
draft | boolean | false | 标记草稿 |
timezone | string | Asia/Shanghai | 发布时间对应时区(IANA) |
正文 Markdown 写法#
基础语法#
## 二级标题
### 三级标题
**加粗**、*斜体*、`行内代码`
- 无序列表
- 列表项
- 嵌套列表
1. 有序列表
2. 第二项
> 引用块
[外部链接](https://astro.build/)
[内部文章链接](/posts/welcome/)
标题自动锚点#
每个 H2/H3 标题自动带 # 链接(鼠标 hover 时显示):
## 我的章节 ← 渲染为 <h2 id="我的章节">我的章节 #</h2>
### 子章节 ← 渲染为 <h3 id="子章节">子章节 #</h3>
深链接形如 https://<your-domain>/posts/xxx/#我的章节。
提示块(callout)#
rehype-callouts 渲染的 5 种彩色块。
源码:
> [!NOTE]
> 这是 Note(蓝色)
> [!TIP]
> 这是 Tip(绿色)
> [!WARNING]
> 这是 Warning(黄色)
> [!IMPORTANT]
> 这是 Important(紫色)
> [!CAUTION]
> 这是 Caution(红色)
实际渲染:
这是 Note(蓝色)
适合说明背景、补充信息。
这是 Tip(绿色)
适合分享小技巧、推荐做法。
这是 Warning(黄色)
适合提示有坑、需要谨慎。
这是 Important(紫色)
适合强调关键步骤。
这是 Caution(红色)
适合警告危险操作。
代码块#
Shiki 提供语法高亮(双主题:min-light + night-owl):
```ts
const greeting: string = "hello";
console.log(greeting);
```
```python
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
```
```bash
npm install astro
```
支持的语言:完整列表。常见的有 ts / js / tsx / jsx / python / bash / json / yaml / css / html / md 等。
行内 diff / highlight 标记:
```ts
function foo() {
console.log("hello"); // [!code --]
console.log("hi"); // [!code ++]
console.log("hey"); // [!code highlight]
}
```
效果:
// [!code --]整行变红(删除)// [!code ++]整行变绿(新增)// [!code highlight]整行变黄高亮
表格#
| 列 1 | 列 2 | 列 3 |
| --- | --- | --- |
| A1 | A2 | A3 |
| B1 | B2 | B3 |
渲染:
| 列 1 | 列 2 | 列 3 |
|---|---|---|
| A1 | A2 | A3 |
| B1 | B2 | B3 |
对齐方式(用 : 标记):
| 左对齐 | 居中 | 右对齐 |
| :--- | :---: | ---: |
| A1 | A2 | A3 |
| B1 | B2 | B3 |
| 左对齐 | 居中 | 右对齐 |
|---|---|---|
| A1 | A2 | A3 |
| B1 | B2 | B3 |
数学公式#
本站用 KaTeX 渲染(remark-math + rehype-katex)。
行内公式:用 $ ... $ 包起来(前后留空格或紧贴非字母字符,避免误识别)。
源码:
勾股定理:$a^2 + b^2 = c^2$。
渲染:勾股定理:。
块级公式:用 $$ ... $$ 包,独立成行(前后空一行)。
源码:
$$
f(x) = \int_{-\infty}^{\infty} \hat{f}(\xi) e^{2\pi i \xi x} d\xi
$$
渲染:
多行公式对齐:用 aligned 环境。
源码:
$$
\begin{aligned}
f(x) &= x^2 + 2x + 1 \\
&= (x+1)^2
\end{aligned}
$$
渲染:
注意:本站 remark-math 配置只识别
$...$和$$...$$。LaTeX 原生的\(...\)/\[...\]不会触发公式渲染,会被当作普通文本输出。常用语法速查:KaTeX Supported Functions
图片#
Astro 项目里的图片分两类:
| 路径 | 处理方式 | 适合场景 |
|---|---|---|
public/img/... | 原样复制到 dist | 文章配图、引用频率低的素材 |
src/assets/... | 被 Vite 处理(hash 化、压缩) | 组件复用图、必须 import |
写文章时永远用
public/。src/assets/里的图必须先import才能用,.md文件不支持import语句(.mdx才支持)。
步骤:
-
把图片放到
public/img/posts/<主题目录>/mkdir -p public/img/posts/setup cp ~/Downloads/screenshot.png public/img/posts/setup/ -
在 markdown 里用相对路径引用

带标题的图(图说):

<img> 标签(可控制尺寸、对齐):
<img
src="/img/posts/setup/screenshot.png"
alt="截图说明"
width="800"
loading="lazy"
class="mx-auto rounded-lg shadow-md"
/>
loading="lazy" 让图片懒加载,长文章必备。
音频#
音频文件放 public/audio/,然后在 markdown 里直接写 HTML5 <audio> 标签。
1. 把音频放进 public/
mkdir -p public/audio
cp ~/Downloads/podcast-ep01.mp3 public/audio/
2. 源码(markdown 里的写法)
<audio src="/audio/podcast-ep01.mp3" controls preload="metadata"></audio>
3. 实际渲染(CommonMark 限制:HTML 块级标签必须在白名单内;不在的标签如 <audio> 必须被 <p> 或 <div> 包裹才会被识别)
上面会显示浏览器原生 audio 控件,但因为这是教程示例文件,路径
/audio/example.mp3并不存在,点击播放会 404。你的真实部署中这个标签会正常工作。
<audio> 常用属性
| 属性 | 说明 |
|---|---|
controls | 显示播放控件(必加) |
preload="none" | 不预加载,按需播放 |
preload="metadata" | 预加载元数据(时长、封面等),省流量(推荐) |
preload="auto" | 整个文件预加载 |
loop | 循环播放 |
autoplay | 自动播放(几乎所有浏览器都禁用 autoplay,慎用) |
带标题的音频(用 <figure>)
<figure>
<figcaption>第 1 期:建站流水账</figcaption>
<audio src="/audio/podcast-ep01.mp3" controls preload="metadata"></audio>
</figure>
视频#
视频文件放 public/video/,markdown 里直接写 HTML5 <video> 标签。
1. 把视频放进 public/
mkdir -p public/video
cp ~/Downloads/demo.mp4 public/video/
2. 源码
<video src="/video/demo.mp4" controls preload="metadata" width="800"></video>
3. 实际渲染
同样,这是教程示例,文件不存在仅供看控件。
<video> 常用属性
| 属性 | 说明 |
|---|---|
controls | 显示播放控件 |
width / height | 设定宽高,避免布局抖动(推荐两个都设) |
preload="metadata" | 预加载元数据 |
loop | 循环播放 |
muted autoplay playsinline | 静音自动播放(移动端必备) |
poster="..." | 视频封面图(强烈推荐,海报图) |
多格式 fallback(更稳):浏览器自动选能播的格式。
<video controls preload="metadata" width="800" poster="/img/posts/setup/video-cover.png">
<source src="/video/demo.webm" type="video/webm" />
<source src="/video/demo.mp4" type="video/mp4" />
你的浏览器不支持 video 标签。
</video>
外部视频(YouTube / B 站)直接嵌入 iframe:
<iframe
src="https://www.youtube.com/embed/VIDEO_ID"
width="800"
height="450"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
本地预览#
npm run dev
# 等价于:astro dev
# 默认监听 http://localhost:4321/
打开 http://localhost:4321/posts/<主题>/<文件名>/ 即可访问你刚写的文章。
- 实时保存:写 markdown 时 dev server 自动重载
- dev mode 搜索有 warning:搜索功能可用,但 pagefind 会显示「DEV mode」警告。生产环境自动消失
- 第一次访问会重建 pagefind 索引
构建 + 部署#
# 1. 构建(包含类型检查 + pagefind 索引生成)
npm run build
这条命令等价于:
astro check && astro build && pagefind --site dist && cp -r dist/pagefind public/
四步依次执行:
- astro check:TypeScript 类型检查 + Astro 诊断
- astro build:生成静态文件到
dist/ - pagefind:扫描
dist/生成搜索索引到dist/pagefind/ - cp:把 pagefind 索引复制到
public/pagefind/,下次 dev 启动就能搜
构建成功后产物大小约几 MB(5 篇文章约 1-2 MB)。
部署到 Vercel(已配置好):
git add .
git commit -m "post: <主题>/<文章名>"
git push origin main # 30 秒后线上自动更新
Vercel 看 GitHub webhook 触发部署,跟 git author email 无关。
常见错误#
❌ pubDatetime 缺省或格式错#
Error: pubDatetime is required
修复:
pubDatetime: 2026-10-08 # ✅
pubDatetime: "2026-10-08" # ✅
pubDatetime: 2026-10-08 12:30 # ✅
pubDatetime: "10/08/2026" # ❌ 格式错
❌ 图片 404#
GET /img/posts/foo/bar.jpg → 404
修复:
# 确认文件存在
ls public/img/posts/foo/bar.jpg
# 不存在就创建 + 复制
mkdir -p public/img/posts/foo
cp /path/to/source.jpg public/img/posts/foo/bar.jpg
❌ 音频 / 视频不播放#
排查顺序:
- 浏览器直接访问
/audio/xxx.mp3,看 200 还是 404 - 文件 MIME 类型对吗(
.mp3/.mp4/.webm) - dev server 缓存:删
node_modules/.vite重启
❌ 公式显示源码不渲染#
$E=mc^2$ 渲染为 E=mc^2(纯文本),说明 remark-math / rehype-katex 没装或没配。
修复:
npm install remark-math rehype-katex katex
astro.config.mjs 的 markdown.processor.unified 加上:
remarkPlugins: [..., remarkMath],
rehypePlugins: [..., rehypeKatex],
Layout.astro 顶部加:
import "katex/dist/katex.min.css";
❌ tags 为空#
tags: [] # ❌ 不会被接受
tags: ["others"] # 默认值,会归到「其他」
建议至少 1 个有意义的标签。
❌ frontmatter 引号 / 冒号#
title: 我的标题 # ✅ 无特殊字符时
title: "我的标题" # ✅ 推荐加引号
title: '我的标题' # ✅
title: my: title with colon # ❌ 解析错
经验法则:标题、描述、含特殊字符的字段都加引号。
引入远程表情包(BlobCatPlus 为例)#
以 BlobCatPlus 表情包 为例,演示 3 种引入方式。
先看 JSON 结构#
{
"blobcatplus": {
"type": "image",
"container": [
{ "text": "cats", "icon": "<img src='https://cdn.jsdelivr.net/gh/infinitesum/Twikoo-emoji@master/Blob/cats.png'>" },
{ "text": "cry", "icon": "<img src='https://cdn.jsdelivr.net/gh/infinitesum/Twikoo-emoji@master/Blob/blobcatcry.png'>" }
]
}
}
关键:每条记录的 icon 字段是个现成的 <img> 字符串,不是裸 URL 。
方式 1:单图直引(最简单,零代码)#
从 JSON 里挑个图,提取 icon 里的 src,直接用 markdown 写:


适用场景:只需插 1-2 个表情时。缺点:要手动挑 + 复制粘贴,75 个表情会抄到手软。
BlobCatPlus(75 个 blobcat(已标注常见情绪))#
| 表情 | 代码 | 说明 |
|---|---|---|
 | 猫猫 | |
 | 害羞喵 | |
 | 假装 | |
 | 搞怪 | |
 | 晕乎乎 | |
 | 哭了 | |
 | 阵亡 | |
 | 抡大锤 | |
 | 哇哦 | |
 | 手织汗 | |
 | 尖叫 | |
 | 紧张 | |
 | 敲代码 | |
 | 蹦跶 | |
 | 舒适微笑 | |
 | 睡了 | |
 | 晚安 | |
 | 病娇 | |
 | 已认证 | |
 | 嗯… | |
 | 胜利 | |
 | 思考 | |
 | 蹭蹭 | |
 | 抓狂 | |
 | 擦汗 | |
 | 震惊 | |
 | 害怕 | |
 | 一拳 | |
 | 伸手求安慰 | |
 | 阅读 | |
 | 送红礼 | |
 | 警察 | |
 | 拍照 | |
 | 躲猫猫 | |
 | 偷看 | |
 | 张嘴 | |
 | 不是吧 | |
 | 吃吃 | |
 | 淡定 | |
 | 融化 | |
 | 融化了 | |
 | 爱 | |
 | 亲亲 | |
 | 击掌 | |
 | 骄傲心 | |
 | 心心 | |
 | 戴耳机 | |
 | 垃圾堆 | |
 | 斗鸡眼哭 | |
 | 彩虹 | |
 | 送花 | |
 | 翻桌 | |
 | 比枪 | |
 | 害怕 | |
 | 捂脸 | |
 | 不安 | |
 | 死了 | |
 | 死亡 | |
 | 哭 | |
 | 舒适汗 | |
 | 舒适 | |
 | 舒适泪 | |
 | 咖啡 | |
 | 关笼子 | |
 | 箱子 | |
 | 害羞 | |
 | 生气 | |
 | alternative | |
 | 瞪眼 | |
 | 挥手 | |
 | 伸手 | |
 | 彩虹 | |
 | 心碎 | |
 | 比心 | |
 | 求关注(反) |
方式 2:Astro 组件 + build 时静态 fetch(推荐)#
第 1 步:新建 src/components/EmojiGrid.astro
---
type Props = {
/** 表情包 JSON 远程 URL */
src?: string;
/** 展示几个,默认 20 */
limit?: number;
};
const {
src = "https://cdn.jsdelivr.net/gh/infinitesum/Twikoo-emoji@master/blobcatplus.json",
limit = 20,
} = Astro.props;
// build 期间拉取一次,输出静态 HTML
const res = await fetch(src);
if (!res.ok) throw new Error(`Failed to fetch ${src}: ${res.status}`);
const data = (await res.json()) as {
aru: { container: { text: string; icon: string }[] };
};
const items = data.aru.container.slice(0, limit);
---
<ul class="my-6 grid grid-cols-8 gap-2 sm:grid-cols-10 md:grid-cols-12">
{items.map(({ text, icon }) => (
<li class="aspect-square overflow-hidden rounded" title={text} set:html={icon} />
))}
</ul>
第 2 步:把文章后缀从 .md 改成 .mdx(仅这一篇需要组件的文章需要 MDX)
第 3 步:文章里写
---
title: "小豆泥表情包"
description: "Twikoo 小豆泥表情包展示。"
---
import EmojiGrid from "@/components/EmojiGrid.astro";
## 小豆泥表情包一览
下面是从 BlobCatPlus 表情包取的前 20 个 blobcat:
<EmojiGrid limit={20} />
npm run build 会触发一次 fetch,把表情网格烘成静态 HTML,线上访问零请求。
方式 3:客户端运行时 fetch#
如果数据需要「拉新不重新 build」,用客户端 fetch:
---
// src/components/EmojiGridClient.astro
const {
src = "https://cdn.jsdelivr.net/gh/infinitesum/Twikoo-emoji@master/blobcatplus.json",
limit = 20,
} = Astro.props;
---
<div
data-emoji-src={src}
data-emoji-limit={limit}
class="my-6 grid grid-cols-8 gap-2 sm:grid-cols-10 md:grid-cols-12"
>
<noscript>需要 JavaScript 才能加载表情包。</noscript>
</div>
<script>
document.querySelectorAll<HTMLDivElement>("[data-emoji-src]").forEach((el) => {
const src = el.dataset.emojiSrc!;
const limit = Number(el.dataset.emojiLimit ?? 20);
fetch(src)
.then((r) => r.json())
.then((data) => {
const items = (data.aru.container as { text: string; icon: string }[]).slice(0, limit);
el.innerHTML = items
.map(({ text, icon }) => `<span title="${text}">${icon}</span>`)
.join("");
});
});
</script>
.mdx 中用法和方式 2 一样。
CORS#
cdn.jsdelivr.net 默认允许跨域 fetch。方式 2/3 都能直接走,不用代理。raw.githubusercontent.com 则会被拦,不要用裸 GitHub URL。
进阶:MDX + 组件混用#
把文件后缀从 .md 改成 .mdx,就能在 markdown 里 import 任何 Astro 组件:
---
title: "组件混用"
---
import Callout from "@/components/Callout.astro";
import { Image } from "astro:assets";
import chartPng from "@/assets/charts/growth.png";
<Callout type="tip">
这是从 .astro 组件渲染的提示块。
</Callout>
<Image src={chartPng} alt="增长图" />
常规 markdown 仍然可用:
- 列表项
- **加粗**
.mdx 适合需要插组件的复杂文章,普通文章保持 .md 即可。
发布前 checklist#
- 文件位置正确(按主题分类到子目录)
- 文件名英文 + kebab-case
- 必填 frontmatter:
title/description/pubDatetime - 至少 1 个
tags -
featured: true(如果要进首页精选) - 图片 / 音频 / 视频放
public/ -
npm run build无错误 - 本地预览过(H2/H3 显示正常、图片能加载、链接可点)
- 提交信息规范(
post: <主题>/<文章名>) - 推送后 Vercel 部署成功
完工。