@k2b/cloud 0.36.0 → 0.38.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 (90) hide show
  1. package/package.json +5 -3
  2. package/scripts/README.md +1 -0
  3. package/scripts/cli-pack.ts +123 -0
  4. package/src/_internal/help-catalog.ts +123 -4
  5. package/src/_internal/help.ts +27 -6
  6. package/src/_internal/shell-focus.ts +11 -0
  7. package/src/access/PermissionEditor.browser-harness.tsx +46 -0
  8. package/src/access/PermissionEditor.tsx +38 -9
  9. package/src/access/ui.ts +1 -0
  10. package/src/account/Avatar.tsx +20 -2
  11. package/src/account/ui.ts +1 -1
  12. package/src/ai/browser.ts +1 -0
  13. package/src/ai/chart-block.ts +44 -13
  14. package/src/ai/chat/answer-links.ts +8 -0
  15. package/src/ai/chat/chart-block.tsx +2 -11
  16. package/src/ai/chat/messages.ts +12 -0
  17. package/src/ai/chat/primitives.tsx +2 -1
  18. package/src/ai/chat/web-tools.tsx +23 -6
  19. package/src/ai/chat-tasks.ts +65 -1
  20. package/src/ai/default-tool-contracts.ts +30 -13
  21. package/src/ai/executor.ts +46 -3
  22. package/src/ai/fetch-file-tool.ts +37 -3
  23. package/src/ai/migrate.ts +19 -2
  24. package/src/ai/readable-html.ts +1057 -0
  25. package/src/ai/types.ts +1 -0
  26. package/src/api/help.ts +23 -4
  27. package/src/api/mcp.ts +8 -2
  28. package/src/browser/FileChooser.tsx +11 -3
  29. package/src/browser/choose-files.browser-harness.tsx +4 -1
  30. package/src/browser/file-chooser-messages.ts +4 -0
  31. package/src/browser/file-providers.ts +14 -1
  32. package/src/browser/provider-folder.tsx +58 -5
  33. package/src/browser/resource-picker.tsx +5 -1
  34. package/src/browser/shell-focus.ts +67 -0
  35. package/src/cli/admin/workflows.ts +1 -0
  36. package/src/cli/commands.ts +2 -0
  37. package/src/cli/plugin.ts +5 -5
  38. package/src/config/env.ts +14 -0
  39. package/src/contracts/file-provider.ts +48 -1
  40. package/src/contracts/outgoing-mail.ts +8 -1
  41. package/src/server/help.ts +136 -10
  42. package/src/server/services/geo.ts +21 -4
  43. package/src/services/allowed-network-fetch.ts +189 -0
  44. package/src/services/help/index.ts +46 -5
  45. package/src/services/help/store.ts +26 -5
  46. package/src/services/help/types.ts +10 -2
  47. package/src/services/index.ts +2 -0
  48. package/src/services/network-security.ts +28 -0
  49. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  50. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  51. package/src/services/outgoing-mail/test-send.ts +18 -3
  52. package/src/services/outgoing-mail/transport.ts +24 -13
  53. package/src/services/settings/index.ts +48 -26
  54. package/src/shared/help.ts +41 -0
  55. package/src/shared/index.ts +9 -1
  56. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  57. package/src/shared/markdown/formula.ts +8 -8
  58. package/src/shared/markdown/index.ts +72 -11
  59. package/src/shared/network-address.ts +9 -0
  60. package/src/ssr/CloudShell.island.tsx +2 -0
  61. package/src/ssr/GlobalSearchTrigger.tsx +1 -1
  62. package/src/ssr/Layout.tsx +28 -2
  63. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  64. package/src/ssr/LayoutHelp.tsx +569 -410
  65. package/src/ssr/LayoutHelpTrigger.tsx +1 -1
  66. package/src/ssr/LayoutRail.tsx +1 -1
  67. package/src/ssr/ProfilePreferences.tsx +1 -1
  68. package/src/ssr/RailApps.tsx +1 -1
  69. package/src/ssr/ShellFocusToggle.tsx +43 -0
  70. package/src/ssr/ShellRuntime.island.tsx +53 -26
  71. package/src/ssr/app-launchpad.tsx +1 -1
  72. package/src/ssr/help-messages.ts +60 -38
  73. package/src/ssr/help.ts +3 -0
  74. package/src/ssr/layout-help-article.ts +41 -0
  75. package/src/ssr/layout-help-navigation.ts +0 -12
  76. package/src/ssr/layout-help-registry.tsx +4 -11
  77. package/src/ssr/layout-help-search.ts +9 -12
  78. package/src/ssr/layout-help-start.ts +68 -0
  79. package/src/ssr/platform-messages.ts +14 -0
  80. package/src/ssr/shell-data.tsx +7 -0
  81. package/src/ssr/shell-focus.browser-harness.tsx +83 -0
  82. package/src/ssr/shell-shortcuts.ts +8 -0
  83. package/src/styles/effects.css +688 -185
  84. package/src/styles/global.css +155 -26
  85. package/src/styles/resource-search.css +25 -0
  86. package/src/styles/utilities-navigation.css +11 -3
  87. package/src/workflows/store/events.ts +24 -0
  88. package/src/workflows/store/index.ts +8 -0
  89. package/src/workflows/store/observability.ts +6 -1
  90. package/src/workflows/store/retention.ts +157 -0
@@ -0,0 +1,1057 @@
1
+ import type * as Htmlparser2 from "htmlparser2";
2
+
3
+ // sanitize-html loads this ESM-only package with require(). A static import would also place it in the static ESM
4
+ // graph, and Bun rejects that require() while the module is still linking ("require() async module"), depending
5
+ // on which importer evaluates first. A plain require() keeps one synchronous path for both, and the bundler still
6
+ // sees it and ships the package in the server bundle.
7
+ const { Parser }: typeof Htmlparser2 = require("htmlparser2");
8
+
9
+ const CHUNK_BYTES = 256 * 1024;
10
+ // Bun 1.4.2 GC heap deltas, including decoded chunks: nested/flat floods ~52/57 B/item,
11
+ // attributed images ~154 B/item. Reserve 256 B/item: 128 MiB / 256 = 524,288 items.
12
+ // This leaves 384 MiB of the 512 MiB conversion allowance for the 20 MiB input,
13
+ // up to 40 MiB of decoded text, runtime, transient allocations and rendering.
14
+ const MAX_PARSER_ITEMS = (128 * 1024 * 1024) / 256;
15
+ const TRUNCATED = "[Readable copy truncated at the file size limit.]";
16
+ const NOTICE_BYTES = Buffer.byteLength(TRUNCATED) + 3;
17
+ const headTags = new Set("title meta link style script noscript base template head".split(" "));
18
+ const droppedTags = new Set(
19
+ "head script style noscript template svg math iframe object embed canvas video audio form button select textarea input dialog nav aside".split(
20
+ " ",
21
+ ),
22
+ );
23
+ const blocks = new Set(
24
+ "p div section article main li blockquote pre table tr dl dt dd figure figcaption address details summary hr".split(" "),
25
+ );
26
+ const inlineBoundaries = new Set("br ul ol td th".split(" "));
27
+ const layoutBlocks = new Set("p div section article main ul ol li blockquote pre h1 h2 h3 h4 h5 h6 dl figure hr".split(" "));
28
+ const renderedTags = new Set([...blocks, ...inlineBoundaries, ..."a img code h1 h2 h3 h4 h5 h6 caption thead tbody tfoot".split(" ")]);
29
+ const retainedAttrs = new Map([
30
+ ["a", ["href"]],
31
+ ["img", ["src", "alt"]],
32
+ ["pre", ["class"]],
33
+ ["code", ["class"]],
34
+ ["ol", ["start"]],
35
+ ["td", ["colspan", "rowspan"]],
36
+ ["th", ["colspan", "rowspan"]],
37
+ ]);
38
+ const emptyAttrs: Record<string, string> = {};
39
+ const SKIP = 1;
40
+ const TITLE = 2;
41
+ const HEAD = 4;
42
+ const EMIT = 8;
43
+ const MAIN = 16;
44
+ const REGION = 32;
45
+ const MAIN_REGION = 64;
46
+ const droppedRoles = new Set("navigation banner contentinfo complementary search dialog alertdialog".split(" "));
47
+ // Bound replace()'s transient match storage even when a capture or attribute spans the whole input.
48
+ const normalizeSpace = (text: string): string => {
49
+ if (text.length <= CHUNK_BYTES) return text.replace(/\s+/gu, " ");
50
+ const parts: string[] = [];
51
+ let space = false;
52
+ for (let offset = 0; offset < text.length; offset += CHUNK_BYTES) {
53
+ const part = text.slice(offset, offset + CHUNK_BYTES).replace(/\s+/gu, " ");
54
+ parts.push(space && part.startsWith(" ") ? part.slice(1) : part);
55
+ space = part.endsWith(" ");
56
+ }
57
+ return parts.join("");
58
+ };
59
+ const collapse = (text: string): string => normalizeSpace(text).trim();
60
+ // Doubles a backslash run before a delimiter so it cannot consume the delimiter's escape. Matching whole runs
61
+ // keeps the scan linear.
62
+ const escapeRun =
63
+ (delimiters: string) =>
64
+ (match: string, offset: number, source: string): string =>
65
+ match[0] !== "\\" ? `\\${match}` : delimiters.includes(source[offset + match.length] || " ") ? match + match : match;
66
+ const escapeLabel = (text: string): string => text.replace(/\\+|[[\]]/g, escapeRun("[]"));
67
+ const language = (classes = ""): string => {
68
+ for (const [token] of classes.matchAll(/\S+/gu)) {
69
+ if (token.length <= 100 && /^(?:language|lang)-[\w+-]+$/.test(token)) return token.slice(token.indexOf("-") + 1);
70
+ }
71
+ return "";
72
+ };
73
+ const hidden = (attrs: Record<string, string>): boolean => {
74
+ if (!("hidden" in attrs || "aria-hidden" in attrs || "role" in attrs || "id" in attrs || "class" in attrs)) return false;
75
+ if ("hidden" in attrs || attrs["aria-hidden"]?.toLowerCase() === "true") return true;
76
+ for (const [role] of (attrs.role ?? "").matchAll(/\S+/gu)) {
77
+ if (role.length <= 100 && droppedRoles.has(role.toLowerCase())) return true;
78
+ }
79
+ for (const value of [attrs.id ?? "", attrs.class ?? ""]) {
80
+ for (const [token] of value.matchAll(/\S+/gu)) {
81
+ if (token.length > 100) continue;
82
+ if (
83
+ /^(cookie|cookies|consent|gdpr|cmp)([-_]?(banner|notice|bar|popup|modal|dialog|overlay|wall|layer|consent|container))?$/i.test(
84
+ token,
85
+ )
86
+ )
87
+ return true;
88
+ if (/cookie-banner|cookie-consent|consent-banner/i.test(token)) return true;
89
+ }
90
+ }
91
+ return false;
92
+ };
93
+ const charset = (value: string): string | undefined => {
94
+ for (const [parameter] of value.matchAll(/[^;]+/g)) {
95
+ const equals = parameter.indexOf("=");
96
+ if (equals < 0 || parameter.slice(0, equals).trim().toLowerCase() !== "charset") continue;
97
+ const label = parameter.slice(equals + 1).trim();
98
+ const quote = label[0];
99
+ return (quote === '"' || quote === "'") && label.endsWith(quote) ? label.slice(1, -1) : label;
100
+ }
101
+ return;
102
+ };
103
+ /** A byte order mark wins over header and meta declarations, as in the HTML encoding sniffing algorithm. */
104
+ const bomCharset = (bytes: Uint8Array): string | undefined => {
105
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
106
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
107
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
108
+ return;
109
+ };
110
+ const decoderFor = (bytes: Uint8Array, contentType?: string): TextDecoder => {
111
+ let label = bomCharset(bytes) ?? charset(contentType ?? "");
112
+ if (!label) {
113
+ const prefix = new TextDecoder("windows-1252").decode(bytes.subarray(0, 1024));
114
+ const meta = new Parser({
115
+ onopentag(name, attrs) {
116
+ if (name !== "meta" || label) return;
117
+ label = attrs.charset || (attrs["http-equiv"]?.toLowerCase() === "content-type" ? charset(attrs.content ?? "") : undefined);
118
+ },
119
+ });
120
+ meta.end(prefix);
121
+ }
122
+ try {
123
+ return new TextDecoder(label || "utf-8");
124
+ } catch {
125
+ return new TextDecoder("utf-8");
126
+ }
127
+ };
128
+
129
+ const voidTags = new Set(
130
+ "area base basefont br col command embed frame hr img input isindex keygen link meta param source track wbr".split(" "),
131
+ );
132
+ // HTML special elements, except address/div/p/li/dd/dt and void elements. These
133
+ // stop implied li/dd/dt closes; paragraph closes use button scope instead.
134
+ const specialTags = new Set(
135
+ "applet article aside bgsound blockquote body button caption center colgroup details dir dl fieldset figcaption figure footer form frameset h1 h2 h3 h4 h5 h6 head header hgroup html iframe listing main marquee menu nav noembed noframes noscript object ol plaintext pre script search section select style summary table tbody td template textarea tfoot th thead title tr ul xmp".split(
136
+ " ",
137
+ ),
138
+ );
139
+ const buttonScopeTags = new Set("applet caption html table td th marquee object template button".split(" "));
140
+ const tablePartCloses = new Map([
141
+ ["thead", ["thead", "tbody", "tfoot", "tr", "td", "th"]],
142
+ ["tbody", ["thead", "tbody", "tfoot", "tr", "td", "th"]],
143
+ ["tfoot", ["thead", "tbody", "tfoot", "tr", "td", "th"]],
144
+ ["tr", ["tr", "td", "th"]],
145
+ ["td", ["td", "th"]],
146
+ ["th", ["td", "th"]],
147
+ ]);
148
+ const tableParts = new Set(tablePartCloses.keys());
149
+ const impliedCloses = new Map<string, Set<string>>();
150
+ for (const name of "address article aside blockquote details div dl fieldset figcaption figure footer form header hr main nav ol p pre section table ul".split(
151
+ " ",
152
+ )) {
153
+ impliedCloses.set(name, new Set(["p"]));
154
+ }
155
+ for (const name of "h1 h2 h3 h4 h5 h6".split(" ")) impliedCloses.set(name, new Set("h1 h2 h3 h4 h5 h6 p".split(" ")));
156
+ for (const name of "select input output button datalist textarea".split(" "))
157
+ impliedCloses.set(name, new Set("input option optgroup select button datalist textarea".split(" ")));
158
+ for (const [name, closes] of [
159
+ ["tr", "tr th td"],
160
+ ["th", "th td"],
161
+ ["td", "thead th td"],
162
+ ["body", "head link script"],
163
+ ["a", "a"],
164
+ ["li", "li"],
165
+ ["option", "option"],
166
+ ["optgroup", "optgroup option"],
167
+ ["dd", "dd dt"],
168
+ ["dt", "dd dt"],
169
+ ["rt", "rt rp"],
170
+ ["rp", "rt rp"],
171
+ ["tbody", "thead tbody"],
172
+ ["tfoot", "thead tbody"],
173
+ ])
174
+ impliedCloses.set(name!, new Set(closes!.split(" ")));
175
+
176
+ /** Parser's normal HTML stack uses unshift/shift and indexOf, quadratic at hostile depths.
177
+ * The supported void-element hook keeps that stack empty. We balance the flat events
178
+ * ourselves with push/pop and a last-open index per tag, including HTML implied closes.
179
+ * Raw-text handling, attributes and entity decoding still belong to htmlparser2.
180
+ */
181
+ class FlatHtmlParser extends Parser {
182
+ private openName = "";
183
+ constructor(
184
+ onOpen: (name: string, attrs: Record<string, string>) => void,
185
+ onText: (text: string) => void,
186
+ private readonly onCloseName: (name: string) => void,
187
+ private readonly sourceSlice: (start: number, end: number) => string,
188
+ private readonly xml: boolean,
189
+ ) {
190
+ super(
191
+ { onopentag: onOpen, ontext: onText },
192
+ { decodeEntities: true, xmlMode: xml, lowerCaseTags: true, lowerCaseAttributeNames: true },
193
+ );
194
+ }
195
+ protected override isVoidElement(): boolean {
196
+ return true;
197
+ }
198
+ override onopentagname(start: number, end: number): void {
199
+ this.openName = this.sourceSlice(start, end).toLowerCase();
200
+ super.onopentagname(start, end);
201
+ }
202
+ override onselfclosingtag(end: number): void {
203
+ super.onselfclosingtag(end);
204
+ // HTML ignores a slash on ordinary tags, especially <script/> and <style/>.
205
+ // XHTML and foreign roots close immediately even though the library's own stack is empty.
206
+ // open() already closes void tags; closing br twice would add an extra break.
207
+ if ((this.xml || this.openName === "svg" || this.openName === "math") && !voidTags.has(this.openName)) this.onCloseName(this.openName);
208
+ }
209
+ override onclosetag(start: number, end: number): void {
210
+ this.endIndex = end;
211
+ this.onCloseName(this.sourceSlice(start, end).toLowerCase());
212
+ this.startIndex = end + 1;
213
+ }
214
+ }
215
+
216
+ type TagEvent = { kind: "open" | "close"; name: string; attrs: Record<string, string>; layout?: boolean };
217
+ type Event = TagEvent | { kind: "text"; text: string };
218
+ type Span = { start: number; end: number };
219
+
220
+ // Entity decoding and input chunks can split '<' from the following tag-like text.
221
+ const escapeText = (text: string, next?: Event): string =>
222
+ text.replace(/<(?=[a-z/!?])/gi, "\\<").replace(/<$/, next?.kind === "text" && /^[a-z/!?]/i.test(next.text) ? "\\<" : "<");
223
+
224
+ /** One budget for parsing, span scans and all render calls. Synchronous parsing
225
+ * checks it between bounded input chunks; rendering checks it between events. */
226
+ class WorkBudget {
227
+ private events = 0;
228
+ private characters = 0;
229
+ constructor(private readonly signal?: AbortSignal) {}
230
+ used(characters = 0): boolean {
231
+ this.events++;
232
+ this.characters += characters;
233
+ return this.events >= 4096 || this.characters >= CHUNK_BYTES;
234
+ }
235
+ async yield(): Promise<void> {
236
+ this.events = 0;
237
+ this.characters = 0;
238
+ await new Promise<void>((resolve) => setImmediate(resolve));
239
+ this.signal?.throwIfAborted();
240
+ }
241
+ }
242
+
243
+ const isPermalink = (text: string): boolean => text.length <= 4 && [...text].length <= 2 && !/[\p{L}\p{N}]/u.test(text);
244
+
245
+ const linkable = (href: string | undefined, baseUrl: URL, image = false): boolean => {
246
+ if (!href || href.trim().startsWith("#")) return false;
247
+ try {
248
+ // Relative-reference validity depends on the base protocol, not its host or path.
249
+ const url = new URL(href, `${baseUrl.protocol}//x/`);
250
+ return url.protocol === "http:" || url.protocol === "https:" || (!image && url.protocol === "mailto:");
251
+ } catch {
252
+ return false;
253
+ }
254
+ };
255
+
256
+ const linkUrl = (href: string | undefined, baseUrl: URL, image = false): string | undefined => {
257
+ if (!href || href.trim().startsWith("#")) return;
258
+ try {
259
+ const url = new URL(href, baseUrl);
260
+ if (url.protocol !== "http:" && url.protocol !== "https:" && (image || url.protocol !== "mailto:")) return;
261
+ const value = url.toString();
262
+ const parts: string[] = [];
263
+ for (let offset = 0; offset < value.length; offset += CHUNK_BYTES) {
264
+ parts.push(
265
+ value.slice(offset, offset + CHUNK_BYTES).replace(/[() ]/g, (char) => (char === "(" ? "%28" : char === ")" ? "%29" : "%20")),
266
+ );
267
+ }
268
+ return parts.join("");
269
+ } catch {
270
+ return;
271
+ }
272
+ };
273
+ type BacktickRun = { longest: number; trailing: number };
274
+ const countBackticks = (text: string, run: BacktickRun): void => {
275
+ for (const char of text) {
276
+ run.trailing = char === "`" ? run.trailing + 1 : 0;
277
+ run.longest = Math.max(run.longest, run.trailing);
278
+ }
279
+ };
280
+ const backticks = (run: BacktickRun, minimum: number, maxCharacters: number): string =>
281
+ "`".repeat(Math.min(maxCharacters, Math.max(minimum, run.longest + 1)));
282
+ const cellSpan = (value: string | undefined, minimum: number, maximum: number): number => {
283
+ const parsed = Number.parseInt(value ?? "", 10);
284
+ return Number.isNaN(parsed) || parsed < 0 ? 1 : Math.min(maximum, Math.max(minimum, parsed));
285
+ };
286
+
287
+ /** Render one inline span once. Placeholder delimiters avoid copying nested link text. */
288
+ const renderInline = async (
289
+ events: Event[],
290
+ start: number,
291
+ end: number,
292
+ baseUrl: URL,
293
+ budget: WorkBudget,
294
+ maxBytes: number,
295
+ ): Promise<{ markdown: string; text: string; truncated: boolean }> => {
296
+ const parts: string[] = [];
297
+ const plain: string[] = [];
298
+ let pendingSpace = false;
299
+ let characters = 0;
300
+ let size = 0;
301
+ let truncated = false;
302
+ let link:
303
+ | {
304
+ index: number;
305
+ href?: string;
306
+ fragment: boolean;
307
+ characters: number;
308
+ plainIndex: number;
309
+ leadingSpace: boolean;
310
+ spaceBefore: boolean;
311
+ size: number;
312
+ }
313
+ | undefined;
314
+ let code: { depth: number; parts: string[]; ticks: BacktickRun } | undefined;
315
+ const fits = (bytes: number): boolean => {
316
+ if (size + bytes > maxBytes) {
317
+ truncated = true;
318
+ return false;
319
+ }
320
+ size += bytes;
321
+ return true;
322
+ };
323
+ const append = (value: string) => {
324
+ const space = pendingSpace && parts.length && !(link && characters === link.characters);
325
+ if (!fits(Buffer.byteLength(value) + (space ? 1 : 0))) return;
326
+ if (pendingSpace && parts.length) {
327
+ if (link && characters === link.characters) link.leadingSpace = true;
328
+ else parts.push(" ");
329
+ }
330
+ pendingSpace = false;
331
+ parts.push(value);
332
+ };
333
+ const text = (value: string, ticks?: BacktickRun, next?: Event) => {
334
+ const asCode = ticks !== undefined;
335
+ plain.push(value);
336
+ const normalized = normalizeSpace(asCode ? value : escapeText(value, next));
337
+ const content = normalized.trim();
338
+ if (content) {
339
+ if (normalized.startsWith(" ")) pendingSpace = true;
340
+ // Two touching code spans would read as one span with stray backticks.
341
+ if (asCode && parts.at(-1)?.endsWith("`")) pendingSpace = true;
342
+ const fence = ticks ? backticks(ticks, 1, Math.max(0, maxBytes - size) + 1) : "";
343
+ const pad = asCode && (content.startsWith("`") || content.endsWith("`")) ? " " : "";
344
+ const markdown = asCode ? `${fence}${pad}${content}${pad}${fence}` : content;
345
+ // Code spans bind tighter than link brackets, so their content stays verbatim inside a label.
346
+ append(link && !link.fragment && !asCode ? escapeLabel(markdown) : markdown);
347
+ characters += content.length;
348
+ }
349
+ if (normalized.endsWith(" ")) pendingSpace = true;
350
+ };
351
+ for (let index = start; index < end && !truncated; index++) {
352
+ const event = events[index]!;
353
+ if (budget.used(event.kind === "text" ? event.text.length : 0)) await budget.yield();
354
+ if (code) {
355
+ if (event.kind === "text") {
356
+ code.parts.push(event.text);
357
+ countBackticks(event.text, code.ticks);
358
+ } else if (event.kind === "open") {
359
+ code.depth++;
360
+ if (blocks.has(event.name) || event.name === "br") {
361
+ code.parts.push(" ");
362
+ code.ticks.trailing = 0;
363
+ }
364
+ } else if (--code.depth === 0) {
365
+ text(code.parts.join(""), code.ticks);
366
+ code = undefined;
367
+ }
368
+ continue;
369
+ }
370
+ if (event.kind === "text") {
371
+ text(event.text, undefined, index + 1 < end ? events[index + 1] : undefined);
372
+ continue;
373
+ }
374
+ const open = event.kind === "open";
375
+ if (event.name === "code" && open) code = { depth: 1, parts: [], ticks: { longest: 0, trailing: 0 } };
376
+ else if (event.name === "a") {
377
+ if (open && !link) {
378
+ const href = event.attrs.href;
379
+ const fragment = href?.trim().startsWith("#") ?? false;
380
+ if (fragment || linkable(href, baseUrl)) {
381
+ link = {
382
+ index: parts.length,
383
+ href,
384
+ fragment,
385
+ characters,
386
+ plainIndex: plain.length,
387
+ leadingSpace: pendingSpace,
388
+ spaceBefore: pendingSpace,
389
+ size,
390
+ };
391
+ pendingSpace = false;
392
+ parts.push("");
393
+ }
394
+ } else if (!open && link) {
395
+ if (link.fragment && isPermalink(collapse(plain.slice(link.plainIndex).join("")))) {
396
+ parts.length = link.index;
397
+ plain.length = link.plainIndex;
398
+ characters = link.characters;
399
+ size = link.size;
400
+ pendingSpace = link.spaceBefore;
401
+ } else if (characters > link.characters) {
402
+ const url = link.fragment ? undefined : linkUrl(link.href, baseUrl);
403
+ const prefix = (link.leadingSpace && link.index > 0 ? " " : "") + (url ? "[" : "");
404
+ const suffix = url ? `](${url})` : "";
405
+ if (fits(Buffer.byteLength(prefix) + Buffer.byteLength(suffix))) {
406
+ parts[link.index] = prefix;
407
+ if (suffix) parts.push(suffix);
408
+ }
409
+ } else pendingSpace ||= link.leadingSpace;
410
+ link = undefined;
411
+ }
412
+ } else if (event.name === "img" && open) {
413
+ const alt = collapse(event.attrs.alt ?? "");
414
+ if (!alt) continue;
415
+ const url = linkUrl(event.attrs.src, baseUrl, true);
416
+ if (url) {
417
+ append(`![${escapeLabel(escapeText(alt.slice(0, Math.max(0, maxBytes - size) + 1)))}](${url})`);
418
+ plain.push(alt);
419
+ characters += alt.length;
420
+ }
421
+ } else if (blocks.has(event.name) || inlineBoundaries.has(event.name)) {
422
+ pendingSpace = true;
423
+ plain.push(" ");
424
+ }
425
+ }
426
+ return { markdown: parts.join("").trim(), text: collapse(plain.join("")), truncated };
427
+ };
428
+
429
+ /** A flat stream of parser events; no DOM, recursive rendering, or ancestor walks. */
430
+ export const htmlToReadableMarkdown = async (
431
+ bytes: Uint8Array,
432
+ /** `maxBytes` caps the Markdown; `maxInputBytes` (default `maxBytes`) caps the HTML that is read. */
433
+ options: { baseUrl: URL; contentType?: string; maxBytes: number; maxInputBytes?: number; signal?: AbortSignal },
434
+ ): Promise<string> => {
435
+ options.signal?.throwIfAborted();
436
+ const events: Event[] = [];
437
+ const budget = new WorkBudget(options.signal);
438
+ let baseUrl = options.baseUrl;
439
+ let hasBaseHref = false;
440
+ type Table = { event: TagEvent; cells: number };
441
+ const tables: Table[] = [];
442
+ // Flags and a shared table slot avoid separate optional fields on every element.
443
+ type Frame = { name: string; previous: number | undefined; flags: number; table: Table | undefined };
444
+ const stack: Frame[] = [];
445
+ const lastOpen = new Map<string, number>();
446
+ const specialIndices: number[] = [];
447
+ const buttonScopeIndices: number[] = [];
448
+ // Tag-name offsets increase monotonically. Keep decoded chunks without repeatedly
449
+ // concatenating them; even an oversized tag name spanning chunks is copied only once.
450
+ const chunks: { start: number; text: string }[] = [];
451
+ let decodedLength = 0;
452
+ let closeChunk = 0;
453
+ const sourceSlice = (start: number, end: number): string => {
454
+ while (closeChunk < chunks.length && chunks[closeChunk]!.start + chunks[closeChunk]!.text.length <= start) closeChunk++;
455
+ const parts: string[] = [];
456
+ for (let index = closeChunk; index < chunks.length && chunks[index]!.start < end; index++) {
457
+ const chunk = chunks[index]!;
458
+ parts.push(chunk.text.slice(Math.max(0, start - chunk.start), end - chunk.start));
459
+ }
460
+ return parts.join("");
461
+ };
462
+ const titleParts: string[] = [];
463
+ let skipDepth = 0;
464
+ let regionDepth = 0;
465
+ let mainDepth = 0;
466
+ let titleDepth = 0;
467
+ let main: Span | undefined;
468
+ let articles: Span | undefined;
469
+ let stateTruncated = false;
470
+ const retain = (count: number): boolean => {
471
+ if (events.length + stack.length + titleParts.length + count <= MAX_PARSER_ITEMS) return true;
472
+ stateTruncated = true;
473
+ parser.pause();
474
+ return false;
475
+ };
476
+ const closeTop = () => {
477
+ const frame = stack.pop();
478
+ if (!frame) return;
479
+ if (specialIndices.at(-1) === stack.length) specialIndices.pop();
480
+ if (buttonScopeIndices.at(-1) === stack.length) buttonScopeIndices.pop();
481
+ if (frame.previous === undefined) lastOpen.delete(frame.name);
482
+ else lastOpen.set(frame.name, frame.previous);
483
+ if (!(frame.flags & SKIP)) {
484
+ if (frame.table) {
485
+ if (frame.name === "table") tables.pop();
486
+ else frame.table.cells--;
487
+ }
488
+ if (frame.flags & EMIT) events.push({ kind: "close", name: frame.name, attrs: emptyAttrs });
489
+ if (frame.flags & MAIN && main) main.end = events.length;
490
+ if (frame.name === "article" && articles) articles.end = events.length;
491
+ if (frame.flags & MAIN_REGION) mainDepth--;
492
+ if (frame.flags & REGION) regionDepth--;
493
+ }
494
+ if (frame.flags & TITLE) titleDepth--;
495
+ if (frame.flags & SKIP) skipDepth--;
496
+ };
497
+ const closeHead = () => {
498
+ const index = lastOpen.get("head");
499
+ if (index !== undefined) while (stack.length > index) closeTop();
500
+ };
501
+ const open = (name: string, attrs: Record<string, string>) => {
502
+ // Template content is inert: it neither ends the head nor sets the document base.
503
+ const inert = lastOpen.has("template");
504
+ if (!headTags.has(name) && !inert) closeHead();
505
+ if (name === "base" && attrs.href !== undefined && !hasBaseHref && !inert) {
506
+ hasBaseHref = true;
507
+ try {
508
+ const resolved = new URL(attrs.href, options.baseUrl);
509
+ if (resolved.protocol === "http:" || resolved.protocol === "https:") baseUrl = resolved;
510
+ } catch {
511
+ // Ignore malformed document base URLs.
512
+ }
513
+ }
514
+ if (name === "form" && lastOpen.has(name)) return;
515
+ const closes = impliedCloses.get(name);
516
+ // Scope checks use only cached indices. Popping each frame once keeps even
517
+ // repeated unmatched item opens at hostile depths amortized linear.
518
+ if (name === "li") {
519
+ const li = lastOpen.get("li") ?? -1;
520
+ const stop = Math.max(specialIndices.at(-1) ?? -1, lastOpen.get("dd") ?? -1, lastOpen.get("dt") ?? -1);
521
+ if (li > stop) while (stack.length > li) closeTop();
522
+ } else if (name === "dd" || name === "dt") {
523
+ const target = Math.max(lastOpen.get("dd") ?? -1, lastOpen.get("dt") ?? -1);
524
+ const stop = Math.max(specialIndices.at(-1) ?? -1, lastOpen.get("li") ?? -1);
525
+ if (target > stop) while (stack.length > target) closeTop();
526
+ } else if (tableParts.has(name)) {
527
+ // A new row group, row or cell ends the open parts of the current table that it replaces, even when
528
+ // their end tags were omitted and other elements are still open inside them.
529
+ const table = lastOpen.get("table") ?? -1;
530
+ let target = Number.POSITIVE_INFINITY;
531
+ for (const part of tablePartCloses.get(name)!) {
532
+ const index = lastOpen.get(part) ?? -1;
533
+ if (index > table) target = Math.min(target, index);
534
+ }
535
+ if (target !== Number.POSITIVE_INFINITY) while (stack.length > target) closeTop();
536
+ }
537
+ if (closes?.has("p") || name === "li" || name === "dd" || name === "dt") {
538
+ const p = lastOpen.get("p") ?? -1;
539
+ if (p > (buttonScopeIndices.at(-1) ?? -1)) while (stack.length > p) closeTop();
540
+ }
541
+ while (stack.length && closes?.has(stack.at(-1)!.name)) closeTop();
542
+ const isHidden = hidden(attrs);
543
+ const title =
544
+ name === "title" &&
545
+ titleDepth === 0 &&
546
+ titleParts.length === 0 &&
547
+ !isHidden &&
548
+ (skipDepth === 0 || (((stack.at(-1)?.flags ?? 0) & HEAD) !== 0 && skipDepth === 1));
549
+ const skip =
550
+ skipDepth > 0 ||
551
+ name === "title" ||
552
+ droppedTags.has(name) ||
553
+ isHidden ||
554
+ ((name === "header" || name === "footer") && regionDepth === 0);
555
+ const isMain = name === "main" || /(?:^|\s)main(?:\s|$)/iu.test(attrs.role ?? "");
556
+ const region =
557
+ !skip && (isMain || name === "article" || name === "section" || /(?:^|\s)(?:article|region)(?:\s|$)/iu.test(attrs.role ?? ""));
558
+ const emit = !skip && renderedTags.has(name);
559
+ if (!retain(emit ? 2 : 1)) return;
560
+ const frame: Frame = {
561
+ name,
562
+ previous: lastOpen.get(name),
563
+ flags:
564
+ (skip ? SKIP : 0) |
565
+ (title ? TITLE : 0) |
566
+ (name === "head" && skipDepth === 0 && !isHidden ? HEAD : 0) |
567
+ (emit ? EMIT : 0) |
568
+ (region ? REGION : 0),
569
+ table: undefined,
570
+ };
571
+ if (title) titleDepth++;
572
+ if (skip) skipDepth++;
573
+ if (region) regionDepth++;
574
+ if (!skip) {
575
+ if (isMain) {
576
+ frame.flags |= MAIN_REGION;
577
+ if (!main && mainDepth === 0) {
578
+ frame.flags |= MAIN;
579
+ main = { start: events.length, end: events.length };
580
+ }
581
+ mainDepth++;
582
+ }
583
+ if (name === "article") {
584
+ const span = { start: events.length, end: events.length };
585
+ articles ??= span;
586
+ }
587
+ const table = tables.at(-1);
588
+ if (table?.cells && (name === "table" || layoutBlocks.has(name))) table.event.layout = true;
589
+ if (emit) {
590
+ let eventAttrs = emptyAttrs;
591
+ for (const key of retainedAttrs.get(name) ?? []) {
592
+ if (attrs[key] === undefined) continue;
593
+ if (eventAttrs === emptyAttrs) eventAttrs = {};
594
+ eventAttrs[key] = attrs[key];
595
+ }
596
+ const event: TagEvent = { kind: "open", name, attrs: eventAttrs };
597
+ if (name === "table") {
598
+ frame.table = { event, cells: 0 };
599
+ tables.push(frame.table);
600
+ } else if (table && (name === "td" || name === "th")) {
601
+ frame.table = table;
602
+ table.cells++;
603
+ }
604
+ events.push(event);
605
+ }
606
+ }
607
+ lastOpen.set(name, stack.length);
608
+ if (specialTags.has(name)) specialIndices.push(stack.length);
609
+ if (buttonScopeTags.has(name)) buttonScopeIndices.push(stack.length);
610
+ stack.push(frame);
611
+ if (voidTags.has(name)) closeTop();
612
+ };
613
+ const parser = new FlatHtmlParser(
614
+ open,
615
+ (text) => {
616
+ if (stack.at(-1)?.name === "head" && /\S/u.test(text)) closeHead();
617
+ if (titleDepth && retain(1)) titleParts.push(text);
618
+ else if (!skipDepth && retain(1)) events.push({ kind: "text", text });
619
+ },
620
+ (name) => {
621
+ const index = lastOpen.get(name);
622
+ if (index !== undefined) while (stack.length > index) closeTop();
623
+ else if (name === "br") open("br", emptyAttrs);
624
+ else if (name === "p") {
625
+ open("p", emptyAttrs);
626
+ closeTop();
627
+ }
628
+ },
629
+ sourceSlice,
630
+ options.contentType?.split(";", 1)[0]?.trim().toLowerCase() === "application/xhtml+xml",
631
+ );
632
+ const feed = (text: string) => {
633
+ chunks.push({ start: decodedLength, text });
634
+ decodedLength += text.length;
635
+ parser.write(text);
636
+ };
637
+ const length = Math.min(bytes.byteLength, options.maxInputBytes ?? options.maxBytes);
638
+ const decoder = decoderFor(bytes.subarray(0, length), options.contentType);
639
+ for (let offset = 0; offset < length && !stateTruncated; offset += CHUNK_BYTES) {
640
+ options.signal?.throwIfAborted();
641
+ feed(decoder.decode(bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, length)), { stream: true }));
642
+ if (budget.used(Math.min(CHUNK_BYTES, length - offset))) await budget.yield();
643
+ }
644
+ options.signal?.throwIfAborted();
645
+ if (!stateTruncated) {
646
+ feed(decoder.decode());
647
+ parser.end();
648
+ }
649
+ // Closing a frame replaces it with at most one event, so finishing cannot exceed the budget.
650
+ while (stack.length) closeTop();
651
+ const hasText = async (span: Span): Promise<boolean> => {
652
+ for (let index = span.start; index < span.end; index++) {
653
+ const event = events[index]!;
654
+ if (budget.used(event.kind === "text" ? event.text.length : 0)) await budget.yield();
655
+ if (event.kind === "text" && /\S/u.test(event.text)) return true;
656
+ if (event.kind === "open" && event.name === "img" && collapse(event.attrs.alt ?? "") && linkable(event.attrs.src, baseUrl, true))
657
+ return true;
658
+ }
659
+ return false;
660
+ };
661
+ const whole = { start: 0, end: events.length };
662
+ const selected = main && (await hasText(main)) ? main : articles && (await hasText(articles)) ? articles : whole;
663
+ if (!(await hasText(selected))) return "";
664
+ const title = collapse(titleParts.join(""));
665
+ const heading = title && !(await hasRenderedH1(events, selected, baseUrl, budget)) ? title : "";
666
+ return render(events, selected, heading, { ...options, baseUrl, inputTruncated: stateTruncated || bytes.byteLength > length }, budget);
667
+ };
668
+
669
+ /** Scan the selected span once, following the render captures. An h1 inside
670
+ * code, a link or a data cell is inline content, not a rendered heading. */
671
+ const hasRenderedH1 = async (events: Event[], span: Span, baseUrl: URL, budget: WorkBudget): Promise<boolean> => {
672
+ let headingDepth = 0;
673
+ let skipDepth = 0;
674
+ let codeDepth = 0;
675
+ let fragment: { depth: number; parts: string[] } | undefined;
676
+ for (let index = span.start; index < span.end; index++) {
677
+ const event = events[index]!;
678
+ if (budget.used(event.kind === "text" ? event.text.length : 0)) await budget.yield();
679
+ if (skipDepth) {
680
+ if (event.kind === "open") skipDepth++;
681
+ else if (event.kind === "close") skipDepth--;
682
+ continue;
683
+ }
684
+ if (headingDepth) {
685
+ if (event.kind === "open") headingDepth++;
686
+ else if (event.kind === "close") headingDepth--;
687
+ if (!codeDepth && event.kind === "open" && event.name === "a" && event.attrs.href?.trim().startsWith("#")) {
688
+ fragment = { depth: headingDepth, parts: [] };
689
+ continue;
690
+ }
691
+ let value = "";
692
+ if (event.kind === "text") value = event.text;
693
+ else if (codeDepth) codeDepth += event.kind === "open" ? 1 : -1;
694
+ else if (event.kind === "open" && event.name === "code") codeDepth = 1;
695
+ else if (
696
+ event.kind === "open" &&
697
+ event.name === "img" &&
698
+ /\S/u.test(event.attrs.alt ?? "") &&
699
+ linkable(event.attrs.src, baseUrl, true)
700
+ )
701
+ value = event.attrs.alt ?? "";
702
+ else if (blocks.has(event.name) || inlineBoundaries.has(event.name)) value = " ";
703
+ if (fragment) {
704
+ if (value) fragment.parts.push(value);
705
+ if (headingDepth < fragment.depth) {
706
+ const label = collapse(fragment.parts.join(""));
707
+ if (label && !isPermalink(label)) return true;
708
+ fragment = undefined;
709
+ }
710
+ } else if (/\S/u.test(value)) return true;
711
+ } else if (event.kind === "open") {
712
+ if (event.name === "h1") headingDepth = 1;
713
+ else if (
714
+ event.name === "pre" ||
715
+ event.name === "code" ||
716
+ event.name === "a" ||
717
+ /^h[2-6]$/.test(event.name) ||
718
+ (event.name === "table" && !event.layout)
719
+ )
720
+ skipDepth = 1;
721
+ }
722
+ }
723
+ return false;
724
+ };
725
+
726
+ const render = async (
727
+ events: Event[],
728
+ span: Span,
729
+ title: string,
730
+ options: { baseUrl: URL; maxBytes: number; signal?: AbortSignal; inputTruncated: boolean },
731
+ budget: WorkBudget,
732
+ ): Promise<string> => {
733
+ const parts: string[] = [];
734
+ let size = 0;
735
+ let truncated = false;
736
+ let limited = false;
737
+ let pendingBreak = 0;
738
+ let pendingSpace = false;
739
+ let lineStart = true;
740
+ let quoteDepth = 0;
741
+ let readable = false;
742
+ const lists: { ordered: boolean; next: number }[] = [];
743
+ const items: { marker: string; indent: string }[] = [];
744
+ let capture:
745
+ | { name: string; depth: number; parts: string[]; lang: string; firstCode: boolean; start: number; ticks: BacktickRun }
746
+ | undefined;
747
+ const tables: boolean[] = [];
748
+ let row: { cells: string[]; lastReserved: number } | undefined;
749
+ let table: { rows: string[][]; width: number; bytes: number; contentBytes: number } | undefined;
750
+ // Expiration row indexes avoid allocating or counting down one entry per spanned row.
751
+ const rowspans = new Map<number, number>();
752
+ let cellStart: number | undefined;
753
+ let colspan = 1;
754
+ let rowspan = 1;
755
+ let captionStart: number | undefined;
756
+ let tableRows = 0;
757
+ const remaining = () => Math.max(0, options.maxBytes - size - (table?.bytes ?? 0) - NOTICE_BYTES);
758
+ const append = (text: string) => {
759
+ if (!text || truncated) return;
760
+ const next = Buffer.byteLength(text);
761
+ if (size + next > options.maxBytes) {
762
+ // Retain at most one extra bounded piece; final byte clipping also reserves the notice.
763
+ const remaining = Math.max(0, options.maxBytes - size);
764
+ parts.push(text.slice(0, remaining));
765
+ truncated = true;
766
+ return;
767
+ }
768
+ parts.push(text);
769
+ size += next;
770
+ };
771
+ const boundary = (count = 2) => {
772
+ pendingBreak = Math.max(pendingBreak, count);
773
+ pendingSpace = false;
774
+ };
775
+ const write = (text: string, verbatim = false) => {
776
+ if (!text) return;
777
+ if (pendingBreak && parts.length) {
778
+ append("\n".repeat(pendingBreak));
779
+ lineStart = true;
780
+ }
781
+ pendingBreak = 0;
782
+ if (lineStart) {
783
+ const item = items.at(-1);
784
+ append("> ".repeat(Math.min(quoteDepth, 6)) + (item ? item.marker || item.indent : ""));
785
+ if (item) item.marker = "";
786
+ } else if (pendingSpace) append(" ");
787
+ pendingSpace = false;
788
+ // One extra character lets append() detect overflow and retain the truncation notice.
789
+ text = text.slice(0, Math.max(0, options.maxBytes - size) + 1);
790
+ const continuation = "> ".repeat(Math.min(quoteDepth, 6)) + (items.at(-1)?.indent ?? "");
791
+ append(
792
+ continuation && text.includes("\n")
793
+ ? text
794
+ .split("\n")
795
+ .map((line, index) => (index && line ? continuation + line : line))
796
+ .join("\n")
797
+ : text,
798
+ );
799
+ lineStart = verbatim ? text.endsWith("\n") : false;
800
+ };
801
+ const text = (value: string, next?: Event) => {
802
+ const normalized = normalizeSpace(escapeText(value, next));
803
+ const trimmed = normalized.trim();
804
+ if (trimmed) {
805
+ if (normalized.startsWith(" ") && !lineStart && !pendingBreak) pendingSpace = true;
806
+ write(trimmed);
807
+ readable = true;
808
+ }
809
+ if (normalized.endsWith(" ")) pendingSpace = true;
810
+ };
811
+ // Reserve the complete padded table, including the current row and separator.
812
+ // A wider cell charges all earlier rows in O(1); padding is materialized only on flush.
813
+ const cellCost = (bytes: number): number => {
814
+ if (!row || !table) return Infinity;
815
+ const width = Math.max(table.width, row.cells.length + 1);
816
+ const rows = table.rows.length + 1;
817
+ const item = items.at(-1);
818
+ const prefixBytes = Buffer.byteLength("> ".repeat(Math.min(quoteDepth, 6)) + (item?.indent ?? ""));
819
+ const leadingBytes = (parts.length ? pendingBreak : 0) + (item?.marker ? item.marker.length - item.indent.length : 0);
820
+ return table.contentBytes + bytes + rows * (3 * width + 2) + 6 * width + 2 + (rows + 1) * prefixBytes + leadingBytes - table.bytes;
821
+ };
822
+ const addCell = (markdown: string): boolean => {
823
+ if (!row || !table) return false;
824
+ const bytes = Buffer.byteLength(markdown);
825
+ const cost = cellCost(bytes);
826
+ if (cost > remaining()) {
827
+ limited = true;
828
+ return false;
829
+ }
830
+ row.cells.push(markdown);
831
+ table.width = Math.max(table.width, row.cells.length);
832
+ table.bytes += cost;
833
+ table.contentBytes += bytes;
834
+ return true;
835
+ };
836
+ const finishRow = () => {
837
+ if (!row) return;
838
+ while (!limited && row.cells.length <= row.lastReserved) addCell("");
839
+ if (row.cells.length) table?.rows.push(row.cells);
840
+ tableRows++;
841
+ row = undefined;
842
+ };
843
+ const flushTable = async () => {
844
+ finishRow();
845
+ const buffered = table;
846
+ table = undefined;
847
+ if (!buffered) return;
848
+ for (let index = 0; index < buffered.rows.length; index++) {
849
+ const cells = buffered.rows[index]!;
850
+ while (cells.length < buffered.width) cells.push("");
851
+ const markdown = `| ${cells.join(" | ")} |`;
852
+ if (budget.used(markdown.length)) await budget.yield();
853
+ write(markdown);
854
+ boundary(1);
855
+ if (index === 0) {
856
+ write(`| ${Array(buffered.width).fill("---").join(" | ")} |`);
857
+ boundary(1);
858
+ }
859
+ }
860
+ };
861
+ if (title) {
862
+ write(`# ${escapeText(title.slice(0, Math.max(0, options.maxBytes - size) + 1))}`);
863
+ boundary();
864
+ }
865
+ for (let index = span.start; index < span.end && !truncated && !limited; index++) {
866
+ const event = events[index]!;
867
+ if (budget.used(event.kind === "text" ? event.text.length : 0)) await budget.yield();
868
+ if (capture) {
869
+ if (event.kind === "text") {
870
+ capture.parts.push(event.text);
871
+ if (capture.name === "pre" || capture.name === "code") countBackticks(event.text, capture.ticks);
872
+ } else if (event.kind === "open") {
873
+ capture.depth++;
874
+ if (capture.name === "pre" && event.name === "code" && !capture.firstCode) {
875
+ capture.lang ||= language(event.attrs.class);
876
+ capture.firstCode = true;
877
+ }
878
+ if (capture.name === "pre" && event.name === "br") {
879
+ capture.parts.push("\n");
880
+ capture.ticks.trailing = 0;
881
+ } else if (capture.name !== "pre" && (blocks.has(event.name) || event.name === "br")) {
882
+ capture.parts.push(" ");
883
+ capture.ticks.trailing = 0;
884
+ }
885
+ } else if (--capture.depth === 0) {
886
+ const raw = capture.parts.join("");
887
+ const value = capture.name === "code" ? collapse(raw) : "";
888
+ if (capture.name === "pre") {
889
+ const fence = backticks(capture.ticks, 3, Math.max(0, options.maxBytes - size) + 1);
890
+ boundary();
891
+ write(`${fence}${capture.lang}\n${raw}${raw.endsWith("\n") ? "" : "\n"}${fence}`, true);
892
+ boundary();
893
+ } else if (capture.name === "code") {
894
+ const fence = backticks(capture.ticks, 1, Math.max(0, options.maxBytes - size) + 1);
895
+ const pad = value.startsWith("`") || value.endsWith("`") ? " " : "";
896
+ // Whitespace at the edges of the code separates it from the surrounding words; two touching code
897
+ // spans would read as one span with stray backticks.
898
+ if (value && (/^\s/u.test(raw) || (!pendingBreak && parts.at(-1)?.endsWith("`")))) pendingSpace = true;
899
+ if (value) write(`${fence}${pad}${value}${pad}${fence}`);
900
+ if (value && /\s$/u.test(raw)) pendingSpace = true;
901
+ } else {
902
+ const inline = await renderInline(
903
+ events,
904
+ capture.name === "a" ? capture.start : capture.start + 1,
905
+ capture.name === "a" ? index + 1 : index,
906
+ options.baseUrl,
907
+ budget,
908
+ Math.max(0, remaining() - (capture.name === "a" ? 0 : Number(capture.name[1]) + 1)),
909
+ );
910
+ if (inline.text) {
911
+ if (capture.name === "a" && /^\s/u.test(raw)) pendingSpace = true;
912
+ if (capture.name !== "a") boundary();
913
+ write(capture.name === "a" ? inline.markdown : `${"#".repeat(Number(capture.name[1]))} ${inline.markdown}`);
914
+ if (capture.name !== "a") boundary();
915
+ }
916
+ if (inline.text) readable = true;
917
+ if (capture.name === "a" && /\s$/u.test(raw)) pendingSpace = true;
918
+ limited ||= inline.truncated;
919
+ }
920
+ if ((capture.name === "pre" && /\S/u.test(raw)) || (capture.name === "code" && value)) readable = true;
921
+ capture = undefined;
922
+ }
923
+ continue;
924
+ }
925
+ if (event.kind === "text") {
926
+ if (tables.at(-1) !== false) text(event.text, index + 1 < span.end ? events[index + 1] : undefined);
927
+ continue;
928
+ }
929
+ const { name, attrs } = event;
930
+ const open = event.kind === "open";
931
+ if (name === "table") {
932
+ if (!open && tables.at(-1) === false) await flushTable();
933
+ boundary();
934
+ if (open) {
935
+ tables.push(event.layout ?? false);
936
+ if (!event.layout) {
937
+ tableRows = 0;
938
+ table = { rows: [], width: 0, bytes: 0, contentBytes: 0 };
939
+ rowspans.clear();
940
+ row = undefined;
941
+ cellStart = undefined;
942
+ }
943
+ } else tables.pop();
944
+ continue;
945
+ }
946
+ if (tables.at(-1) === false) {
947
+ if (name === "thead" || name === "tbody" || name === "tfoot") rowspans.clear();
948
+ else if (name === "caption") {
949
+ if (open) captionStart = index + 1;
950
+ else {
951
+ const inline = await renderInline(events, captionStart ?? index, index, options.baseUrl, budget, remaining());
952
+ if (inline.text) {
953
+ boundary();
954
+ write(inline.markdown);
955
+ boundary();
956
+ readable = true;
957
+ }
958
+ captionStart = undefined;
959
+ limited ||= inline.truncated;
960
+ }
961
+ } else if (name === "tr") {
962
+ if (open) {
963
+ row = { cells: [], lastReserved: -1 };
964
+ for (const [column, until] of rowspans) {
965
+ if (until <= tableRows) rowspans.delete(column);
966
+ else row.lastReserved = Math.max(row.lastReserved, column);
967
+ }
968
+ } else finishRow();
969
+ } else if (name === "td" || name === "th") {
970
+ if (open) {
971
+ cellStart = index + 1;
972
+ colspan = cellSpan(attrs.colspan, 1, 1000);
973
+ rowspan = cellSpan(attrs.rowspan, 0, 65534);
974
+ } else if (row) {
975
+ while (rowspans.has(row.cells.length) && !limited) addCell("");
976
+ if (limited) {
977
+ finishRow();
978
+ continue;
979
+ }
980
+ const inline = await renderInline(
981
+ events,
982
+ cellStart ?? index,
983
+ index,
984
+ options.baseUrl,
985
+ budget,
986
+ Math.max(0, remaining() - cellCost(0)),
987
+ );
988
+ for (let offset = 0; offset < colspan; offset++) {
989
+ const column = row.cells.length;
990
+ if (!addCell(offset === 0 ? inline.markdown.replace(/\\+|\|/g, escapeRun("|")) : "")) break;
991
+ if (rowspan !== 1) rowspans.set(column, rowspan === 0 ? Infinity : tableRows + rowspan);
992
+ }
993
+ if (inline.text) readable = true;
994
+ cellStart = undefined;
995
+ limited ||= inline.truncated;
996
+ if (limited) finishRow();
997
+ }
998
+ }
999
+ continue;
1000
+ }
1001
+ if (open && (name === "pre" || name === "code" || name === "a" || /^h[1-6]$/.test(name))) {
1002
+ capture = {
1003
+ name,
1004
+ depth: 1,
1005
+ parts: [],
1006
+ lang: name === "pre" ? language(attrs.class) : "",
1007
+ firstCode: false,
1008
+ start: index,
1009
+ ticks: { longest: 0, trailing: 0 },
1010
+ };
1011
+ continue;
1012
+ }
1013
+ if (name === "img" && open) {
1014
+ const alt = collapse(attrs.alt ?? "");
1015
+ if (!alt) continue;
1016
+ const url = linkUrl(attrs.src, options.baseUrl, true);
1017
+ if (url) {
1018
+ write(`![${escapeLabel(escapeText(alt.slice(0, Math.max(0, options.maxBytes - size) + 1)))}](${url})`);
1019
+ readable = true;
1020
+ }
1021
+ } else if (name === "ul" || name === "ol") {
1022
+ boundary(1);
1023
+ if (open) {
1024
+ const start = attrs.start ?? "";
1025
+ lists.push({ ordered: name === "ol", next: /^-?\d{1,6}$/.test(start) ? Number(start) : 1 });
1026
+ } else lists.pop();
1027
+ } else if (name === "li") {
1028
+ boundary(1);
1029
+ if (open) {
1030
+ const list = lists.at(-1);
1031
+ const indent = items[Math.min(items.length, 6) - 1]?.indent ?? "";
1032
+ const marker = list?.ordered ? `${list.next++}. ` : "- ";
1033
+ items.push({ marker: indent + marker, indent: indent + " ".repeat(marker.length) });
1034
+ } else items.pop();
1035
+ } else if (name === "blockquote") {
1036
+ boundary();
1037
+ quoteDepth = Math.max(0, quoteDepth + (open ? 1 : -1));
1038
+ } else if (name === "br" && open) {
1039
+ pendingBreak = Math.min(2, pendingBreak + 1);
1040
+ pendingSpace = false;
1041
+ } else if (name === "hr" && open) {
1042
+ boundary();
1043
+ write("---");
1044
+ boundary();
1045
+ } else if (blocks.has(name) || name === "td" || name === "th") boundary();
1046
+ }
1047
+ if (table) await flushTable();
1048
+ options.signal?.throwIfAborted();
1049
+ truncated ||= limited;
1050
+ if (!readable && !truncated) return "";
1051
+ const result = parts.join("").trim();
1052
+ if (!truncated && !options.inputTruncated && Buffer.byteLength(result) + 1 <= options.maxBytes) return `${result}\n`;
1053
+ // The notice itself is the minimum possible result when maxBytes is smaller than it.
1054
+ const outputBudget = Math.max(0, options.maxBytes - NOTICE_BYTES);
1055
+ const prefix = new TextDecoder().decode(Buffer.from(result).subarray(0, outputBudget), { stream: true }).trimEnd();
1056
+ return `${prefix ? `${prefix}\n\n` : ""}${TRUNCATED}\n`;
1057
+ };