refactor(preview): remove juice/Node.js, simplify to pure Go dev server with SSE
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4c6eb99af
commit
0ad7db124a
20 files changed
+319
-633
No files matched your search
+32
-57
@@ -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 <port>]`,
|
||||
Name: "dev",
|
||||
Usage: "Start a live-reload dev server with SSE browser refresh",
|
||||
UsageText: "go run . dev [--port <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()
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import juice from 'juice';
|
||||
|
||||
/** Juice CSS inlining options optimized for HTML email. */
|
||||
const JUICE_OPTIONS = {
|
||||
removeStyleTags: false, // keep <style> for modern clients that support it
|
||||
preserveMediaQueries: true,
|
||||
preserveFontFaces: true,
|
||||
applyWidthAttributes: true,
|
||||
applyHeightAttributes: true,
|
||||
applyAttributesTableElements: true,
|
||||
xmlMode: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline CSS from <style> blocks and <link> tags into inline style attributes.
|
||||
* Produces HTML that renders consistently across Gmail, Outlook, Apple Mail.
|
||||
*/
|
||||
export function inlineCSS(html) {
|
||||
return juice(html, JUICE_OPTIONS);
|
||||
}
|
||||
|
||||
// Gmail / Outlook CSS stripping
|
||||
|
||||
const GMAIL_UNSUPPORTED = [
|
||||
'border-radius','box-shadow','text-shadow',
|
||||
'backdrop-filter','filter',
|
||||
'animation','transition','transform',
|
||||
'background-image\\s*:\\s*(linear|radial|conic)-gradient',
|
||||
];
|
||||
const OUTLOOK_UNSUPPORTED = GMAIL_UNSUPPORTED.concat([
|
||||
'display\\s*:\\s*(flex|grid|inline-flex|inline-grid)',
|
||||
'flex[^;]*','grid[^;]*','gap\\s*:','order\\s*:',
|
||||
]);
|
||||
|
||||
function stripDeclarations(styleValue, patterns) {
|
||||
let v = styleValue;
|
||||
for (const p of patterns) {
|
||||
v = v.replace(new RegExp('(^|;\\s*)' + p + '[^;]*(;|$)', 'gi'), (m) => m.endsWith(';') ? ';' : '');
|
||||
v = v.replace(new RegExp(p + '[^;]*;', 'gi'), '');
|
||||
}
|
||||
return v.replace(/;;+/g, ';').replace(/^\s*;\s*/, '').replace(/\s*;\s*$/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip CSS properties unsupported by Gmail from inline styles and <style> blocks.
|
||||
*/
|
||||
export function stripGmail(html) {
|
||||
return stripClientCSS(html, GMAIL_UNSUPPORTED, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip CSS properties unsupported by Outlook (more aggressive than Gmail).
|
||||
*/
|
||||
export function stripOutlook(html) {
|
||||
return stripClientCSS(html, OUTLOOK_UNSUPPORTED, true);
|
||||
}
|
||||
|
||||
function stripClientCSS(html, patterns, isOutlook) {
|
||||
let result = html;
|
||||
|
||||
// 1. Strip from inline style="..." attributes
|
||||
result = result.replace(/style="([^"]*)"/gi, (_, styles) => {
|
||||
const cleaned = stripDeclarations(styles, patterns);
|
||||
return cleaned ? 'style="' + cleaned + '"' : '';
|
||||
});
|
||||
|
||||
// 2. Strip from <style> blocks
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, cssContent) => {
|
||||
let cleaned = cssContent;
|
||||
for (const p of patterns) {
|
||||
const re = new RegExp(p + '[^;{}]*[;}]?', 'gi');
|
||||
cleaned = cleaned.replace(re, (m) => m.endsWith('}') ? '}' : '');
|
||||
}
|
||||
return '<style>' + cleaned + '</style>';
|
||||
});
|
||||
|
||||
// 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('<style') || rawhtml.includes('</style>')) {
|
||||
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 <input> [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 <input.html> [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); }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "<html>..." } };
|
||||
// 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('<style') && !html.includes('<html')) continue;
|
||||
try {
|
||||
// Modern — Juice inlining only
|
||||
const modern = inlineCSS(html);
|
||||
rendered[theme][tpl] = modern;
|
||||
changed = true;
|
||||
|
||||
// Gmail — Juice + strip unsupported CSS
|
||||
try { renderedGmail[theme][tpl] = stripGmail(modern); } catch {}
|
||||
// Outlook — Juice + aggressive CSS strip
|
||||
try { renderedOutlook[theme][tpl] = stripOutlook(modern); } catch {}
|
||||
} catch {
|
||||
// keep original on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
const rest = jsContent.replace(/window\.__RENDERED__\s*=\s*\{[\s\S]*?\};/, '');
|
||||
return Promise.resolve(
|
||||
'window.__RENDERED__ = ' + JSON.stringify(rendered, null, 2) + ';\n' +
|
||||
'window.__RENDERED_GMAIL__ = ' + JSON.stringify(renderedGmail, null, 2) + ';\n' +
|
||||
'window.__RENDERED_OUTLOOK__ = ' + JSON.stringify(renderedOutlook, null, 2) + ';\n' +
|
||||
rest
|
||||
);
|
||||
}
|
||||
return Promise.resolve(jsContent);
|
||||
}
|
||||
|
||||
// File watcher (native fs.watch, recursive)
|
||||
function relPath(absPath) {
|
||||
return relative(ROOT, absPath).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
// Debounced rebuild trigger
|
||||
let rebuildTimer = null;
|
||||
function scheduleRebuild(filePath) {
|
||||
const theme = getThemeFromPath(filePath);
|
||||
if (!theme) return;
|
||||
|
||||
const ext = filePath.slice(filePath.lastIndexOf('.'));
|
||||
if (ext !== '.tmpl') return;
|
||||
|
||||
CHANGED_THEMES.add(theme);
|
||||
clearTimeout(rebuildTimer);
|
||||
rebuildTimer = setTimeout(rebuild, 300);
|
||||
}
|
||||
|
||||
// Watch themes directory recursively (Windows supports recursive)
|
||||
try {
|
||||
watch(THEMES, { recursive: true }, (event, fname) => {
|
||||
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`);
|
||||
});
|
||||
Reference in new issue
Block a user