OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / helpers.js

579 lines · javascript ref:
/**
 * Shared SSR helpers: HTML escape, body collect, layout render.
 */

import b4a from 'b4a'
import { ASSET_VERSION } from './static.js'
import { gzipIfWorthIt } from './compress.js'

/**
 * Cache-buster stamped onto every asset URL the layout emits. Assets whose
 * URL carries this token are served `immutable` with a one-year max-age
 * (see static.js), so the token MUST change per release — otherwise a
 * deploy would strand browsers on a stale bundle.
 */
const V = '?v=' + encodeURIComponent(ASSET_VERSION)

/**
 * The same `?v=` token, exported for pages that pull in their own static
 * script. Stamping it is what makes the asset cacheable-immutable — an
 * unstamped URL only gets an hour plus ETag revalidation (see static.js).
 */
export const ASSET_QUERY = V

export function escapeHtml (s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

/**
 * Emit a markdown container that `static/pgv3-markdown.js` renders.
 *
 * The source travels as base64 in an attribute, NOT as JSON inside an
 * inline <script>. JSON.stringify does not escape `</script`, so the old
 * inline form let any markdown file containing that sequence close the
 * script element early — blank viewer, and arbitrary HTML/JS executing
 * in the viewer's origin. Base64's alphabet cannot close a tag or an
 * attribute, so the escape hazard is gone by construction.
 *
 * @param {string} src - markdown source
 * @param {object} [opts]
 * @param {string} [opts.id]           element id
 * @param {string} [opts.className]    extra classes (markdown-body is always set)
 * @param {string} [opts.style]        inline style
 * @param {string} [opts.dir]          dir of the source file in the repo ('' = root)
 * @param {string} [opts.linkBase]     prefix for relative links (omit = no rewriting)
 * @param {string} [opts.imgBase]      prefix for relative images (defaults to linkBase)
 * @param {string} [opts.siblingBase]  where a path climbing above the repo root goes
 * @param {string} [opts.stripExt]     comma-separated extensions to drop from
 *                                     rewritten link targets (slug-addressed pages)
 * @param {string} [opts.attrs]        extra raw attributes (caller must pre-escape)
 */
export function markdownBody (src, opts = {}) {
  const b64 = b4a.toString(b4a.from(String(src == null ? '' : src), 'utf8'), 'base64')
  const cls = 'markdown-body' + (opts.className ? ' ' + opts.className : '')
  const attr = (name, value) =>
    value ? ` ${name}="${escapeHtml(value)}"` : ''
  return '<div class="' + cls + '"' +
    attr('id', opts.id) +
    attr('style', opts.style) +
    ` data-md-src-b64="${b64}"` +
    attr('data-md-dir', opts.dir) +
    attr('data-md-link-base', opts.linkBase) +
    attr('data-md-img-base', opts.imgBase) +
    attr('data-md-sibling-base', opts.siblingBase) +
    attr('data-md-strip-ext', opts.stripExt) +
    (opts.attrs ? ' ' + opts.attrs : '') +
    '></div>'
}

/** Directory part of a repo-relative file path ('' for a root-level file). */
export function dirOf (filePath) {
  const parts = String(filePath || '').split('/')
  parts.pop()
  return parts.join('/')
}

export function collectBody (req) {
  return new Promise((resolve, reject) => {
    const parts = []
    req.on('data', (c) => parts.push(c))
    req.on('end', () => resolve(b4a.concat(parts)))
    req.on('error', reject)
  })
}

export async function readForm (req) {
  const body = await collectBody(req)
  const params = new URLSearchParams(b4a.toString(body, 'utf8'))
  const out = {}
  for (const [k, v] of params) out[k] = v
  return out
}

/**
 * Send an HTML response, gzipped when the client accepts it.
 *
 * `res.acceptsGzip` is stamped by the router's request listener — see the
 * note there for why capability lives on res rather than being threaded
 * through this function's 64 call sites.
 *
 * `Vary: Accept-Encoding` is sent either way, so the Apache proxy in front
 * of us caches the two representations separately.
 */
export function sendHtml (res, html, status = 200) {
  const headers = {
    'Content-Type': 'text/html; charset=utf-8',
    Vary: 'Accept-Encoding'
  }

  const gz = res.acceptsGzip ? gzipIfWorthIt(html) : null
  if (gz) {
    headers['Content-Encoding'] = 'gzip'
    res.writeHead(status, headers)
    return res.end(gz)
  }

  res.writeHead(status, headers)
  res.end(html)
}

/**
 * Derive an HTTP status code from a thrown error for inline-rendered
 * error pages. 4xx errors pass through; anything else (5xx, missing
 * status, generic Error) becomes 400 because most server-thrown errors
 * from dispatch() are validation failures, not true 5xx bugs.
 */
export function errorStatus (err) {
  if (err && err.status && err.status >= 400 && err.status < 500) return err.status
  return 400
}

/**
 * Generic error page — one card with the error message plus a back link.
 * Used by routes that don't have a domain-specific renderer (admin,
 * deploy) to keep error responses styled + navigable instead of raw
 * plain-text 500s from the global catch-all.
 */
export function renderError (title, err, backHref, backLabel, ctx) {
  const body = `
<div class="card">
  <h2>error</h2>
  <pre>${escapeHtml((err && err.message) || String(err))}</pre>
  ${backHref ? `<div style="margin-top:0.6rem"><a href="${backHref}">← ${escapeHtml(backLabel || 'back')}</a></div>` : ''}
</div>
`
  return layout(title || 'error', backHref || '/', body, ctx)
}

export function redirect (res, location) {
  res.writeHead(303, { Location: location })
  res.end()
}

/**
 * Redirect with a success-flash query param. Picked up by
 * /static/pgv3-flash.js and rendered as a dismissible toast on the
 * landing page. type is 'success' | 'info' | 'error'.
 */
export function redirectWithFlash (res, location, type, message) {
  const payload = encodeURIComponent(`${type}:${message}`)
  const sep = location.includes('?') ? '&' : '?'
  res.writeHead(303, { Location: location + sep + 'flash=' + payload })
  res.end()
}

export function notFound (res, msg = 'Not found') {
  res.writeHead(404, { 'Content-Type': 'text/plain' })
  res.end(msg)
}

export function serverError (res, err) {
  res.writeHead(500, { 'Content-Type': 'text/plain' })
  res.end((err && err.message) || String(err))
}

export function styles () {
  return `
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding-top: 56px; line-height: 1.5; }
  code, pre, .mono { font-family: 'SF Mono', 'Fira Code', Consolas, Monaco, monospace; }
  .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
  h1 { color: #58a6ff; font-size: 1.5rem; margin-bottom: 0.5rem; }
  h1 a { color: #58a6ff; text-decoration: none; }
  h2 { font-size: 0.9rem; color: #c9d1d9; margin-top: 0; }
  a { color: #58a6ff; text-decoration: none; transition: color 0.15s; }
  a:hover { text-decoration: underline; }

  /* Fixed nav */
  nav { position: fixed; top: 0; left: 0; right: 0; height: 56px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; align-items: center; padding: 0 20px; z-index: 9000; gap: 1rem; }
  nav a { color: #8b949e; font-size: 0.85rem; padding: 0.3rem 0; border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s; white-space: nowrap; }
  nav a:hover { color: #c9d1d9; text-decoration: none; }
  nav a.active { color: #58a6ff; border-bottom-color: #58a6ff; }
  nav .brand { color: #58a6ff; font-weight: bold; font-size: 1rem; }
  nav .nav-spacer { flex: 1; }
  nav .you { display: inline-block; color: #3fb950; font-size: 0.75rem; padding: 0.15rem 0.5rem; background: #2ea04322; border: 1px solid #2ea04344; border-radius: 4px; text-decoration: none; }
  nav .you:hover { background: #2ea04344; text-decoration: none; }
  /* Skeleton shimmer for "you" badge while hydrating (U18) */
  @keyframes pgv3-shimmer { 0%, 100% { background-color: #2ea04322; } 50% { background-color: #2ea04344; } }
  nav .you.shimmer { animation: pgv3-shimmer 1.5s ease-in-out infinite; }
  /* Anon "Sign In" button — styled as a small primary action so the call-to-action is obvious. */
  nav button.you-anon { color: white; background: #238636; border: 1px solid #238636; font-weight: 500; cursor: pointer; font-family: inherit; }
  nav button.you-anon:hover { background: #2ea043; }
  nav button.you-anon:disabled { opacity: 0.7; cursor: wait; }

  /* Welcome modal (shown after fresh sign-in) */
  .pgv3-modal { position: fixed; inset: 0; z-index: 10000; display: none; }
  .pgv3-modal.visible { display: block; }
  .pgv3-modal-backdrop { position: absolute; inset: 0; background: rgba(1, 4, 9, 0.7); backdrop-filter: blur(2px); }
  .pgv3-modal-card { position: relative; max-width: 440px; width: calc(100% - 2rem); margin: 10vh auto 0; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; box-shadow: 0 16px 32px rgba(0,0,0,0.4); }

  /* Nav search autocomplete */
  nav .nav-search { position: relative; flex: 1; max-width: 480px; }
  nav .nav-search-box { display: flex; align-items: center; gap: 6px; }
  nav .nav-search-input { flex: 1; padding: 7px 12px 7px 32px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.85rem; outline: none; width: 100%; }
  nav .nav-search-input:focus { border-color: #58a6ff; }
  nav .nav-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #484f58; font-size: 13px; pointer-events: none; }
  nav .nav-search-tabs { display: flex; gap: 2px; background: #21262d; padding: 2px; border-radius: 6px; flex-shrink: 0; }
  nav .nav-search-tab { padding: 4px 10px; background: transparent; border: none; color: #8b949e; cursor: pointer; border-radius: 4px; font-size: 11px; }
  nav .nav-search-tab.active { background: #30363d; color: #c9d1d9; }
  .nav-search-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: #161b22; border: 1px solid #30363d; border-radius: 6px; margin-top: 4px; max-height: 400px; overflow-y: auto; z-index: 9100; display: none; }
  .nav-search-dropdown.visible { display: block; }
  .nav-search-result { padding: 10px 14px; border-bottom: 1px solid #21262d; cursor: pointer; }
  .nav-search-result:last-child { border-bottom: none; }
  .nav-search-result:hover { background: #0d1117; }
  .nav-search-result-name { color: #58a6ff; font-weight: 500; font-size: 13px; }
  .nav-search-result-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
  .nav-search-result-match { font-family: monospace; font-size: 11px; color: #8b949e; margin-top: 3px; padding: 3px 6px; background: #0d1117; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .nav-search-result-match .hl { background: #5e4b00; color: #f0e68c; }
  .nav-search-loading, .nav-search-empty { padding: 16px; text-align: center; color: #8b949e; font-size: 13px; }
  .nav-search-footer { padding: 10px 14px; border-top: 1px solid #30363d; }
  .nav-search-footer a { color: #58a6ff; text-decoration: none; font-size: 12px; }

  /* Cards */
  .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
  .card h2 { margin: 0 0 0.5rem; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; letter-spacing: 0.5px; }
  .row { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid #21262d; }
  .row:last-child { border-bottom: none; }
  .label { color: #8b949e; font-size: 0.85rem; }
  .meta { color: #6e7681; font-size: 0.75rem; }
  .item { padding: 0.5rem 0; border-bottom: 1px solid #21262d; transition: background 0.1s; }
  .item:last-child { border-bottom: none; }
  .item:hover { background: #1c2128; margin: 0 -0.5rem; padding-left: 0.5rem; padding-right: 0.5rem; border-radius: 4px; }
  .item .title { color: #c9d1d9; font-weight: 600; }
  .item.closed .title { color: #8b949e; text-decoration: line-through; }

  /* Badges */
  .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.7rem; font-weight: 500; margin-left: 0.3rem; }
  .badge-open { background: #23863622; color: #3fb950; border: 1px solid #23863644; }
  .badge-closed { background: #8250df22; color: #a371f7; border: 1px solid #8250df44; }
  .badge-group { background: #1f6feb22; color: #58a6ff; border: 1px solid #1f6feb44; }
  .badge-user { background: #30363d; color: #8b949e; border: 1px solid #484f58; }
  .tag { display: inline-block; padding: 0.1rem 0.5rem; margin: 0.1rem; border-radius: 999px; font-size: 0.7rem; background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; }

  /* Code */
  pre { background: #0d1117; border: 1px solid #30363d; padding: 0.8rem; border-radius: 6px; overflow-x: auto; color: #c9d1d9; font-size: 0.85rem; line-height: 1.6; }
  pre code { font-family: inherit; }
  code { font-size: 0.85em; background: #21262d; padding: 0.15rem 0.4rem; border-radius: 3px; }
  pre code { background: none; padding: 0; }

  /* Forms */
  input, textarea, button, select { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.6rem; border-radius: 6px; font-family: inherit; font-size: 0.85rem; transition: border-color 0.15s; }
  input:focus, textarea:focus, select:focus { border-color: #58a6ff; outline: none; }
  textarea { width: 100%; min-height: 4em; box-sizing: border-box; resize: vertical; }
  input, select { width: 100%; box-sizing: border-box; }
  button { background: #238636; border-color: #238636; color: white; cursor: pointer; font-weight: 500; transition: background 0.15s, box-shadow 0.15s, transform 0.05s; }
  button:hover { background: #2ea043; box-shadow: 0 1px 0 rgba(0,0,0,0.3); }
  button:active { transform: translateY(1px); box-shadow: none; }
  button:disabled { opacity: 0.5; cursor: not-allowed; box-shadow: none; }
  button.btn-secondary { background: #21262d; border-color: #30363d; color: #c9d1d9; }
  button.btn-secondary:hover { background: #30363d; box-shadow: 0 1px 0 rgba(0,0,0,0.3); }
  button.btn-danger { background: #da3633; border-color: #da3633; }
  button.btn-danger:hover { background: #f85149; box-shadow: 0 1px 0 rgba(0,0,0,0.3); }
  .field { margin-bottom: 0.6rem; }
  .field label { display: block; color: #8b949e; font-size: 0.75rem; margin-bottom: 0.2rem; font-weight: 500; }

  /* Visually-hidden label for a11y — kept in the DOM so screen readers
     announce the field, but hidden from sighted users because an
     adjacent heading already provides context (e.g. issue labels form
     inside a card titled "Labels"). */
  .sr-only {
    position: absolute; width: 1px; height: 1px; padding: 0;
    overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;
    border: 0;
  }
  .empty { color: #6e7681; font-style: italic; padding: 1rem 0.2rem; line-height: 1.5; }
  .empty::before { content: '· '; color: #484f58; font-style: normal; }
  form { margin: 0; }
  .hstack { display: flex; gap: 0.5rem; align-items: center; }

  /* Status messages */
  .status-msg { padding: 0.5rem 0.8rem; border-radius: 6px; font-size: 0.85rem; margin: 0.5rem 0; }
  .status-msg.success { background: #23863622; color: #3fb950; border: 1px solid #23863644; }
  .status-msg.error { background: #da363322; color: #f85149; border: 1px solid #da363344; }
  .status-msg.info { background: #1f6feb22; color: #58a6ff; border: 1px solid #1f6feb44; }

  /* Spinner */
  @keyframes spin { to { transform: rotate(360deg); } }
  .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #30363d; border-top-color: #58a6ff; border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 0.3rem; }

  /* Diff colors */
  .diff-add { color: #3fb950; background: #23863611; }
  .diff-del { color: #f85149; background: #da363311; }
  .diff-hunk { color: #79c0ff; }
  .diff-meta { color: #8b949e; }

  /* Collapsible sections — shared baseline style */
  details { margin: 0.5rem 0; }
  details summary { cursor: pointer; color: #8b949e; font-size: 0.85rem; padding: 0.3rem 0; list-style: none; display: flex; align-items: center; gap: 0.4rem; }
  details summary::-webkit-details-marker { display: none; }
  details summary::before { content: '▸'; color: #8b949e; font-size: 0.7rem; transition: transform 0.1s; }
  details[open] summary::before { transform: rotate(90deg); }
  details summary:hover { color: #c9d1d9; }

  /* Markdown rendered content */
  .markdown-body h1, .markdown-body h2 { border-bottom: 1px solid #21262d; padding-bottom: 0.3em; margin: 1em 0 0.5em; }
  .markdown-body h3, .markdown-body h4 { margin: 0.8em 0 0.3em; }
  .markdown-body p { margin: 0.5em 0; }
  .markdown-body ul, .markdown-body ol { padding-left: 2em; margin: 0.5em 0; }
  .markdown-body blockquote { border-left: 3px solid #30363d; padding-left: 1em; color: #8b949e; margin: 0.5em 0; }
  .markdown-body table { border-collapse: collapse; margin: 0.5em 0; }
  .markdown-body th, .markdown-body td { border: 1px solid #30363d; padding: 0.4em 0.8em; }
  .markdown-body img { max-width: 100%; border-radius: 6px; }

  /* Wide-table rescue — any table wrapped in .table-scroll gets a
     horizontal scrollbar on narrow viewports instead of blowing out the
     layout. Admin user lists and sync-remotes tables both use this. */
  .table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -0.25rem; }
  .table-scroll table { min-width: 600px; }

  /* Flash toast (U34) — rendered client-side by pgv3-flash.js after a
     successful POST redirected with ?flash=<type>:<message>. Fixed to
     the bottom-right corner, fades in/out. */
  .pgv3-flash {
    position: fixed; bottom: 1.25rem; right: 1.25rem;
    display: flex; align-items: center; gap: 0.6rem;
    min-width: 220px; max-width: 420px;
    padding: 0.7rem 0.85rem;
    border-radius: 6px; border: 1px solid;
    box-shadow: 0 6px 16px rgba(0,0,0,0.3);
    font-size: 0.85rem;
    animation: pgv3-flash-in 0.2s ease-out;
    z-index: 9500;
  }
  .pgv3-flash-success { background: #23863622; border-color: #23863666; color: #3fb950; }
  .pgv3-flash-info    { background: #1f6feb22; border-color: #1f6feb66; color: #58a6ff; }
  .pgv3-flash-error   { background: #da363322; border-color: #da363366; color: #f85149; }
  .pgv3-flash-message { flex: 1; line-height: 1.35; color: #c9d1d9; word-break: break-word; }
  .pgv3-flash-close {
    background: transparent; border: none; color: inherit;
    font-size: 1.2rem; line-height: 1; padding: 0 0.2rem;
    cursor: pointer; opacity: 0.7;
  }
  .pgv3-flash-close:hover { opacity: 1; }
  .pgv3-flash-leaving { animation: pgv3-flash-out 0.25s ease-in forwards; }
  @keyframes pgv3-flash-in  { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
  @keyframes pgv3-flash-out { to   { opacity: 0; transform: translateY(8px); } }
  @media (max-width: 480px) {
    .pgv3-flash { left: 0.75rem; right: 0.75rem; bottom: 0.75rem; max-width: none; }
  }

  /* ── Responsive breakpoints (U4) ───────────────────────────────────
     Two tiers:
       ≤768px (tablet/landscape phone) — compact nav, stack row flexes,
                                         relax card padding
       ≤480px (portrait phone)         — hide nav's secondary links,
                                         collapse search tabs */
  @media (max-width: 768px) {
    body { padding-top: 52px; }
    .container { padding: 12px; }
    h1 { font-size: 1.25rem; }

    nav { height: 52px; padding: 0 12px; gap: 0.6rem; overflow-x: auto; -webkit-overflow-scrolling: touch; }
    nav a, nav .brand { font-size: 0.8rem; padding: 0.2rem 0; flex-shrink: 0; }
    nav .nav-search { min-width: 180px; }
    nav .nav-search-input { font-size: 0.8rem; padding: 6px 10px 6px 28px; }
    nav .nav-search-tabs { padding: 1px; gap: 1px; }
    nav .nav-search-tab { padding: 3px 7px; font-size: 10px; }
    nav .you, nav button.you-anon { font-size: 0.7rem; padding: 0.15rem 0.4rem; }

    .card { padding: 0.7rem; margin: 0.7rem 0; border-radius: 4px; }
    .card h2 { font-size: 0.7rem; }
    .row { flex-wrap: wrap; gap: 0.3rem; }
    .row .label { flex-basis: 100%; font-size: 0.75rem; }

    /* Two-column grids drop to one column */
    .org-grid, .repo-grid { grid-template-columns: 1fr !important; }
    .edit-form-row { grid-template-columns: 1fr !important; }

    .pgv3-modal-card { margin: 4vh auto 0; padding: 1rem; }
    pre { font-size: 0.75rem; padding: 0.6rem; }
  }

  /* Focus-visible outlines for keyboard navigation (U21) */
  a:focus-visible,
  button:focus-visible,
  input:focus-visible,
  textarea:focus-visible,
  select:focus-visible {
    outline: 2px solid #58a6ff;
    outline-offset: 2px;
  }
  /* Buttons and inputs already shift border on focus; add the outer ring
     only when navigating via keyboard so mouse users aren't distracted. */
  button:focus:not(:focus-visible),
  input:focus:not(:focus-visible),
  textarea:focus:not(:focus-visible),
  select:focus:not(:focus-visible) {
    outline: none;
  }

  @media (max-width: 480px) {
    nav .nav-search-tabs { display: none; }
    nav .nav-search { min-width: 120px; }
    nav .nav-spacer { display: none; }
    .meta { font-size: 0.7rem; }
    h1 { font-size: 1.1rem; }

    /* Badges wrap instead of forcing overflow */
    .badge { margin-left: 0; margin-right: 0.25rem; }

    /* Repo cards stack their activity graph below the info */
    .repo-card { flex-direction: column !important; }
    .repo-activity { width: 100%; margin-top: 0.5rem; }
  }
  `
}

export function layout (title, currentNav, body, ctx) {
  const p = (ctx && ctx.prefix) || ''
  const nav = (path, label) =>
    `<a href="${p}${path}" class="${currentNav === path ? 'active' : ''}">${label}</a>`

  // Show "you" indicator if a session is present. ctx.identity.publicKey
  // is hex (set by extractIdentity in the router). Rendered as a link
  // to /profile. The initial label is the short hex; pgv3-bootstrap.js
  // then fetches /api/auth/me + /rpc/get-profile and replaces the badge
  // text with the display name and adds a picture when available.
  const id = ctx && ctx.identity
  // Signed-in: clickable "you" badge → /profile. Anonymous: explicit
  // "Sign In" button (v1 parity) — clicking it runs the challenge-response
  // ceremony from pgv3-bootstrap.js and on a fresh identity opens the
  // welcome modal.
  const youBadge = id && id.publicKey
    ? `<a href="${p}/profile" class="you shimmer" id="you-badge" title="${escapeHtml(id.publicKey)}"><span id="you-avatar-slot"></span><span id="you-label-slot">you: ${escapeHtml(id.publicKey.slice(0, 8))}…</span></a>`
    : '<button id="signin-btn" class="you you-anon" type="button" onclick="window.pgv3SignIn && window.pgv3SignIn()">Sign In</button>'

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(title)} · pear-git-v3</title>
  <style>${styles()}</style>
  <link rel="stylesheet" href="${p}/static/vendor/prism-tomorrow.min.css${V}">
  <link rel="stylesheet" href="${p}/static/vendor/prism-line-numbers.min.css${V}">
</head>
<body data-prefix="${escapeHtml(p)}">
  <nav>
    <a href="${p}/repos" class="brand">pear-git</a>
    ${nav('/repos', 'repos')}
    ${nav('/orgs', 'orgs')}
    ${nav('/peers', 'peers')}
    ${nav('/devices', 'devices')}
    ${nav('/deploy', 'deploy')}
    ${nav('/docs', 'docs')}
    <div class="nav-search" id="nav-search-wrap">
      <div class="nav-search-box">
        <span class="nav-search-icon">&#x1F50D;</span>
        <input type="text" class="nav-search-input" id="nav-search-input" placeholder="Search...">
        <div class="nav-search-tabs">
          <button class="nav-search-tab active" data-type="repo" type="button">Names</button>
          <button class="nav-search-tab" data-type="code" type="button">Content</button>
        </div>
      </div>
      <div class="nav-search-dropdown" id="nav-search-dropdown">
        <div class="nav-search-loading" id="nav-search-loading" style="display:none;">Searching...</div>
        <div id="nav-search-results"></div>
      </div>
    </div>
    <span class="nav-spacer"></span>
    <a href="${p}/admin" id="admin-nav" class="${currentNav === '/admin' ? 'active' : ''}" style="display:none">admin</a>
    ${youBadge}
  </nav>
  <div class="container">
    ${body}
  </div>

  <!-- Welcome modal — opened by pgv3-bootstrap.js after a fresh sign-in.
       Hidden by default; the .visible class is toggled on the wrapper. -->
  <div id="welcome-modal" class="pgv3-modal" aria-hidden="true">
    <div class="pgv3-modal-backdrop"></div>
    <div class="pgv3-modal-card" role="dialog" aria-labelledby="welcome-modal-title">
      <h2 id="welcome-modal-title" style="font-size:1.1rem;color:#c9d1d9;text-transform:none;letter-spacing:normal;margin-bottom:0.3rem">Welcome to pear-git</h2>
      <div class="meta" style="margin-bottom:1rem;font-size:0.85rem">
        You're signed in. Pick a display name and optionally a profile picture so other peers can recognize you. You can change this anytime in <a href="${p}/profile">/profile</a>.
      </div>
      <form id="welcome-form">
        <div class="field"><label for="welcome-name">Display name</label><input id="welcome-name" name="name" placeholder="alice" autofocus></div>
        <div class="field"><label for="welcome-picture">Profile picture <span class="meta">(optional)</span></label><input id="welcome-picture" type="file" name="picture" accept="image/*"></div>
        <div id="welcome-error" class="status-msg error" style="display:none"></div>
        <div style="display:flex;gap:0.5rem;margin-top:0.8rem;justify-content:flex-end">
          <button type="button" id="welcome-skip" class="btn-secondary">Skip for now</button>
          <button type="submit">Save</button>
        </div>
      </form>
    </div>
  </div>

  <script src="${p}/static/vendor/prism.min.js${V}"></script>
  <script src="${p}/static/vendor/prism-autoloader.min.js${V}"></script>
  <script src="${p}/static/vendor/prism-line-numbers.min.js${V}"></script>
  <script src="${p}/static/vendor/marked.min.js${V}"></script>
  <!-- Must come AFTER marked and WITHOUT defer: it renders every
       [data-md-src-b64] on the page and needs marked already defined. -->
  <script src="${p}/static/pgv3-markdown.js${V}"></script>
  <script type="module" src="${p}/static/pgv3-bootstrap.js${V}"></script>
  <script src="${p}/static/pgv3-search.js${V}" defer></script>
  <script src="${p}/static/pgv3-forms.js${V}" defer></script>
  <script src="${p}/static/pgv3-flash.js${V}" defer></script>
  <script>
    if (typeof Prism !== 'undefined') Prism.plugins.autoloader.languages_path = '${p}/static/vendor/prism-components/';
  </script>
</body>
</html>`
}

export function formatBytes (n) {
  if (n == null) return '?'
  if (n < 1024) return n + ' B'
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB'
  if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MiB'
  return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GiB'
}

/**
 * Deterministic SVG identicon from a hex pubkey. 5×5 symmetric grid
 * (mirrored left/right) — same pubkey always produces the same icon, so
 * users recognize their peers visually across pages.
 *
 * size = pixel dimensions of the SVG. Colors are derived from the first
 * 6 hex chars (hue/saturation) and the grid pattern from bits of the
 * remaining hex.
 */
export function identicon (pubkeyHex, size = 32) {
  if (!pubkeyHex || typeof pubkeyHex !== 'string' || pubkeyHex.length < 12) {
    return `<svg width="${size}" height="${size}" viewBox="0 0 5 5" style="background:#21262d;border-radius:4px"></svg>`
  }

  // Derive HSL hue from first 3 hex chars; fixed saturation/lightness so
  // every icon reads well on the dark theme.
  const hue = parseInt(pubkeyHex.slice(0, 3), 16) % 360
  const color = `hsl(${hue}, 55%, 55%)`

  // 5×5 grid: we only decide the left 3 columns (15 cells) from hex bits,
  // then mirror columns 3→1 and 4→0 for a symmetric pattern.
  const bits = []
  for (let i = 6; i < pubkeyHex.length && bits.length < 15; i++) {
    const n = parseInt(pubkeyHex[i], 16)
    if (!Number.isFinite(n)) continue
    bits.push((n & 8) !== 0, (n & 4) !== 0, (n & 2) !== 0, (n & 1) !== 0)
  }
  while (bits.length < 15) bits.push(false)

  const cells = []
  for (let row = 0; row < 5; row++) {
    for (let col = 0; col < 3; col++) {
      if (bits[row * 3 + col]) {
        cells.push(`<rect x="${col}" y="${row}" width="1" height="1"/>`)
        if (col < 2) cells.push(`<rect x="${4 - col}" y="${row}" width="1" height="1"/>`)
      }
    }
  }

  return `<svg width="${size}" height="${size}" viewBox="0 0 5 5" style="background:#0d1117;border-radius:4px;fill:${color};flex-shrink:0" aria-hidden="true">${cells.join('')}</svg>`
}