| … | … | @@ -2,3 +2,4 @@ |
| 2 | 2 | export { Router, json, html, readBody, readJson, parsePagination } from './lib/router.js' |
| 3 | 3 | export { authMiddleware, createChallenge, verifyChallenge } from './lib/auth.js' |
| 4 | 4 | export { serveStatic } from './lib/static.js' |
| 5 | export { renderMarkdown, renderDiff, renderCode, renderPagination, renderBranchSelector, detectLanguage } from './lib/render.js' |
| … | … | @@ -0,0 +1,179 @@ |
| 1 | /** | |
| 2 | * Server-side rendering helpers | |
| 3 | * | |
| 4 | * Markdown, syntax highlighting, diff coloring, line numbers, pagination, branch selector. | |
| 5 | * These return HTML strings — usable from both SSR routes and SPA client code. | |
| 6 | */ | |
| 7 | ||
| 8 | // ── Markdown ───────────────────────────────────────────────── | |
| 9 | ||
| 10 | // Minimal markdown → HTML (no external dependency) | |
| 11 | // Supports: headings, bold, italic, code blocks, inline code, links, images, lists, blockquotes, paragraphs, hr | |
| 12 | export function renderMarkdown (text) { | |
| 13 | if (!text) return '' | |
| 14 | let html = esc(text) | |
| 15 | ||
| 16 | // Code blocks (``` ... ```) | |
| 17 | html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { | |
| 18 | return `<pre class="code-block${lang ? ' language-' + lang : ''}"><code>${code.trim()}</code></pre>` | |
| 19 | }) | |
| 20 | ||
| 21 | // Inline code | |
| 22 | html = html.replace(/`([^`]+)`/g, '<code>$1</code>') | |
| 23 | ||
| 24 | // Headings | |
| 25 | html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>') | |
| 26 | html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>') | |
| 27 | html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>') | |
| 28 | html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>') | |
| 29 | html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>') | |
| 30 | html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>') | |
| 31 | ||
| 32 | // Horizontal rule | |
| 33 | html = html.replace(/^---+$/gm, '<hr>') | |
| 34 | ||
| 35 | // Bold + italic | |
| 36 | html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>') | |
| 37 | html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') | |
| 38 | html = html.replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| 39 | ||
| 40 | // Images | |
| 41 | html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">') | |
| 42 | ||
| 43 | // Links | |
| 44 | html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>') | |
| 45 | ||
| 46 | // Blockquotes | |
| 47 | html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>') | |
| 48 | ||
| 49 | // Unordered lists | |
| 50 | html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li>$1</li>') | |
| 51 | html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>') | |
| 52 | ||
| 53 | // Ordered lists | |
| 54 | html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>') | |
| 55 | ||
| 56 | // Paragraphs (double newline) | |
| 57 | html = html.replace(/\n\n+/g, '</p><p>') | |
| 58 | html = '<p>' + html + '</p>' | |
| 59 | ||
| 60 | // Clean up empty paragraphs | |
| 61 | html = html.replace(/<p>\s*<\/p>/g, '') | |
| 62 | html = html.replace(/<p>\s*(<h[1-6]>)/g, '$1') | |
| 63 | html = html.replace(/(<\/h[1-6]>)\s*<\/p>/g, '$1') | |
| 64 | html = html.replace(/<p>\s*(<pre)/g, '$1') | |
| 65 | html = html.replace(/(<\/pre>)\s*<\/p>/g, '$1') | |
| 66 | html = html.replace(/<p>\s*(<ul>)/g, '$1') | |
| 67 | html = html.replace(/(<\/ul>)\s*<\/p>/g, '$1') | |
| 68 | html = html.replace(/<p>\s*(<hr>)/g, '$1') | |
| 69 | html = html.replace(/(<hr>)\s*<\/p>/g, '$1') | |
| 70 | html = html.replace(/<p>\s*(<blockquote>)/g, '$1') | |
| 71 | html = html.replace(/(<\/blockquote>)\s*<\/p>/g, '$1') | |
| 72 | ||
| 73 | return html | |
| 74 | } | |
| 75 | ||
| 76 | // ── Diff Coloring ──────────────────────────────────────────── | |
| 77 | ||
| 78 | export function renderDiff (patch) { | |
| 79 | if (!patch) return '' | |
| 80 | const lines = patch.split('\n') | |
| 81 | const out = [] | |
| 82 | ||
| 83 | for (const line of lines) { | |
| 84 | const escaped = esc(line) | |
| 85 | if (line.startsWith('+++') || line.startsWith('---')) { | |
| 86 | out.push(`<div class="diff-file">${escaped}</div>`) | |
| 87 | } else if (line.startsWith('@@')) { | |
| 88 | out.push(`<div class="diff-hunk">${escaped}</div>`) | |
| 89 | } else if (line.startsWith('+')) { | |
| 90 | out.push(`<div class="diff-add">${escaped}</div>`) | |
| 91 | } else if (line.startsWith('-')) { | |
| 92 | out.push(`<div class="diff-del">${escaped}</div>`) | |
| 93 | } else { | |
| 94 | out.push(`<div class="diff-ctx">${escaped}</div>`) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | return `<div class="diff-view">${out.join('')}</div>` | |
| 99 | } | |
| 100 | ||
| 101 | // ── Line Numbers ───────────────────────────────────────────── | |
| 102 | ||
| 103 | export function renderCode (content, opts = {}) { | |
| 104 | if (!content) return '' | |
| 105 | const lines = content.split('\n') | |
| 106 | const rows = lines.map((line, i) => { | |
| 107 | const num = i + 1 | |
| 108 | return `<tr><td class="line-num" data-line="${num}">${num}</td><td class="line-code"><code>${esc(line)}</code></td></tr>` | |
| 109 | }).join('') | |
| 110 | ||
| 111 | return `<table class="code-table"><tbody>${rows}</tbody></table>` | |
| 112 | } | |
| 113 | ||
| 114 | // ── Pagination ─────────────────────────────────────────────── | |
| 115 | ||
| 116 | export function renderPagination (total, page, pageSize, baseUrl) { | |
| 117 | const totalPages = Math.ceil(total / pageSize) | |
| 118 | if (totalPages <= 1) return '' | |
| 119 | ||
| 120 | const links = [] | |
| 121 | if (page > 1) { | |
| 122 | links.push(`<a href="${baseUrl}?page=${page - 1}" class="page-link">« Prev</a>`) | |
| 123 | } | |
| 124 | ||
| 125 | const start = Math.max(1, page - 2) | |
| 126 | const end = Math.min(totalPages, page + 2) | |
| 127 | for (let i = start; i <= end; i++) { | |
| 128 | if (i === page) { | |
| 129 | links.push(`<span class="page-link page-current">${i}</span>`) | |
| 130 | } else { | |
| 131 | links.push(`<a href="${baseUrl}?page=${i}" class="page-link">${i}</a>`) | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | if (page < totalPages) { | |
| 136 | links.push(`<a href="${baseUrl}?page=${page + 1}" class="page-link">Next »</a>`) | |
| 137 | } | |
| 138 | ||
| 139 | return `<div class="pagination">${links.join('')}</div>` | |
| 140 | } | |
| 141 | ||
| 142 | // ── Branch Selector ────────────────────────────────────────── | |
| 143 | ||
| 144 | export function renderBranchSelector (branches, current, baseUrl) { | |
| 145 | if (!branches || branches.length === 0) return '' | |
| 146 | const options = branches.map(b => { | |
| 147 | const selected = b === current ? ' selected' : '' | |
| 148 | return `<option value="${esc(b)}"${selected}>${esc(b)}</option>` | |
| 149 | }).join('') | |
| 150 | ||
| 151 | return `<select class="branch-selector" onchange="window.location.hash='${baseUrl}/' + this.value">${options}</select>` | |
| 152 | } | |
| 153 | ||
| 154 | // ── Helpers ────────────────────────────────────────────────── | |
| 155 | ||
| 156 | function esc (s) { | |
| 157 | if (!s) return '' | |
| 158 | return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 159 | } | |
| 160 | ||
| 161 | // File extension → language hint | |
| 162 | export function detectLanguage (filename) { | |
| 163 | if (!filename) return '' | |
| 164 | const ext = filename.split('.').pop().toLowerCase() | |
| 165 | const map = { | |
| 166 | js: 'javascript', mjs: 'javascript', cjs: 'javascript', | |
| 167 | ts: 'typescript', tsx: 'typescript', | |
| 168 | py: 'python', rb: 'ruby', rs: 'rust', go: 'go', | |
| 169 | c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cc: 'cpp', | |
| 170 | java: 'java', kt: 'kotlin', swift: 'swift', | |
| 171 | sh: 'bash', bash: 'bash', zsh: 'bash', | |
| 172 | json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', | |
| 173 | md: 'markdown', html: 'html', htm: 'html', xml: 'xml', | |
| 174 | css: 'css', scss: 'scss', less: 'less', | |
| 175 | sql: 'sql', gd: 'gdscript', gdshader: 'glsl', glsl: 'glsl', | |
| 176 | dockerfile: 'docker', makefile: 'makefile' | |
| 177 | } | |
| 178 | return map[ext] || '' | |
| 179 | } |
| … | … | @@ -0,0 +1,146 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { renderMarkdown, renderDiff, renderCode, renderPagination, renderBranchSelector, detectLanguage } from '../lib/render.js' | |
| 3 | ||
| 4 | // ── Markdown ────────────────────────────────────────────────── | |
| 5 | ||
| 6 | test('renderMarkdown — headings', (t) => { | |
| 7 | t.ok(renderMarkdown('# Title').includes('<h1>Title</h1>')) | |
| 8 | t.ok(renderMarkdown('## Sub').includes('<h2>Sub</h2>')) | |
| 9 | t.ok(renderMarkdown('### Third').includes('<h3>Third</h3>')) | |
| 10 | }) | |
| 11 | ||
| 12 | test('renderMarkdown — bold and italic', (t) => { | |
| 13 | t.ok(renderMarkdown('**bold**').includes('<strong>bold</strong>')) | |
| 14 | t.ok(renderMarkdown('*italic*').includes('<em>italic</em>')) | |
| 15 | }) | |
| 16 | ||
| 17 | test('renderMarkdown — code blocks', (t) => { | |
| 18 | const md = '```js\nconst x = 1\n```' | |
| 19 | const html = renderMarkdown(md) | |
| 20 | t.ok(html.includes('language-js')) | |
| 21 | t.ok(html.includes('const x = 1')) | |
| 22 | }) | |
| 23 | ||
| 24 | test('renderMarkdown — inline code', (t) => { | |
| 25 | t.ok(renderMarkdown('use `npm install`').includes('<code>npm install</code>')) | |
| 26 | }) | |
| 27 | ||
| 28 | test('renderMarkdown — links and images', (t) => { | |
| 29 | t.ok(renderMarkdown('[link](http://x.com)').includes('<a href="http://x.com">link</a>')) | |
| 30 | t.ok(renderMarkdown('').includes('<img src="img.png"')) | |
| 31 | }) | |
| 32 | ||
| 33 | test('renderMarkdown — lists', (t) => { | |
| 34 | const html = renderMarkdown('- item 1\n- item 2') | |
| 35 | t.ok(html.includes('<li>item 1</li>')) | |
| 36 | t.ok(html.includes('<ul>')) | |
| 37 | }) | |
| 38 | ||
| 39 | test('renderMarkdown — empty input', (t) => { | |
| 40 | t.is(renderMarkdown(''), '') | |
| 41 | t.is(renderMarkdown(null), '') | |
| 42 | }) | |
| 43 | ||
| 44 | test('renderMarkdown — escapes HTML', (t) => { | |
| 45 | const html = renderMarkdown('<script>alert("xss")</script>') | |
| 46 | t.absent(html.includes('<script>')) | |
| 47 | t.ok(html.includes('<script>')) | |
| 48 | }) | |
| 49 | ||
| 50 | // ── Diff ────────────────────────────────────────────────────── | |
| 51 | ||
| 52 | test('renderDiff — colors additions and deletions', (t) => { | |
| 53 | const patch = '--- a/file.js\n+++ b/file.js\n@@ -1,3 +1,3 @@\n context\n-old line\n+new line' | |
| 54 | const html = renderDiff(patch) | |
| 55 | t.ok(html.includes('diff-file')) | |
| 56 | t.ok(html.includes('diff-hunk')) | |
| 57 | t.ok(html.includes('diff-del')) | |
| 58 | t.ok(html.includes('diff-add')) | |
| 59 | t.ok(html.includes('diff-ctx')) | |
| 60 | }) | |
| 61 | ||
| 62 | test('renderDiff — empty', (t) => { | |
| 63 | t.is(renderDiff(''), '') | |
| 64 | t.is(renderDiff(null), '') | |
| 65 | }) | |
| 66 | ||
| 67 | // ── Code with line numbers ─────────────────────────────────── | |
| 68 | ||
| 69 | test('renderCode — line numbers', (t) => { | |
| 70 | const html = renderCode('line 1\nline 2\nline 3') | |
| 71 | t.ok(html.includes('data-line="1"')) | |
| 72 | t.ok(html.includes('data-line="3"')) | |
| 73 | t.ok(html.includes('line-num')) | |
| 74 | t.ok(html.includes('line-code')) | |
| 75 | t.ok(html.includes('code-table')) | |
| 76 | }) | |
| 77 | ||
| 78 | test('renderCode — escapes HTML in code', (t) => { | |
| 79 | const html = renderCode('<div>hello</div>') | |
| 80 | t.ok(html.includes('<div>')) | |
| 81 | }) | |
| 82 | ||
| 83 | test('renderCode — empty', (t) => { | |
| 84 | t.is(renderCode(''), '') | |
| 85 | }) | |
| 86 | ||
| 87 | // ── Pagination ─────────────────────────────────────────────── | |
| 88 | ||
| 89 | test('renderPagination — single page returns empty', (t) => { | |
| 90 | t.is(renderPagination(10, 1, 20, '/issues'), '') | |
| 91 | }) | |
| 92 | ||
| 93 | test('renderPagination — multiple pages', (t) => { | |
| 94 | const html = renderPagination(100, 3, 20, '/issues') | |
| 95 | t.ok(html.includes('page-link')) | |
| 96 | t.ok(html.includes('page-current')) | |
| 97 | t.ok(html.includes('Prev')) | |
| 98 | t.ok(html.includes('Next')) | |
| 99 | t.ok(html.includes('page=2')) | |
| 100 | t.ok(html.includes('page=4')) | |
| 101 | }) | |
| 102 | ||
| 103 | test('renderPagination — first page no prev', (t) => { | |
| 104 | const html = renderPagination(100, 1, 20, '/issues') | |
| 105 | t.absent(html.includes('Prev')) | |
| 106 | t.ok(html.includes('Next')) | |
| 107 | }) | |
| 108 | ||
| 109 | test('renderPagination — last page no next', (t) => { | |
| 110 | const html = renderPagination(100, 5, 20, '/issues') | |
| 111 | t.ok(html.includes('Prev')) | |
| 112 | t.absent(html.includes('Next')) | |
| 113 | }) | |
| 114 | ||
| 115 | // ── Branch selector ────────────────────────────────────────── | |
| 116 | ||
| 117 | test('renderBranchSelector — renders options', (t) => { | |
| 118 | const html = renderBranchSelector(['main', 'dev', 'release'], 'dev', '#/repos/foo/tree') | |
| 119 | t.ok(html.includes('<select')) | |
| 120 | t.ok(html.includes('branch-selector')) | |
| 121 | t.ok(html.includes('<option value="main"')) | |
| 122 | t.ok(html.includes('<option value="dev" selected')) | |
| 123 | t.ok(html.includes('<option value="release"')) | |
| 124 | }) | |
| 125 | ||
| 126 | test('renderBranchSelector — empty branches', (t) => { | |
| 127 | t.is(renderBranchSelector([], 'main', '/'), '') | |
| 128 | t.is(renderBranchSelector(null, 'main', '/'), '') | |
| 129 | }) | |
| 130 | ||
| 131 | // ── Language detection ─────────────────────────────────────── | |
| 132 | ||
| 133 | test('detectLanguage — common extensions', (t) => { | |
| 134 | t.is(detectLanguage('app.js'), 'javascript') | |
| 135 | t.is(detectLanguage('main.py'), 'python') | |
| 136 | t.is(detectLanguage('lib.rs'), 'rust') | |
| 137 | t.is(detectLanguage('style.css'), 'css') | |
| 138 | t.is(detectLanguage('data.json'), 'json') | |
| 139 | t.is(detectLanguage('script.sh'), 'bash') | |
| 140 | t.is(detectLanguage('player.gd'), 'gdscript') | |
| 141 | }) | |
| 142 | ||
| 143 | test('detectLanguage — unknown', (t) => { | |
| 144 | t.is(detectLanguage('file.xyz'), '') | |
| 145 | t.is(detectLanguage(''), '') | |
| 146 | }) |
| … | … | @@ -101,3 +101,51 @@ textarea { min-height: 100px; resize: vertical; } |
| 101 | 101 | |
| 102 | 102 | .loading { color: #8b949e; padding: 40px; text-align: center; } |
| 103 | 103 | .error { color: #f85149; padding: 16px; } |
| 104 | ||
| 105 | /* ── File tree ─────────────────────────────────────────────── */ | |
| 106 | .file-tree { width: 100%; border-collapse: collapse; } | |
| 107 | .file-tree th, .file-tree td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #21262d; } | |
| 108 | .file-tree th { color: #8b949e; font-size: 12px; } | |
| 109 | .file-tree a { color: #58a6ff; text-decoration: none; } | |
| 110 | .file-tree a:hover { text-decoration: underline; } | |
| 111 | ||
| 112 | /* ── Code display ──────────────────────────────────────────── */ | |
| 113 | .code-block { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px; overflow-x: auto; font-size: 13px; line-height: 1.5; white-space: pre; } | |
| 114 | .code-table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; font-size: 13px; line-height: 1.5; } | |
| 115 | .code-table td { padding: 0 12px; vertical-align: top; white-space: pre; } | |
| 116 | .line-num { color: #484f58; user-select: none; text-align: right; width: 1%; min-width: 40px; padding-right: 16px !important; border-right: 1px solid #21262d; } | |
| 117 | .line-code { padding-left: 16px !important; } | |
| 118 | .line-code code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; } | |
| 119 | ||
| 120 | /* ── Diff display ──────────────────────────────────────────── */ | |
| 121 | .diff-view { background: #161b22; border: 1px solid #30363d; border-radius: 6px; overflow-x: auto; font-family: monospace; font-size: 13px; line-height: 1.5; } | |
| 122 | .diff-view div { padding: 0 12px; white-space: pre; } | |
| 123 | .diff-add { background: rgba(35, 134, 54, 0.2); color: #7ee787; } | |
| 124 | .diff-del { background: rgba(248, 81, 73, 0.15); color: #ffa198; } | |
| 125 | .diff-hunk { color: #79c0ff; background: rgba(56, 139, 253, 0.1); padding: 4px 12px !important; } | |
| 126 | .diff-file { color: #f0f6fc; font-weight: 600; padding: 8px 12px !important; border-bottom: 1px solid #21262d; } | |
| 127 | .diff-ctx { color: #8b949e; } | |
| 128 | ||
| 129 | /* ── Pagination ────────────────────────────────────────────── */ | |
| 130 | .pagination { display: flex; gap: 4px; margin: 16px 0; justify-content: center; } | |
| 131 | .page-link { padding: 6px 12px; border: 1px solid #30363d; border-radius: 6px; color: #58a6ff; text-decoration: none; font-size: 13px; } | |
| 132 | .page-link:hover { background: #21262d; } | |
| 133 | .page-current { background: #238636; border-color: #238636; color: #fff; } | |
| 134 | ||
| 135 | /* ── Branch selector ───────────────────────────────────────── */ | |
| 136 | .branch-selector { padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 13px; cursor: pointer; width: auto; margin-bottom: 0; } | |
| 137 | .branch-selector:hover { border-color: #58a6ff; } | |
| 138 | ||
| 139 | /* ── Markdown rendered content ─────────────────────────────── */ | |
| 140 | .markdown-body { line-height: 1.6; } | |
| 141 | .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 24px; margin-bottom: 16px; border-bottom: 1px solid #21262d; padding-bottom: 8px; } | |
| 142 | .markdown-body h1 { font-size: 2em; } | |
| 143 | .markdown-body h2 { font-size: 1.5em; } | |
| 144 | .markdown-body h3 { font-size: 1.25em; } | |
| 145 | .markdown-body code { background: #161b22; padding: 2px 6px; border-radius: 4px; font-size: 85%; } | |
| 146 | .markdown-body pre code { background: none; padding: 0; } | |
| 147 | .markdown-body blockquote { border-left: 4px solid #30363d; padding-left: 16px; color: #8b949e; margin: 16px 0; } | |
| 148 | .markdown-body ul, .markdown-body ol { padding-left: 24px; margin: 8px 0; } | |
| 149 | .markdown-body img { max-width: 100%; border-radius: 6px; } | |
| 150 | .markdown-body a { color: #58a6ff; } | |
| 151 | .markdown-body hr { border: none; border-top: 1px solid #21262d; margin: 24px 0; } |