Commit 7bdc11dec1

7bdc11dec17903d518de056270a59e966e4c32bf

parent: f3e8d80307

Verified · cmc ci/build: success ci/lint: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 14:33 UTC

Translate the strings the Go builders and templates hardcoded

Comment headers, pagination labels, folder and content headings, the
text placeholder, search headings, profile section headings, post
statistics, the about blurb and the NSFW, proxy and upstream error
pages now come from the catalogues. Every page declares the resolved
language in its lang attribute; the index page takes the request
struct like the others. Twenty new keys in both catalogues, with a test
that keeps them in step.

Closes #24

Layout: unified · split

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",