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 = new Map([ ["strong", "strong"], ["b", "strong"], ["em", "emphasis"], ["i", "emphasis"], ["code", "code"], ["a", "link"], ["span", "strong"], ["cite", "cite"], ]); const ENTITIES: ReadonlyMap = 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 Scan { readonly href: string | null; readonly kind: RunKind; readonly runs: readonly Run[]; } const BREAK_RUN: Run = { kind: "break", text: "" }; const runOf = function runOf(scan: Scan, text: string): Run { return scan.href === null ? { kind: scan.kind, text } : { href: scan.href, kind: scan.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 { href: null, kind: "text", runs: scan.runs }; } const kind = KIND_BY_TAG.get(name); if (kind === undefined) { return withText(scan, TAG_OPEN + body + TAG_CLOSE); } return { href: kind === "link" ? hrefOf(body) : null, kind, runs: scan.runs }; }; export const convertMarkup = function convertMarkup(markup: string): readonly Run[] { let scan: Scan = { href: null, kind: "text", 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 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; }; export const summarizeMarkup = function summarizeMarkup(markup: string, fallback: string): string { const text = plainText(markup); return text.length === 0 ? fallback : cutAtWord(text, SUMMARY_LENGTH); }; const MARK_BY_KIND: ReadonlyMap = 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; }; export const markdownOf = function markdownOf(markup: string): string { return convertMarkup(markup).map(markdownRun).join(""); };