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:
KenanZhuandClaude committed 2026-06-23 09:48:04 +08:00
1 parent d4c6eb99af
commit 0ad7db124a
20 files changed
+319 -633

No files matched your search

+32 -57
View File
@@ -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
View File
@@ -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
)
+234
View File
@@ -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
}
-115
View File
@@ -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); }
}
-16
View File
@@ -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"
}
}
-239
View File
@@ -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`);
});