spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/web/routes.mjs8 * Purpose : Server-rendered web UI — every HTML page of the forge9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import nunjucks from 'nunjucks';14import { join, posix } from 'node:path';15import { PROJECT_ROOT } from '../config.mjs';16import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs';17import { search } from '../lib/search.mjs';18import { contributionCalendar } from '../stats/activity.mjs';19import { languageColor } from '../stats/languages.mjs';20import { renderMarkdown } from '../render/markdown.mjs';21import { highlight, highlightFile, langForPath } from '../render/highlight.mjs';22import { renderMonogramPng } from '../render/og-image.mjs';23import { sendArchive } from '../git/archive.mjs';24import { ASSET_MIME, isValidAssetName, isValidTagName } from '../git/releases.mjs';25import {26 relativeTime, formatBytes, escapeHtml, identiconSvg, isValidRepoName,27} from '../lib/util.mjs';2829const BLOB_RENDER_LIMIT = 1024 * 1024; // 1 MB — beyond this, offer raw only30const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif']);31const RAW_MIME = {32 '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',33 '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.svg': 'image/svg+xml',34 '.pdf': 'application/pdf', '.json': 'application/json', '.zip': 'application/zip',35 '.gz': 'application/gzip', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wasm': 'application/wasm',36 '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf',37};3839/** Build the Nunjucks environment with all filters/globals. */40export function setupViews(config) {41 const env = new nunjucks.Environment(42 new nunjucks.FileSystemLoader(join(PROJECT_ROOT, 'src/web/views'), { noCache: config.isDev }),43 { autoescape: true },44 );45 env.addFilter('reltime', (value) => (value ? relativeTime(value) : ''));46 env.addFilter('bytes', (value) => formatBytes(Number(value) || 0));47 env.addFilter('num', (value) => new Intl.NumberFormat('en-US').format(Number(value) || 0));48 env.addFilter('langcolor', (name) => languageColor(name));49 env.addFilter('identicon', (email, size) => new nunjucks.runtime.SafeString(identiconSvg(String(email ?? ''), size ?? 20)));50 env.addFilter('datefull', (value) => {51 if (!value) return '';52 const d = new Date(value);53 return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });54 });55 env.addGlobal('owner', config.owner);56 env.addGlobal('publicUrl', config.publicUrl);57 return env;58}5960/** Render a view with the standard page envelope. */61function makeRender(env, config) {62 return function render(reply, template, data) {63 const html = env.render(template, {64 ...data,65 canonical: `${config.publicUrl}${data.canonicalPath ?? '/'}`,66 ogImage: `${config.publicUrl}${data.ogImagePath ?? '/og/site.png'}`,67 });68 reply.type('text/html; charset=utf-8');69 return reply.send(html);70 };71}7273/** Shared header data for every repo page (tabs, refs, counts). */74async function repoShell(ctx, name) {75 const overview = await repoOverview(ctx, name);76 if (!overview) return null;77 const branches = await ctx.repos.branches(name);78 const tags = await ctx.repos.tags(name);79 return { overview, branches, tags };80}8182/** Breadcrumb segments for a tree path. */83function breadcrumbs(repo, kind, ref, path) {84 const crumbs = [];85 if (!path) return crumbs;86 const parts = path.split('/');87 let acc = '';88 for (const part of parts) {89 acc = acc === '' ? part : `${acc}/${part}`;90 crumbs.push({ name: part, href: `/${repo}/${kind}/${ref}/${acc}` });91 }92 return crumbs;93}9495/** Highlight the ordered lines of one diff hunk with the file's language. */96async function highlightHunk(lines, lang) {97 const fallback = () => lines.map((l) => escapeHtml(l.text));98 if (!lang || lines.length > 500) return fallback();99 try {100 const html = await highlight(lines.map((l) => l.text).join('\n'), lang);101 const parts = html.split('<span class="line">').slice(1);102 if (parts.length !== lines.length) return fallback();103 return parts.map((part) =>104 part105 .replace(/<\/span>\s*<\/code><\/pre>\s*$/s, '')106 .replace(/<\/span>\n?$/s, ''),107 );108 } catch {109 return fallback();110 }111}112113/** Prepare a parsed diff for the template (adds highlighted line HTML). */114async function prepareDiff(files) {115 for (const file of files) {116 const lang = langForPath(file.newPath || file.oldPath);117 for (const hunk of file.hunks) {118 const content = hunk.lines.filter((l) => l.type !== 'meta');119 const highlighted = await highlightHunk(content, lang);120 let i = 0;121 for (const line of hunk.lines) {122 line.html = line.type === 'meta' ? escapeHtml(line.text) : highlighted[i++];123 }124 }125 }126 return files;127}128129/**130 * Register all HTML routes + error pages.131 * @param {import('fastify').FastifyInstance} app132 * @param {object} ctx133 */134export async function registerWeb(app, ctx) {135 const { config } = ctx;136 const env = setupViews(config);137 const render = makeRender(env, config);138 ctx.views = env;139140 /** Resolve `:repo` param or render 404. Returns name or null. */141 function repoParam(request, reply) {142 const name = request.params.repo;143 if (!isValidRepoName(name) || !ctx.repos.exists(name)) {144 notFound(request, reply);145 return null;146 }147 return name;148 }149150 function notFound(request, reply) {151 if (request.url.startsWith('/api/')) {152 return reply.code(404).send({ error: { code: 'not_found', message: 'no such endpoint' } });153 }154 reply.code(404);155 return render(reply, 'error.njk', {156 title: '404 · SPB Git',157 status: 404,158 message: 'This ref does not exist in any timeline.',159 canonicalPath: request.url,160 });161 }162163 app.setNotFoundHandler((request, reply) => notFound(request, reply));164165 app.setErrorHandler((error, request, reply) => {166 request.log.error({ err: error }, 'unhandled error');167 if (error.statusCode === 429) {168 return reply.code(429).send({ error: { code: 'rate_limited', message: 'Too many requests — slow down.' } });169 }170 if (request.url.startsWith('/api/')) {171 return reply.code(500).send({ error: { code: 'internal', message: 'internal server error' } });172 }173 reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 500);174 return render(reply, 'error.njk', {175 title: '500 · SPB Git',176 status: 500,177 message: 'Something went sideways in the reflog. The incident has been logged.',178 canonicalPath: request.url,179 });180 });181182 // ───────────────────────── home ─────────────────────────183 app.get('/', async (request, reply) => {184 const [overviews, stats, heatmap] = await Promise.all([185 allOverviews(ctx),186 siteStats(ctx),187 contributionCalendar(ctx),188 ]);189 const pinned = overviews.filter((o) => o.pinned).slice(0, 6);190 const activity = ctx.activity.recent(15);191 const topics = [...new Set(overviews.flatMap((o) => o.topics))].sort();192 const languages = [...new Set(overviews.map((o) => o.topLanguage).filter(Boolean))].sort();193 return render(reply, 'home.njk', {194 title: 'SPB Git — Simon-Pierre Boucher',195 description: 'Personal git platform of Simon-Pierre Boucher — every repository, public and clonable.',196 overviews,197 pinned,198 stats,199 activity,200 topics,201 languages,202 heatmap,203 canonicalPath: '/',204 });205 });206207 // ───────────────────────── search ─────────────────────────208 app.get('/search', async (request, reply) => {209 const q = String(request.query.q ?? '').slice(0, 120);210 const results = q ? await search(ctx, q) : { repos: [], readmes: [] };211 return render(reply, 'search.njk', {212 title: q ? `Search: ${q} · SPB Git` : 'Search · SPB Git',213 description: `Search results for ${q}`,214 q,215 results,216 canonicalPath: `/search`,217 });218 });219220 // ───────────────────────── meta/polish routes ─────────────────────────221 app.get('/robots.txt', async (_request, reply) => {222 reply.type('text/plain');223 return [224 'User-agent: *',225 'Allow: /',226 'Disallow: /archive/',227 'Disallow: /internal/',228 `Sitemap: ${config.publicUrl}/sitemap.xml`,229 '',230 ].join('\n');231 });232233 app.get('/sitemap.xml', async (_request, reply) => {234 const overviews = await allOverviews(ctx);235 const urls = [236 { loc: `${config.publicUrl}/`, priority: '1.0' },237 ...overviews.map((o) => ({238 loc: `${config.publicUrl}/${o.name}`,239 lastmod: o.lastPush ? new Date(o.lastPush).toISOString() : undefined,240 priority: '0.8',241 })),242 ];243 const body = urls244 .map((u) =>245 [246 ' <url>',247 ` <loc>${escapeHtml(u.loc)}</loc>`,248 u.lastmod ? ` <lastmod>${u.lastmod}</lastmod>` : null,249 ` <priority>${u.priority}</priority>`,250 ' </url>',251 ].filter(Boolean).join('\n'),252 )253 .join('\n');254 reply.type('application/xml');255 return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;256 });257258 app.get('/feed.atom', async (_request, reply) => {259 const events = ctx.activity.recent(30);260 const updated = events[0]?.at ?? new Date().toISOString();261 const entries = events262 .map((e) => {263 const what = e.deleted264 ? `deleted ${e.refType} ${e.ref}`265 : `pushed ${e.commits} commit${e.commits === 1 ? '' : 's'} to ${e.ref}`;266 const id = `${config.publicUrl}/${e.repo}#${e.at}`;267 return [268 ' <entry>',269 ` <title>${escapeHtml(`${e.repo}: ${what}`)}</title>`,270 ` <link href="${escapeHtml(`${config.publicUrl}/${e.repo}`)}"/>`,271 ` <id>${escapeHtml(id)}</id>`,272 ` <updated>${escapeHtml(e.at)}</updated>`,273 ` <author><name>${escapeHtml(config.owner.name)}</name></author>`,274 ` <summary>${escapeHtml(`${config.owner.name} ${what} in ${e.repo}`)}</summary>`,275 ' </entry>',276 ].join('\n');277 })278 .join('\n');279 reply.type('application/atom+xml');280 return [281 '<?xml version="1.0" encoding="utf-8"?>',282 '<feed xmlns="http://www.w3.org/2005/Atom">',283 ` <title>SPB Git — activity</title>`,284 ` <link href="${config.publicUrl}/feed.atom" rel="self"/>`,285 ` <link href="${config.publicUrl}/"/>`,286 ` <id>${config.publicUrl}/feed.atom</id>`,287 ` <updated>${escapeHtml(updated)}</updated>`,288 ` <author><name>${escapeHtml(config.owner.name)}</name><email>${escapeHtml(config.owner.email)}</email></author>`,289 entries,290 '</feed>',291 '',292 ].join('\n');293 });294295 // Favicons + OG images (all generated, cached).296 app.get('/favicon.png', async (_request, reply) => {297 const path = ctx.cache.path('global', 'favicon-32.png');298 let buf = ctx.cache.getBuffer(path);299 if (!buf) {300 buf = await renderMonogramPng(64);301 ctx.cache.set(path, buf);302 }303 reply.type('image/png').header('Cache-Control', 'public, max-age=604800');304 return reply.send(buf);305 });306 app.get('/apple-touch-icon.png', async (_request, reply) => {307 const path = ctx.cache.path('global', 'favicon-180.png');308 let buf = ctx.cache.getBuffer(path);309 if (!buf) {310 buf = await renderMonogramPng(180);311 ctx.cache.set(path, buf);312 }313 reply.type('image/png').header('Cache-Control', 'public, max-age=604800');314 return reply.send(buf);315 });316 app.get('/favicon.ico', async (_request, reply) => reply.redirect('/favicon.png', 301));317318 app.get('/og/site.png', async (_request, reply) => {319 const buf = await ctx.ogImage(null);320 reply.type('image/png').header('Cache-Control', 'public, max-age=86400');321 return reply.send(buf);322 });323 app.get('/og/:file', async (request, reply) => {324 const file = String(request.params.file ?? '');325 if (!file.endsWith('.png')) return notFound(request, reply);326 const name = file.slice(0, -4);327 if (!isValidRepoName(name) || !ctx.repos.exists(name)) return notFound(request, reply);328 const buf = await ctx.ogImage(name);329 reply.type('image/png').header('Cache-Control', 'public, max-age=3600');330 return reply.send(buf);331 });332333 // ───────────────────────── raw files ─────────────────────────334 app.get('/raw/:repo/*', async (request, reply) => {335 const name = repoParam(request, reply);336 if (!name) return reply;337 const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);338 if (!resolved || resolved.path === '') return notFound(request, reply);339 const blob = await ctx.repos.blob(name, resolved.sha, resolved.path);340 if (!blob) return notFound(request, reply);341 const ext = posix.extname(resolved.path).toLowerCase();342 let mime = RAW_MIME[ext];343 if (!mime) mime = blob.binary ? 'application/octet-stream' : 'text/plain; charset=utf-8';344 // Stored-XSS guard: never serve repo HTML as HTML.345 if (['.html', '.htm', '.xhtml'].includes(ext)) mime = 'text/plain; charset=utf-8';346 const immutable = /^[0-9a-f]{40}$/.test(resolved.ref) || /^[0-9a-f]{7,40}$/.test(resolved.ref);347 reply348 .type(mime)349 .header('X-Content-Type-Options', 'nosniff')350 .header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")351 .header('Cache-Control', immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=60');352 return reply.send(blob.content);353 });354355 // ───────────────────────── archives ─────────────────────────356 app.get('/archive/:repo/*', async (request, reply) => {357 const name = repoParam(request, reply);358 if (!name) return reply;359 const splat = String(request.params['*'] ?? '');360 const match = /^(.+?)\.(zip|tar\.gz)$/.exec(splat);361 if (!match) return notFound(request, reply);362 const [, refPart, format] = match;363 const resolved = await ctx.repos.resolveRefAndPath(name, refPart);364 if (!resolved || resolved.path !== '') return notFound(request, reply);365 const safeRef = refPart.replaceAll('/', '-');366 return sendArchive(ctx, name, resolved.sha, format, reply, `${name}-${safeRef}`);367 });368369 // ───────────────────────── release asset downloads ─────────────────────────370 app.get('/releases/:repo/:tag/:asset', async (request, reply) => {371 const name = repoParam(request, reply);372 if (!name) return reply;373 const { tag, asset } = request.params;374 if (!isValidTagName(tag) || !isValidAssetName(asset)) return notFound(request, reply);375 const info = ctx.releases.stat(name, tag, asset);376 if (!info) return notFound(request, reply);377 const ext = posix.extname(asset).toLowerCase();378 reply379 .type(ASSET_MIME[ext] ?? 'application/octet-stream')380 .header('Content-Length', info.size)381 .header('Content-Disposition', `attachment; filename="${asset}"`)382 .header('X-Content-Type-Options', 'nosniff')383 .header('Cache-Control', 'public, max-age=3600');384 if (info.sha256) reply.header('X-Checksum-Sha256', info.sha256);385 return reply.send(ctx.releases.readStream(name, tag, asset));386 });387388 // ───────────────────────── repo home ─────────────────────────389 app.get('/:repo', async (request, reply) => {390 const name = repoParam(request, reply);391 if (!name) return reply;392 const shell = await repoShell(ctx, name);393 const { overview } = shell;394 let entries = [];395 let lastCommits = {};396 let readme = null;397 if (!overview.empty) {398 entries = (await ctx.repos.tree(name, overview.head, '')) ?? [];399 const lcCache = ctx.cache.repoPath(name, overview.head, 'lastcommits-root.json');400 lastCommits = await ctx.cache.remember(lcCache, () =>401 ctx.repos.lastCommits(name, overview.head, '', entries.map((e) => e.name)),402 );403 readme = await ctx.renderReadme(name, overview.head);404 }405 return render(reply, 'repo.njk', {406 title: `${name} · SPB Git`,407 description: overview.description || `${name} — a repository by ${config.owner.name}`,408 ...shell,409 tab: 'code',410 currentRef: overview.defaultBranch,411 entries,412 lastCommits,413 readme,414 canonicalPath: `/${name}`,415 ogImagePath: `/og/${name}.png`,416 });417 });418419 // ───────────────────────── tree browsing ─────────────────────────420 app.get('/:repo/tree/*', async (request, reply) => {421 const name = repoParam(request, reply);422 if (!name) return reply;423 const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);424 if (!resolved) return notFound(request, reply);425 const entries = await ctx.repos.tree(name, resolved.sha, resolved.path);426 if (!entries) return notFound(request, reply);427 const shell = await repoShell(ctx, name);428 const lcKey = `lastcommits-${resolved.path.replaceAll('/', '_') || 'root'}.json`;429 const lastCommits = await ctx.cache.remember(ctx.cache.repoPath(name, resolved.sha, lcKey), () =>430 ctx.repos.lastCommits(name, resolved.sha, resolved.path, entries.map((e) => e.name)),431 );432 return render(reply, 'tree.njk', {433 title: `${resolved.path || name} at ${resolved.ref} · ${name} · SPB Git`,434 description: `Browse ${resolved.path || 'the root tree'} of ${name} at ${resolved.ref}`,435 ...shell,436 tab: 'code',437 currentRef: resolved.ref,438 path: resolved.path,439 parentPath: resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '',440 crumbs: breadcrumbs(name, 'tree', resolved.ref, resolved.path),441 entries,442 lastCommits,443 canonicalPath: `/${name}/tree/${resolved.ref}${resolved.path ? `/${resolved.path}` : ''}`,444 ogImagePath: `/og/${name}.png`,445 });446 });447448 // ───────────────────────── blob view ─────────────────────────449 app.get('/:repo/blob/*', async (request, reply) => {450 const name = repoParam(request, reply);451 if (!name) return reply;452 const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);453 if (!resolved || resolved.path === '') return notFound(request, reply);454 const type = await ctx.repos.objectType(name, resolved.sha, resolved.path);455 if (type === 'tree') {456 return reply.redirect(`/${name}/tree/${resolved.ref}/${resolved.path}`);457 }458 const blob = await ctx.repos.blob(name, resolved.sha, resolved.path);459 if (!blob) return notFound(request, reply);460 const shell = await repoShell(ctx, name);461 const ext = posix.extname(resolved.path).toLowerCase();462 const rawUrl = `/raw/${name}/${resolved.ref}/${resolved.path}`;463464 const view = {465 kind: 'code',466 html: null,467 lines: 0,468 lang: null,469 tooLarge: false,470 isImage: false,471 isMarkdown: false,472 notebookNote: false,473 };474475 if (blob.binary || IMAGE_EXT.has(ext)) {476 view.kind = 'binary';477 view.isImage = IMAGE_EXT.has(ext);478 } else if (blob.size > BLOB_RENDER_LIMIT) {479 view.kind = 'toolarge';480 view.tooLarge = true;481 } else {482 const text = blob.content.toString('utf8');483 if (['.md', '.markdown'].includes(ext) && request.query.plain !== '1') {484 view.kind = 'markdown';485 view.isMarkdown = true;486 const basePath = resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '.';487 view.html = await renderMarkdown(text, {488 repo: name, ref: resolved.ref, basePath, publicUrl: config.publicUrl,489 });490 } else {491 let source = text;492 if (ext === '.ipynb') {493 view.notebookNote = true;494 try {495 source = JSON.stringify(JSON.parse(text), null, 2);496 } catch {497 source = text;498 }499 }500 const result = await highlightFile(source, resolved.path);501 view.html = result.html;502 view.lines = result.lines;503 view.lang = result.lang;504 if (['.md', '.markdown'].includes(ext)) view.isMarkdown = true;505 }506 }507508 return render(reply, 'blob.njk', {509 title: `${resolved.path} at ${resolved.ref} · ${name} · SPB Git`,510 description: `${resolved.path} — ${name} at ${resolved.ref}`,511 ...shell,512 tab: 'code',513 currentRef: resolved.ref,514 refSha: resolved.sha,515 path: resolved.path,516 fileName: resolved.path.split('/').pop(),517 crumbs: breadcrumbs(name, 'blob', resolved.ref, resolved.path),518 blobSize: blob.size,519 rawUrl,520 view,521 canonicalPath: `/${name}/blob/${resolved.ref}/${resolved.path}`,522 ogImagePath: `/og/${name}.png`,523 });524 });525526 // ───────────────────────── commits list ─────────────────────────527 const commitsHandler = async (request, reply) => {528 const name = repoParam(request, reply);529 if (!name) return reply;530 const shell = await repoShell(ctx, name);531 const splat = request.params['*'] ?? '';532 const resolved = await ctx.repos.resolveRefAndPath(name, splat);533 if (!resolved) return notFound(request, reply);534 const page = Math.max(1, Number(request.query.page) || 1);535 const path = typeof request.query.path === 'string' ? request.query.path : undefined;536 const { commits, hasNext } = await ctx.repos.log(name, resolved.sha, { page, path });537 return render(reply, 'commits.njk', {538 title: `Commits on ${resolved.ref} · ${name} · SPB Git`,539 description: `Commit history of ${name} on ${resolved.ref}`,540 ...shell,541 tab: 'commits',542 currentRef: resolved.ref,543 commits,544 page,545 hasNext,546 filterPath: path ?? '',547 canonicalPath: `/${name}/commits/${resolved.ref}`,548 ogImagePath: `/og/${name}.png`,549 });550 };551 app.get('/:repo/commits', commitsHandler);552 app.get('/:repo/commits/*', commitsHandler);553554 // ───────────────────────── single commit ─────────────────────────555 app.get('/:repo/commit/:sha', async (request, reply) => {556 const name = repoParam(request, reply);557 if (!name) return reply;558 const shaParam = String(request.params.sha ?? '');559 if (!/^[0-9a-f]{4,40}$/i.test(shaParam)) return notFound(request, reply);560 const commit = await ctx.repos.commit(name, shaParam);561 if (!commit) return notFound(request, reply);562 await prepareDiff(commit.files);563 const shell = await repoShell(ctx, name);564 return render(reply, 'commit.njk', {565 title: `${commit.subject} · ${commit.shortSha} · ${name} · SPB Git`,566 description: commit.subject,567 ...shell,568 tab: 'commits',569 currentRef: shell.overview.defaultBranch,570 commit,571 canonicalPath: `/${name}/commit/${commit.sha}`,572 ogImagePath: `/og/${name}.png`,573 });574 });575576 // ───────────────────────── blame ─────────────────────────577 app.get('/:repo/blame/*', async (request, reply) => {578 const name = repoParam(request, reply);579 if (!name) return reply;580 const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);581 if (!resolved || resolved.path === '') return notFound(request, reply);582 const blame = await ctx.repos.blame(name, resolved.sha, resolved.path);583 if (!blame) return notFound(request, reply);584 const shell = await repoShell(ctx, name);585 // Age-scale each hunk 0..9 (newer = brighter accent).586 const dates = blame.hunks.map((h) => new Date(h.date).getTime()).filter(Number.isFinite);587 const min = Math.min(...dates);588 const max = Math.max(...dates);589 for (const hunk of blame.hunks) {590 const t = new Date(hunk.date).getTime();591 hunk.age = max === min ? 0 : Math.round((1 - (t - min) / (max - min)) * 9);592 }593 return render(reply, 'blame.njk', {594 title: `Blame ${resolved.path} at ${resolved.ref} · ${name} · SPB Git`,595 description: `Blame view of ${resolved.path} in ${name}`,596 ...shell,597 tab: 'code',598 currentRef: resolved.ref,599 path: resolved.path,600 crumbs: breadcrumbs(name, 'blame', resolved.ref, resolved.path),601 blame,602 canonicalPath: `/${name}/blame/${resolved.ref}/${resolved.path}`,603 ogImagePath: `/og/${name}.png`,604 });605 });606607 // ───────────────────────── branches / tags ─────────────────────────608 app.get('/:repo/branches', async (request, reply) => {609 const name = repoParam(request, reply);610 if (!name) return reply;611 const shell = await repoShell(ctx, name);612 const def = shell.overview.defaultBranch;613 const branches = [];614 for (const branch of shell.branches) {615 const counts = branch.isDefault616 ? { ahead: 0, behind: 0 }617 : await ctx.repos.aheadBehind(name, def, branch.name);618 branches.push({ ...branch, ...counts });619 }620 return render(reply, 'branches.njk', {621 title: `Branches · ${name} · SPB Git`,622 description: `Branches of ${name}`,623 ...shell,624 tab: 'branches',625 currentRef: def,626 branchesDetailed: branches,627 canonicalPath: `/${name}/branches`,628 ogImagePath: `/og/${name}.png`,629 });630 });631632 const tagsHandler = async (request, reply) => {633 const name = repoParam(request, reply);634 if (!name) return reply;635 const shell = await repoShell(ctx, name);636 const assetsByTag = ctx.releases.list(name);637 const tagsWithAssets = shell.tags.map((tag) => {638 const key = tag.name.replaceAll('/', '_');639 return { ...tag, key, assets: assetsByTag[key] ?? [] };640 });641 return render(reply, 'tags.njk', {642 title: `Releases · ${name} · SPB Git`,643 description: `Tags and releases of ${name}`,644 ...shell,645 tab: 'tags',646 currentRef: shell.overview.defaultBranch,647 tagsWithAssets,648 canonicalPath: `/${name}/tags`,649 ogImagePath: `/og/${name}.png`,650 });651 };652 app.get('/:repo/tags', tagsHandler);653 app.get('/:repo/releases', tagsHandler);654}655