diff --git a/src/markdown/markdown-render.css b/src/markdown/markdown-render.css
new file mode 100644
index 0000000..d8e004d
--- /dev/null
+++ b/src/markdown/markdown-render.css
@@ -0,0 +1,262 @@
+:root {
+ /* Core tokens */
+ --md-border-color: #e0e0e0;
+ --md-surface: #f8f8f8;
+ --md-surface-alt: #fdfdfd;
+ --md-radius: 12px;
+
+ --md-text-color: #111;
+ --md-muted-color: #555;
+ --md-link-color: #0a66c2;
+ --md-link-hover-color: #004182;
+
+ --md-code-bg: #fdfdfd;
+ --md-code-inline-bg: #f3f3f3;
+
+ --md-code-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ "Liberation Mono", "Courier New", monospace;
+}
+
+/* Optional wrapper if you choose to wrap markdown output:
+
*/
+.md-root {
+ color: var(--md-text-color);
+ font-size: 1rem;
+ line-height: 1.6;
+}
+
+/* Headings */
+.md-heading {
+ margin: 1.5rem 0 0.75rem;
+ font-weight: 600;
+ line-height: 1.25;
+ color: var(--md-text-color);
+}
+
+.md-heading--1 {
+ font-size: 1.8rem;
+}
+
+.md-heading--2 {
+ font-size: 1.5rem;
+}
+
+.md-heading--3 {
+ font-size: 1.25rem;
+}
+
+.md-heading--4 {
+ font-size: 1.1rem;
+}
+
+/* Horizontal rule */
+.md-hr {
+ border: 0;
+ border-top: 1px solid var(--md-border-color);
+ margin: 1.5rem 0;
+}
+
+/* Lists */
+.md-list {
+ padding-left: 1.4rem;
+ margin: 0.5rem 0 1rem;
+}
+
+.md-list--unordered {
+ list-style: disc;
+}
+
+.md-list--ordered {
+ list-style: decimal;
+}
+
+.md-list__item {
+ margin: 0.2rem 0;
+}
+
+/* Tables */
+.md-table {
+ border-collapse: separate;
+ border-spacing: 0;
+ width: 100%;
+ margin: 1rem 0;
+ border: 1px solid var(--md-border-color);
+ border-radius: var(--md-radius);
+ overflow: hidden;
+}
+
+.md-table thead {
+ background: var(--md-surface);
+}
+
+.md-table__row {
+}
+
+.md-table__row--head {
+}
+
+.md-table__head-cell,
+.md-table__cell {
+ padding: 0.6rem 0.75rem;
+ vertical-align: top;
+ text-align: left;
+ border-bottom: 1px solid var(--md-border-color);
+}
+
+.md-table__row:last-child .md-table__cell {
+ border-bottom: none;
+}
+
+.md-align-left {
+ text-align: left;
+}
+
+.md-align-center {
+ text-align: center;
+}
+
+.md-align-right {
+ text-align: right;
+}
+
+/* Blockquotes */
+.md-blockquote {
+ margin: 1rem 0;
+ padding: 0.75rem 1rem;
+ border-left: 3px solid var(--md-border-color);
+ background: var(--md-surface);
+ color: var(--md-muted-color);
+}
+
+/* Code blocks */
+.md-codeblock {
+ margin: 1rem 0;
+ border: 1px solid var(--md-border-color);
+ border-radius: var(--md-radius);
+ overflow: hidden;
+ background: #fff;
+}
+
+.md-codeblock__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--md-surface);
+ border-bottom: 1px solid var(--md-border-color);
+}
+
+.md-codeblock__lang {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--md-text-color);
+}
+
+.md-codeblock__copy {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--md-border-color);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--md-text-color);
+ cursor: pointer;
+}
+
+.md-codeblock__copy:hover {
+ background: #f3f3f3;
+}
+
+.md-codeblock__copy:active {
+ transform: translateY(1px);
+}
+
+.md-icon {
+ stroke: currentColor;
+ fill: none;
+ flex-shrink: 0;
+}
+
+/* Could be specialized later if needed */
+.md-icon-external {
+}
+
+.md-icon-copy {
+}
+
+.md-codeblock__pre {
+ margin: 0;
+ padding: 0.75rem;
+ border: 0;
+ overflow: auto;
+ max-width: 100%;
+ background: var(--md-code-bg);
+}
+
+.md-codeblock__code {
+ display: block;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ max-width: 100%;
+ font-family: var(--md-code-font);
+ font-size: 0.95em;
+}
+
+/* Inline code (outside of fenced blocks) */
+code:not(.md-codeblock__code) {
+ font-family: var(--md-code-font);
+ font-size: 0.95em;
+ padding: 0.1em 0.25em;
+ border-radius: 4px;
+ background: var(--md-code-inline-bg);
+}
+
+/* Links */
+.md-link {
+ color: var(--md-link-color);
+ text-decoration: underline;
+}
+
+.md-link:hover,
+.md-link:focus-visible {
+ color: var(--md-link-hover-color);
+}
+
+.md-link--external {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.md-link__label {
+ /* can be customized if needed */
+}
+
+.md-link__tooltip {
+ position: absolute;
+ left: 0;
+ top: 100%;
+ margin-top: 0.35rem;
+ padding: 0.35rem 0.5rem;
+ background: #111;
+ color: #fff;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ white-space: nowrap;
+ opacity: 0;
+ transform: translateY(4px);
+ transition: opacity 120ms ease, transform 120ms ease;
+ pointer-events: none;
+ z-index: 10;
+}
+
+.md-link--external:hover .md-link__tooltip,
+.md-link--external:focus-visible .md-link__tooltip {
+ opacity: 1;
+ transform: translateY(0);
+}
\ No newline at end of file
diff --git a/src/markdown/markdown.js b/src/markdown/markdown.js
new file mode 100644
index 0000000..c5ab6bc
--- /dev/null
+++ b/src/markdown/markdown.js
@@ -0,0 +1,275 @@
+export function markdownToHTML(text) {
+ // 0) Remove
.../
... blocks
+ text = text.replace(/
[\s\S]*?(?:<\/think(?:ing)?>|$)/gi, '');
+
+ // Normalize exotic spaces (narrow/non-breaking) to regular spaces
+ text = text.replace(/[\u00a0\u202f\u2007]/g, ' ');
+
+ text = balanceStreamingCodeFence(text);
+
+ const escapeHtml = (value = '') =>
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+
+ const escapeAttr = (value = '') =>
+ escapeHtml(value)
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ const applyInline = (source) => {
+ const codeRuns = [];
+ let tmp = source.replace(/`([^`]+?)`/g, (_, code) => {
+ const idx = codeRuns.push(code) - 1;
+ return `@@CODEINLINE${idx}@@`;
+ });
+
+ const strongRuns = [];
+ tmp = tmp.replace(/\*\*([\s\S]+?)\*\*/g, (_, content) => {
+ const idx = strongRuns.push(content) - 1;
+ return `@@STRONG${idx}@@`;
+ });
+
+ const emphasisRuns = [];
+ tmp = tmp.replace(/(? {
+ const idx = emphasisRuns.push(content) - 1;
+ return `@@EM${idx}@@`;
+ });
+
+ return tmp
+ .replace(/@@STRONG(\d+)@@/g, (_, idx) => `${strongRuns[+idx]}`)
+ .replace(/@@EM(\d+)@@/g, (_, idx) => `${emphasisRuns[+idx]}`)
+ .replace(/@@CODEINLINE(\d+)@@/g, (_, idx) => `${codeRuns[+idx]}`);
+ };
+
+ // 1) Normalize line endings
+ let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+
+ // 2) Extract code blocks and replace with placeholders (protect from all formatting)
+ const codeblocks = [];
+ const placeholder = (idx) => `@@CODEBLOCK${idx}@@`;
+
+ tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
+ let cleaned = (code || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ const lines = cleaned.split('\n');
+ while (lines.length > 0 && /^\s*$/.test(lines[lines.length - 1])) lines.pop();
+ cleaned = lines.join('\n');
+ codeblocks.push({ lang: (lang || '').trim(), code: cleaned });
+ return placeholder(codeblocks.length - 1);
+ });
+
+ // 3) HTML-escape special characters (outside of fenced code blocks)
+ let escaped = escapeHtml(tmp);
+
+ // 4) Headings (with consistent hooks)
+ escaped = escaped
+ .replace(/^#### (.+)$/gm, '$1
')
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
');
+
+ // 4.1) Horizontal rules: --- or *** or ___ on a line
+ escaped = escaped.replace(/^(?:-{3,}|\*{3,}|_{3,})\s*$/gm, '
');
+
+ // 4.2) Ordered lists: lines starting with "1. ", "2. ", ...
+ escaped = escaped.replace(
+ /(^|\n)([ \t]*\d+\. .+(?:\n[ \t]*\d+\. .+)*)/g,
+ (_, lead, listBlock) => {
+ const items = listBlock
+ .split(/\n/)
+ .map((line) => line.replace(/^[ \t]*\d+\.\s+/, '').trim())
+ .filter((item) => item.length > 0)
+ .map((item) => `${item}`)
+ .join('');
+ if (!items) return listBlock;
+ return `${lead}${items}
`;
+ }
+ );
+
+ // 4.3) Blockquotes
+ escaped = escaped.replace(
+ /(^|\n)([ \t]*> .+(?:\n[ \t]*> .+)*)/g,
+ (_, lead, blockquoteBlock) => {
+ const lines = blockquoteBlock
+ .split(/\n/)
+ .map((line) => line.replace(/^[ \t]*>\s*/, '').trim())
+ .join('\n');
+ return `${lead}${lines}
`;
+ }
+ );
+
+ // 4.5) Unordered lists
+ escaped = escaped.replace(
+ /(^|\n)([ \t]*[-*] .+(?:\n[ \t]*[-*] .+)*)/g,
+ (_, lead, listBlock) => {
+ const items = listBlock
+ .split(/\n/)
+ .map((line) => line.replace(/^[ \t]*[-*]\s+/, '').trim())
+ .filter((item) => item.length > 0)
+ .map((item) => `${item}`)
+ .join('');
+ if (!items) return listBlock;
+ return `${lead}`;
+ }
+ );
+
+ // 4.6) Markdown tables (GitHub-style). Strict: requires header, separator, ≥2 cols.
+ const mdTableBlockRe =
+ /(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm;
+
+ escaped = escaped.replace(mdTableBlockRe, (block) => {
+ const hadTrailingNewline = /\n$/.test(block);
+ const lines = block.replace(/\n$/, '').split('\n');
+
+ const split = (line) => line.replace(/^\||\|$/g, '').split('|').map((s) => s.trim());
+
+ const headers = split(lines[0]);
+ const seps = split(lines[1]);
+ if (headers.length < 2 || seps.length < 2) return block;
+ if (!seps.every((s) => /^[ :\-]+$/.test(s) && /-/.test(s))) return block;
+
+ const aligns = seps.map((seg) => {
+ const s = seg.replace(/\s+/g, '');
+ const left = s.startsWith(':');
+ const right = s.endsWith(':');
+ if (left && right) return 'center';
+ if (right) return 'right';
+ return 'left';
+ });
+
+ const bodyLines = lines.slice(2).filter((l) => /^\|/.test(l.trim()));
+ const alignClass = (i) => `md-align-${aligns[i] || 'left'}`;
+
+ const ths = headers
+ .map(
+ (h, i) =>
+ `${h} | `
+ )
+ .join('');
+
+ const rows = bodyLines
+ .map((line) => {
+ const cells = split(line);
+ const tds = cells
+ .map(
+ (c, i) =>
+ `${c} | `
+ )
+ .join('');
+ return `${tds}
`;
+ })
+ .join('');
+
+ const table = ``;
+
+ return table + (hadTrailingNewline ? '\n' : '');
+ });
+
+ // 5) Bold, italic, inline code
+ let html = applyInline(escaped);
+
+ // 5.5) Links
+ const safeLink = (hrefRaw) => {
+ const href = (hrefRaw || '').trim();
+ if (!href) return '';
+ if (/^https?:\/\//i.test(href)) return href;
+ if (/^mailto:/i.test(href) || /^tel:/i.test(href)) return href;
+ if (href.startsWith('/') || href.startsWith('#')) return href;
+ return '';
+ };
+
+ html = html.replace(/$begin:math:display$\(\[\^$end:math:display$]+?)\]$begin:math:text$\(\[\^\)\]\+\?\)$end:math:text$/g, (_, label, href) => {
+ const url = safeLink(href);
+ const tooltip = escapeHtml(href || '');
+ if (!url) return label;
+ return `${label} ${tooltip}`;
+ });
+
+ // 6) Convert line-breaks to
for NON-code content
+ html = html.replace(/\n/g, '
');
+
+ // 6.1) Collapse 3+ consecutive
into a double-break
+ html = html.replace(/(?:
[\s]*){3,}/g, '
');
+
+ // 6.2) Normalize spacing around block elements
+ html = html
+ .replace(
+ /(
\s*)+(<(?:h[1-4]|hr|table|ul|ol|blockquote)\b[^>]*>)/g,
+ '
$2'
+ )
+ .replace(
+ /(<\/(?:h[1-4]|table|ul|ol|blockquote)>\s*)(
\s*)+/g,
+ '$1
'
+ );
+
+ // 6.3) Trim breaks after headings
+ html = html.replace(/(<\/h[1-4]>)(
\s*)+/g, '$1');
+
+ // 6.4) Trim trailing breaks after lists
+ html = html.replace(/(<\/(?:ul|ol)>)(
\s*)+/g, '$1');
+
+ // 7) Restore code blocks with header + copy button
+ html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => {
+ const { lang, code } = codeblocks[+idx];
+ const title = (lang && lang.trim()) ? lang.trim() : 'code';
+ const titleLabel = escapeHtml(title);
+ const languageClass = title.toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'code';
+
+ const escapedCode = escapeHtml(code);
+ const encodedForCopy = encodeURIComponent(code);
+
+ const head = ``;
+
+ const body = `${escapedCode}
`;
+
+ return `${head}${body}
`;
+ });
+
+ // 8) Cleanup around codeblocks
+ html = html
+ .replace(/
\s*(?=]*>[\s\S]*?<\/div>)\s*
/g,
+ '$1'
+ );
+
+ return html;
+}
+
+// Virtually close an unfinished fenced code block so it renders during streaming.
+function balanceStreamingCodeFence(md) {
+ const lines = md.split(/\r?\n/);
+ let open = null;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ if (!open) {
+ const m = line.match(/^\s*([`~]{3,})([^\s]*)?.*$/);
+ if (m) {
+ open = { fenceChar: m[1][0], fenceLen: m[1].length };
+ continue;
+ }
+ } else {
+ const re = new RegExp(
+ `^\\s*(${open.fenceChar}{${open.fenceLen},})\\s*$`
+ );
+ if (re.test(line)) {
+ open = null;
+ continue;
+ }
+ }
+ }
+
+ if (open) {
+ const virtual = `${open.fenceChar.repeat(open.fenceLen)}`;
+ return md.endsWith('\n') ? md + virtual : md + '\n' + virtual;
+ }
+
+ return md;
+}
\ No newline at end of file