213 lines
4.8 KiB
Go
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
|
|
}
|