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
co-authored by Claude Opus 4.6
parent 0578dbcb02
commit 28afaa2c56
31 changed files with 2870 additions and 1 deletions
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
root := &cobra.Command{
Use: "mcdoc",
Short: "Metacircular documentation server",
Version: version,
}
root.AddCommand(serverCmd())
if err := root.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcdoc/internal/cache"
"git.wntrmute.dev/mc/mcdoc/internal/config"
"git.wntrmute.dev/mc/mcdoc/internal/gitea"
"git.wntrmute.dev/mc/mcdoc/internal/render"
"git.wntrmute.dev/mc/mcdoc/internal/server"
)
func serverCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "server",
Short: "Start the documentation server",
RunE: func(cmd *cobra.Command, args []string) error {
return runServer(configPath)
},
}
cmd.Flags().StringVar(&configPath, "config", "/srv/mcdoc/mcdoc.toml", "path to config file")
return cmd
}
func runServer(configPath string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
level := slog.LevelInfo
switch cfg.Log.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
log.Info("starting mcdoc",
"version", version,
"listen", cfg.Server.ListenAddr,
"gitea", cfg.Gitea.URL,
"org", cfg.Gitea.Org,
)
contentCache := cache.New()
renderer := render.New()
giteaClient := gitea.NewClient(cfg.Gitea.URL, cfg.Gitea.Org, cfg.Gitea.FetchTimeout.Duration)
fetcher := server.NewFetcher(server.FetcherConfig{
Client: giteaClient,
Renderer: renderer,
ExcludePaths: cfg.Gitea.ExcludePaths.Patterns,
ExcludeRepos: cfg.Gitea.ExcludeRepos.Names,
Concurrency: cfg.Gitea.MaxConcurrency,
Log: log,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
bgCfg := server.BackgroundConfig{
Cache: contentCache,
Fetcher: fetcher,
PollInterval: cfg.Gitea.PollInterval.Duration,
Log: log,
}
go server.StartBackgroundFetch(ctx, bgCfg)
refreshRepo := func(repo string) {
repos, err := giteaClient.ListRepos(ctx)
if err != nil {
log.Error("webhook: list repos failed", "error", err)
return
}
for _, r := range repos {
if r.Name == repo {
info, err := fetcher.FetchRepo(ctx, r)
if err != nil {
log.Error("webhook: fetch failed", "repo", repo, "error", err)
return
}
contentCache.SetRepo(info)
log.Info("webhook: refreshed repo", "repo", repo)
return
}
}
log.Warn("webhook: repo not found in org", "repo", repo)
}
srv, err := server.New(server.Config{
Cache: contentCache,
WebhookSecret: cfg.Gitea.WebhookSecret,
OnWebhook: refreshRepo,
Log: log,
})
if err != nil {
return err
}
httpServer := &http.Server{
Addr: cfg.Server.ListenAddr,
Handler: srv.Handler(),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Server.ListenAddr)
errCh <- httpServer.ListenAndServe()
}()
select {
case sig := <-sigCh:
log.Info("shutting down", "signal", sig)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
return httpServer.Shutdown(shutdownCtx)
case err := <-errCh:
cancel()
return err
}
}