diff --git a/assets/page.html b/assets/page.html
index 800ac6a..214796e 100644
--- a/assets/page.html
+++ b/assets/page.html
@@ -21,6 +21,11 @@
/{{.Name}}
{{end}}
+ {{if not .EditMode}}
+
+ {{end}}
{{if .EditMode}}
CANCEL
diff --git a/assets/search.html b/assets/search.html
new file mode 100644
index 0000000..17da7ed
--- /dev/null
+++ b/assets/search.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ Search{{if .Query}}: {{.Query}}{{end}}
+
+
+
+
+
+
+
+
+ {{if .Query}}
+ {{if .Results}}
+
+
+ {{range .Results}}
+
+ {{end}}
+
+ {{else}}
+ No folders match “{{.Query}}”.
+ {{end}}
+ {{else}}
+ Enter a query above.
+ {{end}}
+
+
+
diff --git a/assets/style.css b/assets/style.css
index fd78257..e3f1dbd 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -359,6 +359,27 @@ textarea {
box-sizing: border-box;
}
+/* === Search === */
+.search-form {
+ display: flex;
+ gap: 0.25rem;
+}
+.search-input {
+ background: var(--bg-panel);
+ border: 1px solid var(--secondary);
+ color: var(--text);
+ font: inherit;
+ font-size: 0.9rem;
+ padding: 0.3rem 0.5rem;
+ min-width: 0;
+ width: 12rem;
+ max-width: 100%;
+ outline: none;
+}
+.search-input:focus {
+ border-color: var(--primary-hover);
+}
+
/* === Muted text === */
.muted {
color: var(--text-muted);
diff --git a/main.go b/main.go
index a30a81d..93e99d1 100644
--- a/main.go
+++ b/main.go
@@ -115,6 +115,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") {
+ h.handleSearch(w, r)
+ return
+ }
+
info, err := os.Stat(fsPath)
if err != nil {
if os.IsNotExist(err) {
diff --git a/search.go b/search.go
new file mode 100644
index 0000000..7db253a
--- /dev/null
+++ b/search.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+ "html/template"
+ "io/fs"
+ "log"
+ "net/http"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+var searchTmpl = template.Must(template.New("search.html").ParseFS(assets, "assets/search.html"))
+
+// Match ranks. Lower is better.
+const (
+ rankExact = 0
+ rankPrefix = 1
+ rankSubstring = 2
+ rankFuzzy = 3
+)
+
+type searchResult struct {
+ Name string
+ URL string
+ Path string
+ Rank int
+}
+
+type searchPageData struct {
+ Query string
+ Results []searchResult
+}
+
+// handleSearch walks the wiki root and renders a search results page for the
+// query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is
+// present.
+func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ results := searchFolders(h.root, query)
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := searchTmpl.Execute(w, searchPageData{Query: query, Results: results}); err != nil {
+ log.Printf("search template error: %v", err)
+ }
+}
+
+// searchFolders walks root and returns directories whose final path segment
+// matches the query, ranked best-first. Returns nil for an empty query.
+func searchFolders(root, query string) []searchResult {
+ if query == "" {
+ return nil
+ }
+ q := strings.ToLower(query)
+ maxDist := 2
+ if len([]rune(q)) > 6 {
+ maxDist = 3
+ }
+
+ var results []searchResult
+ _ = filepath.WalkDir(root, func(fsPath string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ name := d.Name()
+ if strings.HasPrefix(name, ".") {
+ if d.IsDir() && fsPath != root {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !d.IsDir() || fsPath == root {
+ return nil
+ }
+ rank, ok := matchRank(strings.ToLower(name), q, maxDist)
+ if !ok {
+ return nil
+ }
+ rel, relErr := filepath.Rel(root, fsPath)
+ if relErr != nil {
+ return nil
+ }
+ urlPath := "/" + filepath.ToSlash(rel) + "/"
+ results = append(results, searchResult{
+ Name: name,
+ URL: urlPath,
+ Path: filepath.ToSlash(rel),
+ Rank: rank,
+ })
+ return nil
+ })
+
+ sort.SliceStable(results, func(i, j int) bool {
+ if results[i].Rank != results[j].Rank {
+ return results[i].Rank < results[j].Rank
+ }
+ return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
+ })
+ return results
+}
+
+// matchRank returns the best (lowest) rank for which name matches q, or
+// (0, false) if no rule matches. Inputs are expected to be lowercased.
+func matchRank(name, q string, maxDist int) (int, bool) {
+ if name == q {
+ return rankExact, true
+ }
+ if strings.HasPrefix(name, q) {
+ return rankPrefix, true
+ }
+ if strings.Contains(name, q) {
+ return rankSubstring, true
+ }
+ if levenshtein(name, q) <= maxDist {
+ return rankFuzzy, true
+ }
+ return 0, false
+}
+
+// levenshtein returns the edit distance between a and b. Operates on runes so
+// multi-byte characters count as one edit.
+func levenshtein(a, b string) int {
+ ar, br := []rune(a), []rune(b)
+ if len(ar) == 0 {
+ return len(br)
+ }
+ if len(br) == 0 {
+ return len(ar)
+ }
+ prev := make([]int, len(br)+1)
+ curr := make([]int, len(br)+1)
+ for j := range prev {
+ prev[j] = j
+ }
+ for i := 1; i <= len(ar); i++ {
+ curr[0] = i
+ for j := 1; j <= len(br); j++ {
+ cost := 1
+ if ar[i-1] == br[j-1] {
+ cost = 0
+ }
+ del := prev[j] + 1
+ ins := curr[j-1] + 1
+ sub := prev[j-1] + cost
+ curr[j] = min3(del, ins, sub)
+ }
+ prev, curr = curr, prev
+ }
+ return prev[len(br)]
+}
+
+func min3(a, b, c int) int {
+ m := a
+ if b < m {
+ m = b
+ }
+ if c < m {
+ m = c
+ }
+ return m
+}