From d371c3c602093329215fd6b9b7987a9188114237 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Wed, 29 Apr 2026 09:36:52 -0400 Subject: [PATCH] feat: support folder index pages --- apps/server/internal/docs/service.go | 28 +++++--- apps/server/internal/docs/service_test.go | 39 ++++++++++ apps/server/internal/httpserver/handlers.go | 72 ++++++++++++++++--- .../internal/httpserver/handlers_test.go | 43 +++++++++++ apps/server/internal/httpserver/server.go | 2 +- .../internal/httpserver/static/site.css | 55 +++++++++++++- .../httpserver/templates/document.gohtml | 5 +- 7 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 apps/server/internal/docs/service_test.go create mode 100644 apps/server/internal/httpserver/handlers_test.go diff --git a/apps/server/internal/docs/service.go b/apps/server/internal/docs/service.go index 9af53f9..66793c6 100644 --- a/apps/server/internal/docs/service.go +++ b/apps/server/internal/docs/service.go @@ -104,10 +104,22 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro return nil, err } - normalized := normalizeRequestPath(requestPath) - record, err := s.repo.GetDocumentByPath(ctx, normalized) - if err != nil { - return nil, err + var ( + record *DocumentRecord + normalized string + lastErr error + ) + for _, candidate := range normalizeRequestPathCandidates(requestPath) { + found, err := s.repo.GetDocumentByPath(ctx, candidate) + if err == nil { + record = found + normalized = candidate + break + } + lastErr = err + } + if record == nil { + return nil, lastErr } content, err := s.store.Read(record.CurrentHash) @@ -129,15 +141,15 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro }, nil } -func normalizeRequestPath(path string) string { +func normalizeRequestPathCandidates(path string) []string { path = strings.Trim(path, "/") if path == "" { - return "getting-started.md" + return []string{"index.md", "getting-started.md"} } if !strings.HasSuffix(path, ".md") { - path += ".md" + return []string{path + ".md", path + "/index.md"} } - return path + return []string{path} } func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) { diff --git a/apps/server/internal/docs/service_test.go b/apps/server/internal/docs/service_test.go new file mode 100644 index 0000000..75da8fe --- /dev/null +++ b/apps/server/internal/docs/service_test.go @@ -0,0 +1,39 @@ +package docs + +import ( + "reflect" + "testing" +) + +func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) { + tests := []struct { + name string + path string + want []string + }{ + { + name: "root", + path: "", + want: []string{"index.md", "getting-started.md"}, + }, + { + name: "clean folder or page path", + path: "guide", + want: []string{"guide.md", "guide/index.md"}, + }, + { + name: "explicit markdown path", + path: "guide/index.md", + want: []string{"guide/index.md"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeRequestPathCandidates(tt.path) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("normalizeRequestPathCandidates(%q) = %#v, want %#v", tt.path, got, tt.want) + } + }) + } +} diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index d70833c..95cc4bf 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -60,6 +60,8 @@ type browserItem struct { Path string URL string IsFolder bool + IsIndex bool + HasIndex bool Active bool } @@ -82,12 +84,16 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handleDocsIndexRedirect(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/", http.StatusFound) +func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) { + s.renderDocumentPage(w, r, "") } func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { pagePath := chi.URLParam(r, "*") + s.renderDocumentPage(w, r, pagePath) +} + +func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, pagePath string) { page, err := s.documents.LoadPage(r.Context(), pagePath) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -271,9 +277,13 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) { func buildBrowser(records []docs.DocumentRecord, activePath string) browserData { paths := make([]string, 0, len(records)) titleByPath := make(map[string]string, len(records)) + indexByFolder := make(map[string]string) for _, record := range records { paths = append(paths, record.Path) titleByPath[record.Path] = record.Title + if folder, ok := indexFolder(record.Path); ok { + indexByFolder[folder] = record.Path + } } prefixes := []string{""} @@ -292,7 +302,7 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData for _, prefix := range prefixes { column := browserColumn{ Title: columnTitle(prefix), - Items: buildBrowserItems(paths, titleByPath, prefix, activePath), + Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath), } if len(column.Items) > 0 { columns = append(columns, column) @@ -302,7 +312,7 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData return browserData{Columns: columns} } -func buildBrowserItems(paths []string, titleByPath map[string]string, prefix string, activePath string) []browserItem { +func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem { seenFolders := make(map[string]browserItem) files := make([]browserItem, 0) prefixWithSlash := "" @@ -311,6 +321,10 @@ func buildBrowserItems(paths []string, titleByPath map[string]string, prefix str } for _, path := range paths { + if shouldHideIndexFile(path, prefix) { + continue + } + if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) { continue } @@ -326,21 +340,28 @@ func buildBrowserItems(paths []string, titleByPath map[string]string, prefix str if prefix != "" { folderPath = prefix + "/" + name } + indexPath, hasIndex := indexByFolder[folderPath] + url := "/?folder=" + folderPath + if hasIndex { + url = "/docs/" + strings.TrimSuffix(indexPath, "/index.md") + } seenFolders[folderPath] = browserItem{ - Name: name, + Name: displayFolderName(name, indexPath, titleByPath), Path: folderPath, - URL: "/?folder=" + folderPath, + URL: url, IsFolder: true, + HasIndex: hasIndex, Active: activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"), } continue } files = append(files, browserItem{ - Name: displayDocumentName(path, titleByPath[path]), - Path: path, - URL: "/docs/" + strings.TrimSuffix(path, ".md"), - Active: activePath == path, + Name: displayDocumentName(path, titleByPath[path]), + Path: path, + URL: "/docs/" + strings.TrimSuffix(path, ".md"), + IsIndex: path == "index.md" || strings.HasSuffix(path, "/index.md"), + Active: activePath == path, }) } @@ -372,3 +393,34 @@ func displayDocumentName(path string, title string) string { } return strings.TrimSuffix(filepath.Base(path), ".md") } + +func displayFolderName(name string, indexPath string, titleByPath map[string]string) string { + if indexPath == "" { + return name + } + if title := titleByPath[indexPath]; title != "" && title != "Untitled" { + return title + } + return name +} + +func indexFolder(path string) (string, bool) { + if path == "index.md" { + return "", true + } + if !strings.HasSuffix(path, "/index.md") { + return "", false + } + return strings.TrimSuffix(path, "/index.md"), true +} + +func shouldHideIndexFile(path string, prefix string) bool { + if path == "index.md" { + return false + } + if !strings.HasSuffix(path, "/index.md") { + return false + } + folder := strings.TrimSuffix(path, "/index.md") + return folder == prefix +} diff --git a/apps/server/internal/httpserver/handlers_test.go b/apps/server/internal/httpserver/handlers_test.go new file mode 100644 index 0000000..c317b2e --- /dev/null +++ b/apps/server/internal/httpserver/handlers_test.go @@ -0,0 +1,43 @@ +package httpserver + +import ( + "testing" + "time" + + "github.com/tim/md-hub-secure/apps/server/internal/docs" +) + +func TestBuildBrowserUsesFolderIndex(t *testing.T) { + browser := buildBrowser([]docs.DocumentRecord{ + { + Path: "guide/index.md", + Title: "Guide", + UpdatedAt: time.Now(), + }, + { + Path: "guide/setup.md", + Title: "Setup", + UpdatedAt: time.Now(), + }, + }, "guide/index.md") + + if len(browser.Columns) != 2 { + t.Fatalf("len(browser.Columns) = %d, want 2", len(browser.Columns)) + } + + root := browser.Columns[0].Items + if len(root) != 1 { + t.Fatalf("len(root items) = %d, want 1", len(root)) + } + if !root[0].IsFolder || root[0].Name != "Guide" || root[0].URL != "/docs/guide" { + t.Fatalf("root folder = %#v, want Guide folder linking to /docs/guide", root[0]) + } + + child := browser.Columns[1].Items + if len(child) != 1 { + t.Fatalf("len(child items) = %d, want 1", len(child)) + } + if child[0].Path == "guide/index.md" { + t.Fatalf("folder index file should be represented by the folder, not repeated as a child file") + } +} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 9ab437f..07a2eb8 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -76,7 +76,7 @@ func New(deps Dependencies) (http.Handler, error) { router.Get("/", server.handleIndex) router.Get("/health", server.handleHealth) router.Get("/ws", server.handleWebSocket) - router.Get("/docs", server.handleDocsIndexRedirect) + router.Get("/docs", server.handleDocsIndex) router.Get("/docs/*", server.handleDocument) router.Post("/api/uploads", server.handleUpload) router.Get("/attachments/{hash}", server.handleAttachment) diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index 1173862..7a4ad8b 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -160,13 +160,66 @@ code { text-decoration: none; } +.browser-item-label { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0.45rem; +} + +.browser-icon { + position: relative; + display: inline-block; + width: 1rem; + height: 0.9rem; + flex: 0 0 auto; +} + +.browser-icon--folder { + margin-top: 0.1rem; + border: 1px solid rgba(15, 91, 216, 0.24); + border-radius: 0.18rem; + background: var(--accent-soft); +} + +.browser-icon--folder::before { + position: absolute; + top: -0.22rem; + left: 0.1rem; + width: 0.45rem; + height: 0.25rem; + border: 1px solid rgba(15, 91, 216, 0.24); + border-bottom: 0; + border-radius: 0.14rem 0.14rem 0 0; + background: var(--accent-soft); + content: ""; +} + +.browser-icon--file { + border: 1px solid var(--border); + border-radius: 0.16rem; + background: rgba(255, 255, 255, 0.72); +} + +.browser-icon--file::before { + position: absolute; + right: -1px; + top: -1px; + width: 0.3rem; + height: 0.3rem; + border-left: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--panel); + content: ""; +} + .miller-column a:hover, .miller-column a.is-active { background: var(--accent-soft); color: var(--accent); } -.miller-column a span:first-child { +.browser-item-label span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/apps/server/internal/httpserver/templates/document.gohtml b/apps/server/internal/httpserver/templates/document.gohtml index 858778f..1db7f7f 100644 --- a/apps/server/internal/httpserver/templates/document.gohtml +++ b/apps/server/internal/httpserver/templates/document.gohtml @@ -46,7 +46,10 @@ {{ range .Items }}
  • - {{ .Name }} + + + {{ .Name }} + {{ if .IsFolder }}{{ end }}