main.go (4891B)
1 package main 2 3 import ( 4 "cmp" 5 "fmt" 6 "os" 7 "os/exec" 8 "path/filepath" 9 "runtime" 10 "strings" 11 "sync" 12 "time" 13 ) 14 15 // caps how many content matches are collected per keystroke 16 const resultLimit = 2000 17 18 type doc struct { 19 src string 20 rel string 21 mtime time.Time 22 lines []string 23 linesLower []string 24 } 25 26 type match struct { 27 d *doc 28 line int // 1-based, 0 in browse mode 29 } 30 31 func (m match) text() string { 32 if m.line == 0 { 33 return m.d.rel 34 } 35 return m.d.rel + ":" + strings.TrimSpace(m.d.lines[m.line-1]) 36 } 37 38 type app struct { 39 base, txt string 40 scope string 41 docs []*doc 42 43 content bool 44 query []rune 45 results []match 46 sel int 47 top int 48 49 rows, cols int 50 savedStty string 51 } 52 53 func main() { 54 home, _ := os.UserHomeDir() 55 cache := cmp.Or(os.Getenv("XDG_CACHE_HOME"), filepath.Join(home, ".cache")) 56 a := &app{ 57 base: filepath.Join(home, "misc", "www"), 58 txt: filepath.Join(cache, "wrd", "text"), 59 content: true, 60 } 61 if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { 62 a.scope = os.Args[1] 63 } 64 65 a.scan() 66 if len(a.docs) == 0 { 67 fmt.Fprintln(os.Stderr, "wrd: no pages under", a.base) 68 os.Exit(1) 69 } 70 a.index() 71 a.load() 72 a.run() 73 } 74 75 func (a *app) scan() { 76 _ = filepath.WalkDir(a.base, func(p string, d os.DirEntry, err error) error { 77 if err != nil || d.IsDir() { 78 return nil // skip unreadable entries, keep walking 79 } 80 ext := strings.ToLower(filepath.Ext(p)) 81 if ext != ".html" && ext != ".md" { 82 return nil 83 } 84 rel, _ := filepath.Rel(a.base, p) 85 if a.scope != "" && !strings.Contains(rel, a.scope) { 86 return nil 87 } 88 fi, err := os.Stat(p) 89 if err != nil { 90 return nil // broken symlink or a vanished file 91 } 92 a.docs = append(a.docs, &doc{src: p, rel: rel, mtime: fi.ModTime()}) 93 return nil 94 }) 95 } 96 97 // index renders each page to a text cache, skipping ones already up to date 98 func (a *app) index() { 99 type job struct{ src, out string } 100 var jobs []job 101 for _, d := range a.docs { 102 out := filepath.Join(a.txt, d.rel+".txt") 103 if oi, err := os.Stat(out); err == nil && 104 !d.mtime.Truncate(time.Second).After(oi.ModTime().Truncate(time.Second)) { 105 continue 106 } 107 jobs = append(jobs, job{d.src, out}) 108 } 109 if len(jobs) == 0 { 110 return 111 } 112 113 fmt.Fprintf(os.Stderr, "indexing %d pages\n", len(jobs)) 114 ch := make(chan job) 115 var wg sync.WaitGroup 116 for range runtime.NumCPU() { 117 wg.Add(1) 118 go func() { 119 defer wg.Done() 120 for j := range ch { 121 if err := render(j.src, j.out); err != nil { 122 fmt.Fprintf(os.Stderr, "wrd: %s: %v\n", j.src, err) 123 } 124 } 125 }() 126 } 127 for _, j := range jobs { 128 ch <- j 129 } 130 close(ch) 131 wg.Wait() 132 } 133 134 func render(src, out string) error { 135 fi, err := os.Stat(src) 136 if err != nil { 137 return err 138 } 139 if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { 140 return err 141 } 142 f, err := os.Create(out) 143 if err != nil { 144 return err 145 } 146 147 html := strings.EqualFold(filepath.Ext(src), ".html") 148 cmd := exec.Command("pandoc", "-t", "plain", src) 149 if html { 150 cmd = exec.Command("cha", "-d", src) 151 } 152 cmd.Stdout = f 153 rerr := cmd.Run() 154 if rerr != nil && !html { // fall back to the raw markdown 155 _, _ = f.Seek(0, 0) 156 _ = f.Truncate(0) 157 if b, e := os.ReadFile(src); e == nil { 158 _, _ = f.Write(b) 159 rerr = nil 160 } 161 } 162 _ = f.Close() 163 164 if rerr != nil { 165 _ = os.Remove(out) 166 return rerr 167 } 168 _ = os.Chtimes(out, time.Now(), fi.ModTime()) 169 return nil 170 } 171 172 func (a *app) load() { 173 for _, d := range a.docs { 174 b, err := os.ReadFile(filepath.Join(a.txt, d.rel+".txt")) 175 if err != nil { 176 continue 177 } 178 s := strings.TrimRight(string(b), "\n") 179 if strings.TrimSpace(s) == "" { 180 continue // missing or empty render: leave it as no-text 181 } 182 d.lines = strings.Split(s, "\n") 183 d.linesLower = make([]string, len(d.lines)) 184 for i, ln := range d.lines { 185 d.linesLower[i] = strings.ToLower(ln) 186 } 187 } 188 } 189 190 func (a *app) search() { 191 q := strings.TrimSpace(string(a.query)) 192 fold := q == strings.ToLower(q) // lowercase query => case-insensitive 193 a.results = a.results[:0] 194 195 switch { 196 case a.content && q == "": 197 case a.content: 198 for _, d := range a.docs { 199 lines := d.lines 200 if fold { 201 lines = d.linesLower 202 } 203 for i, ln := range lines { 204 if strings.Contains(ln, q) { 205 a.results = append(a.results, match{d, i + 1}) 206 if len(a.results) >= resultLimit { 207 goto done 208 } 209 } 210 } 211 } 212 default: 213 for _, d := range a.docs { 214 hay := d.rel 215 if fold { 216 hay = strings.ToLower(hay) 217 } 218 if fuzzy(hay, q) { 219 a.results = append(a.results, match{d: d}) 220 } 221 } 222 } 223 done: 224 a.sel = max(0, min(a.sel, len(a.results)-1)) 225 a.top = 0 226 } 227 228 func fuzzy(hay, needle string) bool { 229 nr := []rune(needle) 230 i := 0 231 for _, c := range hay { 232 if i == len(nr) { 233 break 234 } 235 if c == nr[i] { 236 i++ 237 } 238 } 239 return i == len(nr) 240 } 241 242 func url(rel string) string { 243 rel = strings.TrimSuffix(rel, "/index.html") 244 rel = strings.TrimSuffix(rel, "index.html") 245 return "https://" + rel 246 }