SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
27.0 KB · 655 lines javascript
Raw Blame History
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