/**
* Dependency-free HTML → Markdown conversion, page metadata and link extraction.
*
* The converter builds a lightweight DOM from a forgiving tokenizer, optionally isolates the main
* content (readability-style density scoring), then serialises to Markdown. It is intended for
* LLM/RAG pipelines and crawling: stable, compact output rather than perfect fidelity.
*/
import { decodeEntities } from "./text";
import type { PageLink, PageMetadata } from "./schema";
// ---------------------------------------------------------------------------
// Tokenizer / DOM-lite
// ---------------------------------------------------------------------------
export interface HNode {
type: "element" | "text";
tag: string;
attrs: Record;
children: HNode[];
text: string;
parent: HNode | null;
}
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
const RAW_TEXT = new Set(["script", "style", "noscript", "template", "svg", "math", "iframe", "canvas", "object", "textarea"]);
const BLOCK = new Set([
"address", "article", "aside", "blockquote", "body", "center", "dd", "details", "dialog", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
"header", "hr", "html", "li", "main", "nav", "ol", "p", "pre", "section", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul",
]);
/** Elements whose content is closed implicitly by a new block start. */
const AUTO_CLOSE: Record> = {
p: BLOCK,
li: new Set(["li"]),
dt: new Set(["dt", "dd"]),
dd: new Set(["dt", "dd"]),
tr: new Set(["tr"]),
td: new Set(["td", "th", "tr"]),
th: new Set(["td", "th", "tr"]),
option: new Set(["option"]),
};
function makeNode(type: HNode["type"], tag: string, parent: HNode | null): HNode {
return { type, tag, attrs: {}, children: [], text: "", parent };
}
const ATTR_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
export function parseHtml(html: string): HNode {
const root = makeNode("element", "#root", null);
let cur = root;
let i = 0;
const n = html.length;
const pushText = (t: string) => {
if (!t) return;
const node = makeNode("text", "#text", cur);
node.text = t;
cur.children.push(node);
};
while (i < n) {
const lt = html.indexOf("<", i);
if (lt === -1) {
pushText(html.slice(i));
break;
}
if (lt > i) pushText(html.slice(i, lt));
if (html.startsWith("", lt + 4);
i = end === -1 ? n : end + 3;
continue;
}
if (html.startsWith("", lt);
i = end === -1 ? n : end + 3;
continue;
}
if (html[lt + 1] === "!" || html[lt + 1] === "?") {
const end = html.indexOf(">", lt);
i = end === -1 ? n : end + 1;
continue;
}
const gt = findTagEnd(html, lt);
if (gt === -1) {
pushText(html.slice(lt));
break;
}
const raw = html.slice(lt + 1, gt);
i = gt + 1;
if (raw.startsWith("/")) {
const tag = raw.slice(1).trim().toLowerCase().split(/\s/)[0]!;
// close up to matching open element
let p: HNode | null = cur;
while (p && p !== root && p.tag !== tag) p = p.parent;
if (p && p !== root) cur = p.parent ?? root;
continue;
}
const m = raw.match(/^([a-zA-Z][a-zA-Z0-9:-]*)/);
if (!m) {
pushText("<" + raw + ">");
continue;
}
const tag = m[1]!.toLowerCase();
const selfClosing = raw.endsWith("/");
const attrs: Record = {};
const attrStr = raw.slice(m[0].length, selfClosing ? -1 : undefined);
for (const am of attrStr.matchAll(ATTR_RE)) {
const k = am[1]!.toLowerCase();
attrs[k] = decodeEntities(am[2] ?? am[3] ?? am[4] ?? "");
}
// implicit closes
let p: HNode | null = cur;
while (p && p !== root) {
const ac = AUTO_CLOSE[p.tag];
if (ac && ac.has(tag)) {
cur = p.parent ?? root;
break;
}
if (p.tag === "p" && BLOCK.has(tag)) {
cur = p.parent ?? root;
break;
}
p = p.parent;
}
const node = makeNode("element", tag, cur);
node.attrs = attrs;
cur.children.push(node);
if (RAW_TEXT.has(tag) && !selfClosing) {
const close = html.toLowerCase().indexOf(`${tag}`, i);
const end = close === -1 ? n : close;
const t = makeNode("text", "#text", node);
t.text = html.slice(i, end);
node.children.push(t);
i = close === -1 ? n : html.indexOf(">", close) + 1 || n;
continue;
}
if (!VOID.has(tag) && !selfClosing) cur = node;
}
return root;
}
function findTagEnd(html: string, from: number): number {
let q: string | null = null;
for (let j = from + 1; j < html.length; j++) {
const c = html[j]!;
if (q) {
if (c === q) q = null;
} else if (c === '"' || c === "'") {
// only treat as quote when inside attribute area (after a space or =)
const prev = html[j - 1];
if (prev === "=" || prev === " " || prev === "\t" || prev === "\n") q = c;
} else if (c === ">") return j;
else if (c === "<" && j > from + 1) return -1 + 0 * j; // malformed: give up on this tag
}
return -1;
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export function walk(node: HNode, fn: (n: HNode) => boolean | void): void {
if (fn(node) === false) return;
for (const c of node.children) walk(c, fn);
}
export function findAll(root: HNode, pred: (n: HNode) => boolean): HNode[] {
const out: HNode[] = [];
walk(root, (n) => {
if (n.type === "element" && pred(n)) out.push(n);
});
return out;
}
export function textOf(node: HNode): string {
let s = "";
walk(node, (n) => {
if (n.type === "element" && RAW_TEXT.has(n.tag)) return false;
if (n.type === "text") s += n.text;
});
return decodeEntities(s).replace(/\s+/g, " ").trim();
}
// ---------------------------------------------------------------------------
// Main content extraction (readability-lite)
// ---------------------------------------------------------------------------
const NOISE_TAGS = new Set(["nav", "header", "footer", "aside", "form", "button", "select", "input", "label", "dialog", "menu"]);
const NOISE_RE = /(^|[\s_-])(nav|menu|sidebar|side-bar|footer|header|banner|cookie|consent|gdpr|modal|popup|newsletter|subscribe|social|share|sharing|related|recommend|promo|advert|ad-|ads|sponsor|breadcrumb|comment|widget|toolbar|skip|masthead|sitemap|legal|copyright|login|signup|search)([\s_-]|$)/i;
const CONTENT_RE = /(^|[\s_-])(article|content|main|post|entry|body|story|text|blog|product|description|prose|markdown|documentation|docs)([\s_-]|$)/i;
function classId(n: HNode): string {
return `${n.attrs["class"] ?? ""} ${n.attrs["id"] ?? ""}`;
}
export function isNoise(n: HNode): boolean {
if (n.type !== "element") return false;
if (RAW_TEXT.has(n.tag)) return true;
if (NOISE_TAGS.has(n.tag)) return true;
if (n.attrs["hidden"] !== undefined || /display\s*:\s*none/i.test(n.attrs["style"] ?? "")) return true;
if (n.attrs["aria-hidden"] === "true") return true;
const role = n.attrs["role"];
if (role && /navigation|banner|contentinfo|complementary|dialog|search|menu/i.test(role)) return true;
if (n.tag === "div" || n.tag === "section" || n.tag === "ul" || n.tag === "span") {
const ci = classId(n);
if (NOISE_RE.test(ci) && !CONTENT_RE.test(ci)) return true;
}
return false;
}
/** Pick the element that most likely holds the primary content. Falls back to /root. */
export function mainContent(root: HNode): HNode {
const explicit = findAll(root, (n) => n.tag === "article" || n.tag === "main" || n.attrs["role"] === "main" || n.attrs["itemprop"] === "articleBody");
const body = findAll(root, (n) => n.tag === "body")[0] ?? root;
const scored: Array<{ node: HNode; score: number }> = [];
const candidates = explicit.length ? explicit : findAll(body, (n) => ["div", "section", "td", "article", "main"].includes(n.tag));
const bodyLen = Math.max(1, textOf(body).length);
for (const c of candidates) {
if (isNoise(c)) continue;
const text = textOf(c);
if (text.length < 200) continue;
const paragraphs = findAll(c, (n) => n.tag === "p" || n.tag === "h1" || n.tag === "h2" || n.tag === "h3" || n.tag === "li" || n.tag === "pre").length;
const links = findAll(c, (n) => n.tag === "a").reduce((s, a) => s + textOf(a).length, 0);
const linkDensity = links / Math.max(1, text.length);
let score = text.length / bodyLen + Math.min(paragraphs, 40) / 40;
score *= 1 - Math.min(0.9, linkDensity);
if (c.tag === "article" || c.tag === "main" || c.attrs["role"] === "main") score *= 1.6;
if (CONTENT_RE.test(classId(c))) score *= 1.2;
scored.push({ node: c, score });
}
if (!scored.length) return body;
scored.sort((a, b) => b.score - a.score);
const best = scored[0]!;
// Guard against choosing a tiny fragment: require the pick to hold ≥ 25% of the body text.
if (textOf(best.node).length < bodyLen * 0.25 && !explicit.length) return body;
return best.node;
}
// ---------------------------------------------------------------------------
// Markdown serialisation
// ---------------------------------------------------------------------------
export interface MarkdownOptions {
baseUrl?: string;
/** Isolate the main content (default true). */
mainContent?: boolean;
/** Keep images as  (default true). */
images?: boolean;
/** Keep hyperlinks as [text](href) (default true); false renders plain text. */
links?: boolean;
/** Drop navigation/footers/asides etc. even inside the main content (default true). */
stripNoise?: boolean;
/** Maximum output length in characters (default 2,000,000). */
maxLength?: number;
}
function absolutize(href: string | undefined, base?: string): string | null {
if (!href) return null;
const h = href.trim();
if (!h || h.startsWith("javascript:") || h.startsWith("data:") || h.startsWith("mailto:") || h.startsWith("tel:") || h === "#") return h.startsWith("mailto:") || h.startsWith("tel:") ? h : null;
try {
return base ? new URL(h, base).toString() : new URL(h).toString();
} catch {
return null;
}
}
function esc(s: string): string {
return s.replace(/([\\`*_{}[\]<>])/g, "\\$1");
}
type WriterOpts = Required> & { baseUrl?: string; /** Text length of the rendered scope, used to keep "noise" landmarks that actually hold the content (e.g. a