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 | |
| 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.
| -rw-r--r-- | cmd/dashboard/main.go | 4 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -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 | ||||
| -rw-r--r-- | web/templates/docs-index.html | 29 | ||||
| -rw-r--r-- | web/templates/docs-view.html | 36 |
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) @@ -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 @@ -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">← 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">← Docs</a> + <div class="bg-gray-800 rounded-xl p-6 markdown-body"> + {{.Body}} + </div> + </div> +</body> +</html> |
