# core/converters/markdown.converter.ts

> 204 lines of code and 44 definitions.

Tree: Site tree
Language: typescript
Layer: processing
Canonical: https://banes-lab.com/anatomy/tree#file-core-converters-markdown-converter-ts
Source text: https://banes-lab.com/assets/sources/source.43e2aca878c5259585e69fed24fdbcda3192c101863eea30025ffb90349c99e9.generated.txt

## Definitions

- `orderedText` (lexical_declaration, line 53)
- `isTableRule` (lexical_declaration, line 70)
- `charsOf` (lexical_declaration, line 21)
- `isRule` (lexical_declaration, line 29)
- `bulletText` (lexical_declaration, line 48)
- `cellsOf` (lexical_declaration, line 63)
- `bodyLines` (lexical_declaration, line 93)
- `parseMarkdown` (lexical_declaration, line 224, exported)
- `step` (method_definition, line 124)
- `constructor` (method_definition, line 108)
- `simple` (method_definition, line 150)
- `table` (method_definition, line 212)
- `chars` (lexical_declaration, line 30)
- `[first]` (lexical_declaration, line 31)
- `headingLevel` (lexical_declaration, line 40)
- `digits` (lexical_declaration, line 55)
- `closes` (lexical_declaration, line 59)
- `inner` (lexical_declaration, line 65)
- `body` (lexical_declaration, line 66)
- `uncommented` (lexical_declaration, line 78)
- `out` (lexical_declaration, line 79)
- `open` (lexical_declaration, line 82)
- `close` (lexical_declaration, line 98)
- `Parser` (class_declaration, line 102)
- `lines` (public_field_definition, line 103)
- `nodes` (public_field_definition, line 104)
- `paragraph` (public_field_definition, line 105)
- `at` (public_field_definition, line 106)
- `parse` (method_definition, line 112)
- `trimmed` (lexical_declaration, line 125)
- `level` (lexical_declaration, line 151)
- `push` (method_definition, line 162)
- `flush` (method_definition, line 167)
- `fence` (method_definition, line 174)
- `code` (lexical_declaration, line 175)
- `quote` (method_definition, line 185)
- `held` (lexical_declaration, line 186)
- `list` (method_definition, line 194)
- `items` (lexical_declaration, line 195)
- `line` (lexical_declaration, line 197)
- `item` (lexical_declaration, line 198)
- `continued` (lexical_declaration, line 199)
- `headers` (lexical_declaration, line 213)
- `rows` (lexical_declaration, line 214)

## Used by

- [presentation/renderers/markdown.renderer.ts](https://banes-lab.com/source/tree/presentation/renderers/markdown.renderer.ts.md)

## Source

```typescript
import {
    BULLET_MARKS,
    COMMENT_CLOSE,
    COMMENT_OPEN,
    FENCE,
    FRONTMATTER_FENCE,
    HEADING_MARK,
    HEADING_MAX_LEVEL,
    ORDERED_CLOSE,
    QUOTE_MARK,
    RULE_CHARS,
    RULE_MIN_LENGTH,
    TABLE_PIPE,
    TABLE_RULE_CHARS,
} from "#configuration/constants/markdown.constants";
import { DIGITS } from "#configuration/constants/syntax.constants";
import { LINE_BREAK } from "#configuration/constants/code.constants";
import type { MarkdownNode } from "#types/markdown.types";
import { SPACE } from "#configuration/constants/document.constants";

const charsOf = function charsOf(text: string): string[] {
    const chars: string[] = [];
    for (const char of text) {
        chars.push(char);
    }
    return chars;
};

const isRule = function isRule(line: string): boolean {
    const chars = charsOf(line.trim()).filter((char) => char !== SPACE);
    const [first] = chars;
    return (
        chars.length >= RULE_MIN_LENGTH &&
        first !== undefined &&
        RULE_CHARS.has(first) &&
        chars.every((char) => char === first)
    );
};

const headingLevel = function headingLevel(line: string): number {
    let level = 0;
    while (level < line.length && line.charAt(level) === HEADING_MARK) {
        level += 1;
    }
    return level > 0 && level <= HEADING_MAX_LEVEL && line.charAt(level) === SPACE ? level : 0;
};

const bulletText = function bulletText(line: string): string | null {
    const trimmed = line.trim();
    return BULLET_MARKS.has(trimmed.charAt(0)) && trimmed.charAt(1) === SPACE ? trimmed.slice(2) : null;
};

const orderedText = function orderedText(line: string): string | null {
    const trimmed = line.trim();
    let digits = 0;
    while (digits < trimmed.length && DIGITS.includes(trimmed.charAt(digits))) {
        digits += 1;
    }
    const closes = trimmed.charAt(digits) === ORDERED_CLOSE && trimmed.charAt(digits + 1) === SPACE;
    return digits > 0 && closes ? trimmed.slice(digits + 2) : null;
};

const cellsOf = function cellsOf(line: string): string[] {
    const trimmed = line.trim();
    const inner = trimmed.startsWith(TABLE_PIPE) ? trimmed.slice(1) : trimmed;
    const body = inner.endsWith(TABLE_PIPE) ? inner.slice(0, -1) : inner;
    return body.split(TABLE_PIPE).map((cell) => cell.trim());
};

const isTableRule = function isTableRule(line: string): boolean {
    const trimmed = line.trim();
    return (
        trimmed.includes(TABLE_PIPE) &&
        charsOf(trimmed).every((char) => char === TABLE_PIPE || TABLE_RULE_CHARS.has(char))
    );
};

const uncommented = function uncommented(text: string): string {
    let out = "";
    let at = 0;
    while (at < text.length) {
        const open = text.indexOf(COMMENT_OPEN, at);
        if (open === -1) {
            return out + text.slice(at);
        }
        const close = text.indexOf(COMMENT_CLOSE, open + COMMENT_OPEN.length);
        out += text.slice(at, open);
        at = close === -1 ? text.length : close + COMMENT_CLOSE.length;
    }
    return out;
};

const bodyLines = function bodyLines(text: string): string[] {
    const lines = uncommented(text).split(LINE_BREAK);
    if (lines[0]?.trim() !== FRONTMATTER_FENCE) {
        return lines;
    }
    const close = lines.findIndex((line, index) => index > 0 && line.trim() === FRONTMATTER_FENCE);
    return close === -1 ? lines : lines.slice(close + 1);
};

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