From 700b9d72bb78ad8c94a1e50b38b91d1ff2e47e11 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 31 Jan 2026 14:13:38 +0100 Subject: [PATCH] auto-git: [add] src/markdown/markdown-render.css [add] src/markdown/markdown.js --- src/markdown/markdown-render.css | 262 +++++++++++++++++++++++++++++ src/markdown/markdown.js | 275 +++++++++++++++++++++++++++++++ 2 files changed, 537 insertions(+) create mode 100644 src/markdown/markdown-render.css create mode 100644 src/markdown/markdown.js 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}
      ${items}
    `; + } + ); + + // 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 = `${ths}${rows}
    `; + + 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 = `
    ${titleLabel}
    `; + + 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