180 lines
6.6 KiB
TypeScript
180 lines
6.6 KiB
TypeScript
/**
|
||||
|
|
* 极简 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;
|
|||
|
|
}
|