diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go
index 16d1ac84..9a4e5319 100644
--- a/cmd/server/tests/podcast_test.go
+++ b/cmd/server/tests/podcast_test.go
@@ -2520,3 +2520,60 @@ func TestFilterLabelsEndpoint(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
})
}
+
+// ---- the "?" affordance: the builder links to its manual ----
+
+// TestFilterHelpAffordance pins the help links (2026-11-01): every
+// filter-builder mount carries a "?" linking to the manual, and the
+// manual's FOLDER URL resolves to its index page (the docs loader's
+// folder-index fallback — the guide is docs/user/filters/index.md).
+func TestFilterHelpAffordance(t *testing.T) {
+ setup := setupTestServer(t)
+ regToken := setup.RegularToken
+
+ t.Run("docs_folder_url_serves_the_guide", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/docs/user/filters", nil)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ body, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode, "the folder URL resolves to index.md; body: %.400s", string(body))
+ assert.Contains(t, string(body), "Filters — the complete guide")
+ })
+
+ t.Run("drawer_carries_help_link", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf", nil)
+ req.Header.Set("Authorization", "Bearer "+regToken)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ body, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Contains(t, string(body), `href="/docs/user/filters"`,
+ "the All Books drawer links to the filter guide")
+ })
+
+ t.Run("rules_page_carries_help_link", func(t *testing.T) {
+ marker := fmt.Sprintf("helpaff%d", time.Now().UnixNano()%100000)
+ code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
+ `{"name":"test HelpAff `+marker+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
+ require.Equal(t, http.StatusCreated, code, "%s", string(body))
+ var coll struct {
+ ID string `json:"id"`
+ }
+ require.NoError(t, json.Unmarshal(body, &coll))
+ t.Cleanup(func() {
+ podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
+ })
+
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil)
+ req.Header.Set("Authorization", "Bearer "+regToken)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ pageBody, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Contains(t, string(pageBody), `href="/docs/user/filters"`,
+ "the collection rules editor links to the filter guide")
+ })
+}
diff --git a/docs/index.md b/docs/index.md
index c8853d51..5751be02 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,6 +17,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- **Sync Configuration**
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
+- **Filters**
+ - [Filters — the complete guide](user/filters/index.md) - rules, groups (ALL/ANY), saved filters, smart collections
+ - [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
+ - [Every filter field](user/filters/fields.md) - the generated, always-current reference
+
- **Frontend & Settings** (Coming Soon)
- Frontend Guide
- User Areas Guide
diff --git a/internal/docs/handler.go b/internal/docs/handler.go
index e266235d..0c63842f 100644
--- a/internal/docs/handler.go
+++ b/internal/docs/handler.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"os"
+ "path/filepath"
"strings"
"bookhoard/templates"
@@ -24,7 +25,23 @@ type DocsHandler struct {
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(
@@ -74,10 +91,23 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
}
}
- // Read the markdown file
+ // 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 {
- return nil, fmt.Errorf("failed to read document: %w", err)
+ 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
diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ
index 3f5dfeb7..45c581f5 100644
--- a/templates/bookshelf.templ
+++ b/templates/bookshelf.templ
@@ -133,6 +133,7 @@ templ BookShelf(
>
Filters
+
?
diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go
index 6582fc2f..f510c731 100644
--- a/templates/bookshelf_templ.go
+++ b/templates/bookshelf_templ.go
@@ -171,7 +171,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
Clear Filters
Filters
?