# core/converters/markup.converter.ts

> 199 lines of code and 58 definitions.

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

## Definitions

- `convertMarkup` (lexical_declaration, line 114, exported)
- `withText` (lexical_declaration, line 95)
- `isEmphasized` (lexical_declaration, line 185)
- `applyTag` (lexical_declaration, line 99)
- `groupsOf` (lexical_declaration, line 202)
- `cutAtSentence` (lexical_declaration, line 150)
- `hrefOf` (lexical_declaration, line 39)
- `decodeEntities` (lexical_declaration, line 53)
- `runOf` (lexical_declaration, line 84)
- `cutAtWord` (lexical_declaration, line 142)
- `emphasize` (lexical_declaration, line 193)
- `markdownGroup` (lexical_declaration, line 216)
- `markdownOf` (lexical_declaration, line 224, exported)
- `plainText` (lexical_declaration, line 133)
- `summarizeMarkup` (lexical_declaration, line 158, exported)
- `TAG_OPEN` (lexical_declaration, line 6)
- `TAG_CLOSE` (lexical_declaration, line 7)
- `ENTITY_OPEN` (lexical_declaration, line 8)
- `ENTITY_CLOSE` (lexical_declaration, line 9)
- `CLOSING_MARK` (lexical_declaration, line 10)
- `HREF_MARK` (lexical_declaration, line 11)
- `QUOTES` (lexical_declaration, line 12)
- `KIND_BY_TAG` (lexical_declaration, line 14)
- `ENTITIES` (lexical_declaration, line 25)
- `tagName` (lexical_declaration, line 33)
- `space` (lexical_declaration, line 35)
- `at` (lexical_declaration, line 40)
- `quote` (lexical_declaration, line 45)
- `end` (lexical_declaration, line 49)
- `output` (lexical_declaration, line 54)
- `decoded` (lexical_declaration, line 59)
- `Frame` (interface_declaration, line 71)
- `Scan` (interface_declaration, line 76)
- `BREAK_RUN` (lexical_declaration, line 81)
- `TEXT_FRAME` (lexical_declaration, line 82)
- `frame` (lexical_declaration, line 85)
- `enclosed` (lexical_declaration, line 86)
- `name` (lexical_declaration, line 100)
- `kind` (lexical_declaration, line 107)
- `scan` (lexical_declaration, line 115, exported)
- `cursor` (lexical_declaration, line 116, exported)
- `open` (lexical_declaration, line 118, exported)
- `close` (lexical_declaration, line 119, exported)
- `ELLIPSIS` (lexical_declaration, line 130)
- `SENTENCE_BREAK` (lexical_declaration, line 131)
- `boundary` (lexical_declaration, line 154)
- `text` (lexical_declaration, line 159, exported)
- `MARK_BY_KIND` (lexical_declaration, line 163)
- `markdownRun` (lexical_declaration, line 169)
- `mark` (lexical_declaration, line 179)
- `EMPHASIS_MARK` (lexical_declaration, line 183)
- `plainRun` (lexical_declaration, line 189)
- `trimmed` (lexical_declaration, line 194)
- `start` (lexical_declaration, line 198)
- `groups` (lexical_declaration, line 203)
- `last` (lexical_declaration, line 205)
- `first` (lexical_declaration, line 206)
- `[first]` (lexical_declaration, line 217)

## Used by

- [core/analyzers/search.analyzer.ts](https://banes-lab.com/source/tree/core/analyzers/search.analyzer.ts.md)
- [core/matchers/search.matcher.ts](https://banes-lab.com/source/tree/core/matchers/search.matcher.ts.md)
- [presentation/renderers/text.renderer.ts](https://banes-lab.com/source/tree/presentation/renderers/text.renderer.ts.md)

## Source

```typescript
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 { LINE_BREAK } from "#configuration/constants/code.constants";

const TAG_OPEN = "<";
const TAG_CLOSE = ">";
const ENTITY_OPEN = "&";
const ENTITY_CLOSE = ";";
const CLOSING_MARK = "/";
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, TAG_OPEN + body + TAG_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(TAG_OPEN, cursor);
        const close = open === -1 ? -1 : markup.indexOf(TAG_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 EMPHASIS_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("");
};
```
