refactor: extract autoFormat utils from ChatMessage to lib with tests

- Move guessLanguage, looksLikeCode, autoFormatText to src/lib/autoFormat.ts
- Add comprehensive test suite (23 tests) covering language detection, code detection, and auto-formatting
- Reduce ChatMessage.tsx by ~90 lines
- Total tests: 238 (up from 215)
This commit is contained in:
Nicolas Varrot
2026-03-03 21:04:06 +00:00
parent bd7763a6d3
commit b7c18d5f3c
5 changed files with 229 additions and 99 deletions

View File

@@ -15,6 +15,7 @@ import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webho
import { t, getLocale } from '../lib/i18n';
import { useLocale } from '../hooks/useLocale';
import { stripWebhookScaffolding, hasWebhookScaffolding, hasWebchatEnvelope, stripWebchatEnvelope } from '../lib/systemEvent';
import { autoFormatText } from '../lib/autoFormat';
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
/** Avatar image with fallback to Bot icon on load error */
@@ -72,102 +73,6 @@ function Timestamp({ ts, className }: { ts: number; className?: string }) {
);
}
/** Guess a language hint from content patterns */
function guessLanguage(lines: string[]): string {
const joined = lines.join('\n');
if (/^import .+ from ['"]/.test(joined) || /^export (function|const|default|class|interface|type) /.test(joined) || /React\./.test(joined) || /<\w+[\s/>]/.test(joined) && /className=/.test(joined)) return 'tsx';
if (/^(import|export|const|let|var|function|class|interface|type) /.test(joined) || /=>\s*{/.test(joined) || /: (string|number|boolean|any)\b/.test(joined)) return 'typescript';
if (/^(use |fn |let mut |pub |impl |struct |enum |mod |crate::)/.test(joined) || /-> (Self|Result|Option|Vec|String|bool|i32|u32)/.test(joined)) return 'rust';
if (/^(def |class |import |from .+ import |if __name__)/.test(joined) || /self\.\w+/.test(joined) && !/this\./.test(joined)) return 'python';
if (/^\s*(server|location|upstream|proxy_pass|listen \d)/.test(joined)) return 'nginx';
if (/^\[.*\]\s*$/.test(lines[0] || '') && /=/.test(joined)) return 'ini';
if (/^(apiVersion|kind|metadata|spec):/.test(joined)) return 'yaml';
if (/^\{/.test(joined.trim()) && /\}$/.test(joined.trim())) return 'json';
if (/^#!\/(bin|usr)/.test(joined) || /^\s*(if \[|then|fi|echo |export |source )/.test(joined)) return 'bash';
if (/^(<!DOCTYPE|<html|<div|<head|<body)/.test(joined)) return 'html';
if (/^\.\w+\s*\{|^@(media|keyframes|import)/.test(joined)) return 'css';
if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE) /i.test(joined)) return 'sql';
return '';
}
/** Detect if a block of lines looks like code */
function looksLikeCode(lines: string[]): boolean {
if (lines.length < 2) return false;
// If text contains markdown formatting, it's probably prose, not code
const joined = lines.join('\n');
if (/\*\*[^*]+\*\*/.test(joined) || /^#{1,6}\s/m.test(joined) || /^\s*[-*+]\s/m.test(joined)) return false;
let codeSignals = 0;
const patterns = [
/^(import|export|const|let|var|function|class|interface|type|enum|struct|fn|pub|use|def|from|module|package|namespace)\s/,
/[{};]\s*$/,
/^\s*(if|else|for|while|return|match|switch|case|break|continue)\b/,
/^\s*(\/\/|\/\*)/,
/^\s*#\s*(?:include|define|ifdef|ifndef|endif|pragma|import)\b/,
/[├└│┬─]──/,
/^\s+\w+\(.*\)/,
/^\s*<\/?[A-Z]\w*/,
/=>\s*[{(]/,
/\.\w+\(.*\)\s*[;,]?\s*$/,
];
for (const line of lines) {
for (const pat of patterns) {
if (pat.test(line)) { codeSignals++; break; }
}
}
return codeSignals / lines.length > 0.3;
}
/** Auto-wrap unformatted code/terminal output in fenced code blocks */
function autoFormatText(text: string): string {
// Already has code fences — leave as-is
if (text.includes('```')) return text;
const lines = text.split('\n');
// If most of the text looks like code, wrap the whole thing
const nonEmptyLines = lines.filter(l => l.trim());
if (nonEmptyLines.length >= 3 && looksLikeCode(nonEmptyLines)) {
const lang = guessLanguage(nonEmptyLines);
return '```' + lang + '\n' + text + '\n```';
}
// Otherwise, detect contiguous code blocks within prose
const result: string[] = [];
let codeBuffer: string[] = [];
const flushCode = () => {
if (codeBuffer.length >= 3 && looksLikeCode(codeBuffer)) {
const lang = guessLanguage(codeBuffer);
result.push('```' + lang);
result.push(...codeBuffer);
result.push('```');
} else {
result.push(...codeBuffer);
}
codeBuffer = [];
};
const isCodeLine = (line: string): boolean => {
return /^[\s]+(import|export|const|let|var|function|return|if|else|for)/.test(line)
|| /[{};]\s*$/.test(line)
|| /^\s*\/\//.test(line)
|| /[├└│┬─]──/.test(line)
|| /^\s+\w+\(.*\)/.test(line);
};
for (const line of lines) {
if (isCodeLine(line) || (codeBuffer.length > 0 && (line.trim() === '' || /^\s{2,}/.test(line)))) {
codeBuffer.push(line);
} else {
flushCode();
result.push(line);
}
}
flushCode();
return result.join('\n');
}
function getTextBlocks(blocks: MessageBlock[]): MessageBlock[] {
return blocks.filter(b => b.type === 'text' && b.text.trim());
}