后端(新增 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
94 lines
2.8 KiB
JavaScript
94 lines
2.8 KiB
JavaScript
/**
|
||
* 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);
|