spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/**2 * Minimal YAML helpers for the admin console (no runtime YAML dependency in the web bundle):3 * - `toYaml()` serializes plain JSON data (objects / arrays / scalars) into readable block YAML;4 * - `yamlTokens()` tokenizes a YAML line for syntax highlighting;5 * - `diffLinesLcs()` line diff (LCS) for the compare views.6 * Parsing YAML is delegated to the API (`devtool/validate-config` returns the parsed config).7 */89const PLAIN = /^[A-Za-z0-9_][A-Za-z0-9_./:@-]*$/;10const RESERVED = new Set(["true", "false", "null", "yes", "no", "on", "off", "~", ""]);1112function scalar(v: unknown): string {13 if (v === null || v === undefined) return "null";14 if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null";15 if (typeof v === "boolean") return v ? "true" : "false";16 const s = String(v);17 if (s === "") return '""';18 if (RESERVED.has(s.toLowerCase()) || /^[-?:,[\]{}#&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s) || /^[+-]?\d/.test(s) || /[:#]\s|\n|\t/.test(s) || s.includes(": ") || s.endsWith(":")) {19 return JSON.stringify(s);20 }21 if (PLAIN.test(s) || !/["'\\]/.test(s)) return s;22 return JSON.stringify(s);23}2425function isScalar(v: unknown): boolean {26 return v === null || v === undefined || typeof v !== "object";27}2829function inlineable(v: unknown): boolean {30 if (Array.isArray(v)) return v.length > 0 && v.length <= 6 && v.every(isScalar) && v.map(scalar).join(", ").length < 70;31 if (v && typeof v === "object") {32 const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined);33 return entries.length > 0 && entries.length <= 5 && entries.every(([, x]) => isScalar(x)) && entries.map(([k, x]) => `${k}: ${scalar(x)}`).join(", ").length < 80;34 }35 return false;36}3738function inline(v: unknown): string {39 if (Array.isArray(v)) return `[${v.map(scalar).join(", ")}]`;40 const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined);41 return `{ ${entries.map(([k, x]) => `${k}: ${scalar(x)}`).join(", ")} }`;42}4344export function toYaml(value: unknown, indent = 0): string {45 const pad = " ".repeat(indent);46 if (isScalar(value)) return `${pad}${scalar(value)}\n`;47 if (Array.isArray(value)) {48 if (value.length === 0) return `${pad}[]\n`;49 return value50 .map((item) => {51 if (isScalar(item)) return `${pad}- ${scalar(item)}\n`;52 if (inlineable(item)) return `${pad}- ${inline(item)}\n`;53 if (Array.isArray(item)) return `${pad}-\n${toYaml(item, indent + 1)}`;54 const body = toYaml(item, indent + 1);55 // hoist the first key onto the dash line56 const lines = body.split("\n");57 const first = lines.shift() ?? "";58 return `${pad}- ${first.trimStart()}\n${lines.filter((l) => l !== "").join("\n")}${lines.length > 1 ? "\n" : ""}`;59 })60 .join("");61 }62 const entries = Object.entries(value as Record<string, unknown>).filter(([, x]) => x !== undefined);63 if (entries.length === 0) return `${pad}{}\n`;64 return entries65 .map(([k, x]) => {66 const key = PLAIN.test(k) ? k : JSON.stringify(k);67 if (isScalar(x)) {68 if (typeof x === "string" && x.includes("\n")) return `${pad}${key}: |\n${x.split("\n").map((l) => `${pad} ${l}`).join("\n")}\n`;69 return `${pad}${key}: ${scalar(x)}\n`;70 }71 if (inlineable(x)) return `${pad}${key}: ${inline(x)}\n`;72 if (Array.isArray(x) && x.length === 0) return `${pad}${key}: []\n`;73 if (!Array.isArray(x) && Object.keys(x as object).length === 0) return `${pad}${key}: {}\n`;74 return `${pad}${key}:\n${toYaml(x, indent + 1)}`;75 })76 .join("");77}7879/* ------------------------------------------------------------------ highlighting */8081export type YamlTokenKind = "key" | "string" | "number" | "bool" | "comment" | "punct" | "text" | "anchor";82export interface YamlToken {83 kind: YamlTokenKind;84 text: string;85}8687export function yamlTokens(line: string): YamlToken[] {88 const out: YamlToken[] = [];89 let rest = line;90 const indentMatch = /^(\s*)(-\s+)?/.exec(rest);91 if (indentMatch && indentMatch[0]) {92 out.push({ kind: "punct", text: indentMatch[0] });93 rest = rest.slice(indentMatch[0].length);94 }95 if (rest.startsWith("#")) {96 out.push({ kind: "comment", text: rest });97 return out;98 }99 const keyMatch = /^("[^"]*"|'[^']*'|[^\s:#{}[\],]+(?:\s[^\s:#{}[\],]+)*)\s*:(\s|$)/.exec(rest);100 if (keyMatch && keyMatch[1] !== undefined) {101 const keyText = keyMatch[1];102 out.push({ kind: "key", text: keyText });103 out.push({ kind: "punct", text: rest.slice(keyText.length, keyMatch[0].length) });104 rest = rest.slice(keyMatch[0].length);105 }106 while (rest.length) {107 let m: RegExpExecArray | null;108 if ((m = /^#.*$/.exec(rest))) out.push({ kind: "comment", text: m[0] });109 else if ((m = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*')/.exec(rest))) out.push({ kind: "string", text: m[0] });110 else if ((m = /^(true|false|null|~|yes|no)(?=[\s,}\]]|$)/i.exec(rest))) out.push({ kind: "bool", text: m[0] });111 else if ((m = /^[+-]?\d[\d_]*(\.\d+)?(e[+-]?\d+)?(?=[\s,}\]]|$)/i.exec(rest))) out.push({ kind: "number", text: m[0] });112 else if ((m = /^[|>][+-]?\d*$/.exec(rest))) out.push({ kind: "anchor", text: m[0] });113 else if ((m = /^[[\]{},:]\s*/.exec(rest))) out.push({ kind: "punct", text: m[0] });114 else if ((m = /^[^"'#[\]{},:]+/.exec(rest))) out.push({ kind: "text", text: m[0] });115 else {116 out.push({ kind: "text", text: rest[0]! });117 m = null;118 rest = rest.slice(1);119 continue;120 }121 rest = rest.slice(m[0].length);122 }123 return out;124}125126/* ------------------------------------------------------------------ line diff (LCS) */127128export type DiffOp = { type: "same" | "add" | "del"; text: string; a?: number; b?: number };129130/** Line diff with an LCS table (capped: beyond 3000×3000 lines it falls back to a head/tail alignment). */131export function diffLinesLcs(a: string, b: string): DiffOp[] {132 const A = a.split("\n");133 const B = b.split("\n");134 // trim common prefix / suffix to keep the table small135 let start = 0;136 while (start < A.length && start < B.length && A[start] === B[start]) start++;137 let endA = A.length;138 let endB = B.length;139 while (endA > start && endB > start && A[endA - 1] === B[endB - 1]) {140 endA--;141 endB--;142 }143 const out: DiffOp[] = [];144 for (let i = 0; i < start; i++) out.push({ type: "same", text: A[i]!, a: i + 1, b: i + 1 });145 const midA = A.slice(start, endA);146 const midB = B.slice(start, endB);147 if (midA.length * midB.length > 9_000_000) {148 for (let i = 0; i < midA.length; i++) out.push({ type: "del", text: midA[i]!, a: start + i + 1 });149 for (let j = 0; j < midB.length; j++) out.push({ type: "add", text: midB[j]!, b: start + j + 1 });150 } else {151 const n = midA.length;152 const m = midB.length;153 const dp = new Uint32Array((n + 1) * (m + 1));154 for (let i = n - 1; i >= 0; i--) {155 for (let j = m - 1; j >= 0; j--) {156 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]!);157 }158 }159 let i = 0;160 let j = 0;161 while (i < n && j < m) {162 if (midA[i] === midB[j]) {163 out.push({ type: "same", text: midA[i]!, a: start + i + 1, b: start + j + 1 });164 i++;165 j++;166 } else if (dp[(i + 1) * (m + 1) + j]! >= dp[i * (m + 1) + j + 1]!) {167 out.push({ type: "del", text: midA[i]!, a: start + i + 1 });168 i++;169 } else {170 out.push({ type: "add", text: midB[j]!, b: start + j + 1 });171 j++;172 }173 }174 while (i < n) {175 out.push({ type: "del", text: midA[i]!, a: start + i + 1 });176 i++;177 }178 while (j < m) {179 out.push({ type: "add", text: midB[j]!, b: start + j + 1 });180 j++;181 }182 }183 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 });184 return out;185}186187/** Collapse long unchanged runs to `context` lines around changes. */188export function collapseDiff(ops: DiffOp[], context = 3): Array<DiffOp | { type: "skip"; count: number }> {189 const keep = new Array<boolean>(ops.length).fill(false);190 for (let i = 0; i < ops.length; i++) {191 if (ops[i]!.type === "same") continue;192 for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) keep[k] = true;193 }194 const out: Array<DiffOp | { type: "skip"; count: number }> = [];195 let skip = 0;196 for (let i = 0; i < ops.length; i++) {197 if (keep[i]) {198 if (skip) out.push({ type: "skip", count: skip });199 skip = 0;200 out.push(ops[i]!);201 } else skip++;202 }203 if (skip) out.push({ type: "skip", count: skip });204 return out;205}206207/** Bump "v3" → "v4", "1.2" → "1.3", anything else → "<x>-2". */208export function bumpVersion(v: string | null | undefined): string {209 const s = (v ?? "v1").trim();210 const m = /^(.*?)(\d+)$/.exec(s);211 if (m) return `${m[1]}${Number(m[2]) + 1}`;212 return `${s}-2`;213}214