From a4dc03984c246d9a78b92e4e15186faa7d25eeb8 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Nov 2026 16:22:35 -0500 Subject: [PATCH] feat(docs): "?" help affordance on every filter mount + docs loader fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cmd/server/tests/podcast_test.go | 57 +++++++++++++++++++++++++++++ docs/index.md | 5 +++ internal/docs/handler.go | 34 ++++++++++++++++- templates/bookshelf.templ | 1 + templates/bookshelf_templ.go | 42 ++++++++++----------- templates/collection_rules.templ | 1 + templates/collection_rules_templ.go | 2 +- 7 files changed, 118 insertions(+), 24 deletions(-) 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

?