OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-web-ui commits / ea15417

Add rendering helpers: markdown, diff coloring, line numbers, pagination, branch selector
- renderMarkdown(text) — headings, bold, italic, code, links, images, lists, XSS-safe - renderDiff(patch) — green/red line coloring for unified diffs - renderCode(content) — table with line numbers - renderPagination(total, page, pageSize, baseUrl) — prev/next/page links - renderBranchSelector(branches, current, baseUrl) — dropdown widget - detectLanguage(filename) — file extension → language hint - CSS for all components (diff, code table, pagination, branch selector, markdown) - 21 new tests, 57 assertions (38 total tests across package) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ea15417a0a4acc3a7b911cbd196b6995409e8373
by plankatron <fleeky@gmail.com> · 2026-03-30T17:25:46.000Z

Changed files (4) +374 -0

index.js +1 -0
……@@ -2,3 +2,4 @@
22export { Router, json, html, readBody, readJson, parsePagination } from './lib/router.js'
33export { authMiddleware, createChallenge, verifyChallenge } from './lib/auth.js'
44export { serveStatic } from './lib/static.js'
5export { renderMarkdown, renderDiff, renderCode, renderPagination, renderBranchSelector, detectLanguage } from './lib/render.js'
lib/render.js +179 -0
……@@ -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
12export 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(/^&gt;\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
78export 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
103export 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
116export 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">&laquo; 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 &raquo;</a>`)
137 }
138
139 return `<div class="pagination">${links.join('')}</div>`
140}
141
142// ── Branch Selector ──────────────────────────────────────────
143
144export 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
156function esc (s) {
157 if (!s) return ''
158 return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
159}
160
161// File extension → language hint
162export 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}
test/render.test.js +146 -0
……@@ -0,0 +1,146 @@
1import test from 'brittle'
2import { renderMarkdown, renderDiff, renderCode, renderPagination, renderBranchSelector, detectLanguage } from '../lib/render.js'
3
4// ── Markdown ──────────────────────────────────────────────────
5
6test('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
12test('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
17test('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
24test('renderMarkdown — inline code', (t) => {
25 t.ok(renderMarkdown('use `npm install`').includes('<code>npm install</code>'))
26})
27
28test('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('![alt](img.png)').includes('<img src="img.png"'))
31})
32
33test('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
39test('renderMarkdown — empty input', (t) => {
40 t.is(renderMarkdown(''), '')
41 t.is(renderMarkdown(null), '')
42})
43
44test('renderMarkdown — escapes HTML', (t) => {
45 const html = renderMarkdown('<script>alert("xss")</script>')
46 t.absent(html.includes('<script>'))
47 t.ok(html.includes('&lt;script&gt;'))
48})
49
50// ── Diff ──────────────────────────────────────────────────────
51
52test('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
62test('renderDiff — empty', (t) => {
63 t.is(renderDiff(''), '')
64 t.is(renderDiff(null), '')
65})
66
67// ── Code with line numbers ───────────────────────────────────
68
69test('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
78test('renderCode — escapes HTML in code', (t) => {
79 const html = renderCode('<div>hello</div>')
80 t.ok(html.includes('&lt;div&gt;'))
81})
82
83test('renderCode — empty', (t) => {
84 t.is(renderCode(''), '')
85})
86
87// ── Pagination ───────────────────────────────────────────────
88
89test('renderPagination — single page returns empty', (t) => {
90 t.is(renderPagination(10, 1, 20, '/issues'), '')
91})
92
93test('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
103test('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
109test('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
117test('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
126test('renderBranchSelector — empty branches', (t) => {
127 t.is(renderBranchSelector([], 'main', '/'), '')
128 t.is(renderBranchSelector(null, 'main', '/'), '')
129})
130
131// ── Language detection ───────────────────────────────────────
132
133test('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
143test('detectLanguage — unknown', (t) => {
144 t.is(detectLanguage('file.xyz'), '')
145 t.is(detectLanguage(''), '')
146})
web/css/theme.css +48 -0
……@@ -101,3 +101,51 @@ textarea { min-height: 100px; resize: vertical; }
101101
102102.loading { color: #8b949e; padding: 40px; text-align: center; }
103103.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; }