| 1 | const fs = require('fs'); |
| 2 | const path = require('path'); |
| 3 | const config = require('./config'); |
| 4 | |
| 5 | const VIEWS = path.join(__dirname, '..', 'views'); |
| 6 | const viewCache = {}; |
| 7 | |
| 8 | function loadView(name) { |
| 9 | if (!viewCache[name]) { |
| 10 | viewCache[name] = fs.readFileSync(path.join(VIEWS, name), 'utf8'); |
| 11 | } |
| 12 | return viewCache[name]; |
| 13 | } |
| 14 | |
| 15 | function escape(s) { |
| 16 | return String(s).replace(/[&<>"']/g, c => |
| 17 | ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 18 | } |
| 19 | |
| 20 | function fill(template, vars) { |
| 21 | return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => { |
| 22 | const v = vars[key]; |
| 23 | return (v === undefined || v === null) ? '' : String(v); |
| 24 | }); |
| 25 | } |
| 26 | |
| 27 | function sidebar(session) { |
| 28 | if (session) { |
| 29 | const isAdmin = session.username === config.get('admin.user'); |
| 30 | const adminLink = isAdmin |
| 31 | ? '<div><a href="/admin/settings">⚙ Admin settings</a></div>' : ''; |
| 32 | return `<div class="logged-in"> |
| 33 | <div>Signed in as <a href="/${escape(session.username)}"><code>${escape(session.username)}</code></a></div> |
| 34 | ${adminLink} |
| 35 | <button id="logoutBtn" type="button">Sign out</button> |
| 36 | </div>`; |
| 37 | } |
| 38 | return `<form class="login-form" id="loginForm" autocomplete="off"> |
| 39 | <label for="lUser">Username:</label> |
| 40 | <input type="text" id="lUser" name="username" required autocomplete="username"> |
| 41 | <label for="lPass">Password:</label> |
| 42 | <input type="password" id="lPass" name="password" required autocomplete="current-password"> |
| 43 | <button type="submit">Sign in</button> |
| 44 | <div class="login-msg" id="loginMsg"></div> |
| 45 | <div style="margin-top:6px"><a href="/signup">Create account</a></div> |
| 46 | </form>`; |
| 47 | } |
| 48 | |
| 49 | function page(opts) { |
| 50 | const layout = loadView('layout.html'); |
| 51 | const { title, content, session, addressUrl, statusText, extraSidebar } = opts; |
| 52 | return fill(layout, { |
| 53 | TITLE: escape((title ? title + ' - ' : '') + config.get('site.name')), |
| 54 | SITE_NAME: escape(config.get('site.name')), |
| 55 | TAGLINE: escape(config.get('site.tagline')), |
| 56 | MARQUEE_TEXT: config.get('ui.marqueeText'), |
| 57 | FOOTER_TEXT: escape(config.get('ui.footerText')), |
| 58 | ADDRESS_URL: escape(addressUrl || '/'), |
| 59 | CONTENT: content || '', |
| 60 | USER_PANEL: sidebar(session), |
| 61 | EXTRA_SIDEBAR: extraSidebar || '', |
| 62 | STATUS_TEXT: escape(statusText || 'Done') |
| 63 | }); |
| 64 | } |
| 65 | |
| 66 | function breadcrumb(owner, repo, ref, treePath) { |
| 67 | const parts = treePath ? treePath.split('/').filter(Boolean) : []; |
| 68 | let html = `<a href="/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}">${escape(repo)}</a>`; |
| 69 | let accumulated = ''; |
| 70 | for (let i = 0; i < parts.length; i++) { |
| 71 | accumulated += (accumulated ? '/' : '') + parts[i]; |
| 72 | if (i < parts.length - 1) { |
| 73 | html += ` / <a href="/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}/${escape(accumulated)}">${escape(parts[i])}</a>`; |
| 74 | } else { |
| 75 | html += ` / <strong>${escape(parts[i])}</strong>`; |
| 76 | } |
| 77 | } |
| 78 | return html; |
| 79 | } |
| 80 | |
| 81 | function treeRows(entries, owner, repo, ref, treePath) { |
| 82 | const base = `/${escape(owner)}/${escape(repo)}`; |
| 83 | const pathPrefix = treePath ? treePath + '/' : ''; |
| 84 | const rows = entries.map(e => { |
| 85 | const fullPath = pathPrefix + e.name; |
| 86 | const isDir = e.type === 'tree'; |
| 87 | const href = isDir |
| 88 | ? `${base}/tree/${escape(ref)}/${escape(fullPath)}` |
| 89 | : `${base}/blob/${escape(ref)}/${escape(fullPath)}`; |
| 90 | const icon = isDir ? '📁' : '📄'; |
| 91 | return `<tr> |
| 92 | <td class="tree-icon">${icon}</td> |
| 93 | <td class="tree-name"><a href="${href}">${escape(e.name)}${isDir ? '/' : ''}</a></td> |
| 94 | </tr>`; |
| 95 | }); |
| 96 | return rows.join('\n'); |
| 97 | } |
| 98 | |
| 99 | function commitRows(commits, owner, repo) { |
| 100 | return commits.map(c => { |
| 101 | const sha7 = c.hash.slice(0, 7); |
| 102 | const href = `/${escape(owner)}/${escape(repo)}/commit/${escape(c.hash)}`; |
| 103 | const date = c.date ? c.date.slice(0, 10) : ''; |
| 104 | return `<tr> |
| 105 | <td class="commit-sha"><a href="${href}" title="${escape(c.hash)}">${escape(sha7)}</a></td> |
| 106 | <td class="commit-msg">${escape(c.message)}</td> |
| 107 | <td class="commit-author">${escape(c.author)}</td> |
| 108 | <td class="commit-date">${escape(date)}</td> |
| 109 | </tr>`; |
| 110 | }).join('\n'); |
| 111 | } |
| 112 | |
| 113 | function blobLines(content) { |
| 114 | const lines = content.split('\n'); |
| 115 | const rows = lines.map((line, i) => { |
| 116 | const n = i + 1; |
| 117 | const safe = escape(line); |
| 118 | return `<tr><td class="blob-num" id="L${n}"><a href="#L${n}">${n}</a></td><td class="blob-line">${safe || ' '}</td></tr>`; |
| 119 | }).join(''); |
| 120 | return `<table class="blob-table">${rows}</table>`; |
| 121 | } |
| 122 | |
| 123 | function fileTreeSidebar(entries, owner, repo, ref, currentFile) { |
| 124 | const items = entries.map(e => { |
| 125 | const isDir = e.type === 'tree'; |
| 126 | const href = isDir |
| 127 | ? `/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}/${escape(e.name)}` |
| 128 | : `/${escape(owner)}/${escape(repo)}/blob/${escape(ref)}/${escape(e.name)}`; |
| 129 | const active = (currentFile === e.name) ? ' class="active"' : ''; |
| 130 | const icon = isDir ? '📁' : '📄'; |
| 131 | return `<li${active}>${icon} <a href="${href}">${escape(e.name)}${isDir ? '/' : ''}</a></li>`; |
| 132 | }).join(''); |
| 133 | const rootLink = `<div class="tree-sidebar-root"><a href="/${escape(owner)}/${escape(repo)}">↩ ${escape(repo)}</a></div>`; |
| 134 | return `<div class="panel"><div class="head">📁 Files</div><div class="body tree-sidebar-body">${rootLink}<ul class="tree-sidebar">${items}</ul></div></div>`; |
| 135 | } |
| 136 | |
| 137 | const LANG_COLOR = { |
| 138 | JavaScript: '#f1e05a', TypeScript: '#3178c6', Python: '#3572A5', |
| 139 | Ruby: '#701516', Go: '#00ADD8', Rust: '#dea584', Java: '#b07219', |
| 140 | C: '#555555', 'C++': '#f34b7d', 'C#': '#178600', PHP: '#4F5D95', |
| 141 | Shell: '#89e051', PowerShell: '#012456', Batch: '#C1F12E', |
| 142 | HTML: '#e34c26', CSS: '#563d7c', SCSS: '#c6538c', Sass: '#a53b70', Less: '#1d365d', |
| 143 | Markdown: '#083fa1', JSON: '#292929', YAML: '#cb171e', |
| 144 | XML: '#0060ac', TOML: '#9c4221', INI: '#d1dbe0', Config: '#6d8086', |
| 145 | SQL: '#e38c00', |
| 146 | Vue: '#41b883', Svelte: '#ff3e00', |
| 147 | Swift: '#F05138', Kotlin: '#A97BFF', Dart: '#00B4AB', |
| 148 | Elixir: '#6e4a7e', Erlang: '#B83998', |
| 149 | Scala: '#c22d40', Clojure: '#db5855', Elm: '#60B5CC', |
| 150 | Lua: '#000080', Perl: '#0298c3', R: '#198CE7', |
| 151 | Text: '#cccccc', TeX: '#3D6117', Other: '#888888' |
| 152 | }; |
| 153 | |
| 154 | function langColor(name) { return LANG_COLOR[name] || LANG_COLOR.Other; } |
| 155 | |
| 156 | function langBar(stats) { |
| 157 | if (!stats || stats.length === 0) return ''; |
| 158 | const top = stats.slice(0, 6); |
| 159 | const segs = top.map(s => |
| 160 | `<span class="lang-segment" style="background:${langColor(s.lang)};width:${s.pct.toFixed(2)}%" title="${escape(s.lang)} ${s.pct.toFixed(1)}%"></span>` |
| 161 | ).join(''); |
| 162 | const labels = top.map(s => |
| 163 | `<span class="lang-label"><span class="lang-dot" style="background:${langColor(s.lang)}"></span> ${escape(s.lang)} <span class="lang-pct">${s.pct.toFixed(1)}%</span></span>` |
| 164 | ).join(''); |
| 165 | return `<div class="lang-section"><div class="lang-bar">${segs}</div><div class="lang-labels">${labels}</div></div>`; |
| 166 | } |
| 167 | |
| 168 | module.exports = { |
| 169 | escape, fill, page, loadView, |
| 170 | sidebar, breadcrumb, treeRows, commitRows, blobLines, fileTreeSidebar, |
| 171 | langBar, langColor |
| 172 | }; |
| 173 | |