/**
* Server-side rendering helpers
*
* Markdown, syntax highlighting, diff coloring, line numbers, pagination, branch selector.
* These return HTML strings — usable from both SSR routes and SPA client code.
*/
// ── Markdown ─────────────────────────────────────────────────
// Minimal markdown → HTML (no external dependency)
// Supports: headings, bold, italic, code blocks, inline code, links, images, lists, blockquotes, paragraphs, hr
export function renderMarkdown (text) {
if (!text) return ''
let html = esc(text)
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
return `<pre class="code-block${lang ? ' language-' + lang : ''}"><code>${code.trim()}</code></pre>`
})
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Headings
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
// Horizontal rule
html = html.replace(/^---+$/gm, '<hr>')
// Bold + italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Images
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// Blockquotes
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>')
// Unordered lists
html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li>$1</li>')
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
// Ordered lists
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>')
// Paragraphs (double newline)
html = html.replace(/\n\n+/g, '</p><p>')
html = '<p>' + html + '</p>'
// Clean up empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '')
html = html.replace(/<p>\s*(<h[1-6]>)/g, '$1')
html = html.replace(/(<\/h[1-6]>)\s*<\/p>/g, '$1')
html = html.replace(/<p>\s*(<pre)/g, '$1')
html = html.replace(/(<\/pre>)\s*<\/p>/g, '$1')
html = html.replace(/<p>\s*(<ul>)/g, '$1')
html = html.replace(/(<\/ul>)\s*<\/p>/g, '$1')
html = html.replace(/<p>\s*(<hr>)/g, '$1')
html = html.replace(/(<hr>)\s*<\/p>/g, '$1')
html = html.replace(/<p>\s*(<blockquote>)/g, '$1')
html = html.replace(/(<\/blockquote>)\s*<\/p>/g, '$1')
return html
}
// ── Diff Coloring ────────────────────────────────────────────
export function renderDiff (patch) {
if (!patch) return ''
const lines = patch.split('\n')
const out = []
for (const line of lines) {
const escaped = esc(line)
if (line.startsWith('+++') || line.startsWith('---')) {
out.push(`<div class="diff-file">${escaped}</div>`)
} else if (line.startsWith('@@')) {
out.push(`<div class="diff-hunk">${escaped}</div>`)
} else if (line.startsWith('+')) {
out.push(`<div class="diff-add">${escaped}</div>`)
} else if (line.startsWith('-')) {
out.push(`<div class="diff-del">${escaped}</div>`)
} else {
out.push(`<div class="diff-ctx">${escaped}</div>`)
}
}
return `<div class="diff-view">${out.join('')}</div>`
}
// ── Line Numbers ─────────────────────────────────────────────
export function renderCode (content, opts = {}) {
if (!content) return ''
const lines = content.split('\n')
const rows = lines.map((line, i) => {
const num = i + 1
return `<tr><td class="line-num" data-line="${num}">${num}</td><td class="line-code"><code>${esc(line)}</code></td></tr>`
}).join('')
return `<table class="code-table"><tbody>${rows}</tbody></table>`
}
// ── Pagination ───────────────────────────────────────────────
export function renderPagination (total, page, pageSize, baseUrl) {
const totalPages = Math.ceil(total / pageSize)
if (totalPages <= 1) return ''
const links = []
if (page > 1) {
links.push(`<a href="${baseUrl}?page=${page - 1}" class="page-link">« Prev</a>`)
}
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
for (let i = start; i <= end; i++) {
if (i === page) {
links.push(`<span class="page-link page-current">${i}</span>`)
} else {
links.push(`<a href="${baseUrl}?page=${i}" class="page-link">${i}</a>`)
}
}
if (page < totalPages) {
links.push(`<a href="${baseUrl}?page=${page + 1}" class="page-link">Next »</a>`)
}
return `<div class="pagination">${links.join('')}</div>`
}
// ── Branch Selector ──────────────────────────────────────────
export function renderBranchSelector (branches, current, baseUrl) {
if (!branches || branches.length === 0) return ''
const options = branches.map(b => {
const selected = b === current ? ' selected' : ''
return `<option value="${esc(b)}"${selected}>${esc(b)}</option>`
}).join('')
return `<select class="branch-selector" onchange="window.location.hash='${baseUrl}/' + this.value">${options}</select>`
}
// ── Helpers ──────────────────────────────────────────────────
function esc (s) {
if (!s) return ''
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
// File extension → language hint
export function detectLanguage (filename) {
if (!filename) return ''
const ext = filename.split('.').pop().toLowerCase()
const map = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript',
py: 'python', rb: 'ruby', rs: 'rust', go: 'go',
c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cc: 'cpp',
java: 'java', kt: 'kotlin', swift: 'swift',
sh: 'bash', bash: 'bash', zsh: 'bash',
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
md: 'markdown', html: 'html', htm: 'html', xml: 'xml',
css: 'css', scss: 'scss', less: 'less',
sql: 'sql', gd: 'gdscript', gdshader: 'glsl', glsl: 'glsl',
dockerfile: 'docker', makefile: 'makefile'
}
return map[ext] || ''
}