From 7096a256a00fbf79ecee470ceea6e030abbf6429 Mon Sep 17 00:00:00 2001 From: Achim Rohn Date: Fri, 22 Aug 2025 18:33:19 +0200 Subject: [PATCH] Handle language switching for every route --- doc/2025-08-22-extract-path-params.md | 46 +++++++ htmx_route.go | 168 +++++++++++++++++++++++--- middleware.go | 4 +- page.go | 1 + 4 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 doc/2025-08-22-extract-path-params.md diff --git a/doc/2025-08-22-extract-path-params.md b/doc/2025-08-22-extract-path-params.md new file mode 100644 index 0000000..2096cfe --- /dev/null +++ b/doc/2025-08-22-extract-path-params.md @@ -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. + diff --git a/htmx_route.go b/htmx_route.go index 5d8bc3d..9db68e4 100644 --- a/htmx_route.go +++ b/htmx_route.go @@ -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,16 +19,74 @@ 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 { - req *http.Request - res http.ResponseWriter - language Language - activePath *ActivePath + req *http.Request + 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) - } + }) } diff --git a/middleware.go b/middleware.go index 7553b9c..f558c43 100644 --- a/middleware.go +++ b/middleware.go @@ -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) diff --git a/page.go b/page.go index 3397d1a..77060d0 100644 --- a/page.go +++ b/page.go @@ -25,6 +25,7 @@ type PageWebsiteMetaData struct { Description I18nText NavItems []ActivePath FooterNavItems []ActivePath + AllRoutes []HtmxRoute ScriptSrcs []string StyleSrcs []string ActiveNavPath string