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.3 KB · 662 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    // PDFs must not be CSP-sandboxed or the built-in browser viewer refuses to render.348    const csp = ext === '.pdf'349      ? "default-src 'none'; object-src 'self'; style-src 'unsafe-inline'"350      : "default-src 'none'; style-src 'unsafe-inline'; sandbox";351    reply352      .type(mime)353      .header('X-Content-Type-Options', 'nosniff')354      .header('Content-Security-Policy', csp)355      .header('Cache-Control', immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=60');356    if (ext === '.pdf') reply.header('Content-Disposition', 'inline');357    return reply.send(blob.content);358  });359360  // ───────────────────────── archives ─────────────────────────361  app.get('/archive/:repo/*', async (request, reply) => {362    const name = repoParam(request, reply);363    if (!name) return reply;364    const splat = String(request.params['*'] ?? '');365    const match = /^(.+?)\.(zip|tar\.gz)$/.exec(splat);366    if (!match) return notFound(request, reply);367    const [, refPart, format] = match;368    const resolved = await ctx.repos.resolveRefAndPath(name, refPart);369    if (!resolved || resolved.path !== '') return notFound(request, reply);370    const safeRef = refPart.replaceAll('/', '-');371    return sendArchive(ctx, name, resolved.sha, format, reply, `${name}-${safeRef}`);372  });373374  // ───────────────────────── release asset downloads ─────────────────────────375  app.get('/releases/:repo/:tag/:asset', async (request, reply) => {376    const name = repoParam(request, reply);377    if (!name) return reply;378    const { tag, asset } = request.params;379    if (!isValidTagName(tag) || !isValidAssetName(asset)) return notFound(request, reply);380    const info = ctx.releases.stat(name, tag, asset);381    if (!info) return notFound(request, reply);382    const ext = posix.extname(asset).toLowerCase();383    reply384      .type(ASSET_MIME[ext] ?? 'application/octet-stream')385      .header('Content-Length', info.size)386      .header('Content-Disposition', `attachment; filename="${asset}"`)387      .header('X-Content-Type-Options', 'nosniff')388      .header('Cache-Control', 'public, max-age=3600');389    if (info.sha256) reply.header('X-Checksum-Sha256', info.sha256);390    return reply.send(ctx.releases.readStream(name, tag, asset));391  });392393  // ───────────────────────── repo home ─────────────────────────394  app.get('/:repo', async (request, reply) => {395    const name = repoParam(request, reply);396    if (!name) return reply;397    const shell = await repoShell(ctx, name);398    const { overview } = shell;399    let entries = [];400    let lastCommits = {};401    let readme = null;402    if (!overview.empty) {403      entries = (await ctx.repos.tree(name, overview.head, '')) ?? [];404      const lcCache = ctx.cache.repoPath(name, overview.head, 'lastcommits-root.json');405      lastCommits = await ctx.cache.remember(lcCache, () =>406        ctx.repos.lastCommits(name, overview.head, '', entries.map((e) => e.name)),407      );408      readme = await ctx.renderReadme(name, overview.head);409    }410    return render(reply, 'repo.njk', {411      title: `${name} · SPB Git`,412      description: overview.description || `${name} — a repository by ${config.owner.name}`,413      ...shell,414      tab: 'code',415      currentRef: overview.defaultBranch,416      entries,417      lastCommits,418      readme,419      canonicalPath: `/${name}`,420      ogImagePath: `/og/${name}.png`,421    });422  });423424  // ───────────────────────── tree browsing ─────────────────────────425  app.get('/:repo/tree/*', async (request, reply) => {426    const name = repoParam(request, reply);427    if (!name) return reply;428    const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);429    if (!resolved) return notFound(request, reply);430    const entries = await ctx.repos.tree(name, resolved.sha, resolved.path);431    if (!entries) return notFound(request, reply);432    const shell = await repoShell(ctx, name);433    const lcKey = `lastcommits-${resolved.path.replaceAll('/', '_') || 'root'}.json`;434    const lastCommits = await ctx.cache.remember(ctx.cache.repoPath(name, resolved.sha, lcKey), () =>435      ctx.repos.lastCommits(name, resolved.sha, resolved.path, entries.map((e) => e.name)),436    );437    return render(reply, 'tree.njk', {438      title: `${resolved.path || name} at ${resolved.ref} · ${name} · SPB Git`,439      description: `Browse ${resolved.path || 'the root tree'} of ${name} at ${resolved.ref}`,440      ...shell,441      tab: 'code',442      currentRef: resolved.ref,443      path: resolved.path,444      parentPath: resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '',445      crumbs: breadcrumbs(name, 'tree', resolved.ref, resolved.path),446      entries,447      lastCommits,448      canonicalPath: `/${name}/tree/${resolved.ref}${resolved.path ? `/${resolved.path}` : ''}`,449      ogImagePath: `/og/${name}.png`,450    });451  });452453  // ───────────────────────── blob view ─────────────────────────454  app.get('/:repo/blob/*', async (request, reply) => {455    const name = repoParam(request, reply);456    if (!name) return reply;457    const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);458    if (!resolved || resolved.path === '') return notFound(request, reply);459    const type = await ctx.repos.objectType(name, resolved.sha, resolved.path);460    if (type === 'tree') {461      return reply.redirect(`/${name}/tree/${resolved.ref}/${resolved.path}`);462    }463    const blob = await ctx.repos.blob(name, resolved.sha, resolved.path);464    if (!blob) return notFound(request, reply);465    const shell = await repoShell(ctx, name);466    const ext = posix.extname(resolved.path).toLowerCase();467    const rawUrl = `/raw/${name}/${resolved.ref}/${resolved.path}`;468469    const view = {470      kind: 'code',471      html: null,472      lines: 0,473      lang: null,474      tooLarge: false,475      isImage: false,476      isMarkdown: false,477      notebookNote: false,478    };479480    if (ext === '.pdf') {481      view.kind = 'pdf';482    } else if (blob.binary || IMAGE_EXT.has(ext)) {483      view.kind = 'binary';484      view.isImage = IMAGE_EXT.has(ext);485    } else if (blob.size > BLOB_RENDER_LIMIT) {486      view.kind = 'toolarge';487      view.tooLarge = true;488    } else {489      const text = blob.content.toString('utf8');490      if (['.md', '.markdown'].includes(ext) && request.query.plain !== '1') {491        view.kind = 'markdown';492        view.isMarkdown = true;493        const basePath = resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '.';494        view.html = await renderMarkdown(text, {495          repo: name, ref: resolved.ref, basePath, publicUrl: config.publicUrl,496        });497      } else {498        let source = text;499        if (ext === '.ipynb') {500          view.notebookNote = true;501          try {502            source = JSON.stringify(JSON.parse(text), null, 2);503          } catch {504            source = text;505          }506        }507        const result = await highlightFile(source, resolved.path);508        view.html = result.html;509        view.lines = result.lines;510        view.lang = result.lang;511        if (['.md', '.markdown'].includes(ext)) view.isMarkdown = true;512      }513    }514515    return render(reply, 'blob.njk', {516      title: `${resolved.path} at ${resolved.ref} · ${name} · SPB Git`,517      description: `${resolved.path} — ${name} at ${resolved.ref}`,518      ...shell,519      tab: 'code',520      currentRef: resolved.ref,521      refSha: resolved.sha,522      path: resolved.path,523      fileName: resolved.path.split('/').pop(),524      crumbs: breadcrumbs(name, 'blob', resolved.ref, resolved.path),525      blobSize: blob.size,526      rawUrl,527      view,528      canonicalPath: `/${name}/blob/${resolved.ref}/${resolved.path}`,529      ogImagePath: `/og/${name}.png`,530    });531  });532533  // ───────────────────────── commits list ─────────────────────────534  const commitsHandler = async (request, reply) => {535    const name = repoParam(request, reply);536    if (!name) return reply;537    const shell = await repoShell(ctx, name);538    const splat = request.params['*'] ?? '';539    const resolved = await ctx.repos.resolveRefAndPath(name, splat);540    if (!resolved) return notFound(request, reply);541    const page = Math.max(1, Number(request.query.page) || 1);542    const path = typeof request.query.path === 'string' ? request.query.path : undefined;543    const { commits, hasNext } = await ctx.repos.log(name, resolved.sha, { page, path });544    return render(reply, 'commits.njk', {545      title: `Commits on ${resolved.ref} · ${name} · SPB Git`,546      description: `Commit history of ${name} on ${resolved.ref}`,547      ...shell,548      tab: 'commits',549      currentRef: resolved.ref,550      commits,551      page,552      hasNext,553      filterPath: path ?? '',554      canonicalPath: `/${name}/commits/${resolved.ref}`,555      ogImagePath: `/og/${name}.png`,556    });557  };558  app.get('/:repo/commits', commitsHandler);559  app.get('/:repo/commits/*', commitsHandler);560561  // ───────────────────────── single commit ─────────────────────────562  app.get('/:repo/commit/:sha', async (request, reply) => {563    const name = repoParam(request, reply);564    if (!name) return reply;565    const shaParam = String(request.params.sha ?? '');566    if (!/^[0-9a-f]{4,40}$/i.test(shaParam)) return notFound(request, reply);567    const commit = await ctx.repos.commit(name, shaParam);568    if (!commit) return notFound(request, reply);569    await prepareDiff(commit.files);570    const shell = await repoShell(ctx, name);571    return render(reply, 'commit.njk', {572      title: `${commit.subject} · ${commit.shortSha} · ${name} · SPB Git`,573      description: commit.subject,574      ...shell,575      tab: 'commits',576      currentRef: shell.overview.defaultBranch,577      commit,578      canonicalPath: `/${name}/commit/${commit.sha}`,579      ogImagePath: `/og/${name}.png`,580    });581  });582583  // ───────────────────────── blame ─────────────────────────584  app.get('/:repo/blame/*', async (request, reply) => {585    const name = repoParam(request, reply);586    if (!name) return reply;587    const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);588    if (!resolved || resolved.path === '') return notFound(request, reply);589    const blame = await ctx.repos.blame(name, resolved.sha, resolved.path);590    if (!blame) return notFound(request, reply);591    const shell = await repoShell(ctx, name);592    // Age-scale each hunk 0..9 (newer = brighter accent).593    const dates = blame.hunks.map((h) => new Date(h.date).getTime()).filter(Number.isFinite);594    const min = Math.min(...dates);595    const max = Math.max(...dates);596    for (const hunk of blame.hunks) {597      const t = new Date(hunk.date).getTime();598      hunk.age = max === min ? 0 : Math.round((1 - (t - min) / (max - min)) * 9);599    }600    return render(reply, 'blame.njk', {601      title: `Blame ${resolved.path} at ${resolved.ref} · ${name} · SPB Git`,602      description: `Blame view of ${resolved.path} in ${name}`,603      ...shell,604      tab: 'code',605      currentRef: resolved.ref,606      path: resolved.path,607      crumbs: breadcrumbs(name, 'blame', resolved.ref, resolved.path),608      blame,609      canonicalPath: `/${name}/blame/${resolved.ref}/${resolved.path}`,610      ogImagePath: `/og/${name}.png`,611    });612  });613614  // ───────────────────────── branches / tags ─────────────────────────615  app.get('/:repo/branches', async (request, reply) => {616    const name = repoParam(request, reply);617    if (!name) return reply;618    const shell = await repoShell(ctx, name);619    const def = shell.overview.defaultBranch;620    const branches = [];621    for (const branch of shell.branches) {622      const counts = branch.isDefault623        ? { ahead: 0, behind: 0 }624        : await ctx.repos.aheadBehind(name, def, branch.name);625      branches.push({ ...branch, ...counts });626    }627    return render(reply, 'branches.njk', {628      title: `Branches · ${name} · SPB Git`,629      description: `Branches of ${name}`,630      ...shell,631      tab: 'branches',632      currentRef: def,633      branchesDetailed: branches,634      canonicalPath: `/${name}/branches`,635      ogImagePath: `/og/${name}.png`,636    });637  });638639  const tagsHandler = async (request, reply) => {640    const name = repoParam(request, reply);641    if (!name) return reply;642    const shell = await repoShell(ctx, name);643    const assetsByTag = ctx.releases.list(name);644    const tagsWithAssets = shell.tags.map((tag) => {645      const key = tag.name.replaceAll('/', '_');646      return { ...tag, key, assets: assetsByTag[key] ?? [] };647    });648    return render(reply, 'tags.njk', {649      title: `Releases · ${name} · SPB Git`,650      description: `Tags and releases of ${name}`,651      ...shell,652      tab: 'tags',653      currentRef: shell.overview.defaultBranch,654      tagsWithAssets,655      canonicalPath: `/${name}/tags`,656      ogImagePath: `/og/${name}.png`,657    });658  };659  app.get('/:repo/tags', tagsHandler);660  app.get('/:repo/releases', tagsHandler);661}662