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

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.
 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(-)

diff --git a/SETUP.txt b/SETUP.txt
index 15c9d01..b51896b 100755
--- a/SETUP.txt
+++ b/SETUP.txt
@@ -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.
+
+
diff --git a/TODO.txt b/TODO.txt
index eccfd26..f2caa9d 100755
--- a/TODO.txt
+++ b/TODO.txt
@@ -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
diff --git a/app/config.go b/app/config.go
index e1abead..dfc88a5 100755
--- a/app/config.go
+++ b/app/config.go
@@ -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",
diff --git a/app/i18n.go b/app/i18n.go
new file mode 100644
index 0000000..8dc8863
--- /dev/null
+++ b/app/i18n.go
@@ -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
+}
diff --git a/app/i18n_test.go b/app/i18n_test.go
new file mode 100644
index 0000000..ae185e0
--- /dev/null
+++ b/app/i18n_test.go
@@ -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)
+	}
+}
diff --git a/app/router.go b/app/router.go
index 73d38ae..1cafc95 100755
--- a/app/router.go
+++ b/app/router.go
@@ -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
diff --git a/app/util.go b/app/util.go
index 52b1d48..0d8d1fa 100755
--- a/app/util.go
+++ b/app/util.go
@@ -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)
diff --git a/config.example.json b/config.example.json
index 1b492f5..141ec1c 100755
--- a/config.example.json
+++ b/config.example.json
@@ -15,5 +15,6 @@
     "proxy": true,
     "nsfw": false,
     "hide-ai": false,
-    "theme": "auto"
+    "theme": "auto",
+    "language": "auto"
 }
diff --git a/main.go b/main.go
index d913dcf..7368d42 100755
--- a/main.go
+++ b/main.go
@@ -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()
diff --git a/static/html/about.htm b/static/html/about.htm
index 59072b4..4b8289a 100755
--- a/static/html/about.htm
+++ b/static/html/about.htm
@@ -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>
diff --git a/static/html/deviantion.htm b/static/html/deviantion.htm
index 6af5a5d..712efa2 100755
--- a/static/html/deviantion.htm
+++ b/static/html/deviantion.htm
@@ -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}}
diff --git a/static/html/gruser.htm b/static/html/gruser.htm
index 5eb07fa..577581e 100755
--- a/static/html/gruser.htm
+++ b/static/html/gruser.htm
@@ -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 ""}}
diff --git a/static/html/header.htm b/static/html/header.htm
index 8543249..0852ca0 100755
--- a/static/html/header.htm
+++ b/static/html/header.htm
@@ -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
diff --git a/static/html/index.htm b/static/html/index.htm
index a48f8ea..67cda71 100755
--- a/static/html/index.htm
+++ b/static/html/index.htm
@@ -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>
diff --git a/static/lang/en.json b/static/lang/en.json
new file mode 100644
index 0000000..3b93681
--- /dev/null
+++ b/static/lang/en.json
@@ -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"
+}
diff --git a/static/lang/es.json b/static/lang/es.json
new file mode 100644
index 0000000..a00cf20
--- /dev/null
+++ b/static/lang/es.json
@@ -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"
+}
diff --git a/static/templates-noembed.go b/static/templates-noembed.go
index ee97ca7..9e49d7d 100755
--- a/static/templates-noembed.go
+++ b/static/templates-noembed.go
@@ -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
+}
diff --git a/static/templates.go b/static/templates.go
index 859f075..609fe57 100755
--- a/static/templates.go
+++ b/static/templates.go
@@ -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
+}