@f5xc-salesdemos/pi-utils 19.41.0 → 19.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/pi-utils",
4
- "version": "19.41.0",
4
+ "version": "19.42.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3",
42
- "@f5xc-salesdemos/pi-natives": "19.41.0"
42
+ "@f5xc-salesdemos/pi-natives": "19.42.0"
43
43
  },
44
44
  "engines": {
45
45
  "bun": ">=1.3.7"
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export {
21
21
  export * from "./json";
22
22
  export * as logger from "./logger";
23
23
  export * from "./mermaid-ascii";
24
+ export * from "./mermaid-color";
24
25
  export * from "./mime";
25
26
  export * from "./models-yml";
26
27
  export * from "./peek-file";
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Mermaid color helpers — terminal color-mode selection, diagram-type detection,
3
+ * and a best-effort per-node tint pass for ASCII/Unicode mermaid output.
4
+ *
5
+ * `beautiful-mermaid` colorizes strictly by global character role (text/border/
6
+ * line/arrow/…), so per-node hues are not reachable through its API. `tintMermaidNodes`
7
+ * adds them as a post-pass: it detects node-box rectangles in the rendered output and
8
+ * overrides the foreground color of each box's border + label cells with a cycled accent.
9
+ * It is intentionally fail-safe — any ambiguity returns the input unchanged so the
10
+ * library's per-role coloring still shows through.
11
+ */
12
+
13
+ /** Color mode subset we drive `beautiful-mermaid` with. */
14
+ export type MermaidColorMode = "none" | "ansi256" | "truecolor";
15
+
16
+ export interface ColorModeInput {
17
+ /** NO_COLOR set, output piped, or color explicitly disabled. */
18
+ noColor: boolean;
19
+ /** Terminal advertises 24-bit color. */
20
+ trueColor: boolean;
21
+ }
22
+
23
+ /** Choose the richest color mode the environment robustly supports. */
24
+ export function pickColorMode({ noColor, trueColor }: ColorModeInput): MermaidColorMode {
25
+ if (noColor) return "none";
26
+ return trueColor ? "truecolor" : "ansi256";
27
+ }
28
+
29
+ export type MermaidDiagramType = "flowchart" | "sequence" | "class" | "er" | "state" | "xychart" | "unknown";
30
+
31
+ /** Detect the diagram type from the first meaningful header line of mermaid source. */
32
+ export function detectDiagramType(source: string): MermaidDiagramType {
33
+ for (const raw of source.split("\n")) {
34
+ const line = raw.trim();
35
+ // Skip blanks, YAML frontmatter fences, %%{init}%% directives, and %% comments.
36
+ if (line === "" || line === "---" || line.startsWith("%%")) continue;
37
+ // Skip frontmatter key/value lines (anything before we hit a known header).
38
+ if (/^(graph|flowchart)\b/.test(line)) return "flowchart";
39
+ if (/^sequenceDiagram\b/.test(line)) return "sequence";
40
+ if (/^classDiagram(-v2)?\b/.test(line)) return "class";
41
+ if (/^erDiagram\b/.test(line)) return "er";
42
+ if (/^stateDiagram(-v2)?\b/.test(line)) return "state";
43
+ if (/^xychart(-beta)?\b/.test(line)) return "xychart";
44
+ // A header-shaped token that isn't recognized → give up (avoid matching frontmatter values).
45
+ if (/^[A-Za-z]/.test(line) && !line.includes(":")) return "unknown";
46
+ }
47
+ return "unknown";
48
+ }
49
+
50
+ // ── per-node tint pass ──────────────────────────────────────────────────────
51
+
52
+ interface Cell {
53
+ char: string;
54
+ /** Active foreground SGR sequence ("" = default). */
55
+ fg: string;
56
+ }
57
+
58
+ interface Rect {
59
+ top: number;
60
+ left: number;
61
+ bottom: number;
62
+ right: number;
63
+ }
64
+
65
+ const TL = new Set(["┌", "╭", "+"]);
66
+ const isH = (ch: string): boolean => ch === "─" || ch === "-";
67
+ const isV = (ch: string): boolean => ch === "│" || ch === "|";
68
+ // Borders may carry an edge-attachment junction (a tee/cross) where an edge meets
69
+ // the box; accept those so attached nodes are still detected and tinted.
70
+ const isHBorder = (ch: string): boolean => isH(ch) || ch === "┬" || ch === "┴" || ch === "┼";
71
+ const isVBorder = (ch: string): boolean => isV(ch) || ch === "├" || ch === "┤" || ch === "┼";
72
+
73
+ /** Parse a colored line into per-visible-cell {char, fg}, tracking the active fg color. */
74
+ function parseCells(line: string): Cell[] {
75
+ const cells: Cell[] = [];
76
+ let fg = "";
77
+ const re = /\x1b\[[0-9;]*m/g;
78
+ let last = 0;
79
+ for (let m = re.exec(line); m !== null; m = re.exec(line)) {
80
+ for (const ch of line.slice(last, m.index)) cells.push({ char: ch, fg });
81
+ fg = nextFg(fg, m[0]);
82
+ last = re.lastIndex;
83
+ }
84
+ for (const ch of line.slice(last)) cells.push({ char: ch, fg });
85
+ return cells;
86
+ }
87
+
88
+ /** Compute the new active fg from an SGR sequence (only fg-affecting codes matter). */
89
+ function nextFg(prev: string, seq: string): string {
90
+ const params = seq.slice(2, -1); // strip ESC[ and trailing m
91
+ if (params === "" || params === "0") return "";
92
+ if (params.startsWith("38;2;") || params.startsWith("38;5;")) return seq;
93
+ if (params === "39") return "";
94
+ if (/^(3[0-7]|9[0-7])$/.test(params)) return seq;
95
+ return prev; // bold/underline/bg/etc. — leave fg unchanged
96
+ }
97
+
98
+ /** Locate a single rectangle whose top-left corner is at (r,c), or null. */
99
+ function readRect(grid: string[][], r: number, c: number): Rect | null {
100
+ const tl = grid[r]?.[c];
101
+ if (tl === undefined || !TL.has(tl)) return null;
102
+ const ascii = tl === "+";
103
+ const trCh = ascii ? "+" : tl === "╭" ? "╮" : "┐";
104
+ const blCh = ascii ? "+" : tl === "╭" ? "╰" : "└";
105
+ const brCh = ascii ? "+" : tl === "╭" ? "╯" : "┘";
106
+
107
+ const row = grid[r]!;
108
+ let right = -1;
109
+ for (let x = c + 1; x < row.length; x++) {
110
+ const ch = row[x]!;
111
+ if (ch === trCh) {
112
+ right = x;
113
+ break;
114
+ }
115
+ if (!isHBorder(ch)) break;
116
+ }
117
+ if (right < c + 2) return null; // need interior width
118
+
119
+ let bottom = -1;
120
+ for (let y = r + 1; y < grid.length; y++) {
121
+ const ch = grid[y]?.[c];
122
+ if (ch === undefined) break;
123
+ if (ch === blCh) {
124
+ bottom = y;
125
+ break;
126
+ }
127
+ if (!isVBorder(ch)) break;
128
+ }
129
+ if (bottom < r + 2) return null; // need interior height
130
+
131
+ if (grid[bottom]?.[right] !== brCh) return null;
132
+ // Verify bottom edge and right edge (junctions where edges attach are allowed).
133
+ for (let x = c + 1; x < right; x++) if (!isHBorder(grid[bottom]?.[x] ?? "")) return null;
134
+ for (let y = r + 1; y < bottom; y++) if (!isVBorder(grid[y]?.[right] ?? "")) return null;
135
+
136
+ return { top: r, left: c, bottom, right };
137
+ }
138
+
139
+ function findRects(grid: string[][]): Rect[] {
140
+ const rects: Rect[] = [];
141
+ for (let r = 0; r < grid.length; r++) {
142
+ const row = grid[r]!;
143
+ for (let c = 0; c < row.length; c++) {
144
+ if (TL.has(row[c]!)) {
145
+ const rect = readRect(grid, r, c);
146
+ if (rect) rects.push(rect);
147
+ }
148
+ }
149
+ }
150
+ return rects;
151
+ }
152
+
153
+ function contains(a: Rect, b: Rect): boolean {
154
+ if (a === b) return false;
155
+ return a.top <= b.top && a.left <= b.left && a.bottom >= b.bottom && a.right >= b.right;
156
+ }
157
+
158
+ /**
159
+ * Tint each node box a cycled accent color. `coloredLines` is the role-colored
160
+ * mermaid render; `accentsAnsi` is a list of fg SGR escapes to cycle across nodes.
161
+ * Returns the input unchanged if no boxes are found or no accents are given.
162
+ */
163
+ export function tintMermaidNodes(coloredLines: string[], accentsAnsi: string[]): string[] {
164
+ if (accentsAnsi.length === 0) return coloredLines;
165
+ try {
166
+ const rows = coloredLines.map(parseCells);
167
+ const grid = rows.map(cells => cells.map(cell => cell.char));
168
+ const rects = findRects(grid);
169
+ if (rects.length === 0) return coloredLines;
170
+
171
+ // Nodes are the innermost rectangles (a subgraph frame contains others → skip it).
172
+ const nodes = rects
173
+ .filter(rect => !rects.some(other => contains(rect, other)))
174
+ .sort((p, q) => p.top - q.top || p.left - q.left);
175
+ if (nodes.length === 0) return coloredLines;
176
+
177
+ // overrides: "r,c" → accent fg sequence.
178
+ const overrides = new Map<string, string>();
179
+ nodes.forEach((rect, i) => {
180
+ const accent = accentsAnsi[i % accentsAnsi.length]!;
181
+ for (let x = rect.left; x <= rect.right; x++) {
182
+ overrides.set(`${rect.top},${x}`, accent);
183
+ overrides.set(`${rect.bottom},${x}`, accent);
184
+ }
185
+ for (let y = rect.top; y <= rect.bottom; y++) {
186
+ overrides.set(`${y},${rect.left}`, accent);
187
+ overrides.set(`${y},${rect.right}`, accent);
188
+ for (let x = rect.left + 1; x < rect.right; x++) {
189
+ if ((grid[y]?.[x] ?? " ") !== " ") overrides.set(`${y},${x}`, accent);
190
+ }
191
+ }
192
+ });
193
+
194
+ return rows.map((cells, r) => {
195
+ const hasOverride = cells.some((_, c) => overrides.has(`${r},${c}`));
196
+ if (!hasOverride) return coloredLines[r]!;
197
+ let out = "";
198
+ let lastFg = "";
199
+ for (let c = 0; c < cells.length; c++) {
200
+ const cell = cells[c]!;
201
+ const fg = overrides.get(`${r},${c}`) ?? cell.fg;
202
+ if (fg !== lastFg) {
203
+ out += fg === "" ? "\x1b[39m" : fg;
204
+ lastFg = fg;
205
+ }
206
+ out += cell.char;
207
+ }
208
+ if (lastFg !== "") out += "\x1b[0m";
209
+ return out;
210
+ });
211
+ } catch {
212
+ return coloredLines;
213
+ }
214
+ }