@intentic/webq 1.317.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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/app.d.ts +2 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +38 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +4 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/commands/cache.command.d.ts +3 -0
  12. package/dist/commands/cache.command.d.ts.map +1 -0
  13. package/dist/commands/cache.command.js +54 -0
  14. package/dist/commands/cache.command.js.map +1 -0
  15. package/dist/commands/crawl.command.d.ts +3 -0
  16. package/dist/commands/crawl.command.d.ts.map +1 -0
  17. package/dist/commands/crawl.command.js +77 -0
  18. package/dist/commands/crawl.command.js.map +1 -0
  19. package/dist/commands/fetch.command.d.ts +3 -0
  20. package/dist/commands/fetch.command.d.ts.map +1 -0
  21. package/dist/commands/fetch.command.js +55 -0
  22. package/dist/commands/fetch.command.js.map +1 -0
  23. package/dist/lib/bm25.d.ts +7 -0
  24. package/dist/lib/bm25.d.ts.map +1 -0
  25. package/dist/lib/bm25.js +51 -0
  26. package/dist/lib/bm25.js.map +1 -0
  27. package/dist/lib/browser.d.ts +8 -0
  28. package/dist/lib/browser.d.ts.map +1 -0
  29. package/dist/lib/browser.js +29 -0
  30. package/dist/lib/browser.js.map +1 -0
  31. package/dist/lib/cache.d.ts +15 -0
  32. package/dist/lib/cache.d.ts.map +1 -0
  33. package/dist/lib/cache.js +28 -0
  34. package/dist/lib/cache.js.map +1 -0
  35. package/dist/lib/crawl.d.ts +31 -0
  36. package/dist/lib/crawl.d.ts.map +1 -0
  37. package/dist/lib/crawl.js +188 -0
  38. package/dist/lib/crawl.js.map +1 -0
  39. package/dist/lib/dom.d.ts +23 -0
  40. package/dist/lib/dom.d.ts.map +1 -0
  41. package/dist/lib/dom.js +74 -0
  42. package/dist/lib/dom.js.map +1 -0
  43. package/dist/lib/env.d.ts +2 -0
  44. package/dist/lib/env.d.ts.map +1 -0
  45. package/dist/lib/env.js +4 -0
  46. package/dist/lib/env.js.map +1 -0
  47. package/dist/lib/flags.d.ts +69 -0
  48. package/dist/lib/flags.d.ts.map +1 -0
  49. package/dist/lib/flags.js +23 -0
  50. package/dist/lib/flags.js.map +1 -0
  51. package/dist/lib/http.d.ts +16 -0
  52. package/dist/lib/http.d.ts.map +1 -0
  53. package/dist/lib/http.js +75 -0
  54. package/dist/lib/http.js.map +1 -0
  55. package/dist/lib/links.d.ts +9 -0
  56. package/dist/lib/links.d.ts.map +1 -0
  57. package/dist/lib/links.js +41 -0
  58. package/dist/lib/links.js.map +1 -0
  59. package/dist/lib/markdown.d.ts +6 -0
  60. package/dist/lib/markdown.d.ts.map +1 -0
  61. package/dist/lib/markdown.js +292 -0
  62. package/dist/lib/markdown.js.map +1 -0
  63. package/dist/lib/output.d.ts +20 -0
  64. package/dist/lib/output.d.ts.map +1 -0
  65. package/dist/lib/output.js +64 -0
  66. package/dist/lib/output.js.map +1 -0
  67. package/dist/lib/page.d.ts +26 -0
  68. package/dist/lib/page.d.ts.map +1 -0
  69. package/dist/lib/page.js +153 -0
  70. package/dist/lib/page.js.map +1 -0
  71. package/dist/lib/prune.d.ts +7 -0
  72. package/dist/lib/prune.d.ts.map +1 -0
  73. package/dist/lib/prune.js +100 -0
  74. package/dist/lib/prune.js.map +1 -0
  75. package/dist/lib/robots.d.ts +10 -0
  76. package/dist/lib/robots.d.ts.map +1 -0
  77. package/dist/lib/robots.js +84 -0
  78. package/dist/lib/robots.js.map +1 -0
  79. package/dist/lib/sitemap.d.ts +9 -0
  80. package/dist/lib/sitemap.d.ts.map +1 -0
  81. package/dist/lib/sitemap.js +61 -0
  82. package/dist/lib/sitemap.js.map +1 -0
  83. package/dist/lib/version.d.ts +2 -0
  84. package/dist/lib/version.d.ts.map +1 -0
  85. package/dist/lib/version.js +3 -0
  86. package/dist/lib/version.js.map +1 -0
  87. package/package.json +69 -0
  88. package/src/app.ts +43 -0
  89. package/src/cli.integration.test.ts +191 -0
  90. package/src/cli.ts +6 -0
  91. package/src/commands/cache.command.ts +54 -0
  92. package/src/commands/crawl.command.ts +94 -0
  93. package/src/commands/fetch.command.ts +61 -0
  94. package/src/lib/bm25.test.ts +33 -0
  95. package/src/lib/bm25.ts +70 -0
  96. package/src/lib/browser.ts +42 -0
  97. package/src/lib/cache.ts +44 -0
  98. package/src/lib/crawl.ts +247 -0
  99. package/src/lib/dom.ts +107 -0
  100. package/src/lib/env.ts +6 -0
  101. package/src/lib/flags.ts +41 -0
  102. package/src/lib/http.ts +101 -0
  103. package/src/lib/links.test.ts +37 -0
  104. package/src/lib/links.ts +47 -0
  105. package/src/lib/markdown.test.ts +76 -0
  106. package/src/lib/markdown.ts +337 -0
  107. package/src/lib/output.test.ts +16 -0
  108. package/src/lib/output.ts +86 -0
  109. package/src/lib/page.ts +213 -0
  110. package/src/lib/prune.test.ts +78 -0
  111. package/src/lib/prune.ts +133 -0
  112. package/src/lib/robots.test.ts +52 -0
  113. package/src/lib/robots.ts +102 -0
  114. package/src/lib/sitemap.test.ts +37 -0
  115. package/src/lib/sitemap.ts +74 -0
package/src/lib/dom.ts ADDED
@@ -0,0 +1,107 @@
1
+ // parse5's tree plus the traversal helpers every stage (pruning, markdown, links) needs; parse5 over jsdom since webq
2
+ // ships baked into the sandbox image, and every dependency is pull size. Scripts never execute: this tree is data.
3
+ import { type DefaultTreeAdapterMap, parse } from "parse5";
4
+
5
+ export type Document = DefaultTreeAdapterMap["document"];
6
+ export type Node = DefaultTreeAdapterMap["node"];
7
+ export type Element = DefaultTreeAdapterMap["element"];
8
+ export type TextNode = DefaultTreeAdapterMap["textNode"];
9
+
10
+ export const isElement = (node: Node): node is Element => "tagName" in node;
11
+ export const isText = (node: Node): node is TextNode => node.nodeName === "#text";
12
+
13
+ export const parseHtml = (html: string): Document => parse(html);
14
+
15
+ export const attr = (el: Element, name: string): string | undefined => el.attrs.find((a) => a.name === name)?.value;
16
+
17
+ // Tags whose text is never page content; a <script>'s body is code, skipped by every text walk.
18
+ const NON_CONTENT = new Set(["script", "style", "noscript", "template", "svg", "canvas"]);
19
+
20
+ export const childElements = (el: Element): Element[] => el.childNodes.filter(isElement);
21
+
22
+ /** Depth-first visible text of a subtree, single-space normalized. */
23
+ export const textOf = (node: Node): string => collectText(node).join(" ").replaceAll(/\s+/g, " ").trim();
24
+
25
+ /** Raw text of a subtree with whitespace kept, what a <pre> means by its contents. */
26
+ export const rawTextOf = (node: Node): string => collectText(node).join("");
27
+
28
+ const collectText = (node: Node): string[] => {
29
+ if (isText(node)) {
30
+ return [node.value];
31
+ }
32
+ if (!isElement(node) || NON_CONTENT.has(node.tagName)) {
33
+ return [];
34
+ }
35
+ return node.childNodes.flatMap((child) => collectText(child));
36
+ };
37
+
38
+ /** Every element with the given tag under (and including) the root, document order. */
39
+ export const elementsByTag = (root: Node, tag: string): Element[] => {
40
+ const found: Element[] = [];
41
+ walk(root, (el) => {
42
+ if (el.tagName === tag) {
43
+ found.push(el);
44
+ }
45
+ });
46
+ return found;
47
+ };
48
+
49
+ export const walk = (node: Node, visit: (el: Element) => void): void => {
50
+ if (isElement(node)) {
51
+ visit(node);
52
+ }
53
+ if (isElement(node) || node.nodeName === "#document" || node.nodeName === "#document-fragment") {
54
+ for (const child of (node as { childNodes: Node[] }).childNodes) {
55
+ walk(child, visit);
56
+ }
57
+ }
58
+ };
59
+
60
+ // Package-private; bodyOf and metaOf are the shapes every consumer actually wants.
61
+ const findFirst = (root: Node, match: (el: Element) => boolean): Element | undefined => {
62
+ if (isElement(root) && match(root)) {
63
+ return root;
64
+ }
65
+ if (!("childNodes" in root)) {
66
+ return undefined;
67
+ }
68
+ for (const child of (root as { childNodes: Node[] }).childNodes) {
69
+ const hit = findFirst(child, match);
70
+ if (hit !== undefined) {
71
+ return hit;
72
+ }
73
+ }
74
+ return undefined;
75
+ };
76
+
77
+ export const bodyOf = (doc: Document): Element | undefined => findFirst(doc, (el) => el.tagName === "body");
78
+
79
+ /** Detach an element from its parent — parse5 trees are plain objects, so removal is list surgery. */
80
+ export const remove = (el: Element): void => {
81
+ const parent = el.parentNode;
82
+ if (parent === null || parent === undefined) {
83
+ return;
84
+ }
85
+ const index = parent.childNodes.indexOf(el);
86
+ if (index >= 0) {
87
+ parent.childNodes.splice(index, 1);
88
+ }
89
+ };
90
+
91
+ /** Page metadata every capsule and index line leads with. */
92
+ export interface PageMeta {
93
+ readonly title: string;
94
+ readonly description: string | undefined;
95
+ readonly lang: string | undefined;
96
+ }
97
+
98
+ export const metaOf = (doc: Document): PageMeta => {
99
+ const title = textOf(findFirst(doc, (el) => el.tagName === "title") ?? ({ nodeName: "#text", value: "" } as Node));
100
+ const description = findFirst(doc, (el) => el.tagName === "meta" && attr(el, "name") === "description");
101
+ const html = findFirst(doc, (el) => el.tagName === "html");
102
+ return {
103
+ title,
104
+ description: description === undefined ? undefined : attr(description, "content"),
105
+ lang: html === undefined ? undefined : attr(html, "lang"),
106
+ };
107
+ };
package/src/lib/env.ts ADDED
@@ -0,0 +1,6 @@
1
+ /* The one directory webq owns beyond the shared agent-CLI layout (@intentic/agent-cli/env). */
2
+ import { join } from "node:path";
3
+ import { toolHome } from "@intentic/agent-cli/env";
4
+
5
+ // One store for every render mode: a static fetch and a browser fetch of the same URL are one network trip.
6
+ export const cacheDir = (): string => join(toolHome("webq"), "cache");
@@ -0,0 +1,41 @@
1
+ /* Flag parsers and the flag groups both verbs share — declared once so `webq fetch` and `webq crawl`. */
2
+ import { countParser } from "@intentic/agent-cli/flags";
3
+ import type { BrowserMode } from "./page.js";
4
+
5
+ const browserModeParser = (raw: string): BrowserMode => {
6
+ if (raw === "auto" || raw === "never" || raw === "force") {
7
+ return raw;
8
+ }
9
+ throw new Error(`--browser takes auto|never|force, got "${raw}"`);
10
+ };
11
+
12
+ /** A URL an agent typed: a bare host is meant as https. */
13
+ export const urlParser = (raw: string): string => {
14
+ const trimmed = raw.trim();
15
+ return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
16
+ };
17
+
18
+ // The options shared verbatim by fetch and crawl.
19
+ export const sharedFlagParameters = {
20
+ raw: { kind: "boolean", default: false, brief: "Whole page, skip fit-pruning of chrome" },
21
+ query: { kind: "parsed", parse: String, optional: true, brief: "Keep only blocks relevant to this (BM25); crawl: also steer link order" },
22
+ browser: { kind: "parsed", parse: browserModeParser, default: "auto", brief: "JS rendering: auto|never|force" },
23
+ out: { kind: "parsed", parse: String, optional: true, brief: "Directory for the markdown files" },
24
+ fresh: { kind: "boolean", default: false, brief: "Bypass the cache for this run" },
25
+ maxAge: { kind: "parsed", parse: countParser, optional: true, brief: "Cache freshness window in seconds (default 900)" },
26
+ timeout: { kind: "parsed", parse: countParser, default: "20", brief: "Per-page deadline in seconds" },
27
+ threshold: { kind: "parsed", parse: countParser, optional: true, brief: "Fit-pruning score floor (default 0.48; lower keeps more)" },
28
+ json: { kind: "boolean", default: false, brief: "Machine-readable result on stdout" },
29
+ } as const;
30
+
31
+ export interface SharedFlags {
32
+ readonly raw: boolean;
33
+ readonly query?: string;
34
+ readonly browser: BrowserMode;
35
+ readonly out?: string;
36
+ readonly fresh: boolean;
37
+ readonly maxAge?: number;
38
+ readonly timeout: number;
39
+ readonly threshold?: number;
40
+ readonly json: boolean;
41
+ }
@@ -0,0 +1,101 @@
1
+ /* The static fetch path — where every page starts, and where most docs pages end. */
2
+
3
+ export interface HttpFetchOptions {
4
+ readonly timeoutMs?: number;
5
+ readonly maxBytes?: number;
6
+ readonly userAgent?: string;
7
+ }
8
+
9
+ export interface HttpPage {
10
+ readonly url: string;
11
+ /** Where redirects landed — the base every relative link resolves against. */
12
+ readonly finalUrl: string;
13
+ readonly status: number;
14
+ readonly contentType: string;
15
+ readonly body: string;
16
+ /** True when the byte cap cut the body short — the capsule must say so. */
17
+ readonly truncated: boolean;
18
+ }
19
+
20
+ // A current-Chrome UA with webq named at the end: enough for the CDNs that 403 anything without a browser
21
+ // token, honest to anyone who reads their logs. The browser fallback presents real Chromium anyway.
22
+ export const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 webq";
23
+
24
+ const DEFAULT_TIMEOUT_MS = 20_000;
25
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
26
+
27
+ export const httpFetch = async (url: string, options: HttpFetchOptions = {}): Promise<HttpPage> => {
28
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
30
+ const response = await fetch(url, {
31
+ redirect: "follow",
32
+ signal: AbortSignal.timeout(timeoutMs),
33
+ headers: {
34
+ "user-agent": options.userAgent ?? USER_AGENT,
35
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5",
36
+ "accept-language": "en-US,en;q=0.9",
37
+ },
38
+ });
39
+ const contentType = response.headers.get("content-type") ?? "";
40
+ const { bytes, truncated } = await readCapped(response, maxBytes, timeoutMs);
41
+ return {
42
+ url,
43
+ finalUrl: response.url === "" ? url : response.url,
44
+ status: response.status,
45
+ contentType,
46
+ body: decode(bytes, contentType),
47
+ truncated,
48
+ };
49
+ };
50
+
51
+ const readCapped = async (response: Response, maxBytes: number, timeoutMs: number): Promise<{ bytes: Uint8Array; truncated: boolean }> => {
52
+ if (response.body === null) {
53
+ return { bytes: new Uint8Array(0), truncated: false };
54
+ }
55
+ const deadline = Date.now() + timeoutMs;
56
+ const chunks: Uint8Array[] = [];
57
+ let total = 0;
58
+ const reader = response.body.getReader();
59
+ for (;;) {
60
+ if (Date.now() > deadline) {
61
+ await reader.cancel();
62
+ return { bytes: concat(chunks, total), truncated: true };
63
+ }
64
+ const { done, value } = await reader.read();
65
+ if (done) {
66
+ return { bytes: concat(chunks, total), truncated: false };
67
+ }
68
+ chunks.push(value);
69
+ total += value.byteLength;
70
+ if (total >= maxBytes) {
71
+ await reader.cancel();
72
+ return { bytes: concat(chunks, total).slice(0, maxBytes), truncated: true };
73
+ }
74
+ }
75
+ };
76
+
77
+ const concat = (chunks: Uint8Array[], total: number): Uint8Array => {
78
+ const merged = new Uint8Array(total);
79
+ let offset = 0;
80
+ for (const chunk of chunks) {
81
+ merged.set(chunk, offset);
82
+ offset += chunk.byteLength;
83
+ }
84
+ return merged;
85
+ };
86
+
87
+ const decode = (bytes: Uint8Array, contentType: string): string => {
88
+ const headerCharset = /charset=["']?([\w-]+)/i.exec(contentType)?.[1];
89
+ const sniffed = headerCharset ?? sniffMetaCharset(bytes);
90
+ try {
91
+ return new TextDecoder(sniffed ?? "utf8", { fatal: false }).decode(bytes);
92
+ } catch {
93
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
94
+ }
95
+ };
96
+
97
+ // The <meta charset> lives in the first kilobytes by spec; sniff it from an ASCII-safe decode of the head.
98
+ const sniffMetaCharset = (bytes: Uint8Array): string | undefined => {
99
+ const head = new TextDecoder("latin1").decode(bytes.slice(0, 4096));
100
+ return (/<meta[^>]+charset=["']?([\w-]+)/i.exec(head) ?? /<\?xml[^>]+encoding=["']([\w-]+)/i.exec(head))?.[1];
101
+ };
@@ -0,0 +1,37 @@
1
+ import { parseHtml } from "./dom.js";
2
+ import { extractLinks, normalizeUrl, sameOrigin } from "./links.js";
3
+
4
+ describe("extractLinks", () => {
5
+ it("resolves, dedupes and strips fragments, keeping the best anchor text", () => {
6
+ const doc = parseHtml(
7
+ `<html><body>
8
+ <a href="/a">Alpha</a>
9
+ <a href="/a#section"></a>
10
+ <a href="https://other.example/x">Away</a>
11
+ <a href="mailto:a@b.c">mail</a>
12
+ <a href="javascript:void(0)">js</a>
13
+ </body></html>`,
14
+ );
15
+ const links = extractLinks(doc, "https://ex.com/docs/");
16
+ expect(links).toEqual([
17
+ { url: "https://ex.com/a", text: "Alpha" },
18
+ { url: "https://other.example/x", text: "Away" },
19
+ ]);
20
+ });
21
+ });
22
+
23
+ describe("normalizeUrl", () => {
24
+ it("drops fragments and non-http schemes", () => {
25
+ expect(normalizeUrl("https://ex.com/a#b")).toBe("https://ex.com/a");
26
+ expect(normalizeUrl("ftp://ex.com/a")).toBeUndefined();
27
+ expect(normalizeUrl("not a url")).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe("sameOrigin", () => {
32
+ it("compares scheme, host and port", () => {
33
+ expect(sameOrigin("https://ex.com/a", "https://ex.com/b")).toBe(true);
34
+ expect(sameOrigin("https://ex.com/a", "https://sub.ex.com/a")).toBe(false);
35
+ expect(sameOrigin("https://ex.com/a", "http://ex.com/a")).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,47 @@
1
+ /* Link harvesting for the crawler, off the ORIGINAL tree — pruning strips navigation, and navigation is exactly where a site's structure lives. */
2
+ import { attr, elementsByTag, type Node, textOf } from "./dom.js";
3
+
4
+ export interface Link {
5
+ readonly url: string;
6
+ readonly text: string;
7
+ }
8
+
9
+ export const extractLinks = (root: Node, baseUrl: string): Link[] => {
10
+ const seen = new Map<string, Link>();
11
+ for (const anchor of elementsByTag(root, "a")) {
12
+ const url = normalizeUrl(attr(anchor, "href"), baseUrl);
13
+ if (url === undefined) {
14
+ continue;
15
+ }
16
+ const existing = seen.get(url);
17
+ const text = textOf(anchor).slice(0, 200);
18
+ if (existing === undefined || (existing.text === "" && text !== "")) {
19
+ seen.set(url, { url, text });
20
+ }
21
+ }
22
+ return [...seen.values()];
23
+ };
24
+
25
+ export const normalizeUrl = (raw: string | undefined, base?: string): string | undefined => {
26
+ if (raw === undefined || raw.trim() === "") {
27
+ return undefined;
28
+ }
29
+ try {
30
+ const url = base === undefined ? new URL(raw.trim()) : new URL(raw.trim(), base);
31
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
32
+ return undefined;
33
+ }
34
+ url.hash = "";
35
+ return url.href;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ };
40
+
41
+ export const sameOrigin = (a: string, b: string): boolean => {
42
+ try {
43
+ return new URL(a).origin === new URL(b).origin;
44
+ } catch {
45
+ return false;
46
+ }
47
+ };
@@ -0,0 +1,76 @@
1
+ import { bodyOf, parseHtml } from "./dom.js";
2
+ import { renderMarkdown } from "./markdown.js";
3
+
4
+ const md = (html: string, baseUrl?: string): string => {
5
+ const body = bodyOf(parseHtml(`<html><body>${html}</body></html>`));
6
+ if (body === undefined) {
7
+ throw new Error("fixture has no body");
8
+ }
9
+ return renderMarkdown(body, baseUrl === undefined ? {} : { baseUrl });
10
+ };
11
+
12
+ describe("renderMarkdown", () => {
13
+ it("renders headings, paragraphs and inline marks", () => {
14
+ expect(md("<h2>Title</h2><p>Plain <strong>bold</strong> <em>italic</em> <code>code()</code>.</p>")).toBe(
15
+ "## Title\n\nPlain **bold** *italic* `code()`.\n",
16
+ );
17
+ });
18
+
19
+ it("pools loose container text into its own paragraph (flow)", () => {
20
+ expect(md("<div>loose text<p>inner</p></div>")).toBe("loose text\n\ninner\n");
21
+ });
22
+
23
+ it("renders nested lists with indentation and honors ol start", () => {
24
+ expect(md("<ul><li>one</li><li>two<ul><li>two-a</li></ul></li></ul><ol start='3'><li>three</li></ol>")).toBe(
25
+ "- one\n- two\n - two-a\n\n3. three\n",
26
+ );
27
+ });
28
+
29
+ it("fences code with its language and outsizes inner backtick runs", () => {
30
+ expect(md("<pre><code class='language-ts'>const x = `hi`;</code></pre>")).toBe("```ts\nconst x = `hi`;\n```\n");
31
+ expect(md("<pre><code>a ``` b</code></pre>")).toBe("````\na ``` b\n````\n");
32
+ });
33
+
34
+ it("breaks lines at each line element of an editor-rendered code block, keeping blank lines", () => {
35
+ const lines = "<div class='cm-line'>const a = 1;</div><div class='cm-line'><br></div><div class='cm-line'>f(a);</div>";
36
+ expect(md(`<pre><code>${lines}</code></pre>`)).toBe("```\nconst a = 1;\n\nf(a);\n```\n");
37
+ // Pretty-printed markup: the newline between line elements is not a second line break.
38
+ expect(md("<pre><code><div>a</div>\n<div>b</div></code></pre>")).toBe("```\na\nb\n```\n");
39
+ // Highlighter spans with real newlines stay as they are.
40
+ expect(md("<pre><code><span class='line'>a</span>\n<span class='line'>b</span></code></pre>")).toBe("```\na\nb\n```\n");
41
+ });
42
+
43
+ it("renders GFM tables, escaping pipes and taking th as header", () => {
44
+ expect(md("<table><thead><tr><th>A</th><th>B|C</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>")).toBe(
45
+ "| A | B\\|C |\n| --- | --- |\n| 1 | 2 |\n",
46
+ );
47
+ });
48
+
49
+ it("absolutizes links against the page URL and unwraps unlinkable ones", () => {
50
+ expect(md("<p><a href='/x'>rel</a> <a href='javascript:alert(1)'>js</a> <a href='mailto:a@b.c'>mail</a></p>", "https://ex.com/docs/")).toBe(
51
+ "[rel](https://ex.com/x) js mail\n",
52
+ );
53
+ });
54
+
55
+ it("absolutizes images and drops data: blobs to their alt text", () => {
56
+ expect(md("<p><img src='pic.png' alt='Pic'> <img src='data:image/png;base64,x' alt='blob'></p>", "https://ex.com/d/")).toBe(
57
+ "![Pic](https://ex.com/d/pic.png) blob\n",
58
+ );
59
+ });
60
+
61
+ it("prefixes every blockquote line, including inner blank ones", () => {
62
+ expect(md("<blockquote><p>one</p><p>two</p></blockquote>")).toBe("> one\n>\n> two\n");
63
+ });
64
+
65
+ it("keeps br as a line break inside a paragraph", () => {
66
+ expect(md("<p>first<br>second</p>")).toBe("first\nsecond\n");
67
+ });
68
+
69
+ it("collapses whitespace runs and decodes entities", () => {
70
+ expect(md("<p>a\n\t b &amp; c</p>")).toBe("a b & c\n");
71
+ });
72
+
73
+ it("skips script, style and their text entirely", () => {
74
+ expect(md("<p>keep</p><script>drop()</script><style>.x{}</style>")).toBe("keep\n");
75
+ });
76
+ });