refactor(tools): modularize CLI with urfave/cli/v2, externalise template metadata

- Split monolithic build-preview.go into modular packages:
  tools.go (entry), cli/ (list/create/delete/preview commands),
  config/ (JSON loading), preview/ (rendering engine, funcs, locale)
- Extract all template metadata into data/templates_config.json
  as single source of truth — no hardcoded template data in Go
- Replace hand-rolled CLI parsing with github.com/urfave/cli/v2
- Add sensible defaults for --folder (../themes) and --config
  (./data/templates_config.json) — most commands now run bare
- Add type-coercing gt/lt/ge/le template funcs for JSON float64
- Fix AppUrl to gitea.com matching official template convention
- rendered.js now exports __REGISTRY__ and __PARAMS__ alongside
  __RENDERED__ — preview/index.html reads all from single source
- Cross-validate all 11 template data contexts against official
  Gitea source (services/mailer/*.go + templates/mail/*.tmpl)
- Update all docs (README, AGENTS, CONTRIBUTING, 5 languages)
  to reflect new CLI workflow with create scaffolding command

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KenanZhuandClaude Opus 4.8 committed 2026-06-04 12:26:44 +08:00
1 parent 77854ee0ed
commit 0bf982ffb9
30 files changed
+1274 -540

No files matched your search

+1 -1
View File
@@ -18,5 +18,5 @@ Desktop.ini
*.zip *.zip
*.tar.gz *.tar.gz
# Generated preview data (run: go run ./tools/build-preview.go) # Generated preview data (run: cd tools && go run . preview all)
preview/rendered.js preview/rendered.js
+31 -18
View File
@@ -7,15 +7,24 @@ A curated collection of email template themes (10 visual styles) for self-hosted
## Repository Layout ## Repository Layout
``` ```
themes/ # Template themes (5 styles, 11 .tmpl each = 55 source files) themes/ # Template themes (10 styles, 11 .tmpl each = 110 source files)
horizon/ # Enterprise / Corporate aurora/ # Ethereal / Dreamlike
terminal/ # Developers / Tech
ember/ # Community / Open Source
bloom/ # Creative / Startup (glassmorphism) bloom/ # Creative / Startup (glassmorphism)
ember/ # Community / Open Source
heritage/ # Education / Research heritage/ # Education / Research
tools/ # Go build tooling horizon/ # Enterprise / Corporate
build-preview.go # Pre-renders all templates into preview/rendered.js ink/ # Editorial / Publishing
go.mod # Go module (stdlib only, zero dependencies) mono/ # Minimal / Swiss design
neon/ # Cyberpunk / Gaming
terminal/ # Developers / Tech
terra/ # Nature / Sustainability
tools/ # Go CLI tooling (modular, zero dependencies)
tools.go # Main entry point
cli/ # CLI subcommands: list, create, delete, preview
config/ # Config types and templates_config.json loading
data/ # templates_config.json — single source of truth for template metadata
preview/ # Template rendering engine (funcs, locale, engine)
go.mod # Go module (stdlib only, zero dependencies)
preview/ # Browser-based live preview preview/ # Browser-based live preview
index.html # SPA with style/template/client/viewport switching index.html # SPA with style/template/client/viewport switching
rendered.js # Pre-rendered HTML (generated, committed for clone-and-preview) rendered.js # Pre-rendered HTML (generated, committed for clone-and-preview)
@@ -32,30 +41,34 @@ docs/ # Multi-language documentation
- Each style must have all 11 template types - Each style must have all 11 template types
### Adding a New Theme ### Adding a New Theme
1. Create `themes/<name>/` with the full `mail/` directory structure 1. Scaffold the new theme: `cd tools && go run . create <name>` — creates the full directory structure with placeholder `.tmpl` files for all 11 email types
2. Write all 11 `.tmpl` files with unique visual design 2. Write all 11 `.tmpl` files with unique visual design
3. Run `go run ./tools/build-preview.go` to regenerate preview data 3. Run `cd tools && go run . preview all` to regenerate preview data
4. Add the theme to the `<select id="sel-theme">` in `preview/index.html` 4. Update README.md style gallery table
5. Update README.md style gallery table
### 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 switching, template type switching, client simulation (Modern/Gmail/Outlook/Raw), and viewport toggle (Desktop 1386x780 / Mobile 390x780) - Supports theme switching, template type switching, client simulation (Modern/Gmail/Outlook/Raw), and viewport toggle (Desktop 1386x780 / Mobile 390x780)
- Entries in `REGISTRY` and `PARAMS` objects must match the Go build script's template definitions - `REGISTRY` and `PARAMS` are auto-generated from `templates_config.json` by the build tool — no manual syncing needed
### Build Script ### Build Tool
- `tools/build-preview.go` uses Go's native `html/template` package - `tools/tools.go` is the main entry point for the modular CLI
- Defines mock data for each template type matching Gitea's actual data contexts - Subcommands: `list`, `create`, `delete`, `preview`
- Post-processes favicon URLs for preview display - Template metadata lives in `tools/data/templates_config.json` — the single source of truth
- Zero external dependencies (stdlib only) - `tools/config/` handles config loading and data flattening
- `tools/preview/` implements the rendering engine (template funcs, locale, engine)
- `tools/cli/` implements CLI subcommands using `github.com/urfave/cli/v2`
- Uses Go's native `html/template` package for template rendering
## Commit Conventions ## Commit Conventions
- `style(name):` — template changes for a specific theme - `style(name):` — template changes for a specific theme
- `preview:` — preview tooling changes - `preview:` — preview tooling changes
- `tools:` — Go build script changes - `tools:` — Go CLI/build tooling changes
- `docs:` — documentation and translations - `docs:` — documentation and translations
- `fix:` — bug fixes - `fix:` — bug fixes
- `project:` — README, LICENSE, AGENTS.md, meta - `project:` — README, LICENSE, AGENTS.md, meta
- `refactor:` — code restructuring (e.g. modularization)
- `chore:` — maintenance (config updates, build scripts)
## Constraints ## Constraints
- No JavaScript framework dependencies — preview is vanilla JS - No JavaScript framework dependencies — preview is vanilla JS
+7 -9
View File
@@ -8,12 +8,10 @@ Thanks for your interest in contributing! This project aims to provide a diverse
### Adding a New Style ### Adding a New Style
1. Create the style directory: `themes/<your-style-name>/` 1. Scaffold the new style: `cd tools && go run . create <your-style-name>` — this creates the directory structure with placeholder `.tmpl` files for all 11 email types
2. Copy the directory structure from an existing style 2. Edit each `.tmpl` file in `themes/<your-style-name>/` with your unique visual design
3. Implement all 11 `.tmpl` files with your unique visual design 3. Regenerate the preview: `cd tools && go run . preview all` — the build script auto-discovers all theme directories under `themes/` and generates the theme selector dynamically
4. Regenerate preview data: `go run ./tools/build-preview.go` — the build script auto-discovers all theme directories under `themes/` 4. Submit a PR with screenshots of rendered emails
5. Add your theme as an `<option>` in `<select id="sel-theme">` in `preview/index.html`
6. Submit a PR with screenshots of rendered emails
### Style Guidelines ### Style Guidelines
@@ -31,7 +29,7 @@ If a template doesn't render correctly:
1. Check that all referenced Go template variables exist — compare against the Gitea source mail templates 1. Check that all referenced Go template variables exist — compare against the Gitea source mail templates
2. Verify translation keys match Gitea's locale files 2. Verify translation keys match Gitea's locale files
3. Confirm `.DisplayName` isn't used in templates that lack it 3. Confirm `.DisplayName` isn't used in templates that lack it
4. Regenerate the preview: `go run ./tools/build-preview.go` 4. Regenerate the preview: `cd tools && go run . preview all`
5. Open an issue with: the style name, which email type, and the error or unexpected output 5. Open an issue with: the style name, which email type, and the error or unexpected output
### Documentation Improvements ### Documentation Improvements
@@ -53,10 +51,10 @@ No build tools or dependencies are needed — these are raw Go HTML templates.
### Regenerating Previews ### Regenerating Previews
```bash ```bash
go run ./tools/build-preview.go cd tools && go run . preview all
``` ```
This renders all templates (themes auto-discovered from the themes/ directory) using Go's native `html/template` package and writes the output to `preview/rendered.js`. This renders all templates (themes auto-discovered from the themes/ directory) using Go's native `html/template` package and writes the output to `preview/rendered.js`. The `--folder` and `--config` flags default to `../themes` and `./data/templates_config.json` respectively — override them only when using a custom layout.
### Integration Testing ### Integration Testing
+9 -5
View File
@@ -85,16 +85,16 @@ A live preview tool is included to browse all styles and email types without dep
First, generate the preview data (requires Go): First, generate the preview data (requires Go):
```bash ```bash
go run ./tools/build-preview.go cd tools && go run . preview all
``` ```
Then open `preview/index.html` directly in a browser. No server required. Then open `preview/index.html` directly in a browser. No server required.
> `preview/rendered.js` is generated and git-ignored. Re-run the command after modifying templates. > `preview/rendered.js` is generated and git-ignored. Re-run `cd tools && go run . preview all` after modifying templates. Use `cd tools && go run .` to see all commands (list, create, delete, preview). Most flags have sensible defaults — just `go run . create <name>` or `go run . preview all` works out of the box.
### Features ### Features
- Theme switcher — toggle between all 5 visual styles - Theme switcher — toggle between all 10 visual styles
- Template switcher — browse all 11 email types - Template switcher — browse all 11 email types
- Client simulation — Modern, Gmail (no `<style>`), Outlook Desktop, Raw source - Client simulation — Modern, Gmail (no `<style>`), Outlook Desktop, Raw source
- Viewport toggle — Desktop (1386x780) / Mobile (390x780) - Viewport toggle — Desktop (1386x780) / Mobile (390x780)
@@ -112,8 +112,12 @@ gitea-mail-templates/
├── preview/ # Live preview SPA ├── preview/ # Live preview SPA
│ ├── index.html # Style/template/client/viewport switcher │ ├── index.html # Style/template/client/viewport switcher
│ └── rendered.js # Pre-rendered templates (generated by tools/) │ └── rendered.js # Pre-rendered templates (generated by tools/)
├── tools/ # Build tooling ├── tools/ # Modular CLI tooling
│ ├── build-preview.go # Go build script — renders all templates │ ├── tools.go # Main entry point
│ ├── cli/ # CLI subcommands (list, create, delete, preview)
│ ├── config/ # Config types and templates_config.json loading
│ ├── data/ # templates_config.json — single source of truth
│ ├── preview/ # Template rendering engine
│ └── go.mod │ └── go.mod
├── docs/ # Multi-language documentation ├── docs/ # Multi-language documentation
├── AGENTS.md # AI agent guidance ├── AGENTS.md # AI agent guidance
+6 -8
View File
@@ -4,12 +4,10 @@
### 新しいスタイルの追加 ### 新しいスタイルの追加
1. スタイルディレクトリを作成: `themes/<style-name>/` 1. ツールでスキャフォールド: `cd tools && go run . create <style-name>` — 全11種類のメールタイプ用のプレースホルダ `.tmpl` ファイルを含む完全なディレクトリ構造を作成します
2. 既存スタイルからディレクトリ構造をコピー 2. `themes/<style-name>/` 内の各 `.tmpl` ファイルを独自デザインで編集
3. 11個の `.tmpl` ファイルを独自デザインで実装 3. プレビューを再生成: `cd tools && go run . preview all`(ビルドスクリプトが `themes/` 以下の全テーマを自動検出し、テーマセレクターも自動生成されます)
4. プレビューを再生成: `go run ./tools/build-preview.go`(ビルドスクリプトが `themes/` 以下の全テーマを自動検出します) 4. スクリーンショット付きで PR を提出
5. `preview/index.html` の `<select id="sel-theme">` に追加
6. スクリーンショット付きで PR を提出
### スタイルガイドライン ### スタイルガイドライン
@@ -25,7 +23,7 @@
1. Go 変数が正しいか確認 1. Go 変数が正しいか確認
2. 翻訳キーが Gitea ロケールと一致するか確認 2. 翻訳キーが Gitea ロケールと一致するか確認
3. `.DisplayName` が誤用されていないか確認 3. `.DisplayName` が誤用されていないか確認
4. プレビューを再生成: `go run ./tools/build-preview.go` 4. プレビューを再生成: `cd tools && go run . preview all`
5. スタイル名とメールタイプを明記して issue を作成 5. スタイル名とメールタイプを明記して issue を作成
--- ---
@@ -34,7 +32,7 @@
### ローカルプレビュー ### ローカルプレビュー
1. データを生成: `go run ./tools/build-preview.go` 1. データを生成: `cd tools && go run . preview all`
2. `preview/index.html` をブラウザで開く 2. `preview/index.html` をブラウザで開く
3. Modern, Gmail, Outlook, Raw source を切り替えて確認 3. Modern, Gmail, Outlook, Raw source を切り替えて確認
+6 -8
View File
@@ -4,12 +4,10 @@
### 새 스타일 추가하기 ### 새 스타일 추가하기
1. 스타일 디렉토리 생성: `themes/<style-name>/` 1. 도구로 스캐폴드: `cd tools && go run . create <style-name>` — 11개 이메일 유형 모두에 대한 플레이스홀더 `.tmpl` 파일이 포함된 완전한 디렉토리 구조를 생성합니다
2. 기존 스타일에서 디렉토리 구조 복사 2. `themes/<style-name>/`에서 각 `.tmpl` 파일을 고유한 디자인으로 편집
3. 11개 `.tmpl` 파일을 고유한 디자인으로 구현 3. 프리뷰 재생성: `cd tools && go run . preview all` (빌드 스크립트가 `themes/` 아래의 모든 테마 디렉토리를 자동으로 찾고 테마 선택기도 자동 생성합니다)
4. 프리뷰 재생성: `go run ./tools/build-preview.go` (빌드 스크립트가 `themes/` 아래의 모든 테마 디렉토리를 자동으로 찾습니다) 4. 스크린샷과 함께 PR 제출
5. `preview/index.html`의 `<select id="sel-theme">`에 추가
6. 스크린샷과 함께 PR 제출
### 스타일 가이드라인 ### 스타일 가이드라인
@@ -25,7 +23,7 @@
1. Go 변수가 올바른지 확인 1. Go 변수가 올바른지 확인
2. 번역 키가 Gitea 로케일과 일치하는지 확인 2. 번역 키가 Gitea 로케일과 일치하는지 확인
3. `.DisplayName`이 오용되지 않았는지 확인 3. `.DisplayName`이 오용되지 않았는지 확인
4. 프리뷰 재생성: `go run ./tools/build-preview.go` 4. 프리뷰 재생성: `cd tools && go run . preview all`
5. 스타일 이름과 메일 유형을 명시하여 이슈 생성 5. 스타일 이름과 메일 유형을 명시하여 이슈 생성
--- ---
@@ -34,7 +32,7 @@
### 로컬 프리뷰 ### 로컬 프리뷰
1. 데이터 생성: `go run ./tools/build-preview.go` 1. 데이터 생성: `cd tools && go run . preview all`
2. `preview/index.html`을 브라우저에서 열기 2. `preview/index.html`을 브라우저에서 열기
3. Modern, Gmail, Outlook, Raw source 모드 전환 확인 3. Modern, Gmail, Outlook, Raw source 모드 전환 확인
+6 -8
View File
@@ -4,12 +4,10 @@
### Добавление нового стиля ### Добавление нового стиля
1. Создайте директорию стиля: `themes/<имя-стиля>/` 1. Создайте каркас через инструмент: `cd tools && go run . create <имя-стиля>` — создаёт полную структуру директорий с файлами-заготовками `.tmpl` для всех 11 типов писем
2. Скопируйте структуру из существующего стиля 2. Отредактируйте каждый `.tmpl` файл в `themes/<имя-стиля>/` с вашим уникальным дизайном
3. Реализуйте все 11 `.tmpl` файлов с уникальным дизайном 3. Перегенерируйте превью: `cd tools && go run . preview all` (скрипт автоматически находит все темы в `themes/` и динамически генерирует селектор тем)
4. Перегенерируйте превью: `go run ./tools/build-preview.go` (скрипт автоматически находит все темы в `themes/`) 4. Отправьте PR со скриншотами
5. Добавьте тему в `<select id="sel-theme">` в `preview/index.html`
6. Отправьте PR со скриншотами
### Рекомендации по стилю ### Рекомендации по стилю
@@ -25,7 +23,7 @@
1. Проверьте переменные Go в шаблонах 1. Проверьте переменные Go в шаблонах
2. Сверьте ключи перевода с локалью Gitea 2. Сверьте ключи перевода с локалью Gitea
3. Убедитесь что `.DisplayName` не используется где не надо 3. Убедитесь что `.DisplayName` не используется где не надо
4. Перегенерируйте превью: `go run ./tools/build-preview.go` 4. Перегенерируйте превью: `cd tools && go run . preview all`
5. Создайте issue с указанием стиля и типа письма 5. Создайте issue с указанием стиля и типа письма
--- ---
@@ -34,7 +32,7 @@
### Локальный предпросмотр ### Локальный предпросмотр
1. Сгенерируйте данные: `go run ./tools/build-preview.go` 1. Сгенерируйте данные: `cd tools && go run . preview all`
2. Откройте `preview/index.html` в браузере 2. Откройте `preview/index.html` в браузере
3. Переключайте Modern, Gmail, Outlook, Raw source для проверки 3. Переключайте Modern, Gmail, Outlook, Raw source для проверки
+6 -8
View File
@@ -4,12 +4,10 @@
### 添加新风格 ### 添加新风格
1. 创建风格目录:`themes/<风格名称>/` 1. 使用工具脚手架:`cd tools && go run . create <风格名称>` — 这会创建完整的目录结构和全部 11 种邮件类型的占位 `.tmpl` 文件
2. 从现有风格复制目录结构 2. 在 `themes/<风格名称>/` 中编写每个 `.tmpl` 文件,应用独特的视觉设计
3. 用独特的视觉设计实现全部 11 个 `.tmpl` 文件 3. 重新生成预览:`cd tools && go run . preview all`(构建脚本会自动发现 `themes/` 下的所有主题目录并动态生成主题选择器)
4. 重新生成预览:`go run ./tools/build-preview.go`(构建脚本会自动发现 `themes/` 下的所有主题目录) 4. 提交包含截图渲染效果的 PR
5. 在 `preview/index.html` 的 `<select id="sel-theme">` 中添加选项
6. 提交包含截图渲染效果的 PR
### 风格指南 ### 风格指南
@@ -25,7 +23,7 @@
1. 检查引用的 Go 模板变量是否存在 1. 检查引用的 Go 模板变量是否存在
2. 验证翻译键是否与 Gitea 语言文件匹配 2. 验证翻译键是否与 Gitea 语言文件匹配
3. 确认 `.DisplayName` 未在不支持的模板中使用 3. 确认 `.DisplayName` 未在不支持的模板中使用
4. 重新生成预览:`go run ./tools/build-preview.go` 4. 重新生成预览:`cd tools && go run . preview all`
5. 提交 issue,注明风格名称、邮件类型及错误描述 5. 提交 issue,注明风格名称、邮件类型及错误描述
### 改进文档 ### 改进文档
@@ -40,7 +38,7 @@
### 本地预览 ### 本地预览
1. 先生成预览数据:`go run ./tools/build-preview.go` 1. 先生成预览数据:`cd tools && go run . preview all`
2. 在浏览器中打开 `preview/index.html` 2. 在浏览器中打开 `preview/index.html`
3. 在 Modern、Gmail、Outlook、Raw source 模式间切换验证效果 3. 在 Modern、Gmail、Outlook、Raw source 模式间切换验证效果
+6 -8
View File
@@ -4,12 +4,10 @@
### 新增風格 ### 新增風格
1. 建立風格目錄:`themes/<風格名稱>/` 1. 使用工具腳手架:`cd tools && go run . create <風格名稱>` — 這會建立完整的目錄結構和全部 11 種郵件類型的佔位 `.tmpl` 檔案
2. 從現有風格複製目錄結構 2. 在 `themes/<風格名稱>/` 中編寫每個 `.tmpl` 檔案,套用獨特的視覺設計
3. 以獨特的視覺設計實作全部 11 個 `.tmpl` 檔案 3. 重新產生預覽:`cd tools && go run . preview all`(建置腳本會自動發現 `themes/` 下的所有主題目錄並動態生成主題選擇器)
4. 重新產生預覽:`go run ./tools/build-preview.go`(建置腳本會自動發現 `themes/` 下的所有主題目錄) 4. 提交包含螢幕截圖的 PR
5. 在 `preview/index.html` 的 `<select id="sel-theme">` 中新增選項
6. 提交包含螢幕截圖的 PR
### 風格指南 ### 風格指南
@@ -25,7 +23,7 @@
1. 檢查引用的 Go 模板變數是否存在 1. 檢查引用的 Go 模板變數是否存在
2. 驗證翻譯鍵是否與 Gitea 語系檔案相符 2. 驗證翻譯鍵是否與 Gitea 語系檔案相符
3. 確認 `.DisplayName` 未在不支援的模板中使用 3. 確認 `.DisplayName` 未在不支援的模板中使用
4. 重新產生預覽:`go run ./tools/build-preview.go` 4. 重新產生預覽:`cd tools && go run . preview all`
5. 提交 issue,註明風格名稱、郵件類型及錯誤描述 5. 提交 issue,註明風格名稱、郵件類型及錯誤描述
### 改善文件 ### 改善文件
@@ -40,7 +38,7 @@
### 本機預覽 ### 本機預覽
1. 先生成預覽資料:`go run ./tools/build-preview.go` 1. 先生成預覽資料:`cd tools && go run . preview all`
2. 在瀏覽器中開啟 `preview/index.html` 2. 在瀏覽器中開啟 `preview/index.html`
3. 在 Modern、Gmail、Outlook、Raw source 模式間切換驗證效果 3. 在 Modern、Gmail、Outlook、Raw source 模式間切換驗證效果
+1 -1
View File
@@ -37,7 +37,7 @@ systemctl restart gitea
## プレビュー ## プレビュー
```bash ```bash
go run ./tools/build-preview.go cd tools && go run . preview all
``` ```
その後 `preview/index.html` を開く。 その後 `preview/index.html` を開く。
+1 -1
View File
@@ -37,7 +37,7 @@ systemctl restart gitea
## 프리뷰 ## 프리뷰
```bash ```bash
go run ./tools/build-preview.go cd tools && go run . preview all
``` ```
그런 다음 `preview/index.html` 열기. 그런 다음 `preview/index.html` 열기.
+1 -1
View File
@@ -37,7 +37,7 @@ systemctl restart gitea
## Предпросмотр ## Предпросмотр
```bash ```bash
go run ./tools/build-preview.go cd tools && go run . preview all
``` ```
Затем откройте `preview/index.html`. Затем откройте `preview/index.html`.
+1 -1
View File
@@ -51,7 +51,7 @@ systemctl restart gitea
## 预览 ## 预览
```bash ```bash
go run ./tools/build-preview.go cd tools && go run . preview all
``` ```
然后打开 `preview/index.html`。 然后打开 `preview/index.html`。
+1 -1
View File
@@ -51,7 +51,7 @@ systemctl restart gitea
## 預覽 ## 預覽
```bash ```bash
go run ./tools/build-preview.go cd tools && go run . preview all
``` ```
然後開啟 `preview/index.html`。 然後開啟 `preview/index.html`。
+1 -1
View File
@@ -11,7 +11,7 @@ neon.png mono.png terra.png ink.png aurora.png
## How to Capture ## How to Capture
1. Run `go run ./tools/build-preview.go` from the project root 1. Run `cd tools && go run . preview all` from the project root
2. Open `preview/index.html` in a browser 2. Open `preview/index.html` in a browser
3. For each style, select the "Activate Account" template and "Modern" client mode 3. For each style, select the "Activate Account" template and "Modern" client mode
4. Take a screenshot of the rendered email (600px width recommended) 4. Take a screenshot of the rendered email (600px width recommended)
+58 -62
View File
@@ -56,6 +56,7 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
#panel .badge{display:inline-block;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:.2px;white-space:nowrap} #panel .badge{display:inline-block;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:.2px;white-space:nowrap}
.badge-req{background:rgba(52,211,153,.12);color:var(--green)} .badge-req{background:rgba(52,211,153,.12);color:var(--green)}
.badge-opt{background:rgba(107,125,153,.1);color:var(--muted)} .badge-opt{background:rgba(107,125,153,.1);color:var(--muted)}
#panel .stat{font-size:10px;color:var(--muted);margin-top:6px}
// ── Client indicators ── // ── Client indicators ──
#panel .indicators{display:flex;flex-wrap:wrap;gap:4px 12px} #panel .indicators{display:flex;flex-wrap:wrap;gap:4px 12px}
@@ -81,16 +82,7 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
<span class="brand">Gitea Mail Templates <span>Preview</span></span> <span class="brand">Gitea Mail Templates <span>Preview</span></span>
<span class="sep"></span> <span class="sep"></span>
<label>Theme</label> <label>Theme</label>
<select id="sel-theme" onchange="switchTheme(this.value)"> <select id="sel-theme" onchange="switchTheme(this.value)"></select>
<option value="horizon">Horizon</option><option value="terminal">Terminal</option>
<option value="ember">Ember</option><option value="bloom">Bloom</option>
<option value="heritage">Heritage</option>
<option value="neon">Neon</option>
<option value="mono">Mono</option>
<option value="terra">Terra</option>
<option value="ink">Ink</option>
<option value="aurora">Aurora</option>
</select>
<span class="sep"></span> <span class="sep"></span>
<label>Template</label> <label>Template</label>
<select id="sel-template" onchange="switchTemplate(this.value)"></select> <select id="sel-template" onchange="switchTemplate(this.value)"></select>
@@ -130,6 +122,7 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
<div class="label">Description</div> <div class="label">Description</div>
<div class="value" id="panel-desc" style="font-size:11px;line-height:1.5">-</div> <div class="value" id="panel-desc" style="font-size:11px;line-height:1.5">-</div>
</div> </div>
<div class="stat" id="panel-stats"></div>
</section> </section>
<section style="flex:1;overflow-y:auto"> <section style="flex:1;overflow-y:auto">
<h3>Parameters</h3> <h3>Parameters</h3>
@@ -149,44 +142,12 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
<script src="rendered.js"></script> <script src="rendered.js"></script>
<script> <script>
// //
// Template registry — mirrors the Go build script definitions // State — REGISTRY and PARAMS are loaded from rendered.js (single source of truth)
// //
const REGISTRY = { const state = { theme:'', template:'', client:'modern', viewport:'desktop' };
activate: {name:'Activate Account', path:'mail/user/auth/activate.tmpl', desc:'Account activation — sent when a new user registers.'}, let REGISTRY = {};
activate_email: {name:'Activate Email', path:'mail/user/auth/activate_email.tmpl', desc:'Email address verification — sent when adding or changing an email address.'}, let PARAMS = {};
register_notify: {name:'Register Notify', path:'mail/user/auth/register_notify.tmpl', desc:'Registration notification — confirms successful account registration.'}, let rendered = null; // window.__RENDERED__
reset_passwd: {name:'Reset Password', path:'mail/user/auth/reset_passwd.tmpl', desc:'Password reset — sent when user requests a password reset link.'},
team_invite: {name:'Team Invite', path:'mail/org/team_invite.tmpl', desc:'Team invitation — sent when a user is invited to join an organization team.'},
collaborator: {name:'Collaborator Added', path:'mail/repo/collaborator.tmpl', desc:'Collaborator added — sent when a user is added as a repository collaborator.'},
transfer: {name:'Repository Transfer', path:'mail/repo/transfer.tmpl', desc:'Repository transfer — sent when repository ownership is transferred.'},
release: {name:'New Release', path:'mail/repo/release.tmpl', desc:'New release — sent when a new release is published.'},
workflow_run: {name:'Workflow Run', path:'mail/repo/actions/workflow_run.tmpl', desc:'Workflow run — sent when a CI/CD workflow completes.'},
assigned: {name:'Issue Assigned', path:'mail/repo/issue/assigned.tmpl', desc:'Issue/PR assigned — sent when an issue or pull request is assigned.'},
default: {name:'Issue Update', path:'mail/repo/issue/default.tmpl', desc:'Issue/PR update — comments, mentions, reviews, merges, and other activity.'}
};
//
// Parameter definitions (informational only — rendering is done by Go)
//
const PARAMS = {
activate: {'DisplayName':'Alex Johnson','Code':'activation-code-abc123','ActiveCodeLives':'24 hours','AppName':'Gitea','AppUrl':'https://about.gitea.com'},
activate_email: {'DisplayName':'Alex Johnson','Code':'verify-code-xyz789','Email':'alex@example.com','ActiveCodeLives':'24 hours','AppName':'Gitea','AppUrl':'https://about.gitea.com'},
register_notify: {'DisplayName':'Alex Johnson','Username':'alexj','AppName':'Gitea','AppUrl':'https://about.gitea.com'},
reset_passwd: {'DisplayName':'Alex Johnson','Code':'reset-token-abc123','ResetPwdCodeLives':'3 hours','AppName':'Gitea','AppUrl':'https://about.gitea.com'},
team_invite: {'AppName':'Gitea','AppUrl':'https://about.gitea.com','Inviter.DisplayName':'Sarah Chen','Team.Name':'Core Developers','Organization.DisplayName':'Acme Corp','InviteURL':'...invite?token=abc','Invite.Email':'alex@example.com'},
collaborator: {'AppName':'Gitea','AppUrl':'https://about.gitea.com','Subject':'Collaborator Added','Link':'.../repo/project','RepoName':'acme/project'},
transfer: {'AppName':'Gitea','AppUrl':'https://about.gitea.com','Subject':'Repository Transfer','Link':'.../transferred-project','Repo':'acme/transferred-project'},
release: {'AppName':'Gitea','AppUrl':'https://about.gitea.com','Subject':'New Release v2.0.0','Link':'.../repo/project/releases/tag/v2.0.0','Release.Publisher.Name':'Sarah Chen','Release.TagName':'v2.0.0','Release.Title':'Version 2.0','Release.HTMLURL':'.../releases/tag/v2.0.0','Release.Repo.HTMLURL':'.../repo/project','Release.Repo.FullName':'acme/project','Release.RenderedNote':'<p>Major new features...</p>','DisableDownloadSourceArchives':'false','Release.Attachments':'[]'},
workflow_run: {'AppName':'Gitea','AppUrl':'https://about.gitea.com','Subject':'Workflow Run: CI','RunStatusText':'All jobs passed','Repo.FullName':'acme/project','Run.WorkflowID':'ci.yml','Run.HTMLURL':'.../actions/runs/42','Jobs':'[3 items]'},
assigned: {'AppName':'Gitea','AppUrl':'https://about.gitea.com','Subject':'Issue #42 Assigned','Link':'.../repo/project/issues/42','Doer.Name':'Sarah Chen','IsPull':'false','Issue.Index':'42','Issue.Repo.HTMLURL':'.../repo/project','Issue.Repo.FullName':'acme/project'},
default: {'AppName':'Gitea','AppUrl':'https://about.gitea.com','Subject':'Re: Issue #42','Link':'.../repo/project/issues/42','ActionName':'comment','Doer.Name':'Sarah Chen','Body':'This is a comment on the issue...','CanReply':'true','IsMention':'false','IsPull':'false','Issue.Index':'42','Issue.PullRequest.BaseBranch':'main','Comment.Commits':'[]','Comment.IsForcePush':'false','ReviewComments':'[]'}
};
//
// State
//
const state = { theme:'horizon', template:'activate', client:'modern', viewport:'desktop' };
let rendered = null; // loaded from rendered.js via <script> tag
// //
// Client transforms // Client transforms
@@ -240,18 +201,31 @@ function render() {
// Panel updates // Panel updates
// //
function updatePanel() { function updatePanel() {
const t = REGISTRY[state.template]; const r = REGISTRY[state.template];
document.getElementById('panel-name').textContent = t.name; document.getElementById('panel-name').textContent = r ? r.name : state.template;
document.getElementById('panel-desc').textContent = t.desc; document.getElementById('panel-desc').textContent = r ? r.desc : '';
document.getElementById('panel-path').textContent = 'themes/'+state.theme+'/'+t.path; document.getElementById('panel-path').textContent = r ? 'themes/'+state.theme+'/'+r.path : '-';
const p = PARAMS[state.template]; // Template stats
const themes = rendered ? Object.keys(rendered).length : 0;
let totalTemplates = 0, totalFailed = 0;
if (rendered && rendered[state.theme]) {
const themeData = rendered[state.theme];
totalTemplates = Object.keys(themeData).length;
for (const v of Object.values(themeData)) {
if (v.startsWith('<p style=\'color:red\'')) totalFailed++;
}
}
document.getElementById('panel-stats').textContent =
themes + ' themes loaded · ' + totalTemplates + ' templates (' + (totalTemplates - totalFailed) + ' ok, ' + totalFailed + ' failed)';
const p = PARAMS[state.template] || {};
let rows = ''; let rows = '';
for(const [k,v] of Object.entries(p)){ for(const [k,v] of Object.entries(p)){
const isReq = !k.includes('RenderedNote') && !k.includes('Attachments') && !k.includes('DisableDownload') && !k.includes('IsMention') && !k.includes('IsPull') && !k.includes('IsForcePush') && !k.includes('ReviewComments') && !k.includes('Commits') && !k.includes('BaseBranch'); const isReq = !k.includes('RenderedNote') && !k.includes('Attachments') && !k.includes('DisableDownload') && !k.includes('IsMention') && !k.includes('IsPull') && !k.includes('IsForcePush') && !k.includes('ReviewComments') && !k.includes('Commits') && !k.includes('BaseBranch');
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; document.getElementById('panel-params').innerHTML = rows || '<tr><td colspan="3" style="color:var(--muted)">-</td></tr>';
// Client simulation indicators // Client simulation indicators
const c=state.client, const c=state.client,
@@ -292,21 +266,43 @@ document.addEventListener('keydown',e=>{
}); });
// //
// Init // Init — load everything from rendered.js (single source of truth)
// //
(function(){ (function(){
const sel=document.getElementById('sel-template'); // Load REGISTRY and PARAMS from rendered.js
for(const[id,t]of Object.entries(REGISTRY)){ if(window.__REGISTRY__) REGISTRY = window.__REGISTRY__;
const o=document.createElement('option');o.value=id;o.textContent=t.name;sel.appendChild(o); if(window.__PARAMS__) PARAMS = window.__PARAMS__;
}
if(window.__RENDERED__){ if(window.__RENDERED__){
rendered=window.__RENDERED__; rendered = window.__RENDERED__;
// Populate theme selector from rendered data
const themeKeys = Object.keys(rendered).sort();
const selTheme = document.getElementById('sel-theme');
themeKeys.forEach(t => {
const o = document.createElement('option');
o.value = t;
o.textContent = t.charAt(0).toUpperCase() + t.slice(1);
selTheme.appendChild(o);
});
state.theme = themeKeys[0] || '';
// Populate template selector from REGISTRY
const selTpl = document.getElementById('sel-template');
for(const [id, t] of Object.entries(REGISTRY)){
const o = document.createElement('option');
o.value = id;
o.textContent = t.name;
selTpl.appendChild(o);
}
state.template = Object.keys(REGISTRY)[0] || '';
document.getElementById('loading').style.display='none'; document.getElementById('loading').style.display='none';
}else{ } else {
document.getElementById('loading').innerHTML='<span style="color:var(--red)">rendered.js not found</span><small style="color:var(--muted);margin-top:8px">Run: cd tools && go run build-preview.go</small>'; document.getElementById('loading').innerHTML = '<span style="color:var(--red)">rendered.js not found</span><small style="color:var(--muted);margin-top:8px">Run: cd tools && go run . preview all</small>';
} }
render(); render();
})(); })();
</script> </script>
</body> </body>
</html> </html>
-399
View File
@@ -1,399 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/url"
"os"
"path/filepath"
"strings"
)
// TemplateFuncs maps Gitea's built-in template functions
var TemplateFuncs = template.FuncMap{
"AppUrl": func() string { return "https://about.gitea.com/" },
"AppName": func() string { return "Gitea" },
"DotEscape": func(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) },
"QueryEscape": url.QueryEscape,
"ShortSha": ShortSha,
"PathEscapeSegments": PathEscapeSegments,
"FileSize": FileSize,
"HTMLFormat": HTMLFormat,
"Dict": Dict,
}
func ShortSha(s string) string {
if len(s) > 10 { return s[:10] }
return s
}
func PathEscapeSegments(s string) string {
parts := strings.Split(s, "/")
for i, p := range parts { parts[i] = url.PathEscape(p) }
return strings.Join(parts, "/")
}
func FileSize(n int64) string {
const unit = 1024
if n < unit { return fmt.Sprintf("%d B", n) }
div, exp := int64(unit), 0
for nn := n / unit; nn >= unit && exp < 3; nn /= unit { div *= unit; exp++ }
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), []string{"KB","MB","GB","TB"}[exp])
}
func HTMLFormat(fmtStr string, args ...any) template.HTML {
result := fmtStr
for i, arg := range args {
escaped := template.HTMLEscapeString(fmt.Sprint(arg))
result = strings.Replace(result, fmt.Sprintf("%%[%d]s", i+1), escaped, 1)
if !strings.Contains(result, fmt.Sprintf("%%[%d]s", i+1)) {
result = strings.Replace(result, "%s", escaped, 1)
result = strings.Replace(result, "%d", fmt.Sprint(arg), 1)
}
}
return template.HTML(result)
}
func Dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 { return nil, fmt.Errorf("odd number of Dict args") }
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok { return nil, fmt.Errorf("Dict keys must be strings") }
dict[key] = values[i+1]
}
return dict, nil
}
// Locale provides English fallback translations
type Locale struct{}
func (l Locale) Tr(key string, args ...any) template.HTML {
s := LocaleEN[key]
if s == "" { s = key }
for _, arg := range args {
var val string
if h, ok := arg.(template.HTML); ok {
val = string(h) // already safe HTML — don't double-escape
} else {
val = template.HTMLEscapeString(fmt.Sprint(arg))
}
s = strings.Replace(s, "%s", val, 1)
s = strings.Replace(s, "%d", fmt.Sprint(arg), 1)
}
return template.HTML(s)
}
func (l Locale) TrN(count any, singular, plural string, args ...any) template.HTML {
n := 0
switch v := count.(type) {
case int: n = v
case int64: n = int(v)
case float64: n = int(v)
default: n = 1
}
key := plural
if n == 1 { key = singular }
return l.Tr(key, args...)
}
var LocaleEN = map[string]string{
"mail.activate_account.title": "Activate your %s account",
"mail.hi_user_x": "Hi %s,",
"mail.activate_account.text_1": "Welcome to %s! Please activate your account by clicking the button below.",
"mail.activate_account.text_2": "This activation link will expire in %s.",
"mail.activate_email.title": "Verify your email address for %s",
"mail.activate_email.text": "Please verify your email address. The link will expire in %s.",
"mail.register_notify.title": "Welcome to %s, %s",
"mail.register_notify.text_1": "Your account on %s has been created successfully.",
"mail.register_notify.text_2": "Your username is: %s",
"mail.register_notify.text_3": "If you need to set a password, visit %s.",
"mail.reset_password.title": "Reset your %s password",
"mail.reset_password.text": "Click the link below to reset your password. It will expire in %s.",
"mail.link_not_working_do_paste": "If the button doesn't work, copy and paste this link into your browser:",
"mail.team_invite.text_1": "%s has invited you to join the team %s in the organization %s.",
"mail.team_invite.text_2": "Click the button below to accept the invitation.",
"mail.team_invite.text_3": "This invitation was sent to %s.",
"mail.repo.collaborator.added.text": "You have been added as a collaborator on the repository",
"mail.view_it_on": "View it on %s",
"mail.repo.transfer.body": "The repository %s has been transferred to you.",
"mail.release.new.text": "%s published a new release %s in the repository %s.",
"mail.release.title": "Release: %s",
"mail.release.note": "Release Notes:",
"mail.release.downloads": "Downloads",
"mail.release.download.zip": "Source Code (ZIP)",
"mail.release.download.targz": "Source Code (TAR.GZ)",
"mail.issue_assigned.pull": "%s assigned pull request %s to you in the repository %s.",
"mail.issue_assigned.issue": "%s assigned issue %s to you in the repository %s.",
"mail.issue.x_mentioned_you": "%s mentioned you.",
"mail.issue.action.close": "%s closed issue #%d.",
"mail.issue.action.reopen": "%s reopened issue #%d.",
"mail.issue.action.merge": "%s merged pull request #%d into %s.",
"mail.issue.action.approve": "%s approved this change.",
"mail.issue.action.reject": "%s requested changes.",
"mail.issue.action.review": "%s reviewed this change.",
"mail.issue.action.review_dismissed": "%s dismissed the review from %s.",
"mail.issue.action.ready_for_review": "%s marked this as ready for review.",
"mail.issue.action.new": "%s created issue #%d.",
"mail.issue.action.force_push": "%s force-pushed the %s branch from %s to %s.",
"mail.issue.action.push_1": "%s pushed %d commit to %s.",
"mail.issue.action.push_n": "%s pushed %d commits to %s.",
"mail.issue.in_tree_path": "In %s:",
"mail.reply": "Reply to this email",
}
// MockData for each template type
type TemplateDef struct {
Path string
Data map[string]any
}
var templates = map[string]TemplateDef{
"activate": {
Path: "mail/user/auth/activate.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Code": "activation-code-abc123",
"ActiveCodeLives": "24 hours",
},
},
"activate_email": {
Path: "mail/user/auth/activate_email.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Code": "verify-code-xyz789",
"Email": "alex@example.com",
"ActiveCodeLives": "24 hours",
},
},
"register_notify": {
Path: "mail/user/auth/register_notify.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Username": "alexj",
},
},
"reset_passwd": {
Path: "mail/user/auth/reset_passwd.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Code": "reset-token-abc123",
"ResetPwdCodeLives": "3 hours",
},
},
"team_invite": {
Path: "mail/org/team_invite.tmpl",
Data: map[string]any{
"Inviter": map[string]any{"DisplayName": "Sarah Chen"},
"Team": map[string]any{"Name": "Core Developers"},
"Organization": map[string]any{"DisplayName": "Acme Corp"},
"InviteURL": "https://gitea.example.com/org/team/invite?token=invite-abc",
"Invite": map[string]any{"Email": "alex@example.com"},
},
},
"collaborator": {
Path: "mail/repo/collaborator.tmpl",
Data: map[string]any{
"Subject": "Collaborator Added to repo",
"Link": "https://gitea.example.com/repo/project",
"RepoName": "acme/project",
},
},
"transfer": {
Path: "mail/repo/transfer.tmpl",
Data: map[string]any{
"Subject": "Repository Transfer",
"Link": "https://gitea.example.com/repo/transferred-project",
"Repo": "acme/transferred-project",
},
},
"release": {
Path: "mail/repo/release.tmpl",
Data: map[string]any{
"Subject": "New Release v2.0.0",
"Link": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"Release": map[string]any{
"Publisher": map[string]any{"Name": "Sarah Chen"},
"HTMLURL": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"TagName": "v2.0.0",
"Title": "Version 2.0 -- Major Update",
"RenderedNote": "<p>Major new features and performance improvements in this release.</p>",
"Repo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project",
},
"Attachments": []any{},
},
"DisableDownloadSourceArchives": false,
},
},
"workflow_run": {
Path: "mail/repo/actions/workflow_run.tmpl",
Data: map[string]any{
"Subject": "Workflow Run: CI",
"RunStatusText": "All jobs passed",
"Repo": map[string]any{"FullName": "acme/project"},
"Run": map[string]any{
"WorkflowID": "ci.yml",
"HTMLURL": "https://gitea.example.com/repo/project/actions/runs/42",
},
"Jobs": []map[string]any{
{"Name": "build", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s"},
{"Name": "test", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s"},
{"Name": "deploy", "Status": "success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s"},
},
},
},
"assigned": {
Path: "mail/repo/issue/assigned.tmpl",
Data: map[string]any{
"Subject": "Issue #42 Assigned",
"Link": "https://gitea.example.com/repo/project/issues/42",
"Doer": map[string]any{"Name": "Sarah Chen"},
"IsPull": false,
"Issue": map[string]any{
"Index": 42,
"Repo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project",
},
},
},
},
"default": {
Path: "mail/repo/issue/default.tmpl",
Data: map[string]any{
"Subject": "Re: Issue #42 -- Bug fix",
"Link": "https://gitea.example.com/repo/project/issues/42",
"ActionName": "comment",
"Doer": map[string]any{"Name": "Sarah Chen"},
"Body": "This is a comment on the issue with details about the fix.",
"CanReply": true,
"IsMention": false,
"IsPull": false,
"Issue": map[string]any{
"Index": 42,
"PullRequest": map[string]any{
"BaseBranch": "main",
"BaseRepo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
},
"HeadBranch": "feature/fix",
},
},
"Comment": map[string]any{
"Commits": []any{},
"IsForcePush": false,
"Issue": map[string]any{
"PullRequest": map[string]any{
"BaseRepo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
},
"HeadBranch": "feature/fix",
},
},
},
"ReviewComments": []any{},
},
},
}
// discoverThemes scans the themes directory and returns all subdirectory names.
func discoverThemes(themesDir string) []string {
entries, err := os.ReadDir(themesDir)
if err != nil {
log.Fatalf("Cannot read themes directory %s: %v", themesDir, err)
}
var themes []string
for _, e := range entries {
if e.IsDir() {
themes = append(themes, e.Name())
}
}
if len(themes) == 0 {
log.Fatalf("No theme directories found in %s", themesDir)
}
return themes
}
func main() {
exe, _ := os.Executable()
rootDir := filepath.Dir(exe)
// When run with `go run`, use CWD's parent
if _, err := os.Stat(filepath.Join(rootDir, "themes")); os.IsNotExist(err) {
cwd, _ := os.Getwd()
// Try cwd, then cwd's parent
for _, d := range []string{cwd, filepath.Dir(cwd)} {
if _, e := os.Stat(filepath.Join(d, "themes")); e == nil { rootDir = d; break }
}
}
themesDir := filepath.Join(rootDir, "themes")
previewDir := filepath.Join(rootDir, "preview")
result := make(map[string]map[string]string)
themes := discoverThemes(themesDir)
for _, theme := range themes {
themeDir := filepath.Join(themesDir, theme)
result[theme] = make(map[string]string)
for tplID, tplDef := range templates {
tmplPath := filepath.Join(themeDir, tplDef.Path)
tmplContent, err := os.ReadFile(tmplPath)
if err != nil {
log.Printf("WARN: cannot read %s: %v", tmplPath, err)
result[theme][tplID] = fmt.Sprintf("<p style='color:red'>Failed to read template: %s</p>", err)
continue
}
// Build data context
data := make(map[string]any)
for k, v := range tplDef.Data {
data[k] = v
}
data["locale"] = Locale{}
// Parse and execute
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
if err != nil {
log.Printf("WARN: parse error %s/%s: %v", theme, tplID, err)
result[theme][tplID] = fmt.Sprintf("<p style='color:red'>Parse error: %s</p>", err)
continue
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
log.Printf("WARN: execute error %s/%s: %v", theme, tplID, err)
result[theme][tplID] = fmt.Sprintf("<p style='color:red'>Render error: %s</p>", err)
continue
}
// Fix favicon path for preview (official Gitea icon)
rendered := strings.Replace(buf.String(),
`src="https://about.gitea.com/assets/img/favicon.png"`,
`src="https://about.gitea.com/gitea.svg"`, -1)
result[theme][tplID] = rendered
log.Printf("OK: %s/%s (%d bytes)", theme, tplID, buf.Len())
}
}
// Write output as JS (script-loadable, avoids file:// CORS issues)
os.MkdirAll(previewDir, 0755)
outPath := filepath.Join(previewDir, "rendered.js")
outFile, err := os.Create(outPath)
if err != nil { log.Fatalf("Cannot create output: %v", err) }
defer outFile.Close()
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil { log.Fatalf("JSON marshal error: %v", err) }
outFile.WriteString("// Auto-generated by tools/build-preview.go — do not edit\n")
outFile.WriteString("window.__RENDERED__ = ")
outFile.Write(jsonBytes)
outFile.WriteString(";\n")
log.Printf("Wrote %d themes x %d templates to %s", len(themes), len(templates), outPath)
}
+8
View File
@@ -0,0 +1,8 @@
package cli
// Package cli defines all subcommands for the Gitea Mail Templates CLI tool.
// Command definitions use github.com/urfave/cli/v2.
//
// Shared flag names used across multiple subcommands:
// --folder : path to the themes directory
// --config : path to templates_config.json
+86
View File
@@ -0,0 +1,86 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/config"
)
// CreateCommand returns the "create" subcommand.
func CreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Create one or more new theme style directories",
UsageText: "go run . create [--folder <themes-dir>] [--config <config-file>] <style-name>...",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
&cli.StringFlag{
Name: "config",
Value: "./data/templates_config.json",
Usage: "Path to templates_config.json",
},
},
Action: runCreate,
}
}
func runCreate(c *cli.Context) error {
names := c.Args().Slice()
if len(names) == 0 {
return fmt.Errorf("at least one style name is required")
}
folder := c.String("folder")
cfg, err := config.Load(c.String("config"))
if err != nil {
return err
}
for _, styleName := range names {
styleDir := filepath.Join(folder, styleName)
if info, err := os.Stat(styleDir); err == nil {
if info.IsDir() {
fmt.Printf(" [skip] '%s' already exists\n", styleName)
continue
}
return fmt.Errorf("'%s' exists but is not a directory", styleName)
}
created := 0
for _, tplCfg := range cfg.Templates {
tmplPath := filepath.Join(styleDir, tplCfg.PathStr())
dir := filepath.Dir(tmplPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("cannot create directory %s: %w", dir, err)
}
placeholder := fmt.Sprintf("<!DOCTYPE html>\n<html>\n<head>\n"+
" <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n"+
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n"+
" <title>{{.locale.Tr \"mail.%s.title\"}}</title>\n"+
" <style type=\"text/css\">\n /* TODO: Add your custom styles here */\n"+
" </style>\n</head>\n<body style=\"margin:0;padding:0;\">\n"+
" <!-- TODO: Add your custom template design for: %s -->\n"+
"</body>\n</html>\n", tplCfg.Name, tplCfg.Desc)
if err := os.WriteFile(tmplPath, []byte(placeholder), 0644); err != nil {
return fmt.Errorf("cannot write %s: %w", tmplPath, err)
}
created++
}
fmt.Printf(" Created style '%s' with %d template files\n", styleName, created)
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"github.com/urfave/cli/v2"
)
// DeleteCommand returns the "delete" subcommand.
func DeleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete one or more theme style directories",
UsageText: "go run . delete [--folder <themes-dir>] <style-name>...",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
},
Action: runDelete,
}
}
func runDelete(c *cli.Context) error {
names := c.Args().Slice()
if len(names) == 0 {
return fmt.Errorf("at least one style name is required")
}
folder := c.String("folder")
for _, styleName := range names {
styleDir := filepath.Join(folder, styleName)
if _, err := os.Stat(styleDir); os.IsNotExist(err) {
fmt.Printf(" [skip] '%s' does not exist\n", styleName)
continue
}
if err := os.RemoveAll(styleDir); err != nil {
return fmt.Errorf("cannot delete %s: %w", styleDir, err)
}
fmt.Printf(" Deleted style '%s'\n", styleName)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"sort"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/preview"
)
// ListCommand returns the "list" subcommand.
func ListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List available theme styles in the target folder",
UsageText: "go run . list [--folder <themes-dir>]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
},
Action: runList,
}
}
func runList(c *cli.Context) error {
folder := c.String("folder")
themes, err := preview.DiscoverThemes(folder)
if err != nil {
return err
}
absDir, _ := filepath.Abs(folder)
fmt.Printf("Available styles in '%s' (%d):\n", filepath.Base(absDir), len(themes))
sort.Strings(themes)
for _, t := range themes {
count := 0
filepath.Walk(filepath.Join(folder, t), func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if filepath.Ext(path) == ".tmpl" {
count++
}
return nil
})
fmt.Printf(" %-16s (%d .tmpl files)\n", t, count)
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package cli
import (
"fmt"
"log"
"path/filepath"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/preview"
)
// PreviewCommand returns the "preview" subcommand.
func PreviewCommand() *cli.Command {
return &cli.Command{
Name: "preview",
Usage: "Pre-render mail templates for preview/testing",
UsageText: `go run . preview [--folder <themes-dir>] [--config <config-file>] [--output <output.js>] all
go run . preview [--folder <themes-dir>] [--config <config-file>] <style-name>...`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
&cli.StringFlag{
Name: "config",
Value: "./data/templates_config.json",
Usage: "Path to templates_config.json",
},
&cli.StringFlag{
Name: "output",
Usage: "Path to output rendered.js (default: derived from --folder)",
},
},
Action: runPreview,
}
}
func runPreview(c *cli.Context) error {
folder := c.String("folder")
configPath := c.String("config")
outputPath := c.String("output")
if outputPath == "" {
outputPath = filepath.Join(filepath.Dir(folder), "preview", "rendered.js")
}
names := c.Args().Slice()
if len(names) == 0 {
return fmt.Errorf("specify style names or 'all' (e.g. go run . preview all)")
}
cfg, err := config.Load(configPath)
if err != nil {
return err
}
var themeFilter map[string]bool
if len(names) == 1 && names[0] == "all" {
themeFilter = nil
} else {
themeFilter = make(map[string]bool)
for _, name := range names {
themeFilter[name] = true
}
}
result := preview.RenderAll(folder, cfg, themeFilter)
if result == nil {
return fmt.Errorf("no results produced")
}
if err := preview.WriteRenderedJS(result, outputPath); err != nil {
return err
}
preview.PrintDetailedSummary(result, folder, cfg)
log.Printf("Wrote %d themes x %d templates to %s",
len(result.Summaries), len(cfg.Templates), outputPath)
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// TemplateConfig holds metadata for a single mail template type.
type TemplateConfig struct {
Name string `json:"name"`
Desc string `json:"desc"`
Path []string `json:"path"`
Params map[string]any `json:"params"`
}
// TemplatesConfig is the root structure of templates_config.json.
type TemplatesConfig struct {
Templates map[string]TemplateConfig `json:"templates"`
}
// Load reads and parses a templates_config.json file.
func Load(path string) (*TemplatesConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("cannot read config file %s: %w", path, err)
}
var cfg TemplatesConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("cannot parse config file %s: %w", path, err)
}
if len(cfg.Templates) == 0 {
return nil, fmt.Errorf("config file %s contains no template definitions", path)
}
return &cfg, nil
}
// PathStr returns the file path of a template definition joined with OS separator.
func (t *TemplateConfig) PathStr() string {
return filepath.Join(t.Path...)
}
// Registry builds a lookup map suitable for serializing into rendered.js
// as window.__REGISTRY__: maps template ID → {name, desc, path}.
func Registry(cfg *TemplatesConfig) map[string]map[string]string {
reg := make(map[string]map[string]string, len(cfg.Templates))
for id, t := range cfg.Templates {
reg[id] = map[string]string{
"name": t.Name,
"desc": t.Desc,
"path": strings.Join(t.Path, "/"),
}
}
return reg
}
// FlattenParams converts nested template params into a flat dot-notation map
// suitable for the preview panel display in rendered.js.
func FlattenParams(params map[string]any) map[string]string {
flat := make(map[string]string)
flatten("", params, flat)
return flat
}
func flatten(prefix string, val any, out map[string]string) {
switch v := val.(type) {
case map[string]any:
for k, inner := range v {
key := k
if prefix != "" {
key = prefix + "." + k
}
flatten(key, inner, out)
}
case []any:
out[prefix] = fmt.Sprintf("[%d items]", len(v))
default:
out[prefix] = fmt.Sprintf("%v", v)
}
}
+176
View File
@@ -0,0 +1,176 @@
{
"templates": {
"activate": {
"name": "Activate Account",
"desc": "Account activation — sent when a new user registers.",
"path": ["mail", "user", "auth", "activate.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "activation-code-abc123",
"ActiveCodeLives": "24 hours"
}
},
"activate_email": {
"name": "Activate Email",
"desc": "Email address verification — sent when adding or changing an email address.",
"path": ["mail", "user", "auth", "activate_email.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "verify-code-xyz789",
"Email": "alex@example.com",
"ActiveCodeLives": "24 hours"
}
},
"register_notify": {
"name": "Register Notify",
"desc": "Registration notification — confirms successful account registration.",
"path": ["mail", "user", "auth", "register_notify.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Username": "alexj"
}
},
"reset_passwd": {
"name": "Reset Password",
"desc": "Password reset — sent when user requests a password reset link.",
"path": ["mail", "user", "auth", "reset_passwd.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "reset-token-abc123",
"ResetPwdCodeLives": "3 hours"
}
},
"team_invite": {
"name": "Team Invite",
"desc": "Team invitation — sent when a user is invited to join an organization team.",
"path": ["mail", "org", "team_invite.tmpl"],
"params": {
"Inviter": { "DisplayName": "Sarah Chen" },
"Team": { "Name": "Core Developers" },
"Organization": { "DisplayName": "Acme Corp" },
"InviteURL": "https://gitea.example.com/org/team/invite?token=invite-abc",
"Invite": { "Email": "alex@example.com" }
}
},
"collaborator": {
"name": "Collaborator Added",
"desc": "Collaborator added — sent when a user is added as a repository collaborator.",
"path": ["mail", "repo", "collaborator.tmpl"],
"params": {
"Subject": "Collaborator Added to repo",
"Link": "https://gitea.example.com/repo/project",
"RepoName": "acme/project"
}
},
"transfer": {
"name": "Repository Transfer",
"desc": "Repository transfer — sent when repository ownership is transferred.",
"path": ["mail", "repo", "transfer.tmpl"],
"params": {
"Subject": "Repository Transfer",
"Link": "https://gitea.example.com/repo/transferred-project",
"Repo": "acme/transferred-project"
}
},
"release": {
"name": "New Release",
"desc": "New release — sent when a new release is published.",
"path": ["mail", "repo", "release.tmpl"],
"params": {
"Subject": "New Release v2.0.0",
"Link": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"Release": {
"Publisher": { "Name": "Sarah Chen" },
"HTMLURL": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"TagName": "v2.0.0",
"Title": "Version 2.0 -- Major Update",
"RenderedNote": "<p>Major new features and performance improvements in this release.</p>",
"Repo": {
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project"
},
"Attachments": []
},
"DisableDownloadSourceArchives": false
}
},
"workflow_run": {
"name": "Workflow Run",
"desc": "Workflow run — sent when a CI/CD workflow completes.",
"path": ["mail", "repo", "actions", "workflow_run.tmpl"],
"params": {
"Subject": "Workflow Run: CI",
"RunStatusText": "All jobs passed",
"Repo": { "FullName": "acme/project" },
"Run": {
"WorkflowID": "ci.yml",
"HTMLURL": "https://gitea.example.com/repo/project/actions/runs/42"
},
"Jobs": [
{ "Name": "build", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s" },
{ "Name": "test", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s" },
{ "Name": "deploy", "Status": "success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s" }
]
}
},
"assigned": {
"name": "Issue Assigned",
"desc": "Issue/PR assigned — sent when an issue or pull request is assigned.",
"path": ["mail", "repo", "issue", "assigned.tmpl"],
"params": {
"Subject": "Issue #42 Assigned",
"Link": "https://gitea.example.com/repo/project/issues/42",
"Doer": { "Name": "Sarah Chen" },
"IsPull": false,
"Issue": {
"Index": 42,
"Repo": {
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project"
}
}
}
},
"default": {
"name": "Issue Update",
"desc": "Issue/PR update — comments, mentions, reviews, merges, and other activity.",
"path": ["mail", "repo", "issue", "default.tmpl"],
"params": {
"Subject": "Re: Issue #42 -- Bug fix",
"Link": "https://gitea.example.com/repo/project/issues/42",
"ActionName": "comment",
"Doer": { "Name": "Sarah Chen" },
"Body": "This is a comment on the issue with details about the fix.",
"CanReply": true,
"IsMention": false,
"IsPull": false,
"Issue": {
"Index": 42,
"PullRequest": {
"BaseBranch": "main",
"BaseRepo": {
"HTMLURL": "https://gitea.example.com/repo/project"
},
"HeadBranch": "feature/fix"
}
},
"Comment": {
"Commits": [],
"IsForcePush": false,
"OldCommit": "",
"NewCommit": "",
"Review": { "Reviewer": { "Name": "Taylor Kim" } },
"Issue": {
"PullRequest": {
"BaseRepo": {
"HTMLURL": "https://gitea.example.com/repo/project"
},
"HeadBranch": "feature/fix"
}
}
},
"ReviewComments": []
}
}
}
}
+7
View File
@@ -1,3 +1,10 @@
module gitea-mail-templates/tools module gitea-mail-templates/tools
go 1.21 go 1.21
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
)
+8
View File
@@ -0,0 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
+285
View File
@@ -0,0 +1,285 @@
package preview
import (
"encoding/json"
"fmt"
"html/template"
"log"
"os"
"path/filepath"
"strings"
"time"
"gitea-mail-templates/tools/config"
)
// RenderResult holds the rendered HTML for a single template in a single theme.
type RenderResult struct {
HTML string
Size int64 // bytes of the rendered HTML
Error string
Skipped bool // true when the template file was not found
}
// ThemeSummary collects statistics for one theme directory.
type ThemeSummary struct {
Name string
TotalFiles int
TotalSize int64 // total size of .tmpl source files
RenderedSize int64 // total size of rendered HTML
RenderedCount int
MissingCount int
ErrorCount int
RenderDuration time.Duration
Missing []string // template IDs that were not found
Errors []string // template IDs that had render errors
Results map[string]*RenderResult // per-template results
}
// PreviewResult is the full output from a preview run.
type PreviewResult struct {
Rendered map[string]map[string]string `json:"rendered"`
Registry map[string]map[string]string `json:"registry"`
Params map[string]map[string]string `json:"params"`
Summaries []ThemeSummary `json:"-"`
}
// DiscoverThemes scans a directory and returns all subdirectory names.
func DiscoverThemes(themesDir string) ([]string, error) {
entries, err := os.ReadDir(themesDir)
if err != nil {
return nil, fmt.Errorf("cannot read themes directory %s: %w", themesDir, err)
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
if len(names) == 0 {
return nil, fmt.Errorf("no theme directories found in %s", themesDir)
}
return names, nil
}
// RenderAll renders all templates from the config for all discovered themes.
// If themeFilter is non-empty, only themes in the filter set are rendered.
func RenderAll(themesDir string, cfg *config.TemplatesConfig, themeFilter map[string]bool) *PreviewResult {
themes, err := DiscoverThemes(themesDir)
if err != nil {
log.Printf("ERROR: %v", err)
return nil
}
// Apply theme filter
if len(themeFilter) > 0 {
filtered := themes[:0]
for _, t := range themes {
if themeFilter[t] {
filtered = append(filtered, t)
}
}
themes = filtered
if len(themes) == 0 {
log.Printf("WARN: no themes matched the requested filter")
return nil
}
}
result := &PreviewResult{
Rendered: make(map[string]map[string]string),
Registry: config.Registry(cfg),
}
// Build flattened params for the preview panel
result.Params = make(map[string]map[string]string, len(cfg.Templates))
for id, t := range cfg.Templates {
result.Params[id] = config.FlattenParams(t.Params)
}
for _, themeName := range themes {
themeDir := filepath.Join(themesDir, themeName)
summary := renderTheme(themeDir, themeName, cfg)
result.Summaries = append(result.Summaries, summary)
if result.Rendered[themeName] == nil {
result.Rendered[themeName] = make(map[string]string)
}
for tplID, rr := range summary.Results {
if rr.Error != "" {
result.Rendered[themeName][tplID] = fmt.Sprintf(
"<p style='color:red'>Render error for %s: %s</p>", tplID, rr.Error)
} else if rr.Skipped {
result.Rendered[themeName][tplID] = fmt.Sprintf(
"<p style='color:red'>Template not found: %s</p>", tplID)
} else {
result.Rendered[themeName][tplID] = rr.HTML
}
}
}
return result
}
func renderTheme(themeDir, themeName string, cfg *config.TemplatesConfig) ThemeSummary {
summary := ThemeSummary{
Name: themeName,
Results: make(map[string]*RenderResult),
}
start := time.Now()
for tplID, tplCfg := range cfg.Templates {
tmplRelPath := tplCfg.PathStr()
tmplAbsPath := filepath.Join(themeDir, tmplRelPath)
// Check source file existence
info, err := os.Stat(tmplAbsPath)
if os.IsNotExist(err) {
summary.MissingCount++
summary.Missing = append(summary.Missing, tplID)
summary.Results[tplID] = &RenderResult{Skipped: true}
continue
}
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: err.Error()}
continue
}
summary.TotalFiles++
summary.TotalSize += info.Size()
// Read template content
tmplContent, err := os.ReadFile(tmplAbsPath)
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("read error: %v", err)}
continue
}
// Build data context
data := make(map[string]any)
for k, v := range tplCfg.Params {
data[k] = v
}
data["locale"] = Locale{}
// Parse and execute
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("parse error: %v", err)}
continue
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("render error: %v", err)}
continue
}
rendered := buf.String()
summary.RenderedCount++
summary.RenderedSize += int64(len(rendered))
summary.Results[tplID] = &RenderResult{
HTML: rendered,
Size: int64(len(rendered)),
}
}
summary.RenderDuration = time.Since(start)
return summary
}
// WriteRenderedJS writes the preview result to a JavaScript file loaded by preview/index.html.
func WriteRenderedJS(result *PreviewResult, outputPath string) error {
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("cannot create output directory %s: %w", dir, err)
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("cannot create output file %s: %w", outputPath, err)
}
defer outFile.Close()
outFile.WriteString("// Auto-generated by tools/tools.go — do not edit\n")
// Write __RENDERED__
renderedJSON, err := json.MarshalIndent(result.Rendered, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal rendered data: %w", err)
}
outFile.WriteString("window.__RENDERED__ = ")
outFile.Write(renderedJSON)
outFile.WriteString(";\n\n")
// Write __REGISTRY__
registryJSON, err := json.MarshalIndent(result.Registry, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal registry data: %w", err)
}
outFile.WriteString("window.__REGISTRY__ = ")
outFile.Write(registryJSON)
outFile.WriteString(";\n\n")
// Write __PARAMS__
paramsJSON, err := json.MarshalIndent(result.Params, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal params data: %w", err)
}
outFile.WriteString("window.__PARAMS__ = ")
outFile.Write(paramsJSON)
outFile.WriteString(";\n")
return nil
}
// PrintDetailedSummary prints a human-readable summary of the preview results.
func PrintDetailedSummary(result *PreviewResult, themesDir string, cfg *config.TemplatesConfig) {
absDir, _ := filepath.Abs(themesDir)
fmt.Printf("\nfound %d styles of possible Gitea mail template in '%s' folder:\n\n",
len(result.Summaries), filepath.Base(absDir))
for _, s := range result.Summaries {
if s.MissingCount == 0 && s.ErrorCount == 0 {
fmt.Printf(" %-16s all required mail templates are properly rendered, (total %d .tmpl files, %s after, %v)\n",
s.Name, s.TotalFiles,
formatSize(s.RenderedSize),
s.RenderDuration.Round(time.Millisecond))
} else {
fmt.Printf(" %-16s only %d required mail templates are properly rendered:\n",
s.Name, s.RenderedCount)
for _, tplID := range s.Missing {
if t, ok := cfg.Templates[tplID]; ok {
fmt.Printf(" [%s] not found, expected at 'themes/%s/%s'\n",
filepath.Base(t.PathStr()), s.Name, t.PathStr())
} else {
fmt.Printf(" [%s] not found\n", tplID)
}
}
for _, tplID := range s.Errors {
if rr, ok := s.Results[tplID]; ok && rr.Error != "" {
fmt.Printf(" [%s] render error: %s\n", tplID, rr.Error)
} else {
fmt.Printf(" [%s] render error\n", tplID)
}
}
}
}
fmt.Println()
}
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%dB", bytes)
}
return fmt.Sprintf("%dKiB", bytes/unit)
}
+159
View File
@@ -0,0 +1,159 @@
package preview
import (
"fmt"
"html/template"
"net/url"
"strings"
)
// TemplateFuncs maps Gitea's built-in template functions available in mail templates.
var TemplateFuncs = template.FuncMap{
"AppUrl": func() string { return "https://gitea.com/" },
"AppName": func() string { return "Gitea" },
"DotEscape": func(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) },
"QueryEscape": url.QueryEscape,
"ShortSha": ShortSha,
"PathEscapeSegments": PathEscapeSegments,
"FileSize": FileSize,
"HTMLFormat": HTMLFormat,
"Dict": Dict,
"gt": Gt, // override built-in for JSON float64/int coercion
"lt": Lt,
"ge": Ge,
"le": Le,
}
// ShortSha truncates a commit SHA to 10 characters.
func ShortSha(s string) string {
if len(s) > 10 {
return s[:10]
}
return s
}
// PathEscapeSegments splits a path and URL-escapes each segment.
func PathEscapeSegments(s string) string {
parts := strings.Split(s, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
// FileSize formats a byte count as a human-readable string (e.g. "2.5 MB").
func FileSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for nn := n / unit; nn >= unit && exp < 3; nn /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
}
// HTMLFormat substitutes %s/%d placeholders with HTML-escaped values.
func HTMLFormat(fmtStr string, args ...any) template.HTML {
result := fmtStr
for i, arg := range args {
escaped := template.HTMLEscapeString(fmt.Sprint(arg))
result = strings.Replace(result, fmt.Sprintf("%%[%d]s", i+1), escaped, 1)
if !strings.Contains(result, fmt.Sprintf("%%[%d]s", i+1)) {
result = strings.Replace(result, "%s", escaped, 1)
result = strings.Replace(result, "%d", fmt.Sprint(arg), 1)
}
}
return template.HTML(result)
}
// Dict builds a map from alternating key/value pairs (used by Gitea's Dict function).
func Dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("odd number of Dict args")
}
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("Dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}
// toFloat64 coerces a value to float64 if numeric.
func toFloat64(v any) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case float32:
return float64(val), true
case int:
return float64(val), true
case int64:
return float64(val), true
case int32:
return float64(val), true
case int16:
return float64(val), true
case int8:
return float64(val), true
case uint:
return float64(val), true
case uint64:
return float64(val), true
case uint32:
return float64(val), true
case uint16:
return float64(val), true
case uint8:
return float64(val), true
default:
return 0, false
}
}
// Gt is a type-coercing > comparison that handles JSON float64 vs int literals.
func Gt(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af > bf
}
// Fallback to string comparison
return fmt.Sprint(a) > fmt.Sprint(b)
}
// Lt is a type-coercing < comparison that handles JSON float64 vs int literals.
func Lt(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af < bf
}
return fmt.Sprint(a) < fmt.Sprint(b)
}
// Ge is a type-coercing >= comparison.
func Ge(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af >= bf
}
return fmt.Sprint(a) >= fmt.Sprint(b)
}
// Le is a type-coercing <= comparison.
func Le(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af <= bf
}
return fmt.Sprint(a) <= fmt.Sprint(b)
}
+96
View File
@@ -0,0 +1,96 @@
package preview
import (
"fmt"
"html/template"
"strings"
)
// Locale provides English fallback translations, matching Gitea's locale.Tr interface.
type Locale struct{}
// Tr translates a key and substitutes positional %s/%d arguments.
// HTML arguments are preserved; plain strings are HTML-escaped.
func (l Locale) Tr(key string, args ...any) template.HTML {
s := LocaleEN[key]
if s == "" {
s = key
}
for _, arg := range args {
var val string
if h, ok := arg.(template.HTML); ok {
val = string(h) // already safe HTML — don't double-escape
} else {
val = template.HTMLEscapeString(fmt.Sprint(arg))
}
s = strings.Replace(s, "%s", val, 1)
s = strings.Replace(s, "%d", fmt.Sprint(arg), 1)
}
return template.HTML(s)
}
// TrN picks singular/plural by count, then delegates to Tr.
func (l Locale) TrN(count any, singular, plural string, args ...any) template.HTML {
n := 0
switch v := count.(type) {
case int:
n = v
case int64:
n = int(v)
case float64:
n = int(v)
default:
n = 1
}
key := plural
if n == 1 {
key = singular
}
return l.Tr(key, args...)
}
// LocaleEN holds all English translation strings used by Gitea mail templates.
var LocaleEN = map[string]string{
"mail.activate_account.title": "Activate your %s account",
"mail.hi_user_x": "Hi %s,",
"mail.activate_account.text_1": "Welcome to %s! Please activate your account by clicking the button below.",
"mail.activate_account.text_2": "This activation link will expire in %s.",
"mail.activate_email.title": "Verify your email address for %s",
"mail.activate_email.text": "Please verify your email address. The link will expire in %s.",
"mail.register_notify.title": "Welcome to %s, %s",
"mail.register_notify.text_1": "Your account on %s has been created successfully.",
"mail.register_notify.text_2": "Your username is: %s",
"mail.register_notify.text_3": "If you need to set a password, visit %s.",
"mail.reset_password.title": "Reset your %s password",
"mail.reset_password.text": "Click the link below to reset your password. It will expire in %s.",
"mail.link_not_working_do_paste": "If the button doesn't work, copy and paste this link into your browser:",
"mail.team_invite.text_1": "%s has invited you to join the team %s in the organization %s.",
"mail.team_invite.text_2": "Click the button below to accept the invitation.",
"mail.team_invite.text_3": "This invitation was sent to %s.",
"mail.repo.collaborator.added.text": "You have been added as a collaborator on the repository",
"mail.view_it_on": "View it on %s",
"mail.repo.transfer.body": "The repository %s has been transferred to you.",
"mail.release.new.text": "%s published a new release %s in the repository %s.",
"mail.release.title": "Release: %s",
"mail.release.note": "Release Notes:",
"mail.release.downloads": "Downloads",
"mail.release.download.zip": "Source Code (ZIP)",
"mail.release.download.targz": "Source Code (TAR.GZ)",
"mail.issue_assigned.pull": "%s assigned pull request %s to you in the repository %s.",
"mail.issue_assigned.issue": "%s assigned issue %s to you in the repository %s.",
"mail.issue.x_mentioned_you": "%s mentioned you.",
"mail.issue.action.close": "%s closed issue #%d.",
"mail.issue.action.reopen": "%s reopened issue #%d.",
"mail.issue.action.merge": "%s merged pull request #%d into %s.",
"mail.issue.action.approve": "%s approved this change.",
"mail.issue.action.reject": "%s requested changes.",
"mail.issue.action.review": "%s reviewed this change.",
"mail.issue.action.review_dismissed": "%s dismissed the review from %s.",
"mail.issue.action.ready_for_review": "%s marked this as ready for review.",
"mail.issue.action.new": "%s created issue #%d.",
"mail.issue.action.force_push": "%s force-pushed the %s branch from %s to %s.",
"mail.issue.action.push_1": "%s pushed %d commit to %s.",
"mail.issue.action.push_n": "%s pushed %d commits to %s.",
"mail.issue.in_tree_path": "In %s:",
"mail.reply": "Reply to this email",
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"os"
"github.com/urfave/cli/v2"
cmds "gitea-mail-templates/tools/cli"
)
func main() {
app := &cli.App{
Name: "tools",
Usage: "Gitea Mail Templates CLI — manage and preview email template themes",
UsageText: "tools <command> [args...] [--flags]",
Commands: []*cli.Command{
cmds.ListCommand(),
cmds.CreateCommand(),
cmds.DeleteCommand(),
cmds.PreviewCommand(),
},
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}