feat: support folder index pages

This commit is contained in:
2026-04-29 09:36:52 -04:00
parent 8c454de809
commit d371c3c602
7 changed files with 223 additions and 21 deletions

View File

@@ -104,10 +104,22 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro
return nil, err return nil, err
} }
normalized := normalizeRequestPath(requestPath) var (
record, err := s.repo.GetDocumentByPath(ctx, normalized) record *DocumentRecord
if err != nil { normalized string
return nil, err 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) content, err := s.store.Read(record.CurrentHash)
@@ -129,15 +141,15 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro
}, nil }, nil
} }
func normalizeRequestPath(path string) string { func normalizeRequestPathCandidates(path string) []string {
path = strings.Trim(path, "/") path = strings.Trim(path, "/")
if path == "" { if path == "" {
return "getting-started.md" return []string{"index.md", "getting-started.md"}
} }
if !strings.HasSuffix(path, ".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) { func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {

View File

@@ -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)
}
})
}
}

View File

@@ -60,6 +60,8 @@ type browserItem struct {
Path string Path string
URL string URL string
IsFolder bool IsFolder bool
IsIndex bool
HasIndex bool
Active 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) { func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound) s.renderDocumentPage(w, r, "")
} }
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*") 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) page, err := s.documents.LoadPage(r.Context(), pagePath)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { 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 { func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
paths := make([]string, 0, len(records)) paths := make([]string, 0, len(records))
titleByPath := make(map[string]string, len(records)) titleByPath := make(map[string]string, len(records))
indexByFolder := make(map[string]string)
for _, record := range records { for _, record := range records {
paths = append(paths, record.Path) paths = append(paths, record.Path)
titleByPath[record.Path] = record.Title titleByPath[record.Path] = record.Title
if folder, ok := indexFolder(record.Path); ok {
indexByFolder[folder] = record.Path
}
} }
prefixes := []string{""} prefixes := []string{""}
@@ -292,7 +302,7 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData
for _, prefix := range prefixes { for _, prefix := range prefixes {
column := browserColumn{ column := browserColumn{
Title: columnTitle(prefix), Title: columnTitle(prefix),
Items: buildBrowserItems(paths, titleByPath, prefix, activePath), Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
} }
if len(column.Items) > 0 { if len(column.Items) > 0 {
columns = append(columns, column) columns = append(columns, column)
@@ -302,7 +312,7 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData
return browserData{Columns: columns} 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) seenFolders := make(map[string]browserItem)
files := make([]browserItem, 0) files := make([]browserItem, 0)
prefixWithSlash := "" prefixWithSlash := ""
@@ -311,6 +321,10 @@ func buildBrowserItems(paths []string, titleByPath map[string]string, prefix str
} }
for _, path := range paths { for _, path := range paths {
if shouldHideIndexFile(path, prefix) {
continue
}
if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) { if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) {
continue continue
} }
@@ -326,21 +340,28 @@ func buildBrowserItems(paths []string, titleByPath map[string]string, prefix str
if prefix != "" { if prefix != "" {
folderPath = prefix + "/" + name folderPath = prefix + "/" + name
} }
indexPath, hasIndex := indexByFolder[folderPath]
url := "/?folder=" + folderPath
if hasIndex {
url = "/docs/" + strings.TrimSuffix(indexPath, "/index.md")
}
seenFolders[folderPath] = browserItem{ seenFolders[folderPath] = browserItem{
Name: name, Name: displayFolderName(name, indexPath, titleByPath),
Path: folderPath, Path: folderPath,
URL: "/?folder=" + folderPath, URL: url,
IsFolder: true, IsFolder: true,
HasIndex: hasIndex,
Active: activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"), Active: activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"),
} }
continue continue
} }
files = append(files, browserItem{ files = append(files, browserItem{
Name: displayDocumentName(path, titleByPath[path]), Name: displayDocumentName(path, titleByPath[path]),
Path: path, Path: path,
URL: "/docs/" + strings.TrimSuffix(path, ".md"), URL: "/docs/" + strings.TrimSuffix(path, ".md"),
Active: activePath == path, 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") 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
}

View File

@@ -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")
}
}

View File

@@ -76,7 +76,7 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/", server.handleIndex) router.Get("/", server.handleIndex)
router.Get("/health", server.handleHealth) router.Get("/health", server.handleHealth)
router.Get("/ws", server.handleWebSocket) router.Get("/ws", server.handleWebSocket)
router.Get("/docs", server.handleDocsIndexRedirect) router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument) router.Get("/docs/*", server.handleDocument)
router.Post("/api/uploads", server.handleUpload) router.Post("/api/uploads", server.handleUpload)
router.Get("/attachments/{hash}", server.handleAttachment) router.Get("/attachments/{hash}", server.handleAttachment)

View File

@@ -160,13 +160,66 @@ code {
text-decoration: none; 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:hover,
.miller-column a.is-active { .miller-column a.is-active {
background: var(--accent-soft); background: var(--accent-soft);
color: var(--accent); color: var(--accent);
} }
.miller-column a span:first-child { .browser-item-label span:last-child {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

View File

@@ -46,7 +46,10 @@
{{ range .Items }} {{ range .Items }}
<li> <li>
<a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}"> <a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}">
<span>{{ .Name }}</span> <span class="browser-item-label">
<span class="browser-icon {{ if .IsFolder }}browser-icon--folder{{ else }}browser-icon--file{{ end }}" aria-hidden="true"></span>
<span>{{ .Name }}</span>
</span>
{{ if .IsFolder }}<span aria-hidden="true"></span>{{ end }} {{ if .IsFolder }}<span aria-hidden="true"></span>{{ end }}
</a> </a>
</li> </li>