OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / pages / repo-docs.js

215 lines · javascript ref:
/**
 * 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)
}