/** * Minimal YAML helpers for the admin console (no runtime YAML dependency in the web bundle): * - `toYaml()` serializes plain JSON data (objects / arrays / scalars) into readable block YAML; * - `yamlTokens()` tokenizes a YAML line for syntax highlighting; * - `diffLinesLcs()` line diff (LCS) for the compare views. * Parsing YAML is delegated to the API (`devtool/validate-config` returns the parsed config). */ const PLAIN = /^[A-Za-z0-9_][A-Za-z0-9_./:@-]*$/; const RESERVED = new Set(["true", "false", "null", "yes", "no", "on", "off", "~", ""]); function scalar(v: unknown): string { if (v === null || v === undefined) return "null"; if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null"; if (typeof v === "boolean") return v ? "true" : "false"; const s = String(v); if (s === "") return '""'; if (RESERVED.has(s.toLowerCase()) || /^[-?:,[\]{}#&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s) || /^[+-]?\d/.test(s) || /[:#]\s|\n|\t/.test(s) || s.includes(": ") || s.endsWith(":")) { return JSON.stringify(s); } if (PLAIN.test(s) || !/["'\\]/.test(s)) return s; return JSON.stringify(s); } function isScalar(v: unknown): boolean { return v === null || v === undefined || typeof v !== "object"; } function inlineable(v: unknown): boolean { if (Array.isArray(v)) return v.length > 0 && v.length <= 6 && v.every(isScalar) && v.map(scalar).join(", ").length < 70; if (v && typeof v === "object") { const entries = Object.entries(v as Record).filter(([, x]) => x !== undefined); return entries.length > 0 && entries.length <= 5 && entries.every(([, x]) => isScalar(x)) && entries.map(([k, x]) => `${k}: ${scalar(x)}`).join(", ").length < 80; } return false; } function inline(v: unknown): string { if (Array.isArray(v)) return `[${v.map(scalar).join(", ")}]`; const entries = Object.entries(v as Record).filter(([, x]) => x !== undefined); return `{ ${entries.map(([k, x]) => `${k}: ${scalar(x)}`).join(", ")} }`; } export function toYaml(value: unknown, indent = 0): string { const pad = " ".repeat(indent); if (isScalar(value)) return `${pad}${scalar(value)}\n`; if (Array.isArray(value)) { if (value.length === 0) return `${pad}[]\n`; return value .map((item) => { if (isScalar(item)) return `${pad}- ${scalar(item)}\n`; if (inlineable(item)) return `${pad}- ${inline(item)}\n`; if (Array.isArray(item)) return `${pad}-\n${toYaml(item, indent + 1)}`; const body = toYaml(item, indent + 1); // hoist the first key onto the dash line const lines = body.split("\n"); const first = lines.shift() ?? ""; return `${pad}- ${first.trimStart()}\n${lines.filter((l) => l !== "").join("\n")}${lines.length > 1 ? "\n" : ""}`; }) .join(""); } const entries = Object.entries(value as Record).filter(([, x]) => x !== undefined); if (entries.length === 0) return `${pad}{}\n`; return entries .map(([k, x]) => { const key = PLAIN.test(k) ? k : JSON.stringify(k); if (isScalar(x)) { if (typeof x === "string" && x.includes("\n")) return `${pad}${key}: |\n${x.split("\n").map((l) => `${pad} ${l}`).join("\n")}\n`; return `${pad}${key}: ${scalar(x)}\n`; } if (inlineable(x)) return `${pad}${key}: ${inline(x)}\n`; if (Array.isArray(x) && x.length === 0) return `${pad}${key}: []\n`; if (!Array.isArray(x) && Object.keys(x as object).length === 0) return `${pad}${key}: {}\n`; return `${pad}${key}:\n${toYaml(x, indent + 1)}`; }) .join(""); } /* ------------------------------------------------------------------ highlighting */ export type YamlTokenKind = "key" | "string" | "number" | "bool" | "comment" | "punct" | "text" | "anchor"; export interface YamlToken { kind: YamlTokenKind; text: string; } export function yamlTokens(line: string): YamlToken[] { const out: YamlToken[] = []; let rest = line; const indentMatch = /^(\s*)(-\s+)?/.exec(rest); if (indentMatch && indentMatch[0]) { out.push({ kind: "punct", text: indentMatch[0] }); rest = rest.slice(indentMatch[0].length); } if (rest.startsWith("#")) { out.push({ kind: "comment", text: rest }); return out; } const keyMatch = /^("[^"]*"|'[^']*'|[^\s:#{}[\],]+(?:\s[^\s:#{}[\],]+)*)\s*:(\s|$)/.exec(rest); if (keyMatch && keyMatch[1] !== undefined) { const keyText = keyMatch[1]; out.push({ kind: "key", text: keyText }); out.push({ kind: "punct", text: rest.slice(keyText.length, keyMatch[0].length) }); rest = rest.slice(keyMatch[0].length); } while (rest.length) { let m: RegExpExecArray | null; if ((m = /^#.*$/.exec(rest))) out.push({ kind: "comment", text: m[0] }); else if ((m = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*')/.exec(rest))) out.push({ kind: "string", text: m[0] }); else if ((m = /^(true|false|null|~|yes|no)(?=[\s,}\]]|$)/i.exec(rest))) out.push({ kind: "bool", text: m[0] }); else if ((m = /^[+-]?\d[\d_]*(\.\d+)?(e[+-]?\d+)?(?=[\s,}\]]|$)/i.exec(rest))) out.push({ kind: "number", text: m[0] }); else if ((m = /^[|>][+-]?\d*$/.exec(rest))) out.push({ kind: "anchor", text: m[0] }); else if ((m = /^[[\]{},:]\s*/.exec(rest))) out.push({ kind: "punct", text: m[0] }); else if ((m = /^[^"'#[\]{},:]+/.exec(rest))) out.push({ kind: "text", text: m[0] }); else { out.push({ kind: "text", text: rest[0]! }); m = null; rest = rest.slice(1); continue; } rest = rest.slice(m[0].length); } return out; } /* ------------------------------------------------------------------ line diff (LCS) */ export type DiffOp = { type: "same" | "add" | "del"; text: string; a?: number; b?: number }; /** Line diff with an LCS table (capped: beyond 3000×3000 lines it falls back to a head/tail alignment). */ export function diffLinesLcs(a: string, b: string): DiffOp[] { const A = a.split("\n"); const B = b.split("\n"); // trim common prefix / suffix to keep the table small let start = 0; while (start < A.length && start < B.length && A[start] === B[start]) start++; let endA = A.length; let endB = B.length; while (endA > start && endB > start && A[endA - 1] === B[endB - 1]) { endA--; endB--; } const out: DiffOp[] = []; for (let i = 0; i < start; i++) out.push({ type: "same", text: A[i]!, a: i + 1, b: i + 1 }); const midA = A.slice(start, endA); const midB = B.slice(start, endB); if (midA.length * midB.length > 9_000_000) { for (let i = 0; i < midA.length; i++) out.push({ type: "del", text: midA[i]!, a: start + i + 1 }); for (let j = 0; j < midB.length; j++) out.push({ type: "add", text: midB[j]!, b: start + j + 1 }); } else { const n = midA.length; const m = midB.length; const dp = new Uint32Array((n + 1) * (m + 1)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i * (m + 1) + j] = midA[i] === midB[j] ? dp[(i + 1) * (m + 1) + j + 1]! + 1 : Math.max(dp[(i + 1) * (m + 1) + j]!, dp[i * (m + 1) + j + 1]!); } } let i = 0; let j = 0; while (i < n && j < m) { if (midA[i] === midB[j]) { out.push({ type: "same", text: midA[i]!, a: start + i + 1, b: start + j + 1 }); i++; j++; } else if (dp[(i + 1) * (m + 1) + j]! >= dp[i * (m + 1) + j + 1]!) { out.push({ type: "del", text: midA[i]!, a: start + i + 1 }); i++; } else { out.push({ type: "add", text: midB[j]!, b: start + j + 1 }); j++; } } while (i < n) { out.push({ type: "del", text: midA[i]!, a: start + i + 1 }); i++; } while (j < m) { out.push({ type: "add", text: midB[j]!, b: start + j + 1 }); j++; } } for (let k = 0; k < A.length - endA; k++) out.push({ type: "same", text: A[endA + k]!, a: endA + k + 1, b: endB + k + 1 }); return out; } /** Collapse long unchanged runs to `context` lines around changes. */ export function collapseDiff(ops: DiffOp[], context = 3): Array { const keep = new Array(ops.length).fill(false); for (let i = 0; i < ops.length; i++) { if (ops[i]!.type === "same") continue; for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) keep[k] = true; } const out: Array = []; let skip = 0; for (let i = 0; i < ops.length; i++) { if (keep[i]) { if (skip) out.push({ type: "skip", count: skip }); skip = 0; out.push(ops[i]!); } else skip++; } if (skip) out.push({ type: "skip", count: skip }); return out; } /** Bump "v3" → "v4", "1.2" → "1.3", anything else → "-2". */ export function bumpVersion(v: string | null | undefined): string { const s = (v ?? "v1").trim(); const m = /^(.*?)(\d+)$/.exec(s); if (m) return `${m[1]}${Number(m[2]) + 1}`; return `${s}-2`; }