package preview import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "gitea-mail-templates/tools/config" ) // DevServerConfig holds configuration for the dev server. type DevServerConfig struct { Port int ThemesDir string PreviewDir string Config *config.TemplatesConfig } // DevServer is a live-reload dev server with SSE-based browser notifications. // It serves preview/ as static files, watches theme resources and snapshots, // re-renders templates in-process, and pushes reload events to connected browsers. type DevServer struct { cfg DevServerConfig clients map[chan string]struct{} mu sync.Mutex lastMods map[string]time.Time modsMu sync.Mutex } // NewDevServer creates a new dev server instance. func NewDevServer(cfg DevServerConfig) *DevServer { return &DevServer{ cfg: cfg, clients: make(map[chan string]struct{}), lastMods: make(map[string]time.Time), } } // Start initializes the rendered.js, begins file watching, and serves HTTP. // Blocks until the server stops. func (s *DevServer) Start() error { // Build initial rendered.js if err := s.rebuildAll(); err != nil { return fmt.Errorf("initial preview build: %w", err) } // Start file watcher in background go s.watchThemes() mux := http.NewServeMux() // SSE endpoint — registered before static file handler mux.HandleFunc("/events", s.handleSSE) // Static file server for preview/ directory previewFS := http.FileServer(http.Dir(s.cfg.PreviewDir)) mux.Handle("/", previewFS) addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port) fmt.Printf("\033[32m[I]\033[0m [Server] Gitea Mail Templates — Dev Server\n") fmt.Printf("\033[32m[I]\033[0m [Server] http://%s\n", addr) fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching theme CSS, framework, lock/cache and fixtures\n") fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint http://%s/events\n\n", addr) return http.ListenAndServe(addr, mux) } // --- SSE --- func (s *DevServer) handleSSE(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.Header().Set("Access-Control-Allow-Origin", "*") ch := make(chan string, 16) s.mu.Lock() s.clients[ch] = struct{}{} s.mu.Unlock() defer func() { s.mu.Lock() delete(s.clients, ch) s.mu.Unlock() close(ch) }() ctx := r.Context() for { select { case msg := <-ch: fmt.Fprintf(w, "data: %s\n\n", msg) flusher.Flush() case <-ctx.Done(): return } } } // broadcast sends a JSON message to all connected SSE clients. func (s *DevServer) broadcast(typ string, payload map[string]interface{}) { msg := map[string]interface{}{"type": typ} for k, v := range payload { msg[k] = v } data, err := json.Marshal(msg) if err != nil { return } s.mu.Lock() defer s.mu.Unlock() for ch := range s.clients { select { case ch <- string(data): default: // client buffer full, skip } } } // --- File watcher (polling) --- // scanMods records theme resources, snapshot files and fixture modification times. func (s *DevServer) scanMods() (map[string]time.Time, error) { mods := make(map[string]time.Time) err := filepath.Walk(s.cfg.ThemesDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip unreadable } if !info.IsDir() && (strings.HasSuffix(info.Name(), ".json") || strings.HasSuffix(info.Name(), ".css") || strings.HasSuffix(info.Name(), ".html")) { mods[path] = info.ModTime() } return nil }) if err != nil { return mods, err } for _, dir := range []string{filepath.Join(filepath.Dir(s.cfg.ThemesDir), "framework"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "build", "upstream"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data")} { err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { mods[path] = info.ModTime() } return nil }) if err != nil { return mods, err } } lock := filepath.Join(filepath.Dir(s.cfg.ThemesDir), "gitea.lock.json") if info, err := os.Stat(lock); err == nil { mods[lock] = info.ModTime() } else { return mods, err } return mods, nil } // watchThemes polls the themes directory every 500ms and triggers rebuild on changes. func (s *DevServer) watchThemes() { // Seed initial state if mods, err := s.scanMods(); err == nil { s.modsMu.Lock() s.lastMods = mods s.modsMu.Unlock() } ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for range ticker.C { current, err := s.scanMods() if err != nil { continue } changed := false s.modsMu.Lock() for path, mod := range current { last, ok := s.lastMods[path] if !ok || mod.After(last) { changed = true } } // Also detect deletions for path := range s.lastMods { if _, ok := current[path]; !ok { changed = true } } if changed { // Log which files changed for path, mod := range current { last, ok := s.lastMods[path] if !ok || mod.After(last) { rel, _ := filepath.Rel(s.cfg.ThemesDir, path) fmt.Printf("\033[32m[I]\033[0m [Watcher] %s changed\n", filepath.ToSlash(rel)) } } for path := range s.lastMods { if _, ok := current[path]; !ok { rel, _ := filepath.Rel(s.cfg.ThemesDir, path) fmt.Printf("\033[32m[I]\033[0m [Watcher] %s deleted\n", filepath.ToSlash(rel)) } } s.lastMods = current s.modsMu.Unlock() s.rebuildAll() } else { s.modsMu.Unlock() } } } // --- Rebuild --- var rebuildMu sync.Mutex func (s *DevServer) rebuildAll() error { rebuildMu.Lock() defer rebuildMu.Unlock() start := time.Now() fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuilding all themes...\n") cfg, err := config.Load(filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data", "templates_config.json")) if err != nil { s.broadcast("error", map[string]interface{}{"message": err.Error()}) return err } result, err := RenderAll(s.cfg.ThemesDir, cfg, nil) if err != nil { s.broadcast("error", map[string]interface{}{"message": err.Error()}) return err } outputPath := filepath.Join(s.cfg.PreviewDir, "rendered.js") if err := WriteRenderedJS(result, outputPath); err != nil { s.broadcast("error", map[string]interface{}{"message": err.Error()}) return err } elapsed := time.Since(start) fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuild done in %v\n", elapsed.Round(time.Millisecond)) s.broadcast("reload", map[string]interface{}{"elapsed": elapsed.Milliseconds()}) return nil }