Commit b99e4eb459

b99e4eb4597a51c477380c0bf32a0e3e98213996

parent: ee25e46446

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 01:10 UTC

Add a multilingual interface

Every user-visible string in the templates was English written inline, so the
interface could not be anything else. They are now 45 keys resolved through
catalogues in static/lang, with en.json as the reference.

Language is chosen from Accept-Language, a header the browser already sends on
every request — so an instance learns nothing it did not already have, and there
is no cookie, query string or switcher to remember a choice with. An instance
can pin one with "language" in its config; an unknown code falls back to
English rather than refusing to start, because being up in the wrong language
beats being down.

A catalogue may be partial. T falls back key by key to English, then to the key
itself, so a translation that has not caught up shows English for the strings it
lacks rather than blanks — and a key nobody defined is visible in the page
instead of silently empty.

es.json is a complete Spanish catalogue and wants a native speaker's eye before
anyone leans on it; the mechanism does not depend on it being perfect.

Verified against a running instance: Accept-Language switches the page, a
regional tag falls back to its base, a pinned language ignores the header, and a
partial catalogue mixes translated and English strings as intended.

Layout: unified · split

SETUP.txt +14
@@ -63,3 +63,17 @@ server {
6363 `light` pins one for everybody. An unrecognised value stops startup rather
6464 than quietly falling back.
6565
66* `language` — Interface language. `auto` (default) reads the browser's own
67 `Accept-Language` header, which it sends on every request anyway, so nothing
68 extra is stored or asked for. A language code (`en`, `es`) pins one for
69 everybody. An unknown code falls back to English rather than refusing to
70 start, since a missing catalogue is a worse reason to be down than to be in
71 the wrong language.
72
73 Catalogues live in `static/lang/*.json`, keyed by the strings the templates
74 ask for. `en.json` is the reference and is always complete; a catalogue that
75 is missing a key shows the English for that one string, so a partial
76 translation is useful immediately. To add a language, copy `en.json`,translate it,
77 and name it after the code.
78
79
TODO.txt +1 −1
@@ -24,4 +24,4 @@
2424- [x] Implement an API
2525- [x] Implement themes
2626- [ ] Switch to arenas in the cache
27- [ ] Implement a multilingual interface
27- [x] Implement a multilingual interface
app/config.go +6 −4
@@ -36,6 +36,7 @@ type config struct {
3636 Nsfw bool `json:"nsfw"`
3737 HideAI bool `json:"hide-ai"`
3838 Theme string `json:"theme"`
39 Language string `json:"language"`
3940 UserAgent string `json:"user-agent"`
4041 DownloadProxy string `json:"download-proxy"`
4142 StaticPath string `json:"static-path"`
@@ -44,10 +45,11 @@ type config struct {
4445// CFG is the running instance's configuration, holding the defaults below until
4546// ExecuteConfig overwrites them from the config file.
4647var CFG = config{
47 cfg: "config.json",
48 Listen: "127.0.0.1:3003",
49 Theme: "auto",
50 URI: "/",
48 cfg: "config.json",
49 Listen: "127.0.0.1:3003",
50 Theme: "auto",
51 Language: "auto",
52 URI: "/",
5153 Cache: cacheConfig{
5254 Enabled: false,
5355 Path: "cache",
app/i18n.go added +110
@@ -0,0 +1,110 @@
1package app
2
3import (
4 "encoding/json"
5 "io"
6 "skunkyart/static"
7 "sort"
8 "strings"
9 "sync"
10)
11
12// DefaultLang is the catalogue every other one falls back to, key by key. It is
13// also the only catalogue guaranteed complete: a translation that has not caught
14// up shows English for the strings it is missing rather than a blank or a key.
15const DefaultLang = "en"
16
17var (
18 catalogues = map[string]map[string]string{}
19 langOnce sync.Once
20)
21
22// LoadLanguages reads static/lang/*.json into memory. Called once, from the same
23// startup path that copies the templates.
24//
25// A malformed or missing catalogue is not fatal: the interface falls back to
26// English, which is a worse experience than a correct translation but a better
27// one than refusing to start.
28func LoadLanguages() {
29 langOnce.Do(func() {
30 for _, name := range static.LanguageFiles() {
31 f, err := static.Templates.Open("lang/" + name)
32 if err != nil {
33 continue
34 }
35 body, err := io.ReadAll(f)
36 _ = f.Close()
37 if err != nil {
38 continue
39 }
40 var c map[string]string
41 if json.Unmarshal(body, &c) != nil {
42 continue
43 }
44 catalogues[strings.TrimSuffix(name, ".json")] = c
45 }
46 })
47}
48
49// Languages lists the catalogues that loaded, sorted, for the About page.
50func Languages() []string {
51 out := make([]string, 0, len(catalogues))
52 for k := range catalogues {
53 out = append(out, k)
54 }
55 sort.Strings(out)
56 return out
57}
58
59// T returns the string for key in lang, falling back to English and finally to
60// the key itself. Returning the key rather than "" makes a missing translation
61// visible in the page instead of silently blank.
62func T(lang, key string) string {
63 if c, ok := catalogues[lang]; ok {
64 if s, ok := c[key]; ok && s != "" {
65 return s
66 }
67 }
68 if c, ok := catalogues[DefaultLang]; ok {
69 if s, ok := c[key]; ok {
70 return s
71 }
72 }
73 return key
74}
75
76// ResolveLang picks the catalogue for a request.
77//
78// When the instance pins a language, that wins. Otherwise the browser's own
79// Accept-Language header decides — a header it already sends on every request,
80// so reading it adds nothing to what the instance could fingerprint, and needs
81// no cookie or query string to remember.
82func ResolveLang(acceptLanguage string) string {
83 if CFG.Language != "auto" {
84 if _, ok := catalogues[CFG.Language]; ok {
85 return CFG.Language
86 }
87 return DefaultLang
88 }
89
90 for _, part := range strings.Split(acceptLanguage, ",") {
91 tag := strings.TrimSpace(part)
92 if i := strings.Index(tag, ";"); i >= 0 {
93 tag = tag[:i]
94 }
95 if tag == "" {
96 continue
97 }
98 tag = strings.ToLower(tag)
99 if _, ok := catalogues[tag]; ok {
100 return tag
101 }
102 // en-GB and en-US both mean the en catalogue when there is no regional one.
103 if i := strings.Index(tag, "-"); i > 0 {
104 if _, ok := catalogues[tag[:i]]; ok {
105 return tag[:i]
106 }
107 }
108 }
109 return DefaultLang
110}
app/i18n_test.go added +118
@@ -0,0 +1,118 @@
1package app
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestEnglishCatalogueCoversEveryKeyTheTemplatesUse is the guard against a
12// template asking for a key nobody defined: T falls back to the key itself, so
13// the failure renders as "nav.home" on the page rather than crashing.
14func TestEnglishCatalogueCoversEveryKeyTheTemplatesUse(t *testing.T) {
15 body, err := os.ReadFile(filepath.Join("..", "static", "lang", "en.json"))
16 if err != nil {
17 t.Fatalf("read en.json: %v", err)
18 }
19 var cat map[string]string
20 if err := json.Unmarshal(body, &cat); err != nil {
21 t.Fatalf("en.json is not valid JSON: %v", err)
22 }
23
24 files, err := filepath.Glob(filepath.Join("..", "static", "html", "*.htm"))
25 if err != nil || len(files) == 0 {
26 t.Fatalf("no templates found: %v", err)
27 }
28
29 for _, f := range files {
30 src, err := os.ReadFile(f)
31 if err != nil {
32 t.Fatalf("read %s: %v", f, err)
33 }
34 for _, key := range templateKeys(string(src)) {
35 if _, ok := cat[key]; !ok {
36 t.Errorf("%s uses %q, which en.json does not define", filepath.Base(f), key)
37 }
38 }
39 }
40}
41
42// templateKeys pulls the key out of every {{T "..."}} action in src.
43func templateKeys(src string) []string {
44 var out []string
45 for rest := src; ; {
46 i := strings.Index(rest, `{{T "`)
47 if i < 0 {
48 return out
49 }
50 rest = rest[i+len(`{{T "`):]
51 j := strings.Index(rest, `"`)
52 if j < 0 {
53 return out
54 }
55 out = append(out, rest[:j])
56 rest = rest[j:]
57 }
58}
59
60// TestTFallsBackRatherThanBlanking: a catalogue that has not caught up must show
61// English, and an unknown key must show itself, because a blank label in the UI
62// gives nobody anything to search for.
63func TestTFallsBackRatherThanBlanking(t *testing.T) {
64 saved := catalogues
65 defer func() { catalogues = saved }()
66
67 catalogues = map[string]map[string]string{
68 "en": {"nav.home": "HOME", "nav.about": "About"},
69 "xx": {"nav.home": "INICIO"},
70 }
71
72 if got := T("xx", "nav.home"); got != "INICIO" {
73 t.Errorf("translated key = %q, want INICIO", got)
74 }
75 if got := T("xx", "nav.about"); got != "About" {
76 t.Errorf("untranslated key = %q, want the English fallback", got)
77 }
78 if got := T("xx", "nav.missing"); got != "nav.missing" {
79 t.Errorf("unknown key = %q, want the key itself", got)
80 }
81 if got := T("zz", "nav.home"); got != "HOME" {
82 t.Errorf("unknown language = %q, want the English fallback", got)
83 }
84}
85
86// TestResolveLangReadsAcceptLanguage covers the header parsing: quality values,
87// regional tags falling back to their base, and an instance that has pinned one.
88func TestResolveLangReadsAcceptLanguage(t *testing.T) {
89 saved, savedCfg := catalogues, CFG.Language
90 defer func() { catalogues, CFG.Language = saved, savedCfg }()
91
92 catalogues = map[string]map[string]string{"en": {}, "xx": {}}
93 CFG.Language = "auto"
94
95 for _, tc := range []struct{ header, want string }{
96 {"xx", "xx"},
97 {"xx-XX,xx;q=0.9", "xx"},
98 {"fr-FR,fr;q=0.9,en;q=0.8", "en"},
99 {"", "en"},
100 {"zz", "en"},
101 } {
102 if got := ResolveLang(tc.header); got != tc.want {
103 t.Errorf("ResolveLang(%q) = %q, want %q", tc.header, got, tc.want)
104 }
105 }
106
107 // A pinned language ignores the header entirely.
108 CFG.Language = "xx"
109 if got := ResolveLang("fr-FR"); got != "xx" {
110 t.Errorf("pinned language = %q, want xx", got)
111 }
112
113 // Pinned to something that did not load: English rather than nothing.
114 CFG.Language = "nope"
115 if got := ResolveLang("xx"); got != DefaultLang {
116 t.Errorf("pinned-but-missing = %q, want %q", got, DefaultLang)
117 }
118}
app/router.go +1
@@ -86,6 +86,7 @@ func Router() {
8686 skunky.API.main = &skunky
8787 skunky.Writer = w
8888 skunky.BasePath = CFG.URI
89 skunky.Lang = ResolveLang(r.Header.Get("Accept-Language"))
8990 skunky.QueryRaw = arg("q")
9091 skunky.Query = url.QueryEscape(skunky.QueryRaw)
9192 skunky.Page = p
app/util.go +9
@@ -86,6 +86,10 @@ type skunkyart struct {
8686 Type rune
8787 Atom bool
8888
89 // Lang is the catalogue chosen for this request, resolved once in the
90 // handler so every template and helper agrees on one answer.
91 Lang string
92
8993 // Host is the scheme and host this request arrived on, e.g.
9094 // "https://art.example.com". It is per-request rather than global because
9195 // concurrent requests can arrive on different hosts and ports.
@@ -144,6 +148,11 @@ type skunkyart struct {
144148func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
145149 var buf strings.Builder
146150 tmp := template.New(file)
151 // T is bound to this request's language, so templates ask for a key and
152 // never have to know which catalogue answered.
153 tmp = tmp.Funcs(template.FuncMap{
154 "T": func(key string) string { return T(s.Lang, key) },
155 })
147156 tmp, err := tmp.ParseFS(static.Templates, dir+"/*")
148157 if err != nil {
149158 s.Writer.WriteHeader(500)
config.example.json +2 −1
@@ -15,5 +15,6 @@
1515 "proxy": true,
1616 "nsfw": false,
1717 "hide-ai": false,
18 "theme": "auto"
18 "theme": "auto",
19 "language": "auto"
1920}
main.go +4
@@ -25,6 +25,10 @@ func main() {
2525 app.ExecuteConfig()
2626 static.CopyTemplatesToMemory()
2727
28 // After the copy, not before: the catalogues are assets, and ExecuteConfig
29 // runs while static/ is still unread.
30 app.LoadLanguages()
31
2832 // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods
2933 // can't exhaust the process or get our egress IP banned by CloudFront/WAF.
3034 app.InstallDAThrottle()
static/html/about.htm +16 −16
@@ -6,46 +6,46 @@
66 <p>
77 SkunkyArt is an alternative frontend for deviantart.com, written in Go.
88 </p>
9 <h3><a href="https://github.com/krazywarez/skunky-art/issues" target="_blank">Report an issue</a></h3>
10 <b>Instance settings:</b>
9 <h3><a href="https://github.com/krazywarez/skunky-art/issues" target="_blank">{{T "about.report"}}</a></h3>
10 <b>{{T "about.settings"}}</b>
1111 <ul>
12 <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li>
13 <li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li>
14 <li><b>Hide AI</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}YES{{else}}NO{{end}}</span></li>
15 <li><b>Theme</b>: {{.Templates.About.Theme}}</li>
12 <li><b>{{T "about.nsfw"}}</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li>
13 <li><b>{{T "about.proxy"}}</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li>
14 <li><b>{{T "about.hideai"}}</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}YES{{else}}NO{{end}}</span></li>
15 <li><b>{{T "about.theme"}}</b>: {{.Templates.About.Theme}}</li>
1616 </ul>
1717 <details>
18 <summary><b>Instances:</b></summary>
18 <summary><b>{{T "about.instances"}}</b></summary>
1919 <ul>
2020 {{range .Templates.About.Instances}}
2121 <li><u><b>{{.Title}}</b></u>:
2222 <ul>
23 <li><b>Country</b>: {{.Country}}</li>
24 <li><b>URLs</b>: </li>
23 <li><b>{{T "about.country"}}</b>: {{.Country}}</li>
24 <li><b>{{T "about.urls"}}</b>: </li>
2525 <ul>
2626 {{if ne .Urls.I2P ""}}
27 <li><b>I2P</b>: <a href="{{.Urls.I2P}}">Yes</a></li>
27 <li><b>I2P</b>: <a href="{{.Urls.I2P}}">{{T "about.yes"}}</a></li>
2828 {{end}}
2929 {{if ne .Urls.Ygg ""}}
30 <li><b>Ygg</b>: <a href="{{.Urls.Ygg}}">Yes</a></li>
30 <li><b>Ygg</b>: <a href="{{.Urls.Ygg}}">{{T "about.yes"}}</a></li>
3131 {{end}}
3232 {{if ne .Urls.Tor ""}}
33 <li><b>Tor</b>: <a href="{{.Urls.Tor}}">Yes</a></li>
33 <li><b>Tor</b>: <a href="{{.Urls.Tor}}">{{T "about.yes"}}</a></li>
3434 {{end}}
3535 {{if ne .Urls.Clearnet ""}}
3636 <li><b>Clearnet</b>: <a href="{{.Urls.Clearnet}}">{{.Urls.Clearnet}}</a></li>
3737 {{end}}
3838 </ul>
39 <li><b>Settings</b>: </li>
39 <li><b>{{T "common.settings"}}</b>: </li>
4040 <ul>
41 <li><b>NSFW</b>: <span class="about-{{.Settings.Nsfw}}">{{if .Settings.Nsfw}}YES{{else}}NO{{end}}</span></li>
42 <li><b>Proxyfing</b>: <span class="about-{{.Settings.Proxy}}">{{if .Settings.Proxy}}YES{{else}}NO{{end}}</span></li>
41 <li><b>{{T "about.nsfw"}}</b>: <span class="about-{{.Settings.Nsfw}}">{{if .Settings.Nsfw}}YES{{else}}NO{{end}}</span></li>
42 <li><b>{{T "about.proxy"}}</b>: <span class="about-{{.Settings.Proxy}}">{{if .Settings.Proxy}}YES{{else}}NO{{end}}</span></li>
4343 </ul>
4444 </ul>
4545 </li>
4646 {{end}}
4747 </ul>
4848 </details>
49 <p>Copyright <a href="https://git.macaw.me/skunky/SkunkyArt" target="_blank">lost+skunk</a> and <a href="https://github.com/krazywarez" target="_blank">zerolabs</a>, X11. <a href="https://github.com/krazywarez/skunky-art/releases/tag/v{{.Version}}" target="_blank">SkunkyArt v{{.Version}}</a></p>
49 <p>{{T "about.copyright"}}<a href="https://git.macaw.me/skunky/SkunkyArt" target="_blank">lost+skunk</a> and <a href="https://github.com/krazywarez" target="_blank">zerolabs</a>, X11. <a href="https://github.com/krazywarez/skunky-art/releases/tag/v{{.Version}}" target="_blank">SkunkyArt v{{.Version}}</a></p>
5050 </main>
5151</html>
static/html/deviantion.htm +8 −8
@@ -6,33 +6,33 @@
66 <figure>
77 <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px" alt="{{.Templates.Deviation.Post.Deviation.Author.Username}} avatar">
88 <span><strong><a href="{{.BasePath}}group_user?type=about&q={{.Templates.Deviation.Post.Deviation.Author.Username}}">{{.Templates.Deviation.Post.Deviation.Author.Username}}</a></strong> — {{if (.Templates.Deviation.Post.Deviation.DD)}}
9 <span class="dd" title="Daily Deviation!"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span>
9 <span class="dd" title="{{T "deviation.daily"}}"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span>
1010 {{else}}{{.Templates.Deviation.Post.Deviation.Title}}{{end}}
11 {{if (ne .Templates.Deviation.Post.Deviation.License "none")}}<mark title="License">{{.Templates.Deviation.Post.Deviation.License}}</mark>{{end}} {{if (.Templates.Deviation.Post.Deviation.AI)}}[🤖]{{end}}
12 {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">NSFW</span>]{{end}}
11 {{if (ne .Templates.Deviation.Post.Deviation.License "none")}}<mark title="{{T "deviation.license"}}">{{.Templates.Deviation.Post.Deviation.License}}</mark>{{end}} {{if (.Templates.Deviation.Post.Deviation.AI)}}[🤖]{{end}}
12 {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">{{T "deviation.nsfw"}}</span>]{{end}}
1313 </span>
1414 <br>
1515 {{if (ne .Templates.Deviation.Post.IMG "")}}
16 <a href="{{.Templates.Deviation.Post.IMG}}" title="open/download image"><img src="{{.Templates.Deviation.Post.IMG}}" width="50%" alt="{{.Templates.Deviation.Post.Deviation.Title}}"></a>
16 <a href="{{.Templates.Deviation.Post.IMG}}" title="{{T "deviation.open"}}"><img src="{{.Templates.Deviation.Post.IMG}}" width="50%" alt="{{.Templates.Deviation.Post.Deviation.Title}}"></a>
1717 <br>
1818 {{end}}
1919 {{if (ne .Templates.Deviation.Tags "")}}
2020 {{.Templates.Deviation.Tags}}<br>
2121 {{end}}
22 <span>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>
23 <br><a target="_blank" href="https://www.deviantart.com/{{.Templates.Deviation.Post.Deviation.Author.Username}}/art/art-{{.Templates.Deviation.Post.Deviation.ID}}">Redirect to original</a>
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>
23 <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.Post.Description "")}}
2626 <figcaption>
2727 <details>
28 <summary>Description</summary>
28 <summary>{{T "deviation.description"}}</summary>
2929 {{.Templates.Deviation.Post.Description}}
3030 </details>
3131 </figcaption>
3232 {{end}}
3333 {{if ne .Templates.Deviation.Related ""}}
3434 <details>
35 <summary>Related content</summary>
35 <summary>{{T "deviation.related"}}</summary>
3636 {{.Templates.Deviation.Related}}
3737 </details>
3838 {{end}}
static/html/gruser.htm +23 −23
@@ -4,29 +4,29 @@
44 <main>
55 <header>
66 <h1>
7 <a href="{{.BasePath}}">HOME</a>
8 | <a href="{{.BasePath}}dd">DD</a>
7 <a href="{{.BasePath}}">{{T "nav.home"}}</a>
8 | <a href="{{.BasePath}}dd">{{T "nav.dd"}}</a>
99 {{if ne .Type 'f'}}
10 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">Gallery{{else}}about">About{{end}}</a>
11 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">Favourites</a>
10 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">{{T "nav.gallery"}}{{else}}about">{{T "nav.about"}}{{end}}</a>
11 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">{{T "nav.favourites"}}</a>
1212 {{else}}
13 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=about">About</a>
14 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery">Gallery</a>
15 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">Favourites</a>
13 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=about">{{T "nav.about"}}</a>
14 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery">{{T "nav.gallery"}}</a>
15 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">{{T "nav.favourites"}}</a>
1616 {{end}}
17 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">RSS</a>
17 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">{{T "nav.rss"}}</a>
1818 </h1>
1919 <form method="get" action="{{.BasePath}}search">
20 <input type="text" name="q" aria-label="Search query" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
20 <input type="text" name="q" aria-label="Search query" placeholder="{{T "search.placeholder"}}" autocomplete="off" autocapitalize="none" spellcheck="false">
2121 <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}">
2222 <select name="type" aria-label="Search type">
23 <option value="gallery">Gallery</option>
24 <option value="folders">Folders</option>
25 <option value="all">All</option>
26 <option value="tag">Tag</option>
27 <option value="r">Groups</option>
23 <option value="gallery">{{T "nav.gallery"}}</option>
24 <option value="folders">{{T "search.folders"}}</option>
25 <option value="all">{{T "search.all"}}</option>
26 <option value="tag">{{T "search.tag"}}</option>
27 <option value="r">{{T "search.groups"}}</option>
2828 </select>
29 <button type="submit">Search!</button>
29 <button type="submit">{{T "search.submit"}}</button>
3030 </form> <h1>| {{.Templates.GroupUser.GR.Owner.Username}}</h1>
3131 </header>
3232 {{if eq .Type 'a'}}
@@ -42,20 +42,20 @@
4242 {{if (eq .Templates.GroupUser.About.A.Gender "female")}}
4343 ♀️
4444 {{end}}
45 [<span title="UID">{{.Templates.GroupUser.GR.Gruser.ID}}</span>]
46 [<span title="Registration date">{{.Templates.GroupUser.CreationDate}}</span>]
45 [<span title="{{T "user.uid"}}">{{.Templates.GroupUser.GR.Gruser.ID}}</span>]
46 [<span title="{{T "user.registered"}}">{{.Templates.GroupUser.CreationDate}}</span>]
4747 {{if ne .Templates.GroupUser.GR.Extra.Tag ""}}
48 <i title="User's Tag">"{{.Templates.GroupUser.GR.Extra.Tag}}"</i>{{end}} {{if ne .Templates.GroupUser.About.A.Country ""}}
48 <i title="{{T "user.tag"}}">"{{.Templates.GroupUser.GR.Extra.Tag}}"</i>{{end}} {{if ne .Templates.GroupUser.About.A.Country ""}}
4949 (<b>{{.Templates.GroupUser.About.A.Country}}</b>)
5050 {{end}}
5151
5252 {{if .Templates.GroupUser.Group}}
53 <h3 id="stats"><a href="#stats">#</a> Statistics</h3>
54 <p>Watchers: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watchers}}</b>; Pageviews: <b>{{.Templates.GroupUser.GR.Extra.Stats.Pageviews}}</b></b>
53 <h3 id="stats"><a href="#stats">#</a> {{T "user.statistics"}}</h3>
54 <p>{{T "user.watchers"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watchers}}</b>; {{T "user.pageviews"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Pageviews}}</b></b>
5555 {{else}}
56 <h3 id="stats"><a href="#stats">#</a> Statistics</h3>
57 <p>Favourites: <b>{{.Templates.GroupUser.GR.Extra.Stats.Favourites}}</b>; Deviations: <b>{{.Templates.GroupUser.GR.Extra.Stats.Deviations}}</b>; Watchers: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watchers}}</b>
58 <p>Watching: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watching}}</b>; Pageviews: <b>{{.Templates.GroupUser.GR.Extra.Stats.Pageviews}}</b>; Comments Made: <b>{{.Templates.GroupUser.GR.Extra.Stats.CommentsMade}}</b>; Friends: <b>{{.Templates.GroupUser.GR.Extra.Stats.Friends}}</b></p>
56 <h3 id="stats"><a href="#stats">#</a> {{T "user.statistics"}}</h3>
57 <p>{{T "nav.favourites"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Favourites}}</b>; {{T "user.deviations"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Deviations}}</b>; {{T "user.watchers"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watchers}}</b>
58 <p>{{T "user.watching"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watching}}</b>; {{T "user.pageviews"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Pageviews}}</b>; {{T "user.comments"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.CommentsMade}}</b>; {{T "user.friends"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Friends}}</b></p>
5959 {{end}}
6060
6161 {{if ne .Templates.GroupUser.Admins ""}}
static/html/header.htm +6 −6
@@ -1,14 +1,14 @@
11{{define "header"}}
22<header>
3 <h1><a href="">HOME</a> | <a href="dd">DD</a> {{if eq .Endpoint "dd"}}| <a href="{{.Endpoint}}?atom=true">RSS</a>{{end}}</h1>
3 <h1><a href="">{{T "nav.home"}}</a> | <a href="dd">DD</a> {{if eq .Endpoint "dd"}}| <a href="{{.Endpoint}}?atom=true">{{T "nav.rss"}}</a>{{end}}</h1>
44 <form method="get" action="search">
5 <input type="text" name="q" aria-label="Search query" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false" value="{{.QueryRaw}}">
5 <input type="text" name="q" aria-label="Search query" placeholder="{{T "search.placeholder"}}" autocomplete="off" autocapitalize="none" spellcheck="false" value="{{.QueryRaw}}">
66 <select name="type" aria-label="Search type">
7 <option value="all">All</option>
8 <option value="tag">Tag</option>
9 <option value="r">Groups</option>
7 <option value="all">{{T "search.all"}}</option>
8 <option value="tag">{{T "search.tag"}}</option>
9 <option value="r">{{T "search.groups"}}</option>
1010 </select>
11 <button type="submit">Search!</button>
11 <button type="submit">{{T "search.submit"}}</button>
1212 </form>
1313</header>
1414{{end}}
\ No newline at end of file
static/html/index.htm +8 −8
@@ -70,20 +70,20 @@
7070 </style>
7171 </head>
7272 <main>
73 <img src="{{.}}favicon.ico" title="SkunkyArt logo" alt="SkunkyArt logo" draggable="false">
73 <img src="{{.}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false">
7474 <div>
75 <h1><a href="{{.}}dd">Daily Deviations</a> | <a href="{{.}}about">About</a></h1>
75 <h1><a href="{{.}}dd">{{T "nav.daily"}}</a> | <a href="{{.}}about">{{T "nav.about"}}</a></h1>
7676 <form method="get" action="{{.}}search">
77 <input type="text" name="q" aria-label="Search query" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
77 <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">
79 <option value="all">All</option>
80 <option value="tag">Tag</option>
81 <option value="r">Groups</option>
79 <option value="all">{{T "search.all"}}</option>
80 <option value="tag">{{T "search.tag"}}</option>
81 <option value="r">{{T "search.groups"}}</option>
8282 </select>
83 <button type="submit">Search!</button>
83 <button type="submit">{{T "search.submit"}}</button>
8484 </form>
8585 <h1 style="margin-top: 5%; font-size: 200%; text-align: center;">
86 <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="Source Code">SkunkyArt</a>
86 <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="{{T "index.source"}}">SkunkyArt</a>
8787 </h1>
8888 </div>
8989 </main>
static/lang/en.json added +47
@@ -0,0 +1,47 @@
1{
2 "nav.home": "HOME",
3 "nav.about": "About",
4 "nav.gallery": "Gallery",
5 "nav.favourites": "Favourites",
6 "nav.rss": "RSS",
7 "search.placeholder": "Search for ...",
8 "search.submit": "Search!",
9 "search.folders": "Folders",
10 "search.all": "All",
11 "search.tag": "Tag",
12 "search.groups": "Groups",
13 "deviation.daily": "Daily Deviation!",
14 "deviation.license": "License",
15 "deviation.nsfw": "NSFW",
16 "deviation.open": "open/download image",
17 "deviation.published": "Published: ",
18 "deviation.original": "Redirect to original",
19 "deviation.description": "Description",
20 "deviation.related": "Related content",
21 "about.report": "Report an issue",
22 "about.settings": "Instance settings:",
23 "about.nsfw": "NSFW",
24 "about.proxy": "Proxyfing",
25 "about.hideai": "Hide AI",
26 "about.theme": "Theme",
27 "about.instances": "Instances:",
28 "about.country": "Country",
29 "about.urls": "URLs",
30 "about.yes": "Yes",
31 "about.copyright": "Copyright ",
32 "common.settings": "Settings",
33 "nav.dd": "DD",
34 "user.uid": "UID",
35 "user.registered": "Registration date",
36 "user.tag": "User's Tag",
37 "user.statistics": "Statistics",
38 "user.watchers": "Watchers",
39 "user.watching": "Watching",
40 "user.pageviews": "Pageviews",
41 "user.deviations": "Deviations",
42 "user.comments": "Comments Made",
43 "user.friends": "Friends",
44 "nav.daily": "Daily Deviations",
45 "index.logo": "SkunkyArt logo",
46 "index.source": "Source Code"
47}
static/lang/es.json added +47
@@ -0,0 +1,47 @@
1{
2 "about.copyright": "Copyright ",
3 "about.country": "País",
4 "about.hideai": "Ocultar IA",
5 "about.instances": "Instancias:",
6 "about.nsfw": "NSFW",
7 "about.proxy": "Proxy",
8 "about.report": "Informar de un problema",
9 "about.settings": "Ajustes de la instancia:",
10 "about.theme": "Tema",
11 "about.urls": "URLs",
12 "about.yes": "Sí",
13 "common.settings": "Ajustes",
14 "deviation.daily": "¡Desviación del día!",
15 "deviation.description": "Descripción",
16 "deviation.license": "Licencia",
17 "deviation.nsfw": "NSFW",
18 "deviation.open": "abrir/descargar imagen",
19 "deviation.original": "Ir al original",
20 "deviation.published": "Publicado: ",
21 "deviation.related": "Contenido relacionado",
22 "index.logo": "Logo de SkunkyArt",
23 "index.source": "Código fuente",
24 "nav.about": "Acerca de",
25 "nav.daily": "Desviaciones del día",
26 "nav.dd": "DD",
27 "nav.favourites": "Favoritos",
28 "nav.gallery": "Galería",
29 "nav.home": "INICIO",
30 "nav.rss": "RSS",
31 "search.all": "Todo",
32 "search.folders": "Carpetas",
33 "search.groups": "Grupos",
34 "search.placeholder": "Buscar ...",
35 "search.submit": "¡Buscar!",
36 "search.tag": "Etiqueta",
37 "user.comments": "Comentarios",
38 "user.deviations": "Desviaciones",
39 "user.friends": "Amigos",
40 "user.pageviews": "Visitas",
41 "user.registered": "Fecha de registro",
42 "user.statistics": "Estadísticas",
43 "user.tag": "Etiqueta del usuario",
44 "user.uid": "UID",
45 "user.watchers": "Seguidores",
46 "user.watching": "Siguiendo"
47}
static/templates-noembed.go +12
@@ -169,3 +169,15 @@ func (f *File) Close() error {
169169 f.closed = true
170170 return nil
171171}
172
173// LanguageFiles lists the assets under lang/, which is how the i18n loader finds
174// catalogues without the FS needing directory listing.
175func LanguageFiles() []string {
176 var out []string
177 for _, x := range templates["lang"] {
178 if strings.HasSuffix(x.name, ".json") {
179 out = append(out, x.name)
180 }
181 }
182 return out
183}
static/templates.go +21 −1
@@ -2,7 +2,11 @@
22
33package static
44
5import "embed"
5import (
6 "embed"
7 "io/fs"
8 "strings"
9)
610
711// Templates is the asset filesystem compiled into the binary.
812//
@@ -20,3 +24,19 @@ var StaticPath string
2024func CopyTemplatesToMemory() {
2125 _ = StaticPath
2226}
27
28// LanguageFiles lists the assets under lang/, which is how the i18n loader finds
29// catalogues without the FS needing directory listing.
30func LanguageFiles() []string {
31 entries, err := fs.ReadDir(Templates, "lang")
32 if err != nil {
33 return nil
34 }
35 var out []string
36 for _, e := range entries {
37 if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
38 out = append(out, e.Name())
39 }
40 }
41 return out
42}