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 4795a6c1fd
commit cac64f18c7
24 files changed
+3597 -221

No files matched your search

+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;
}