slidewalk/internal/watch/server.go
2026-07-15 20:14:07 +02:00

213 lines
4.8 KiB
Go

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
}