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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/mcdoc
|
||||||
|
srv/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
80
.golangci.yaml
Normal file
80
.golangci.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- unused
|
||||||
|
- errorlint
|
||||||
|
- gosec
|
||||||
|
- staticcheck
|
||||||
|
- revive
|
||||||
|
|
||||||
|
settings:
|
||||||
|
errcheck:
|
||||||
|
check-blank: false
|
||||||
|
check-type-assertions: true
|
||||||
|
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- shadow
|
||||||
|
- fieldalignment
|
||||||
|
|
||||||
|
gosec:
|
||||||
|
severity: medium
|
||||||
|
confidence: medium
|
||||||
|
excludes:
|
||||||
|
- G104
|
||||||
|
|
||||||
|
errorlint:
|
||||||
|
errorf: true
|
||||||
|
asserts: true
|
||||||
|
comparison: true
|
||||||
|
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: error-return
|
||||||
|
severity: error
|
||||||
|
- name: unexported-return
|
||||||
|
severity: error
|
||||||
|
- name: error-strings
|
||||||
|
severity: warning
|
||||||
|
- name: if-return
|
||||||
|
severity: warning
|
||||||
|
- name: increment-decrement
|
||||||
|
severity: warning
|
||||||
|
- name: var-naming
|
||||||
|
severity: warning
|
||||||
|
- name: range
|
||||||
|
severity: warning
|
||||||
|
- name: time-naming
|
||||||
|
severity: warning
|
||||||
|
- name: indent-error-flow
|
||||||
|
severity: warning
|
||||||
|
- name: early-return
|
||||||
|
severity: warning
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
|
- vendor
|
||||||
|
rules:
|
||||||
|
- path: "_test\\.go"
|
||||||
|
linters:
|
||||||
|
- gosec
|
||||||
|
text: "G101"
|
||||||
@@ -34,7 +34,7 @@ go test ./internal/server -run TestDocPage
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Language:** Go 1.25+, `CGO_ENABLED=0`, statically linked
|
- **Language:** Go 1.25+, `CGO_ENABLED=0`, statically linked
|
||||||
- **Module path:** `git.wntrmute.dev/kyle/mcdoc`
|
- **Module path:** `git.wntrmute.dev/mc/mcdoc`
|
||||||
- **Config:** TOML via `go-toml/v2`, env overrides via `MCDOC_*`
|
- **Config:** TOML via `go-toml/v2`, env overrides via `MCDOC_*`
|
||||||
- **HTTP:** chi router, htmx for navigation
|
- **HTTP:** chi router, htmx for navigation
|
||||||
- **Rendering:** goldmark (GFM), chroma (syntax highlighting)
|
- **Rendering:** goldmark (GFM), chroma (syntax highlighting)
|
||||||
|
|||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mcdoc ./cmd/mcdoc
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY --from=builder /build/mcdoc /usr/local/bin/mcdoc
|
||||||
|
|
||||||
|
WORKDIR /srv/mcdoc
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["mcdoc"]
|
||||||
|
CMD ["server", "--config", "/srv/mcdoc/mcdoc.toml"]
|
||||||
31
Makefile
Normal file
31
Makefile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.PHONY: build test vet lint clean docker all devserver
|
||||||
|
|
||||||
|
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
|
||||||
|
|
||||||
|
mcdoc:
|
||||||
|
CGO_ENABLED=0 go build $(LDFLAGS) -o mcdoc ./cmd/mcdoc
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f mcdoc
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build --build-arg VERSION=$(shell git describe --tags --always --dirty) -t mcdoc -f Dockerfile .
|
||||||
|
|
||||||
|
devserver: mcdoc
|
||||||
|
@mkdir -p srv
|
||||||
|
@if [ ! -f srv/mcdoc.toml ]; then cp deploy/examples/mcdoc.toml srv/mcdoc.toml; echo "Created srv/mcdoc.toml from example — edit before running."; fi
|
||||||
|
./mcdoc server --config srv/mcdoc.toml
|
||||||
|
|
||||||
|
all: vet lint test mcdoc
|
||||||
25
cmd/mcdoc/main.go
Normal file
25
cmd/mcdoc/main.go
Normal 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
cmd/mcdoc/server.go
Normal file
144
cmd/mcdoc/server.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
deploy/examples/mcdoc.toml
Normal file
19
deploy/examples/mcdoc.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[server]
|
||||||
|
listen_addr = ":8080"
|
||||||
|
|
||||||
|
[gitea]
|
||||||
|
url = "https://git.wntrmute.dev"
|
||||||
|
org = "mc"
|
||||||
|
webhook_secret = "change-me"
|
||||||
|
poll_interval = "15m"
|
||||||
|
fetch_timeout = "30s"
|
||||||
|
max_concurrency = 4
|
||||||
|
|
||||||
|
[gitea.exclude_paths]
|
||||||
|
patterns = ["vendor/", ".claude/", "node_modules/", ".junie/"]
|
||||||
|
|
||||||
|
[gitea.exclude_repos]
|
||||||
|
names = []
|
||||||
|
|
||||||
|
[log]
|
||||||
|
level = "info"
|
||||||
14
deploy/mcdoc-rift.toml
Normal file
14
deploy/mcdoc-rift.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "mcdoc"
|
||||||
|
node = "rift"
|
||||||
|
version = "v0.1.0"
|
||||||
|
|
||||||
|
[build.images]
|
||||||
|
mcdoc = "Dockerfile"
|
||||||
|
|
||||||
|
[[components]]
|
||||||
|
name = "mcdoc"
|
||||||
|
|
||||||
|
[[components.routes]]
|
||||||
|
port = 443
|
||||||
|
mode = "l7"
|
||||||
|
hostname = "docs.metacircular.net"
|
||||||
18
go.mod
Normal file
18
go.mod
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module git.wntrmute.dev/mc/mcdoc
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.18.0
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/yuin/goldmark v1.7.12
|
||||||
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
)
|
||||||
39
go.sum
Normal file
39
go.sum
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||||
|
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||||
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
|
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||||
|
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||||
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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)
|
||||||
|
}
|
||||||
165
internal/cache/cache_test.go
vendored
Normal file
165
internal/cache/cache_test.go
vendored
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
164
internal/config/config.go
Normal file
164
internal/config/config.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pelletier/go-toml/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `toml:"server"`
|
||||||
|
Gitea GiteaConfig `toml:"gitea"`
|
||||||
|
Log LogConfig `toml:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
ListenAddr string `toml:"listen_addr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiteaConfig struct {
|
||||||
|
URL string `toml:"url"`
|
||||||
|
Org string `toml:"org"`
|
||||||
|
WebhookSecret string `toml:"webhook_secret"`
|
||||||
|
PollInterval Duration `toml:"poll_interval"`
|
||||||
|
FetchTimeout Duration `toml:"fetch_timeout"`
|
||||||
|
MaxConcurrency int `toml:"max_concurrency"`
|
||||||
|
ExcludePaths ExcludePaths `toml:"exclude_paths"`
|
||||||
|
ExcludeRepos ExcludeRepos `toml:"exclude_repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExcludePaths struct {
|
||||||
|
Patterns []string `toml:"patterns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExcludeRepos struct {
|
||||||
|
Names []string `toml:"names"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `toml:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration wraps time.Duration for TOML string parsing.
|
||||||
|
type Duration struct {
|
||||||
|
time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Duration) UnmarshalText(text []byte) error {
|
||||||
|
var err error
|
||||||
|
d.Duration, err = time.ParseDuration(string(text))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Duration) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(d.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path) // #nosec G304 -- config path is operator-controlled
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
},
|
||||||
|
Gitea: GiteaConfig{
|
||||||
|
URL: "https://git.wntrmute.dev",
|
||||||
|
Org: "mc",
|
||||||
|
PollInterval: Duration{15 * time.Minute},
|
||||||
|
FetchTimeout: Duration{30 * time.Second},
|
||||||
|
MaxConcurrency: 4,
|
||||||
|
ExcludePaths: ExcludePaths{
|
||||||
|
Patterns: []string{"vendor/", ".claude/", "node_modules/", ".junie/"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Log: LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEnvOverrides(cfg)
|
||||||
|
|
||||||
|
if err := cfg.validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
if c.Server.ListenAddr == "" {
|
||||||
|
return fmt.Errorf("server.listen_addr is required")
|
||||||
|
}
|
||||||
|
if c.Gitea.URL == "" {
|
||||||
|
return fmt.Errorf("gitea.url is required")
|
||||||
|
}
|
||||||
|
if c.Gitea.Org == "" {
|
||||||
|
return fmt.Errorf("gitea.org is required")
|
||||||
|
}
|
||||||
|
if c.Gitea.MaxConcurrency < 1 {
|
||||||
|
return fmt.Errorf("gitea.max_concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnvOverrides checks for MCDOC_* environment variables and applies
|
||||||
|
// them to the config. Also checks $PORT for MCP agent port assignment.
|
||||||
|
func applyEnvOverrides(cfg *Config) {
|
||||||
|
if port := os.Getenv("PORT"); port != "" {
|
||||||
|
cfg.Server.ListenAddr = ":" + port
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEnvToStruct("MCDOC", reflect.ValueOf(cfg).Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEnvToStruct(prefix string, v reflect.Value) {
|
||||||
|
t := v.Type()
|
||||||
|
for i := range t.NumField() {
|
||||||
|
field := t.Field(i)
|
||||||
|
fv := v.Field(i)
|
||||||
|
|
||||||
|
tag := field.Tag.Get("toml")
|
||||||
|
if tag == "" || tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
envKey := prefix + "_" + strings.ToUpper(tag)
|
||||||
|
|
||||||
|
if fv.Kind() == reflect.Struct && field.Type != reflect.TypeOf(Duration{}) {
|
||||||
|
applyEnvToStruct(envKey, fv)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
envVal := os.Getenv(envKey)
|
||||||
|
if envVal == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fv.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
fv.SetString(envVal)
|
||||||
|
case reflect.Int:
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(envVal, "%d", &n); err == nil {
|
||||||
|
fv.SetInt(int64(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Type == reflect.TypeOf(Duration{}) {
|
||||||
|
if d, err := time.ParseDuration(envVal); err == nil {
|
||||||
|
fv.Set(reflect.ValueOf(Duration{d}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
internal/config/config_test.go
Normal file
154
internal/config/config_test.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadValidConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "mcdoc.toml")
|
||||||
|
|
||||||
|
content := `
|
||||||
|
[server]
|
||||||
|
listen_addr = ":9090"
|
||||||
|
|
||||||
|
[gitea]
|
||||||
|
url = "https://git.example.com"
|
||||||
|
org = "myorg"
|
||||||
|
webhook_secret = "secret123"
|
||||||
|
poll_interval = "5m"
|
||||||
|
fetch_timeout = "10s"
|
||||||
|
max_concurrency = 2
|
||||||
|
|
||||||
|
[gitea.exclude_paths]
|
||||||
|
patterns = ["vendor/"]
|
||||||
|
|
||||||
|
[gitea.exclude_repos]
|
||||||
|
names = ["ignore-me"]
|
||||||
|
|
||||||
|
[log]
|
||||||
|
level = "debug"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Server.ListenAddr != ":9090" {
|
||||||
|
t.Fatalf("listen_addr = %q, want :9090", cfg.Server.ListenAddr)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.URL != "https://git.example.com" {
|
||||||
|
t.Fatalf("gitea.url = %q", cfg.Gitea.URL)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.Org != "myorg" {
|
||||||
|
t.Fatalf("gitea.org = %q", cfg.Gitea.Org)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.PollInterval.Duration != 5*time.Minute {
|
||||||
|
t.Fatalf("poll_interval = %v", cfg.Gitea.PollInterval.Duration)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.FetchTimeout.Duration != 10*time.Second {
|
||||||
|
t.Fatalf("fetch_timeout = %v", cfg.Gitea.FetchTimeout.Duration)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.MaxConcurrency != 2 {
|
||||||
|
t.Fatalf("max_concurrency = %d", cfg.Gitea.MaxConcurrency)
|
||||||
|
}
|
||||||
|
if len(cfg.Gitea.ExcludePaths.Patterns) != 1 {
|
||||||
|
t.Fatalf("exclude_paths = %v", cfg.Gitea.ExcludePaths.Patterns)
|
||||||
|
}
|
||||||
|
if len(cfg.Gitea.ExcludeRepos.Names) != 1 {
|
||||||
|
t.Fatalf("exclude_repos = %v", cfg.Gitea.ExcludeRepos.Names)
|
||||||
|
}
|
||||||
|
if cfg.Log.Level != "debug" {
|
||||||
|
t.Fatalf("log.level = %q", cfg.Log.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "mcdoc.toml")
|
||||||
|
|
||||||
|
// Minimal config — everything should get defaults
|
||||||
|
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Server.ListenAddr != ":8080" {
|
||||||
|
t.Fatalf("default listen_addr = %q, want :8080", cfg.Server.ListenAddr)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.URL != "https://git.wntrmute.dev" {
|
||||||
|
t.Fatalf("default gitea.url = %q", cfg.Gitea.URL)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.PollInterval.Duration != 15*time.Minute {
|
||||||
|
t.Fatalf("default poll_interval = %v", cfg.Gitea.PollInterval.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMissingFile(t *testing.T) {
|
||||||
|
_, err := Load("/nonexistent/mcdoc.toml")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortEnvOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "mcdoc.toml")
|
||||||
|
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PORT", "12345")
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Server.ListenAddr != ":12345" {
|
||||||
|
t.Fatalf("PORT override: got %q, want :12345", cfg.Server.ListenAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "mcdoc.toml")
|
||||||
|
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("MCDOC_GITEA_ORG", "custom-org")
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Gitea.Org != "custom-org" {
|
||||||
|
t.Fatalf("env override: got %q, want custom-org", cfg.Gitea.Org)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationFailsOnEmptyURL(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "mcdoc.toml")
|
||||||
|
content := `
|
||||||
|
[gitea]
|
||||||
|
url = ""
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Load(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error for empty gitea.url")
|
||||||
|
}
|
||||||
|
}
|
||||||
161
internal/gitea/client.go
Normal file
161
internal/gitea/client.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repo represents a Gitea repository.
|
||||||
|
type Repo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TreeEntry represents a file in a Gitea repo tree.
|
||||||
|
type TreeEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"` // "blob" or "tree"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TreeResponse is the Gitea API response for a recursive tree listing.
|
||||||
|
type TreeResponse struct {
|
||||||
|
Tree []TreeEntry `json:"tree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitMeta holds minimal commit info for a file.
|
||||||
|
type CommitMeta struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Date time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoCommit is used to extract the latest commit SHA for a repo.
|
||||||
|
type RepoCommit struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Commit repoCommitInfo `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoCommitInfo struct {
|
||||||
|
Committer commitPerson `json:"committer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commitPerson struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client fetches content from a Gitea instance.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
org string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a Gitea API client.
|
||||||
|
func NewClient(baseURL, org string, timeout time.Duration) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
org: org,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepos returns all repositories in the configured organization.
|
||||||
|
func (c *Client) ListRepos(ctx context.Context) ([]Repo, error) {
|
||||||
|
var allRepos []Repo
|
||||||
|
page := 1
|
||||||
|
|
||||||
|
for {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?page=%d&limit=50", c.baseURL, c.org, page)
|
||||||
|
var repos []Repo
|
||||||
|
if err := c.getJSON(ctx, url, &repos); err != nil {
|
||||||
|
return nil, fmt.Errorf("list repos page %d: %w", page, err)
|
||||||
|
}
|
||||||
|
if len(repos) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
allRepos = append(allRepos, repos...)
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRepos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMarkdownFiles returns paths to all .md files in a repo's default branch.
|
||||||
|
func (c *Client) ListMarkdownFiles(ctx context.Context, repo, branch string) ([]string, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
|
||||||
|
c.baseURL, c.org, repo, branch)
|
||||||
|
|
||||||
|
var tree TreeResponse
|
||||||
|
if err := c.getJSON(ctx, url, &tree); err != nil {
|
||||||
|
return nil, fmt.Errorf("list tree for %s: %w", repo, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
for _, entry := range tree.Tree {
|
||||||
|
if entry.Type == "blob" && strings.HasSuffix(strings.ToLower(entry.Path), ".md") {
|
||||||
|
files = append(files, entry.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchFileContent returns the raw content of a file in a repo.
|
||||||
|
func (c *Client) FetchFileContent(ctx context.Context, repo, branch, filepath string) ([]byte, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
|
||||||
|
c.baseURL, c.org, repo, filepath, branch)
|
||||||
|
return c.getRaw(ctx, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestCommitSHA returns the SHA of the latest commit on a branch.
|
||||||
|
func (c *Client) LatestCommitSHA(ctx context.Context, repo, branch string) (string, time.Time, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?sha=%s&limit=1",
|
||||||
|
c.baseURL, c.org, repo, branch)
|
||||||
|
|
||||||
|
var commits []RepoCommit
|
||||||
|
if err := c.getJSON(ctx, url, &commits); err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("latest commit for %s: %w", repo, err)
|
||||||
|
}
|
||||||
|
if len(commits) == 0 {
|
||||||
|
return "", time.Time{}, fmt.Errorf("no commits found for %s/%s", repo, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return commits[0].SHA, commits[0].Commit.Committer.Date, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getJSON(ctx context.Context, url string, target interface{}) error {
|
||||||
|
body, err := c.getRaw(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(body, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getRaw(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch %s: %w", url, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("fetch %s: status %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response from %s: %w", url, err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
123
internal/render/render.go
Normal file
123
internal/render/render.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
"github.com/yuin/goldmark/parser"
|
||||||
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Heading represents a heading extracted from a document for TOC generation.
|
||||||
|
type Heading struct {
|
||||||
|
Level int
|
||||||
|
ID string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result holds the rendered HTML and extracted metadata.
|
||||||
|
type Result struct {
|
||||||
|
HTML string
|
||||||
|
Headings []Heading
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderer converts markdown to HTML using goldmark.
|
||||||
|
type Renderer struct {
|
||||||
|
md goldmark.Markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Renderer with GFM, syntax highlighting, and heading anchors.
|
||||||
|
func New() *Renderer {
|
||||||
|
md := goldmark.New(
|
||||||
|
goldmark.WithExtensions(
|
||||||
|
extension.GFM,
|
||||||
|
highlighting.NewHighlighting(
|
||||||
|
highlighting.WithStyle("github"),
|
||||||
|
highlighting.WithFormatOptions(
|
||||||
|
chromahtml.WithClasses(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
goldmark.WithParserOptions(
|
||||||
|
parser.WithAutoHeadingID(),
|
||||||
|
),
|
||||||
|
goldmark.WithRendererOptions(
|
||||||
|
html.WithUnsafe(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &Renderer{md: md}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render converts markdown source to HTML and extracts headings.
|
||||||
|
func (r *Renderer) Render(source []byte) (*Result, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := r.md.Convert(source, &buf); err != nil {
|
||||||
|
return nil, fmt.Errorf("render markdown: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headings := extractHeadings(source, r.md.Parser())
|
||||||
|
|
||||||
|
return &Result{
|
||||||
|
HTML: buf.String(),
|
||||||
|
Headings: headings,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractHeadings(source []byte, p parser.Parser) []Heading {
|
||||||
|
reader := text.NewReader(source)
|
||||||
|
doc := p.Parse(reader)
|
||||||
|
|
||||||
|
var headings []Heading
|
||||||
|
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
heading, ok := n.(*ast.Heading)
|
||||||
|
if !ok {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := n.AttributeString("id")
|
||||||
|
idStr := ""
|
||||||
|
if idBytes, ok := id.([]byte); ok {
|
||||||
|
idStr = string(idBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
var textBuf bytes.Buffer
|
||||||
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
||||||
|
if t, ok := child.(*ast.Text); ok {
|
||||||
|
textBuf.Write(t.Segment.Value(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headings = append(headings, Heading{
|
||||||
|
Level: heading.Level,
|
||||||
|
ID: idStr,
|
||||||
|
Text: textBuf.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return headings
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
// Slugify creates a URL-safe slug from text, matching goldmark's auto heading IDs.
|
||||||
|
func Slugify(text string) string {
|
||||||
|
text = strings.ToLower(text)
|
||||||
|
text = nonAlphanumeric.ReplaceAllString(text, "-")
|
||||||
|
text = strings.Trim(text, "-")
|
||||||
|
return text
|
||||||
|
}
|
||||||
99
internal/render/render_test.go
Normal file
99
internal/render/render_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderBasicMarkdown(t *testing.T) {
|
||||||
|
r := New()
|
||||||
|
result, err := r.Render([]byte("# Hello\n\nThis is a paragraph."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.HTML, "<h1") {
|
||||||
|
t.Fatalf("expected h1 tag, got: %s", result.HTML)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.HTML, "Hello") {
|
||||||
|
t.Fatalf("expected heading text, got: %s", result.HTML)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.HTML, "<p>This is a paragraph.</p>") {
|
||||||
|
t.Fatalf("expected paragraph, got: %s", result.HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderGFMTable(t *testing.T) {
|
||||||
|
md := "| A | B |\n|---|---|\n| 1 | 2 |"
|
||||||
|
r := New()
|
||||||
|
result, err := r.Render([]byte(md))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.HTML, "<table>") {
|
||||||
|
t.Fatalf("expected table, got: %s", result.HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCodeHighlighting(t *testing.T) {
|
||||||
|
md := "```go\nfunc main() {}\n```"
|
||||||
|
r := New()
|
||||||
|
result, err := r.Render([]byte(md))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
// chroma with classes should produce class attributes
|
||||||
|
if !strings.Contains(result.HTML, "class=") {
|
||||||
|
t.Fatalf("expected syntax highlighting classes, got: %s", result.HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHeadings(t *testing.T) {
|
||||||
|
md := "# Title\n## Section\n### Subsection"
|
||||||
|
r := New()
|
||||||
|
result, err := r.Render([]byte(md))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Headings) != 3 {
|
||||||
|
t.Fatalf("expected 3 headings, got %d", len(result.Headings))
|
||||||
|
}
|
||||||
|
if result.Headings[0].Level != 1 || result.Headings[0].Text != "Title" {
|
||||||
|
t.Fatalf("unexpected first heading: %+v", result.Headings[0])
|
||||||
|
}
|
||||||
|
if result.Headings[1].Level != 2 || result.Headings[1].Text != "Section" {
|
||||||
|
t.Fatalf("unexpected second heading: %+v", result.Headings[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadingAnchors(t *testing.T) {
|
||||||
|
md := "# Hello World"
|
||||||
|
r := New()
|
||||||
|
result, err := r.Render([]byte(md))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Headings) == 0 {
|
||||||
|
t.Fatal("no headings extracted")
|
||||||
|
}
|
||||||
|
if result.Headings[0].ID == "" {
|
||||||
|
t.Fatal("expected heading ID to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlugify(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Hello World", "hello-world"},
|
||||||
|
{"API Design", "api-design"},
|
||||||
|
{"foo--bar", "foo-bar"},
|
||||||
|
{" leading spaces ", "leading-spaces"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := Slugify(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
internal/server/fetch.go
Normal file
218
internal/server/fetch.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/internal/cache"
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/internal/gitea"
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/internal/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher coordinates fetching content from Gitea and populating the cache.
|
||||||
|
type Fetcher struct {
|
||||||
|
client *gitea.Client
|
||||||
|
renderer *render.Renderer
|
||||||
|
excludePaths []string
|
||||||
|
excludeRepos map[string]bool
|
||||||
|
concurrency int
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetcherConfig holds fetcher configuration.
|
||||||
|
type FetcherConfig struct {
|
||||||
|
Client *gitea.Client
|
||||||
|
Renderer *render.Renderer
|
||||||
|
ExcludePaths []string
|
||||||
|
ExcludeRepos []string
|
||||||
|
Concurrency int
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFetcher creates a Fetcher.
|
||||||
|
func NewFetcher(cfg FetcherConfig) *Fetcher {
|
||||||
|
excludeRepos := make(map[string]bool, len(cfg.ExcludeRepos))
|
||||||
|
for _, name := range cfg.ExcludeRepos {
|
||||||
|
excludeRepos[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Concurrency < 1 {
|
||||||
|
cfg.Concurrency = 4
|
||||||
|
}
|
||||||
|
if cfg.Log == nil {
|
||||||
|
cfg.Log = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Fetcher{
|
||||||
|
client: cfg.Client,
|
||||||
|
renderer: cfg.Renderer,
|
||||||
|
excludePaths: cfg.ExcludePaths,
|
||||||
|
excludeRepos: excludeRepos,
|
||||||
|
concurrency: cfg.Concurrency,
|
||||||
|
log: cfg.Log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchRepo fetches and renders all markdown files for a single repo.
|
||||||
|
func (f *Fetcher) FetchRepo(ctx context.Context, repo gitea.Repo) (*cache.RepoInfo, error) {
|
||||||
|
files, err := f.client.ListMarkdownFiles(ctx, repo.Name, repo.DefaultBranch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sha, commitDate, err := f.client.LatestCommitSHA(ctx, repo.Name, repo.DefaultBranch)
|
||||||
|
if err != nil {
|
||||||
|
f.log.Warn("could not get latest commit", "repo", repo.Name, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var docs []*cache.Document
|
||||||
|
for _, filePath := range files {
|
||||||
|
if f.isExcluded(filePath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := f.client.FetchFileContent(ctx, repo.Name, repo.DefaultBranch, filePath)
|
||||||
|
if err != nil {
|
||||||
|
f.log.Warn("skip file", "repo", repo.Name, "file", filePath, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := f.renderer.Render(content)
|
||||||
|
if err != nil {
|
||||||
|
f.log.Warn("render failed", "repo", repo.Name, "file", filePath, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPath := strings.TrimSuffix(filePath, filepath.Ext(filePath))
|
||||||
|
title := titleFromHeadings(result.Headings)
|
||||||
|
if title == "" {
|
||||||
|
title = titleFromPath(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = append(docs, &cache.Document{
|
||||||
|
Repo: repo.Name,
|
||||||
|
FilePath: filePath,
|
||||||
|
URLPath: urlPath,
|
||||||
|
Title: title,
|
||||||
|
HTML: result.HTML,
|
||||||
|
Headings: result.Headings,
|
||||||
|
LastUpdated: commitDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cache.RepoInfo{
|
||||||
|
Name: repo.Name,
|
||||||
|
Description: repo.Description,
|
||||||
|
Docs: docs,
|
||||||
|
CommitSHA: sha,
|
||||||
|
FetchedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) isExcluded(filePath string) bool {
|
||||||
|
for _, pattern := range f.excludePaths {
|
||||||
|
if strings.Contains(filePath, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleFromHeadings(headings []render.Heading) string {
|
||||||
|
for _, h := range headings {
|
||||||
|
if h.Level == 1 {
|
||||||
|
return h.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(headings) > 0 {
|
||||||
|
return headings[0].Text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleFromPath(filePath string) string {
|
||||||
|
base := filepath.Base(filePath)
|
||||||
|
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAllRepos(ctx context.Context, cfg BackgroundConfig) error {
|
||||||
|
repos, err := cfg.Fetcher.client.ListRepos(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, cfg.Fetcher.concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
var firstErr error
|
||||||
|
|
||||||
|
for _, repo := range repos {
|
||||||
|
if cfg.Fetcher.excludeRepos[repo.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(r gitea.Repo) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
info, err := cfg.Fetcher.FetchRepo(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
cfg.Log.Warn("fetch repo failed", "repo", r.Name, "error", err)
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(info.Docs) > 0 {
|
||||||
|
cfg.Cache.SetRepo(info)
|
||||||
|
}
|
||||||
|
cfg.Log.Info("fetched repo", "repo", r.Name, "docs", len(info.Docs))
|
||||||
|
}(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollForChanges(ctx context.Context, cfg BackgroundConfig) error {
|
||||||
|
repos, err := cfg.Fetcher.client.ListRepos(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, repo := range repos {
|
||||||
|
if cfg.Fetcher.excludeRepos[repo.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sha, _, err := cfg.Fetcher.client.LatestCommitSHA(ctx, repo.Name, repo.DefaultBranch)
|
||||||
|
if err != nil {
|
||||||
|
cfg.Log.Warn("poll: could not check commit", "repo", repo.Name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cached := cfg.Cache.GetCommitSHA(repo.Name)
|
||||||
|
if sha == cached {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Log.Info("repo changed, re-fetching", "repo", repo.Name, "old_sha", cached, "new_sha", sha)
|
||||||
|
info, err := cfg.Fetcher.FetchRepo(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
cfg.Log.Warn("poll: re-fetch failed", "repo", repo.Name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg.Cache.SetRepo(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
422
internal/server/server.go
Normal file
422
internal/server/server.go
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/internal/cache"
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/internal/render"
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the mcdoc HTTP server.
|
||||||
|
type Server struct {
|
||||||
|
cache *cache.Cache
|
||||||
|
pages map[string]*template.Template
|
||||||
|
loadingTmpl *template.Template
|
||||||
|
webhookSecret string
|
||||||
|
onWebhook func(repo string)
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds server configuration.
|
||||||
|
type Config struct {
|
||||||
|
Cache *cache.Cache
|
||||||
|
WebhookSecret string
|
||||||
|
OnWebhook func(repo string)
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a server with its routes.
|
||||||
|
func New(cfg Config) (*Server, error) {
|
||||||
|
tmplFS, err := fs.Sub(web.Content, "templates")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"safeHTML": func(s string) template.HTML {
|
||||||
|
return template.HTML(s) // #nosec G203 -- content is rendered from markdown by our own renderer
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutTmpl, err := template.New("layout.html").Funcs(funcMap).ParseFS(tmplFS, "layout.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse layout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pageNames := []string{"index.html", "repo.html", "doc.html", "error.html"}
|
||||||
|
pages := make(map[string]*template.Template, len(pageNames))
|
||||||
|
for _, name := range pageNames {
|
||||||
|
clone, err := layoutTmpl.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clone layout for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
_, err = clone.ParseFS(tmplFS, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", name, err)
|
||||||
|
}
|
||||||
|
pages[name] = clone
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingTmpl, err := template.New("loading.html").ParseFS(tmplFS, "loading.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse loading template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Log == nil {
|
||||||
|
cfg.Log = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
cache: cfg.Cache,
|
||||||
|
pages: pages,
|
||||||
|
loadingTmpl: loadingTmpl,
|
||||||
|
webhookSecret: cfg.WebhookSecret,
|
||||||
|
onWebhook: cfg.OnWebhook,
|
||||||
|
log: cfg.Log,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the chi router with all routes mounted.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
staticFS, err := fs.Sub(web.Content, "static")
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to open static fs", "error", err)
|
||||||
|
} else {
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Get("/health", s.handleHealth)
|
||||||
|
r.Post("/webhook", s.handleWebhook)
|
||||||
|
r.Get("/", s.handleIndex)
|
||||||
|
r.Get("/{repo}/", s.handleRepo)
|
||||||
|
r.Get("/{repo}/*", s.handleDoc)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadcrumb is a navigation element.
|
||||||
|
type Breadcrumb struct {
|
||||||
|
Label string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SidebarItem is a sidebar navigation entry.
|
||||||
|
type SidebarItem struct {
|
||||||
|
Label string
|
||||||
|
URL string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageData struct {
|
||||||
|
Title string
|
||||||
|
Breadcrumbs []Breadcrumb
|
||||||
|
Sidebar []SidebarItem
|
||||||
|
LastUpdated string
|
||||||
|
|
||||||
|
// Index page
|
||||||
|
Repos []*cache.RepoInfo
|
||||||
|
|
||||||
|
// Repo page
|
||||||
|
RepoName string
|
||||||
|
RepoDescription string
|
||||||
|
Docs []*cache.Document
|
||||||
|
|
||||||
|
// Doc page
|
||||||
|
Content template.HTML
|
||||||
|
TOC []render.Heading
|
||||||
|
|
||||||
|
// Error page
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.cache.IsReady() {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = w.Write([]byte("loading"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.cache.IsReady() {
|
||||||
|
s.renderLoading(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pageData{
|
||||||
|
Repos: s.cache.ListRepos(),
|
||||||
|
}
|
||||||
|
s.render(w, r, "index.html", data, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.cache.IsReady() {
|
||||||
|
s.renderLoading(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
repoName = sanitizePath(repoName)
|
||||||
|
|
||||||
|
repo, ok := s.cache.GetRepo(repoName)
|
||||||
|
if !ok {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "Repository not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pageData{
|
||||||
|
Title: repo.Name,
|
||||||
|
RepoName: repo.Name,
|
||||||
|
RepoDescription: repo.Description,
|
||||||
|
Docs: repo.Docs,
|
||||||
|
Breadcrumbs: []Breadcrumb{
|
||||||
|
{Label: repo.Name, URL: "/" + repo.Name + "/"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.render(w, r, "repo.html", data, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.cache.IsReady() {
|
||||||
|
s.renderLoading(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
repoName = sanitizePath(repoName)
|
||||||
|
docPath := chi.URLParam(r, "*")
|
||||||
|
docPath = sanitizePath(docPath)
|
||||||
|
|
||||||
|
doc, ok := s.cache.GetDocument(repoName, docPath)
|
||||||
|
if !ok {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "Document not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, _ := s.cache.GetRepo(repoName)
|
||||||
|
var sidebar []SidebarItem
|
||||||
|
if repo != nil {
|
||||||
|
for _, d := range repo.Docs {
|
||||||
|
sidebar = append(sidebar, SidebarItem{
|
||||||
|
Label: d.Title,
|
||||||
|
URL: "/" + repoName + "/" + d.URLPath,
|
||||||
|
Active: d.URLPath == docPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdated := ""
|
||||||
|
if !doc.LastUpdated.IsZero() {
|
||||||
|
lastUpdated = doc.LastUpdated.Format("2006-01-02 15:04 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pageData{
|
||||||
|
Title: doc.Title + " — " + repoName,
|
||||||
|
Content: template.HTML(doc.HTML), // #nosec G203 -- rendered by our goldmark pipeline
|
||||||
|
TOC: doc.Headings,
|
||||||
|
Sidebar: sidebar,
|
||||||
|
Breadcrumbs: []Breadcrumb{
|
||||||
|
{Label: repoName, URL: "/" + repoName + "/"},
|
||||||
|
{Label: doc.Title, URL: "/" + repoName + "/" + doc.URLPath},
|
||||||
|
},
|
||||||
|
LastUpdated: lastUpdated,
|
||||||
|
}
|
||||||
|
s.render(w, r, "doc.html", data, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.webhookSecret == "" {
|
||||||
|
http.Error(w, "webhook not configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := r.Header.Get("X-Gitea-Signature")
|
||||||
|
if !verifyHMAC(body, sig, s.webhookSecret) {
|
||||||
|
http.Error(w, "invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repoName := extractRepoName(body)
|
||||||
|
if repoName == "" {
|
||||||
|
http.Error(w, "cannot determine repo", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("webhook received", "repo", repoName)
|
||||||
|
if s.onWebhook != nil {
|
||||||
|
go s.onWebhook(repoName)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData, status int) {
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
s.renderPartial(w, name, data, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, ok := s.pages[name]
|
||||||
|
if !ok {
|
||||||
|
s.log.Error("page template not found", "template", name)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
|
||||||
|
s.log.Error("render template", "template", name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderPartial(w http.ResponseWriter, name string, data pageData, status int) {
|
||||||
|
tmpl, ok := s.pages[name]
|
||||||
|
if !ok {
|
||||||
|
s.log.Error("page template not found for partial", "template", name)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "content", data); err != nil {
|
||||||
|
s.log.Error("render partial", "template", name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderLoading(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
if err := s.loadingTmpl.Execute(w, nil); err != nil {
|
||||||
|
s.log.Error("render loading", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, code int, message string) {
|
||||||
|
data := pageData{
|
||||||
|
Title: http.StatusText(code),
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
s.render(w, r, "error.html", data, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyHMAC(body []byte, signature, secret string) bool {
|
||||||
|
if signature == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return hmac.Equal([]byte(expected), []byte(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRepoName pulls the repo name from a Gitea webhook JSON payload.
|
||||||
|
// We do minimal parsing to avoid importing encoding/json for a single field.
|
||||||
|
func extractRepoName(body []byte) string {
|
||||||
|
// Look for "repository":{"...", "name":"<value>"
|
||||||
|
s := string(body)
|
||||||
|
idx := strings.Index(s, `"repository"`)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sub := s[idx:]
|
||||||
|
nameIdx := strings.Index(sub, `"name":"`)
|
||||||
|
if nameIdx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start := nameIdx + len(`"name":"`)
|
||||||
|
end := strings.Index(sub[start:], `"`)
|
||||||
|
if end < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return sanitizePath(sub[start : start+end])
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizePath removes path traversal components.
|
||||||
|
func sanitizePath(p string) string {
|
||||||
|
// Remove all .. segments before cleaning to prevent traversal.
|
||||||
|
segments := strings.Split(p, "/")
|
||||||
|
var clean []string
|
||||||
|
for _, seg := range segments {
|
||||||
|
if seg == ".." || seg == "." || seg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clean = append(clean, seg)
|
||||||
|
}
|
||||||
|
return strings.Join(clean, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackgroundFetch coordinates the initial fetch and periodic polling.
|
||||||
|
func StartBackgroundFetch(ctx context.Context, cfg BackgroundConfig) {
|
||||||
|
log := cfg.Log
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch with retries
|
||||||
|
for {
|
||||||
|
if err := fetchAllRepos(ctx, cfg); err != nil {
|
||||||
|
log.Error("initial fetch failed, retrying in 30s", "error", err)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.Cache.SetReady()
|
||||||
|
log.Info("initial fetch complete")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll loop
|
||||||
|
ticker := time.NewTicker(cfg.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := pollForChanges(ctx, cfg); err != nil {
|
||||||
|
log.Warn("poll failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackgroundConfig holds configuration for background fetching.
|
||||||
|
type BackgroundConfig struct {
|
||||||
|
Cache *cache.Cache
|
||||||
|
Fetcher *Fetcher
|
||||||
|
PollInterval time.Duration
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
325
internal/server/server_test.go
Normal file
325
internal/server/server_test.go
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/internal/cache"
|
||||||
|
"git.wntrmute.dev/mc/mcdoc/internal/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T, c *cache.Cache, secret string) *Server {
|
||||||
|
t.Helper()
|
||||||
|
srv, err := New(Config{
|
||||||
|
Cache: c,
|
||||||
|
WebhookSecret: secret,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new server: %v", err)
|
||||||
|
}
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthNotReady(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthReady(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
c.SetReady()
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexNotReady(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexReady(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
c.SetRepo(&cache.RepoInfo{Name: "testrepo", Description: "A test"})
|
||||||
|
c.SetReady()
|
||||||
|
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "testrepo") {
|
||||||
|
t.Fatal("expected testrepo in index page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoPage(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
c.SetRepo(&cache.RepoInfo{
|
||||||
|
Name: "mcr",
|
||||||
|
Docs: []*cache.Document{
|
||||||
|
{Repo: "mcr", URLPath: "README", Title: "README"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.SetReady()
|
||||||
|
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcr/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "README") {
|
||||||
|
t.Fatal("expected README in repo page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoPageNotFound(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
c.SetReady()
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/nonexistent/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocPage(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
c.SetRepo(&cache.RepoInfo{
|
||||||
|
Name: "mcr",
|
||||||
|
Docs: []*cache.Document{
|
||||||
|
{
|
||||||
|
Repo: "mcr",
|
||||||
|
URLPath: "ARCHITECTURE",
|
||||||
|
Title: "Architecture",
|
||||||
|
HTML: "<h1>Architecture</h1><p>content</p>",
|
||||||
|
Headings: []render.Heading{
|
||||||
|
{Level: 1, ID: "architecture", Text: "Architecture"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.SetReady()
|
||||||
|
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcr/ARCHITECTURE", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "Architecture") {
|
||||||
|
t.Fatal("expected Architecture in doc page")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "content") {
|
||||||
|
t.Fatal("expected rendered content in doc page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocPageNotFound(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
c.SetRepo(&cache.RepoInfo{Name: "mcr"})
|
||||||
|
c.SetReady()
|
||||||
|
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcr/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTMXPartialResponse(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
c.SetRepo(&cache.RepoInfo{Name: "r", Description: "test"})
|
||||||
|
c.SetReady()
|
||||||
|
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
// Partial should not include the full layout (no <html> tag)
|
||||||
|
if strings.Contains(body, "<html") {
|
||||||
|
t.Fatal("htmx response should not include full layout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookValidSignature(t *testing.T) {
|
||||||
|
secret := "test-secret"
|
||||||
|
payload := `{"repository":{"name":"mcr","full_name":"mc/mcr"}}`
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
sig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
c := cache.New()
|
||||||
|
c.SetReady()
|
||||||
|
|
||||||
|
webhookCh := make(chan string, 1)
|
||||||
|
srv := newTestServer(t, c, secret)
|
||||||
|
srv.onWebhook = func(repo string) {
|
||||||
|
webhookCh <- repo
|
||||||
|
}
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(payload))
|
||||||
|
req.Header.Set("X-Gitea-Signature", sig)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case repo := <-webhookCh:
|
||||||
|
if repo != "mcr" {
|
||||||
|
t.Fatalf("webhook repo = %q, want mcr", repo)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("webhook callback not called within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookInvalidSignature(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
srv := newTestServer(t, c, "real-secret")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"repository":{"name":"mcr"}}`))
|
||||||
|
req.Header.Set("X-Gitea-Signature", "bad-signature")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookNoSecret(t *testing.T) {
|
||||||
|
c := cache.New()
|
||||||
|
srv := newTestServer(t, c, "")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRepoName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid payload",
|
||||||
|
payload: `{"repository":{"id":1,"name":"mcr","full_name":"mc/mcr"}}`,
|
||||||
|
want: "mcr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no repository",
|
||||||
|
payload: `{"action":"push"}`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
payload: `{}`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := extractRepoName([]byte(tt.payload))
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("extractRepoName = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/foo/bar", "foo/bar"},
|
||||||
|
{"../etc/passwd", "etc/passwd"},
|
||||||
|
{"normal/path", "normal/path"},
|
||||||
|
{"../../bad", "bad"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := sanitizePath(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
mcdoc.toml.example
Normal file
19
mcdoc.toml.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[server]
|
||||||
|
listen_addr = ":8080"
|
||||||
|
|
||||||
|
[gitea]
|
||||||
|
url = "https://git.wntrmute.dev"
|
||||||
|
org = "mc"
|
||||||
|
webhook_secret = "change-me"
|
||||||
|
poll_interval = "15m"
|
||||||
|
fetch_timeout = "30s"
|
||||||
|
max_concurrency = 4
|
||||||
|
|
||||||
|
[gitea.exclude_paths]
|
||||||
|
patterns = ["vendor/", ".claude/", "node_modules/", ".junie/"]
|
||||||
|
|
||||||
|
[gitea.exclude_repos]
|
||||||
|
names = []
|
||||||
|
|
||||||
|
[log]
|
||||||
|
level = "info"
|
||||||
6
web/embed.go
Normal file
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed templates/*.html static/*
|
||||||
|
var Content embed.FS
|
||||||
1
web/static/htmx.min.js
vendored
Normal file
1
web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
343
web/static/style.css
Normal file
343
web/static/style.css
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
/* Metacircular Docs — clean technical document styling */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #fafafa;
|
||||||
|
--fg: #1a1a1a;
|
||||||
|
--fg-muted: #555;
|
||||||
|
--border: #ddd;
|
||||||
|
--link: #1a56db;
|
||||||
|
--link-hover: #0f3d91;
|
||||||
|
--code-bg: #f0f0f0;
|
||||||
|
--code-border: #ddd;
|
||||||
|
--sidebar-bg: #f5f5f5;
|
||||||
|
--sidebar-active: #e8e8e8;
|
||||||
|
--max-width: 72rem;
|
||||||
|
--content-width: 70ch;
|
||||||
|
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header / Breadcrumbs */
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .sep {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main layout */
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-height: calc(100vh - 8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex: 0 0 14rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
max-height: calc(100vh - 4rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li a:hover {
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li.active a {
|
||||||
|
background: var(--sidebar-active);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
|
||||||
|
#content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: var(--content-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Index page */
|
||||||
|
|
||||||
|
.repo-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card p {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Repo page */
|
||||||
|
|
||||||
|
.repo-desc {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-list li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table of contents */
|
||||||
|
|
||||||
|
.toc {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc li {
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-h2 { padding-left: 0; }
|
||||||
|
.toc-h3 { padding-left: 1rem; }
|
||||||
|
.toc-h4 { padding-left: 2rem; }
|
||||||
|
.toc-h5 { padding-left: 3rem; }
|
||||||
|
|
||||||
|
/* Document content */
|
||||||
|
|
||||||
|
.doc-content {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content h1 { font-size: 1.75rem; margin: 2rem 0 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
|
||||||
|
.doc-content h2 { font-size: 1.4rem; margin: 1.75rem 0 0.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
|
||||||
|
.doc-content h3 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; }
|
||||||
|
.doc-content h4 { font-size: 1rem; margin: 1.25rem 0 0.5rem; }
|
||||||
|
|
||||||
|
.doc-content h1:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.doc-content p {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875em;
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--code-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content th, .doc-content td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content th {
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content ul, .doc-content ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task list (GFM) */
|
||||||
|
.doc-content ul.contains-task-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error page */
|
||||||
|
|
||||||
|
.error-page {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading page */
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 5rem 0;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
main {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
flex: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
web/templates/doc.html
Normal file
17
web/templates/doc.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{if .TOC}}
|
||||||
|
<nav class="toc">
|
||||||
|
<details open>
|
||||||
|
<summary>Contents</summary>
|
||||||
|
<ul>
|
||||||
|
{{range .TOC}}
|
||||||
|
<li class="toc-h{{.Level}}"><a href="#{{.ID}}">{{.Text}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
<article class="doc-content">
|
||||||
|
{{.Content}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
7
web/templates/error.html
Normal file
7
web/templates/error.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="error-page">
|
||||||
|
<h1>{{.Code}}</h1>
|
||||||
|
<p>{{.Message}}</p>
|
||||||
|
<a href="/" hx-get="/" hx-target="#content" hx-push-url="true">Back to index</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
14
web/templates/index.html
Normal file
14
web/templates/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Metacircular Platform Documentation</h1>
|
||||||
|
<div class="repo-list">
|
||||||
|
{{range .Repos}}
|
||||||
|
<div class="repo-card">
|
||||||
|
<h2><a href="/{{.Name}}/" hx-get="/{{.Name}}/" hx-target="#content" hx-push-url="true">{{.Name}}</a></h2>
|
||||||
|
{{if .Description}}<p>{{.Description}}</p>{{end}}
|
||||||
|
<span class="doc-count">{{len .Docs}} document{{if ne (len .Docs) 1}}s{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p>No repositories found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
42
web/templates/layout.html
Normal file
42
web/templates/layout.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{if .Title}}{{.Title}} — {{end}}Metacircular Docs</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/" hx-get="/" hx-target="#content" hx-push-url="true">Metacircular Docs</a>
|
||||||
|
{{range .Breadcrumbs}}
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<a href="{{.URL}}" hx-get="{{.URL}}" hx-target="#content" hx-push-url="true">{{.Label}}</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{{if .Sidebar}}
|
||||||
|
<aside class="sidebar">
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
{{range .Sidebar}}
|
||||||
|
<li{{if .Active}} class="active"{{end}}>
|
||||||
|
<a href="{{.URL}}" hx-get="{{.URL}}" hx-target="#content" hx-push-url="true">{{.Label}}</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
{{end}}
|
||||||
|
<div id="content">
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
{{if .LastUpdated}}<time>Last updated: {{.LastUpdated}}</time>{{end}}
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
web/templates/loading.html
Normal file
20
web/templates/loading.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Loading — Metacircular Docs</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div id="content">
|
||||||
|
<div class="loading">
|
||||||
|
<h1>Metacircular Docs</h1>
|
||||||
|
<p>Fetching documentation from Gitea... please wait.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
web/templates/repo.html
Normal file
11
web/templates/repo.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>{{.RepoName}}</h1>
|
||||||
|
{{if .RepoDescription}}<p class="repo-desc">{{.RepoDescription}}</p>{{end}}
|
||||||
|
<ul class="doc-list">
|
||||||
|
{{range .Docs}}
|
||||||
|
<li>
|
||||||
|
<a href="/{{$.RepoName}}/{{.URLPath}}" hx-get="/{{$.RepoName}}/{{.URLPath}}" hx-target="#content" hx-push-url="true">{{.Title}}</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user