krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
b99e4eb4597a51c477380c0bf32a0e3e98213996
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T01:10:59Z
SETUP.txt | 14 ++++++ TODO.txt | 2 +- app/config.go | 10 ++-- app/i18n.go | 110 +++++++++++++++++++++++++++++++++++++++++ app/i18n_test.go | 118 ++++++++++++++++++++++++++++++++++++++++++++ app/router.go | 1 + app/util.go | 9 ++++ config.example.json | 3 +- main.go | 4 ++ static/html/about.htm | 32 ++++++------ static/html/deviantion.htm | 16 +++--- static/html/gruser.htm | 46 ++++++++--------- static/html/header.htm | 12 ++--- static/html/index.htm | 16 +++--- static/lang/en.json | 47 ++++++++++++++++++ static/lang/es.json | 47 ++++++++++++++++++ static/templates-noembed.go | 12 +++++ static/templates.go | 22 ++++++++- 18 files changed, 453 insertions(+), 68 deletions(-) @@ -63,3 +63,17 @@ server { `light` pins one for everybody. An unrecognised value stops startup rather than quietly falling back. +* `language` — Interface language. `auto` (default) reads the browser's own + `Accept-Language` header, which it sends on every request anyway, so nothing + extra is stored or asked for. A language code (`en`, `es`) pins one for + everybody. An unknown code falls back to English rather than refusing to + start, since a missing catalogue is a worse reason to be down than to be in + the wrong language. + + Catalogues live in `static/lang/*.json`, keyed by the strings the templates + ask for. `en.json` is the reference and is always complete; a catalogue that + is missing a key shows the English for that one string, so a partial + translation is useful immediately. To add a language, copy `en.json`,translate it, + and name it after the code. + + @@ -24,4 +24,4 @@ - [x] Implement an API - [x] Implement themes - [ ] Switch to arenas in the cache -- [ ] Implement a multilingual interface +- [x] Implement a multilingual interface @@ -36,6 +36,7 @@ type config struct { Nsfw bool `json:"nsfw"` HideAI bool `json:"hide-ai"` Theme string `json:"theme"` + Language string `json:"language"` UserAgent string `json:"user-agent"` DownloadProxy string `json:"download-proxy"` StaticPath string `json:"static-path"` @@ -44,10 +45,11 @@ type config struct { // CFG is the running instance's configuration, holding the defaults below until // ExecuteConfig overwrites them from the config file. var CFG = config{ - cfg: "config.json", - Listen: "127.0.0.1:3003", - Theme: "auto", - URI: "/", + cfg: "config.json", + Listen: "127.0.0.1:3003", + Theme: "auto", + Language: "auto", + URI: "/", Cache: cacheConfig{ Enabled: false, Path: "cache", new file mode 100644 @@ -0,0 +1,110 @@ +package app + +import ( + "encoding/json" + "io" + "skunkyart/static" + "sort" + "strings" + "sync" +) + +// DefaultLang is the catalogue every other one falls back to, key by key. It is +// also the only catalogue guaranteed complete: a translation that has not caught +// up shows English for the strings it is missing rather than a blank or a key. +const DefaultLang = "en" + +var ( + catalogues = map[string]map[string]string{} + langOnce sync.Once +) + +// LoadLanguages reads static/lang/*.json into memory. Called once, from the same +// startup path that copies the templates. +// +// A malformed or missing catalogue is not fatal: the interface falls back to +// English, which is a worse experience than a correct translation but a better +// one than refusing to start. +func LoadLanguages() { + langOnce.Do(func() { + for _, name := range static.LanguageFiles() { + f, err := static.Templates.Open("lang/" + name) + if err != nil { + continue + } + body, err := io.ReadAll(f) + _ = f.Close() + if err != nil { + continue + } + var c map[string]string + if json.Unmarshal(body, &c) != nil { + continue + } + catalogues[strings.TrimSuffix(name, ".json")] = c + } + }) +} + +// Languages lists the catalogues that loaded, sorted, for the About page. +func Languages() []string { + out := make([]string, 0, len(catalogues)) + for k := range catalogues { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// T returns the string for key in lang, falling back to English and finally to +// the key itself. Returning the key rather than "" makes a missing translation +// visible in the page instead of silently blank. +func T(lang, key string) string { + if c, ok := catalogues[lang]; ok { + if s, ok := c[key]; ok && s != "" { + return s + } + } + if c, ok := catalogues[DefaultLang]; ok { + if s, ok := c[key]; ok { + return s + } + } + return key +} + +// ResolveLang picks the catalogue for a request. +// +// When the instance pins a language, that wins. Otherwise the browser's own +// Accept-Language header decides — a header it already sends on every request, +// so reading it adds nothing to what the instance could fingerprint, and needs +// no cookie or query string to remember. +func ResolveLang(acceptLanguage string) string { + if CFG.Language != "auto" { + if _, ok := catalogues[CFG.Language]; ok { + return CFG.Language + } + return DefaultLang + } + + for _, part := range strings.Split(acceptLanguage, ",") { + tag := strings.TrimSpace(part) + if i := strings.Index(tag, ";"); i >= 0 { + tag = tag[:i] + } + if tag == "" { + continue + } + tag = strings.ToLower(tag) + if _, ok := catalogues[tag]; ok { + return tag + } + // en-GB and en-US both mean the en catalogue when there is no regional one. + if i := strings.Index(tag, "-"); i > 0 { + if _, ok := catalogues[tag[:i]]; ok { + return tag[:i] + } + } + } + return DefaultLang +} new file mode 100644 @@ -0,0 +1,118 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEnglishCatalogueCoversEveryKeyTheTemplatesUse is the guard against a +// template asking for a key nobody defined: T falls back to the key itself, so +// the failure renders as "nav.home" on the page rather than crashing. +func TestEnglishCatalogueCoversEveryKeyTheTemplatesUse(t *testing.T) { + body, err := os.ReadFile(filepath.Join("..", "static", "lang", "en.json")) + if err != nil { + t.Fatalf("read en.json: %v", err) + } + var cat map[string]string + if err := json.Unmarshal(body, &cat); err != nil { + t.Fatalf("en.json is not valid JSON: %v", err) + } + + files, err := filepath.Glob(filepath.Join("..", "static", "html", "*.htm")) + if err != nil || len(files) == 0 { + t.Fatalf("no templates found: %v", err) + } + + for _, f := range files { + src, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + for _, key := range templateKeys(string(src)) { + if _, ok := cat[key]; !ok { + t.Errorf("%s uses %q, which en.json does not define", filepath.Base(f), key) + } + } + } +} + +// templateKeys pulls the key out of every {{T "..."}} action in src. +func templateKeys(src string) []string { + var out []string + for rest := src; ; { + i := strings.Index(rest, `{{T "`) + if i < 0 { + return out + } + rest = rest[i+len(`{{T "`):] + j := strings.Index(rest, `"`) + if j < 0 { + return out + } + out = append(out, rest[:j]) + rest = rest[j:] + } +} + +// TestTFallsBackRatherThanBlanking: a catalogue that has not caught up must show +// English, and an unknown key must show itself, because a blank label in the UI +// gives nobody anything to search for. +func TestTFallsBackRatherThanBlanking(t *testing.T) { + saved := catalogues + defer func() { catalogues = saved }() + + catalogues = map[string]map[string]string{ + "en": {"nav.home": "HOME", "nav.about": "About"}, + "xx": {"nav.home": "INICIO"}, + } + + if got := T("xx", "nav.home"); got != "INICIO" { + t.Errorf("translated key = %q, want INICIO", got) + } + if got := T("xx", "nav.about"); got != "About" { + t.Errorf("untranslated key = %q, want the English fallback", got) + } + if got := T("xx", "nav.missing"); got != "nav.missing" { + t.Errorf("unknown key = %q, want the key itself", got) + } + if got := T("zz", "nav.home"); got != "HOME" { + t.Errorf("unknown language = %q, want the English fallback", got) + } +} + +// TestResolveLangReadsAcceptLanguage covers the header parsing: quality values, +// regional tags falling back to their base, and an instance that has pinned one. +func TestResolveLangReadsAcceptLanguage(t *testing.T) { + saved, savedCfg := catalogues, CFG.Language + defer func() { catalogues, CFG.Language = saved, savedCfg }() + + catalogues = map[string]map[string]string{"en": {}, "xx": {}} + CFG.Language = "auto" + + for _, tc := range []struct{ header, want string }{ + {"xx", "xx"}, + {"xx-XX,xx;q=0.9", "xx"}, + {"fr-FR,fr;q=0.9,en;q=0.8", "en"}, + {"", "en"}, + {"zz", "en"}, + } { + if got := ResolveLang(tc.header); got != tc.want { + t.Errorf("ResolveLang(%q) = %q, want %q", tc.header, got, tc.want) + } + } + + // A pinned language ignores the header entirely. + CFG.Language = "xx" + if got := ResolveLang("fr-FR"); got != "xx" { + t.Errorf("pinned language = %q, want xx", got) + } + + // Pinned to something that did not load: English rather than nothing. + CFG.Language = "nope" + if got := ResolveLang("xx"); got != DefaultLang { + t.Errorf("pinned-but-missing = %q, want %q", got, DefaultLang) + } +} @@ -86,6 +86,7 @@ func Router() { skunky.API.main = &skunky skunky.Writer = w skunky.BasePath = CFG.URI + skunky.Lang = ResolveLang(r.Header.Get("Accept-Language")) skunky.QueryRaw = arg("q") skunky.Query = url.QueryEscape(skunky.QueryRaw) skunky.Page = p @@ -86,6 +86,10 @@ type skunkyart struct { Type rune Atom bool + // Lang is the catalogue chosen for this request, resolved once in the + // handler so every template and helper agrees on one answer. + Lang string + // Host is the scheme and host this request arrived on, e.g. // "https://art.example.com". It is per-request rather than global because // concurrent requests can arrive on different hosts and ports. @@ -144,6 +148,11 @@ type skunkyart struct { func (s skunkyart) ExecuteTemplate(file, dir string, data any) { var buf strings.Builder tmp := template.New(file) + // T is bound to this request's language, so templates ask for a key and + // never have to know which catalogue answered. + tmp = tmp.Funcs(template.FuncMap{ + "T": func(key string) string { return T(s.Lang, key) }, + }) tmp, err := tmp.ParseFS(static.Templates, dir+"/*") if err != nil { s.Writer.WriteHeader(500) @@ -15,5 +15,6 @@ "proxy": true, "nsfw": false, "hide-ai": false, - "theme": "auto" + "theme": "auto", + "language": "auto" } @@ -25,6 +25,10 @@ func main() { app.ExecuteConfig() static.CopyTemplatesToMemory() + // After the copy, not before: the catalogues are assets, and ExecuteConfig + // runs while static/ is still unread. + app.LoadLanguages() + // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods // can't exhaust the process or get our egress IP banned by CloudFront/WAF. app.InstallDAThrottle() @@ -6,46 +6,46 @@ <p> SkunkyArt is an alternative frontend for deviantart.com, written in Go. </p> - <h3><a href="https://github.com/krazywarez/skunky-art/issues" target="_blank">Report an issue</a></h3> - <b>Instance settings:</b> + <h3><a href="https://github.com/krazywarez/skunky-art/issues" target="_blank">{{T "about.report"}}</a></h3> + <b>{{T "about.settings"}}</b> <ul> - <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li> - <li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li> - <li><b>Hide AI</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}YES{{else}}NO{{end}}</span></li> - <li><b>Theme</b>: {{.Templates.About.Theme}}</li> + <li><b>{{T "about.nsfw"}}</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li> + <li><b>{{T "about.proxy"}}</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li> + <li><b>{{T "about.hideai"}}</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}YES{{else}}NO{{end}}</span></li> + <li><b>{{T "about.theme"}}</b>: {{.Templates.About.Theme}}</li> </ul> <details> - <summary><b>Instances:</b></summary> + <summary><b>{{T "about.instances"}}</b></summary> <ul> {{range .Templates.About.Instances}} <li><u><b>{{.Title}}</b></u>: <ul> - <li><b>Country</b>: {{.Country}}</li> - <li><b>URLs</b>: </li> + <li><b>{{T "about.country"}}</b>: {{.Country}}</li> + <li><b>{{T "about.urls"}}</b>: </li> <ul> {{if ne .Urls.I2P ""}} - <li><b>I2P</b>: <a href="{{.Urls.I2P}}">Yes</a></li> + <li><b>I2P</b>: <a href="{{.Urls.I2P}}">{{T "about.yes"}}</a></li> {{end}} {{if ne .Urls.Ygg ""}} - <li><b>Ygg</b>: <a href="{{.Urls.Ygg}}">Yes</a></li> + <li><b>Ygg</b>: <a href="{{.Urls.Ygg}}">{{T "about.yes"}}</a></li> {{end}} {{if ne .Urls.Tor ""}} - <li><b>Tor</b>: <a href="{{.Urls.Tor}}">Yes</a></li> + <li><b>Tor</b>: <a href="{{.Urls.Tor}}">{{T "about.yes"}}</a></li> {{end}} {{if ne .Urls.Clearnet ""}} <li><b>Clearnet</b>: <a href="{{.Urls.Clearnet}}">{{.Urls.Clearnet}}</a></li> {{end}} </ul> - <li><b>Settings</b>: </li> + <li><b>{{T "common.settings"}}</b>: </li> <ul> - <li><b>NSFW</b>: <span class="about-{{.Settings.Nsfw}}">{{if .Settings.Nsfw}}YES{{else}}NO{{end}}</span></li> - <li><b>Proxyfing</b>: <span class="about-{{.Settings.Proxy}}">{{if .Settings.Proxy}}YES{{else}}NO{{end}}</span></li> + <li><b>{{T "about.nsfw"}}</b>: <span class="about-{{.Settings.Nsfw}}">{{if .Settings.Nsfw}}YES{{else}}NO{{end}}</span></li> + <li><b>{{T "about.proxy"}}</b>: <span class="about-{{.Settings.Proxy}}">{{if .Settings.Proxy}}YES{{else}}NO{{end}}</span></li> </ul> </ul> </li> {{end}} </ul> </details> - <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> + <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> </main> </html> @@ -6,33 +6,33 @@ <figure> <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px" alt="{{.Templates.Deviation.Post.Deviation.Author.Username}} avatar"> <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)}} - <span class="dd" title="Daily Deviation!"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span> + <span class="dd" title="{{T "deviation.daily"}}"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span> {{else}}{{.Templates.Deviation.Post.Deviation.Title}}{{end}} - {{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}} - {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">NSFW</span>]{{end}} + {{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}} + {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">{{T "deviation.nsfw"}}</span>]{{end}} </span> <br> {{if (ne .Templates.Deviation.Post.IMG "")}} - <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> + <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> <br> {{end}} {{if (ne .Templates.Deviation.Tags "")}} {{.Templates.Deviation.Tags}}<br> {{end}} - <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> - <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> + <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> + <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> </span> {{if (ne .Templates.Deviation.Post.Description "")}} <figcaption> <details> - <summary>Description</summary> + <summary>{{T "deviation.description"}}</summary> {{.Templates.Deviation.Post.Description}} </details> </figcaption> {{end}} {{if ne .Templates.Deviation.Related ""}} <details> - <summary>Related content</summary> + <summary>{{T "deviation.related"}}</summary> {{.Templates.Deviation.Related}} </details> {{end}} @@ -4,29 +4,29 @@ <main> <header> <h1> - <a href="{{.BasePath}}">HOME</a> - | <a href="{{.BasePath}}dd">DD</a> + <a href="{{.BasePath}}">{{T "nav.home"}}</a> + | <a href="{{.BasePath}}dd">{{T "nav.dd"}}</a> {{if ne .Type 'f'}} - | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">Gallery{{else}}about">About{{end}}</a> - | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">Favourites</a> + | <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> + | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">{{T "nav.favourites"}}</a> {{else}} - | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=about">About</a> - | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery">Gallery</a> - | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">Favourites</a> + | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=about">{{T "nav.about"}}</a> + | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery">{{T "nav.gallery"}}</a> + | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">{{T "nav.favourites"}}</a> {{end}} - | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">RSS</a> + | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">{{T "nav.rss"}}</a> </h1> <form method="get" action="{{.BasePath}}search"> - <input type="text" name="q" aria-label="Search query" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> + <input type="text" name="q" aria-label="Search query" placeholder="{{T "search.placeholder"}}" autocomplete="off" autocapitalize="none" spellcheck="false"> <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}"> <select name="type" aria-label="Search type"> - <option value="gallery">Gallery</option> - <option value="folders">Folders</option> - <option value="all">All</option> - <option value="tag">Tag</option> - <option value="r">Groups</option> + <option value="gallery">{{T "nav.gallery"}}</option> + <option value="folders">{{T "search.folders"}}</option> + <option value="all">{{T "search.all"}}</option> + <option value="tag">{{T "search.tag"}}</option> + <option value="r">{{T "search.groups"}}</option> </select> - <button type="submit">Search!</button> + <button type="submit">{{T "search.submit"}}</button> </form> <h1>| {{.Templates.GroupUser.GR.Owner.Username}}</h1> </header> {{if eq .Type 'a'}} @@ -42,20 +42,20 @@ {{if (eq .Templates.GroupUser.About.A.Gender "female")}} ♀️ {{end}} - [<span title="UID">{{.Templates.GroupUser.GR.Gruser.ID}}</span>] - [<span title="Registration date">{{.Templates.GroupUser.CreationDate}}</span>] + [<span title="{{T "user.uid"}}">{{.Templates.GroupUser.GR.Gruser.ID}}</span>] + [<span title="{{T "user.registered"}}">{{.Templates.GroupUser.CreationDate}}</span>] {{if ne .Templates.GroupUser.GR.Extra.Tag ""}} - <i title="User's Tag">"{{.Templates.GroupUser.GR.Extra.Tag}}"</i>{{end}} {{if ne .Templates.GroupUser.About.A.Country ""}} + <i title="{{T "user.tag"}}">"{{.Templates.GroupUser.GR.Extra.Tag}}"</i>{{end}} {{if ne .Templates.GroupUser.About.A.Country ""}} (<b>{{.Templates.GroupUser.About.A.Country}}</b>) {{end}} {{if .Templates.GroupUser.Group}} - <h3 id="stats"><a href="#stats">#</a> Statistics</h3> - <p>Watchers: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watchers}}</b>; Pageviews: <b>{{.Templates.GroupUser.GR.Extra.Stats.Pageviews}}</b></b> + <h3 id="stats"><a href="#stats">#</a> {{T "user.statistics"}}</h3> + <p>{{T "user.watchers"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watchers}}</b>; {{T "user.pageviews"}}: <b>{{.Templates.GroupUser.GR.Extra.Stats.Pageviews}}</b></b> {{else}} - <h3 id="stats"><a href="#stats">#</a> Statistics</h3> - <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> - <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> + <h3 id="stats"><a href="#stats">#</a> {{T "user.statistics"}}</h3> + <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> + <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> {{end}} {{if ne .Templates.GroupUser.Admins ""}} @@ -1,14 +1,14 @@ {{define "header"}} <header> - <h1><a href="">HOME</a> | <a href="dd">DD</a> {{if eq .Endpoint "dd"}}| <a href="{{.Endpoint}}?atom=true">RSS</a>{{end}}</h1> + <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> <form method="get" action="search"> - <input type="text" name="q" aria-label="Search query" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false" value="{{.QueryRaw}}"> + <input type="text" name="q" aria-label="Search query" placeholder="{{T "search.placeholder"}}" autocomplete="off" autocapitalize="none" spellcheck="false" value="{{.QueryRaw}}"> <select name="type" aria-label="Search type"> - <option value="all">All</option> - <option value="tag">Tag</option> - <option value="r">Groups</option> + <option value="all">{{T "search.all"}}</option> + <option value="tag">{{T "search.tag"}}</option> + <option value="r">{{T "search.groups"}}</option> </select> - <button type="submit">Search!</button> + <button type="submit">{{T "search.submit"}}</button> </form> </header> {{end}} \ No newline at end of file @@ -70,20 +70,20 @@ </style> </head> <main> - <img src="{{.}}favicon.ico" title="SkunkyArt logo" alt="SkunkyArt logo" draggable="false"> + <img src="{{.}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false"> <div> - <h1><a href="{{.}}dd">Daily Deviations</a> | <a href="{{.}}about">About</a></h1> + <h1><a href="{{.}}dd">{{T "nav.daily"}}</a> | <a href="{{.}}about">{{T "nav.about"}}</a></h1> <form method="get" action="{{.}}search"> - <input type="text" name="q" aria-label="Search query" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> + <input type="text" name="q" aria-label="Search query" placeholder="{{T "search.placeholder"}}" autocomplete="off" autocapitalize="none" spellcheck="false"> <select name="type" aria-label="Search type"> - <option value="all">All</option> - <option value="tag">Tag</option> - <option value="r">Groups</option> + <option value="all">{{T "search.all"}}</option> + <option value="tag">{{T "search.tag"}}</option> + <option value="r">{{T "search.groups"}}</option> </select> - <button type="submit">Search!</button> + <button type="submit">{{T "search.submit"}}</button> </form> <h1 style="margin-top: 5%; font-size: 200%; text-align: center;"> - <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="Source Code">SkunkyArt</a> + <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="{{T "index.source"}}">SkunkyArt</a> </h1> </div> </main> new file mode 100644 @@ -0,0 +1,47 @@ +{ + "nav.home": "HOME", + "nav.about": "About", + "nav.gallery": "Gallery", + "nav.favourites": "Favourites", + "nav.rss": "RSS", + "search.placeholder": "Search for ...", + "search.submit": "Search!", + "search.folders": "Folders", + "search.all": "All", + "search.tag": "Tag", + "search.groups": "Groups", + "deviation.daily": "Daily Deviation!", + "deviation.license": "License", + "deviation.nsfw": "NSFW", + "deviation.open": "open/download image", + "deviation.published": "Published: ", + "deviation.original": "Redirect to original", + "deviation.description": "Description", + "deviation.related": "Related content", + "about.report": "Report an issue", + "about.settings": "Instance settings:", + "about.nsfw": "NSFW", + "about.proxy": "Proxyfing", + "about.hideai": "Hide AI", + "about.theme": "Theme", + "about.instances": "Instances:", + "about.country": "Country", + "about.urls": "URLs", + "about.yes": "Yes", + "about.copyright": "Copyright ", + "common.settings": "Settings", + "nav.dd": "DD", + "user.uid": "UID", + "user.registered": "Registration date", + "user.tag": "User's Tag", + "user.statistics": "Statistics", + "user.watchers": "Watchers", + "user.watching": "Watching", + "user.pageviews": "Pageviews", + "user.deviations": "Deviations", + "user.comments": "Comments Made", + "user.friends": "Friends", + "nav.daily": "Daily Deviations", + "index.logo": "SkunkyArt logo", + "index.source": "Source Code" +} new file mode 100644 @@ -0,0 +1,47 @@ +{ + "about.copyright": "Copyright ", + "about.country": "País", + "about.hideai": "Ocultar IA", + "about.instances": "Instancias:", + "about.nsfw": "NSFW", + "about.proxy": "Proxy", + "about.report": "Informar de un problema", + "about.settings": "Ajustes de la instancia:", + "about.theme": "Tema", + "about.urls": "URLs", + "about.yes": "Sí", + "common.settings": "Ajustes", + "deviation.daily": "¡Desviación del día!", + "deviation.description": "Descripción", + "deviation.license": "Licencia", + "deviation.nsfw": "NSFW", + "deviation.open": "abrir/descargar imagen", + "deviation.original": "Ir al original", + "deviation.published": "Publicado: ", + "deviation.related": "Contenido relacionado", + "index.logo": "Logo de SkunkyArt", + "index.source": "Código fuente", + "nav.about": "Acerca de", + "nav.daily": "Desviaciones del día", + "nav.dd": "DD", + "nav.favourites": "Favoritos", + "nav.gallery": "Galería", + "nav.home": "INICIO", + "nav.rss": "RSS", + "search.all": "Todo", + "search.folders": "Carpetas", + "search.groups": "Grupos", + "search.placeholder": "Buscar ...", + "search.submit": "¡Buscar!", + "search.tag": "Etiqueta", + "user.comments": "Comentarios", + "user.deviations": "Desviaciones", + "user.friends": "Amigos", + "user.pageviews": "Visitas", + "user.registered": "Fecha de registro", + "user.statistics": "Estadísticas", + "user.tag": "Etiqueta del usuario", + "user.uid": "UID", + "user.watchers": "Seguidores", + "user.watching": "Siguiendo" +} @@ -169,3 +169,15 @@ func (f *File) Close() error { f.closed = true return nil } + +// LanguageFiles lists the assets under lang/, which is how the i18n loader finds +// catalogues without the FS needing directory listing. +func LanguageFiles() []string { + var out []string + for _, x := range templates["lang"] { + if strings.HasSuffix(x.name, ".json") { + out = append(out, x.name) + } + } + return out +} @@ -2,7 +2,11 @@ package static -import "embed" +import ( + "embed" + "io/fs" + "strings" +) // Templates is the asset filesystem compiled into the binary. // @@ -20,3 +24,19 @@ var StaticPath string func CopyTemplatesToMemory() { _ = StaticPath } + +// LanguageFiles lists the assets under lang/, which is how the i18n loader finds +// catalogues without the FS needing directory listing. +func LanguageFiles() []string { + entries, err := fs.ReadDir(Templates, "lang") + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") { + out = append(out, e.Name()) + } + } + return out +}