@oh-my-pi/pi-utils 18.6.3 → 18.8.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/types/ar/index.d.ts +1 -0
  3. package/dist/types/ar/zip-package.d.ts +21 -0
  4. package/dist/types/docx/converter.d.ts +2 -0
  5. package/dist/types/dom/core.d.ts +15 -4
  6. package/dist/types/dom/parser.d.ts +5 -3
  7. package/dist/types/dom/selector.d.ts +7 -1
  8. package/dist/types/logger/rotating-file.d.ts +4 -2
  9. package/dist/types/marked/core.d.ts +7 -6
  10. package/dist/types/marked-list.d.ts +38 -0
  11. package/dist/types/math-delimiters.d.ts +10 -0
  12. package/dist/types/readability/types.d.ts +2 -0
  13. package/dist/types/turndown/types.d.ts +7 -0
  14. package/dist/types/vterm/buffer.d.ts +16 -5
  15. package/package.json +2 -2
  16. package/src/ar/index.ts +1 -0
  17. package/src/ar/zip-package.ts +57 -0
  18. package/src/chalk.ts +66 -23
  19. package/src/docx/converter.ts +26 -12
  20. package/src/dom/core.ts +354 -111
  21. package/src/dom/parser.ts +42 -19
  22. package/src/dom/selector.ts +328 -95
  23. package/src/fetch-retry.ts +2 -0
  24. package/src/json-parse.ts +21 -6
  25. package/src/logger/rotating-file.ts +5 -3
  26. package/src/logger.ts +39 -23
  27. package/src/marked/core.ts +42 -18
  28. package/src/marked-list.ts +73 -0
  29. package/src/math-delimiters.ts +16 -0
  30. package/src/prompt.ts +3 -1
  31. package/src/ptree.ts +28 -35
  32. package/src/readability/readability.ts +349 -39
  33. package/src/readability/types.ts +2 -0
  34. package/src/stream.ts +34 -13
  35. package/src/temp.ts +1 -10
  36. package/src/template.ts +7 -4
  37. package/src/turndown/gfm.ts +16 -5
  38. package/src/turndown/html.ts +28 -8
  39. package/src/turndown/service.ts +43 -15
  40. package/src/turndown/types.ts +7 -0
  41. package/src/vterm/buffer.ts +31 -11
  42. package/src/vterm/terminal.ts +185 -92
@@ -1,17 +1,25 @@
1
1
  import type { TurndownNode, TurndownPlugin, TurndownServiceLike } from "./types";
2
2
 
3
- function descendantElements(node: TurndownNode, name: string): TurndownNode[] {
3
+ /** Elements named `name` below `node` in document order, not descending into nested tables. */
4
+ function descendantElements(node: TurndownNode, name: string, limit = Number.POSITIVE_INFINITY): TurndownNode[] {
4
5
  const matches: TurndownNode[] = [];
5
- for (const child of Array.from(node.children)) {
6
+ const stack: TurndownNode[] = [];
7
+ const pushChildren = (parent: TurndownNode): void => {
8
+ const children = parent.children;
9
+ for (let index = children.length - 1; index >= 0; index--) stack.push(children[index]!);
10
+ };
11
+ pushChildren(node);
12
+ while (stack.length > 0 && matches.length < limit) {
13
+ const child = stack.pop()!;
6
14
  if (child.nodeName === name) matches.push(child);
7
- if (child.nodeName !== "TABLE") matches.push(...descendantElements(child, name));
15
+ if (child.nodeName !== "TABLE") pushChildren(child);
8
16
  }
9
17
  return matches;
10
18
  }
11
19
 
12
20
  function isHeadingTable(node: TurndownNode): boolean {
13
21
  if (node.nodeName !== "TABLE") return false;
14
- const firstRow = descendantElements(node, "TR")[0];
22
+ const firstRow = descendantElements(node, "TR", 1)[0];
15
23
  if (!firstRow) return false;
16
24
  const cells = Array.from(firstRow.children);
17
25
  return cells.length > 0 && cells.every(cell => cell.nodeName === "TH");
@@ -50,12 +58,13 @@ function renderTable(service: TurndownServiceLike, table: TurndownNode): string
50
58
  /** Install GitHub fenced code blocks selected by `highlight-source-*` wrappers. */
51
59
  export const highlightedCodeBlock: TurndownPlugin = service => {
52
60
  service.addRule("highlightedCodeBlock", {
61
+ skipContent: true,
53
62
  filter(node) {
54
63
  return node.nodeName === "DIV" && /(?:^|\s)highlight-source-([^\s]+)/.test(node.getAttribute("class") ?? "");
55
64
  },
56
65
  replacement(_content, node, options) {
57
66
  const language = /(?:^|\s)highlight-source-([^\s]+)/.exec(node.getAttribute("class") ?? "")?.[1] ?? "";
58
- const text = descendantElements(node, "PRE")[0]?.textContent ?? "";
67
+ const text = descendantElements(node, "PRE", 1)[0]?.textContent ?? "";
59
68
  return `\n\n${options.fence}${language}\n${text}\n${options.fence}\n\n`;
60
69
  },
61
70
  });
@@ -74,6 +83,7 @@ export const strikethrough: TurndownPlugin = service => {
74
83
  /** Install GFM task-list checkbox conversion. */
75
84
  export const taskListItems: TurndownPlugin = service => {
76
85
  service.addRule("taskListItems", {
86
+ skipContent: true,
77
87
  filter(node) {
78
88
  return (
79
89
  node.nodeName === "INPUT" &&
@@ -90,6 +100,7 @@ export const taskListItems: TurndownPlugin = service => {
90
100
  /** Install GFM table conversion for tables with a heading row. */
91
101
  export const tables: TurndownPlugin = service => {
92
102
  service.addRule("table", {
103
+ skipContent: true,
93
104
  filter: isHeadingTable,
94
105
  replacement(_content, node) {
95
106
  return renderTable(service, node);
@@ -56,11 +56,16 @@ function escapeAttribute(value: string): string {
56
56
  abstract class HtmlNode implements TurndownNode {
57
57
  parentNode: HtmlNode | null = null;
58
58
  readonly childNodes: HtmlNode[] = [];
59
+ /** Position in the parent's `childNodes`; assigned on append (the tree is immutable after parse). */
60
+ index = -1;
61
+ /** Position in the parent's `children`; -1 for non-element nodes. */
62
+ elementIndex = -1;
63
+ readonly #elements: HtmlNode[] = [];
59
64
  abstract readonly nodeType: number;
60
65
  abstract readonly nodeName: string;
61
66
 
62
- get children(): HtmlNode[] {
63
- return this.childNodes.filter(child => child.nodeType === 1);
67
+ get children(): readonly HtmlNode[] {
68
+ return this.#elements;
64
69
  }
65
70
 
66
71
  get firstChild(): HtmlNode | null {
@@ -72,19 +77,29 @@ abstract class HtmlNode implements TurndownNode {
72
77
  }
73
78
 
74
79
  get previousSibling(): HtmlNode | null {
75
- if (!this.parentNode) return null;
76
- const index = this.parentNode.childNodes.indexOf(this);
77
- return index > 0 ? (this.parentNode.childNodes[index - 1] ?? null) : null;
80
+ if (!this.parentNode || this.index <= 0) return null;
81
+ return this.parentNode.childNodes[this.index - 1] ?? null;
78
82
  }
79
83
 
80
84
  get nextSibling(): HtmlNode | null {
81
85
  if (!this.parentNode) return null;
82
- const index = this.parentNode.childNodes.indexOf(this);
83
- return index >= 0 ? (this.parentNode.childNodes[index + 1] ?? null) : null;
86
+ return this.parentNode.childNodes[this.index + 1] ?? null;
84
87
  }
85
88
 
86
89
  get textContent(): string {
87
- return this.childNodes.map(child => child.textContent).join("");
90
+ const parts: string[] = [];
91
+ const stack: HtmlNode[] = [];
92
+ for (let index = this.childNodes.length - 1; index >= 0; index--) stack.push(this.childNodes[index]!);
93
+ while (stack.length > 0) {
94
+ const node = stack.pop()!;
95
+ if (node instanceof HtmlText) {
96
+ parts.push(node.value);
97
+ continue;
98
+ }
99
+ const childNodes = node.childNodes;
100
+ for (let index = childNodes.length - 1; index >= 0; index--) stack.push(childNodes[index]!);
101
+ }
102
+ return parts.join("");
88
103
  }
89
104
 
90
105
  abstract get outerHTML(): string;
@@ -99,7 +114,12 @@ abstract class HtmlNode implements TurndownNode {
99
114
 
100
115
  append(child: HtmlNode): void {
101
116
  child.parentNode = this;
117
+ child.index = this.childNodes.length;
102
118
  this.childNodes.push(child);
119
+ if (child.nodeType === 1) {
120
+ child.elementIndex = this.#elements.length;
121
+ this.#elements.push(child);
122
+ }
103
123
  }
104
124
  }
105
125
 
@@ -75,7 +75,8 @@ const NONBLANK_EMPTY_ELEMENTS: Readonly<Record<string, true>> = {
75
75
  VIDEO: true,
76
76
  };
77
77
 
78
- type RuleEntry = { key: string; rule: TurndownRule };
78
+ /** `filter` is `rule.filter` with string tag names lowercased once at registration. */
79
+ type RuleEntry = { key: string; rule: TurndownRule; filter: RuleFilter };
79
80
  type Reference = { destination: string };
80
81
 
81
82
  const DEFAULT_OPTIONS: ResolvedTurndownOptions = {
@@ -91,11 +92,37 @@ const DEFAULT_OPTIONS: ResolvedTurndownOptions = {
91
92
  preformattedCode: false,
92
93
  };
93
94
 
94
- function matchesFilter(filter: RuleFilter, node: TurndownNode, options: ResolvedTurndownOptions): boolean {
95
+ function normalizeFilter(filter: RuleFilter): RuleFilter {
96
+ if (typeof filter === "function") return filter;
97
+ if (typeof filter === "string") return filter.toLowerCase();
98
+ return filter.map(tag => tag.toLowerCase());
99
+ }
100
+
101
+ function matchesFilter(
102
+ filter: RuleFilter,
103
+ node: TurndownNode,
104
+ name: string,
105
+ options: ResolvedTurndownOptions,
106
+ ): boolean {
95
107
  if (typeof filter === "function") return filter(node, options);
96
- const name = node.nodeName.toLowerCase();
97
- if (typeof filter === "string") return name === filter.toLowerCase();
98
- return filter.some(tag => name === tag.toLowerCase());
108
+ if (typeof filter === "string") return name === filter;
109
+ return filter.includes(name);
110
+ }
111
+
112
+ /** Equivalent to `node.textContent.trim() !== ""` without building the subtree text. */
113
+ function hasNonWhitespaceText(node: TurndownNode): boolean {
114
+ const stack: TurndownNode[] = [node];
115
+ while (stack.length > 0) {
116
+ const current = stack.pop()!;
117
+ const type = current.nodeType;
118
+ if (type === 3 || type === 4) {
119
+ if (/\S/.test(current.textContent ?? "")) return true;
120
+ } else if (type === 1 || type === 9 || type === 11) {
121
+ const childNodes = current.childNodes;
122
+ for (let index = 0; index < childNodes.length; index++) stack.push(childNodes[index]!);
123
+ }
124
+ }
125
+ return false;
99
126
  }
100
127
 
101
128
  function contentWithFlankingWhitespace(content: string, delimiter: string): string {
@@ -115,8 +142,8 @@ function listItemPrefix(node: TurndownNode, options: ResolvedTurndownOptions): s
115
142
  const parent = node.parentNode;
116
143
  if (parent?.nodeName !== "OL") return `${options.bulletListMarker} `;
117
144
  const start = Number(parent.getAttribute("start") ?? "1");
118
- const siblings = Array.from(parent.children);
119
- return `${(Number.isFinite(start) ? start : 1) + siblings.indexOf(node)}. `;
145
+ const index = node.elementIndex ?? Array.prototype.indexOf.call(parent.children, node);
146
+ return `${(Number.isFinite(start) ? start : 1) + index}. `;
120
147
  }
121
148
  function hasNonblankDescendant(node: TurndownNode): boolean {
122
149
  for (const child of Array.from(node.children)) {
@@ -142,19 +169,19 @@ export default class TurndownService {
142
169
  addRule(key: string, rule: TurndownRule): this {
143
170
  const previous = this.#rules.findIndex(entry => entry.key === key);
144
171
  if (previous >= 0) this.#rules.splice(previous, 1);
145
- this.#rules.unshift({ key, rule });
172
+ this.#rules.unshift({ key, rule, filter: normalizeFilter(rule.filter) });
146
173
  return this;
147
174
  }
148
175
 
149
176
  /** Preserve matching elements as HTML when no custom rule handles them. */
150
177
  keep(filter: RuleFilter): this {
151
- this.#keepFilters.unshift(filter);
178
+ this.#keepFilters.unshift(normalizeFilter(filter));
152
179
  return this;
153
180
  }
154
181
 
155
182
  /** Drop matching elements and all of their converted content. */
156
183
  remove(filter: RuleFilter): this {
157
- this.#removeFilters.unshift(filter);
184
+ this.#removeFilters.unshift(normalizeFilter(filter));
158
185
  return this;
159
186
  }
160
187
 
@@ -216,23 +243,24 @@ export default class TurndownService {
216
243
  return node.parentNode?.nodeName === "CODE" ? collapsed : this.escape(collapsed);
217
244
  }
218
245
  if (node.nodeType !== 1) return this.#convertChildren(node);
219
- if (!(node.textContent ?? "").trim() && !NONBLANK_EMPTY_ELEMENTS[node.nodeName] && !hasNonblankDescendant(node)) {
246
+ if (!hasNonWhitespaceText(node) && !NONBLANK_EMPTY_ELEMENTS[node.nodeName] && !hasNonblankDescendant(node)) {
220
247
  return (
221
248
  this.options.blankReplacement?.("", node, this.options) ?? (BLOCK_ELEMENTS[node.nodeName] ? "\n\n" : "")
222
249
  );
223
250
  }
224
251
 
252
+ const name = node.nodeName.toLowerCase();
253
+ const custom = this.#rules.find(entry => matchesFilter(entry.filter, node, name, this.options));
225
254
  const block = Boolean(BLOCK_ELEMENTS[node.nodeName]);
226
255
  if (block) this.#previousSourceWhitespace = false;
227
- const content = this.#convertChildren(node);
256
+ const content = custom?.rule.skipContent ? "" : this.#convertChildren(node);
228
257
  if (block) this.#previousSourceWhitespace = false;
229
258
  if (node.nodeName === "CODE") this.#previousSourceWhitespace = false;
230
- const custom = this.#rules.find(entry => matchesFilter(entry.rule.filter, node, this.options));
231
259
  if (custom) return custom.rule.replacement(content, node, this.options);
232
- if (this.#keepFilters.some(filter => matchesFilter(filter, node, this.options))) {
260
+ if (this.#keepFilters.some(filter => matchesFilter(filter, node, name, this.options))) {
233
261
  return this.options.keepReplacement?.(content, node, this.options) ?? serializeNode(node);
234
262
  }
235
- if (this.#removeFilters.some(filter => matchesFilter(filter, node, this.options))) return "";
263
+ if (this.#removeFilters.some(filter => matchesFilter(filter, node, name, this.options))) return "";
236
264
  return this.#defaultReplacement(content, node);
237
265
  }
238
266
 
@@ -13,6 +13,8 @@ export interface TurndownNode {
13
13
  readonly previousSibling: TurndownNode | null;
14
14
  readonly textContent: string | null;
15
15
  readonly outerHTML?: string;
16
+ /** Position among the parent's element children, when the node implementation tracks it. */
17
+ readonly elementIndex?: number;
16
18
  getAttribute(name: string): string | null;
17
19
  hasAttribute(name: string): boolean;
18
20
  }
@@ -64,6 +66,11 @@ export type ReplacementFunction = (content: string, node: TurndownNode, options:
64
66
  export interface TurndownRule {
65
67
  filter: RuleFilter;
66
68
  replacement: ReplacementFunction;
69
+ /**
70
+ * Set when `replacement` ignores its `content` argument; the service then skips
71
+ * converting the node's children before calling it.
72
+ */
73
+ skipContent?: boolean;
67
74
  }
68
75
 
69
76
  /** A plugin that installs one or more conversion rules. */
@@ -13,11 +13,15 @@ export interface CellAttributes {
13
13
  bg: number;
14
14
  }
15
15
 
16
- /** A mutable terminal grid cell. */
16
+ /**
17
+ * A terminal grid cell. Cells written by the terminal are frozen and may be
18
+ * shared between grid positions (blank cells, repeated ASCII glyphs), so the
19
+ * type is readonly: replace a cell instead of mutating it.
20
+ */
17
21
  export interface CellData {
18
- chars: string;
19
- width: number;
20
- attrs: CellAttributes;
22
+ readonly chars: string;
23
+ readonly width: number;
24
+ readonly attrs: Readonly<CellAttributes>;
21
25
  }
22
26
 
23
27
  /** Creates the default rendition. */
@@ -37,18 +41,33 @@ export function defaultAttributes(): CellAttributes {
37
41
  };
38
42
  }
39
43
 
40
- function cloneAttributes(attrs: CellAttributes): CellAttributes {
41
- return { ...attrs };
42
- }
44
+ /** Frozen default rendition shared by every unstyled cell. */
45
+ export const DEFAULT_CELL_ATTRIBUTES: Readonly<CellAttributes> = Object.freeze(defaultAttributes());
43
46
 
44
47
  /** Creates an empty cell with the supplied rendition. */
45
48
  export function blankCell(attrs: CellAttributes = defaultAttributes()): CellData {
46
- return { chars: "", width: 1, attrs: cloneAttributes(attrs) };
49
+ return { chars: "", width: 1, attrs: { ...attrs } };
50
+ }
51
+
52
+ const sharedBlankCells = new WeakMap<Readonly<CellAttributes>, CellData>();
53
+
54
+ /**
55
+ * Returns the frozen empty cell shared by every blank position with `attrs`.
56
+ * Frozen renditions are interned; a mutable one is snapshotted first.
57
+ */
58
+ export function sharedBlankCell(attrs: Readonly<CellAttributes> = DEFAULT_CELL_ATTRIBUTES): CellData {
59
+ if (!Object.isFrozen(attrs)) return Object.freeze({ chars: "", width: 1, attrs: Object.freeze({ ...attrs }) });
60
+ let cell = sharedBlankCells.get(attrs);
61
+ if (!cell) {
62
+ cell = Object.freeze({ chars: "", width: 1, attrs });
63
+ sharedBlankCells.set(attrs, cell);
64
+ }
65
+ return cell;
47
66
  }
48
67
 
49
68
  /** Proposed xterm-compatible cell readback object. */
50
69
  export class BufferCell {
51
- #cell: CellData = blankCell();
70
+ #cell: CellData = sharedBlankCell();
52
71
 
53
72
  /** Reuses this object for another grid cell. */
54
73
  setFrom(cell: CellData): this {
@@ -137,8 +156,9 @@ export class BufferLine {
137
156
  cells: CellData[];
138
157
  isWrapped = false;
139
158
 
140
- constructor(columns: number, attrs: CellAttributes = defaultAttributes()) {
141
- this.cells = Array.from({ length: columns }, () => blankCell(attrs));
159
+ constructor(columns: number, attrs: Readonly<CellAttributes> = DEFAULT_CELL_ATTRIBUTES) {
160
+ const blank = sharedBlankCell(attrs);
161
+ this.cells = Array.from({ length: columns }, () => blank);
142
162
  }
143
163
 
144
164
  /** Number of grid columns in the line. */