core/converters/text.converter.ts

core/converters/text.converter.ts is a file in Bane's Lab Site. 181 lines of code and 55 definitions.

import { EMPHASIS_MARK, HEADING_MARK } from "#configuration/constants/markdown.constants";
import { EXPORT_IGNORED_SELECTORS, SETTLED_TEXT_ATTRIBUTE, SPACE } from "#configuration/constants/document.constants";
import { LANGUAGE_ATTRIBUTE, LINE_BREAK } from "#configuration/constants/code.constants";
import { collapseBreaks, collapseSpaces, joinParts } from "#core/normalizers/text.normalizer";
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 BOLD = "**";
const TERM_SEPARATOR = " · ";
const CODE_SELECTOR = "code";
const CODE_TITLE_SELECTOR = ".code-title";
const BLOCK_TAGS = new Set([
    "DIV",
    "SECTION",
    "ARTICLE",
    "HEADER",
    "FOOTER",
    "NAV",
    "ASIDE",
    "UL",
    "OL",
    "DL",
    "FIGURE",
    "DETAILS",
    "SUMMARY",
    "BLOCKQUOTE",
]);

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 isComposite = function isComposite(element: Element): boolean {
    return element.children.length > 1 && [...element.childNodes].every((node) => node instanceof Element);
};

const partsLabel = function partsLabel(element: Element): string {
    return isComposite(element)
        ? [...element.children]
              .map((child) => collapseSpaces(child.textContent).trim())
              .filter((part) => part.length > 0)
              .join(TERM_SEPARATOR)
        : collapseSpaces(element.textContent).trim();
};

const headingText = function headingText(element: Element): string {
    const level = Number(element.tagName.slice(1));
    return PARAGRAPH + HEADING_MARK.repeat(level) + SPACE + partsLabel(element) + LINE_BREAK;
};

const fenceText = function fenceText(element: Element): string {
    const code = element.querySelector(CODE_SELECTOR) ?? element;
    const block = element.closest("pre");
    const language = block?.getAttribute(LANGUAGE_ATTRIBUTE) ?? "";
    const title = block?.querySelector(CODE_TITLE_SELECTOR);
    const caption = title === null || title === undefined ? "" : PARAGRAPH + childrenText(title) + LINE_BREAK;
    return caption + 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 {
    if (element.classList.contains(ICON_CLASS)) {
        return "";
    }
    const inner = childrenJoined(element);
    const trimmed = inner.trim();
    if (trimmed.length === 0) {
        return inner;
    }
    const start = inner.indexOf(trimmed);
    return inner.slice(0, start) + EMPHASIS_MARK + trimmed + EMPHASIS_MARK + inner.slice(start + trimmed.length);
};

const childrenJoined = function childrenJoined(element: Element): string {
    return joinParts(
        [...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(partsLabel).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<string, Formatter>([
    ["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 nodeText = function nodeText(node: Node): string {
    if (node.nodeType === Node.TEXT_NODE) {
        return collapseSpaces(node.textContent ?? "");
    }
    if (!(node instanceof Element)) {
        return "";
    }
    const settled = node.getAttribute(SETTLED_TEXT_ATTRIBUTE);
    if (settled !== null) {
        const formatter = FORMATTERS.get(node.tagName);
        const shown = node.cloneNode(false);
        shown.textContent = settled;
        return formatter === undefined || !(shown instanceof Element) ? settled : formatter(shown);
    }
    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) + LINE_BREAK : childrenJoined(node);
};

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);
};

export const nounFor = function nounFor(count: number, one: string, many: string): string {
    return count === 1 ? one : many;
};