diff --git a/README.md b/README.md index a4dc1e8..d0bc682 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,14 @@ Output goes to `my-site/public/`. Open `index.html` to see it. To change the page, edit `my-site/pages/index/content.md` and run `inkssg build my-site` again. +## Dev server + +``` +inkssg serve my-site +``` + +Builds the site, serves it at `http://localhost:3000`, watches your sources, and reloads the browser on every change. Pass `--addr :4000` to use a different port. + ## Use as a library ``` diff --git a/ROADMAP.md b/ROADMAP.md index 884bcbc..fa83d37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ Note: `ink.yaml` is optional in v0.1. Add it only when you need site-wide data ( ## v0.2 — Polish + daily iteration -- [ ] `inkssg serve` — local server with file watcher and auto-rebuild +- [x] `inkssg serve` — local server with file watcher and auto-rebuild - [ ] Options: `FromConfig`, `WithTheme`, `WithOutputDir`, `WithPagesDir` - [ ] Hooks: `BeforeBuild`, `AfterBuild` - [ ] CSS/JS minification diff --git a/cmd/inkssg/main.go b/cmd/inkssg/main.go index a481228..913ae5c 100644 --- a/cmd/inkssg/main.go +++ b/cmd/inkssg/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "runtime/debug" + "strings" "github.com/snowztech/inkssg" ) @@ -47,17 +48,43 @@ func main() { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } + case "serve": + path, addr := parseServeArgs(os.Args[2:]) + if err := inkssg.Serve(path, inkssg.ServeOptions{Addr: addr}); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } default: usage() os.Exit(1) } } +func parseServeArgs(args []string) (path, addr string) { + path = "." + addr = ":3000" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--addr": + if i+1 < len(args) { + addr = args[i+1] + i++ + } + default: + if !strings.HasPrefix(args[i], "-") { + path = args[i] + } + } + } + return path, addr +} + func usage() { fmt.Println("inkssg — a small static site generator") fmt.Println() fmt.Println("Usage:") - fmt.Println(" inkssg new [path] scaffold a new site") - fmt.Println(" inkssg build [path] build the site into ./public") - fmt.Println(" inkssg version print version") + fmt.Println(" inkssg new [path] scaffold a new site") + fmt.Println(" inkssg build [path] build the site into ./public") + fmt.Println(" inkssg serve [path] [--addr] build, serve, watch, live-reload") + fmt.Println(" inkssg version print version") } diff --git a/go.mod b/go.mod index 0e4c813..78ebfaf 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,8 @@ require ( github.com/yuin/goldmark v1.8.2 gopkg.in/yaml.v3 v3.0.1 ) + +require ( + github.com/fsnotify/fsnotify v1.10.0 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/go.sum b/go.sum index f3e50ca..763c1a4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= +github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/livereload.go b/livereload.go new file mode 100644 index 0000000..74bd04e --- /dev/null +++ b/livereload.go @@ -0,0 +1,170 @@ +package inkssg + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const reloadPath = "/__inkssg/reload" + +const reloadScript = `` + +// reloader fans out reload pings to connected SSE clients. +type reloader struct { + mu sync.Mutex + clients map[chan struct{}]struct{} +} + +func newReloader() *reloader { + return &reloader{clients: map[chan struct{}]struct{}{}} +} + +func (r *reloader) subscribe() chan struct{} { + ch := make(chan struct{}, 1) + r.mu.Lock() + r.clients[ch] = struct{}{} + r.mu.Unlock() + return ch +} + +func (r *reloader) unsubscribe(ch chan struct{}) { + r.mu.Lock() + delete(r.clients, ch) + r.mu.Unlock() +} + +func (r *reloader) broadcast() { + r.mu.Lock() + defer r.mu.Unlock() + for ch := range r.clients { + select { + case ch <- struct{}{}: + default: + } + } +} + +func serveSSE(r *reloader) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := r.subscribe() + defer r.unsubscribe(ch) + + fmt.Fprintf(w, ": connected\n\n") + flusher.Flush() + + ctx := req.Context() + ping := time.NewTicker(30 * time.Second) + defer ping.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ch: + fmt.Fprintf(w, "data: reload\n\n") + flusher.Flush() + case <-ping.C: + fmt.Fprintf(w, ": ping\n\n") + flusher.Flush() + } + } + } +} + +// htmlInjector wraps a file server so HTML responses get the reload script +// inserted before . Non-HTML files pass through untouched. +type htmlInjector struct { + root string + next http.Handler +} + +func (h *htmlInjector) ServeHTTP(w http.ResponseWriter, r *http.Request) { + urlPath := r.URL.Path + if urlPath == "/" || strings.HasSuffix(urlPath, "/") { + urlPath += "index.html" + } + if !strings.HasSuffix(urlPath, ".html") { + h.next.ServeHTTP(w, r) + return + } + + clean := filepath.Clean(strings.TrimPrefix(urlPath, "/")) + abs := filepath.Join(h.root, clean) + rel, err := filepath.Rel(h.root, abs) + if err != nil || strings.HasPrefix(rel, "..") { + http.NotFound(w, r) + return + } + + data, err := os.ReadFile(abs) + if err != nil { + h.next.ServeHTTP(w, r) + return + } + + out := injectReloadScript(data) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write(out) +} + +func injectReloadScript(html []byte) []byte { + closing := []byte("") + idx := bytes.LastIndex(html, closing) + if idx < 0 { + return append(html, []byte(reloadScript)...) + } + out := make([]byte, 0, len(html)+len(reloadScript)) + out = append(out, html[:idx]...) + out = append(out, []byte(reloadScript)...) + out = append(out, html[idx:]...) + return out +} + +func runServer(ctx context.Context, addr, root string, r *reloader) error { + mux := http.NewServeMux() + mux.Handle(reloadPath, serveSSE(r)) + mux.Handle("/", &htmlInjector{root: root, next: http.FileServer(http.Dir(root))}) + + srv := &http.Server{Addr: addr, Handler: mux} + + errCh := make(chan error, 1) + go func() { + errCh <- srv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + case err := <-errCh: + if err == http.ErrServerClosed { + return nil + } + return err + } +} diff --git a/serve.go b/serve.go new file mode 100644 index 0000000..1e6e388 --- /dev/null +++ b/serve.go @@ -0,0 +1,73 @@ +package inkssg + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" +) + +type ServeOptions struct { + Addr string +} + +func Serve(dir string, opts ...ServeOptions) error { + o := ServeOptions{Addr: ":3000"} + if len(opts) > 0 { + if opts[0].Addr != "" { + o.Addr = opts[0].Addr + } + } + + if dir == "" { + dir = "." + } + absDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + site, err := NewSite(absDir) + if err != nil { + return err + } + if err := site.Build(); err != nil { + return err + } + + outAbs, err := filepath.Abs(filepath.Join(absDir, site.Output)) + if err != nil { + return fmt.Errorf("resolve output: %w", err) + } + + rl := newReloader() + + rebuild := func() error { + start := time.Now() + s, err := NewSite(absDir) + if err != nil { + return err + } + if err := s.Build(); err != nil { + return err + } + fmt.Printf("↻ rebuilt in %s\n", time.Since(start).Round(time.Millisecond)) + rl.broadcast() + return nil + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + go func() { + if err := watch(ctx, absDir, outAbs, rebuild); err != nil { + fmt.Fprintf(os.Stderr, "watcher stopped: %v\n", err) + } + }() + + fmt.Printf("inkssg serving %s on http://localhost%s\n", absDir, o.Addr) + return runServer(ctx, o.Addr, filepath.Join(absDir, site.Output), rl) +} diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..1d2672b --- /dev/null +++ b/watch.go @@ -0,0 +1,98 @@ +package inkssg + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" +) + +// watch rebuilds the site whenever a source file under dir changes. +// It debounces bursts of events (editors often emit several per save). +// outAbs is the absolute path of the build output, which is excluded. +func watch(ctx context.Context, dir, outAbs string, onChange func() error) error { + w, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("watcher: %w", err) + } + defer w.Close() + + if err := addWatchTree(w, dir, outAbs); err != nil { + return err + } + + const debounce = 100 * time.Millisecond + var timer *time.Timer + trigger := make(chan struct{}, 1) + + for { + select { + case <-ctx.Done(): + return nil + case ev, ok := <-w.Events: + if !ok { + return nil + } + if shouldIgnore(ev.Name, outAbs) { + continue + } + if ev.Op&fsnotify.Create != 0 { + if info, err := os.Stat(ev.Name); err == nil && info.IsDir() { + _ = addWatchTree(w, ev.Name, outAbs) + } + } + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(debounce, func() { + select { + case trigger <- struct{}{}: + default: + } + }) + case err, ok := <-w.Errors: + if !ok { + return nil + } + fmt.Fprintf(os.Stderr, "watch error: %v\n", err) + case <-trigger: + if err := onChange(); err != nil { + fmt.Fprintf(os.Stderr, "rebuild failed: %v\n", err) + } + } + } +} + +func addWatchTree(w *fsnotify.Watcher, root, outAbs string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + return nil + } + if shouldIgnore(path, outAbs) { + return filepath.SkipDir + } + return w.Add(path) + }) +} + +func shouldIgnore(path, outAbs string) bool { + abs, err := filepath.Abs(path) + if err != nil { + return false + } + if abs == outAbs || strings.HasPrefix(abs, outAbs+string(filepath.Separator)) { + return true + } + base := filepath.Base(abs) + if strings.HasPrefix(base, ".") && base != "." { + return true + } + return false +}