package watch import ( "bytes" "fmt" "io/fs" "net/http" "path/filepath" "strings" "sync" "github.com/fsnotify/fsnotify" "slidewalk/internal/parser" "slidewalk/internal/render" "slidewalk/web" ) // Server serves a slide deck over HTTP and pushes browser reloads via // Server-Sent Events whenever a slide file changes on disk. type Server struct { dir string watcher *fsnotify.Watcher broker *broker mu sync.RWMutex page []byte } // NewServer parses and renders the slide deck in dir, then starts watching // dir for changes so the in-memory page stays up to date. func NewServer(dir string) (*Server, error) { watcher, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("creating file watcher: %w", err) } if err := watcher.Add(dir); err != nil { watcher.Close() return nil, fmt.Errorf("watching %s: %w", dir, err) } s := &Server{ dir: dir, watcher: watcher, broker: newBroker(), } if err := s.reload(); err != nil { watcher.Close() return nil, err } go s.watchLoop() return s, nil } // Close stops the file watcher. func (s *Server) Close() error { return s.watcher.Close() } // reload re-parses and re-renders the slide deck, replacing the in-memory // page. func (s *Server) reload() error { slides, err := parser.ParseDir(s.dir) if err != nil { return fmt.Errorf("parsing slides in %s: %w", s.dir, err) } var buf bytes.Buffer page := render.Page{ Title: filepath.Base(filepath.Clean(s.dir)), Slides: slides, DevReload: true, } if err := render.Render(&buf, page); err != nil { return fmt.Errorf("rendering page: %w", err) } s.mu.Lock() s.page = buf.Bytes() s.mu.Unlock() return nil } func (s *Server) watchLoop() { for { select { case event, ok := <-s.watcher.Events: if !ok { return } if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 { continue } if err := s.reload(); err != nil { // The edit left the deck in a state that fails to parse or // render (e.g. invalid frontmatter); keep serving the last // good version instead of crashing the dev server. continue } s.broker.notify() case _, ok := <-s.watcher.Errors: if !ok { return } } } } // Handler returns the HTTP handler serving the current rendered page, its // static assets, and the SSE endpoint used for live reload. func (s *Server) Handler() (http.Handler, error) { assets, err := fs.Sub(web.Assets, "assets") if err != nil { return nil, fmt.Errorf("preparing embedded assets: %w", err) } userAssets := http.FileServer(http.Dir(s.dir)) mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { s.mu.RLock() page := s.page s.mu.RUnlock() w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(page) return } if !isServableUserAsset(r.URL.Path) { http.NotFound(w, r) return } userAssets.ServeHTTP(w, r) }) mux.HandleFunc("/events", s.handleEvents) mux.Handle("/vendor/", http.FileServer(http.FS(assets))) return mux, nil } // isServableUserAsset reports whether urlPath may be served from the slide // source directory as a user asset (e.g. an image referenced by a slide). // Slide Markdown files and dotfiles/dot-directories (".git", ".DS_Store", // ...) are excluded. func isServableUserAsset(urlPath string) bool { if strings.EqualFold(filepath.Ext(urlPath), ".md") { return false } for _, part := range strings.Split(urlPath, "/") { if strings.HasPrefix(part, ".") && part != "" { return false } } return true } // handleEvents serves the SSE endpoint that live-reload.js connects to. It // pushes one event per change notification and otherwise blocks until the // client disconnects. func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) flusher.Flush() ch := s.broker.subscribe() defer s.broker.unsubscribe(ch) for { select { case <-r.Context().Done(): return case _, ok := <-ch: if !ok { return } fmt.Fprint(w, "data: reload\n\n") flusher.Flush() } } } // Serve starts an HTTP server on addr that serves the slide deck in dir, // live-reloading connected browsers whenever a slide file changes. func Serve(addr, dir string) error { srv, err := NewServer(dir) if err != nil { return err } defer srv.Close() handler, err := srv.Handler() if err != nil { return err } if err := http.ListenAndServe(addr, handler); err != nil { return fmt.Errorf("dev server: %w", err) } return nil }