summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/handlers/docs.go106
-rw-r--r--internal/handlers/docs_test.go154
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)
+ }
+}