diff --git a/go.mod b/go.mod index 09c0c9e..c2a8c83 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.15 require ( github.com/BurntSushi/toml v0.3.1 + github.com/PuerkitoBio/goquery v1.5.0 // indirect github.com/friendsofgo/errors v0.9.2 github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/go-kit/kit v0.10.0 diff --git a/web/handlers/http_test.go b/web/handlers/http_test.go index a855976..d8d223a 100644 --- a/web/handlers/http_test.go +++ b/web/handlers/http_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,7 +24,12 @@ func TestIndex(t *testing.T) { r.Nil(err) html, resp := testClient.GetHTML(url.String(), nil) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") - a.Equal("Index landing page", html.Find("#welcome").Text()) + assertLocalized(t, html, []localizedElement{ + {"#welcome", "LandingWelcome"}, + {"title", "LandingTitle"}, + // {"#nav", "FooBar"}, + }) + val, has := html.Find("#logo").Attr("src") a.True(has, "logo src attribute not found") a.Equal("/assets/img/test-hermie.png", val) @@ -61,9 +67,10 @@ func TestNewsRegisterd(t *testing.T) { html, resp := testClient.GetHTML("/news/", nil) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") - found := html.Find("h1").Text() - t.Log(found) - // a.Equal("fooo", found) + assertLocalized(t, html, []localizedElement{ + {"#welcome", "NewsWelcome"}, + {"title", "NewsTitle"}, + }) } func TestRestricted(t *testing.T) { @@ -87,6 +94,21 @@ func TestLoginForm(t *testing.T) { html, resp := testClient.GetHTML(url.String(), nil) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") - found := html.Find("title").Text() - a.Equal("fallback sign-in", found) + assertLocalized(t, html, []localizedElement{ + {"#welcome", "AuthFallbackWelcome"}, + {"title", "AuthFallbackTitle"}, + }) +} + +// utils + +type localizedElement struct { + Selector, Label string +} + +func assertLocalized(t *testing.T, html *goquery.Document, elems []localizedElement) { + a := assert.New(t) + for i, pair := range elems { + a.Equal(pair.Label, html.Find(pair.Selector).Text(), "localized pair %d failed", i+1) + } } diff --git a/web/handlers/setup_test.go b/web/handlers/setup_test.go index d05ff1a..a4d9f7c 100644 --- a/web/handlers/setup_test.go +++ b/web/handlers/setup_test.go @@ -3,16 +3,21 @@ package handlers import ( + "bytes" + "fmt" + "io/ioutil" "net/http" "os" "path/filepath" "testing" + "github.com/BurntSushi/toml" "github.com/pkg/errors" "go.mindeco.de/http/tester" "github.com/ssb-ngi-pointer/go-ssb-room/admindb/mockdb" "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" + "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" ) @@ -24,6 +29,8 @@ var ( // mocked dbs testAuthDB *mockdb.FakeAuthWithSSBService testAuthFallbackDB *mockdb.FakeAuthFallbackService + + testI18N = justTheKeys() ) func setup(t *testing.T) { @@ -32,6 +39,13 @@ func setup(t *testing.T) { os.RemoveAll(testRepoPath) testRepo := repo.New(testRepoPath) + testOverride := filepath.Join(testRepo.GetPath("i18n"), "active.en.toml") + os.MkdirAll(filepath.Dir(testOverride), 0700) + err := ioutil.WriteFile(testOverride, testI18N, 0700) + if err != nil { + t.Fatal(err) + } + testAuthDB = new(mockdb.FakeAuthWithSSBService) testAuthFallbackDB = new(mockdb.FakeAuthFallbackService) h, err := New( @@ -57,3 +71,24 @@ func teardown() { testAuthDB = nil testAuthFallbackDB = nil } + +// auto generate from defaults a list of Label = "Label" +func justTheKeys() []byte { + f, err := i18n.Defaults.Open("/active.en.toml") + if err != nil { + panic(err) + } + justAMap := make(map[string]interface{}) + md, err := toml.DecodeReader(f, &justAMap) + if err != nil { + panic(err) + } + + var buf = &bytes.Buffer{} + + for _, key := range md.Keys() { + fmt.Fprintf(buf, "%s = \"%s\"\n", key, key) + } + + return buf.Bytes() +} diff --git a/web/i18n/defaults/active.en.toml b/web/i18n/defaults/active.en.toml new file mode 100644 index 0000000..bb96198 --- /dev/null +++ b/web/i18n/defaults/active.en.toml @@ -0,0 +1,9 @@ +LandingTitle = "ohai my room" +LandingWelcome = "Landing welcome here" +AuthFallbackWelcome = "You really shouldn't be here.... Let's get you through this." +AuthFallbackTitle = "The place of last resort" +AuthSignIn = "Sign in" +AuthSignOut = "Sign out" +NewsWelcome = "so, what happend (recently)" +NewsTitle = "News" +NewsOverview = "News - Overview" \ No newline at end of file diff --git a/web/i18n/defaults_dev.go b/web/i18n/defaults_dev.go new file mode 100644 index 0000000..56ce3ec --- /dev/null +++ b/web/i18n/defaults_dev.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +// +build dev + +/* +This is the development version of the templates, where they are read directly from the local filesystem. + +to use this pass '-tags dev' to your go build or test commands. +*/ + +package i18n + +import ( + "net/http" + "path/filepath" + + "go.mindeco.de/goutils" +) + +// absolute path of where this package is located +var pkgDir = goutils.MustLocatePackage("github.com/ssb-ngi-pointer/go-ssb-room/web/i18n") + +var Defaults http.FileSystem = http.Dir(filepath.Join(pkgDir, "defaults")) diff --git a/web/i18n/defaults_generate.go b/web/i18n/defaults_generate.go new file mode 100644 index 0000000..7aeedd6 --- /dev/null +++ b/web/i18n/defaults_generate.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +// +build ignore + +package main + +import ( + "log" + + "github.com/shurcooL/vfsgen" + + "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n" +) + +func main() { + err := vfsgen.Generate(i18n.Defaults, vfsgen.Options{ + PackageName: "i18n", + BuildTags: "!dev", + VariableName: "Defaults", + }) + if err != nil { + log.Fatalln(err) + } + +} diff --git a/web/i18n/defaults_vfsdata.go b/web/i18n/defaults_vfsdata.go new file mode 100644 index 0000000..9324e3d --- /dev/null +++ b/web/i18n/defaults_vfsdata.go @@ -0,0 +1,186 @@ +// Code generated by vfsgen; DO NOT EDIT. + +// +build !dev + +package i18n + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + pathpkg "path" + "time" +) + +// Defaults statically implements the virtual filesystem provided to vfsgen. +var Defaults = func() http.FileSystem { + fs := vfsgen۰FS{ + "/": &vfsgen۰DirInfo{ + name: "/", + modTime: time.Date(2021, 2, 10, 12, 9, 47, 401093107, time.UTC), + }, + "/active.en.toml": &vfsgen۰CompressedFileInfo{ + name: "active.en.toml", + modTime: time.Date(2021, 2, 10, 13, 36, 16, 98174331, time.UTC), + uncompressedSize: 345, + + compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\x54\x8e\xc1\x4e\xc3\x30\x0c\x86\xef\x7b\x8a\x5f\xb9\x0c\x24\xc8\x1b\x70\xe0\x82\x84\x34\xb1\x03\x93\x10\xc7\xac\x35\x75\x84\x1b\x57\x89\xb3\xaa\x6f\x8f\x4a\xbb\x32\x6e\xf6\xf7\x7f\xb6\xfe\x43\x48\x6d\x4c\xdd\x29\x9a\x10\x9e\xe0\x94\x43\x44\x3f\x21\xab\xf6\x6e\xb7\xa6\x1f\x24\x8d\xf6\xbf\xf9\x4a\x30\xae\x88\x29\x93\xdb\x3d\x57\xe3\x97\x20\x72\x0e\xcd\xf7\x8d\xfc\xa9\x15\x99\x82\xc8\x84\xc2\x5a\xa5\x4d\x7b\xc3\x79\x39\xf2\xde\x7b\x1c\xc8\xf6\x05\x1d\x19\x26\xad\x30\xce\x5a\x3b\x86\x71\x2c\xfe\xff\xd3\xad\xdf\x89\x09\x83\x84\x86\xa0\x5f\x90\x50\x0c\x99\x8a\x66\x5b\xf4\xf7\xd8\xa5\xd7\x34\x7b\xf3\x84\x98\xfe\xf0\xb1\xda\xc6\xb5\x9a\xdb\xbd\xd1\x58\x6e\xba\x16\x7d\xc0\xc8\xc1\xc0\x61\x18\x28\xb5\xb8\xcb\xd4\x50\x32\x99\xee\x17\x77\xab\x30\x2f\x0b\x3a\x5e\x28\x5f\x22\x8d\x57\x8a\x47\x5c\x91\xfb\x09\x00\x00\xff\xff\x5e\xfa\x34\x19\x59\x01\x00\x00"), + }, + } + fs["/"].(*vfsgen۰DirInfo).entries = []os.FileInfo{ + fs["/active.en.toml"].(os.FileInfo), + } + + return fs +}() + +type vfsgen۰FS map[string]interface{} + +func (fs vfsgen۰FS) Open(path string) (http.File, error) { + path = pathpkg.Clean("/" + path) + f, ok := fs[path] + if !ok { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist} + } + + switch f := f.(type) { + case *vfsgen۰CompressedFileInfo: + gr, err := gzip.NewReader(bytes.NewReader(f.compressedContent)) + if err != nil { + // This should never happen because we generate the gzip bytes such that they are always valid. + panic("unexpected error reading own gzip compressed bytes: " + err.Error()) + } + return &vfsgen۰CompressedFile{ + vfsgen۰CompressedFileInfo: f, + gr: gr, + }, nil + case *vfsgen۰DirInfo: + return &vfsgen۰Dir{ + vfsgen۰DirInfo: f, + }, nil + default: + // This should never happen because we generate only the above types. + panic(fmt.Sprintf("unexpected type %T", f)) + } +} + +// vfsgen۰CompressedFileInfo is a static definition of a gzip compressed file. +type vfsgen۰CompressedFileInfo struct { + name string + modTime time.Time + compressedContent []byte + uncompressedSize int64 +} + +func (f *vfsgen۰CompressedFileInfo) Readdir(count int) ([]os.FileInfo, error) { + return nil, fmt.Errorf("cannot Readdir from file %s", f.name) +} +func (f *vfsgen۰CompressedFileInfo) Stat() (os.FileInfo, error) { return f, nil } + +func (f *vfsgen۰CompressedFileInfo) GzipBytes() []byte { + return f.compressedContent +} + +func (f *vfsgen۰CompressedFileInfo) Name() string { return f.name } +func (f *vfsgen۰CompressedFileInfo) Size() int64 { return f.uncompressedSize } +func (f *vfsgen۰CompressedFileInfo) Mode() os.FileMode { return 0444 } +func (f *vfsgen۰CompressedFileInfo) ModTime() time.Time { return f.modTime } +func (f *vfsgen۰CompressedFileInfo) IsDir() bool { return false } +func (f *vfsgen۰CompressedFileInfo) Sys() interface{} { return nil } + +// vfsgen۰CompressedFile is an opened compressedFile instance. +type vfsgen۰CompressedFile struct { + *vfsgen۰CompressedFileInfo + gr *gzip.Reader + grPos int64 // Actual gr uncompressed position. + seekPos int64 // Seek uncompressed position. +} + +func (f *vfsgen۰CompressedFile) Read(p []byte) (n int, err error) { + if f.grPos > f.seekPos { + // Rewind to beginning. + err = f.gr.Reset(bytes.NewReader(f.compressedContent)) + if err != nil { + return 0, err + } + f.grPos = 0 + } + if f.grPos < f.seekPos { + // Fast-forward. + _, err = io.CopyN(ioutil.Discard, f.gr, f.seekPos-f.grPos) + if err != nil { + return 0, err + } + f.grPos = f.seekPos + } + n, err = f.gr.Read(p) + f.grPos += int64(n) + f.seekPos = f.grPos + return n, err +} +func (f *vfsgen۰CompressedFile) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + f.seekPos = 0 + offset + case io.SeekCurrent: + f.seekPos += offset + case io.SeekEnd: + f.seekPos = f.uncompressedSize + offset + default: + panic(fmt.Errorf("invalid whence value: %v", whence)) + } + return f.seekPos, nil +} +func (f *vfsgen۰CompressedFile) Close() error { + return f.gr.Close() +} + +// vfsgen۰DirInfo is a static definition of a directory. +type vfsgen۰DirInfo struct { + name string + modTime time.Time + entries []os.FileInfo +} + +func (d *vfsgen۰DirInfo) Read([]byte) (int, error) { + return 0, fmt.Errorf("cannot Read from directory %s", d.name) +} +func (d *vfsgen۰DirInfo) Close() error { return nil } +func (d *vfsgen۰DirInfo) Stat() (os.FileInfo, error) { return d, nil } + +func (d *vfsgen۰DirInfo) Name() string { return d.name } +func (d *vfsgen۰DirInfo) Size() int64 { return 0 } +func (d *vfsgen۰DirInfo) Mode() os.FileMode { return 0755 | os.ModeDir } +func (d *vfsgen۰DirInfo) ModTime() time.Time { return d.modTime } +func (d *vfsgen۰DirInfo) IsDir() bool { return true } +func (d *vfsgen۰DirInfo) Sys() interface{} { return nil } + +// vfsgen۰Dir is an opened dir instance. +type vfsgen۰Dir struct { + *vfsgen۰DirInfo + pos int // Position within entries for Seek and Readdir. +} + +func (d *vfsgen۰Dir) Seek(offset int64, whence int) (int64, error) { + if offset == 0 && whence == io.SeekStart { + d.pos = 0 + return 0, nil + } + return 0, fmt.Errorf("unsupported Seek in directory %s", d.name) +} + +func (d *vfsgen۰Dir) Readdir(count int) ([]os.FileInfo, error) { + if d.pos >= len(d.entries) && count > 0 { + return nil, io.EOF + } + if count <= 0 || count > len(d.entries)-d.pos { + count = len(d.entries) - d.pos + } + e := d.entries[d.pos : d.pos+count] + d.pos += count + return e, nil +} diff --git a/web/i18n/helper.go b/web/i18n/helper.go index ded2d51..48dc597 100644 --- a/web/i18n/helper.go +++ b/web/i18n/helper.go @@ -5,16 +5,22 @@ package i18n import ( "fmt" + "io" + "io/ioutil" "os" "path/filepath" "strings" "github.com/BurntSushi/toml" "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" + "github.com/shurcooL/httpfs/vfsutil" "golang.org/x/text/language" + + "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" ) +//go:generate go run -tags=dev defaults_generate.go + type Helper struct { bundle *i18n.Bundle } @@ -24,9 +30,8 @@ func New(r repo.Interface) (*Helper, error) { bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) - // TODO: could additionally embedd the defaults like we do with the html assets and templates - - err := filepath.Walk(r.GetPath("i18n"), func(path string, info os.FileInfo, err error) error { + // parse toml files and add them to the bundle + walkFn := func(path string, info os.FileInfo, rs io.ReadSeeker, err error) error { if err != nil { return err } @@ -39,10 +44,44 @@ func New(r repo.Interface) (*Helper, error) { return nil } - _, err = bundle.LoadMessageFile(path) + mfb, err := ioutil.ReadAll(rs) + if err != nil { + return err + } + _, err = bundle.ParseMessageFileBytes(mfb, path) if err != nil { return fmt.Errorf("i18n: failed to parse file %s: %w", path, err) } + fmt.Println("loaded", path) + return nil + } + + // walk the embedded defaults + err := vfsutil.WalkFiles(Defaults, "/", walkFn) + if err != nil { // && !os.IsNotExist(err) { + return nil, fmt.Errorf("i18n: failed to iterate localizations: %w", err) + } + + // walk the local filesystem for overrides and additions + err = filepath.Walk(r.GetPath("i18n"), func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + rs, err := os.Open(path) + if err != nil { + return err + } + defer rs.Close() + + err = walkFn(path, info, rs, err) + if err != nil { + return err + } return nil }) @@ -73,9 +112,7 @@ func (l Localizer) LocalizeSimple(messageID string) string { return msg } - // TODO: could panic() and let the http recovery handle this? - // might also be easier to catch in testing - return fmt.Sprintf("i18n/error: failed to localize %s: %s", messageID, err) + panic(fmt.Sprintf("i18n/error: failed to localize label %s: %s", messageID, err)) } func (l Localizer) LocalizePlurals(messageID string, pluralCount int) string { @@ -87,9 +124,7 @@ func (l Localizer) LocalizePlurals(messageID string, pluralCount int) string { return msg } - // TODO: could panic() and let the http recovery handle this? - // might also be easier to catch in testing - return fmt.Sprintf("i18n/error: failed to localize %s: %s", messageID, err) + panic(fmt.Sprintf("i18n/error: failed to localize label %s: %s", messageID, err)) } func (l Localizer) LocalizePluralsWithData(messageID string, pluralCount int, tplData map[string]string) string { @@ -102,7 +137,5 @@ func (l Localizer) LocalizePluralsWithData(messageID string, pluralCount int, tp return msg } - // TODO: could panic() and let the http recovery handle this? - // might also be easier to catch in testing - return fmt.Sprintf("i18n/error: failed to localize %s: %s", messageID, err) + panic(fmt.Sprintf("i18n/error: failed to localize label %s: %s", messageID, err)) } diff --git a/web/templates/admin/dashboard.tmpl b/web/templates/admin/dashboard.tmpl index 9012eca..ca73fa1 100644 --- a/web/templates/admin/dashboard.tmpl +++ b/web/templates/admin/dashboard.tmpl @@ -1,4 +1,4 @@ -{{ define "title" }}{{i18n "AdminOverview"}}{{ end }} +{{ define "title" }}{{i18n "AdminDashboard"}}{{ end }} {{ define "content" }}