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