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