go-help/server.go
Snider 944cad006b feat(help): Phase 2 — HTTP server, rendering, static site generator, CLI ingestion
Add complete HTTP server and rendering layer for the help catalog:

- render.go: Markdown-to-HTML via goldmark (GFM, typographer, raw HTML)
- server.go: HTTP server with 6 routes (HTML index/topic/search + JSON API)
- templates.go: Embedded HTML templates with dark theme (bg #0d1117)
- templates/: base, index, topic, search, 404 page templates
- generate.go: Static site generator with client-side JS search
- ingest.go: CLI help text parser (Usage/Flags/Examples/Commands sections)

320 tests passing, 95.5% coverage, race-clean, vet-clean.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:50:10 +00:00

162 lines
4.3 KiB
Go

// SPDX-Licence-Identifier: EUPL-1.2
package help
import (
"encoding/json"
"net/http"
)
// Server serves the help catalog over HTTP.
type Server struct {
catalog *Catalog
addr string
mux *http.ServeMux
}
// NewServer creates an HTTP server for the given catalog.
// Routes are registered on creation; the caller can use ServeHTTP as
// an http.Handler or call ListenAndServe to start listening.
func NewServer(catalog *Catalog, addr string) *Server {
s := &Server{
catalog: catalog,
addr: addr,
mux: http.NewServeMux(),
}
// HTML routes
s.mux.HandleFunc("GET /", s.handleIndex)
s.mux.HandleFunc("GET /topics/{id}", s.handleTopic)
s.mux.HandleFunc("GET /search", s.handleSearch)
// JSON API routes
s.mux.HandleFunc("GET /api/topics", s.handleAPITopics)
s.mux.HandleFunc("GET /api/topics/{id}", s.handleAPITopic)
s.mux.HandleFunc("GET /api/search", s.handleAPISearch)
return s
}
// ServeHTTP implements http.Handler, delegating to the internal mux.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// ListenAndServe starts the HTTP server.
func (s *Server) ListenAndServe() error {
srv := &http.Server{
Addr: s.addr,
Handler: s.mux,
}
return srv.ListenAndServe()
}
// setSecurityHeaders sets common security headers.
func setSecurityHeaders(w http.ResponseWriter) {
w.Header().Set("X-Content-Type-Options", "nosniff")
}
// --- HTML handlers ---
func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
setSecurityHeaders(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
topics := s.catalog.List()
data := indexData{
Topics: topics,
Groups: groupTopicsByTag(topics),
}
if err := renderPage(w, "index.html", data); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w)
id := r.PathValue("id")
topic, err := s.catalog.Get(id)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_ = renderPage(w, "404.html", nil)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := renderPage(w, "topic.html", topicData{Topic: topic}); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w)
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Missing search query parameter 'q'", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
results := s.catalog.Search(query)
data := searchData{
Query: query,
Results: results,
}
if err := renderPage(w, "search.html", data); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// --- JSON API handlers ---
func (s *Server) handleAPITopics(w http.ResponseWriter, _ *http.Request) {
setSecurityHeaders(w)
w.Header().Set("Content-Type", "application/json")
topics := s.catalog.List()
if err := json.NewEncoder(w).Encode(topics); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Server) handleAPITopic(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w)
id := r.PathValue("id")
topic, err := s.catalog.Get(id)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "topic not found"})
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(topic); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Server) handleAPISearch(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w)
query := r.URL.Query().Get("q")
if query == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "missing query parameter 'q'"})
return
}
w.Header().Set("Content-Type", "application/json")
results := s.catalog.Search(query)
if err := json.NewEncoder(w).Encode(results); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}