Files
bookhoard/internal/docs/handler.go
T
John O'Keefe a4dc03984c feat(docs): "?" help affordance on every filter mount + docs loader fixes
The filter manual (1ed1602e) was reachable only by knowing /docs
already existed — nothing in the UI pointed at it (the parked manual
plan's open decision). Every filter-builder mount now carries a "?"
in its header linking to the guide (opens in a new tab so drawer and
editor state survive): the All Books drawer and the collection rules
page — the two mounts sharing FilterBuilderRows. The docs home
indexes the manual (guide, cookbook, generated field reference).

Two docs-loader fixes surfaced by pinning this:
- Folder URLs resolve to their index page: /docs/user/filters now
  serves user/filters/index.md (the manual is a folder: guide +
  cookbook + generated reference); previously only root /docs had
  index handling.
- The docs folder path is resolved by walking upward (bounded):
  `go test` runs test binaries from the PACKAGE directory, so the
  relative "docs" path resolved to a folder that didn't exist there
  and every /docs route 404'd in tests while working in prod —
  found by the new pin, fixed so tests exercise the docs routes
  exactly as prod serves them.

Pins: TestFilterHelpAffordance (folder URL serves the guide; drawer
and rules pages carry the link). internal/docs suite + full suite
green (239s).
2026-11-01 16:22:35 -05:00

311 lines
7.8 KiB
Go

package docs
import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"bookhoard/templates"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type DocsHandler struct {
markdown goldmark.Markdown
docsPath string
docsFS fs.FS
}
// resolveDocsPath — the docs folder lives at the repo root, but `go test`
// runs test binaries from the PACKAGE directory (/cmd/server/tests), so a
// plain relative "docs" resolves to a folder that doesn't exist there and
// every /docs route 404s in tests (2026-11-01). Walk upward, bounded,
// until the folder is real; prod (/app) matches on the first try.
func resolveDocsPath(p string) string {
for i := 0; i < 6; i++ {
if fi, err := os.Stat(filepath.Join(p, "index.md")); err == nil && !fi.IsDir() {
return p
}
p = filepath.Join("..", p)
}
return "docs"
}
func NewDocsHandler(docsPath string) *DocsHandler {
docsPath = resolveDocsPath(docsPath)
// Create goldmark renderer with syntax highlighting
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
highlighting.NewHighlighting(
// Chroma renders code blocks with inline styles, so the
// palette ships with the HTML — no client-side highlighter
// needed. The docs are read on dark themes most of the time;
// code blocks keep a dark surface under light themes too.
highlighting.WithStyle("github-dark"),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(),
),
)
return &DocsHandler{
markdown: md,
docsPath: docsPath,
docsFS: os.DirFS(docsPath),
}
}
// LoadDocument loads and renders a markdown document
func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error) {
// Clean the path
docPath = strings.TrimPrefix(docPath, "/")
docPath = strings.TrimSuffix(docPath, "/")
// Default to INDEX.md if root
if docPath == "" || docPath == "docs" {
docPath = "index.md"
} else {
// Remove /docs prefix if present
docPath = strings.TrimPrefix(docPath, "docs/")
// Add .md if not present
if !strings.HasSuffix(docPath, ".md") {
docPath += ".md"
}
}
// Read the markdown file. Folder URLs resolve to their index page:
// /docs/user/filters → user/filters/index.md (2026-11-01 — the
// filter manual is a folder: guide + cookbook + generated field
// reference).
content, err := fs.ReadFile(h.docsFS, docPath)
if err != nil {
if !strings.HasSuffix(docPath, "index.md") {
alt := strings.TrimSuffix(docPath, ".md") + "/index.md"
if altContent, altErr := fs.ReadFile(h.docsFS, alt); altErr == nil {
docPath = alt
content = altContent
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
}
// Convert markdown to HTML
var buf bytes.Buffer
context := parser.NewContext()
if err := h.markdown.Convert(content, &buf, parser.WithContext(context)); err != nil {
return nil, fmt.Errorf("failed to convert markdown: %w", err)
}
// Extract title from first heading
title := h.extractTitle(content)
// Generate table of contents
toc := h.generateTOC(content)
// Generate breadcrumb
breadcrumb := h.generateBreadcrumb(docPath)
// Determine category
category := h.getCategory(docPath)
return &templates.Document{
Title: title,
Content: buf.String(),
TOC: toc,
Breadcrumb: breadcrumb,
Category: category,
SourceFile: docPath,
}, nil
}
// extractTitle extracts the first heading from markdown content
func (h *DocsHandler) extractTitle(content []byte) string {
lines := bytes.Split(content, []byte("\n"))
for _, line := range lines {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("# ")) {
return string(bytes.TrimPrefix(line, []byte("# ")))
}
}
return "Documentation"
}
// generateTOC generates a table of contents from markdown headings
func (h *DocsHandler) generateTOC(content []byte) []templates.TOCItem {
var toc []templates.TOCItem
lines := bytes.Split(content, []byte("\n"))
for _, line := range lines {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("#")) && !bytes.HasPrefix(line, []byte("# ")) {
// Count heading level
level := 0
for _, c := range line {
if c == '#' {
level++
} else {
break
}
}
if level > 1 && level <= 4 { // Only ## ### ####
title := strings.TrimSpace(string(bytes.TrimLeft(line, "#")))
anchor := h.slugify(title)
toc = append(toc, templates.TOCItem{
Level: level,
Title: title,
Anchor: anchor,
})
}
}
}
return toc
}
// generateBreadcrumb generates breadcrumb navigation. Only "Docs" links out:
// directory segments have no landing pages, so they render as plain text
// (URL left empty). The current page is omitted — the h1 below shows it.
func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbItem {
parts := strings.Split(strings.TrimSuffix(docPath, ".md"), "/")
breadcrumb := []templates.BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
}
for i, part := range parts {
if part == "" {
continue
}
if i == len(parts)-1 {
break // current page
}
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
})
}
return breadcrumb
}
// getCategory determines the category of a document. It reuses the nav
// routing (placeDoc) so search results and breadcrumbs name sections exactly
// as the sidebar does.
func (h *DocsHandler) getCategory(docPath string) string {
return placeDoc(docPath).section
}
// slugify converts a string to a URL-safe slug
func (h *DocsHandler) slugify(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "/", "-")
// Remove non-alphanumeric characters (except hyphens)
var result strings.Builder
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
result.WriteRune(c)
}
}
return result.String()
}
// ListDocuments returns all markdown files in the docs directory
func (h *DocsHandler) ListDocuments() ([]string, error) {
var docs []string
err := fs.WalkDir(h.docsFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".md") {
docs = append(docs, strings.TrimSuffix(path, ".md"))
}
return nil
})
return docs, err
}
// SearchDoc represents a document for search indexing
type SearchDoc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
URL string `json:"url"`
}
// GenerateSearchIndex generates a search index for all documentation
func (h *DocsHandler) GenerateSearchIndex() ([]SearchDoc, error) {
docs, err := h.ListDocuments()
if err != nil {
return nil, fmt.Errorf("failed to list documents: %w", err)
}
var searchDocs []SearchDoc
for _, path := range docs {
doc, err := h.LoadDocument(path)
if err != nil {
continue
}
// Strip HTML tags for better search
content := h.stripHTML(doc.Content)
searchDocs = append(searchDocs, SearchDoc{
ID: path,
Title: doc.Title,
Content: content,
URL: "/docs/" + strings.TrimSuffix(path, ".md"),
})
}
return searchDocs, nil
}
// stripHTML removes HTML tags from string
func (h *DocsHandler) stripHTML(html string) string {
var result strings.Builder
inTag := false
for _, r := range html {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result.WriteRune(r)
}
}
return result.String()
}