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>
This commit is contained in:
159
internal/cache/cache.go
vendored
Normal file
159
internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user