@difizen/libro-rendermime 0.3.35 → 0.3.37

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/es/renderers.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { IRenderHTMLOptions, IRenderImageOptions, IRenderMarkdownOptions, IRenderSVGOptions, IRenderTextOptions } from './rendermime-protocol.js';
2
2
  /**
3
- * Render text into a host node.
4
- *
5
- * @param options - The options for rendering.
6
- *
7
- * @returns A promise which resolves when rendering is complete.
3
+ * renderText
4
+ * - 使用 autolinkRanges 得到全局的链接区间信息(start/end/url)。
5
+ * - 在脱离文档的 parsed fragment(template.content)上按子节点边界切片,
6
+ * 仅创建必要的 Text / shallow-clone element / <a> 节点。
7
+ * - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
8
+ * - 最后一次性将构建好的 DocumentFragment 插回 pre 中,减少重排与 GC 压力。
8
9
  */
9
10
  export declare function renderText(options: IRenderTextOptions): Promise<void>;
10
11
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../src/renderers.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,sBAAsB,EACtB,iBAAiB,EACjB,kBAAkB,EACnB,MAAM,0BAA0B,CAAC;AAUlC;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmFrE;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCvE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuErE;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BnE;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBnF"}
1
+ {"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../src/renderers.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,sBAAsB,EACtB,iBAAiB,EACjB,kBAAkB,EACnB,MAAM,0BAA0B,CAAC;AAWlC;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwIrE;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCvE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuErE;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BnE;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBnF"}
package/es/renderers.js CHANGED
@@ -11,89 +11,145 @@ function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) r
11
11
  function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
12
12
  function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
13
13
  import { concatMultilineString } from '@difizen/libro-common';
14
- import { ansiSpan, autolink, evalInnerHTMLScriptTags, handleDefaults, handleUrls, splitShallowNode } from "./rendermime-utils.js";
14
+ import { ansiSpan, autolinkRanges, createAnchorForUrl, evalInnerHTMLScriptTags, handleDefaults, handleUrls, pieceFromNodeSlice } from "./rendermime-utils.js";
15
15
 
16
16
  /**
17
- * Render text into a host node.
18
- *
19
- * @param options - The options for rendering.
20
- *
21
- * @returns A promise which resolves when rendering is complete.
17
+ * renderText
18
+ * - 使用 autolinkRanges 得到全局的链接区间信息(start/end/url)。
19
+ * - 在脱离文档的 parsed fragment(template.content)上按子节点边界切片,
20
+ * 仅创建必要的 Text / shallow-clone element / <a> 节点。
21
+ * - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
22
+ * - 最后一次性将构建好的 DocumentFragment 插回 pre 中,减少重排与 GC 压力。
22
23
  */
23
24
  export function renderText(options) {
24
25
  var host = options.host,
25
26
  sanitizer = options.sanitizer,
26
27
  source = options.source,
27
28
  mimeType = options.mimeType;
28
- var data = concatMultilineString(JSON.parse(JSON.stringify(source)));
29
+ var data = concatMultilineString(source);
30
+
31
+ // 对文本做 ansi -> span 的转换并 sanitize(仅允许 <span>)
29
32
  var content = sanitizer.sanitize(ansiSpan(data), {
30
33
  allowedTags: ['span']
31
34
  });
32
35
  if (mimeType === 'application/vnd.jupyter.stderr') {
33
36
  host.setAttribute('data-mime-type', 'application/vnd.jupyter.stderr');
34
37
  }
38
+
39
+ // 确保存在 <pre>,但不要马上把 innerHTML 写入实际文档(我们将在脱离文档的 fragment 上操作)
35
40
  var pre = host.querySelector('pre');
36
41
  if (!pre) {
37
42
  pre = document.createElement('pre');
38
43
  host.appendChild(pre);
39
44
  }
40
- pre.innerHTML = content;
41
- var preTextContent = pre.textContent;
42
- if (preTextContent) {
43
- var linkedNodes = autolink(preTextContent);
44
- var inAnchorElement = false;
45
- var combinedNodes = [];
46
- var preNodes = Array.from(pre.childNodes);
47
-
48
- // 使用 shift/unshift 替代索引遍历,确保所有节点被处理
49
- while (preNodes.length && linkedNodes.length) {
50
- var _preNode$textContent, _linkNode$textContent;
51
- var preNode = preNodes.shift();
52
- var linkNode = linkedNodes.shift();
53
- var preLen = ((_preNode$textContent = preNode.textContent) === null || _preNode$textContent === void 0 ? void 0 : _preNode$textContent.length) || 0;
54
- var linkLen = ((_linkNode$textContent = linkNode.textContent) === null || _linkNode$textContent === void 0 ? void 0 : _linkNode$textContent.length) || 0;
55
-
56
- // 如果长度不一致,进行分割处理
57
- if (preLen > linkLen) {
58
- var _splitShallowNode = splitShallowNode(preNode, linkLen),
59
- keep = _splitShallowNode.pre,
60
- postpone = _splitShallowNode.post;
61
- preNodes.unshift(postpone); // 剩余部分放回数组头部
62
- preNode.textContent = keep.textContent;
63
- } else if (linkLen > preLen) {
64
- var _splitShallowNode2 = splitShallowNode(linkNode, preLen),
65
- _keep = _splitShallowNode2.pre,
66
- _postpone = _splitShallowNode2.post;
67
- linkedNodes.unshift(_postpone);
68
- linkNode.textContent = _keep.textContent;
45
+
46
+ // 使用 template 元素来解析 HTML,但 template.content 尚未插入到文档中 -> 不触发重排
47
+ var tpl = document.createElement('template');
48
+ tpl.innerHTML = content;
49
+ var parsedFrag = tpl.content;
50
+
51
+ // 全文文本(用于按字符位置计算 ranges)
52
+ var fullText = parsedFrag.textContent || '';
53
+ if (!fullText) {
54
+ // 若没有文本,清空并返回(保持 class)
55
+ pre.innerHTML = '';
56
+ host.classList.add('libro-text-render');
57
+ return Promise.resolve(undefined);
58
+ }
59
+
60
+ // 计算链接 ranges
61
+ var ranges = autolinkRanges(fullText);
62
+
63
+ // 准备输出 fragment(最终将一次性 append 到 pre)
64
+ var outFrag = document.createDocumentFragment();
65
+
66
+ // pendingAnchor 用于处理链接跨多个原始子节点的情形:
67
+ // 当链接在一个子节点中没有结束时,把该子节点片段 append 到 pendingAnchor,
68
+ // 等到链接结束时再把 pendingAnchor push outFrag。
69
+ var pendingAnchor = null;
70
+ // 当前处理到的 range 索引
71
+ var rangeIdx = 0;
72
+
73
+ // 遍历 parsedFrag 的顶层子节点(这些通常是由 sanitizer/ansiSpan 产生的 span/text 节点)
74
+ var globalOffset = 0;
75
+ var childNodes = Array.from(parsedFrag.childNodes);
76
+ for (var ni = 0; ni < childNodes.length; ni++) {
77
+ var node = childNodes[ni];
78
+ var nodeText = node.textContent || '';
79
+ var nodeLen = nodeText.length;
80
+ if (nodeLen === 0) {
81
+ // 空节点直接跳过(同时 pendingAnchor 不受影响)
82
+ continue;
83
+ }
84
+ var localPos = 0; // 当前在该节点内的偏移
85
+ while (localPos < nodeLen) {
86
+ var absPos = globalOffset + localPos;
87
+ // 跳过已经结束在当前位置之前的 ranges(将 rangeIdx 推到第一个可能相关的 range)
88
+ while (rangeIdx < ranges.length && ranges[rangeIdx].end <= absPos) {
89
+ rangeIdx++;
90
+ }
91
+ var curRange = rangeIdx < ranges.length ? ranges[rangeIdx] : null;
92
+
93
+ // 如果当前有正在构建的 pendingAnchor(链接已经开始但尚未结束)
94
+ if (pendingAnchor) {
95
+ // 计算本次可以从该节点中“消费”的长度(尽量多取,直到链接结束或节点结束)
96
+ var take = Math.min(nodeLen - localPos, (curRange ? curRange.end : absPos) - absPos);
97
+ var piece = pieceFromNodeSlice(node, localPos, localPos + take);
98
+ pendingAnchor.appendChild(piece);
99
+ localPos += take;
100
+
101
+ // 若到达了当前 range 的结束位置,则关闭 pendingAnchor
102
+ if (curRange && globalOffset + localPos >= curRange.end) {
103
+ outFrag.appendChild(pendingAnchor);
104
+ pendingAnchor = null;
105
+ rangeIdx++; // 当前 range 完成,推进到下一个
106
+ }
107
+ // 继续循环:可能在同一节点内就完成多个 ranges
108
+ continue;
69
109
  }
70
- var lastCombined = combinedNodes[combinedNodes.length - 1];
71
- if (inAnchorElement && linkNode.href === lastCombined.href) {
72
- lastCombined.appendChild(preNode);
110
+
111
+ // 没有 pendingAnchor:决定当前段是普通文本还是链接起点
112
+ if (!curRange || curRange.start >= globalOffset + nodeLen) {
113
+ // 当前剩余的节点内容中,没有链接开始 -> 全部作为普通片段
114
+ var _piece = pieceFromNodeSlice(node, localPos, nodeLen);
115
+ outFrag.appendChild(_piece);
116
+ localPos = nodeLen;
117
+ } else if (curRange.start > absPos) {
118
+ // 有链接,但还没到链接起点:把从 localPos 到链接起点之前的文本作为普通片段
119
+ var until = Math.min(nodeLen, curRange.start - globalOffset);
120
+ var _piece2 = pieceFromNodeSlice(node, localPos, until);
121
+ outFrag.appendChild(_piece2);
122
+ localPos = until;
73
123
  } else {
74
- var isAnchor = linkNode.nodeType !== Node.TEXT_NODE;
75
- if (!isAnchor) {
76
- combinedNodes.push(preNode);
77
- inAnchorElement = false;
78
- } else {
79
- linkNode.textContent = '';
80
- linkNode.appendChild(preNode);
81
- combinedNodes.push(linkNode);
82
- inAnchorElement = true;
124
+ // 当前绝对位置处于链接范围之内(curRange.start <= absPos < curRange.end)
125
+ // 新建一个 anchor 并把该节点中属于该链接的部分 append 到 anchor
126
+ pendingAnchor = createAnchorForUrl(curRange.url);
127
+ var _take = Math.min(nodeLen - localPos, curRange.end - absPos);
128
+ var _piece3 = pieceFromNodeSlice(node, localPos, localPos + _take);
129
+ pendingAnchor.appendChild(_piece3);
130
+ localPos += _take;
131
+
132
+ // 如果这个 range 在当前节点内就结束,则立即关闭 pendingAnchor
133
+ if (globalOffset + localPos >= curRange.end) {
134
+ outFrag.appendChild(pendingAnchor);
135
+ pendingAnchor = null;
136
+ rangeIdx++;
83
137
  }
138
+ // 否则 pendingAnchor 会在后续节点继续被填充
84
139
  }
85
140
  }
141
+ globalOffset += nodeLen;
142
+ }
86
143
 
87
- // 使用 DocumentFragment 一次性插入 DOM,提升性能
88
- var fragment = document.createDocumentFragment();
89
- for (var _i = 0, _combinedNodes = combinedNodes; _i < _combinedNodes.length; _i++) {
90
- var child = _combinedNodes[_i];
91
- fragment.appendChild(child);
92
- }
93
- pre.innerHTML = ''; // 清空内容
94
- pre.appendChild(fragment); // 一次性插入
144
+ // 如果循环结束后仍有 pendingAnchor(理论上不应发生,但以防万一),追加它
145
+ if (pendingAnchor) {
146
+ outFrag.appendChild(pendingAnchor);
147
+ pendingAnchor = null;
95
148
  }
96
- host.appendChild(pre);
149
+
150
+ // 一次性替换 pre 的内容:先清空再 append(减少中间重排)
151
+ pre.innerHTML = '';
152
+ pre.appendChild(outFrag);
97
153
  host.classList.add('libro-text-render');
98
154
  return Promise.resolve(undefined);
99
155
  }
@@ -1,21 +1,5 @@
1
1
  import type { ISanitizer } from '@difizen/libro-common';
2
2
  import type { ILinkHandler, IResolver, RankMap } from './rendermime-protocol.js';
3
- /**
4
- * Transform ANSI color escape codes into HTML <span> tags with CSS
5
- * classes such as "ansi-green-intense-fg".
6
- * The actual colors used are set in the CSS file.
7
- * This also removes non-color escape sequences.
8
- * This is supposed to have the same behavior as nbconvert.filters.ansi2html()
9
- */
10
- export declare function ansiSpan(str: string): string;
11
- /**
12
- * Replace URLs with links.
13
- *
14
- * @param content - The text content of a node.
15
- *
16
- * @returns A list of text nodes and anchor elements.
17
- */
18
- export declare function autolink(content: string): (HTMLAnchorElement | Text)[];
19
3
  export interface IRenderOptions {
20
4
  /**
21
5
  * The host node for the text content.
@@ -30,6 +14,34 @@ export interface IRenderOptions {
30
14
  */
31
15
  source: string;
32
16
  }
17
+ /**
18
+ * Transform ANSI color escape codes into HTML <span> tags with CSS
19
+ * classes such as "ansi-green-intense-fg".
20
+ * The actual colors used are set in the CSS file.
21
+ * This also removes non-color escape sequences.
22
+ * This is supposed to have the same behavior as nbconvert.filters.ansi2html()
23
+ */
24
+ export declare function ansiSpan(str: string): string;
25
+ export interface LinkRange {
26
+ start: number;
27
+ end: number;
28
+ url: string;
29
+ }
30
+ /**
31
+ * 根据原始节点、start/end 在该节点的偏移,创建对应的片段节点(不修改原节点)
32
+ * - 若原节点是 Text,则创建 document.createTextNode(substring)
33
+ * - 若原节点是 Element(如 span),则 cloneNode(false) shallow clone 后设 textContent 为 substring(保留属性/类)
34
+ */
35
+ export declare function pieceFromNodeSlice(node: Node, startInNode: number, endInNode: number): Node;
36
+ /**
37
+ * 创建 <a> 的 helper(保留 rel/target,并处理 www. 前缀)
38
+ */
39
+ export declare function createAnchorForUrl(urlText: string): HTMLAnchorElement;
40
+ /**
41
+ * 在字符串中查找链接范围(不创建 DOM),返回按 start 升序、不重叠的 LinkRange 数组。
42
+ * 该函数仅做正则匹配与位置信息记录,避免任何 DOM 分配以降低开销。
43
+ */
44
+ export declare function autolinkRanges(content: string): LinkRange[];
33
45
  /**
34
46
  * Split a shallow node (node without nested nodes inside) at a given text content position.
35
47
  *
@@ -1 +1 @@
1
- {"version":3,"file":"rendermime-utils.d.ts","sourceRoot":"","sources":["../src/rendermime-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAKxD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAmIjF;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAqI5C;AACD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,CAAC,iBAAiB,GAAG,IAAI,CAAC,EAAE,CAuCtE;AACD,MAAM,WAAW,cAAc;IAC7B;;OAEG;IACH,IAAI,EAAE,WAAW,CAAC;IAElB;;OAEG;IACH,SAAS,EAAE,UAAU,CAAC;IAEtB;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB;AACD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,IAAI,EAC7C,IAAI,EAAE,CAAC,EACP,EAAE,EAAE,MAAM,GACT;IAAE,GAAG,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,CAAC,CAAA;CAAE,CASrB;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,EAAE,CASlD;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CA2B/D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,SAAS,GAAG,IAAI,GAAG,IAAI,CA8BnF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,SAAS,EACnB,WAAW,EAAE,YAAY,GAAG,IAAI,GAC/B,OAAO,CAAC,IAAI,CAAC,CAwBf;AAoFD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAEtD;AAED;;GAEG"}
1
+ {"version":3,"file":"rendermime-utils.d.ts","sourceRoot":"","sources":["../src/rendermime-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAKxD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAEjF,MAAM,WAAW,cAAc;IAC7B;;OAEG;IACH,IAAI,EAAE,WAAW,CAAC;IAElB;;OAEG;IACH,SAAS,EAAE,UAAU,CAAC;IAEtB;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB;AAoID;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAqI5C;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,IAAI,CASN;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,iBAAiB,CAMrE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAuC3D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,IAAI,EAC7C,IAAI,EAAE,CAAC,EACP,EAAE,EAAE,MAAM,GACT;IAAE,GAAG,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,CAAC,CAAA;CAAE,CASrB;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,EAAE,CASlD;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CA2B/D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,SAAS,GAAG,IAAI,GAAG,IAAI,CA8BnF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,SAAS,EACnB,WAAW,EAAE,YAAY,GAAG,IAAI,GAC/B,OAAO,CAAC,IAAI,CAAC,CAwBf;AAoFD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAEtD;AAED;;GAEG"}
@@ -107,6 +107,7 @@ function getExtendedColors(numbers) {
107
107
  }
108
108
  return [r, g, b];
109
109
  }
110
+
110
111
  /**
111
112
  * Transform ANSI color escape codes into HTML <span> tags with CSS
112
113
  * classes such as "ansi-green-intense-fg".
@@ -246,43 +247,72 @@ export function ansiSpan(str) {
246
247
  return out.join('');
247
248
  }
248
249
  /**
249
- * Replace URLs with links.
250
- *
251
- * @param content - The text content of a node.
252
- *
253
- * @returns A list of text nodes and anchor elements.
250
+ * 根据原始节点、start/end 在该节点的偏移,创建对应的片段节点(不修改原节点)
251
+ * - 若原节点是 Text,则创建 document.createTextNode(substring)
252
+ * - 若原节点是 Element(如 span),则 cloneNode(false) shallow clone 后设 textContent 为 substring(保留属性/类)
253
+ */
254
+ export function pieceFromNodeSlice(node, startInNode, endInNode) {
255
+ var text = (node.textContent || '').slice(startInNode, endInNode);
256
+ if (node.nodeType === Node.TEXT_NODE) {
257
+ return document.createTextNode(text);
258
+ } else {
259
+ var el = node.cloneNode(false);
260
+ el.textContent = text;
261
+ return el;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 创建 <a> 的 helper(保留 rel/target,并处理 www. 前缀)
267
+ */
268
+ export function createAnchorForUrl(urlText) {
269
+ var a = document.createElement('a');
270
+ a.href = urlText.startsWith('www.') ? 'https://' + urlText : urlText;
271
+ a.rel = 'noopener';
272
+ a.target = '_blank';
273
+ return a;
274
+ }
275
+
276
+ /**
277
+ * 在字符串中查找链接范围(不创建 DOM),返回按 start 升序、不重叠的 LinkRange 数组。
278
+ * 该函数仅做正则匹配与位置信息记录,避免任何 DOM 分配以降低开销。
254
279
  */
255
- export function autolink(content) {
280
+ export function autolinkRanges(content) {
281
+ if (!content) {
282
+ return [];
283
+ }
284
+ var MAX_AUTOLINK_LENGTH = 100000;
285
+
286
+ // 长度保护,超长文本直接不检测链接,返回空
287
+ // 避免超长文本正则匹配引起栈溢出
288
+ if (content.length > MAX_AUTOLINK_LENGTH) {
289
+ return [];
290
+ }
291
+
256
292
  // Taken from Visual Studio Code:
257
293
  // https://github.com/microsoft/vscode/blob/9f709d170b06e991502153f281ec3c012add2e42/src/vs/workbench/contrib/debug/browser/linkDetector.ts#L17-L18
258
294
  var controlCodes = "\\u0000-\\u0020\\u007f-\\u009f";
259
295
  var webLinkRegex = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|data:|www\\.)[^\\s' + controlCodes + '"]{2,}[^\\s' + controlCodes + '"\'(){}\\[\\],:;.!?]', 'ug');
260
- var nodes = [];
261
- var lastIndex = 0;
296
+ var ranges = [];
262
297
  var match;
263
- while (null !== (match = webLinkRegex.exec(content))) {
264
- if (match.index !== lastIndex) {
265
- nodes.push(document.createTextNode(content.slice(lastIndex, match.index)));
266
- }
298
+ while (match = webLinkRegex.exec(content)) {
267
299
  var url = match[0];
268
- // Special case when the URL ends with ">" or "<"
300
+ // 处理特殊情况:末尾为 '>' '<' 时排除该字符
269
301
  var lastChars = url.slice(-1);
270
- var endsWithGtLt = ['>', '<'].indexOf(lastChars) !== -1;
302
+ var endsWithGtLt = lastChars === '>' || lastChars === '<';
271
303
  var len = endsWithGtLt ? url.length - 1 : url.length;
272
- var anchor = document.createElement('a');
273
- url = url.slice(0, len);
274
- anchor.href = url.startsWith('www.') ? 'https://' + url : url;
275
- anchor.rel = 'noopener';
276
- anchor.target = '_blank';
277
- anchor.appendChild(document.createTextNode(url.slice(0, len)));
278
- nodes.push(anchor);
279
- lastIndex = match.index + len;
304
+ var start = match.index;
305
+ var end = match.index + len;
306
+ ranges.push({
307
+ start: start,
308
+ end: end,
309
+ url: url.slice(0, len)
310
+ });
311
+ // 正则使用全局标志 'g',exec 会自动推进位置
280
312
  }
281
- if (lastIndex !== content.length) {
282
- nodes.push(document.createTextNode(content.slice(lastIndex, content.length)));
283
- }
284
- return nodes;
313
+ return ranges;
285
314
  }
315
+
286
316
  /**
287
317
  * Split a shallow node (node without nested nodes inside) at a given text content position.
288
318
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@difizen/libro-rendermime",
3
- "version": "0.3.35",
3
+ "version": "0.3.37",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "libro",
@@ -32,9 +32,9 @@
32
32
  "src"
33
33
  ],
34
34
  "dependencies": {
35
- "@difizen/libro-common": "^0.3.35",
36
- "@difizen/libro-core": "^0.3.35",
37
- "@difizen/libro-markdown": "^0.3.35",
35
+ "@difizen/libro-common": "^0.3.37",
36
+ "@difizen/libro-core": "^0.3.37",
37
+ "@difizen/libro-markdown": "^0.3.37",
38
38
  "@difizen/mana-app": "latest",
39
39
  "lodash.escape": "^4.0.1"
40
40
  },
package/src/renderers.ts CHANGED
@@ -9,23 +9,27 @@ import type {
9
9
  } from './rendermime-protocol.js';
10
10
  import {
11
11
  ansiSpan,
12
- autolink,
12
+ autolinkRanges,
13
+ createAnchorForUrl,
13
14
  evalInnerHTMLScriptTags,
14
15
  handleDefaults,
15
16
  handleUrls,
16
- splitShallowNode,
17
+ pieceFromNodeSlice,
17
18
  } from './rendermime-utils.js';
18
19
 
19
20
  /**
20
- * Render text into a host node.
21
- *
22
- * @param options - The options for rendering.
23
- *
24
- * @returns A promise which resolves when rendering is complete.
21
+ * renderText
22
+ * - 使用 autolinkRanges 得到全局的链接区间信息(start/end/url)。
23
+ * - 在脱离文档的 parsed fragment(template.content)上按子节点边界切片,
24
+ * 仅创建必要的 Text / shallow-clone element / <a> 节点。
25
+ * - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
26
+ * - 最后一次性将构建好的 DocumentFragment 插回 pre 中,减少重排与 GC 压力。
25
27
  */
26
28
  export function renderText(options: IRenderTextOptions): Promise<void> {
27
29
  const { host, sanitizer, source, mimeType } = options;
28
- const data = concatMultilineString(JSON.parse(JSON.stringify(source)));
30
+ const data = concatMultilineString(source);
31
+
32
+ // 对文本做 ansi -> span 的转换并 sanitize(仅允许 <span>)
29
33
  const content = sanitizer.sanitize(ansiSpan(data), {
30
34
  allowedTags: ['span'],
31
35
  });
@@ -34,75 +38,126 @@ export function renderText(options: IRenderTextOptions): Promise<void> {
34
38
  host.setAttribute('data-mime-type', 'application/vnd.jupyter.stderr');
35
39
  }
36
40
 
41
+ // 确保存在 <pre>,但不要马上把 innerHTML 写入实际文档(我们将在脱离文档的 fragment 上操作)
37
42
  let pre = host.querySelector('pre');
38
43
  if (!pre) {
39
44
  pre = document.createElement('pre');
40
45
  host.appendChild(pre);
41
46
  }
42
47
 
43
- pre.innerHTML = content;
44
-
45
- const preTextContent = pre.textContent;
46
- if (preTextContent) {
47
- const linkedNodes = autolink(preTextContent);
48
- let inAnchorElement = false;
49
-
50
- const combinedNodes: (HTMLAnchorElement | Text | HTMLSpanElement)[] = [];
51
- const preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
52
-
53
- // 使用 shift/unshift 替代索引遍历,确保所有节点被处理
54
- while (preNodes.length && linkedNodes.length) {
55
- const preNode = preNodes.shift()!;
56
- const linkNode = linkedNodes.shift()!;
57
-
58
- const preLen = preNode.textContent?.length || 0;
59
- const linkLen = linkNode.textContent?.length || 0;
60
-
61
- // 如果长度不一致,进行分割处理
62
- if (preLen > linkLen) {
63
- const { pre: keep, post: postpone } = splitShallowNode(preNode, linkLen);
64
- preNodes.unshift(postpone); // 剩余部分放回数组头部
65
- preNode.textContent = keep.textContent;
66
- } else if (linkLen > preLen) {
67
- const { pre: keep, post: postpone } = splitShallowNode(linkNode, preLen);
68
- linkedNodes.unshift(postpone);
69
- linkNode.textContent = keep.textContent;
70
- }
48
+ // 使用 template 元素来解析 HTML,但 template.content 尚未插入到文档中 -> 不触发重排
49
+ const tpl = document.createElement('template');
50
+ tpl.innerHTML = content;
51
+ const parsedFrag = tpl.content;
52
+
53
+ // 全文文本(用于按字符位置计算 ranges)
54
+ const fullText = parsedFrag.textContent || '';
55
+ if (!fullText) {
56
+ // 若没有文本,清空并返回(保持 class)
57
+ pre.innerHTML = '';
58
+ host.classList.add('libro-text-render');
59
+ return Promise.resolve(undefined);
60
+ }
71
61
 
72
- const lastCombined = combinedNodes[combinedNodes.length - 1];
62
+ // 计算链接 ranges
63
+ const ranges = autolinkRanges(fullText);
64
+
65
+ // 准备输出 fragment(最终将一次性 append 到 pre)
66
+ const outFrag = document.createDocumentFragment();
67
+
68
+ // pendingAnchor 用于处理链接跨多个原始子节点的情形:
69
+ // 当链接在一个子节点中没有结束时,把该子节点片段 append 到 pendingAnchor,
70
+ // 等到链接结束时再把 pendingAnchor push 到 outFrag。
71
+ let pendingAnchor: HTMLAnchorElement | null = null;
72
+ // 当前处理到的 range 索引
73
+ let rangeIdx = 0;
74
+
75
+ // 遍历 parsedFrag 的顶层子节点(这些通常是由 sanitizer/ansiSpan 产生的 span/text 节点)
76
+ let globalOffset = 0;
77
+ const childNodes = Array.from(parsedFrag.childNodes) as Node[];
78
+ for (let ni = 0; ni < childNodes.length; ni++) {
79
+ const node = childNodes[ni];
80
+ const nodeText = node.textContent || '';
81
+ const nodeLen = nodeText.length;
82
+ if (nodeLen === 0) {
83
+ // 空节点直接跳过(同时 pendingAnchor 不受影响)
84
+ continue;
85
+ }
86
+
87
+ let localPos = 0; // 当前在该节点内的偏移
88
+ while (localPos < nodeLen) {
89
+ const absPos = globalOffset + localPos;
90
+ // 跳过已经结束在当前位置之前的 ranges(将 rangeIdx 推到第一个可能相关的 range)
91
+ while (rangeIdx < ranges.length && ranges[rangeIdx].end <= absPos) {
92
+ rangeIdx++;
93
+ }
94
+ const curRange = rangeIdx < ranges.length ? ranges[rangeIdx] : null;
95
+
96
+ // 如果当前有正在构建的 pendingAnchor(链接已经开始但尚未结束)
97
+ if (pendingAnchor) {
98
+ // 计算本次可以从该节点中“消费”的长度(尽量多取,直到链接结束或节点结束)
99
+ const take = Math.min(
100
+ nodeLen - localPos,
101
+ (curRange ? curRange.end : absPos) - absPos,
102
+ );
103
+ const piece = pieceFromNodeSlice(node, localPos, localPos + take);
104
+ pendingAnchor.appendChild(piece);
105
+ localPos += take;
106
+
107
+ // 若到达了当前 range 的结束位置,则关闭 pendingAnchor
108
+ if (curRange && globalOffset + localPos >= curRange.end) {
109
+ outFrag.appendChild(pendingAnchor);
110
+ pendingAnchor = null;
111
+ rangeIdx++; // 当前 range 完成,推进到下一个
112
+ }
113
+ // 继续循环:可能在同一节点内就完成多个 ranges
114
+ continue;
115
+ }
73
116
 
74
- if (
75
- inAnchorElement &&
76
- (linkNode as HTMLAnchorElement).href ===
77
- (lastCombined as HTMLAnchorElement).href
78
- ) {
79
- lastCombined.appendChild(preNode);
117
+ // 没有 pendingAnchor:决定当前段是普通文本还是链接起点
118
+ if (!curRange || curRange.start >= globalOffset + nodeLen) {
119
+ // 当前剩余的节点内容中,没有链接开始 -> 全部作为普通片段
120
+ const piece = pieceFromNodeSlice(node, localPos, nodeLen);
121
+ outFrag.appendChild(piece);
122
+ localPos = nodeLen;
123
+ } else if (curRange.start > absPos) {
124
+ // 有链接,但还没到链接起点:把从 localPos 到链接起点之前的文本作为普通片段
125
+ const until = Math.min(nodeLen, curRange.start - globalOffset);
126
+ const piece = pieceFromNodeSlice(node, localPos, until);
127
+ outFrag.appendChild(piece);
128
+ localPos = until;
80
129
  } else {
81
- const isAnchor = linkNode.nodeType !== Node.TEXT_NODE;
82
-
83
- if (!isAnchor) {
84
- combinedNodes.push(preNode);
85
- inAnchorElement = false;
86
- } else {
87
- linkNode.textContent = '';
88
- linkNode.appendChild(preNode);
89
- combinedNodes.push(linkNode);
90
- inAnchorElement = true;
130
+ // 当前绝对位置处于链接范围之内(curRange.start <= absPos < curRange.end)
131
+ // 新建一个 anchor 并把该节点中属于该链接的部分 append 到 anchor
132
+ pendingAnchor = createAnchorForUrl(curRange.url);
133
+ const take = Math.min(nodeLen - localPos, curRange.end - absPos);
134
+ const piece = pieceFromNodeSlice(node, localPos, localPos + take);
135
+ pendingAnchor.appendChild(piece);
136
+ localPos += take;
137
+
138
+ // 如果这个 range 在当前节点内就结束,则立即关闭 pendingAnchor
139
+ if (globalOffset + localPos >= curRange.end) {
140
+ outFrag.appendChild(pendingAnchor);
141
+ pendingAnchor = null;
142
+ rangeIdx++;
91
143
  }
144
+ // 否则 pendingAnchor 会在后续节点继续被填充
92
145
  }
93
146
  }
94
147
 
95
- // 使用 DocumentFragment 一次性插入 DOM,提升性能
96
- const fragment = document.createDocumentFragment();
97
- for (const child of combinedNodes) {
98
- fragment.appendChild(child);
99
- }
148
+ globalOffset += nodeLen;
149
+ }
100
150
 
101
- pre.innerHTML = ''; // 清空内容
102
- pre.appendChild(fragment); // 一次性插入
151
+ // 如果循环结束后仍有 pendingAnchor(理论上不应发生,但以防万一),追加它
152
+ if (pendingAnchor) {
153
+ outFrag.appendChild(pendingAnchor);
154
+ pendingAnchor = null;
103
155
  }
104
156
 
105
- host.appendChild(pre);
157
+ // 一次性替换 pre 的内容:先清空再 append(减少中间重排)
158
+ pre.innerHTML = '';
159
+ pre.appendChild(outFrag);
160
+
106
161
  host.classList.add('libro-text-render');
107
162
 
108
163
  return Promise.resolve(undefined);
@@ -5,6 +5,23 @@ import escape from 'lodash.escape';
5
5
 
6
6
  import type { ILinkHandler, IResolver, RankMap } from './rendermime-protocol.js';
7
7
 
8
+ export interface IRenderOptions {
9
+ /**
10
+ * The host node for the text content.
11
+ */
12
+ host: HTMLElement;
13
+
14
+ /**
15
+ * The html sanitizer for untrusted source.
16
+ */
17
+ sanitizer: ISanitizer;
18
+
19
+ /**
20
+ * The source text to render.
21
+ */
22
+ source: string;
23
+ }
24
+
8
25
  const ANSI_COLORS = [
9
26
  'ansi-black',
10
27
  'ansi-red',
@@ -134,6 +151,7 @@ function getExtendedColors(numbers: number[]): number | number[] {
134
151
  }
135
152
  return [r, g, b];
136
153
  }
154
+
137
155
  /**
138
156
  * Transform ANSI color escape codes into HTML <span> tags with CSS
139
157
  * classes such as "ansi-green-intense-fg".
@@ -275,14 +293,61 @@ export function ansiSpan(str: string): string {
275
293
  }
276
294
  return out.join('');
277
295
  }
296
+
297
+ export interface LinkRange {
298
+ start: number; // inclusive index in whole content string
299
+ end: number; // exclusive index
300
+ url: string; // raw matched url text (without trailing > or <)
301
+ }
302
+
278
303
  /**
279
- * Replace URLs with links.
280
- *
281
- * @param content - The text content of a node.
282
- *
283
- * @returns A list of text nodes and anchor elements.
304
+ * 根据原始节点、start/end 在该节点的偏移,创建对应的片段节点(不修改原节点)
305
+ * - 若原节点是 Text,则创建 document.createTextNode(substring)
306
+ * - 若原节点是 Element(如 span),则 cloneNode(false) shallow clone 后设 textContent 为 substring(保留属性/类)
307
+ */
308
+ export function pieceFromNodeSlice(
309
+ node: Node,
310
+ startInNode: number,
311
+ endInNode: number,
312
+ ): Node {
313
+ const text = (node.textContent || '').slice(startInNode, endInNode);
314
+ if (node.nodeType === Node.TEXT_NODE) {
315
+ return document.createTextNode(text);
316
+ } else {
317
+ const el = (node as Element).cloneNode(false) as Element;
318
+ el.textContent = text;
319
+ return el;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * 创建 <a> 的 helper(保留 rel/target,并处理 www. 前缀)
284
325
  */
285
- export function autolink(content: string): (HTMLAnchorElement | Text)[] {
326
+ export function createAnchorForUrl(urlText: string): HTMLAnchorElement {
327
+ const a = document.createElement('a');
328
+ a.href = urlText.startsWith('www.') ? 'https://' + urlText : urlText;
329
+ a.rel = 'noopener';
330
+ a.target = '_blank';
331
+ return a;
332
+ }
333
+
334
+ /**
335
+ * 在字符串中查找链接范围(不创建 DOM),返回按 start 升序、不重叠的 LinkRange 数组。
336
+ * 该函数仅做正则匹配与位置信息记录,避免任何 DOM 分配以降低开销。
337
+ */
338
+ export function autolinkRanges(content: string): LinkRange[] {
339
+ if (!content) {
340
+ return [];
341
+ }
342
+
343
+ const MAX_AUTOLINK_LENGTH = 100_000;
344
+
345
+ // 长度保护,超长文本直接不检测链接,返回空
346
+ // 避免超长文本正则匹配引起栈溢出
347
+ if (content.length > MAX_AUTOLINK_LENGTH) {
348
+ return [];
349
+ }
350
+
286
351
  // Taken from Visual Studio Code:
287
352
  // https://github.com/microsoft/vscode/blob/9f709d170b06e991502153f281ec3c012add2e42/src/vs/workbench/contrib/debug/browser/linkDetector.ts#L17-L18
288
353
  const controlCodes = '\\u0000-\\u0020\\u007f-\\u009f';
@@ -295,49 +360,22 @@ export function autolink(content: string): (HTMLAnchorElement | Text)[] {
295
360
  'ug',
296
361
  );
297
362
 
298
- const nodes = [];
299
- let lastIndex = 0;
300
-
363
+ const ranges: LinkRange[] = [];
301
364
  let match: RegExpExecArray | null;
302
- while (null !== (match = webLinkRegex.exec(content))) {
303
- if (match.index !== lastIndex) {
304
- nodes.push(document.createTextNode(content.slice(lastIndex, match.index)));
305
- }
306
- let url = match[0];
307
- // Special case when the URL ends with ">" or "<"
365
+ while ((match = webLinkRegex.exec(content))) {
366
+ const url = match[0];
367
+ // 处理特殊情况:末尾为 '>' 或 '<' 时排除该字符
308
368
  const lastChars = url.slice(-1);
309
- const endsWithGtLt = ['>', '<'].indexOf(lastChars) !== -1;
369
+ const endsWithGtLt = lastChars === '>' || lastChars === '<';
310
370
  const len = endsWithGtLt ? url.length - 1 : url.length;
311
- const anchor = document.createElement('a');
312
- url = url.slice(0, len);
313
- anchor.href = url.startsWith('www.') ? 'https://' + url : url;
314
- anchor.rel = 'noopener';
315
- anchor.target = '_blank';
316
- anchor.appendChild(document.createTextNode(url.slice(0, len)));
317
- nodes.push(anchor);
318
- lastIndex = match.index + len;
319
- }
320
- if (lastIndex !== content.length) {
321
- nodes.push(document.createTextNode(content.slice(lastIndex, content.length)));
371
+ const start = match.index;
372
+ const end = match.index + len;
373
+ ranges.push({ start, end, url: url.slice(0, len) });
374
+ // 正则使用全局标志 'g',exec 会自动推进位置
322
375
  }
323
- return nodes;
376
+ return ranges;
324
377
  }
325
- export interface IRenderOptions {
326
- /**
327
- * The host node for the text content.
328
- */
329
- host: HTMLElement;
330
-
331
- /**
332
- * The html sanitizer for untrusted source.
333
- */
334
- sanitizer: ISanitizer;
335
378
 
336
- /**
337
- * The source text to render.
338
- */
339
- source: string;
340
- }
341
379
  /**
342
380
  * Split a shallow node (node without nested nodes inside) at a given text content position.
343
381
  *