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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user