package ersteller_lib import ( "encoding/json" "fmt" "net/http" "sort" "strings" "maragu.dev/gomponents" htmx "maragu.dev/gomponents-htmx" ) type HtmxContext interface { Render(node gomponents.Node) SetError(err error) GetLanguage() Language GetActivePath() *ActivePath 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 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) { c.res.Header().Set(key, value) } func (c *HtmxContextImpl) GetFormValue(key string) string { err := c.req.ParseForm() if err != nil { Error("failed to parse form", err) c.SetError(err) return "" } return c.req.FormValue(key) } func (c *HtmxContextImpl) SetError(err error) { _, writeErr := c.res.Write([]byte(err.Error())) if writeErr != nil { Error("failed to write error", writeErr) } c.res.WriteHeader(500) } func (c *HtmxContextImpl) GetPathParam(key string, defaultValue ...string) string { pathParam := c.req.PathValue(key) if pathParam != "" { return pathParam } if len(defaultValue) > 0 { return defaultValue[0] } return "" } func (c *HtmxContextImpl) GetPath() string { return c.req.URL.Path } func (c *HtmxContextImpl) SetActivePath(activePath *ActivePath) { c.activePath = activePath } func (c *HtmxContextImpl) GetActivePath() *ActivePath { return c.activePath } func (c *HtmxContextImpl) HasActivePath() bool { return c.activePath != nil } func (c *HtmxContextImpl) GetLanguage() Language { return c.language } func NewHtmxContext(req *http.Request, res http.ResponseWriter, language Language) HtmxContext { return &HtmxContextImpl{req: req, res: res, language: language} } func (c *HtmxContextImpl) Render(node gomponents.Node) { err := node.Render(c.res) if err != nil { Error("failed to render", err) c.SetError(err) } } type HtmxRouteFunc func(ctx HtmxContext) type HtmxPathParam struct { Key string Value string } type LocationRedirectParams struct { Path string `json:"path"` Target string `json:"target,omitempty"` Swap string `json:"swap,omitempty"` } 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 HtmxServer) Execute(c HtmxContext) RedirectToThisRoute(c HtmxContext, params LocationRedirectParams) IsCurrentRoute(c HtmxContext) bool } func addLanguageToPath(path string, language Language) string { return "/" + string(language) + path } type CommonHtmxRoute struct { Paths LanguagePaths RouteFunc HtmxRouteFunc PathParams []HtmxPathParam ActivePath *ActivePath Method string 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) if err != nil { marshalError := fmt.Errorf("failed to marshal LocationRedirectParams: %w, %w", err, params) Error(marshalError) c.SetError(marshalError) return } c.SetHeader("HX-Location", string(jsonData)) } func NewCommonHtmxRoute(routeFunc HtmxRouteFunc, paths LanguagePaths, method string, htmxMethod HtmxHttpMethodFunction) *CommonHtmxRoute { return &CommonHtmxRoute{RouteFunc: routeFunc, Paths: paths, Method: method, HtmxMethod: htmxMethod} } func (h CommonHtmxRoute) Execute(c HtmxContext) { h.RouteFunc(c) } func (h CommonHtmxRoute) SetActivePath(activePath *ActivePath) HtmxRoute { h.ActivePath = activePath return h } func (h CommonHtmxRoute) AddWithMethod(method string, server HtmxServer) { for language, path := range h.Paths { 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) } h.RouteFunc(context) }) } } func (h CommonHtmxRoute) Add(server HtmxServer) { server.Add(h) h.AddWithMethod(h.Method, server) } func (h CommonHtmxRoute) WithPathParams(params ...HtmxPathParam) HtmxRoute { h.PathParams = params return h } func (h CommonHtmxRoute) ToUrl(language Language, queryParams ...HtmxPathParam) string { path := addLanguageToPath(h.Paths[language], language) if len(h.PathParams) == 0 { return path } for _, param := range h.PathParams { path = strings.ReplaceAll(path, "{"+param.Key+"}", param.Value) } 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 { return method(h.ToUrl(language, queryParams...)) } func (h CommonHtmxRoute) GetHtmx(language Language, queryParams ...HtmxPathParam) gomponents.Node { return h.GetHtmxFromMethod(h.HtmxMethod, language, queryParams...) } func NewHtmxGetRoute(routeFunc HtmxRouteFunc, paths LanguagePaths) HtmxRoute { return NewCommonHtmxRoute(routeFunc, paths, "GET", htmx.Get) } func NewHtmxPostRoute(routeFunc HtmxRouteFunc, paths LanguagePaths) HtmxRoute { return NewCommonHtmxRoute(routeFunc, paths, "POST", htmx.Post) } 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"))) 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) return } if path == "/" { defaultRoute.Execute(NewHtmxContext(r, w, defaultLanguage)) return } http.Error(w, "Not found", http.StatusNotFound) }) }