Files
blog/blog-admin/src/lib/simple-yaml.ts
T
zqlit cac64f18c7 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

181 lines
6.6 KiB
TypeScript
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.
/**
* 极简 YAML 读写 —— 只认「嵌套 map + 标量 + 列表(块式与内联)」,与设置页的需求严格同构。
*
* ★ 为什么不用真正的 YAML 库:设置页只需要读写 artalk.yml 的一个子集,
* 引一整个 yaml 依赖(几十 KB)只为读几个键不值;而且自己写可以保证
* 「自己写出去的字符串自己一定读得回来」(toSimpleYaml ↔ parseSimpleYaml 互为逆运算)。
*
* ★ 必须支持**块式列表**(`- item`):artalk.yml 里 admin_notify.telegram.receivers、
* moderator.keywords.files 都是这种写法。老解析器把它们读成空对象,再序列化就丢数据
* —— 表单化保存走的是「解析→重序列化」,这个丢法会静默改配置,不能忍。
*/
export function parseSimpleYaml(text: string): Record<string, unknown> {
const root: Record<string, unknown> = {};
type Frame = {
indent: number;
node: Record<string, unknown> | unknown[];
/** 裸键先建空对象占位;遇到列表项时升级成数组 */
pendingList: boolean;
parent?: Record<string, unknown>;
key?: string;
};
const stack: Frame[] = [{ indent: -1, node: root, pendingList: false }];
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
if (!rawLine.trim() || /^\s*#/.test(rawLine)) continue;
const indent = rawLine.match(/^\s*/)![0].length;
const line = rawLine.trim();
// ---- 列表项:`- 值` / `- 键: 值` / `-` ----
if (/^(-\s+|$)/.test(line)) {
const top = stack[stack.length - 1];
// 裸键占位 → 升级为数组
if (top.pendingList && top.parent && top.key) {
const arr: unknown[] = [];
top.parent[top.key] = arr;
top.node = arr;
top.pendingList = false;
}
if (Array.isArray(top.node)) {
const rest = line === '-' ? '' : line.slice(2).trim();
if (!rest) {
(top.node as unknown[]).push('');
} else {
const kv = rest.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
if (kv) {
const item: Record<string, unknown> = {};
item[kv[1]] = kv[2] ? coerce(kv[2]) : {};
(top.node as unknown[]).push(item);
} else {
(top.node as unknown[]).push(coerce(rest));
}
}
}
continue; // 列表项不参与键栈
}
const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
if (!m) continue;
const key = m[1];
let value = m[2];
// 去掉行内注释(只处理 " #" 且不在引号里的情况)
if (value && !/^['"]/.test(value)) {
const hash = value.search(/\s+#/);
if (hash >= 0) value = value.slice(0, hash).trim();
}
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
const parent = stack[stack.length - 1].node;
if (Array.isArray(parent) || typeof parent !== 'object') continue; // 键不能挂在数组上
if (!value) {
const child: Record<string, unknown> = {};
parent[key] = child;
stack.push({ indent, node: child, pendingList: true, parent, key });
continue;
}
(parent as Record<string, unknown>)[key] = coerce(value);
}
return root;
}
function coerce(value: string): unknown {
let v = value.trim();
if (
(v.startsWith('"') && v.endsWith('"') && v.length > 1) ||
(v.startsWith("'") && v.endsWith("'") && v.length > 1)
) {
v = v.slice(1, -1);
}
if (v === 'true' || v === 'True' || v === 'yes') return true;
if (v === 'false' || v === 'False' || v === 'no') return false;
if (v === 'null' || v === '~') return null;
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
if (v.startsWith('[') && v.endsWith(']')) {
return v
.slice(1, -1)
.split(',')
.map((s) => coerce(s.trim()))
.filter((s) => s !== '');
}
return v;
}
// ------------------------------------------------------------------ 序列化
/** comments:路径(如 "frontend.placeholder")→ 写在该键上方的注释行 */
export function toSimpleYaml(
data: Record<string, unknown>,
comments?: Map<string, string>,
): string {
return writeMap(data ?? {}, 0, '', comments ?? new Map());
}
function writeMap(
obj: Record<string, unknown>,
indent: number,
prefix: string,
comments: Map<string, string>,
): string {
const pad = ' '.repeat(indent);
let out = '';
for (const [k, v] of Object.entries(obj)) {
const p = prefix ? prefix + '.' + k : k;
const c = comments.get(p) ? '# ' + comments.get(p) + '\n' : '';
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
const keys = Object.keys(v as Record<string, unknown>);
if (keys.length === 0) {
out += c + pad + k + ': {}\n';
} else {
out += c + pad + k + ':\n' + writeMap(v as Record<string, unknown>, indent + 1, p, comments);
}
} else if (Array.isArray(v)) {
if (v.length === 0) {
out += c + pad + k + ': []\n';
} else {
// 块式列表(与 artalk.yml 模板的写法一致,解析器已支持)
out += c + pad + k + ':\n' + v.map((x) => writeItem(x, indent + 1)).join('');
}
} else {
out += c + pad + k + ': ' + scalarYaml(v) + '\n';
}
}
return out;
}
function writeItem(v: unknown, indent: number): string {
const pad = ' '.repeat(indent);
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
const o = v as Record<string, unknown>;
const keys = Object.keys(o);
if (keys.length === 1 && (o[keys[0]] === null || typeof o[keys[0]] !== 'object')) {
return pad + '- ' + keys[0] + ': ' + scalarYaml(o[keys[0]]) + '\n';
}
return pad + '- ' + JSON.stringify(v) + '\n'; // 复杂条目(当前模板里没有)
}
return pad + '- ' + scalarYaml(v) + '\n';
}
function scalarYaml(v: unknown): string {
if (v === null || v === undefined) return 'null';
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null';
const s = String(v);
return needsQuote(s) ? '"' + s.replace(/"/g, "'") + '"' : s;
}
function needsQuote(s: string): boolean {
if (s === '') return true;
if (/^\s|\s$/.test(s)) return true; // 首尾空白
if (/^(true|false|yes|no|null|~)$/i.test(s)) return true; // 会被解析成布尔/null
if (/^-?\d+(\.\d+)?$/.test(s)) return true; // 会被解析成数字
if (/^[[{]/.test(s)) return true; // 会被解析成数组/对象
if (/\s#/.test(s)) return true; // 会被当成行内注释截断
if (/\\/.test(s)) return true; // 反斜杠加引号保护(如 "\n" 分隔符)
return false;
}