From 0ad7db124a5679b3188ae65a9c0f6350431807e3 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 23 Jun 2026 09:48:04 +0800 Subject: [PATCH] refactor(preview): remove juice/Node.js, simplify to pure Go dev server with SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete tools/server/ (Node.js, juice, inliner.mjs, server.mjs, node_modules) - Create tools/preview/server.go — pure Go dev server with SSE + file polling - Rewrite tools/cli/dev.go — start Go server in-process, no Node.js required - Simplify preview/index.html — View dropdown (Modern/Source), remove Client Simulation panel, remove Gmail/Outlook logic, WebSocket → SSE - Update all 14 documentation files (README, CONTRIBUTING, AGENTS + translations) to reflect pure Go dev server and simplified preview Co-Authored-By: Claude --- AGENTS.md | 11 +- CONTRIBUTING.md | 20 +--- README.md | 21 +--- docs/CONTRIBUTING.ja.md | 7 +- docs/CONTRIBUTING.ko.md | 34 +----- docs/CONTRIBUTING.ru.md | 7 +- docs/CONTRIBUTING.zh-CN.md | 7 +- docs/CONTRIBUTING.zh-TW.md | 7 +- docs/README.ja.md | 7 +- docs/README.ko.md | 14 +-- docs/README.ru.md | 7 +- docs/README.zh-CN.md | 13 +- docs/README.zh-TW.md | 6 +- preview/index.html | 95 +++------------ tools/cli/dev.go | 89 +++++--------- tools/go.mod | 3 +- tools/preview/server.go | 234 ++++++++++++++++++++++++++++++++++++ tools/server/inliner.mjs | 115 ------------------ tools/server/package.json | 16 --- tools/server/server.mjs | 239 ------------------------------------- 20 files changed, 319 insertions(+), 633 deletions(-) create mode 100644 tools/preview/server.go delete mode 100644 tools/server/inliner.mjs delete mode 100644 tools/server/package.json delete mode 100644 tools/server/server.mjs diff --git a/AGENTS.md b/AGENTS.md index 5dd2530..9ba6bc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,12 +48,11 @@ docs/ # Multi-language documentation ### Preview System - `preview/index.html` loads `preview/rendered.js` (pre-rendered by Go) and displays in iframes -- Supports theme/template switching, client simulation (Modern/Gmail/Outlook/Raw), and viewport toggle (Desktop 1386x780 / Mobile 390x780) -- Keyboard navigation: `←→` cycles focus between Theme/Template/Client selects, `↑↓` selects within the focused dropdown, `d`/`m` toggles viewport +- Supports theme/template switching, view mode (Modern/Source), and viewport toggle (Desktop 1386x780 / Mobile 390x780) +- Keyboard navigation: `←→` cycles focus between Theme/Template/View selects, `↑↓` selects within the focused dropdown, `d`/`m` toggles viewport - `REGISTRY` and `PARAMS` are auto-generated from `templates_config.json` — no manual syncing needed -- Static preview (open `index.html` directly) — all client modes show the same HTML; simulation warning is displayed -- Dev server (`go run . dev`) — applies Juice CSS inlining server-side, generates three `rendered.js` variants (modern/gmail/outlook) with client-specific CSS stripping; includes live reload via WebSocket -- Dev mode shows an info notice: simulation cannot 100% reproduce every email client — always verify against real clients +- Static preview (open `index.html` directly) — works via `file://` protocol with Modern and Source views +- Dev server (`go run . dev`) — pure Go HTTP server with SSE live reload; watches `themes/` for `.tmpl` changes, re-renders in-process, and pushes reload events to the browser ### Build Tool - `tools/tools.go` is the main entry point for the modular CLI @@ -62,7 +61,7 @@ docs/ # Multi-language documentation - `tools/config/` handles config loading and data flattening - `tools/preview/` implements the rendering engine (template funcs, locale, engine, markSafeHTML) - `tools/cli/` implements CLI subcommands using `github.com/urfave/cli/v2` -- `tools/server/` Node.js dev server with Juice CSS inlining and live reload (Express + WebSocket + fs.watch) +- `tools/preview/server.go` pure Go dev server with SSE live reload and in-process template re-rendering - Uses Go's native `html/template` package for template rendering ## Commit Conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e2a3a2..d6be3ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Thanks for your interest in contributing! This project aims to provide a diverse - Translation keys must come from Gitea's official locale files (`mail.*` namespace) - **Never reference `.DisplayName`** in templates where the data context lacks it (collaborator, transfer, release, workflow_run, assigned, default) - Design for 600px max-width email clients -- Test against major email clients (Gmail, Outlook, Apple Mail) when possible +- Test against major email clients (Apple Mail, Gmail, Outlook) when possible ### Bug Reports @@ -41,18 +41,14 @@ Documentation updates, preview screenshots, installation guides, and translation ## Development Setup - **Go 1.21+** for template rendering and the CLI tool -- **Node.js 18+** (optional) for the live-reload dev server with Juice CSS inlining ### Previewing Locally (Static) 1. Run `cd tools && go run . preview all` to generate rendered data 2. Open `preview/index.html` directly in a browser — no server needed -3. Use the theme switcher, template selector, and client mode toggles +3. Use the theme switcher, template selector, and view mode toggles -> [!WARNING] -> Static Gmail/Outlook simulation is approximate. Use dev mode for relatively accurate rendering. - -### Dev Server (Live Reload + CSS Inlining + Client Simulation) +### Dev Server (Live Reload) ```bash cd tools && go run . dev @@ -60,13 +56,9 @@ cd tools && go run . dev ``` - Watches `themes/**/*.tmpl` — auto-rebuilds on save -- Runs [Juice](https://github.com/Automattic/juice) to inline `
'+e+'
'; } - // Modern, Gmail, Outlook — the HTML is pre-processed server-side. - // In dev mode rendered.js contains three variants (modern / gmail / outlook); - // the caller picks the right one before passing to transform(). return html; } -/** Pick the right rendered object for the current client mode. */ -function getRendered() { - if (!devMode) return rendered; // static: always modern - if (state.client==='gmail') return renderedGmail || rendered; - if (state.client==='outlook') return renderedOutlook || rendered; - return rendered; -} - // // Render preview // @@ -233,7 +215,7 @@ function render() { frame.style.display = 'none'; loading.style.display = 'flex'; - const src = getRendered(); + const src = rendered; const raw = src[state.theme]?.[state.template]; let content = raw ? transform(raw, state.client) : '

Not found: '+state.theme+'/'+state.template+'

'; // Inject invisible scrollbar into iframe content for immersive preview @@ -282,18 +264,6 @@ function updatePanel() { rows += '.'+k+''+String(v)+''+(isReq?'required':'optional')+''; } document.getElementById('panel-params').innerHTML = rows || '-'; - - // Client simulation indicators - const c=state.client, - on='',off='',warn='', - items=[ - [c==='raw'?off:(c==='gmail'||c==='outlook'?warn:on),'CSS'], - [c==='raw'?off:(c==='outlook'?warn:on),'Web Fonts'], - [c==='modern'?on:off,'Border Radius'], - [c==='modern'?on:off,'Shadows'], - [c==='modern'?on:off,'Gradients'] - ]; - document.getElementById('panel-indicators').innerHTML = items.map(([d,label])=>''+d+' '+label+'').join(''); } // @@ -350,8 +320,6 @@ document.addEventListener('keydown',e=>{ if(window.__RENDERED__){ rendered = window.__RENDERED__; - if(window.__RENDERED_GMAIL__) renderedGmail = window.__RENDERED_GMAIL__; - if(window.__RENDERED_OUTLOOK__) renderedOutlook = window.__RENDERED_OUTLOOK__; // Populate theme selector from rendered data const themeKeys = Object.keys(rendered).sort(); @@ -394,57 +362,37 @@ function clearMessages() { } // Dev-mode detection -// Don't show static warnings if the dev server is running. -// The WebSocket open handler will call setDevMode() which clears anything stale. +// The SSE open handler will call setDevMode() which clears any stale messages. function setDevMode(on) { devMode = on; - if (devCheckTimer) { clearTimeout(devCheckTimer); devCheckTimer = null; } if (on) { clearMessages(); - // Remove any stale static-mode notice injected by a previous check - const stale = document.getElementById('msg-static-notice'); - if (stale) stale.remove(); - // Dev-mode disclaimer — simulation is never 100% accurate showMessage( - 'Dev simulation cannot 100% reproduce every client — for reference only; always verify against real clients.', + 'Live reload active — changes to .tmpl files will auto-refresh the preview.', 'info' ).id = 'msg-dev-disclaimer'; } } -// Static-mode notice (delayed — only shown if no WebSocket within 1 s) -devCheckTimer = setTimeout(function() { - devCheckTimer = null; - if (!devMode) { - showMessage( - 'Gmail / Outlook simulation is approximate. For more accurate rendering with CSS inlining, use cd tools && go run . dev', - 'warn' - ).id = 'msg-static-notice'; - } -}, 1000); - -// Dev-server live-reload (WebSocket) +// Dev-server live-reload (SSE) (function(){ - let ws; + let es; try { - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(proto + '//' + location.host + '/ws'); + es = new EventSource('/events'); } catch(e) { return; } - ws.onmessage = function(e) { + es.onmessage = function(e) { try { const msg = JSON.parse(e.data); if (msg.type === 'reload') { const script = document.createElement('script'); script.src = 'rendered.js?v=' + Date.now(); script.onload = function() { - if (window.__RENDERED__) rendered = window.__RENDERED__; - if (window.__RENDERED_GMAIL__) renderedGmail = window.__RENDERED_GMAIL__; - if (window.__RENDERED_OUTLOOK__) renderedOutlook = window.__RENDERED_OUTLOOK__; - if (window.__REGISTRY__) REGISTRY = window.__REGISTRY__; - if (window.__PARAMS__) PARAMS = window.__PARAMS__; + if (window.__RENDERED__) rendered = window.__RENDERED__; + if (window.__REGISTRY__) REGISTRY = window.__REGISTRY__; + if (window.__PARAMS__) PARAMS = window.__PARAMS__; render(); }; document.head.appendChild(script); @@ -453,22 +401,17 @@ devCheckTimer = setTimeout(function() { } } catch(_) {} }; - ws.onopen = function() { + es.onopen = function() { setDevMode(true); console.log('[dev] live-reload connected'); }; - ws.onclose = function() { - // Only alert if the server was previously connected (crash / shutdown) + es.onerror = function() { if (devMode) { showMessage('Dev server disconnected — reload the page to reconnect', 'error'); setDevMode(false); } console.log('[dev] live-reload disconnected'); }; - ws.onerror = function() { - // onerror always fires before onclose — let onclose decide whether to show a message - console.log('[dev] WebSocket error (dev server may be offline)'); - }; })(); diff --git a/tools/cli/dev.go b/tools/cli/dev.go index a3ced9f..dc3ee54 100644 --- a/tools/cli/dev.go +++ b/tools/cli/dev.go @@ -3,20 +3,22 @@ package cli import ( "fmt" "os" - "os/exec" "os/signal" "path/filepath" "syscall" "github.com/urfave/cli/v2" + + "gitea-mail-templates/tools/config" + "gitea-mail-templates/tools/preview" ) // DevCommand returns the "dev" subcommand. func DevCommand() *cli.Command { return &cli.Command{ - Name: "dev", - Usage: "Start a live-reload dev server with CSS inlining (requires Node.js)", - UsageText: `go run . dev [--port ]`, + Name: "dev", + Usage: "Start a live-reload dev server with SSE browser refresh", + UsageText: "go run . dev [--port ]", Flags: []cli.Flag{ &cli.IntFlag{ Name: "port", @@ -31,72 +33,45 @@ func DevCommand() *cli.Command { func runDev(c *cli.Context) error { port := c.Int("port") - // Find the server directory relative to the tools/ dir - exe, _ := os.Executable() - toolsDir := filepath.Dir(exe) - // When run with `go run`, resolve relative to CWD - if _, err := os.Stat(filepath.Join(toolsDir, "server")); os.IsNotExist(err) { - cwd, _ := os.Getwd() - toolsDir = cwd + // Resolve project paths relative to the tools/ directory + toolsDir, err := os.Getwd() + if err != nil { + toolsDir = "." } - serverDir := filepath.Join(toolsDir, "server") + projectRoot := filepath.Dir(toolsDir) + themesDir := filepath.Join(projectRoot, "themes") + previewDir := filepath.Join(projectRoot, "preview") + configPath := filepath.Join(toolsDir, "data", "templates_config.json") - // Verify Node.js is available - if _, err := exec.LookPath("node"); err != nil { - return fmt.Errorf("Node.js is required but not found in PATH") - } - - // Verify node_modules are installed - if _, err := os.Stat(filepath.Join(serverDir, "node_modules")); os.IsNotExist(err) { - fmt.Println("\033[32m[I]\033[0m [Builder] Installing Node.js dependencies...") - cmd := exec.Command("npm", "install") - cmd.Dir = serverDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("npm install failed: %w", err) + // Verify paths exist + for _, p := range []string{themesDir, previewDir, configPath} { + if _, err := os.Stat(p); os.IsNotExist(err) { + return fmt.Errorf("required path not found: %s", p) } } - // Ensure rendered.js exists before starting - initCmd := exec.Command("go", "run", ".", "preview", "all") - initCmd.Dir = toolsDir - initCmd.Stdout = os.Stdout - initCmd.Stderr = os.Stderr - if err := initCmd.Run(); err != nil { - fmt.Println("\033[33m[W]\033[0m [Builder] Initial preview generation failed, starting anyway") + // Load template config + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) } - // Start the Node.js dev server - serverCmd := exec.Command("node", "server.mjs") - serverCmd.Dir = serverDir - serverCmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port)) - serverCmd.Stdout = os.Stdout - serverCmd.Stderr = os.Stderr + // Start dev server + srv := preview.NewDevServer(preview.DevServerConfig{ + Port: port, + ThemesDir: themesDir, + PreviewDir: previewDir, + Config: cfg, + }) - if err := serverCmd.Start(); err != nil { - return fmt.Errorf("failed to start dev server: %w", err) - } - - fmt.Printf("\033[32m[I]\033[0m [Server] Dev server running at http://localhost:%d\n", port) - fmt.Print("\033[32m[I]\033[0m [Server] Press Ctrl+C to stop\n") - - // Handle graceful shutdown + // Handle Ctrl+C sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - go func() { <-sigCh - fmt.Println("\033[32m[I]\033[0m [Server] Shutting down") - if err := serverCmd.Process.Signal(os.Interrupt); err != nil { - serverCmd.Process.Kill() - } + fmt.Println("\n\033[32m[I]\033[0m [Server] Shutting down") os.Exit(0) }() - if err := serverCmd.Wait(); err != nil { - return fmt.Errorf("dev server exited: %w", err) - } - - return nil + return srv.Start() } diff --git a/tools/go.mod b/tools/go.mod index 0ebb8aa..e655724 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -2,9 +2,10 @@ module gitea-mail-templates/tools go 1.21 +require github.com/urfave/cli/v2 v2.27.7 + require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/urfave/cli/v2 v2.27.7 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect ) diff --git a/tools/preview/server.go b/tools/preview/server.go new file mode 100644 index 0000000..fb4d388 --- /dev/null +++ b/tools/preview/server.go @@ -0,0 +1,234 @@ +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 themes/ for .tmpl changes, +// 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 { + fmt.Fprintf(os.Stderr, "\033[33m[W]\033[0m [Builder] Initial render warning: %v\n", 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(":%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://localhost%s\n", addr) + fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching themes/ for changes\n") + fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint ws://localhost%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 scans themes/ for .tmpl files and returns a map of path → mod time. +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(), ".tmpl") { + mods[path] = info.ModTime() + } + return nil + }) + return mods, err +} + +// 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") + + result := RenderAll(s.cfg.ThemesDir, s.cfg.Config, nil) + if result == nil { + err := fmt.Errorf("no results produced") + 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 +} diff --git a/tools/server/inliner.mjs b/tools/server/inliner.mjs deleted file mode 100644 index b9e5fac..0000000 --- a/tools/server/inliner.mjs +++ /dev/null @@ -1,115 +0,0 @@ -import juice from 'juice'; - -/** Juice CSS inlining options optimized for HTML email. */ -const JUICE_OPTIONS = { - removeStyleTags: false, // keep '; - }); - - // 3. Outlook: replace CSS custom properties with initial - if (isOutlook) { - result = result.replace(/style="([^"]*)"/gi, - (_, s) => 'style="' + s.replace(/var\(--[^)]*\)/g, 'initial') + '"'); - } - - return result; -} - -/** - * Apply juice inlining to a rendered.js-style HTML snippet and return the - * result. Handles escaped HTML (JSON-stringified) by first unescaping, - * inlining, then re-escaping for storage in rendered.js. - */ -export function inlineRenderedHTML(rawhtml) { - try { - // If this is already a standalone HTML document fragment - if (rawhtml.includes('')) { - return inlineCSS(rawhtml); - } - // Otherwise pass through unchanged (inline-only content) - return rawhtml; - } catch (err) { - console.error('Juice inline error:', err.message); - return rawhtml; - } -} - -if (process.argv[1] && import.meta.url.endsWith(process.argv[1])) { - // CLI mode: juice [output] - const { readFileSync, writeFileSync } = await import('fs'); - const input = process.argv[2]; - const output = process.argv[3]; - if (!input) { console.error('Usage: node inliner.mjs [output.html]'); process.exit(1); } - const html = readFileSync(input, 'utf8'); - const result = inlineCSS(html); - if (output) { writeFileSync(output, result, 'utf8'); console.log(`Inlined → ${output}`); } - else { console.log(result); } -} diff --git a/tools/server/package.json b/tools/server/package.json deleted file mode 100644 index e8ecfb2..0000000 --- a/tools/server/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "gitea-mail-templates-dev", - "version": "1.0.0", - "private": true, - "description": "Live-reload dev server with Juice CSS inlining for Gitea Mail Templates", - "type": "module", - "scripts": { - "start": "node server.mjs", - "inline": "node inliner.mjs" - }, - "dependencies": { -"express": "^5.1.0", - "express-ws": "^5.0.2", - "juice": "^11.0.0" - } -} diff --git a/tools/server/server.mjs b/tools/server/server.mjs deleted file mode 100644 index 5a81be5..0000000 --- a/tools/server/server.mjs +++ /dev/null @@ -1,239 +0,0 @@ -import { readFileSync, writeFileSync, existsSync, watch } from 'fs'; -import { resolve, dirname, basename, join, relative } from 'path'; -import { fileURLToPath } from 'url'; -import { spawn } from 'child_process'; - -import express from 'express'; -import expressWs from 'express-ws'; - -import { inlineCSS, stripGmail, stripOutlook } from './inliner.mjs'; - -// ANSI color helpers -const G = '\x1b[32m'; // green (Info) -const Y = '\x1b[33m'; // yellow (Warn) -const R = '\x1b[31m'; // red (Error) -const Z = '\x1b[0m'; // reset -const I = `${G}[I]${Z}`; -const W = `${Y}[W]${Z}`; -const E = `${R}[E]${Z}`; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '..', '..'); // project root -const TOOLS = join(ROOT, 'tools'); // tools/ (for go run) -const PREVIEW = join(ROOT, 'preview'); // preview/ -const THEMES = join(ROOT, 'themes'); // themes/ -const RENDERED_JS = join(PREVIEW, 'rendered.js'); -const PORT = parseInt(process.env.PORT || '3456', 10); - -// Express + WebSocket -const app = express(); -expressWs(app); - -// Track connected WebSocket clients -const clients = new Set(); - -app.ws('/ws', (ws) => { - clients.add(ws); - ws.on('close', () => clients.delete(ws)); -}); - -// Broadcast to all connected browsers -function broadcast(type, payload = {}) { - const msg = JSON.stringify({ type, ...payload }); - for (const c of clients) c.send(msg); -} - -// Serves preview/ as static (index.html, rendered.js) -app.use(express.static(PREVIEW, { etag: false })); - -// Rebuild pipeline -let building = false; -const CHANGED_THEMES = new Set(); - -function getThemeFromPath(filePath) { - const rel = relative(THEMES, filePath).replace(/\\/g, '/'); - const parts = rel.split('/').filter(Boolean); - return parts[0] || null; -} - -async function rebuild() { - if (building) { building = false; return rebuild(); } // debounce — restart - building = true; - - const themes = [...CHANGED_THEMES]; - CHANGED_THEMES.clear(); - - const start = Date.now(); - console.log(`${I} [Builder] Rebuilding: ${themes.length > 0 ? themes.join(', ') : 'all'} changed`); - - // Step 1: Go render - try { - await goPreview(themes.length > 0 ? themes : ['all']); - } catch (err) { - console.error(`${E} [Builder] Go render failed: ${err.message}`); - building = false; - broadcast('error', { message: err.message }); - return; - } - - // Step 2: Read rendered.js, apply juice inlining - try { - const renderedJS = readFileSync(RENDERED_JS, 'utf8'); - const juiced = await juiceRenderedOutput(renderedJS); - writeFileSync(RENDERED_JS, juiced, 'utf8'); - } catch (err) { - console.error(`${E} [Builder] Juice step failed: ${err.message}`); - } - - const elapsed = Date.now() - start; - console.log(`${I} [Builder] Rebuild done in ${elapsed}ms`); - - building = false; - - // Step 3: Notify browsers - broadcast('reload', { elapsed }); -} - -// Go preview (child process) -function goPreview(themes) { - return new Promise((resolve, reject) => { - const args = ['run', '.', 'preview', '--folder', '../themes', '--config', './data/templates_config.json', ...themes]; - const proc = spawn('go', args, { cwd: TOOLS, stdio: ['ignore', 'pipe', 'pipe'] }); - - let stdout = '', stderr = ''; - proc.stdout.on('data', (d) => { stdout += d.toString(); }); - proc.stderr.on('data', (d) => { stderr += d.toString(); }); - - proc.on('close', (code) => { - if (code !== 0) reject(new Error(stderr.trim() || `exit ${code}`)); - else { - // Print the summary lines - const lines = stdout.split('\n').filter(l => l.includes('all required') || l.includes('only')); - for (const l of lines) console.log(' ' + l.trim()); - resolve(stdout); - } - }); - - proc.on('error', reject); - }); -} - -// Juice post-processing -function juiceRenderedOutput(jsContent) { - // rendered.js format: window.__RENDERED__ = { "theme": { "tpl": "..." } }; - // We inline CSS and produce three client-specific variants: - // __RENDERED__ — modern (Juice-inlined only) - // __RENDERED_GMAIL__ — Gmail (Juice + strip unsupported CSS) - // __RENDERED_OUTLOOK__ — Outlook (Juice + aggressive CSS strip) - const match = jsContent.match(/window\.__RENDERED__\s*=\s*(\{[\s\S]*?\});/); - if (!match) return Promise.resolve(jsContent); - - let rendered; - try { - rendered = JSON.parse(match[1]); - } catch { - return Promise.resolve(jsContent); - } - - // Deep-clone for Gmail and Outlook variants - const renderedGmail = JSON.parse(JSON.stringify(rendered)); - const renderedOutlook = JSON.parse(JSON.stringify(rendered)); - - let changed = false; - for (const theme of Object.keys(rendered)) { - for (const tpl of Object.keys(rendered[theme])) { - const html = rendered[theme][tpl]; - if (typeof html !== 'string') continue; - if (!html.includes(' { - if (!fname || !fname.endsWith('.tmpl')) return; - const fullPath = join(THEMES, fname); - // event is 'rename' for both create and delete; 'change' for modifications - if (event === 'change') { - console.log(`${I} [Watcher] ${relPath(fullPath)} changed`); - scheduleRebuild(fullPath); - } else if (event === 'rename') { - if (existsSync(fullPath)) { - console.log(`${I} [Watcher] ${relPath(fullPath)} created`); - } else { - console.log(`${I} [Watcher] ${relPath(fullPath)} deleted`); - } - } - }); - console.log(`${I} [Watcher] Watching themes/ (recursive)`); -} catch (err) { - console.error(`${E} [Watcher] File watch failed: ${err.message}`); -} - -// Initial Juice pass -// The Go `preview all` step (dev.go) already generated raw rendered.js. -// Only run juice inlining — no need to re-render with Go. -(async function initialJuice() { - try { - const js = readFileSync(RENDERED_JS, 'utf8'); - const juiced = await juiceRenderedOutput(js); - writeFileSync(RENDERED_JS, juiced, 'utf8'); - console.log(`${I} [Builder] CSS inlining applied`); - } catch (err) { - console.error(`${E} [Builder] CSS inlining failed: ${err.message}`); - } -})(); - -// Start server -app.get('/health', (_req, res) => res.json({ status: 'ok', port: PORT })); - -app.listen(PORT, () => { - console.log(`${I} [Server] Gitea Mail Templates — Dev Server`); - console.log(`${I} [Server] http://localhost:${PORT}`); - console.log(`${I} [Watcher] Watching themes/ for changes`); - console.log(`${I} [Server] WebSocket ws://localhost:${PORT}/ws\n`); -});