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:
1 parent
48a2fd697f
commit
c217d20c30
24 files changed
+3597
-221
No files matched your search
@@ -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);
|
||||
Reference in new issue
Block a user