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:
2026-03-27 13:04:15 -07:00
parent 0578dbcb02
commit 28afaa2c56
31 changed files with 2870 additions and 1 deletions

159
internal/cache/cache.go vendored Normal file
View 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)
}

165
internal/cache/cache_test.go vendored Normal file
View File

@@ -0,0 +1,165 @@
package cache
import (
"testing"
"time"
"git.wntrmute.dev/mc/mcdoc/internal/render"
)
func TestSetAndGetRepo(t *testing.T) {
c := New()
info := &RepoInfo{
Name: "testrepo",
Description: "A test repo",
CommitSHA: "abc123",
FetchedAt: time.Now(),
Docs: []*Document{
{Repo: "testrepo", URLPath: "README", Title: "README", HTML: "<p>hello</p>"},
{Repo: "testrepo", URLPath: "ARCHITECTURE", Title: "Architecture", HTML: "<p>arch</p>"},
},
}
c.SetRepo(info)
got, ok := c.GetRepo("testrepo")
if !ok {
t.Fatal("expected repo to exist")
}
if got.Name != "testrepo" {
t.Fatalf("got name %q, want %q", got.Name, "testrepo")
}
if len(got.Docs) != 2 {
t.Fatalf("got %d docs, want 2", len(got.Docs))
}
}
func TestGetDocument(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{
{Repo: "r", URLPath: "foo/bar", Title: "Bar", HTML: "<p>bar</p>"},
},
})
doc, ok := c.GetDocument("r", "foo/bar")
if !ok {
t.Fatal("expected document to exist")
}
if doc.Title != "Bar" {
t.Fatalf("got title %q, want %q", doc.Title, "Bar")
}
_, ok = c.GetDocument("r", "nonexistent")
if ok {
t.Fatal("expected document to not exist")
}
_, ok = c.GetDocument("nonexistent", "foo/bar")
if ok {
t.Fatal("expected repo to not exist")
}
}
func TestListReposSorted(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{Name: "mcr"})
c.SetRepo(&RepoInfo{Name: "abc"})
c.SetRepo(&RepoInfo{Name: "mcp"})
repos := c.ListRepos()
if len(repos) != 3 {
t.Fatalf("got %d repos, want 3", len(repos))
}
if repos[0].Name != "abc" || repos[1].Name != "mcp" || repos[2].Name != "mcr" {
t.Fatalf("unexpected order: %s, %s, %s", repos[0].Name, repos[1].Name, repos[2].Name)
}
}
func TestDocSortOrder(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{
{URLPath: "CLAUDE"},
{URLPath: "zebra"},
{URLPath: "README"},
{URLPath: "ARCHITECTURE"},
{URLPath: "alpha"},
{URLPath: "RUNBOOK"},
},
})
repo, _ := c.GetRepo("r")
expected := []string{"README", "ARCHITECTURE", "RUNBOOK", "CLAUDE", "alpha", "zebra"}
for i, doc := range repo.Docs {
if doc.URLPath != expected[i] {
t.Errorf("position %d: got %q, want %q", i, doc.URLPath, expected[i])
}
}
}
func TestReadyState(t *testing.T) {
c := New()
if c.IsReady() {
t.Fatal("new cache should not be ready")
}
c.SetReady()
if !c.IsReady() {
t.Fatal("cache should be ready after SetReady")
}
}
func TestGetCommitSHA(t *testing.T) {
c := New()
if sha := c.GetCommitSHA("nope"); sha != "" {
t.Fatalf("expected empty sha, got %q", sha)
}
c.SetRepo(&RepoInfo{Name: "r", CommitSHA: "abc123"})
if sha := c.GetCommitSHA("r"); sha != "abc123" {
t.Fatalf("expected abc123, got %q", sha)
}
}
func TestAtomicRepoSwap(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{{URLPath: "old", Title: "Old"}},
})
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{{URLPath: "new", Title: "New"}},
})
repo, _ := c.GetRepo("r")
if len(repo.Docs) != 1 || repo.Docs[0].URLPath != "new" {
t.Fatal("expected atomic swap to replace docs")
}
}
func TestRemoveRepo(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{Name: "r"})
c.RemoveRepo("r")
_, ok := c.GetRepo("r")
if ok {
t.Fatal("expected repo to be removed")
}
}
// Ensure Document's Headings field works with the render package type.
func TestDocumentHeadingsType(t *testing.T) {
doc := &Document{
Headings: []render.Heading{
{Level: 1, ID: "title", Text: "Title"},
},
}
if doc.Headings[0].Text != "Title" {
t.Fatal("unexpected heading text")
}
}