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>
160 lines
3.2 KiB
Go
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)
|
|
}
|