@oh-my-pi/pi-utils 18.11.2 → 18.12.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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.0] - 2026-10-17
6
+
7
+ ### Added
8
+
9
+ - Added `sanitizeTextDeep()`, which applies `sanitizeText()` to every string in a JSON-shaped value without mutating it ([#15963](https://github.com/can1357/oh-my-pi/issues/15963))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Windows archive extraction overwriting existing files or following existing symlinks when copying a symlink entry without symlink privileges ([#15931](https://github.com/can1357/oh-my-pi/pull/15931) by [@jchanghong023](https://github.com/jchanghong023)).
14
+
15
+ ## [18.11.3] - 2026-10-16
16
+
17
+ ### Fixed
18
+
19
+ - Fixed Markdown fence info-string parsing to keep backtick-containing backtick openers as paragraph text while accepting backticks in tilde info strings and Unicode line separators in either fence's info string ([#15928](https://github.com/can1357/oh-my-pi/pull/15928) by [@jchanghong023](https://github.com/jchanghong023)).
20
+ - Fixed `parseHTML` treating `<link>` in RSS/Atom documents as an HTML void element, which dropped the URL text of RSS `<link>…</link>` ([#15954](https://github.com/can1357/oh-my-pi/issues/15954))
21
+
5
22
  ## [18.11.2] - 2026-10-16
6
23
 
7
24
  ### Added
@@ -12,6 +12,17 @@
12
12
  * string after the control probe.
13
13
  */
14
14
  export declare function sanitizeText(text: string): string;
15
+ /**
16
+ * Apply {@link sanitizeText} to every string value inside a JSON-shaped value.
17
+ * Only plain objects and arrays are walked; class instances, typed arrays, and
18
+ * cyclic back-references pass through untouched. Object keys are kept as-is.
19
+ *
20
+ * Returns the input reference when nothing changed, so reference-keyed caches
21
+ * stay warm; changed containers are copied and the input is never mutated.
22
+ * The iterative walk also handles arbitrarily deep tool-result `details`
23
+ * without exhausting the TUI's call stack.
24
+ */
25
+ export declare function sanitizeTextDeep(value: unknown): unknown;
15
26
  /**
16
27
  * Escape the three XML-significant characters (`&`, `<`, `>`) in text destined
17
28
  * for an XML/markup element body. Allocation-conscious: returns the input
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.11.2",
3
+ "version": "18.12.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.11.2"
60
+ "@oh-my-pi/pi-natives": "18.12.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/ar/open.ts CHANGED
@@ -234,7 +234,8 @@ export async function extractArchive(
234
234
  // privilege; skip the entry so the rest still extracts.
235
235
  const extracted = await archive.readFile(link.path).catch(() => undefined);
236
236
  if (!extracted) continue;
237
- await Bun.write(outputPath, extracted.bytes);
237
+ // Keep symlink creation's exclusive destination semantics.
238
+ await fs.writeFile(outputPath, extracted.bytes, { flag: "wx" });
238
239
  const permissions = (extracted.mode ?? 0) & 0o777;
239
240
  if (permissions) await fs.chmod(outputPath, permissions);
240
241
  }
package/src/dom/parser.ts CHANGED
@@ -366,7 +366,12 @@ export function parseInto(html: string, document: Document, root: Document | Doc
366
366
  index = match ? closePattern.lastIndex : html.length;
367
367
  continue;
368
368
  }
369
- if (!VOID_ELEMENTS[parsed.name] && !(namespace === SVG_NAMESPACE && parsed.selfClosing)) {
369
+ // Feed documents follow XML rules: only an explicit `/>` closes immediately, so RSS `<link>url</link>`
370
+ // keeps its text instead of being treated as an HTML void element.
371
+ const isVoid = xmlLike
372
+ ? parsed.selfClosing
373
+ : VOID_ELEMENTS[parsed.name] || (namespace === SVG_NAMESPACE && parsed.selfClosing);
374
+ if (!isVoid) {
370
375
  stack.push(element);
371
376
  parent = element;
372
377
  }
@@ -416,7 +416,11 @@ function wrapNetworkError(error: unknown): Error {
416
416
  return new Error("Request was aborted");
417
417
  }
418
418
  if (error.message === "fetch failed" && error.cause instanceof Error) {
419
- return new Error(`Network error: ${error.cause.message}`);
419
+ // Keep the original rejection as `cause`: undici nests the errno under
420
+ // `fetch failed` (often through an AggregateError), and
421
+ // classifyNetworkCause (packages/ai/src/error/network-cause.ts) walks
422
+ // `cause`/`errors` off this error. Flattening to text alone loses it.
423
+ return new Error(`Network error: ${error.cause.message}`, { cause: error });
420
424
  }
421
425
  return error;
422
426
  }
@@ -775,7 +775,10 @@ function splitTableRow(line: string): string[] {
775
775
  }
776
776
 
777
777
  function isFence(line: string): RegExpExecArray | null {
778
- return /^ {0,3}(`{3,}|~{3,})(.*?)(?:\n|$)$/.exec(line);
778
+ // Unicode line separators may occur in the info string.
779
+ const match = /^ {0,3}(`{3,}|~{3,})([^\n]*?)(?:\n|$)$/.exec(line);
780
+ // Backtick fences cannot contain backticks in their info string.
781
+ return match && match[1]!.startsWith("`") && match[2]!.includes("`") ? null : match;
779
782
  }
780
783
  function isHeading(line: string): boolean {
781
784
  return /^ {0,3}#{1,6}(?:\s|$)/.test(line);
@@ -12,6 +12,8 @@
12
12
  * string after the control probe.
13
13
  */
14
14
 
15
+ import { isRecord } from "./type-guards";
16
+
15
17
  const ESC_CHAR = "\x1b";
16
18
 
17
19
  // Well-formed strings only need control/ANSI detection: C0 (excl. \t \n),
@@ -37,6 +39,74 @@ function sanitizeWellFormedText(text: string): string {
37
39
  return stripped.replace(CONTROL_RE, "");
38
40
  }
39
41
 
42
+ /**
43
+ * Apply {@link sanitizeText} to every string value inside a JSON-shaped value.
44
+ * Only plain objects and arrays are walked; class instances, typed arrays, and
45
+ * cyclic back-references pass through untouched. Object keys are kept as-is.
46
+ *
47
+ * Returns the input reference when nothing changed, so reference-keyed caches
48
+ * stay warm; changed containers are copied and the input is never mutated.
49
+ * The iterative walk also handles arbitrarily deep tool-result `details`
50
+ * without exhausting the TUI's call stack.
51
+ */
52
+ export function sanitizeTextDeep(value: unknown): unknown {
53
+ if (typeof value === "string") return sanitizeText(value);
54
+ const root = sanitizerFrame(value);
55
+ if (!root) return value;
56
+ const stack: SanitizerFrame[] = [root];
57
+ const ancestors = new Set<object>([root.value]);
58
+ while (true) {
59
+ const frame = stack[stack.length - 1]!;
60
+ const length = frame.kind === "array" ? frame.value.length : frame.keys.length;
61
+ if (frame.index === length) {
62
+ const clean = frame.copy ?? frame.value;
63
+ ancestors.delete(frame.value);
64
+ stack.pop();
65
+ if (stack.length === 0) return clean;
66
+ acceptSanitizedValue(stack[stack.length - 1]!, clean);
67
+ continue;
68
+ }
69
+ const child = frame.kind === "array" ? frame.value[frame.index++] : frame.value[frame.keys[frame.index++]!];
70
+ if (typeof child === "string") {
71
+ acceptSanitizedValue(frame, sanitizeText(child));
72
+ continue;
73
+ }
74
+ if (child === null || typeof child !== "object" || ancestors.has(child)) continue;
75
+ const next = sanitizerFrame(child);
76
+ if (!next) continue;
77
+ ancestors.add(child);
78
+ stack.push(next);
79
+ }
80
+ }
81
+
82
+ type SanitizerFrame =
83
+ | { kind: "array"; value: unknown[]; index: number; copy?: unknown[] }
84
+ | { kind: "record"; value: Record<string, unknown>; keys: string[]; index: number; copy?: Record<string, unknown> };
85
+
86
+ function sanitizerFrame(value: unknown): SanitizerFrame | undefined {
87
+ if (Array.isArray(value)) return { kind: "array", value, index: 0 };
88
+ if (!isRecord(value)) return undefined;
89
+ const proto: unknown = Object.getPrototypeOf(value);
90
+ if (proto !== Object.prototype && proto !== null) return undefined;
91
+ return { kind: "record", value, keys: Object.keys(value), index: 0 };
92
+ }
93
+
94
+ function acceptSanitizedValue(frame: SanitizerFrame, clean: unknown): void {
95
+ if (frame.kind === "array") {
96
+ const index = frame.index - 1;
97
+ if (clean !== frame.value[index]) (frame.copy ??= frame.value.slice())[index] = clean;
98
+ return;
99
+ }
100
+ const key = frame.keys[frame.index - 1]!;
101
+ if (clean === frame.value[key]) return;
102
+ const copy = (frame.copy ??= { ...frame.value });
103
+ if (key === "__proto__") {
104
+ Object.defineProperty(copy, key, { value: clean, writable: true, enumerable: true, configurable: true });
105
+ } else {
106
+ copy[key] = clean;
107
+ }
108
+ }
109
+
40
110
  /**
41
111
  * Escape the three XML-significant characters (`&`, `<`, `>`) in text destined
42
112
  * for an XML/markup element body. Allocation-conscious: returns the input