SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
8.8 KB · 214 lines typescript
Raw Blame History
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