@pi-archimedes/ui 2.9.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/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +44 -0
- package/src/bash/index.ts +10 -0
- package/src/bash/renderer.test.ts +424 -0
- package/src/bash/renderer.ts +301 -0
- package/src/bash/tool.test.ts +26 -0
- package/src/bash/tool.ts +17 -0
- package/src/codemode/index.ts +20 -0
- package/src/codemode/loader.test.ts +139 -0
- package/src/codemode/loader.ts +115 -0
- package/src/codemode/renderer.test.ts +717 -0
- package/src/codemode/renderer.ts +399 -0
- package/src/codemode/tool.test.ts +149 -0
- package/src/codemode/tool.ts +96 -0
- package/src/config.test.ts +199 -0
- package/src/config.ts +129 -0
- package/src/editor/index.test.ts +820 -0
- package/src/editor/index.ts +380 -0
- package/src/editor/spin-quips.test.ts +106 -0
- package/src/editor/spin-quips.ts +44 -0
- package/src/editor/spin.test.ts +763 -0
- package/src/editor/spin.ts +493 -0
- package/src/index.test.ts +402 -0
- package/src/index.ts +235 -0
- package/src/migration.test.ts +256 -0
- package/src/migration.ts +94 -0
- package/src/settings.test.ts +122 -0
- package/src/settings.ts +135 -0
- package/src/startup/capture.ts +47 -0
- package/src/startup/index.test.ts +175 -0
- package/src/startup/index.ts +348 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/logo.ts +85 -0
- package/src/startup/sections.test.ts +333 -0
- package/src/startup/sections.ts +306 -0
- package/src/startup/version.test.ts +98 -0
- package/src/startup/version.ts +26 -0
- package/src/thinking/patch.test.ts +857 -0
- package/src/thinking/patch.ts +407 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/theme.ts +156 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/transform.ts +21 -0
- package/src/thinking/unindent.test.ts +74 -0
- package/src/thinking/unindent.ts +74 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
highlightCode as piHighlightCode,
|
|
4
|
+
initTheme as piInitTheme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { MarkdownTheme } from "@earendil-works/pi-tui";
|
|
7
|
+
import {
|
|
8
|
+
deriveDimColor,
|
|
9
|
+
hslToRgb,
|
|
10
|
+
parseAnsiFgToRgb,
|
|
11
|
+
rgbToHsl,
|
|
12
|
+
rgbToTruecolorFg,
|
|
13
|
+
hexToRgb,
|
|
14
|
+
} from "@pi-archimedes/core/color";
|
|
15
|
+
|
|
16
|
+
export interface MutedThemeOptions {
|
|
17
|
+
saturationFactor?: number; // default 0.5
|
|
18
|
+
codeDefaultLightness?: number; // default 0.85 — lighter than the token dim floor
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_ANCHOR_L = 0.4;
|
|
22
|
+
const DEFAULT_CODE_DEFAULT_L = 0.85;
|
|
23
|
+
|
|
24
|
+
// Matches any foreground-color SGR escape: truecolor (38;2;r;g;b) or 256-palette (38;5;n).
|
|
25
|
+
// We intentionally only target fg color escapes; bg (48;...) and style escapes
|
|
26
|
+
// (bold/italic/reset/etc.) are left untouched.
|
|
27
|
+
const FG_COLOR_ESCAPE_RE = /\x1b\[38;(?:2;\d{1,3};\d{1,3};\d{1,3}|5;\d{1,3})m/g;
|
|
28
|
+
|
|
29
|
+
// pi 0.84.4's module-level `highlightCode` reads a *global* Theme
|
|
30
|
+
// singleton that throws until `initTheme()` runs. pi's interactive
|
|
31
|
+
// mode initializes it at startup, but contexts that build the muted
|
|
32
|
+
// theme without going through that initialization (e.g. unit tests)
|
|
33
|
+
// would otherwise get "Theme not initialized. Call initTheme() first."
|
|
34
|
+
// We probe on first use and, only when the global is not set, call
|
|
35
|
+
// `initTheme("dark")` — built-in theme, no file watcher attached.
|
|
36
|
+
let piThemeReady = false;
|
|
37
|
+
function ensurePiHighlightable(): void {
|
|
38
|
+
if (piThemeReady) return;
|
|
39
|
+
piThemeReady = true;
|
|
40
|
+
try {
|
|
41
|
+
piHighlightCode(""); // probe: throws when the global is unset
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// Only fall back when the probe failed for the known reason, so an
|
|
44
|
+
// already-established pi theme is never clobbered by a surprise.
|
|
45
|
+
if (String(err).includes("Theme not initialized")) piInitTheme("dark");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rewrite every foreground-color SGR escape in `line` to its dimmed truecolor
|
|
51
|
+
* variant, preserving all other content (text, non-color escapes, resets).
|
|
52
|
+
*
|
|
53
|
+
* The cache memoizes raw→dim escape strings; callers should share a single
|
|
54
|
+
* cache across all lines in one theme-build invocation — highlightCode runs
|
|
55
|
+
* on every streaming chunk so the rewrite must be cheap.
|
|
56
|
+
*/
|
|
57
|
+
export function dimAnsiLine(
|
|
58
|
+
line: string,
|
|
59
|
+
anchorL: number,
|
|
60
|
+
saturationFactor: number,
|
|
61
|
+
cache: Map<string, string>,
|
|
62
|
+
): string {
|
|
63
|
+
return line.replace(FG_COLOR_ESCAPE_RE, (match) => {
|
|
64
|
+
const cached = cache.get(match);
|
|
65
|
+
if (cached !== undefined) return cached;
|
|
66
|
+
const rgb = parseAnsiFgToRgb(match);
|
|
67
|
+
if (!rgb) {
|
|
68
|
+
cache.set(match, match);
|
|
69
|
+
return match;
|
|
70
|
+
}
|
|
71
|
+
// deriveDimColor takes hex; we already have rgb, so build a hex string.
|
|
72
|
+
const hex =
|
|
73
|
+
"#" +
|
|
74
|
+
[rgb.r, rgb.g, rgb.b]
|
|
75
|
+
.map((v) => v.toString(16).padStart(2, "0"))
|
|
76
|
+
.join("");
|
|
77
|
+
const dimHex = deriveDimColor(hex, anchorL, saturationFactor);
|
|
78
|
+
const dimmed = rgbToTruecolorFg(hexToRgb(dimHex));
|
|
79
|
+
cache.set(match, dimmed);
|
|
80
|
+
return dimmed;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildMutedMarkdownTheme(
|
|
85
|
+
piTheme: Theme,
|
|
86
|
+
opts: MutedThemeOptions = {},
|
|
87
|
+
): MarkdownTheme {
|
|
88
|
+
const saturationFactor = opts.saturationFactor ?? 0.5;
|
|
89
|
+
const codeDefaultL = opts.codeDefaultLightness ?? DEFAULT_CODE_DEFAULT_L;
|
|
90
|
+
|
|
91
|
+
// Anchor lightness: derived from the theme's thinkingText foreground color.
|
|
92
|
+
const thinkingAnsi = piTheme.getFgAnsi("thinkingText");
|
|
93
|
+
const anchorRgb = parseAnsiFgToRgb(thinkingAnsi);
|
|
94
|
+
const anchorHsl = anchorRgb ? rgbToHsl(anchorRgb) : null;
|
|
95
|
+
const anchorL = anchorHsl ? anchorHsl.l : DEFAULT_ANCHOR_L;
|
|
96
|
+
|
|
97
|
+
// Default fg color used for UN-highlighted chars inside code blocks.
|
|
98
|
+
// cli-highlight only emits color escapes for recognized tokens — the gaps
|
|
99
|
+
// between them (operators, whitespace, unrecognized identifiers) render in
|
|
100
|
+
// the terminal's default color (often bright white on dark themes).
|
|
101
|
+
//
|
|
102
|
+
// We build a color that harmonizes with thinkingText (same hue/saturation)
|
|
103
|
+
// but sits at a BRIGHTER lightness than the token dim floor (anchorL),
|
|
104
|
+
// so white text is dimmed noticeably less than the colored tokens. Prepend
|
|
105
|
+
// this escape per line and re-emit it after every `\x1b[39m` fg reset so
|
|
106
|
+
// the gaps inherit this color instead of the terminal default.
|
|
107
|
+
const codeDefaultFg = anchorHsl
|
|
108
|
+
? rgbToTruecolorFg(
|
|
109
|
+
hslToRgb({
|
|
110
|
+
h: anchorHsl.h,
|
|
111
|
+
s: anchorHsl.s,
|
|
112
|
+
l: codeDefaultL,
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
: "";
|
|
116
|
+
|
|
117
|
+
// Shared memoization cache for the entire theme lifetime.
|
|
118
|
+
const dimCache = new Map<string, string>();
|
|
119
|
+
|
|
120
|
+
const fg = (token: string, text: string) =>
|
|
121
|
+
piTheme.fg(token as Parameters<Theme["fg"]>[0], text);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
codeBlockIndent: "",
|
|
125
|
+
heading: (text) => `\x1b[1m${rgbToTruecolorFg(hexToRgb("#FFD700"))}${text}\x1b[39m\x1b[22m`,
|
|
126
|
+
link: (text) => `\x1b[4m${fg("thinkingText", text)}\x1b[24m`,
|
|
127
|
+
linkUrl: (text) => fg("dim", text),
|
|
128
|
+
code: (text) => fg("dim", text),
|
|
129
|
+
codeBlock: (text) => fg("thinkingText", text),
|
|
130
|
+
codeBlockBorder: (text) => fg("dim", text),
|
|
131
|
+
quote: (text) => fg("thinkingText", text),
|
|
132
|
+
quoteBorder: (text) => fg("dim", text),
|
|
133
|
+
hr: (text) => fg("dim", text),
|
|
134
|
+
listBullet: (text) => fg("dim", text),
|
|
135
|
+
bold: (text) => `\x1b[1m${rgbToTruecolorFg(hexToRgb("#FFD700"))}${text}\x1b[39m\x1b[22m`,
|
|
136
|
+
italic: (text) => `\x1b[3m${text}\x1b[23m`,
|
|
137
|
+
strikethrough: (text) => `\x1b[9m${fg("dim", text)}\x1b[29m`,
|
|
138
|
+
underline: (text) => `\x1b[4m${fg("thinkingText", text)}\x1b[24m`,
|
|
139
|
+
highlightCode: (code, lang) => {
|
|
140
|
+
ensurePiHighlightable();
|
|
141
|
+
const lines = piHighlightCode(code, lang);
|
|
142
|
+
return lines.map((l) => {
|
|
143
|
+
const dimmed = dimAnsiLine(l, anchorL, saturationFactor, dimCache);
|
|
144
|
+
if (!codeDefaultFg) return dimmed;
|
|
145
|
+
// Blanket the line in the code-default color; after every fg reset
|
|
146
|
+
// (emitted by cli-highlight between colored tokens) re-open the
|
|
147
|
+
// default so un-tokenized chars inherit it.
|
|
148
|
+
const withDefault = dimmed.replace(
|
|
149
|
+
/\x1b\[39m/g,
|
|
150
|
+
`\x1b[39m${codeDefaultFg}`,
|
|
151
|
+
);
|
|
152
|
+
return `${codeDefaultFg}${withDefault}\x1b[39m`;
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { transformThinkingContent } from "./transform.js";
|
|
3
|
+
|
|
4
|
+
describe("transformThinkingContent", () => {
|
|
5
|
+
it("modifies thinking content on assistant messages", () => {
|
|
6
|
+
const message = {
|
|
7
|
+
role: "assistant" as const,
|
|
8
|
+
content: [
|
|
9
|
+
{ type: "thinking" as const, thinking: " some thinking " },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
transformThinkingContent(message);
|
|
13
|
+
expect(message.content[0]!.thinking).toBe("some thinking");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("skips non-assistant messages", () => {
|
|
17
|
+
const message = {
|
|
18
|
+
role: "user" as const,
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "thinking" as const, thinking: " should not change " },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
transformThinkingContent(message);
|
|
24
|
+
expect(message.content[0]!.thinking).toBe(" should not change ");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("skips empty thinking", () => {
|
|
28
|
+
const message = {
|
|
29
|
+
role: "assistant" as const,
|
|
30
|
+
content: [
|
|
31
|
+
{ type: "thinking" as const, thinking: " " },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
transformThinkingContent(message);
|
|
35
|
+
expect(message.content[0]!.thinking).toBe(" ");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("skips thinking with undefined value", () => {
|
|
39
|
+
const message = {
|
|
40
|
+
role: "assistant" as const,
|
|
41
|
+
content: [
|
|
42
|
+
{ type: "thinking" as const } as { type: string; thinking?: string },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
transformThinkingContent(message);
|
|
46
|
+
expect(message.content[0]!.thinking).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("calls unindentCodeBlocks on thinking content", () => {
|
|
50
|
+
const message = {
|
|
51
|
+
role: "assistant" as const,
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "thinking" as const,
|
|
55
|
+
thinking: "```\n indented code\n more code\n```",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
transformThinkingContent(message);
|
|
60
|
+
expect(message.content[0]!.thinking).toBe("```\nindented code\nmore code\n```");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles mixed content types", () => {
|
|
64
|
+
const message = {
|
|
65
|
+
role: "assistant" as const,
|
|
66
|
+
content: [
|
|
67
|
+
{ type: "text" as const, text: "some text" },
|
|
68
|
+
{ type: "thinking" as const, thinking: " thinking content " },
|
|
69
|
+
{ type: "text" as const, text: "more text" },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
transformThinkingContent(message);
|
|
73
|
+
expect(message.content[0]).toEqual({ type: "text", text: "some text" });
|
|
74
|
+
expect(message.content[1]!.thinking).toBe("thinking content");
|
|
75
|
+
expect(message.content[2]).toEqual({ type: "text", text: "more text" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("after transform, thinking is trimmed", () => {
|
|
79
|
+
const message = {
|
|
80
|
+
role: "assistant" as const,
|
|
81
|
+
content: [
|
|
82
|
+
{ type: "thinking" as const, thinking: " hello world " },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
transformThinkingContent(message);
|
|
86
|
+
const thinking = message.content[0]!.thinking;
|
|
87
|
+
expect(thinking).toBe(thinking?.trim());
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { unindentCodeBlocks } from "./unindent.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* message_end handler: mutate thinking content before it reaches the UI.
|
|
5
|
+
*
|
|
6
|
+
* Only does unindent of fenced code blocks. The "Thinking..." label is
|
|
7
|
+
* prepended inside updateContent (patch.ts) so it appears during streaming,
|
|
8
|
+
* not just after thinking completes.
|
|
9
|
+
*/
|
|
10
|
+
export function transformThinkingContent(
|
|
11
|
+
message: { role: string; content: Array<{ type: string; thinking?: string }> },
|
|
12
|
+
): void {
|
|
13
|
+
if (message.role !== "assistant") return;
|
|
14
|
+
|
|
15
|
+
for (const content of message.content) {
|
|
16
|
+
if (content.type === "thinking" && content.thinking?.trim()) {
|
|
17
|
+
const trimmed = content.thinking.trim();
|
|
18
|
+
content.thinking = unindentCodeBlocks(trimmed);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { unindentCodeBlocks } from "./unindent.js";
|
|
4
|
+
|
|
5
|
+
describe("unindentCodeBlocks", () => {
|
|
6
|
+
it("strips common leading whitespace from fenced code blocks", () => {
|
|
7
|
+
const input = "```\n line1\n line2\n```";
|
|
8
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("preserves empty lines structure", () => {
|
|
12
|
+
const input = "```\n line1\n\n line2\n```";
|
|
13
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\n\nline2\n```");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("leaves whitespace-only blocks untouched", () => {
|
|
17
|
+
const input = "```\n \n \n```";
|
|
18
|
+
expect(unindentCodeBlocks(input)).toBe("```\n \n \n```");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handles CRLF → LF normalization", () => {
|
|
22
|
+
const input = "```\r\n line1\r\n line2\r\n```";
|
|
23
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles blocks with 0 indent on some lines (no stripping)", () => {
|
|
27
|
+
const input = "```\n indented\nnotindented\n indented\n```";
|
|
28
|
+
expect(unindentCodeBlocks(input)).toBe("```\n indented\nnotindented\n indented\n```");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips trailing empty lines from code blocks", () => {
|
|
32
|
+
const input = "```\n line1\n line2\n\n\n```";
|
|
33
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles language tags", () => {
|
|
37
|
+
const input = "```python\n def foo():\n pass\n```";
|
|
38
|
+
expect(unindentCodeBlocks(input)).toBe("```python\ndef foo():\n pass\n```");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves text outside code blocks unchanged", () => {
|
|
42
|
+
const input = "Some text\n```\n code\n```\nMore text";
|
|
43
|
+
expect(unindentCodeBlocks(input)).toBe("Some text\n```\ncode\n```\nMore text");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles multiple code blocks", () => {
|
|
47
|
+
const input = "```\n block1\n```\n```\n block2\n```";
|
|
48
|
+
expect(unindentCodeBlocks(input)).toBe("```\nblock1\n```\n```\nblock2\n```");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles empty code blocks with content", () => {
|
|
52
|
+
const input = "```\n```";
|
|
53
|
+
expect(unindentCodeBlocks(input)).toBe("```\n```");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("property: idempotence — unindentCodeBlocks(unindentCodeBlocks(x)) === unindentCodeBlocks(x)", () => {
|
|
57
|
+
fc.assert(
|
|
58
|
+
fc.property(fc.string({ maxLength: 500 }), (s) => {
|
|
59
|
+
const once = unindentCodeBlocks(s);
|
|
60
|
+
const twice = unindentCodeBlocks(once);
|
|
61
|
+
return once === twice;
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("property: output never contains CRLF (\\r\\n)", () => {
|
|
67
|
+
fc.assert(
|
|
68
|
+
fc.property(fc.string({ maxLength: 500 }), (s) => {
|
|
69
|
+
const result = unindentCodeBlocks(s);
|
|
70
|
+
return !result.includes("\r\n");
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip common leading whitespace and trailing blank lines from every fenced
|
|
3
|
+
* code block in `text`. Returns the transformed string.
|
|
4
|
+
*
|
|
5
|
+
* Algorithm per block:
|
|
6
|
+
* 0. Normalize `\r\n` → `\n` in the full input text before processing.
|
|
7
|
+
* 1. Find the minimum leading whitespace across all non-empty lines.
|
|
8
|
+
* 2. Strip that many characters from the start of every line
|
|
9
|
+
* (empty lines stay empty — they contribute no content but preserve structure).
|
|
10
|
+
* 3. Pop trailing empty lines.
|
|
11
|
+
*
|
|
12
|
+
* Trailing blank lines are ALWAYS stripped, even from 0-indent blocks.
|
|
13
|
+
* Leading whitespace is only stripped when minIndent > 0.
|
|
14
|
+
* Blocks containing only whitespace are left untouched.
|
|
15
|
+
*
|
|
16
|
+
* Known limitations:
|
|
17
|
+
* - Only space-based indentation is handled (tabs are not expanded).
|
|
18
|
+
* - If one line has 0 indent and others have N indent, nothing is stripped
|
|
19
|
+
* (minIndent === 0). This is standard textwrap.dedent behavior.
|
|
20
|
+
* - Fenced code blocks using 4+ backticks may be misparsed. The regex uses a
|
|
21
|
+
* negative lookahead to reject 4+ backtick openings (`/^(```(?!`))/
|
|
22
|
+
* - Line endings in the output are always `\n` (CRLF input is normalized).
|
|
23
|
+
*/
|
|
24
|
+
export function unindentCodeBlocks(text: string): string {
|
|
25
|
+
// Step 0: normalize CRLF → LF
|
|
26
|
+
text = text.replace(/\r\n/g, "\n");
|
|
27
|
+
|
|
28
|
+
// Regex matches fenced code blocks with 3 backticks (not 4+).
|
|
29
|
+
// Group 1: opening fence
|
|
30
|
+
// Group 2: language tag (may be empty)
|
|
31
|
+
// Group 3: block content (everything between fences)
|
|
32
|
+
// Group 4: closing fence
|
|
33
|
+
const regex = /^(```(?!`))([^\n]*)\n([\s\S]*?)^(```(?!`))[ \t]*$/gm;
|
|
34
|
+
|
|
35
|
+
return text.replace(regex, (match, opening, lang, content, closing) => {
|
|
36
|
+
const lines = content.split("\n");
|
|
37
|
+
|
|
38
|
+
// Check if all lines are empty or whitespace-only → leave untouched
|
|
39
|
+
const allWhitespace = lines.every((line: string) => line.trim() === "");
|
|
40
|
+
if (allWhitespace) {
|
|
41
|
+
return match;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Find minimum leading space count across non-empty lines
|
|
45
|
+
let minIndent = Infinity;
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
if (line.length === 0) continue; // skip empty lines
|
|
48
|
+
const m = line.match(/^( +)/);
|
|
49
|
+
if (m) {
|
|
50
|
+
minIndent = Math.min(minIndent, m[1].length);
|
|
51
|
+
} else {
|
|
52
|
+
// Line has no leading spaces → minIndent is 0
|
|
53
|
+
minIndent = 0;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Strip common leading whitespace (only if minIndent > 0)
|
|
59
|
+
if (minIndent > 0 && minIndent !== Infinity) {
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
if (lines[i].length >= minIndent) {
|
|
62
|
+
lines[i] = lines[i].slice(minIndent);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Pop trailing empty lines
|
|
68
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
69
|
+
lines.pop();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return `${opening}${lang}\n${lines.join("\n")}\n${closing}`;
|
|
73
|
+
});
|
|
74
|
+
}
|