core/converters/markdown.converter.ts

core/converters/markdown.converter.ts is a file in Bane's Lab Site. 126 lines of code and 26 definitions.

import { FENCE, QUOTE_MARK, TABLE_PIPE } from "#configuration/constants/markdown.constants";
import {
    bodyLines,
    bulletText,
    cellsOf,
    headingLevel,
    isRule,
    isTableRule,
    orderedText,
} from "#core/matchers/markdown.matcher";
import { LINE_BREAK } from "#configuration/constants/code.constants";
import type { MarkdownNode } from "#types/markdown.types";
import { SPACE } from "#configuration/constants/document.constants";

class Parser {
    private readonly lines: readonly string[];
    private readonly nodes: MarkdownNode[] = [];
    private paragraph: string[] = [];
    private at = 0;

    public constructor(text: string) {
        this.lines = bodyLines(text);
    }

    public parse(): MarkdownNode[] {
        while (this.at < this.lines.length) {
            this.step(this.lines[this.at] ?? "");
        }
        this.flush();
        return this.nodes;
    }

    private line(offset = 0): string {
        return this.lines[this.at + offset] ?? "";
    }

    private step(line: string): void {
        const trimmed = line.trim();
        if (trimmed.length === 0) {
            this.flush();
            this.at += 1;
            return;
        }
        if (trimmed.startsWith(FENCE)) {
            this.fence(trimmed.slice(FENCE.length).trim());
            return;
        }
        if (trimmed.startsWith(QUOTE_MARK)) {
            this.quote();
            return;
        }
        if (bulletText(line) !== null || orderedText(line) !== null) {
            this.list(orderedText(line) !== null);
            return;
        }
        if (trimmed.includes(TABLE_PIPE) && isTableRule(this.line(1))) {
            this.table();
            return;
        }
        this.simple(trimmed);
    }

    private simple(trimmed: string): void {
        const level = headingLevel(trimmed);
        if (level > 0) {
            this.push({ kind: "heading", level, text: trimmed.slice(level + 1).trim() });
        } else if (isRule(trimmed)) {
            this.push({ kind: "rule" });
        } else {
            this.paragraph.push(trimmed);
        }
        this.at += 1;
    }

    private push(node: MarkdownNode): void {
        this.flush();
        this.nodes.push(node);
    }

    private flush(): void {
        if (this.paragraph.length > 0) {
            this.nodes.push({ kind: "paragraph", text: this.paragraph.join(SPACE) });
            this.paragraph = [];
        }
    }

    private fence(language: string): void {
        const code: string[] = [];
        this.at += 1;
        while (this.at < this.lines.length && this.line().trim() !== FENCE) {
            code.push(this.line());
            this.at += 1;
        }
        this.at += 1;
        this.push({ code: code.join(LINE_BREAK), kind: "code", language });
    }

    private quote(): void {
        const held: string[] = [];
        while (this.at < this.lines.length && this.line().trim().startsWith(QUOTE_MARK)) {
            held.push(this.line().trim().slice(1).trim());
            this.at += 1;
        }
        this.push({ kind: "quote", text: held.join(SPACE) });
    }

    private list(ordered: boolean): void {
        const items: string[] = [];
        while (this.at < this.lines.length) {
            const line = this.line();
            const item = ordered ? orderedText(line) : bulletText(line);
            const continued = item === null && line.startsWith(SPACE) && line.trim().length > 0 && items.length > 0;
            if (item !== null) {
                items.push(item);
            } else if (continued) {
                items[items.length - 1] = `${items.at(-1) ?? ""}${SPACE}${line.trim()}`;
            } else {
                break;
            }
            this.at += 1;
        }
        this.push({ items, kind: "list", ordered });
    }

    private table(): void {
        const headers = cellsOf(this.line());
        const rows: string[][] = [];
        this.at += 2;
        while (this.at < this.lines.length && this.line().includes(TABLE_PIPE)) {
            rows.push(cellsOf(this.line()));
            this.at += 1;
        }
        this.push({ headers, kind: "table", rows });
    }
}

export const parseMarkdown = function parseMarkdown(text: string): MarkdownNode[] {
    return new Parser(text).parse();
};