chore: simplify workflows and unify tool logging and mail presentation
This commit is contained in:
1 parent
f4d96de79e
commit
c93f2bf8b2
40 files changed
+518
-1510
No files matched your search
@@ -1,11 +1,10 @@
|
||||
"""Gitea API operations for the tracker and release workflows (Python stdlib only)."""
|
||||
"""Gitea API operations for the release workflow (Python stdlib only)."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote, urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
@@ -50,56 +49,33 @@ class GiteaAPI:
|
||||
raise RuntimeError(f"Gitea API {method} {path}: HTTP {status}") from None
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(["git", *args], text=True).strip()
|
||||
|
||||
|
||||
def create_pull_request(api, version):
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
|
||||
raise ValueError("Expected a stable X.Y.Z upstream version")
|
||||
branch = "track/gitea-" + version
|
||||
title = f"Track Gitea {version} compatibility"
|
||||
changed = git("diff", "--name-only", "HEAD", "--").splitlines()
|
||||
if any(not name.endswith(".md") for name in changed):
|
||||
raise ValueError("Tracker may commit only Markdown changes")
|
||||
if not changed:
|
||||
print("[PASS] No tracked documentation changes")
|
||||
return
|
||||
|
||||
# Repeated scheduled runs can find a branch from an earlier pending PR.
|
||||
# Reuse identical content, but never force-push over a changed branch.
|
||||
remote = git("ls-remote", "--heads", "origin", "refs/heads/" + branch)
|
||||
if remote:
|
||||
git("fetch", "origin", "refs/heads/" + branch)
|
||||
if git("diff", "--name-only", "FETCH_HEAD", "--"):
|
||||
raise ValueError(f"Existing {branch} differs; review it before updating the tracking PR")
|
||||
else:
|
||||
git("switch", "-c", branch)
|
||||
git("add", "--", *changed)
|
||||
git("-c", "user.name=release-bot", "-c", "user.email=release-bot@users.noreply.local",
|
||||
"commit", "-m", f"docs: track Gitea {version} pending verification")
|
||||
git("push", "origin", "HEAD:refs/heads/" + branch)
|
||||
|
||||
def create_issue(api, version):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", version):
|
||||
raise ValueError("Expected a stable vX.Y.Z release tag")
|
||||
marker = f"<!-- gitea-mail-templates:template-release:{version} -->"
|
||||
title = f"Update documentation for template release {version}"
|
||||
page = 1
|
||||
while True:
|
||||
pulls = api.request("GET", f"/pulls?state=open&base_branch=main&limit=50&page={page}")
|
||||
for pull in pulls:
|
||||
if (pull["head"]["ref"] == branch and pull["base"]["ref"] == "main"
|
||||
and pull["head"]["repo"]["full_name"] == pull["base"]["repo"]["full_name"]):
|
||||
print(f"[PASS] Tracking PR already exists: {pull['html_url']}")
|
||||
return
|
||||
if not pulls:
|
||||
issues = api.request("GET", f"/issues?state=all&type=issues&limit=50&page={page}")
|
||||
for issue in issues:
|
||||
if issue.get("pull_request") is None and (marker in (issue.get("body") or "") or issue.get("title") == title):
|
||||
print(f"[PASS] Reminder issue already exists: {issue['html_url']}")
|
||||
return issue
|
||||
if not issues:
|
||||
break
|
||||
page += 1
|
||||
body = f"""Gitea **{version}** is recorded as [PENDING]. Verified/tested versions remain unchanged.
|
||||
body = f"""{marker}
|
||||
Template release **{version}** has been published. Documentation is maintained manually.
|
||||
|
||||
- [ ] Review upstream mail templates, mailer data, functions and translation keys.
|
||||
- [ ] Run `go test ./...` and `go run . preview all` from `tools/`.
|
||||
- [ ] Record results in `COMPATIBILITY.md`; update verified status only after testing.
|
||||
- [ ] Release template changes separately with reviewed notes and assets.
|
||||
- [ ] Update the release version in the English and Chinese READMEs.
|
||||
- [ ] Review compatibility records and mark versions verified only according to test results.
|
||||
- [ ] Check release links, release notes and remaining version references.
|
||||
|
||||
This reminder does not change repository files, branches or existing release assets.
|
||||
"""
|
||||
pull = api.request("POST", "/pulls", {"title": title, "head": branch, "base": "main", "body": body})
|
||||
print(f"[PASS] Created tracking PR: {pull['html_url']}")
|
||||
issue = api.request("POST", "/issues", {"title": title, "body": body})
|
||||
print(f"[PASS] Created reminder issue: {issue['html_url']}")
|
||||
return issue
|
||||
|
||||
|
||||
def publish_release(api, version, root=Path(".")):
|
||||
@@ -135,16 +111,16 @@ def publish_release(api, version, root=Path(".")):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("operation", choices=("create-pull-request", "publish-release"))
|
||||
parser.add_argument("operation", choices=("create-issue", "publish-release"))
|
||||
parser.add_argument("--version", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
api = GiteaAPI(os.environ.get("GITEA_SERVER_URL", ""), os.environ.get("GITEA_REPOSITORY", ""), os.environ.get("GITEA_TOKEN", ""))
|
||||
if args.operation == "create-pull-request":
|
||||
create_pull_request(api, args.version)
|
||||
if args.operation == "create-issue":
|
||||
create_issue(api, args.version)
|
||||
else:
|
||||
publish_release(api, args.version)
|
||||
except (ValueError, KeyError, OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
except (ValueError, KeyError, OSError, RuntimeError) as error:
|
||||
parser.exit(1, f"[FAIL] {error}\n")
|
||||
|
||||
|
||||
|
||||
@@ -87,8 +87,15 @@ def collect(root, version):
|
||||
if sorted(payload) != names or any(set(payload[name]) != set(manifest["registry"]) for name in names):
|
||||
raise ValueError(f"Incomplete preview language bundle: {lang}")
|
||||
files[f"preview/rendered/{lang}.js"] = data
|
||||
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]:
|
||||
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]:
|
||||
files[name] = read_file(root / name)
|
||||
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
|
||||
if (root / name).exists():
|
||||
files[name] = read_file(root / name)
|
||||
for directory in [root / "docs", root / ".github" / "release-notes"]:
|
||||
for path in sorted(directory.rglob("*.md")):
|
||||
name = path.relative_to(root).as_posix()
|
||||
files[name] = read_file(checked_path(root, name))
|
||||
for path in sorted((root / "docs" / "images").iterdir()):
|
||||
if path.is_file() and (path.suffix == ".png" or path.name == "README.md"):
|
||||
files["docs/images/" + path.name] = read_file(path)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Offline Gitea API and disposable-Git workflow regression tests."""
|
||||
"""Offline Gitea API and issue-reminder regression tests."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
@@ -101,59 +99,43 @@ class ReleaseTests(unittest.TestCase):
|
||||
api.request.assert_not_called()
|
||||
|
||||
|
||||
class PullRequestTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.previous = Path.cwd()
|
||||
os.chdir(self.root)
|
||||
self.addCleanup(os.chdir, self.previous)
|
||||
subprocess.run(["git", "init", "--bare", "remote.git"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", "-b", "main", "work"], check=True, capture_output=True)
|
||||
os.chdir(self.root / "work")
|
||||
ACTIONS.git("config", "user.name", "test")
|
||||
ACTIONS.git("config", "user.email", "test@example.invalid")
|
||||
ACTIONS.git("remote", "add", "origin", str(self.root / "remote.git"))
|
||||
Path("README.md").write_text("Baseline\n")
|
||||
Path("source.txt").write_text("Source\n")
|
||||
ACTIONS.git("add", ".")
|
||||
ACTIONS.git("commit", "-m", "baseline")
|
||||
ACTIONS.git("push", "origin", "main")
|
||||
Path("README.md").write_text("Pending 28.1.0\n")
|
||||
|
||||
def test_create_then_reuse_branch_and_paginated_pr(self):
|
||||
class IssueTests(unittest.TestCase):
|
||||
def test_creates_release_documentation_issue(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [[], {"html_url": "https://git.example/pulls/1"}]
|
||||
ACTIONS.create_pull_request(api, "28.1.0")
|
||||
self.assertEqual("track/gitea-28.1.0", ACTIONS.git("branch", "--show-current"))
|
||||
payload = api.request.call_args.args[2]
|
||||
self.assertEqual("main", payload["base"])
|
||||
self.assertEqual("track/gitea-28.1.0", payload["head"])
|
||||
self.assertIn("[PENDING]", payload["body"])
|
||||
original_head = ACTIONS.git("rev-parse", "HEAD")
|
||||
ACTIONS.git("switch", "main")
|
||||
Path("README.md").write_text("Pending 28.1.0\n")
|
||||
api.reset_mock()
|
||||
api.request.side_effect = [[{"head": {"ref": "unrelated"}}], [{
|
||||
"head": {"ref": "track/gitea-28.1.0", "repo": {"full_name": "owner/repo"}},
|
||||
"base": {"ref": "main", "repo": {"full_name": "owner/repo"}},
|
||||
"html_url": "https://git.example/pulls/1",
|
||||
}]]
|
||||
ACTIONS.create_pull_request(api, "28.1.0")
|
||||
api.request.side_effect = [[], {"html_url": "https://git.example/issues/1"}]
|
||||
ACTIONS.create_issue(api, "v28.1.0")
|
||||
calls = api.request.call_args_list
|
||||
self.assertEqual(["GET", "POST"], [c.args[0] for c in calls])
|
||||
self.assertEqual("/issues", calls[-1].args[1])
|
||||
payload = calls[-1].args[2]
|
||||
self.assertIn("<!-- gitea-mail-templates:template-release:v28.1.0 -->", payload["body"])
|
||||
self.assertIn("Documentation is maintained manually", payload["body"])
|
||||
|
||||
def test_reuses_closed_issue_on_later_page_and_ignores_pull_requests(self):
|
||||
api = Mock()
|
||||
marker = "<!-- gitea-mail-templates:template-release:v28.1.0 -->"
|
||||
api.request.side_effect = [[{"body": marker, "pull_request": {"url": "pr"}}],
|
||||
[{"body": marker, "state": "closed", "title": "Renamed", "html_url": "https://git.example/issues/1"}]]
|
||||
ACTIONS.create_issue(api, "v28.1.0")
|
||||
self.assertEqual(["GET", "GET"], [c.args[0] for c in api.request.call_args_list])
|
||||
self.assertEqual(original_head, ACTIONS.git("rev-parse", "FETCH_HEAD"))
|
||||
Path("README.md").write_text("Different pending content\n")
|
||||
with self.assertRaisesRegex(ValueError, "Existing.*differs"):
|
||||
ACTIONS.create_pull_request(api, "28.1.0")
|
||||
self.assertIn("state=all", api.request.call_args_list[0].args[1])
|
||||
self.assertIn("page=2", api.request.call_args_list[1].args[1])
|
||||
|
||||
def test_non_documentation_change_is_not_committed(self):
|
||||
Path("source.txt").write_text("Changed source\n")
|
||||
def test_other_release_versions_do_not_suppress_reminder(self):
|
||||
api = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "only Markdown"):
|
||||
ACTIONS.create_pull_request(api, "28.1.0")
|
||||
api.request.side_effect = [[{"body": "<!-- gitea-mail-templates:template-release:v28.0.0 -->"}], [],
|
||||
{"html_url": "https://git.example/issues/2"}]
|
||||
ACTIONS.create_issue(api, "v28.1.0")
|
||||
payload = api.request.call_args.args[2]
|
||||
self.assertIn("template-release:v28.1.0", payload["body"])
|
||||
self.assertIn("READMEs", payload["body"])
|
||||
|
||||
def test_invalid_versions_fail_before_api_call(self):
|
||||
api = Mock()
|
||||
for version in ("28.1.0", "v28.1.0-rc1", "../../x"):
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.create_issue(api, version)
|
||||
api.request.assert_not_called()
|
||||
self.assertEqual("baseline", ACTIONS.git("log", "-1", "--format=%s"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -33,7 +33,7 @@ class PackagingTests(unittest.TestCase):
|
||||
manifest = {"upstream": self.tag, "upstream_commit": self.commit, "themes": ["demo"], "languages": ["en-US"], "registry": {"activate": {}}}
|
||||
self.write("preview/rendered.js", ("window.__MAIL_PREVIEW__ = " + json.dumps(manifest) + ";").encode())
|
||||
self.write("preview/rendered/en-US.js", ('window.__MAIL_PREVIEW_DATA__["en-US"] = ' + json.dumps({"demo": {"activate": "<html></html>"}}) + ";").encode())
|
||||
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html", "docs/images/README.md"]:
|
||||
for name in ["LICENSE", "AGENTS.md", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html", "docs/images/README.md"]:
|
||||
self.write(name, b"test")
|
||||
|
||||
def write(self, name, data):
|
||||
@@ -72,6 +72,30 @@ class PackagingTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
|
||||
def test_includes_nested_documentation_and_release_note_sources(self):
|
||||
documents = {
|
||||
"docs/INDEX.zh-CN.md": "中文索引".encode(),
|
||||
"docs/images/README.zh-CN.md": "截图指南".encode(),
|
||||
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode(),
|
||||
".github/release-notes/v28.0.0.md": b"English release notes",
|
||||
}
|
||||
for name, data in documents.items():
|
||||
self.write(name, data)
|
||||
self.write("docs/private.tmp", b"not documentation")
|
||||
files = PACKER.collect(self.root, self.tag)
|
||||
for name, data in documents.items():
|
||||
self.assertEqual(files[name], data)
|
||||
self.assertIn("AGENTS.md", files)
|
||||
self.assertNotIn("docs/private.tmp", files)
|
||||
|
||||
def test_packages_without_removed_optional_root_documents(self):
|
||||
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
|
||||
(self.root / name).unlink()
|
||||
files = PACKER.collect(self.root, self.tag)
|
||||
self.assertNotIn("AGENTS.md", files)
|
||||
self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
|
||||
self.assertIn("GITEA-LICENSE", files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,285 +0,0 @@
|
||||
"""Offline tests for the compatibility tracker."""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("track_gitea_release.py")
|
||||
SPEC = importlib.util.spec_from_file_location("track_gitea_release", SCRIPT)
|
||||
TRACKER = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(TRACKER)
|
||||
RELEASE_URL = "https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest"
|
||||
FIXTURES = {
|
||||
"COMPATIBILITY.md": """# Gitea Compatibility
|
||||
<!-- DOC-TAGS: {"TRACKER":["LATEST-VERIFIED","VERSION-MAP","HISTORY"]} -->
|
||||
<!-- TRACKER:LATEST-VERIFIED -->
|
||||
> **Latest verified:** Release v28.0.0 passes tests.
|
||||
<!-- /TRACKER:LATEST-VERIFIED -->
|
||||
<!-- TRACKER:VERSION-MAP -->
|
||||
| Gitea version | Recommended template release | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 28.1.0 | — | [PENDING] | Compatibility verification pending |
|
||||
| 28.0.0 | **v28.0.0** | [PASS] | Tested |
|
||||
<!-- /TRACKER:VERSION-MAP -->
|
||||
<!-- TRACKER:HISTORY -->
|
||||
| Gitea | Release Date | Mail Template Changes | Impact |
|
||||
|---|---|---|---|
|
||||
| **28.1.0** | 2026-10-06 | [PENDING] Review of mail-template changes | TBD |
|
||||
| **28.0.0** | 2026-09-29 | FormatByteSize | Yes |
|
||||
| **≤ 1.24.x** | — | Old directory | [UNSUPPORTED] |
|
||||
<!-- /TRACKER:HISTORY -->
|
||||
""",
|
||||
"README.md": f"""# Gitea Mail Templates
|
||||
<!-- DOC-TAGS: {{"TRACKER":["BADGE","LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]}} -->
|
||||
<!-- RELEASE:HEADER -->
|
||||
> Latest Release: [v28.0.0]({RELEASE_URL})
|
||||
<!-- /RELEASE:HEADER -->
|
||||
<!-- TRACKER:BADGE -->
|
||||
[](COMPATIBILITY.md)
|
||||
<!-- /TRACKER:BADGE -->
|
||||
<!-- TRACKER:LATEST-TESTED -->
|
||||
- **Latest tested:** Gitea 28.0.0
|
||||
<!-- /TRACKER:LATEST-TESTED -->
|
||||
<!-- RELEASE:SUMMARY -->
|
||||
- **Latest release:** [v28.0.0]({RELEASE_URL})
|
||||
<!-- /RELEASE:SUMMARY -->
|
||||
<!-- TRACKER:UPSTREAM -->
|
||||
- **Upstream Gitea 28.1.0:** [PENDING]
|
||||
<!-- /TRACKER:UPSTREAM -->
|
||||
""",
|
||||
"AGENTS.md": """# Agents
|
||||
<!-- DOC-TAGS: {"TRACKER":["UPSTREAM"],"RELEASE":["CURRENT"]} -->
|
||||
<!-- RELEASE:CURRENT -->
|
||||
- Current template release: **v28.0.0**.
|
||||
<!-- /RELEASE:CURRENT -->
|
||||
<!-- TRACKER:UPSTREAM -->
|
||||
- Latest upstream Gitea release: 28.1.0 [PENDING].
|
||||
<!-- /TRACKER:UPSTREAM -->
|
||||
""",
|
||||
"docs/README.zh-CN.md": f"""# Gitea 邮件模板
|
||||
<!-- DOC-TAGS: {{"TRACKER":["LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]}} -->
|
||||
<!-- RELEASE:HEADER -->
|
||||
> 最新发布版:[v28.0.0]({RELEASE_URL})
|
||||
<!-- /RELEASE:HEADER -->
|
||||
<!-- TRACKER:LATEST-TESTED -->
|
||||
- **最新测试:** Gitea 28.0.0
|
||||
<!-- /TRACKER:LATEST-TESTED -->
|
||||
<!-- RELEASE:SUMMARY -->
|
||||
- **最新发布版:** [v28.0.0]({RELEASE_URL})
|
||||
<!-- /RELEASE:SUMMARY -->
|
||||
<!-- TRACKER:UPSTREAM -->
|
||||
- **上游 Gitea 28.1.0:** [PENDING]
|
||||
<!-- /TRACKER:UPSTREAM -->
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
class TrackerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory(prefix="tracker-test-", dir=TRACKER.ROOT)
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
self.paths = ("COMPATIBILITY.md", "README.md", "AGENTS.md", "docs/README.zh-CN.md")
|
||||
self.originals = {}
|
||||
for name in self.paths:
|
||||
destination = self.root / name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = FIXTURES[name]
|
||||
destination.write_text(content, encoding="utf-8")
|
||||
self.originals[name] = content
|
||||
|
||||
def read(self, name):
|
||||
return (self.root / name).read_text(encoding="utf-8")
|
||||
|
||||
def assert_unchanged(self):
|
||||
for name, content in self.originals.items():
|
||||
self.assertEqual(content, self.read(name), name)
|
||||
|
||||
def test_new_release_adds_only_pending_rows(self):
|
||||
changed = TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
self.assertEqual({self.root / name for name in self.paths}, set(changed))
|
||||
compatibility = self.read("COMPATIBILITY.md")
|
||||
self.assertIn("| 28.2.0 | — | [PENDING] | Compatibility verification pending |", compatibility)
|
||||
self.assertIn("| **28.2.0** | 2026-11-01 | [PENDING]", compatibility)
|
||||
self.assertIn("| 28.0.0 | **v28.0.0** | [PASS] | Tested |", compatibility)
|
||||
self.assertIn("Latest verified:** Release v28.0.0", compatibility)
|
||||
self.assertIn("Gitea-28.2.0%20%5BPENDING%5D%20%7C%2028.0.0%20tested-yellow", self.read("README.md"))
|
||||
self.assertIn("Gitea 28.2.0:** [PENDING]", self.read("README.md"))
|
||||
self.assertIn("Gitea 28.2.0:** [PENDING]", self.read("docs/README.zh-CN.md"))
|
||||
self.assertIn("Gitea release: 28.2.0 [PENDING]", self.read("AGENTS.md"))
|
||||
self.assertIn("Latest tested:** Gitea 28.0.0", self.read("README.md"))
|
||||
self.assertIn("最新测试:** Gitea 28.0.0", self.read("docs/README.zh-CN.md"))
|
||||
self.assertEqual([], TRACKER.apply_release(self.root, "28.2.0", "2026-11-01"))
|
||||
|
||||
def test_repository_markers_are_valid(self):
|
||||
documents, blocks = TRACKER.documents(TRACKER.ROOT)
|
||||
self.assertTrue({TRACKER.ROOT / name for name in self.paths}.issubset(documents))
|
||||
self.assertIn(("TRACKER", "HISTORY"), {(block.subject, block.content) for block in blocks})
|
||||
self.assertIn(("RELEASE", "HEADER"), {(block.subject, block.content) for block in blocks})
|
||||
TRACKER.latest_tested(documents, blocks)
|
||||
matrix = next(block for block in blocks if (block.subject, block.content) == ("TRACKER", "VERSION-MAP"))
|
||||
history = next(block for block in blocks if (block.subject, block.content) == ("TRACKER", "HISTORY"))
|
||||
self.assertEqual(
|
||||
TRACKER.table(TRACKER.body(documents[matrix.path], matrix), "VERSION-MAP"),
|
||||
TRACKER.table(TRACKER.body(documents[history.path], history), "HISTORY"),
|
||||
)
|
||||
|
||||
def test_existing_version_is_idempotent(self):
|
||||
self.assertEqual([], TRACKER.apply_release(self.root, "28.1.0", "2026-10-06"))
|
||||
self.assert_unchanged()
|
||||
|
||||
def test_partial_update_is_repaired(self):
|
||||
compatibility = self.root / "COMPATIBILITY.md"
|
||||
partial = self.read("COMPATIBILITY.md").replace("| 28.1.0 | — | [PENDING] | Compatibility verification pending |\n", "")
|
||||
compatibility.write_text(partial, encoding="utf-8")
|
||||
self.assertEqual([compatibility], TRACKER.apply_release(self.root, "28.1.0", "2026-10-06"))
|
||||
self.assertIn("| 28.1.0 | — | [PENDING] | Compatibility verification pending |", self.read("COMPATIBILITY.md"))
|
||||
|
||||
def test_older_version_does_not_rewrite_history(self):
|
||||
self.assertEqual([], TRACKER.apply_release(self.root, "28.0.0", "2026-09-29"))
|
||||
self.assert_unchanged()
|
||||
|
||||
def test_dry_run_does_not_write(self):
|
||||
self.assertEqual(4, len(TRACKER.apply_release(self.root, "28.2.0", "2026-11-01", dry_run=True)))
|
||||
self.assert_unchanged()
|
||||
|
||||
def test_missing_marker_fails_without_writing(self):
|
||||
compatibility = self.root / "COMPATIBILITY.md"
|
||||
compatibility.write_text(self.read("COMPATIBILITY.md").replace("<!-- TRACKER:HISTORY -->\n", ""), encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
self.assertEqual(self.originals["README.md"], self.read("README.md"))
|
||||
|
||||
def test_unknown_marker_fails_without_writing(self):
|
||||
news = self.root / "docs/NEWS.md"
|
||||
news.write_text('<!-- DOC-TAGS: {"TRACKER":["FUTURE-UNKNOWN"]} -->\n', encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "Unknown TRACKER content"):
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
self.assert_unchanged()
|
||||
|
||||
def test_new_marked_document_is_discovered(self):
|
||||
news = self.root / "misc/notes/NEWS.md"
|
||||
news.parent.mkdir(parents=True)
|
||||
news.write_text('<!-- DOC-TAGS: {"TRACKER":["UPSTREAM"]} -->\n<!-- TRACKER:UPSTREAM -->\nGitea 28.1.0 [PENDING]\n<!-- /TRACKER:UPSTREAM -->\n', encoding="utf-8")
|
||||
changed = TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
self.assertIn(news, changed)
|
||||
self.assertIn("Gitea 28.2.0 [PENDING]", news.read_text(encoding="utf-8"))
|
||||
|
||||
def test_duplicate_block_uses_first_in_document_order(self):
|
||||
english = self.root / "README.md"
|
||||
duplicate = "<!-- TRACKER:UPSTREAM -->\nGitea 28.1.0 [PENDING]\n<!-- /TRACKER:UPSTREAM -->\n"
|
||||
english.write_text(self.read("README.md") + duplicate, encoding="utf-8")
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
self.assertIn("**Upstream Gitea 28.2.0:** [PENDING]", self.read("README.md"))
|
||||
self.assertTrue(self.read("README.md").endswith(duplicate))
|
||||
|
||||
def test_unclosed_block_rejected_without_writing(self):
|
||||
english = self.root / "README.md"
|
||||
english.write_text(self.read("README.md").replace("<!-- /TRACKER:UPSTREAM -->", ""), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "Unclosed tag block"):
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
self.assertEqual(self.originals["COMPATIBILITY.md"], self.read("COMPATIBILITY.md"))
|
||||
|
||||
def test_undeclared_block_rejected(self):
|
||||
english = self.root / "README.md"
|
||||
english.write_text(self.read("README.md") + "<!-- RELEASE:CURRENT -->\nv28.0.0\n<!-- /RELEASE:CURRENT -->\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "Undeclared RELEASE:CURRENT"):
|
||||
TRACKER.apply_template_release(self.root, "v28.0.1")
|
||||
|
||||
def test_misspelled_subject_rejected(self):
|
||||
english = self.root / "README.md"
|
||||
english.write_text(self.read("README.md").replace("<!-- TRACKER:UPSTREAM -->", "<!-- TRACER:UPSTREAM -->"), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "Undeclared TRACER:UPSTREAM"):
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
|
||||
def test_block_without_manifest_rejected(self):
|
||||
news = self.root / "NEWS.md"
|
||||
news.write_text("<!-- TRACKER:UPSTREAM -->\nGitea 28.1.0 [PENDING]\n<!-- /TRACKER:UPSTREAM -->\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "without DOC-TAGS"):
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
|
||||
def test_mismatched_close_rejected(self):
|
||||
english = self.root / "README.md"
|
||||
english.write_text(self.read("README.md").replace("<!-- /RELEASE:SUMMARY -->", "<!-- /RELEASE:HEADER -->"), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "Unmatched closing tag"):
|
||||
TRACKER.apply_template_release(self.root, "v28.0.1")
|
||||
|
||||
def test_disagreeing_tested_versions_fail(self):
|
||||
chinese = self.root / "docs/README.zh-CN.md"
|
||||
chinese.write_text(self.read("docs/README.zh-CN.md").replace("**最新测试:** Gitea 28.0.0", "**最新测试:** Gitea 28.1.0"), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "disagree"):
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
|
||||
def test_after_verification_new_version_becomes_pending(self):
|
||||
english = self.root / "README.md"
|
||||
chinese = self.root / "docs/README.zh-CN.md"
|
||||
agents = self.root / "AGENTS.md"
|
||||
english.write_text(self.read("README.md")
|
||||
.replace("**Latest tested:** Gitea 28.0.0", "**Latest tested:** Gitea 28.1.0")
|
||||
.replace("Gitea-28.1.0%20%5BPENDING%5D%20%7C%2028.0.0%20tested-yellow", "Gitea-28.1.0%20%7C%2028.1.0%20tested-blue")
|
||||
.replace("**Upstream Gitea 28.1.0:** [PENDING]", "**Upstream Gitea 28.1.0:** [PASS]"), encoding="utf-8")
|
||||
chinese.write_text(self.read("docs/README.zh-CN.md")
|
||||
.replace("**最新测试:** Gitea 28.0.0", "**最新测试:** Gitea 28.1.0")
|
||||
.replace("**上游 Gitea 28.1.0:** [PENDING]", "**上游 Gitea 28.1.0:** [PASS]"), encoding="utf-8")
|
||||
agents.write_text(self.read("AGENTS.md").replace("28.1.0 [PENDING]", "28.1.0 [PASS]"), encoding="utf-8")
|
||||
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
|
||||
self.assertIn("Gitea-28.2.0%20%5BPENDING%5D%20%7C%2028.1.0%20tested-yellow", self.read("README.md"))
|
||||
self.assertIn("Gitea 28.2.0:** [PENDING]", self.read("README.md"))
|
||||
self.assertIn("Latest tested:** Gitea 28.1.0", self.read("README.md"))
|
||||
self.assertIn("最新测试:** Gitea 28.1.0", self.read("docs/README.zh-CN.md"))
|
||||
|
||||
def test_invalid_version_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
TRACKER.apply_release(self.root, "28.2.0-rc1", "2026-11-01")
|
||||
|
||||
def test_template_release_updates_all_marked_labels(self):
|
||||
changed = TRACKER.apply_template_release(self.root, "v28.0.1")
|
||||
self.assertEqual({self.root / name for name in ("README.md", "docs/README.zh-CN.md", "AGENTS.md")}, set(changed))
|
||||
for name in ("README.md", "docs/README.zh-CN.md"):
|
||||
self.assertEqual(2, self.read(name).count("[v28.0.1](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)"))
|
||||
self.assertNotIn("/releases/tag/v28.0.1", self.read(name))
|
||||
self.assertIn("Current template release: **v28.0.1**", self.read("AGENTS.md"))
|
||||
self.assertEqual(self.originals["COMPATIBILITY.md"], self.read("COMPATIBILITY.md"))
|
||||
self.assertEqual([], TRACKER.apply_template_release(self.root, "v28.0.1"))
|
||||
self.assertEqual([], TRACKER.apply_template_release(self.root, "v28.0.0"))
|
||||
|
||||
def test_template_release_dry_run_does_not_write(self):
|
||||
self.assertEqual(3, len(TRACKER.apply_template_release(self.root, "v28.0.1", dry_run=True)))
|
||||
self.assert_unchanged()
|
||||
|
||||
def test_template_release_rejects_mismatched_labels(self):
|
||||
english = self.root / "README.md"
|
||||
english.write_text(self.read("README.md").replace("Latest Release: [v28.0.0]", "Latest Release: [v28.0.1]"), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "disagree"):
|
||||
TRACKER.apply_template_release(self.root, "v28.0.2")
|
||||
|
||||
def test_template_release_rejects_tagged_link(self):
|
||||
english = self.root / "README.md"
|
||||
english.write_text(self.read("README.md").replace("[v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)", "[v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v28.0.0)", 1), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "/releases/latest"):
|
||||
TRACKER.apply_template_release(self.root, "v28.0.1")
|
||||
|
||||
def test_template_release_rejects_invalid_tag(self):
|
||||
with self.assertRaises(ValueError):
|
||||
TRACKER.apply_template_release(self.root, "v28.0.1-rc1")
|
||||
|
||||
def test_release_api_uses_published_date(self):
|
||||
payload = {"tag_name": "v28.2.0", "published_at": "2026-11-01T08:15:00Z", "draft": False, "prerelease": False}
|
||||
with patch.object(TRACKER, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode("utf-8"))) as mocked:
|
||||
self.assertEqual(("28.2.0", "2026-11-01"), TRACKER.fetch_release("28.2.0"))
|
||||
self.assertEqual(f"{TRACKER.RELEASES_API}/tags/v28.2.0", mocked.call_args.args[0].full_url)
|
||||
|
||||
def test_prerelease_is_rejected(self):
|
||||
payload = {"tag_name": "v28.2.0", "published_at": "2026-11-01T08:15:00Z", "prerelease": True}
|
||||
with patch.object(TRACKER, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode("utf-8"))):
|
||||
with self.assertRaises(ValueError):
|
||||
TRACKER.fetch_release(None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,311 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update marker-driven documentation for upstream and template releases."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RELEASES_API = "https://api.github.com/repos/go-gitea/gitea/releases"
|
||||
VERSION_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)\Z")
|
||||
ROW_RE = re.compile(r"^\|\s*(?:\*\*)?(\d+\.\d+\.\d+)(?:\*\*)?\s*\|")
|
||||
INLINE_VERSION_RE = re.compile(r"(?<!\d)\d+\.\d+\.\d+(?!\d)")
|
||||
STATUS_RE = re.compile(r"\[(?:PENDING|PASS|WARN|FAIL|UNSUPPORTED)\]")
|
||||
MANIFEST_RE = re.compile(r"^<!-- DOC-TAGS: (\{.*\}) -->\s*$")
|
||||
TAG_RE = re.compile(r"^<!-- (/?)([A-Z][A-Z0-9-]*):([A-Z][A-Z0-9-]*) -->\s*$")
|
||||
BADGE_RE = re.compile(r"Gitea-(\d+\.\d+\.\d+)(?:%20(?:pending|%5BPENDING%5D))?%20%7C%20(\d+\.\d+\.\d+)%20tested-(?:blue|yellow)")
|
||||
RELEASE_VERSION_RE = re.compile(r"\bv\d+\.\d+\.\d+\b")
|
||||
RELEASE_LINK_RE = re.compile(r"\[v\d+\.\d+\.\d+\]\(https://[^)]+/releases/latest\)")
|
||||
TABLES = {
|
||||
"VERSION-MAP": "| Gitea version | Recommended template release | Status | Notes |",
|
||||
"HISTORY": "| Gitea | Release Date | Mail Template Changes | Impact |",
|
||||
}
|
||||
SKIP_DIRECTORIES = frozenset((".git", "node_modules", "vendor", "dist"))
|
||||
|
||||
|
||||
class TagBlock(NamedTuple):
|
||||
path: Path
|
||||
subject: str
|
||||
content: str
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
def body(lines, block):
|
||||
return lines[block.start + 1:block.end]
|
||||
|
||||
|
||||
def version_key(value):
|
||||
match = VERSION_RE.fullmatch(value)
|
||||
if not match:
|
||||
raise ValueError(f"Expected a stable X.Y.Z version, got {value!r}")
|
||||
return tuple(int(part) for part in match.groups())
|
||||
|
||||
|
||||
def fetch_release(requested_version):
|
||||
endpoint = f"{RELEASES_API}/tags/v{requested_version}" if requested_version else f"{RELEASES_API}/latest"
|
||||
request = Request(endpoint, headers={"Accept": "application/vnd.github+json", "User-Agent": "gitea-mail-templates-tracker"})
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
with urlopen(request, timeout=20) as response:
|
||||
release = json.load(response)
|
||||
if release.get("draft") or release.get("prerelease"):
|
||||
raise ValueError("Draft and prerelease Gitea versions are not tracked")
|
||||
version = str(release["tag_name"]).removeprefix("v")
|
||||
version_key(version)
|
||||
if requested_version and version != requested_version:
|
||||
raise ValueError(f"Release tag mismatch: expected {requested_version}, got {version}")
|
||||
published = release.get("published_at")
|
||||
if not published:
|
||||
raise ValueError(f"Release v{version} has no published_at date")
|
||||
release_date = date.fromisoformat(published[:10]).isoformat()
|
||||
return version, release_date
|
||||
|
||||
|
||||
def table(lines, name):
|
||||
if len(lines) < 3 or lines[0].strip() != TABLES[name]:
|
||||
raise ValueError(f"Unexpected table header in TRACKER:{name}")
|
||||
if not re.fullmatch(r"\|[\s|:-]+\|", lines[1].strip()):
|
||||
raise ValueError(f"Missing Markdown table separator in TRACKER:{name}")
|
||||
if any(not line.startswith("|") for line in lines[2:]):
|
||||
raise ValueError(f"Non-table line in TRACKER:{name}")
|
||||
versions = set()
|
||||
for line in lines[2:]:
|
||||
match = ROW_RE.match(line)
|
||||
if not match:
|
||||
if name == "HISTORY" and re.match(r"^\|\s*\*\*≤\s*\d+\.\d+\.x\*\*\s*\|", line):
|
||||
continue
|
||||
raise ValueError(f"Malformed version row in TRACKER:{name}: {line.strip()}")
|
||||
if match.group(1) in versions:
|
||||
raise ValueError(f"Duplicate version in TRACKER:{name}: {match.group(1)}")
|
||||
versions.add(match.group(1))
|
||||
if not versions:
|
||||
raise ValueError(f"Empty version table in TRACKER:{name}")
|
||||
return versions
|
||||
|
||||
|
||||
def documents(root):
|
||||
"""Scan every Markdown file; each participant declares its paired tag blocks."""
|
||||
result = {}
|
||||
blocks = []
|
||||
for path in sorted(root.rglob("*.md")):
|
||||
if any(part in SKIP_DIRECTORIES or part.startswith("tracker-test-") for part in path.relative_to(root).parts[:-1]):
|
||||
continue
|
||||
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
declarations = [MANIFEST_RE.match(line) for line in lines]
|
||||
declarations = [match for match in declarations if match]
|
||||
if len(declarations) > 1:
|
||||
raise ValueError(f"Multiple DOC-TAGS declarations in {path}")
|
||||
if not declarations:
|
||||
if any(TAG_RE.match(line) for line in lines):
|
||||
raise ValueError(f"Tag block without DOC-TAGS declaration in {path}")
|
||||
continue
|
||||
manifest = json.loads(declarations[0].group(1))
|
||||
if not isinstance(manifest, dict) or not manifest:
|
||||
raise ValueError(f"DOC-TAGS must be a non-empty object in {path}")
|
||||
declared = set()
|
||||
for subject, contents in manifest.items():
|
||||
if subject not in HANDLERS or not isinstance(contents, list) or not contents:
|
||||
raise ValueError(f"Unknown or empty DOC-TAGS subject {subject!r} in {path}")
|
||||
for content in contents:
|
||||
if content not in HANDLERS[subject]:
|
||||
raise ValueError(f"Unknown {subject} content {content!r} in {path}")
|
||||
declared.add((subject, content))
|
||||
result[path] = lines
|
||||
opened = None
|
||||
found = set()
|
||||
for index, line in enumerate(lines):
|
||||
match = TAG_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
closing, subject, content = match.groups()
|
||||
key = (subject, content)
|
||||
if key not in declared:
|
||||
raise ValueError(f"Undeclared {subject}:{content} block in {path}")
|
||||
if closing:
|
||||
if opened is None or opened[0] != key:
|
||||
raise ValueError(f"Unmatched closing tag {subject}:{content} in {path}")
|
||||
if key not in found:
|
||||
blocks.append(TagBlock(path, subject, content, opened[1], index))
|
||||
found.add(key)
|
||||
opened = None
|
||||
elif opened is not None:
|
||||
raise ValueError(f"Nested tag block in {path}")
|
||||
else:
|
||||
opened = (key, index)
|
||||
if opened is not None:
|
||||
raise ValueError(f"Unclosed tag block {opened[0]} in {path}")
|
||||
if missing := declared - found:
|
||||
raise ValueError(f"Missing declared tag block(s) {sorted(missing)} in {path}")
|
||||
return result, blocks
|
||||
|
||||
|
||||
def latest_tested(documents_by_path, blocks):
|
||||
versions = set()
|
||||
for block in blocks:
|
||||
if (block.subject, block.content) == ("TRACKER", "LATEST-TESTED"):
|
||||
match = re.search(r"Gitea\s+(\d+\.\d+\.\d+)", "".join(body(documents_by_path[block.path], block)))
|
||||
if not match:
|
||||
raise ValueError(f"Missing Gitea version in TRACKER:LATEST-TESTED in {block.path}")
|
||||
versions.add(match.group(1))
|
||||
if len(versions) != 1:
|
||||
raise ValueError(f"LATEST-TESTED markers disagree: {sorted(versions)}")
|
||||
return versions.pop()
|
||||
|
||||
|
||||
def update_version_map(content, version, release_date, tested):
|
||||
if version not in table(content, "VERSION-MAP"):
|
||||
content.insert(2, f"| {version} | — | [PENDING] | Compatibility verification pending |\n")
|
||||
return content
|
||||
|
||||
|
||||
def update_history(content, version, release_date, tested):
|
||||
if version not in table(content, "HISTORY"):
|
||||
content.insert(2, f"| **{version}** | {release_date} | [PENDING] Review of mail-template changes | TBD |\n")
|
||||
return content
|
||||
|
||||
|
||||
def update_upstream(content, version, release_date, tested):
|
||||
if len(content) != 1 or len(STATUS_RE.findall(content[0])) != 1 or len(INLINE_VERSION_RE.findall(content[0])) != 1:
|
||||
raise ValueError("TRACKER:UPSTREAM needs exactly one status and version on one body line")
|
||||
line = INLINE_VERSION_RE.sub(version, content[0], count=1)
|
||||
content[0] = STATUS_RE.sub("[PENDING]", line, count=1)
|
||||
return content
|
||||
|
||||
|
||||
def update_badge(content, version, release_date, tested):
|
||||
match = BADGE_RE.search(content[0]) if len(content) == 1 else None
|
||||
if not match or match.group(2) != tested:
|
||||
raise ValueError("TRACKER:BADGE has an unexpected format or tested version")
|
||||
badge = f"Gitea-{version}%20%5BPENDING%5D%20%7C%20{tested}%20tested-yellow"
|
||||
content[0] = BADGE_RE.sub(badge, content[0], count=1)
|
||||
return content
|
||||
|
||||
|
||||
def keep_manual(content, version, release_date, tested):
|
||||
return content
|
||||
|
||||
|
||||
def update_release(content, tag, path):
|
||||
line = "".join(content)
|
||||
matches = RELEASE_VERSION_RE.findall(line)
|
||||
if len(content) != 1 or len(matches) != 1 or ("[v" in line and len(RELEASE_LINK_RE.findall(line)) != 1):
|
||||
raise ValueError(f"RELEASE block needs one vX.Y.Z label and a /releases/latest link when linked: {path}")
|
||||
return [RELEASE_VERSION_RE.sub(tag, content[0], count=1)]
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"TRACKER": {
|
||||
"VERSION-MAP": update_version_map,
|
||||
"HISTORY": update_history,
|
||||
"UPSTREAM": update_upstream,
|
||||
"BADGE": update_badge,
|
||||
"LATEST-TESTED": keep_manual,
|
||||
"LATEST-VERIFIED": keep_manual,
|
||||
},
|
||||
"RELEASE": {"HEADER": update_release, "SUMMARY": update_release, "CURRENT": update_release},
|
||||
}
|
||||
|
||||
|
||||
def dispatch(originals, blocks, subject, handler):
|
||||
updated = {path: lines.copy() for path, lines in originals.items()}
|
||||
for block in sorted((item for item in blocks if item.subject == subject), key=lambda item: (str(item.path), item.start), reverse=True):
|
||||
lines = updated[block.path]
|
||||
lines[block.start + 1:block.end] = handler(block, body(lines, block))
|
||||
return updated
|
||||
|
||||
|
||||
def save_changes(originals, updated, dry_run):
|
||||
changed = [path for path in updated if updated[path] != originals[path]]
|
||||
if not dry_run:
|
||||
for path in changed:
|
||||
path.write_text("".join(updated[path]), encoding="utf-8")
|
||||
return changed
|
||||
|
||||
|
||||
def apply_release(root, version, release_date, dry_run=False):
|
||||
target = version_key(version)
|
||||
date.fromisoformat(release_date)
|
||||
originals, blocks = documents(root)
|
||||
tested = latest_tested(originals, blocks)
|
||||
history = [block for block in blocks if (block.subject, block.content) == ("TRACKER", "HISTORY")]
|
||||
if len(history) != 1:
|
||||
raise ValueError(f"Expected one TRACKER:HISTORY block, found {len(history)}")
|
||||
history_lines = body(originals[history[0].path], history[0])
|
||||
latest = max(map(version_key, table(history_lines, "HISTORY")))
|
||||
if target < latest or target <= version_key(tested):
|
||||
return []
|
||||
updated = dispatch(originals, blocks, "TRACKER", lambda block, content: HANDLERS["TRACKER"][block.content](content, version, release_date, tested))
|
||||
return save_changes(originals, updated, dry_run)
|
||||
|
||||
|
||||
def apply_template_release(root, tag, dry_run=False):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
|
||||
raise ValueError(f"Expected a stable vX.Y.Z release tag, got {tag!r}")
|
||||
originals, blocks = documents(root)
|
||||
release_blocks = [block for block in blocks if block.subject == "RELEASE"]
|
||||
if not release_blocks:
|
||||
raise ValueError("No RELEASE blocks declared")
|
||||
versions = set()
|
||||
for block in release_blocks:
|
||||
content = body(originals[block.path], block)
|
||||
line = "".join(content)
|
||||
matches = RELEASE_VERSION_RE.findall(line)
|
||||
if len(content) != 1 or len(matches) != 1 or ("[v" in line and len(RELEASE_LINK_RE.findall(line)) != 1):
|
||||
raise ValueError(f"RELEASE block needs one vX.Y.Z label and a /releases/latest link when linked: {block.path}")
|
||||
versions.add(matches[0])
|
||||
if len(versions) != 1:
|
||||
raise ValueError(f"TRACKER:RELEASE labels disagree: {sorted(versions)}")
|
||||
current = versions.pop()
|
||||
if version_key(tag) <= version_key(current):
|
||||
return []
|
||||
updated = dispatch(originals, blocks, "RELEASE", lambda block, content: HANDLERS["RELEASE"][block.content](content, tag, block.path))
|
||||
return save_changes(originals, updated, dry_run)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", default=os.environ.get("GITEA_VERSION"), help="Specific stable Gitea version; omit for latest release")
|
||||
parser.add_argument("--release-date", help="YYYY-MM-DD; use with --version for offline checks")
|
||||
parser.add_argument("--template-release", help="Published template tag vX.Y.Z; update RELEASE blocks only")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Check without changing repository documentation")
|
||||
args = parser.parse_args()
|
||||
if args.template_release and (args.version or args.release_date):
|
||||
parser.error("--template-release cannot be combined with upstream release options")
|
||||
if args.release_date and not args.version:
|
||||
parser.error("--release-date requires --version")
|
||||
if args.template_release:
|
||||
version = args.template_release
|
||||
changed = apply_template_release(ROOT, version, dry_run=args.dry_run)
|
||||
label = "Template release"
|
||||
else:
|
||||
if args.version:
|
||||
args.version = args.version.removeprefix("v")
|
||||
version_key(args.version)
|
||||
if args.release_date:
|
||||
version, release_date = args.version, date.fromisoformat(args.release_date).isoformat()
|
||||
else:
|
||||
version, release_date = fetch_release(args.version)
|
||||
changed = apply_release(ROOT, version, release_date, dry_run=args.dry_run)
|
||||
label = "Gitea"
|
||||
print(f"{label} {version}: {', '.join(str(path.relative_to(ROOT)) for path in changed) if changed else 'already tracked'}")
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_path:
|
||||
with open(output_path, "a", encoding="utf-8") as output:
|
||||
output.write(f"version={version}\nchanged={'true' if changed else 'false'}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (KeyError, OSError, ValueError) as error:
|
||||
sys.exit(f"Tracker failed: {error}")
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Gitea Version Tracker
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Stable Gitea version (e.g. 28.2.0); empty means latest'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
track:
|
||||
name: Track Gitea Release
|
||||
runs-on: linux-amd64-docker-small
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Test tracker
|
||||
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
- name: Record pending Gitea release
|
||||
id: version
|
||||
run: python -B .github/scripts/track_gitea_release.py
|
||||
env:
|
||||
GITEA_VERSION: ${{ github.event.inputs.version }}
|
||||
# Optional GitHub token for upstream API rate limits; never use the Gitea job token.
|
||||
GITHUB_TOKEN: ${{ secrets.UPSTREAM_GITHUB_TOKEN }}
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.version.outputs.changed == 'true'
|
||||
run: python -B .github/scripts/gitea_actions.py create-pull-request --version "$UPSTREAM_VERSION"
|
||||
env:
|
||||
UPSTREAM_VERSION: ${{ steps.version.outputs.version }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Test documentation tracker
|
||||
- name: Test workflow scripts
|
||||
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
- name: Verify pinned official snapshot
|
||||
@@ -64,11 +64,6 @@ jobs:
|
||||
- name: Verify release notes
|
||||
run: test -s ".github/release-notes/${GITHUB_REF_NAME}.md"
|
||||
|
||||
- name: Update release labels in the archive
|
||||
run: python -B .github/scripts/track_gitea_release.py --template-release "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
|
||||
- name: Generate static preview for the archive
|
||||
working-directory: tools
|
||||
run: go run . preview all
|
||||
@@ -86,36 +81,26 @@ jobs:
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
sync-release-docs:
|
||||
name: Update Latest Release Documentation
|
||||
notify-release-docs:
|
||||
name: Remind to Update Release Documentation
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: package
|
||||
concurrency:
|
||||
group: release-documentation-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
runs-on: linux-amd64-docker-small
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Update marked release labels on main
|
||||
run: python -B .github/scripts/track_gitea_release.py --template-release "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
|
||||
- name: Commit documentation update
|
||||
run: |
|
||||
git add -- ':(glob)**/*.md'
|
||||
if git diff --cached --quiet; then
|
||||
echo "Release labels are already current."
|
||||
exit 0
|
||||
fi
|
||||
git -c user.name='release-bot' -c user.email='release-bot@users.noreply.local' commit -m "docs: mark ${TEMPLATE_RELEASE} as latest release"
|
||||
git push origin HEAD:main
|
||||
- name: Create documentation reminder Issue
|
||||
run: python -B .github/scripts/gitea_actions.py create-issue --version "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -1,72 +0,0 @@
|
||||
# AGENTS.md — Gitea Mail Templates
|
||||
<!-- DOC-TAGS: {"TRACKER":["UPSTREAM"],"RELEASE":["CURRENT"]} -->
|
||||
|
||||
## Project Overview
|
||||
|
||||
Theme presentation for self-hosted Gitea, driven by locked official inputs and a reusable control framework. New source architecture supports Gitea 28+; historical release tags retain their original layouts and compatibility.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
gitea.lock.json # Generated official version, commit and file SHA-256; no vendored upstream source
|
||||
framework/ # Shared header/action/fallback/footer controls and structural layout presets
|
||||
themes/<name>/ # theme.json and theme.css only; no mail data/logic/translation definitions
|
||||
tools/ # Go 1.24+ CLI; urfave/cli and x/net HTML validation
|
||||
upstream/ # Explicit sync, checksum/adapter verification and key discovery
|
||||
builder/ # One official-context alignment layer and theme package generation
|
||||
config/, data/ # Names, descriptions and mock contexts; not a template inventory
|
||||
preview/ # Official-compatible rendering, locale adapter and SSE dev server
|
||||
cli/ # list, create, delete, build, preview, dev, upstream
|
||||
build/themes/ # Generated installable overrides; ignored
|
||||
build/upstream/ # Downloaded immutable official mail/locales/assets/license; ignored
|
||||
preview/ # Vanilla JS UI, generated manifest and per-language scripts
|
||||
docs/ # English/Simplified Chinese documentation and gallery images
|
||||
```
|
||||
|
||||
## Source and Theme Rules
|
||||
|
||||
- Never commit or hand-maintain official input files. `upstream prepare` downloads missing inputs to the ignored cache. `upstream sync --tag vX.Y.Z` explicitly regenerates the lock after reviewing a new version.
|
||||
- Build/preview bootstrap a missing cache, then verify it offline against the lock's commit and per-file SHA-256. Corruption is an error, never silently repaired.
|
||||
- Root `gitea.lock.json` is mandatory for prepare/verify/build/preview/dev; a cached lock is not a substitute. Restore the tracked lock for normal clones; intentional initialization uses explicit `upstream sync --tag v28.0.0`. There is no automatic latest-version selection.
|
||||
- Run upstream commands from `tools/`, or provide the subcommand's `--root` (default `..`). `verify` is read-only; `prepare` never rewrites the root lock; `sync` requires network and stable 28+ tags, replaces cache/lock but does not change docs or publish. See CONTRIBUTING for recovery; preserve invalid caches before rebuilding and avoid concurrent cache writers.
|
||||
- Changed reviewed translation/mail-renderer references require an adapter review before updating reference hashes.
|
||||
- Official templates own notification data, conditions, subject sections and functional URLs. The shared framework may reorganize presentation and add controls using those values and official translations; themes may not add business logic.
|
||||
- `theme.json` has `name`, `description`, `mode` (`shared` or `framed`) and optional `layout`. New themes default to `framed` with the `standard` framework preset. `shared` remains a minimal CSS-only mode without framework controls.
|
||||
- All framed themes use the same header/action/fallback/footer partials and single adapter. Structural presets live in `framework/layouts/`, not in theme directories; color/font/spacing rules live in theme CSS.
|
||||
- Layout fragments form balanced presentation markup. `__HEADER__` inserts the shared brand control; `__MAIL_TYPE__` expands to the discovered mail ID. Deployment logos use `{{AppUrl}}assets/img/favicon.png`; the static preview uses the downloaded icon in its generated data only.
|
||||
- CSS must not add text, external resources or hide official content. Use literal email-compatible CSS; browser preview is not a mail-client emulator.
|
||||
- All referenced translation keys must exist in the official English catalog, including keys outside `mail.*`. Other languages use official English fallback.
|
||||
- The reviewed Polish v28.0.0 invitation placeholder defect is reported as `[UPSTREAM-WARN]`; its exact source text is preserved.
|
||||
- Mail entrypoints come from the snapshot; new types need fixtures. Keep JSON fixture integers as integers for Go formatting.
|
||||
|
||||
## Development
|
||||
|
||||
1. `cd tools && go run . create <name>`.
|
||||
2. Edit only theme metadata/CSS. Shared controls or layout presets are framework changes.
|
||||
3. Run `go run . upstream prepare`, `go test ./...` and `go run . preview all`.
|
||||
4. Check desktop/mobile layouts and languages; update the gallery if appropriate.
|
||||
|
||||
`build all` generates `build/themes/<name>/mail/`. `preview all` builds and renders every official language, writing `preview/rendered.js` and `preview/rendered/<locale>.js`. Static preview must work on `file://` via script loading. Keep generated output ignored.
|
||||
|
||||
`dev` uses Go HTTP/SSE on loopback and watches theme resources, framework, lock/cache and fixture data. The preview remains vanilla JS with theme/template/language/view switching, desktop/mobile viewports, panel controls and keyboard navigation.
|
||||
|
||||
Tests compare subjects, notification text and functional links across every language/theme and relevant branch, accounting explicitly for framework-added controls/branding. Verify button labels and identical button/fallback URLs separately. Before release, capture real mail using an isolated Gitea matching the locked version.
|
||||
|
||||
## Versioning
|
||||
|
||||
<!-- RELEASE:CURRENT -->
|
||||
- Current template release: **v28.0.0**.
|
||||
<!-- /RELEASE:CURRENT -->
|
||||
- The snapshot-driven source refactor is unreleased. Its baseline is Gitea v28.0.0; do not replace the existing v28.0.0 assets.
|
||||
- Historical compatibility is documented in `COMPATIBILITY.md`, including the incomplete v1.27.2 push fix. New source architecture does not extend support to earlier Gitea versions.
|
||||
<!-- TRACKER:UPSTREAM -->
|
||||
- Latest upstream Gitea release: 28.1.0 [PENDING].
|
||||
<!-- /TRACKER:UPSTREAM -->
|
||||
- Upstream discovery only adds pending documentation; it must not implicitly update snapshots or mark versions verified.
|
||||
- Release tags match the locked Gitea version. Review notes in `.github/release-notes/vX.Y.Z.md`, test, build and complete the matching-instance smoke test before publication.
|
||||
- The workflow packages generated overrides, preview language bundles, docs and upstream license/provenance. Verify Actions/write permissions on the configured Gitea host before relying on automatic upload.
|
||||
- Markdown managed blocks declare pairs in `DOC-TAGS`. Opening `<!-- SUBJECT:CONTENT -->` and closing `<!-- /SUBJECT:CONTENT -->` delimit managed text; the first repeated pair wins. TRACKER manages pending upstream labels; RELEASE manages published labels. LATEST-TESTED and LATEST-VERIFIED remain manual-only.
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
Use `style(name):`, `preview:`, `tools:`, `docs:`, `fix:`, `project:`, `refactor:` or `chore:` as appropriate. Preserve unrelated user changes and keep generated files out of commits.
|
||||
@@ -1,51 +1,51 @@
|
||||
# Gitea Mail Templates
|
||||
<!-- DOC-TAGS: {"TRACKER":["BADGE","LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]} -->
|
||||
# Gitea Mail Template
|
||||
---
|
||||
|
||||
Email themes for self-hosted [Gitea](https://about.gitea.com), with a local preview and tools for building custom mail templates.
|
||||
[简体中文](docs/README.zh-CN.md)
|
||||
|
||||
[简体中文](docs/README.zh-CN.md) · [Installation](#installation) · [Preview](#preview) · [Compatibility](COMPATIBILITY.md) · [Contributing](CONTRIBUTING.md)
|
||||
Gitea Mail Template offers a range of email template styles for self-hosted [Gitea](https://about.gitea.com) instances.
|
||||
|
||||
<!-- TRACKER:BADGE -->
|
||||
[](COMPATIBILITY.md)
|
||||
<!-- /TRACKER:BADGE -->
|
||||
|
||||
<!-- RELEASE:HEADER -->
|
||||
> Latest release: [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
|
||||
<!-- /RELEASE:HEADER -->
|
||||
|
||||
The repository includes ten themes for account, repository, issue and workflow notifications. Gitea supplies notification content and translations; themes provide the visual presentation.
|
||||
|
||||
The source architecture on `main` is **unreleased** and uses Gitea v28.0.0 as its baseline. It builds templates from locked official inputs and a shared layout framework. Published archives retain their original contents and compatibility. Use the [compatibility matrix](COMPATIBILITY.md#compatibility-matrix) to choose an archive for your Gitea version.
|
||||
The repository includes several themes for Gitea emails, suitable for most deployment scenarios and use cases. Before installing, consult the [compatibility matrix](COMPATIBILITY.md#compatibility-matrix) for your Gitea version and choose the corresponding release archive.
|
||||
|
||||
## Style Gallery
|
||||
|
||||
| Preview | Theme | Appearance |
|
||||
|---|---|---|
|
||||
|  | **Horizon** | Blue accents, gray text and a centered white card |
|
||||
|  | **Terminal** | Dark background, monospace text and green accents |
|
||||
|  | **Ember** | Warm orange palette, serif headings and rounded buttons |
|
||||
|  | **Bloom** | Light blue gradients, rounded cards and buttons |
|
||||
|  | **Heritage** | Navy and gold accents, double borders and serif text |
|
||||
|  | **Neon** | Dark background, pink and cyan accents and glow effects |
|
||||
|  | **Mono** | Black and white, red accents and square borders |
|
||||
|  | **Terra** | Earth tones, terracotta buttons and serif text |
|
||||
|  | **Ink** | Newspaper layout, sidebar, serif text and drop caps |
|
||||
|  | **Aurora** | Dark purple background, teal accents and soft glow effects |
|
||||
The gallery shows how the email templates render in the preview tool.
|
||||
|
||||
Images show the current source build. For other mail types and languages, generate the [local preview](#preview). See the [capture guide](docs/images/README.md) when updating screenshots.
|
||||
| Preview | Theme | Style Features |
|
||||
|---|---|---|
|
||||
|  | **Aurora** | Dark purple background, teal accents and a soft glow |
|
||||
|  | **Bloom** | Light blue gradients, rounded cards and buttons |
|
||||
|  | **Ember** | Warm orange tones, serif headings and rounded buttons |
|
||||
|  | **Heritage** | Navy and gold, double borders and serif fonts |
|
||||
|  | **Horizon** | Blue accents, gray text and a centered white card |
|
||||
|  | **Ink** | Newspaper layout, a sidebar, serif fonts and drop caps |
|
||||
|  | **Mono** | Black and white, red accents and square borders |
|
||||
|  | **Neon** | Dark background, pink and cyan accents and glow effects |
|
||||
|  | **Terminal** | Dark background, monospace fonts and green accents |
|
||||
|  | **Terra** | Earth tones, terracotta buttons and serif fonts |
|
||||
|
||||
Screenshots show only the current default build. Use the [local preview](#preview) to view other email types and languages. See the [screenshot guide](docs/images/README.md) when updating screenshots.
|
||||
|
||||
## Installation
|
||||
|
||||
### Choose a Package
|
||||
### Choose an Installation Source
|
||||
|
||||
Check your Gitea version with `gitea --version`, then select a template release from the [compatibility matrix](COMPATIBILITY.md#compatibility-matrix).
|
||||
Run `gitea --version` to confirm the version of your deployed Gitea instance, then choose a template version from the [compatibility matrix](COMPATIBILITY.md#compatibility-matrix).
|
||||
|
||||
| Source | Mail template directory | Preparation |
|
||||
> [!WARNING]
|
||||
> We do not recommend building directly from the source repository, as these versions have not yet been fully tested for release readiness. Outdated or missing template parameters that have not been identified may prevent your Gitea instance from starting correctly.
|
||||
|
||||
> [!TIP]
|
||||
> If you still need to build the email templates from source, follow the steps below.
|
||||
|
||||
| Source | Email Template Directory | Preparation |
|
||||
|---|---|---|
|
||||
| Release archive | `themes/<name>/mail/` | Download and extract the archive for the recommended release |
|
||||
| Source checkout | `build/themes/<name>/mail/` | Build with Go 1.24 or later |
|
||||
| Release archive | `themes/<name>/mail/` | Download and extract the recommended release archive |
|
||||
| Source | `build/themes/<name>/mail/` | Build with Go 1.24 or later |
|
||||
|
||||
For a source checkout, run from the repository root:
|
||||
To build from source, run the following from the repository root:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
@@ -53,45 +53,45 @@ go run . build all
|
||||
cd ..
|
||||
```
|
||||
|
||||
The first build downloads the official files pinned by `gitea.lock.json` if the cache is absent. Later builds verify the cache before use. The root lock file is required; missing or damaged inputs are covered in the [setup and recovery guide](CONTRIBUTING.md#official-snapshot-updates).
|
||||
Building usable templates requires resources from the official Gitea repository to ensure consistent source inputs. The command automatically downloads the official files pinned by `gitea.lock.json`. Subsequent builds use the cached resources without downloading them again. For missing files or a damaged cache, see [official snapshot updates](CONTRIBUTING.md#official-snapshot-updates).
|
||||
|
||||
### Install a Theme
|
||||
|
||||
Copy the chosen theme's `mail/` contents into `<GITEA_CUSTOM>/templates/mail/`, then restart Gitea. Confirm your instance's custom directory before copying files. Common deployment paths include:
|
||||
Copy the contents of the selected theme's `mail/` directory into `<GITEA_CUSTOM>/templates/mail/`, then restart Gitea to apply the theme. Before copying, confirm the custom directory your instance actually uses. Common deployment paths include:
|
||||
|
||||
| Deployment | Example custom directory |
|
||||
| Deployment | Example Custom Directory |
|
||||
|---|---|
|
||||
| Linux binary | `/var/lib/gitea/custom` |
|
||||
| Docker | `/data/gitea` |
|
||||
| Windows | `C:\gitea\custom` |
|
||||
|
||||
For example, from an extracted release archive on a Linux host managed by systemd:
|
||||
For example, on a Linux host where systemd manages Gitea, run the following from the extracted release archive directory:
|
||||
|
||||
```bash
|
||||
systemctl stop gitea
|
||||
mkdir -p /var/lib/gitea/custom/templates/mail
|
||||
cp -r themes/horizon/mail/. /var/lib/gitea/custom/templates/mail/
|
||||
systemctl restart gitea
|
||||
```
|
||||
|
||||
For a source build, use `build/themes/horizon/mail/.` as the copy source. Docker and Windows installations should restart Gitea using their deployment's service or container controls.
|
||||
> [!TIP]
|
||||
> For templates built from source, change the copy source to `build/themes/horizon/mail/.`. For Docker and Windows deployments, use the appropriate container or service controls to stop and restart Gitea.
|
||||
|
||||
### Switching Styles
|
||||
### Switch Themes
|
||||
|
||||
Back up existing mail overrides before replacing a theme. Remove the previous theme's installed files, then copy the new theme's complete output. Current source builds include a `build.json` file listing the generated files; for historical archives, refer to the archive contents. Preserve unrelated custom templates.
|
||||
Back up or remove the files installed by the previous theme, choose a new email theme, and repeat the installation steps above.
|
||||
|
||||
Removing old overrides is especially important when switching from `framed` to `shared` mode: files left behind can keep the previous theme's layout.
|
||||
### Confirm the Theme Is Applied
|
||||
|
||||
### Confirming It Works
|
||||
|
||||
Trigger a notification that uses Gitea's mail templates, such as a password-reset email for a test account, and check its appearance and links. The administration test-email button does not use custom mail templates.
|
||||
Use a test account to trigger an email notification, such as a password reset, and check its appearance and links. The administration panel's test-email button does not use custom email templates, so it cannot confirm whether the theme has been applied.
|
||||
|
||||
## Preview
|
||||
|
||||
The preview supports theme, mail type and language selection, rendered HTML and source views, desktop/mobile viewports, and a panel showing the example data. The v28.0.0 snapshot contains 11 mail types and 28 languages.
|
||||
The preview tool included in the repository lets you view how emails render across themes, template types and languages before deployment.
|
||||
|
||||
### Static Preview
|
||||
|
||||
From a source checkout, generate the preview data:
|
||||
First, generate the preview data in the source repository:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
@@ -99,69 +99,88 @@ go run . preview all
|
||||
cd ..
|
||||
```
|
||||
|
||||
Open [preview/index.html](preview/index.html) in a browser. Generated language bundles load on demand and work over `file://`, so no server is needed. Archives produced by the current packaging script include these bundles; historical archives retain their original preview contents.
|
||||
Open [preview/index.html](preview/index.html) in a browser. No server is required.
|
||||
|
||||
### Dev Server (Live Reload)
|
||||
### Development with Live Reload
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
go run . dev
|
||||
# Press Ctrl+C in the terminal to stop the server.
|
||||
cd ..
|
||||
```
|
||||
|
||||
Open [http://127.0.0.1:3456](http://127.0.0.1:3456). The Go server watches theme files, the shared framework, the lock/cache and preview fixtures. Changes trigger a rebuild and browser refresh through server-sent events (SSE).
|
||||
Open [http://127.0.0.1:3456](http://127.0.0.1:3456). The Go server watches theme files, the shared framework, the lock file and cache, and preview test data. When a file changes, it rebuilds the email templates and refreshes the page through server-sent events (SSE) to provide live reload.
|
||||
|
||||
| Control | Options or shortcut |
|
||||
### Controls and Keyboard Shortcuts
|
||||
|
||||
| Control | Options or Shortcuts |
|
||||
|---|---|
|
||||
| Theme, template, language and view | `←` / `→` moves between selectors; `↑` / `↓` selects an option |
|
||||
| View | **Modern** for rendered HTML; **Source** for generated HTML text |
|
||||
| Viewport | **Desktop** (1386 × 780), **Mobile** (390 × 780); `d` / `m` |
|
||||
| Information panel | `p` toggles the panel |
|
||||
| View | **Modern** displays the rendered result; **Source** displays the generated HTML text |
|
||||
| Viewport | **Desktop** (1386 × 780), **Mobile** (390 × 780); shortcuts `d` / `m` |
|
||||
| Information panel | `p` expands or collapses the panel |
|
||||
|
||||
The preview renders templates using example data. Check your target mail clients separately; browser rendering does not reproduce their CSS support.
|
||||
> [!WARNING]
|
||||
> The preview renders templates using example data. Browsers and email clients differ in their CSS support, so check the appearance in your target email clients before deployment.
|
||||
|
||||
## Compatibility
|
||||
|
||||
<!-- TRACKER:LATEST-TESTED -->
|
||||
- **Latest tested:** Gitea 28.0.0
|
||||
<!-- /TRACKER:LATEST-TESTED -->
|
||||
<!-- RELEASE:SUMMARY -->
|
||||
- **Latest release:** [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
|
||||
<!-- /RELEASE:SUMMARY -->
|
||||
<!-- TRACKER:UPSTREAM -->
|
||||
- **Upstream Gitea 28.1.0:** [PENDING]
|
||||
<!-- /TRACKER:UPSTREAM -->
|
||||
|
||||
The current source architecture targets Gitea 28 and later, with compatibility verified against the locked version. A newer upstream release remains pending until reviewed. Earlier Gitea versions require the packages listed in the [compatibility matrix](COMPATIBILITY.md#compatibility-matrix), which also records the incomplete push-notification fix in v1.27.2.
|
||||
## Project Directory Structure
|
||||
|
||||
Generated templates use Gitea's built-in functions and official translation keys. Missing translations fall back to English. The locked v28.0.0 catalog has a known Polish invitation formatting defect; preview reports `[UPSTREAM-WARN]` and preserves the official output. See [known limitations](COMPATIBILITY.md#snapshot-driven-source-status).
|
||||
The project is organized around a shared framework, theme styles and build tools. Source code and documentation are tracked in version control; downloaded official files, generated templates and preview data are not.
|
||||
|
||||
## Directory Structure
|
||||
### Tracked in Version Control
|
||||
|
||||
```text
|
||||
gitea.lock.json # Official tag, commit and file checksums
|
||||
framework/ # Shared mail controls and layout presets
|
||||
themes/<name>/ # Theme metadata (theme.json) and CSS (theme.css)
|
||||
.github/ # Workflows and maintenance scripts
|
||||
release-notes/ # Release notes for each version
|
||||
scripts/ # Documentation updates, packaging and Gitea release scripts
|
||||
workflows/ # Validation and publication
|
||||
docs/ # Chinese documentation, documentation index and gallery images
|
||||
framework/ # Shared email controls and layout presets
|
||||
layouts/ # Layout presets shared across email types
|
||||
mail/base/ # Header, action buttons, fallback links, sidebar and footer
|
||||
preview/index.html # Preview interface
|
||||
themes/<THEME_NAME>/ # Theme source files
|
||||
tools/ # Go CLI, build tools and tests
|
||||
cli/ # Command definitions
|
||||
upstream/ # Snapshot downloads, verification and key discovery
|
||||
builder/ # Official template adaptation and theme generation
|
||||
preview/ # Mail rendering, locale adapter and development server
|
||||
config/, data/ # Preview metadata and example contexts
|
||||
integration/, qa/ # Optional Gitea and browser checks
|
||||
preview/ # Browser UI; generated manifest and language bundles
|
||||
docs/ # Simplified Chinese guides and gallery images
|
||||
.github/ # Workflows, release notes and packaging/tracking scripts
|
||||
build/upstream/ # Downloaded official inputs (ignored)
|
||||
build/themes/ # Generated installable templates (ignored)
|
||||
cli/ # Command definitions and argument handling
|
||||
config/ # Preview configuration loading and validation
|
||||
data/ # Email type descriptions and preview example data
|
||||
preview/ # Email rendering, locale adaptation and development server
|
||||
upstream/ # Official snapshot downloads, verification and translation key discovery
|
||||
gitea.lock.json # Official version tag, commit and file checksums
|
||||
README.md
|
||||
```
|
||||
|
||||
Gitea's official templates define notification data, conditions, subjects and URLs. The shared framework arranges these into headers, action buttons, fallback links and footers. Themes define colors, typography and spacing. See [theme development](CONTRIBUTING.md#adding-a-theme) for the `framed` and `shared` modes.
|
||||
Official templates provide notification content, conditions, subject lines and links. The shared framework handles email presentation, while themes use metadata and CSS to select layouts and define styles. Theme directories do not contain official email source files or translations.
|
||||
|
||||
### Not Tracked in Version Control
|
||||
|
||||
The following files are generated by the build and preview commands or downloaded according to the lock file. They do not need to be maintained manually or committed:
|
||||
|
||||
```text
|
||||
build/themes/<THEME_NAME>/ # Theme build output
|
||||
mail/ # Email templates ready to install in Gitea
|
||||
build.json # Build provenance and generated file checksums
|
||||
build/upstream/ # Official templates, locales, assets and licenses pinned by the lock file
|
||||
dist/ # Default output directory for release archives
|
||||
preview/rendered.js # Preview manifest
|
||||
preview/rendered/<LOCALE>.js # Preview data for each locale
|
||||
```
|
||||
|
||||
`build all` generates installable templates for all themes. `preview all` generates both templates and preview data for each locale. On first use, the tools prepare any missing official file cache according to `gitea.lock.json`; subsequent uses verify the cache before proceeding.
|
||||
|
||||
### Template Types
|
||||
|
||||
Mail types are discovered from the locked snapshot. The v28.0.0 entrypoints are:
|
||||
Gitea currently includes the following 11 email templates [1](#notes):
|
||||
|
||||
| File | Notification |
|
||||
| File | Notification Type |
|
||||
|---|---|
|
||||
| `mail/user/auth/activate.tmpl` | Account activation |
|
||||
| `mail/user/auth/activate_email.tmpl` | Email address verification |
|
||||
@@ -170,24 +189,23 @@ Mail types are discovered from the locked snapshot. The v28.0.0 entrypoints are:
|
||||
| `mail/org/team_invite.tmpl` | Team invitation |
|
||||
| `mail/repo/collaborator.tmpl` | Repository collaborator added |
|
||||
| `mail/repo/transfer.tmpl` | Repository ownership transfer |
|
||||
| `mail/repo/release.tmpl` | Release published |
|
||||
| `mail/repo/release.tmpl` | New release published |
|
||||
| `mail/repo/actions/workflow_run.tmpl` | Actions workflow run |
|
||||
| `mail/repo/issue/assigned.tmpl` | Issue or pull request assigned |
|
||||
| `mail/repo/issue/assigned.tmpl` | Issue or pull request assignment |
|
||||
| `mail/repo/issue/default.tmpl` | Issue or pull request activity |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to themes, tooling, documentation and translations are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) for local setup, design guidelines, checks and release procedures.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [简体中文使用说明](docs/README.zh-CN.md)
|
||||
- [Contributor guide](CONTRIBUTING.md) · [简体中文贡献指南](docs/CONTRIBUTING.zh-CN.md)
|
||||
- [Compatibility and template reference](COMPATIBILITY.md)
|
||||
- [Gallery capture guide](docs/images/README.md)
|
||||
Contributions to themes, tools, documentation and translations are welcome. The [contributor guide](CONTRIBUTING.md) covers local environment setup, design guidelines, required checks and the release process.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under [MIT](LICENSE). Generated release archives retain Gitea's license and snapshot provenance; see [third-party notices](THIRD_PARTY_NOTICES.md).
|
||||
This project is licensed under [MIT](LICENSE). Generated release archives retain Gitea's license and snapshot provenance.
|
||||
|
||||
---
|
||||
|
||||
### Notes
|
||||
|
||||
[1](#notes): [Mail templates | Gitea Documentation](https://docs.gitea.com/administration/mail-templates/)
|
||||
|
||||
This project is not affiliated with Gitea.
|
||||
@@ -1,18 +0,0 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## Gitea
|
||||
|
||||
The official mail templates, locale catalogs, preview favicon and implementation references used by the adapter come from [Gitea](https://github.com/go-gitea/gitea), which is licensed under MIT.
|
||||
|
||||
The build tools download these files into the ignored `build/upstream/` directory. The committed `gitea.lock.json` records their tag, commit and SHA-256 hashes. The upstream license is retained without modification at `build/upstream/LICENSE`.
|
||||
|
||||
Archives generated by the current packaging script include:
|
||||
|
||||
- `GITEA-LICENSE`: the upstream license.
|
||||
- `upstream-lock.json`: the provenance of the official inputs used to generate templates and translated previews.
|
||||
|
||||
Deployed email logos reference the Gitea instance's asset URL. Generated preview data embeds the official icon for offline display.
|
||||
|
||||
## Project and Dependencies
|
||||
|
||||
Theme presentation and tooling are covered by this repository's [MIT license](LICENSE). Go dependencies are recorded in `tools/go.mod` and `tools/go.sum`; optional browser QA dependencies are declared in `tools/qa/package.json`. Each dependency retains its own license.
|
||||
+88
-68
@@ -1,93 +1,96 @@
|
||||
# Gitea 邮件模板
|
||||
<!-- DOC-TAGS: {"TRACKER":["LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]} -->
|
||||
# Gitea Mail Template
|
||||
---
|
||||
|
||||
为自托管 [Gitea](https://about.gitea.com) 提供邮件主题、本地预览和自定义邮件模板构建工具。
|
||||
[English](../README.md)
|
||||
|
||||
[English](../README.md) · [安装](#安装) · [预览](#预览) · [兼容性](../COMPATIBILITY.md) · [贡献指南](CONTRIBUTING.zh-CN.md)
|
||||
Gitea Mail Template 为自托管的 [Gitea](https://about.gitea.com) 提供多种可选、适合的邮件模板风格。
|
||||
|
||||
<!-- RELEASE:HEADER -->
|
||||
> 最新发布版:[v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
|
||||
<!-- /RELEASE:HEADER -->
|
||||
|
||||
仓库包含十种主题,覆盖账户、仓库、议题和工作流等邮件通知。通知内容和翻译由 Gitea 提供,主题负责展示样式。
|
||||
|
||||
`main` 分支的源码架构**尚未发布**,以 Gitea v28.0.0 为基线,通过锁定的官方文件和共享布局框架构建模板。已发布的压缩包保留原有内容和兼容范围。安装前,请根据 Gitea 版本查阅[兼容矩阵](../COMPATIBILITY.md#compatibility-matrix),选择对应的发行包。
|
||||
仓库包含数种主题的 Gitea 邮件模板,适合大多数的部署场景与应用范围。安装前,请根据 Gitea 版本查阅[兼容矩阵](../COMPATIBILITY.md#compatibility-matrix),选择对应的发行包。
|
||||
|
||||
## 风格画廊
|
||||
|
||||
这里展示邮件模板通过预览工具呈现的实际渲染风格和效果。
|
||||
|
||||
| 预览 | 主题 | 样式特点 |
|
||||
|---|---|---|
|
||||
|  | **Horizon** | 蓝色强调色、灰色文字、居中白色卡片 |
|
||||
|  | **Terminal** | 深色背景、等宽字体、绿色强调色 |
|
||||
|  | **Ember** | 暖橙色调、衬线标题、圆角按钮 |
|
||||
|  | **Bloom** | 浅蓝色渐变、圆角卡片与按钮 |
|
||||
|  | **Heritage** | 藏蓝与金色、双线边框、衬线字体 |
|
||||
|  | **Neon** | 深色背景、粉红与青色、发光效果 |
|
||||
|  | **Mono** | 黑白配色、红色强调色、直角边框 |
|
||||
|  | **Terra** | 大地色调、陶土色按钮、衬线字体 |
|
||||
|  | **Ink** | 报刊式布局、侧栏、衬线字体与首字下沉 |
|
||||
|  | **Aurora** | 深紫色背景、青绿色强调色、柔和光晕 |
|
||||
|  | **Bloom** | 浅蓝色渐变、圆角卡片与按钮 |
|
||||
|  | **Ember** | 暖橙色调、衬线标题、圆角按钮 |
|
||||
|  | **Heritage** | 藏蓝与金色、双线边框、衬线字体 |
|
||||
|  | **Horizon** | 蓝色强调色、灰色文字、居中白色卡片 |
|
||||
|  | **Ink** | 报刊式布局、侧栏、衬线字体与首字下沉 |
|
||||
|  | **Mono** | 黑白配色、红色强调色、直角边框 |
|
||||
|  | **Neon** | 深色背景、粉红与青色、发光效果 |
|
||||
|  | **Terminal** | 深色背景、等宽字体、绿色强调色 |
|
||||
|  | **Terra** | 大地色调、陶土色按钮、衬线字体 |
|
||||
|
||||
截图展示当前源码的构建结果。其他邮件类型和语言可通过[本地预览](#预览)查看;更新截图请参阅[截图指南](images/README.md)。
|
||||
截图仅展示当前默认构建效果,其他邮件类型和语言可通过[本地预览](#预览)查看;更新截图请参阅[截图指南](images/README.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
### 选择安装来源
|
||||
|
||||
运行 `gitea --version` 确认实例版本,再按[兼容矩阵](../COMPATIBILITY.md#compatibility-matrix)选择模板版本。
|
||||
运行 `gitea --version` 确认部署的 Gitea 实例版本,再按照[兼容矩阵](../COMPATIBILITY.md#compatibility-matrix)选择模板版本。
|
||||
|
||||
> [!WARNING]
|
||||
> 我们不建议直接从源码仓库拉取并构建,因为这些版本还未经过完全测试达到发行可用的水平。如果存在未知的模板参数过时或遗漏可能导致 Gitea 实例启动异常。
|
||||
|
||||
> [!TIP]
|
||||
> 如果仍然需要从源码构建邮件模板,下方同样给出的相应步骤方法。
|
||||
|
||||
| 来源 | 邮件模板目录 | 准备步骤 |
|
||||
|---|---|---|
|
||||
| 发行压缩包 | `themes/<名称>/mail/` | 下载并解压推荐版本的发行包 |
|
||||
| 源码仓库 | `build/themes/<名称>/mail/` | 使用 Go 1.24 或更高版本构建 |
|
||||
| 发行版 | `themes/<名称>/mail/` | 下载并解压推荐版本的发行包 |
|
||||
| 源码 | `build/themes/<名称>/mail/` | 使用 Go 1.24 或更高版本构建 |
|
||||
|
||||
从源码构建时,在仓库根目录执行:
|
||||
从源码构建时,请在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
go run . build all
|
||||
cd ..
|
||||
```
|
||||
|
||||
缓存不存在时,首次构建会下载 `gitea.lock.json` 锁定的官方文件;后续构建会先校验缓存。构建需要根目录的锁文件,文件缺失或缓存损坏的处理方式见[准备与恢复说明](CONTRIBUTING.zh-CN.md#更新官方快照)。
|
||||
构建可用模板时需要依赖于 Gitea 官方仓库的资源文件,以确保来源的一致性。因此该命令会自动下载由`gitea.lock.json` 文件锁定的官方文件,后续构建无需再次下载,使用缓存目录资源即可运行。文件缺失或缓存损坏的处理方式见[更新官方快照](CONTRIBUTING.zh-CN.md#更新官方快照)。
|
||||
|
||||
### 安装主题
|
||||
|
||||
将所选主题 `mail/` 目录中的内容复制到 `<GITEA_CUSTOM>/templates/mail/`,然后重启 Gitea。复制前请确认实例实际使用的自定义目录。以下是常见部署路径示例:
|
||||
将所选主题 `mail/` 目录中的内容复制到 `<GITEA_CUSTOM>/templates/mail/`,然后重启 Gitea 即可生效。复制前请确认实例实际使用的自定义目录。以下是常见部署路径示例:
|
||||
|
||||
| 部署方式 | 自定义目录示例 |
|
||||
|---|---|
|
||||
| Linux 二进制部署 | `/var/lib/gitea/custom` |
|
||||
| Linux Binary | `/var/lib/gitea/custom` |
|
||||
| Docker | `/data/gitea` |
|
||||
| Windows | `C:\gitea\custom` |
|
||||
|
||||
例如,在使用 systemd 管理 Gitea 的 Linux 主机上,从已解压的发行包目录执行:
|
||||
|
||||
```bash
|
||||
systemctl stop gitea
|
||||
mkdir -p /var/lib/gitea/custom/templates/mail
|
||||
cp -r themes/horizon/mail/. /var/lib/gitea/custom/templates/mail/
|
||||
systemctl restart gitea
|
||||
```
|
||||
|
||||
使用源码构建产物时,将复制来源改为 `build/themes/horizon/mail/.`。Docker 和 Windows 部署请使用相应的容器或服务管理方式重启。
|
||||
> [!TIP]
|
||||
> 使用源码构建产物时,将复制来源改为 `build/themes/horizon/mail/.`。Docker 和 Windows 部署请使> 用相应的容器或服务管理方式暂停和重启。
|
||||
|
||||
### 切换主题
|
||||
|
||||
先备份已有邮件模板,再移除上一主题安装的文件,复制新主题的完整产物。当前源码构建会在 `build.json` 中记录生成的文件;历史发行包可参照压缩包内容确认文件范围。保留其他自定义模板。
|
||||
|
||||
从 `framed` 切换到 `shared` 模式时,尤其需要清理旧主题文件,否则残留的覆盖模板可能继续使用原有布局。
|
||||
先备份或移除上一主题安装的文件,选择新的邮件主题依照上述安装步骤重新操作即可。
|
||||
|
||||
### 确认生效
|
||||
|
||||
使用测试账户触发密码重置等邮件通知,检查邮件样式和链接。管理后台的测试邮件按钮不使用自定义邮件模板。
|
||||
使用测试账户触发密码重置等邮件通知,检查邮件样式和链接。管理后台的测试邮件按钮不使用自定义邮件模板,因此无法确认邮件模板是否生效。
|
||||
|
||||
## 预览
|
||||
|
||||
预览支持切换主题、邮件类型和语言,查看渲染结果或 HTML 源码,切换桌面与移动端视口,以及查看示例数据面板。v28.0.0 快照包含 11 种邮件类型和 28 种语言。
|
||||
通过仓库中提供的预览工具,可以在部署前预览邮件不同主题,不同模板类型和语言的渲染效果
|
||||
|
||||
### 静态预览
|
||||
|
||||
在源码仓库中生成预览数据:
|
||||
首先需要在源码仓库中生成预览数据:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
@@ -95,16 +98,20 @@ go run . preview all
|
||||
cd ..
|
||||
```
|
||||
|
||||
在浏览器中打开 [preview/index.html](../preview/index.html)。语言数据按需加载,支持 `file://`,无需启动服务器。当前打包脚本生成的发行包包含这些数据;历史发行包保留其原有预览内容。
|
||||
在浏览器中打开 [preview/index.html](../preview/index.html) 即可。无需启动服务器。
|
||||
|
||||
### 开发服务器
|
||||
### 热重载开发
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
go run . dev
|
||||
## 在终端中输入 Ctrl + C 即可终止服务器运行
|
||||
cd ..
|
||||
```
|
||||
|
||||
打开 [http://127.0.0.1:3456](http://127.0.0.1:3456)。Go 服务器监听主题文件、共享框架、锁文件与缓存、预览测试数据的变化,重新构建后通过服务器发送事件(SSE)刷新页面。
|
||||
打开 [http://127.0.0.1:3456](http://127.0.0.1:3456)。Go 服务器监听主题文件、共享框架、锁文件与缓存、预览测试数据的变化。一旦监听到文件更改,服务器会将邮件模板重新构建后通过发送事件(SSE)刷新页面,以实现实时热重载。
|
||||
|
||||
### 控制和快捷键
|
||||
|
||||
| 控件 | 选项或快捷键 |
|
||||
|---|---|
|
||||
@@ -113,49 +120,64 @@ go run . dev
|
||||
| 视口 | **Desktop**(1386 × 780)、**Mobile**(390 × 780);快捷键 `d` / `m` |
|
||||
| 信息面板 | `p` 展开或收起面板 |
|
||||
|
||||
预览使用示例数据渲染模板。浏览器与邮件客户端对 CSS 的支持不同,部署前还需在目标邮件客户端中检查效果。
|
||||
> [!WARNING]
|
||||
> 预览使用示例数据渲染模板。浏览器与邮件客户端对 CSS 的支持不同,部署前还需在目标邮件客户端中检查效果。
|
||||
|
||||
## 兼容性
|
||||
|
||||
<!-- TRACKER:LATEST-TESTED -->
|
||||
- **最新测试:** Gitea 28.0.0
|
||||
<!-- /TRACKER:LATEST-TESTED -->
|
||||
<!-- RELEASE:SUMMARY -->
|
||||
- **最新发布版:** [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
|
||||
<!-- /RELEASE:SUMMARY -->
|
||||
<!-- TRACKER:UPSTREAM -->
|
||||
- **上游 Gitea 28.1.0:** [PENDING]
|
||||
<!-- /TRACKER:UPSTREAM -->
|
||||
|
||||
当前源码架构面向 Gitea 28 及更高版本,兼容性按锁定版本验证。新的上游版本在完成评审前保持待验证状态。早期 Gitea 版本请使用[兼容矩阵](../COMPATIBILITY.md#compatibility-matrix)推荐的发行包;其中也记录了 v1.27.2 推送通知修复不完整的问题。
|
||||
## 项目目录结构
|
||||
|
||||
生成的模板使用 Gitea 内置函数和官方翻译键,缺少的翻译回退为英文。锁定的 v28.0.0 语言文件存在已知的波兰语邀请文案格式缺陷,预览会报告 `[UPSTREAM-WARN]` 并保留官方输出。详见[已知限制](../COMPATIBILITY.md#snapshot-driven-source-status)。
|
||||
项目按共享框架、主题样式和构建工具组织。源码与文档纳入版本控制,下载的官方文件和生成的模板、预览数据不纳入版本控制。
|
||||
|
||||
## 目录结构
|
||||
### 版本控制中
|
||||
|
||||
```text
|
||||
gitea.lock.json # 官方标签、提交和文件校验值
|
||||
.github/ # 工作流及维护脚本
|
||||
release-notes/ # 各版本的发行说明
|
||||
scripts/ # 文档更新、打包和 Gitea 发布脚本
|
||||
workflows/ # 验证和发布
|
||||
docs/ # 中文文档、文档索引和画廊图片
|
||||
framework/ # 共享邮件控件和布局预设
|
||||
themes/<名称>/ # 主题元数据(theme.json)和样式(theme.css)
|
||||
layouts/ # 各类邮件共用的布局预设
|
||||
mail/base/ # 页头、操作按钮、备用链接、侧栏和页脚
|
||||
preview/index.html # 预览界面
|
||||
themes/<THEME_NAME>/ # 主题源码
|
||||
tools/ # Go 命令行工具、构建工具和测试
|
||||
cli/ # 命令定义
|
||||
upstream/ # 快照下载、校验和翻译键发现
|
||||
builder/ # 官方模板适配与主题生成
|
||||
builder/ # 官方模板适配和主题生成
|
||||
cli/ # 命令定义和参数处理
|
||||
config/ # 预览配置读取和校验
|
||||
data/ # 邮件类型说明和预览示例数据
|
||||
preview/ # 邮件渲染、语言适配和开发服务器
|
||||
config/, data/ # 预览元数据和示例上下文
|
||||
integration/, qa/ # 可选的 Gitea 实例与浏览器检查
|
||||
preview/ # 浏览器界面;生成的清单和语言数据包
|
||||
docs/ # 简体中文指南和画廊图片
|
||||
.github/ # 工作流、发行说明、打包与版本追踪脚本
|
||||
build/upstream/ # 下载的官方文件,不纳入版本控制
|
||||
build/themes/ # 生成的安装模板,不纳入版本控制
|
||||
upstream/ # 官方快照下载、校验和翻译键发现
|
||||
gitea.lock.json # 官方版本标签、提交和文件校验值
|
||||
README.md
|
||||
```
|
||||
|
||||
官方模板定义通知数据、条件、主题行和 URL。共享框架将其组织为页头、操作按钮、备用链接和页脚,主题定义配色、字体与间距。`framed` 和 `shared` 模式的说明见[主题开发指南](CONTRIBUTING.zh-CN.md#添加主题)。
|
||||
官方模板提供通知内容、条件、主题行和链接;共享框架负责邮件呈现,主题通过元数据和 CSS 选择布局、定义样式。主题目录中不保存官方邮件源码或翻译文件。
|
||||
|
||||
### 不在版本控制中
|
||||
|
||||
以下文件由构建和预览命令生成,或按锁文件下载,无需手动维护或提交:
|
||||
|
||||
```text
|
||||
build/themes/<THEME_NAME>/ # 主题构建结果
|
||||
mail/ # 可安装到 Gitea 的邮件模板
|
||||
build.json # 构建来源和生成文件的校验记录
|
||||
build/upstream/ # 锁文件指定的官方模板、语言、资源和许可证
|
||||
dist/ # 默认输出的发行压缩包
|
||||
preview/rendered.js # 预览清单
|
||||
preview/rendered/<LOCALE>.js # 各语言的预览数据
|
||||
```
|
||||
|
||||
`build all` 生成全部主题的安装模板,`preview all` 同时生成模板和各语言的预览数据。首次使用时,工具按 `gitea.lock.json` 准备缺失的官方文件缓存;后续使用前会校验缓存。
|
||||
|
||||
### 模板类型
|
||||
|
||||
邮件类型从锁定的快照中发现。v28.0.0 的邮件入口如下:
|
||||
当前 Gitea 包含以下 11 种邮件模板 [1](#注释):
|
||||
|
||||
| 文件 | 通知类型 |
|
||||
|---|---|
|
||||
@@ -175,15 +197,13 @@ build/themes/ # 生成的安装模板,不纳入版本控制
|
||||
|
||||
欢迎改进主题、工具、文档和翻译。[贡献指南](CONTRIBUTING.zh-CN.md)介绍了本地环境准备、设计规范、检查要求和发布流程。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [English README](../README.md)
|
||||
- [简体中文贡献指南](CONTRIBUTING.zh-CN.md) · [English contributor guide](../CONTRIBUTING.md)
|
||||
- [兼容性与模板参考](../COMPATIBILITY.md)
|
||||
- [画廊截图指南](images/README.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [MIT 许可证](../LICENSE)。生成的发行包保留 Gitea 许可证和快照来源信息,详见[第三方声明](../THIRD_PARTY_NOTICES.md)。
|
||||
|
||||
---
|
||||
### 注释
|
||||
|
||||
[1](#注释):[Mail templates | Gitea Documentation](https://docs.gitea.com/administration/mail-templates/)
|
||||
|
||||
本项目与 Gitea 官方无隶属关系。
|
||||
@@ -17,23 +17,6 @@ Use the same settings for every theme so images remain comparable:
|
||||
|
||||
Save images as `<theme-name>.png` in this directory, matching the directory name under `themes/`. Each file must be at most **50 KiB**; **10–20 KiB** is preferred. Optimize larger PNGs with tools such as `pngquant` or `optipng`.
|
||||
|
||||
## Automated Capture
|
||||
|
||||
Generate preview data from the repository root:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
go run . preview all
|
||||
cd qa
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm test -- --update-gallery
|
||||
```
|
||||
|
||||
To use installed Chrome or Edge, set `BROWSER_EXECUTABLE_PATH` instead of installing Chromium. The script checks language loading, controls and mobile overflow, saves captures in `build/screenshots/`, then copies them here after enforcing the size limit. Review the resulting images before committing.
|
||||
|
||||
Run `npm test` without `--update-gallery` to perform the checks and capture images without changing the committed gallery.
|
||||
|
||||
## Manual Capture
|
||||
|
||||
1. Run `go run . dev` from `tools/` and open [http://127.0.0.1:3456](http://127.0.0.1:3456). Alternatively, run `go run . preview all` and open [preview/index.html](../../preview/index.html) directly.
|
||||
|
||||
+14
-2
@@ -503,8 +503,20 @@ function applyManifest(data) {
|
||||
if (!data.languages.includes(state.locale)) state.locale=data.languages.includes('en-US') ? 'en-US' : data.languages[0];
|
||||
fillSelect('sel-theme',data.themes.map(t=>[t,t.charAt(0).toUpperCase()+t.slice(1)]),state.theme);
|
||||
fillSelect('sel-template',Object.entries(REGISTRY).map(([id,t])=>[id,t.name]),state.template);
|
||||
let names; try { names=new Intl.DisplayNames([navigator.language],{type:'language'}); } catch (_) {}
|
||||
fillSelect('sel-locale',data.languages.map(l=>[l,names ? l+' — '+names.of(l) : l]),state.locale);
|
||||
fillSelect('sel-locale',data.languages.map(locale=>{
|
||||
let name;
|
||||
try {
|
||||
// Show each language in its own language, independent of the browser UI.
|
||||
if (Intl.DisplayNames.supportedLocalesOf([locale]).length) {
|
||||
name=new Intl.DisplayNames([locale],{type:'language',fallback:'none'}).of(locale);
|
||||
}
|
||||
} catch (_) { /* Older browsers can still select languages by locale code. */ }
|
||||
return [locale,name && name!==locale ? locale+' — '+name : locale];
|
||||
}),state.locale);
|
||||
document.querySelectorAll('#sel-locale option').forEach(option=>{
|
||||
option.lang=option.value;
|
||||
option.dir='auto';
|
||||
});
|
||||
}
|
||||
|
||||
function loadLocale(lang) {
|
||||
|
||||
+11
-3
@@ -9,8 +9,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { padding:48px 40px;background:#191432cc;border:1px solid #0d948833;box-shadow:0 0 60px #0d948814,0 0 120px #6366f10a }
|
||||
.email-heading { margin:0 0 20px;font-size:24px;font-weight:300;color:#ccfbf1;line-height:1.3;letter-spacing:-.5px }
|
||||
.email-text { margin:0 0 24px;font-size:14px;line-height:2;color:#d2fcf5e0;font-weight:300 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 20px;font-size:24px;font-weight:300;color:#ccfbf1;line-height:1.3;letter-spacing:-.5px }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 24px;font-size:14px;line-height:2;color:#d2fcf5e0;font-weight:300 }
|
||||
.email-subtext { margin:0;font-size:13px;line-height:1.8;color:#b4c3d7f2;font-weight:300 }
|
||||
|
||||
/* Divider */
|
||||
@@ -41,7 +44,9 @@
|
||||
@media only screen and (max-width:480px) {
|
||||
.email-card { padding:32px 20px!important }
|
||||
.email-btn-row { padding:24px 20px!important }
|
||||
.email-heading { font-size:20px!important }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { font-size:20px!important }
|
||||
}
|
||||
|
||||
/* Official-snapshot presentation adapter: no content or translation overrides. */
|
||||
@@ -76,3 +81,6 @@ body { background-image:radial-gradient(ellipse at center,#0d948814,transparent
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { max-width:600px;margin:24px auto;background:#ffffff;border-radius:16px;box-shadow:0 2px 4px #0000000f,0 8px 24px #0000000f,0 0 0 1px #0000000a;overflow:hidden }
|
||||
.email-heading { margin:0 0 12px;font-size:17px;line-height:1.5;color:#1e293b;font-weight:600 }
|
||||
.email-text { margin:0 0 8px;font-size:15px;line-height:1.6;color:#475569 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 12px;font-size:17px;line-height:1.5;color:#1e293b;font-weight:600 }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 8px;font-size:15px;line-height:1.6;color:#475569 }
|
||||
.email-subtext { margin:0 0 20px;font-size:14px;line-height:1.6;color:#64748b }
|
||||
|
||||
/* Button */
|
||||
@@ -59,3 +62,6 @@ body, td { font-family:Arial,sans-serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { max-width:600px;margin:20px auto;background:#ffffff;border-radius:16px;box-shadow:0 2px 16px #ea580c14;overflow:hidden;border:1px solid #fed7aa }
|
||||
.email-heading { margin:0 0 12px;font-size:17px;line-height:1.5;color:#9a3412;font-family:Georgia,'Times New Roman',serif;font-weight:600 }
|
||||
.email-text { margin:0 0 8px;font-size:16px;line-height:1.6;color:#78350f }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 12px;font-size:17px;line-height:1.5;color:#9a3412;font-family:Georgia,'Times New Roman',serif;font-weight:600 }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 8px;font-size:16px;line-height:1.6;color:#78350f }
|
||||
.email-subtext { margin:0 0 20px;font-size:15px;line-height:1.6;color:#92400e }
|
||||
|
||||
/* Button */
|
||||
@@ -59,3 +62,6 @@ body, td { font-family:Arial,sans-serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { max-width:600px;margin:24px auto;background:#ffffff;border:3px double #c4a97d;padding:0 }
|
||||
.email-heading { margin:0 0 14px;font-size:17px;line-height:1.5;color:#1e3a5f;font-weight:600;font-family:Georgia,'Times New Roman',serif }
|
||||
.email-text { margin:0 0 8px;font-size:16px;line-height:1.7;color:#2c1810 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 14px;font-size:17px;line-height:1.5;color:#1e3a5f;font-weight:600;font-family:Georgia,'Times New Roman',serif }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 8px;font-size:16px;line-height:1.7;color:#2c1810 }
|
||||
.email-subtext { margin:0 0 20px;font-size:15px;line-height:1.7;color:#5c4a3a }
|
||||
|
||||
/* Button */
|
||||
@@ -59,3 +62,6 @@ body, td { font-family:Georgia,'Times New Roman',serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
@@ -9,8 +9,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { padding:32px 32px 24px;color:#334155;background:#ffffff }
|
||||
.email-heading { margin:0 0 16px;font-size:16px;line-height:1.5;color:#1e293b }
|
||||
.email-text { margin:0 0 8px;font-size:16px;line-height:1.5;color:#334155 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 16px;font-size:16px;line-height:1.5;color:#1e293b }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 8px;font-size:16px;line-height:1.5;color:#334155 }
|
||||
.email-subtext { margin:0 0 20px;font-size:16px;line-height:1.5;color:#334155 }
|
||||
|
||||
/* Divider */
|
||||
@@ -73,3 +76,6 @@ body, td { font-family:Helvetica,Arial,sans-serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
@@ -10,8 +10,11 @@
|
||||
/* Card */
|
||||
.email-card { padding:24px 24px 16px 28px;vertical-align:top;color:#333 }
|
||||
.email-sidebar { padding:24px 28px 16px 0;vertical-align:top;border-left:1px solid #ddd }
|
||||
.email-heading { margin:0 0 16px;font-size:15px;color:#333;line-height:1.7 }
|
||||
.email-text { margin:0 0 12px;font-size:13px;line-height:1.8;color:#444 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 16px;font-size:15px;color:#333;line-height:1.7 }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 12px;font-size:13px;line-height:1.8;color:#444 }
|
||||
.email-subtext { margin:0 0 20px;font-size:12px;line-height:1.8;color:#666;font-style:italic }
|
||||
|
||||
/* Drop-cap */
|
||||
@@ -93,3 +96,6 @@ body, td { font-family:Georgia,'Times New Roman',serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
+11
-3
@@ -10,8 +10,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { padding:32px 0 0;vertical-align:top }
|
||||
.email-heading { margin:0 0 32px;font-size:28px;font-weight:900;color:#000;line-height:1.1;letter-spacing:-1px }
|
||||
.email-text { margin:0 0 8px;font-size:13px;line-height:1.8;color:#333;font-weight:500 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 32px;font-size:28px;font-weight:900;color:#000;line-height:1.1;letter-spacing:-1px }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 8px;font-size:13px;line-height:1.8;color:#333;font-weight:500 }
|
||||
.email-subtext { margin:0 0 32px;font-size:12px;line-height:1.8;color:#888;border-left:2px solid #e03131;padding-left:12px }
|
||||
|
||||
/* Button */
|
||||
@@ -38,7 +41,9 @@
|
||||
.email-card { width:100%!important;display:block!important }
|
||||
.resp-hide { display:none!important }
|
||||
.email-btn { display:block!important;width:auto!important }
|
||||
.email-heading { font-size:22px!important }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { font-size:22px!important }
|
||||
.email-footer { padding-left:16px!important;padding-right:16px!important }
|
||||
}
|
||||
|
||||
@@ -73,3 +78,6 @@ body, td { font-family:Arial,sans-serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
+11
-3
@@ -9,8 +9,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { padding:8px 32px 24px;color:#cbd5e1 }
|
||||
.email-heading { margin:0 0 20px;font-size:15px;font-weight:600;color:#00d4ff;line-height:1.3 }
|
||||
.email-text { margin:0 0 24px;font-size:14px;line-height:1.7;color:#cbd5e1;font-weight:400 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 20px;font-size:15px;font-weight:600;color:#00d4ff;line-height:1.3 }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 24px;font-size:14px;line-height:1.7;color:#cbd5e1;font-weight:400 }
|
||||
.email-subtext { margin:0;font-size:13px;line-height:1.6;color:#64748b;font-weight:400 }
|
||||
|
||||
/* Divider */
|
||||
@@ -41,7 +44,9 @@
|
||||
@media only screen and (max-width:480px) {
|
||||
.email-card { padding:32px 20px!important }
|
||||
.email-btn-row { padding:24px 20px!important }
|
||||
.email-heading { font-size:14px!important }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { font-size:14px!important }
|
||||
}
|
||||
|
||||
/* Official-snapshot presentation adapter: no content or translation overrides. */
|
||||
@@ -76,3 +81,6 @@ body, td { font-family:Arial,sans-serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
@@ -9,8 +9,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { padding:28px 28px 20px;background:#161b22;color:#c9d1d9 }
|
||||
.email-heading { margin:0 0 12px;font-size:15px;font-weight:400;color:#58a6ff;line-height:1.6 }
|
||||
.email-text { margin:0 0 8px;font-size:14px;line-height:1.6;color:#c9d1d9;font-weight:400 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0 0 12px;font-size:15px;font-weight:400;color:#58a6ff;line-height:1.6 }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 8px;font-size:14px;line-height:1.6;color:#c9d1d9;font-weight:400 }
|
||||
.email-subtext { margin:0 0 20px;font-size:14px;line-height:1.6;color:#8b949e;font-weight:400 }
|
||||
|
||||
/* Divider */
|
||||
@@ -41,7 +44,9 @@
|
||||
@media only screen and (max-width:480px) {
|
||||
.email-card { padding:20px 16px!important }
|
||||
.email-btn-row { padding:20px 16px!important }
|
||||
.email-heading { font-size:14px!important }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { font-size:14px!important }
|
||||
}
|
||||
|
||||
/* Official-snapshot presentation adapter: no content or translation overrides. */
|
||||
@@ -77,3 +82,6 @@ body, td { font-family:Consolas,'Courier New',monospace; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
+11
-3
@@ -9,8 +9,11 @@
|
||||
|
||||
/* Card */
|
||||
.email-card { padding:28px 32px 24px;background:#fffdf9;color:#5c4030 }
|
||||
.email-heading { margin:0;font-size:22px;line-height:1.4;color:#3d2b1f;font-weight:700 }
|
||||
.email-text { margin:0 0 8px;font-size:15px;line-height:1.8;color:#6b5040 }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { margin:0;font-size:22px;line-height:1.4;color:#3d2b1f;font-weight:700 }
|
||||
.email-text,
|
||||
.mail-type-activate > p:nth-of-type(2) { margin:0 0 8px;font-size:15px;line-height:1.8;color:#6b5040 }
|
||||
.email-subtext { margin:0 0 24px;font-size:14px;line-height:1.8;color:#8c7a6e;font-style:italic }
|
||||
|
||||
/* Divider */
|
||||
@@ -41,7 +44,9 @@
|
||||
@media only screen and (max-width:480px) {
|
||||
.email-card { padding:20px 16px!important }
|
||||
.email-btn-row { padding:16px!important }
|
||||
.email-heading { font-size:18px!important }
|
||||
.email-heading,
|
||||
.mail-type-activate > p:first-child,
|
||||
.mail-type-activate_email > p:first-child { font-size:18px!important }
|
||||
}
|
||||
|
||||
/* Official-snapshot presentation adapter: no content or translation overrides. */
|
||||
@@ -78,3 +83,6 @@ body, td { font-family:Georgia,'Times New Roman',serif; }
|
||||
.email-btn-row { box-sizing:border-box; }
|
||||
.card-body .email-btn { max-width:100%; box-sizing:border-box; white-space:normal; overflow-wrap:anywhere; }
|
||||
@media only screen and (max-width:480px) { .card-body .email-btn { display:block; text-align:center; width:100%; } .email-btn-table { width:100%; } }
|
||||
|
||||
/* Account activation instructions use the normal body style. */
|
||||
.mail-type-activate > p:nth-of-type(2) { font-style:normal; border-left:0; padding-left:0; }
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"github.com/urfave/cli/v2"
|
||||
"path/filepath"
|
||||
@@ -26,7 +26,7 @@ func BuildCommand() *cli.Command {
|
||||
if err := builder.Write(output, s, packages); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Built %d themes for %s at %s\n", len(packages), s.Lock.Tag, output)
|
||||
logging.Info("Builder", "Built %d themes for %s at %s", len(packages), s.Lock.Tag, output)
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
+2
-1
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"github.com/urfave/cli/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -36,7 +37,7 @@ func runCreate(c *cli.Context) error {
|
||||
if err := os.WriteFile(filepath.Join(dir, "theme.css"), []byte(css), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Created framework theme %s\n", name)
|
||||
logging.Info("CLI", "Created framework theme %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+3
-2
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -44,7 +45,7 @@ func runDelete(c *cli.Context) error {
|
||||
}
|
||||
|
||||
if _, err := os.Stat(styleDir); os.IsNotExist(err) {
|
||||
fmt.Printf("\033[33m[W]\033[0m [CLI] '%s' does not exist, skipped\n", styleName)
|
||||
logging.Warn("CLI", "'%s' does not exist, skipped", styleName)
|
||||
continue
|
||||
}
|
||||
if _, err := builder.LoadTheme(styleDir); err != nil {
|
||||
@@ -54,7 +55,7 @@ func runDelete(c *cli.Context) error {
|
||||
if err := os.RemoveAll(styleDir); err != nil {
|
||||
return fmt.Errorf("cannot delete %s: %w", styleDir, err)
|
||||
}
|
||||
fmt.Printf("\033[32m[I]\033[0m [CLI] Deleted style '%s'\n", styleName)
|
||||
logging.Info("CLI", "Deleted style '%s'", styleName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -69,7 +70,7 @@ func runDev(c *cli.Context) error {
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
fmt.Println("\n\033[32m[I]\033[0m [Server] Shutting down")
|
||||
logging.Info("Server", "Shutting down")
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -77,7 +78,7 @@ func runPreview(c *cli.Context) error {
|
||||
|
||||
preview.PrintDetailedSummary(result, folder, cfg)
|
||||
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] Wrote %d themes x %d templates to %s\n",
|
||||
logging.Info("Preview", "Wrote %d themes x %d templates to %s",
|
||||
len(result.Themes), len(cfg.Templates), outputPath)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
@@ -19,10 +19,10 @@ func UpstreamCommand() *cli.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] %s: %d mail files, %d languages\n", s.Lock.Tag, len(s.Templates), len(s.Locales))
|
||||
logging.Info("Upstream", "%s: %d mail files, %d languages", s.Lock.Tag, len(s.Templates), len(s.Locales))
|
||||
for _, lang := range s.Languages() {
|
||||
if keys := missing[lang]; len(keys) > 0 {
|
||||
fmt.Printf("[FALLBACK] %s -> en-US: %v\n", lang, keys)
|
||||
logging.Warn("Locale", "%s -> en-US: %v", lang, keys)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
// Package integration exercises an explicitly supplied official Gitea binary.
|
||||
// Everything runs on loopback with disposable users, SQLite and captured SMTP.
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
)
|
||||
|
||||
type safeLog struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (l *safeLog) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.Write(p)
|
||||
}
|
||||
func (l *safeLog) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.buf.String() }
|
||||
|
||||
func smtpCapture(t *testing.T) (int, <-chan []byte) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
mailbox := make(chan []byte, 4)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(time.Minute))
|
||||
r := textproto.NewReader(bufio.NewReader(conn))
|
||||
fmt.Fprint(conn, "220 localhost SMTP capture\r\n")
|
||||
for {
|
||||
line, err := r.ReadLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
command := strings.ToUpper(fields[0])
|
||||
switch command {
|
||||
case "EHLO", "HELO":
|
||||
fmt.Fprint(conn, "250 localhost\r\n")
|
||||
case "DATA":
|
||||
fmt.Fprint(conn, "354 Send mail\r\n")
|
||||
data, err := r.ReadDotBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mailbox <- data
|
||||
fmt.Fprint(conn, "250 Captured\r\n")
|
||||
case "QUIT":
|
||||
fmt.Fprint(conn, "221 Goodbye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprint(conn, "250 OK\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return listener.Addr().(*net.TCPAddr).Port, mailbox
|
||||
}
|
||||
|
||||
func TestGiteaMailSmoke(t *testing.T) {
|
||||
binary := os.Getenv("GITEA_SMOKE_BINARY")
|
||||
if binary == "" {
|
||||
t.Skip("set GITEA_SMOKE_BINARY to a checksum-verified official Gitea binary")
|
||||
}
|
||||
binary, err := filepath.Abs(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version, err := exec.Command(binary, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := strings.Fields(strings.ToLower(string(version)))
|
||||
if len(fields) < 3 || fields[0] != "gitea" || fields[1] != "version" || fields[2] != strings.TrimPrefix(s.Lock.Tag, "v") {
|
||||
t.Fatalf("binary does not match snapshot: %s", version)
|
||||
}
|
||||
for _, mode := range []string{"shared", "framed"} {
|
||||
t.Run(mode, func(t *testing.T) { smokeMode(t, binary, s, mode) })
|
||||
}
|
||||
}
|
||||
|
||||
func smokeMode(t *testing.T, binary string, s *upstream.Snapshot, mode string) {
|
||||
port, mailbox := smtpCapture(t)
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webPort := probe.Addr().(*net.TCPAddr).Port
|
||||
probe.Close()
|
||||
work := t.TempDir()
|
||||
// Preseed only Gitea's disposable Git home; never touch the user's Git config.
|
||||
gitHome := filepath.Join(work, "data", "home")
|
||||
if err := os.MkdirAll(gitHome, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitConfig := "[user]\nname = Gitea\nemail = gitea@fake.local\n[core]\nquotepath = false\nlongpaths = true\ncommitGraph = true\n[gc]\nwriteCommitGraph = true\n[fetch]\nwriteCommitGraph = true\n[init]\ndefaultBranch = main\n[receive]\nadvertisePushOptions = true\nprocReceiveRefs = refs/for\n[uploadpack]\nallowfilter = true\nallowAnySHA1InWant = true\n[safe]\ndirectory = *\n"
|
||||
if err := os.WriteFile(filepath.Join(gitHome, ".gitconfig"), []byte(gitConfig), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := filepath.Join(work, "custom")
|
||||
configFile := filepath.Join(work, "app.ini")
|
||||
rootURL := fmt.Sprintf("http://127.0.0.1:%d/", webPort)
|
||||
ini := fmt.Sprintf("APP_NAME = Smoke Gitea\nRUN_MODE = prod\n[server]\nAPP_DATA_PATH = %s/data\nHTTP_ADDR = 127.0.0.1\nHTTP_PORT = %d\nROOT_URL = %s\nDISABLE_SSH = true\n[database]\nDB_TYPE = sqlite3\nPATH = %s/gitea.db\n[repository]\nROOT = %s/repos\n[security]\nINSTALL_LOCK = true\n[service]\nDISABLE_REGISTRATION = true\nREGISTER_EMAIL_CONFIRM = false\nENABLE_NOTIFY_MAIL = true\n[mailer]\nENABLED = true\nPROTOCOL = smtp\nSMTP_ADDR = 127.0.0.1\nSMTP_PORT = %d\nFROM = Smoke Gitea <smoke@example.invalid>\n[log]\nMODE = console\nLEVEL = warn\n", filepath.ToSlash(work), webPort, rootURL, filepath.ToSlash(work), filepath.ToSlash(work), port)
|
||||
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Disable key-file maintenance and isolate even its unused paths from HOME.
|
||||
ini = strings.Replace(ini, "DISABLE_SSH = true", "DISABLE_SSH = true\nSSH_ROOT_PATH = "+filepath.ToSlash(filepath.Join(work, "ssh"))+"\nSSH_CREATE_AUTHORIZED_KEYS_FILE = false\nSSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = false", 1)
|
||||
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p *builder.Package
|
||||
if mode == "framed" {
|
||||
theme, err := builder.LoadTheme(filepath.Join("..", "..", "themes", "horizon"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err = builder.Build(s, theme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
p, err = builder.Build(s, &builder.Theme{Name: "smoke", Mode: "shared", CSS: "a { color: #123456; }"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, data := range p.Files {
|
||||
target := filepath.Join(custom, "templates", filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--work-path", work, "--custom-path", custom, "--config", configFile}
|
||||
run := func(more ...string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binary, append(append([]string{}, args...), more...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Gitea %v: %v\n%s", more, err, output)
|
||||
}
|
||||
}
|
||||
run("migrate")
|
||||
run("admin", "user", "create", "--username", "smoke-user", "--password", "local-smoke-password-123!", "--email", "smoke@example.invalid", "--admin", "--must-change-password=false")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var logs safeLog
|
||||
web := exec.CommandContext(ctx, binary, append(args, "web")...)
|
||||
web.Stdout = &logs
|
||||
web.Stderr = &logs
|
||||
if err := web.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { _ = web.Wait(); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
var page []byte
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("Gitea exited: %s", logs.String())
|
||||
default:
|
||||
}
|
||||
resp, err := client.Get(rootURL + "user/forgot_password")
|
||||
if err == nil {
|
||||
page, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if len(page) == 0 {
|
||||
t.Fatalf("Gitea did not start: %s", logs.String())
|
||||
}
|
||||
if !bytes.Contains(page, []byte(`name="email"`)) {
|
||||
t.Fatalf("no reset form: %s\n%s", page, logs.String())
|
||||
}
|
||||
// Gitea 28 uses origin checks rather than a hidden _csrf form field.
|
||||
req, err := http.NewRequest(http.MethodPost, rootURL+"user/forgot_password", strings.NewReader(url.Values{"email": {"smoke@example.invalid"}}.Encode()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", strings.TrimSuffix(rootURL, "/"))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("password reset HTTP %d: %s", resp.StatusCode, logs.String())
|
||||
}
|
||||
select {
|
||||
case raw := <-mailbox:
|
||||
message, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts, err := mimeBodies(message.Header.Get("Content-Type"), message.Header.Get("Content-Transfer-Encoding"), message.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := parts["text/html"]
|
||||
if !strings.Contains(body, "/user/recover_account?code=") || !strings.Contains(body, "<style>") {
|
||||
t.Fatalf("official reset body or injected head missing: %s", body)
|
||||
}
|
||||
if mode == "framed" && !strings.Contains(body, `class="email-wrap`) {
|
||||
t.Fatal("framed override was not loaded")
|
||||
}
|
||||
if mode == "framed" && (!strings.Contains(body, `class="email-btn">Recover your account</a>`) || !strings.Contains(body, `src="`+rootURL+`assets/img/favicon.png"`) || strings.Count(body, "/user/recover_account?code=") < 3) {
|
||||
t.Fatal("shared action label, fallback or instance logo missing from real mail")
|
||||
}
|
||||
if mode == "shared" && (!strings.Contains(body, "#123456") || strings.Contains(body, `class="email-wrap`)) {
|
||||
t.Fatal("shared partial was not loaded")
|
||||
}
|
||||
if !strings.Contains(parts["text/plain"], "recover_account") {
|
||||
t.Fatal("Gitea plain-text alternative missing official recovery link")
|
||||
}
|
||||
t.Logf("[PASS] %s %s: real password-reset mail captured; HTML override and plain-text link verified", s.Lock.Tag, mode)
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatalf("no mail captured: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mimeBodies(contentType, encoding string, r io.Reader) (map[string]string, error) {
|
||||
media, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]string{}
|
||||
if strings.HasPrefix(media, "multipart/") {
|
||||
reader := multipart.NewReader(r, params["boundary"])
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodies, err := mimeBodies(part.Header.Get("Content-Type"), part.Header.Get("Content-Transfer-Encoding"), part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range bodies {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
switch strings.ToLower(encoding) {
|
||||
case "quoted-printable":
|
||||
r = quotedprintable.NewReader(r)
|
||||
case "base64":
|
||||
r = base64.NewDecoder(base64.StdEncoding, r)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[media] = string(b)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package logging formats tool diagnostics consistently across commands.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var outputMu sync.Mutex
|
||||
|
||||
// Info reports normal progress.
|
||||
func Info(name, format string, args ...any) { output(os.Stdout, "I", name, format, args...) }
|
||||
|
||||
// Warn reports a condition that does not prevent the operation from continuing.
|
||||
func Warn(name, format string, args ...any) { output(os.Stderr, "W", name, format, args...) }
|
||||
|
||||
// Error reports a recoverable operation failure.
|
||||
func Error(name, format string, args ...any) { output(os.Stderr, "E", name, format, args...) }
|
||||
|
||||
// Fatal reports a command-ending failure. The caller controls the exit status.
|
||||
func Fatal(name, format string, args ...any) { output(os.Stderr, "F", name, format, args...) }
|
||||
|
||||
func output(file *os.File, level, name, format string, args ...any) {
|
||||
stat, err := file.Stat()
|
||||
_, noColor := os.LookupEnv("NO_COLOR")
|
||||
color := err == nil && stat.Mode()&os.ModeCharDevice != 0 && !noColor && os.Getenv("TERM") != "dumb"
|
||||
outputMu.Lock()
|
||||
defer outputMu.Unlock()
|
||||
write(file, color, level, name, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func write(w io.Writer, color bool, level, name, message string) {
|
||||
label := "[" + level + "]"
|
||||
if color {
|
||||
code := map[string]string{"I": "32", "W": "33", "E": "31", "F": "35"}[level]
|
||||
label = "\033[" + code + "m" + label + "\033[0m"
|
||||
}
|
||||
// Each physical line retains its level and component, including wrapped errors.
|
||||
message = strings.ReplaceAll(message, "\r\n", "\n")
|
||||
for _, line := range strings.Split(strings.TrimRight(message, "\n"), "\n") {
|
||||
fmt.Fprintf(w, "%s [%s] %s\n", label, name, line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
level, code string
|
||||
}{{"I", "32"}, {"W", "33"}, {"E", "31"}, {"F", "35"}} {
|
||||
t.Run(tc.level, func(t *testing.T) {
|
||||
for _, color := range []bool{false, true} {
|
||||
var buf bytes.Buffer
|
||||
write(&buf, color, tc.level, "Builder", "first\r\nsecond\n")
|
||||
label := "[" + tc.level + "]"
|
||||
if color {
|
||||
label = "\033[" + tc.code + "m" + label + "\033[0m"
|
||||
}
|
||||
want := label + " [Builder] first\n" + label + " [Builder] second\n"
|
||||
if buf.String() != want {
|
||||
t.Fatalf("got %q, want %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -259,15 +260,15 @@ func WriteRenderedJS(result *PreviewResult, output string) error {
|
||||
}
|
||||
|
||||
func PrintDetailedSummary(result *PreviewResult, _ string, _ *config.TemplatesConfig) {
|
||||
fmt.Printf("[PASS] %s: %d themes × %d mail types × %d languages\n", result.Upstream, len(result.Themes), len(result.Registry), len(result.Languages))
|
||||
logging.Info("Preview", "%s: %d themes × %d mail types × %d languages", result.Upstream, len(result.Themes), len(result.Registry), len(result.Languages))
|
||||
for _, lang := range result.Languages {
|
||||
for _, warnings := range result.Warnings[lang] {
|
||||
for _, warning := range warnings {
|
||||
fmt.Println(warning)
|
||||
logging.Warn("Upstream", "%s", strings.TrimPrefix(warning, "[UPSTREAM-WARN] "))
|
||||
}
|
||||
}
|
||||
if keys := result.Fallback[lang]; len(keys) > 0 {
|
||||
fmt.Printf("[FALLBACK] %s -> en-US: %s\n", lang, strings.Join(keys, ", "))
|
||||
logging.Warn("Locale", "%s -> en-US: %s", lang, strings.Join(keys, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -3,6 +3,7 @@ package preview
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -62,10 +63,10 @@ func (s *DevServer) Start() error {
|
||||
mux.Handle("/", previewFS)
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] Gitea Mail Templates — Dev Server\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] http://%s\n", addr)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching theme CSS, framework, lock/cache and fixtures\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint http://%s/events\n\n", addr)
|
||||
logging.Info("Server", "Gitea Mail Templates — Dev Server")
|
||||
logging.Info("Server", "http://%s", addr)
|
||||
logging.Info("Watcher", "Watching theme CSS, framework, lock/cache and fixtures")
|
||||
logging.Info("Server", "SSE endpoint http://%s/events", addr)
|
||||
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
@@ -207,18 +208,20 @@ func (s *DevServer) watchThemes() {
|
||||
last, ok := s.lastMods[path]
|
||||
if !ok || mod.After(last) {
|
||||
rel, _ := filepath.Rel(s.cfg.ThemesDir, path)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] %s changed\n", filepath.ToSlash(rel))
|
||||
logging.Info("Watcher", "%s changed", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
for path := range s.lastMods {
|
||||
if _, ok := current[path]; !ok {
|
||||
rel, _ := filepath.Rel(s.cfg.ThemesDir, path)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] %s deleted\n", filepath.ToSlash(rel))
|
||||
logging.Info("Watcher", "%s deleted", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
s.lastMods = current
|
||||
s.modsMu.Unlock()
|
||||
s.rebuildAll()
|
||||
if err := s.rebuildAll(); err != nil {
|
||||
logging.Error("Builder", "Rebuild failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
s.modsMu.Unlock()
|
||||
}
|
||||
@@ -234,7 +237,7 @@ func (s *DevServer) rebuildAll() error {
|
||||
defer rebuildMu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuilding all themes...\n")
|
||||
logging.Info("Builder", "Rebuilding all themes...")
|
||||
|
||||
cfg, err := config.Load(filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data", "templates_config.json"))
|
||||
if err != nil {
|
||||
@@ -254,7 +257,7 @@ func (s *DevServer) rebuildAll() error {
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuild done in %v\n", elapsed.Round(time.Millisecond))
|
||||
logging.Info("Builder", "Rebuild done in %v", elapsed.Round(time.Millisecond))
|
||||
|
||||
s.broadcast("reload", map[string]interface{}{"elapsed": elapsed.Milliseconds()})
|
||||
return nil
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "gitea-mail-preview-qa",
|
||||
"private": true,
|
||||
"scripts": { "test": "node preview.cjs" },
|
||||
"devDependencies": { "playwright": "1.62.1", "sharp": "0.35.5" }
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Optional browser QA: npm install in this directory, then npm test.
|
||||
const { chromium } = require('playwright');
|
||||
const sharp = require('sharp');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
async function ready(page) {
|
||||
await page.waitForFunction(() => document.getElementById('preview-frame').style.display === 'block' && document.getElementById('loading').style.display === 'none');
|
||||
const frame = page.frameLocator('#preview-frame');
|
||||
await frame.locator('body').waitFor();
|
||||
return frame;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true, executablePath:process.env.BROWSER_EXECUTABLE_PATH || undefined});
|
||||
try {
|
||||
const page = await browser.newPage({viewport:{width:1650,height:1050}});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(pathToFileURL(path.join(root,'preview/index.html')).href);
|
||||
await page.addStyleTag({content:'*{transition:none!important;animation:none!important}'});
|
||||
await ready(page);
|
||||
// Floating inspector is preview chrome, not part of the rendered email.
|
||||
if (await page.locator('#panel-close').isVisible()) await page.click('#panel-close');
|
||||
const languages = await page.locator('#sel-locale option').evaluateAll(options => options.map(o=>o.value));
|
||||
const themes = await page.locator('#sel-theme option').evaluateAll(options => options.map(o=>o.value));
|
||||
const types = await page.locator('#sel-template option').evaluateAll(options => options.map(o=>o.value));
|
||||
const buttonColors = {aurora:'rgb(224, 245, 243)',bloom:'rgb(255, 255, 255)',ember:'rgb(255, 255, 255)',heritage:'rgb(255, 255, 255)',horizon:'rgb(255, 255, 255)',ink:'rgb(254, 253, 251)',mono:'rgb(0, 0, 0)',neon:'rgb(10, 10, 15)',terminal:'rgb(13, 17, 23)',terra:'rgb(255, 255, 255)'};
|
||||
if (!languages.includes('en-US') || !languages.includes('zh-CN')) throw new Error('official languages missing');
|
||||
for (const lang of languages) {
|
||||
await page.selectOption('#sel-locale',lang);
|
||||
const frame = await ready(page);
|
||||
if (!(await frame.locator('body').textContent()).trim()) throw new Error('empty '+lang+' preview');
|
||||
}
|
||||
await page.selectOption('#sel-locale','pl-PL');
|
||||
await page.selectOption('#sel-template','team_invite');
|
||||
await ready(page);
|
||||
await page.waitForFunction(()=>document.getElementById('locale-message')?.textContent.includes('[UPSTREAM-WARN]'));
|
||||
await page.selectOption('#sel-locale','zh-CN');
|
||||
await page.selectOption('#sel-locale','en-US');
|
||||
await ready(page);
|
||||
const screenshotDir = path.join(root,'build','screenshots');
|
||||
await fs.mkdir(screenshotDir,{recursive:true});
|
||||
for (const theme of themes) {
|
||||
await page.selectOption('#sel-theme',theme);
|
||||
await page.selectOption('#sel-template','register_notify');
|
||||
await page.click('#btn-desk');
|
||||
const desktop = await ready(page);
|
||||
const cta = desktop.locator('.card-body .email-btn').first();
|
||||
const color = await cta.evaluate(el=>getComputedStyle(el).color);
|
||||
if (buttonColors[theme] && color!==buttonColors[theme]) throw new Error('CTA styling overridden: '+theme+' '+color);
|
||||
const label = (await cta.textContent()).trim();
|
||||
if (!label || /^https?:/i.test(label)) throw new Error('button displays URL: '+theme);
|
||||
const fallback = desktop.locator('.email-link-url a').first();
|
||||
if (await cta.getAttribute('href') !== await fallback.getAttribute('href')) throw new Error('button/fallback target mismatch: '+theme);
|
||||
const logo = desktop.locator('img.email-logo');
|
||||
if (await logo.count()) {
|
||||
if (!(await logo.getAttribute('src')).startsWith('data:image/png;base64,')) throw new Error('preview logo is not generated official asset: '+theme);
|
||||
if (!(await logo.evaluate(img=>img.complete && img.naturalWidth>0))) throw new Error('logo failed to load: '+theme);
|
||||
}
|
||||
// Capture the actual 600px email, not the wide preview canvas/inspector.
|
||||
await page.waitForFunction(()=>document.getAnimations().every(a=>a.playState!=='running'));
|
||||
const box = await desktop.locator('.email-wrap').boundingBox();
|
||||
if (!box) throw new Error('email frame missing: '+theme);
|
||||
const png = await page.screenshot({clip:box,animations:'disabled'});
|
||||
const background = await desktop.locator('body').evaluate(el=>getComputedStyle(el).backgroundColor);
|
||||
const image = await sharp(png).extend({top:16,bottom:16,left:16,right:16,background}).resize({width:600}).png({compressionLevel:9}).toBuffer();
|
||||
await fs.writeFile(path.join(screenshotDir,theme+'.png'),image);
|
||||
for (const type of types) {
|
||||
await page.selectOption('#sel-template',type);
|
||||
await page.click('#btn-mob');
|
||||
const frame = await ready(page);
|
||||
const overflow = await frame.locator('html').evaluate(el=>el.scrollWidth > window.innerWidth + 1);
|
||||
if (overflow) throw new Error('mobile overflow: '+theme+'/'+type);
|
||||
}
|
||||
}
|
||||
if (errors.length) throw new Error(errors.join('\n'));
|
||||
console.log('[PASS] file:// language bundles, warning, rapid switching, '+themes.length+' theme screenshots and mobile overflow');
|
||||
// A running dev server can be included without making static QA depend on it.
|
||||
if (process.env.PREVIEW_DEV_URL) {
|
||||
await page.goto(process.env.PREVIEW_DEV_URL);
|
||||
await ready(page);
|
||||
await page.selectOption('#sel-locale','zh-CN');
|
||||
await ready(page);
|
||||
console.log('[PASS] HTTP preview and language switching');
|
||||
}
|
||||
if (process.argv.includes('--update-gallery')) {
|
||||
for (const theme of themes) {
|
||||
const image=await fs.readFile(path.join(screenshotDir,theme+'.png'));
|
||||
if (image.length>50*1024) throw new Error('gallery image exceeds 50 KiB: '+theme);
|
||||
await fs.writeFile(path.join(root,'docs','images',theme+'.png'),image);
|
||||
}
|
||||
console.log('[PASS] gallery screenshots updated');
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -26,7 +26,7 @@ func main() {
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[31m[E]\033[0m [CLI] %v\n", err)
|
||||
logging.Fatal("CLI", "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -116,7 +117,7 @@ func prepareFrom(ctx context.Context, root, rawBase string) (*Snapshot, error) {
|
||||
if err = os.Rename(stage, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("[PASS] Downloaded locked Gitea %s (%s) to build cache\n", lock.Tag, lock.Commit)
|
||||
logging.Info("Upstream", "Downloaded locked Gitea %s (%s) to build cache", lock.Tag, lock.Commit)
|
||||
return LoadPinned(root)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/logging"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -159,6 +160,6 @@ func syncFrom(ctx context.Context, tag, dir, api, raw string) error {
|
||||
} else if err := os.Rename(stage, abs); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Locked %s (%s): %d templates, %d languages\n", tag, commit.SHA, len(s.Templates), len(s.Locales))
|
||||
logging.Info("Upstream", "Locked %s (%s): %d templates, %d languages", tag, commit.SHA, len(s.Templates), len(s.Locales))
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user