diff --git a/Makefile b/Makefile index c61d1e0..d51c703 100644 --- a/Makefile +++ b/Makefile @@ -22,10 +22,8 @@ $(OUT_DIR)/index.stamp: $(PAGES) $(TEMPLATES) $(ASSETS_FILES) cmd/build/main.go @if [ -d $(ASSETS_DIR) ]; then rm -rf $(OUT_DIR)/$(ASSETS_DIR); cp -r $(ASSETS_DIR) $(OUT_DIR)/; fi @date +%s > $(OUT_DIR)/index.stamp -serve: build - @echo "Serving $(OUT_DIR) as document root..." - @xdg-open "http://localhost:8000/index.html" || true - @cd $(OUT_DIR) && python -m http.server +serve: deps + @$(GO) run ./cmd/serve --port 8000 $(PWD) PDF := $(OUT_DIR)/output.pdf PDF_2UP := $(OUT_DIR)/output-2up.pdf diff --git a/cmd/serve/main.go b/cmd/serve/main.go new file mode 100644 index 0000000..ff0d9d8 --- /dev/null +++ b/cmd/serve/main.go @@ -0,0 +1,217 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" +) + +// serveDist starts an HTTP file server rooted at distPath on the given address. +func serveDist(ctx context.Context, distPath, addr string, wg *sync.WaitGroup) *http.Server { + fs := http.FileServer(http.Dir(distPath)) + srv := &http.Server{Addr: addr, Handler: fs} + + wg.Add(1) + go func() { + defer wg.Done() + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("http server error: %v", err) + } + }() + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + return srv +} + +// runMake executes `make` in the provided project directory. +func runMake(projectDir string) error { + cmd := exec.Command("make") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Printf("running make in %s", projectDir) + return cmd.Run() +} + +func shouldSkipPath(projectDir, path string) bool { + rel, err := filepath.Rel(projectDir, path) + if err != nil { + return false + } + if rel == "." { + return false + } + // Normalize to forward slashes for simple contains checks. + rel = filepath.ToSlash(rel) + // Skip changes inside these directories to avoid rebuild loops and noise. + skipPrefixes := []string{ + "_dist/", + ".git/", + } + for _, p := range skipPrefixes { + if strings.HasPrefix(rel, p) { + return true + } + } + return false +} + +// addWatchersRecursively walks projectDir and adds fsnotify watchers to all directories except skipped ones. +func addWatchersRecursively(w *fsnotify.Watcher, projectDir string) error { + return filepath.WalkDir(projectDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + if shouldSkipPath(projectDir, path) { + return filepath.SkipDir + } + if err := w.Add(path); err != nil { + return fmt.Errorf("add watcher for %s: %w", path, err) + } + return nil + }) +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + // Support optional --port flag while keeping the requested positional path arg. + port := flag.Int("port", 8000, "port to serve _dist on") + flag.Parse() + + if flag.NArg() < 1 { + fmt.Fprintf(os.Stderr, "usage: %s [--port PORT] \n", filepath.Base(os.Args[0])) + os.Exit(2) + } + projectDir := flag.Arg(0) + absProjectDir, err := filepath.Abs(projectDir) + if err != nil { + log.Fatalf("resolve project path: %v", err) + } + + // Validate Makefile presence. + if _, err := os.Stat(filepath.Join(absProjectDir, "Makefile")); err != nil { + log.Fatalf("Makefile not found in %s: %v", absProjectDir, err) + } + + distDir := filepath.Join(absProjectDir, "_dist") + if err := os.MkdirAll(distDir, 0o755); err != nil { + log.Fatalf("ensure _dist exists: %v", err) + } + + // Initial build. + if err := runMake(absProjectDir); err != nil { + log.Printf("initial make failed: %v", err) + } + + // Context and signal handling. + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + // Start HTTP server. + var wg sync.WaitGroup + addr := fmt.Sprintf(":%d", *port) + _ = serveDist(ctx, distDir, addr, &wg) + log.Printf("serving %s at http://localhost%s/", distDir, addr) + + // Set up the file watcher with debounced rebuilds. + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatalf("create watcher: %v", err) + } + defer watcher.Close() + + if err := addWatchersRecursively(watcher, absProjectDir); err != nil { + log.Fatalf("add watchers: %v", err) + } + + rebuildRequests := make(chan struct{}, 1) + + // Watch events and coalesce rebuild requests. + go func() { + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-watcher.Events: + if !ok { + return + } + // Track newly created directories to watch them. + if (ev.Op & fsnotify.Create) == fsnotify.Create { + info, err := os.Stat(ev.Name) + if err == nil && info.IsDir() && !shouldSkipPath(absProjectDir, ev.Name) { + _ = watcher.Add(ev.Name) + } + } + + if shouldSkipPath(absProjectDir, ev.Name) { + continue + } + // Signal a rebuild (non-blocking). + select { + case rebuildRequests <- struct{}{}: + default: + } + + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("watcher error: %v", err) + } + } + }() + + // Debounce and run make. + go func() { + for { + select { + case <-ctx.Done(): + return + case <-rebuildRequests: + // Debounce window. + time.Sleep(200 * time.Millisecond) + // Drain any queued requests. + for { + select { + case <-rebuildRequests: + default: + goto START_BUILD + } + } + START_BUILD: + if err := runMake(absProjectDir); err != nil { + log.Printf("make failed: %v", err) + } + } + } + }() + + // Block until signal then wait for server shutdown. + <-ctx.Done() + cancel() + wg.Wait() +} diff --git a/go.mod b/go.mod index e1d0840..e09a4aa 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 github.com/chromedp/chromedp v0.9.5 + github.com/fsnotify/fsnotify v1.9.0 ) require ( diff --git a/go.sum b/go.sum index 0744849..6d11f84 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93 github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=