diff options
| author | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
| commit | e14a4250efb828262347f5a4bbc4067cd71d885f (patch) | |
| tree | 3fcf6512f3b030b06c818ff4a224677a026d3b1b /internal/web/web_test.go | |
| parent | 282d2ab3e0fb3a74f32160a231225773001acd14 (diff) | |
| download | blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.gz blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.bz2 blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.zip | |
Add languages: English and Greek, chosen per blog on the Settings tab
Every blog and its dashboard were hard-wired to English. A blogger can
now pick the language of their blog; it switches the whole dashboard and
the blog's fixed text — post dates, archive months, the RSS link, the
pager, the 404 page — while what the blogger wrote is left alone.
The new internal/i18n package keys translations by the English string,
so an untranslated key renders as English rather than blank, and
TestGreekCatalogComplete scans the templates and handlers to fail when
the Greek catalog misses a key or keeps a stale one. Templates are
compiled once per language with t/tf/date/postdate/month closed over the
language, so they need no data plumbing.
The language lives in settings.language (blog migration 00002), not in
the theme, so "Reset design" does not touch it. Public pages use the
blog's language; management pages use the logged-in user's own blog's,
so a superadmin editing someone else's blog keeps theirs; the login page
follows Accept-Language.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/web_test.go')
| -rw-r--r-- | internal/web/web_test.go | 111 |
1 files changed, 93 insertions, 18 deletions
diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 5078c44..ae26029 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1,15 +1,20 @@ package web import ( + "io/fs" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" + "regexp" "strings" "testing" "time" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/i18n" "github.com/gramanas/blogspace/internal/store" ) @@ -188,16 +193,16 @@ func TestModuleKinds(t *testing.T) { if !moduleHasSettings("html") || moduleHasSettings("rss") { t.Error("moduleHasSettings") } - if got := moduleSummary(store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { + if got := moduleSummary("en", store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { t.Errorf("recent summary: %q", got) } - if got := moduleSummary(store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" { + if got := moduleSummary("en", store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" { t.Errorf("html summary: %q", got) } - if got := moduleSummary(store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { + if got := moduleSummary("en", store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { t.Errorf("long html summary: %q", got) } - if got := moduleSummary(store.Module{Kind: "menu"}); got != "" { + if got := moduleSummary("en", store.Module{Kind: "menu"}); got != "" { t.Errorf("menu summary: %q", got) } } @@ -222,7 +227,7 @@ func TestGroupArchive(t *testing.T) { if len(years) != 2 || years[0].Year != 2026 || years[0].Count != 3 || years[1].Year != 2025 || years[1].Count != 1 { t.Fatalf("years: %+v", years) } - if len(years[0].Months) != 2 || years[0].Months[0].Name != "September" || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Name != "February" { + if len(years[0].Months) != 2 || years[0].Months[0].Month != time.September || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Month != time.February { t.Errorf("months: %+v", years[0].Months) } if years[0].Months[0].Posts[0].Title != "d" || years[1].Months[0].Posts[0].Title != "a" { @@ -294,26 +299,96 @@ func TestPresets(t *testing.T) { } } -func TestFormatDate(t *testing.T) { - at := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC) - for format, want := range map[string]string{"long": "3 September 2026", "short": "3 Sep 2026", "iso": "2026-09-03"} { - th := Theme{DateFormat: format} - if got := th.FormatDate(at); got != want { - t.Errorf("%s: got %q, want %q", format, got, want) - } - } -} - func TestAllTemplatesParse(t *testing.T) { - tpl := newTemplates(false, funcs) + tpl := newTemplates(false) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/404.html"} { - if _, err := tpl.get(name); err != nil { - t.Errorf("%s: %v", name, err) + for _, l := range i18n.Languages() { + if _, err := tpl.get(l.Code, name); err != nil { + t.Errorf("%s (%s): %v", name, l.Code, err) + } + } + } +} + +// Every string the templates and handlers translate must be in the Greek +// catalog, and the catalog must not keep entries nothing uses any more (a +// reworded English key would otherwise silently fall back to English). +func TestGreekCatalogComplete(t *testing.T) { + used := map[string]bool{} + add := func(re *regexp.Regexp, src string) { + for _, m := range re.FindAllStringSubmatch(src, -1) { + used[m[1]] = true + } + } + tplKey := regexp.MustCompile(`\b(?:t|tf) "((?:[^"\\]|\\.)*)"`) + goKey := regexp.MustCompile(`\b(?:s\.trf?\(r|i18n\.Tf?\([A-Za-z]+), "((?:[^"\\]|\\.)*)"`) + if err := fs.WalkDir(assets, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := fs.ReadFile(assets, path) + add(tplKey, string(b)) + return err + }); err != nil { + t.Fatal(err) + } + goFiles, _ := filepath.Glob("*.go") + for _, f := range goFiles { + if strings.HasSuffix(f, "_test.go") { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + add(goKey, string(b)) + } + // Keys that reach t/tr through a variable rather than a literal. + for _, m := range []map[string]string{moduleNames, areaNames} { + for _, v := range m { + used[v] = true } } + for _, p := range Presets() { + used[p.Name] = true + } + for _, k := range []string{"top", "bottom", store.RoleSuperadmin, store.RoleBlogger} { + used[k] = true + } + have := map[string]bool{} + for _, k := range i18n.Keys("el") { + have[k] = true + if !used[k] { + t.Errorf("el catalog: %q is not used anywhere", k) + } + } + for k := range used { + if !have[k] { + t.Errorf("el catalog: missing %q", k) + } + } + if len(used) < 100 { + t.Errorf("only %d keys found; the scan is broken", len(used)) + } +} + +// Anonymous pages follow the browser's language; unknown ones fall back to English. +func TestLangFromAcceptLanguage(t *testing.T) { + s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil) + req := httptest.NewRequest("GET", "/webadmin", nil) + if got := s.lang(req); got != "en" { + t.Errorf("no header: %q", got) + } + req.Header.Set("Accept-Language", "el-GR,el;q=0.9,en;q=0.8") + if got := s.lang(req); got != "el" { + t.Errorf("greek header: %q", got) + } + if got := s.lang(withLang(req, "en")); got != "en" { + t.Errorf("context wins: %q", got) + } } // /webadmin on a blog's own host bounces to the root login page and back to that blog's dashboard. |
