Skip to content

Commit bad81dd

Browse files
DCodeBotdector
andcommitted
feat: add README preview side menu
Co-authored-by: D <code@dector.space>
1 parent ec4bf36 commit bad81dd

5 files changed

Lines changed: 240 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ serv .
6565
serv README.md
6666
```
6767

68-
In preview mode, supported Markdown files (`.md`, `.markdown`, `.mdown`, `.mkd`) render as HTML. Directories resolve `README` first, then `index.html`, then fall back to a listing.
68+
In preview mode, supported Markdown files (`.md`, `.markdown`, `.mdown`, `.mkd`) render as HTML. Directories resolve `README` first, then `index.html`, then fall back to a listing. Auto-resolved README previews include a foldable side menu for navigating sibling files and folders.
6969

7070
Use file mode for raw file browsing:
7171

docs/RECIPES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Browser opening is best-effort; if it fails, the server keeps running and prints
6666
serv Downloads
6767
```
6868

69-
In default preview mode, directories resolve `README` first, then `index.html`, then fall back to a listing page.
69+
In default preview mode, directories resolve `README` first, then `index.html`, then fall back to a listing page. Auto-resolved README previews include a foldable side menu for navigating sibling files and folders.
7070

7171
### Use file browsing mode
7272

main.go

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"path"
1616
"path/filepath"
1717
"runtime"
18+
"sort"
1819
"strings"
1920
"syscall"
2021
"time"
@@ -638,18 +639,85 @@ func findReadme(fsys fs.FS, dir string) (string, bool) {
638639
if err != nil {
639640
return "", false
640641
}
641-
allowed := map[string]bool{"readme.md": true, "readme.markdown": true, "readme.mdown": true, "readme.mkd": true}
642642
for _, entry := range entries {
643643
if entry.IsDir() {
644644
continue
645645
}
646-
if allowed[strings.ToLower(entry.Name())] {
646+
if isReadmeName(entry.Name()) {
647647
return path.Join(dir, entry.Name()), true
648648
}
649649
}
650650
return "", false
651651
}
652652

653+
func isReadmeName(name string) bool {
654+
allowed := map[string]bool{"readme.md": true, "readme.markdown": true, "readme.mdown": true, "readme.mkd": true}
655+
return allowed[strings.ToLower(name)]
656+
}
657+
658+
func buildSideMenu(r *http.Request, dir string, currentPath string, fsys fs.FS) *preview.SideMenu {
659+
menu := &preview.SideMenu{CurrentPath: displayDirPath(dir)}
660+
entries, err := fs.ReadDir(fsys, dir)
661+
if err != nil {
662+
menu.Error = "Could not load directory menu."
663+
return menu
664+
}
665+
666+
if !isRootDirPath(dir) {
667+
menu.Items = append(menu.Items, preview.SideMenuItem{
668+
Label: "../",
669+
Href: sideMenuHref(r, "../"),
670+
Title: "../",
671+
})
672+
}
673+
674+
sort.SliceStable(entries, func(i, j int) bool {
675+
if entries[i].IsDir() != entries[j].IsDir() {
676+
return entries[i].IsDir()
677+
}
678+
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
679+
})
680+
681+
currentName := path.Base(currentPath)
682+
for _, entry := range entries {
683+
name := entry.Name()
684+
label := name
685+
href := sideMenuHref(r, name)
686+
if entry.IsDir() {
687+
label = name + "/"
688+
href = sideMenuHref(r, name+"/")
689+
}
690+
menu.Items = append(menu.Items, preview.SideMenuItem{
691+
Label: label,
692+
Href: href,
693+
Title: label,
694+
IsDir: entry.IsDir(),
695+
IsCurrent: !entry.IsDir() && name == currentName,
696+
})
697+
}
698+
return menu
699+
}
700+
701+
func displayDirPath(dir string) string {
702+
clean := path.Clean(dir)
703+
if clean == "." || clean == "/" {
704+
return "/"
705+
}
706+
return "/" + strings.Trim(clean, "/") + "/"
707+
}
708+
709+
func isRootDirPath(dir string) bool {
710+
clean := path.Clean(dir)
711+
return clean == "." || clean == "/"
712+
}
713+
714+
func sideMenuHref(r *http.Request, href string) string {
715+
if r == nil || r.URL == nil || r.URL.RawQuery == "" {
716+
return href
717+
}
718+
return href + "?" + r.URL.RawQuery
719+
}
720+
653721
func shouldRenderPreview(r *http.Request, requestedPath string, mode serveMode) bool {
654722
if !preview.CanPreview(requestedPath) {
655723
return false
@@ -699,6 +767,12 @@ func serveFolder(lw http.ResponseWriter, r *http.Request, requestedPath string,
699767
strategy := requestDirResolveStrategy(r, config.DirResolve)
700768
if strategy != dirResolveNone {
701769
if candidate, ok := selectDirectoryCandidate(fsys, requestedPath, strategy); ok {
770+
if isReadmeName(path.Base(candidate)) && shouldRenderPreview(r, candidate, config.Mode) {
771+
if err := servePreviewFile(lw, r, candidate, fsys, preview.Options{SideMenu: buildSideMenu(r, requestedPath, candidate, fsys)}); err != nil {
772+
serveFileError(lw, r, "Serve error", err, directoryRecoveryLinks(strategy))
773+
}
774+
return
775+
}
702776
if err := serveFile(lw, r, candidate, fsys, config); err != nil {
703777
serveFileError(lw, r, "Serve error", err, directoryRecoveryLinks(strategy))
704778
}
@@ -727,14 +801,7 @@ func serveFile(lw http.ResponseWriter, r *http.Request, requestedPath string, fs
727801
}
728802

729803
if shouldRenderPreview(r, requestedPath, config.Mode) {
730-
rendered, err := preview.Render(requestedPath, content)
731-
if err != nil {
732-
serveFileError(lw, r, "Preview error", err, []recoveryLink{{Label: "View raw file", Query: "raw=1"}})
733-
return nil
734-
}
735-
lw.Header().Set("Content-Type", "text/html; charset=utf-8")
736-
_, err = lw.Write(rendered)
737-
return err
804+
return writePreview(lw, r, requestedPath, content, preview.Options{})
738805
}
739806

740807
// Serve the file using MIME-by-extension behavior for raw responses.
@@ -746,6 +813,25 @@ func serveFile(lw http.ResponseWriter, r *http.Request, requestedPath string, fs
746813
return err
747814
}
748815

816+
func servePreviewFile(lw http.ResponseWriter, r *http.Request, requestedPath string, fsys fs.FS, options preview.Options) error {
817+
content, err := fs.ReadFile(fsys, requestedPath)
818+
if err != nil {
819+
return err
820+
}
821+
return writePreview(lw, r, requestedPath, content, options)
822+
}
823+
824+
func writePreview(lw http.ResponseWriter, r *http.Request, requestedPath string, content []byte, options preview.Options) error {
825+
rendered, err := preview.RenderWithOptions(requestedPath, content, options)
826+
if err != nil {
827+
serveFileError(lw, r, "Preview error", err, []recoveryLink{{Label: "View raw file", Query: "raw=1"}})
828+
return nil
829+
}
830+
lw.Header().Set("Content-Type", "text/html; charset=utf-8")
831+
_, err = lw.Write(rendered)
832+
return err
833+
}
834+
749835
func serveFileError(lw http.ResponseWriter, r *http.Request, title string, err error, links []recoveryLink) {
750836
lw.Header().Set("Content-Type", "text/html; charset=utf-8")
751837
lw.WriteHeader(http.StatusInternalServerError)

main_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,88 @@ func TestServeFolderPreviewDefaultServesReadmeBeforeIndex(t *testing.T) {
243243
t.Fatalf("body = %s, want README preview", body)
244244
}
245245
}
246+
247+
func TestServeFolderReadmePreviewIncludesSideMenu(t *testing.T) {
248+
root := t.TempDir()
249+
rootInfo, err := fstest.MapFS{"docs": &fstest.MapFile{Mode: 0755 | fs.ModeDir}}.Stat("docs")
250+
if err != nil {
251+
t.Fatalf("stat root: %v", err)
252+
}
253+
fsys := fstest.MapFS{
254+
"docs": &fstest.MapFile{Mode: 0755 | fs.ModeDir},
255+
"docs/README.md": &fstest.MapFile{Data: []byte("# Readme")},
256+
"docs/a.txt": &fstest.MapFile{Data: []byte("a")},
257+
"docs/sub": &fstest.MapFile{Mode: 0755 | fs.ModeDir},
258+
}
259+
r := httptest.NewRequest(http.MethodGet, "/docs/?preview=1&resolve=readme-only", nil)
260+
w := httptest.NewRecorder()
261+
262+
serveFolder(w, r, "docs", fsys, rootInfo, root, serveConfig{Mode: serveModeFile, DirResolve: dirResolveReadmeFirst})
263+
264+
bodyBytes, _ := io.ReadAll(w.Result().Body)
265+
body := string(bodyBytes)
266+
for _, want := range []string{"<nav class=\"side-menu\"", "/docs/", "../?preview=1&amp;resolve=readme-only", "sub/?preview=1&amp;resolve=readme-only", "a.txt?preview=1&amp;resolve=readme-only", "[dir]", "side-menu__item--current"} {
267+
if !strings.Contains(body, want) {
268+
t.Fatalf("body missing %q: %s", want, body)
269+
}
270+
}
271+
if strings.Contains(body, "href=\"README.md") {
272+
t.Fatalf("current README should not be a link: %s", body)
273+
}
274+
}
275+
276+
func TestServeFileDirectReadmePreviewOmitsSideMenu(t *testing.T) {
277+
fsys := fstest.MapFS{"README.md": &fstest.MapFile{Data: []byte("# Title")}}
278+
r := httptest.NewRequest(http.MethodGet, "/README.md", nil)
279+
w := httptest.NewRecorder()
280+
281+
serveFile(w, r, "README.md", fsys, serveConfig{Mode: serveModePreview})
282+
283+
body, _ := io.ReadAll(w.Result().Body)
284+
if strings.Contains(string(body), "<nav class=\"side-menu\"") {
285+
t.Fatalf("direct README preview should not include side menu: %s", body)
286+
}
287+
}
288+
289+
func TestServeFolderIndexPreviewOmitsSideMenu(t *testing.T) {
290+
root := t.TempDir()
291+
rootInfo, err := fstest.MapFS{"docs": &fstest.MapFile{Mode: 0755 | fs.ModeDir}}.Stat("docs")
292+
if err != nil {
293+
t.Fatalf("stat root: %v", err)
294+
}
295+
fsys := fstest.MapFS{
296+
"docs": &fstest.MapFile{Mode: 0755 | fs.ModeDir},
297+
"docs/README.md": &fstest.MapFile{Data: []byte("# Readme")},
298+
"docs/index.html": &fstest.MapFile{Data: []byte("<h1>Index</h1>")},
299+
}
300+
r := httptest.NewRequest(http.MethodGet, "/docs/?resolve=index-only", nil)
301+
w := httptest.NewRecorder()
302+
303+
serveFolder(w, r, "docs", fsys, rootInfo, root, serveConfig{Mode: serveModePreview, DirResolve: dirResolveReadmeFirst})
304+
305+
body, _ := io.ReadAll(w.Result().Body)
306+
if strings.Contains(string(body), "<nav class=\"side-menu\"") {
307+
t.Fatalf("index resolution should not include side menu: %s", body)
308+
}
309+
}
310+
311+
func TestServeFolderRawReadmeOmitsSideMenu(t *testing.T) {
312+
root := t.TempDir()
313+
rootInfo, err := fstest.MapFS{"docs": &fstest.MapFile{Mode: 0755 | fs.ModeDir}}.Stat("docs")
314+
if err != nil {
315+
t.Fatalf("stat root: %v", err)
316+
}
317+
fsys := fstest.MapFS{
318+
"docs": &fstest.MapFile{Mode: 0755 | fs.ModeDir},
319+
"docs/README.md": &fstest.MapFile{Data: []byte("# Readme")},
320+
}
321+
r := httptest.NewRequest(http.MethodGet, "/docs/?raw=1", nil)
322+
w := httptest.NewRecorder()
323+
324+
serveFolder(w, r, "docs", fsys, rootInfo, root, serveConfig{Mode: serveModePreview, DirResolve: dirResolveReadmeFirst})
325+
326+
body, _ := io.ReadAll(w.Result().Body)
327+
if got := string(body); got != "# Readme" {
328+
t.Fatalf("body = %q, want raw README", got)
329+
}
330+
}

preview/preview.go

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,37 @@ func CanPreview(name string) bool {
2626

2727
// Render renders name/content with the default renderer.
2828
func Render(name string, content []byte) ([]byte, error) {
29+
return RenderWithOptions(name, content, Options{})
30+
}
31+
32+
// Options configures optional preview page features.
33+
type Options struct {
34+
SideMenu *SideMenu
35+
}
36+
37+
// SideMenu describes an optional directory navigation menu.
38+
type SideMenu struct {
39+
CurrentPath string
40+
Items []SideMenuItem
41+
Error string
42+
}
43+
44+
// SideMenuItem describes one directory navigation menu item.
45+
type SideMenuItem struct {
46+
Label string
47+
Href string
48+
Title string
49+
IsDir bool
50+
IsCurrent bool
51+
}
52+
53+
// RenderWithOptions renders name/content with optional page features.
54+
func RenderWithOptions(name string, content []byte, options Options) ([]byte, error) {
55+
if renderer, ok := defaultRenderer.(interface {
56+
RenderWithOptions(string, []byte, Options) ([]byte, error)
57+
}); ok {
58+
return renderer.RenderWithOptions(name, content, options)
59+
}
2960
return defaultRenderer.Render(name, content)
3061
}
3162

@@ -43,7 +74,12 @@ func (MarkdownRenderer) CanPreview(name string) bool {
4374
}
4475

4576
// Render renders Markdown content to a self-contained dark HTML page.
46-
func (MarkdownRenderer) Render(name string, content []byte) ([]byte, error) {
77+
func (r MarkdownRenderer) Render(name string, content []byte) ([]byte, error) {
78+
return r.RenderWithOptions(name, content, Options{})
79+
}
80+
81+
// RenderWithOptions renders Markdown content to a self-contained dark HTML page with optional page features.
82+
func (MarkdownRenderer) RenderWithOptions(name string, content []byte, options Options) ([]byte, error) {
4783
md := goldmark.New(
4884
goldmark.WithExtensions(
4985
extension.Table,
@@ -60,19 +96,21 @@ func (MarkdownRenderer) Render(name string, content []byte) ([]byte, error) {
6096

6197
var page bytes.Buffer
6298
if err := pageTemplate.Execute(&page, pageData{
63-
Title: filepath.Base(name),
64-
Body: template.HTML(body.String()),
65-
Theme: theme.Dark,
99+
Title: filepath.Base(name),
100+
Body: template.HTML(body.String()),
101+
Theme: theme.Dark,
102+
SideMenu: options.SideMenu,
66103
}); err != nil {
67104
return nil, err
68105
}
69106
return page.Bytes(), nil
70107
}
71108

72109
type pageData struct {
73-
Title string
74-
Body template.HTML
75-
Theme theme.Colors
110+
Title string
111+
Body template.HTML
112+
Theme theme.Colors
113+
SideMenu *SideMenu
76114
}
77115

78116
var pageTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
@@ -83,15 +121,26 @@ var pageTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
83121
<title>{{.Title}}</title>
84122
<style>
85123
:root{color-scheme:dark;--bg:{{.Theme.Background}};--panel:{{.Theme.Panel}};--text:{{.Theme.Text}};--muted:{{.Theme.Muted}};--border:{{.Theme.Border}};--accent:{{.Theme.Accent}};--code:{{.Theme.Code}}}
86-
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.6 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.topbar,.footer{background:var(--panel);border-color:var(--border);color:var(--muted)}.topbar{border-bottom:1px solid var(--border);padding:12px 24px;font-weight:600}.footer{border-top:1px solid var(--border);padding:16px 24px;text-align:center}.content{max-width:920px;margin:0 auto;padding:32px 24px 56px}a{color:var(--accent)}h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.5em 0 .6em}h1,h2{border-bottom:1px solid var(--border);padding-bottom:.3em}p,ul,ol,blockquote,pre,table{margin:0 0 1em}blockquote{border-left:4px solid var(--border);color:var(--muted);padding:0 1em}code{background:rgba(110,118,129,.28);border-radius:6px;padding:.2em .4em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}pre{background:var(--code);border:1px solid var(--border);border-radius:8px;overflow:auto;padding:16px}pre code{background:transparent;padding:0}table{border-collapse:collapse;width:100%;display:block;overflow:auto}th,td{border:1px solid var(--border);padding:6px 13px}th{background:var(--panel)}img{max-width:100%}hr{border:0;border-top:1px solid var(--border);margin:24px 0}.task-list-item{list-style-type:none}.task-list-item input{margin:0 .5em 0 -1.4em}
124+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.6 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.topbar,.footer{background:var(--panel);border-color:var(--border);color:var(--muted)}.topbar{border-bottom:1px solid var(--border);padding:12px 24px;font-weight:600}.footer{border-top:1px solid var(--border);padding:16px 24px;text-align:center}.page-shell{display:flex;min-height:calc(100vh - 50px)}.content{max-width:920px;margin:0 auto;padding:32px 24px 56px;flex:1;min-width:0}.side-menu{background:var(--panel);border-right:1px solid var(--border);flex:0 0 260px;width:260px;max-height:calc(100vh - 50px);overflow:auto;position:sticky;top:0;align-self:flex-start}.side-menu__header{border-bottom:1px solid var(--border);color:var(--muted);font-size:13px;font-weight:600;overflow:hidden;padding:12px 16px;text-overflow:ellipsis;white-space:nowrap}.side-menu__list{list-style:none;margin:0;padding:8px}.side-menu__item{border-radius:6px;color:var(--muted);display:block;overflow:hidden;padding:6px 8px;text-overflow:ellipsis;white-space:nowrap}.side-menu__item a{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.side-menu__item--current{background:rgba(110,118,129,.18);color:var(--text);font-weight:600}.side-menu__kind{color:var(--muted);font-size:12px;margin-right:6px}.side-menu__error{color:var(--muted);padding:12px 16px}.side-menu-toggle{background:var(--panel);border:1px solid var(--border);color:var(--text);cursor:pointer;z-index:3}.side-menu__collapse{border-radius:999px;height:28px;position:absolute;right:-14px;top:50%;transform:translateY(-50%);width:28px}.side-menu__expand{border-left:0;border-radius:0 999px 999px 0;display:none;left:0;padding:16px 6px;position:fixed;top:50%;transform:translateY(-50%)}body.side-menu-collapsed .side-menu{display:none}body.side-menu-collapsed .side-menu__expand{display:block}@media(max-width:760px){.page-shell{display:block}.side-menu{bottom:0;box-shadow:0 0 0 9999px rgba(0,0,0,.45);left:0;max-height:none;position:fixed;top:0;z-index:2}body.side-menu-collapsed .side-menu{display:none}}a{color:var(--accent)}h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.5em 0 .6em}h1,h2{border-bottom:1px solid var(--border);padding-bottom:.3em}p,ul,ol,blockquote,pre,table{margin:0 0 1em}blockquote{border-left:4px solid var(--border);color:var(--muted);padding:0 1em}code{background:rgba(110,118,129,.28);border-radius:6px;padding:.2em .4em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}pre{background:var(--code);border:1px solid var(--border);border-radius:8px;overflow:auto;padding:16px}pre code{background:transparent;padding:0}table{border-collapse:collapse;width:100%;display:block;overflow:auto}th,td{border:1px solid var(--border);padding:6px 13px}th{background:var(--panel)}img{max-width:100%}hr{border:0;border-top:1px solid var(--border);margin:24px 0}.task-list-item{list-style-type:none}.task-list-item input{margin:0 .5em 0 -1.4em}
87125
</style>
88126
</head>
89127
<body>
90128
<header class="topbar">{{.Title}}</header>
129+
{{if .SideMenu}}<button class="side-menu-toggle side-menu__expand" type="button" aria-label="Show directory menu">›</button>{{end}}
130+
<div class="page-shell">
131+
{{with .SideMenu}}<nav class="side-menu" aria-label="Directory menu">
132+
<button class="side-menu-toggle side-menu__collapse" type="button" aria-label="Hide directory menu">‹</button>
133+
<div class="side-menu__header" title="{{.CurrentPath}}">{{.CurrentPath}}</div>
134+
{{if .Error}}<div class="side-menu__error">{{.Error}}</div>{{else}}<ul class="side-menu__list">
135+
{{range .Items}}<li class="side-menu__item{{if .IsCurrent}} side-menu__item--current{{end}}" title="{{.Title}}">{{if .IsCurrent}}{{if .IsDir}}<span class="side-menu__kind">[dir]</span>{{end}}{{.Label}}{{else}}<a href="{{.Href}}" title="{{.Title}}">{{if .IsDir}}<span class="side-menu__kind">[dir]</span>{{end}}{{.Label}}</a>{{end}}</li>{{end}}
136+
</ul>{{end}}
137+
</nav>{{end}}
91138
<main class="content">
92139
{{.Body}}
93140
</main>
141+
</div>
94142
<footer class="footer">served by serv</footer>
143+
{{if .SideMenu}}<script>(function(){var key='serv.sideMenuCollapsed';var body=document.body;var apply=function(v){body.classList.toggle('side-menu-collapsed',v==='1')};try{apply(localStorage.getItem(key))}catch(e){}function set(v){apply(v?'1':'0');try{localStorage.setItem(key,v?'1':'0')}catch(e){}}document.querySelectorAll('.side-menu__collapse').forEach(function(b){b.addEventListener('click',function(){set(true)})});document.querySelectorAll('.side-menu__expand').forEach(function(b){b.addEventListener('click',function(){set(false)})});})();</script>{{end}}
95144
</body>
96145
</html>
97146
`))

0 commit comments

Comments
 (0)