@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
103 lines • 5.17 kB
JavaScript
import chalk from 'chalk';
import { highlight } from 'cli-highlight';
import { decodeHtmlEntities } from './html-entities.js';
import { parseMarkdownTable } from './table-parser.js';
// Helper function to get compatible color from theme (handles both old and new naming)
function _getColor(themeColors, colorProperty) {
const color = themeColors[colorProperty];
return color || '#ffffff'; // fallback to white
}
// Basic markdown parser for terminal
export function parseMarkdown(text, themeColors, width) {
// First decode HTML entities
let result = decodeHtmlEntities(text);
// Step 1: Parse tables FIRST (before <br> conversion and code extraction)
// Tables should have plain text only, no markdown formatting
// Note: We parse tables before converting <br> tags so that multi-line
// cells don't break the table regex
result = result.replace(/(?:^|\n)((?:\|.+\|\n)+)/gm, (_match, tableText) => {
return '\n' + parseMarkdownTable(tableText, themeColors, width) + '\n';
});
// Step 2: Convert <br> and <br/> tags to newlines (AFTER table parsing)
result = result.replace(/<br\s*\/?>/gi, '\n');
// Step 3: Extract and protect code blocks and inline code with placeholders
const codeBlocks = [];
const inlineCodes = [];
// Extract code blocks first (```language\ncode\n```)
result = result.replace(/```([a-zA-Z0-9\-+#]+)?\n([\s\S]*?)```/g, (_match, lang, code) => {
try {
// Convert tabs to 2 spaces to prevent terminal rendering at 8-space width
const codeStr = String(code).trim().replace(/\t/g, ' ');
// Apply syntax highlighting with detected language
const highlighted = highlight(codeStr, {
language: lang || 'plaintext',
theme: 'default',
});
const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`;
codeBlocks.push(highlighted);
return placeholder;
}
catch {
// Fallback to plain colored text if highlighting fails
const formatted = chalk.hex(themeColors.tool)(String(code).trim().replace(/\t/g, ' '));
const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`;
codeBlocks.push(formatted);
return placeholder;
}
});
// Extract inline code (`code`)
result = result.replace(/`([^`]+)`/g, (_match, code) => {
const formatted = chalk.hex(themeColors.tool)(String(code).trim());
const placeholder = `__INLINE_CODE_${inlineCodes.length}__`;
inlineCodes.push(formatted);
return placeholder;
});
// Step 4: Process markdown formatting (now safe from code interference)
// Process lists FIRST before italic, since * at start of line is a list, not italic
// List items (- item or * item or 1. item)
// Use [ \t]* instead of \s* to avoid consuming newlines before the list
// Preserve indentation for nested lists
result = result.replace(/^([ \t]*)[-*]\s+(.+)$/gm, (_match, indent, text) => {
return indent + chalk.hex(themeColors.text)(`• ${text}`);
});
result = result.replace(/^([ \t]*)(\d+)\.\s+(.+)$/gm, (_match, indent, num, text) => {
return indent + chalk.hex(themeColors.text)(`${num}. ${text}`);
});
// Bold (**text** only - avoid __ to prevent conflicts with snake_case)
result = result.replace(/\*\*([^*]+)\*\*/g, (_match, text) => {
return chalk.hex(themeColors.text).bold(text);
});
// Italic (*text* only - avoid _ to prevent conflicts with snake_case)
// Require whitespace or line boundaries around asterisks to avoid matching char*ptr
// Use [^*\n] to prevent matching across lines
// Only match if content contains at least one letter to avoid matching math like "5 * 3 * 2"
result = result.replace(/(^|\s)\*([^*\n]*[a-zA-Z][^*\n]*)\*($|\s)/gm, (_match, before, text, after) => {
return before + chalk.hex(themeColors.text).italic(text) + after;
});
// Headings (# Heading)
result = result.replace(/^(#{1,6})\s+(.+)$/gm, (_match, _hashes, text) => {
return chalk.hex(themeColors.primary).bold(text);
});
// Links [text](url)
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
return (chalk.hex(themeColors.info).underline(text) +
' ' +
chalk.hex(themeColors.secondary)(`(${url})`));
});
// Blockquotes (> text)
result = result.replace(/^>\s+(.+)$/gm, (_match, text) => {
return chalk.hex(themeColors.secondary).italic(`> ${text}`);
});
// Step 5: Restore code blocks and inline code from placeholders
result = result.replace(/__CODE_BLOCK_(\d+)__/g, (_match, index) => {
return codeBlocks[parseInt(index, 10)] || '';
});
result = result.replace(/__INLINE_CODE_(\d+)__/g, (_match, index) => {
return inlineCodes[parseInt(index, 10)] || '';
});
return result;
}
// Re-export utilities for convenience
export { decodeHtmlEntities } from './html-entities.js';
export { parseMarkdownTable } from './table-parser.js';
//# sourceMappingURL=index.js.map