package ersteller import ( "context" "encoding/json" "fmt" "net/http" "runtime" "runtime/debug" "sort" "strings" "maragu.dev/gomponents" htmx "maragu.dev/gomponents-htmx" ) const AuthContextKey = "authContext" type AuthContext struct { Email string UserId int } 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 GetAuthContext() (bool, AuthContext) GetGoContext() context.Context } type HtmxContextImpl struct { req *http.Request res http.ResponseWriter language Language activePath *ActivePath pathTemplate string routes []HtmxRoute } func (c *HtmxContextImpl) GetGoContext() context.Context { return c.req.Context() } func (c *HtmxContextImpl) GetAuthContext() (bool, AuthContext) { authCtx := c.req.Context().Value(AuthContextKey) if authCtx == nil { return false, AuthContext{} } return true, authCtx.(AuthContext) } 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 GetActivePath() *ActivePath GetPaths() LanguagePaths Add(server HtmxServer) Execute(c HtmxContext) RedirectToThisRoute(c HtmxContext, params LocationRedirectParams) IsCurrentRoute(c HtmxContext) bool } type GlobalHtmxRoutes interface { GetRoutes() []HtmxRoute Add(route HtmxRoute) // MatchByPath tries to find a route whose template matches the given path // even if the path contains path parameters. Returns (route, true) if found. MatchByPath(path string) (*HtmxRouteInfo, bool) } type HtmxRouteInfo struct { initTrace string route HtmxRoute } type GlobalHtmxRoutesImpl struct { routes map[string]*HtmxRouteInfo } var GlobalHtmxRoutesInstance GlobalHtmxRoutes = NewGlobalHtmxRoutesImpl() func (g *GlobalHtmxRoutesImpl) GetRoutes() []HtmxRoute { routes := make([]HtmxRoute, len(g.routes)) index := 0 for _, route := range g.routes { Debug("index: ", index, "route: ", route, "") routes[index] = route.route index++ } return routes } func (g *GlobalHtmxRoutesImpl) Add(route HtmxRoute) { paths := route.GetPaths() uniqueKey := "" for language, path := range paths { uniqueKey += string(language) + path } g.routes[uniqueKey] = &HtmxRouteInfo{ initTrace: string(debug.Stack()), route: route, } } // MatchByPath tries to find a route whose language-specific template matches the given // concrete URL path. It treats path parameters like {id} as wildcards. func (g *GlobalHtmxRoutesImpl) MatchByPath(path string) (*HtmxRouteInfo, bool) { p := normalizePath(path) // Extract language as first segment (if present) trim := strings.Trim(p, "/") segs := []string{} if trim != "" { segs = strings.Split(trim, "/") } if len(segs) == 0 { return nil, false } lang := Language(segs[0]) for _, info := range g.routes { paths := info.route.GetPaths() tpl, ok := paths[lang] if !ok { continue } candidate := addLanguageToPath(tpl, lang) if matchTemplate(candidate, p) { return info, true } } return nil, false } func normalizePath(path string) string { if path == "" { return "/" } // strip query string and fragment if i := strings.Index(path, "?"); i >= 0 { path = path[:i] } if i := strings.Index(path, "#"); i >= 0 { path = path[:i] } if !strings.HasPrefix(path, "/") { path = "/" + path } // remove duplicate slashes (basic) for strings.Contains(path, "//") { path = strings.ReplaceAll(path, "//", "/") } // drop trailing slash except root if len(path) > 1 && strings.HasSuffix(path, "/") { path = strings.TrimSuffix(path, "/") } return path } func matchTemplate(templatePath, concretePath string) bool { // Normalize both t := normalizePath(templatePath) p := normalizePath(concretePath) // Quick equality short-circuit if t == p { return true } // Compare segment-wise tSegs := strings.Split(strings.Trim(t, "/"), "/") pSegs := strings.Split(strings.Trim(p, "/"), "/") if len(tSegs) != len(pSegs) { return false } for i := range tSegs { seg := tSegs[i] if isParamSegment(seg) { // wildcard for one segment continue } if seg != pSegs[i] { return false } } return true } func isParamSegment(seg string) bool { return strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") && len(seg) > 2 } func NewGlobalHtmxRoutesImpl() *GlobalHtmxRoutesImpl { return &GlobalHtmxRoutesImpl{routes: make(map[string]*HtmxRouteInfo)} } func addLanguageToPath(path string, language Language) string { return "/" + string(language) + path } // getCallerLocation returns the file location of the function that called NewCommonHtmxRoute func getCallerLocation() string { // Skip 2 frames: getCallerLocation itself and NewCommonHtmxRoute _, file, line, ok := runtime.Caller(3) if !ok { return "unknown location" } return fmt.Sprintf("%s:%d", file, line) } type CommonHtmxRoute struct { Paths LanguagePaths RouteFunc HtmxRouteFunc PathParams []HtmxPathParam ActivePath *ActivePath Method string HtmxMethod HtmxHttpMethodFunction createdLocation string } func (h CommonHtmxRoute) GetActivePath() *ActivePath { return h.ActivePath } func (h CommonHtmxRoute) GetPaths() LanguagePaths { return h.Paths } 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 { route := &CommonHtmxRoute{RouteFunc: routeFunc, Paths: paths, Method: method, HtmxMethod: htmxMethod, createdLocation: getCallerLocation()} GlobalHtmxRoutesInstance.Add(route) return route } func (h CommonHtmxRoute) Execute(c HtmxContext) { h.traceLocation() h.RouteFunc(c) } func (h CommonHtmxRoute) SetActivePath(activePath *ActivePath) HtmxRoute { h.ActivePath = activePath return h } var HtmxRouteDebugTrace bool = false 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) { h.traceLocation() 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) traceLocation() { if HtmxRouteDebugTrace { Debug(h.createdLocation) } } 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 == "/" { context := NewHtmxContext(r, w, defaultLanguage) context.SetActivePath(defaultRoute.GetActivePath()) defaultRoute.Execute(context) return } http.Error(w, "Not found", http.StatusNotFound) }) }