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
|
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) {
|
||||||
|
|||||||
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
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("/", 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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user