/**
* 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, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
/**
* 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">🔍</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>`
}