feat: support folder index pages
This commit is contained in:
@@ -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) {
|
||||
|
||||
39
apps/server/internal/docs/service_test.go
Normal file
39
apps/server/internal/docs/service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
43
apps/server/internal/httpserver/handlers_test.go
Normal file
43
apps/server/internal/httpserver/handlers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user