chore: migrate mail themes to shared framework and locked upstream inputs
This commit is contained in:
1 parent
5c0589f6f6
commit
fec3ace600
225 files changed
+4718
-15807
No files matched your search
+249
-270
@@ -1,313 +1,292 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
texttmpl "text/template"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/config"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
)
|
||||
|
||||
// RenderResult holds the rendered HTML for a single template in a single theme.
|
||||
type RenderResult struct {
|
||||
HTML string
|
||||
Size int64 // bytes of the rendered HTML
|
||||
Error string
|
||||
Skipped bool // true when the template file was not found
|
||||
}
|
||||
|
||||
// ThemeSummary collects statistics for one theme directory.
|
||||
type ThemeSummary struct {
|
||||
Name string
|
||||
TotalFiles int
|
||||
TotalSize int64 // total size of .tmpl source files
|
||||
RenderedSize int64 // total size of rendered HTML
|
||||
RenderedCount int
|
||||
MissingCount int
|
||||
ErrorCount int
|
||||
RenderDuration time.Duration
|
||||
Missing []string // template IDs that were not found
|
||||
Errors []string // template IDs that had render errors
|
||||
Results map[string]*RenderResult // per-template results
|
||||
}
|
||||
|
||||
// PreviewResult is the full output from a preview run.
|
||||
type PreviewResult struct {
|
||||
Rendered map[string]map[string]string `json:"rendered"`
|
||||
Registry map[string]map[string]string `json:"registry"`
|
||||
Params map[string]map[string]string `json:"params"`
|
||||
Summaries []ThemeSummary `json:"-"`
|
||||
Rendered map[string]map[string]map[string]string
|
||||
Registry map[string]map[string]string
|
||||
Params map[string]map[string]string
|
||||
Languages []string
|
||||
Themes []string
|
||||
Upstream string
|
||||
Commit string
|
||||
Logo []byte
|
||||
Fallback map[string][]string
|
||||
Warnings map[string]map[string][]string
|
||||
}
|
||||
|
||||
// DiscoverThemes scans a directory and returns all subdirectory names.
|
||||
func DiscoverThemes(themesDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(themesDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read themes directory %s: %w", themesDir, err)
|
||||
func DiscoverThemes(dir string) ([]string, error) { return builder.Discover(dir) }
|
||||
|
||||
// AlignConfig discovers all entrypoints from the snapshot. Metadata is a fixture
|
||||
// catalog, not a second list of templates. Uncovered new mail types fail closed.
|
||||
func AlignConfig(s *upstream.Snapshot, cfg *config.TemplatesConfig) error {
|
||||
found := map[string]bool{}
|
||||
for _, p := range s.Entrypoints() {
|
||||
id := upstream.TemplateID(p)
|
||||
if found[id] {
|
||||
return fmt.Errorf("duplicate entrypoint ID %s", id)
|
||||
}
|
||||
found[id] = true
|
||||
fixture, ok := cfg.Templates[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("new upstream mail type %s needs a preview fixture", p)
|
||||
}
|
||||
fixture.Path = strings.Split(p, "/")
|
||||
cfg.Templates[id] = fixture
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
for id := range cfg.Templates {
|
||||
if !found[id] {
|
||||
return fmt.Errorf("stale fixture %s is absent from upstream snapshot", id)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("no theme directories found in %s", themesDir)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// RenderAll renders all templates from the config for all discovered themes.
|
||||
// If themeFilter is non-empty, only themes in the filter set are rendered.
|
||||
func RenderAll(themesDir string, cfg *config.TemplatesConfig, themeFilter map[string]bool) *PreviewResult {
|
||||
themes, err := DiscoverThemes(themesDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[31m[E]\033[0m [Preview] Error discovering themes: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply theme filter
|
||||
if len(themeFilter) > 0 {
|
||||
filtered := themes[:0]
|
||||
for _, t := range themes {
|
||||
if themeFilter[t] {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
themes = filtered
|
||||
if len(themes) == 0 {
|
||||
fmt.Println("\033[33m[W]\033[0m [Preview] No themes matched the requested filter")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
result := &PreviewResult{
|
||||
Rendered: make(map[string]map[string]string),
|
||||
Registry: config.Registry(cfg),
|
||||
}
|
||||
// Build flattened params for the preview panel
|
||||
result.Params = make(map[string]map[string]string, len(cfg.Templates))
|
||||
for id, t := range cfg.Templates {
|
||||
result.Params[id] = config.FlattenParams(t.Params)
|
||||
}
|
||||
|
||||
for _, themeName := range themes {
|
||||
themeDir := filepath.Join(themesDir, themeName)
|
||||
summary := renderTheme(themeDir, themeName, cfg)
|
||||
result.Summaries = append(result.Summaries, summary)
|
||||
|
||||
if result.Rendered[themeName] == nil {
|
||||
result.Rendered[themeName] = make(map[string]string)
|
||||
}
|
||||
for tplID, rr := range summary.Results {
|
||||
if rr.Error != "" {
|
||||
result.Rendered[themeName][tplID] = fmt.Sprintf(
|
||||
"<p style='color:red'>Render error for %s: %s</p>", tplID, rr.Error)
|
||||
} else if rr.Skipped {
|
||||
result.Rendered[themeName][tplID] = fmt.Sprintf(
|
||||
"<p style='color:red'>Template not found: %s</p>", tplID)
|
||||
} else {
|
||||
result.Rendered[themeName][tplID] = rr.HTML
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func renderTheme(themeDir, themeName string, cfg *config.TemplatesConfig) ThemeSummary {
|
||||
summary := ThemeSummary{
|
||||
Name: themeName,
|
||||
Results: make(map[string]*RenderResult),
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for tplID, tplCfg := range cfg.Templates {
|
||||
tmplRelPath := tplCfg.PathStr()
|
||||
tmplAbsPath := filepath.Join(themeDir, tmplRelPath)
|
||||
|
||||
// Check source file existence
|
||||
info, err := os.Stat(tmplAbsPath)
|
||||
if os.IsNotExist(err) {
|
||||
summary.MissingCount++
|
||||
summary.Missing = append(summary.Missing, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Skipped: true}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
summary.TotalFiles++
|
||||
summary.TotalSize += info.Size()
|
||||
|
||||
// Read template content
|
||||
tmplContent, err := os.ReadFile(tmplAbsPath)
|
||||
if err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("read error: %v", err)}
|
||||
continue
|
||||
}
|
||||
|
||||
// Build data context
|
||||
data := make(map[string]any)
|
||||
for k, v := range tplCfg.Params {
|
||||
data[k] = v
|
||||
}
|
||||
data["locale"] = Locale{}
|
||||
|
||||
// Convert HTML-safe fields from string to template.HTML so Go's
|
||||
// html/template renders them raw instead of escaping. Matches Gitea's
|
||||
// behavior where RenderedNote / Body / RenderedContent are template.HTML.
|
||||
markSafeHTML(data)
|
||||
|
||||
// Parse and execute
|
||||
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("parse error: %v", err)}
|
||||
continue
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("render error: %v", err)}
|
||||
continue
|
||||
}
|
||||
|
||||
rendered := buf.String()
|
||||
|
||||
summary.RenderedCount++
|
||||
summary.RenderedSize += int64(len(rendered))
|
||||
summary.Results[tplID] = &RenderResult{
|
||||
HTML: rendered,
|
||||
Size: int64(len(rendered)),
|
||||
}
|
||||
}
|
||||
|
||||
summary.RenderDuration = time.Since(start)
|
||||
return summary
|
||||
}
|
||||
|
||||
// WriteRenderedJS writes the preview result to a JavaScript file loaded by preview/index.html.
|
||||
func WriteRenderedJS(result *PreviewResult, outputPath string) error {
|
||||
dir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("cannot create output directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create output file %s: %w", outputPath, err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
outFile.WriteString("// Auto-generated by tools/tools.go — do not edit\n")
|
||||
|
||||
// Write __RENDERED__
|
||||
renderedJSON, err := json.MarshalIndent(result.Rendered, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal rendered data: %w", err)
|
||||
}
|
||||
outFile.WriteString("window.__RENDERED__ = ")
|
||||
outFile.Write(renderedJSON)
|
||||
outFile.WriteString(";\n\n")
|
||||
|
||||
// Write __REGISTRY__
|
||||
registryJSON, err := json.MarshalIndent(result.Registry, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal registry data: %w", err)
|
||||
}
|
||||
outFile.WriteString("window.__REGISTRY__ = ")
|
||||
outFile.Write(registryJSON)
|
||||
outFile.WriteString(";\n\n")
|
||||
|
||||
// Write __PARAMS__
|
||||
paramsJSON, err := json.MarshalIndent(result.Params, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal params data: %w", err)
|
||||
}
|
||||
outFile.WriteString("window.__PARAMS__ = ")
|
||||
outFile.Write(paramsJSON)
|
||||
outFile.WriteString(";\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintDetailedSummary prints a human-readable summary of the preview results.
|
||||
func PrintDetailedSummary(result *PreviewResult, themesDir string, cfg *config.TemplatesConfig) {
|
||||
absDir, _ := filepath.Abs(themesDir)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] Found %d styles in '%s':\n",
|
||||
len(result.Summaries), filepath.Base(absDir))
|
||||
|
||||
for _, s := range result.Summaries {
|
||||
if s.MissingCount == 0 && s.ErrorCount == 0 {
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] %-16s all required templates OK (total %d .tmpl, %s, %v)\n",
|
||||
s.Name, s.TotalFiles,
|
||||
formatSize(s.RenderedSize),
|
||||
s.RenderDuration.Round(time.Millisecond))
|
||||
} else {
|
||||
fmt.Printf("\033[33m[W]\033[0m [Preview] %-16s only %d required templates rendered:\n",
|
||||
s.Name, s.RenderedCount)
|
||||
for _, tplID := range s.Missing {
|
||||
if t, ok := cfg.Templates[tplID]; ok {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found, expected at 'themes/%s/%s'\n",
|
||||
filepath.Base(t.PathStr()), s.Name, t.PathStr())
|
||||
} else {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found\n", tplID)
|
||||
// RenderAll builds installable overrides and renders every official language.
|
||||
func RenderAll(themesDir string, cfg *config.TemplatesConfig, filter map[string]bool) (*PreviewResult, error) {
|
||||
root := filepath.Dir(themesDir)
|
||||
s, err := upstream.Prepare(context.Background(), root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := AlignConfig(s, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packages, err := builder.BuildAll(themesDir, s, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.Write(filepath.Join(root, "build", "themes"), s, packages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Include framework-added labels/controls, not only stock mail keys.
|
||||
reference := *s
|
||||
reference.Templates = map[string][]byte{}
|
||||
for name, b := range s.Templates {
|
||||
reference.Templates[name] = b
|
||||
}
|
||||
for _, p := range packages {
|
||||
for name, b := range p.Files {
|
||||
reference.Templates[name] = b
|
||||
}
|
||||
}
|
||||
missing, err := upstream.MissingKeys(&reference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{}, Registry: config.Registry(cfg), Params: map[string]map[string]string{}, Languages: s.Languages(), Upstream: s.Lock.Tag, Fallback: missing, Warnings: map[string]map[string][]string{}}
|
||||
for id, t := range cfg.Templates {
|
||||
result.Params[id] = config.FlattenParams(t.Params)
|
||||
}
|
||||
for name := range packages {
|
||||
result.Themes = append(result.Themes, name)
|
||||
}
|
||||
sort.Strings(result.Themes)
|
||||
result.Commit = s.Lock.Commit
|
||||
result.Logo = s.Files["public/assets/img/favicon.png"]
|
||||
for _, lang := range result.Languages {
|
||||
result.Rendered[lang] = map[string]map[string]string{}
|
||||
result.Warnings[lang] = map[string][]string{}
|
||||
for _, name := range result.Themes {
|
||||
templates := builder.EffectiveTemplates(s, packages[name])
|
||||
result.Rendered[lang][name] = map[string]string{}
|
||||
for id, fixture := range cfg.Templates {
|
||||
mail, err := RenderMail(s, templates, strings.Join(fixture.Path, "/"), fixture.Params, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s/%s/%s: %w", lang, name, id, err)
|
||||
}
|
||||
}
|
||||
for _, tplID := range s.Errors {
|
||||
if rr, ok := s.Results[tplID]; ok && rr.Error != "" {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error: %s\n", tplID, rr.Error)
|
||||
} else {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error\n", tplID)
|
||||
result.Rendered[lang][name][id] = mail.HTML
|
||||
if len(mail.Warnings) > 0 {
|
||||
result.Warnings[lang][id] = mail.Warnings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type Mail struct {
|
||||
Subject string
|
||||
HTML string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
var subjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
|
||||
|
||||
// RenderMail parses subjects with text/template, bodies with html/template, and
|
||||
// registers shared partials under Gitea's mail/... names.
|
||||
func RenderMail(s *upstream.Snapshot, files map[string][]byte, entry string, params map[string]any, lang string) (Mail, error) {
|
||||
var result Mail
|
||||
catalog, ok := s.Locales[lang]
|
||||
if !ok {
|
||||
return result, fmt.Errorf("unknown preview language %s", lang)
|
||||
}
|
||||
data := CloneParams(params)
|
||||
var problems []error
|
||||
data["locale"] = Locale{Lang: lang, Catalog: catalog, English: s.Locales["en-US"], Problems: &problems, Warnings: &result.Warnings}
|
||||
markSafeHTML(data)
|
||||
root := template.New("").Funcs(TemplateFuncs).Option("missingkey=error")
|
||||
var entrySubject string
|
||||
names := make([]string, 0, len(files))
|
||||
for p := range files {
|
||||
names = append(names, p)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, p := range names {
|
||||
source := string(files[p])
|
||||
subject, body := splitMail(source)
|
||||
if p == entry {
|
||||
entrySubject = subject
|
||||
}
|
||||
if _, err := root.New(builder.TemplateName(p)).Parse(body); err != nil {
|
||||
return result, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
}
|
||||
if entrySubject != "" {
|
||||
// Gitea's subject environment is intentionally smaller than the body map.
|
||||
sub, err := texttmpl.New("subject").Funcs(texttmpl.FuncMap{"AppName": TemplateFuncs["AppName"], "AppDomain": TemplateFuncs["AppDomain"], "dict": Dict}).Option("missingkey=error").Parse(entrySubject)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := sub.Execute(&buf, data); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Subject = strings.Join(strings.Fields(buf.String()), " ")
|
||||
runes := []rune(result.Subject)
|
||||
if len(runes) > 256 {
|
||||
result.Subject = string(runes[:256])
|
||||
}
|
||||
}
|
||||
if result.Subject == "" {
|
||||
if subject, ok := data["FallbackSubject"].(string); ok {
|
||||
result.Subject = subject
|
||||
} else if subject, ok := data["Subject"].(string); ok {
|
||||
result.Subject = subject
|
||||
}
|
||||
}
|
||||
data["Subject"] = result.Subject
|
||||
var buf strings.Builder
|
||||
if err := root.ExecuteTemplate(&buf, builder.TemplateName(entry), data); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return result, problems[0]
|
||||
}
|
||||
result.HTML = buf.String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func splitMail(source string) (string, string) {
|
||||
loc := subjectSplit.FindStringIndex(source)
|
||||
if loc == nil {
|
||||
return "", source
|
||||
}
|
||||
return source[:loc[0]], source[loc[1]:]
|
||||
}
|
||||
|
||||
// CloneParams prevents safe-HTML conversions or branch fixtures from mutating
|
||||
// another language's input data.
|
||||
func CloneParams(params map[string]any) map[string]any {
|
||||
result := map[string]any{}
|
||||
for k, v := range params {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
result[k] = CloneParams(v)
|
||||
case []any:
|
||||
values := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
values[i] = CloneParams(m)
|
||||
} else {
|
||||
values[i] = item
|
||||
}
|
||||
}
|
||||
result[k] = values
|
||||
default:
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// The small manifest is always loaded first; each catalog's rendered data is a
|
||||
// classic script so static preview also works without fetch on file://.
|
||||
func WriteRenderedJS(result *PreviewResult, output string) error {
|
||||
dir := filepath.Dir(output)
|
||||
dataDir := filepath.Join(dir, "rendered")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
manifest := map[string]any{"schema": 1, "upstream": result.Upstream, "languages": result.Languages, "themes": result.Themes, "registry": result.Registry, "params": result.Params, "fallback": result.Fallback, "warnings": result.Warnings}
|
||||
manifest["logo"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(result.Logo)
|
||||
manifest["upstream_commit"] = result.Commit
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Payloads are written before the manifest, so a failed render never publishes
|
||||
// a manifest that advertises missing files.
|
||||
for _, lang := range result.Languages {
|
||||
payload, err := json.Marshal(result.Rendered[lang])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, _ := json.Marshal(lang)
|
||||
script := "window.__MAIL_PREVIEW_DATA__ = window.__MAIL_PREVIEW_DATA__ || {};\nwindow.__MAIL_PREVIEW_DATA__[" + string(key) + "] = " + string(payload) + ";\n"
|
||||
if err := os.WriteFile(filepath.Join(dataDir, lang+".js"), []byte(script), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
script := "// Generated by tools; do not edit.\nwindow.__MAIL_PREVIEW__ = " + string(data) + ";\n"
|
||||
return os.WriteFile(output, []byte(script), 0644)
|
||||
}
|
||||
|
||||
func PrintDetailedSummary(result *PreviewResult, _ string, _ *config.TemplatesConfig) {
|
||||
fmt.Printf("[PASS] %s: %d themes × %d mail types × %d languages\n", result.Upstream, len(result.Themes), len(result.Registry), len(result.Languages))
|
||||
for _, lang := range result.Languages {
|
||||
for _, warnings := range result.Warnings[lang] {
|
||||
for _, warning := range warnings {
|
||||
fmt.Println(warning)
|
||||
}
|
||||
}
|
||||
if keys := result.Fallback[lang]; len(keys) > 0 {
|
||||
fmt.Printf("[FALLBACK] %s -> en-US: %s\n", lang, strings.Join(keys, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markSafeHTML recursively converts known HTML-containing string fields to
|
||||
// template.HTML so Go's html/template renders them raw instead of escaping.
|
||||
// These fields mirror Gitea's data model where rendered markdown is typed as
|
||||
// template.HTML: Release.RenderedNote, Body (comment content), and
|
||||
// ReviewComments[].RenderedContent.
|
||||
func markSafeHTML(data map[string]any) {
|
||||
for k, v := range data {
|
||||
switch val := v.(type) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
if k == "RenderedNote" || k == "Body" || k == "RenderedContent" {
|
||||
data[k] = template.HTML(val)
|
||||
data[k] = template.HTML(v)
|
||||
}
|
||||
case map[string]any:
|
||||
markSafeHTML(val)
|
||||
markSafeHTML(v)
|
||||
case []any:
|
||||
for i, item := range val {
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
markSafeHTML(m)
|
||||
val[i] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%dB", bytes)
|
||||
}
|
||||
return fmt.Sprintf("%dKiB", bytes/unit)
|
||||
}
|
||||
Reference in new issue
Block a user