feat(dev): add live-reload dev server with Juice CSS inlining
- tools/server/: Node.js dev server (Express + WebSocket + fs.watch) Watches themes/ for .tmpl changes, rebuilds via Go preview command, applies Juice CSS inlining for email client compatibility, pushes live reload to browser via WebSocket. - tools/cli/dev.go: 'go run . dev' subcommand — starts Node.js server with automatic npm install, initial preview generation, and Ctrl+C graceful shutdown. - tools/tools.go: register dev command in CLI app. - .gitignore: add node_modules exclusion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bf982ffb9
commit
05d76a90af
6 files changed
+377
No files matched your search
@@ -18,5 +18,8 @@ Desktop.ini
|
|||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# Package lock file
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Generated preview data (run: cd tools && go run . preview all)
|
# Generated preview data (run: cd tools && go run . preview all)
|
||||||
preview/rendered.js
|
preview/rendered.js
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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>]`,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "port",
|
||||||
|
Value: 3456,
|
||||||
|
Usage: "Port for the dev server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: runDev,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
serverDir := filepath.Join(toolsDir, "server")
|
||||||
|
|
||||||
|
// 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("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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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("Warning: initial preview generation failed, starting anyway...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
if err := serverCmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start dev server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nDev server running at http://localhost:%d\n", port)
|
||||||
|
fmt.Print("Press Ctrl+C to stop.\n\n")
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-sigCh
|
||||||
|
fmt.Println("\nShutting down...")
|
||||||
|
if err := serverCmd.Process.Signal(os.Interrupt); err != nil {
|
||||||
|
serverCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := serverCmd.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("dev server exited: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { readFileSync, writeFileSync, existsSync, watch } from 'fs';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
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 } from './inliner.mjs';
|
||||||
|
|
||||||
|
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(`\n[rebuild] ${themes.length > 0 ? themes.join(', ') : 'all'} changed`);
|
||||||
|
|
||||||
|
// Step 1: Go render
|
||||||
|
try {
|
||||||
|
await goPreview(themes.length > 0 ? themes : ['all']);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[rebuild] 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('[rebuild] juice step failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
console.log(`[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 inside each rendered HTML snippet
|
||||||
|
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); // can't parse, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Only inline HTML that has a <style> block (skip error messages)
|
||||||
|
if (!html.includes('<style') && !html.includes('<html')) continue;
|
||||||
|
try {
|
||||||
|
const juiced = inlineCSS(html);
|
||||||
|
if (juiced !== html) {
|
||||||
|
rendered[theme][tpl] = juiced;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep original on juice failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
const newRendered = 'window.__RENDERED__ = ' + JSON.stringify(rendered, null, 2) + ';\n';
|
||||||
|
// Preserve __REGISTRY__ and __PARAMS__ from original
|
||||||
|
const rest = jsContent.replace(/window\.__RENDERED__\s*=\s*\{[\s\S]*?\};/, '');
|
||||||
|
return Promise.resolve(newRendered + 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('[watch] ' + relPath(fullPath) + ' edited');
|
||||||
|
scheduleRebuild(fullPath);
|
||||||
|
} else if (event === 'rename') {
|
||||||
|
if (existsSync(fullPath)) {
|
||||||
|
console.log('[watch] ' + relPath(fullPath) + ' created');
|
||||||
|
} else {
|
||||||
|
console.log('[watch] ' + relPath(fullPath) + ' deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('[watch] themes/ (recursive)');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[watch] failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start server ──
|
||||||
|
app.get('/health', (_req, res) => res.json({ status: 'ok', port: PORT }));
|
||||||
|
|
||||||
|
createServer(app).listen(PORT, () => {
|
||||||
|
console.log(`\n Gitea Mail Templates — Dev Server`);
|
||||||
|
console.log(` http://localhost:${PORT}\n`);
|
||||||
|
console.log(` Watching themes/ for changes...`);
|
||||||
|
console.log(` WebSocket ws://localhost:${PORT}/ws`);
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ func main() {
|
|||||||
cmds.CreateCommand(),
|
cmds.CreateCommand(),
|
||||||
cmds.DeleteCommand(),
|
cmds.DeleteCommand(),
|
||||||
cmds.PreviewCommand(),
|
cmds.PreviewCommand(),
|
||||||
|
cmds.DevCommand(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user