import { EXPORT_IGNORED_SELECTORS, SPACE } from "#configuration/constants/document.constants"; import { LANGUAGE_ATTRIBUTE, LINE_BREAK } from "#configuration/constants/code.constants"; import { HREF_ATTRIBUTE } from "#configuration/constants/element.constants"; const ICON_CLASS = "bi"; const PARAGRAPH = "\n\n"; const FENCE = "\n```\n"; const FENCE_OPEN = "\n```"; const BULLET = "\n- "; const CELL = " | "; const RULE = "---"; const PIPE = "|"; const HEADING_MARK = "#"; const BOLD = "**"; const TERM_SEPARATOR = " ยท "; const CODE_SELECTOR = "code"; const WHITESPACE = new Set([SPACE, LINE_BREAK, "\t", "\r"]); const MAX_BREAKS = 2; const ASCII_LIMIT = 128; const BLOCK_TAGS = new Set([ "DIV", "SECTION", "ARTICLE", "HEADER", "FOOTER", "NAV", "ASIDE", "UL", "OL", "DL", "FIGURE", "DETAILS", "SUMMARY", "BLOCKQUOTE", ]); const isLetter = function isLetter(char: string): boolean { const lower = char.toLowerCase(); return lower >= "a" && lower <= "z"; }; const isWordChar = function isWordChar(char: string): boolean { const code = char.codePointAt(0) ?? 0; return isLetter(char) || (char >= "0" && char <= "9") || code >= ASCII_LIMIT; }; const isSpaced = function isSpaced(char: string): boolean { return WHITESPACE.has(char); }; const needsSpace = function needsSpace(last: string, next: string, elements: boolean): boolean { if (isSpaced(last) || isSpaced(next)) { return false; } return elements || (isWordChar(last) && isWordChar(next)); }; const joined = function joined(parts: readonly { readonly element: boolean; readonly text: string }[]): string { let result = ""; let previous = false; for (const part of parts.filter((entry) => entry.text.length > 0)) { const last = result.at(-1) ?? SPACE; const next = part.text.at(0) ?? SPACE; result += (needsSpace(last, next, previous && part.element) ? SPACE : "") + part.text; previous = part.element; } return result; }; type Formatter = (element: Element) => string; const cellsOf = function cellsOf(row: Element): readonly string[] { return [...row.querySelectorAll("th, td")].map((cell) => cell.textContent.trim()); }; const tableText = function tableText(table: Element): string { let text = ""; const rows = [...table.querySelectorAll("tr")]; for (const [index, row] of rows.entries()) { const cells = cellsOf(row); text += LINE_BREAK + PIPE + SPACE + cells.join(CELL) + SPACE + PIPE; if (index === 0) { text += LINE_BREAK + PIPE + cells.map(() => RULE).join(PIPE) + PIPE; } } return text + LINE_BREAK; }; const headingText = function headingText(element: Element): string { const level = Number(element.tagName.slice(1)); return PARAGRAPH + HEADING_MARK.repeat(level) + SPACE + element.textContent.trim() + LINE_BREAK; }; const fenceText = function fenceText(element: Element): string { const code = element.querySelector(CODE_SELECTOR) ?? element; const language = element.closest("pre")?.getAttribute(LANGUAGE_ATTRIBUTE) ?? ""; return FENCE_OPEN + language + LINE_BREAK + code.textContent + FENCE; }; const noText = function noText(): string { return ""; }; const wrapped = function wrapped(mark: string): Formatter { return (element) => mark + element.textContent + mark; }; const codeText = function codeText(element: Element): string { return element.closest("pre") === null ? wrapped("`")(element) : fenceText(element); }; const emphasisText = function emphasisText(element: Element): string { return element.classList.contains(ICON_CLASS) ? "" : wrapped("*")(element); }; const childrenJoined = function childrenJoined(element: Element): string { return joined( [...element.childNodes].map((child) => ({ element: child instanceof Element, text: nodeText(child) })), ); }; const bulletText = function bulletText(element: Element): string { return BULLET + childrenJoined(element).trim(); }; const childrenText = function childrenText(element: Element): string { return childrenJoined(element).trim(); }; const breakText = function breakText(): string { return LINE_BREAK; }; const termText = function termText(element: Element): string { const parts = [...element.children] .map((child) => collapseSpaces(child.textContent).trim()) .filter((part) => part.length > 0); const label = parts.length === 0 ? childrenText(element) : parts.join(TERM_SEPARATOR); return PARAGRAPH + BOLD + label + BOLD; }; const blockText = function blockText(element: Element): string { return PARAGRAPH + childrenText(element); }; const linkText = function linkText(element: Element): string { const href = element.getAttribute(HREF_ATTRIBUTE) ?? ""; const label = childrenText(element); return href.length === 0 || label.length === 0 ? label : `[${label}](${href})`; }; const FORMATTERS = new Map([ ["H1", headingText], ["H2", headingText], ["H3", headingText], ["H4", headingText], ["PRE", fenceText], ["CODE", codeText], ["LI", bulletText], ["DT", termText], ["DD", blockText], ["FIGCAPTION", blockText], ["A", linkText], ["STRONG", wrapped(BOLD)], ["B", wrapped(BOLD)], ["EM", emphasisText], ["I", emphasisText], ["TABLE", tableText], ["BR", breakText], ["svg", noText], ]); const collapseSpaces = function collapseSpaces(text: string): string { let result = ""; let pending = false; for (const character of text) { if (WHITESPACE.has(character)) { pending = true; continue; } result += (pending ? SPACE : "") + character; pending = false; } return pending ? result + SPACE : result; }; const nodeText = function nodeText(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { return collapseSpaces(node.textContent ?? ""); } if (!(node instanceof Element)) { return ""; } const formatter = FORMATTERS.get(node.tagName); if (formatter !== undefined) { return formatter(node); } if (node.tagName === "P") { return PARAGRAPH + childrenJoined(node); } return BLOCK_TAGS.has(node.tagName) ? LINE_BREAK + childrenJoined(node) : childrenJoined(node); }; const collapseBreaks = function collapseBreaks(text: string): string { let result = ""; let run = 0; for (const character of text) { run = character === LINE_BREAK ? run + 1 : 0; if (character === LINE_BREAK && result.endsWith(SPACE)) { result = result.slice(0, -1); } if (run <= MAX_BREAKS && !(character === SPACE && result.endsWith(LINE_BREAK))) { result += character; } } return result.trim(); }; export const convertText = function convertText(element: Element, ignored: readonly string[]): string { const clone = element.cloneNode(true); if (!(clone instanceof Element)) { return ""; } for (const selector of ignored) { for (const removed of clone.querySelectorAll(selector)) { removed.remove(); } } return collapseBreaks(nodeText(clone)); }; export const exportText = function exportText(element: Element): string { return convertText(element, EXPORT_IGNORED_SELECTORS); };