diff --git a/.gitignore b/.gitignore index 8c9ee8b..5ac1fab 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,8 @@ Desktop.ini *.zip *.tar.gz +# Package lock file +package-lock.json + # Generated preview data (run: cd tools && go run . preview all) preview/rendered.js diff --git a/tools/cli/dev.go b/tools/cli/dev.go new file mode 100644 index 0000000..999c54f --- /dev/null +++ b/tools/cli/dev.go @@ -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 ]`, + 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 +} diff --git a/tools/server/inliner.mjs b/tools/server/inliner.mjs new file mode 100644 index 0000000..699ba4e --- /dev/null +++ b/tools/server/inliner.mjs @@ -0,0 +1,51 @@ +import juice from 'juice'; + +/** Juice CSS inlining options optimized for HTML email. */ +const JUICE_OPTIONS = { + removeStyleTags: false, // keep ')) { + 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 new file mode 100644 index 0000000..e8ecfb2 --- /dev/null +++ b/tools/server/package.json @@ -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" + } +} diff --git a/tools/server/server.mjs b/tools/server/server.mjs new file mode 100644 index 0000000..270407d --- /dev/null +++ b/tools/server/server.mjs @@ -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": "..." } }; + // 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