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>
145 lines
3.3 KiB
Go
145 lines
3.3 KiB
Go
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
|
|
}
|
|
}
|