{"version":3,"file":"markdown-to-html.d.ts","sourceRoot":"","sources":["../../src/utils/markdown-to-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAuFH;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA+HvD","sourcesContent":["/**\n * Markdown to the HTML a word processor will take.\n *\n * ## Why this exists at all\n *\n * The transcript on screen is *rendered* markdown: a table is box-drawing\n * characters, a code block is a bordered panel, a heading is a coloured chip, a\n * long line is hard-wrapped to the terminal's width. Selecting that with the\n * mouse and pasting it into Word or Confluence pastes exactly what it looks\n * like — dotted rules, broken table edges, wrapping frozen at eighty columns —\n * because the terminal's clipboard carries glyphs and nothing else.\n *\n * What the user wanted to copy is the *structure*, and the structure is still\n * in the markdown the model wrote. So the copy path goes back to that source\n * and offers it twice: the markdown itself as plain text, and this, as HTML,\n * for anything that knows how to take a rich paste. Word turns `<h2>` into a\n * heading and `<table>` into a table; Confluence does the same; a plain text\n * field gets the markdown and is none the wiser.\n *\n * ## What it is not\n *\n * Not a markdown implementation. It is the subset an agent transcript actually\n * contains — headings, emphasis, code, links, lists, quotes, rules and GFM\n * tables — rendered as the plainest HTML that survives a paste. Anything it\n * does not recognise comes through as text, which is the failure a reader can\n * still work with. There is no HTML passthrough: source markdown is untrusted\n * input as far as this is concerned, every `<` is escaped, and a model that\n * wrote `<script>` gets `&lt;script&gt;` pasted into the document.\n *\n * ## Why the styling is inline\n *\n * A clipboard fragment carries no stylesheet. Word keeps inline `style`\n * attributes and drops everything else, so the handful of rules that make code\n * read as code and a table read as a table ride on the elements themselves.\n */\n\n/** Escape the five characters that are markup, so text stays text. */\nfunction escapeHtml(text: string): string {\n\treturn text\n\t\t.replace(/&/g, \"&amp;\")\n\t\t.replace(/</g, \"&lt;\")\n\t\t.replace(/>/g, \"&gt;\")\n\t\t.replace(/\"/g, \"&quot;\")\n\t\t.replace(/'/g, \"&#39;\");\n}\n\n/** The styling that has to survive a paste, so it travels on the element. */\nconst STYLE = {\n\tcode: 'style=\"font-family:Consolas,Menlo,monospace;background:#f4f4f4;padding:1px 4px;border-radius:3px\"',\n\tpre: 'style=\"font-family:Consolas,Menlo,monospace;background:#f4f4f4;padding:10px;border-radius:4px;white-space:pre-wrap\"',\n\ttable: 'style=\"border-collapse:collapse\"',\n\tcell: 'style=\"border:1px solid #bbb;padding:4px 8px;text-align:left\"',\n\tquote: 'style=\"border-left:3px solid #bbb;margin:0 0 0 8px;padding-left:10px;color:#555\"',\n} as const;\n\n/**\n * Inline markup, innermost first.\n *\n * Code spans are taken out before anything else and put back at the end:\n * whatever is inside one is literal, and an asterisk in a shell snippet is not\n * an emphasis marker. Everything between is ordinary text and is escaped before\n * a single tag is introduced, so no pattern below can ever match markup this\n * function itself produced.\n */\nfunction renderInline(markdown: string): string {\n\tconst codeSpans: string[] = [];\n\t// A placeholder no markdown can contain and no pattern below can match.\n\tconst stash = (html: string): string => `\u0000${codeSpans.push(html) - 1}\u0000`;\n\n\tlet text = markdown.replace(/(`+)([^`]|[^`][\\s\\S]*?[^`])\\1(?!`)/g, (_all, _ticks, code: string) =>\n\t\tstash(`<code ${STYLE.code}>${escapeHtml(code.trim())}</code>`),\n\t);\n\n\ttext = escapeHtml(text);\n\t// Images before links: the syntax is a link with a bang, and the link\n\t// pattern would otherwise eat the `[...]` and leave the `!` stranded.\n\ttext = text.replace(\n\t\t/!\\[([^\\]]*)\\]\\(([^)\\s]+)(?:\\s+&quot;[^&]*&quot;)?\\)/g,\n\t\t(_all, alt: string, src: string) => `<img src=\"${src}\" alt=\"${alt}\">`,\n\t);\n\ttext = text.replace(\n\t\t/\\[([^\\]]+)\\]\\(([^)\\s]+)(?:\\s+&quot;[^&]*&quot;)?\\)/g,\n\t\t(_all, label: string, href: string) => `<a href=\"${href}\">${label}</a>`,\n\t);\n\t// A bare URL on its own is a link too — agents write them constantly, and a\n\t// URL that is not clickable in the pasted document is a URL retyped by hand.\n\ttext = text.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<>()]+)/g, (_all, lead: string, url: string) => {\n\t\tconst trailing = url.match(/[.,;:!?]+$/)?.[0] ?? \"\";\n\t\tconst bare = url.slice(0, url.length - trailing.length);\n\t\treturn `${lead}<a href=\"${bare}\">${bare}</a>${trailing}`;\n\t});\n\ttext = text.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, \"<strong><em>$1</em></strong>\");\n\ttext = text.replace(/\\*\\*([^*]+)\\*\\*/g, \"<strong>$1</strong>\");\n\ttext = text.replace(/(^|[^*\\w])\\*([^*\\n]+)\\*(?![*\\w])/g, \"$1<em>$2</em>\");\n\ttext = text.replace(/(^|[^_\\w])_([^_\\n]+)_(?![_\\w])/g, \"$1<em>$2</em>\");\n\ttext = text.replace(/~~([^~]+)~~/g, \"<del>$1</del>\");\n\n\treturn text.replace(/\u0000(\\d+)\u0000/g, (_all, index: string) => codeSpans[Number(index)]);\n}\n\n/** One row of a GFM table, split on the pipes that are not escaped. */\nfunction splitRow(line: string): string[] {\n\treturn line\n\t\t.replace(/^\\s*\\|/, \"\")\n\t\t.replace(/\\|\\s*$/, \"\")\n\t\t.split(/(?<!\\\\)\\|/)\n\t\t.map((cell) => cell.replace(/\\\\\\|/g, \"|\").trim());\n}\n\n/** Whether `line` is the `|---|:--:|` rule that makes the row above a header. */\nfunction isTableRule(line: string | undefined): boolean {\n\treturn line !== undefined && /^\\s*\\|?[\\s:|-]+\\|[\\s:|-]*$/.test(line) && line.includes(\"-\");\n}\n\n/** A list item's marker, if the line opens one. */\nfunction listMarker(line: string): { indent: number; ordered: boolean; content: string } | undefined {\n\tconst match = line.match(/^(\\s*)([-*+]|\\d+[.)])\\s+(.*)$/);\n\tif (!match) return undefined;\n\treturn { indent: match[1].length, ordered: /\\d/.test(match[2]), content: match[3] };\n}\n\n/**\n * Render a markdown document as an HTML fragment (no `<html>`, no `<body>`).\n *\n * A fragment rather than a document because that is what a clipboard carries:\n * the platform wraps it in whatever envelope it needs (CF_HTML on Windows,\n * `«class HTML»` on macOS), and a full document inside that envelope is a\n * second `<body>` for the receiving application to make sense of.\n */\nexport function markdownToHtml(markdown: string): string {\n\tconst lines = markdown.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n\tconst html: string[] = [];\n\t/** Open list elements, outermost first, so nesting closes in order. */\n\tconst lists: { indent: number; tag: \"ul\" | \"ol\" }[] = [];\n\tlet paragraph: string[] = [];\n\n\tconst closeLists = (toIndent = -1): void => {\n\t\twhile (lists.length > 0 && lists[lists.length - 1].indent > toIndent) {\n\t\t\thtml.push(`</${lists.pop()?.tag}>`);\n\t\t}\n\t};\n\tconst flushParagraph = (): void => {\n\t\tif (paragraph.length === 0) return;\n\t\t// A single newline inside a paragraph is a line the author broke on\n\t\t// purpose often enough — an address, a list of names — that keeping it\n\t\t// costs less than losing it.\n\t\thtml.push(`<p>${paragraph.map(renderInline).join(\"<br>\")}</p>`);\n\t\tparagraph = [];\n\t};\n\tconst flush = (): void => {\n\t\tflushParagraph();\n\t\tcloseLists();\n\t};\n\n\tfor (let i = 0; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\n\t\t// Fenced code: taken whole, contents never interpreted.\n\t\tconst fence = line.match(/^\\s*(```+|~~~+)(.*)$/);\n\t\tif (fence) {\n\t\t\tflush();\n\t\t\tconst closing = fence[1][0].repeat(3);\n\t\t\tconst body: string[] = [];\n\t\t\ti += 1;\n\t\t\twhile (i < lines.length && !lines[i].trimStart().startsWith(closing)) {\n\t\t\t\tbody.push(lines[i]);\n\t\t\t\ti += 1;\n\t\t\t}\n\t\t\thtml.push(`<pre ${STYLE.pre}><code>${escapeHtml(body.join(\"\\n\"))}</code></pre>`);\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (line.trim() === \"\") {\n\t\t\tflushParagraph();\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst heading = line.match(/^(#{1,6})\\s+(.*)$/);\n\t\tif (heading) {\n\t\t\tflush();\n\t\t\tconst level = heading[1].length;\n\t\t\thtml.push(`<h${level}>${renderInline(heading[2].replace(/\\s+#+\\s*$/, \"\"))}</h${level}>`);\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (/^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$/.test(line)) {\n\t\t\tflush();\n\t\t\thtml.push(\"<hr>\");\n\t\t\tcontinue;\n\t\t}\n\n\t\t// A table is only a table with its rule: without one those pipes are\n\t\t// prose, and prose promoted to a table is unreadable in both forms.\n\t\tif (line.includes(\"|\") && isTableRule(lines[i + 1])) {\n\t\t\tflush();\n\t\t\tconst header = splitRow(line);\n\t\t\tconst rows: string[][] = [];\n\t\t\ti += 2;\n\t\t\twhile (i < lines.length && lines[i].includes(\"|\") && lines[i].trim() !== \"\") {\n\t\t\t\trows.push(splitRow(lines[i]));\n\t\t\t\ti += 1;\n\t\t\t}\n\t\t\ti -= 1;\n\t\t\tconst cells = (row: string[], tag: \"th\" | \"td\"): string =>\n\t\t\t\trow.map((cell) => `<${tag} ${STYLE.cell}>${renderInline(cell)}</${tag}>`).join(\"\");\n\t\t\thtml.push(\n\t\t\t\t`<table ${STYLE.table}><thead><tr>${cells(header, \"th\")}</tr></thead><tbody>` +\n\t\t\t\t\t`${rows.map((row) => `<tr>${cells(row, \"td\")}</tr>`).join(\"\")}</tbody></table>`,\n\t\t\t);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst quote = line.match(/^\\s*>\\s?(.*)$/);\n\t\tif (quote) {\n\t\t\tflush();\n\t\t\tconst body = [quote[1]];\n\t\t\twhile (i + 1 < lines.length && /^\\s*>/.test(lines[i + 1])) {\n\t\t\t\tbody.push(lines[i + 1].replace(/^\\s*>\\s?/, \"\"));\n\t\t\t\ti += 1;\n\t\t\t}\n\t\t\thtml.push(`<blockquote ${STYLE.quote}>${markdownToHtml(body.join(\"\\n\"))}</blockquote>`);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst item = listMarker(line);\n\t\tif (item) {\n\t\t\tflushParagraph();\n\t\t\tconst tag = item.ordered ? \"ol\" : \"ul\";\n\t\t\tconst open = lists[lists.length - 1];\n\t\t\tif (!open || item.indent > open.indent) {\n\t\t\t\tlists.push({ indent: item.indent, tag });\n\t\t\t\thtml.push(`<${tag}>`);\n\t\t\t} else {\n\t\t\t\tcloseLists(item.indent);\n\t\t\t\tconst current = lists[lists.length - 1];\n\t\t\t\tif (!current) {\n\t\t\t\t\tlists.push({ indent: item.indent, tag });\n\t\t\t\t\thtml.push(`<${tag}>`);\n\t\t\t\t} else if (current.tag !== tag) {\n\t\t\t\t\t// The marker changed at the same depth: one list ended and\n\t\t\t\t\t// another began, which is what the author meant by changing it.\n\t\t\t\t\thtml.push(`</${current.tag}>`);\n\t\t\t\t\tlists[lists.length - 1] = { indent: item.indent, tag };\n\t\t\t\t\thtml.push(`<${tag}>`);\n\t\t\t\t}\n\t\t\t}\n\t\t\thtml.push(`<li>${renderInline(item.content)}</li>`);\n\t\t\tcontinue;\n\t\t}\n\n\t\tcloseLists();\n\t\tparagraph.push(line.trim());\n\t}\n\n\tflush();\n\treturn html.join(\"\\n\");\n}\n"]}