CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | /**
* Markdown rendering — server-side, sanitized.
*/
import { Marked } from "marked";
import { highlightCode } from "./highlight";
const marked = new Marked({
gfm: true,
breaks: false,
renderer: {
code({ text, lang }) {
if (lang) {
const { html } = highlightCode(text, `code.${lang}`);
return `<pre><code class="language-${escapeAttr(lang)}">${html}</code></pre>`;
}
return `<pre><code>${escapeHtml(text)}</code></pre>`;
},
link({ href, title, text }) {
// Sanitize: only allow http(s) and relative links
const safeHref =
href.startsWith("http://") ||
href.startsWith("https://") ||
href.startsWith("/") ||
href.startsWith("#") ||
href.startsWith(".")
? href
: "#";
const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
return `<a href="${escapeAttr(safeHref)}"${titleAttr}>${text}</a>`;
},
image({ href, title, text }) {
const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
return `<img src="${escapeAttr(href)}" alt="${escapeAttr(text)}"${titleAttr} style="max-width: 100%;" loading="lazy" />`;
},
},
});
export function renderMarkdown(source: string): string {
const html = marked.parse(source);
if (typeof html !== "string") return "";
return html;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function escapeAttr(str: string): string {
return str
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}
export const markdownCss = `
.markdown-body {
font-size: 15px;
line-height: 1.7;
word-wrap: break-word;
padding: 20px;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.markdown-body h1 { font-size: 2em; }
.markdown-body h2 { font-size: 1.5em; }
.markdown-body h3 { font-size: 1.25em; border-bottom: none; }
.markdown-body h4 { font-size: 1em; border-bottom: none; }
.markdown-body p { margin-bottom: 16px; }
.markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 16px; }
.markdown-body li { margin-bottom: 4px; }
.markdown-body code {
font-family: var(--font-mono);
font-size: 85%;
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: 3px;
}
.markdown-body pre {
padding: 16px;
overflow-x: auto;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
line-height: 1.5;
}
.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 13px;
}
.markdown-body blockquote {
padding: 0 1em;
color: var(--text-muted);
border-left: 3px solid var(--border);
margin-bottom: 16px;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.markdown-body table th, .markdown-body table td {
padding: 8px 16px;
border: 1px solid var(--border);
text-align: left;
}
.markdown-body table th { background: var(--bg-secondary); font-weight: 600; }
.markdown-body hr {
height: 2px;
background: var(--border);
border: none;
margin: 24px 0;
}
.markdown-body a { color: var(--text-link); }
.markdown-body img { max-width: 100%; border-radius: var(--radius); }
.markdown-body .task-list-item { list-style: none; }
.markdown-body .task-list-item input { margin-right: 8px; }
`;
|