This commit is contained in:
aaron 2026-04-30 23:43:22 +08:00
parent cc6ce8729a
commit bdaaa83bf6
2 changed files with 209 additions and 28 deletions

View File

@ -327,3 +327,98 @@ body::after {
.score-bar-gradient-low {
background: linear-gradient(90deg, #585860, #3a3a40);
}
/* Markdown rendered inside AI responses */
.prose h1,
.prose h2,
.prose h3,
.prose h4 {
color: var(--text-primary);
font-weight: 700;
line-height: 1.35;
}
.prose h1 {
margin: 1rem 0 0.5rem;
font-size: 1.05rem;
}
.prose h2 {
margin: 0.9rem 0 0.45rem;
font-size: 0.98rem;
}
.prose h3 {
margin: 0.8rem 0 0.4rem;
font-size: 0.92rem;
}
.prose h4 {
margin: 0.7rem 0 0.35rem;
font-size: 0.86rem;
}
.prose p {
color: var(--text-secondary);
}
.prose code {
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--surface-2);
padding: 0.1rem 0.3rem;
color: var(--accent-cyan);
font-size: 0.9em;
}
.prose pre {
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: var(--surface-1);
padding: 0.75rem;
}
.prose pre code {
border: 0;
background: transparent;
padding: 0;
color: var(--text-secondary);
}
.markdown-table-wrap {
margin: 0.75rem 0;
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: 12px;
}
.markdown-table-wrap table {
width: 100%;
min-width: 520px;
border-collapse: collapse;
font-size: 0.78rem;
}
.markdown-table-wrap th,
.markdown-table-wrap td {
border-bottom: 1px solid var(--border-subtle);
padding: 0.55rem 0.65rem;
text-align: left;
vertical-align: top;
}
.markdown-table-wrap th {
background: var(--surface-2);
color: var(--text-primary);
font-weight: 700;
}
.markdown-table-wrap td {
color: var(--text-secondary);
}
.markdown-table-wrap tr:last-child td {
border-bottom: 0;
}

View File

@ -1,34 +1,120 @@
export function markdownToHtml(md: string): string {
return md
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`)
.replace(/\n{2,}/g, "</p><p>")
.replace(/^(?!<[hulo])/gm, "<p>")
.replace(/(?<![>])$/gm, "</p>")
.replace(/<p><\/p>/g, "")
.replace(/<p>(<h[23]>)/g, "$1")
.replace(/(<\/h[23]>)<\/p>/g, "$1")
.replace(/<p>(<ul>)/g, "$1")
.replace(/(<\/ul>)<\/p>/g, "$1");
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatInline(value: string): string {
return escapeHtml(value)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
}
function isTableSeparator(line: string): boolean {
const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function splitTableRow(line: string): string[] {
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
}
function renderTable(lines: string[]): string {
const headers = splitTableRow(lines[0] ?? "");
const rows = lines.slice(2).map(splitTableRow);
const head = headers.map((cell) => `<th>${formatInline(cell)}</th>`).join("");
const body = rows
.map((row) => `<tr>${row.map((cell) => `<td>${formatInline(cell)}</td>`).join("")}</tr>`)
.join("");
return `<div class="markdown-table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function renderList(lines: string[]): string {
const items = lines.map((line) => line.replace(/^\s*[-*]\s+/, ""));
return `<ul>${items.map((item) => `<li>${formatInline(item)}</li>`).join("")}</ul>`;
}
function renderParagraph(lines: string[]): string {
return `<p>${lines.map((line) => formatInline(line.trim())).join("<br>")}</p>`;
}
export function markdownToHtml(md: string): string {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const blocks: string[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index] ?? "";
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (trimmed.startsWith("```")) {
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !(lines[index] ?? "").trim().startsWith("```")) {
codeLines.push(lines[index] ?? "");
index += 1;
}
index += 1;
blocks.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
continue;
}
const heading = /^(#{1,4})\s+(.+)$/.exec(trimmed);
if (heading) {
const level = heading[1].length;
blocks.push(`<h${level}>${formatInline(heading[2])}</h${level}>`);
index += 1;
continue;
}
if (trimmed.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1] ?? "")) {
const tableLines = [line, lines[index + 1] ?? ""];
index += 2;
while (index < lines.length && (lines[index] ?? "").trim().includes("|")) {
tableLines.push(lines[index] ?? "");
index += 1;
}
blocks.push(renderTable(tableLines));
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
const listLines = [line];
index += 1;
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index] ?? "")) {
listLines.push(lines[index] ?? "");
index += 1;
}
blocks.push(renderList(listLines));
continue;
}
const paragraphLines = [line];
index += 1;
while (
index < lines.length &&
(lines[index] ?? "").trim() &&
!/^(#{1,4})\s+/.test((lines[index] ?? "").trim()) &&
!/^\s*[-*]\s+/.test(lines[index] ?? "") &&
!((lines[index] ?? "").trim().includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1] ?? ""))
) {
paragraphLines.push(lines[index] ?? "");
index += 1;
}
blocks.push(renderParagraph(paragraphLines));
}
return blocks.join("");
}
export function formatMarkdown(text: string): string {
let html = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^\s*[-*]\s+(.+)/gm, "<li>$1</li>")
.replace(/\n/g, "<br>");
// Wrap consecutive <li> items in <ul>
html = html.replace(/(<li>.*?<\/li>(<br>)?)+/g, (match) => {
return "<ul>" + match.replace(/<br>/g, "") + "</ul>";
});
return html;
}
return markdownToHtml(text);
}