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,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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user