summaryrefslogtreecommitdiff
path: root/internal/handlers/docs.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers/docs.go')
-rw-r--r--internal/handlers/docs.go106
1 files changed, 106 insertions, 0 deletions
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
+}