/**
* GET /repos/:name/docs — two-panel documentation viewer.
*
* Closes audit item U38 + U38-b (docs/plans/2026-04-14-ux-reaudit.md).
* Two-pane layout:
* - Left sidebar: list of every .md / .markdown file (README at top,
* then docs/* sorted by path).
* - Right pane: rendered markdown of the currently-selected file.
* Selection is driven by `#<path>` in the URL so deep-links work.
* Switching files happens client-side (no full reload) via marked().
*
* All doc contents are embedded in the initial page response as a JS
* map. Typical repos have a handful of docs — total payload is small.
* For repos with an unusually large docs/ tree, we cap at 32 entries.
*
* Read-only — no mutations, no auth beyond the usual session.
*/
import { layout, escapeHtml, markdownBody, dirOf, ASSET_QUERY } from '../helpers.js'
import { renderRepoTabs, REPO_TAB_STYLES } from '../components/repo-tabs.js'
const MAX_EMBEDDED_DOCS = 32
const DOCS_STYLES = `
<style>
.docs-pane { display: grid; grid-template-columns: 240px 1fr; gap: 1rem; align-items: start; }
.docs-sidebar { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 0.5rem; max-height: 75vh; overflow-y: auto; position: sticky; top: 70px; }
.docs-sidebar h3 { font-size: 0.7rem; text-transform: uppercase; color: #8b949e; letter-spacing: 0.5px; padding: 0.3rem 0.5rem; margin: 0; }
.docs-sidebar ul { list-style: none; padding: 0; margin: 0; }
.docs-sidebar a { display: block; padding: 0.35rem 0.5rem; font-size: 0.8rem; color: #c9d1d9; text-decoration: none; border-radius: 4px; font-family: monospace; word-break: break-word; line-height: 1.3; }
.docs-sidebar a:hover { background: #0d1117; color: #58a6ff; }
.docs-sidebar a.active { background: #1f6feb22; color: #58a6ff; font-weight: 500; }
.docs-viewer { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1.25rem 1.5rem; min-height: 200px; }
/* One pane per doc, all rendered server-side; switching is a class
toggle so there is no re-parse and no flash of empty content. */
.docs-doc { display: none; }
.docs-doc.active { display: block; }
.docs-doc h1:first-child, .docs-doc h2:first-child { margin-top: 0; }
.docs-pane-title { font-size: 0.8rem; color: #8b949e; margin-bottom: 0.5rem; font-family: monospace; }
@media (max-width: 768px) {
.docs-pane { grid-template-columns: 1fr; }
.docs-sidebar { position: static; max-height: 260px; }
}
</style>`
function isMarkdownPath (p) {
if (!p) return false
const lower = p.toLowerCase()
return lower.endsWith('.md') || lower.endsWith('.markdown')
}
async function safeReadFile (sync, repoName, path, ref) {
try {
const content = await sync.readFile(repoName, path, ref)
if (typeof content === 'string' && content.length > 0) return content
} catch {}
return null
}
// How deep under docs/ we walk. hyper-sync labels directory entries
// 'directory' (lib/browser.js maps git's 'tree' → 'directory'); this used
// to test for 'tree', so no subdirectory was EVER descended into and
// docs/<sub>/*.md was invisible in the Docs tab.
const MAX_DOCS_DEPTH = 3
async function listMarkdownUnder (sync, repoName, dir, ref, depth = 0) {
const out = []
let entries
try {
entries = await sync.listFiles(repoName, ref, dir)
} catch {
return out
}
for (const e of (entries || [])) {
if (e.type === 'file') {
if (isMarkdownPath(e.path)) out.push(e)
} else if (e.type === 'directory' && depth < MAX_DOCS_DEPTH) {
const nested = await listMarkdownUnder(sync, repoName, e.path, ref, depth + 1)
for (const n of nested) out.push(n)
}
}
out.sort((a, b) => a.path.localeCompare(b.path))
return out
}
export async function renderRepoDocs (svc, repoName, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const sync = svc._sync
if (!sync) {
const body = `
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'docs', {}, prefix)}
<div class="card"><h2>Docs</h2><div class="empty">Sync service is not active — can't read files.</div></div>`
return layout(`docs · ${repoName}`, '/repos', body, ctx)
}
if (!sync.repos || !sync.repos.has(repoName)) return null
let ref = null
try {
const info = sync.info(repoName)
ref = (info && info.head) || null
} catch {}
// README first, then everything under docs/.
const readmeCandidates = ['README.md', 'readme.md', 'README.markdown', 'Readme.md']
let readmePath = null
let readmeContent = null
for (const p of readmeCandidates) {
const content = await safeReadFile(sync, repoName, p, ref)
if (content) {
readmePath = p
readmeContent = content
break
}
}
const docsEntries = [
...(await listMarkdownUnder(sync, repoName, 'docs', ref)),
...(await listMarkdownUnder(sync, repoName, 'Docs', ref))
]
const seen = new Set()
const docs = []
if (readmePath) {
docs.push({ path: readmePath, content: readmeContent })
seen.add(readmePath)
}
for (const e of docsEntries) {
if (seen.has(e.path)) continue
if (docs.length >= MAX_EMBEDDED_DOCS) break
const content = await safeReadFile(sync, repoName, e.path, ref)
if (content == null) continue
seen.add(e.path)
docs.push({ path: e.path, content })
}
const enc = encodeURIComponent
const blobLink = (p) => `${prefix}/repos/${enc(repoName)}/blob/${p.split('/').map(enc).join('/')}`
if (docs.length === 0) {
const emptyBody = `
${DOCS_STYLES}
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'docs', {}, prefix)}
<div class="card">
<h2><a href="${prefix}/repos/${enc(repoName)}">${escapeHtml(repoName)}</a> / docs</h2>
<div class="empty">
This repo has no README and no docs/ directory. Add a
<code>README.md</code> at the repo root, or any <code>.md</code>
files under <code>docs/</code>.
</div>
</div>`
return layout(`docs · ${repoName}`, '/repos', emptyBody, ctx)
}
const sidebar = `
<div class="docs-sidebar">
<h3>${docs.length} document${docs.length === 1 ? '' : 's'}</h3>
<ul>
${docs.map((d, i) => `
<li>
<a href="#${encodeURIComponent(d.path)}" data-doc-path="${escapeHtml(d.path)}"${i === 0 ? ' class="active"' : ''}>
${escapeHtml(d.path)}
</a>
</li>`).join('')}
</ul>
</div>`
const initialDoc = docs[0]
// Every doc is rendered server-side into its own pane; switching is a
// CSS class toggle. The previous version shipped a JSON map inside an
// inline <script> and re-ran marked on each switch — that inline script
// is exactly where both of this page's bugs lived (a template literal
// ate the backslashes out of `/…|\/\/|#/`, so the whole block was a
// SyntaxError and the viewer stayed empty for EVERY repo; and any doc
// containing `</script` closed the element early). No markdown source
// travels through JS source text any more.
const repoBase = prefix + '/repos/' + enc(repoName)
const panes = docs.map((d, i) => markdownBody(d.content, {
className: 'docs-doc' + (i === 0 ? ' active' : ''),
attrs: `data-doc-path="${escapeHtml(d.path)}"`,
dir: dirOf(d.path),
linkBase: repoBase + '/blob/',
imgBase: repoBase + '/raw/',
siblingBase: prefix + '/repos/'
})).join('\n')
const body = `
${DOCS_STYLES}
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'docs', {}, prefix)}
<div class="card">
<h2><a href="${prefix}/repos/${enc(repoName)}">${escapeHtml(repoName)}</a> / docs</h2>
</div>
<div class="docs-pane">
${sidebar}
<div>
<div class="docs-pane-title" id="docs-pane-title">${escapeHtml(initialDoc.path)}</div>
<div class="docs-viewer" id="docs-viewer">
${panes}
</div>
<div class="meta" style="margin-top:0.6rem;font-size:0.75rem">
<a id="docs-raw-link" href="${blobLink(initialDoc.path)}"
data-blob-base="${escapeHtml(repoBase + '/blob/')}">view raw</a>
</div>
</div>
</div>
<script src="${prefix}/static/pgv3-docs-switch.js${ASSET_QUERY}" defer></script>
`
return layout(`docs · ${repoName}`, '/repos', body, ctx)
}