feat(editor): 在线编辑文章(Worker 前端 + 轻量 docker 后端)

后端(新增 editor-api/,零 npm 依赖,只用 node 内置模块):
- 只做文章相关:列表/读取/新建/保存/删除/图片上传/git 状态·发布·同步
- front matter 往返保真:未改动的块按字节照抄,CRLF/块标量/引号写法都不动
- 列表用目录名当 id,slug 撞名不再静默改错文件(返回 409 列候选)
- 鉴权只有一条路:X-Editor-Token(只存在 Worker 侧,浏览器拿不到)
- 端口只绑 127.0.0.1,由宿主机 nginx 反代出去

部署(新增 deploy/editor-api/bootstrap.sh):
- 一条命令在新机器上完成 克隆仓库→写 .env→起容器→健康检查
- 状态全在两个目录(/srv/blog 仓库工作区 + /srv/editor-api 配置),
  迁移 = 复制目录或在新机重跑本脚本,容器本身无状态

Cloudflare Worker 侧(blog-admin):
- src/routes/editor.ts:鉴权 + 反代,浏览器只跟 Worker 说话
- 管理面板新增「文章编辑」:两栏布局 + 快捷插入面板(13 项短代码,
  与 write-server 的 ShortcutPanel 一致)+ 底部 草稿/保存/发布
- 系统设置改为 schema 驱动的表单,且**以运行时实际生效的配置为准**
  (frontend_conf/captcha/moderator/ip_region/site_default + KV human_check),
  修复「表单显示一套、评论系统跑另一套」的脱节问题

验证:tsc 0 错;front matter 往返 130/130;保存往返 130/130;
API e2e 44/44;无头 Chrome UI e2e 14/14
This commit is contained in:
zqlit committed 2026-10-04 21:16:06 +08:00
1 parent 48a2fd697f
commit c217d20c30
24 files changed
+3597 -221

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# editor-api 镜像 —— 零 npm 依赖,所以没有 package.json / 锁文件 / npm ci
#
# 为什么这么小:
# · base 用 alpine
# · 代码只用 node 内置模块(node:http / node:fs / node:crypto),不需要 npm install
# · git 是必须的:发布 = 在仓库工作区里 add + commit + push
#
# 仓库本体不进镜像 —— 挂载进去(compose 里 /blog)。这样 git pull 不用重建镜像。
FROM node:22-alpine
# git:发布用。ca-certificates:https 远端(CNB / GitHub)要它
RUN apk add --no-cache git ca-certificates
WORKDIR /app
ENV NODE_ENV=production
COPY server.mjs ./
COPY src/ ./src/
# 回收目录(删除的文章挪到这里,在仓库外,不进 git)
RUN mkdir -p /app/trash
EXPOSE 8017
# 没配 EDITOR_TOKEN 时 server.mjs 会自己 process.exit(1),容器随即退出 ——
# 这是刻意的:宁可起不来,也不能以无鉴权状态对公网服务
CMD ["node", "server.mjs"]
+110
View File
@@ -0,0 +1,110 @@
# editor-api —— 只做「在线编辑文章」的轻量后端
给 `api.200181.xyz/admin` 的「文章编辑」tab 当后端。**零 npm 依赖**(只用 node 内置模块),
所以没有 `npm ci`、没有锁文件、没有供应链风险,镜像也小。
## 它做什么 / 不做什么
只做这一件事:读写 `content/posts/<YYYY>/<目录>/index.md`,外加把图片放进文章同级目录,
以及把改动 commit + push 触发上线。
**不做**:评论、AI 摘要、部署编排、用户体系、订阅……那些要么在 Worker 里,要么已经不需要了。
## 三层结构
```
浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上)
/api/v2/editor/* 127.0.0.1:8017
只做「登录鉴权 + 注入 X-Editor-Token 转发」 真正读写文件 / git
```
- 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。
- 容器端口只绑宿主机 `127.0.0.1`,公网扫不到;外面那层是 nginx 的 `/editor-api/`。
- 鉴权是复用后台已有的管理员登录(`isAdminRequest`),不用再造一套账号。
## 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `EDITOR_TOKEN` | **必填** | 编辑器令牌。没设直接拒绝启动。`openssl rand -hex 32` 生成 |
| `BLOG_ROOT` | `/blog` | 博客仓库(git 工作区)路径 |
| `PORT` | `8017` | 监听端口 |
| `BIND_HOST` | `0.0.0.0` | 容器内监听地址;靠 compose 的端口映射限成 127.0.0.1 |
| `TRASH_DIR` | `/app/trash` | 删除文章的回收目录(**在仓库外**,不进 git) |
| `GIT_BRANCH` | `main` | 工作分支 |
| `PUSH_REMOTES` | `origin,gh` | 依次推送;主仓失败才算发布失败,备份仓失败只警告 |
| `GIT_AUTHOR_NAME` / `GIT_AUTHOR_EMAIL` | `blog-editor` | 自动提交的作者 |
| `DEFAULT_AUTHOR` | 空 | 新建文章时 front matter `author` 的默认值 |
| `MAX_UPLOAD_MB` | `20` | 单张图片上限 |
| `GIT_PATHS` | `content,static` | `git add` 的范围(不会把别的东西误提交) |
| `BLOG_BASE` | 空 | 博客对外地址(如 `https://usj.cc`)。配了列表/编辑页才返回绝对链接,「预览」按钮才能直接开新窗口 |
## slug 生成规则(与 write-server 一致)
新文章不填 slug 时自动生成 `YYYYMMDDHHMMSS`(本地时间 14 位),
目录名 `<YYYY-MM-DD>-<标题段>-<slug>`——与 write-server 的 `makeSlug`(`src/lib/bot/helpers.ts`)
和 `computeDirPath`(`src/lib/bot/sessions.ts`)一字不差。仓库里 2026-06 之后
的文章全是这个风格。**不要**用标题当 slug。
## 接口
除 `GET /health` 外一律要 `X-Editor-Token`,没有就 401。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/health` | 健康检查(免鉴权) |
| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记) |
| POST | `/posts` | 新建,body `{frontMatter:{title,...}, content}` |
| GET | `/posts/:id` | 读单篇(**id = 目录名**,见下) |
| PUT | `/posts/:id` | 保存,body `{content, frontMatter}` |
| DELETE | `/posts/:id` | 移到回收目录 |
| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录 |
| GET | `/git/status` | 分支 / 改动文件 / 最近提交 |
| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push |
| POST | `/git/sync` | pull --rebase --autostash |
### 为什么用目录名当 id,不用 slug
仓库里**真实存在 5 组 slug 撞名**的文章(`20210901`、`20211122`、`20211128`、`20211223`、`20240602`)。
Hugo 的 permalink 是 `/:slug`,撞名时线上必有一篇被另一篇覆盖 —— 也就是说这 10 篇里有 5 篇
**线上本来就打不开**。如果按 slug 定位,编辑器会静默地打开/保存到另一篇文件上,直接毁数据。
所以:id 用目录名(文件系统保证唯一、单段路径),slug 降级为展示字段 + `slugConflict` 告警;
拿撞名的 slug 来查会返回 **409 并列出候选篇目**,绝不猜。
## 三条不会写坏老文章的底线
1. **没动 front matter → 原文一个字节都不重写。**
判断方式是「把前端传来的字段合并进已解析对象,再和已解析对象深比较」;相等就原样照抄
`frontMatterRaw`。这样解析器对冷门语法理解有偏差也无所谓。前端也配合:只回传**真正改了**的字段。
2. **换行风格原样保留**(CRLF/LF)。仓库 `.gitattributes` 是 `* text=auto`,仓库存 LF、
Windows 工作区是 CRLF,统一化会产生整文件 diff。
3. **front matter 与正文间的空行原样保留**。语料里 111 篇有空行、19 篇没有;
正文剥掉前导空行给编辑框,回写时按原文件的风格还原。
## 测试
```bash
# ① 纯函数往返:split/join/parse/stringify 对全部真实文章逐字节还原
node test/frontmatter-roundtrip.mjs
# ② 保存往返(最重要):每篇都「读出来原样存回去」,断言 sha256 不变;测完自动还原
BLOG_ROOT=E:/GitHub/blog node test/save-roundtrip.mjs
# ③ 接口端到端(对真实仓库跑,测完自动还原)
BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端
node test/api-e2e.mjs http://127.0.0.1:8017 devtoken
```
## 本地联调
```bash
# 1) 后端
EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs
# 2) Worker(blog-admin 目录)
npx wrangler dev --port 8799
# .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken
```
部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。
+256
View File
@@ -0,0 +1,256 @@
/**
* editor-api —— 只做「在线编辑文章」的轻量后端。
*
* 设计要点:
* · 零 npm 依赖,只用 node 内置模块 —— 镜像小、没有供应链风险、也不用 npm ci
* · 所有接口(除 /health)强制 X-Editor-Token,没有令牌一律 401。
* 前端永远拿不到这个令牌 —— 它只存在于 Cloudflare Worker 的环境变量里,
* 浏览器只跟 Worker 说话,Worker 鉴权通过后再加上令牌转发过来。
* · 端口只绑到宿主机的 127.0.0.1,由 nginx 反代出去,公网扫不到。
*
* 环境变量见 README.md。
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { makePosts } from './src/posts.mjs';
import { makeGit } from './src/git.mjs';
// ------------------------------------------------------------------ 配置
const env = process.env;
const cfg = {
port: Number(env.PORT || 8017),
host: env.BIND_HOST || '0.0.0.0',
token: env.EDITOR_TOKEN || '',
repoRoot: path.resolve(env.BLOG_ROOT || '/blog'),
contentDir: path.resolve(env.BLOG_ROOT || '/blog', 'content', 'posts'),
trashDir: path.resolve(env.TRASH_DIR || '/app/trash'),
branch: env.GIT_BRANCH || 'main',
pushRemotes: (env.PUSH_REMOTES || 'origin,gh').split(',').map((s) => s.trim()).filter(Boolean),
authorName: env.GIT_AUTHOR_NAME || 'blog-editor',
authorEmail: env.GIT_AUTHOR_EMAIL || 'editor@localhost',
defaultAuthor: env.DEFAULT_AUTHOR || '',
maxUpload: Number(env.MAX_UPLOAD_MB || 20) * 1024 * 1024,
gitPaths: (env.GIT_PATHS || 'content,static').split(',').map((s) => s.trim()).filter(Boolean),
// 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。
blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''),
};
if (!cfg.token) {
console.error('[editor-api] 致命:没有设置 EDITOR_TOKEN。拒绝以无鉴权状态启动。');
process.exit(1);
}
const posts = makePosts(cfg);
const git = makeGit({ ...cfg, repoRoot: cfg.repoRoot, paths: cfg.gitPaths });
// ------------------------------------------------------------------ HTTP 小工具
function json(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function readBody(req, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > limit) {
reject(Object.assign(new Error('请求体超过上限 ' + Math.round(limit / 1048576) + 'MB'), { status: 413 }));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
async function readJson(req, limit = 2 * 1024 * 1024) {
const buf = await readBody(req, limit);
if (!buf.length) return {};
try {
return JSON.parse(buf.toString('utf8'));
} catch {
throw Object.assign(new Error('请求体不是合法 JSON'), { status: 400 });
}
}
/** 定长比较,避免令牌被逐字节试探 */
function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
const safeName = (s) =>
String(s || '')
.replace(/[\\/]/g, '')
.replace(/[^\w\u4e00-\u9fff.-]+/g, '_')
.slice(0, 120);
// ------------------------------------------------------------------ 路由
const routes = [];
const route = (method, pattern, handler) => {
routes.push({ method, segs: pattern.split('/').filter(Boolean), handler });
};
const R = '<param>';
const health = () => ({
ok: true,
repo: cfg.repoRoot,
branch: cfg.branch,
remotes: cfg.pushRemotes,
time: new Date().toISOString(),
});
route('GET', '/health', health);
route('GET', '/posts', async ({ query }) => posts.listPosts({
q: query.get('q') || '',
page: Number(query.get('page') || 1),
perPage: Number(query.get('perPage') || 20),
}));
route('POST', '/posts', async ({ req }) => {
const body = await readJson(req);
if (!body.frontMatter || !body.frontMatter.title) {
throw Object.assign(new Error('缺少标题'), { status: 400 });
}
return posts.createPost(body);
});
route('GET', `/posts/${R}`, async ({ params }) => {
const post = posts.getPost(params[0]);
if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 });
return post;
});
route('PUT', `/posts/${R}`, async ({ req, params }) => {
const body = await readJson(req, 4 * 1024 * 1024);
const out = posts.savePost(params[0], body);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
route('DELETE', `/posts/${R}`, async ({ params }) => {
const out = posts.deletePost(params[0]);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单
route('POST', '/upload', async ({ req, query }) => {
const type = req.headers['content-type'] || '';
if (!type.startsWith('image/')) {
throw Object.assign(new Error('只接受图片'), { status: 400 });
}
const buf = await readBody(req, cfg.maxUpload);
if (!buf.length) throw Object.assign(new Error('空文件'), { status: 400 });
const ext = (safeName(query.get('name')).split('.').pop() || 'png').toLowerCase().slice(0, 6);
const base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
const fileName = base + '.' + ext;
const target = posts.imageTargetDir(query.get('key') || '');
fs.mkdirSync(target.dir, { recursive: true });
fs.writeFileSync(path.join(target.dir, fileName), buf);
return {
ok: true,
fileName,
url: target.bare ? fileName : (target.urlPrefix || '/') + fileName,
size: buf.length,
dir: path.relative(cfg.repoRoot, target.dir).split(path.sep).join('/'),
};
});
route('GET', '/git/status', async () => git.status());
route('POST', '/git/publish', async ({ req }) => {
const body = await readJson(req);
const out = await git.publish(body.message);
if (!out.ok) {
return { ...out, __status: out.conflict ? 409 : 500 };
}
return out;
});
route('POST', '/git/sync', async () => {
const out = await git.sync();
return out.ok ? out : { ...out, __status: out.conflict ? 409 : 500 };
});
// ------------------------------------------------------------------ 分发
function match(method, pathname) {
const segs = pathname.split('/').filter(Boolean).map(decodeURIComponent);
for (const r of routes) {
if (r.method !== method) continue;
if (r.segs.length !== segs.length) continue;
const params = [];
let ok = true;
for (let i = 0; i < r.segs.length; i++) {
if (r.segs[i] === R) params.push(segs[i]);
else if (r.segs[i] !== segs[i]) {
ok = false;
break;
}
}
if (ok) return { handler: r.handler, params };
}
return null;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
try {
// /health 免鉴权,供健康检查用;其余一律先验令牌
if (url.pathname === '/health' && req.method === 'GET') {
json(res, 200, health());
return;
}
if (!safeEqual(req.headers['x-editor-token'] || '', cfg.token)) {
json(res, 401, { error: '未授权' });
return;
}
const hit = match(req.method, url.pathname);
if (!hit) {
json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname });
return;
}
const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params });
const status = data && data.__status ? data.__status : 200;
if (data && data.__status) delete data.__status;
json(res, status, data);
} catch (e) {
const status = e.status || 500;
if (status >= 500) console.error('[editor-api]', req.method, url.pathname, e);
json(res, status, { error: e.message || '服务端错误' });
}
});
server.headersTimeout = 30_000;
server.requestTimeout = 120_000;
server.listen(cfg.port, cfg.host, () => {
console.log('[editor-api] 已启动 http://' + cfg.host + ':' + cfg.port);
console.log('[editor-api] 仓库 ' + cfg.repoRoot);
console.log('[editor-api] 分支 ' + cfg.branch + ' 推送远端 ' + cfg.pushRemotes.join(', '));
console.log('[editor-api] 回收目录 ' + cfg.trashDir);
});
+332
View File
@@ -0,0 +1,332 @@
/**
* 极简 YAML front matter 子集:解析 + 序列化(零依赖)。
*
* 为什么不用 gray-matter / js-yaml:
* 这个后端只服务「编辑自己的 Hugo 文章」这一件事,语料是固定的 100 多篇
* Markdown。为此拖进一棵依赖树不值得,镜像也能小一圈。
*
* 覆盖的形态(Hugo front matter 实际会用到的全部):
* key: 标量 key: '单引号' key: "双引号"
* key: 123 / true key: [a, b] key: []
* key: (空值 → null)
* - a
* - b (列表,缩进两格)
* key: | / |- / |+ / > / >- / >+ (块标量)
* key:
* sub: 1 (一层嵌套 map,Hugo 的 params 会用到)
*
* 遇到覆盖不到的结构一律 **抛错**,由调用方降级为「原文照存」——
* 宁可少解析,绝不猜错后把用户文章写坏。
*/
const OPEN = /^---[ \t]*\n/;
/**
* 把整篇文本切成 front matter 原文 / 正文 / 原文件换行风格。
*
* ★ 换行符必须原样保留:仓库 `.gitattributes` 是 `* text=auto`,
* 仓库里存的是 LF,但 Windows 工作区检出是 CRLF。如果写回时统一成 LF,
* 在 Windows 侧就会产生「整文件换行变更」的巨型 diff。所以这里记住原风格,
* 交给 joinFrontMatter 还原。
*
* body **保留**它前面的空行,这样 join 出来的结果与原文件逐字节相同。
*/
export function splitFrontMatter(text) {
const eol = text.includes('\r\n') ? '\r\n' : '\n';
const norm = text.replace(/\r\n/g, '\n');
const open = OPEN.exec(norm);
if (!open) return { raw: null, body: norm, eol };
const rest = norm.slice(open[0].length);
const lines = rest.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '---') {
return {
raw: lines.slice(0, i).join('\n'),
body: lines.slice(i + 1).join('\n'),
eol,
};
}
}
return { raw: null, body: norm, eol };
}
/** 组装回整篇文本(raw 为 null 表示本来就没有 front matter) */
export function joinFrontMatter(raw, body, eol = '\n') {
const s = raw == null ? body : '---\n' + raw.replace(/[ \t]+$/, '') + '\n---\n' + body;
return eol === '\n' ? s : s.replace(/\n/g, eol);
}
// ---------------------------------------------------------------- 解析
export function parse(raw) {
const lines = raw.split('\n');
const out = {};
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim() === '' || line.trimStart().startsWith('#')) {
i++;
continue;
}
if (/^[ \t]/.test(line)) {
throw new Error('顶层出现意外缩进: ' + JSON.stringify(line));
}
const m = /^([^:\s][^:]*):(.*)$/.exec(line);
if (!m) throw new Error('无法解析的行: ' + JSON.stringify(line));
const key = m[1].trim();
const rest = m[2].trim();
i++;
// 空值:看后面有没有缩进块
if (rest === '') {
const block = [];
while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) {
block.push(lines[i]);
i++;
}
while (block.length && block[block.length - 1].trim() === '') block.pop();
if (!block.length) {
out[key] = null;
} else if (/^-[ \t]?/.test(block[0].trimStart())) {
out[key] = parseList(block);
} else {
out[key] = parseMap(block);
}
continue;
}
// 块标量
if (/^[|>][+-]?$/.test(rest)) {
const block = [];
while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) {
block.push(lines[i]);
i++;
}
out[key] = parseBlockScalar(block, rest);
continue;
}
out[key] = parseScalar(rest);
}
return out;
}
function dedent(block) {
let min = Infinity;
for (const l of block) {
if (l.trim() === '') continue;
const n = l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
if (n < min) min = n;
}
if (!isFinite(min)) min = 0;
return block.map((l) => (l.trim() === '' ? '' : l.replace(/\t/g, ' ').slice(min)));
}
function parseBlockScalar(block, header) {
const lines = dedent(block);
while (lines.length && lines[lines.length - 1] === '') lines.pop();
let text;
if (header[0] === '|') {
text = lines.join('\n');
} else {
// 折叠:单个换行变空格,空行保留为换行
let acc = '';
for (const l of lines) {
if (l === '') {
acc += '\n';
continue;
}
if (acc !== '' && !acc.endsWith('\n')) acc += ' ';
acc += l;
}
text = acc;
}
const chomp = header[1];
if (chomp === '-') return text.replace(/\n+$/, '');
if (chomp === '+') return text + '\n';
return text.replace(/\n+$/, '') + '\n';
}
function parseList(block) {
const lines = dedent(block);
const out = [];
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (l.trim() === '') continue;
const m = /^-[ \t]?(.*)$/.exec(l);
if (!m) throw new Error('列表中出现了非列表项: ' + JSON.stringify(l));
if (/^[ \t]/.test(l)) throw new Error('不支持多级列表');
out.push(parseScalar(m[1]));
}
return out;
}
function parseMap(block) {
const lines = dedent(block);
const out = {};
for (const l of lines) {
if (l.trim() === '') continue;
const m = /^([^:\s][^:]*):(.*)$/.exec(l);
if (!m) throw new Error('嵌套 map 中出现无法解析的行: ' + JSON.stringify(l));
out[m[1].trim()] = parseScalar(m[2].trim());
}
return out;
}
function parseScalar(s) {
s = s.trim();
if (s === '' || s === '~' || s === 'null') return null;
if (s === 'true') return true;
if (s === 'false') return false;
if (/^'.*'$/.test(s)) return s.slice(1, -1).replace(/''/g, "'");
if (/^".*"$/.test(s)) {
try {
return JSON.parse(s);
} catch {
// JSON.parse 不认 YAML 专属的 \Uxxxxxxxx(8 位)与 \xXX 转义 ——
// 仓库里就有这种标题(Telegram bot 写入的 emoji,如 \U0001F605)。
// 先把它们解成真实字符,再交给 JSON.parse 处理其余转义(\n、\" 等)。
const pre = s
.slice(1, -1)
.replace(/\\U([0-9a-fA-F]{8})/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCodePoint(parseInt(h, 16)));
try {
return JSON.parse('"' + pre + '"');
} catch {
return pre;
}
}
}
if (s.startsWith('[') && s.endsWith(']')) return parseInlineList(s);
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
return s;
}
function parseInlineList(s) {
const inner = s.slice(1, -1).trim();
if (!inner) return [];
const out = [];
let cur = '';
let quote = null;
for (const ch of inner) {
if (quote) {
cur += ch;
if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
cur += ch;
continue;
}
if (ch === ',') {
out.push(parseScalar(cur));
cur = '';
continue;
}
cur += ch;
}
if (cur.trim() !== '') out.push(parseScalar(cur));
return out;
}
// ---------------------------------------------------------------- 序列化
const PLAIN_OK = /^[^\s\-?:,[\]{}#&*!|>'"%@`][^:#\n]*$/;
function toYamlScalar(v) {
if (v === null || v === undefined) return '';
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (typeof v === 'number') return String(v);
const s = String(v);
if (s === '') return "''";
// 这些形态不引起来会被 YAML 当成别的类型
if (/^(true|false|null|~|yes|no|on|off)$/i.test(s)) return "'" + s + "'";
if (/^-?\d+(\.\d+)?$/.test(s)) return s; // 纯数字:保持裸写,与现有语料一致
if (s.includes('\n')) return null; // 交给调用方走块标量
if (PLAIN_OK.test(s) && !s.startsWith(' ') && !s.endsWith(' ')) return s;
return "'" + s.replace(/'/g, "''") + "'";
}
export function stringify(obj) {
const out = [];
for (const key of Object.keys(obj)) {
const v = obj[key];
if (v === null || v === undefined) {
out.push(key + ':');
continue;
}
if (Array.isArray(v)) {
if (v.length === 0) {
out.push(key + ': []');
continue;
}
out.push(key + ':');
for (const item of v) {
const one = toYamlScalar(item);
if (one === null) throw new Error('列表项不支持多行内容: ' + key);
out.push(' - ' + one);
}
continue;
}
if (typeof v === 'object') {
out.push(key + ':');
for (const sub of Object.keys(v)) {
const one = toYamlScalar(v[sub]);
if (one === null) throw new Error('嵌套对象不支持多行内容: ' + key + '.' + sub);
out.push(' ' + sub + ': ' + one);
}
continue;
}
if (typeof v === 'string' && v.includes('\n')) {
// 块标量必须带上正确的 chomping 记号,否则值会变:
// | clip —— 保留结尾的一个换行(YAML 默认,Hugo 语料里的 >- 折叠块就是这个)
// |- strip —— 结尾不要换行
// |+ keep —— 保留全部结尾换行
// 漏了这一步,「值 = "xxx\n"」会被写成「值 = "xxx"」,是实打实的语义改动。
const trailing = (/\n+$/.exec(v) || [''])[0].length;
const header = trailing === 0 ? '|-' : trailing === 1 ? '|' : '|+';
out.push(key + ': ' + header);
for (const line of v.replace(/\n+$/, '').split('\n')) {
out.push(' ' + line);
}
continue;
}
const one = toYamlScalar(v);
if (one === null) throw new Error('无法序列化: ' + key);
out.push(key + ': ' + one);
}
return out.join('\n');
}
/** 深比较:用来判断「用户到底动没动 front matter」 */
export function deepEqual(a, b) {
if (a === b) return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return String(a) === String(b);
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k])) return false;
}
return true;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* git 操作:状态、发布(提交 + 推两端)、同步。
*
* 所有 git 调用串行化——两个并发的 commit/push 撞在一起会互相踩工作区,
* 单用户场景下也值得防一手(用户手快连点两次「发布」就会遇到)。
*/
import { execFile } from 'node:child_process';
function run(cwd, args, { allowFailure = false } = {}) {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
const out = (stdout || '') + (stderr || '');
if (err && !allowFailure) {
const e = new Error(out.trim() || err.message);
e.stdout = stdout;
e.stderr = stderr;
e.code = err.code;
reject(e);
return;
}
resolve({ ok: !err, code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, out, stdout, stderr });
});
});
}
export function makeGit(cfg) {
const { repoRoot, branch, pushRemotes, authorName, authorEmail, paths } = cfg;
// 串行队列
let tail = Promise.resolve();
function serial(task) {
const next = tail.then(task, task);
tail = next.catch(() => {});
return next;
}
const identity = [
'-c', 'user.name=' + authorName,
'-c', 'user.email=' + authorEmail,
// 容器里通常没有 GPG,且我们不需要签名
'-c', 'commit.gpgsign=false',
];
async function status() {
// core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义,
// 否则发布弹层里满屏都是看不懂的编码
const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...paths]);
const head = await run(repoRoot, ['log', '-1', '--pretty=%h%x09%ad%x09%s', '--date=format:%Y-%m-%d %H:%M']);
const br = await run(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean);
return {
branch: br.out.trim(),
dirty: files.length > 0,
changed: files.length,
files: files.slice(0, 50),
lastCommit: head.out.trim(),
};
}
async function publish(message) {
return serial(async () => {
const log = [];
const add = await run(repoRoot, ['add', '--', ...paths]);
log.push(add.out.trim());
// 先提交(没有暂存内容就跳过)
const diff = await run(repoRoot, ['diff', '--cached', '--quiet'], { allowFailure: true });
if (diff.code === 1) {
const msg = message || ('编辑: ' + new Date().toISOString().slice(0, 16).replace('T', ' '));
const c = await run(repoRoot, [...identity, 'commit', '-m', msg]);
log.push(c.out.trim());
} else if (diff.code !== 0) {
throw new Error('检查暂存区失败: ' + diff.out.trim());
} else {
log.push('(没有需要提交的改动)');
}
// 先拉再推,减少被拒概率
const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true });
log.push(pull.out.trim());
if (!pull.ok && /conflict|CONFLICT|nothing to rebase|Automatic merge failed/i.test(pull.out)) {
await run(repoRoot, ['rebase', '--abort'], { allowFailure: true });
return { ok: false, conflict: true, log: log.join('\n'), error: '远程有冲突,已放弃合并。请先「同步」后手动处理。' };
}
const pushed = [];
const failed = [];
for (const remote of pushRemotes) {
const p = await run(repoRoot, ['push', remote, 'HEAD:' + branch], { allowFailure: true });
log.push(p.out.trim());
if (p.ok) pushed.push(remote);
else failed.push(remote + ': ' + p.out.trim().split('\n').slice(-2).join(' '));
}
if (!pushed.length) {
return { ok: false, log: log.join('\n'), error: '推送失败:' + failed.join(' | ') };
}
return {
ok: true,
pushed,
// 备份远端推失败不算发布失败(主仓成了就算成),但如实说明
warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null,
log: log.join('\n'),
};
});
}
async function sync() {
return serial(async () => {
const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true });
if (pull.ok) return { ok: true, log: pull.out.trim() };
if (/conflict|CONFLICT|Automatic merge failed/i.test(pull.out)) {
await run(repoRoot, ['rebase', '--abort'], { allowFailure: true });
return { ok: false, conflict: true, error: '存在冲突,已放弃合并' };
}
return { ok: false, error: pull.out.trim() };
});
}
return { status, publish, sync };
}
+318
View File
@@ -0,0 +1,318 @@
/**
* 文章读写:扫目录、解析、保存、新建、删除。
*
* 目录约定(与仓库现状一致):
* content/posts/<YYYY>/<任意目录名>/index.md
* —— 目录名只为人眼好看,真正决定 URL 的是 front matter 里的 slug
* (hugo.toml: [permalinks] posts = "/:slug",且 uglyURLs = true → /<slug>.html)
*
* ★ 定位键用**目录名**,不用 slug。
* 原因是仓库里真实存在 5 组 slug 撞名的文章(Hugo 是 /:slug,撞名的那一篇
* 在线上已经被另一篇覆盖了)。如果按 slug 打开,编辑器会静默地打开/保存
* **另一篇**文件 —— 这是能直接毁数据的。目录名由文件系统保证唯一,且是
* 单段路径(不含 /),当 API id 最稳。slug 只作为展示字段 + 撞名告警。
*
* ★ 保存时的核心安全策略:
* 用户只改正文、没动 front matter 字段时,front matter 原文**一个字节都不重写**。
* 这样即使解析器对某些冷门语法理解有偏差,也永远不会把老文章写坏。
* 判断方式是「合并 incoming 到已解析对象,再和已解析对象深比较」——
* 用合并而不是替换,是为了防止前端只回传部分字段时把 pid / ai_comment 这类
* 它不认识的键弄丢。
*/
import fs from 'node:fs';
import path from 'node:path';
import {
splitFrontMatter,
joinFrontMatter,
parse,
stringify,
deepEqual,
} from './frontmatter.mjs';
export function makePosts(cfg) {
const { contentDir, trashDir, defaultAuthor, repoRoot, blogBase } = cfg;
/** 带 HTTP 状态码的错误,避免所有业务错误都退化成 500 */
const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
/** 文章线上地址:配了 BLOG_BASE 就是绝对地址(列表「预览」按钮直接新窗口打开) */
const blogUrl = (slug) => (blogBase ? String(blogBase).replace(/\/+$/, '') : '') + '/' + slug + '.html';
function isDir(p) {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
}
/** 找出所有 content/posts/<YYYY>/<dir>/index.md */
function findPostDirs() {
const out = [];
if (!isDir(contentDir)) return out;
for (const year of fs.readdirSync(contentDir)) {
if (!/^\d{4}$/.test(year)) continue;
const yearDir = path.join(contentDir, year);
if (!isDir(yearDir)) continue;
for (const name of fs.readdirSync(yearDir)) {
const dir = path.join(yearDir, name);
if (isDir(dir) && fs.existsSync(path.join(dir, 'index.md'))) out.push(dir);
}
}
return out;
}
function slugFromDirName(dirName) {
const m = /^\d{4}-\d{2}-\d{2}-(.+)$/.exec(dirName);
return m ? m[1] : dirName;
}
function readIndex(dir) {
const filePath = path.join(dir, 'index.md');
const text = fs.readFileSync(filePath, 'utf8');
const { raw, body, eol } = splitFrontMatter(text);
let fm = null;
let fmError = null;
if (raw != null) {
try {
fm = parse(raw);
} catch (e) {
fmError = e.message;
}
}
// front matter 与正文之间的空行:语料里两种写法都存在(111 篇有空行、19 篇没有)。
// 正文给前端时要剥掉它(否则编辑框顶上永远空一行),但**必须记下来**,
// 回写时原样还原 —— 否则那 19 篇一保存就会被平白多插一个空行。
const lead = (/^\n*/.exec(body) || [''])[0];
return { filePath, text, fmRaw: raw, fm, fmError, body, bodyLead: lead, eol };
}
function toListItem(dir) {
const info = readIndex(dir);
const name = path.basename(dir);
const fm = info.fm || {};
return {
id: name, // ← API 定位键(目录名,文件系统保证唯一)
slug: String(fm.slug || slugFromDirName(name)),
title: fm.title || name,
date: fm.date ? String(fm.date) : '',
draft: fm.draft === true,
tags: Array.isArray(fm.tags) ? fm.tags : [],
categories: Array.isArray(fm.categories) ? fm.categories : [],
author: fm.author || '',
dirName: name,
relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'),
parseable: !info.fmError,
url: blogUrl(String(fm.slug || slugFromDirName(name))),
};
}
function listPosts({ q = '', page = 1, perPage = 20 } = {}) {
let rows = findPostDirs().map(toListItem);
// slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉
const seen = new Map();
for (const r of rows) seen.set(r.slug, (seen.get(r.slug) || 0) + 1);
for (const r of rows) r.slugConflict = (seen.get(r.slug) || 0) > 1;
const needle = String(q || '').trim().toLowerCase();
if (needle) {
rows = rows.filter((r) =>
[r.title, r.slug, r.author, ...(r.tags || []), ...(r.categories || [])]
.join(' ')
.toLowerCase()
.includes(needle),
);
}
rows.sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.title.localeCompare(b.title));
const total = rows.length;
const size = Math.max(1, Math.min(200, Number(perPage) || 20));
const pages = Math.max(1, Math.ceil(total / size));
const cur = Math.min(Math.max(1, Number(page) || 1), pages);
return {
total,
totalPages: pages,
page: cur,
posts: rows.slice((cur - 1) * size, cur * size),
};
}
/**
* 把 id 解析成一个真实目录。
* 依次尝试:目录名 → 仓库相对路径 → slug(仅当唯一)。
* slug 撞名时**抛 409 而不是随便挑一个** —— 挑错了就是在改别的文章。
*/
function locate(key) {
const k = String(key || '').trim();
if (!k) return null;
const all = findPostDirs();
const byName = all.filter((d) => path.basename(d) === k);
if (byName.length === 1) return byName[0];
const byRel = all.filter(
(d) => path.relative(repoRoot || path.resolve(contentDir, '..', '..'), d).split(path.sep).join('/') === k,
);
if (byRel.length === 1) return byRel[0];
const bySlug = all.filter((d) => toListItem(d).slug === k);
if (bySlug.length === 1) return bySlug[0];
if (bySlug.length > 1) {
throw bad(
'slug「' + k + '」有多篇重复,无法确定改哪一篇:\n' +
bySlug.map((d) => ' · ' + path.basename(d)).join('\n'),
409,
);
}
return null;
}
function getPost(key) {
const dir = locate(key);
if (!dir) return null;
const item = toListItem(dir);
const info = readIndex(dir);
return {
...item,
filePath: info.filePath,
eol: info.eol,
frontMatter: info.fm,
frontMatterRaw: info.fmRaw,
frontMatterError: info.fmError,
// content 是给编辑框看的(没有前导空行);bodyLead 是回写时还原原文件用的
content: info.body.slice(info.bodyLead.length),
bodyLead: info.bodyLead,
url: blogUrl(item.slug),
};
}
/** 只把前端真正传上来的字段合并进去,undefined 一律忽略(避免覆盖成空) */
function pickDefined(obj) {
const out = {};
if (!obj || typeof obj !== 'object') return out;
for (const k of Object.keys(obj)) {
if (obj[k] !== undefined) out[k] = obj[k];
}
return out;
}
function savePost(key, patch = {}) {
const post = getPost(key);
if (!post) return null;
// 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格
if (typeof post.filePath !== 'string' || !post.eol) {
throw Object.assign(new Error('内部错误:文章记录缺少 filePath/eol'), { status: 500 });
}
const incoming = pickDefined(patch.frontMatter);
const merged = { ...(post.frontMatter || {}), ...incoming };
let fmText;
if (post.frontMatter && deepEqual(merged, post.frontMatter)) {
// 没动 front matter → 原文照抄,零风险
fmText = post.frontMatterRaw;
} else if (post.frontMatter === null) {
throw bad('这篇的 front matter 无法解析,为避免写坏,请只改正文(本次未提供字段变更)');
} else {
fmText = stringify(merged);
}
const body = patch.content !== undefined ? String(patch.content) : post.content;
// 前导空行:用户自己敲了就用他的,没敲就沿用原文件的风格(原文没有空行就不给加)
const typed = (/^\n*/.exec(body) || [''])[0];
const lead = typed.length ? typed : (post.bodyLead ?? '');
const text = joinFrontMatter(fmText, lead + body.slice(typed.length), post.eol);
fs.writeFileSync(post.filePath, text, 'utf8');
// 回读用 dirName 定位(用 slug 有撞名风险,可能读到别的文章)
const after = getPost(post.dirName);
return { id: post.dirName, slug: post.slug, dirPath: path.dirname(post.filePath), url: after ? after.url : post.url };
}
/**
* 新文章的默认 slug:YYYYMMDDHHMMSS(本地时间 14 位)。
* 与 write-server 的 makeSlug(src/lib/bot/helpers.ts)完全一致 ——
* 仓库里 2026-06-22 之后的所有文章都是这个风格,别再用标题转拼音那套。
*/
function makeSlug(d = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return (
d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) +
p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds())
);
}
/** 目录名里的标题段:与 write-server 的 computeDirPath/titlePart 同一套规则 */
function titlePartOf(title) {
return (
String(title || '')
.replace(/[^\w一-鿿㐀-䶿]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase() || 'post'
);
}
function createPost({ frontMatter = {}, content = '' } = {}) {
const title = String(frontMatter.title || '').trim();
if (!title) throw bad('缺少标题');
const slug = String(frontMatter.slug || '').trim() || makeSlug();
if (!slug) throw bad('缺少 slug(且无法自动生成)');
// slug 必须全仓唯一 —— 否则就是新造一篇线上打不开的文章
const clash = listPosts({ perPage: 200 }).posts.find((r) => r.slug === slug);
if (clash) throw bad('slug「' + slug + '」已被占用(' + clash.dirName + '),请换一个', 409);
const date = String(frontMatter.date || new Date().toISOString().slice(0, 10));
const ymd = date.slice(0, 10);
const year = ymd.slice(0, 4);
const fm = {
title,
date,
slug,
author: String(frontMatter.author || defaultAuthor || ''),
layout: String(frontMatter.layout || 'post'),
categories: Array.isArray(frontMatter.categories) ? frontMatter.categories : [],
tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : [],
draft: frontMatter.draft === true,
};
// 目录名与 write-server 的 computeDirPath 一致:<日期>-<标题段>-<slug>
const dirName = ymd + '-' + titlePartOf(title) + '-' + slug;
const dir = path.join(contentDir, year, dirName);
if (fs.existsSync(dir)) throw bad('目录已存在: ' + dirName, 409);
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, 'index.md');
fs.writeFileSync(filePath, joinFrontMatter(stringify(fm), '\n' + String(content).replace(/^\n+/, ''), '\n'), 'utf8');
return { id: dirName, slug, dirPath: dir, dirName, url: blogUrl(slug) };
}
/** 删除 = 移到仓库外的回收目录(不进 git,也不误伤版本历史) */
function deletePost(key) {
const post = getPost(key);
if (!post) return null;
const dir = path.dirname(post.filePath);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const dest = path.join(trashDir, stamp + '-' + path.basename(dir));
fs.mkdirSync(trashDir, { recursive: true });
fs.renameSync(dir, dest);
return { id: post.dirName, slug: post.slug, movedTo: dest };
}
/** 图片落到文章同级目录,正文用裸文件名引用(仓库现行写法) */
function imageTargetDir(key) {
if (key) {
const post = getPost(key);
if (post) return { dir: path.dirname(post.filePath), bare: true };
throw bad('图片要挂到哪篇文章?找不到: ' + key, 404);
}
const year = new Date().getFullYear().toString();
return { dir: path.join(contentDir, '..', '..', 'static', 'image', year), bare: false, urlPrefix: '/image/' + year + '/' };
}
return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, contentDir };
}
+238
View File
@@ -0,0 +1,238 @@
/**
* editor-api 端到端回归测试(对真实仓库跑,测完自动还原)。
*
* node test/api-e2e.mjs [baseUrl] [token]
*
* 重点验证「不写坏老文章」这条底线:
* ① 只改正文 → front matter 区块必须**逐字节不变**(含 pid / ai_comment 等冷门键)
* ② 原样回存 → 整个文件必须**逐字节不变**
* ③ 改 front matter 字段 → 其余字段必须原样保留
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const BASE = process.argv[2] || 'http://127.0.0.1:8017';
const TOKEN = process.argv[3] || 'devtoken';
const ROOT = path.resolve(import.meta.dirname, '../..');
const TARGET_SLUG = '20251121';
const TARGET_FILE = path.join(ROOT, 'content/posts/2025/2025-11-21-除草日记/index.md');
// ★ API 的定位键是**目录名**(全仓唯一),不是 slug —— 仓库里有 5 组 slug 撞名,
// 按 slug 取会静默地操作到另一篇文章上。这里刻意用带中文的目录名走一遍,
// 顺带验证 URL 编码链路。
const TARGET_ID = path.basename(path.dirname(TARGET_FILE));
const P = (id) => '/posts/' + encodeURIComponent(id);
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const read = (p) => fs.readFileSync(p, 'utf8');
let pass = 0;
const fails = [];
function check(name, cond, extra = '') {
if (cond) {
pass++;
console.log(' ✅ ' + name);
} else {
fails.push(name + (extra ? ' → ' + extra : ''));
console.log(' ❌ ' + name + (extra ? ' → ' + extra : ''));
}
}
async function call(method, p, body, raw) {
const res = await fetch(BASE + p, {
method,
headers: {
'X-Editor-Token': TOKEN,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined,
});
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
return { status: res.status, data };
}
const original = read(TARGET_FILE);
const originalSha = sha(TARGET_FILE);
console.log('\n== ① 鉴权 ==');
{
const noToken = await fetch(BASE + '/posts');
check('无令牌 → 401', noToken.status === 401, '实际 ' + noToken.status);
const health = await fetch(BASE + '/health');
check('/health 免鉴权', health.status === 200, '实际 ' + health.status);
const bad = await call('GET', '/posts');
check('带令牌 → 200', bad.status === 200, '实际 ' + bad.status);
}
console.log('\n== ② 读取 ==');
let post;
{
const r = await call('GET', P(TARGET_ID));
post = r.data;
check('能用目录名(含中文)定位', r.status === 200 && post.slug === TARGET_SLUG, r.status + ' / ' + post?.slug);
check('返回 id = 目录名', post.id === TARGET_ID, String(post.id));
check('front matter 解析成功', post.frontMatter && !post.frontMatterError, String(post.frontMatterError || ''));
check('冷门键 pid 已解析', post.frontMatter && post.frontMatter.pid === 133, JSON.stringify(post.frontMatter?.pid));
check('title 正确', post.title === '除草日记', String(post.title));
}
console.log('\n== ③ 原样回存 → 必须逐字节不变 ==');
{
const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
check('文件 sha256 未变', sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16) + ' vs ' + originalSha.slice(0, 16));
}
console.log('\n== ④ 只改正文 → front matter 区块必须逐字节不变 ==');
{
const body = post.content + '\n\n(测试追加的一行,稍后还原)\n';
const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: body });
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
const now = read(TARGET_FILE);
const fmOf = (t) => t.replace(/\r\n/g, '\n').split('\n').slice(0, t.split('\n').findIndex((l, i) => i > 0 && l.trim() === '---') + 1).join('\n');
check('front matter 区块逐字节不变', fmOf(now) === fmOf(original));
check('正文确实写进去了', now.includes('测试追加的一行'));
check('冷门键 ai_comment 仍在', now.includes('ai_comment:'));
check('pid 仍在', now.includes('pid: 133'));
// 还原
const back = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
check('还原成功', back.status === 200 && sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16));
}
console.log('\n== ⑤ 改 front matter 字段 → 其余字段保留 ==');
{
const fm = { ...post.frontMatter, title: '除草日记(测试改名)' };
const r = await call('PUT', P(TARGET_ID), { frontMatter: fm, content: post.content });
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
const after = (await call('GET', P(TARGET_ID))).data;
check('title 已更新', after.title === '除草日记(测试改名)', String(after.title));
check('pid 未丢', after.frontMatter.pid === 133, JSON.stringify(after.frontMatter.pid));
check('ai_comment 未丢', 'ai_comment' in after.frontMatter);
check('ai_comment 值未变(含结尾换行)', after.frontMatter.ai_comment === post.frontMatter.ai_comment);
check('categories 未丢', Array.isArray(after.frontMatter.categories) && after.frontMatter.categories.length === 1);
// 还原。注意:一旦真的改过 front matter,整块会被按规范重排
// (例如 >- 折叠块变成 | 字面块),所以这里校验的是**值**而不是字节。
// 「字节不变」的保证只适用于「没碰 front matter」的场景,见 ③ / ④。
await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
const back = (await call('GET', P(TARGET_ID))).data;
check('还原后 front matter 值与原值一致',
JSON.stringify(back.frontMatter) === JSON.stringify(post.frontMatter),
JSON.stringify(back.frontMatter).slice(0, 160));
check('还原后正文与原文一致', back.content === post.content);
check('还原后换行风格未变(CRLF/LF 保持)', (read(TARGET_FILE).includes('\r\n')) === original.includes('\r\n'));
}
console.log('\n== ⑥ 新建 → 删除(走回收站)==');
{
const slug = 'e2e-tmp-' + Date.now().toString(36);
const created = await call('POST', '/posts', {
frontMatter: { title: 'E2E 临时文章', slug, date: '2026-10-04', categories: ['测试'], tags: ['a', 'b'], draft: true },
content: '# 标题\n\n正文。\n',
});
check('新建成功', created.status === 200, JSON.stringify(created.data).slice(0, 160));
const dir = created.data?.dirPath;
check('目录已创建', !!dir && fs.existsSync(path.join(dir, 'index.md')), String(dir));
const got = await call('GET', '/posts/' + slug);
check('能读回', got.status === 200 && got.data.title === 'E2E 临时文章');
const dup = await call('POST', '/posts', { frontMatter: { title: '重名', slug }, content: '' });
check('重复 slug 被拒绝(400/409)', dup.status === 400 || dup.status === 409, '实际 ' + dup.status);
const del = await call('DELETE', '/posts/' + slug);
check('删除成功', del.status === 200, JSON.stringify(del.data).slice(0, 120));
check('目录已移走', !!dir && !fs.existsSync(dir));
if (del.data?.movedTo && fs.existsSync(del.data.movedTo)) fs.rmSync(del.data.movedTo, { recursive: true, force: true });
}
console.log('\n== ⑦ 图片上传 ==');
{
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const res = await fetch(BASE + '/upload?name=t.png&key=' + encodeURIComponent(TARGET_ID), {
method: 'POST',
headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'image/png' },
body: png,
});
const data = await res.json();
check('上传成功', res.status === 200 && data.ok, JSON.stringify(data).slice(0, 140));
if (data.fileName) {
const f = path.join(path.dirname(TARGET_FILE), data.fileName);
check('文件落盘', fs.existsSync(f));
check('返回裸文件名(仓库现行写法)', data.url === data.fileName, String(data.url));
if (fs.existsSync(f)) fs.unlinkSync(f);
}
const bad = await fetch(BASE + '/upload?name=x.txt', {
method: 'POST',
headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'text/plain' },
body: 'hi',
});
check('非图片被拒绝', bad.status === 400, String(bad.status));
}
console.log('\n== ⑧ git 状态 ==');
{
const r = await call('GET', '/git/status');
check('能取到状态', r.status === 200 && typeof r.data.branch === 'string', JSON.stringify(r.data).slice(0, 140));
console.log(' 分支 ' + r.data.branch + ' | 改动 ' + r.data.changed + ' 个文件 | 最近提交 ' + String(r.data.lastCommit).slice(0, 60));
}
console.log('\n== ⑨ slug 撞名 → 必须拒绝,不能猜 ==');
{
// 仓库真实存在:2021-09-01-Twitter… 与 2021-08-31-无悔 都用 slug 20210901
const r = await call('GET', '/posts/20210901');
check('撞名 slug → 409(而不是随便挑一篇)', r.status === 409, '实际 ' + r.status);
check('错误信息里点出候选篇目', String(r.data?.error || '').includes('无悔'), String(r.data?.error || '').slice(0, 120));
check('单篇 slug 仍可定位', (await call('GET', '/posts/20251121')).status === 200);
check('不存在的 id → 404', (await call('GET', '/posts/__不存在__')).status === 404);
}
console.log('\n== ⑩ front matter 后没有空行的文章 → 不能平白多插空行 ==');
{
// 语料里 111 篇有空行、19 篇没有;这类写法差异最容易在回存时产生脏 diff
const list = (await call('GET', '/posts?perPage=200')).data.posts;
let tested = 0;
for (const item of list) {
const r = await call('GET', P(item.id));
if (r.status !== 200 || !r.data?.filePath) continue;
const file = r.data.filePath;
const before = read(file);
// 只挑「--- 之后紧接正文」的那种
if (/\n---\r?\n[^\r\n]/.test(before) === false) continue;
await call('PUT', P(item.id), { frontMatter: r.data.frontMatter, content: r.data.content });
check('无空行文章原样回存字节不变:' + item.id,
sha(file) === crypto.createHash('sha256').update(before).digest('hex'));
fs.writeFileSync(file, before);
if (++tested >= 3) break;
}
if (!tested) console.log(' (语料里没找到这种写法,跳过)');
}
// 收尾:确保目标文件恢复原样
if (sha(TARGET_FILE) !== originalSha) {
fs.writeFileSync(TARGET_FILE, original);
check('测试后强制还原目标文件', true);
}
console.log('\n──────────────────────────────');
console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项');
if (fails.length) {
console.log('失败明细:');
for (const f of fails) console.log(' · ' + f);
}
process.exit(fails.length ? 1 : 0);
+93
View File
@@ -0,0 +1,93 @@
/**
* front matter 解析器回归测试:拿仓库里全部真实文章跑无损往返。
*
* node test/frontmatter-roundtrip.mjs [contentDir]
*
* 断言三件事:
* 1. split → join 能**逐字节**还原原文件(说明切分没吃掉任何字符)
* 2. parse → stringify → parse 后对象与首次解析**深相等**(说明丢了不信息)
* 3. 记录所有解析失败的样本,便于判断是否需要补语法
*/
import fs from 'node:fs';
import path from 'node:path';
import { splitFrontMatter, joinFrontMatter, parse, stringify, deepEqual } from '../src/frontmatter.mjs';
const root = process.argv[2] || path.resolve(import.meta.dirname, '../../content/posts');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p, out);
else if (name.endsWith('.md')) out.push(p);
}
return out;
}
const files = walk(root);
let ok = 0;
const byteFail = [];
const parseFail = [];
const roundFail = [];
for (const f of files) {
const text = fs.readFileSync(f, 'utf8');
const rel = path.relative(root, f);
const { raw, body, eol } = splitFrontMatter(text);
if (raw == null) {
parseFail.push([rel, '没有 front matter']);
continue;
}
// 1. 逐字节还原(含原始换行风格)
if (joinFrontMatter(raw, body, eol) !== text) byteFail.push(rel);
// 2. 解析 + 往返
let first;
try {
first = parse(raw);
} catch (e) {
parseFail.push([rel, e.message]);
continue;
}
let second;
try {
second = parse(stringify(first));
} catch (e) {
roundFail.push([rel, 'stringify/parse 失败: ' + e.message]);
continue;
}
if (!deepEqual(first, second)) {
const diff = Object.keys(first).concat(Object.keys(second)).filter(
(k, i, a) => a.indexOf(k) === i && !deepEqual(first[k], second[k]),
);
roundFail.push([rel, '字段不一致: ' + diff.join(', ')]);
continue;
}
ok++;
}
const total = files.length;
console.log('样本总数 :', total);
console.log('解析 + 往返通过 :', ok);
console.log('切分不还原 :', byteFail.length);
console.log('解析失败 :', parseFail.length);
console.log('往返不一致 :', roundFail.length);
const show = (title, arr) => {
if (!arr.length) return;
console.log('\n--- ' + title + ' ---');
for (const [f, why] of arr.slice(0, 12)) console.log(' ' + f + ' || ' + why);
if (arr.length > 12) console.log(' … 另有 ' + (arr.length - 12) + ' 条');
};
show('切分不还原(必须为 0)', byteFail.map((f) => [f, 'join != 原文']));
show('解析失败', parseFail);
show('往返不一致', roundFail);
const bad = byteFail.length + parseFail.length + roundFail.length;
process.exit(bad === 0 ? 0 : 1);
+106
View File
@@ -0,0 +1,106 @@
/**
* savePost 全量往返测试 —— 对仓库里**每一篇**真实文章跑一遍「读出来原样存回去」。
*
* 为什么要有这个:frontmatter-roundtrip 只测 split/join 这两个纯函数,
* 但真正会写坏文章的是 getPost → savePost 这条链路(中间的 content 剥了前导换行、
* 回写时又要还原,任何一处不对称都会在部分文章上产生脏 diff)。
* 所以这里必须用真文件、真篇数跑,不能只挑一篇(19/130 的写法差异就藏在里面)。
*
* 测试期间会**真实改写文件**,但每篇测完都立刻按原字节还原;
* 结尾再逐篇核对 sha256,任何一篇没还原都会让脚本失败并指名道姓。
*
* 跑法:node test/save-roundtrip.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { makePosts } from '../src/posts.mjs';
import { splitFrontMatter } from '../src/frontmatter.mjs';
const repoRoot = path.resolve(process.env.BLOG_ROOT || path.join(import.meta.dirname, '..', '..'));
const contentDir = path.join(repoRoot, 'content', 'posts');
const trashDir = path.join(repoRoot, '.editor-tmp', 'trash');
const posts = makePosts({ contentDir, trashDir, repoRoot, defaultAuthor: '' });
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const fmBlockOf = (text) => {
const { raw } = splitFrontMatter(text);
if (raw == null) return null;
return '---\n' + raw + '\n---';
};
const list = posts.listPosts({ perPage: 500 });
console.log('待测文章: ' + list.total + ' 篇 (仓库 ' + repoRoot + ')\n');
let pass = 0;
let fail = 0;
const failures = [];
/** 记录每篇的原始字节,最后统一核对还原情况 */
const originals = new Map();
for (const item of list.posts) {
// 用 id(目录名)而不是 slug —— 仓库里有 slug 撞名,按 slug 会拿到 409 或别的文章
const post = posts.getPost(item.id);
if (!post || typeof post.filePath !== 'string') {
fail++;
failures.push(item.id + ' → getPost 拿不到 filePath');
continue;
}
const file = post.filePath;
const before = fs.readFileSync(file);
originals.set(file, before);
const beforeHash = crypto.createHash('sha256').update(before).digest('hex');
let mark = '✓';
// ---- ① 原样回存:必须逐字节不变(这是最重要的一条)----
posts.savePost(item.id, { content: post.content, frontMatter: post.frontMatter });
const afterHash = sha(file);
if (afterHash !== beforeHash) {
mark = '✗';
failures.push(item.id + ' → 「原样回存」字节变了 ' + beforeHash.slice(0, 12) + ' ≠ ' + afterHash.slice(0, 12));
}
// ---- ② 只改正文:front matter 区块必须逐字节不变 ----
const midText = fs.readFileSync(file, 'utf8');
const fmBefore = fmBlockOf(midText);
posts.savePost(item.id, { content: post.content + '\n\n<!-- probe -->\n' });
const editedText = fs.readFileSync(file, 'utf8');
if (fmBefore !== fmBlockOf(editedText)) {
mark = '✗';
failures.push(item.id + ' → 改正文时 front matter 被改动');
}
if (!editedText.includes('<!-- probe -->')) {
mark = '✗';
failures.push(item.id + ' → 正文没写进去');
}
// ---- ③ 立刻还原成原字节 ----
fs.writeFileSync(file, before);
if (mark === '✓') pass++;
else fail++;
if (mark === '✗') console.log(mark + ' ' + item.slug);
}
// ---- ④ 总核对:全仓库不允许残留任何被改动的文章 ----
console.log('\n--- 还原核对 ---');
let notRestored = 0;
for (const [file, buf] of originals) {
const now = fs.readFileSync(file);
if (!now.equals(buf)) {
notRestored++;
console.log(' ✗ 未还原: ' + file);
}
}
if (notRestored === 0) console.log(' ✓ ' + originals.size + ' 篇全部还原为原始字节');
console.log('\n================================');
console.log('通过 ' + pass + ' / 失败 ' + fail + ' 未还原 ' + notRestored);
if (failures.length) {
console.log('\n失败明细:');
for (const f of failures) console.log(' · ' + f);
}
process.exit(fail || notRestored ? 1 : 0);