pi-unsloth-webtools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/html-to-md.ts ADDED
@@ -0,0 +1,1085 @@
1
+ import { NAMED_ENTITIES, INVALID_CHARREFS, INVALID_CODEPOINTS } from "./entities.ts";
2
+
3
+ const SKIP_TAGS = new Set([
4
+ "script",
5
+ "style",
6
+ "head",
7
+ "noscript",
8
+ "svg",
9
+ "math",
10
+ "nav",
11
+ "footer",
12
+ "template",
13
+ "dialog",
14
+ "button",
15
+ "select",
16
+ "datalist",
17
+ ]);
18
+
19
+ const VOID_TAGS = new Set([
20
+ "area",
21
+ "base",
22
+ "br",
23
+ "col",
24
+ "embed",
25
+ "hr",
26
+ "img",
27
+ "input",
28
+ "link",
29
+ "meta",
30
+ "param",
31
+ "source",
32
+ "track",
33
+ "wbr",
34
+ ]);
35
+
36
+ const P_CLOSING_TAGS = new Set([
37
+ "address",
38
+ "article",
39
+ "aside",
40
+ "blockquote",
41
+ "details",
42
+ "div",
43
+ "dl",
44
+ "fieldset",
45
+ "figcaption",
46
+ "figure",
47
+ "footer",
48
+ "form",
49
+ "h1",
50
+ "h2",
51
+ "h3",
52
+ "h4",
53
+ "h5",
54
+ "h6",
55
+ "header",
56
+ "hgroup",
57
+ "hr",
58
+ "main",
59
+ "menu",
60
+ "nav",
61
+ "ol",
62
+ "p",
63
+ "pre",
64
+ "section",
65
+ "table",
66
+ "ul",
67
+ ]);
68
+
69
+ const IMPLICIT_CLOSERS: Record<string, Set<string>> = {
70
+ p: P_CLOSING_TAGS,
71
+ li: new Set(["li"]),
72
+ dt: new Set(["dt", "dd"]),
73
+ dd: new Set(["dt", "dd"]),
74
+ tr: new Set(["tr"]),
75
+ td: new Set(["td", "th", "tr"]),
76
+ th: new Set(["td", "th", "tr"]),
77
+ option: new Set(["option", "optgroup"]),
78
+ optgroup: new Set(["optgroup"]),
79
+ };
80
+
81
+ const CLOSE_BARRIERS: Record<string, Set<string>> = {
82
+ li: new Set(["ul", "ol", "menu"]),
83
+ dt: new Set(["dl"]),
84
+ dd: new Set(["dl"]),
85
+ tr: new Set(["table"]),
86
+ td: new Set(["table"]),
87
+ th: new Set(["table"]),
88
+ option: new Set(["select", "datalist"]),
89
+ optgroup: new Set(["select", "datalist"]),
90
+ };
91
+
92
+ const BLOCK_TAGS = new Set([
93
+ "p",
94
+ "div",
95
+ "section",
96
+ "article",
97
+ "main",
98
+ "aside",
99
+ "figure",
100
+ "figcaption",
101
+ "details",
102
+ "summary",
103
+ "dl",
104
+ "dt",
105
+ "dd",
106
+ ]);
107
+
108
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
109
+ const INLINE_EMPHASIS: Record<string, string> = { strong: "**", b: "**", em: "*", i: "*" };
110
+
111
+ const HEADER_LINK_DENSITY = 0.93;
112
+ const HEADER_MIN_CHARS = 150;
113
+ const HEADER_MAX_RENDERED_CHARS = 800;
114
+ const MAX_HEADER_NESTING = 8;
115
+
116
+ const MIN_MAIN_CONTENT_CHARS = 200;
117
+
118
+ export type AttrDict = Record<string, string | null>;
119
+
120
+ export function styleHidesElement(style: string): boolean {
121
+ const lowered = style.toLowerCase();
122
+ if (!lowered.includes("none") && !lowered.includes("hidden")) return false;
123
+ for (const declaration of style.split(";")) {
124
+ const sep = declaration.indexOf(":");
125
+ if (sep === -1) continue;
126
+ const prop = declaration.slice(0, sep).trim().toLowerCase();
127
+ const value = declaration.slice(sep + 1).split("!", 1)[0].trim().toLowerCase();
128
+ if (prop === "display" && value === "none") return true;
129
+ if (prop === "visibility" && value === "hidden") return true;
130
+ }
131
+ return false;
132
+ }
133
+
134
+ export function isHiddenElement(attrs: AttrDict): boolean {
135
+ if ("hidden" in attrs) return true;
136
+ if ((attrs["aria-hidden"] ?? "").trim().toLowerCase() === "true") return true;
137
+ return styleHidesElement(attrs["style"] ?? "");
138
+ }
139
+
140
+ export function isAriaHeading(attrs: AttrDict): boolean {
141
+ return (attrs["role"] ?? "").toLowerCase().split(/\s+/).includes("heading");
142
+ }
143
+
144
+ class HeaderFrame {
145
+ depth: number;
146
+ parts: string[] = [];
147
+ headingParts: string[] = [];
148
+ stripped = false;
149
+ renderedChars = 0;
150
+ headingChars = 0;
151
+ outerListDepth: number;
152
+ outerLinkSeq: number;
153
+ outerCellSeq: number;
154
+ outerInPre: boolean;
155
+ outerInCode: number;
156
+ outerBqDepth: number;
157
+ textChars = 0;
158
+ linkChars = 0;
159
+
160
+ constructor(
161
+ depth: number,
162
+ linkSeq: number,
163
+ cellSeq: number,
164
+ inPre: boolean,
165
+ inCode: number,
166
+ bqDepth: number,
167
+ listDepth: number,
168
+ ) {
169
+ this.depth = depth;
170
+ this.outerListDepth = listDepth;
171
+ this.outerLinkSeq = linkSeq;
172
+ this.outerCellSeq = cellSeq;
173
+ this.outerInPre = inPre;
174
+ this.outerInCode = inCode;
175
+ this.outerBqDepth = bqDepth;
176
+ }
177
+
178
+ render(closedByOwnTag: boolean): string {
179
+ this.stripped = false;
180
+ if (!closedByOwnTag) return this.parts.join("");
181
+ const headings = this.headingParts.join("");
182
+ const droppable = this.renderedChars - this.headingChars;
183
+ const bigEnough =
184
+ this.textChars >= HEADER_MIN_CHARS || droppable >= HEADER_MAX_RENDERED_CHARS;
185
+ if (bigEnough && this.linkChars >= HEADER_LINK_DENSITY * this.textChars) {
186
+ this.stripped = true;
187
+ return headings.trim() ? headings + "\n\n" : headings;
188
+ }
189
+ return this.parts.join("");
190
+ }
191
+ }
192
+
193
+
194
+ const CHARREF_RE = /&(#[0-9]+;?|#[xX][0-9a-fA-F]+;?|[^\t\n\f <&#;]{1,32};?)/g;
195
+
196
+ export function decodeHtmlEntities(text: string): string {
197
+ return text.replace(CHARREF_RE, (whole, s: string) => {
198
+ if (s[0] === "#") {
199
+ const hex = s[1] === "x" || s[1] === "X";
200
+ const num = parseInt(s.slice(2).replace(/;+$/, ""), hex ? 16 : 10);
201
+ const mapped = INVALID_CHARREFS[num];
202
+ if (mapped !== undefined) return mapped;
203
+ if ((num >= 0xd800 && num <= 0xdfff) || num > 0x10ffff) return "\ufffd";
204
+ if (INVALID_CODEPOINTS.has(num)) return "";
205
+ return String.fromCodePoint(num);
206
+ }
207
+ const known = NAMED_ENTITIES[s];
208
+ if (known !== undefined) return known;
209
+ for (let x = s.length - 1; x > 1; x--) {
210
+ const prefix = s.slice(0, x);
211
+ if (prefix in NAMED_ENTITIES) return NAMED_ENTITIES[prefix] + s.slice(x);
212
+ }
213
+ return "&" + s;
214
+ });
215
+ }
216
+
217
+
218
+ interface HtmlHandlers {
219
+ handleStartTag(name: string, attrs: AttrDict): void;
220
+ handleEndTag(name: string): void;
221
+ handleStartEndTag(name: string, attrs: AttrDict): void;
222
+ handleData(text: string): void;
223
+ handleEntityRef(name: string): void;
224
+ handleCharRef(name: string): void;
225
+ }
226
+
227
+ const START_TAG_NAME_RE = /^[a-zA-Z][^\s/>]*/;
228
+ const ATTR_NAME_RE = /^[^\s=/>]+/;
229
+
230
+ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, boolean] {
231
+ const attrs: AttrDict = {};
232
+ while (pos < input.length) {
233
+ while (pos < input.length && /\s/.test(input[pos])) pos++;
234
+ if (pos >= input.length) return [attrs, -1, false];
235
+ if (input[pos] === ">") return [attrs, pos + 1, false];
236
+ if (input[pos] === "/") {
237
+ if (pos + 1 < input.length && input[pos + 1] === ">") return [attrs, pos + 2, true];
238
+ pos++;
239
+ continue;
240
+ }
241
+ const nameMatch = ATTR_NAME_RE.exec(input.slice(pos));
242
+ if (!nameMatch) return [attrs, -1, false];
243
+ const name = nameMatch[0].toLowerCase();
244
+ pos += nameMatch[0].length;
245
+ while (pos < input.length && /\s/.test(input[pos])) pos++;
246
+ let value: string | null = null;
247
+ if (pos < input.length && input[pos] === "=") {
248
+ pos++;
249
+ while (pos < input.length && /\s/.test(input[pos])) pos++;
250
+ if (pos < input.length && (input[pos] === '"' || input[pos] === "'")) {
251
+ const quote = input[pos];
252
+ pos++;
253
+ const valueStart = pos;
254
+ while (pos < input.length && input[pos] !== quote) pos++;
255
+ value = decodeHtmlEntities(input.slice(valueStart, pos));
256
+ pos++;
257
+ } else {
258
+ const valueStart = pos;
259
+ while (pos < input.length && !/[\s>]/.test(input[pos])) pos++;
260
+ value = decodeHtmlEntities(input.slice(valueStart, pos));
261
+ }
262
+ }
263
+ attrs[name] = value;
264
+ }
265
+ return [attrs, -1, false];
266
+ }
267
+
268
+ function scanTag(
269
+ html: string,
270
+ i: number,
271
+ ): { end: number; kind: "comment" | "decl" | "end" | "start" | "startend"; name?: string; attrs?: AttrDict } | null {
272
+ const rest = html.slice(i + 1);
273
+ if (rest.startsWith("!--")) {
274
+ const close = html.indexOf("-->", i + 4);
275
+ if (close === -1) return null;
276
+ return { end: close + 3, kind: "decl" };
277
+ }
278
+ if (rest.startsWith("!") || rest.startsWith("?")) {
279
+ let j = i + 2;
280
+ while (j < html.length && html[j] !== ">") j++;
281
+ if (j >= html.length) return null;
282
+ return { end: j + 1, kind: "decl" };
283
+ }
284
+ if (rest.startsWith("/")) {
285
+ let j = i + 2;
286
+ while (j < html.length && /[\s>]/.test(html[j]) === false) j++;
287
+ const name = html.slice(i + 2, j).toLowerCase();
288
+ if (!name) return null;
289
+ while (j < html.length && html[j] !== ">") j++;
290
+ if (j >= html.length) return null;
291
+ return { end: j + 1, kind: "end", name };
292
+ }
293
+ const nameMatch = START_TAG_NAME_RE.exec(rest);
294
+ if (!nameMatch) return null;
295
+ const name = nameMatch[0].toLowerCase();
296
+ const [attrs, next, selfClosing] = parseAttrsUntilClose(html, i + 1 + nameMatch[0].length);
297
+ if (next === -1) return null;
298
+ return { end: next, kind: selfClosing ? "startend" : "start", name, attrs };
299
+ }
300
+
301
+
302
+ export function feedHtml(input: string, handlers: HtmlHandlers): void {
303
+ const emitText = (text: string) => {
304
+ if (!text) return;
305
+ let pos = 0;
306
+ while (pos < text.length) {
307
+ const amp = text.indexOf("&", pos);
308
+ if (amp === -1) {
309
+ handlers.handleData(text.slice(pos));
310
+ return;
311
+ }
312
+ if (amp > pos) handlers.handleData(text.slice(pos, amp));
313
+ const named = /^&([A-Za-z][A-Za-z0-9.-]*);/.exec(text.slice(amp));
314
+ if (named) {
315
+ handlers.handleEntityRef(named[1]);
316
+ pos = amp + named[0].length;
317
+ continue;
318
+ }
319
+ const numeric = /^&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));/.exec(text.slice(amp));
320
+ if (numeric) {
321
+ handlers.handleCharRef(numeric[1] ?? numeric[2]);
322
+ pos = amp + numeric[0].length;
323
+ continue;
324
+ }
325
+ const legacy = /^&([A-Za-z][A-Za-z0-9.-]*)(?=[^A-Za-z0-9]|$)/.exec(text.slice(amp));
326
+ if (legacy) {
327
+ handlers.handleEntityRef(legacy[1]);
328
+ pos = amp + legacy[0].length;
329
+ continue;
330
+ }
331
+ handlers.handleData("&");
332
+ pos = amp + 1;
333
+ }
334
+ };
335
+
336
+ let i = 0;
337
+ let textStart = 0;
338
+ while (i < input.length) {
339
+ if (input[i] !== "<") {
340
+ i++;
341
+ continue;
342
+ }
343
+ const tag = scanTag(input, i);
344
+ if (!tag) {
345
+ i++;
346
+ continue;
347
+ }
348
+ emitText(input.slice(textStart, i));
349
+ if (tag.kind === "start") handlers.handleStartTag(tag.name!, tag.attrs!);
350
+ else if (tag.kind === "startend") handlers.handleStartEndTag(tag.name!, tag.attrs!);
351
+ else if (tag.kind === "end") handlers.handleEndTag(tag.name!);
352
+ textStart = tag.end;
353
+ i = tag.end;
354
+ }
355
+ emitText(input.slice(textStart));
356
+ }
357
+
358
+ class MarkdownRenderer {
359
+ out: string[] = [];
360
+ private skipDepth = 0;
361
+ private scopeTags: Set<string> | null;
362
+ private scopeDepth = 0;
363
+ scopeSegments: string[] = [];
364
+ private scopeSegStart: number | null = null;
365
+ private openTags: string[] = [];
366
+ private closableOpen = 0;
367
+ private hiddenMarks: number[] = [];
368
+ private stripHeader: boolean;
369
+ private headerStack: HeaderFrame[] = [];
370
+ private droppedChars = 0;
371
+ private segDroppedStart = 0;
372
+ scopeDropped: number[] = [];
373
+ private segHeadingTexts: string[] = [];
374
+ scopeHeadingProse: number[] = [];
375
+ private headingMarks: number[] = [];
376
+ private linkHref: string | null = null;
377
+ private linkTextParts: string[] = [];
378
+ private inLink = false;
379
+ private linkSeq = 0;
380
+ private linkHadHeading = false;
381
+ private linkHeadingParts: string[] = [];
382
+ private emitAsHeading = false;
383
+ private replaying = false;
384
+ private linkHeaderChars = 0;
385
+ private listStack: string[] = [];
386
+ private olCounter: number[] = [];
387
+ private inTable = false;
388
+ private currentRow: string[] = [];
389
+ private cellParts: string[] = [];
390
+ private inCell = false;
391
+ private cellSeq = 0;
392
+ private headerRowDone = false;
393
+ private rowHasTh = false;
394
+ private isFirstRow = false;
395
+ private inPre = false;
396
+ private preParts: string[] = [];
397
+ private inlineCodeDepth = 0;
398
+ private bqStack: string[][] = [];
399
+
400
+ constructor(scopeTags: Set<string> | null = null, stripHeader = false) {
401
+ this.scopeTags = scopeTags;
402
+ this.stripHeader = stripHeader;
403
+ }
404
+
405
+ private nestedBufferOpen(frame: HeaderFrame): boolean {
406
+ if (this.inLink) return this.linkSeq !== frame.outerLinkSeq;
407
+ if (this.inCell) return this.cellSeq !== frame.outerCellSeq;
408
+ if (this.inPre) return !frame.outerInPre;
409
+ return this.bqStack.length > frame.outerBqDepth;
410
+ }
411
+
412
+ private emit(text: string): void {
413
+ const frame = this.headerStack.length ? this.headerStack[this.headerStack.length - 1] : null;
414
+ const inNestedLink = frame
415
+ ? this.inLink && this.linkSeq !== frame.outerLinkSeq
416
+ : false;
417
+ const asHeading =
418
+ (this.headingMarks.length > 0 && !inNestedLink && !this.replaying) || this.emitAsHeading;
419
+ if (frame && asHeading) {
420
+ frame.headingParts.push(text);
421
+ frame.headingChars += text.trim().length;
422
+ }
423
+ if (!this.replaying && ((this.headingMarks.length > 0 && !this.inLink) || this.emitAsHeading)) {
424
+ this.segHeadingTexts.push(text);
425
+ }
426
+ const nestedOpen = frame ? this.nestedBufferOpen(frame) : false;
427
+ if (frame && !nestedOpen) {
428
+ frame.renderedChars += text.trim().length;
429
+ frame.parts.push(text);
430
+ return;
431
+ }
432
+ if (this.inLink) {
433
+ this.linkTextParts.push(text);
434
+ if (this.headingMarks.length > 0) this.linkHeadingParts.push(text);
435
+ } else if (this.inCell) {
436
+ this.cellParts.push(text);
437
+ } else if (this.inPre) {
438
+ this.preParts.push(text);
439
+ } else if (this.bqStack.length) {
440
+ this.bqStack[this.bqStack.length - 1].push(text);
441
+ } else {
442
+ this.out.push(text);
443
+ }
444
+ }
445
+
446
+ private segHeadingProse(): number {
447
+ return visibleChars(this.segHeadingTexts.join(""));
448
+ }
449
+
450
+ private drainPre(): void {
451
+ const raw = this.preParts.join("");
452
+ this.inPre = false;
453
+ this.preParts = [];
454
+ const fence = fenceFor(raw);
455
+ this.emitReplay(`\n\n${fence}\n${raw}\n${fence}\n\n`);
456
+ }
457
+
458
+ private drainBlockquote(): void {
459
+ const content = this.bqStack.pop() ?? [];
460
+ const prefixed = prefixBlockquote(content.join(""));
461
+ if (prefixed) this.emitReplay("\n\n" + prefixed + "\n\n");
462
+ }
463
+
464
+ private emitReplay(text: string): void {
465
+ const was = this.replaying;
466
+ this.replaying = true;
467
+ try {
468
+ this.emit(text);
469
+ } finally {
470
+ this.replaying = was;
471
+ }
472
+ }
473
+
474
+ private finishCell(): void {
475
+ if (!this.inCell) return;
476
+ this.inCell = false;
477
+ let cellText = this.cellParts.join("").trim().replace(/\n/g, " ");
478
+ cellText = cellText.replace(/\|/g, "\\|");
479
+ this.currentRow.push(cellText);
480
+ this.cellParts = [];
481
+ }
482
+
483
+ private finishRow(): void {
484
+ if (!this.currentRow.length) return;
485
+ const line = "| " + this.currentRow.join(" | ") + " |";
486
+ this.emitReplay(line + "\n");
487
+ if (!this.headerRowDone && (this.rowHasTh || this.isFirstRow)) {
488
+ const sep = "| " + this.currentRow.map(() => "---").join(" | ") + " |";
489
+ this.emit(sep + "\n");
490
+ this.headerRowDone = true;
491
+ }
492
+ this.isFirstRow = false;
493
+ this.currentRow = [];
494
+ this.rowHasTh = false;
495
+ }
496
+
497
+ private finishLink(): void {
498
+ const text = this.linkTextParts.join("").replace(/\s+/g, " ").trim();
499
+ const headingText = this.linkHeadingParts.join("").replace(/\s+/g, " ").trim();
500
+ const href = this.linkHref ?? "";
501
+ this.inLink = false;
502
+ this.linkTextParts = [];
503
+ this.linkHeadingParts = [];
504
+ const partial = Boolean(headingText) && headingText !== text;
505
+ this.emitAsHeading = this.linkHadHeading && !partial;
506
+ this.linkHadHeading = false;
507
+ this.linkHeaderChars = 0;
508
+ if (href && text) {
509
+ this.emit(`[${text}](${href})`);
510
+ } else if (text) {
511
+ this.emit(text);
512
+ }
513
+ this.emitAsHeading = false;
514
+ if (partial && this.headerStack.length) {
515
+ const frame = this.headerStack[this.headerStack.length - 1];
516
+ frame.headingParts.push(headingText + "\n\n");
517
+ frame.headingChars += headingText.length;
518
+ this.segHeadingTexts.push(headingText);
519
+ }
520
+ }
521
+
522
+ private truncateOpenTags(index: number): void {
523
+ for (const name of this.openTags.slice(index)) {
524
+ if (name in IMPLICIT_CLOSERS) this.closableOpen--;
525
+ }
526
+ this.openTags.length = index;
527
+ }
528
+
529
+ private closeImplicit(tag: string): void {
530
+ if (!this.closableOpen) return;
531
+ const barriers = CLOSE_BARRIERS[tag] ?? new Set<string>();
532
+ while (true) {
533
+ let closeAt: number | null = null;
534
+ for (let i = this.openTags.length - 1; i >= 0; i--) {
535
+ const name = this.openTags[i];
536
+ const closers = IMPLICIT_CLOSERS[name];
537
+ if (closers && closers.has(tag)) {
538
+ closeAt = i;
539
+ break;
540
+ }
541
+ if (barriers.has(name)) break;
542
+ }
543
+ if (closeAt === null) break;
544
+ this.truncateOpenTags(closeAt);
545
+ while (this.hiddenMarks.length && this.hiddenMarks[this.hiddenMarks.length - 1] >= closeAt) {
546
+ this.hiddenMarks.pop();
547
+ }
548
+ while (this.headingMarks.length && this.headingMarks[this.headingMarks.length - 1] >= closeAt) {
549
+ this.headingMarks.pop();
550
+ }
551
+ this.closeHeaderFrames(closeAt);
552
+ }
553
+ }
554
+
555
+ private closeHeaderFrames(depth: number, ownTag = false): void {
556
+ let closedByOwnTag = ownTag;
557
+ while (this.headerStack.length && this.headerStack[this.headerStack.length - 1].depth >= depth) {
558
+ this.finalizeNestedBuffers(this.headerStack[this.headerStack.length - 1]);
559
+ const frame = this.headerStack.pop()!;
560
+ if (this.headerStack.length) {
561
+ const outer = this.headerStack[this.headerStack.length - 1];
562
+ outer.textChars += frame.textChars;
563
+ outer.linkChars += frame.linkChars;
564
+ outer.headingParts.push(...frame.headingParts);
565
+ outer.headingChars += frame.headingChars;
566
+ }
567
+ const out = frame.render(closedByOwnTag);
568
+ if (frame.stripped) {
569
+ this.droppedChars += Math.max(0, frame.renderedChars - frame.headingChars);
570
+ }
571
+ this.emit(out);
572
+ closedByOwnTag = false;
573
+ }
574
+ }
575
+
576
+ private finalizeNestedBuffers(frame: HeaderFrame): void {
577
+ if (this.inLink && this.linkSeq !== frame.outerLinkSeq) {
578
+ frame.linkChars += this.linkHeaderChars;
579
+ this.finishLink();
580
+ }
581
+ while (this.inlineCodeDepth > frame.outerInCode) {
582
+ this.inlineCodeDepth--;
583
+ this.emit("`");
584
+ }
585
+ if (this.inPre && !frame.outerInPre) this.drainPre();
586
+ if (this.inCell && this.cellSeq !== frame.outerCellSeq) {
587
+ this.finishCell();
588
+ this.finishRow();
589
+ }
590
+ while (this.bqStack.length > frame.outerBqDepth) this.drainBlockquote();
591
+ while (this.listStack.length > frame.outerListDepth) {
592
+ if (this.listStack.pop() === "ol" && this.olCounter.length) this.olCounter.pop();
593
+ }
594
+ }
595
+
596
+ private flushHeaderFrames(): void {
597
+ while (this.headerStack.length) {
598
+ this.finalizeNestedBuffers(this.headerStack[this.headerStack.length - 1]);
599
+ this.emit(this.headerStack.pop()!.parts.join(""));
600
+ }
601
+ }
602
+
603
+ private countHeaderText(text: string): void {
604
+ if (!this.headerStack.length || this.headingMarks.length) return;
605
+ const frame = this.headerStack[this.headerStack.length - 1];
606
+ const chars = text.trim().length;
607
+ frame.textChars += chars;
608
+ if (!(this.inLink && this.linkHref)) return;
609
+ if (this.linkSeq === frame.outerLinkSeq) frame.linkChars += chars;
610
+ else this.linkHeaderChars += chars;
611
+ }
612
+
613
+ private enterTag(tag: string, attrs: AttrDict): boolean {
614
+ if (!VOID_TAGS.has(tag)) {
615
+ this.openTags.push(tag);
616
+ if (tag in IMPLICIT_CLOSERS) this.closableOpen++;
617
+ if (isHiddenElement(attrs)) this.hiddenMarks.push(this.openTags.length - 1);
618
+ if (HEADING_TAGS.has(tag) || tag === "hgroup" || isAriaHeading(attrs)) {
619
+ this.headingMarks.push(this.openTags.length - 1);
620
+ if (this.inLink) this.linkHadHeading = true;
621
+ }
622
+ if (
623
+ this.stripHeader &&
624
+ tag === "header" &&
625
+ !this.hiddenMarks.length &&
626
+ this.headerStack.length < MAX_HEADER_NESTING
627
+ ) {
628
+ this.headerStack.push(
629
+ new HeaderFrame(
630
+ this.openTags.length - 1,
631
+ this.inLink ? this.linkSeq : -1,
632
+ this.inCell ? this.cellSeq : -1,
633
+ this.inPre,
634
+ this.inlineCodeDepth,
635
+ this.bqStack.length,
636
+ this.listStack.length,
637
+ ),
638
+ );
639
+ }
640
+ } else if (isHiddenElement(attrs)) {
641
+ return false;
642
+ }
643
+ if (this.scopeTags && this.scopeTags.has(tag)) {
644
+ this.flushHeaderFrames();
645
+ if (this.scopeDepth === 0) {
646
+ this.scopeSegStart = this.out.length;
647
+ this.segDroppedStart = this.droppedChars;
648
+ this.segHeadingTexts = [];
649
+ }
650
+ this.scopeDepth++;
651
+ }
652
+ if (this.hiddenMarks.length) return false;
653
+ if (this.scopeTags && this.scopeDepth === 0) return false;
654
+ return true;
655
+ }
656
+
657
+ private exitTag(tag: string): boolean {
658
+ if (this.inLink && this.scopeTags && this.scopeTags.has(tag)) this.finishLink();
659
+ const suppressed =
660
+ this.hiddenMarks.length > 0 || (this.scopeTags !== null && this.scopeDepth === 0);
661
+ if (this.inLink && tag === "a" && this.headingMarks.length) this.finishLink();
662
+ if (!VOID_TAGS.has(tag)) {
663
+ for (let i = this.openTags.length - 1; i >= 0; i--) {
664
+ if (this.openTags[i] === tag) {
665
+ this.truncateOpenTags(i);
666
+ while (this.hiddenMarks.length && this.hiddenMarks[this.hiddenMarks.length - 1] >= i) {
667
+ this.hiddenMarks.pop();
668
+ }
669
+ while (this.headingMarks.length && this.headingMarks[this.headingMarks.length - 1] >= i) {
670
+ this.headingMarks.pop();
671
+ }
672
+ this.closeHeaderFrames(i, tag === "header");
673
+ break;
674
+ }
675
+ }
676
+ }
677
+ if (this.scopeTags && this.scopeTags.has(tag) && this.scopeDepth > 0) {
678
+ this.scopeDepth--;
679
+ if (this.scopeDepth === 0 && this.scopeSegStart !== null) {
680
+ this.scopeSegments.push(this.out.slice(this.scopeSegStart).join(""));
681
+ this.scopeDropped.push(this.droppedChars - this.segDroppedStart);
682
+ this.scopeHeadingProse.push(this.segHeadingProse());
683
+ this.scopeSegStart = null;
684
+ }
685
+ }
686
+ return !suppressed;
687
+ }
688
+
689
+ handleStartEndTag(_name: string, _attrs: AttrDict): void {}
690
+ handleStartTag(tag: string, attrs: AttrDict): void {
691
+ if (this.skipDepth) {
692
+ if (SKIP_TAGS.has(tag)) this.skipDepth++;
693
+ return;
694
+ }
695
+ this.closeImplicit(tag);
696
+ if (SKIP_TAGS.has(tag)) {
697
+ this.skipDepth++;
698
+ return;
699
+ }
700
+ if (!this.enterTag(tag, attrs)) return;
701
+ if (HEADING_TAGS.has(tag)) {
702
+ const level = Number(tag[1]);
703
+ this.emit("\n\n" + "#".repeat(level) + " ");
704
+ } else if (tag === "a") {
705
+ this.linkHref = attrs["href"];
706
+ this.linkTextParts = [];
707
+ this.linkHeadingParts = [];
708
+ this.inLink = true;
709
+ this.linkSeq++;
710
+ this.linkHeaderChars = 0;
711
+ } else if (tag in INLINE_EMPHASIS) {
712
+ this.emit(INLINE_EMPHASIS[tag]);
713
+ } else if (tag === "br") {
714
+ this.emit("\n");
715
+ } else if (BLOCK_TAGS.has(tag)) {
716
+ this.emit("\n\n");
717
+ } else if (tag === "hr") {
718
+ this.emit("\n\n---\n\n");
719
+ } else if (tag === "blockquote") {
720
+ this.emit("\n\n");
721
+ this.bqStack.push([]);
722
+ } else if (tag === "ul") {
723
+ this.listStack.push("ul");
724
+ this.emit("\n");
725
+ } else if (tag === "ol") {
726
+ this.listStack.push("ol");
727
+ const startAttr = attrs["start"];
728
+ let start = 1;
729
+ if (startAttr !== null && startAttr !== undefined && startAttr.trim()) {
730
+ const parsed = Number(startAttr);
731
+ if (Number.isInteger(parsed)) start = parsed;
732
+ }
733
+ this.olCounter.push(start - 1);
734
+ this.emit("\n");
735
+ } else if (tag === "li") {
736
+ const indent = " ".repeat(Math.max(0, this.listStack.length - 1));
737
+ if (this.listStack.length && this.listStack[this.listStack.length - 1] === "ol") {
738
+ if (this.olCounter.length) {
739
+ this.olCounter[this.olCounter.length - 1]++;
740
+ this.emit(`\n${indent}${this.olCounter[this.olCounter.length - 1]}. `);
741
+ } else {
742
+ this.emit(`\n${indent}1. `);
743
+ }
744
+ } else {
745
+ this.emit(`\n${indent}* `);
746
+ }
747
+ } else if (tag === "pre") {
748
+ this.preParts = [];
749
+ this.inPre = true;
750
+ } else if (tag === "code" && !this.inPre) {
751
+ this.inlineCodeDepth++;
752
+ this.emit("`");
753
+ } else if (tag === "table") {
754
+ this.inTable = true;
755
+ this.headerRowDone = false;
756
+ this.isFirstRow = true;
757
+ this.emit("\n\n");
758
+ } else if (tag === "tr") {
759
+ this.finishCell();
760
+ this.finishRow();
761
+ } else if (tag === "th" || tag === "td") {
762
+ this.finishCell();
763
+ this.cellParts = [];
764
+ this.inCell = true;
765
+ this.cellSeq++;
766
+ if (tag === "th") this.rowHasTh = true;
767
+ }
768
+ }
769
+
770
+ handleEndTag(tag: string): void {
771
+ if (SKIP_TAGS.has(tag)) {
772
+ this.skipDepth = Math.max(0, this.skipDepth - 1);
773
+ return;
774
+ }
775
+ if (this.skipDepth) return;
776
+ if (!this.exitTag(tag)) return;
777
+ if (HEADING_TAGS.has(tag)) {
778
+ this.emit("\n\n");
779
+ } else if (tag === "a") {
780
+ if (this.headerStack.length) {
781
+ this.headerStack[this.headerStack.length - 1].linkChars += this.linkHeaderChars;
782
+ }
783
+ this.finishLink();
784
+ } else if (tag in INLINE_EMPHASIS) {
785
+ this.emit(INLINE_EMPHASIS[tag]);
786
+ } else if (BLOCK_TAGS.has(tag)) {
787
+ this.emit("\n\n");
788
+ } else if (tag === "blockquote") {
789
+ if (this.bqStack.length) this.drainBlockquote();
790
+ } else if (tag === "ul") {
791
+ if (this.listStack.length && this.listStack[this.listStack.length - 1] === "ul") {
792
+ this.listStack.pop();
793
+ }
794
+ this.emit("\n");
795
+ } else if (tag === "ol") {
796
+ if (this.listStack.length && this.listStack[this.listStack.length - 1] === "ol") {
797
+ this.listStack.pop();
798
+ if (this.olCounter.length) this.olCounter.pop();
799
+ }
800
+ this.emit("\n");
801
+ } else if (tag === "pre" && this.inPre) {
802
+ this.drainPre();
803
+ } else if (tag === "code" && !this.inPre && this.inlineCodeDepth) {
804
+ this.inlineCodeDepth--;
805
+ this.emit("`");
806
+ } else if (tag === "th" || tag === "td") {
807
+ this.finishCell();
808
+ } else if (tag === "tr") {
809
+ this.finishCell();
810
+ this.finishRow();
811
+ } else if (tag === "table") {
812
+ this.finishCell();
813
+ this.finishRow();
814
+ this.inTable = false;
815
+ this.emit("\n");
816
+ }
817
+ }
818
+
819
+ private textSuppressed(): boolean {
820
+ if (this.skipDepth || this.hiddenMarks.length) return true;
821
+ return this.scopeTags !== null && this.scopeDepth === 0;
822
+ }
823
+
824
+ handleData(data: string): void {
825
+ if (this.textSuppressed()) return;
826
+ if (this.inPre) {
827
+ this.countHeaderText(data);
828
+ this.preParts.push(data);
829
+ return;
830
+ }
831
+ if (this.inlineCodeDepth) {
832
+ this.countHeaderText(data);
833
+ this.emit(data);
834
+ return;
835
+ }
836
+ const text = data.replace(/\s+/g, " ");
837
+ this.countHeaderText(text);
838
+ if (this.inTable && !this.inCell && !text.trim()) return;
839
+ this.emit(text);
840
+ }
841
+
842
+ handleEntityRef(name: string): void {
843
+ if (this.textSuppressed()) return;
844
+ const text = decodeHtmlEntities(`&${name};`);
845
+ this.countHeaderText(text);
846
+ this.emit(text);
847
+ }
848
+
849
+ handleCharRef(name: string): void {
850
+ if (this.textSuppressed()) return;
851
+ const text = decodeHtmlEntities(`&#${name};`);
852
+ this.countHeaderText(text);
853
+ this.emit(text);
854
+ }
855
+
856
+ flushPending(): void {
857
+ this.flushHeaderFrames();
858
+ if (this.inLink) this.finishLink();
859
+ while (this.inlineCodeDepth) {
860
+ this.inlineCodeDepth--;
861
+ this.emit("`");
862
+ }
863
+ this.finishCell();
864
+ this.finishRow();
865
+ if (this.inPre) this.drainPre();
866
+ while (this.bqStack.length) {
867
+ const content = this.bqStack.pop()!.join("");
868
+ const prefixed = prefixBlockquote(content);
869
+ if (!prefixed) continue;
870
+ if (this.bqStack.length) {
871
+ this.bqStack[this.bqStack.length - 1].push("\n\n" + prefixed + "\n\n");
872
+ } else {
873
+ this.out.push("\n\n" + prefixed + "\n\n");
874
+ }
875
+ }
876
+ if (this.scopeSegStart !== null) {
877
+ this.scopeSegments.push(this.out.slice(this.scopeSegStart).join(""));
878
+ this.scopeDropped.push(this.droppedChars - this.segDroppedStart);
879
+ this.scopeHeadingProse.push(this.segHeadingProse());
880
+ this.scopeSegStart = null;
881
+ this.scopeDepth = 0;
882
+ }
883
+ }
884
+ }
885
+
886
+ function prefixBlockquote(content: string): string {
887
+ content = content.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
888
+ if (!content) return "";
889
+ return content
890
+ .split("\n")
891
+ .map((line) => (line.trim() ? "> " + line : ">"))
892
+ .join("\n");
893
+ }
894
+
895
+ function cleanup(text: string): string {
896
+ const lines = text.split("\n");
897
+ const out: string[] = [];
898
+ let fence = 0;
899
+ let blankRun = 0;
900
+ for (const line of lines) {
901
+ const stripped = line.replace(/[ \t]+$/, "");
902
+ const moved = fenceState(stripped, fence);
903
+ if (moved !== fence) {
904
+ fence = moved;
905
+ blankRun = 0;
906
+ out.push(stripped);
907
+ continue;
908
+ }
909
+ if (fence) {
910
+ out.push(line);
911
+ continue;
912
+ }
913
+ if (!stripped) {
914
+ blankRun++;
915
+ if (blankRun <= 1) out.push("");
916
+ continue;
917
+ }
918
+ blankRun = 0;
919
+ out.push(stripped);
920
+ }
921
+ return out.join("\n").trim();
922
+ }
923
+
924
+ const BOILERPLATE_FRAGMENTS = [
925
+ "skip to content",
926
+ "skip to main content",
927
+ "there was an error while loading",
928
+ "please reload this page",
929
+ "you can't perform that action at this time",
930
+ "you signed in with another tab or window",
931
+ "you signed out in another tab or window",
932
+ "you switched accounts on another tab or window",
933
+ "reload to refresh your session",
934
+ "you must be signed in to change notification settings",
935
+ "uh oh!",
936
+ "{{ message }}",
937
+ "this website uses cookies",
938
+ "we use cookies",
939
+ "accept all cookies",
940
+ "manage cookie preferences",
941
+ ];
942
+
943
+ const BOILERPLATE_MAX_LINE_CHARS = 300;
944
+
945
+ const BOILERPLATE_NORMALIZED = new Set(
946
+ BOILERPLATE_FRAGMENTS.map((fragment) =>
947
+ fragment.replace(/\s+/g, " ").trim().toLowerCase().replace(/[.!:]+$/, ""),
948
+ ),
949
+ );
950
+
951
+ function lineIsBoilerplate(line: string): boolean {
952
+ const normalized = line.replace(/\s+/g, " ").trim().toLowerCase();
953
+ if (!normalized) return false;
954
+ const segments = normalized
955
+ .split(/[.!]/)
956
+ .map((segment) => segment.trim().replace(/[.!:]+$/, ""))
957
+ .filter((segment) => segment.length > 0);
958
+ return segments.length > 0 && segments.every((segment) => BOILERPLATE_NORMALIZED.has(segment));
959
+ }
960
+
961
+ function fenceState(line: string, fence: number): number {
962
+ const stripped = line.trim();
963
+ if (stripped.length < 3 || stripped.replace(/`/g, "").length !== 0) return fence;
964
+ if (!fence) return stripped.length;
965
+ return stripped.length >= fence ? 0 : fence;
966
+ }
967
+
968
+ function stripBoilerplateLines(text: string): string {
969
+ const out: string[] = [];
970
+ let fence = 0;
971
+ for (const line of text.split("\n")) {
972
+ const moved = fenceState(line, fence);
973
+ if (moved !== fence) {
974
+ fence = moved;
975
+ out.push(line);
976
+ continue;
977
+ }
978
+ if (!fence && line.length <= BOILERPLATE_MAX_LINE_CHARS && lineIsBoilerplate(line)) continue;
979
+ out.push(line);
980
+ }
981
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
982
+ }
983
+
984
+ function newRenderer(
985
+ sourceHtml: string,
986
+ scopeTags: Set<string> | null,
987
+ stripHeader: boolean,
988
+ ): MarkdownRenderer {
989
+ const renderer = new MarkdownRenderer(scopeTags, stripHeader);
990
+ feedHtml(sourceHtml, renderer);
991
+ renderer.flushPending();
992
+ return renderer;
993
+ }
994
+
995
+ function render(sourceHtml: string, scopeTags: Set<string> | null, stripHeader = false): string {
996
+ return cleanup(newRenderer(sourceHtml, scopeTags, stripHeader).out.join(""));
997
+ }
998
+
999
+ function selectMainScopeRender(sourceHtml: string, tag: string): [number, string] {
1000
+ const renderer = newRenderer(sourceHtml, new Set([tag]), true);
1001
+ const dropped = renderer.scopeDropped;
1002
+ const headingProse = renderer.scopeHeadingProse;
1003
+ let bestLen = 0;
1004
+ let bestRender = "";
1005
+ for (let i = 0; i < renderer.scopeSegments.length; i++) {
1006
+ const rendered = stripBoilerplateLines(cleanup(renderer.scopeSegments[i]));
1007
+ const prose = visibleChars(rendered) - headingProse[i];
1008
+ if (prose < MIN_MAIN_CONTENT_CHARS) continue;
1009
+ const size = rendered.length + Math.min(dropped[i], rendered.length);
1010
+ if (size > bestLen) {
1011
+ bestLen = size;
1012
+ bestRender = rendered;
1013
+ }
1014
+ }
1015
+ return [bestLen, bestRender];
1016
+ }
1017
+
1018
+ export function visibleChars(text: string): number {
1019
+ let total = 0;
1020
+ for (const line of text.split("\n")) {
1021
+ if (line.trim()) total += visibleLineChars(line);
1022
+ }
1023
+ return total;
1024
+ }
1025
+
1026
+ function visibleLineChars(line: string): number {
1027
+ let total = 0;
1028
+ let i = 0;
1029
+ const n = line.length;
1030
+ let openBracket = false;
1031
+ while (i < n) {
1032
+ if (line[i] === "\\") {
1033
+ total += 2;
1034
+ i += 2;
1035
+ continue;
1036
+ }
1037
+ if (line[i] === "[") openBracket = true;
1038
+ if (openBracket && line[i] === "]" && i + 1 < n && line[i + 1] === "(") {
1039
+ let j = i + 2;
1040
+ let depth = 1;
1041
+ while (j < n && depth) {
1042
+ const char = line[j];
1043
+ if (char === "\\") {
1044
+ j += 2;
1045
+ continue;
1046
+ }
1047
+ depth += (char === "(" ? 1 : 0) - (char === ")" ? 1 : 0);
1048
+ j++;
1049
+ }
1050
+ if (depth) {
1051
+ total += 1;
1052
+ i += 1;
1053
+ continue;
1054
+ }
1055
+ i = j;
1056
+ openBracket = false;
1057
+ continue;
1058
+ }
1059
+ total += 1;
1060
+ i += 1;
1061
+ }
1062
+ return total;
1063
+ }
1064
+
1065
+ function fenceFor(raw: string): string {
1066
+ let longest = 0;
1067
+ let run = 0;
1068
+ for (const char of raw) {
1069
+ run = char === "`" ? run + 1 : 0;
1070
+ longest = Math.max(longest, run);
1071
+ }
1072
+ return "`".repeat(Math.max(3, longest + 1));
1073
+ }
1074
+
1075
+ export function htmlToMarkdown(sourceHtml: string, mainContent = false): string {
1076
+ sourceHtml = sourceHtml.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1077
+ if (mainContent) {
1078
+ for (const scopeTag of ["article", "main"]) {
1079
+ const [length, rendered] = selectMainScopeRender(sourceHtml, scopeTag);
1080
+ if (length >= MIN_MAIN_CONTENT_CHARS) return rendered;
1081
+ }
1082
+ return stripBoilerplateLines(render(sourceHtml, null, true));
1083
+ }
1084
+ return render(sourceHtml, null);
1085
+ }