@linxiraos/pi-utils 1.1.27 → 1.2.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 (59) hide show
  1. package/CHANGELOG.md +4 -6
  2. package/README.md +1 -1
  3. package/dist/types/ar/index.d.ts +1 -0
  4. package/dist/types/ar/zip-package.d.ts +21 -0
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +31 -12
  7. package/dist/types/docx/converter.d.ts +2 -0
  8. package/dist/types/dom/core.d.ts +15 -4
  9. package/dist/types/dom/parser.d.ts +5 -3
  10. package/dist/types/dom/selector.d.ts +7 -1
  11. package/dist/types/logger/rotating-file.d.ts +4 -2
  12. package/dist/types/loop-phase.d.ts +12 -7
  13. package/dist/types/marked/core.d.ts +7 -6
  14. package/dist/types/marked-list.d.ts +38 -0
  15. package/dist/types/math-delimiters.d.ts +10 -0
  16. package/dist/types/readability/types.d.ts +2 -0
  17. package/dist/types/temp.d.ts +7 -0
  18. package/dist/types/turndown/types.d.ts +7 -0
  19. package/dist/types/vterm/buffer.d.ts +16 -5
  20. package/package.json +2 -2
  21. package/src/ar/index.ts +1 -0
  22. package/src/ar/open.ts +29 -8
  23. package/src/ar/zip-package.ts +57 -0
  24. package/src/chalk.ts +66 -23
  25. package/src/color.ts +6 -0
  26. package/src/dirs.ts +84 -24
  27. package/src/docx/converter.ts +26 -12
  28. package/src/dom/core.ts +354 -111
  29. package/src/dom/parser.ts +42 -19
  30. package/src/dom/selector.ts +328 -95
  31. package/src/fetch-retry.ts +2 -0
  32. package/src/format.ts +5 -1
  33. package/src/json-lexer.ts +54 -0
  34. package/src/json-parse.ts +21 -6
  35. package/src/logger/rotating-file.ts +5 -3
  36. package/src/logger.ts +39 -23
  37. package/src/loop-phase.ts +66 -21
  38. package/src/marked/core.ts +42 -18
  39. package/src/marked-list.ts +73 -0
  40. package/src/math-delimiters.ts +16 -0
  41. package/src/module-timer.ts +5 -5
  42. package/src/postmortem.ts +1 -1
  43. package/src/process-name.ts +2 -2
  44. package/src/prompt.ts +3 -1
  45. package/src/ptree.ts +28 -35
  46. package/src/readability/readability.ts +349 -39
  47. package/src/readability/types.ts +2 -0
  48. package/src/runtime-install.ts +2 -2
  49. package/src/stream.ts +34 -13
  50. package/src/temp.ts +7 -0
  51. package/src/template.ts +7 -4
  52. package/src/timing-buffer.ts +1 -1
  53. package/src/tls-fetch.ts +1 -1
  54. package/src/turndown/gfm.ts +16 -5
  55. package/src/turndown/html.ts +28 -8
  56. package/src/turndown/service.ts +43 -15
  57. package/src/turndown/types.ts +7 -0
  58. package/src/vterm/buffer.ts +31 -11
  59. package/src/vterm/terminal.ts +213 -100
package/src/stream.ts CHANGED
@@ -6,6 +6,10 @@ import { parseStreamingJson } from "./json-parse";
6
6
  const LF = 0x0a;
7
7
  const CR = 0x0d;
8
8
 
9
+ // Capacity above this is released once the buffered bytes no longer need it,
10
+ // so one oversized frame does not pin its peak buffer for the stream's life.
11
+ const CONCAT_SINK_RETAIN_BYTES = 1024 * 1024;
12
+
9
13
  /**
10
14
  * Split a byte stream on LF boundaries.
11
15
  *
@@ -89,6 +93,21 @@ export class ConcatSink {
89
93
  return next;
90
94
  }
91
95
 
96
+ /** Drop or right-size an oversized buffer after the buffered length shrank. */
97
+ #releaseCapacity(): void {
98
+ const space = this.#space;
99
+ if (!space || space.length <= CONCAT_SINK_RETAIN_BYTES) return;
100
+ const length = this.#length;
101
+ if (length === 0) {
102
+ this.#space = undefined;
103
+ return;
104
+ }
105
+ if (length * 4 > space.length) return;
106
+ const next = Buffer.allocUnsafe(length);
107
+ space.copy(next, 0, 0, length);
108
+ this.#space = next;
109
+ }
110
+
92
111
  append(chunk: Uint8Array) {
93
112
  const n = chunk.length;
94
113
  if (!n) return;
@@ -99,11 +118,10 @@ export class ConcatSink {
99
118
  }
100
119
 
101
120
  reset(chunk: Uint8Array) {
121
+ this.#length = 0;
122
+ this.#releaseCapacity();
102
123
  const n = chunk.length;
103
- if (!n) {
104
- this.#length = 0;
105
- return;
106
- }
124
+ if (!n) return;
107
125
  const space = this.#ensureCapacity(n);
108
126
  space.set(chunk, 0);
109
127
  this.#length = n;
@@ -127,14 +145,16 @@ export class ConcatSink {
127
145
  if (count <= 0) return;
128
146
  if (count >= this.#length) {
129
147
  this.#length = 0;
130
- return;
148
+ } else {
149
+ this.#space!.copyWithin(0, count, this.#length);
150
+ this.#length -= count;
131
151
  }
132
- this.#space!.copyWithin(0, count, this.#length);
133
- this.#length -= count;
152
+ this.#releaseCapacity();
134
153
  }
135
154
 
136
155
  clear() {
137
156
  this.#length = 0;
157
+ this.#releaseCapacity();
138
158
  }
139
159
 
140
160
  /**
@@ -229,13 +249,14 @@ export class ConcatSink {
229
249
  if (error) throw error;
230
250
  if (done) {
231
251
  this.#length = 0;
232
- return;
233
- }
234
- const rem = total - read;
235
- if (rem < total) {
236
- space.copyWithin(0, read, total);
252
+ } else {
253
+ const rem = total - read;
254
+ if (rem < total) {
255
+ space.copyWithin(0, read, total);
256
+ }
257
+ this.#length = rem;
237
258
  }
238
- this.#length = rem;
259
+ this.#releaseCapacity();
239
260
  }
240
261
  }
241
262
 
package/src/temp.ts CHANGED
@@ -3,6 +3,13 @@ import * as fsPromises from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
 
6
+ /**
7
+ * Owned temporary directory, removed via `remove()` or `using`/`await using`.
8
+ *
9
+ * Prefixes follow `mkdtemp`: `"@name-"` creates under the OS temp dir, any other
10
+ * prefix is a path (a bare `"name-"` lands in the process cwd), and no prefix
11
+ * means `"@pi-temp-"`.
12
+ */
6
13
  export class TempDir {
7
14
  #path: string;
8
15
  private constructor(path: string) {
package/src/template.ts CHANGED
@@ -226,7 +226,9 @@ function findTagEnd(source: string, start: number, triple: boolean): number {
226
226
 
227
227
  function parseTemplate(source: string): Node[] {
228
228
  const root: Node[] = [];
229
- const stack: { node: BlockNode; target: Node[]; inverted: boolean }[] = [];
229
+ // `chained` marks a block opened by `{{else name …}}`: it has no closing tag of
230
+ // its own and closes with the block it chains from, as in Handlebars.
231
+ const stack: { node: BlockNode; target: Node[]; inverted: boolean; chained: boolean }[] = [];
230
232
  let target = root;
231
233
  let cursor = 0;
232
234
  while (cursor < source.length) {
@@ -263,7 +265,7 @@ function parseTemplate(source: string): Node[] {
263
265
  };
264
266
  target.push(nested);
265
267
  target = nested.body;
266
- stack.push({ node: nested, target: parentTarget, inverted: false });
268
+ stack.push({ node: nested, target: parentTarget, inverted: false, chained: true });
267
269
  }
268
270
  continue;
269
271
  }
@@ -277,12 +279,13 @@ function parseTemplate(source: string): Node[] {
277
279
  inverted,
278
280
  };
279
281
  target.push(node);
280
- stack.push({ node, target, inverted });
282
+ stack.push({ node, target, inverted, chained: false });
281
283
  target = node.body;
282
284
  continue;
283
285
  }
284
286
  if (raw.startsWith("/")) {
285
- const current = stack.pop();
287
+ let current = stack.pop();
288
+ while (current?.chained) current = stack.pop();
286
289
  if (!current || current.node.expression.name !== raw.slice(1).trim())
287
290
  throw new Error(`Parse error: mismatched ${raw}`);
288
291
  if (current.inverted) [current.node.body, current.node.inverse] = [current.node.inverse, current.node.body];
@@ -22,7 +22,7 @@ export interface ModuleLoadEvent {
22
22
  * Registry-global key under which the preload accumulates module-load events.
23
23
  * `Symbol.for` so both modules resolve the same symbol independently.
24
24
  */
25
- const KEY: symbol = Symbol.for("omp.moduleLoadBuffer");
25
+ const KEY: symbol = Symbol.for("zeta.moduleLoadBuffer");
26
26
 
27
27
  type Store = Record<symbol, ModuleLoadEvent[] | undefined>;
28
28
 
package/src/tls-fetch.ts CHANGED
@@ -52,7 +52,7 @@ type BunTlsOptions = {
52
52
 
53
53
  type BunTlsRequestInit = RequestInit & { tls?: BunTlsOptions };
54
54
 
55
- const EXTRA_CA_FETCH_MARKER = Symbol("omp.extraCaFetch");
55
+ const EXTRA_CA_FETCH_MARKER = Symbol("zeta.extraCaFetch");
56
56
  type ExtraCaFetch = FetchImpl & { [EXTRA_CA_FETCH_MARKER]?: true };
57
57
 
58
58
  /**
@@ -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. */