summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go4
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/handlers/docs.go106
-rw-r--r--internal/handlers/docs_test.go154
-rw-r--r--web/templates/docs-index.html29
-rw-r--r--web/templates/docs-view.html36
8 files changed, 334 insertions, 0 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index eced067..72fe78e 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -308,6 +308,10 @@ func main() {
r.Get("/api/boards", h.HandleGetBoards)
r.Get("/api/shopping", h.HandleGetShoppingList)
+ // Docs -- read-only rendered view of docs/superpowers specs and plans
+ r.Get("/docs", h.HandleDocsIndex)
+ r.Get("/docs/{category}/{filename}", h.HandleDocsView)
+
// Tab routes for HTMX
r.Get("/tabs/tasks", h.HandleTabTasks)
r.Get("/tabs/planning", h.HandleTabPlanning)
diff --git a/go.mod b/go.mod
index 9775436..6ec123b 100644
--- a/go.mod
+++ b/go.mod
@@ -38,6 +38,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
+ github.com/yuin/goldmark v1.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
diff --git a/go.sum b/go.sum
index f82d427..8a6741a 100644
--- a/go.sum
+++ b/go.sum
@@ -67,6 +67,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
+github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
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)
+ }
+}
diff --git a/web/templates/docs-index.html b/web/templates/docs-index.html
new file mode 100644
index 0000000..c041a3f
--- /dev/null
+++ b/web/templates/docs-index.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Docs</title>
+ <link rel="stylesheet" href="/static/css/output.css">
+</head>
+<body class="min-h-screen bg-gray-900 text-slate-200 p-4">
+ <div class="max-w-2xl mx-auto pt-6">
+ <a href="/" class="text-sm text-slate-400 hover:text-slate-200 mb-4 inline-block">&larr; Dashboard</a>
+ <h1 class="font-semibold text-xl mb-6">Docs</h1>
+ {{range .Categories}}
+ <div class="bg-gray-800 rounded-xl p-5 mb-4">
+ <h2 class="font-semibold text-base mb-3 capitalize text-slate-100">{{.Name}}</h2>
+ {{if .Entries}}
+ <ul class="space-y-1">
+ {{range .Entries}}
+ <li><a href="{{.URL}}" class="text-sm text-blue-400 hover:text-blue-300">{{.Name}}</a></li>
+ {{end}}
+ </ul>
+ {{else}}
+ <p class="text-sm text-slate-500">No files yet.</p>
+ {{end}}
+ </div>
+ {{end}}
+ </div>
+</body>
+</html>
diff --git a/web/templates/docs-view.html b/web/templates/docs-view.html
new file mode 100644
index 0000000..396e846
--- /dev/null
+++ b/web/templates/docs-view.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{.Title}}</title>
+ <link rel="stylesheet" href="/static/css/output.css">
+ <style>
+ .markdown-body h1 { font-size: 1.5rem; font-weight: 600; margin: 1.5rem 0 0.75rem; color: #f1f5f9; }
+ .markdown-body h2 { font-size: 1.25rem; font-weight: 600; margin: 1.5rem 0 0.5rem; color: #f1f5f9; }
+ .markdown-body h3 { font-size: 1.1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e2e8f0; }
+ .markdown-body p { margin: 0.75rem 0; line-height: 1.6; }
+ .markdown-body ul, .markdown-body ol { margin: 0.5rem 0 0.75rem 1.5rem; }
+ .markdown-body li { margin: 0.25rem 0; line-height: 1.5; }
+ .markdown-body li > ul, .markdown-body li > ol { margin-top: 0.25rem; }
+ .markdown-body code { background: #1e293b; padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.85em; }
+ .markdown-body pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 0.75rem 0; }
+ .markdown-body pre code { background: none; padding: 0; }
+ .markdown-body blockquote { border-left: 3px solid #334155; padding-left: 1rem; color: #94a3b8; margin: 0.75rem 0; }
+ .markdown-body a { color: #60a5fa; }
+ .markdown-body a:hover { color: #93c5fd; }
+ .markdown-body strong { color: #f1f5f9; }
+ .markdown-body hr { border-color: #1e293b; margin: 1.5rem 0; }
+ .markdown-body table { border-collapse: collapse; margin: 0.75rem 0; }
+ .markdown-body th, .markdown-body td { border: 1px solid #334155; padding: 0.4rem 0.75rem; }
+ </style>
+</head>
+<body class="min-h-screen bg-gray-900 text-slate-200 p-4">
+ <div class="max-w-3xl mx-auto pt-6 pb-12">
+ <a href="/docs" class="text-sm text-slate-400 hover:text-slate-200 mb-4 inline-block">&larr; Docs</a>
+ <div class="bg-gray-800 rounded-xl p-6 markdown-body">
+ {{.Body}}
+ </div>
+ </div>
+</body>
+</html>