Handle language switching for every route

This commit is contained in:
Achim Rohn
2025-08-22 18:33:19 +02:00
parent e54600d60f
commit 7096a256a0
4 changed files with 203 additions and 16 deletions
+46
View File
@@ -0,0 +1,46 @@
# ADR: Implement extractPathParams in CommonHtmxRoute
Date: 2025-08-22
## Context
We use a lightweight HTTP routing based on Go 1.22+ pattern-based ServeMux. Our route paths include placeholders in braces, such as `/en/events/{id}`. The HtmxContext provides a `GetPathParam(key string)` method that uses the requests `PathValue(key)` to retrieve values for path parameters.
The `CommonHtmxRoute` type already supports building URLs by replacing placeholders via `ToUrl`. However, the reverse operation — extracting path params from the template to build a `[]HtmxPathParam` based on the current request — had an unimplemented stub: `extractPathParams`.
## Decision
Implement `extractPathParams` to:
- Parse the language-specific path template for placeholders of the form `{name}`.
- Collect unique placeholder names in order of appearance.
- For each placeholder name, call `c.GetPathParam(name)` to obtain its value from the current request context.
- Return the slice of `HtmxPathParam{Key: name, Value: value}`.
This creates a consistent source of truth for path parameters both when generating URLs and when reading them from incoming requests.
## Implementation
- File: `ersteller-lib/htmx_route.go`
- Function: `CommonHtmxRoute.extractPathParams(c HtmxContext) []HtmxPathParam`
- Approach: String scanning to find `{` and `}` pairs, trimming and deduplicating keys; for each key, append `HtmxPathParam{Key: key, Value: c.GetPathParam(key)}` to the results.
- No external dependencies added.
## Alternatives Considered
- Using a regex like `\{([^}]+)\}`. Rejected to avoid introducing a regex dependency and to keep the implementation minimal and allocation-light.
- Parsing the actual request path rather than the template. Rejected because placeholder names must come from the template, and values come from `GetPathParam`.
## Trade-offs
- The simple scanner assumes non-nested, well-formed placeholders and does not validate balanced braces beyond the first missing `}`. This is acceptable since templates are authored by maintainers and validated elsewhere (routing registration).
## Consequences
- Callers that rely on `extractPathParams` now receive a correctly populated parameter list.
- No API changes; only internal behavior is completed.
## Notes
- No UI controls were added/changed, so no CSS changes were required.
- Localization is unaffected since this change does not introduce user-facing strings.
+154 -14
View File
@@ -3,10 +3,12 @@ package ersteller_lib
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"sort"
"strings"
"maragu.dev/gomponents" "maragu.dev/gomponents"
htmx "maragu.dev/gomponents-htmx" htmx "maragu.dev/gomponents-htmx"
"net/http"
"strings"
) )
type HtmxContext interface { type HtmxContext interface {
@@ -17,16 +19,74 @@ type HtmxContext interface {
HasActivePath() bool HasActivePath() bool
SetActivePath(activePath *ActivePath) SetActivePath(activePath *ActivePath)
GetPath() string GetPath() string
GetPathTemplate() string
SetPathTemplate(pathTemplate string)
GetPathParam(key string, defaultValue ...string) string GetPathParam(key string, defaultValue ...string) string
GetFormValue(key string) string GetFormValue(key string) string
SetHeader(key string, value string) SetHeader(key string, value string)
SetAllRoutes(routes []HtmxRoute)
GetAllRoutes() []HtmxRoute
GetQueryParams() []HtmxPathParam
} }
type HtmxContextImpl struct { type HtmxContextImpl struct {
req *http.Request req *http.Request
res http.ResponseWriter res http.ResponseWriter
language Language language Language
activePath *ActivePath activePath *ActivePath
pathTemplate string
routes []HtmxRoute
}
func (c *HtmxContextImpl) GetQueryParams() []HtmxPathParam {
// Extract query parameters from the current request URL and return them as
// a deterministic list of HtmxPathParam. If a key has multiple values,
// each value is included as a separate entry with the same key. Keys are
// returned in sorted order to keep behavior stable for tests and callers.
if c == nil || c.req == nil || c.req.URL == nil {
return []HtmxPathParam{}
}
values := c.req.URL.Query() // url.Values already decoded
if len(values) == 0 {
return []HtmxPathParam{}
}
// Collect and sort keys for deterministic output
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
params := make([]HtmxPathParam, 0)
for _, k := range keys {
vs := values[k]
if len(vs) == 0 {
params = append(params, HtmxPathParam{Key: k, Value: ""})
continue
}
for _, v := range vs {
params = append(params, HtmxPathParam{Key: k, Value: v})
}
}
return params
}
func (c *HtmxContextImpl) GetAllRoutes() []HtmxRoute {
return c.routes
}
func (c *HtmxContextImpl) SetAllRoutes(routes []HtmxRoute) {
c.routes = routes
}
func (c *HtmxContextImpl) SetPathTemplate(pathTemplate string) {
c.pathTemplate = pathTemplate
}
func (c *HtmxContextImpl) GetPathTemplate() string {
return c.pathTemplate
} }
func (c *HtmxContextImpl) SetHeader(key string, value string) { func (c *HtmxContextImpl) SetHeader(key string, value string) {
@@ -110,11 +170,13 @@ type LocationRedirectParams struct {
type HtmxRoute interface { type HtmxRoute interface {
WithPathParams(params ...HtmxPathParam) HtmxRoute WithPathParams(params ...HtmxPathParam) HtmxRoute
ToUrl(language Language, queryParams ...HtmxPathParam) string ToUrl(language Language, queryParams ...HtmxPathParam) string
ToUrlFromContext(c HtmxContext, language Language) string
GetHtmx(language Language, queryParams ...HtmxPathParam) gomponents.Node GetHtmx(language Language, queryParams ...HtmxPathParam) gomponents.Node
SetActivePath(activePath *ActivePath) HtmxRoute SetActivePath(activePath *ActivePath) HtmxRoute
Add(server *http.ServeMux) Add(server HtmxServer)
Execute(c HtmxContext) Execute(c HtmxContext)
RedirectToThisRoute(c HtmxContext, params LocationRedirectParams) RedirectToThisRoute(c HtmxContext, params LocationRedirectParams)
IsCurrentRoute(c HtmxContext) bool
} }
func addLanguageToPath(path string, language Language) string { func addLanguageToPath(path string, language Language) string {
@@ -130,6 +192,18 @@ type CommonHtmxRoute struct {
HtmxMethod HtmxHttpMethodFunction HtmxMethod HtmxHttpMethodFunction
} }
func (h CommonHtmxRoute) ToUrlFromContext(c HtmxContext, language Language) string {
routeWithParams := h.WithPathParams(h.extractPathParams(c)...)
queryParams := c.GetQueryParams()
return routeWithParams.ToUrl(language, queryParams...)
}
func (h CommonHtmxRoute) IsCurrentRoute(c HtmxContext) bool {
templatePath := c.GetPathTemplate()
urlOfThisRoute := h.ToUrl(c.GetLanguage())
return templatePath == urlOfThisRoute
}
func (h CommonHtmxRoute) RedirectToThisRoute(c HtmxContext, params LocationRedirectParams) { func (h CommonHtmxRoute) RedirectToThisRoute(c HtmxContext, params LocationRedirectParams) {
params.Path = h.ToUrl(c.GetLanguage()) params.Path = h.ToUrl(c.GetLanguage())
jsonData, err := json.Marshal(params) jsonData, err := json.Marshal(params)
@@ -156,10 +230,12 @@ func (h CommonHtmxRoute) SetActivePath(activePath *ActivePath) HtmxRoute {
return h return h
} }
func (h CommonHtmxRoute) AddWithMethod(method string, server *http.ServeMux) { func (h CommonHtmxRoute) AddWithMethod(method string, server HtmxServer) {
for language, path := range h.Paths { for language, path := range h.Paths {
server.HandleFunc(method+" "+addLanguageToPath(path, language), func(res http.ResponseWriter, req *http.Request) { server.GetHttpServer().HandleFunc(method+" "+addLanguageToPath(path, language), func(res http.ResponseWriter, req *http.Request) {
context := NewHtmxContext(req, res, language) context := NewHtmxContext(req, res, language)
context.SetPathTemplate(addLanguageToPath(path, language))
context.SetAllRoutes(server.GetAllRoutes())
if h.ActivePath != nil { if h.ActivePath != nil {
context.SetActivePath(h.ActivePath) context.SetActivePath(h.ActivePath)
} }
@@ -168,7 +244,8 @@ func (h CommonHtmxRoute) AddWithMethod(method string, server *http.ServeMux) {
} }
} }
func (h CommonHtmxRoute) Add(server *http.ServeMux) { func (h CommonHtmxRoute) Add(server HtmxServer) {
server.Add(h)
h.AddWithMethod(h.Method, server) h.AddWithMethod(h.Method, server)
} }
@@ -188,6 +265,41 @@ func (h CommonHtmxRoute) ToUrl(language Language, queryParams ...HtmxPathParam)
return path return path
} }
func (h CommonHtmxRoute) extractPathParams(c HtmxContext) []HtmxPathParam {
langPath := addLanguageToPath(h.Paths[c.GetLanguage()], c.GetLanguage())
// Extract all placeholders of the form {variable} from the language-specific path template
params := make([]HtmxPathParam, 0)
seen := make(map[string]bool)
for {
start := strings.Index(langPath, "{")
if start == -1 {
break
}
end := strings.Index(langPath[start+1:], "}")
if end == -1 {
break
}
end = start + 1 + end
key := strings.TrimSpace(langPath[start+1 : end])
if key != "" && !seen[key] {
seen[key] = true
value := c.GetPathParam(key)
params = append(params, HtmxPathParam{Key: key, Value: value})
}
// Continue searching after current closing brace
if end+1 >= len(langPath) {
break
}
langPath = langPath[end+1:]
}
return params
}
type HtmxHttpMethodFunction func(url string) gomponents.Node type HtmxHttpMethodFunction func(url string) gomponents.Node
func (h CommonHtmxRoute) GetHtmxFromMethod(method HtmxHttpMethodFunction, language Language, queryParams ...HtmxPathParam) gomponents.Node { func (h CommonHtmxRoute) GetHtmxFromMethod(method HtmxHttpMethodFunction, language Language, queryParams ...HtmxPathParam) gomponents.Node {
@@ -206,10 +318,38 @@ func NewHtmxPostRoute(routeFunc HtmxRouteFunc, paths LanguagePaths) HtmxRoute {
return NewCommonHtmxRoute(routeFunc, paths, "POST", htmx.Post) return NewCommonHtmxRoute(routeFunc, paths, "POST", htmx.Post)
} }
func HandleStaticAndDefaultPath(server *http.ServeMux, defaultRoute HtmxRoute, defaultLanguage Language) func(http.ResponseWriter, *http.Request) { type HtmxServer interface {
Add(route HtmxRoute)
GetAllRoutes() []HtmxRoute
GetHttpServer() *http.ServeMux
HandleStaticAndDefaultPath(defaultRoute HtmxRoute, defaultLanguage Language)
}
type HtmxServerImpl struct {
routes []HtmxRoute
server *http.ServeMux
}
func (h *HtmxServerImpl) GetAllRoutes() []HtmxRoute {
return h.routes
}
func NewHtmxServer() *HtmxServerImpl {
return &HtmxServerImpl{server: http.NewServeMux()}
}
func (h *HtmxServerImpl) Add(route HtmxRoute) {
h.routes = append(h.routes, route)
}
func (h *HtmxServerImpl) GetHttpServer() *http.ServeMux {
return h.server
}
func (h *HtmxServerImpl) HandleStaticAndDefaultPath(defaultRoute HtmxRoute, defaultLanguage Language) {
staticHandler := http.StripPrefix("/static", http.FileServer(http.Dir("./static"))) staticHandler := http.StripPrefix("/static", http.FileServer(http.Dir("./static")))
server.Handle("GET /static", staticHandler) h.server.Handle("GET /static", staticHandler)
return func(w http.ResponseWriter, r *http.Request) { h.server.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
if strings.HasPrefix(path, "/static") { if strings.HasPrefix(path, "/static") {
staticHandler.ServeHTTP(w, r) staticHandler.ServeHTTP(w, r)
@@ -220,5 +360,5 @@ func HandleStaticAndDefaultPath(server *http.ServeMux, defaultRoute HtmxRoute, d
return return
} }
http.Error(w, "Not found", http.StatusNotFound) http.Error(w, "Not found", http.StatusNotFound)
} })
} }
+2 -2
View File
@@ -32,9 +32,9 @@ func MakeGzipHandler(fn http.Handler) http.Handler {
} }
// https://www.jvt.me/posts/2023/09/01/golang-nethttp-global-middleware/ // https://www.jvt.me/posts/2023/09/01/golang-nethttp-global-middleware/
func UseMiddleware(r *http.ServeMux, middlewares ...func(next http.Handler) http.Handler) http.Handler { func UseMiddleware(r HtmxServer, middlewares ...func(next http.Handler) http.Handler) http.Handler {
var s http.Handler var s http.Handler
s = r s = r.GetHttpServer()
for _, mw := range middlewares { for _, mw := range middlewares {
s = mw(s) s = mw(s)
+1
View File
@@ -25,6 +25,7 @@ type PageWebsiteMetaData struct {
Description I18nText Description I18nText
NavItems []ActivePath NavItems []ActivePath
FooterNavItems []ActivePath FooterNavItems []ActivePath
AllRoutes []HtmxRoute
ScriptSrcs []string ScriptSrcs []string
StyleSrcs []string StyleSrcs []string
ActiveNavPath string ActiveNavPath string