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

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

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("/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)

View File

@@ -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;

View File

@@ -46,7 +46,10 @@
{{ range .Items }}
<li>
<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 }}
</a>
</li>