diff --git a/VERSION b/VERSION index 1d0ba9e..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 diff --git a/src/css/styles.css b/src/css/styles.css index 8225689..84b3a72 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1412,3 +1412,157 @@ body { border: 1px solid #f59e0b33; color: #fbbf24; } + +/* ── Debug Modal ─────────────────────────── */ + +.debug-modal { + max-width: 900px; + height: 80vh; + display: flex; + flex-direction: column; +} + +.debug-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + padding: 0 1rem; + background: var(--bg-secondary); +} + +.debug-tab { + padding: 0.5rem 1rem; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; + border-bottom: 2px solid transparent; + transition: color 0.2s, border-color 0.2s; +} + +.debug-tab:hover { color: var(--text-primary); } +.debug-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.debug-modal-body { + flex: 1; + overflow: hidden; + padding: 0 !important; +} + +.debug-tab-content { + height: 100%; + display: flex; + flex-direction: column; +} + +.debug-toolbar { + display: flex; + gap: 1rem; + padding: 0.4rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.75rem; +} + +.debug-check { + display: flex; + align-items: center; + gap: 0.3rem; + color: var(--text-secondary); + cursor: pointer; +} + +.debug-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + font-size: 0.72rem; + line-height: 1.5; +} + +.debug-entry { + padding: 1px 0.5rem; + border-bottom: 1px solid rgba(128, 128, 128, 0.08); + white-space: pre-wrap; + word-break: break-all; +} + +.debug-entry:hover { + background: rgba(255, 255, 255, 0.02); +} + +.debug-time { + color: var(--text-secondary); + opacity: 0.6; + font-size: 0.68rem; +} + +.debug-msg { + color: var(--text-primary); +} + +.debug-empty { + color: var(--text-secondary); + text-align: center; + padding: 2rem; + font-family: inherit; +} + +.debug-net-entry { + border-bottom: 1px solid var(--border); +} + +.debug-net-entry summary { + padding: 0.4rem 0.5rem; + cursor: pointer; + font-size: 0.75rem; +} + +.debug-net-entry summary:hover { + background: rgba(255, 255, 255, 0.02); +} + +.debug-net-method { + font-weight: bold; + color: var(--accent); + min-width: 3em; + display: inline-block; +} + +.debug-net-url { + color: var(--text-primary); + word-break: break-all; +} + +.debug-net-detail { + padding: 0.5rem 1rem; + font-size: 0.72rem; +} + +.debug-pre { + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow: auto; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + margin: 0.3rem 0; +} + +.debug-state-pre { + max-height: none; + font-size: 0.75rem; +} + +.debug-footer { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-top: 1px solid var(--border); +} diff --git a/src/index.html b/src/index.html index ad3efee..e93207c 100644 --- a/src/index.html +++ b/src/index.html @@ -4,7 +4,7 @@ Chat Switchboard - + + + +
- - - - - - - + + + + + + + + diff --git a/src/js/api.js b/src/js/api.js index 9b9d74f..0ebc323 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -174,13 +174,16 @@ async function sendApiRequest(messages) { const reader = response.body.getReader(); const decoder = new TextDecoder(); + let sseBuffer = ''; // Buffer for incomplete SSE lines while (true) { const { done, value } = await reader.read(); if (done) break; - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); + sseBuffer += decoder.decode(value, { stream: true }); + const lines = sseBuffer.split('\n'); + // Keep the last (potentially incomplete) line in the buffer + sseBuffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { diff --git a/src/js/debug.js b/src/js/debug.js new file mode 100644 index 0000000..cc0a804 --- /dev/null +++ b/src/js/debug.js @@ -0,0 +1,548 @@ +// ========================================== +// Debug Logger & Modal +// ========================================== +// Console interceptor, fetch logger, state inspector. +// Adapted from ai-editor's error-logger.js + llm-debug-modal.js +// for no-devtools "hardmode" debugging. +// +// Activate: Ctrl+Shift+D or tap the 🐛 badge (appears after init) +// ========================================== + +const DebugLog = { + entries: [], + maxEntries: 500, + networkLog: [], + maxNetwork: 200, + _origConsole: {}, + _origFetch: null, + _initTime: Date.now(), + _errorCount: 0, + + // ── Bootstrap ──────────────────────────── + + init() { + this._interceptConsole(); + this._interceptFetch(); + this._interceptErrors(); + this._injectBadge(); + this.log('DEBUG', '🐛 Debug logger initialized'); + this.log('DEBUG', `Page: ${location.href}`); + this.log('DEBUG', `UA: ${navigator.userAgent.slice(0, 120)}`); + }, + + // ── Console Interception ──────────────── + + _interceptConsole() { + ['log', 'warn', 'error', 'info', 'debug'].forEach(method => { + this._origConsole[method] = console[method].bind(console); + console[method] = (...args) => { + const type = method.toUpperCase(); + const message = args.map(a => this._serialize(a)).join(' '); + this._addEntry(type, message); + this._origConsole[method](...args); + }; + }); + }, + + // ── Fetch Interception ────────────────── + + _interceptFetch() { + this._origFetch = window.fetch.bind(window); + const self = this; + + window.fetch = async function(input, init) { + const url = typeof input === 'string' ? input : input?.url || String(input); + const method = init?.method || 'GET'; + const entry = { + id: Date.now(), + ts: new Date().toISOString(), + method, + url: url.slice(0, 300), + status: null, + statusText: '', + duration: null, + error: null, + responsePreview: null, + requestBodyPreview: null + }; + + // Capture request body preview (redact keys) + if (init?.body) { + try { + const bodyStr = typeof init.body === 'string' ? init.body : ''; + const parsed = bodyStr ? JSON.parse(bodyStr) : {}; + // Redact sensitive fields + const safe = { ...parsed }; + ['password', 'api_key', 'apiKey', 'refresh_token', 'access_token'].forEach(k => { + if (safe[k]) safe[k] = '***'; + }); + entry.requestBodyPreview = JSON.stringify(safe).slice(0, 300); + } catch (e) { + entry.requestBodyPreview = '(non-JSON body)'; + } + } + + self.networkLog.push(entry); + if (self.networkLog.length > self.maxNetwork) { + self.networkLog.shift(); + } + + const start = performance.now(); + try { + const resp = await self._origFetch(input, init); + entry.status = resp.status; + entry.statusText = resp.statusText; + entry.duration = Math.round(performance.now() - start); + + // Log non-ok responses at higher severity + const logType = resp.ok ? 'NET' : 'NET:ERR'; + self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`); + + // Clone and peek at error responses + if (!resp.ok) { + try { + const clone = resp.clone(); + const text = await clone.text(); + entry.responsePreview = text.slice(0, 500); + } catch (e) { /* can't peek */ } + } + + return resp; + } catch (err) { + entry.duration = Math.round(performance.now() - start); + entry.error = err.message || String(err); + self._addEntry('NET:ERR', `${method} ${url.slice(0, 80)} → FAILED: ${entry.error} (${entry.duration}ms)`); + throw err; + } + }; + }, + + // ── Global Error Handlers ─────────────── + + _interceptErrors() { + window.addEventListener('error', (e) => { + this._addEntry('EXCEPTION', `${e.message} at ${e.filename || '?'}:${e.lineno || 0}:${e.colno || 0}`); + this._errorCount++; + this._updateBadge(); + }); + + window.addEventListener('unhandledrejection', (e) => { + const msg = e.reason?.message || e.reason || 'Unknown rejection'; + this._addEntry('REJECTION', String(msg)); + this._errorCount++; + this._updateBadge(); + }); + }, + + // ── Entry Management ──────────────────── + + _addEntry(type, message) { + const entry = { + ts: new Date().toISOString(), + elapsed: Date.now() - this._initTime, + type, + message + }; + this.entries.push(entry); + if (this.entries.length > this.maxEntries) { + this.entries.shift(); + } + + if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR') { + this._errorCount++; + this._updateBadge(); + } + + // Auto-refresh if modal is open + if (document.getElementById('debugModal')?.classList.contains('active')) { + this._scheduleRender(); + } + }, + + _renderTimer: null, + _scheduleRender() { + if (this._renderTimer) return; + this._renderTimer = setTimeout(() => { + this._renderTimer = null; + this.render(); + }, 250); + }, + + log(type, message) { + this._addEntry(type, message); + }, + + clear() { + this.entries = []; + this.networkLog = []; + this._errorCount = 0; + this._updateBadge(); + }, + + // ── Serialization ─────────────────────── + + _serialize(arg) { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (arg instanceof Error) { + return `${arg.name}: ${arg.message}`; + } + if (typeof arg === 'object') { + try { return JSON.stringify(arg, null, 0); } + catch (e) { return String(arg); } + } + return String(arg); + }, + + // ── State Snapshot ────────────────────── + + getStateSnapshot() { + const snap = {}; + + // Backend state (redact tokens) + if (typeof Backend !== 'undefined') { + snap.backend = { + baseUrl: Backend.baseUrl || '(empty)', + hasAccessToken: !!Backend.accessToken, + hasRefreshToken: !!Backend.refreshToken, + user: Backend.user ? { + username: Backend.user.username, + role: Backend.user.role, + display_name: Backend.user.display_name + } : null, + isConnected: Backend.isConnected, + isManaged: Backend.isManaged + }; + } + + // App state + if (typeof State !== 'undefined') { + snap.state = { + chatCount: State.chats?.length || 0, + currentChatId: State.currentChatId, + modelCount: State.models?.length || 0, + isGenerating: State.isGenerating, + settings: { + model: State.settings?.model || '?', + stream: State.settings?.stream, + apiEndpoint: State.settings?.apiEndpoint ? '(set)' : '(empty)', + apiKey: State.settings?.apiKey ? '(set)' : '(empty)', + } + }; + } + + // localStorage keys + try { + snap.storageKeys = Object.keys(localStorage).filter(k => + k.startsWith('chatSwitchboard') || k.startsWith('switchboard') + ); + } catch (e) { + snap.storageKeys = '(error reading)'; + } + + return snap; + }, + + // ── Connection Diagnostics ────────────── + + async runDiagnostics() { + this.log('DIAG', '── Starting connection diagnostics ──'); + + const baseUrl = (typeof Backend !== 'undefined' && Backend.baseUrl) + ? Backend.baseUrl + : window.location.origin; + + // Test 1: Basic fetch to same origin + this.log('DIAG', `Test 1: Health check → ${baseUrl}/api/v1/health`); + try { + const start = performance.now(); + const resp = await this._origFetch(baseUrl + '/api/v1/health', { + signal: AbortSignal.timeout(10000) + }); + const dur = Math.round(performance.now() - start); + const contentType = resp.headers.get('content-type') || '(none)'; + const text = await resp.text(); + this.log('DIAG', ` Status: ${resp.status} (${dur}ms)`); + this.log('DIAG', ` Content-Type: ${contentType}`); + this.log('DIAG', ` Body: ${text.slice(0, 300)}`); + + if (contentType.includes('text/html')) { + this.log('DIAG', ' ⚠️ Got HTML instead of JSON — likely a PROXY interception page'); + } + if (!resp.ok) { + this.log('DIAG', ` ⚠️ Non-200 status: ${resp.status} ${resp.statusText}`); + } + try { + const json = JSON.parse(text); + this.log('DIAG', ` ✅ Valid JSON: database=${json.database}, registration=${json.registration_enabled}`); + } catch (e) { + this.log('DIAG', ` ❌ NOT valid JSON — proxy or wrong endpoint`); + } + } catch (err) { + this.log('DIAG', ` ❌ Fetch failed: ${err.message}`); + if (err.name === 'TimeoutError' || err.name === 'AbortError') { + this.log('DIAG', ' ⚠️ Request timed out — proxy may be blocking'); + } + } + + // Test 2: Check if we're getting CORS issues + this.log('DIAG', 'Test 2: OPTIONS preflight check'); + try { + const resp = await this._origFetch(baseUrl + '/api/v1/health', { + method: 'OPTIONS', + signal: AbortSignal.timeout(5000) + }); + this.log('DIAG', ` Status: ${resp.status}`); + this.log('DIAG', ` CORS headers: ${resp.headers.get('access-control-allow-origin') || '(none)'}`); + } catch (err) { + this.log('DIAG', ` ❌ OPTIONS failed: ${err.message}`); + } + + // Test 3: Token validity + if (typeof Backend !== 'undefined' && Backend.accessToken) { + this.log('DIAG', 'Test 3: Token validation'); + try { + const resp = await this._origFetch(baseUrl + '/api/v1/profile', { + headers: { + 'Authorization': `Bearer ${Backend.accessToken}`, + 'Content-Type': 'application/json' + }, + signal: AbortSignal.timeout(5000) + }); + this.log('DIAG', ` Profile endpoint: ${resp.status}`); + if (resp.status === 401) { + this.log('DIAG', ' ⚠️ Token expired or invalid'); + } + } catch (err) { + this.log('DIAG', ` ❌ Token check failed: ${err.message}`); + } + } else { + this.log('DIAG', 'Test 3: Skipped (no token)'); + } + + this.log('DIAG', '── Diagnostics complete ──'); + this.render(); + }, + + // ── Badge ─────────────────────────────── + + _injectBadge() { + const badge = document.createElement('div'); + badge.id = 'debugBadge'; + badge.innerHTML = '🐛'; + badge.title = 'Debug Log (Ctrl+Shift+D)'; + badge.style.cssText = ` + position: fixed; bottom: 12px; right: 12px; z-index: 100000; + width: 36px; height: 36px; border-radius: 50%; + background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444); + display: flex; align-items: center; justify-content: center; + cursor: pointer; font-size: 18px; opacity: 0.5; + transition: opacity 0.2s, transform 0.2s; + `; + badge.addEventListener('mouseenter', () => { badge.style.opacity = '1'; badge.style.transform = 'scale(1.1)'; }); + badge.addEventListener('mouseleave', () => { badge.style.opacity = this._errorCount > 0 ? '0.9' : '0.5'; badge.style.transform = ''; }); + badge.addEventListener('click', () => openDebugModal()); + document.body.appendChild(badge); + + // Keyboard shortcut + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.shiftKey && e.key === 'D') { + e.preventDefault(); + openDebugModal(); + } + }); + }, + + _updateBadge() { + const badge = document.getElementById('debugBadge'); + if (!badge) return; + if (this._errorCount > 0) { + badge.style.opacity = '0.9'; + badge.style.background = 'var(--danger, #c0392b)'; + badge.innerHTML = `🐛${this._errorCount > 99 ? '99+' : this._errorCount}`; + badge.style.position = 'fixed'; // keep relative positioning for the counter + } + }, + + // ── Modal Rendering ───────────────────── + + render() { + this._renderConsoleTab(); + this._renderNetworkTab(); + this._renderStateTab(); + }, + + _renderConsoleTab() { + const container = document.getElementById('debugConsoleContent'); + if (!container) return; + + const count = document.getElementById('debugConsoleCount'); + if (count) count.textContent = this.entries.length; + + if (this.entries.length === 0) { + container.innerHTML = '
No log entries yet
'; + return; + } + + const typeColors = { + 'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c', + 'NET:ERR': '#e74c3c', + 'WARN': '#f39c12', 'WARNING': '#f39c12', + 'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d', + 'NET': '#2ecc71', + 'DIAG': '#9b59b6' + }; + + let html = ''; + const entries = document.getElementById('debugFilterErrors')?.checked + ? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'WARN'].includes(e.type)) + : this.entries; + + for (const entry of entries) { + const color = typeColors[entry.type] || '#95a5a6'; + const time = entry.ts.slice(11, 23); + const elapsed = (entry.elapsed / 1000).toFixed(1); + html += `
`; + html += `${time} +${elapsed}s `; + html += `[${this._esc(entry.type)}] `; + html += `${this._esc(entry.message)}`; + html += `
`; + } + container.innerHTML = html; + + if (document.getElementById('debugAutoScroll')?.checked) { + container.scrollTop = container.scrollHeight; + } + }, + + _renderNetworkTab() { + const container = document.getElementById('debugNetworkContent'); + if (!container) return; + + const count = document.getElementById('debugNetworkCount'); + if (count) count.textContent = this.networkLog.length; + + if (this.networkLog.length === 0) { + container.innerHTML = '
No network requests logged
'; + return; + } + + let html = ''; + for (const req of [...this.networkLog].reverse()) { + const isErr = req.error || (req.status && req.status >= 400); + const statusColor = isErr ? '#e74c3c' : '#2ecc71'; + const statusText = req.error + ? `❌ ${req.error}` + : (req.status ? `${req.status} ${req.statusText}` : '⏳ pending'); + + html += `
`; + html += ``; + html += `${req.method} `; + html += `${this._esc(req.url)} `; + html += `${statusText}`; + if (req.duration != null) html += ` ${req.duration}ms`; + html += ``; + html += `
`; + if (req.requestBodyPreview) { + html += `
Request: ${this._esc(req.requestBodyPreview)}
`; + } + if (req.responsePreview) { + html += `
Response:
${this._esc(req.responsePreview)}
`; + } + html += `
${req.ts}
`; + html += `
`; + } + container.innerHTML = html; + }, + + _renderStateTab() { + const container = document.getElementById('debugStateContent'); + if (!container) return; + + const snap = this.getStateSnapshot(); + container.innerHTML = `
${this._esc(JSON.stringify(snap, null, 2))}
`; + }, + + _esc(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + }, + + // ── Export ─────────────────────────────── + + exportText() { + let text = `=== Chat Switchboard Debug Export ===\n`; + text += `Exported: ${new Date().toISOString()}\n`; + text += `URL: ${location.href}\n`; + text += `UA: ${navigator.userAgent}\n\n`; + + text += `--- STATE ---\n`; + text += JSON.stringify(this.getStateSnapshot(), null, 2) + '\n\n'; + + text += `--- CONSOLE LOG (${this.entries.length}) ---\n`; + for (const e of this.entries) { + text += `[${e.ts}] [${e.type}] ${e.message}\n`; + } + text += '\n'; + + text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`; + for (const r of this.networkLog) { + text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'} ${r.error || ''} (${r.duration || '?'}ms)\n`; + if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`; + } + + return text; + } +}; + +// ── Modal Controls ────────────────────────── + +function openDebugModal() { + DebugLog.render(); + document.getElementById('debugModal').classList.add('active'); +} + +function closeDebugModal() { + document.getElementById('debugModal').classList.remove('active'); +} + +function switchDebugTab(tab) { + document.querySelectorAll('.debug-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.debug-tab-content').forEach(c => c.style.display = 'none'); + document.querySelector(`.debug-tab[data-tab="${tab}"]`)?.classList.add('active'); + document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`)?.style.display = ''; + DebugLog.render(); +} + +function clearDebugLog() { + if (confirm('Clear all debug logs?')) { + DebugLog.clear(); + DebugLog.render(); + } +} + +function copyDebugLog() { + const text = DebugLog.exportText(); + navigator.clipboard.writeText(text) + .then(() => { if (typeof showToast === 'function') showToast('📋 Debug log copied', 'success'); }) + .catch(() => { alert('Failed to copy'); }); +} + +function exportDebugLog() { + const text = DebugLog.exportText(); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `switchboard-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; + a.click(); + URL.revokeObjectURL(url); +} + +function runDebugDiagnostics() { + switchDebugTab('console'); + DebugLog.runDiagnostics(); +} + +// Initialize ASAP — before app.js init() so we capture everything +DebugLog.init(); diff --git a/src/js/ui.js b/src/js/ui.js index a958e52..c253417 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -205,7 +205,7 @@ async function loadProviderList() {
${escapeHtmlUI(c.name)} - ${c.provider} · ${c.model_default || 'no default'} + ${escapeHtmlUI(c.provider)} · ${escapeHtmlUI(c.model_default || 'no default')}
${c.has_key ? '🔑' : '⚠️'} @@ -214,7 +214,7 @@ async function loadProviderList() {
`).join(''); } catch (e) { - container.innerHTML = '
Failed to load providers: ' + e.message + '
'; + container.innerHTML = '
Failed to load providers: ' + escapeHtmlUI(e.message) + '
'; } } @@ -361,53 +361,55 @@ function createMessageHTML(message, index) { function formatMessage(content) { let formatted = content; - - // Extract and format thinking blocks FIRST (before escaping) + const thinkingBlocks = []; + + // Extract thinking blocks FIRST (before escaping) if (State.settings.showThinking) { formatted = formatted.replace(/([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => { - const escapedContent = escapeHtml(thinkingContent.trim()); const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9); - return `__THINKING_BLOCK_${blockId}__${escapedContent}__END_THINKING_BLOCK__`; + // Store raw content; we'll escape it when reinserting + thinkingBlocks.push({ id: blockId, content: thinkingContent.trim() }); + return `__THINKPLACEHOLDER_${blockId}__`; }); } else { - // Remove thinking blocks if setting is off formatted = formatted.replace(/[\s\S]*?<\/thinking>/gi, ''); } - - // Now escape HTML for the rest + + // Escape HTML for the entire string (placeholders are plain text, safe to escape) formatted = escapeHtml(formatted); - - // Restore thinking blocks with proper HTML - formatted = formatted.replace(/__THINKING_BLOCK_([\w-]+)__([\s\S]*?)__END_THINKING_BLOCK__/g, (match, blockId, content) => { - return ` -
-
+ + // Restore thinking blocks with properly-escaped content (single escape only) + for (const block of thinkingBlocks) { + const escapedContent = escapeHtml(block.content).replace(/\n/g, '
'); + formatted = formatted.replace(`__THINKPLACEHOLDER_${block.id}__`, ` +
+
💭 Thinking
-
${content.replace(/\n/g, '
')}
+
${escapedContent}
- `; - }); - + `); + } + // Code blocks with copy button formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => { const codeId = 'code-' + Math.random().toString(36).substr(2, 9); return `
${code.trim()}
`; }); - + // Inline code formatted = formatted.replace(/`([^`]+)`/g, '$1'); - + // Bold formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '$1'); - - // Italic + + // Italic formatted = formatted.replace(/\*([^*]+)\*/g, '$1'); - + // Line breaks (but not inside pre/code) formatted = formatted.replace(/\n/g, '
'); - + return formatted; }