import json
from ..linter.schemas import LintResult
def render_widget_html(result: LintResult) -> str:
"""
Generates a standalone HTML widget with inline CSS/JS and injected results.
"""
# Sanitize JSON to prevent script injection
json_data = result.model_dump_json().replace("</script>", "<\\/script>")
html_template = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IVR Flow Linter</title>
<style>
:root {
--bg-color: #ffffff;
--text-color: #333333;
--border-color: #e0e0e0;
--primary-color: #007aff;
--danger-color: #ff3b30;
--warning-color: #ffcc00;
--success-color: #34c759;
--code-bg: #f5f5f5;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background: var(--bg-color);
color: var(--text-color);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
}
.title { font-weight: 600; font-size: 16px; }
.badge-group { display: flex; gap: 8px; }
.badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.score { background: #eee; border: 1px solid #ccc; }
.score.good { background: #d4f7dc; color: #006400; border-color: #34c759; }
.score.ok { background: #fffbe6; color: #856404; border-color: #ffcc00; }
.score.bad { background: #ffeef0; color: #721c24; border-color: #f5c6cb; }
.errors { background: #ffeef0; color: #cc0000; }
.warnings { background: #fffbe6; color: #856404; }
.container { display: flex; flex: 1; overflow: hidden; height: 100%; }
.sidebar {
width: 300px;
border-right: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 8px 12px;
background: #f0f0f0;
font-size: 12px;
font-weight: 600;
color: #666;
text-transform: uppercase;
}
.node-item {
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
}
.node-item:hover { background: #f9f9f9; }
.node-item.selected { background: #e6f2ff; border-left: 3px solid var(--primary-color); }
.node-item.has-error { border-left-color: var(--danger-color); }
.node-id { font-weight: 500; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; justify-content: space-between; }
.node-type { font-size: 11px; color: #888; background: #eee; padding: 2px 4px; border-radius: 4px; }
.issue-indicator { font-size: 12px; margin-top: 4px; display: flex; gap: 6px; }
.main-panel {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.detail-card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.detail-header { border-bottom: 1px solid #eee; padding-bottom: 8px; margin-bottom: 12px; font-weight: 600; font-size: 15px; }
.finding {
background: #fafafa;
border-left: 3px solid #ccc;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 14px;
}
.finding.error { border-left-color: var(--danger-color); background: #fff5f5; }
.finding.warning { border-left-color: var(--warning-color); background: #fffbeb; }
.finding-code { font-family: monospace; font-size: 11px; color: #666; display: block; margin-bottom: 4px; }
.finding-msg { margin-bottom: 6px; display: block; }
.finding-fix { font-size: 13px; color: #006400; font-style: italic; }
.empty-state { text-align: center; color: #999; margin-top: 40px; }
.footer {
padding: 12px;
border-top: 1px solid var(--border-color);
background: #fafafa;
text-align: right;
}
button {
background: var(--primary-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
button:hover { background: #0056b3; }
#unhandled-error {
display: none;
background: #ffeef0;
color: #cc0000;
padding: 10px;
border-bottom: 1px solid red;
font-size: 12px;
font-family: monospace;
white-space: pre-wrap;
}
@media (max-width: 600px) {
.container { flex-direction: column; }
.sidebar { width: 100%; height: 40%; }
.main-panel { height: 60%; }
}
</style>
</head>
<body>
<div id="unhandled-error"></div>
<div class="header">
<div class="title">IVR Linter</div>
<div class="badge-group">
<div id="score-badge" class="badge score">Score: <span id="score-val">-</span></div>
<div class="badge errors">🔴 <span id="error-count">0</span></div>
<div class="badge warnings">⚠️ <span id="warn-count">0</span></div>
</div>
</div>
<div class="container">
<div class="sidebar">
<div class="sidebar-header">Nodes with Issues</div>
<div id="node-list"></div>
</div>
<div class="main-panel">
<div id="detail-view">
<div class="empty-state">Select a node to view details</div>
</div>
</div>
</div>
<div class="footer">
<button id="export-btn">📋 Export Markdown</button>
</div>
<!-- INJECTED DATA -->
<script>
window.__IVR_LINT_RESULT__ = __JSON_DATA__;
</script>
<script>
(function() {
try {
const data = window.__IVR_LINT_RESULT__;
const scoreVal = document.getElementById('score-val');
const scoreBadge = document.getElementById('score-badge');
if (!data) throw new Error("Lint data is missing");
// Header Stats
scoreVal.textContent = data.score;
document.getElementById('error-count').textContent = data.errors.length;
document.getElementById('warn-count').textContent = data.warnings.length;
if(data.score >= 90) scoreBadge.classList.add('good');
else if(data.score >= 50) scoreBadge.classList.add('ok');
else scoreBadge.classList.add('bad');
// Group findings by node
const nodesMap = {};
const findings = [...(data.errors || []), ...(data.warnings || [])];
findings.forEach(f => {
const nid = f.node_id || '_global_';
if(!nodesMap[nid]) nodesMap[nid] = { errors: [], warnings: [] };
if(f.severity === 'error') nodesMap[nid].errors.push(f);
else nodesMap[nid].warnings.push(f);
});
// Render List
const listContainer = document.getElementById('node-list');
const sortedNodes = Object.keys(nodesMap).sort();
if (sortedNodes.length === 0) {
listContainer.innerHTML = '<div style="padding:20px; color:#888; text-align:center">No issues found! 🎉</div>';
document.getElementById('detail-view').innerHTML = '<div class="empty-state">Great job! This flow looks clean.</div>';
}
sortedNodes.forEach(nid => {
const item = document.createElement('div');
item.className = 'node-item';
const issues = nodesMap[nid];
const hasErr = issues.errors.length > 0;
if(hasErr) item.classList.add('has-error');
item.innerHTML = `
<div class="node-id">
${nid === '_global_' ? 'Global Issues' : nid}
</div>
<div class="issue-indicator">
${issues.errors.length > 0 ? `<span style="color:#cc0000">🔴 ${issues.errors.length}</span>` : ''}
${issues.warnings.length > 0 ? `<span style="color:#856404">⚠️ ${issues.warnings.length}</span>` : ''}
</div>
`;
item.onclick = () => {
document.querySelectorAll('.node-item').forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
renderDetail(nid, nodesMap[nid]);
};
listContainer.appendChild(item);
});
function renderDetail(nid, issues) {
const container = document.getElementById('detail-view');
let html = `<div class="detail-header">Details for ${nid}</div>`;
if(issues.errors.length > 0) {
html += `<h4>Errors</h4>`;
issues.errors.forEach(e => {
html += `
<div class="finding error">
<span class="finding-code">${e.code}</span>
<span class="finding-msg">${e.message}</span>
${e.fix ? `<div class="finding-fix">💡 Fix: ${e.fix}</div>` : ''}
</div>`;
});
}
if(issues.warnings.length > 0) {
html += `<h4>Warnings</h4>`;
issues.warnings.forEach(w => {
html += `
<div class="finding warning">
<span class="finding-code">${w.code}</span>
<span class="finding-msg">${w.message}</span>
${w.fix ? `<div class="finding-fix">💡 Fix: ${w.fix}</div>` : ''}
</div>`;
});
}
container.innerHTML = html;
}
// Export Markdown
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.onclick = () => {
let md = `# IVR Flow Analysis\\n\\n`;
md += `- Score: **${data.score}**\\n`;
md += `- Errors: ${data.errors.length}\\n`;
md += `- Warnings: ${data.warnings.length}\\n\\n`;
md += `## Findings\\n\\n`;
const all = [...(data.errors || []), ...(data.warnings || [])];
if(all.length === 0) md += "No issues found.\\n";
else {
all.forEach(f => {
md += `- **[${(f.severity || 'info').toUpperCase()}]** ${f.node_id}: ${f.message}\\n`;
if(f.fix) md += ` - Suggestion: ${f.fix}\\n`;
});
}
navigator.clipboard.writeText(md).then(() => {
const originalText = exportBtn.textContent;
exportBtn.textContent = '✅ Copied!';
setTimeout(() => exportBtn.textContent = originalText, 2000);
}).catch(err => {
alert("Failed to copy to clipboard: " + err);
});
};
}
} catch (e) {
const errDiv = document.getElementById('unhandled-error');
if(errDiv) {
errDiv.style.display = 'block';
errDiv.textContent = 'Widget Crash: ' + e.message + '\\n' + e.stack;
console.error("Widget Error:", e);
}
}
})();
</script>
</body>
</html>"""
return html_template.replace("__JSON_DATA__", json_data)