package watch import ( "crypto/sha256" "encoding/binary" "encoding/hex" "hash" "io/fs" "log/slog" "path" "strings" "time" ) // Interval is how often the site root is looked at, and Settle is how long it must hold still afterwards. // // Polled rather than watched: notifications need a dependency and per-platform code, while a stat of a personal // site's content costs almost nothing (ADR-0022). The settle window exists because saving a file is rarely one // operation — an editor may write, rename and chmod — and rebuilding halfway through that reads a half-written // bundle. // Variables rather than constants for the same reason clock.go holds a variable: a test that has to wait out // two-second polls is a test nobody runs. Nothing but a test assigns them. var ( Interval = 2 * time.Second Settle = time.Second ) // Changed is called once per settled change, with the reason for the log. type Changed func() // Watch polls fsys until stop is closed, calling onChange after each settled change. // // A goroutine's worth of work, which `conventions.md` allows outside the render path: nothing here runs while a // request is being served, and the only shared state is whatever onChange swaps. func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed) { previous := Fingerprint(fsys) pending := "" ticker := time.NewTicker(Interval) defer ticker.Stop() settling := time.NewTimer(Settle) settling.Stop() for { select { case <-stop: return case <-ticker.C: current := Fingerprint(fsys) switch { case current == previous: // Nothing moved. If something was pending, its settle timer is still running. case current == pending: // Still the same as last tick: the write has stopped, so let the timer finish. default: // Something changed, or changed again — restart the settle window. pending = current settling.Reset(Settle) } case <-settling.C: if pending == "" || pending == previous { continue } previous = pending pending = "" slog.Info("site root changed, rebuilding") onChange() } } } // Fingerprint is one string standing for the current state of the content. // // Names, sizes and modification times — not contents: reading every file to detect a change would cost more // than the rebuild it triggers. Editor droppings are excluded, or saving a file in vim would look like three // changes and a deletion. func Fingerprint(fsys fs.FS) string { sum := sha256.New() for _, root := range []string{"content", "templates"} { _ = fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error { return record(sum, p, d, err) }) } // site.yaml is part of the site's state, and editing it should not need a restart. if info, err := fs.Stat(fsys, "site.yaml"); err == nil { sum.Write([]byte("site.yaml")) _ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano()) } return hex.EncodeToString(sum.Sum(nil)) } // record folds one entry into the running sum, skipping anything that is not content. // // Its own function so the walk stays flat: an error, a directory and a dropping are three early returns, which // read as a list of exclusions rather than as nesting. func record(sum hash.Hash, p string, d fs.DirEntry, err error) error { switch { case err != nil: return nil case d.IsDir() && dropping(d.Name()): return fs.SkipDir case d.IsDir(), dropping(d.Name()): return nil } info, statErr := d.Info() if statErr != nil { return nil } sum.Write([]byte(p)) _ = binary.Write(sum, binary.LittleEndian, info.Size()) _ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano()) return nil } // dropping reports whether a name is an editor's leftovers rather than content. // // Every one of these is something a real editor writes beside the file it is saving: swap files, backups, // atomic-write temporaries, and the number vim touches to test whether a directory is writable. func dropping(name string) bool { switch { case strings.HasPrefix(name, "."), strings.HasSuffix(name, "~"): return true case name == "4913", name == "__pycache__": return true } switch strings.ToLower(path.Ext(name)) { case ".swp", ".swo", ".swx", ".tmp", ".bak", ".orig": return true } return false }