/** * 极简 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 { const root: Record = {}; type Frame = { indent: number; node: Record | unknown[]; /** 裸键先建空对象占位;遇到列表项时升级成数组 */ pendingList: boolean; parent?: Record; 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 = {}; 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 = {}; parent[key] = child; stack.push({ indent, node: child, pendingList: true, parent, key }); continue; } (parent as Record)[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, comments?: Map, ): string { return writeMap(data ?? {}, 0, '', comments ?? new Map()); } function writeMap( obj: Record, indent: number, prefix: string, comments: Map, ): 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); if (keys.length === 0) { out += c + pad + k + ': {}\n'; } else { out += c + pad + k + ':\n' + writeMap(v as Record, 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; 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; }