Add Catalan and Spanish translation with gotext[3]

I had to choose between [1], [2], and [3].

As far as i could find, [1] is not easy to work with templates[4] and at
the moment is not maintained[5].

Both [2] and [3] use the same approach to be used from within templates:
you have to define a FuncMap with template functions that call the
message catalog.  Also, both libraries seems to be reasonably
maintained, and have packages in Debian’s repository.

However, [2] repeats the same mistakes that POSIX did with its
catalogs—using identifiers that are not the strings in the source
language—, however this time the catalogs are written in JSON or YAML!
This, somehow, makes things worse….

[3], the one i settled with, is fine and decently maintained.  There are
some surprising things, such as to be able to use directly the PO file,
and that it has higher priority over the corresponding MO, or that the
order of parameters is reversed in respect to gettext.  However, it uses
a saner format, and is a lot easier to work with than [3].

The problem, of course, is that xgettext does not know how to find
translatable strings inside the template.  [3] includes a CLI tool
similar to xgettext, but is not a drop-in replacement[6] and does not
process templates.

The proper way to handle this would be to add a parser to xgettext, but
for now i found out that if i surround the call to the translation
functions from within the template with parentheses, i can trick
xgettext into believing it is parsing Scheme code, and extracts the
strings successfully—at least, for what i have tried.  Had to add the
keyword for pgettext, because Schemed does not have it, but at least i
can do that with command line parameters.

For now i left only Spanish and Catalan as the two available languages,
even though the source text is written in English, because that way i
can make sure i do not leave strings untranslated.

[1]: https://golang.org/x/text
[2]: https://github.com/nicksnyder/go-i18n
[3]: https://github.com/leonelquinteros/gotext
[4]: https://github.com/golang/go/issues/39954
[5]: https://github.com/golang/go/issues/12750
[6]: https://github.com/leonelquinteros/gotext/issues/38
This commit is contained in:
jordi fita mas 2023-01-18 19:07:42 +01:00
parent afd4bc16b7
commit e38420697b
15 changed files with 235 additions and 51 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
locales/
po/*.pot

17
Makefile Normal file
View File

@ -0,0 +1,17 @@
INPUT_FILES := $(shell find web -name *.html)
DEFAULT_DOMAIN = numerus
POT_FILE = po/$(DEFAULT_DOMAIN).pot
LINGUAS = ca es
MO_FILES = $(patsubst %,locales/%/LC_MESSAGES/$(DEFAULT_DOMAIN).mo,$(LINGUAS))
locales: $(MO_FILES)
locales/%/LC_MESSAGES/numerus.mo: po/%.po
mkdir -p $(@D)
msgfmt -o $@ $<
po/%.po: $(POT_FILE)
msgmerge --no-wrap --update --backup=off $@ $<
$(POT_FILE): $(INPUT_FILES)
xgettext --no-wrap --language=Scheme --from-code=UTF-8 --output=$@ --keyword=pgettext:1,2c --package-name=numerus --msgid-bugs-address=jordi@tandem.blog $^

5
debian/control vendored
View File

@ -5,8 +5,11 @@ Maintainer: jordi fita mas <jordi@tandem.blog>
Build-Depends:
debhelper-compat (= 13),
dh-golang,
gettext,
golang-any,
golang-github-jackc-pgx-v4-dev,
golang-github-leonelquinteros-gotext-dev,
golang-golang-x-text-dev,
Standards-Version: 4.6.0
XS-Go-Import-Path: dev.tandem.ws/tandem/numerus
Vcs-Browser: https://dev.tandem.ws/tandem/numerus
@ -56,4 +59,4 @@ Description: Simple invoicing and accounting web application
A simple web application to keep invoice and accouting records, intended for
contractors working in Spain.
.
This is the demo SQL script.
This is the demo package.

View File

@ -1,2 +1,3 @@
usr/bin/numerus usr/bin
locales usr/share/numerus
web usr/share/numerus

3
debian/rules vendored
View File

@ -2,3 +2,6 @@
%:
dh $@ --builddirectory=_build --buildsystem=golang --with=golang
execute_before_dh_auto_build:
make

7
go.mod
View File

@ -2,7 +2,11 @@ module dev.tandem.ws/tandem/numerus
go 1.18
require github.com/jackc/pgx/v4 v4.17.2
require (
github.com/jackc/pgx/v4 v4.17.2
github.com/leonelquinteros/gotext v1.5.1
golang.org/x/text v0.3.8
)
require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
@ -14,5 +18,4 @@ require (
github.com/jackc/pgtype v1.12.0 // indirect
github.com/jackc/puddle v1.3.0 // indirect
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect
golang.org/x/text v0.3.8 // indirect
)

10
go.sum
View File

@ -69,6 +69,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leonelquinteros/gotext v1.5.1 h1:vmddRn3gHp67YFjZLZE2AZsgYMT4IBTJhua4yfe7/4Q=
github.com/leonelquinteros/gotext v1.5.1/go.mod h1:/A4Y7BvIsf5JHO60E43ZQDVkV3qO+7eP8HjeqD6ChIA=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -105,6 +107,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@ -126,19 +129,23 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -152,6 +159,7 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -169,7 +177,9 @@ golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

50
pkg/locale.go Normal file
View File

@ -0,0 +1,50 @@
package pkg
import (
"context"
"net/http"
"github.com/leonelquinteros/gotext"
"golang.org/x/text/language"
)
const contextLocaleKey = "numerus-locale"
func Locale(next http.Handler) http.Handler {
supportedLanguages := []language.Tag{
language.Catalan,
language.Spanish,
}
var matcher = language.NewMatcher(supportedLanguages)
locales := map[language.Tag]*gotext.Locale{}
for _, lang := range supportedLanguages {
locale := gotext.NewLocale("locales", lang.String())
locale.AddDomain("numerus")
locales[lang] = locale
}
defaultLocale := locales[language.Catalan]
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var locale *gotext.Locale
t, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language"))
if err == nil {
tag, _, _ := matcher.Match(t...)
var ok bool
locale, ok = locales[tag]
for !ok && !tag.IsRoot() {
tag = tag.Parent()
locale, ok = locales[tag]
}
}
if locale == nil {
locale = defaultLocale
}
ctx := context.WithValue(r.Context(), contextLocaleKey, locale)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getLocale(r *http.Request) *gotext.Locale {
return r.Context().Value(contextLocaleKey).(*gotext.Locale)
}

View File

@ -2,7 +2,6 @@ package pkg
import (
"context"
"html/template"
"net"
"net/http"
"time"
@ -17,7 +16,7 @@ const (
)
type LoginPage struct {
LoginError string
LoginError bool
Email string
Password string
}
@ -33,30 +32,26 @@ func LoginHandler(db *Db) http.Handler {
user := getUser(r)
if user.LoggedIn {
http.Redirect(w, r, "/", http.StatusSeeOther)
} else {
r.ParseForm()
page := LoginPage{
Email: r.FormValue("email"),
Password: r.FormValue("password"),
}
return
}
r.ParseForm()
page := LoginPage{
Email: r.FormValue("email"),
Password: r.FormValue("password"),
}
if r.Method == "POST" {
cookie := db.Text(r, "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r))
if cookie == "" {
page.LoginError = "Invalid user or password"
w.WriteHeader(http.StatusUnauthorized)
t, err := template.ParseFiles("web/template/login.html")
if err != nil {
panic(err)
}
err = t.Execute(w, page)
if err != nil {
panic(err)
}
} else {
if cookie != "" {
http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour))
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusUnauthorized)
page.LoginError = true
} else {
w.WriteHeader(http.StatusOK)
}
renderTemplate(w, r, "login.html", page)
})
}
@ -67,7 +62,7 @@ func LogoutHandler(db *Db) http.Handler {
db.Exec(r, "select logout()")
http.SetCookie(w, createSessionCookie("", -24*time.Hour))
}
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
}

View File

@ -1,7 +1,6 @@
package pkg
import (
"html/template"
"net/http"
)
@ -13,27 +12,13 @@ func NewRouter(db *Db) http.Handler {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.LoggedIn {
t, err := template.ParseFiles("web/template/index.html")
if err != nil {
panic(err)
}
err = t.Execute(w, nil)
if err != nil {
panic(err)
}
renderTemplate(w, r, "index.html", nil)
} else {
var page LoginPage;
t, err := template.ParseFiles("web/template/login.html")
if err != nil {
panic(err)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
err = t.Execute(w, page)
if err != nil {
panic(err)
}
}
})
var handler http.Handler = router
handler = Locale(handler)
handler = CheckLogin(db, handler)
handler = Recoverer(handler)
handler = Logger(handler)

22
pkg/template.go Normal file
View File

@ -0,0 +1,22 @@
package pkg
import (
"html/template"
"io"
"net/http"
)
func renderTemplate(wr io.Writer, r *http.Request, filename string, data interface{}) {
locale := getLocale(r)
t := template.New(filename)
t.Funcs(template.FuncMap{
"gettext": locale.Get,
"pgettext": locale.GetC,
})
if _, err := t.ParseFiles("web/template/" + filename); err != nil {
panic(err)
}
if err := t.Execute(wr, data); err != nil {
panic(err)
}
}

47
po/ca.po Normal file
View File

@ -0,0 +1,47 @@
# Catalan translations for numerus package
# Traduccions al català del paquet «numerus».
# Copyright (C) 2023 jordi fita mas
# This file is distributed under the same license as the numerus package.
# jordi fita mas <jordi@tandem.blog>, 2023.
#
msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-18 17:54+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
"Language: ca\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: web/template/login.html:6 web/template/login.html:19
msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/template/login.html:14
msgid "Invalid user or password"
msgstr "Nom dusuari o contrasenya incorrectes"
#: web/template/login.html:21
msgctxt "input"
msgid "Email"
msgstr "Correu electrònic"
#: web/template/login.html:24
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
#: web/template/login.html:27
msgctxt "action"
msgid "Login"
msgstr "Entra"
#: web/template/index.html:16
msgctxt "action"
msgid "Logout"
msgstr "Surt"

47
po/es.po Normal file
View File

@ -0,0 +1,47 @@
# Spanish translations for numerus package.
# Copyright (C) 2023 jordi fita mas
# This file is distributed under the same license as the numerus package.
# jordi fita mas <jordi@tandem.blog>, 2023.
#
msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-18 17:54+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: web/template/login.html:6 web/template/login.html:19
msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/template/login.html:14
msgid "Invalid user or password"
msgstr "Nombre de usuario o contraseña inválido"
#: web/template/login.html:21
msgctxt "input"
msgid "Email"
msgstr "Correo electrónico"
#: web/template/login.html:24
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
#: web/template/login.html:27
msgctxt "action"
msgid "Login"
msgstr "Entrar"
#: web/template/index.html:16
msgctxt "action"
msgid "Logout"
msgstr "Salir"

View File

@ -13,12 +13,11 @@
<button aria-haspopup="true">
<svg role="image" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="50" height="50"><path fill="none" d="M0 0h24v24H0z"/><path d="M9.342 18.782l-1.931-.518.787-2.939a10.988 10.988 0 0 1-3.237-1.872l-2.153 2.154-1.415-1.415 2.154-2.153a10.957 10.957 0 0 1-2.371-5.07l1.968-.359C3.903 10.812 7.579 14 12 14c4.42 0 8.097-3.188 8.856-7.39l1.968.358a10.957 10.957 0 0 1-2.37 5.071l2.153 2.153-1.415 1.415-2.153-2.154a10.988 10.988 0 0 1-3.237 1.872l.787 2.94-1.931.517-.788-2.94a11.072 11.072 0 0 1-3.74 0l-.788 2.94z"/></svg></button>
<ul>
<li><form method="POST" action="/logout"><button type="submit">Logout</button></form></li>
<li><form method="POST" action="/logout"><button type="submit">{{( pgettext "Logout" "action" )}}</button></form></li>
</ul>
</nav>
</header>
<main>
<h2>Welcome</h2>
</main>
</body>
</html>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login — Numerus</title>
<title>{{( pgettext "Login" "title" )}} — Numerus</title>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
</head>
<body class="web">
@ -11,20 +11,20 @@
{{ if .LoginError }}
<div class="error" role="alert">
<p>{{ .LoginError }}</p>
<p>{{( gettext "Invalid user or password" )}}</p>
</div>
{{ end }}
<section id="login">
<h2>Login</h2>
<h2>{{( pgettext "Login" "title" )}}</h2>
<form method="POST" action="/login">
<label for="user_email">Email</label>
<label for="user_email">{{( pgettext "Email" "input" )}}</label>
<input id="user_email" type="email" required autofocus name="email" autocapitalize="none" value="{{ .Email }}">
<label for="user_password">Password</label>
<label for="user_password">{{( pgettext "Password" "input" )}}</label>
<input id="user_password" type="password" required name="password" autocomplete="current-password" value="{{ .Password }}">
<button type="submit">Login</button>
<button type="submit">{{( pgettext "Login" "action" )}}</button>
</form>
</section>
</body>