import { parseHTML } from "linkedom";
import type { DefuddleResponse } from "defuddle/node";
const RAW_ID_SELECTOR_SAFE = /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/;
/** Removes schema.org scripts that Defuddle would report directly to the process console. */
function removeMalformedSchemaOrgData(document: Document): void {
for (const script of document.querySelectorAll(
'script[type="application/ld+json"]',
)) {
const jsonContent = (script.textContent || "")
.replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "")
.replace(/^\s*\s*$/, "$1")
.replace(/^\s*(\*\/|\/\*)\s*|\s*(\*\/|\/\*)\s*$/g, "")
.trim();
try {
if (JSON.parse(jsonContent) === null) script.remove();
} catch {
script.remove();
}
}
}
/**
* Replaces element IDs that are unsafe for CSS selectors and updates matching fragment links.
*
* @param document - The document whose element IDs and same-document links are normalized
*/
function normalizeSelectorUnsafeIds(document: Document): void {
const replacements = new Map();
const occupiedIds = new Set(
[...document.querySelectorAll("[id]")].map((element) => element.id),
);
let replacementIndex = 0;
for (const element of document.querySelectorAll("[id]")) {
const id = element.id;
if (!id || RAW_ID_SELECTOR_SAFE.test(id)) continue;
let replacement: string;
do {
replacement = `defuddle-safe-id-${replacementIndex++}`;
} while (occupiedIds.has(replacement));
occupiedIds.add(replacement);
if (!replacements.has(id)) replacements.set(id, replacement);
element.id = replacement;
}
if (replacements.size === 0) return;
for (const anchor of document.querySelectorAll('a[href^="#"]')) {
const href = anchor.getAttribute("href");
if (!href) continue;
const replacement = replacements.get(href.slice(1));
if (replacement) anchor.setAttribute("href", `#${replacement}`);
}
}
/**
* Extracts normalized plain text from HTML when structured Markdown extraction is unavailable.
*
* @param html - The HTML content to convert
* @returns The trimmed text content with excluded elements and excessive whitespace removed
*/
export function htmlToMarkdownFallback(html: string): string {
const { document } = parseHTML(html);
for (const element of document.querySelectorAll(
"script, style, svg, noscript, template, iframe, nav, header, footer, aside, form",
)) {
element.remove();
}
return document.body.textContent
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.replace(/[ \t]{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/**
* Runs Defuddle over an already-normalized document with Markdown extraction enabled.
*
* Defuddle can fail in two distinct ways. The common case rejects the promise
* we `await` below, which the caller's `try/catch` catches and turns into a
* fallback. The dangerous case is when Defuddle schedules a throw on a
* *detached* microtask or timer — for example, when it resolves a
* document-relative link such as `/owner/repo/releases` into
* `new URL(relative, undefined)` *after* its own promise has already resolved.
* That rejection never reaches the `await` and instead escapes as an unhandled
* rejection that bypasses the surrounding `try/catch` and crashes the calling
* harness UI. Passing the absolute `pageUrl` prevents the URL-resolution form
* of this failure, but the guard below still covers any residual detached
* rejection.
*
* To keep `extractHtmlToMarkdown` from ever propagating such a failure, this
* helper arms a scoped `unhandledRejection` listener for the lifetime of the
* call. The listener is scoped, not process-wide in effect: it only treats a
* rejection as a Defuddle failure when its message or stack mentions Defuddle,
* so unrelated rejections from other concurrent work are ignored and do not
* force a spurious fallback to the basic extractor. After Defuddle resolves we
* flush a microtask and a macrotask so any rejection Defuddle scheduled settles
* inside the armed window; a rejection observed there is re-thrown so the
* caller falls back. Deeply-nested timers in Defuddle are out of scope and would
* still surface as a logged (non-crashing) unhandled rejection.
*
* @param document - The normalized document to parse
* @param pageUrl - The absolute URL of the page, used to resolve relative links
* @returns The Defuddle result, or `undefined` when extraction must fall back
*/
async function runDefuddle(
document: Document,
pageUrl: string,
): Promise {
let escapedRejection: unknown = undefined;
let armed = true;
// Only attribute a rejection to Defuddle when it mentions Defuddle. This keeps
// the guard scoped so unrelated concurrent rejections are ignored.
const captureUnhandled = (cause: unknown): void => {
if (!armed || escapedRejection !== undefined) return;
const detail =
cause instanceof Error ? `${cause.message}\n${cause.stack ?? ""}` : String(cause);
if (/defuddle/i.test(detail)) escapedRejection = cause;
};
process.on("unhandledRejection", captureUnhandled);
try {
const { Defuddle } = await import("defuddle/node");
const result = await Defuddle(document, pageUrl, { markdown: true, useAsync: false });
// Let Defuddle's scheduled microtask/macrotask work settle so a detached
// rejection is observed by the guard instead of reaching the harness.
await Promise.resolve();
await new Promise((resolve) => setImmediate(resolve));
if (escapedRejection !== undefined) throw escapedRejection;
return result;
} catch {
return undefined;
} finally {
armed = false;
process.off("unhandledRejection", captureUnhandled);
}
}
/** Discovery links advertised by a page via `` relations. */
interface AdvertisedLinks {
describedByLink?: string;
markdownAlternateLink?: string;
}
/**
* Reads agent-discovery `` relations from an HTML document (llmstxt.org v2):
* `rel="describedby"` points at the covering `llms.txt`, and
* `rel="alternate" type="text/markdown"` points at the Markdown version of the page.
* HREFs are resolved against the page URL; absent or malformed links are omitted.
*/
function readAdvertisedLinks(
document: ReturnType["document"],
baseUrl: URL,
): AdvertisedLinks {
const resolve = (href: string | null): string | undefined => {
if (!href) return undefined;
try {
return new URL(href, baseUrl.href).href;
} catch {
return undefined;
}
};
let describedByLink: string | undefined;
let markdownAlternateLink: string | undefined;
for (const link of document.querySelectorAll("link")) {
const href = resolve(link.getAttribute("href"));
if (!href) continue;
const tokens = (link.getAttribute("rel") ?? "").toLowerCase().split(/\s+/);
if (!describedByLink && tokens.includes("describedby")) describedByLink = href;
if (
!markdownAlternateLink &&
tokens.includes("alternate") &&
(link.getAttribute("type") ?? "").toLowerCase() === "text/markdown"
) {
markdownAlternateLink = href;
}
}
return { describedByLink, markdownAlternateLink };
}
/**
* Extracts readable Markdown and an optional title from HTML.
*
* @param html - The HTML document to convert
* @param baseUrl - The absolute base URL used to resolve document-relative links
* @returns The extracted Markdown, optional title, extractor used, and any advertised
* discovery links resolved against the base URL
*/
export async function extractHtmlToMarkdown(
html: string,
baseUrl: URL,
): Promise<{
markdown: string;
title?: string;
extractor: "defuddle" | "basic";
describedByLink?: string;
markdownAlternateLink?: string;
}> {
let advertised: AdvertisedLinks = {};
try {
const { document } = parseHTML(html);
removeMalformedSchemaOrgData(document);
normalizeSelectorUnsafeIds(document);
advertised = readAdvertisedLinks(document, baseUrl);
// Pass the full absolute URL so Defuddle resolves relative links (e.g.
// `/owner/repo/releases`) and metadata against the real origin instead of
// dropping the scheme and host and constructing `new URL(pathname)`.
const result = await runDefuddle(document, baseUrl.href);
const markdown = result?.content?.trim() ?? "";
const trimmedTitle = result?.title?.trim();
if (markdown) {
return {
markdown,
title: trimmedTitle || undefined,
extractor: "defuddle",
...advertised,
};
}
} catch {
// Fall through to the basic converter for malformed or unsupported pages.
}
return { markdown: htmlToMarkdownFallback(html), extractor: "basic", ...advertised };
}