Files
mcdoc/internal/cache/cache.go
Kyle Isom 28afaa2c56 Implement mcdoc v0.1.0: public documentation server
Single-binary Go server that fetches markdown from Gitea (mc org),
renders to HTML with goldmark (GFM, chroma syntax highlighting,
heading anchors), and serves a navigable read-only documentation site.

Features:
- Boot fetch with retry, webhook refresh, 15-minute poll fallback
- In-memory cache with atomic per-repo swap
- chi router with htmx partial responses for SPA-like navigation
- HMAC-SHA256 webhook validation
- Responsive CSS, TOC generation, priority doc ordering
- $PORT env var support for MCP agent port assignment

33 tests across config, cache, render, and server packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:04:15 -07:00

160 lines
3.2 KiB
Go

package cache
import (
"sort"
"strings"
"sync"
"time"
"git.wntrmute.dev/mc/mcdoc/internal/render"
)
// Document represents a single rendered markdown document.
type Document struct {
Repo string
FilePath string // original path with .md extension
URLPath string // path without .md extension
Title string // first heading or filename
HTML string
Headings []render.Heading
LastUpdated time.Time
}
// RepoInfo holds metadata about a repository and its documents.
type RepoInfo struct {
Name string
Description string
Docs []*Document
CommitSHA string
FetchedAt time.Time
}
// Cache stores rendered documents in memory with atomic per-repo swaps.
type Cache struct {
mu sync.RWMutex
repos map[string]*RepoInfo
ready bool
}
// New creates an empty cache.
func New() *Cache {
return &Cache{
repos: make(map[string]*RepoInfo),
}
}
// SetRepo atomically replaces all documents for a repository.
func (c *Cache) SetRepo(info *RepoInfo) {
sortDocs(info.Docs)
c.mu.Lock()
c.repos[info.Name] = info
c.mu.Unlock()
}
// RemoveRepo removes a repository from the cache.
func (c *Cache) RemoveRepo(name string) {
c.mu.Lock()
delete(c.repos, name)
c.mu.Unlock()
}
// SetReady marks the cache as ready to serve requests.
func (c *Cache) SetReady() {
c.mu.Lock()
c.ready = true
c.mu.Unlock()
}
// IsReady returns whether the initial fetch has completed.
func (c *Cache) IsReady() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.ready
}
// ListRepos returns all cached repos, sorted by name.
func (c *Cache) ListRepos() []*RepoInfo {
c.mu.RLock()
defer c.mu.RUnlock()
repos := make([]*RepoInfo, 0, len(c.repos))
for _, r := range c.repos {
repos = append(repos, r)
}
sort.Slice(repos, func(i, j int) bool {
return repos[i].Name < repos[j].Name
})
return repos
}
// GetRepo returns info for a specific repository.
func (c *Cache) GetRepo(name string) (*RepoInfo, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
r, ok := c.repos[name]
return r, ok
}
// GetDocument returns a document by repo name and URL path.
func (c *Cache) GetDocument(repo, urlPath string) (*Document, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
r, ok := c.repos[repo]
if !ok {
return nil, false
}
for _, doc := range r.Docs {
if doc.URLPath == urlPath {
return doc, true
}
}
return nil, false
}
// GetCommitSHA returns the cached commit SHA for a repo.
func (c *Cache) GetCommitSHA(repo string) string {
c.mu.RLock()
defer c.mu.RUnlock()
r, ok := c.repos[repo]
if !ok {
return ""
}
return r.CommitSHA
}
// docPriority defines the sort order for well-known filenames.
var docPriority = map[string]int{
"README": 0,
"ARCHITECTURE": 1,
"RUNBOOK": 2,
"CLAUDE": 3,
}
func sortDocs(docs []*Document) {
sort.Slice(docs, func(i, j int) bool {
pi, oki := docPriority[baseNameUpper(docs[i].URLPath)]
pj, okj := docPriority[baseNameUpper(docs[j].URLPath)]
switch {
case oki && okj:
return pi < pj
case oki:
return true
case okj:
return false
default:
return strings.ToLower(docs[i].URLPath) < strings.ToLower(docs[j].URLPath)
}
})
}
func baseNameUpper(urlPath string) string {
parts := strings.Split(urlPath, "/")
base := parts[len(parts)-1]
return strings.ToUpper(base)
}