core/converters/markup.converter.ts

core/converters/markup.converter.ts is a file in Bane's Lab Site. 197 lines of code and 54 definitions.

import { ANGLE_CLOSE, ANGLE_OPEN, CLOSING_MARK } from "#configuration/constants/syntax.constants";
import { CITE_CLOSE, CITE_OPEN } from "#configuration/constants/chapter.constants";
import type { Run, RunKind } from "#types/markup.types";
import { SPACE, SUMMARY_LENGTH } from "#configuration/constants/document.constants";
import { EMPHASIS_MARK } from "#configuration/constants/markdown.constants";
import { LINE_BREAK } from "#configuration/constants/code.constants";

const ENTITY_OPEN = "&";
const ENTITY_CLOSE = ";";
const HREF_MARK = "href=";
const QUOTES = new Set(['"', "'"]);

const KIND_BY_TAG: ReadonlyMap<string, RunKind> = new Map([
    ["strong", "strong"],
    ["b", "strong"],
    ["em", "emphasis"],
    ["i", "emphasis"],
    ["code", "code"],
    ["a", "link"],
    ["span", "strong"],
    ["cite", "cite"],
]);

const ENTITIES: ReadonlyMap<string, string> = new Map([
    ["lt", "<"],
    ["gt", ">"],
    ["amp", "&"],
    ["quot", '"'],
    ["nbsp", " "],
]);

const tagName = function tagName(body: string): string {
    const trimmed = body.startsWith(CLOSING_MARK) ? body.slice(1) : body;
    const space = trimmed.indexOf(" ");
    return (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
};

const hrefOf = function hrefOf(body: string): string | null {
    const at = body.indexOf(HREF_MARK);
    if (at === -1) {
        return null;
    }
    const start = at + HREF_MARK.length + 1;
    const quote = body.charAt(start - 1);
    if (!QUOTES.has(quote)) {
        return null;
    }
    const end = body.indexOf(quote, start);
    return end === -1 ? null : body.slice(start, end);
};

const decodeEntities = function decodeEntities(text: string): string {
    let output = "";
    let cursor = 0;
    while (cursor < text.length) {
        const open = text.indexOf(ENTITY_OPEN, cursor);
        const close = open === -1 ? -1 : text.indexOf(ENTITY_CLOSE, open);
        const decoded = close === -1 ? undefined : ENTITIES.get(text.slice(open + 1, close));
        if (open === -1 || decoded === undefined) {
            output += text.slice(cursor, open === -1 ? text.length : open + 1);
            cursor = open === -1 ? text.length : open + 1;
            continue;
        }
        output += text.slice(cursor, open) + decoded;
        cursor = close + 1;
    }
    return output;
};

interface Frame {
    readonly href: string | null;
    readonly kind: RunKind;
}

interface Scan {
    readonly frames: readonly Frame[];
    readonly runs: readonly Run[];
}

const BREAK_RUN: Run = { kind: "break", text: "" };
const TEXT_FRAME: Frame = { href: null, kind: "text" };

const runOf = function runOf(scan: Scan, text: string): Run {
    const frame = scan.frames.at(-1) ?? TEXT_FRAME;
    const enclosed = scan.frames.slice(0, -1).some((outer) => outer.kind === "emphasis");
    return {
        ...(enclosed && frame.kind !== "emphasis" ? { emphasis: true as const } : {}),
        ...(frame.href === null ? {} : { href: frame.href }),
        kind: frame.kind,
        text,
    };
};

const withText = function withText(scan: Scan, raw: string): Scan {
    return raw.length === 0 ? scan : { ...scan, runs: [...scan.runs, runOf(scan, decodeEntities(raw))] };
};

const applyTag = function applyTag(scan: Scan, body: string): Scan {
    const name = tagName(body);
    if (name === "br") {
        return { ...scan, runs: [...scan.runs, BREAK_RUN] };
    }
    if (body.startsWith(CLOSING_MARK)) {
        return { ...scan, frames: scan.frames.slice(0, -1) };
    }
    const kind = KIND_BY_TAG.get(name);
    if (kind === undefined) {
        return withText(scan, ANGLE_OPEN + body + ANGLE_CLOSE);
    }
    return { ...scan, frames: [...scan.frames, { href: kind === "link" ? hrefOf(body) : null, kind }] };
};

export const convertMarkup = function convertMarkup(markup: string): readonly Run[] {
    let scan: Scan = { frames: [], runs: [] };
    let cursor = 0;
    while (cursor < markup.length) {
        const open = markup.indexOf(ANGLE_OPEN, cursor);
        const close = open === -1 ? -1 : markup.indexOf(ANGLE_CLOSE, open);
        if (open === -1 || close === -1) {
            scan = withText(scan, markup.slice(cursor));
            break;
        }
        scan = applyTag(withText(scan, markup.slice(cursor, open)), markup.slice(open + 1, close));
        cursor = close + 1;
    }
    return scan.runs;
};

const ELLIPSIS = "…";
const SENTENCE_BREAK = ". ";

const plainText = function plainText(markup: string): string {
    return convertMarkup(markup)
        .map((run) => run.text)
        .join("")
        .split(SPACE)
        .filter((word) => word.length > 0)
        .join(SPACE);
};

const cutAtWord = function cutAtWord(text: string, limit: number): string {
    if (text.length <= limit) {
        return text;
    }
    const boundary = text.lastIndexOf(SPACE, limit);
    return (boundary === -1 ? text.slice(0, limit) : text.slice(0, boundary)) + ELLIPSIS;
};

const cutAtSentence = function cutAtSentence(text: string, limit: number): string {
    if (text.length <= limit) {
        return text;
    }
    const boundary = text.lastIndexOf(SENTENCE_BREAK, limit - 1);
    return boundary === -1 ? cutAtWord(text, limit) : text.slice(0, boundary + 1);
};

export const summarizeMarkup = function summarizeMarkup(markup: string, fallback: string): string {
    const text = plainText(markup);
    return text.length === 0 ? fallback : cutAtSentence(text, SUMMARY_LENGTH);
};

const MARK_BY_KIND: ReadonlyMap<RunKind, string> = new Map([
    ["strong", "**"],
    ["emphasis", "*"],
    ["code", "`"],
]);

const markdownRun = function markdownRun(run: Run): string {
    if (run.kind === "break") {
        return LINE_BREAK;
    }
    if (run.kind === "cite") {
        return CITE_OPEN + run.text + CITE_CLOSE;
    }
    if (run.kind === "link" && run.href !== undefined) {
        return `[${run.text}](${run.href})`;
    }
    const mark = MARK_BY_KIND.get(run.kind) ?? "";
    return mark + run.text + mark;
};

const isEmphasized = function isEmphasized(run: Run): boolean {
    return run.kind === "emphasis" || run.emphasis === true;
};

const plainRun = function plainRun(run: Run): Run {
    return run.kind === "emphasis" ? { kind: "text", text: run.text } : run;
};

const emphasize = function emphasize(inner: string): string {
    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 groupsOf = function groupsOf(runs: readonly Run[]): readonly (readonly Run[])[] {
    const groups: Run[][] = [];
    for (const run of runs) {
        const last = groups.at(-1);
        const first = last?.[0];
        if (last !== undefined && first !== undefined && isEmphasized(first) === isEmphasized(run)) {
            last.push(run);
        } else {
            groups.push([run]);
        }
    }
    return groups;
};

const markdownGroup = function markdownGroup(group: readonly Run[]): string {
    const [first] = group;
    if (first === undefined || !isEmphasized(first)) {
        return group.map(markdownRun).join("");
    }
    return emphasize(group.map(plainRun).map(markdownRun).join(""));
};

export const markdownOf = function markdownOf(markup: string): string {
    return groupsOf(convertMarkup(markup)).map(markdownGroup).join("");
};