Handle language switching for every route
This commit is contained in:
@@ -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 request’s `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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user