diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-14 18:13:22 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:44:02 +0000 |
| commit | 9a7f19270c50def47be5dc1a9b30d8655d7098c9 (patch) | |
| tree | 889533960475b82491644496d3af57bba167ca25 /internal | |
| parent | 7cf03d3559d25377699c78085c6d722ba0aebae5 (diff) | |
feat: add authenticated /docs viewer for superpowers specs/plans
Renders docs/superpowers/{specs,plans}/*.md to HTML via goldmark,
served from doot's own web server behind the existing session auth
instead of as raw files. DOCS_DIR is configurable (defaults to
docs/superpowers relative to the working directory) so the deployed
server can point straight at the working repo and stay live-synced
with no separate copy/deploy step for new docs.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/config.go | 2 | ||||
| -rw-r--r-- | internal/handlers/docs.go | 106 | ||||
| -rw-r--r-- | internal/handlers/docs_test.go | 154 |
3 files changed, 262 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index f4baeef..e6bc19a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { MigrationDir string TemplateDir string StaticDir string + DocsDir string // root of superpowers spec/plan docs, served read-only at /docs // Server Port string @@ -72,6 +73,7 @@ func Load() (*Config, error) { MigrationDir: getEnvWithDefault("MIGRATION_DIR", "migrations"), TemplateDir: getEnvWithDefault("TEMPLATE_DIR", "web/templates"), StaticDir: getEnvWithDefault("STATIC_DIR", "web/static"), + DocsDir: getEnvWithDefault("DOCS_DIR", "docs/superpowers"), // Server Port: getEnvWithDefault("PORT", "8080"), diff --git a/internal/handlers/docs.go b/internal/handlers/docs.go new file mode 100644 index 0000000..24732cb --- /dev/null +++ b/internal/handlers/docs.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "html/template" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/yuin/goldmark" +) + +// docsCategories are the only subdirectories served under /docs -- a +// whitelist, not a filesystem listing, so an unexpected file dropped into +// DocsDir doesn't unintentionally become servable. +var docsCategories = []string{"specs", "plans"} + +type docsEntry struct { + Name string // filename, e.g. "2026-07-14-foo-design.md" + URL string +} + +type docsCategoryListing struct { + Name string + Entries []docsEntry +} + +// HandleDocsIndex lists every markdown file under each whitelisted category +// subdirectory of cfg.DocsDir, newest first (filenames are date-prefixed, +// so reverse-alphabetical is reverse-chronological). +func (h *Handler) HandleDocsIndex(w http.ResponseWriter, r *http.Request) { + var categories []docsCategoryListing + for _, cat := range docsCategories { + files, err := os.ReadDir(filepath.Join(h.config.DocsDir, cat)) + if err != nil { + continue + } + var entries []docsEntry + for _, f := range files { + if f.IsDir() || !strings.HasSuffix(f.Name(), ".md") { + continue + } + entries = append(entries, docsEntry{ + Name: f.Name(), + URL: "/docs/" + cat + "/" + f.Name(), + }) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name > entries[j].Name }) + categories = append(categories, docsCategoryListing{Name: cat, Entries: entries}) + } + + if err := h.renderer.Render(w, "docs-index.html", struct { + Categories []docsCategoryListing + }{categories}); err != nil { + http.Error(w, "Failed to render template", http.StatusInternalServerError) + } +} + +// HandleDocsView renders a single markdown file to HTML. category and +// filename both come from the URL and are validated against a whitelist / +// base-name check before touching the filesystem, so a request can't +// escape DocsDir via "..": chi's {filename} route param never contains a +// literal "/", and Go's filepath.Base further strips any residual path +// separators before the read. +func (h *Handler) HandleDocsView(w http.ResponseWriter, r *http.Request) { + category := chi.URLParam(r, "category") + if !isValidDocsCategory(category) { + http.NotFound(w, r) + return + } + filename := filepath.Base(chi.URLParam(r, "filename")) + if !strings.HasSuffix(filename, ".md") { + http.NotFound(w, r) + return + } + + raw, err := os.ReadFile(filepath.Join(h.config.DocsDir, category, filename)) + if err != nil { + http.NotFound(w, r) + return + } + + var body strings.Builder + if err := goldmark.Convert(raw, &body); err != nil { + http.Error(w, "Failed to render markdown", http.StatusInternalServerError) + return + } + + if err := h.renderer.Render(w, "docs-view.html", struct { + Title string + Body template.HTML + }{filename, template.HTML(body.String())}); err != nil { + http.Error(w, "Failed to render template", http.StatusInternalServerError) + } +} + +func isValidDocsCategory(category string) bool { + for _, c := range docsCategories { + if c == category { + return true + } + } + return false +} diff --git a/internal/handlers/docs_test.go b/internal/handlers/docs_test.go new file mode 100644 index 0000000..9d684d8 --- /dev/null +++ b/internal/handlers/docs_test.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "context" + "html/template" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +func setupDocsTestHandler(t *testing.T) *Handler { + t.Helper() + h, cleanup := setupTestHandler(t) + t.Cleanup(cleanup) + + docsDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(docsDir, "specs"), 0755); err != nil { + t.Fatalf("failed to create specs dir: %v", err) + } + if err := os.MkdirAll(filepath.Join(docsDir, "plans"), 0755); err != nil { + t.Fatalf("failed to create plans dir: %v", err) + } + if err := os.WriteFile(filepath.Join(docsDir, "specs", "2026-07-14-example-design.md"), []byte("# Example\n\nSome body text."), 0644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + + h.config.DocsDir = docsDir + return h +} + +func TestHandleDocsIndex_ListsWhitelistedCategories(t *testing.T) { + h := setupDocsTestHandler(t) + + req := httptest.NewRequest("GET", "/docs", nil) + w := httptest.NewRecorder() + h.HandleDocsIndex(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + mock := h.renderer.(*MockRenderer) + if len(mock.Calls) != 1 { + t.Fatalf("expected 1 render call, got %d", len(mock.Calls)) + } + if mock.Calls[0].Name != "docs-index.html" { + t.Errorf("template name = %q, want %q", mock.Calls[0].Name, "docs-index.html") + } + + data, ok := mock.Calls[0].Data.(struct{ Categories []docsCategoryListing }) + if !ok { + t.Fatalf("unexpected data type: %T", mock.Calls[0].Data) + } + if len(data.Categories) != 2 { + t.Fatalf("expected 2 categories (specs, plans), got %d", len(data.Categories)) + } + specs := data.Categories[0] + if specs.Name != "specs" { + t.Fatalf("expected first category 'specs', got %q", specs.Name) + } + if len(specs.Entries) != 1 || specs.Entries[0].Name != "2026-07-14-example-design.md" { + t.Fatalf("expected 1 spec entry, got %+v", specs.Entries) + } + if specs.Entries[0].URL != "/docs/specs/2026-07-14-example-design.md" { + t.Errorf("URL = %q, want %q", specs.Entries[0].URL, "/docs/specs/2026-07-14-example-design.md") + } + + plans := data.Categories[1] + if plans.Name != "plans" || len(plans.Entries) != 0 { + t.Fatalf("expected empty plans category, got %+v", plans) + } +} + +func routeWithParams(method, target, category, filename string) *http.Request { + req := httptest.NewRequest(method, target, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("category", category) + rctx.URLParams.Add("filename", filename) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func TestHandleDocsView_RendersMarkdownFile(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/specs/2026-07-14-example-design.md", "specs", "2026-07-14-example-design.md") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + mock := h.renderer.(*MockRenderer) + if len(mock.Calls) != 1 || mock.Calls[0].Name != "docs-view.html" { + t.Fatalf("expected 1 call to docs-view.html, got %+v", mock.Calls) + } + data, ok := mock.Calls[0].Data.(struct { + Title string + Body template.HTML + }) + if !ok { + t.Fatalf("unexpected data type: %T", mock.Calls[0].Data) + } + if data.Title != "2026-07-14-example-design.md" { + t.Errorf("Title = %q, want %q", data.Title, "2026-07-14-example-design.md") + } + if !strings.Contains(string(data.Body), "<h1>Example</h1>") { + t.Errorf("Body does not contain expected rendered heading: %s", data.Body) + } + if !strings.Contains(string(data.Body), "Some body text.") { + t.Errorf("Body does not contain expected paragraph text: %s", data.Body) + } +} + +func TestHandleDocsView_RejectsUnknownCategory(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/other/2026-07-14-example-design.md", "other", "2026-07-14-example-design.md") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for unknown category, got %d", w.Code) + } +} + +func TestHandleDocsView_RejectsNonMarkdownFilename(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/specs/..", "specs", "..") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for a non-.md filename (e.g. '..'), got %d", w.Code) + } +} + +func TestHandleDocsView_RejectsMissingFile(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/specs/does-not-exist.md", "specs", "does-not-exist.md") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for a missing file, got %d", w.Code) + } +} |
