Translate the strings the Go builders and templates hardcoded !17

merged merged by cmc on 2026-09-11 15:13 UTC · krz/skunky-art:feat/i18n-coverage into main

16 files changed, +131 −44

Layout: unified · split

.mailmap deleted −4
@@ -1,4 +0,0 @@
1lost+skunk <skunky@ebloid.ru> <skunky@macaw.me>
2lost+skunk <skunky@ebloid.ru> <me@lost-skunk.cc>
3lost+skunk <skunky@ebloid.ru> <skunky@noreply.git.macaw.me>
4lost+skunk <skunky@ebloid.ru> skunky <skunky@ebloid.ru>
app/cache.go +1 −1
@@ -234,7 +234,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
234234 default:
235235 s.Writer.Header().Del("Cache-Control")
236236 s.Writer.WriteHeader(403)
237 response = []byte("Sorry, butt proxy on this instance are disabled.")
237 response = []byte(esc(T(s.Lang, "error.proxy")))
238238 }
239239
240240 _, _ = s.Writer.Write(response)
app/escape_test.go +3 −3
@@ -43,9 +43,9 @@ func TestEveryPageTemplateRenders(t *testing.T) {
4343 }
4444
4545 rec := httptest.NewRecorder()
46 uri := "/"
47 skunkyart{Writer: rec}.ExecuteTemplate("index.htm", "html", &uri)
48 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "</html>") {
46 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Lang: "en"}
47 s.ExecuteTemplate("index.htm", "html", &s)
48 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "</html>") || !strings.Contains(rec.Body.String(), `lang="en"`) {
4949 t.Errorf("index.htm: status %d, body %q", rec.Code, rec.Body.String())
5050 }
5151}
app/i18n_test.go +47
@@ -116,3 +116,50 @@ func TestResolveLangReadsAcceptLanguage(t *testing.T) {
116116 t.Errorf("pinned-but-missing = %q, want %q", got, DefaultLang)
117117 }
118118}
119
120// TestSpanishCatalogueMatchesEnglish keeps the two catalogues in step: a key
121// added to one and not the other falls back to English silently, which is
122// fine for a contributor's partial translation but not for the shipped one.
123func TestSpanishCatalogueMatchesEnglish(t *testing.T) {
124 read := func(name string) map[string]string {
125 body, err := os.ReadFile("../static/lang/" + name) //nolint:gosec // G304: the test reads the repository's own catalogues
126 if err != nil {
127 t.Fatal(err)
128 }
129 var c map[string]string
130 if err := json.Unmarshal(body, &c); err != nil {
131 t.Fatal(err)
132 }
133 return c
134 }
135 en, es := read("en.json"), read("es.json")
136 for k := range en {
137 if _, ok := es[k]; !ok {
138 t.Errorf("es.json lacks %q", k)
139 }
140 }
141 for k := range es {
142 if _, ok := en[k]; !ok {
143 t.Errorf("es.json has %q, which en.json does not", k)
144 }
145 }
146}
147
148// TestGoBuiltStringsAreInTheCatalogue lists every key the Go builders ask for,
149// so a typo in a T call shows up here rather than as the key on a page.
150func TestGoBuiltStringsAreInTheCatalogue(t *testing.T) {
151 src, err := os.ReadFile("../static/lang/en.json")
152 if err != nil {
153 t.Fatal(err)
154 }
155 var en map[string]string
156 if err := json.Unmarshal(src, &en); err != nil {
157 t.Fatal(err)
158 }
159 for _, key := range []string{"comments.failed", "comments.reply_to", "list.text", "nav.prev", "nav.next",
160 "gallery.folders", "gallery.content", "error.nsfw", "error.proxy", "error.upstream", "deviation.comments", "deviation.open"} {
161 if _, ok := en[key]; !ok {
162 t.Errorf("en.json lacks %q, which Go code asks for", key)
163 }
164 }
165}
app/parsers.go +9 −5
@@ -15,13 +15,15 @@ import (
1515// fetch errored.
1616func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string {
1717 if daError.RAW != nil {
18 return "Failed to fetch comments :("
18 return esc(T(s.Lang, "comments.failed"))
1919 }
2020
2121 var cmmts strings.Builder
2222 replied := make(map[int]string)
2323
24 cmmts.WriteString("<details><summary>Comments: <b>")
24 cmmts.WriteString("<details><summary>")
25 cmmts.WriteString(esc(T(s.Lang, "deviation.comments")))
26 cmmts.WriteString(": <b>")
2527 cmmts.WriteString(strconv.Itoa(c.Total))
2628 cmmts.WriteString("</b></summary>")
2729 for _, x := range c.Thread {
@@ -49,7 +51,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
4951 cmmts.WriteString("</b></a> ")
5052
5153 if x.Parent > 0 {
52 cmmts.WriteString(` In reply to <a href="`)
54 cmmts.WriteString(" ")
55 cmmts.WriteString(esc(T(s.Lang, "comments.reply_to")))
56 cmmts.WriteString(` <a href="`)
5357 cmmts.WriteString(esc(s._pth))
5458 cmmts.WriteString("#")
5559 cmmts.WriteString(strconv.Itoa(x.Parent))
@@ -152,13 +156,13 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
152156 } else {
153157 listContent.WriteString(`<div class="block">`)
154158 if fullview != "" && preview != "" {
155 listContent.WriteString(`<a title="open/download" href="`)
159 listContent.WriteString(`<a title="` + esc(T(s.Lang, "deviation.open")) + `" href="`)
156160 listContent.WriteString(fullview)
157161 listContent.WriteString(`"><img loading="lazy" src="`)
158162 listContent.WriteString(preview)
159163 listContent.WriteString(`" width="15%"></a>`)
160164 } else {
161 listContent.WriteString(`<h1>[ TEXT ]</h1>`)
165 listContent.WriteString("<h1>[ " + esc(T(s.Lang, "list.text")) + " ]</h1>")
162166 }
163167 listContent.WriteString(`<br><a href="`)
164168 listContent.WriteString(postURL)
app/router.go +1 −1
@@ -146,7 +146,7 @@ func Handler() http.HandlerFunc {
146146 switch skunky.Endpoint {
147147 // main
148148 case "":
149 skunky.ExecuteTemplate("index.htm", "html", &CFG.URI)
149 skunky.ExecuteTemplate("index.htm", "html", &skunky)
150150 case "about":
151151 skunky.Templates.About = About
152152 skunky.ExecuteTemplate("about.htm", "html", &skunky)
app/util.go +3 −3
@@ -233,7 +233,7 @@ func (s skunkyart) Error(dAerr devianter.Error) {
233233 var msg strings.Builder
234234 msg.WriteString(`<html><link rel="stylesheet" href="`)
235235 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
236 msg.WriteString(`" /><h3>DeviantArt error — '`)
236 msg.WriteString(`" /><h3>` + esc(T(s.Lang, "error.upstream")) + ` — '`)
237237 msg.WriteString(esc(reason))
238238 msg.WriteString("'</h3></html>")
239239
@@ -430,7 +430,7 @@ func (s skunkyart) NavBase(c DeviationList) string {
430430 p := s.Page
431431
432432 if p > 1 {
433 prevrev("<= Prev |", p-1, false)
433 prevrev("<= "+esc(T(s.Lang, "nav.prev"))+" |", p-1, false)
434434 } else {
435435 p = 1
436436 }
@@ -456,7 +456,7 @@ func (s skunkyart) NavBase(c DeviationList) string {
456456 }
457457
458458 if c.More {
459 prevrev("| Next =>", p+1, false)
459 prevrev("| "+esc(T(s.Lang, "nav.next"))+" =>", p+1, false)
460460 }
461461
462462 return list.String()
app/wrapper.go +3 −3
@@ -172,7 +172,7 @@ func (s skunkyart) GRUser() {
172172 for _, x := range content.Content.Gruser.Page.Modules {
173173 if len(x.ModuleData.Folders.Results) != 0 {
174174 var folders strings.Builder
175 folders.WriteString(`<h1 id="folders"><a href="#folder">#</a> Folders</h1><div class="folders"><br>`)
175 folders.WriteString(`<h1 id="folders"><a href="#folders">#</a> ` + esc(T(s.Lang, "gallery.folders")) + `</h1><div class="folders"><br>`)
176176 for _, x := range x.ModuleData.Folders.Results {
177177 if x.FolderId != -1 && x.Size != 0 {
178178 folders.WriteString(`<div class="block folder-item">`)
@@ -203,7 +203,7 @@ func (s skunkyart) GRUser() {
203203 folders.WriteString("</div>")
204204 }
205205 }
206 folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> Content</h1>`)
206 folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> ` + esc(T(s.Lang, "gallery.content")) + `</h1>`)
207207 group.Gallery.Folders = template.HTML(folders.String()) //nolint:gosec // G203: escaped above
208208 }
209209
@@ -248,7 +248,7 @@ func (s skunkyart) Deviation(author, postname string) {
248248 s.Writer.WriteHeader(403)
249249 wr(s.Writer, `<html><link rel="stylesheet" href="`+
250250 URLBuilder(s.Host, "stylesheet")+
251 `" /><h1>NSFW content are disabled on this instance.</h1></html>`)
251 `" /><h1>`+esc(T(s.Lang, "error.nsfw"))+`</h1></html>`)
252252 return
253253 }
254254
static/html/about.htm +2 −2
@@ -1,10 +1,10 @@
11<!DOCTYPE html>
2<html lang="en">
2<html lang="{{.Lang}}">
33 {{template "head" .}}
44 <main>
55 {{template "header" .}}
66 <p>
7 SkunkyArt is an alternative frontend for deviantart.com, written in Go.
7 {{T "about.blurb"}}
88 </p>
99 <h3><a href="https://github.com/krazywarez/skunky-art/issues" target="_blank">{{T "about.report"}}</a></h3>
1010 <b>{{T "about.settings"}}</b>
static/html/daily.htm +3 −3
@@ -1,13 +1,13 @@
11<!DOCTYPE html>
2<html lang="en">
2<html lang="{{.Lang}}">
33 {{template "head" . }}
44 <main>
55 {{template "header" . }}
66 {{if ne .Templates.DDStrips ""}}
7 <h2 id="strips"><a href="#strips">#</a> Strips</h2>
7 <h2 id="strips"><a href="#strips">#</a> {{T "daily.strips"}}</h2>
88 {{.Templates.DDStrips}}
99 {{end}}
10 <h2 id="content"><a href="#content">#</a> Content</h2>
10 <h2 id="content"><a href="#content">#</a> {{T "gallery.content"}}</h2>
1111 {{.Templates.SomeList}}
1212 </main>
1313</html>
\ No newline at end of file
static/html/deviantion.htm +2 −2
@@ -1,5 +1,5 @@
11<!DOCTYPE html>
2<html lang="en">
2<html lang="{{.Lang}}">
33 {{template "head" . }}
44 <main>
55 {{template "header" . }}
@@ -19,7 +19,7 @@
1919 {{if (ne .Templates.Deviation.Tags "")}}
2020 {{.Templates.Deviation.Tags}}<br>
2121 {{end}}
22 <span>{{T "deviation.published"}}<strong>{{.Templates.Deviation.StringTime}}</strong>; Views: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Views}}</strong>; Favourites: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Favourites}}</strong>; Downloads: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Downloads}}</strong>
22 <span>{{T "deviation.published"}}<strong>{{.Templates.Deviation.StringTime}}</strong>; {{T "deviation.views"}}: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Views}}</strong>; {{T "nav.favourites"}}: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Favourites}}</strong>; {{T "deviation.downloads"}}: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Downloads}}</strong>
2323 <br><a target="_blank" href="https://www.deviantart.com/{{.Templates.Deviation.Post.Deviation.Author.Username}}/art/art-{{.Templates.Deviation.Post.Deviation.ID}}">{{T "deviation.original"}}</a>
2424 </span>
2525 {{if (ne .Templates.Deviation.Description "")}}
static/html/gruser.htm +6 −6
@@ -1,5 +1,5 @@
11<!DOCTYPE html>
2<html lang="en">
2<html lang="{{.Lang}}">
33 {{template "head" . }}
44 <main>
55 <header>
@@ -59,29 +59,29 @@
5959 {{end}}
6060
6161 {{if ne .Templates.GroupUser.Admins ""}}
62 <h3 id="admins"><a href="#admins">#</a> Group admins</h3>
62 <h3 id="admins"><a href="#admins">#</a> {{T "user.admins"}}</h3>
6363 <div class="plates admins">
6464 {{.Templates.GroupUser.Admins}}
6565 </div>
6666 {{end}}
6767
6868 {{if ne .Templates.GroupUser.About.Interests ""}}
69 <h3 id="interests"><a href="#interests">#</a> Interests</h3>
69 <h3 id="interests"><a href="#interests">#</a> {{T "user.interests"}}</h3>
7070 {{.Templates.GroupUser.About.Interests}}
7171 {{end}}
7272
7373 {{if ne .Templates.GroupUser.About.Social ""}}
74 <h3 id="social"><a href="#social">#</a> Social Links</h3>
74 <h3 id="social"><a href="#social">#</a> {{T "user.social"}}</h3>
7575 {{.Templates.GroupUser.About.Social}}
7676 {{end}}
7777
7878 {{if ne .Templates.GroupUser.About.DescriptionFormatted ""}}
79 <h3 id="about"><a href="#about">#</a> About me</h3>
79 <h3 id="about"><a href="#about">#</a> {{T "user.about"}}</h3>
8080 {{.Templates.GroupUser.About.DescriptionFormatted}}
8181 {{end}}
8282 {{if ne .Templates.GroupUser.About.Comments ""}}
8383 <br>
84 <h3 id="comments"><a href="#comments">#</a> Comments</h3>
84 <h3 id="comments"><a href="#comments">#</a> {{T "deviation.comments"}}</h3>
8585 {{.Templates.GroupUser.About.Comments}}
8686 {{end}}
8787 {{else}}
static/html/index.htm +6 −6
@@ -1,9 +1,9 @@
11<!DOCTYPE html>
2<html lang="en">
2<html lang="{{.Lang}}">
33 <head>
44 <title>SkunkyArt</title>
5 <link rel="stylesheet" href="{{.}}stylesheet"/>
6 <link rel="icon" type="image/x-icon" href="{{.}}favicon.ico">
5 <link rel="stylesheet" href="{{.BasePath}}stylesheet"/>
6 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico">
77 <meta name="viewport" content="width=device-width, height=device-height, initial-scale=0.4"/>
88
99 <style>
@@ -70,10 +70,10 @@
7070 </style>
7171 </head>
7272 <main>
73 <img src="{{.}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false">
73 <img src="{{.BasePath}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false">
7474 <div>
75 <h1><a href="{{.}}dd">{{T "nav.daily"}}</a> | <a href="{{.}}about">{{T "nav.about"}}</a></h1>
76 <form method="get" action="{{.}}search">
75 <h1><a href="{{.BasePath}}dd">{{T "nav.daily"}}</a> | <a href="{{.BasePath}}about">{{T "nav.about"}}</a></h1>
76 <form method="get" action="{{.BasePath}}search">
7777 <input type="text" name="q" aria-label="Search query" placeholder="{{T "search.placeholder"}}" autocomplete="off" autocapitalize="none" spellcheck="false">
7878 <select name="type" aria-label="Search type">
7979 <option value="all">{{T "search.all"}}</option>
static/html/search.htm +3 −3
@@ -1,16 +1,16 @@
11<!DOCTYPE html>
2<html lang="en">
2<html lang="{{.Lang}}">
33 {{template "head" . }}
44 <main>
55 {{template "header" . }}
66
77 {{if ne .Templates.Search.List ""}}
88 {{if ne .Templates.Search.Content.Total 0}}
9 <h1>Results by request '{{.QueryRaw}}': {{.Templates.Search.Content.Total}}</h1>
9 <h1>{{T "search.results"}} '{{.QueryRaw}}': {{.Templates.Search.Content.Total}}</h1>
1010 {{end}}
1111 {{.Templates.Search.List}}
1212 {{else}}
13 <p>No results :(</p>
13 <p>{{T "search.none"}}</p>
1414 {{end}}
1515 </main>
1616</html>
\ No newline at end of file
static/lang/en.json +21 −1
@@ -43,5 +43,25 @@
4343 "user.friends": "Friends",
4444 "nav.daily": "Daily Deviations",
4545 "index.logo": "SkunkyArt logo",
46 "index.source": "Source Code"
46 "index.source": "Source Code",
47 "comments.failed": "Failed to fetch comments :(",
48 "comments.reply_to": "In reply to",
49 "list.text": "TEXT",
50 "nav.prev": "Prev",
51 "nav.next": "Next",
52 "gallery.folders": "Folders",
53 "gallery.content": "Content",
54 "daily.strips": "Strips",
55 "search.results": "Results for",
56 "search.none": "No results :(",
57 "user.admins": "Group admins",
58 "user.interests": "Interests",
59 "user.social": "Social links",
60 "user.about": "About me",
61 "deviation.views": "Views",
62 "deviation.downloads": "Downloads",
63 "about.blurb": "SkunkyArt is an alternative frontend for deviantart.com, written in Go.",
64 "error.nsfw": "NSFW content is disabled on this instance.",
65 "error.proxy": "Media proxying is disabled on this instance.",
66 "error.upstream": "DeviantArt error"
4767}
static/lang/es.json +21 −1
@@ -1,4 +1,5 @@
11{
2 "about.blurb": "SkunkyArt es una interfaz alternativa para deviantart.com, escrita en Go.",
23 "about.copyright": "Copyright ",
34 "about.country": "País",
45 "about.hideai": "Ocultar IA",
@@ -10,35 +11,54 @@
1011 "about.theme": "Tema",
1112 "about.urls": "URLs",
1213 "about.yes": "Sí",
14 "comments.failed": "No se pudieron cargar los comentarios :(",
15 "comments.reply_to": "En respuesta a",
1316 "common.settings": "Ajustes",
17 "daily.strips": "Tiras",
18 "deviation.comments": "Comentarios",
1419 "deviation.daily": "¡Desviación del día!",
1520 "deviation.description": "Descripción",
21 "deviation.downloads": "Descargas",
1622 "deviation.license": "Licencia",
1723 "deviation.nsfw": "NSFW",
1824 "deviation.open": "abrir/descargar imagen",
1925 "deviation.original": "Ir al original",
2026 "deviation.published": "Publicado: ",
2127 "deviation.related": "Contenido relacionado",
22 "deviation.comments": "Comentarios",
28 "deviation.views": "Vistas",
29 "error.nsfw": "El contenido NSFW está desactivado en esta instancia.",
30 "error.proxy": "El proxy de medios está desactivado en esta instancia.",
31 "error.upstream": "Error de DeviantArt",
32 "gallery.content": "Contenido",
33 "gallery.folders": "Carpetas",
2334 "index.logo": "Logo de SkunkyArt",
2435 "index.source": "Código fuente",
36 "list.text": "TEXTO",
2537 "nav.about": "Acerca de",
2638 "nav.daily": "Desviaciones del día",
2739 "nav.dd": "DD",
2840 "nav.favourites": "Favoritos",
2941 "nav.gallery": "Galería",
3042 "nav.home": "INICIO",
43 "nav.next": "Siguiente",
44 "nav.prev": "Anterior",
3145 "nav.rss": "RSS",
3246 "search.all": "Todo",
3347 "search.groups": "Grupos",
48 "search.none": "Sin resultados :(",
3449 "search.placeholder": "Buscar ...",
50 "search.results": "Resultados para",
3551 "search.submit": "¡Buscar!",
3652 "search.tag": "Etiqueta",
53 "user.about": "Sobre mí",
54 "user.admins": "Administradores del grupo",
3755 "user.comments": "Comentarios",
3856 "user.deviations": "Desviaciones",
3957 "user.friends": "Amigos",
58 "user.interests": "Intereses",
4059 "user.pageviews": "Visitas",
4160 "user.registered": "Fecha de registro",
61 "user.social": "Enlaces sociales",
4262 "user.statistics": "Estadísticas",
4363 "user.tag": "Etiqueta del usuario",
4464 "user.uid": "UID",