Commit b99e4eb459
Verified · cmc
Layout: unified · split
SETUP.txt +14
| @@ -63,3 +63,17 @@ server { | |||
| 63 | `light` pins one for everybody. An unrecognised value stops startup rather | 63 | `light` pins one for everybody. An unrecognised value stops startup rather |
| 64 | than quietly falling back. | 64 | than quietly falling back. |
| 65 | 65 | ||
| 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 @@ | |||
| 24 | - [x] Implement an API | 24 | - [x] Implement an API |
| 25 | - [x] Implement themes | 25 | - [x] Implement themes |
| 26 | - [ ] Switch to arenas in the cache | 26 | - [ ] 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 { | |||
| 36 | Nsfw bool `json:"nsfw"` | 36 | Nsfw bool `json:"nsfw"` |
| 37 | HideAI bool `json:"hide-ai"` | 37 | HideAI bool `json:"hide-ai"` |
| 38 | Theme string `json:"theme"` | 38 | Theme string `json:"theme"` |
| 39 | Language string `json:"language"` | ||
| 39 | UserAgent string `json:"user-agent"` | 40 | UserAgent string `json:"user-agent"` |
| 40 | DownloadProxy string `json:"download-proxy"` | 41 | DownloadProxy string `json:"download-proxy"` |
| 41 | StaticPath string `json:"static-path"` | 42 | StaticPath string `json:"static-path"` |
| @@ -44,10 +45,11 @@ type config struct { | |||
| 44 | // CFG is the running instance's configuration, holding the defaults below until | 45 | // CFG is the running instance's configuration, holding the defaults below until |
| 45 | // ExecuteConfig overwrites them from the config file. | 46 | // ExecuteConfig overwrites them from the config file. |
| 46 | var CFG = config{ | 47 | var CFG = config{ |
| 47 | cfg: "config.json", | 48 | cfg: "config.json", |
| 48 | Listen: "127.0.0.1:3003", | 49 | Listen: "127.0.0.1:3003", |
| 49 | Theme: "auto", | 50 | Theme: "auto", |
| 50 | URI: "/", | 51 | Language: "auto", |
| 52 | URI: "/", | ||
| 51 | Cache: cacheConfig{ | 53 | Cache: cacheConfig{ |
| 52 | Enabled: false, | 54 | Enabled: false, |
| 53 | Path: "cache", | 55 | Path: "cache", |
app/i18n.go added +110
| @@ -0,0 +1,110 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 15 | const DefaultLang = "en" | ||
| 16 | |||
| 17 | var ( | ||
| 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. | ||
| 28 | func 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. | ||
| 50 | func 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. | ||
| 62 | func 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. | ||
| 82 | func 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 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 14 | func 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. | ||
| 43 | func 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. | ||
| 63 | func 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. | ||
| 88 | func 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() { | |||
| 86 | skunky.API.main = &skunky | 86 | skunky.API.main = &skunky |
| 87 | skunky.Writer = w | 87 | skunky.Writer = w |
| 88 | skunky.BasePath = CFG.URI | 88 | skunky.BasePath = CFG.URI |
| 89 | skunky.Lang = ResolveLang(r.Header.Get("Accept-Language")) | ||
| 89 | skunky.QueryRaw = arg("q") | 90 | skunky.QueryRaw = arg("q") |
| 90 | skunky.Query = url.QueryEscape(skunky.QueryRaw) | 91 | skunky.Query = url.QueryEscape(skunky.QueryRaw) |
| 91 | skunky.Page = p | 92 | skunky.Page = p |
app/util.go +9
| @@ -86,6 +86,10 @@ type skunkyart struct { | |||
| 86 | Type rune | 86 | Type rune |
| 87 | Atom bool | 87 | Atom bool |
| 88 | 88 | ||
| 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 | |||
| 89 | // Host is the scheme and host this request arrived on, e.g. | 93 | // Host is the scheme and host this request arrived on, e.g. |
| 90 | // "https://art.example.com". It is per-request rather than global because | 94 | // "https://art.example.com". It is per-request rather than global because |
| 91 | // concurrent requests can arrive on different hosts and ports. | 95 | // concurrent requests can arrive on different hosts and ports. |
| @@ -144,6 +148,11 @@ type skunkyart struct { | |||
| 144 | func (s skunkyart) ExecuteTemplate(file, dir string, data any) { | 148 | func (s skunkyart) ExecuteTemplate(file, dir string, data any) { |
| 145 | var buf strings.Builder | 149 | var buf strings.Builder |
| 146 | tmp := template.New(file) | 150 | 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 | }) | ||
| 147 | tmp, err := tmp.ParseFS(static.Templates, dir+"/*") | 156 | tmp, err := tmp.ParseFS(static.Templates, dir+"/*") |
| 148 | if err != nil { | 157 | if err != nil { |
| 149 | s.Writer.WriteHeader(500) | 158 | s.Writer.WriteHeader(500) |
config.example.json +2 −1
| @@ -15,5 +15,6 @@ | |||
| 15 | "proxy": true, | 15 | "proxy": true, |
| 16 | "nsfw": false, | 16 | "nsfw": false, |
| 17 | "hide-ai": false, | 17 | "hide-ai": false, |
| 18 | "theme": "auto" | 18 | "theme": "auto", |
| 19 | "language": "auto" | ||
| 19 | } | 20 | } |
main.go +4
| @@ -25,6 +25,10 @@ func main() { | |||
| 25 | app.ExecuteConfig() | 25 | app.ExecuteConfig() |
| 26 | static.CopyTemplatesToMemory() | 26 | static.CopyTemplatesToMemory() |
| 27 | 27 | ||
| 28 | // After the copy, not before: the catalogues are assets, and ExecuteConfig | ||
| 29 | // runs while static/ is still unread. | ||
| 30 | app.LoadLanguages() | ||
| 31 | |||
| 28 | // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods | 32 | // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods |
| 29 | // can't exhaust the process or get our egress IP banned by CloudFront/WAF. | 33 | // can't exhaust the process or get our egress IP banned by CloudFront/WAF. |
| 30 | app.InstallDAThrottle() | 34 | app.InstallDAThrottle() |
static/html/about.htm +16 −16
| @@ -6,46 +6,46 @@ | |||
| 6 | <p> | 6 | <p> |
| 7 | SkunkyArt is an alternative frontend for deviantart.com, written in Go. | 7 | SkunkyArt is an alternative frontend for deviantart.com, written in Go. |
| 8 | </p> | 8 | </p> |
| 9 | <h3><a href="https://github.com/krazywarez/skunky-art/issues" target="_blank">Report an issue</a></h3> | 9 | <h3><a href="https://github.com/krazywarez/skunky-art/issues" target="_blank">{{T "about.report"}}</a></h3> |
| 10 | <b>Instance settings:</b> | 10 | <b>{{T "about.settings"}}</b> |
| 11 | <ul> | 11 | <ul> |
| 12 | <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></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>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}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>Hide AI</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}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>Theme</b>: {{.Templates.About.Theme}}</li> | 15 | <li><b>{{T "about.theme"}}</b>: {{.Templates.About.Theme}}</li> |
| 16 | </ul> | 16 | </ul> |
| 17 | <details> | 17 | <details> |
| 18 | <summary><b>Instances:</b></summary> | 18 | <summary><b>{{T "about.instances"}}</b></summary> |
| 19 | <ul> | 19 | <ul> |
| 20 | {{range .Templates.About.Instances}} | 20 | {{range .Templates.About.Instances}} |
| 21 | <li><u><b>{{.Title}}</b></u>: | 21 | <li><u><b>{{.Title}}</b></u>: |
| 22 | <ul> | 22 | <ul> |
| 23 | <li><b>Country</b>: {{.Country}}</li> | 23 | <li><b>{{T "about.country"}}</b>: {{.Country}}</li> |
| 24 | <li><b>URLs</b>: </li> | 24 | <li><b>{{T "about.urls"}}</b>: </li> |
| 25 | <ul> | 25 | <ul> |
| 26 | {{if ne .Urls.I2P ""}} | 26 | {{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> |
| 28 | {{end}} | 28 | {{end}} |
| 29 | {{if ne .Urls.Ygg ""}} | 29 | {{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> |
| 31 | {{end}} | 31 | {{end}} |
| 32 | {{if ne .Urls.Tor ""}} | 32 | {{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> |
| 34 | {{end}} | 34 | {{end}} |
| 35 | {{if ne .Urls.Clearnet ""}} | 35 | {{if ne .Urls.Clearnet ""}} |
| 36 | <li><b>Clearnet</b>: <a href="{{.Urls.Clearnet}}">{{.Urls.Clearnet}}</a></li> | 36 | <li><b>Clearnet</b>: <a href="{{.Urls.Clearnet}}">{{.Urls.Clearnet}}</a></li> |
| 37 | {{end}} | 37 | {{end}} |
| 38 | </ul> | 38 | </ul> |
| 39 | <li><b>Settings</b>: </li> | 39 | <li><b>{{T "common.settings"}}</b>: </li> |
| 40 | <ul> | 40 | <ul> |
| 41 | <li><b>NSFW</b>: <span class="about-{{.Settings.Nsfw}}">{{if .Settings.Nsfw}}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>Proxyfing</b>: <span class="about-{{.Settings.Proxy}}">{{if .Settings.Proxy}}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> |
| 43 | </ul> | 43 | </ul> |
| 44 | </ul> | 44 | </ul> |
| 45 | </li> | 45 | </li> |
| 46 | {{end}} | 46 | {{end}} |
| 47 | </ul> | 47 | </ul> |
| 48 | </details> | 48 | </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> |
| 50 | </main> | 50 | </main> |
| 51 | </html> | 51 | </html> |
static/html/deviantion.htm +8 −8
| @@ -6,33 +6,33 @@ | |||
| 6 | <figure> | 6 | <figure> |
| 7 | <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px" alt="{{.Templates.Deviation.Post.Deviation.Author.Username}} avatar"> | 7 | <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px" alt="{{.Templates.Deviation.Post.Deviation.Author.Username}} avatar"> |
| 8 | <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)}} | 8 | <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> |
| 10 | {{else}}{{.Templates.Deviation.Post.Deviation.Title}}{{end}} | 10 | {{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}} | 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">NSFW</span>]{{end}} | 12 | {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">{{T "deviation.nsfw"}}</span>]{{end}} |
| 13 | </span> | 13 | </span> |
| 14 | <br> | 14 | <br> |
| 15 | {{if (ne .Templates.Deviation.Post.IMG "")}} | 15 | {{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> |
| 17 | <br> | 17 | <br> |
| 18 | {{end}} | 18 | {{end}} |
| 19 | {{if (ne .Templates.Deviation.Tags "")}} | 19 | {{if (ne .Templates.Deviation.Tags "")}} |
| 20 | {{.Templates.Deviation.Tags}}<br> | 20 | {{.Templates.Deviation.Tags}}<br> |
| 21 | {{end}} | 21 | {{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> | 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}}">Redirect to original</a> | 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> |
| 24 | </span> | 24 | </span> |
| 25 | {{if (ne .Templates.Deviation.Post.Description "")}} | 25 | {{if (ne .Templates.Deviation.Post.Description "")}} |
| 26 | <figcaption> | 26 | <figcaption> |
| 27 | <details> | 27 | <details> |
| 28 | <summary>Description</summary> | 28 | <summary>{{T "deviation.description"}}</summary> |
| 29 | {{.Templates.Deviation.Post.Description}} | 29 | {{.Templates.Deviation.Post.Description}} |
| 30 | </details> | 30 | </details> |
| 31 | </figcaption> | 31 | </figcaption> |
| 32 | {{end}} | 32 | {{end}} |
| 33 | {{if ne .Templates.Deviation.Related ""}} | 33 | {{if ne .Templates.Deviation.Related ""}} |
| 34 | <details> | 34 | <details> |
| 35 | <summary>Related content</summary> | 35 | <summary>{{T "deviation.related"}}</summary> |
| 36 | {{.Templates.Deviation.Related}} | 36 | {{.Templates.Deviation.Related}} |
| 37 | </details> | 37 | </details> |
| 38 | {{end}} | 38 | {{end}} |
static/html/gruser.htm +23 −23
| @@ -4,29 +4,29 @@ | |||
| 4 | <main> | 4 | <main> |
| 5 | <header> | 5 | <header> |
| 6 | <h1> | 6 | <h1> |
| 7 | <a href="{{.BasePath}}">HOME</a> | 7 | <a href="{{.BasePath}}">{{T "nav.home"}}</a> |
| 8 | | <a href="{{.BasePath}}dd">DD</a> | 8 | | <a href="{{.BasePath}}dd">{{T "nav.dd"}}</a> |
| 9 | {{if ne .Type 'f'}} | 9 | {{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> | 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">Favourites</a> | 11 | | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">{{T "nav.favourites"}}</a> |
| 12 | {{else}} | 12 | {{else}} |
| 13 | | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=about">About</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">Gallery</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">Favourites</a> | 15 | | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">{{T "nav.favourites"}}</a> |
| 16 | {{end}} | 16 | {{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> |
| 18 | </h1> | 18 | </h1> |
| 19 | <form method="get" action="{{.BasePath}}search"> | 19 | <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"> |
| 21 | <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}"> | 21 | <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}"> |
| 22 | <select name="type" aria-label="Search type"> | 22 | <select name="type" aria-label="Search type"> |
| 23 | <option value="gallery">Gallery</option> | 23 | <option value="gallery">{{T "nav.gallery"}}</option> |
| 24 | <option value="folders">Folders</option> | 24 | <option value="folders">{{T "search.folders"}}</option> |
| 25 | <option value="all">All</option> | 25 | <option value="all">{{T "search.all"}}</option> |
| 26 | <option value="tag">Tag</option> | 26 | <option value="tag">{{T "search.tag"}}</option> |
| 27 | <option value="r">Groups</option> | 27 | <option value="r">{{T "search.groups"}}</option> |
| 28 | </select> | 28 | </select> |
| 29 | <button type="submit">Search!</button> | 29 | <button type="submit">{{T "search.submit"}}</button> |
| 30 | </form> <h1>| {{.Templates.GroupUser.GR.Owner.Username}}</h1> | 30 | </form> <h1>| {{.Templates.GroupUser.GR.Owner.Username}}</h1> |
| 31 | </header> | 31 | </header> |
| 32 | {{if eq .Type 'a'}} | 32 | {{if eq .Type 'a'}} |
| @@ -42,20 +42,20 @@ | |||
| 42 | {{if (eq .Templates.GroupUser.About.A.Gender "female")}} | 42 | {{if (eq .Templates.GroupUser.About.A.Gender "female")}} |
| 43 | ♀️ | 43 | ♀️ |
| 44 | {{end}} | 44 | {{end}} |
| 45 | [<span title="UID">{{.Templates.GroupUser.GR.Gruser.ID}}</span>] | 45 | [<span title="{{T "user.uid"}}">{{.Templates.GroupUser.GR.Gruser.ID}}</span>] |
| 46 | [<span title="Registration date">{{.Templates.GroupUser.CreationDate}}</span>] | 46 | [<span title="{{T "user.registered"}}">{{.Templates.GroupUser.CreationDate}}</span>] |
| 47 | {{if ne .Templates.GroupUser.GR.Extra.Tag ""}} | 47 | {{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 ""}} |
| 49 | (<b>{{.Templates.GroupUser.About.A.Country}}</b>) | 49 | (<b>{{.Templates.GroupUser.About.A.Country}}</b>) |
| 50 | {{end}} | 50 | {{end}} |
| 51 | 51 | ||
| 52 | {{if .Templates.GroupUser.Group}} | 52 | {{if .Templates.GroupUser.Group}} |
| 53 | <h3 id="stats"><a href="#stats">#</a> Statistics</h3> | 53 | <h3 id="stats"><a href="#stats">#</a> {{T "user.statistics"}}</h3> |
| 54 | <p>Watchers: <b>{{.Templates.GroupUser.GR.Extra.Stats.Watchers}}</b>; Pageviews: <b>{{.Templates.GroupUser.GR.Extra.Stats.Pageviews}}</b></b> | 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> |
| 55 | {{else}} | 55 | {{else}} |
| 56 | <h3 id="stats"><a href="#stats">#</a> Statistics</h3> | 56 | <h3 id="stats"><a href="#stats">#</a> {{T "user.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> | 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>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> | 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> |
| 59 | {{end}} | 59 | {{end}} |
| 60 | 60 | ||
| 61 | {{if ne .Templates.GroupUser.Admins ""}} | 61 | {{if ne .Templates.GroupUser.Admins ""}} |
static/html/header.htm +6 −6
| @@ -1,14 +1,14 @@ | |||
| 1 | {{define "header"}} | 1 | {{define "header"}} |
| 2 | <header> | 2 | <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> |
| 4 | <form method="get" action="search"> | 4 | <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}}"> |
| 6 | <select name="type" aria-label="Search type"> | 6 | <select name="type" aria-label="Search type"> |
| 7 | <option value="all">All</option> | 7 | <option value="all">{{T "search.all"}}</option> |
| 8 | <option value="tag">Tag</option> | 8 | <option value="tag">{{T "search.tag"}}</option> |
| 9 | <option value="r">Groups</option> | 9 | <option value="r">{{T "search.groups"}}</option> |
| 10 | </select> | 10 | </select> |
| 11 | <button type="submit">Search!</button> | 11 | <button type="submit">{{T "search.submit"}}</button> |
| 12 | </form> | 12 | </form> |
| 13 | </header> | 13 | </header> |
| 14 | {{end}}\ No newline at end of file | 14 | {{end}}\ No newline at end of file |
static/html/index.htm +8 −8
| @@ -70,20 +70,20 @@ | |||
| 70 | </style> | 70 | </style> |
| 71 | </head> | 71 | </head> |
| 72 | <main> | 72 | <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"> |
| 74 | <div> | 74 | <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> |
| 76 | <form method="get" action="{{.}}search"> | 76 | <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"> |
| 78 | <select name="type" aria-label="Search type"> | 78 | <select name="type" aria-label="Search type"> |
| 79 | <option value="all">All</option> | 79 | <option value="all">{{T "search.all"}}</option> |
| 80 | <option value="tag">Tag</option> | 80 | <option value="tag">{{T "search.tag"}}</option> |
| 81 | <option value="r">Groups</option> | 81 | <option value="r">{{T "search.groups"}}</option> |
| 82 | </select> | 82 | </select> |
| 83 | <button type="submit">Search!</button> | 83 | <button type="submit">{{T "search.submit"}}</button> |
| 84 | </form> | 84 | </form> |
| 85 | <h1 style="margin-top: 5%; font-size: 200%; text-align: center;"> | 85 | <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> |
| 87 | </h1> | 87 | </h1> |
| 88 | </div> | 88 | </div> |
| 89 | </main> | 89 | </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 { | |||
| 169 | f.closed = true | 169 | f.closed = true |
| 170 | return nil | 170 | return nil |
| 171 | } | 171 | } |
| 172 | |||
| 173 | // LanguageFiles lists the assets under lang/, which is how the i18n loader finds | ||
| 174 | // catalogues without the FS needing directory listing. | ||
| 175 | func 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 @@ | |||
| 2 | 2 | ||
| 3 | package static | 3 | package static |
| 4 | 4 | ||
| 5 | import "embed" | 5 | import ( |
| 6 | "embed" | ||
| 7 | "io/fs" | ||
| 8 | "strings" | ||
| 9 | ) | ||
| 6 | 10 | ||
| 7 | // Templates is the asset filesystem compiled into the binary. | 11 | // Templates is the asset filesystem compiled into the binary. |
| 8 | // | 12 | // |
| @@ -20,3 +24,19 @@ var StaticPath string | |||
| 20 | func CopyTemplatesToMemory() { | 24 | func CopyTemplatesToMemory() { |
| 21 | _ = StaticPath | 25 | _ = StaticPath |
| 22 | } | 26 | } |
| 27 | |||
| 28 | // LanguageFiles lists the assets under lang/, which is how the i18n loader finds | ||
| 29 | // catalogues without the FS needing directory listing. | ||
| 30 | func 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 | } | ||