diff --git a/internal/routes/routes.go b/internal/routes/routes.go index f197256..768e68d 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -3,6 +3,7 @@ package routes import ( "io/fs" "net/http" + "strings" "time" "git.juancwu.dev/juancwu/budgit/assets" @@ -12,6 +13,10 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/router" ) +// registry holds the most recently built router so templates can resolve +// named routes via URL(). SetupRoutes assigns this during construction. +var registry *router.Router + func SetupRoutes(a *app.App) http.Handler { authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) homeH := handler.NewHomeHandler() @@ -20,6 +25,7 @@ func SetupRoutes(a *app.App) http.Handler { redirectH := handler.NewRedirectHandler() r := router.New() + registry = r // Global middleware r.Use( @@ -45,11 +51,11 @@ func SetupRoutes(a *app.App) http.Handler { ) // Public pages - r.Get("/{$}", homeH.HomePage) - r.Get("/forbidden", homeH.ForbiddenPage) - r.Get("/privacy", homeH.PrivacyPage) - r.Get("/terms", homeH.TermsPage) - r.Get("/join/{token}", authH.JoinSpace) + r.Get("/{$}", homeH.HomePage).Name("page.public.home") + r.Get("/forbidden", homeH.ForbiddenPage).Name("page.public.forbidden") + r.Get("/privacy", homeH.PrivacyPage).Name("page.public.privacy") + r.Get("/terms", homeH.TermsPage).Name("page.public.terms") + r.Get("/join/{token}", authH.JoinSpace).Name("page.public.join-space") // Permanent redirects r.Get("/app/dashboard", redirectH.Spaces) @@ -57,39 +63,39 @@ func SetupRoutes(a *app.App) http.Handler { // Auth - guest routes r.Group("/auth", func(g *router.Group) { g.Use(middleware.RequireGuest) - g.Get("", authH.AuthPage) - g.Get("/password", authH.PasswordPage) - g.Get("/magic-link/{token}", authH.VerifyMagicLink) + g.Get("", authH.AuthPage).Name("page.auth.index") + g.Get("/password", authH.PasswordPage).Name("page.auth.password") + g.Get("/magic-link/{token}", authH.VerifyMagicLink).Name("page.auth.magic-link.verify") g.SubGroup("", func(g *router.Group) { g.RateLimit(5, 15*time.Minute) - g.Post("/magic-link", authH.SendMagicLink) - g.Post("/password", authH.LoginWithPassword) + g.Post("/magic-link", authH.SendMagicLink).Name("action.auth.magic-link.send") + g.Post("/password", authH.LoginWithPassword).Name("action.auth.password.login") }) }) // Auth - authenticated routes r.Group("/auth", func(g *router.Group) { g.Use(middleware.RequireAuth) - g.Get("/onboarding", authH.OnboardingPage) - g.Post("/onboarding", authH.CompleteOnboarding) + g.Get("/onboarding", authH.OnboardingPage).Name("page.auth.onboarding") + g.Post("/onboarding", authH.CompleteOnboarding).Name("action.auth.onboarding.complete") }) - r.Post("/auth/logout", authH.Logout) + r.Post("/auth/logout", authH.Logout).Name("action.auth.logout") // App routes r.Group("/app", func(g *router.Group) { g.Use(middleware.RequireAuth) g.SubGroup("/spaces", func(g *router.Group) { - g.Get("", spaceH.SpacesPage) + g.Get("", spaceH.SpacesPage).Name("page.app.spaces") }) g.SubGroup("/settings", func(g *router.Group) { - g.Get("", settingsH.SettingsPage) + g.Get("", settingsH.SettingsPage).Name("page.app.settings") g.SubGroup("", func(g *router.Group) { g.RateLimit(5, 15*time.Minute) - g.Post("/password", settingsH.SetPassword) + g.Post("/password", settingsH.SetPassword).Name("action.app.settings.password.set") }) }) }) @@ -99,3 +105,33 @@ func SetupRoutes(a *app.App) http.Handler { return r.Handler() } + +// URL resolves a named route to its concrete path, substituting named +// wildcard segments from the supplied key/value pairs. Unknown names +// return "#" so templates degrade gracefully instead of panicking. +// +// Each key is replaced exactly once. This is safe because net/http.ServeMux +// rejects patterns with duplicate wildcard names at registration time, so any +// path stored in the registry has at most one `{key}` (or `{key...}`) segment +// per name. If two segments need similar semantics (e.g. nested tokens), they +// must be given distinct wildcard names — `/spaces/{spaceToken}/invites/{inviteToken}`. +// +// Example: +// +// routes.URL("page.public.join-space", "token", tok) +// // → "/join/" +func URL(name string, kv ...string) string { + if registry == nil { + return "#" + } + path, ok := registry.Lookup(name) + if !ok { + return "#" + } + for i := 0; i+1 < len(kv); i += 2 { + key, val := kv[i], kv[i+1] + path = strings.Replace(path, "{"+key+"...}", val, 1) + path = strings.Replace(path, "{"+key+"}", val, 1) + } + return path +} diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index e64e0e9..3f3f10c 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -217,6 +217,18 @@ func TestSetupRoutes_NotFound(t *testing.T) { }) } +func TestURL_ResolvesNamedRoute(t *testing.T) { + testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { + a := newTestApp(dbi) + SetupRoutes(a) + + assert.Equal(t, "/privacy", URL("page.public.privacy")) + assert.Equal(t, "/app/spaces", URL("page.app.spaces")) + assert.Equal(t, "/join/abc123", URL("page.public.join-space", "token", "abc123")) + assert.Equal(t, "#", URL("does.not.exist")) + }) +} + func TestSetupRoutes_StaticAssets(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { a := newTestApp(dbi)