refactor(preview): remove juice/Node.js, simplify to pure Go dev server with SSE
- Delete tools/server/ (Node.js, juice, inliner.mjs, server.mjs, node_modules) - Create tools/preview/server.go — pure Go dev server with SSE + file polling - Rewrite tools/cli/dev.go — start Go server in-process, no Node.js required - Simplify preview/index.html — View dropdown (Modern/Source), remove Client Simulation panel, remove Gmail/Outlook logic, WebSocket → SSE - Update all 14 documentation files (README, CONTRIBUTING, AGENTS + translations) to reflect pure Go dev server and simplified preview Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
d4c6eb99af
commit
0ad7db124a
20 files changed
+319
-633
No files matched your search
@@ -48,12 +48,11 @@ docs/ # Multi-language documentation
|
||||
|
||||
### Preview System
|
||||
- `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)
|
||||
- Keyboard navigation: `←→` cycles focus between Theme/Template/Client selects, `↑↓` selects within the focused dropdown, `d`/`m` toggles viewport
|
||||
- Supports theme/template switching, view mode (Modern/Source), and viewport toggle (Desktop 1386x780 / Mobile 390x780)
|
||||
- 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
|
||||
- Static preview (open `index.html` directly) — all client modes show the same HTML; simulation warning is displayed
|
||||
- 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 mode shows an info notice: simulation cannot 100% reproduce every email client — always verify against real clients
|
||||
- Static preview (open `index.html` directly) — works via `file://` protocol with Modern and Source views
|
||||
- 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
|
||||
|
||||
### Build Tool
|
||||
- `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/preview/` implements the rendering engine (template funcs, locale, engine, markSafeHTML)
|
||||
- `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
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
+6
-14
@@ -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)
|
||||
- **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
|
||||
- Test against major email clients (Gmail, Outlook, Apple Mail) when possible
|
||||
- Test against major email clients (Apple Mail, Gmail, Outlook) when possible
|
||||
|
||||
### Bug Reports
|
||||
|
||||
@@ -41,18 +41,14 @@ Documentation updates, preview screenshots, installation guides, and translation
|
||||
## Development Setup
|
||||
|
||||
- **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)
|
||||
|
||||
1. Run `cd tools && go run . preview all` to generate rendered data
|
||||
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]
|
||||
> Static Gmail/Outlook simulation is approximate. Use dev mode for relatively accurate rendering.
|
||||
|
||||
### Dev Server (Live Reload + CSS Inlining + Client Simulation)
|
||||
### Dev Server (Live Reload)
|
||||
|
||||
```bash
|
||||
cd tools && go run . dev
|
||||
@@ -60,13 +56,9 @@ cd tools && go run . dev
|
||||
```
|
||||
|
||||
- Watches `themes/**/*.tmpl` — auto-rebuilds on save
|
||||
- Runs [Juice](https://github.com/Automattic/juice) to inline `<style>` into `style=""` attributes
|
||||
- Generates three `rendered.js` variants: modern (Juice only), Gmail (Juice + CSS strip), Outlook (Juice + aggressive CSS strip)
|
||||
- Pushes live reload to browser via WebSocket
|
||||
- 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.
|
||||
- Pure Go HTTP server with SSE push — no external dependencies
|
||||
- Re-renders templates in-process and pushes reload events to the browser
|
||||
- Terminal output: `themes/aurora/mail/repo/release.tmpl changed` → `[Builder] Rebuild done in 45ms`
|
||||
|
||||
### Integration Testing
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
[**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
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Gmail/Outlook simulation in static mode is approximate. Use dev mode for relatively accurate CSS inlining.
|
||||
### Dev Server (Live Reload)
|
||||
|
||||
### Dev Server (Live Reload + Juice CSS Inlining)
|
||||
|
||||
Start a development server that watches for `.tmpl` changes, auto-rebuilds, inlines CSS for email client compatibility, and pushes live updates to the browser:
|
||||
Start a pure Go development server that watches for `.tmpl` changes, auto-rebuilds, and pushes live updates to the browser via SSE:
|
||||
|
||||
```bash
|
||||
cd tools && go run . dev # requires Node.js
|
||||
cd tools && go run . dev
|
||||
open http://localhost:3456
|
||||
```
|
||||
|
||||
@@ -107,22 +104,16 @@ open http://localhost:3456
|
||||
|-----------|--------|-----|
|
||||
| Go template rendering | ✅ | ✅ |
|
||||
| Theme/template switching | ✅ | ✅ |
|
||||
| Juice CSS inlining | — | ✅ |
|
||||
| Gmail/Outlook CSS stripping | — | ✅ |
|
||||
| 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
|
||||
|
||||
- Theme switcher — browse all 10 visual styles
|
||||
- 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
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,17 +31,14 @@
|
||||
## 開発セットアップ
|
||||
|
||||
- **Go 1.21+** テンプレートレンダリングとCLI用
|
||||
- **Node.js 18+**(任意)開発サーバーとJuice CSSインライン用
|
||||
|
||||
### ローカルプレビュー(静的)
|
||||
|
||||
1. データを生成: `cd tools && go run . preview all`
|
||||
2. `preview/index.html` をブラウザで開く
|
||||
|
||||
> [!WARNING]
|
||||
> 静的Gmail/Outlookシミュレーションは参考用です。比較的正確なレンダリングにはdevモードを使用してください。
|
||||
|
||||
### 開発サーバー(ライブリロード + CSSインライン + クライアントシミュレーション)
|
||||
### 開発サーバー(ライブリロード)
|
||||
|
||||
```bash
|
||||
cd tools && go run . dev
|
||||
@@ -50,8 +47,6 @@ cd tools && go run . dev
|
||||
|
||||
`.tmpl` ファイルを編集すると自動的に再構築されブラウザに反映されます。
|
||||
|
||||
> [!NOTE]
|
||||
> Dev シミュレーションは各メールクライアントのレンダリングを完全には再現できません — 参考用です。実際のクライアントで必ず確認してください。
|
||||
|
||||
### 結合テスト
|
||||
|
||||
|
||||
+1
-33
@@ -31,17 +31,14 @@
|
||||
## 개발 설정
|
||||
|
||||
- **Go 1.21+** 템플릿 렌더링 및 CLI 도구
|
||||
- **Node.js 18+** (선택) 개발 서버 및 Juice CSS 인라인
|
||||
|
||||
### 로컬 프리뷰 (정적)
|
||||
|
||||
1. 데이터 생성: `cd tools && go run . preview all`
|
||||
2. `preview/index.html`을 브라우저에서 열기
|
||||
|
||||
> [!WARNING]
|
||||
> 정적 Gmail/Outlook 시뮬레이션은 참고용입니다. 비교적 정확한 렌더링은 dev 모드를 사용하세요.
|
||||
|
||||
### 개발 서버 (실시간 리로드 + CSS 인라인 + 클라이언트 시뮬레이션)
|
||||
### 개발 서버 (실시간 리로드)
|
||||
|
||||
```bash
|
||||
cd tools && go run . dev
|
||||
@@ -50,32 +47,3 @@ cd tools && go run . dev
|
||||
|
||||
`.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 라이선스에 동의하게 됩니다.
|
||||
@@ -31,17 +31,14 @@
|
||||
## Настройка разработки
|
||||
|
||||
- **Go 1.21+** для рендеринга шаблонов и CLI
|
||||
- **Node.js 18+** (опционально) для сервера разработки с Juice CSS
|
||||
|
||||
### Локальный предпросмотр (статический)
|
||||
|
||||
1. Сгенерируйте данные: `cd tools && go run . preview all`
|
||||
2. Откройте `preview/index.html` в браузере — сервер не нужен
|
||||
|
||||
> [!WARNING]
|
||||
> Статическая симуляция Gmail/Outlook приблизительна. Используйте dev-режим для относительно точного рендеринга.
|
||||
|
||||
### Сервер разработки (live reload + CSS инлайн + симуляция клиентов)
|
||||
### Сервер разработки (live reload)
|
||||
|
||||
```bash
|
||||
cd tools && go run . dev
|
||||
@@ -50,8 +47,6 @@ cd tools && go run . dev
|
||||
|
||||
При изменении `.tmpl` файлов автоматически пересобирает и обновляет браузер.
|
||||
|
||||
> [!NOTE]
|
||||
> Dev-симуляция не может на 100% воспроизвести рендеринг каждого почтового клиента — только для справки. Всегда проверяйте на реальных клиентах.
|
||||
|
||||
### Интеграционное тестирование
|
||||
|
||||
|
||||
@@ -35,17 +35,14 @@
|
||||
## 开发环境
|
||||
|
||||
- **Go 1.21+** 用于模板渲染和 CLI 工具
|
||||
- **Node.js 18+**(可选)用于实时开发服务器与 Juice CSS 内联
|
||||
|
||||
### 本地预览(静态)
|
||||
|
||||
1. 先生成预览数据:`cd tools && go run . preview all`
|
||||
2. 在浏览器中打开 `preview/index.html` — 无需服务器
|
||||
|
||||
> [!WARNING]
|
||||
> 静态 Gmail/Outlook 模拟仅供参考,使用 dev 模式可获得相对准确的 CSS 内联渲染。
|
||||
|
||||
### 开发服务器(实时重载 + CSS 内联 + 客户端模拟)
|
||||
### 开发服务器(实时重载)
|
||||
|
||||
```bash
|
||||
cd tools && go run . dev
|
||||
@@ -54,8 +51,6 @@ cd tools && go run . dev
|
||||
|
||||
修改 `.tmpl` 文件后自动重建并推送至浏览器。
|
||||
|
||||
> [!NOTE]
|
||||
> Dev 模拟无法 100% 还原各个邮件客户端的渲染差异,仅供参考,请以实际效果为准。
|
||||
|
||||
### 集成测试
|
||||
|
||||
|
||||
@@ -35,17 +35,14 @@
|
||||
## 開發環境
|
||||
|
||||
- **Go 1.21+** 用於模板渲染與 CLI 工具
|
||||
- **Node.js 18+**(可選)用於即時開發伺服器與 Juice CSS 內聯
|
||||
|
||||
### 本機預覽(靜態)
|
||||
|
||||
1. 先生成預覽資料:`cd tools && go run . preview all`
|
||||
2. 在瀏覽器中開啟 `preview/index.html` — 無需伺服器
|
||||
|
||||
> [!WARNING]
|
||||
> 靜態 Gmail/Outlook 模擬僅供參考,使用 dev 模式可獲得相對準確的 CSS 內聯渲染。
|
||||
|
||||
### 開發伺服器(即時重載 + CSS 內聯 + 客戶端模擬)
|
||||
### 開發伺服器(即時重載)
|
||||
|
||||
```bash
|
||||
cd tools && go run . dev
|
||||
@@ -54,8 +51,6 @@ cd tools && go run . dev
|
||||
|
||||
修改 `.tmpl` 檔案後自動重建並推送至瀏覽器。
|
||||
|
||||
> [!NOTE]
|
||||
> Dev 模擬無法 100% 還原各個郵件用戶端的渲染差異,僅供參考,請以實際效果為準。
|
||||
|
||||
### 整合測試
|
||||
|
||||
|
||||
+2
-5
@@ -49,15 +49,12 @@ cd tools && go run . preview all
|
||||
```
|
||||
その後 `preview/index.html` を開く。
|
||||
|
||||
**開発サーバー(ライブリロード + Juice CSSインライン + クライアントシミュレーション):**
|
||||
**開発サーバー(ライブリロード):**
|
||||
```bash
|
||||
cd tools && go run . dev # Node.jsが必要です
|
||||
cd tools && go run . dev
|
||||
# → http://localhost:3456
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Dev シミュレーションは各メールクライアントのレンダリングを完全には再現できません — 参考用です。実際のクライアントで必ず確認してください。
|
||||
|
||||
## 互換性
|
||||
|
||||
- **Gitea 1.25+**, 100%互換, 組み込み関数のみ
|
||||
|
||||
+2
-12
@@ -49,19 +49,9 @@ cd tools && go run . preview all
|
||||
```
|
||||
그런 다음 `preview/index.html` 열기.
|
||||
|
||||
**개발 서버 (실시간 리로드 + Juice CSS 인라인 + 클라이언트 시뮬레이션):**
|
||||
**개발 서버 (실시간 리로드):**
|
||||
```bash
|
||||
cd tools && go run . dev # Node.js 필요
|
||||
cd tools && go run . dev
|
||||
# → http://localhost:3456
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Dev 시뮬레이션은 각 이메일 클라이언트의 렌더링을 완전히 재현할 수 없습니다 — 참고용입니다. 실제 클라이언트에서 반드시 확인하세요.
|
||||
|
||||
## 호환성
|
||||
|
||||
- **Gitea 1.25+**, 100% 호환, 내장 함수만 사용
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT — [LICENSE](../LICENSE).
|
||||
+2
-5
@@ -49,15 +49,12 @@ cd tools && go run . preview all
|
||||
```
|
||||
Затем откройте `preview/index.html`.
|
||||
|
||||
**Dev-сервер (live reload + Juice CSS inline + симуляция клиентов):**
|
||||
**Dev-сервер (live reload):**
|
||||
```bash
|
||||
cd tools && go run . dev # требуется Node.js
|
||||
cd tools && go run . dev
|
||||
# → http://localhost:3456
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Dev-симуляция не может на 100% воспроизвести рендеринг каждого почтового клиента — только для справки. Всегда проверяйте на реальных клиентах.
|
||||
|
||||
## Совместимость
|
||||
|
||||
- **Gitea 1.25+**, 100% совместимость, только встроенные функции
|
||||
|
||||
+2
-11
@@ -62,26 +62,17 @@ cd tools && go run . preview all
|
||||
```
|
||||
然后打开 `preview/index.html`。
|
||||
|
||||
> [!WARNING]
|
||||
> 静态模式下 Gmail/Outlook 模拟仅供参考,使用 dev 模式可获得相对准确的 CSS 内联渲染。
|
||||
|
||||
**开发服务器(实时重载 + Juice CSS 内联 + Gmail/Outlook 模拟):**
|
||||
**开发服务器(实时重载):**
|
||||
```bash
|
||||
cd tools && go run . dev # 需要 Node.js
|
||||
cd tools && go run . dev
|
||||
# → http://localhost:3456
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Dev 模拟无法 100% 还原各个邮件客户端的渲染差异,仅供参考,请以实际效果为准。
|
||||
|
||||
| 功能 | 静态 | Dev |
|
||||
|-----------|--------|-----|
|
||||
| Go 模板渲染 | ✅ | ✅ |
|
||||
| 主题/模板切换 | ✅ | ✅ |
|
||||
| Juice CSS 内联 | — | ✅ |
|
||||
| Gmail/Outlook CSS 剥离 | — | ✅ |
|
||||
| 实时重载 | — | ✅ |
|
||||
| 需要 Node.js | — | ✅ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -62,14 +62,12 @@ cd tools && go run . preview all
|
||||
```
|
||||
然後開啟 `preview/index.html`。
|
||||
|
||||
**開發伺服器(即時重載 + Juice CSS 內聯 + 客戶端模擬):**
|
||||
**開發伺服器(即時重載):**
|
||||
```bash
|
||||
cd tools && go run . dev # 需要 Node.js
|
||||
cd tools && go run . dev
|
||||
# → http://localhost:3456
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Dev 模擬無法 100% 還原各個郵件用戶端的渲染差異,僅供參考,請以實際效果為準。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+19
-76
@@ -126,10 +126,10 @@ html,body,#preview-area,#panel{
|
||||
<label>Template</label>
|
||||
<select id="sel-template" onchange="switchTemplate(this.value)"></select>
|
||||
<span class="sep"></span>
|
||||
<label>Client</label>
|
||||
<label>View</label>
|
||||
<select id="sel-client" onchange="switchClient(this.value)">
|
||||
<option value="modern">Modern</option><option value="gmail">Gmail</option>
|
||||
<option value="outlook">Outlook</option><option value="raw">Raw Source</option>
|
||||
<option value="modern">Modern</option>
|
||||
<option value="source">Source</option>
|
||||
</select>
|
||||
<span class="sep"></span>
|
||||
<div class="btn-group">
|
||||
@@ -169,10 +169,6 @@ html,body,#preview-area,#panel{
|
||||
<h3>Parameters</h3>
|
||||
<table id="panel-params"><tr><td colspan="2" style="color:var(--muted)">-</td></tr></table>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Client Simulation</h3>
|
||||
<div class="indicators" id="panel-indicators"></div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Source</h3>
|
||||
<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' };
|
||||
let REGISTRY = {};
|
||||
let PARAMS = {};
|
||||
let rendered = null; // window.__RENDERED__ (modern / static fallback)
|
||||
let renderedGmail = null; // window.__RENDERED_GMAIL__ (dev mode, Juice + Gmail strip)
|
||||
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
|
||||
let rendered = null; // window.__RENDERED__
|
||||
let devMode = false; // true once SSE connects
|
||||
|
||||
//
|
||||
// Client transforms
|
||||
//
|
||||
function transform(html, mode) {
|
||||
// Raw source view — escape HTML for display
|
||||
if(mode==='raw') {
|
||||
// Source view — escape HTML for raw display
|
||||
if(mode==='source') {
|
||||
const e = html.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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
|
||||
//
|
||||
@@ -233,7 +215,7 @@ function render() {
|
||||
frame.style.display = 'none';
|
||||
loading.style.display = 'flex';
|
||||
|
||||
const src = getRendered();
|
||||
const src = rendered;
|
||||
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>';
|
||||
// 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>';
|
||||
}
|
||||
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__){
|
||||
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
|
||||
const themeKeys = Object.keys(rendered).sort();
|
||||
@@ -394,57 +362,37 @@ function clearMessages() {
|
||||
}
|
||||
|
||||
// Dev-mode detection
|
||||
// Don't show static warnings if the dev server is running.
|
||||
// The WebSocket open handler will call setDevMode() which clears anything stale.
|
||||
// The SSE open handler will call setDevMode() which clears any stale messages.
|
||||
|
||||
function setDevMode(on) {
|
||||
devMode = on;
|
||||
if (devCheckTimer) { clearTimeout(devCheckTimer); devCheckTimer = null; }
|
||||
if (on) {
|
||||
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(
|
||||
'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'
|
||||
).id = 'msg-dev-disclaimer';
|
||||
}
|
||||
}
|
||||
|
||||
// Static-mode notice (delayed — only shown if no WebSocket within 1 s)
|
||||
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)
|
||||
// Dev-server live-reload (SSE)
|
||||
(function(){
|
||||
let ws;
|
||||
let es;
|
||||
try {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(proto + '//' + location.host + '/ws');
|
||||
es = new EventSource('/events');
|
||||
} catch(e) {
|
||||
return;
|
||||
}
|
||||
ws.onmessage = function(e) {
|
||||
es.onmessage = function(e) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'reload') {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'rendered.js?v=' + Date.now();
|
||||
script.onload = function() {
|
||||
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.__PARAMS__) PARAMS = window.__PARAMS__;
|
||||
if (window.__RENDERED__) rendered = window.__RENDERED__;
|
||||
if (window.__REGISTRY__) REGISTRY = window.__REGISTRY__;
|
||||
if (window.__PARAMS__) PARAMS = window.__PARAMS__;
|
||||
render();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
@@ -453,22 +401,17 @@ devCheckTimer = setTimeout(function() {
|
||||
}
|
||||
} catch(_) {}
|
||||
};
|
||||
ws.onopen = function() {
|
||||
es.onopen = function() {
|
||||
setDevMode(true);
|
||||
console.log('[dev] live-reload connected');
|
||||
};
|
||||
ws.onclose = function() {
|
||||
// Only alert if the server was previously connected (crash / shutdown)
|
||||
es.onerror = function() {
|
||||
if (devMode) {
|
||||
showMessage('Dev server disconnected — reload the page to reconnect', 'error');
|
||||
setDevMode(false);
|
||||
}
|
||||
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>
|
||||
</body>
|
||||
|
||||
+32
-57
@@ -3,20 +3,22 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"gitea-mail-templates/tools/config"
|
||||
"gitea-mail-templates/tools/preview"
|
||||
)
|
||||
|
||||
// DevCommand returns the "dev" subcommand.
|
||||
func DevCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "dev",
|
||||
Usage: "Start a live-reload dev server with CSS inlining (requires Node.js)",
|
||||
UsageText: `go run . dev [--port <port>]`,
|
||||
Name: "dev",
|
||||
Usage: "Start a live-reload dev server with SSE browser refresh",
|
||||
UsageText: "go run . dev [--port <port>]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
@@ -31,72 +33,45 @@ func DevCommand() *cli.Command {
|
||||
func runDev(c *cli.Context) error {
|
||||
port := c.Int("port")
|
||||
|
||||
// Find the server directory relative to the tools/ dir
|
||||
exe, _ := os.Executable()
|
||||
toolsDir := filepath.Dir(exe)
|
||||
// When run with `go run`, resolve relative to CWD
|
||||
if _, err := os.Stat(filepath.Join(toolsDir, "server")); os.IsNotExist(err) {
|
||||
cwd, _ := os.Getwd()
|
||||
toolsDir = cwd
|
||||
// Resolve project paths relative to the tools/ directory
|
||||
toolsDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
toolsDir = "."
|
||||
}
|
||||
serverDir := filepath.Join(toolsDir, "server")
|
||||
projectRoot := filepath.Dir(toolsDir)
|
||||
themesDir := filepath.Join(projectRoot, "themes")
|
||||
previewDir := filepath.Join(projectRoot, "preview")
|
||||
configPath := filepath.Join(toolsDir, "data", "templates_config.json")
|
||||
|
||||
// Verify Node.js is available
|
||||
if _, err := exec.LookPath("node"); err != nil {
|
||||
return fmt.Errorf("Node.js is required but not found in PATH")
|
||||
}
|
||||
|
||||
// Verify node_modules are installed
|
||||
if _, err := os.Stat(filepath.Join(serverDir, "node_modules")); os.IsNotExist(err) {
|
||||
fmt.Println("\033[32m[I]\033[0m [Builder] Installing Node.js dependencies...")
|
||||
cmd := exec.Command("npm", "install")
|
||||
cmd.Dir = serverDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("npm install failed: %w", err)
|
||||
// Verify paths exist
|
||||
for _, p := range []string{themesDir, previewDir, configPath} {
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
return fmt.Errorf("required path not found: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure rendered.js exists before starting
|
||||
initCmd := exec.Command("go", "run", ".", "preview", "all")
|
||||
initCmd.Dir = toolsDir
|
||||
initCmd.Stdout = os.Stdout
|
||||
initCmd.Stderr = os.Stderr
|
||||
if err := initCmd.Run(); err != nil {
|
||||
fmt.Println("\033[33m[W]\033[0m [Builder] Initial preview generation failed, starting anyway")
|
||||
// Load template config
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Start the Node.js dev server
|
||||
serverCmd := exec.Command("node", "server.mjs")
|
||||
serverCmd.Dir = serverDir
|
||||
serverCmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port))
|
||||
serverCmd.Stdout = os.Stdout
|
||||
serverCmd.Stderr = os.Stderr
|
||||
// Start dev server
|
||||
srv := preview.NewDevServer(preview.DevServerConfig{
|
||||
Port: port,
|
||||
ThemesDir: themesDir,
|
||||
PreviewDir: previewDir,
|
||||
Config: cfg,
|
||||
})
|
||||
|
||||
if err := serverCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start dev server: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] Dev server running at http://localhost:%d\n", port)
|
||||
fmt.Print("\033[32m[I]\033[0m [Server] Press Ctrl+C to stop\n")
|
||||
|
||||
// Handle graceful shutdown
|
||||
// Handle Ctrl+C
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
fmt.Println("\033[32m[I]\033[0m [Server] Shutting down")
|
||||
if err := serverCmd.Process.Signal(os.Interrupt); err != nil {
|
||||
serverCmd.Process.Kill()
|
||||
}
|
||||
fmt.Println("\n\033[32m[I]\033[0m [Server] Shutting down")
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
if err := serverCmd.Wait(); err != nil {
|
||||
return fmt.Errorf("dev server exited: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return srv.Start()
|
||||
}
|
||||
+2
-1
@@ -2,9 +2,10 @@ module gitea-mail-templates/tools
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/urfave/cli/v2 v2.27.7
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea-mail-templates/tools/config"
|
||||
)
|
||||
|
||||
// DevServerConfig holds configuration for the dev server.
|
||||
type DevServerConfig struct {
|
||||
Port int
|
||||
ThemesDir string
|
||||
PreviewDir string
|
||||
Config *config.TemplatesConfig
|
||||
}
|
||||
|
||||
// DevServer is a live-reload dev server with SSE-based browser notifications.
|
||||
// It serves preview/ as static files, watches themes/ for .tmpl changes,
|
||||
// re-renders templates in-process, and pushes reload events to connected browsers.
|
||||
type DevServer struct {
|
||||
cfg DevServerConfig
|
||||
clients map[chan string]struct{}
|
||||
mu sync.Mutex
|
||||
lastMods map[string]time.Time
|
||||
modsMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewDevServer creates a new dev server instance.
|
||||
func NewDevServer(cfg DevServerConfig) *DevServer {
|
||||
return &DevServer{
|
||||
cfg: cfg,
|
||||
clients: make(map[chan string]struct{}),
|
||||
lastMods: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Start initializes the rendered.js, begins file watching, and serves HTTP.
|
||||
// Blocks until the server stops.
|
||||
func (s *DevServer) Start() error {
|
||||
// Build initial rendered.js
|
||||
if err := s.rebuildAll(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[33m[W]\033[0m [Builder] Initial render warning: %v\n", err)
|
||||
}
|
||||
|
||||
// Start file watcher in background
|
||||
go s.watchThemes()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// SSE endpoint — registered before static file handler
|
||||
mux.HandleFunc("/events", s.handleSSE)
|
||||
|
||||
// Static file server for preview/ directory
|
||||
previewFS := http.FileServer(http.Dir(s.cfg.PreviewDir))
|
||||
mux.Handle("/", previewFS)
|
||||
|
||||
addr := fmt.Sprintf(":%d", s.cfg.Port)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] Gitea Mail Templates — Dev Server\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] http://localhost%s\n", addr)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching themes/ for changes\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint ws://localhost%s/events\n\n", addr)
|
||||
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
// --- SSE ---
|
||||
|
||||
func (s *DevServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
ch := make(chan string, 16)
|
||||
s.mu.Lock()
|
||||
s.clients[ch] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.clients, ch)
|
||||
s.mu.Unlock()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case msg := <-ch:
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast sends a JSON message to all connected SSE clients.
|
||||
func (s *DevServer) broadcast(typ string, payload map[string]interface{}) {
|
||||
msg := map[string]interface{}{"type": typ}
|
||||
for k, v := range payload {
|
||||
msg[k] = v
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for ch := range s.clients {
|
||||
select {
|
||||
case ch <- string(data):
|
||||
default:
|
||||
// client buffer full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- File watcher (polling) ---
|
||||
|
||||
// scanMods scans themes/ for .tmpl files and returns a map of path → mod time.
|
||||
func (s *DevServer) scanMods() (map[string]time.Time, error) {
|
||||
mods := make(map[string]time.Time)
|
||||
err := filepath.Walk(s.cfg.ThemesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".tmpl") {
|
||||
mods[path] = info.ModTime()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return mods, err
|
||||
}
|
||||
|
||||
// watchThemes polls the themes directory every 500ms and triggers rebuild on changes.
|
||||
func (s *DevServer) watchThemes() {
|
||||
// Seed initial state
|
||||
if mods, err := s.scanMods(); err == nil {
|
||||
s.modsMu.Lock()
|
||||
s.lastMods = mods
|
||||
s.modsMu.Unlock()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
current, err := s.scanMods()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
changed := false
|
||||
s.modsMu.Lock()
|
||||
for path, mod := range current {
|
||||
last, ok := s.lastMods[path]
|
||||
if !ok || mod.After(last) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// Also detect deletions
|
||||
for path := range s.lastMods {
|
||||
if _, ok := current[path]; !ok {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
// Log which files changed
|
||||
for path, mod := range current {
|
||||
last, ok := s.lastMods[path]
|
||||
if !ok || mod.After(last) {
|
||||
rel, _ := filepath.Rel(s.cfg.ThemesDir, path)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] %s changed\n", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
for path := range s.lastMods {
|
||||
if _, ok := current[path]; !ok {
|
||||
rel, _ := filepath.Rel(s.cfg.ThemesDir, path)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] %s deleted\n", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
s.lastMods = current
|
||||
s.modsMu.Unlock()
|
||||
s.rebuildAll()
|
||||
} else {
|
||||
s.modsMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rebuild ---
|
||||
|
||||
var rebuildMu sync.Mutex
|
||||
|
||||
func (s *DevServer) rebuildAll() error {
|
||||
rebuildMu.Lock()
|
||||
defer rebuildMu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuilding all themes...\n")
|
||||
|
||||
result := RenderAll(s.cfg.ThemesDir, s.cfg.Config, nil)
|
||||
if result == nil {
|
||||
err := fmt.Errorf("no results produced")
|
||||
s.broadcast("error", map[string]interface{}{"message": err.Error()})
|
||||
return err
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(s.cfg.PreviewDir, "rendered.js")
|
||||
if err := WriteRenderedJS(result, outputPath); err != nil {
|
||||
s.broadcast("error", map[string]interface{}{"message": err.Error()})
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuild done in %v\n", elapsed.Round(time.Millisecond))
|
||||
|
||||
s.broadcast("reload", map[string]interface{}{"elapsed": elapsed.Milliseconds()})
|
||||
return nil
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import juice from 'juice';
|
||||
|
||||
/** Juice CSS inlining options optimized for HTML email. */
|
||||
const JUICE_OPTIONS = {
|
||||
removeStyleTags: false, // keep <style> for modern clients that support it
|
||||
preserveMediaQueries: true,
|
||||
preserveFontFaces: true,
|
||||
applyWidthAttributes: true,
|
||||
applyHeightAttributes: true,
|
||||
applyAttributesTableElements: true,
|
||||
xmlMode: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline CSS from <style> blocks and <link> tags into inline style attributes.
|
||||
* Produces HTML that renders consistently across Gmail, Outlook, Apple Mail.
|
||||
*/
|
||||
export function inlineCSS(html) {
|
||||
return juice(html, JUICE_OPTIONS);
|
||||
}
|
||||
|
||||
// Gmail / Outlook CSS stripping
|
||||
|
||||
const GMAIL_UNSUPPORTED = [
|
||||
'border-radius','box-shadow','text-shadow',
|
||||
'backdrop-filter','filter',
|
||||
'animation','transition','transform',
|
||||
'background-image\\s*:\\s*(linear|radial|conic)-gradient',
|
||||
];
|
||||
const OUTLOOK_UNSUPPORTED = GMAIL_UNSUPPORTED.concat([
|
||||
'display\\s*:\\s*(flex|grid|inline-flex|inline-grid)',
|
||||
'flex[^;]*','grid[^;]*','gap\\s*:','order\\s*:',
|
||||
]);
|
||||
|
||||
function stripDeclarations(styleValue, patterns) {
|
||||
let v = styleValue;
|
||||
for (const p of patterns) {
|
||||
v = v.replace(new RegExp('(^|;\\s*)' + p + '[^;]*(;|$)', 'gi'), (m) => m.endsWith(';') ? ';' : '');
|
||||
v = v.replace(new RegExp(p + '[^;]*;', 'gi'), '');
|
||||
}
|
||||
return v.replace(/;;+/g, ';').replace(/^\s*;\s*/, '').replace(/\s*;\s*$/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip CSS properties unsupported by Gmail from inline styles and <style> blocks.
|
||||
*/
|
||||
export function stripGmail(html) {
|
||||
return stripClientCSS(html, GMAIL_UNSUPPORTED, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip CSS properties unsupported by Outlook (more aggressive than Gmail).
|
||||
*/
|
||||
export function stripOutlook(html) {
|
||||
return stripClientCSS(html, OUTLOOK_UNSUPPORTED, true);
|
||||
}
|
||||
|
||||
function stripClientCSS(html, patterns, isOutlook) {
|
||||
let result = html;
|
||||
|
||||
// 1. Strip from inline style="..." attributes
|
||||
result = result.replace(/style="([^"]*)"/gi, (_, styles) => {
|
||||
const cleaned = stripDeclarations(styles, patterns);
|
||||
return cleaned ? 'style="' + cleaned + '"' : '';
|
||||
});
|
||||
|
||||
// 2. Strip from <style> blocks
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, cssContent) => {
|
||||
let cleaned = cssContent;
|
||||
for (const p of patterns) {
|
||||
const re = new RegExp(p + '[^;{}]*[;}]?', 'gi');
|
||||
cleaned = cleaned.replace(re, (m) => m.endsWith('}') ? '}' : '');
|
||||
}
|
||||
return '<style>' + cleaned + '</style>';
|
||||
});
|
||||
|
||||
// 3. Outlook: replace CSS custom properties with initial
|
||||
if (isOutlook) {
|
||||
result = result.replace(/style="([^"]*)"/gi,
|
||||
(_, s) => 'style="' + s.replace(/var\(--[^)]*\)/g, 'initial') + '"');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply juice inlining to a rendered.js-style HTML snippet and return the
|
||||
* result. Handles escaped HTML (JSON-stringified) by first unescaping,
|
||||
* inlining, then re-escaping for storage in rendered.js.
|
||||
*/
|
||||
export function inlineRenderedHTML(rawhtml) {
|
||||
try {
|
||||
// If this is already a standalone HTML document fragment
|
||||
if (rawhtml.includes('<style') || rawhtml.includes('</style>')) {
|
||||
return inlineCSS(rawhtml);
|
||||
}
|
||||
// Otherwise pass through unchanged (inline-only content)
|
||||
return rawhtml;
|
||||
} catch (err) {
|
||||
console.error('Juice inline error:', err.message);
|
||||
return rawhtml;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url.endsWith(process.argv[1])) {
|
||||
// CLI mode: juice <input> [output]
|
||||
const { readFileSync, writeFileSync } = await import('fs');
|
||||
const input = process.argv[2];
|
||||
const output = process.argv[3];
|
||||
if (!input) { console.error('Usage: node inliner.mjs <input.html> [output.html]'); process.exit(1); }
|
||||
const html = readFileSync(input, 'utf8');
|
||||
const result = inlineCSS(html);
|
||||
if (output) { writeFileSync(output, result, 'utf8'); console.log(`Inlined → ${output}`); }
|
||||
else { console.log(result); }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "gitea-mail-templates-dev",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Live-reload dev server with Juice CSS inlining for Gitea Mail Templates",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"inline": "node inliner.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"express-ws": "^5.0.2",
|
||||
"juice": "^11.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { readFileSync, writeFileSync, existsSync, watch } from 'fs';
|
||||
import { resolve, dirname, basename, join, relative } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import express from 'express';
|
||||
import expressWs from 'express-ws';
|
||||
|
||||
import { inlineCSS, stripGmail, stripOutlook } from './inliner.mjs';
|
||||
|
||||
// ANSI color helpers
|
||||
const G = '\x1b[32m'; // green (Info)
|
||||
const Y = '\x1b[33m'; // yellow (Warn)
|
||||
const R = '\x1b[31m'; // red (Error)
|
||||
const Z = '\x1b[0m'; // reset
|
||||
const I = `${G}[I]${Z}`;
|
||||
const W = `${Y}[W]${Z}`;
|
||||
const E = `${R}[E]${Z}`;
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..'); // project root
|
||||
const TOOLS = join(ROOT, 'tools'); // tools/ (for go run)
|
||||
const PREVIEW = join(ROOT, 'preview'); // preview/
|
||||
const THEMES = join(ROOT, 'themes'); // themes/
|
||||
const RENDERED_JS = join(PREVIEW, 'rendered.js');
|
||||
const PORT = parseInt(process.env.PORT || '3456', 10);
|
||||
|
||||
// Express + WebSocket
|
||||
const app = express();
|
||||
expressWs(app);
|
||||
|
||||
// Track connected WebSocket clients
|
||||
const clients = new Set();
|
||||
|
||||
app.ws('/ws', (ws) => {
|
||||
clients.add(ws);
|
||||
ws.on('close', () => clients.delete(ws));
|
||||
});
|
||||
|
||||
// Broadcast to all connected browsers
|
||||
function broadcast(type, payload = {}) {
|
||||
const msg = JSON.stringify({ type, ...payload });
|
||||
for (const c of clients) c.send(msg);
|
||||
}
|
||||
|
||||
// Serves preview/ as static (index.html, rendered.js)
|
||||
app.use(express.static(PREVIEW, { etag: false }));
|
||||
|
||||
// Rebuild pipeline
|
||||
let building = false;
|
||||
const CHANGED_THEMES = new Set();
|
||||
|
||||
function getThemeFromPath(filePath) {
|
||||
const rel = relative(THEMES, filePath).replace(/\\/g, '/');
|
||||
const parts = rel.split('/').filter(Boolean);
|
||||
return parts[0] || null;
|
||||
}
|
||||
|
||||
async function rebuild() {
|
||||
if (building) { building = false; return rebuild(); } // debounce — restart
|
||||
building = true;
|
||||
|
||||
const themes = [...CHANGED_THEMES];
|
||||
CHANGED_THEMES.clear();
|
||||
|
||||
const start = Date.now();
|
||||
console.log(`${I} [Builder] Rebuilding: ${themes.length > 0 ? themes.join(', ') : 'all'} changed`);
|
||||
|
||||
// Step 1: Go render
|
||||
try {
|
||||
await goPreview(themes.length > 0 ? themes : ['all']);
|
||||
} catch (err) {
|
||||
console.error(`${E} [Builder] Go render failed: ${err.message}`);
|
||||
building = false;
|
||||
broadcast('error', { message: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Read rendered.js, apply juice inlining
|
||||
try {
|
||||
const renderedJS = readFileSync(RENDERED_JS, 'utf8');
|
||||
const juiced = await juiceRenderedOutput(renderedJS);
|
||||
writeFileSync(RENDERED_JS, juiced, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(`${E} [Builder] Juice step failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(`${I} [Builder] Rebuild done in ${elapsed}ms`);
|
||||
|
||||
building = false;
|
||||
|
||||
// Step 3: Notify browsers
|
||||
broadcast('reload', { elapsed });
|
||||
}
|
||||
|
||||
// Go preview (child process)
|
||||
function goPreview(themes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = ['run', '.', 'preview', '--folder', '../themes', '--config', './data/templates_config.json', ...themes];
|
||||
const proc = spawn('go', args, { cwd: TOOLS, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '', stderr = '';
|
||||
proc.stdout.on('data', (d) => { stdout += d.toString(); });
|
||||
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) reject(new Error(stderr.trim() || `exit ${code}`));
|
||||
else {
|
||||
// Print the summary lines
|
||||
const lines = stdout.split('\n').filter(l => l.includes('all required') || l.includes('only'));
|
||||
for (const l of lines) console.log(' ' + l.trim());
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Juice post-processing
|
||||
function juiceRenderedOutput(jsContent) {
|
||||
// rendered.js format: window.__RENDERED__ = { "theme": { "tpl": "<html>..." } };
|
||||
// We inline CSS and produce three client-specific variants:
|
||||
// __RENDERED__ — modern (Juice-inlined only)
|
||||
// __RENDERED_GMAIL__ — Gmail (Juice + strip unsupported CSS)
|
||||
// __RENDERED_OUTLOOK__ — Outlook (Juice + aggressive CSS strip)
|
||||
const match = jsContent.match(/window\.__RENDERED__\s*=\s*(\{[\s\S]*?\});/);
|
||||
if (!match) return Promise.resolve(jsContent);
|
||||
|
||||
let rendered;
|
||||
try {
|
||||
rendered = JSON.parse(match[1]);
|
||||
} catch {
|
||||
return Promise.resolve(jsContent);
|
||||
}
|
||||
|
||||
// Deep-clone for Gmail and Outlook variants
|
||||
const renderedGmail = JSON.parse(JSON.stringify(rendered));
|
||||
const renderedOutlook = JSON.parse(JSON.stringify(rendered));
|
||||
|
||||
let changed = false;
|
||||
for (const theme of Object.keys(rendered)) {
|
||||
for (const tpl of Object.keys(rendered[theme])) {
|
||||
const html = rendered[theme][tpl];
|
||||
if (typeof html !== 'string') continue;
|
||||
if (!html.includes('<style') && !html.includes('<html')) continue;
|
||||
try {
|
||||
// Modern — Juice inlining only
|
||||
const modern = inlineCSS(html);
|
||||
rendered[theme][tpl] = modern;
|
||||
changed = true;
|
||||
|
||||
// Gmail — Juice + strip unsupported CSS
|
||||
try { renderedGmail[theme][tpl] = stripGmail(modern); } catch {}
|
||||
// Outlook — Juice + aggressive CSS strip
|
||||
try { renderedOutlook[theme][tpl] = stripOutlook(modern); } catch {}
|
||||
} catch {
|
||||
// keep original on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
const rest = jsContent.replace(/window\.__RENDERED__\s*=\s*\{[\s\S]*?\};/, '');
|
||||
return Promise.resolve(
|
||||
'window.__RENDERED__ = ' + JSON.stringify(rendered, null, 2) + ';\n' +
|
||||
'window.__RENDERED_GMAIL__ = ' + JSON.stringify(renderedGmail, null, 2) + ';\n' +
|
||||
'window.__RENDERED_OUTLOOK__ = ' + JSON.stringify(renderedOutlook, null, 2) + ';\n' +
|
||||
rest
|
||||
);
|
||||
}
|
||||
return Promise.resolve(jsContent);
|
||||
}
|
||||
|
||||
// File watcher (native fs.watch, recursive)
|
||||
function relPath(absPath) {
|
||||
return relative(ROOT, absPath).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
// Debounced rebuild trigger
|
||||
let rebuildTimer = null;
|
||||
function scheduleRebuild(filePath) {
|
||||
const theme = getThemeFromPath(filePath);
|
||||
if (!theme) return;
|
||||
|
||||
const ext = filePath.slice(filePath.lastIndexOf('.'));
|
||||
if (ext !== '.tmpl') return;
|
||||
|
||||
CHANGED_THEMES.add(theme);
|
||||
clearTimeout(rebuildTimer);
|
||||
rebuildTimer = setTimeout(rebuild, 300);
|
||||
}
|
||||
|
||||
// Watch themes directory recursively (Windows supports recursive)
|
||||
try {
|
||||
watch(THEMES, { recursive: true }, (event, fname) => {
|
||||
if (!fname || !fname.endsWith('.tmpl')) return;
|
||||
const fullPath = join(THEMES, fname);
|
||||
// event is 'rename' for both create and delete; 'change' for modifications
|
||||
if (event === 'change') {
|
||||
console.log(`${I} [Watcher] ${relPath(fullPath)} changed`);
|
||||
scheduleRebuild(fullPath);
|
||||
} else if (event === 'rename') {
|
||||
if (existsSync(fullPath)) {
|
||||
console.log(`${I} [Watcher] ${relPath(fullPath)} created`);
|
||||
} else {
|
||||
console.log(`${I} [Watcher] ${relPath(fullPath)} deleted`);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(`${I} [Watcher] Watching themes/ (recursive)`);
|
||||
} catch (err) {
|
||||
console.error(`${E} [Watcher] File watch failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Initial Juice pass
|
||||
// The Go `preview all` step (dev.go) already generated raw rendered.js.
|
||||
// Only run juice inlining — no need to re-render with Go.
|
||||
(async function initialJuice() {
|
||||
try {
|
||||
const js = readFileSync(RENDERED_JS, 'utf8');
|
||||
const juiced = await juiceRenderedOutput(js);
|
||||
writeFileSync(RENDERED_JS, juiced, 'utf8');
|
||||
console.log(`${I} [Builder] CSS inlining applied`);
|
||||
} catch (err) {
|
||||
console.error(`${E} [Builder] CSS inlining failed: ${err.message}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// Start server
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok', port: PORT }));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`${I} [Server] Gitea Mail Templates — Dev Server`);
|
||||
console.log(`${I} [Server] http://localhost:${PORT}`);
|
||||
console.log(`${I} [Watcher] Watching themes/ for changes`);
|
||||
console.log(`${I} [Server] WebSocket ws://localhost:${PORT}/ws\n`);
|
||||
});
|
||||
Reference in new issue
Block a user