Files
blog/editor-api/test/save-roundtrip.mjs
T
zqlit c217d20c30 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
2026-10-04 21:16:06 +08:00

107 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);