trace.ai-cli
Version:
A powerful AI-powered CLI tool
188 lines (161 loc) • 5.69 kB
JavaScript
// markdown.js
// Minimal Markdown renderer (safe): supports **bold**, `code`, bullet lists, and fenced code blocks ```lang
// Additionally provides a terminal-friendly ANSI renderer for Node CLI.
function escapeHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
// Terminal-friendly Markdown to ANSI using chalk (Node only)
function markdownToAnsi(md) {
if (!md) return '';
const chalk = (typeof window === 'undefined') ? require('chalk') : null;
if (!chalk) return md; // Fallback if chalk not available
const lines = md.replace(/\r\n?/g, '\n').split('\n');
let out = '';
let inCode = false;
let codeLines = [];
const flushCode = () => {
if (!inCode) return;
const codeText = codeLines.join('\n');
out += '\n' + chalk.gray('┌─ code ───────────────────────────────') + '\n';
out += chalk.white(codeText) + '\n';
out += chalk.gray('└──────────────────────────────────────') + '\n';
inCode = false;
codeLines = [];
};
const processInlineFormatting = (text) => {
return text
.replace(/\*\*(.+?)\*\*/g, (_, t) => chalk.bold(t))
.replace(/`([^`]+)`/g, (_, t) => chalk.black.bgYellow(` ${t} `));
};
for (let raw of lines) {
const line = raw.trimEnd();
const fenceMatch = /^```/.test(line);
if (fenceMatch) {
if (!inCode) {
inCode = true;
codeLines = [];
continue;
} else {
flushCode();
continue;
}
}
if (inCode) {
codeLines.push(raw);
continue;
}
// Handle bullet lists
const bullet = /^\s*([*-])\s+(.+)$/.exec(line);
if (bullet) {
const item = processInlineFormatting(bullet[2]);
out += chalk.blue('• ') + item + '\n';
continue;
}
// Handle empty lines
if (line.trim() === '') {
out += '\n';
continue;
}
// Handle regular paragraphs
const para = processInlineFormatting(line);
out += para + '\n';
}
flushCode();
return out;
}
function markdownToHtml(md) {
if (!md) return '';
// Normalize line endings
md = md.replace(/\r\n?/g, '\n');
const lines = md.split('\n');
let html = '';
let inList = false;
let inCode = false;
let codeLang = '';
let codeLines = [];
const flushList = () => { if (inList) { html += '</ul>'; inList = false; } };
for (let raw of lines) {
let line = raw.trimEnd();
// Fenced code blocks
const fenceMatch = /^```\s*([A-Za-z0-9_+-]*)\s*$/.exec(line);
if (fenceMatch) {
if (!inCode) {
// starting code block
flushList();
inCode = true;
codeLang = fenceMatch[1] || '';
codeLines = [];
} else {
// ending code block -> flush code block
const codeText = codeLines.join('\n');
const codeEsc = escapeHtml(codeText);
const langClass = codeLang ? `language-${codeLang}` : '';
html += `<div class="code-block">\n<button class="copy-btn" title="Copy code" aria-label="Copy code">Copy</button><pre><code class="${langClass}">${codeEsc}</code></pre></div>`;
inCode = false;
codeLang = '';
codeLines = [];
}
continue;
}
if (inCode) {
codeLines.push(raw); // preserve exact indentation inside code
continue;
}
const bulletMatch = /^\s*([*-])\s+(.+)$/.exec(line);
if (bulletMatch) {
if (!inList) { html += '<ul>'; inList = true; }
const item = bulletMatch[2];
const itemEsc = escapeHtml(item)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
html += `<li>${itemEsc}</li>`;
continue;
}
// Empty line -> paragraph break
if (line.trim() === '') {
flushList(); html += '<br/>';
continue;
}
// Normal paragraph line
flushList();
const para = escapeHtml(line)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
html += `<p>${para}</p>`;
}
flushList();
// If code block was never closed, flush it anyway
if (inCode) {
const codeText = codeLines.join('\n');
const codeEsc = escapeHtml(codeText);
const langClass = codeLang ? `language-${codeLang}` : '';
html += `<div class="code-block"><button class="copy-btn" title="Copy code" aria-label="Copy code">📋</button><pre><code class="${langClass}">${codeEsc}</code></pre></div>`;
}
return html;
}
// Optional: centralize copy button handler so renderer.js stays clean
function initMarkdownCopyHandler() {
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.copy-btn');
if (!btn) return;
try {
const wrapper = btn.closest('.code-block');
const codeEl = wrapper ? wrapper.querySelector('code') : null;
const codeText = codeEl ? codeEl.textContent : '';
if (!codeText) return;
await navigator.clipboard.writeText(codeText);
const prev = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = prev; }, 1200);
} catch (_) { }
});
}
// Expose to browser global when available
if (typeof window !== 'undefined') {
window.markdownToHtml = markdownToHtml;
window.initMarkdownCopyHandler = initMarkdownCopyHandler;
}
// Support CommonJS environments if available (harmless in extension builds)
if (typeof module !== 'undefined' && module.exports) {
module.exports = { markdownToHtml, initMarkdownCopyHandler, markdownToAnsi };
}