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:
1 parent
77854ee0ed
commit
0bf982ffb9
30 files changed
+1273
-539
No files matched your search
+1
-1
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 を切り替えて確認
|
||||||
|
|
||||||
|
|||||||
@@ -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 모드 전환 확인
|
||||||
|
|
||||||
|
|||||||
@@ -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 для проверки
|
||||||
|
|
||||||
|
|||||||
@@ -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 模式间切换验证效果
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+57
-61
@@ -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,18 +266,40 @@ 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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user