Add the language switched to the public layout

The language switcher needs the same information as languageLinks
needed, namely the list of locales and the current Path, to construct
the URI to all alternate versions.  However, in this case i need access
to this data in the template context, to build the list of links.

At first i use request’s context to hold the list of available locales
from application, and it worked, possibly without ill-effects, but i
realized that i was doing it just to avoid a new parameter.  Or, more
precise, an _explicit_ parameter; the context was used to skip the
inner functions between app and template.MustRenderPublic, but the
parameter was there all the same.

Finally, i thought that some handler might want to filter the list of
locales to show only the ones that it has a translation of.  In that
case, i would need to extract the locales from the context, filter it,
and create a new request with the updated context.  That made little
sense, and made me add the explicit locales parameter.

Since now the template has the same data as languageLinks, there is
little point of having the link in the HTTP response headers, and added
the <link> elements to <head>.

I thought that maybe i could avoid these <links> as they give the exact
same data as the language switch, but Google says nothing of using
regular anchors to gather information about localized versions of the
document[0], thus i opted to be conservative.  One can reason that the
<head> has more weight for Google, as most sites with user-generated
content, which could contain these anchors, rarely allow users to edit
the <head>.

[0]: https://developers.google.com/search/docs/specialty/international/localized-versions
This commit is contained in:
jordi fita mas 2023-08-06 05:53:52 +02:00
parent e3138652fa
commit 9a8ef8ce9f
6 changed files with 102 additions and 72 deletions

View File

@ -116,10 +116,7 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
user.Locale = urlLocale
if r.Method == http.MethodGet || r.Method == http.MethodHead {
w = httplib.LanguageLinks(w, false, r.Host, r.URL.Path, h.locales)
}
h.public.Handler(user, company, conn).ServeHTTP(w, r)
h.public.Handler(user, company, h.locales, conn).ServeHTTP(w, r)
}
}
}

View File

@ -6,11 +6,13 @@
package app
import (
"fmt"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
@ -20,15 +22,56 @@ func newPublicHandler() *publicHandler {
return &publicHandler{}
}
func (h *publicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
func (h *publicHandler) Handler(user *auth.User, company *auth.Company, locales locale.Locales, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
template.MustRenderPublic(w, r, user, company, "home.gohtml", nil)
page := newHomePage()
page.MustRender(w, r, user, company, locales)
default:
http.NotFound(w, r)
}
})
}
type homePage struct {
*PublicPage
}
func newHomePage() *homePage {
return &homePage{newPublicPage("home.gohtml")}
}
type PublicPage struct {
template string
LocalizedAlternates []*LocalizedAlternate
}
func newPublicPage(template string) *PublicPage {
return &PublicPage{
template: template,
}
}
func (p *PublicPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, locales locale.Locales) {
schema := httplib.Protocol(r)
authority := httplib.Host(r)
_, path := httplib.ShiftPath(r.RequestURI)
for _, l := range locales {
p.LocalizedAlternates = append(p.LocalizedAlternates, &LocalizedAlternate{
Lang: l.Language.String(),
Endonym: l.Endonym,
HRef: fmt.Sprintf("%s://%s/%s%s", schema, authority, l.Language, path),
})
}
template.MustRenderPublic(w, r, user, company, p.template, p)
}
type LocalizedAlternate struct {
Lang string
HRef string
Endonym string
}

View File

@ -1,49 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package http
import (
"fmt"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
)
type languageLinks struct {
http.ResponseWriter
schemaAuthority string
path string
locales locale.Locales
wroteHeader bool
}
func (w *languageLinks) WriteHeader(statusCode int) {
if statusCode >= 200 && statusCode < 300 {
for k := range w.locales {
tag := k.String()
w.Header().Add("Link", fmt.Sprintf(`<%[1]s/%[2]s%[3]s>; rel="alternate"; hreflang="%[2]s"`, w.schemaAuthority, tag, w.path))
}
}
w.wroteHeader = true
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *languageLinks) Write(data []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
func LanguageLinks(w http.ResponseWriter, https bool, authority string, path string, locales locale.Locales) http.ResponseWriter {
var schema string
if https {
schema = "https"
} else {
schema = "http"
}
return &languageLinks{w, schema + "://" + authority, path, locales, false}
}

View File

@ -42,3 +42,23 @@ func MethodNotAllowed(w http.ResponseWriter, _ *http.Request, allowed ...string)
w.Header().Set("Allow", strings.Join(allowed, ", "))
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
func Host(r *http.Request) string {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
return host
}
func IsHTTPS(r *http.Request) bool {
return r.Header.Get("X-Forwarded-Proto") == "https"
}
func Protocol(r *http.Request) string {
if IsHTTPS(r) {
return "https"
} else {
return "http"
}
}

View File

@ -7,6 +7,7 @@ package locale
import (
"context"
"github.com/leonelquinteros/gotext"
"golang.org/x/text/language"
@ -17,6 +18,7 @@ type Locale struct {
*gotext.Locale
CurrencyPattern string
Language language.Tag
Endonym string
}
type Locales map[language.Tag]*Locale
@ -36,23 +38,15 @@ func GetAll(ctx context.Context, db *database.DB) (Locales, error) {
if err != nil {
return nil, err
}
locales := map[language.Tag]*Locale{}
locales := Locales{}
for _, lang := range availableLanguages {
locale := newLocale(lang)
locale := lang.locale()
locale.AddDomain("camper")
locales[lang.tag] = locale
}
return locales, nil
}
func newLocale(lang availableLanguage) *Locale {
return &Locale{
gotext.NewLocale("locale", lang.tag.String()),
lang.currencyPattern,
lang.tag,
}
}
func (l *Locale) Gettext(str string) string {
return l.GetD(l.GetDomain(), str)
}
@ -85,25 +79,27 @@ func Match(acceptLanguage string, locales Locales, matcher language.Matcher) *Lo
type availableLanguage struct {
tag language.Tag
endonym string
currencyPattern string
}
func getAvailableLanguages(ctx context.Context, db *database.DB) ([]availableLanguage, error) {
rows, err := db.Query(ctx, "select lang_tag, currency_pattern from language where selectable")
func getAvailableLanguages(ctx context.Context, db *database.DB) ([]*availableLanguage, error) {
rows, err := db.Query(ctx, "select lang_tag, endonym, currency_pattern from language where selectable")
if err != nil {
return nil, err
}
defer rows.Close()
var languages []availableLanguage
var languages []*availableLanguage
for rows.Next() {
lang := &availableLanguage{}
var langTag string
var currencyPattern string
err = rows.Scan(&langTag, &currencyPattern)
err = rows.Scan(&langTag, &lang.endonym, &lang.currencyPattern)
if err != nil {
return nil, err
}
languages = append(languages, availableLanguage{language.MustParse(langTag), currencyPattern})
lang.tag = language.MustParse(langTag)
languages = append(languages, lang)
}
if rows.Err() != nil {
return nil, rows.Err()
@ -111,3 +107,12 @@ func getAvailableLanguages(ctx context.Context, db *database.DB) ([]availableLan
return languages, nil
}
func (lang *availableLanguage) locale() *Locale {
return &Locale{
gotext.NewLocale("locale", lang.tag.String()),
lang.currencyPattern,
lang.tag,
lang.endonym,
}
}

View File

@ -2,6 +2,7 @@
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.PublicPage*/ -}}
<!doctype html>
<html lang="{{ currentLocale }}">
<head>
@ -9,12 +10,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ template "title" . }} — {{( gettext "Campsite Montagut" )}}</title>
<link rel="stylesheet" media="screen" href="/static/public.css">
{{ block "head" . }}{{ end }}
{{ range .LocalizedAlternates -}}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .HRef }}"/>
{{ end }}
{{- block "head" . }}{{ end }}
</head>
<body>
<header>
<a href="#content">{{( gettext "Skip to main content" )}}</a>
<h1>{{( gettext "Campsite Montagut" )}}</h1>
{{ if .LocalizedAlternates -}}
<nav>
<ul>
{{ range .LocalizedAlternates -}}
<li><a rel="alternate" href="{{ .HRef }}" hreflang="{{ .Lang }}"
lang="{{ .Lang }}">{{ .Endonym }}</a></li>
{{ end }}
</ul>
</nav>
{{- end }}
</header>
<main id="content">
{{- template "content" . }}