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
+315 -629

No files matched your search

+5 -6
View File
@@ -48,12 +48,11 @@ docs/ # Multi-language documentation
### Preview System ### Preview System
- `preview/index.html` loads `preview/rendered.js` (pre-rendered by Go) and displays in iframes - `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) - Supports theme/template switching, view mode (Modern/Source), 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 - 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 - `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 - Static preview (open `index.html` directly) — works via `file://` protocol with Modern and Source views
- 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 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
- Dev mode shows an info notice: simulation cannot 100% reproduce every email client — always verify against real clients
### Build Tool ### Build Tool
- `tools/tools.go` is the main entry point for the modular CLI - `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/config/` handles config loading and data flattening
- `tools/preview/` implements the rendering engine (template funcs, locale, engine, markSafeHTML) - `tools/preview/` implements the rendering engine (template funcs, locale, engine, markSafeHTML)
- `tools/cli/` implements CLI subcommands using `github.com/urfave/cli/v2` - `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 - Uses Go's native `html/template` package for template rendering
## Commit Conventions ## Commit Conventions
+6 -14
View File
@@ -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) - 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) - **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 - 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 ### Bug Reports
@@ -41,18 +41,14 @@ Documentation updates, preview screenshots, installation guides, and translation
## Development Setup ## Development Setup
- **Go 1.21+** for template rendering and the CLI tool - **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) ### Previewing Locally (Static)
1. Run `cd tools && go run . preview all` to generate rendered data 1. Run `cd tools && go run . preview all` to generate rendered data
2. Open `preview/index.html` directly in a browser — no server needed 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] ### Dev Server (Live Reload)
> Static Gmail/Outlook simulation is approximate. Use dev mode for relatively accurate rendering.
### Dev Server (Live Reload + CSS Inlining + Client Simulation)
```bash ```bash
cd tools && go run . dev cd tools && go run . dev
@@ -60,13 +56,9 @@ cd tools && go run . dev
``` ```
- Watches `themes/**/*.tmpl` — auto-rebuilds on save - Watches `themes/**/*.tmpl` — auto-rebuilds on save
- Runs [Juice](https://github.com/Automattic/juice) to inline `<style>` into `style=""` attributes - Pure Go HTTP server with SSE push — no external dependencies
- Generates three `rendered.js` variants: modern (Juice only), Gmail (Juice + CSS strip), Outlook (Juice + aggressive CSS strip) - Re-renders templates in-process and pushes reload events to the browser
- Pushes live reload to browser via WebSocket - Terminal output: `themes/aurora/mail/repo/release.tmpl changed` → `[Builder] Rebuild done in 45ms`
- Terminal output: `themes/aurora/mail/repo/release.tmpl edited` → `[rebuild] done in 480ms`
> [!NOTE]
> Dev simulation cannot 100% reproduce every email client — for reference only; always verify against real clients.
### Integration Testing ### Integration Testing
+6 -15
View File
@@ -31,7 +31,7 @@ Every template is a drop-in replacement. All Go template variables, translation
> Images are screenshots from the [live preview](preview/index.html). See [docs/images/README.md](docs/images/README.md) for capture instructions. > Images are screenshots from the [live preview](preview/index.html). See [docs/images/README.md](docs/images/README.md) for capture instructions.
[**Live preview gallery**](preview/index.html) — open in a browser for an interactive style switcher with desktop/mobile viewports and email client simulation (Modern, Gmail, Outlook, Raw source). [**Live preview gallery**](preview/index.html) — open in a browser for an interactive style switcher with desktop/mobile viewports and view mode (Modern, Source).
--- ---
@@ -91,15 +91,12 @@ cd tools && go run . preview all
open preview/index.html # no server needed open preview/index.html # no server needed
``` ```
> [!WARNING] ### Dev Server (Live Reload)
> Gmail/Outlook simulation in static mode is approximate. Use dev mode for relatively accurate CSS inlining.
### Dev Server (Live Reload + Juice CSS Inlining) Start a pure Go development server that watches for `.tmpl` changes, auto-rebuilds, and pushes live updates to the browser via SSE:
Start a development server that watches for `.tmpl` changes, auto-rebuilds, inlines CSS for email client compatibility, and pushes live updates to the browser:
```bash ```bash
cd tools && go run . dev # requires Node.js cd tools && go run . dev
open http://localhost:3456 open http://localhost:3456
``` ```
@@ -107,22 +104,16 @@ open http://localhost:3456
|-----------|--------|-----| |-----------|--------|-----|
| Go template rendering | ✅ | ✅ | | Go template rendering | ✅ | ✅ |
| Theme/template switching | ✅ | ✅ | | Theme/template switching | ✅ | ✅ |
| Juice CSS inlining | — | ✅ |
| Gmail/Outlook CSS stripping | — | ✅ |
| Live reload on save | — | ✅ | | Live reload on save | — | ✅ |
| Node.js required | — | ✅ |
> [!NOTE]
> Dev simulation cannot 100% reproduce every email client — for reference only; always verify against real clients.
### Features ### Features
- Theme switcher — browse all 10 visual styles - Theme switcher — browse all 10 visual styles
- Template switcher — all 11 email types - Template switcher — all 11 email types
- Client simulation — Modern, Gmail, Outlook, Raw Source (CSS stripping in dev mode) - View mode — Modern (rendered preview), Source (raw HTML)
- Viewport toggle — Desktop 1386×780 / Mobile 390×780 - Viewport toggle — Desktop 1386×780 / Mobile 390×780
- Parameter panel — mock data per email type - Parameter panel — mock data per email type
- Keyboard shortcuts — `←→` tab between Theme/Template/Client, `↑↓` select within, `d`/`m` viewport - Keyboard shortcuts — `←→` tab between Theme/Template/View, `↑↓` select within, `d`/`m` viewport
--- ---
+1 -6
View File
@@ -31,17 +31,14 @@
## 開発セットアップ ## 開発セットアップ
- **Go 1.21+** テンプレートレンダリングとCLI用 - **Go 1.21+** テンプレートレンダリングとCLI用
- **Node.js 18+**(任意)開発サーバーとJuice CSSインライン用
### ローカルプレビュー(静的) ### ローカルプレビュー(静的)
1. データを生成: `cd tools && go run . preview all` 1. データを生成: `cd tools && go run . preview all`
2. `preview/index.html` をブラウザで開く 2. `preview/index.html` をブラウザで開く
> [!WARNING]
> 静的Gmail/Outlookシミュレーションは参考用です。比較的正確なレンダリングにはdevモードを使用してください。
### 開発サーバー(ライブリロード + CSSインライン + クライアントシミュレーション) ### 開発サーバー(ライブリロード)
```bash ```bash
cd tools && go run . dev cd tools && go run . dev
@@ -50,8 +47,6 @@ cd tools && go run . dev
`.tmpl` ファイルを編集すると自動的に再構築されブラウザに反映されます。 `.tmpl` ファイルを編集すると自動的に再構築されブラウザに反映されます。
> [!NOTE]
> Dev シミュレーションは各メールクライアントのレンダリングを完全には再現できません — 参考用です。実際のクライアントで必ず確認してください。
### 結合テスト ### 結合テスト
+1 -33
View File
@@ -31,17 +31,14 @@
## 개발 설정 ## 개발 설정
- **Go 1.21+** 템플릿 렌더링 및 CLI 도구 - **Go 1.21+** 템플릿 렌더링 및 CLI 도구
- **Node.js 18+** (선택) 개발 서버 및 Juice CSS 인라인
### 로컬 프리뷰 (정적) ### 로컬 프리뷰 (정적)
1. 데이터 생성: `cd tools && go run . preview all` 1. 데이터 생성: `cd tools && go run . preview all`
2. `preview/index.html`을 브라우저에서 열기 2. `preview/index.html`을 브라우저에서 열기
> [!WARNING]
> 정적 Gmail/Outlook 시뮬레이션은 참고용입니다. 비교적 정확한 렌더링은 dev 모드를 사용하세요.
### 개발 서버 (실시간 리로드 + CSS 인라인 + 클라이언트 시뮬레이션) ### 개발 서버 (실시간 리로드)
```bash ```bash
cd tools && go run . dev cd tools && go run . dev
@@ -50,32 +47,3 @@ cd tools && go run . dev
`.tmpl` 파일 수정 시 자동 재빌드되어 브라우저에 반영됩니다. `.tmpl` 파일 수정 시 자동 재빌드되어 브라우저에 반영됩니다.
> [!NOTE]
> Dev 시뮬레이션은 각 이메일 클라이언트의 렌더링을 완전히 재현할 수 없습니다 — 참고용입니다. 실제 클라이언트에서 반드시 확인하세요.
### 통합 테스트
Gitea에 배포 후 실제 트랜잭션 이메일로 검증하세요. 관리자 테스트 이메일
(**Site Administration > Configuration > Mailer > Send Test Email**)은 커스텀
메일 템플릿을 사용하지 않습니다——내장 코드 경로를 따릅니다.
가장 확실한 방법은 실제 알림을 트리거하는 것입니다. 예를 들어, 비밀번호 재설정 흐름:
1. 로그아웃 후 로그인 페이지에서 **"Forgot password"** 클릭
2. 계정 이메일을 입력하고 제출
3. 비밀번호 재설정 이메일 확인——커스텀 메일 템플릿으로 렌더링됩니다
---
## 커밋 규칙
- `style(<name>):` — 스타일 템플릿 변경
- `preview:` — 프리뷰 도구 변경
- `tools:` — Go 빌드 스크립트 변경
- `docs:` — 문서 및 번역
- `fix:` — 버그 수정
- `project:` — README, LICENSE, 메타 파일
## 라이선스
기여함으로써 MIT 라이선스에 동의하게 됩니다.
+1 -6
View File
@@ -31,17 +31,14 @@
## Настройка разработки ## Настройка разработки
- **Go 1.21+** для рендеринга шаблонов и CLI - **Go 1.21+** для рендеринга шаблонов и CLI
- **Node.js 18+** (опционально) для сервера разработки с Juice CSS
### Локальный предпросмотр (статический) ### Локальный предпросмотр (статический)
1. Сгенерируйте данные: `cd tools && go run . preview all` 1. Сгенерируйте данные: `cd tools && go run . preview all`
2. Откройте `preview/index.html` в браузере — сервер не нужен 2. Откройте `preview/index.html` в браузере — сервер не нужен
> [!WARNING]
> Статическая симуляция Gmail/Outlook приблизительна. Используйте dev-режим для относительно точного рендеринга.
### Сервер разработки (live reload + CSS инлайн + симуляция клиентов) ### Сервер разработки (live reload)
```bash ```bash
cd tools && go run . dev cd tools && go run . dev
@@ -50,8 +47,6 @@ cd tools && go run . dev
При изменении `.tmpl` файлов автоматически пересобирает и обновляет браузер. При изменении `.tmpl` файлов автоматически пересобирает и обновляет браузер.
> [!NOTE]
> Dev-симуляция не может на 100% воспроизвести рендеринг каждого почтового клиента — только для справки. Всегда проверяйте на реальных клиентах.
### Интеграционное тестирование ### Интеграционное тестирование
+1 -6
View File
@@ -35,17 +35,14 @@
## 开发环境 ## 开发环境
- **Go 1.21+** 用于模板渲染和 CLI 工具 - **Go 1.21+** 用于模板渲染和 CLI 工具
- **Node.js 18+**(可选)用于实时开发服务器与 Juice CSS 内联
### 本地预览(静态) ### 本地预览(静态)
1. 先生成预览数据:`cd tools && go run . preview all` 1. 先生成预览数据:`cd tools && go run . preview all`
2. 在浏览器中打开 `preview/index.html` — 无需服务器 2. 在浏览器中打开 `preview/index.html` — 无需服务器
> [!WARNING]
> 静态 Gmail/Outlook 模拟仅供参考,使用 dev 模式可获得相对准确的 CSS 内联渲染。
### 开发服务器(实时重载 + CSS 内联 + 客户端模拟) ### 开发服务器(实时重载)
```bash ```bash
cd tools && go run . dev cd tools && go run . dev
@@ -54,8 +51,6 @@ cd tools && go run . dev
修改 `.tmpl` 文件后自动重建并推送至浏览器。 修改 `.tmpl` 文件后自动重建并推送至浏览器。
> [!NOTE]
> Dev 模拟无法 100% 还原各个邮件客户端的渲染差异,仅供参考,请以实际效果为准。
### 集成测试 ### 集成测试
+1 -6
View File
@@ -35,17 +35,14 @@
## 開發環境 ## 開發環境
- **Go 1.21+** 用於模板渲染與 CLI 工具 - **Go 1.21+** 用於模板渲染與 CLI 工具
- **Node.js 18+**(可選)用於即時開發伺服器與 Juice CSS 內聯
### 本機預覽(靜態) ### 本機預覽(靜態)
1. 先生成預覽資料:`cd tools && go run . preview all` 1. 先生成預覽資料:`cd tools && go run . preview all`
2. 在瀏覽器中開啟 `preview/index.html` — 無需伺服器 2. 在瀏覽器中開啟 `preview/index.html` — 無需伺服器
> [!WARNING]
> 靜態 Gmail/Outlook 模擬僅供參考,使用 dev 模式可獲得相對準確的 CSS 內聯渲染。
### 開發伺服器(即時重載 + CSS 內聯 + 客戶端模擬) ### 開發伺服器(即時重載)
```bash ```bash
cd tools && go run . dev cd tools && go run . dev
@@ -54,8 +51,6 @@ cd tools && go run . dev
修改 `.tmpl` 檔案後自動重建並推送至瀏覽器。 修改 `.tmpl` 檔案後自動重建並推送至瀏覽器。
> [!NOTE]
> Dev 模擬無法 100% 還原各個郵件用戶端的渲染差異,僅供參考,請以實際效果為準。
### 整合測試 ### 整合測試
+2 -5
View File
@@ -49,15 +49,12 @@ cd tools && go run . preview all
``` ```
その後 `preview/index.html` を開く。 その後 `preview/index.html` を開く。
**開発サーバー(ライブリロード + Juice CSSインライン + クライアントシミュレーション):** **開発サーバー(ライブリロード):**
```bash ```bash
cd tools && go run . dev # Node.jsが必要です cd tools && go run . dev
# → http://localhost:3456 # → http://localhost:3456
``` ```
> [!NOTE]
> Dev シミュレーションは各メールクライアントのレンダリングを完全には再現できません — 参考用です。実際のクライアントで必ず確認してください。
## 互換性 ## 互換性
- **Gitea 1.25+**, 100%互換, 組み込み関数のみ - **Gitea 1.25+**, 100%互換, 組み込み関数のみ
+2 -12
View File
@@ -49,19 +49,9 @@ cd tools && go run . preview all
``` ```
그런 다음 `preview/index.html` 열기. 그런 다음 `preview/index.html` 열기.
**개발 서버 (실시간 리로드 + Juice CSS 인라인 + 클라이언트 시뮬레이션):** **개발 서버 (실시간 리로드):**
```bash ```bash
cd tools && go run . dev # Node.js 필요 cd tools && go run . dev
# → http://localhost:3456 # → http://localhost:3456
``` ```
> [!NOTE]
> Dev 시뮬레이션은 각 이메일 클라이언트의 렌더링을 완전히 재현할 수 없습니다 — 참고용입니다. 실제 클라이언트에서 반드시 확인하세요.
## 호환성
- **Gitea 1.25+**, 100% 호환, 내장 함수만 사용
## 라이선스
MIT — [LICENSE](../LICENSE).
+2 -5
View File
@@ -49,15 +49,12 @@ cd tools && go run . preview all
``` ```
Затем откройте `preview/index.html`. Затем откройте `preview/index.html`.
**Dev-сервер (live reload + Juice CSS inline + симуляция клиентов):** **Dev-сервер (live reload):**
```bash ```bash
cd tools && go run . dev # требуется Node.js cd tools && go run . dev
# → http://localhost:3456 # → http://localhost:3456
``` ```
> [!NOTE]
> Dev-симуляция не может на 100% воспроизвести рендеринг каждого почтового клиента — только для справки. Всегда проверяйте на реальных клиентах.
## Совместимость ## Совместимость
- **Gitea 1.25+**, 100% совместимость, только встроенные функции - **Gitea 1.25+**, 100% совместимость, только встроенные функции
+2 -11
View File
@@ -62,26 +62,17 @@ cd tools && go run . preview all
``` ```
然后打开 `preview/index.html`。 然后打开 `preview/index.html`。
> [!WARNING] **开发服务器(实时重载):**
> 静态模式下 Gmail/Outlook 模拟仅供参考,使用 dev 模式可获得相对准确的 CSS 内联渲染。
**开发服务器(实时重载 + Juice CSS 内联 + Gmail/Outlook 模拟):**
```bash ```bash
cd tools && go run . dev # 需要 Node.js cd tools && go run . dev
# → http://localhost:3456 # → http://localhost:3456
``` ```
> [!NOTE]
> Dev 模拟无法 100% 还原各个邮件客户端的渲染差异,仅供参考,请以实际效果为准。
| 功能 | 静态 | Dev | | 功能 | 静态 | Dev |
|-----------|--------|-----| |-----------|--------|-----|
| Go 模板渲染 | ✅ | ✅ | | Go 模板渲染 | ✅ | ✅ |
| 主题/模板切换 | ✅ | ✅ | | 主题/模板切换 | ✅ | ✅ |
| Juice CSS 内联 | — | ✅ |
| Gmail/Outlook CSS 剥离 | — | ✅ |
| 实时重载 | — | ✅ | | 实时重载 | — | ✅ |
| 需要 Node.js | — | ✅ |
--- ---
+2 -4
View File
@@ -62,14 +62,12 @@ cd tools && go run . preview all
``` ```
然後開啟 `preview/index.html`。 然後開啟 `preview/index.html`。
**開發伺服器(即時重載 + Juice CSS 內聯 + 客戶端模擬):** **開發伺服器(即時重載):**
```bash ```bash
cd tools && go run . dev # 需要 Node.js cd tools && go run . dev
# → http://localhost:3456 # → http://localhost:3456
``` ```
> [!NOTE]
> Dev 模擬無法 100% 還原各個郵件用戶端的渲染差異,僅供參考,請以實際效果為準。
--- ---
+16 -73
View File
@@ -126,10 +126,10 @@ html,body,#preview-area,#panel{
<label>Template</label> <label>Template</label>
<select id="sel-template" onchange="switchTemplate(this.value)"></select> <select id="sel-template" onchange="switchTemplate(this.value)"></select>
<span class="sep"></span> <span class="sep"></span>
<label>Client</label> <label>View</label>
<select id="sel-client" onchange="switchClient(this.value)"> <select id="sel-client" onchange="switchClient(this.value)">
<option value="modern">Modern</option><option value="gmail">Gmail</option> <option value="modern">Modern</option>
<option value="outlook">Outlook</option><option value="raw">Raw Source</option> <option value="source">Source</option>
</select> </select>
<span class="sep"></span> <span class="sep"></span>
<div class="btn-group"> <div class="btn-group">
@@ -169,10 +169,6 @@ html,body,#preview-area,#panel{
<h3>Parameters</h3> <h3>Parameters</h3>
<table id="panel-params"><tr><td colspan="2" style="color:var(--muted)">-</td></tr></table> <table id="panel-params"><tr><td colspan="2" style="color:var(--muted)">-</td></tr></table>
</section> </section>
<section>
<h3>Client Simulation</h3>
<div class="indicators" id="panel-indicators"></div>
</section>
<section> <section>
<h3>Source</h3> <h3>Source</h3>
<div class="card" style="font-family:monospace;font-size:10px;color:var(--muted);word-break:break-all" id="panel-path">-</div> <div class="card" style="font-family:monospace;font-size:10px;color:var(--muted);word-break:break-all" id="panel-path">-</div>
@@ -188,35 +184,21 @@ html,body,#preview-area,#panel{
const state = { theme:'', template:'', client:'modern', viewport:'desktop' }; const state = { theme:'', template:'', client:'modern', viewport:'desktop' };
let REGISTRY = {}; let REGISTRY = {};
let PARAMS = {}; let PARAMS = {};
let rendered = null; // window.__RENDERED__ (modern / static fallback) let rendered = null; // window.__RENDERED__
let renderedGmail = null; // window.__RENDERED_GMAIL__ (dev mode, Juice + Gmail strip) let devMode = false; // true once SSE connects
let renderedOutlook = null; // window.__RENDERED_OUTLOOK__ (dev mode, Juice + Outlook strip)
let devMode = false; // true once WebSocket connects
let devCheckTimer = null; // delayed static-notice timer
// //
// Client transforms // Client transforms
// //
function transform(html, mode) { function transform(html, mode) {
// Raw source view — escape HTML for display // Source view — escape HTML for raw display
if(mode==='raw') { if(mode==='source') {
const e = html.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const e = html.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return '<!DOCTYPE html><html><head><style>body{background:#0d1117;color:#c9d1d9;font-family:Consolas,monospace;padding:16px;margin:0}pre{white-space:pre-wrap;word-break:break-all;font-size:11px;line-height:1.6;margin:0}</style></head><body><pre>'+e+'</pre></body></html>'; return '<!DOCTYPE html><html><head><style>body{background:#0d1117;color:#c9d1d9;font-family:Consolas,monospace;padding:16px;margin:0}pre{white-space:pre-wrap;word-break:break-all;font-size:11px;line-height:1.6;margin:0}</style></head><body><pre>'+e+'</pre></body></html>';
} }
// 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; 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 // Render preview
// //
@@ -233,7 +215,7 @@ function render() {
frame.style.display = 'none'; frame.style.display = 'none';
loading.style.display = 'flex'; loading.style.display = 'flex';
const src = getRendered(); const src = rendered;
const raw = src[state.theme]?.[state.template]; const raw = src[state.theme]?.[state.template];
let content = raw ? transform(raw, state.client) : '<p style=\"color:red;padding:20px\">Not found: '+state.theme+'/'+state.template+'</p>'; let content = raw ? transform(raw, state.client) : '<p style=\"color:red;padding:20px\">Not found: '+state.theme+'/'+state.template+'</p>';
// Inject invisible scrollbar into iframe content for immersive preview // Inject invisible scrollbar into iframe content for immersive preview
@@ -282,18 +264,6 @@ function updatePanel() {
rows += '<tr><td>.'+k+'</td><td>'+String(v)+'</td><td><span class="badge '+(isReq?'badge-req':'badge-opt')+'">'+(isReq?'required':'optional')+'</span></td></tr>'; rows += '<tr><td>.'+k+'</td><td>'+String(v)+'</td><td><span class="badge '+(isReq?'badge-req':'badge-opt')+'">'+(isReq?'required':'optional')+'</span></td></tr>';
} }
document.getElementById('panel-params').innerHTML = rows || '<tr><td colspan="3" style="color:var(--muted)">-</td></tr>'; document.getElementById('panel-params').innerHTML = rows || '<tr><td colspan="3" style="color:var(--muted)">-</td></tr>';
// Client simulation indicators
const c=state.client,
on='<span class="dot d-on"></span>',off='<span class="dot d-off"></span>',warn='<span class="dot d-warn"></span>',
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])=>'<span>'+d+' '+label+'</span>').join('');
} }
// //
@@ -350,8 +320,6 @@ document.addEventListener('keydown',e=>{
if(window.__RENDERED__){ if(window.__RENDERED__){
rendered = 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 // Populate theme selector from rendered data
const themeKeys = Object.keys(rendered).sort(); const themeKeys = Object.keys(rendered).sort();
@@ -394,46 +362,28 @@ function clearMessages() {
} }
// Dev-mode detection // Dev-mode detection
// Don't show static warnings if the dev server is running. // The SSE open handler will call setDevMode() which clears any stale messages.
// The WebSocket open handler will call setDevMode() which clears anything stale.
function setDevMode(on) { function setDevMode(on) {
devMode = on; devMode = on;
if (devCheckTimer) { clearTimeout(devCheckTimer); devCheckTimer = null; }
if (on) { if (on) {
clearMessages(); 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( 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' 'info'
).id = 'msg-dev-disclaimer'; ).id = 'msg-dev-disclaimer';
} }
} }
// Static-mode notice (delayed — only shown if no WebSocket within 1 s) // Dev-server live-reload (SSE)
devCheckTimer = setTimeout(function() {
devCheckTimer = null;
if (!devMode) {
showMessage(
'Gmail / Outlook simulation is approximate. For more accurate rendering with CSS inlining, use <code>cd tools && go run . dev</code>',
'warn'
).id = 'msg-static-notice';
}
}, 1000);
// Dev-server live-reload (WebSocket)
(function(){ (function(){
let ws; let es;
try { try {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; es = new EventSource('/events');
ws = new WebSocket(proto + '//' + location.host + '/ws');
} catch(e) { } catch(e) {
return; return;
} }
ws.onmessage = function(e) { es.onmessage = function(e) {
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'reload') { if (msg.type === 'reload') {
@@ -441,8 +391,6 @@ devCheckTimer = setTimeout(function() {
script.src = 'rendered.js?v=' + Date.now(); script.src = 'rendered.js?v=' + Date.now();
script.onload = function() { script.onload = function() {
if (window.__RENDERED__) rendered = window.__RENDERED__; 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.__REGISTRY__) REGISTRY = window.__REGISTRY__;
if (window.__PARAMS__) PARAMS = window.__PARAMS__; if (window.__PARAMS__) PARAMS = window.__PARAMS__;
render(); render();
@@ -453,22 +401,17 @@ devCheckTimer = setTimeout(function() {
} }
} catch(_) {} } catch(_) {}
}; };
ws.onopen = function() { es.onopen = function() {
setDevMode(true); setDevMode(true);
console.log('[dev] live-reload connected'); console.log('[dev] live-reload connected');
}; };
ws.onclose = function() { es.onerror = function() {
// Only alert if the server was previously connected (crash / shutdown)
if (devMode) { if (devMode) {
showMessage('Dev server disconnected — reload the page to reconnect', 'error'); showMessage('Dev server disconnected — reload the page to reconnect', 'error');
setDevMode(false); setDevMode(false);
} }
console.log('[dev] live-reload disconnected'); 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)');
};
})(); })();
</script> </script>
</body> </body>
+31 -56
View File
@@ -3,20 +3,22 @@ package cli
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"syscall" "syscall"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/preview"
) )
// DevCommand returns the "dev" subcommand. // DevCommand returns the "dev" subcommand.
func DevCommand() *cli.Command { func DevCommand() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "dev", Name: "dev",
Usage: "Start a live-reload dev server with CSS inlining (requires Node.js)", Usage: "Start a live-reload dev server with SSE browser refresh",
UsageText: `go run . dev [--port <port>]`, UsageText: "go run . dev [--port <port>]",
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.IntFlag{ &cli.IntFlag{
Name: "port", Name: "port",
@@ -31,72 +33,45 @@ func DevCommand() *cli.Command {
func runDev(c *cli.Context) error { func runDev(c *cli.Context) error {
port := c.Int("port") port := c.Int("port")
// Find the server directory relative to the tools/ dir // Resolve project paths relative to the tools/ directory
exe, _ := os.Executable() toolsDir, err := os.Getwd()
toolsDir := filepath.Dir(exe) if err != nil {
// When run with `go run`, resolve relative to CWD toolsDir = "."
if _, err := os.Stat(filepath.Join(toolsDir, "server")); os.IsNotExist(err) {
cwd, _ := os.Getwd()
toolsDir = cwd
} }
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 // Verify paths exist
if _, err := exec.LookPath("node"); err != nil { for _, p := range []string{themesDir, previewDir, configPath} {
return fmt.Errorf("Node.js is required but not found in PATH") if _, err := os.Stat(p); os.IsNotExist(err) {
} return fmt.Errorf("required path not found: %s", p)
// 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)
} }
} }
// Ensure rendered.js exists before starting // Load template config
initCmd := exec.Command("go", "run", ".", "preview", "all") cfg, err := config.Load(configPath)
initCmd.Dir = toolsDir if err != nil {
initCmd.Stdout = os.Stdout return fmt.Errorf("failed to load config: %w", err)
initCmd.Stderr = os.Stderr
if err := initCmd.Run(); err != nil {
fmt.Println("\033[33m[W]\033[0m [Builder] Initial preview generation failed, starting anyway")
} }
// Start the Node.js dev server // Start dev server
serverCmd := exec.Command("node", "server.mjs") srv := preview.NewDevServer(preview.DevServerConfig{
serverCmd.Dir = serverDir Port: port,
serverCmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port)) ThemesDir: themesDir,
serverCmd.Stdout = os.Stdout PreviewDir: previewDir,
serverCmd.Stderr = os.Stderr Config: cfg,
})
if err := serverCmd.Start(); err != nil { // Handle Ctrl+C
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
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
<-sigCh <-sigCh
fmt.Println("\033[32m[I]\033[0m [Server] Shutting down") fmt.Println("\n\033[32m[I]\033[0m [Server] Shutting down")
if err := serverCmd.Process.Signal(os.Interrupt); err != nil {
serverCmd.Process.Kill()
}
os.Exit(0) os.Exit(0)
}() }()
if err := serverCmd.Wait(); err != nil { return srv.Start()
return fmt.Errorf("dev server exited: %w", err)
}
return nil
} }
+2 -1
View File
@@ -2,9 +2,10 @@ module gitea-mail-templates/tools
go 1.21 go 1.21
require github.com/urfave/cli/v2 v2.27.7
require ( require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // 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 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`);
});