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.
+150 -10
View File
@@ -3,10 +3,12 @@ package ersteller_lib
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"maragu.dev/gomponents"
htmx "maragu.dev/gomponents-htmx"
"net/http"
"strings"
)
type HtmxContext interface {
@@ -17,9 +19,14 @@ type HtmxContext interface {
HasActivePath() bool
SetActivePath(activePath *ActivePath)
GetPath() string
GetPathTemplate() string
SetPathTemplate(pathTemplate string)
GetPathParam(key string, defaultValue ...string) string
GetFormValue(key string) string
SetHeader(key string, value string)
SetAllRoutes(routes []HtmxRoute)
GetAllRoutes() []HtmxRoute
GetQueryParams() []HtmxPathParam
}
type HtmxContextImpl struct {
@@ -27,6 +34,59 @@ type HtmxContextImpl struct {
res http.ResponseWriter
language Language
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) {
@@ -110,11 +170,13 @@ type LocationRedirectParams struct {
type HtmxRoute interface {
WithPathParams(params ...HtmxPathParam) HtmxRoute
ToUrl(language Language, queryParams ...HtmxPathParam) string
ToUrlFromContext(c HtmxContext, language Language) string
GetHtmx(language Language, queryParams ...HtmxPathParam) gomponents.Node
SetActivePath(activePath *ActivePath) HtmxRoute
Add(server *http.ServeMux)
Add(server HtmxServer)
Execute(c HtmxContext)
RedirectToThisRoute(c HtmxContext, params LocationRedirectParams)
IsCurrentRoute(c HtmxContext) bool
}
func addLanguageToPath(path string, language Language) string {
@@ -130,6 +192,18 @@ type CommonHtmxRoute struct {
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) {
params.Path = h.ToUrl(c.GetLanguage())
jsonData, err := json.Marshal(params)
@@ -156,10 +230,12 @@ func (h CommonHtmxRoute) SetActivePath(activePath *ActivePath) HtmxRoute {
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 {
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.SetPathTemplate(addLanguageToPath(path, language))
context.SetAllRoutes(server.GetAllRoutes())
if h.ActivePath != nil {
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)
}
@@ -188,6 +265,41 @@ func (h CommonHtmxRoute) ToUrl(language Language, queryParams ...HtmxPathParam)
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
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)
}
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")))
server.Handle("GET /static", staticHandler)
return func(w http.ResponseWriter, r *http.Request) {
h.server.Handle("GET /static", staticHandler)
h.server.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasPrefix(path, "/static") {
staticHandler.ServeHTTP(w, r)
@@ -220,5 +360,5 @@ func HandleStaticAndDefaultPath(server *http.ServeMux, defaultRoute HtmxRoute, d
return
}
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/
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
s = r
s = r.GetHttpServer()
for _, mw := range middlewares {
s = mw(s)
+1
View File
@@ -25,6 +25,7 @@ type PageWebsiteMetaData struct {
Description I18nText
NavItems []ActivePath
FooterNavItems []ActivePath
AllRoutes []HtmxRoute
ScriptSrcs []string
StyleSrcs []string
ActiveNavPath string