Compare commits
3
Commits
5b06a795e4
...
6733e81356
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6733e81356 | ||
|
|
e51e2e9996 | ||
|
|
5f5ad058a8 |
No files matched your search
@@ -1,16 +0,0 @@
|
||||
# v1.27.2
|
||||
|
||||
> **Correction (2026-09-23):** The push-notification fix in this release is incomplete. Bloom, Ember and Heritage still use the old commit ID path and can fail to render push notifications on Gitea 1.27.1+. Use v1.27.3 for the complete fix; see the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix).
|
||||
|
||||
## Changes
|
||||
|
||||
- Add the `email-text` class to Aurora release paragraphs for consistent typography.
|
||||
- Update push commit paths in seven themes for Gitea 1.27.1 compatibility (`.ID` → `.UserCommit.GitCommit.ID`). The three remaining themes are covered by the correction above.
|
||||
- Replace `rgba()` colors with `#RRGGBBAA` notation across all ten themes.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Record compatibility for Gitea 1.27.0, 1.27.1 and 1.27.2.
|
||||
- Maintain English and Simplified Chinese README and contributor guides.
|
||||
|
||||
[Full changelog: v1.0.1…v1.27.2](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.0.1...v1.27.2)
|
||||
@@ -1,20 +0,0 @@
|
||||
# v1.27.3
|
||||
|
||||
This release completes the push-notification fix for Gitea 1.27.1–1.27.3. See the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix) for recommended release combinations.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fix pull request push-notification links and abbreviated hashes in Bloom, Ember and Heritage by reading `.UserCommit.GitCommit.ID`.
|
||||
- Add a push-notification regression test covering all ten themes and run Go tests before packaging.
|
||||
|
||||
## Packaging
|
||||
|
||||
- Include English and Simplified Chinese README and contributor guides in release archives.
|
||||
- Use reviewed release notes in the publishing workflow.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Record compatibility with Gitea 1.27.3, whose mail templates, mailer and locale files are unchanged from 1.27.2.
|
||||
- Distinguish the affected v1.27.2 archive from the complete fix in this release.
|
||||
|
||||
[Full changelog: v1.27.2…v1.27.3](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.27.2...v1.27.3)
|
||||
@@ -1,18 +0,0 @@
|
||||
# v28.0.0
|
||||
|
||||
This release targets Gitea 28.0.0 and includes ten themes with eleven mail types each. For earlier Gitea versions, use the packages listed in the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix).
|
||||
|
||||
## Fixes
|
||||
|
||||
- Replace the removed `FileSize` function with `FormatByteSize` in release-attachment emails across all ten themes.
|
||||
- Update the preview's file-size function for Gitea 28.0.0 and include release attachments in the example data.
|
||||
- Add an all-theme regression test for release attachments and the removed function.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update both READMEs and the compatibility matrix for Gitea 28.0.0.
|
||||
- Document the Gitea 28.0.0 requirement and direct users of earlier versions to the compatibility matrix.
|
||||
|
||||
The snapshot-based source architecture now on `main` is separate, unreleased work. It is not included in the existing v28.0.0 archives.
|
||||
|
||||
[Full changelog: v1.27.3…v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.27.3...v28.0.0)
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Gitea API operations for the release workflow (Python stdlib only)."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote, urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
|
||||
class NoRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# Never forward an instance token to a redirect destination.
|
||||
return None
|
||||
|
||||
|
||||
class GiteaAPI:
|
||||
def __init__(self, server, repository, token):
|
||||
parsed = urlsplit(server)
|
||||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("GITEA_SERVER_URL must be an HTTPS instance URL")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
|
||||
raise ValueError("GITEA_REPOSITORY must be owner/repository")
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN is required")
|
||||
self.base = server.rstrip("/") + "/api/v1/repos/" + repository
|
||||
self.token = token
|
||||
self.opener = build_opener(NoRedirects())
|
||||
|
||||
def request(self, method, path, data=None, content_type="application/json", allow_missing=False):
|
||||
if data is not None and not isinstance(data, bytes):
|
||||
data = json.dumps(data).encode("utf-8")
|
||||
req = Request(self.base + path, data=data, method=method, headers={
|
||||
"Authorization": "token " + self.token,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": content_type,
|
||||
"User-Agent": "GiteaMailTemplates-actions",
|
||||
})
|
||||
try:
|
||||
with self.opener.open(req, timeout=120) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as error:
|
||||
status = error.code
|
||||
error.close()
|
||||
if allow_missing and status == 404:
|
||||
return None
|
||||
raise RuntimeError(f"Gitea API {method} {path}: HTTP {status}") from None
|
||||
|
||||
|
||||
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:
|
||||
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"""{marker}
|
||||
Template release **{version}** has been published. Documentation is maintained manually.
|
||||
|
||||
- [ ] 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.
|
||||
"""
|
||||
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(".")):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", version):
|
||||
raise ValueError("Expected a stable vX.Y.Z release tag")
|
||||
root = Path(root)
|
||||
lock = json.loads((root / "gitea.lock.json").read_text(encoding="utf-8"))
|
||||
if lock["tag"] != version:
|
||||
raise ValueError("Release tag must match gitea.lock.json")
|
||||
notes = (root / ".github" / "release-notes" / (version + ".md")).read_text(encoding="utf-8")
|
||||
if not notes.strip():
|
||||
raise ValueError("Release notes are empty")
|
||||
assets = [root / "dist" / ("gitea-mail-templates-" + version + ext) for ext in (".zip", ".tar.gz")]
|
||||
for asset in assets:
|
||||
if asset.is_symlink() or not asset.is_file() or not asset.stat().st_size:
|
||||
raise ValueError(f"Missing or invalid release archive: {asset}")
|
||||
existing = api.request("GET", "/releases/tags/" + quote(version, safe=""), allow_missing=True)
|
||||
if existing is not None:
|
||||
raise ValueError("Release already exists; refusing to replace its notes or assets")
|
||||
|
||||
release = api.request("POST", "/releases", {
|
||||
"tag_name": version, "name": version, "body": notes, "draft": True, "prerelease": False,
|
||||
})
|
||||
release_id = int(release["id"])
|
||||
# Gitea accepts raw attachment data with the filename in the query string.
|
||||
# Publish only after both uploads succeed; failures leave a draft for review.
|
||||
for asset in assets:
|
||||
api.request("POST", f"/releases/{release_id}/assets?" + urlencode({"name": asset.name}),
|
||||
asset.read_bytes(), content_type="application/octet-stream")
|
||||
release = api.request("PATCH", f"/releases/{release_id}", {"draft": False})
|
||||
print(f"[PASS] Published {release['html_url']}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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-issue":
|
||||
create_issue(api, args.version)
|
||||
else:
|
||||
publish_release(api, args.version)
|
||||
except (ValueError, KeyError, OSError, RuntimeError) as error:
|
||||
parser.exit(1, f"[FAIL] {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,10 +4,31 @@ import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
VERSION_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+")
|
||||
THEME_NAME_PATTERN = re.compile(r"[a-z][a-z0-9-]*")
|
||||
BASE_TEMPLATES = {"mail/base/head.tmpl", "mail/base/footer.tmpl"}
|
||||
REQUIRED_FILES = (
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CONTRIBUTING.md",
|
||||
"COMPATIBILITY.md",
|
||||
"docs/README.zh-CN.md",
|
||||
"docs/CONTRIBUTING.zh-CN.md",
|
||||
"preview/index.html",
|
||||
"preview/lib/html2canvas/html2canvas.min.js",
|
||||
"preview/lib/html2canvas/LICENSE",
|
||||
"preview/lib/html2canvas/README.md",
|
||||
)
|
||||
OPTIONAL_ROOT_DOCUMENTS = ("AGENTS.md", "THIRD_PARTY_NOTICES.md")
|
||||
DOCUMENTATION_DIRECTORIES = ("docs", ".github/release-notes")
|
||||
PREVIEW_MANIFEST_PREFIX = b"window.__MAIL_PREVIEW__ = "
|
||||
ARCHIVE_EXTENSIONS = (".zip", ".tar.gz")
|
||||
|
||||
|
||||
def read_file(path):
|
||||
@@ -31,41 +52,75 @@ def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def collect(root, version):
|
||||
root = Path(root)
|
||||
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version):
|
||||
def read_json(path):
|
||||
return json.loads(read_file(path))
|
||||
|
||||
|
||||
def load_lock(root, version):
|
||||
"""Validate the requested release identity before collecting any output."""
|
||||
if not VERSION_PATTERN.fullmatch(version):
|
||||
raise ValueError("Expected a stable vX.Y.Z package version")
|
||||
lock = json.loads(read_file(root / "gitea.lock.json"))
|
||||
lock = read_json(root / "gitea.lock.json")
|
||||
if version != lock["tag"]:
|
||||
raise ValueError("Package version must match the locked Gitea tag")
|
||||
files = {}
|
||||
names = sorted(p.name for p in (root / "themes").iterdir() if p.is_dir() and not p.is_symlink())
|
||||
return lock
|
||||
|
||||
|
||||
def discover_theme_names(root):
|
||||
names = sorted(
|
||||
path.name
|
||||
for path in (root / "themes").iterdir()
|
||||
if path.is_dir() and not path.is_symlink()
|
||||
)
|
||||
if not names:
|
||||
raise ValueError("No source theme manifests")
|
||||
for name in names:
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
|
||||
return names
|
||||
|
||||
|
||||
def expected_template_paths(root, lock, mode):
|
||||
"""Shared themes override base files; framed themes also supply all bodies."""
|
||||
paths = set(BASE_TEMPLATES)
|
||||
if mode == "framed":
|
||||
paths.update(
|
||||
path.removeprefix("templates/")
|
||||
for path in lock["files"]
|
||||
if path.startswith("templates/mail/") and path.endswith(".tmpl")
|
||||
)
|
||||
paths.update(
|
||||
path.relative_to(root / "framework").as_posix()
|
||||
for path in (root / "framework" / "mail").rglob("*.tmpl")
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def collect_theme(root, lock, name):
|
||||
"""Accept only a complete build whose identity and checksums still match."""
|
||||
if not THEME_NAME_PATTERN.fullmatch(name):
|
||||
raise ValueError(f"Invalid theme name: {name}")
|
||||
source = root / "themes" / name
|
||||
theme = json.loads(read_file(source / "theme.json"))
|
||||
theme = read_json(source / "theme.json")
|
||||
if theme["name"] != name or theme["mode"] not in ("shared", "framed"):
|
||||
raise ValueError(f"Invalid theme metadata: {name}")
|
||||
built = root / "build" / "themes" / name
|
||||
meta = json.loads(read_file(built / "build.json"))
|
||||
if (meta["theme"], meta["mode"], meta["gitea_tag"], meta["gitea_commit"]) != (name, theme["mode"], version, lock["commit"]):
|
||||
metadata = read_json(built / "build.json")
|
||||
actual_identity = (
|
||||
metadata["theme"], metadata["mode"],
|
||||
metadata["gitea_tag"], metadata["gitea_commit"],
|
||||
)
|
||||
expected_identity = (name, theme["mode"], lock["tag"], lock["commit"])
|
||||
if actual_identity != expected_identity:
|
||||
raise ValueError(f"Stale build identity: {name}")
|
||||
if not {"theme.json", "theme.css"}.issubset(meta.get("sources") or {}) or not meta.get("files"):
|
||||
has_sources = {"theme.json", "theme.css"}.issubset(metadata.get("sources") or {})
|
||||
if not has_sources or not metadata.get("files"):
|
||||
raise ValueError(f"Missing source/file provenance: {name}")
|
||||
expected_files = {"mail/base/head.tmpl", "mail/base/footer.tmpl"}
|
||||
if theme["mode"] == "framed":
|
||||
expected_files.update(path.removeprefix("templates/") for path in lock["files"] if path.startswith("templates/mail/") and path.endswith(".tmpl"))
|
||||
expected_files.update(path.relative_to(root / "framework").as_posix() for path in (root / "framework" / "mail").rglob("*.tmpl"))
|
||||
if set(meta["files"]) != expected_files:
|
||||
if set(metadata["files"]) != expected_template_paths(root, lock, theme["mode"]):
|
||||
raise ValueError(f"Incomplete generated install package: {name}")
|
||||
for path, expected in meta["sources"].items():
|
||||
for path, expected in metadata["sources"].items():
|
||||
base = root if path.startswith(("framework/", "tools/")) else source
|
||||
if digest(read_file(checked_path(base, path))) != expected:
|
||||
raise ValueError(f"Source changed since build: {name}/{path}")
|
||||
for path, expected in meta["files"].items():
|
||||
files = {}
|
||||
for path, expected in metadata["files"].items():
|
||||
if not path.startswith("mail/") or not path.endswith(".tmpl"):
|
||||
raise ValueError(f"Unexpected generated file: {path}")
|
||||
data = read_file(checked_path(built, path))
|
||||
@@ -73,59 +128,111 @@ def collect(root, version):
|
||||
raise ValueError(f"Generated checksum mismatch: {name}/{path}")
|
||||
files[f"themes/{name}/{path}"] = data
|
||||
files[f"themes/{name}/build.json"] = read_file(built / "build.json")
|
||||
return files
|
||||
|
||||
|
||||
def parse_script_payload(data, prefix):
|
||||
"""Read generated JSON without executing the surrounding JavaScript."""
|
||||
return json.loads(data.split(prefix, 1)[1].strip().removesuffix(b";"))
|
||||
|
||||
|
||||
def collect_preview(root, lock, theme_names):
|
||||
manifest_data = read_file(root / "preview" / "rendered.js")
|
||||
prefix = b"window.__MAIL_PREVIEW__ = "
|
||||
manifest = json.loads(manifest_data.split(prefix, 1)[1].strip().removesuffix(b";"))
|
||||
languages = sorted(path.removeprefix("options/locale/locale_").removesuffix(".json") for path in lock["files"] if path.startswith("options/locale/locale_") and path.endswith(".json"))
|
||||
if manifest["upstream"] != version or manifest.get("upstream_commit") != lock["commit"] or sorted(manifest["themes"]) != names or sorted(manifest["languages"]) != languages:
|
||||
raise ValueError("Preview does not match lock/all source themes and languages; run preview all")
|
||||
files["preview/rendered.js"] = manifest_data
|
||||
for lang in languages:
|
||||
data = read_file(root / "preview" / "rendered" / (lang + ".js"))
|
||||
marker = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(lang) + '] = ').encode()
|
||||
payload = json.loads(data.split(marker, 1)[1].strip().removesuffix(b";"))
|
||||
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", "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"]:
|
||||
manifest = parse_script_payload(manifest_data, PREVIEW_MANIFEST_PREFIX)
|
||||
languages = sorted(
|
||||
path.removeprefix("options/locale/locale_").removesuffix(".json")
|
||||
for path in lock["files"]
|
||||
if path.startswith("options/locale/locale_") and path.endswith(".json")
|
||||
)
|
||||
matches_build = (
|
||||
manifest["upstream"] == lock["tag"]
|
||||
and manifest.get("upstream_commit") == lock["commit"]
|
||||
and sorted(manifest["themes"]) == theme_names
|
||||
and sorted(manifest["languages"]) == languages
|
||||
)
|
||||
if not matches_build:
|
||||
raise ValueError(
|
||||
"Preview does not match lock/all source themes and languages; run preview all"
|
||||
)
|
||||
files = {"preview/rendered.js": manifest_data}
|
||||
expected_templates = set(manifest["registry"])
|
||||
for language in languages:
|
||||
data = read_file(root / "preview" / "rendered" / (language + ".js"))
|
||||
prefix = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(language) + '] = ').encode()
|
||||
payload = parse_script_payload(data, prefix)
|
||||
if sorted(payload) != theme_names or any(
|
||||
set(payload[name]) != expected_templates for name in theme_names
|
||||
):
|
||||
raise ValueError(f"Incomplete preview language bundle: {language}")
|
||||
files[f"preview/rendered/{language}.js"] = data
|
||||
return files
|
||||
|
||||
|
||||
def collect_documentation(root):
|
||||
files = {name: read_file(root / name) for name in REQUIRED_FILES}
|
||||
for name in OPTIONAL_ROOT_DOCUMENTS:
|
||||
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")):
|
||||
for directory in DOCUMENTATION_DIRECTORIES:
|
||||
for path in sorted((root / 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)
|
||||
return files
|
||||
|
||||
|
||||
def collect_provenance(root, lock):
|
||||
license_data = read_file(root / "build" / "upstream" / "LICENSE")
|
||||
if digest(license_data) != lock["files"]["LICENSE"]:
|
||||
raise ValueError("Upstream license checksum mismatch")
|
||||
files["GITEA-LICENSE"] = license_data
|
||||
files["upstream-lock.json"] = read_file(root / "gitea.lock.json")
|
||||
return {
|
||||
"GITEA-LICENSE": license_data,
|
||||
"upstream-lock.json": read_file(root / "gitea.lock.json"),
|
||||
}
|
||||
|
||||
|
||||
def collect(root, version):
|
||||
"""Collect verified release content without writing any archives."""
|
||||
root = Path(root)
|
||||
lock = load_lock(root, version)
|
||||
theme_names = discover_theme_names(root)
|
||||
files = {}
|
||||
for name in theme_names:
|
||||
files.update(collect_theme(root, lock, name))
|
||||
files.update(collect_preview(root, lock, theme_names))
|
||||
files.update(collect_documentation(root))
|
||||
files.update(collect_provenance(root, lock))
|
||||
return files
|
||||
|
||||
|
||||
def package(root, version, output):
|
||||
"""Write both archive formats from the same verified file collection."""
|
||||
files = collect(root, version)
|
||||
archive = f"gitea-mail-templates-{version}"
|
||||
output = Path(output)
|
||||
targets = [output / (archive + ext) for ext in (".zip", ".tar.gz")]
|
||||
targets = [output / (archive + ext) for ext in ARCHIVE_EXTENSIONS]
|
||||
if any(path.exists() for path in targets):
|
||||
raise ValueError("Refusing to overwrite existing release archives; use a new output directory")
|
||||
raise ValueError(
|
||||
"Refusing to overwrite existing release archives; use a new output directory"
|
||||
)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(targets[0], "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zipped, tarfile.open(targets[1], "w:gz") as tar:
|
||||
with (
|
||||
zipfile.ZipFile(targets[0], "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zipped,
|
||||
tarfile.open(targets[1], "w:gz") as tar,
|
||||
):
|
||||
for name, data in sorted(files.items()):
|
||||
path = archive + "/" + name
|
||||
zipped.writestr(path, data)
|
||||
info = tarfile.TarInfo(path)
|
||||
info.size, info.mode = len(data), 0o644
|
||||
info.size = len(data)
|
||||
info.mode = 0o644
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
||||
@@ -135,3 +242,7 @@ if __name__ == "__main__":
|
||||
package(args.root, args.version, args.output)
|
||||
except (ValueError, KeyError, IndexError, OSError) as error:
|
||||
parser.exit(1, f"[FAIL] {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Offline Gitea API and issue-reminder regression tests."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("gitea_actions", Path(__file__).with_name("gitea_actions.py"))
|
||||
ACTIONS = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(ACTIONS)
|
||||
|
||||
|
||||
class APITests(unittest.TestCase):
|
||||
def test_instance_url_token_and_raw_asset_request(self):
|
||||
api = ACTIONS.GiteaAPI("https://git.example/subpath/", "owner/repo", "test-token")
|
||||
response = Mock()
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
response.read.return_value = b'{"id": 1}'
|
||||
api.opener = Mock()
|
||||
api.opener.open.return_value = response
|
||||
self.assertEqual({"id": 1}, api.request("POST", "/releases/1/assets?name=test.zip", b"archive", "application/octet-stream"))
|
||||
request = api.opener.open.call_args.args[0]
|
||||
self.assertEqual("https://git.example/subpath/api/v1/repos/owner/repo/releases/1/assets?name=test.zip", request.full_url)
|
||||
self.assertEqual("token test-token", request.get_header("Authorization"))
|
||||
self.assertEqual(b"archive", request.data)
|
||||
self.assertEqual("application/octet-stream", request.get_header("Content-type"))
|
||||
|
||||
def test_only_explicit_404_is_missing_and_redirects_are_refused(self):
|
||||
api = ACTIONS.GiteaAPI("https://git.example", "owner/repo", "test-token")
|
||||
api.opener = Mock()
|
||||
for status in (401, 403, 500, 302):
|
||||
api.opener.open.side_effect = HTTPError(api.base, status, "error", {}, None)
|
||||
with self.assertRaisesRegex(RuntimeError, f"HTTP {status}"):
|
||||
api.request("GET", "/releases/tags/v28.0.0", allow_missing=True)
|
||||
api.opener.open.side_effect = HTTPError(api.base, 404, "missing", {}, None)
|
||||
self.assertIsNone(api.request("GET", "/releases/tags/v28.0.0", allow_missing=True))
|
||||
self.assertIsNone(ACTIONS.NoRedirects().redirect_request(None, None, 302, "", {}, "https://elsewhere.example"))
|
||||
|
||||
def test_rejects_invalid_configuration(self):
|
||||
for server, repository, token in [("http://git.example", "owner/repo", "x"),
|
||||
("https://user:password@git.example", "owner/repo", "x"),
|
||||
("https://git.example", "../owner/repo", "x"), ("https://git.example", "owner/repo", "")]:
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.GiteaAPI(server, repository, token)
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "gitea.lock.json").write_text(json.dumps({"tag": "v28.0.0"}))
|
||||
notes = self.root / ".github/release-notes/v28.0.0.md"
|
||||
notes.parent.mkdir(parents=True)
|
||||
notes.write_text("Reviewed notes", encoding="utf-8")
|
||||
(self.root / "dist").mkdir()
|
||||
for ext in (".zip", ".tar.gz"):
|
||||
(self.root / "dist" / ("gitea-mail-templates-v28.0.0" + ext)).write_bytes(b"archive")
|
||||
|
||||
def test_existing_release_is_not_modified(self):
|
||||
api = Mock()
|
||||
api.request.return_value = {"id": 5}
|
||||
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
self.assertEqual(["GET"], [call.args[0] for call in api.request.call_args_list])
|
||||
|
||||
def test_publish_only_after_both_uploads_succeed(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [None, {"id": 5}, {"id": 6}, {"id": 7}, {"html_url": "https://git.example/release"}]
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
calls = api.request.call_args_list
|
||||
self.assertEqual(["GET", "POST", "POST", "POST", "PATCH"], [c.args[0] for c in calls])
|
||||
self.assertTrue(calls[1].args[2]["draft"])
|
||||
self.assertEqual("Reviewed notes", calls[1].args[2]["body"])
|
||||
self.assertEqual("/releases/5/assets?name=gitea-mail-templates-v28.0.0.zip", calls[2].args[1])
|
||||
self.assertEqual("/releases/5/assets?name=gitea-mail-templates-v28.0.0.tar.gz", calls[3].args[1])
|
||||
self.assertEqual({"draft": False}, calls[4].args[2])
|
||||
|
||||
def test_failed_upload_leaves_draft_unpublished(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [None, {"id": 5}, RuntimeError("upload failed")]
|
||||
with self.assertRaisesRegex(RuntimeError, "upload failed"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
self.assertNotIn("PATCH", [c.args[0] for c in api.request.call_args_list])
|
||||
|
||||
def test_bad_version_or_missing_archive_fails_before_api_call(self):
|
||||
api = Mock()
|
||||
for version in ("v28.0.1", "v28.0.0-rc1", "../../x"):
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.publish_release(api, version, self.root)
|
||||
(self.root / "dist/gitea-mail-templates-v28.0.0.zip").unlink()
|
||||
with self.assertRaisesRegex(ValueError, "archive"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
api.request.assert_not_called()
|
||||
|
||||
|
||||
class IssueTests(unittest.TestCase):
|
||||
def test_creates_release_documentation_issue(self):
|
||||
api = Mock()
|
||||
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.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_other_release_versions_do_not_suppress_reminder(self):
|
||||
api = Mock()
|
||||
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()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,97 +1,206 @@
|
||||
"""Offline artifact/provenance tests independent of generated repository output."""
|
||||
"""Offline packaging tests using a minimal, independently generated repository."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("package_release", Path(__file__).with_name("package_release.py"))
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("package_release.py")
|
||||
SPEC = importlib.util.spec_from_file_location("package_release", SCRIPT_PATH)
|
||||
PACKER = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(PACKER)
|
||||
|
||||
|
||||
class PackagingTests(unittest.TestCase):
|
||||
VERSION = "v28.0.0"
|
||||
COMMIT = "a" * 40
|
||||
THEME = "demo"
|
||||
LANGUAGE = "en-US"
|
||||
ROOT_DOCUMENTS = (
|
||||
"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",
|
||||
"preview/lib/html2canvas/html2canvas.min.js",
|
||||
"preview/lib/html2canvas/LICENSE",
|
||||
"preview/lib/html2canvas/README.md",
|
||||
"docs/images/README.md",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.tag = "v28.0.0"
|
||||
self.commit = "a" * 40
|
||||
theme = json.dumps({"name": "demo", "mode": "shared"}).encode()
|
||||
css = b"a { color:blue; }"
|
||||
self.write("themes/demo/theme.json", theme)
|
||||
self.write("themes/demo/theme.css", css)
|
||||
files = {"mail/base/head.tmpl": b"<title>test</title>", "mail/base/footer.tmpl": b"<p>footer</p>"}
|
||||
meta = {"theme": "demo", "mode": "shared", "gitea_tag": self.tag, "gitea_commit": self.commit, "sources": {"theme.json": PACKER.digest(theme), "theme.css": PACKER.digest(css)}, "files": {p: PACKER.digest(data) for p, data in files.items()}}
|
||||
for path, data in files.items():
|
||||
self.write("build/themes/demo/" + path, data)
|
||||
self.write_json("build/themes/demo/build.json", meta)
|
||||
self.write_json("gitea.lock.json", {"tag": self.tag, "commit": self.commit, "files": {"LICENSE": PACKER.digest(b"MIT"), "options/locale/locale_en-US.json": PACKER.digest(b"{}")}})
|
||||
self.write("build/upstream/LICENSE", b"MIT")
|
||||
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", "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"]:
|
||||
temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
self.root = Path(temporary_directory.name)
|
||||
self.output = self.root / "artifacts"
|
||||
self.create_theme()
|
||||
self.create_snapshot()
|
||||
self.create_preview()
|
||||
for name in self.ROOT_DOCUMENTS:
|
||||
self.write(name, b"test")
|
||||
|
||||
# Fixture helpers keep test bodies focused on the behavior being checked.
|
||||
def write(self, name, data):
|
||||
path = self.root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
def write_json(self, name, data):
|
||||
self.write(name, json.dumps(data).encode())
|
||||
self.write(name, json.dumps(data).encode("utf-8"))
|
||||
|
||||
def test_packages_declared_outputs_only_and_never_overwrites(self):
|
||||
def create_theme(self):
|
||||
theme_data = json.dumps({"name": self.THEME, "mode": "shared"}).encode("utf-8")
|
||||
css_data = b"a { color:blue; }"
|
||||
self.write("themes/demo/theme.json", theme_data)
|
||||
self.write("themes/demo/theme.css", css_data)
|
||||
|
||||
generated_files = {
|
||||
"mail/base/head.tmpl": b"<title>test</title>",
|
||||
"mail/base/footer.tmpl": b"<p>footer</p>",
|
||||
}
|
||||
metadata = {
|
||||
"theme": self.THEME,
|
||||
"mode": "shared",
|
||||
"gitea_tag": self.VERSION,
|
||||
"gitea_commit": self.COMMIT,
|
||||
"sources": {
|
||||
"theme.json": PACKER.digest(theme_data),
|
||||
"theme.css": PACKER.digest(css_data),
|
||||
},
|
||||
"files": {
|
||||
path: PACKER.digest(data)
|
||||
for path, data in generated_files.items()
|
||||
},
|
||||
}
|
||||
for path, data in generated_files.items():
|
||||
self.write("build/themes/demo/" + path, data)
|
||||
self.write_json("build/themes/demo/build.json", metadata)
|
||||
|
||||
def create_snapshot(self):
|
||||
self.write_json("gitea.lock.json", {
|
||||
"tag": self.VERSION,
|
||||
"commit": self.COMMIT,
|
||||
"files": {
|
||||
"LICENSE": PACKER.digest(b"MIT"),
|
||||
"options/locale/locale_en-US.json": PACKER.digest(b"{}"),
|
||||
},
|
||||
})
|
||||
self.write("build/upstream/LICENSE", b"MIT")
|
||||
|
||||
def create_preview(self):
|
||||
manifest = {
|
||||
"upstream": self.VERSION,
|
||||
"upstream_commit": self.COMMIT,
|
||||
"themes": [self.THEME],
|
||||
"languages": [self.LANGUAGE],
|
||||
"registry": {"activate": {}},
|
||||
}
|
||||
bundle = {self.THEME: {"activate": "<html></html>"}}
|
||||
self.write("preview/rendered.js", (
|
||||
"window.__MAIL_PREVIEW__ = " + json.dumps(manifest) + ";"
|
||||
).encode("utf-8"))
|
||||
self.write("preview/rendered/en-US.js", (
|
||||
'window.__MAIL_PREVIEW_DATA__["en-US"] = ' + json.dumps(bundle) + ";"
|
||||
).encode("utf-8"))
|
||||
|
||||
# Archives: both formats contain the verified bytes, never stale build files.
|
||||
def test_archives_contain_only_verified_outputs(self):
|
||||
self.write("build/themes/stale/mail/stale.tmpl", b"stale")
|
||||
out = self.root / "artifacts"
|
||||
PACKER.package(self.root, self.tag, out)
|
||||
with zipfile.ZipFile(next(out.glob("*.zip"))) as archive:
|
||||
names = archive.namelist()
|
||||
self.assertTrue(any(name.endswith("themes/demo/mail/base/head.tmpl") for name in names))
|
||||
self.assertFalse(any("stale" in name or "/build/" in name for name in names))
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.package(self.root, self.tag, out)
|
||||
expected_files = PACKER.collect(self.root, self.VERSION)
|
||||
prefix = f"gitea-mail-templates-{self.VERSION}/"
|
||||
expected_members = {
|
||||
prefix + name: data for name, data in expected_files.items()
|
||||
}
|
||||
|
||||
def test_refuses_stale_source_tampered_output_and_wrong_version(self):
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.package(self.root, self.VERSION, self.output)
|
||||
|
||||
with zipfile.ZipFile(self.output / f"gitea-mail-templates-{self.VERSION}.zip") as archive:
|
||||
zipped = {name: archive.read(name) for name in archive.namelist()}
|
||||
with tarfile.open(self.output / f"gitea-mail-templates-{self.VERSION}.tar.gz") as archive:
|
||||
tarred = {
|
||||
member.name: archive.extractfile(member).read()
|
||||
for member in archive.getmembers()
|
||||
}
|
||||
self.assertTrue(all(member.mode == 0o644 for member in archive.getmembers()))
|
||||
self.assertEqual(expected_members, zipped)
|
||||
self.assertEqual(expected_members, tarred)
|
||||
self.assertIn(prefix + "themes/demo/mail/base/head.tmpl", zipped)
|
||||
for name in ("html2canvas.min.js", "LICENSE", "README.md"):
|
||||
self.assertIn(prefix + "preview/lib/html2canvas/" + name, zipped)
|
||||
self.assertFalse(any("stale" in name or "/build/" in name for name in zipped))
|
||||
|
||||
def test_missing_screenshot_library_is_rejected(self):
|
||||
(self.root / "preview/lib/html2canvas/html2canvas.min.js").unlink()
|
||||
with self.assertRaisesRegex(ValueError, "Expected regular file"):
|
||||
PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
def test_existing_archives_are_not_overwritten(self):
|
||||
PACKER.package(self.root, self.VERSION, self.output)
|
||||
original_archives = {
|
||||
path.name: path.read_bytes() for path in self.output.iterdir()
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Refusing to overwrite"):
|
||||
PACKER.package(self.root, self.VERSION, self.output)
|
||||
|
||||
self.assertEqual(original_archives, {
|
||||
path.name: path.read_bytes() for path in self.output.iterdir()
|
||||
})
|
||||
|
||||
# Validation: release identity, source checksums and generated checksums.
|
||||
def test_wrong_version_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "must match the locked Gitea tag"):
|
||||
PACKER.collect(self.root, "v28.1.0")
|
||||
|
||||
def test_changed_source_is_rejected(self):
|
||||
self.write("themes/demo/theme.css", b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "Source changed"):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
def test_refuses_missing_language_bundle(self):
|
||||
def test_missing_language_bundle_is_rejected(self):
|
||||
(self.root / "preview/rendered/en-US.js").unlink()
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
with self.assertRaisesRegex(ValueError, "Expected regular file"):
|
||||
PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
def test_refuses_modified_generated_files(self):
|
||||
def test_modified_generated_files_are_rejected(self):
|
||||
self.write("build/themes/demo/mail/base/head.tmpl", b"modified")
|
||||
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
def test_includes_nested_documentation_and_release_note_sources(self):
|
||||
# Documentation: recursive inclusion and optional root files.
|
||||
def test_nested_documentation_and_release_notes_are_included(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(),
|
||||
"docs/INDEX.zh-CN.md": "中文索引".encode("utf-8"),
|
||||
"docs/images/README.zh-CN.md": "截图指南".encode("utf-8"),
|
||||
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode("utf-8"),
|
||||
".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)
|
||||
|
||||
files = PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
for name, data in documents.items():
|
||||
self.assertEqual(files[name], data)
|
||||
with self.subTest(document=name):
|
||||
self.assertEqual(data, files[name])
|
||||
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"]:
|
||||
def test_optional_root_documents_may_be_absent(self):
|
||||
for name in ("AGENTS.md", "THIRD_PARTY_NOTICES.md"):
|
||||
(self.root / name).unlink()
|
||||
files = PACKER.collect(self.root, self.tag)
|
||||
|
||||
files = PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
self.assertNotIn("AGENTS.md", files)
|
||||
self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
|
||||
self.assertIn("GITEA-LICENSE", files)
|
||||
|
||||
@@ -2,44 +2,17 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Templates
|
||||
runs-on: linux-amd64-docker-small
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Test workflow scripts
|
||||
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
- name: Verify pinned official snapshot
|
||||
working-directory: tools
|
||||
run: |
|
||||
go run . upstream prepare
|
||||
go run . upstream verify
|
||||
|
||||
- name: Test — framework alignment and multilingual notification parity
|
||||
working-directory: tools
|
||||
run: go test ./...
|
||||
|
||||
- name: Build and render all themes and official languages
|
||||
working-directory: tools
|
||||
run: go run . preview all
|
||||
name: Validate
|
||||
uses: ./.github/workflows/validate.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
package:
|
||||
name: Package & Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Package
|
||||
needs: validate
|
||||
runs-on: linux-amd64-docker-small
|
||||
permissions:
|
||||
@@ -57,50 +30,44 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify release tag matches the official snapshot
|
||||
- name: Verify tag matches the snapshot
|
||||
run: |
|
||||
python -c 'import json, os; lock=json.load(open("gitea.lock.json")); assert lock["tag"] == os.environ["GITHUB_REF_NAME"], "Release tag must match the pinned Gitea tag"'
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
- name: Verify release notes
|
||||
run: test -s ".github/release-notes/${GITHUB_REF_NAME}.md"
|
||||
with open("gitea.lock.json", encoding="utf-8") as lock_file:
|
||||
lock = json.load(lock_file)
|
||||
assert lock["tag"] == os.environ["GITHUB_REF_NAME"], (
|
||||
"Release tag must match the pinned Gitea tag"
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Generate static preview for the archive
|
||||
- name: Generate preview
|
||||
working-directory: tools
|
||||
run: go run . preview all
|
||||
|
||||
- name: Package generated installable themes and multilingual preview
|
||||
- name: Package themes and preview
|
||||
run: python -B .github/scripts/package_release.py --version "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
|
||||
- name: Create Release
|
||||
run: python -B .github/scripts/gitea_actions.py publish-release --version "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
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: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- name: Prepare release runtime
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- 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 }}
|
||||
node-version: '22'
|
||||
|
||||
- name: Release
|
||||
uses: actions/gitea-release-action@v1.3.7
|
||||
with:
|
||||
server_url: ${{ github.server_url }}
|
||||
repository: ${{ github.repository }}
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
body: ''
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
dist/gitea-mail-templates-${{ github.ref_name }}.zip
|
||||
dist/gitea-mail-templates-${{ github.ref_name }}.tar.gz
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: linux-amd64-docker-small
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Test workflow scripts
|
||||
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
- name: Verify snapshot
|
||||
working-directory: tools
|
||||
run: |
|
||||
go run . upstream prepare
|
||||
go run . upstream verify
|
||||
|
||||
- name: Tools test
|
||||
working-directory: tools
|
||||
run: go test ./...
|
||||
|
||||
- name: Build and render all themes and official languages
|
||||
working-directory: tools
|
||||
run: go run . preview all
|
||||
+31
-130
@@ -1,23 +1,24 @@
|
||||
# Gitea Compatibility
|
||||
<!-- DOC-TAGS: {"TRACKER":["LATEST-VERIFIED","VERSION-MAP","HISTORY"]} -->
|
||||
---
|
||||
[简体中文](docs/COMPATIBILITY.zh-CN.md)
|
||||
|
||||
This document lists supported release combinations, known limitations and the checks used to assess compatibility.
|
||||
|
||||
[Project overview](README.md) · [Contributor guide](CONTRIBUTING.md) · [简体中文使用说明](docs/README.zh-CN.md)
|
||||
This document describes the compatibility of Gitea Mail Templates with different Gitea versions.
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
Select a template release for the Gitea version you run. `[PASS]` records a compatible combination; `[PENDING]` means verification is incomplete. Legacy entries reflect the project's recorded compatibility assessments rather than a fresh test of every patch release.
|
||||
Select the recommended template release for the Gitea version you run. `[PASS]` identifies a combination recorded as compatible; `[PENDING]` means verification is incomplete.
|
||||
|
||||
> [!NOTE]
|
||||
> Historical entries reflect the project's existing compatibility assessments. They do not mean that every patch version is retested for each release.
|
||||
|
||||
<!-- TRACKER:VERSION-MAP -->
|
||||
| Gitea version | Recommended template release | Status | Notes |
|
||||
|---------------|------------------------------|--------|-------|
|
||||
|---|---|---|---|
|
||||
| 28.1.0 | — | [PENDING] | Compatibility verification pending |
|
||||
| 28.0.0 | **v28.0.0** | [PASS] | Earlier template releases use the removed `FileSize` mail function |
|
||||
| 1.27.3 | **v1.27.3** | [PASS] | Push-to-PR commit links fixed in every theme |
|
||||
| 1.27.2 | **v1.27.3** | [PASS] | Matching v1.27.2 has a push-notification defect in Bloom, Ember, and Heritage |
|
||||
| 1.27.1 | **v1.27.3** | [PASS] | Older v1.0.x templates use obsolete push-commit fields; v1.27.2 is only partially fixed |
|
||||
| 1.27.0 | — | [PENDING] | New commit data shape breaks older push templates; v1.27.3 is a plausible fix but was not release-tested here |
|
||||
| 28.0.0 | **v28.0.0** | [PASS] | Gitea replaced the mail function `FileSize` with `FormatByteSize` |
|
||||
| 1.27.3 | **v1.27.3** | [PASS] | Pull request push notification commit links fixed in every theme |
|
||||
| 1.27.2 | **v1.27.3** | [PASS] | The matching v1.27.2 template release has push notification defects in Bloom, Ember and Heritage |
|
||||
| 1.27.1 | **v1.27.3** | [PASS] | Older v1.0.x templates use obsolete push commit fields |
|
||||
| 1.27.0 | — | [PENDING] | Gitea uses a new commit data structure; v1.27.3 may be compatible but has not been verified against this version |
|
||||
| 1.26.4 | **v1.0.1** | [PASS] | — |
|
||||
| 1.26.3 | **v1.0.1** | [PASS] | — |
|
||||
| 1.26.2 | **v1.0.1** | [PASS] | — |
|
||||
@@ -25,141 +26,41 @@ Select a template release for the Gitea version you run. `[PASS]` records a comp
|
||||
| 1.26.0 | **v1.0.1** | [PASS] | — |
|
||||
| 1.25.5 | **v1.0.1** | [PASS] | — |
|
||||
| 1.25.0 | **v1.0.1** | [PASS] | — |
|
||||
<!-- /TRACKER:VERSION-MAP -->
|
||||
|
||||
v1.0.1 retains the pre-1.27 push-commit fields used by the listed Gitea 1.25/1.26 releases. Gitea 1.24.x and earlier use a different custom-mail-template layout and are [UNSUPPORTED]. The v1.0.0 template tag is superseded by v1.0.1; it is not a separate recommendation.
|
||||
|
||||
<!-- TRACKER:LATEST-VERIFIED -->
|
||||
> **Latest verified:** Release v28.0.0 passes the Gitea 28.0.0 mail-template, mailer-context, function, and translation-key source audit plus all-theme rendering tests. For other Gitea versions, follow the per-version matrix above.
|
||||
<!-- /TRACKER:LATEST-VERIFIED -->
|
||||
|
||||
## Snapshot-Driven Source Status
|
||||
|
||||
The source architecture on `main` is **unreleased**. Its lock pins Gitea **v28.0.0**, commit `15b8a5805adf57c5189602008d38cccfd3c795e0`, with 13 mail files (11 entrypoints and 2 shared partials) and 28 locale files. It targets Gitea 28 and later, with each new snapshot requiring review. Historical archives retain the compatibility recorded above.
|
||||
|
||||
Themes contain CSS and metadata. The shared framework adapts official templates into reusable headers, action buttons, fallback links and footers while preserving notification conditions, subjects and functional URLs. Official inputs are downloaded to `build/upstream/` and verified against the committed `gitea.lock.json`. Builds reject mismatched checksums, missing English keys, changed adapter references and unreviewed action anchors. New mail types require framework support and fixtures.
|
||||
|
||||
### Translation Behavior
|
||||
|
||||
Missing translations in other languages follow the official English fallback and are reported as `[FALLBACK]`. Gitea v28.0.0's Polish `mail.team_invite.text_1` starts with malformed `%[1]z…` instead of `%[1]s`; preview preserves this official defect and reports `[UPSTREAM-WARN]`. The exception matches the exact source text; other formatting defects fail rendering.
|
||||
|
||||
### Validation Scope
|
||||
|
||||
Source validation covers rendering and notification parity across all themes and languages. Optional browser checks cover static and HTTP previews, language switching and mobile layouts. An isolated Gitea smoke test checks template loading and captures a password-reset email. These checks do not establish rendering compatibility with Gmail, Outlook or Apple Mail; test those clients for your deployment. The release workflow does not automatically run the optional browser or real-Gitea suites.
|
||||
|
||||
Snapshot preparation, cache verification and version updates are separate operations. `upstream prepare` uses the root lock to create an absent cache; `upstream verify` checks an existing cache offline. `upstream sync --tag vX.Y.Z` explicitly replaces the snapshot and lock. A successful sync does not certify compatibility or update this matrix. See [command usage and cache recovery](CONTRIBUTING.md#official-snapshot-updates).
|
||||
|
||||
## Versioning and Known Exceptions
|
||||
|
||||
Release tags identify downloadable packages. Use the matrix to select a supported combination: v1.27.2 has a known defect despite its matching version number, and there is no v1.27.1 template tag. Changes on `main` become part of a release only when a new tag and archive are published.
|
||||
|
||||
- Gitea 1.27.0 changed the push-to-PR commit data shape. Gitea 1.27.1 fixed its **bundled** mail template, but custom overrides still need the new `.UserCommit.GitCommit` path. v1.27.2 updated seven themes; v1.27.3 completed the remaining three. Older v1.0.x templates use the pre-1.27 path. See the [upstream regression report](https://github.com/go-gitea/gitea/issues/38469), [upstream fix](https://github.com/go-gitea/gitea/pull/38467), and [v1.27.2 correction](.github/release-notes/v1.27.2.md).
|
||||
- Gitea 28.0.0 removed the mail-template `FileSize` function in favor of `FormatByteSize` ([mail function map](https://raw.githubusercontent.com/go-gitea/gitea/v28.0.0/modules/templates/mail.go)). v28.0.0 uses the new function; older template releases can fail when rendering release attachments. Conversely, v28.0.0 is not a drop-in replacement for earlier Gitea mail contexts.
|
||||
- A source-only fix is **unreleased** until its tag and downloadable archive exist. New upstream versions remain [PENDING] until their compatibility is checked.
|
||||
|
||||
## Check Your Gitea Version
|
||||
|
||||
```bash
|
||||
# On your Gitea server:
|
||||
gitea --version
|
||||
# Or check the web UI footer / Site Administration → Monitoring
|
||||
```
|
||||
> [!NOTE]
|
||||
> Gitea 1.24.x and earlier use a different mail template layout and are `[UNSUPPORTED]`; Gitea Mail Templates no longer provides separate support for them. The v1.0.0 template tag has been superseded by v1.0.1 and is no longer recommended separately.
|
||||
|
||||
## Gitea Version History — Mail Template Impact
|
||||
|
||||
This table describes changes in **Gitea**, not fixes in this template repository. `None` means no relevant new mail-template impact for that Gitea release; the recommended archive is listed in the matrix above.
|
||||
This table describes changes in **Gitea**, not fixes in this template repository. “None” means that the Gitea version introduces no relevant mail template changes; recommended archives are listed in the compatibility matrix above.
|
||||
|
||||
<!-- 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 | `FileSize` replaced by `FormatByteSize`; mail template internals reorganized | **Breaking:** legacy release-attachment templates can fail |
|
||||
| Gitea | Release date | Mail template changes | Impact |
|
||||
|---|---|---|---|
|
||||
| **28.1.0** | 2026-10-06 | [PENDING] | [PENDING] |
|
||||
| **28.0.0** | 2026-09-29 | Mail function `FileSize` renamed to `FormatByteSize` ([PR](https://github.com/go-gitea/gitea/pull/39083)) | **Breaking change:** old release attachment templates fail |
|
||||
| | | Introduced `templates/mail/base/` ([PR](https://github.com/go-gitea/gitea/pull/38396)) | **Breaking change:** older Gitea versions cannot directly use the new mail templates |
|
||||
| **1.27.3** | 2026-08-29 | None | None |
|
||||
| **1.27.2** | 2026-08-14 | None | None |
|
||||
| **1.27.1** | 2026-07-27 | Bundled push template updated to `.UserCommit.GitCommit` ([fix](https://github.com/go-gitea/gitea/pull/38467)) | **Existing incompatibility persists:** old custom push-to-PR templates can fail |
|
||||
| **1.27.0** | 2026-07-13 | Push commit data moved under `.UserCommit.GitCommit` ([report](https://github.com/go-gitea/gitea/issues/38469)) | **Breaking:** old push-to-PR templates can fail |
|
||||
| **1.27.1** | 2026-07-27 | Bundled push template updated to use `.UserCommit.GitCommit` ([PR](https://github.com/go-gitea/gitea/pull/38467)) | **Breaking change: an existing defect remains unresolved.** Old custom pull request push templates can fail |
|
||||
| **1.27.0** | 2026-07-13 | Push commit data moved under `.UserCommit.GitCommit` ([ISSUE](https://github.com/go-gitea/gitea/issues/38469)) | **Breaking change:** old pull request push templates fail |
|
||||
| **1.26.4** | 2026-06-21 | None | None |
|
||||
| **1.26.3** | 2026-06-20 | None | None |
|
||||
| **1.26.2** | 2026-05-20 | None | None |
|
||||
| **1.26.1** | 2026-04-22 | None | None |
|
||||
| **1.26.0** | 2026-04-19 | None | None |
|
||||
| **1.25.5** | 2026-03-10 | None | None |
|
||||
| **1.25.0** | 2025 | Custom-mail paths and subject/body format changed ([refactor](https://github.com/go-gitea/gitea/pull/35150)) | **Breaking:** older template layout is not supported |
|
||||
| **≤ 1.24.x** | — | Legacy custom-mail layout | [UNSUPPORTED] |
|
||||
<!-- /TRACKER:HISTORY -->
|
||||
| **1.25.0** | 2025 | Custom mail paths and subject/body format changed ([PR](https://github.com/go-gitea/gitea/pull/35150)) | **Breaking change:** older template layouts are unsupported |
|
||||
| **≤ 1.24.x** | — | Legacy custom mail layout | [UNSUPPORTED] |
|
||||
|
||||
## Template Variable Reference
|
||||
|
||||
Official templates define each mail context. The tables below summarize functions and example data relevant to development; they are not a complete API reference. Check the locked official sources and framework adapter when changing a template or fixture.
|
||||
Official templates define the context for each mail type. For specific mail templates, functions and variables used during development, refer to the official mail template documentation [1](#notes).
|
||||
|
||||
### Relevant Template Functions
|
||||
## Translation Keys
|
||||
|
||||
| Function | Gitea version | Notes |
|
||||
|----------|---------------|-------|
|
||||
| `AppName` | ≤ 1.21 | Application name |
|
||||
| `AppUrl` | ≤ 1.21 | Application base URL |
|
||||
| `AppDomain` | ≤ 1.21 | Server domain |
|
||||
| `DotEscape` | ≤ 1.21 | Prevents auto-linking of dotted text |
|
||||
| `QueryEscape` | ≤ 1.21 | URL query encoding |
|
||||
| `PathEscapeSegments` | ≤ 1.21 | Per-segment path encoding |
|
||||
| `ShortSha` | ≤ 1.21 | Truncated commit hash |
|
||||
| `FileSize` | Before 28.0.0 | Used by older template releases for attachment sizes; unavailable in Gitea 28 mail templates |
|
||||
| `FormatByteSize` | 28.0.0 | Used by v28.0.0 for attachment sizes; unavailable in earlier Gitea mail templates |
|
||||
| `HTMLFormat` | ≤ 1.21 | Render string as safe HTML |
|
||||
| `Iif` | ≤ 1.21 | Inline conditional |
|
||||
| `dict` | ≤ 1.21 | Build maps from key-value pairs |
|
||||
| `Eval` | ≤ 1.21 | Evaluate template tokens |
|
||||
| `StringUtils` | ≤ 1.21 | String manipulation helpers |
|
||||
| `SliceUtils` | ≤ 1.21 | Slice manipulation helpers |
|
||||
| `JsonUtils` | ≤ 1.21 | JSON helpers |
|
||||
| `DumpVar` | ≤ 1.21 | Debug variable dump |
|
||||
Official templates reference `mail.*` and `actions.runs.attempt`. AST-based checks cover `.locale.Tr`, `$.locale.Tr` and both plural keys in `TrN`, including nested pipelines. Every referenced key must exist in the locked English catalog.
|
||||
|
||||
### Data Contexts by Template
|
||||
---
|
||||
### Notes
|
||||
|
||||
| Template | Key Variables |
|
||||
|----------|--------------|
|
||||
| `user/auth/activate` | `DisplayName`, `Code`, `ActiveCodeLives` |
|
||||
| `user/auth/activate_email` | `DisplayName`, `Code`, `Email`, `ActiveCodeLives` |
|
||||
| `user/auth/register_notify` | `DisplayName`, `Username` |
|
||||
| `user/auth/reset_passwd` | `DisplayName`, `Code`, `ResetPwdCodeLives` |
|
||||
| `org/team_invite` | `Inviter`, `Team`, `Organization`, `InviteURL`, `Invite` |
|
||||
| `repo/collaborator` | `Subject`, `RepoName`, `Link` |
|
||||
| `repo/transfer` | `Subject`, `Repo`, `Link` |
|
||||
| `repo/release` | `Release` (with `Publisher`, `TagName`, `Title`, `RenderedNote`, `Attachments`), `Link` |
|
||||
| `repo/actions/workflow_run` | `Subject`, `Run` (with `WorkflowID`, `HTMLURL`), `Jobs` (with status class, icon CID/alt, attempt, URL and duration) |
|
||||
| `repo/issue/assigned` | `Subject`, `Doer`, `Issue`, `Link`, `IsPull`, `CanReply` |
|
||||
| `repo/issue/default` | `Doer`, `Issue`, `Link`, `Body`, `ActionName`, `Comment`, `IsPull`, `IsMention`, `ReviewComments`, `CanReply` |
|
||||
|
||||
`.DisplayName` is available in the authentication mail contexts. Collaborator, transfer, release, workflow, assignment and issue-update templates use the context-specific fields listed above.
|
||||
|
||||
### Translation Keys
|
||||
|
||||
Official templates reference `mail.*` keys and `actions.runs.attempt`. AST-based checks inspect `.locale.Tr`, `$.locale.Tr` and both plural keys in `TrN`, including nested pipelines. Every referenced key must exist in the locked English catalog. Preview loads full official locale JSON files rather than a copied Go dictionary; formatting, escaping and plural selection follow the reviewed Gitea adapter.
|
||||
|
||||
## How Compatibility Is Verified
|
||||
|
||||
1. **Locked input verification** — run `go run . upstream prepare` then `go run . upstream verify` from `tools/`; first preparation downloads missing cache files, verification checks hashes, adapter references and English keys offline.
|
||||
2. **Framework alignment** — `go test ./...` checks deterministic shared adaptation/generation and fails on changed primary-action anchors; theme sources cannot own business templates.
|
||||
3. **Notification semantics and controls** — tests compare subjects, notification text and functional links for every theme/language and critical branches, accounting explicitly for added controls/branding. Browser QA checks translated button labels, identical fallback targets, logos and mobile layout. `go run . preview all` builds all language bundles and reports fallback/upstream defects.
|
||||
4. **Matching-instance smoke test** — before publication, load generated overrides in an isolated Gitea matching the snapshot and capture a real notification or password-reset mail. The admin test email does not exercise custom templates.
|
||||
5. **Release identity** — the release tag must match the snapshot tag. Only mark the new release verified after its tests and smoke check; published tags/assets remain unchanged.
|
||||
|
||||
## Version Tracking
|
||||
|
||||
The [tracker workflow](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/.github/workflows/gitea-tracker.yml) records upstream releases as pending. Snapshot updates and compatibility verification require a separate review.
|
||||
|
||||
Participating Markdown files declare managed subject/content pairs in a `DOC-TAGS` JSON comment. Each body is enclosed by `<!-- TRACKER:CONTENT -->` and `<!-- /TRACKER:CONTENT -->`, or the corresponding `RELEASE` pair. Only the first occurrence of a repeated pair in a document is processed. Undeclared, unknown or unclosed blocks, and missing declared blocks, cause validation to fail.
|
||||
|
||||
| Managed blocks | Update policy |
|
||||
|---|---|
|
||||
| `TRACKER:VERSION-MAP`, `TRACKER:HISTORY` | Add pending rows for new upstream releases |
|
||||
| `TRACKER:UPSTREAM` | Update the upstream version and pending status |
|
||||
| `TRACKER:BADGE` | Update the upstream version while retaining the last tested version |
|
||||
| `TRACKER:LATEST-TESTED`, `TRACKER:LATEST-VERIFIED` | Updated manually after verification |
|
||||
| `RELEASE:HEADER`, `RELEASE:SUMMARY`, `RELEASE:CURRENT` | Updated by the release workflow after publication; archive labels are prepared during packaging |
|
||||
|
||||
The [release workflow](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/.github/workflows/release.yml) keeps release links at `/releases/latest` and leaves published tags unchanged. Actions execution and write permissions have not been verified on the configured Gitea host. Until confirmed, check [upstream releases](https://github.com/go-gitea/gitea/releases) and published assets manually. New template tags are published when template content changes; see [versioning and known exceptions](#versioning-and-known-exceptions).
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Open an issue with the Gitea version, template release or source commit, affected theme and mail type, language, reproduction steps and error output. Include the mail client for display problems. See the [reporting guide](CONTRIBUTING.md#reporting-problems) for source-build diagnostics.
|
||||
[1](#notes): [Mail templates | Gitea Documentation](https://docs.gitea.com/administration/mail-templates/)
|
||||
+10
-27
@@ -1,6 +1,6 @@
|
||||
# Contributing to Gitea Mail Templates
|
||||
|
||||
[简体中文](docs/CONTRIBUTING.zh-CN.md) · [Project overview](README.md) · [Compatibility](COMPATIBILITY.md)
|
||||
[简体中文](docs/CONTRIBUTING.zh-CN.md)
|
||||
|
||||
Contributions to themes, tooling, tests, documentation and translations are welcome. This guide covers the current source architecture; installation and release selection are described in the [README](README.md#installation).
|
||||
|
||||
@@ -18,7 +18,7 @@ go run . preview all
|
||||
|
||||
Open `preview/index.html` in a browser, or run `go run . dev` from `tools/` and visit [http://127.0.0.1:3456](http://127.0.0.1:3456) for live reload. The development server watches themes, the framework, the lock/cache and preview fixtures.
|
||||
|
||||
The CLI uses urfave/cli and the x/net HTML parser. Python 3.11 or later is used by the documentation and packaging checks. Node.js and the dependencies in `tools/qa` are needed only for browser QA.
|
||||
The CLI uses urfave/cli and the x/net HTML parser. Python 3.11 or later is used by the documentation and packaging checks.
|
||||
|
||||
### Common Commands
|
||||
|
||||
@@ -114,7 +114,7 @@ go test ./...
|
||||
go run . preview all
|
||||
```
|
||||
|
||||
To review a different version, specify its tag explicitly. Review the lock diff, update the framework adapter and fixtures as needed, and complete the checks and matching-instance smoke test before updating compatibility records. `sync` changes the cache and lock only; documentation and release publication are separate steps. Use `prepare` for a missing cache.
|
||||
To review a different version, specify its tag explicitly. Review the lock diff, update the framework adapter and fixtures as needed, and complete the checks before updating compatibility records. `sync` changes the cache and lock only; documentation and release publication are separate steps. Use `prepare` for a missing cache.
|
||||
|
||||
Changes to the reviewed translation or mail-renderer sources require an adapter review before their reference hashes can be updated. Official English must contain all referenced keys; other languages use English fallback. New mail types require framework alignment and fixtures in `tools/data/templates_config.json`, which supplies preview metadata and example contexts. Keep integer values as integers in JSON fixtures for Go formatting.
|
||||
|
||||
@@ -145,7 +145,7 @@ Gitea v28.0.0 contains a Polish `mail.team_invite.text_1` placeholder defect. Pr
|
||||
|
||||
From `tools/`, run `go test ./...` and `go run . preview all`. Tests cover deterministic generation, changes to action anchors, translation keys, and notification subjects, text and links across all themes and languages. Fixtures include push, review, reply, workflow and attachment branches. Shared controls and branding are accounted for separately from official notification content.
|
||||
|
||||
For documentation, tracker or packaging changes, run from the repository root:
|
||||
For documentation or packaging changes, run from the repository root:
|
||||
|
||||
```bash
|
||||
python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
@@ -153,29 +153,12 @@ python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
Keep English and Simplified Chinese guides aligned when changing shared instructions. Include a description of the change, relevant checks and screenshots for visual changes in the pull request.
|
||||
|
||||
### Browser and Gitea Checks
|
||||
|
||||
After generating preview data, run the optional browser suite:
|
||||
|
||||
```bash
|
||||
cd tools/qa
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
```
|
||||
|
||||
To use installed Chrome or Edge, set `BROWSER_EXECUTABLE_PATH` instead of installing Chromium. Set `PREVIEW_DEV_URL` to include a running HTTP preview in the checks. `npm test -- --update-gallery` also refreshes gallery screenshots.
|
||||
|
||||
Before a release, test generated overrides in an isolated Gitea instance matching the locked version and capture a real password-reset or notification email. The administration test-email button bypasses custom templates.
|
||||
|
||||
The optional `tools/integration` test uses disposable SQLite data, users, Git/SSH paths and loopback SMTP. Set `GITEA_SMOKE_BINARY` to a checksum-verified official binary matching the lock, then run `go test ./integration -v -count=1` from `tools/`. It captures mail locally without sending to real users. Without this variable, the test is skipped. The release workflow does not run the optional browser or real-Gitea checks automatically.
|
||||
|
||||
### Preparing a Release
|
||||
|
||||
Release tags must match the locked Gitea version. The current source refactor is unreleased; retain existing v28.0.0 and historical assets.
|
||||
|
||||
1. Complete automated checks, preview review and the matching-instance smoke test.
|
||||
2. Add reviewed notes at `.github/release-notes/vX.Y.Z.md` and update compatibility records with the verification results.
|
||||
1. Complete the Go tests, documentation checks and preview generation.
|
||||
2. Update documentation and compatibility records with the verification results. Release notes are optional and are not used as the Release body.
|
||||
3. Generate all themes and language bundles with `go run . preview all` from `tools/`.
|
||||
4. Package the release and verify its contents before publication.
|
||||
|
||||
@@ -187,15 +170,15 @@ python .github/scripts/package_release.py --version vX.Y.Z --output <new-output-
|
||||
|
||||
Replace the version and output placeholder with the reviewed tag and a new directory. The script checks source and generated hashes, includes all language bundles, excludes stale theme builds, and refuses to overwrite existing archives.
|
||||
|
||||
The release workflow uses the same script to package templates, previews, documentation, the upstream license and provenance. It also updates managed release labels. Confirm Actions support and write permissions on the configured Gitea host before relying on automated publication. See [version tracking](COMPATIBILITY.md#version-tracking) for the managed Markdown blocks.
|
||||
The release workflow uses the same script to package templates, previews, documentation, the upstream license and provenance. It publishes only the archives, with the tag as the Release title and an empty body. Version labels, badges and compatibility records are maintained manually; review archive documentation before tagging. Confirm Actions support and write permissions on the configured Gitea host before relying on automated publication. See [version maintenance](COMPATIBILITY.md#version-maintenance).
|
||||
|
||||
### Gitea Workflow Configuration
|
||||
|
||||
Both workflows use the `linux-amd64-docker-small` runner label. The job image must support the Node.js actions used for checkout and toolchain setup, as well as Git and a POSIX shell. Go and Python are installed by the setup steps.
|
||||
The release workflow uses the `linux-amd64-docker-small` runner label. The job image must support the Node.js actions used for checkout and toolchain setup, as well as Git and a POSIX shell. Go and Python are installed by the setup steps.
|
||||
|
||||
PR creation and release uploads use `.github/scripts/gitea_actions.py` and the instance's `/api/v1` API. The workflows pass the instance URL, repository and built-in `GITEA_TOKEN`; repository settings must allow the requested code, release and pull-request writes. The optional `UPSTREAM_GITHUB_TOKEN` secret is used only to query official releases on GitHub. Without it, those queries use GitHub's unauthenticated API limit.
|
||||
Publication uses [`gitea-release-action@v1.3.7`](https://gitea.com/actions/gitea-release-action/src/tag/v1.3.7/action.yml) directly, with the instance URL, repository, built-in `GITEA_TOKEN`, tag, title and the two archive paths. The Release body is empty when created; no notes file or Issue reminder is required. The repository must allow release writes. Action URLs explicitly use `https://gitea.com` rather than depending on `DEFAULT_ACTIONS_URL`.
|
||||
|
||||
The tracker reuses an existing branch when its content matches and reuses an open PR for that branch. A differing branch requires manual review; the workflow does not force-push. Release publication refuses any existing release, including a draft. New releases remain drafts until both archives upload successfully. If an upload fails, inspect the draft before retrying; published releases and assets must remain unchanged.
|
||||
Before publication, `actions/setup-node@v4` prepares Node.js 22. The current Gitea runner executes JavaScript actions with `node` from `PATH`; this provides the runtime interfaces used by the release action without `--experimental-fetch`. On repeated publication of an existing tag, the action preserves an existing body and replaces same-name attachments. It does not provide the previous script's draft-until-all-uploads-complete behavior. Review the release and attachments before retrying a failed publication.
|
||||
|
||||
## Reporting Problems
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ go run . build all
|
||||
cd ..
|
||||
```
|
||||
|
||||
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).
|
||||
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](#official-snapshot-updates).
|
||||
|
||||
### Install a Theme
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Gitea 兼容性
|
||||
---
|
||||
[English](../COMPATIBILITY.md)
|
||||
|
||||
此文件描述了 Gitea Mail Templates 与 Gitea 不同版本之间的兼容性。
|
||||
|
||||
## 兼容性表
|
||||
|
||||
根据所运行的 Gitea 版本选择对应推荐的模板版本。`[PASS]` 表示已记录为兼容的组合,`[PENDING]` 表示尚未完成验证。
|
||||
|
||||
> [!NOTE]
|
||||
> 历史条目仅反映项目已有的兼容性评估,不代表每次发布都重新测试了每一个补丁版本。
|
||||
|
||||
| Gitea 版本 | 推荐模板版本 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 28.1.0 | — | [PENDING] | 等待兼容性验证 |
|
||||
| 28.0.0 | **v28.0.0** | [PASS] | Gitea 将邮件函数 `FileSize` 替换为 `FormatByteSize` |
|
||||
| 1.27.3 | **v1.27.3** | [PASS] | 全部主题已修复拉取请求推送通知的提交链接 |
|
||||
| 1.27.2 | **v1.27.3** | [PASS] | 同号 v1.27.2 模板的 Bloom、Ember、Heritage 存在推送通知缺陷 |
|
||||
| 1.27.1 | **v1.27.3** | [PASS] | 较早 v1.0.x 模板使用过时的推送提交字段 |
|
||||
| 1.27.0 | — | [PENDING] | Gitea 使用了新的提交数据结构;v1.27.3 可能适用,但未在此版本上验证 |
|
||||
| 1.26.4 | **v1.0.1** | [PASS] | — |
|
||||
| 1.26.3 | **v1.0.1** | [PASS] | — |
|
||||
| 1.26.2 | **v1.0.1** | [PASS] | — |
|
||||
| 1.26.1 | **v1.0.1** | [PASS] | — |
|
||||
| 1.26.0 | **v1.0.1** | [PASS] | — |
|
||||
| 1.25.5 | **v1.0.1** | [PASS] | — |
|
||||
| 1.25.0 | **v1.0.1** | [PASS] | — |
|
||||
|
||||
> [!NOTE]
|
||||
> Gitea 1.24.x 及更早版本使用不同的邮件模板布局,属于 `[UNSUPPORTED]`,Gitea Mail Templates 不再另外支持。v1.0.0 模板标签已由 v1.0.1 替代,不再单独推荐。
|
||||
|
||||
## Gitea 历版本对邮件模板影响
|
||||
|
||||
此表描述 **Gitea** 的变化,不是本模板仓库的修复记录。“无”表示该 Gitea 版本没有新增相关邮件模板影响;推荐归档见上方兼容性表。
|
||||
|
||||
| Gitea | 发布日期 | 邮件模板变化 | 影响 |
|
||||
|---|---|---|---|
|
||||
| **28.1.0** | 2026-10-06 | [PENDING] | [PENDING] |
|
||||
| **28.0.0** | 2026-09-29 | 邮件函数 `FileSize` 改为 `FormatByteSize` ([PR](https://github.com/go-gitea/gitea/pull/#39083)) | **不兼容变更:** 旧的发布附件模板失效 |
|
||||
|||引入 templates/mail/base/ ([PR](https://github.com/go-gitea/gitea/pull/#38396))| **不兼容变更:**旧版本 Gitea 无法直接使用新版本邮件模板 |
|
||||
| **1.27.3** | 2026-08-29 | 无 | 无 |
|
||||
| **1.27.2** | 2026-08-14 | 无 | 无 |
|
||||
| **1.27.1** | 2026-07-27 | 内置推送模板改用 `.UserCommit.GitCommit`([PR](https://github.com/go-gitea/gitea/pull/38467)) | **不兼容变更:存在既有未修复缺陷** 旧的自定义拉取请求推送模板可能失效 |
|
||||
| **1.27.0** | 2026-07-13 | 推送提交数据移至 `.UserCommit.GitCommit`([ISSUE](https://github.com/go-gitea/gitea/issues/38469)) | **不兼容变更:** 旧的拉取请求推送模板失效 |
|
||||
| **1.26.4** | 2026-06-21 | 无 | 无 |
|
||||
| **1.26.3** | 2026-06-20 | 无 | 无 |
|
||||
| **1.26.2** | 2026-05-20 | 无 | 无 |
|
||||
| **1.26.1** | 2026-04-22 | 无 | 无 |
|
||||
| **1.26.0** | 2026-04-19 | 无 | 无 |
|
||||
| **1.25.5** | 2026-03-10 | 无 | 无 |
|
||||
| **1.25.0** | 2025 | 自定义邮件路径及主题和正文格式变化([PR](https://github.com/go-gitea/gitea/pull/35150)) | **不兼容变更:** 不支持更早的模板布局 |
|
||||
| **≤ 1.24.x** | — | 旧的自定义邮件布局 | [UNSUPPORTED] |
|
||||
|
||||
## 模板变量参考
|
||||
|
||||
各类邮件上下文由官方模板定义。开发时具体的邮件模板和函数和变量请参考官方邮件模板文档 [1](#注释)。
|
||||
|
||||
## 翻译键
|
||||
|
||||
官方模板引用 `mail.*` 和 `actions.runs.attempt`。基于 AST 的检查覆盖 `.locale.Tr`、`$.locale.Tr` 和 `TrN` 的两个复数键,包括嵌套管道。所有引用键必须存在于锁定的英文目录。
|
||||
|
||||
---
|
||||
### 注释
|
||||
|
||||
[1](#注释): [Mail templates | Gitea Documentation](https://docs.gitea.com/administration/mail-templates/)
|
||||
@@ -1,7 +1,5 @@
|
||||
# 贡献指南
|
||||
|
||||
[English](../CONTRIBUTING.md) · [项目概览](README.zh-CN.md) · [兼容性](../COMPATIBILITY.md)
|
||||
|
||||
欢迎改进主题、工具、测试、文档和翻译。本指南介绍当前源码架构的开发流程;安装和版本选择请参阅 [README](README.zh-CN.md#安装)。
|
||||
|
||||
## 本地开发
|
||||
@@ -18,7 +16,7 @@ go run . preview all
|
||||
|
||||
在浏览器中打开 `preview/index.html`,或在 `tools/` 中运行 `go run . dev`,访问 [http://127.0.0.1:3456](http://127.0.0.1:3456) 使用实时预览。开发服务器监听主题、框架、锁文件与缓存、预览测试数据的变化。
|
||||
|
||||
CLI 使用 urfave/cli 和 x/net HTML 解析器。文档与打包检查使用 Python 3.11 或更高版本;只有浏览器检查需要 Node.js 和 `tools/qa` 中的依赖。
|
||||
CLI 使用 urfave/cli 和 x/net HTML 解析器。文档与打包检查使用 Python 3.11 或更高版本。
|
||||
|
||||
### 常用命令
|
||||
|
||||
@@ -114,7 +112,7 @@ go test ./...
|
||||
go run . preview all
|
||||
```
|
||||
|
||||
评审其他版本时,明确指定对应标签。检查锁文件差异,按需更新框架适配逻辑和测试数据,完成检查及同版本实例冒烟测试后,再更新兼容性记录。`sync` 只修改缓存和锁文件,文档更新与版本发布需单独完成。仅缺少缓存时使用 `prepare`。
|
||||
评审其他版本时,明确指定对应标签。检查锁文件差异,按需更新框架适配逻辑和测试数据,完成检查后,再更新兼容性记录。`sync` 只修改缓存和锁文件,文档更新与版本发布需单独完成。仅缺少缓存时使用 `prepare`。
|
||||
|
||||
已评审的翻译或邮件渲染参考源码发生变化时,须先评审适配逻辑,再更新参考哈希。官方英文必须包含所有引用的翻译键;其他语言缺少的翻译回退为英文。新增邮件类型需要补充框架适配和 `tools/data/templates_config.json` 中的测试数据,该文件提供预览元数据和示例上下文。JSON 测试数据中的整数应保持整数形式,以便 Go 正确格式化。
|
||||
|
||||
@@ -145,7 +143,7 @@ Gitea v28.0.0 的波兰语 `mail.team_invite.text_1` 存在占位符缺陷。预
|
||||
|
||||
在 `tools/` 中运行 `go test ./...` 和 `go run . preview all`。测试覆盖生成结果的确定性、操作锚点变化、翻译键,以及全部主题和语言的通知主题行、正文与链接。测试数据包含推送、评审、回复、工作流和附件分支;共享控件与品牌展示作为附加内容单独处理。
|
||||
|
||||
修改文档、追踪脚本或打包流程时,在仓库根目录执行:
|
||||
修改文档或打包流程时,在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
@@ -153,29 +151,12 @@ python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
调整通用说明时,同步更新英文和简体中文指南。拉取请求应说明改动内容及相关检查结果,涉及视觉变化时附上截图。
|
||||
|
||||
### 浏览器与 Gitea 实例检查
|
||||
|
||||
生成预览数据后,可运行浏览器检查:
|
||||
|
||||
```bash
|
||||
cd tools/qa
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
```
|
||||
|
||||
如需使用已安装的 Chrome 或 Edge,设置 `BROWSER_EXECUTABLE_PATH`,无需再安装 Chromium。设置 `PREVIEW_DEV_URL` 可同时检查运行中的 HTTP 预览。`npm test -- --update-gallery` 会一并更新画廊截图。
|
||||
|
||||
发布前,在与锁定版本一致的隔离 Gitea 实例中加载生成的模板,捕获真实的密码重置或通知邮件。管理后台的测试邮件按钮不使用自定义模板。
|
||||
|
||||
可选的 `tools/integration` 测试使用临时 SQLite 数据、用户、Git/SSH 路径和回环 SMTP。将 `GITEA_SMOKE_BINARY` 设为已校验官方校验值、版本与锁文件一致的 Gitea 可执行文件,再在 `tools/` 中运行 `go test ./integration -v -count=1`。邮件仅在本地捕获,不发送给真实用户。未设置该变量时会跳过此测试。发布工作流不会自动运行可选的浏览器和真实 Gitea 实例检查。
|
||||
|
||||
### 准备发行版
|
||||
|
||||
发行标签必须与锁定的 Gitea 版本一致。当前源码重构尚未发布,已有 v28.0.0 和历史版本的附件应保留。
|
||||
|
||||
1. 完成自动化检查、预览检查和同版本实例冒烟测试。
|
||||
2. 在 `.github/release-notes/vX.Y.Z.md` 添加已评审的发行说明,并按验证结果更新兼容性记录。
|
||||
1. 完成 Go 测试、文档检查和预览生成。
|
||||
2. 按验证结果更新文档和兼容性记录。发行说明为可选内容,不用于 Release 正文。
|
||||
3. 在 `tools/` 中运行 `go run . preview all`,生成全部主题和语言数据。
|
||||
4. 打包并检查发行包内容后再发布。
|
||||
|
||||
@@ -187,15 +168,15 @@ python .github/scripts/package_release.py --version vX.Y.Z --output <new-output-
|
||||
|
||||
将版本和输出目录占位符替换为已评审的标签及新的目录。脚本会检查源码与产物哈希,包含所有语言数据包,排除残留的旧主题构建,并拒绝覆盖已有压缩包。
|
||||
|
||||
发布工作流使用同一脚本打包模板、预览、文档、官方许可证和来源信息,并更新受管理的版本标记。依赖自动发布前,请确认所用 Gitea 主机的 Actions 支持和写入权限。Markdown 管理区块的说明见[版本追踪](../COMPATIBILITY.md#version-tracking)。
|
||||
发布工作流使用同一脚本打包模板、预览、文档、官方许可证和来源信息,仅上传发行归档,以标签作为 Release 标题,新建 Release 的正文为空。版本标记、徽章和兼容性记录均由维护者手动修改,归档文档应在打标签前检查。依赖自动发布前,请确认所用 Gitea 主机的 Actions 支持和写入权限。工作流行为见[版本维护](COMPATIBILITY.zh-CN.md#版本维护)。
|
||||
|
||||
### Gitea 工作流配置
|
||||
|
||||
两个工作流均使用 `linux-amd64-docker-small` Runner 标签。任务镜像需要支持检出和工具链安装步骤使用的 Node.js Action,并提供 Git 和 POSIX Shell。Go 与 Python 由安装步骤准备。
|
||||
发布工作流使用 `linux-amd64-docker-small` Runner 标签。任务镜像需要支持检出和工具链安装步骤使用的 Node.js Action,并提供 Git 和 POSIX Shell。Go 与 Python 由安装步骤准备。
|
||||
|
||||
PR 创建和发行附件上传通过 `.github/scripts/gitea_actions.py` 调用实例的 `/api/v1` API。工作流传入实例地址、仓库名称和内置 `GITEA_TOKEN`,仓库设置需要允许所请求的代码、发行版及拉取请求写入权限。可选的 `UPSTREAM_GITHUB_TOKEN` Secret 仅用于查询 GitHub 上的官方发行版;未配置时使用 GitHub 的匿名 API 限额。
|
||||
发布直接使用 [`gitea-release-action@v1.3.7`](https://gitea.com/actions/gitea-release-action/src/tag/v1.3.7/action.yml),传入实例地址、仓库、内置 `GITEA_TOKEN`、标签、标题和两个归档路径。新建 Release 的正文为空,不要求发行说明文件,也不创建提醒 Issue。仓库需允许发行版写入。Action 地址显式使用 `https://gitea.com`,不依赖 `DEFAULT_ACTIONS_URL` 配置。
|
||||
|
||||
追踪工作流会复用内容相同的已有分支及对应的未关闭 PR。分支内容不同时需要人工检查,工作流不会强制推送。发布流程拒绝修改任何已有发行版,包括草稿;新版本在两个压缩包上传成功后才从草稿转为发布状态。上传失败时,重试前先检查草稿;已发布版本及附件应保持不变。
|
||||
发布前通过 `actions/setup-node@v4` 准备 Node.js 22。当前 Gitea runner 使用 `PATH` 中的 `node` 执行 JavaScript Action,提供发布 Action 所需的运行接口,无需 `--experimental-fetch`。重复发布已有标签时,Action 保留已有正文并替换同名附件,不具备原脚本在全部附件上传完成前保持草稿的行为。发布失败后,重试前应检查发行版和附件。
|
||||
|
||||
## 报告问题
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Gitea Mail Template 为自托管的 [Gitea](https://about.gitea.com) 提供多
|
||||
|
||||
### 选择安装来源
|
||||
|
||||
运行 `gitea --version` 确认部署的 Gitea 实例版本,再按照[兼容矩阵](../COMPATIBILITY.md#compatibility-matrix)选择模板版本。
|
||||
运行 `gitea --version` 确认部署的 Gitea 实例版本,再按照[兼容性表](COMPATIBILITY.zh-CN.md#兼容性表)选择模板版本。
|
||||
|
||||
> [!WARNING]
|
||||
> 我们不建议直接从源码仓库拉取并构建,因为这些版本还未经过完全测试达到发行可用的水平。如果存在未知的模板参数过时或遗漏可能导致 Gitea 实例启动异常。
|
||||
@@ -52,7 +52,7 @@ cd tools
|
||||
go run . build all
|
||||
cd ..
|
||||
```
|
||||
构建可用模板时需要依赖于 Gitea 官方仓库的资源文件,以确保来源的一致性。因此该命令会自动下载由`gitea.lock.json` 文件锁定的官方文件,后续构建无需再次下载,使用缓存目录资源即可运行。文件缺失或缓存损坏的处理方式见[更新官方快照](CONTRIBUTING.zh-CN.md#更新官方快照)。
|
||||
构建可用模板时需要依赖于 Gitea 官方仓库的资源文件,以确保来源的一致性。因此该命令会自动下载由`gitea.lock.json` 文件锁定的官方文件,后续构建无需再次下载,使用缓存目录资源即可运行。文件缺失或缓存损坏的处理方式见[更新官方快照](#更新官方快照)。
|
||||
|
||||
### 安装主题
|
||||
|
||||
|
||||
+174
-63
@@ -29,6 +29,11 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
#topbar .btn-group button+button{border-left:none}
|
||||
#topbar .btn-group button.on{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
|
||||
#topbar .hint{font-size:10px;color:var(--muted);opacity:.5;white-space:nowrap}
|
||||
#btn-screenshot{display:inline-flex;align-items:center;gap:6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--rad);padding:6px 12px;font-size:12px;cursor:pointer}
|
||||
#btn-screenshot svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;flex-shrink:0}
|
||||
#btn-screenshot:hover:not(:disabled){border-color:var(--accent)}
|
||||
#btn-screenshot:disabled{opacity:.45;cursor:wait}
|
||||
button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
|
||||
/* Topbar responsive — progressively compact as width shrinks */
|
||||
@media(max-width:900px){#topbar .hint{display:none}}
|
||||
@@ -49,32 +54,29 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
|
||||
/* Main layout */
|
||||
#main{flex:1;display:flex;position:relative;min-height:0;overflow:hidden}
|
||||
#preview-area{flex:1;display:flex;align-items:flex-start;justify-content:center;padding:24px;overflow:auto;background:linear-gradient(135deg,#0b1120,#131c30,#0d1525);transition:padding-right .3s cubic-bezier(.4,0,.2,1)}
|
||||
#preview-area{flex:1;min-width:0;min-height:0;display:flex;align-items:flex-start;justify-content:center;padding:24px;overflow:auto;background:linear-gradient(135deg,#0b1120,#131c30,#0d1525)}
|
||||
|
||||
/* --- Floating panel --- */
|
||||
/* Panel and preview share the available width; the handle occupies its own gutter. */
|
||||
#panel{
|
||||
position:absolute;right:12px;top:12px;bottom:12px;
|
||||
width:var(--panel-w);
|
||||
--panel-w:min(440px,40vw);
|
||||
position:relative;flex:0 0 var(--panel-w);width:var(--panel-w);min-width:0;
|
||||
margin:12px 12px 12px 40px;
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-radius:10px;
|
||||
box-shadow:-4px 4px 32px rgba(0,0,0,.5),0 0 0 1px rgba(255,255,255,.03) inset;
|
||||
z-index:20;
|
||||
display:flex;flex-direction:column;
|
||||
overflow:hidden;
|
||||
transition:transform .35s cubic-bezier(.4,0,.2,1),opacity .35s ease,box-shadow .35s ease;
|
||||
transition:flex-basis .3s ease,width .3s ease;
|
||||
}
|
||||
#panel.collapsed{
|
||||
transform:translateX(calc(var(--panel-w) - 36px));
|
||||
box-shadow:-2px 2px 16px rgba(0,0,0,.35),0 0 0 1px rgba(255,255,255,.03) inset;
|
||||
opacity:.92;
|
||||
flex-basis:0;width:0;border-width:0;background:transparent;box-shadow:none;
|
||||
}
|
||||
#panel.collapsed:hover{opacity:1}
|
||||
#panel.collapsed #panel-body{visibility:hidden}
|
||||
|
||||
/* Panel drag-handle / collapse tab */
|
||||
#panel-handle{
|
||||
position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:28px;height:64px;
|
||||
position:absolute;left:-40px;top:50%;transform:translateY(-50%);
|
||||
width:40px;height:80px;
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-right:none;
|
||||
@@ -82,17 +84,11 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
transition:background .15s;
|
||||
z-index:21;
|
||||
/* sit just outside the panel's left edge */
|
||||
margin-left:-1px;
|
||||
}
|
||||
#panel.collapsed #panel-handle{
|
||||
/* when collapsed, handle moves with panel, peeking from right edge */
|
||||
left:1px;
|
||||
color:var(--muted);
|
||||
}
|
||||
#panel-handle:hover{background:var(--bg)}
|
||||
#panel-handle svg{
|
||||
width:14px;height:14px;
|
||||
width:20px;height:20px;
|
||||
stroke:var(--muted);
|
||||
fill:none;stroke-width:2;
|
||||
transition:transform .35s cubic-bezier(.4,0,.2,1),stroke .15s;
|
||||
@@ -101,22 +97,7 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
#panel.collapsed #panel-handle svg{transform:rotate(180deg)}
|
||||
|
||||
/* Panel inner scroll */
|
||||
#panel-body{flex:1;overflow-y:auto;overflow-x:hidden}
|
||||
|
||||
/* Panel close button (top-right) */
|
||||
#panel-close{
|
||||
position:absolute;right:8px;top:8px;
|
||||
width:24px;height:24px;
|
||||
background:none;border:none;
|
||||
cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
border-radius:4px;
|
||||
color:var(--muted);
|
||||
font-size:16px;line-height:1;
|
||||
transition:background .15s,color .15s;
|
||||
z-index:2;
|
||||
}
|
||||
#panel-close:hover{background:rgba(255,255,255,.06);color:var(--text)}
|
||||
#panel-body{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;border-radius:10px}
|
||||
|
||||
/* Section */
|
||||
#panel section{padding:12px 14px;border-bottom:1px solid var(--border)}
|
||||
@@ -148,17 +129,12 @@ section.collapsed .section-chevron{transform:rotate(-90deg)}
|
||||
|
||||
/* Collapsible content wrapper */
|
||||
.section-content{
|
||||
overflow:hidden;
|
||||
transition:max-height .3s ease,opacity .25s ease,margin-top .3s ease;
|
||||
max-height:2000px;
|
||||
opacity:1;
|
||||
margin-top:10px;
|
||||
}
|
||||
section.collapsed .section-content{
|
||||
max-height:0;
|
||||
opacity:0;
|
||||
margin-top:0;
|
||||
display:none;
|
||||
}
|
||||
#sec-params .section-content{max-height:50vh;overflow:auto;overscroll-behavior:contain}
|
||||
|
||||
#panel .card{background:var(--bg);border-radius:var(--rad);padding:10px 12px;margin-bottom:8px}
|
||||
#panel .card:last-child{margin-bottom:0}
|
||||
@@ -166,17 +142,19 @@ section.collapsed .section-content{
|
||||
#panel .card .value{font-size:12px;color:var(--text)}
|
||||
#panel table{width:100%;border-collapse:collapse}
|
||||
#panel table td{padding:4px 6px;font-size:11px;border-bottom:1px solid rgba(255,255,255,.03);vertical-align:top}
|
||||
#panel table td:first-child{font-family:monospace;color:var(--accent);font-size:10px;width:0;white-space:nowrap}
|
||||
#panel table td:last-child{color:var(--muted);text-align:right;font-size:10px;word-break:break-all}
|
||||
#panel table td:first-child{font-family:monospace;color:var(--accent);font-size:10px;overflow-wrap:anywhere}
|
||||
#panel table td:nth-child(2){color:var(--muted);font-size:10px;overflow-wrap:anywhere}
|
||||
#panel table td:last-child{text-align:right;width:1%}
|
||||
#panel .badge{display:inline-block;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:.2px;white-space:nowrap}
|
||||
.badge-req{background:rgba(52,211,153,.12);color:var(--green)}
|
||||
.badge-opt{background:rgba(107,125,153,.1);color:var(--muted)}
|
||||
#panel .stat{font-size:10px;color:var(--muted);margin-top:6px}
|
||||
|
||||
/* Viewport */
|
||||
#viewport{border-radius:var(--rad);box-shadow:0 8px 32px rgba(0,0,0,.45);transition:all .3s;flex-shrink:0;overflow:hidden;background:#fff}
|
||||
#viewport.desktop{width:1386px;height:780px}
|
||||
#viewport{border-radius:var(--rad);box-shadow:0 8px 32px rgba(0,0,0,.45);flex-shrink:0;max-width:100%;overflow:hidden;background:#fff}
|
||||
#viewport.desktop{width:100%;max-width:1386px;height:780px}
|
||||
#viewport.mobile{width:390px;height:780px;border-radius:28px;border:3px solid #1e293b;box-shadow:0 8px 32px rgba(0,0,0,.5),inset 0 0 0 1px #0f172a;display:flex;flex-direction:column}
|
||||
#viewport.mobile #preview-frame{flex:1;min-height:0}
|
||||
#viewport.mobile::before{content:'';display:block;height:24px;background:#1e293b;flex-shrink:0}
|
||||
#preview-frame{width:100%;height:100%;border:none;display:block;background:#fff}
|
||||
#viewport.desktop #preview-frame{border-radius:var(--rad)}
|
||||
@@ -193,7 +171,7 @@ section.collapsed .section-content{
|
||||
.message code{background:rgba(0,0,0,0.12);padding:1px 6px;border-radius:3px}
|
||||
|
||||
/* Scrollbar — neutral gray, consistent across all browsers */
|
||||
html,body,#preview-area,#panel-body{
|
||||
html,body,#preview-area,#panel-body,#sec-params .section-content{
|
||||
scrollbar-width:thin;
|
||||
scrollbar-color:rgba(107,125,153,.25) transparent;
|
||||
}
|
||||
@@ -201,27 +179,30 @@ html,body,#preview-area,#panel-body{
|
||||
-ms-overflow-style:-ms-autohiding-scrollbar;
|
||||
}
|
||||
#preview-area::-webkit-scrollbar,
|
||||
#sec-params .section-content::-webkit-scrollbar,
|
||||
#panel-body::-webkit-scrollbar { width:6px; height:6px }
|
||||
#preview-area::-webkit-scrollbar-track,
|
||||
#sec-params .section-content::-webkit-scrollbar-track,
|
||||
#panel-body::-webkit-scrollbar-track { background:transparent }
|
||||
#preview-area::-webkit-scrollbar-thumb,
|
||||
#sec-params .section-content::-webkit-scrollbar-thumb,
|
||||
#panel-body::-webkit-scrollbar-thumb {
|
||||
background-color:rgba(107,125,153,.25);
|
||||
border-radius:3px;
|
||||
border:1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
#preview-area::-webkit-scrollbar-thumb:hover,
|
||||
#sec-params .section-content::-webkit-scrollbar-thumb:hover,
|
||||
#panel-body::-webkit-scrollbar-thumb:hover { background-color:rgba(107,125,153,.45) }
|
||||
#preview-area::-webkit-scrollbar-corner,
|
||||
#sec-params .section-content::-webkit-scrollbar-corner,
|
||||
#panel-body::-webkit-scrollbar-corner { background:transparent }
|
||||
|
||||
@media(max-width:900px){
|
||||
#panel{--panel-w:380px;right:6px;top:6px;bottom:6px}
|
||||
#panel.collapsed{transform:translateX(calc(var(--panel-w) - 36px))}
|
||||
}
|
||||
@media(max-width:700px){
|
||||
#panel{display:none}
|
||||
#panel{--panel-w:50vw;margin-top:6px;margin-bottom:6px;margin-right:6px}
|
||||
#preview-area{padding:12px}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){#panel,#panel-handle svg{transition:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -256,6 +237,10 @@ html,body,#preview-area,#panel-body{
|
||||
<button id="btn-desk" class="on" onclick="setViewport('desktop')">Desktop</button>
|
||||
<button id="btn-mob" onclick="setViewport('mobile')">Mobile</button>
|
||||
</div>
|
||||
<button id="btn-screenshot" type="button" onclick="capturePreview()" disabled>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l1.5-2h5L16 5h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||
<span id="screenshot-label">Screenshot</span>
|
||||
</button>
|
||||
<span class="hint">keys: d/m ←→ tab ↑↓ select p toggle panel</span>
|
||||
</nav>
|
||||
|
||||
@@ -274,12 +259,9 @@ html,body,#preview-area,#panel-body{
|
||||
|
||||
<aside id="panel">
|
||||
<!-- Collapse handle tab on the left edge -->
|
||||
<div id="panel-handle" title="Toggle panel" onclick="togglePanel()">
|
||||
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Close button (top-right) -->
|
||||
<button id="panel-close" title="Close panel" onclick="togglePanel()">×</button>
|
||||
<button id="panel-handle" type="button" title="Collapse panel" aria-label="Collapse panel" aria-expanded="true" aria-controls="panel-body" onclick="togglePanel()">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
|
||||
<div id="panel-body">
|
||||
<section id="sec-info">
|
||||
@@ -323,6 +305,7 @@ html,body,#preview-area,#panel-body{
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="lib/html2canvas/html2canvas.min.js"></script>
|
||||
<script src="rendered.js"></script>
|
||||
<script>
|
||||
//
|
||||
@@ -337,6 +320,122 @@ let localeRequest = 0;
|
||||
let renderRequest = 0, renderTimer;
|
||||
const localeLoads = new Map();
|
||||
let devMode = false; // true once SSE connects
|
||||
let previewReady = false;
|
||||
let capturing = false;
|
||||
|
||||
function updateCaptureButton() {
|
||||
document.getElementById('btn-screenshot').disabled = !previewReady || capturing;
|
||||
}
|
||||
|
||||
// Capture only the visible viewport, including the mobile frame when selected.
|
||||
async function capturePreview() {
|
||||
if (!previewReady || capturing) return;
|
||||
const label = document.getElementById('screenshot-label');
|
||||
const selection = { ...state };
|
||||
const request = renderRequest;
|
||||
const frame = document.getElementById('preview-frame');
|
||||
const viewport = document.getElementById('viewport');
|
||||
capturing = true;
|
||||
label.textContent = 'Capturing…';
|
||||
updateCaptureButton();
|
||||
document.getElementById('capture-message')?.remove();
|
||||
try {
|
||||
if (typeof html2canvas !== 'function') throw new Error('Screenshot library unavailable; restore preview/lib/html2canvas.');
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc) throw new Error('Preview document is not accessible.');
|
||||
if (doc.fonts) await doc.fonts.ready;
|
||||
await Promise.all(Array.from(doc.images, image => image.decode().catch(() => {})));
|
||||
if (request !== renderRequest || !previewReady) throw new Error('Preview changed; capture again after it finishes loading.');
|
||||
const width = viewport.offsetWidth;
|
||||
const height = viewport.offsetHeight;
|
||||
const frameWidth = frame.clientWidth;
|
||||
const frameHeight = frame.clientHeight;
|
||||
const scrollX = frame.contentWindow.scrollX;
|
||||
const scrollY = frame.contentWindow.scrollY;
|
||||
const viewportBounds = viewport.getBoundingClientRect();
|
||||
const frameBounds = frame.getBoundingClientRect();
|
||||
const frameLeft = frameBounds.left - viewportBounds.left;
|
||||
const frameTop = frameBounds.top - viewportBounds.top;
|
||||
const scale = Math.min(window.devicePixelRatio || 1, 2);
|
||||
// Render in the mail document's own coordinates. Rendering nested iframes
|
||||
// with the outer document's context can shift centered tables and text.
|
||||
const mailCanvas = await html2canvas(doc.documentElement, {
|
||||
backgroundColor: '#fff',
|
||||
scale,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
windowWidth: frameWidth,
|
||||
windowHeight: frameHeight,
|
||||
x: scrollX,
|
||||
y: scrollY,
|
||||
scrollX,
|
||||
scrollY,
|
||||
useCORS: true,
|
||||
logging: false
|
||||
});
|
||||
const canvas = await html2canvas(viewport, {
|
||||
backgroundColor: null,
|
||||
scale,
|
||||
width,
|
||||
height,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
ignoreElements: element => element.id === 'preview-frame',
|
||||
onclone: clone => {
|
||||
const clonedViewport = clone.getElementById('viewport');
|
||||
clonedViewport.style.width = width + 'px';
|
||||
clonedViewport.style.maxWidth = 'none';
|
||||
clonedViewport.style.height = height + 'px';
|
||||
}
|
||||
});
|
||||
if (request !== renderRequest || !previewReady) throw new Error('Preview changed; capture again after it finishes loading.');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Could not create the screenshot canvas.');
|
||||
const style = getComputedStyle(viewport);
|
||||
const border = parseFloat(style.borderLeftWidth) || 0;
|
||||
const radius = Math.max(0, (parseFloat(style.borderTopLeftRadius) || 0) - border);
|
||||
// Clip the composite to the container's inner rounded border, including
|
||||
// the mobile frame. No top bar or sidebar enters the exported image.
|
||||
const left = border, top = border;
|
||||
const right = width - border, bottom = height - border;
|
||||
const rounding = Math.min(radius, (right - left) / 2, (bottom - top) / 2);
|
||||
context.save();
|
||||
context.scale(scale, scale);
|
||||
context.beginPath();
|
||||
context.moveTo(left + rounding, top);
|
||||
context.arcTo(right, top, right, bottom, rounding);
|
||||
context.arcTo(right, bottom, left, bottom, rounding);
|
||||
context.arcTo(left, bottom, left, top, rounding);
|
||||
context.arcTo(left, top, right, top, rounding);
|
||||
context.closePath();
|
||||
context.clip();
|
||||
context.drawImage(mailCanvas, frameLeft, frameTop, frameWidth, frameHeight);
|
||||
context.restore();
|
||||
const blob = await new Promise((resolve, reject) => canvas.toBlob(
|
||||
value => value ? resolve(value) : reject(new Error('Could not encode the screenshot.')),
|
||||
'image/png'
|
||||
));
|
||||
const filename = [selection.theme, selection.template, selection.locale]
|
||||
.map(value => value.replace(/[<>:"/\\|?*\x00-\x1f]/g, '-')).join('_') + '.png';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
// Give the browser time to start reading the download before releasing it.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch (error) {
|
||||
const message = showMessage('', 'error');
|
||||
message.id = 'capture-message';
|
||||
message.textContent = 'Screenshot failed: ' + String(error.message || error);
|
||||
} finally {
|
||||
capturing = false;
|
||||
label.textContent = 'Screenshot';
|
||||
updateCaptureButton();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Client transforms
|
||||
@@ -371,7 +470,10 @@ function render() {
|
||||
const loading = document.getElementById('loading');
|
||||
if(!rendered) return;
|
||||
|
||||
frame.setAttribute('sandbox', '');
|
||||
// Same-origin DOM access is required for capture; scripts remain disabled.
|
||||
frame.setAttribute('sandbox', 'allow-same-origin');
|
||||
previewReady = false;
|
||||
updateCaptureButton();
|
||||
|
||||
frame.style.display = 'none';
|
||||
loading.style.display = 'flex';
|
||||
@@ -388,6 +490,8 @@ function render() {
|
||||
loading.style.display = 'none';
|
||||
frame.style.display = 'block';
|
||||
frame.onload = null;
|
||||
previewReady = true;
|
||||
updateCaptureButton();
|
||||
}
|
||||
frame.onload = finish;
|
||||
frame.srcdoc = content;
|
||||
@@ -400,7 +504,12 @@ function render() {
|
||||
//
|
||||
function togglePanel() {
|
||||
const panel = document.getElementById('panel');
|
||||
panel.classList.toggle('collapsed');
|
||||
const collapsed = panel.classList.toggle('collapsed');
|
||||
const handle = document.getElementById('panel-handle');
|
||||
handle.setAttribute('aria-expanded', String(!collapsed));
|
||||
handle.setAttribute('aria-label', collapsed ? 'Expand panel' : 'Collapse panel');
|
||||
handle.title = collapsed ? 'Expand panel' : 'Collapse panel';
|
||||
document.getElementById('panel-body').inert = collapsed;
|
||||
}
|
||||
|
||||
function toggleSection(id) {
|
||||
@@ -461,7 +570,7 @@ document.addEventListener('keydown',e=>{
|
||||
// Viewport shortcuts (no modifiers)
|
||||
if(e.key==='d'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ setViewport('desktop'); return; }
|
||||
if(e.key==='m'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ setViewport('mobile'); return; }
|
||||
// 'p' to toggle floating panel
|
||||
// 'p' to toggle the side panel
|
||||
if(e.key==='p'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ togglePanel(); return; }
|
||||
|
||||
// ← → cycle focus between Theme / Template / Client selects
|
||||
@@ -541,6 +650,8 @@ function loadLocale(lang) {
|
||||
async function switchLocale(lang) {
|
||||
if (!manifest?.languages.includes(lang)) return;
|
||||
state.locale=lang; rendered=null;
|
||||
previewReady=false;
|
||||
updateCaptureButton();
|
||||
const request=++localeRequest;
|
||||
document.getElementById('preview-frame').style.display='none';
|
||||
document.getElementById('loading').style.display='flex';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
Copyright (c) 2012 Niklas von Hertzen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,11 @@
|
||||
# html2canvas
|
||||
|
||||
Version: **1.4.1**, distributed under the [MIT license](LICENSE).
|
||||
|
||||
The preview uses this local copy to export the visible email container as PNG without a CDN connection. Release archives include the library and its license.
|
||||
|
||||
- Project: https://github.com/niklasvh/html2canvas
|
||||
- Distribution: https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js
|
||||
- SHA-256: `e87e550794322e574a1fda0c1549a3c70dae5a93d9113417a429016838eab8cb`
|
||||
|
||||
Capture uses DOM rendering, so CSS support and cross-origin images follow [html2canvas's documented limitations](https://html2canvas.hertzen.com/documentation). The iframe permits same-origin DOM access while keeping scripts disabled. Keep the version, license and checksum together when updating the library.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*!
|
||||
* html2canvas 1.4.1 <https://html2canvas.hertzen.com>
|
||||
* Copyright (c) 2022 Niklas von Hertzen <https://hertzen.com>
|
||||
* Released under MIT License
|
||||
*/
|
||||
!function(A,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(A="undefined"!=typeof globalThis?globalThis:A||self).html2canvas=e()}(this,function(){"use strict";
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
***************************************************************************** */var r=function(A,e){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(A,e){A.__proto__=e}||function(A,e){for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&(A[t]=e[t])})(A,e)};function A(A,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function t(){this.constructor=A}r(A,e),A.prototype=null===e?Object.create(e):(t.prototype=e.prototype,new t)}var h=function(){return(h=Object.assign||function(A){for(var e,t=1,r=arguments.length;t<r;t++)for(var B in e=arguments[t])Object.prototype.hasOwnProperty.call(e,B)&&(A[B]=e[B]);return A}).apply(this,arguments)};function a(A,s,o,i){return new(o=o||Promise)(function(t,e){function r(A){try{n(i.next(A))}catch(A){e(A)}}function B(A){try{n(i.throw(A))}catch(A){e(A)}}function n(A){var e;A.done?t(A.value):((e=A.value)instanceof o?e:new o(function(A){A(e)})).then(r,B)}n((i=i.apply(A,s||[])).next())})}function H(t,r){var B,n,s,o={label:0,sent:function(){if(1&s[0])throw s[1];return s[1]},trys:[],ops:[]},A={next:e(0),throw:e(1),return:e(2)};return"function"==typeof Symbol&&(A[Symbol.iterator]=function(){return this}),A;function e(e){return function(A){return function(e){if(B)throw new TypeError("Generator is already executing.");for(;o;)try{if(B=1,n&&(s=2&e[0]?n.return:e[0]?n.throw||((s=n.return)&&s.call(n),0):n.next)&&!(s=s.call(n,e[1])).done)return s;switch(n=0,(e=s?[2&e[0],s.value]:e)[0]){case 0:case 1:s=e;break;case 4:return o.label++,{value:e[1],done:!1};case 5:o.label++,n=e[1],e=[0];continue;case 7:e=o.ops.pop(),o.trys.pop();continue;default:if(!(s=0<(s=o.trys).length&&s[s.length-1])&&(6===e[0]||2===e[0])){o=0;continue}if(3===e[0]&&(!s||e[1]>s[0]&&e[1]<s[3])){o.label=e[1];break}if(6===e[0]&&o.label<s[1]){o.label=s[1],s=e;break}if(s&&o.label<s[2]){o.label=s[2],o.ops.push(e);break}s[2]&&o.ops.pop(),o.trys.pop();continue}e=r.call(t,o)}catch(A){e=[6,A],n=0}finally{B=s=0}if(5&e[0])throw e[1];return{value:e[0]?e[1]:void 0,done:!0}}([e,A])}}}function t(A,e,t){if(t||2===arguments.length)for(var r,B=0,n=e.length;B<n;B++)!r&&B in e||((r=r||Array.prototype.slice.call(e,0,B))[B]=e[B]);return A.concat(r||e)}var d=(B.prototype.add=function(A,e,t,r){return new B(this.left+A,this.top+e,this.width+t,this.height+r)},B.fromClientRect=function(A,e){return new B(e.left+A.windowBounds.left,e.top+A.windowBounds.top,e.width,e.height)},B.fromDOMRectList=function(A,e){e=Array.from(e).find(function(A){return 0!==A.width});return e?new B(e.left+A.windowBounds.left,e.top+A.windowBounds.top,e.width,e.height):B.EMPTY},B.EMPTY=new B(0,0,0,0),B);function B(A,e,t,r){this.left=A,this.top=e,this.width=t,this.height=r}for(var f=function(A,e){return d.fromClientRect(A,e.getBoundingClientRect())},Q=function(A){for(var e=[],t=0,r=A.length;t<r;){var B,n=A.charCodeAt(t++);55296<=n&&n<=56319&&t<r?56320==(64512&(B=A.charCodeAt(t++)))?e.push(((1023&n)<<10)+(1023&B)+65536):(e.push(n),t--):e.push(n)}return e},g=function(){for(var A=[],e=0;e<arguments.length;e++)A[e]=arguments[e];if(String.fromCodePoint)return String.fromCodePoint.apply(String,A);var t=A.length;if(!t)return"";for(var r=[],B=-1,n="";++B<t;){var s=A[B];s<=65535?r.push(s):(s-=65536,r.push(55296+(s>>10),s%1024+56320)),(B+1===t||16384<r.length)&&(n+=String.fromCharCode.apply(String,r),r.length=0)}return n},e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",n="undefined"==typeof Uint8Array?[]:new Uint8Array(256),s=0;s<e.length;s++)n[e.charCodeAt(s)]=s;for(var o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",c="undefined"==typeof Uint8Array?[]:new Uint8Array(256),i=0;i<o.length;i++)c[o.charCodeAt(i)]=i;function w(A,e,t){return A.slice?A.slice(e,t):new Uint16Array(Array.prototype.slice.call(A,e,t))}var U=(l.prototype.get=function(A){var e;if(0<=A){if(A<55296||56319<A&&A<=65535)return e=this.index[A>>5],this.data[e=(e<<2)+(31&A)];if(A<=65535)return e=this.index[2048+(A-55296>>5)],this.data[e=(e<<2)+(31&A)];if(A<this.highStart)return e=this.index[e=2080+(A>>11)],e=this.index[e+=A>>5&63],this.data[e=(e<<2)+(31&A)];if(A<=1114111)return this.data[this.highValueIndex]}return this.errorValue},l);function l(A,e,t,r,B,n){this.initialValue=A,this.errorValue=e,this.highStart=t,this.highValueIndex=r,this.index=B,this.data=n}for(var C="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u="undefined"==typeof Uint8Array?[]:new Uint8Array(256),F=0;F<C.length;F++)u[C.charCodeAt(F)]=F;function p(A,e,t,r){var B=r[t];if(Array.isArray(A)?-1!==A.indexOf(B):A===B)for(var n=t;n<=r.length;){if((o=r[++n])===e)return 1;if(o!==D)break}if(B===D)for(n=t;0<n;){var s=r[--n];if(Array.isArray(A)?-1!==A.indexOf(s):A===s)for(var o,i=t;i<=r.length;){if((o=r[++i])===e)return 1;if(o!==D)break}if(s!==D)break}}function E(A,e){for(var t=A;0<=t;){var r=e[t];if(r!==D)return r;t--}return 0}function I(t,A){var e=(B=function(A,r){void 0===r&&(r="strict");var B=[],n=[],sLine truncated
|
||||
Reference in new issue
Block a user