Cooperative email for PDS operators
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

Admin dashboard + multi-domain member model

+6219 -300
+33 -24
cmd/relay/main.go
··· 21 21 "time" 22 22 23 23 "atmosphere-mail/internal/admin" 24 + adminui "atmosphere-mail/internal/admin/ui" 24 25 "atmosphere-mail/internal/relay" 25 26 "atmosphere-mail/internal/relaystore" 26 27 ··· 38 39 InboundAddr string `json:"inboundAddr"` // default ":25" (port 25 for receiving bounces) 39 40 40 41 // Admin API 41 - AdminAddr string `json:"adminAddr"` // default ":8443" (Tailscale-only) 42 + AdminAddr string `json:"adminAddr"` // default ":8080" (Tailscale-only) 42 43 AdminToken string `json:"adminToken"` // Bearer token for admin API 43 44 44 45 // Labeler ··· 163 164 log.Printf("spool.reload: recovered %d queued messages", reloaded) 164 165 } 165 166 166 - // Member lookup for SMTP AUTH 167 - memberLookup := func(ctx context.Context, did string) (*relay.AuthMember, error) { 168 - member, err := store.GetMember(ctx, did) 167 + // Member lookup for SMTP AUTH — returns member + all domains so auth 168 + // can match the API key to a specific domain. 169 + memberLookup := func(ctx context.Context, did string) (*relay.MemberWithDomains, error) { 170 + member, domains, err := store.GetMemberWithDomains(ctx, did) 169 171 if err != nil { 170 172 return nil, err 171 173 } ··· 173 175 return nil, nil 174 176 } 175 177 176 - // Deserialize DKIM keys 177 - rsaKey, edKey, err := deserializeDKIMKeys(member.DKIMRSAPriv, member.DKIMEdPriv) 178 - if err != nil { 179 - return nil, fmt.Errorf("deserialize DKIM keys for %s: %v", did, err) 178 + domainInfos := make([]relay.DomainInfo, len(domains)) 179 + for i, d := range domains { 180 + rsaKey, edKey, err := deserializeDKIMKeys(d.DKIMRSAPriv, d.DKIMEdPriv) 181 + if err != nil { 182 + return nil, fmt.Errorf("deserialize DKIM keys for %s/%s: %v", did, d.Domain, err) 183 + } 184 + domainInfos[i] = relay.DomainInfo{ 185 + Domain: d.Domain, 186 + APIKeyHash: d.APIKeyHash, 187 + DKIMKeys: &relay.DKIMKeys{ 188 + Selector: d.DKIMSelector, 189 + RSAPriv: rsaKey, 190 + EdPriv: edKey, 191 + }, 192 + DKIMSelector: d.DKIMSelector, 193 + } 180 194 } 181 195 182 - return &relay.AuthMember{ 183 - DID: member.DID, 184 - Domain: member.Domain, 185 - APIKeyHash: member.APIKeyHash, 186 - Status: member.Status, 187 - SendCount: member.SendCount, 188 - DKIMKeys: &relay.DKIMKeys{ 189 - Selector: member.DKIMSelector, 190 - RSAPriv: rsaKey, 191 - EdPriv: edKey, 192 - }, 193 - DKIMSelector: member.DKIMSelector, 194 - HourlyLimit: member.HourlyLimit, 195 - DailyLimit: member.DailyLimit, 196 - CreatedAt: member.CreatedAt, 196 + return &relay.MemberWithDomains{ 197 + DID: member.DID, 198 + Status: member.Status, 199 + SendCount: member.SendCount, 200 + HourlyLimit: member.HourlyLimit, 201 + DailyLimit: member.DailyLimit, 202 + CreatedAt: member.CreatedAt, 203 + Domains: domainInfos, 197 204 }, nil 198 205 } 199 206 ··· 383 390 384 391 // Start admin API (includes /metrics endpoint) 385 392 adminAPI := admin.NewComplete(store, cfg.AdminToken, cfg.Domain, labelChecker, spfChecker, challengeStore, didResolver) 393 + dashboardUI := adminui.New(store, labelChecker) 386 394 adminMux := http.NewServeMux() 395 + adminMux.Handle("/ui/", dashboardUI) 387 396 adminMux.Handle("/", adminAPI) 388 397 adminMux.Handle("/metrics", promhttp.HandlerFor(metricsRegistry, promhttp.HandlerOpts{})) 389 398 adminServer := &http.Server{ ··· 506 515 cfg.SMTPAddr = ":587" 507 516 } 508 517 if cfg.AdminAddr == "" { 509 - cfg.AdminAddr = ":8443" 518 + cfg.AdminAddr = ":8080" 510 519 } 511 520 if cfg.StateDir == "" { 512 521 cfg.StateDir = "./state"
+1
go.mod
··· 12 12 ) 13 13 14 14 require ( 15 + github.com/a-h/templ v0.3.1001 // indirect 15 16 github.com/beorn7/perks v1.0.1 // indirect 16 17 github.com/cespare/xxhash/v2 v2.3.0 // indirect 17 18 github.com/dustin/go-humanize v1.0.1 // indirect
+2
go.sum
··· 1 + github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= 2 + github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= 1 3 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 4 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 5 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+18 -1
infra/nixos/default.nix
··· 95 95 # Don't restart tailscaled during deploys — avoids SSH drops 96 96 systemd.services.tailscaled.restartIfChanged = false; 97 97 98 + # Tailscale Serve: proxy HTTPS :443 → admin/dashboard on :8080 99 + # Gives clean URLs: https://atmos-relay.internal.example/ui/ 100 + systemd.services.tailscale-serve = { 101 + description = "Configure Tailscale Serve for admin dashboard"; 102 + after = [ "tailscaled.service" "atmos-relay.service" ]; 103 + wants = [ "tailscaled.service" ]; 104 + wantedBy = [ "multi-user.target" ]; 105 + serviceConfig = { 106 + Type = "oneshot"; 107 + RemainAfterExit = true; 108 + ExecStart = "${pkgs.tailscale}/bin/tailscale serve --bg --https=443 http://localhost:8080"; 109 + ExecStop = "${pkgs.tailscale}/bin/tailscale serve reset"; 110 + Restart = "on-failure"; 111 + RestartSec = "5s"; 112 + }; 113 + }; 114 + 98 115 # ------------------------------------------------------------------- 99 116 # Secrets via sops-nix — decrypted at activation from encrypted file. 100 117 # Host age key derived from SSH host key (automatic with sops-nix). ··· 154 171 { 155 172 "domain": "atmos.email", 156 173 "smtpAddr": ":587", 157 - "adminAddr": ":8443", 174 + "adminAddr": ":8080", 158 175 "stateDir": "/var/lib/atmos-relay", 159 176 "tlsCertFile": "/var/lib/acme/smtp.atmos.email/fullchain.pem", 160 177 "tlsKeyFile": "/var/lib/acme/smtp.atmos.email/key.pem"
+86 -28
internal/admin/api.go
··· 97 97 } 98 98 a.mux.HandleFunc("/admin/enroll", a.handleEnroll) 99 99 a.mux.HandleFunc("/admin/challenge", a.handleChallenge) 100 + a.mux.HandleFunc("/admin/members", a.handleListMembers) 100 101 a.mux.HandleFunc("/admin/member/", a.handleMember) 101 102 a.mux.HandleFunc("/admin/stats", a.handleStats) 102 103 a.mux.HandleFunc("/admin/bypass-labels", a.handleBypassList) ··· 278 279 log.Printf("admin.enroll: did=%s did_ownership_verified=true", did) 279 280 } 280 281 281 - // Check for existing member with same domain 282 - existingByDomain, err := a.store.GetMemberByDomain(r.Context(), domain) 282 + // Check for existing domain (must be unique across all members) 283 + existingDomain, err := a.store.GetMemberDomain(r.Context(), domain) 283 284 if err != nil { 284 285 log.Printf("admin.enroll: did=%s error=%v", did, err) 285 286 http.Error(w, "internal error", http.StatusInternalServerError) 286 287 return 287 288 } 288 - if existingByDomain != nil { 289 - http.Error(w, "domain already registered to another member", http.StatusConflict) 289 + if existingDomain != nil { 290 + http.Error(w, "domain already registered", http.StatusConflict) 290 291 return 291 292 } 292 293 293 - // Check for existing member 294 + // Check if the DID already exists — if so, add domain to existing member 294 295 existing, err := a.store.GetMember(r.Context(), did) 295 296 if err != nil { 296 297 log.Printf("admin.enroll: did=%s error=%v", did, err) 297 298 http.Error(w, "internal error", http.StatusInternalServerError) 298 - return 299 - } 300 - if existing != nil { 301 - http.Error(w, "member already enrolled", http.StatusConflict) 302 299 return 303 300 } 304 301 ··· 340 337 } 341 338 342 339 now := time.Now().UTC() 343 - member := &relaystore.Member{ 344 - DID: did, 340 + 341 + // Build member record only if this is a new DID 342 + var memberRecord *relaystore.Member 343 + if existing == nil { 344 + memberRecord = &relaystore.Member{ 345 + DID: did, 346 + Status: relaystore.StatusActive, 347 + HourlyLimit: 100, 348 + DailyLimit: 1000, 349 + CreatedAt: now, 350 + UpdatedAt: now, 351 + } 352 + } 353 + 354 + domainRecord := &relaystore.MemberDomain{ 345 355 Domain: domain, 356 + DID: did, 346 357 APIKeyHash: apiKeyHash, 347 358 DKIMRSAPriv: rsaBytes, 348 359 DKIMEdPriv: edBytes, 349 360 DKIMSelector: selector, 350 - Status: relaystore.StatusActive, 351 - HourlyLimit: 100, 352 - DailyLimit: 1000, 353 361 CreatedAt: now, 354 - UpdatedAt: now, 355 362 } 356 363 357 - if err := a.store.InsertMember(r.Context(), member); err != nil { 358 - log.Printf("admin.enroll: did=%s error=insert_member %v", did, err) 364 + // Atomic insert: member (if new) + domain in one transaction 365 + if err := a.store.EnrollMember(r.Context(), memberRecord, domainRecord); err != nil { 366 + log.Printf("admin.enroll: did=%s domain=%s error=enroll %v", did, domain, err) 359 367 http.Error(w, "internal error", http.StatusInternalServerError) 360 368 return 361 369 } 362 370 363 - log.Printf("admin.enroll: did=%s domain=%s selector=%s", did, domain, selector) 371 + log.Printf("admin.enroll: did=%s domain=%s selector=%s new_did=%v", did, domain, selector, existing == nil) 364 372 365 373 // Check SPF alignment if checker is configured 366 374 var spfResult *SPFAlignmentResponse ··· 401 409 402 410 // --- Member status / suspend / reactivate --- 403 411 412 + type DomainStatusInfo struct { 413 + Domain string `json:"domain"` 414 + } 415 + 404 416 type MemberStatusResponse struct { 405 - DID string `json:"did"` 406 - Domain string `json:"domain"` 407 - Status string `json:"status"` 408 - SuspendReason string `json:"suspendReason,omitempty"` 409 - SendCount int64 `json:"sendCount"` 410 - HourlyLimit int `json:"hourlyLimit"` 411 - DailyLimit int `json:"dailyLimit"` 412 - CreatedAt string `json:"createdAt"` 417 + DID string `json:"did"` 418 + Domains []DomainStatusInfo `json:"domains"` 419 + Status string `json:"status"` 420 + SuspendReason string `json:"suspendReason,omitempty"` 421 + SendCount int64 `json:"sendCount"` 422 + HourlyLimit int `json:"hourlyLimit"` 423 + DailyLimit int `json:"dailyLimit"` 424 + CreatedAt string `json:"createdAt"` 413 425 } 414 426 415 427 func (a *API) handleMember(w http.ResponseWriter, r *http.Request) { ··· 452 464 } 453 465 454 466 func (a *API) handleMemberStatus(w http.ResponseWriter, r *http.Request, did string) { 455 - member, err := a.store.GetMember(r.Context(), did) 467 + member, domains, err := a.store.GetMemberWithDomains(r.Context(), did) 456 468 if err != nil { 457 469 http.Error(w, "internal error", http.StatusInternalServerError) 458 470 return ··· 462 474 return 463 475 } 464 476 477 + domainInfos := make([]DomainStatusInfo, len(domains)) 478 + for i, d := range domains { 479 + domainInfos[i] = DomainStatusInfo{Domain: d.Domain} 480 + } 481 + 465 482 w.Header().Set("Content-Type", "application/json") 466 483 json.NewEncoder(w).Encode(MemberStatusResponse{ 467 484 DID: member.DID, 468 - Domain: member.Domain, 485 + Domains: domainInfos, 469 486 Status: member.Status, 470 487 SuspendReason: member.SuspendReason, 471 488 SendCount: member.SendCount, ··· 514 531 json.NewEncoder(w).Encode(map[string]string{"status": "active", "did": did}) 515 532 } 516 533 534 + // --- List members --- 535 + 536 + func (a *API) handleListMembers(w http.ResponseWriter, r *http.Request) { 537 + if r.Method != http.MethodGet { 538 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 539 + return 540 + } 541 + if !a.requireAuth(w, r) { 542 + return 543 + } 544 + 545 + members, err := a.store.ListMembersWithDomains(r.Context()) 546 + if err != nil { 547 + log.Printf("admin.list_members: error=%v", err) 548 + http.Error(w, "internal error", http.StatusInternalServerError) 549 + return 550 + } 551 + 552 + resp := make([]MemberStatusResponse, len(members)) 553 + for i, m := range members { 554 + domainInfos := make([]DomainStatusInfo, len(m.Domains)) 555 + for j, d := range m.Domains { 556 + domainInfos[j] = DomainStatusInfo{Domain: d} 557 + } 558 + resp[i] = MemberStatusResponse{ 559 + DID: m.DID, 560 + Domains: domainInfos, 561 + Status: m.Status, 562 + SuspendReason: m.SuspendReason, 563 + SendCount: m.SendCount, 564 + HourlyLimit: m.HourlyLimit, 565 + DailyLimit: m.DailyLimit, 566 + CreatedAt: m.CreatedAt.Format(time.RFC3339), 567 + } 568 + } 569 + 570 + w.Header().Set("Content-Type", "application/json") 571 + json.NewEncoder(w).Encode(map[string][]MemberStatusResponse{"members": resp}) 572 + } 573 + 517 574 // --- Stats --- 518 575 519 576 func (a *API) handleStats(w http.ResponseWriter, r *http.Request) { ··· 534 591 w.Header().Set("Content-Type", "application/json") 535 592 json.NewEncoder(w).Encode(map[string]int64{ 536 593 "members": stats.Members, 594 + "domains": stats.Domains, 537 595 "messages": stats.Messages, 538 596 }) 539 597 }
+182 -23
internal/admin/api_test.go
··· 83 83 if member == nil { 84 84 t.Fatal("member not found in store after enrollment") 85 85 } 86 - if member.Domain != "example.com" { 87 - t.Errorf("stored domain = %q, want %q", member.Domain, "example.com") 86 + 87 + // Verify domain was stored in member_domains 88 + dom, err := store.GetMemberDomain(req.Context(), "example.com") 89 + if err != nil { 90 + t.Fatal(err) 91 + } 92 + if dom == nil { 93 + t.Fatal("domain not found in store after enrollment") 94 + } 95 + if dom.DID != "did:plc:abcdefghijklmnopqrstuvwx" { 96 + t.Errorf("stored domain DID = %q, want %q", dom.DID, "did:plc:abcdefghijklmnopqrstuvwx") 88 97 } 89 98 } 90 99 ··· 151 160 t.Fatalf("first enroll: status = %d", w.Code) 152 161 } 153 162 154 - // Second enroll with same DID should fail 163 + // Same DID + same domain should fail (domain already registered) 155 164 w = httptest.NewRecorder() 156 165 api.ServeHTTP(w, req) 157 166 if w.Code != http.StatusConflict { 158 - t.Errorf("duplicate enroll: status = %d, want 409", w.Code) 167 + t.Errorf("duplicate domain enroll: status = %d, want 409", w.Code) 168 + } 169 + } 170 + 171 + func TestEnrollSecondDomainSameDID(t *testing.T) { 172 + api, store := testAdminAPI(t) 173 + 174 + did := "did:plc:eecdefghijklmnopqrstuvwx" 175 + 176 + // First domain 177 + req := httptest.NewRequest("POST", "/admin/enroll?did="+did+"&domain=first.com", nil) 178 + req.Header.Set("Authorization", "Bearer test-admin-token") 179 + w := httptest.NewRecorder() 180 + api.ServeHTTP(w, req) 181 + if w.Code != http.StatusOK { 182 + t.Fatalf("first enroll: status = %d; body: %s", w.Code, w.Body.String()) 183 + } 184 + 185 + // Second domain for same DID should succeed 186 + req = httptest.NewRequest("POST", "/admin/enroll?did="+did+"&domain=second.com", nil) 187 + req.Header.Set("Authorization", "Bearer test-admin-token") 188 + w = httptest.NewRecorder() 189 + api.ServeHTTP(w, req) 190 + if w.Code != http.StatusOK { 191 + t.Errorf("second domain enroll: status = %d, want 200; body: %s", w.Code, w.Body.String()) 192 + } 193 + 194 + // Verify both domains exist 195 + domains, err := store.ListMemberDomains(context.Background(), did) 196 + if err != nil { 197 + t.Fatal(err) 198 + } 199 + if len(domains) != 2 { 200 + t.Errorf("expected 2 domains, got %d", len(domains)) 159 201 } 160 202 } 161 203 ··· 164 206 165 207 did := "did:plc:statustest22222222222222" 166 208 // Enroll a member first 209 + now := time.Now().UTC() 167 210 store.InsertMember(context.Background(), &relaystore.Member{ 211 + DID: did, 212 + Status: "active", 213 + HourlyLimit: 100, 214 + DailyLimit: 1000, 215 + CreatedAt: now, 216 + UpdatedAt: now, 217 + }) 218 + store.InsertMemberDomain(context.Background(), &relaystore.MemberDomain{ 219 + Domain: "example.com", 168 220 DID: did, 169 - Domain: "example.com", 170 221 APIKeyHash: []byte("hash"), 171 222 DKIMRSAPriv: []byte("rsa"), 172 223 DKIMEdPriv: []byte("ed"), 173 224 DKIMSelector: "sel1", 174 - Status: "active", 175 - HourlyLimit: 100, 176 - DailyLimit: 1000, 177 - CreatedAt: time.Now().UTC(), 178 - UpdatedAt: time.Now().UTC(), 225 + CreatedAt: now, 179 226 }) 180 227 181 228 req := httptest.NewRequest("GET", "/admin/member/"+did, nil) ··· 216 263 api, store := testAdminAPI(t) 217 264 218 265 did := "did:plc:suspendtest2222222222222" 266 + now := time.Now().UTC() 219 267 store.InsertMember(context.Background(), &relaystore.Member{ 220 - DID: did, 268 + DID: did, 269 + Status: "active", 270 + HourlyLimit: 100, 271 + DailyLimit: 1000, 272 + CreatedAt: now, 273 + UpdatedAt: now, 274 + }) 275 + store.InsertMemberDomain(context.Background(), &relaystore.MemberDomain{ 221 276 Domain: "example.com", 277 + DID: did, 222 278 APIKeyHash: []byte("hash"), 223 279 DKIMRSAPriv: []byte("rsa"), 224 280 DKIMEdPriv: []byte("ed"), 225 281 DKIMSelector: "sel1", 226 - Status: "active", 227 - HourlyLimit: 100, 228 - DailyLimit: 1000, 229 - CreatedAt: time.Now().UTC(), 230 - UpdatedAt: time.Now().UTC(), 282 + CreatedAt: now, 231 283 }) 232 284 233 285 // Suspend ··· 280 332 } 281 333 } 282 334 335 + func TestListMembers(t *testing.T) { 336 + api, store := testAdminAPI(t) 337 + ctx := context.Background() 338 + 339 + // Empty list 340 + req := httptest.NewRequest("GET", "/admin/members", nil) 341 + req.Header.Set("Authorization", "Bearer test-admin-token") 342 + w := httptest.NewRecorder() 343 + api.ServeHTTP(w, req) 344 + 345 + if w.Code != http.StatusOK { 346 + t.Fatalf("empty list: status = %d, want 200; body: %s", w.Code, w.Body.String()) 347 + } 348 + 349 + var emptyResp struct { 350 + Members []MemberStatusResponse `json:"members"` 351 + } 352 + if err := json.NewDecoder(w.Body).Decode(&emptyResp); err != nil { 353 + t.Fatal(err) 354 + } 355 + if len(emptyResp.Members) != 0 { 356 + t.Errorf("expected 0 members, got %d", len(emptyResp.Members)) 357 + } 358 + 359 + // Add two members 360 + now := time.Now().UTC() 361 + for i, did := range []string{"did:plc:listtest111111111111111", "did:plc:listtest222222222222222"} { 362 + store.InsertMember(ctx, &relaystore.Member{ 363 + DID: did, 364 + Status: relaystore.StatusActive, 365 + HourlyLimit: 100, 366 + DailyLimit: 1000, 367 + CreatedAt: now, 368 + UpdatedAt: now, 369 + }) 370 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 371 + Domain: fmt.Sprintf("list%d.example.com", i), 372 + DID: did, 373 + APIKeyHash: []byte("hash"), 374 + DKIMRSAPriv: []byte("rsa"), 375 + DKIMEdPriv: []byte("ed"), 376 + DKIMSelector: "sel1", 377 + CreatedAt: now, 378 + }) 379 + } 380 + 381 + // List with members 382 + req = httptest.NewRequest("GET", "/admin/members", nil) 383 + req.Header.Set("Authorization", "Bearer test-admin-token") 384 + w = httptest.NewRecorder() 385 + api.ServeHTTP(w, req) 386 + 387 + if w.Code != http.StatusOK { 388 + t.Fatalf("list: status = %d, want 200; body: %s", w.Code, w.Body.String()) 389 + } 390 + 391 + var resp struct { 392 + Members []MemberStatusResponse `json:"members"` 393 + } 394 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 395 + t.Fatal(err) 396 + } 397 + if len(resp.Members) != 2 { 398 + t.Fatalf("expected 2 members, got %d", len(resp.Members)) 399 + } 400 + if resp.Members[0].DID != "did:plc:listtest111111111111111" { 401 + t.Errorf("first member DID = %q", resp.Members[0].DID) 402 + } 403 + if len(resp.Members[0].Domains) != 1 || resp.Members[0].Domains[0].Domain != "list0.example.com" { 404 + t.Errorf("first member domains = %v", resp.Members[0].Domains) 405 + } 406 + if resp.Members[1].DID != "did:plc:listtest222222222222222" { 407 + t.Errorf("second member DID = %q", resp.Members[1].DID) 408 + } 409 + } 410 + 411 + func TestListMembersUnauthorized(t *testing.T) { 412 + api, _ := testAdminAPI(t) 413 + 414 + req := httptest.NewRequest("GET", "/admin/members", nil) 415 + req.Header.Set("Authorization", "Bearer wrong-token") 416 + w := httptest.NewRecorder() 417 + api.ServeHTTP(w, req) 418 + 419 + if w.Code != http.StatusUnauthorized { 420 + t.Errorf("status = %d, want 401", w.Code) 421 + } 422 + } 423 + 424 + func TestListMembersMethodNotAllowed(t *testing.T) { 425 + api, _ := testAdminAPI(t) 426 + 427 + req := httptest.NewRequest("POST", "/admin/members", nil) 428 + req.Header.Set("Authorization", "Bearer test-admin-token") 429 + w := httptest.NewRecorder() 430 + api.ServeHTTP(w, req) 431 + 432 + if w.Code != http.StatusMethodNotAllowed { 433 + t.Errorf("status = %d, want 405", w.Code) 434 + } 435 + } 436 + 283 437 func TestLabelBypassAddRemove(t *testing.T) { 284 438 store, err := relaystore.New(":memory:") 285 439 if err != nil { ··· 370 524 t.Fatal("no API key returned") 371 525 } 372 526 373 - // Look up the stored member and verify the API key against its hash 374 - member, err := store.GetMember(context.Background(), did) 375 - if err != nil || member == nil { 376 - t.Fatalf("member not found: %v", err) 527 + // Look up the stored domain and verify the API key against its hash 528 + dom, err := store.GetMemberDomain(context.Background(), "example.com") 529 + if err != nil || dom == nil { 530 + t.Fatalf("domain not found: %v", err) 377 531 } 378 532 379 - if !relay.VerifyAPIKey(resp.APIKey, member.APIKeyHash) { 533 + if !relay.VerifyAPIKey(resp.APIKey, dom.APIKeyHash) { 380 534 t.Error("returned API key does not verify against stored hash") 381 535 } 382 536 } ··· 558 712 if err != nil || member == nil { 559 713 t.Fatalf("member not found after enrollment: %v", err) 560 714 } 561 - if member.Domain != "challenge-test.com" { 562 - t.Errorf("domain = %q, want challenge-test.com", member.Domain) 715 + // Verify domain was stored 716 + dom, domErr := store.GetMemberDomain(context.Background(), "challenge-test.com") 717 + if domErr != nil || dom == nil { 718 + t.Fatalf("domain not found after enrollment: %v", domErr) 719 + } 720 + if dom.DID != did { 721 + t.Errorf("domain DID = %q, want %q", dom.DID, did) 563 722 } 564 723 } 565 724
+6
internal/admin/ui/embed.go
··· 1 + package ui 2 + 3 + import "embed" 4 + 5 + //go:embed static/* 6 + var StaticFiles embed.FS
+254
internal/admin/ui/handlers.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "io/fs" 6 + "log" 7 + "net/http" 8 + "strings" 9 + 10 + "atmosphere-mail/internal/admin/ui/templates" 11 + "atmosphere-mail/internal/relaystore" 12 + ) 13 + 14 + // LabelQuerier queries labels for a DID from the labeler. 15 + type LabelQuerier interface { 16 + QueryLabels(ctx context.Context, did string) ([]string, error) 17 + } 18 + 19 + // Handler serves the operator dashboard UI. 20 + type Handler struct { 21 + store *relaystore.Store 22 + labelQuerier LabelQuerier 23 + mux *http.ServeMux 24 + } 25 + 26 + // New creates a UI handler that serves the operator dashboard. 27 + // labelQuerier may be nil if the labeler is not configured. 28 + func New(store *relaystore.Store, labelQuerier LabelQuerier) *Handler { 29 + h := &Handler{ 30 + store: store, 31 + labelQuerier: labelQuerier, 32 + mux: http.NewServeMux(), 33 + } 34 + 35 + // Static assets 36 + staticFS, _ := fs.Sub(StaticFiles, "static") 37 + h.mux.Handle("/ui/static/", http.StripPrefix("/ui/static/", http.FileServer(http.FS(staticFS)))) 38 + 39 + // Pages 40 + h.mux.HandleFunc("/ui/", h.handleDashboard) 41 + h.mux.HandleFunc("/ui/members", h.handleMembers) 42 + h.mux.HandleFunc("/ui/member/", h.handleMemberDetail) 43 + h.mux.HandleFunc("/ui/partials/dashboard", h.handleDashboardPartial) 44 + 45 + return h 46 + } 47 + 48 + func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 49 + h.mux.ServeHTTP(w, r) 50 + } 51 + 52 + func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { 53 + if r.URL.Path != "/ui/" && r.URL.Path != "/ui" { 54 + http.NotFound(w, r) 55 + return 56 + } 57 + 58 + data, err := h.getDashboardData(r) 59 + if err != nil { 60 + log.Printf("ui.dashboard: error=%v", err) 61 + http.Error(w, "internal error", http.StatusInternalServerError) 62 + return 63 + } 64 + 65 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 66 + templates.DashboardPage(data).Render(r.Context(), w) 67 + } 68 + 69 + func (h *Handler) handleDashboardPartial(w http.ResponseWriter, r *http.Request) { 70 + data, err := h.getDashboardData(r) 71 + if err != nil { 72 + log.Printf("ui.dashboard_partial: error=%v", err) 73 + http.Error(w, "internal error", http.StatusInternalServerError) 74 + return 75 + } 76 + 77 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 78 + templates.DashboardContent(data).Render(r.Context(), w) 79 + } 80 + 81 + func (h *Handler) getDashboardData(r *http.Request) (templates.DashboardData, error) { 82 + stats, err := h.store.Stats(r.Context()) 83 + if err != nil { 84 + return templates.DashboardData{}, err 85 + } 86 + 87 + active, suspended, err := h.store.MemberCountsByStatus(r.Context()) 88 + if err != nil { 89 + return templates.DashboardData{}, err 90 + } 91 + 92 + return templates.DashboardData{ 93 + TotalMembers: stats.Members, 94 + ActiveMembers: active, 95 + SuspendedMembers: suspended, 96 + TotalMessages: stats.Messages, 97 + }, nil 98 + } 99 + 100 + func (h *Handler) handleMembers(w http.ResponseWriter, r *http.Request) { 101 + members, err := h.store.ListMembersWithDomains(r.Context()) 102 + if err != nil { 103 + log.Printf("ui.members: error=%v", err) 104 + http.Error(w, "internal error", http.StatusInternalServerError) 105 + return 106 + } 107 + 108 + rows := make([]templates.MemberRow, len(members)) 109 + for i, m := range members { 110 + primaryDomain := "" 111 + if len(m.Domains) > 0 { 112 + primaryDomain = m.Domains[0] 113 + } 114 + rows[i] = templates.MemberRow{ 115 + DID: m.DID, 116 + Domain: primaryDomain, 117 + DomainCount: len(m.Domains), 118 + Status: m.Status, 119 + SendCount: m.SendCount, 120 + CreatedAt: m.CreatedAt.Format("2006-01-02"), 121 + } 122 + } 123 + 124 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 125 + if r.Header.Get("HX-Request") == "true" { 126 + templates.MembersContent(rows).Render(r.Context(), w) 127 + } else { 128 + templates.MembersPage(rows).Render(r.Context(), w) 129 + } 130 + } 131 + 132 + func (h *Handler) handleMemberDetail(w http.ResponseWriter, r *http.Request) { 133 + // Parse: /ui/member/{did}[/action] 134 + path := strings.TrimPrefix(r.URL.Path, "/ui/member/") 135 + parts := strings.SplitN(path, "/", 2) 136 + did := parts[0] 137 + action := "" 138 + if len(parts) > 1 { 139 + action = parts[1] 140 + } 141 + 142 + if did == "" { 143 + http.NotFound(w, r) 144 + return 145 + } 146 + 147 + switch { 148 + case action == "" && r.Method == http.MethodGet: 149 + h.handleMemberDetailView(w, r, did) 150 + case action == "suspend" && r.Method == http.MethodPost: 151 + h.handleMemberSuspendAction(w, r, did) 152 + case action == "reactivate" && r.Method == http.MethodPost: 153 + h.handleMemberReactivateAction(w, r, did) 154 + default: 155 + http.NotFound(w, r) 156 + } 157 + } 158 + 159 + func (h *Handler) handleMemberDetailView(w http.ResponseWriter, r *http.Request, did string) { 160 + member, domains, err := h.store.GetMemberWithDomains(r.Context(), did) 161 + if err != nil { 162 + log.Printf("ui.member_detail: did=%s error=%v", did, err) 163 + http.Error(w, "internal error", http.StatusInternalServerError) 164 + return 165 + } 166 + if member == nil { 167 + http.NotFound(w, r) 168 + return 169 + } 170 + 171 + detail := memberToDetail(member, domains) 172 + 173 + // Fetch labels from the labeler (best-effort — don't fail if unavailable) 174 + if h.labelQuerier != nil { 175 + labels, err := h.labelQuerier.QueryLabels(r.Context(), did) 176 + if err != nil { 177 + log.Printf("ui.member_detail: label query did=%s error=%v", did, err) 178 + } 179 + detail.Labels = labels 180 + } 181 + 182 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 183 + if r.Header.Get("HX-Request") == "true" { 184 + templates.MemberDetailContent(detail).Render(r.Context(), w) 185 + } else { 186 + templates.MemberDetailPage(detail).Render(r.Context(), w) 187 + } 188 + } 189 + 190 + func (h *Handler) handleMemberSuspendAction(w http.ResponseWriter, r *http.Request, did string) { 191 + reason := r.URL.Query().Get("reason") 192 + if reason == "" { 193 + reason = "manual suspension via dashboard" 194 + } 195 + 196 + if err := h.store.UpdateMemberStatus(r.Context(), did, relaystore.StatusSuspended, reason); err != nil { 197 + log.Printf("ui.suspend: did=%s error=%v", did, err) 198 + http.Error(w, "internal error", http.StatusInternalServerError) 199 + return 200 + } 201 + 202 + log.Printf("ui.suspend: did=%s reason=%q", did, reason) 203 + 204 + // Re-fetch and render the status section 205 + member, domains, err := h.store.GetMemberWithDomains(r.Context(), did) 206 + if err != nil || member == nil { 207 + http.Error(w, "internal error", http.StatusInternalServerError) 208 + return 209 + } 210 + 211 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 212 + templates.MemberStatusSection(memberToDetail(member, domains)).Render(r.Context(), w) 213 + } 214 + 215 + func (h *Handler) handleMemberReactivateAction(w http.ResponseWriter, r *http.Request, did string) { 216 + if err := h.store.UpdateMemberStatus(r.Context(), did, relaystore.StatusActive, ""); err != nil { 217 + log.Printf("ui.reactivate: did=%s error=%v", did, err) 218 + http.Error(w, "internal error", http.StatusInternalServerError) 219 + return 220 + } 221 + 222 + log.Printf("ui.reactivate: did=%s", did) 223 + 224 + member, domains, err := h.store.GetMemberWithDomains(r.Context(), did) 225 + if err != nil || member == nil { 226 + http.Error(w, "internal error", http.StatusInternalServerError) 227 + return 228 + } 229 + 230 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 231 + templates.MemberStatusSection(memberToDetail(member, domains)).Render(r.Context(), w) 232 + } 233 + 234 + func memberToDetail(m *relaystore.Member, domains []relaystore.MemberDomain) templates.MemberDetail { 235 + primaryDomain := "" 236 + allDomains := make([]string, len(domains)) 237 + for i, d := range domains { 238 + allDomains[i] = d.Domain 239 + } 240 + if len(allDomains) > 0 { 241 + primaryDomain = allDomains[0] 242 + } 243 + return templates.MemberDetail{ 244 + DID: m.DID, 245 + Domain: primaryDomain, 246 + AllDomains: allDomains, 247 + Status: m.Status, 248 + SuspendReason: m.SuspendReason, 249 + SendCount: m.SendCount, 250 + HourlyLimit: m.HourlyLimit, 251 + DailyLimit: m.DailyLimit, 252 + CreatedAt: m.CreatedAt.Format("2006-01-02 15:04 UTC"), 253 + } 254 + }
+311
internal/admin/ui/handlers_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "atmosphere-mail/internal/relaystore" 12 + ) 13 + 14 + func testUI(t *testing.T) (*Handler, *relaystore.Store) { 15 + t.Helper() 16 + store, err := relaystore.New(":memory:") 17 + if err != nil { 18 + t.Fatalf("New store: %v", err) 19 + } 20 + t.Cleanup(func() { store.Close() }) 21 + return New(store, nil), store 22 + } 23 + 24 + func insertTestMember(t *testing.T, store *relaystore.Store, did, domain, status string) { 25 + t.Helper() 26 + ctx := context.Background() 27 + now := time.Now().UTC() 28 + 29 + // Check if DID already exists; if not, create the member 30 + existing, _ := store.GetMember(ctx, did) 31 + if existing == nil { 32 + err := store.InsertMember(ctx, &relaystore.Member{ 33 + DID: did, 34 + Status: status, 35 + HourlyLimit: 100, 36 + DailyLimit: 1000, 37 + CreatedAt: now, 38 + UpdatedAt: now, 39 + }) 40 + if err != nil { 41 + t.Fatalf("insert member %s: %v", did, err) 42 + } 43 + } 44 + 45 + // Add domain 46 + err := store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 47 + Domain: domain, 48 + DID: did, 49 + APIKeyHash: []byte("hash"), 50 + DKIMRSAPriv: []byte("rsa"), 51 + DKIMEdPriv: []byte("ed"), 52 + DKIMSelector: "sel1", 53 + CreatedAt: now, 54 + }) 55 + if err != nil { 56 + t.Fatalf("insert member domain %s/%s: %v", did, domain, err) 57 + } 58 + } 59 + 60 + func TestDashboardPage(t *testing.T) { 61 + h, store := testUI(t) 62 + insertTestMember(t, store, "did:plc:uitest1111111111111111", "test1.example.com", relaystore.StatusActive) 63 + 64 + req := httptest.NewRequest("GET", "/ui/", nil) 65 + w := httptest.NewRecorder() 66 + h.ServeHTTP(w, req) 67 + 68 + if w.Code != http.StatusOK { 69 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 70 + } 71 + body := w.Body.String() 72 + if !strings.Contains(body, "Dashboard") { 73 + t.Error("response missing 'Dashboard' heading") 74 + } 75 + if !strings.Contains(body, "Total Members") { 76 + t.Error("response missing stat card") 77 + } 78 + if !strings.Contains(body, "text/html") { 79 + // Check content-type header 80 + } 81 + ct := w.Header().Get("Content-Type") 82 + if !strings.Contains(ct, "text/html") { 83 + t.Errorf("Content-Type = %q, want text/html", ct) 84 + } 85 + } 86 + 87 + func TestDashboardPartial(t *testing.T) { 88 + h, _ := testUI(t) 89 + 90 + req := httptest.NewRequest("GET", "/ui/partials/dashboard", nil) 91 + w := httptest.NewRecorder() 92 + h.ServeHTTP(w, req) 93 + 94 + if w.Code != http.StatusOK { 95 + t.Fatalf("status = %d, want 200", w.Code) 96 + } 97 + body := w.Body.String() 98 + // Partial should NOT contain full layout (no <html> tag) 99 + if strings.Contains(body, "<html") { 100 + t.Error("partial response contains full HTML layout") 101 + } 102 + if !strings.Contains(body, "dashboard-stats") { 103 + t.Error("partial missing dashboard-stats div") 104 + } 105 + } 106 + 107 + func TestMembersPage(t *testing.T) { 108 + h, store := testUI(t) 109 + insertTestMember(t, store, "did:plc:uitest2222222222222222", "alpha.example.com", relaystore.StatusActive) 110 + insertTestMember(t, store, "did:plc:uitest3333333333333333", "beta.example.com", relaystore.StatusSuspended) 111 + 112 + req := httptest.NewRequest("GET", "/ui/members", nil) 113 + w := httptest.NewRecorder() 114 + h.ServeHTTP(w, req) 115 + 116 + if w.Code != http.StatusOK { 117 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 118 + } 119 + body := w.Body.String() 120 + if !strings.Contains(body, "alpha.example.com") { 121 + t.Error("missing member alpha.example.com") 122 + } 123 + if !strings.Contains(body, "beta.example.com") { 124 + t.Error("missing member beta.example.com") 125 + } 126 + if !strings.Contains(body, "badge-active") { 127 + t.Error("missing active badge") 128 + } 129 + if !strings.Contains(body, "badge-suspended") { 130 + t.Error("missing suspended badge") 131 + } 132 + } 133 + 134 + func TestMembersPageHTMX(t *testing.T) { 135 + h, _ := testUI(t) 136 + 137 + req := httptest.NewRequest("GET", "/ui/members", nil) 138 + req.Header.Set("HX-Request", "true") 139 + w := httptest.NewRecorder() 140 + h.ServeHTTP(w, req) 141 + 142 + if w.Code != http.StatusOK { 143 + t.Fatalf("status = %d, want 200", w.Code) 144 + } 145 + body := w.Body.String() 146 + if strings.Contains(body, "<html") { 147 + t.Error("HTMX partial should not contain full HTML layout") 148 + } 149 + if !strings.Contains(body, "members-list") { 150 + t.Error("missing members-list div") 151 + } 152 + } 153 + 154 + type mockLabelQuerier struct { 155 + labels map[string][]string 156 + } 157 + 158 + func (m *mockLabelQuerier) QueryLabels(_ context.Context, did string) ([]string, error) { 159 + return m.labels[did], nil 160 + } 161 + 162 + func TestMemberDetailPageWithLabels(t *testing.T) { 163 + store, err := relaystore.New(":memory:") 164 + if err != nil { 165 + t.Fatalf("New store: %v", err) 166 + } 167 + t.Cleanup(func() { store.Close() }) 168 + 169 + did := "did:plc:labeltest111111111111111" 170 + insertTestMember(t, store, did, "labeled.example.com", relaystore.StatusActive) 171 + 172 + lq := &mockLabelQuerier{labels: map[string][]string{ 173 + did: {"verified-mail-operator", "relay-member", "clean-sender"}, 174 + }} 175 + h := New(store, lq) 176 + 177 + req := httptest.NewRequest("GET", "/ui/member/"+did, nil) 178 + w := httptest.NewRecorder() 179 + h.ServeHTTP(w, req) 180 + 181 + if w.Code != http.StatusOK { 182 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 183 + } 184 + body := w.Body.String() 185 + for _, label := range []string{"verified-mail-operator", "relay-member", "clean-sender"} { 186 + if !strings.Contains(body, label) { 187 + t.Errorf("missing label %q in detail page", label) 188 + } 189 + } 190 + if !strings.Contains(body, "badge-label") { 191 + t.Error("missing label badge CSS class") 192 + } 193 + } 194 + 195 + func TestMemberDetailPage(t *testing.T) { 196 + h, store := testUI(t) 197 + insertTestMember(t, store, "did:plc:detailtest111111111111", "detail.example.com", relaystore.StatusActive) 198 + 199 + req := httptest.NewRequest("GET", "/ui/member/did:plc:detailtest111111111111", nil) 200 + w := httptest.NewRecorder() 201 + h.ServeHTTP(w, req) 202 + 203 + if w.Code != http.StatusOK { 204 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 205 + } 206 + body := w.Body.String() 207 + if !strings.Contains(body, "detail.example.com") { 208 + t.Error("missing domain in detail page") 209 + } 210 + if !strings.Contains(body, "did:plc:detailtest111111111111") { 211 + t.Error("missing DID in detail page") 212 + } 213 + if !strings.Contains(body, "Suspend") { 214 + t.Error("missing suspend button for active member") 215 + } 216 + } 217 + 218 + func TestMemberDetailNotFound(t *testing.T) { 219 + h, _ := testUI(t) 220 + 221 + req := httptest.NewRequest("GET", "/ui/member/did:plc:nonexistent2222222222222", nil) 222 + w := httptest.NewRecorder() 223 + h.ServeHTTP(w, req) 224 + 225 + if w.Code != http.StatusNotFound { 226 + t.Errorf("status = %d, want 404", w.Code) 227 + } 228 + } 229 + 230 + func TestSuspendAction(t *testing.T) { 231 + h, store := testUI(t) 232 + insertTestMember(t, store, "did:plc:suspendui11111111111111", "suspend.example.com", relaystore.StatusActive) 233 + 234 + req := httptest.NewRequest("POST", "/ui/member/did:plc:suspendui11111111111111/suspend", nil) 235 + w := httptest.NewRecorder() 236 + h.ServeHTTP(w, req) 237 + 238 + if w.Code != http.StatusOK { 239 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 240 + } 241 + 242 + // Verify member is now suspended in store 243 + member, _ := store.GetMember(context.Background(), "did:plc:suspendui11111111111111") 244 + if member.Status != relaystore.StatusSuspended { 245 + t.Errorf("member status = %q, want suspended", member.Status) 246 + } 247 + 248 + // Response should show reactivate button 249 + body := w.Body.String() 250 + if !strings.Contains(body, "Reactivate") { 251 + t.Error("suspend response should show Reactivate button") 252 + } 253 + if !strings.Contains(body, "badge-suspended") { 254 + t.Error("suspend response should show suspended badge") 255 + } 256 + } 257 + 258 + func TestReactivateAction(t *testing.T) { 259 + h, store := testUI(t) 260 + insertTestMember(t, store, "did:plc:reactiveui1111111111111", "reactive.example.com", relaystore.StatusSuspended) 261 + 262 + req := httptest.NewRequest("POST", "/ui/member/did:plc:reactiveui1111111111111/reactivate", nil) 263 + w := httptest.NewRecorder() 264 + h.ServeHTTP(w, req) 265 + 266 + if w.Code != http.StatusOK { 267 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 268 + } 269 + 270 + member, _ := store.GetMember(context.Background(), "did:plc:reactiveui1111111111111") 271 + if member.Status != relaystore.StatusActive { 272 + t.Errorf("member status = %q, want active", member.Status) 273 + } 274 + 275 + body := w.Body.String() 276 + if !strings.Contains(body, "Suspend") { 277 + t.Error("reactivate response should show Suspend button") 278 + } 279 + if !strings.Contains(body, "badge-active") { 280 + t.Error("reactivate response should show active badge") 281 + } 282 + } 283 + 284 + func TestStaticAssets(t *testing.T) { 285 + h, _ := testUI(t) 286 + 287 + for _, path := range []string{"/ui/static/htmx.min.js", "/ui/static/pico.min.css"} { 288 + req := httptest.NewRequest("GET", path, nil) 289 + w := httptest.NewRecorder() 290 + h.ServeHTTP(w, req) 291 + 292 + if w.Code != http.StatusOK { 293 + t.Errorf("%s: status = %d, want 200", path, w.Code) 294 + } 295 + if w.Body.Len() == 0 { 296 + t.Errorf("%s: empty response body", path) 297 + } 298 + } 299 + } 300 + 301 + func TestDashboardOnlyExactPath(t *testing.T) { 302 + h, _ := testUI(t) 303 + 304 + req := httptest.NewRequest("GET", "/ui/nonexistent", nil) 305 + w := httptest.NewRecorder() 306 + h.ServeHTTP(w, req) 307 + 308 + if w.Code != http.StatusNotFound { 309 + t.Errorf("status = %d, want 404 for unknown path", w.Code) 310 + } 311 + }
+1
internal/admin/ui/static/htmx.min.js
··· 1 + var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
+4
internal/admin/ui/static/pico.min.css
··· 1 + @charset "UTF-8";/*! 2 + * Pico CSS ✨ v2.1.1 (https://picocss.com) 3 + * Copyright 2019-2025 - Licensed under MIT 4 + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
+45
internal/admin/ui/templates/dashboard.templ
··· 1 + package templates 2 + 3 + import "fmt" 4 + 5 + type DashboardData struct { 6 + TotalMembers int64 7 + ActiveMembers int64 8 + SuspendedMembers int64 9 + TotalMessages int64 10 + } 11 + 12 + templ DashboardPage(data DashboardData) { 13 + @Layout("Dashboard") { 14 + @DashboardContent(data) 15 + } 16 + } 17 + 18 + templ DashboardContent(data DashboardData) { 19 + <div 20 + id="dashboard-stats" 21 + hx-get="/ui/partials/dashboard" 22 + hx-trigger="every 5s" 23 + hx-swap="outerHTML" 24 + > 25 + <h1>Dashboard</h1> 26 + <div class="stat-grid"> 27 + <article class="stat-card"> 28 + <h2>{ fmt.Sprintf("%d", data.TotalMembers) }</h2> 29 + <p>Total Members</p> 30 + </article> 31 + <article class="stat-card"> 32 + <h2>{ fmt.Sprintf("%d", data.ActiveMembers) }</h2> 33 + <p>Active</p> 34 + </article> 35 + <article class="stat-card"> 36 + <h2>{ fmt.Sprintf("%d", data.SuspendedMembers) }</h2> 37 + <p>Suspended</p> 38 + </article> 39 + <article class="stat-card"> 40 + <h2>{ fmt.Sprintf("%d", data.TotalMessages) }</h2> 41 + <p>Messages Sent</p> 42 + </article> 43 + </div> 44 + </div> 45 + }
+148
internal/admin/ui/templates/dashboard_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.1001 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import "fmt" 12 + 13 + type DashboardData struct { 14 + TotalMembers int64 15 + ActiveMembers int64 16 + SuspendedMembers int64 17 + TotalMessages int64 18 + } 19 + 20 + func DashboardPage(data DashboardData) templ.Component { 21 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 22 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 23 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 24 + return templ_7745c5c3_CtxErr 25 + } 26 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 27 + if !templ_7745c5c3_IsBuffer { 28 + defer func() { 29 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 30 + if templ_7745c5c3_Err == nil { 31 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 32 + } 33 + }() 34 + } 35 + ctx = templ.InitializeContext(ctx) 36 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 37 + if templ_7745c5c3_Var1 == nil { 38 + templ_7745c5c3_Var1 = templ.NopComponent 39 + } 40 + ctx = templ.ClearChildren(ctx) 41 + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 42 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 43 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 44 + if !templ_7745c5c3_IsBuffer { 45 + defer func() { 46 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 47 + if templ_7745c5c3_Err == nil { 48 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 49 + } 50 + }() 51 + } 52 + ctx = templ.InitializeContext(ctx) 53 + templ_7745c5c3_Err = DashboardContent(data).Render(ctx, templ_7745c5c3_Buffer) 54 + if templ_7745c5c3_Err != nil { 55 + return templ_7745c5c3_Err 56 + } 57 + return nil 58 + }) 59 + templ_7745c5c3_Err = Layout("Dashboard").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 60 + if templ_7745c5c3_Err != nil { 61 + return templ_7745c5c3_Err 62 + } 63 + return nil 64 + }) 65 + } 66 + 67 + func DashboardContent(data DashboardData) templ.Component { 68 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 69 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 70 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 71 + return templ_7745c5c3_CtxErr 72 + } 73 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 74 + if !templ_7745c5c3_IsBuffer { 75 + defer func() { 76 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 77 + if templ_7745c5c3_Err == nil { 78 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 79 + } 80 + }() 81 + } 82 + ctx = templ.InitializeContext(ctx) 83 + templ_7745c5c3_Var3 := templ.GetChildren(ctx) 84 + if templ_7745c5c3_Var3 == nil { 85 + templ_7745c5c3_Var3 = templ.NopComponent 86 + } 87 + ctx = templ.ClearChildren(ctx) 88 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"dashboard-stats\" hx-get=\"/ui/partials/dashboard\" hx-trigger=\"every 5s\" hx-swap=\"outerHTML\"><h1>Dashboard</h1><div class=\"stat-grid\"><article class=\"stat-card\"><h2>") 89 + if templ_7745c5c3_Err != nil { 90 + return templ_7745c5c3_Err 91 + } 92 + var templ_7745c5c3_Var4 string 93 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalMembers)) 94 + if templ_7745c5c3_Err != nil { 95 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/dashboard.templ`, Line: 28, Col: 46} 96 + } 97 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 98 + if templ_7745c5c3_Err != nil { 99 + return templ_7745c5c3_Err 100 + } 101 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h2><p>Total Members</p></article><article class=\"stat-card\"><h2>") 102 + if templ_7745c5c3_Err != nil { 103 + return templ_7745c5c3_Err 104 + } 105 + var templ_7745c5c3_Var5 string 106 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ActiveMembers)) 107 + if templ_7745c5c3_Err != nil { 108 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/dashboard.templ`, Line: 32, Col: 47} 109 + } 110 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 111 + if templ_7745c5c3_Err != nil { 112 + return templ_7745c5c3_Err 113 + } 114 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h2><p>Active</p></article><article class=\"stat-card\"><h2>") 115 + if templ_7745c5c3_Err != nil { 116 + return templ_7745c5c3_Err 117 + } 118 + var templ_7745c5c3_Var6 string 119 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.SuspendedMembers)) 120 + if templ_7745c5c3_Err != nil { 121 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/dashboard.templ`, Line: 36, Col: 50} 122 + } 123 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 124 + if templ_7745c5c3_Err != nil { 125 + return templ_7745c5c3_Err 126 + } 127 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h2><p>Suspended</p></article><article class=\"stat-card\"><h2>") 128 + if templ_7745c5c3_Err != nil { 129 + return templ_7745c5c3_Err 130 + } 131 + var templ_7745c5c3_Var7 string 132 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalMessages)) 133 + if templ_7745c5c3_Err != nil { 134 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/dashboard.templ`, Line: 40, Col: 47} 135 + } 136 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 137 + if templ_7745c5c3_Err != nil { 138 + return templ_7745c5c3_Err 139 + } 140 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</h2><p>Messages Sent</p></article></div></div>") 141 + if templ_7745c5c3_Err != nil { 142 + return templ_7745c5c3_Err 143 + } 144 + return nil 145 + }) 146 + } 147 + 148 + var _ = templruntime.GeneratedTemplate
+84
internal/admin/ui/templates/layout.templ
··· 1 + package templates 2 + 3 + templ Layout(title string) { 4 + <!DOCTYPE html> 5 + <html lang="en" data-theme="dark"> 6 + <head> 7 + <meta charset="utf-8"/> 8 + <meta name="viewport" content="width=device-width, initial-scale=1"/> 9 + <title>{ title } — Atmosphere Mail</title> 10 + <link rel="stylesheet" href="/ui/static/pico.min.css"/> 11 + <script src="/ui/static/htmx.min.js"></script> 12 + <style> 13 + :root { 14 + --pico-font-size: 16px; 15 + } 16 + nav ul li strong { 17 + color: var(--pico-primary); 18 + } 19 + .badge { 20 + display: inline-block; 21 + padding: 0.15em 0.5em; 22 + border-radius: 4px; 23 + font-size: 0.85em; 24 + font-weight: 600; 25 + } 26 + .badge-active { 27 + background: var(--pico-ins-color); 28 + color: #fff; 29 + } 30 + .badge-suspended { 31 + background: var(--pico-del-color); 32 + color: #fff; 33 + } 34 + .badge-label { 35 + background: var(--pico-primary-background); 36 + color: var(--pico-primary-inverse); 37 + } 38 + .stat-grid { 39 + display: grid; 40 + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); 41 + gap: 1rem; 42 + } 43 + .stat-card { 44 + text-align: center; 45 + padding: 1.5rem 1rem; 46 + } 47 + .stat-card h2 { 48 + margin-bottom: 0.25rem; 49 + font-size: 2.5rem; 50 + } 51 + .stat-card p { 52 + margin: 0; 53 + color: var(--pico-muted-color); 54 + } 55 + .htmx-indicator { 56 + display: none; 57 + } 58 + .htmx-request .htmx-indicator { 59 + display: inline; 60 + } 61 + .htmx-request.htmx-indicator { 62 + display: inline; 63 + } 64 + </style> 65 + </head> 66 + <body> 67 + <nav class="container"> 68 + <ul> 69 + <li><strong>Atmosphere Mail</strong></li> 70 + </ul> 71 + <ul> 72 + <li><a href="/ui/">Dashboard</a></li> 73 + <li><a href="/ui/members">Members</a></li> 74 + </ul> 75 + </nav> 76 + <main class="container"> 77 + { children... } 78 + </main> 79 + <footer class="container"> 80 + <small>Atmosphere Mail Relay — Admin Dashboard</small> 81 + </footer> 82 + </body> 83 + </html> 84 + }
+61
internal/admin/ui/templates/layout_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.1001 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + func Layout(title string) templ.Component { 12 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 + return templ_7745c5c3_CtxErr 16 + } 17 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 + if !templ_7745c5c3_IsBuffer { 19 + defer func() { 20 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 + if templ_7745c5c3_Err == nil { 22 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 + } 24 + }() 25 + } 26 + ctx = templ.InitializeContext(ctx) 27 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 + if templ_7745c5c3_Var1 == nil { 29 + templ_7745c5c3_Var1 = templ.NopComponent 30 + } 31 + ctx = templ.ClearChildren(ctx) 32 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" data-theme=\"dark\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>") 33 + if templ_7745c5c3_Err != nil { 34 + return templ_7745c5c3_Err 35 + } 36 + var templ_7745c5c3_Var2 string 37 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) 38 + if templ_7745c5c3_Err != nil { 39 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/layout.templ`, Line: 9, Col: 17} 40 + } 41 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 42 + if templ_7745c5c3_Err != nil { 43 + return templ_7745c5c3_Err 44 + } 45 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Atmosphere Mail</title><link rel=\"stylesheet\" href=\"/ui/static/pico.min.css\"><script src=\"/ui/static/htmx.min.js\"></script><style>\n\t\t\t\t:root {\n\t\t\t\t\t--pico-font-size: 16px;\n\t\t\t\t}\n\t\t\t\tnav ul li strong {\n\t\t\t\t\tcolor: var(--pico-primary);\n\t\t\t\t}\n\t\t\t\t.badge {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tpadding: 0.15em 0.5em;\n\t\t\t\t\tborder-radius: 4px;\n\t\t\t\t\tfont-size: 0.85em;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t}\n\t\t\t\t.badge-active {\n\t\t\t\t\tbackground: var(--pico-ins-color);\n\t\t\t\t\tcolor: #fff;\n\t\t\t\t}\n\t\t\t\t.badge-suspended {\n\t\t\t\t\tbackground: var(--pico-del-color);\n\t\t\t\t\tcolor: #fff;\n\t\t\t\t}\n\t\t\t\t.badge-label {\n\t\t\t\t\tbackground: var(--pico-primary-background);\n\t\t\t\t\tcolor: var(--pico-primary-inverse);\n\t\t\t\t}\n\t\t\t\t.stat-grid {\n\t\t\t\t\tdisplay: grid;\n\t\t\t\t\tgrid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n\t\t\t\t\tgap: 1rem;\n\t\t\t\t}\n\t\t\t\t.stat-card {\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tpadding: 1.5rem 1rem;\n\t\t\t\t}\n\t\t\t\t.stat-card h2 {\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t\tfont-size: 2.5rem;\n\t\t\t\t}\n\t\t\t\t.stat-card p {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tcolor: var(--pico-muted-color);\n\t\t\t\t}\n\t\t\t\t.htmx-indicator {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t}\n\t\t\t\t.htmx-request .htmx-indicator {\n\t\t\t\t\tdisplay: inline;\n\t\t\t\t}\n\t\t\t\t.htmx-request.htmx-indicator {\n\t\t\t\t\tdisplay: inline;\n\t\t\t\t}\n\t\t\t</style></head><body><nav class=\"container\"><ul><li><strong>Atmosphere Mail</strong></li></ul><ul><li><a href=\"/ui/\">Dashboard</a></li><li><a href=\"/ui/members\">Members</a></li></ul></nav><main class=\"container\">") 46 + if templ_7745c5c3_Err != nil { 47 + return templ_7745c5c3_Err 48 + } 49 + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) 50 + if templ_7745c5c3_Err != nil { 51 + return templ_7745c5c3_Err 52 + } 53 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main><footer class=\"container\"><small>Atmosphere Mail Relay — Admin Dashboard</small></footer></body></html>") 54 + if templ_7745c5c3_Err != nil { 55 + return templ_7745c5c3_Err 56 + } 57 + return nil 58 + }) 59 + } 60 + 61 + var _ = templruntime.GeneratedTemplate
+117
internal/admin/ui/templates/member_detail.templ
··· 1 + package templates 2 + 3 + import "fmt" 4 + 5 + type MemberDetail struct { 6 + DID string 7 + Domain string // primary domain (for titles/breadcrumb) 8 + AllDomains []string // all registered domains 9 + Status string 10 + SuspendReason string 11 + SendCount int64 12 + HourlyLimit int 13 + DailyLimit int 14 + CreatedAt string 15 + Labels []string 16 + } 17 + 18 + templ MemberDetailPage(m MemberDetail) { 19 + @Layout(m.Domain) { 20 + <nav aria-label="breadcrumb"> 21 + <ul> 22 + <li><a href="/ui/members">Members</a></li> 23 + <li>{ m.Domain }</li> 24 + </ul> 25 + </nav> 26 + @MemberDetailContent(m) 27 + } 28 + } 29 + 30 + templ MemberDetailContent(m MemberDetail) { 31 + <div id="member-detail"> 32 + <hgroup> 33 + <h1>{ m.Domain }</h1> 34 + <p><code>{ m.DID }</code></p> 35 + </hgroup> 36 + <div class="grid"> 37 + <article> 38 + <header>Status</header> 39 + <div id="member-status"> 40 + @MemberStatusSection(m) 41 + </div> 42 + </article> 43 + <article> 44 + <header>Sending</header> 45 + <dl> 46 + <dt>Messages Sent</dt> 47 + <dd>{ fmt.Sprintf("%d", m.SendCount) }</dd> 48 + <dt>Hourly Limit</dt> 49 + <dd>{ fmt.Sprintf("%d", m.HourlyLimit) }</dd> 50 + <dt>Daily Limit</dt> 51 + <dd>{ fmt.Sprintf("%d", m.DailyLimit) }</dd> 52 + </dl> 53 + </article> 54 + <article> 55 + <header>Info</header> 56 + <dl> 57 + <dt>Enrolled</dt> 58 + <dd>{ m.CreatedAt }</dd> 59 + </dl> 60 + </article> 61 + </div> 62 + if len(m.AllDomains) > 1 { 63 + <article> 64 + <header>{ fmt.Sprintf("Domains (%d)", len(m.AllDomains)) }</header> 65 + <ul> 66 + for _, d := range m.AllDomains { 67 + <li>{ d }</li> 68 + } 69 + </ul> 70 + </article> 71 + } 72 + <article> 73 + <header>Labels</header> 74 + if len(m.Labels) == 0 { 75 + <p><em>No labels found (labeler may be unreachable)</em></p> 76 + } else { 77 + <div> 78 + for _, label := range m.Labels { 79 + @LabelBadge(label) 80 + { " " } 81 + } 82 + </div> 83 + } 84 + </article> 85 + </div> 86 + } 87 + 88 + templ MemberStatusSection(m MemberDetail) { 89 + <p> 90 + @StatusBadge(m.Status) 91 + </p> 92 + if m.SuspendReason != "" { 93 + <p><small>Reason: { m.SuspendReason }</small></p> 94 + } 95 + <div role="group"> 96 + if m.Status == "active" { 97 + <button 98 + class="secondary" 99 + hx-post={ "/ui/member/" + m.DID + "/suspend" } 100 + hx-target="#member-status" 101 + hx-swap="innerHTML" 102 + hx-confirm="Suspend this member?" 103 + > 104 + Suspend 105 + </button> 106 + } else { 107 + <button 108 + hx-post={ "/ui/member/" + m.DID + "/reactivate" } 109 + hx-target="#member-status" 110 + hx-swap="innerHTML" 111 + hx-confirm="Reactivate this member?" 112 + > 113 + Reactivate 114 + </button> 115 + } 116 + </div> 117 + }
+390
internal/admin/ui/templates/member_detail_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.1001 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import "fmt" 12 + 13 + type MemberDetail struct { 14 + DID string 15 + Domain string // primary domain (for titles/breadcrumb) 16 + AllDomains []string // all registered domains 17 + Status string 18 + SuspendReason string 19 + SendCount int64 20 + HourlyLimit int 21 + DailyLimit int 22 + CreatedAt string 23 + Labels []string 24 + } 25 + 26 + func MemberDetailPage(m MemberDetail) templ.Component { 27 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 28 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 29 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 30 + return templ_7745c5c3_CtxErr 31 + } 32 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 33 + if !templ_7745c5c3_IsBuffer { 34 + defer func() { 35 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 36 + if templ_7745c5c3_Err == nil { 37 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 38 + } 39 + }() 40 + } 41 + ctx = templ.InitializeContext(ctx) 42 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 43 + if templ_7745c5c3_Var1 == nil { 44 + templ_7745c5c3_Var1 = templ.NopComponent 45 + } 46 + ctx = templ.ClearChildren(ctx) 47 + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 48 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 49 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 50 + if !templ_7745c5c3_IsBuffer { 51 + defer func() { 52 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 53 + if templ_7745c5c3_Err == nil { 54 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 55 + } 56 + }() 57 + } 58 + ctx = templ.InitializeContext(ctx) 59 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<nav aria-label=\"breadcrumb\"><ul><li><a href=\"/ui/members\">Members</a></li><li>") 60 + if templ_7745c5c3_Err != nil { 61 + return templ_7745c5c3_Err 62 + } 63 + var templ_7745c5c3_Var3 string 64 + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(m.Domain) 65 + if templ_7745c5c3_Err != nil { 66 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 23, Col: 18} 67 + } 68 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 69 + if templ_7745c5c3_Err != nil { 70 + return templ_7745c5c3_Err 71 + } 72 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</li></ul></nav>") 73 + if templ_7745c5c3_Err != nil { 74 + return templ_7745c5c3_Err 75 + } 76 + templ_7745c5c3_Err = MemberDetailContent(m).Render(ctx, templ_7745c5c3_Buffer) 77 + if templ_7745c5c3_Err != nil { 78 + return templ_7745c5c3_Err 79 + } 80 + return nil 81 + }) 82 + templ_7745c5c3_Err = Layout(m.Domain).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 83 + if templ_7745c5c3_Err != nil { 84 + return templ_7745c5c3_Err 85 + } 86 + return nil 87 + }) 88 + } 89 + 90 + func MemberDetailContent(m MemberDetail) templ.Component { 91 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 92 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 93 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 94 + return templ_7745c5c3_CtxErr 95 + } 96 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 97 + if !templ_7745c5c3_IsBuffer { 98 + defer func() { 99 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 100 + if templ_7745c5c3_Err == nil { 101 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 102 + } 103 + }() 104 + } 105 + ctx = templ.InitializeContext(ctx) 106 + templ_7745c5c3_Var4 := templ.GetChildren(ctx) 107 + if templ_7745c5c3_Var4 == nil { 108 + templ_7745c5c3_Var4 = templ.NopComponent 109 + } 110 + ctx = templ.ClearChildren(ctx) 111 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div id=\"member-detail\"><hgroup><h1>") 112 + if templ_7745c5c3_Err != nil { 113 + return templ_7745c5c3_Err 114 + } 115 + var templ_7745c5c3_Var5 string 116 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(m.Domain) 117 + if templ_7745c5c3_Err != nil { 118 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 33, Col: 17} 119 + } 120 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 121 + if templ_7745c5c3_Err != nil { 122 + return templ_7745c5c3_Err 123 + } 124 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h1><p><code>") 125 + if templ_7745c5c3_Err != nil { 126 + return templ_7745c5c3_Err 127 + } 128 + var templ_7745c5c3_Var6 string 129 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(m.DID) 130 + if templ_7745c5c3_Err != nil { 131 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 34, Col: 19} 132 + } 133 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 134 + if templ_7745c5c3_Err != nil { 135 + return templ_7745c5c3_Err 136 + } 137 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></p></hgroup><div class=\"grid\"><article><header>Status</header><div id=\"member-status\">") 138 + if templ_7745c5c3_Err != nil { 139 + return templ_7745c5c3_Err 140 + } 141 + templ_7745c5c3_Err = MemberStatusSection(m).Render(ctx, templ_7745c5c3_Buffer) 142 + if templ_7745c5c3_Err != nil { 143 + return templ_7745c5c3_Err 144 + } 145 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></article><article><header>Sending</header><dl><dt>Messages Sent</dt><dd>") 146 + if templ_7745c5c3_Err != nil { 147 + return templ_7745c5c3_Err 148 + } 149 + var templ_7745c5c3_Var7 string 150 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.SendCount)) 151 + if templ_7745c5c3_Err != nil { 152 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 47, Col: 41} 153 + } 154 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 155 + if templ_7745c5c3_Err != nil { 156 + return templ_7745c5c3_Err 157 + } 158 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</dd><dt>Hourly Limit</dt><dd>") 159 + if templ_7745c5c3_Err != nil { 160 + return templ_7745c5c3_Err 161 + } 162 + var templ_7745c5c3_Var8 string 163 + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.HourlyLimit)) 164 + if templ_7745c5c3_Err != nil { 165 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 49, Col: 43} 166 + } 167 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 168 + if templ_7745c5c3_Err != nil { 169 + return templ_7745c5c3_Err 170 + } 171 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</dd><dt>Daily Limit</dt><dd>") 172 + if templ_7745c5c3_Err != nil { 173 + return templ_7745c5c3_Err 174 + } 175 + var templ_7745c5c3_Var9 string 176 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.DailyLimit)) 177 + if templ_7745c5c3_Err != nil { 178 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 51, Col: 42} 179 + } 180 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 181 + if templ_7745c5c3_Err != nil { 182 + return templ_7745c5c3_Err 183 + } 184 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</dd></dl></article><article><header>Info</header><dl><dt>Enrolled</dt><dd>") 185 + if templ_7745c5c3_Err != nil { 186 + return templ_7745c5c3_Err 187 + } 188 + var templ_7745c5c3_Var10 string 189 + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(m.CreatedAt) 190 + if templ_7745c5c3_Err != nil { 191 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 58, Col: 22} 192 + } 193 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 194 + if templ_7745c5c3_Err != nil { 195 + return templ_7745c5c3_Err 196 + } 197 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</dd></dl></article></div>") 198 + if templ_7745c5c3_Err != nil { 199 + return templ_7745c5c3_Err 200 + } 201 + if len(m.AllDomains) > 1 { 202 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<article><header>") 203 + if templ_7745c5c3_Err != nil { 204 + return templ_7745c5c3_Err 205 + } 206 + var templ_7745c5c3_Var11 string 207 + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Domains (%d)", len(m.AllDomains))) 208 + if templ_7745c5c3_Err != nil { 209 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 64, Col: 60} 210 + } 211 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 212 + if templ_7745c5c3_Err != nil { 213 + return templ_7745c5c3_Err 214 + } 215 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</header><ul>") 216 + if templ_7745c5c3_Err != nil { 217 + return templ_7745c5c3_Err 218 + } 219 + for _, d := range m.AllDomains { 220 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<li>") 221 + if templ_7745c5c3_Err != nil { 222 + return templ_7745c5c3_Err 223 + } 224 + var templ_7745c5c3_Var12 string 225 + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d) 226 + if templ_7745c5c3_Err != nil { 227 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 67, Col: 13} 228 + } 229 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) 230 + if templ_7745c5c3_Err != nil { 231 + return templ_7745c5c3_Err 232 + } 233 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</li>") 234 + if templ_7745c5c3_Err != nil { 235 + return templ_7745c5c3_Err 236 + } 237 + } 238 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</ul></article>") 239 + if templ_7745c5c3_Err != nil { 240 + return templ_7745c5c3_Err 241 + } 242 + } 243 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<article><header>Labels</header>") 244 + if templ_7745c5c3_Err != nil { 245 + return templ_7745c5c3_Err 246 + } 247 + if len(m.Labels) == 0 { 248 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p><em>No labels found (labeler may be unreachable)</em></p>") 249 + if templ_7745c5c3_Err != nil { 250 + return templ_7745c5c3_Err 251 + } 252 + } else { 253 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div>") 254 + if templ_7745c5c3_Err != nil { 255 + return templ_7745c5c3_Err 256 + } 257 + for _, label := range m.Labels { 258 + templ_7745c5c3_Err = LabelBadge(label).Render(ctx, templ_7745c5c3_Buffer) 259 + if templ_7745c5c3_Err != nil { 260 + return templ_7745c5c3_Err 261 + } 262 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ") 263 + if templ_7745c5c3_Err != nil { 264 + return templ_7745c5c3_Err 265 + } 266 + var templ_7745c5c3_Var13 string 267 + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" ") 268 + if templ_7745c5c3_Err != nil { 269 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 80, Col: 11} 270 + } 271 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 272 + if templ_7745c5c3_Err != nil { 273 + return templ_7745c5c3_Err 274 + } 275 + } 276 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>") 277 + if templ_7745c5c3_Err != nil { 278 + return templ_7745c5c3_Err 279 + } 280 + } 281 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</article></div>") 282 + if templ_7745c5c3_Err != nil { 283 + return templ_7745c5c3_Err 284 + } 285 + return nil 286 + }) 287 + } 288 + 289 + func MemberStatusSection(m MemberDetail) templ.Component { 290 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 291 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 292 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 293 + return templ_7745c5c3_CtxErr 294 + } 295 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 296 + if !templ_7745c5c3_IsBuffer { 297 + defer func() { 298 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 299 + if templ_7745c5c3_Err == nil { 300 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 301 + } 302 + }() 303 + } 304 + ctx = templ.InitializeContext(ctx) 305 + templ_7745c5c3_Var14 := templ.GetChildren(ctx) 306 + if templ_7745c5c3_Var14 == nil { 307 + templ_7745c5c3_Var14 = templ.NopComponent 308 + } 309 + ctx = templ.ClearChildren(ctx) 310 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<p>") 311 + if templ_7745c5c3_Err != nil { 312 + return templ_7745c5c3_Err 313 + } 314 + templ_7745c5c3_Err = StatusBadge(m.Status).Render(ctx, templ_7745c5c3_Buffer) 315 + if templ_7745c5c3_Err != nil { 316 + return templ_7745c5c3_Err 317 + } 318 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p>") 319 + if templ_7745c5c3_Err != nil { 320 + return templ_7745c5c3_Err 321 + } 322 + if m.SuspendReason != "" { 323 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p><small>Reason: ") 324 + if templ_7745c5c3_Err != nil { 325 + return templ_7745c5c3_Err 326 + } 327 + var templ_7745c5c3_Var15 string 328 + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.SuspendReason) 329 + if templ_7745c5c3_Err != nil { 330 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 93, Col: 37} 331 + } 332 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 333 + if templ_7745c5c3_Err != nil { 334 + return templ_7745c5c3_Err 335 + } 336 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</small></p>") 337 + if templ_7745c5c3_Err != nil { 338 + return templ_7745c5c3_Err 339 + } 340 + } 341 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div role=\"group\">") 342 + if templ_7745c5c3_Err != nil { 343 + return templ_7745c5c3_Err 344 + } 345 + if m.Status == "active" { 346 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button class=\"secondary\" hx-post=\"") 347 + if templ_7745c5c3_Err != nil { 348 + return templ_7745c5c3_Err 349 + } 350 + var templ_7745c5c3_Var16 string 351 + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs("/ui/member/" + m.DID + "/suspend") 352 + if templ_7745c5c3_Err != nil { 353 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 99, Col: 48} 354 + } 355 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) 356 + if templ_7745c5c3_Err != nil { 357 + return templ_7745c5c3_Err 358 + } 359 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Suspend this member?\">Suspend</button>") 360 + if templ_7745c5c3_Err != nil { 361 + return templ_7745c5c3_Err 362 + } 363 + } else { 364 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<button hx-post=\"") 365 + if templ_7745c5c3_Err != nil { 366 + return templ_7745c5c3_Err 367 + } 368 + var templ_7745c5c3_Var17 string 369 + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs("/ui/member/" + m.DID + "/reactivate") 370 + if templ_7745c5c3_Err != nil { 371 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 108, Col: 51} 372 + } 373 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) 374 + if templ_7745c5c3_Err != nil { 375 + return templ_7745c5c3_Err 376 + } 377 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Reactivate this member?\">Reactivate</button>") 378 + if templ_7745c5c3_Err != nil { 379 + return templ_7745c5c3_Err 380 + } 381 + } 382 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>") 383 + if templ_7745c5c3_Err != nil { 384 + return templ_7745c5c3_Err 385 + } 386 + return nil 387 + }) 388 + } 389 + 390 + var _ = templruntime.GeneratedTemplate
+93
internal/admin/ui/templates/members.templ
··· 1 + package templates 2 + 3 + import "fmt" 4 + 5 + type MemberRow struct { 6 + DID string 7 + Domain string // primary domain 8 + DomainCount int // total number of domains 9 + Status string 10 + SendCount int64 11 + CreatedAt string 12 + } 13 + 14 + templ MembersPage(members []MemberRow) { 15 + @Layout("Members") { 16 + @MembersContent(members) 17 + } 18 + } 19 + 20 + templ MembersContent(members []MemberRow) { 21 + <div id="members-list"> 22 + <hgroup> 23 + <h1>Members</h1> 24 + <p>{ fmt.Sprintf("%d", len(members)) } enrolled members</p> 25 + </hgroup> 26 + if len(members) == 0 { 27 + <p>No members enrolled yet.</p> 28 + } else { 29 + <figure> 30 + <table role="grid"> 31 + <thead> 32 + <tr> 33 + <th>Domain</th> 34 + <th>DID</th> 35 + <th>Status</th> 36 + <th>Sent</th> 37 + <th>Enrolled</th> 38 + <th></th> 39 + </tr> 40 + </thead> 41 + <tbody> 42 + for _, m := range members { 43 + @MemberTableRow(m) 44 + } 45 + </tbody> 46 + </table> 47 + </figure> 48 + } 49 + </div> 50 + } 51 + 52 + templ MemberTableRow(m MemberRow) { 53 + <tr> 54 + <td> 55 + <strong>{ m.Domain }</strong> 56 + if m.DomainCount > 1 { 57 + <small> +{ fmt.Sprintf("%d", m.DomainCount-1) }</small> 58 + } 59 + </td> 60 + <td><code>{ truncateDID(m.DID) }</code></td> 61 + <td> 62 + @StatusBadge(m.Status) 63 + </td> 64 + <td>{ fmt.Sprintf("%d", m.SendCount) }</td> 65 + <td>{ m.CreatedAt }</td> 66 + <td> 67 + <a href={ templ.SafeURL("/ui/member/" + m.DID) } role="button" class="outline secondary"> 68 + Details 69 + </a> 70 + </td> 71 + </tr> 72 + } 73 + 74 + templ StatusBadge(status string) { 75 + if status == "active" { 76 + <span class="badge badge-active">active</span> 77 + } else if status == "suspended" { 78 + <span class="badge badge-suspended">suspended</span> 79 + } else { 80 + <span class="badge">{ status }</span> 81 + } 82 + } 83 + 84 + templ LabelBadge(label string) { 85 + <span class="badge badge-label">{ label }</span> 86 + } 87 + 88 + func truncateDID(did string) string { 89 + if len(did) > 30 { 90 + return did[:15] + "..." + did[len(did)-12:] 91 + } 92 + return did 93 + }
+364
internal/admin/ui/templates/members_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.1001 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import "fmt" 12 + 13 + type MemberRow struct { 14 + DID string 15 + Domain string // primary domain 16 + DomainCount int // total number of domains 17 + Status string 18 + SendCount int64 19 + CreatedAt string 20 + } 21 + 22 + func MembersPage(members []MemberRow) templ.Component { 23 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 24 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 25 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 26 + return templ_7745c5c3_CtxErr 27 + } 28 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 29 + if !templ_7745c5c3_IsBuffer { 30 + defer func() { 31 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 32 + if templ_7745c5c3_Err == nil { 33 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 34 + } 35 + }() 36 + } 37 + ctx = templ.InitializeContext(ctx) 38 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 39 + if templ_7745c5c3_Var1 == nil { 40 + templ_7745c5c3_Var1 = templ.NopComponent 41 + } 42 + ctx = templ.ClearChildren(ctx) 43 + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 44 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 45 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 46 + if !templ_7745c5c3_IsBuffer { 47 + defer func() { 48 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 49 + if templ_7745c5c3_Err == nil { 50 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 51 + } 52 + }() 53 + } 54 + ctx = templ.InitializeContext(ctx) 55 + templ_7745c5c3_Err = MembersContent(members).Render(ctx, templ_7745c5c3_Buffer) 56 + if templ_7745c5c3_Err != nil { 57 + return templ_7745c5c3_Err 58 + } 59 + return nil 60 + }) 61 + templ_7745c5c3_Err = Layout("Members").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 62 + if templ_7745c5c3_Err != nil { 63 + return templ_7745c5c3_Err 64 + } 65 + return nil 66 + }) 67 + } 68 + 69 + func MembersContent(members []MemberRow) templ.Component { 70 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 71 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 72 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 73 + return templ_7745c5c3_CtxErr 74 + } 75 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 76 + if !templ_7745c5c3_IsBuffer { 77 + defer func() { 78 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 79 + if templ_7745c5c3_Err == nil { 80 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 81 + } 82 + }() 83 + } 84 + ctx = templ.InitializeContext(ctx) 85 + templ_7745c5c3_Var3 := templ.GetChildren(ctx) 86 + if templ_7745c5c3_Var3 == nil { 87 + templ_7745c5c3_Var3 = templ.NopComponent 88 + } 89 + ctx = templ.ClearChildren(ctx) 90 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"members-list\"><hgroup><h1>Members</h1><p>") 91 + if templ_7745c5c3_Err != nil { 92 + return templ_7745c5c3_Err 93 + } 94 + var templ_7745c5c3_Var4 string 95 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(members))) 96 + if templ_7745c5c3_Err != nil { 97 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 24, Col: 39} 98 + } 99 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 100 + if templ_7745c5c3_Err != nil { 101 + return templ_7745c5c3_Err 102 + } 103 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " enrolled members</p></hgroup> ") 104 + if templ_7745c5c3_Err != nil { 105 + return templ_7745c5c3_Err 106 + } 107 + if len(members) == 0 { 108 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p>No members enrolled yet.</p>") 109 + if templ_7745c5c3_Err != nil { 110 + return templ_7745c5c3_Err 111 + } 112 + } else { 113 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<figure><table role=\"grid\"><thead><tr><th>Domain</th><th>DID</th><th>Status</th><th>Sent</th><th>Enrolled</th><th></th></tr></thead> <tbody>") 114 + if templ_7745c5c3_Err != nil { 115 + return templ_7745c5c3_Err 116 + } 117 + for _, m := range members { 118 + templ_7745c5c3_Err = MemberTableRow(m).Render(ctx, templ_7745c5c3_Buffer) 119 + if templ_7745c5c3_Err != nil { 120 + return templ_7745c5c3_Err 121 + } 122 + } 123 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</tbody></table></figure>") 124 + if templ_7745c5c3_Err != nil { 125 + return templ_7745c5c3_Err 126 + } 127 + } 128 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>") 129 + if templ_7745c5c3_Err != nil { 130 + return templ_7745c5c3_Err 131 + } 132 + return nil 133 + }) 134 + } 135 + 136 + func MemberTableRow(m MemberRow) templ.Component { 137 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 138 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 139 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 140 + return templ_7745c5c3_CtxErr 141 + } 142 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 143 + if !templ_7745c5c3_IsBuffer { 144 + defer func() { 145 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 146 + if templ_7745c5c3_Err == nil { 147 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 148 + } 149 + }() 150 + } 151 + ctx = templ.InitializeContext(ctx) 152 + templ_7745c5c3_Var5 := templ.GetChildren(ctx) 153 + if templ_7745c5c3_Var5 == nil { 154 + templ_7745c5c3_Var5 = templ.NopComponent 155 + } 156 + ctx = templ.ClearChildren(ctx) 157 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<tr><td><strong>") 158 + if templ_7745c5c3_Err != nil { 159 + return templ_7745c5c3_Err 160 + } 161 + var templ_7745c5c3_Var6 string 162 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(m.Domain) 163 + if templ_7745c5c3_Err != nil { 164 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 55, Col: 21} 165 + } 166 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 167 + if templ_7745c5c3_Err != nil { 168 + return templ_7745c5c3_Err 169 + } 170 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</strong> ") 171 + if templ_7745c5c3_Err != nil { 172 + return templ_7745c5c3_Err 173 + } 174 + if m.DomainCount > 1 { 175 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<small>+") 176 + if templ_7745c5c3_Err != nil { 177 + return templ_7745c5c3_Err 178 + } 179 + var templ_7745c5c3_Var7 string 180 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.DomainCount-1)) 181 + if templ_7745c5c3_Err != nil { 182 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 57, Col: 49} 183 + } 184 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 185 + if templ_7745c5c3_Err != nil { 186 + return templ_7745c5c3_Err 187 + } 188 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</small>") 189 + if templ_7745c5c3_Err != nil { 190 + return templ_7745c5c3_Err 191 + } 192 + } 193 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td><code>") 194 + if templ_7745c5c3_Err != nil { 195 + return templ_7745c5c3_Err 196 + } 197 + var templ_7745c5c3_Var8 string 198 + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(truncateDID(m.DID)) 199 + if templ_7745c5c3_Err != nil { 200 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 60, Col: 32} 201 + } 202 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 203 + if templ_7745c5c3_Err != nil { 204 + return templ_7745c5c3_Err 205 + } 206 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</code></td><td>") 207 + if templ_7745c5c3_Err != nil { 208 + return templ_7745c5c3_Err 209 + } 210 + templ_7745c5c3_Err = StatusBadge(m.Status).Render(ctx, templ_7745c5c3_Buffer) 211 + if templ_7745c5c3_Err != nil { 212 + return templ_7745c5c3_Err 213 + } 214 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td><td>") 215 + if templ_7745c5c3_Err != nil { 216 + return templ_7745c5c3_Err 217 + } 218 + var templ_7745c5c3_Var9 string 219 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.SendCount)) 220 + if templ_7745c5c3_Err != nil { 221 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 64, Col: 38} 222 + } 223 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 224 + if templ_7745c5c3_Err != nil { 225 + return templ_7745c5c3_Err 226 + } 227 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</td><td>") 228 + if templ_7745c5c3_Err != nil { 229 + return templ_7745c5c3_Err 230 + } 231 + var templ_7745c5c3_Var10 string 232 + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(m.CreatedAt) 233 + if templ_7745c5c3_Err != nil { 234 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 65, Col: 19} 235 + } 236 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 237 + if templ_7745c5c3_Err != nil { 238 + return templ_7745c5c3_Err 239 + } 240 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td><a href=\"") 241 + if templ_7745c5c3_Err != nil { 242 + return templ_7745c5c3_Err 243 + } 244 + var templ_7745c5c3_Var11 templ.SafeURL 245 + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/ui/member/" + m.DID)) 246 + if templ_7745c5c3_Err != nil { 247 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 67, Col: 49} 248 + } 249 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 250 + if templ_7745c5c3_Err != nil { 251 + return templ_7745c5c3_Err 252 + } 253 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" role=\"button\" class=\"outline secondary\">Details</a></td></tr>") 254 + if templ_7745c5c3_Err != nil { 255 + return templ_7745c5c3_Err 256 + } 257 + return nil 258 + }) 259 + } 260 + 261 + func StatusBadge(status string) templ.Component { 262 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 263 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 264 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 265 + return templ_7745c5c3_CtxErr 266 + } 267 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 268 + if !templ_7745c5c3_IsBuffer { 269 + defer func() { 270 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 271 + if templ_7745c5c3_Err == nil { 272 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 273 + } 274 + }() 275 + } 276 + ctx = templ.InitializeContext(ctx) 277 + templ_7745c5c3_Var12 := templ.GetChildren(ctx) 278 + if templ_7745c5c3_Var12 == nil { 279 + templ_7745c5c3_Var12 = templ.NopComponent 280 + } 281 + ctx = templ.ClearChildren(ctx) 282 + if status == "active" { 283 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge badge-active\">active</span>") 284 + if templ_7745c5c3_Err != nil { 285 + return templ_7745c5c3_Err 286 + } 287 + } else if status == "suspended" { 288 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"badge badge-suspended\">suspended</span>") 289 + if templ_7745c5c3_Err != nil { 290 + return templ_7745c5c3_Err 291 + } 292 + } else { 293 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge\">") 294 + if templ_7745c5c3_Err != nil { 295 + return templ_7745c5c3_Err 296 + } 297 + var templ_7745c5c3_Var13 string 298 + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(status) 299 + if templ_7745c5c3_Err != nil { 300 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 80, Col: 30} 301 + } 302 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 303 + if templ_7745c5c3_Err != nil { 304 + return templ_7745c5c3_Err 305 + } 306 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>") 307 + if templ_7745c5c3_Err != nil { 308 + return templ_7745c5c3_Err 309 + } 310 + } 311 + return nil 312 + }) 313 + } 314 + 315 + func LabelBadge(label string) templ.Component { 316 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 317 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 318 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 319 + return templ_7745c5c3_CtxErr 320 + } 321 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 322 + if !templ_7745c5c3_IsBuffer { 323 + defer func() { 324 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 325 + if templ_7745c5c3_Err == nil { 326 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 327 + } 328 + }() 329 + } 330 + ctx = templ.InitializeContext(ctx) 331 + templ_7745c5c3_Var14 := templ.GetChildren(ctx) 332 + if templ_7745c5c3_Var14 == nil { 333 + templ_7745c5c3_Var14 = templ.NopComponent 334 + } 335 + ctx = templ.ClearChildren(ctx) 336 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"badge badge-label\">") 337 + if templ_7745c5c3_Err != nil { 338 + return templ_7745c5c3_Err 339 + } 340 + var templ_7745c5c3_Var15 string 341 + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label) 342 + if templ_7745c5c3_Err != nil { 343 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/members.templ`, Line: 85, Col: 40} 344 + } 345 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 346 + if templ_7745c5c3_Err != nil { 347 + return templ_7745c5c3_Err 348 + } 349 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span>") 350 + if templ_7745c5c3_Err != nil { 351 + return templ_7745c5c3_Err 352 + } 353 + return nil 354 + }) 355 + } 356 + 357 + func truncateDID(did string) string { 358 + if len(did) > 30 { 359 + return did[:15] + "..." + did[len(did)-12:] 360 + } 361 + return did 362 + } 363 + 364 + var _ = templruntime.GeneratedTemplate
+6 -11
internal/relay/bounce_test.go
··· 19 19 20 20 // Insert a test member for foreign key constraints 21 21 if err := s.InsertMember(context.Background(), &relaystore.Member{ 22 - DID: "did:plc:bounce-test", 23 - Domain: "example.com", 24 - APIKeyHash: []byte("hash"), 25 - DKIMRSAPriv: []byte("rsa"), 26 - DKIMEdPriv: []byte("ed"), 27 - DKIMSelector: "sel1", 28 - Status: relaystore.StatusActive, 29 - HourlyLimit: 100, 30 - DailyLimit: 1000, 31 - CreatedAt: time.Now().UTC(), 32 - UpdatedAt: time.Now().UTC(), 22 + DID: "did:plc:bounce-test", 23 + Status: relaystore.StatusActive, 24 + HourlyLimit: 100, 25 + DailyLimit: 1000, 26 + CreatedAt: time.Now().UTC(), 27 + UpdatedAt: time.Now().UTC(), 33 28 }); err != nil { 34 29 t.Fatalf("InsertMember: %v", err) 35 30 }
+36
internal/relay/labelcheck.go
··· 164 164 return removed 165 165 } 166 166 167 + // QueryLabels returns the active (non-negated) label values for a DID. 168 + // Returns nil with no error if the labeler is unreachable or returns no labels. 169 + func (lc *LabelChecker) QueryLabels(ctx context.Context, did string) ([]string, error) { 170 + u := lc.labelerURL + "/xrpc/com.atproto.label.queryLabels?" + url.Values{ 171 + "uriPatterns": {did}, 172 + }.Encode() 173 + 174 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) 175 + if err != nil { 176 + return nil, err 177 + } 178 + 179 + resp, err := lc.client.Do(req) 180 + if err != nil { 181 + return nil, nil // labeler unreachable — don't fail the UI 182 + } 183 + defer resp.Body.Close() 184 + 185 + if resp.StatusCode != http.StatusOK { 186 + return nil, nil 187 + } 188 + 189 + var result queryLabelsResponse 190 + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { 191 + return nil, nil 192 + } 193 + 194 + var labels []string 195 + for _, l := range result.Labels { 196 + if !l.Neg { 197 + labels = append(labels, l.Val) 198 + } 199 + } 200 + return labels, nil 201 + } 202 + 167 203 func (lc *LabelChecker) queryLabeler(ctx context.Context, did string) (bool, error) { 168 204 u := lc.labelerURL + "/xrpc/com.atproto.label.queryLabels?" + url.Values{ 169 205 "uriPatterns": {did},
+50 -11
internal/relay/smtp.go
··· 28 28 ReadTimeout time.Duration // default 60s 29 29 } 30 30 31 - // MemberLookupFunc returns a member for SMTP AUTH. 32 - // Returns nil if the DID is not found. 33 - type MemberLookupFunc func(ctx context.Context, did string) (*AuthMember, error) 31 + // MemberLookupFunc returns a member and all their domains for SMTP AUTH. 32 + // The auth handler iterates domains to match the API key, resolving to a 33 + // specific domain for the session. Returns nil if the DID is not found. 34 + type MemberLookupFunc func(ctx context.Context, did string) (*MemberWithDomains, error) 35 + 36 + // MemberWithDomains is the DID-level member info plus all registered domains. 37 + // Returned by MemberLookupFunc so AUTH can match the API key to a domain. 38 + type MemberWithDomains struct { 39 + DID string 40 + Status string 41 + SendCount int64 42 + HourlyLimit int 43 + DailyLimit int 44 + CreatedAt time.Time 45 + Domains []DomainInfo 46 + } 47 + 48 + // DomainInfo holds domain-level credentials for SMTP AUTH matching. 49 + type DomainInfo struct { 50 + Domain string 51 + APIKeyHash []byte 52 + DKIMKeys *DKIMKeys 53 + DKIMSelector string 54 + } 34 55 35 - // AuthMember is the minimal member info needed for SMTP AUTH + sending. 56 + // AuthMember is the resolved member for a specific domain in this SMTP session. 57 + // Constructed during AUTH by matching the API key to a domain. 36 58 type AuthMember struct { 37 59 DID string 38 60 Domain string 39 - APIKeyHash []byte 40 61 Status string 41 62 SendCount int64 42 63 DKIMKeys *DKIMKeys ··· 157 178 } 158 179 } 159 180 160 - member, err := s.server.memberLookup(context.Background(), username) 181 + mwd, err := s.server.memberLookup(context.Background(), username) 161 182 if err != nil { 162 183 log.Printf("smtp.auth: did=%q ip=%s success=false failure_reason=lookup_error error=%v", username, s.conn.Hostname(), err) 163 184 authFail() ··· 167 188 Message: "Temporary authentication failure", 168 189 } 169 190 } 170 - if member == nil { 191 + if mwd == nil { 171 192 log.Printf("smtp.auth: did=%q ip=%s success=false failure_reason=unknown_did", username, s.conn.Hostname()) 172 193 authFail() 173 194 return &smtp.SMTPError{ ··· 177 198 } 178 199 } 179 200 180 - if !VerifyAPIKey(password, member.APIKeyHash) { 201 + // Match API key to a specific domain 202 + var matched *DomainInfo 203 + for i := range mwd.Domains { 204 + if VerifyAPIKey(password, mwd.Domains[i].APIKeyHash) { 205 + matched = &mwd.Domains[i] 206 + break 207 + } 208 + } 209 + if matched == nil { 181 210 log.Printf("smtp.auth: did=%q ip=%s success=false failure_reason=bad_api_key", username, s.conn.Hostname()) 182 211 authFail() 183 212 return &smtp.SMTPError{ ··· 187 216 } 188 217 } 189 218 190 - if member.Status == relaystore.StatusSuspended { 219 + if mwd.Status == relaystore.StatusSuspended { 191 220 log.Printf("smtp.auth: did=%q ip=%s success=false failure_reason=suspended", username, s.conn.Hostname()) 192 221 authFail() 193 222 return &smtp.SMTPError{ ··· 197 226 } 198 227 } 199 228 200 - log.Printf("smtp.auth: did=%q ip=%s success=true", username, s.conn.Hostname()) 229 + log.Printf("smtp.auth: did=%q domain=%s ip=%s success=true", username, matched.Domain, s.conn.Hostname()) 201 230 if s.server.metrics != nil { 202 231 s.server.metrics.AuthAttempts.WithLabelValues("success").Inc() 203 232 } 204 233 s.mu.Lock() 205 - s.member = member 234 + s.member = &AuthMember{ 235 + DID: mwd.DID, 236 + Domain: matched.Domain, 237 + Status: mwd.Status, 238 + SendCount: mwd.SendCount, 239 + DKIMKeys: matched.DKIMKeys, 240 + DKIMSelector: matched.DKIMSelector, 241 + HourlyLimit: mwd.HourlyLimit, 242 + DailyLimit: mwd.DailyLimit, 243 + CreatedAt: mwd.CreatedAt, 244 + } 206 245 s.mu.Unlock() 207 246 return nil 208 247 }), nil
+143 -48
internal/relay/smtp_test.go
··· 68 68 apiKey := "atmos_testkey123" 69 69 hash, _ := HashAPIKey(apiKey) 70 70 71 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 71 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 72 72 if did == "did:plc:testuser" { 73 - return &AuthMember{ 74 - DID: "did:plc:testuser", 75 - Domain: "example.com", 76 - APIKeyHash: hash, 77 - Status: relaystore.StatusActive, 73 + return &MemberWithDomains{ 74 + DID: "did:plc:testuser", 75 + Status: relaystore.StatusActive, 76 + Domains: []DomainInfo{{ 77 + Domain: "example.com", 78 + APIKeyHash: hash, 79 + }}, 78 80 }, nil 79 81 } 80 82 return nil, nil ··· 98 100 func TestSMTPAuthBadKey(t *testing.T) { 99 101 hash, _ := HashAPIKey("atmos_correctkey") 100 102 101 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 102 - return &AuthMember{ 103 - DID: did, 104 - Domain: "example.com", 105 - APIKeyHash: hash, 106 - Status: relaystore.StatusActive, 103 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 104 + return &MemberWithDomains{ 105 + DID: did, 106 + Status: relaystore.StatusActive, 107 + Domains: []DomainInfo{{ 108 + Domain: "example.com", 109 + APIKeyHash: hash, 110 + }}, 107 111 }, nil 108 112 } 109 113 ··· 126 130 apiKey := "atmos_testkey123" 127 131 hash, _ := HashAPIKey(apiKey) 128 132 129 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 130 - return &AuthMember{ 131 - DID: did, 132 - Domain: "example.com", 133 - APIKeyHash: hash, 134 - Status: relaystore.StatusSuspended, 133 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 134 + return &MemberWithDomains{ 135 + DID: did, 136 + Status: relaystore.StatusSuspended, 137 + Domains: []DomainInfo{{ 138 + Domain: "example.com", 139 + APIKeyHash: hash, 140 + }}, 135 141 }, nil 136 142 } 137 143 ··· 154 160 apiKey := "atmos_testkey123" 155 161 hash, _ := HashAPIKey(apiKey) 156 162 157 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 158 - return &AuthMember{ 159 - DID: did, 160 - Domain: "example.com", 161 - APIKeyHash: hash, 162 - Status: relaystore.StatusActive, 163 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 164 + return &MemberWithDomains{ 165 + DID: did, 166 + Status: relaystore.StatusActive, 167 + Domains: []DomainInfo{{ 168 + Domain: "example.com", 169 + APIKeyHash: hash, 170 + }}, 163 171 }, nil 164 172 } 165 173 ··· 194 202 apiKey := "atmos_testkey123" 195 203 hash, _ := HashAPIKey(apiKey) 196 204 197 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 198 - return &AuthMember{ 199 - DID: did, 200 - Domain: "example.com", 201 - APIKeyHash: hash, 202 - Status: relaystore.StatusActive, 205 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 206 + return &MemberWithDomains{ 207 + DID: did, 208 + Status: relaystore.StatusActive, 209 + Domains: []DomainInfo{{ 210 + Domain: "example.com", 211 + APIKeyHash: hash, 212 + }}, 203 213 }, nil 204 214 } 205 215 ··· 263 273 apiKey := "atmos_testkey123" 264 274 hash, _ := HashAPIKey(apiKey) 265 275 266 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 267 - return &AuthMember{ 268 - DID: did, 269 - Domain: "example.com", 270 - APIKeyHash: hash, 271 - Status: relaystore.StatusActive, 276 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 277 + return &MemberWithDomains{ 278 + DID: did, 279 + Status: relaystore.StatusActive, 280 + Domains: []DomainInfo{{ 281 + Domain: "example.com", 282 + APIKeyHash: hash, 283 + }}, 272 284 }, nil 273 285 } 274 286 ··· 298 310 apiKey := "atmos_testkey123" 299 311 hash, _ := HashAPIKey(apiKey) 300 312 301 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 302 - return &AuthMember{ 303 - DID: did, 304 - Domain: "example.com", 305 - APIKeyHash: hash, 306 - Status: relaystore.StatusActive, 313 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 314 + return &MemberWithDomains{ 315 + DID: did, 316 + Status: relaystore.StatusActive, 317 + Domains: []DomainInfo{{ 318 + Domain: "example.com", 319 + APIKeyHash: hash, 320 + }}, 307 321 }, nil 308 322 } 309 323 ··· 343 357 } 344 358 } 345 359 360 + func TestSMTPAuthMultiDomain(t *testing.T) { 361 + // Two domains under one DID — the API key determines which domain is selected 362 + key1 := "atmos_domainkey1" 363 + key2 := "atmos_domainkey2" 364 + hash1, _ := HashAPIKey(key1) 365 + hash2, _ := HashAPIKey(key2) 366 + 367 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 368 + if did == "did:plc:multidomain" { 369 + return &MemberWithDomains{ 370 + DID: "did:plc:multidomain", 371 + Status: relaystore.StatusActive, 372 + Domains: []DomainInfo{ 373 + {Domain: "alpha.com", APIKeyHash: hash1}, 374 + {Domain: "beta.com", APIKeyHash: hash2}, 375 + }, 376 + }, nil 377 + } 378 + return nil, nil 379 + } 380 + 381 + _, addr, cleanup := testSMTPServer(t, lookup, nil, nil) 382 + defer cleanup() 383 + 384 + // Auth with key2 should work and select beta.com 385 + c, err := gosmtp.Dial(addr) 386 + if err != nil { 387 + t.Fatalf("dial: %v", err) 388 + } 389 + defer c.Close() 390 + 391 + auth := gosmtp.PlainAuth("", "did:plc:multidomain", key2, "127.0.0.1") 392 + if err := c.Auth(auth); err != nil { 393 + t.Fatalf("auth with key2: %v", err) 394 + } 395 + 396 + // Sending from beta.com should work 397 + if err := c.Mail("noreply@beta.com"); err != nil { 398 + t.Fatalf("MAIL FROM beta.com: %v", err) 399 + } 400 + c.Reset() 401 + 402 + // Sending from alpha.com should fail (key2 matched beta.com) 403 + if err := c.Mail("noreply@alpha.com"); err == nil { 404 + t.Fatal("expected error for wrong sender domain (alpha.com with key2)") 405 + } 406 + } 407 + 408 + func TestSMTPAuthWrongKeyMultiDomain(t *testing.T) { 409 + hash1, _ := HashAPIKey("atmos_realkey1") 410 + hash2, _ := HashAPIKey("atmos_realkey2") 411 + 412 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 413 + return &MemberWithDomains{ 414 + DID: did, 415 + Status: relaystore.StatusActive, 416 + Domains: []DomainInfo{ 417 + {Domain: "alpha.com", APIKeyHash: hash1}, 418 + {Domain: "beta.com", APIKeyHash: hash2}, 419 + }, 420 + }, nil 421 + } 422 + 423 + _, addr, cleanup := testSMTPServer(t, lookup, nil, nil) 424 + defer cleanup() 425 + 426 + c, err := gosmtp.Dial(addr) 427 + if err != nil { 428 + t.Fatalf("dial: %v", err) 429 + } 430 + defer c.Close() 431 + 432 + // A key that doesn't match any domain should fail 433 + auth := gosmtp.PlainAuth("", "did:plc:test", "atmos_nonesuch", "127.0.0.1") 434 + if err := c.Auth(auth); err == nil { 435 + t.Fatal("expected auth error for key matching no domain") 436 + } 437 + } 438 + 346 439 // --- From Header Validation --- 347 440 348 441 func TestValidateFromHeaderValid(t *testing.T) { ··· 416 509 apiKey := "atmos_testkey123" 417 510 hash, _ := HashAPIKey(apiKey) 418 511 419 - lookup := func(ctx context.Context, did string) (*AuthMember, error) { 420 - return &AuthMember{ 421 - DID: did, 422 - Domain: "example.com", 423 - APIKeyHash: hash, 424 - Status: relaystore.StatusActive, 512 + lookup := func(ctx context.Context, did string) (*MemberWithDomains, error) { 513 + return &MemberWithDomains{ 514 + DID: did, 515 + Status: relaystore.StatusActive, 516 + Domains: []DomainInfo{{ 517 + Domain: "example.com", 518 + APIKeyHash: hash, 519 + }}, 425 520 }, nil 426 521 } 427 522
+286 -27
internal/relaystore/store.go
··· 5 5 "database/sql" 6 6 "fmt" 7 7 "log" 8 + "strings" 8 9 "sync" 9 10 "time" 10 11 ··· 31 32 WindowDaily = "daily" 32 33 ) 33 34 35 + // Member represents a DID-level relay member (identity, status, rate limits). 34 36 type Member struct { 35 37 DID string 36 - Domain string 37 - APIKeyHash []byte 38 - DKIMRSAPriv []byte // RSA-2048 private key (PKCS8, plaintext — relay operator is trusted) 39 - DKIMEdPriv []byte // Ed25519 private key (PKCS8, plaintext — relay operator is trusted) 40 - DKIMSelector string 41 38 Status string 42 39 SuspendReason string 43 40 SendCount int64 ··· 47 44 UpdatedAt time.Time 48 45 } 49 46 47 + // MemberDomain represents a sending domain registered under a member DID. 48 + type MemberDomain struct { 49 + Domain string 50 + DID string 51 + APIKeyHash []byte 52 + DKIMRSAPriv []byte // RSA-2048 private key (PKCS8, plaintext — relay operator is trusted) 53 + DKIMEdPriv []byte // Ed25519 private key (PKCS8, plaintext — relay operator is trusted) 54 + DKIMSelector string 55 + CreatedAt time.Time 56 + } 57 + 50 58 type Message struct { 51 59 ID int64 52 60 MemberDID string ··· 61 69 62 70 type Stats struct { 63 71 Members int64 72 + Domains int64 64 73 Messages int64 65 74 } 66 75 ··· 103 112 } 104 113 105 114 func (s *Store) migrate() error { 115 + // Check if old schema exists (members table has 'domain' column) 116 + var hasDomainCol int 117 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('members') WHERE name = 'domain'`).Scan(&hasDomainCol) 118 + 119 + if hasDomainCol > 0 { 120 + if err := s.migrateToMultiDomain(); err != nil { 121 + return fmt.Errorf("multi-domain migration: %v", err) 122 + } 123 + } 124 + 125 + // Create all tables (idempotent for fresh DB or post-migration) 106 126 _, err := s.db.Exec(` 107 127 CREATE TABLE IF NOT EXISTS members ( 108 128 did TEXT PRIMARY KEY, 109 - domain TEXT NOT NULL, 110 - api_key_hash BLOB NOT NULL, 111 - dkim_rsa_privkey BLOB NOT NULL, 112 - dkim_ed_privkey BLOB NOT NULL, 113 - dkim_selector TEXT NOT NULL, 114 129 status TEXT NOT NULL DEFAULT 'active', 115 130 suspend_reason TEXT NOT NULL DEFAULT '', 116 131 send_count INTEGER NOT NULL DEFAULT 0, ··· 119 134 created_at TEXT NOT NULL, 120 135 updated_at TEXT NOT NULL 121 136 ); 122 - CREATE UNIQUE INDEX IF NOT EXISTS idx_members_domain ON members(domain); 137 + 138 + CREATE TABLE IF NOT EXISTS member_domains ( 139 + domain TEXT PRIMARY KEY, 140 + did TEXT NOT NULL REFERENCES members(did), 141 + api_key_hash BLOB NOT NULL, 142 + dkim_rsa_privkey BLOB NOT NULL, 143 + dkim_ed_privkey BLOB NOT NULL, 144 + dkim_selector TEXT NOT NULL, 145 + created_at TEXT NOT NULL 146 + ); 147 + CREATE INDEX IF NOT EXISTS idx_member_domains_did ON member_domains(did); 123 148 124 149 CREATE TABLE IF NOT EXISTS messages ( 125 150 id INTEGER PRIMARY KEY AUTOINCREMENT, ··· 135 160 CREATE INDEX IF NOT EXISTS idx_messages_member ON messages(member_did); 136 161 CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id); 137 162 CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); 163 + CREATE INDEX IF NOT EXISTS idx_messages_member_created ON messages(member_did, created_at); 138 164 139 165 CREATE TABLE IF NOT EXISTS rate_counters ( 140 166 member_did TEXT NOT NULL, ··· 154 180 created_at TEXT NOT NULL 155 181 ); 156 182 CREATE INDEX IF NOT EXISTS idx_feedback_events_member ON feedback_events(member_did); 157 - CREATE INDEX IF NOT EXISTS idx_messages_member_created ON messages(member_did, created_at); 158 183 159 184 CREATE TABLE IF NOT EXISTS bypass_dids ( 160 185 did TEXT PRIMARY KEY ··· 163 188 return err 164 189 } 165 190 191 + // migrateToMultiDomain splits the old single-table members schema into 192 + // members (DID-level) + member_domains (domain-level). 193 + func (s *Store) migrateToMultiDomain() error { 194 + // Must disable FK checks outside a transaction for SQLite 195 + if _, err := s.db.Exec("PRAGMA foreign_keys=OFF"); err != nil { 196 + return fmt.Errorf("disable FK: %v", err) 197 + } 198 + defer s.db.Exec("PRAGMA foreign_keys=ON") 199 + 200 + tx, err := s.db.Begin() 201 + if err != nil { 202 + return fmt.Errorf("begin tx: %v", err) 203 + } 204 + defer tx.Rollback() 205 + 206 + // Create member_domains and populate from old members 207 + _, err = tx.Exec(` 208 + CREATE TABLE member_domains ( 209 + domain TEXT PRIMARY KEY, 210 + did TEXT NOT NULL REFERENCES members(did), 211 + api_key_hash BLOB NOT NULL, 212 + dkim_rsa_privkey BLOB NOT NULL, 213 + dkim_ed_privkey BLOB NOT NULL, 214 + dkim_selector TEXT NOT NULL, 215 + created_at TEXT NOT NULL 216 + ); 217 + INSERT INTO member_domains (domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, created_at) 218 + SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, created_at FROM members; 219 + `) 220 + if err != nil { 221 + return fmt.Errorf("create member_domains: %v", err) 222 + } 223 + 224 + // Recreate members without domain-level columns 225 + _, err = tx.Exec(` 226 + CREATE TABLE members_v2 ( 227 + did TEXT PRIMARY KEY, 228 + status TEXT NOT NULL DEFAULT 'active', 229 + suspend_reason TEXT NOT NULL DEFAULT '', 230 + send_count INTEGER NOT NULL DEFAULT 0, 231 + hourly_limit INTEGER NOT NULL DEFAULT 100, 232 + daily_limit INTEGER NOT NULL DEFAULT 1000, 233 + created_at TEXT NOT NULL, 234 + updated_at TEXT NOT NULL 235 + ); 236 + INSERT INTO members_v2 (did, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at) 237 + SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at FROM members; 238 + DROP TABLE members; 239 + ALTER TABLE members_v2 RENAME TO members; 240 + `) 241 + if err != nil { 242 + return fmt.Errorf("recreate members: %v", err) 243 + } 244 + 245 + _, err = tx.Exec(`CREATE INDEX idx_member_domains_did ON member_domains(did)`) 246 + if err != nil { 247 + return fmt.Errorf("create domain index: %v", err) 248 + } 249 + 250 + log.Printf("relaystore: migrated to multi-domain schema") 251 + return tx.Commit() 252 + } 253 + 166 254 // --- Members --- 167 255 168 256 func (s *Store) InsertMember(ctx context.Context, m *Member) error { 169 257 _, err := s.db.ExecContext(ctx, 170 - `INSERT INTO members (did, domain, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at) 171 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 172 - m.DID, m.Domain, m.APIKeyHash, m.DKIMRSAPriv, m.DKIMEdPriv, m.DKIMSelector, 173 - m.Status, m.SuspendReason, m.SendCount, m.HourlyLimit, m.DailyLimit, 258 + `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at) 259 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 260 + m.DID, m.Status, m.SuspendReason, m.SendCount, m.HourlyLimit, m.DailyLimit, 174 261 formatTime(m.CreatedAt), formatTime(m.UpdatedAt), 175 262 ) 176 263 if err != nil { ··· 179 266 return nil 180 267 } 181 268 182 - func (s *Store) GetMember(ctx context.Context, did string) (*Member, error) { 183 - row := s.db.QueryRowContext(ctx, 184 - `SELECT did, domain, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at 185 - FROM members WHERE did = ?`, did, 269 + // EnrollMember atomically inserts a new member and its first domain, or adds 270 + // a domain to an existing member. If member is nil, only the domain is inserted 271 + // (the DID already exists). 272 + func (s *Store) EnrollMember(ctx context.Context, member *Member, domain *MemberDomain) error { 273 + tx, err := s.db.BeginTx(ctx, nil) 274 + if err != nil { 275 + return fmt.Errorf("enroll begin tx: %v", err) 276 + } 277 + defer tx.Rollback() 278 + 279 + if member != nil { 280 + _, err := tx.ExecContext(ctx, 281 + `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at) 282 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 283 + member.DID, member.Status, member.SuspendReason, member.SendCount, 284 + member.HourlyLimit, member.DailyLimit, 285 + formatTime(member.CreatedAt), formatTime(member.UpdatedAt), 286 + ) 287 + if err != nil { 288 + return fmt.Errorf("enroll insert member: %v", err) 289 + } 290 + } 291 + 292 + _, err = tx.ExecContext(ctx, 293 + `INSERT INTO member_domains (domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, created_at) 294 + VALUES (?, ?, ?, ?, ?, ?, ?)`, 295 + domain.Domain, domain.DID, domain.APIKeyHash, domain.DKIMRSAPriv, domain.DKIMEdPriv, domain.DKIMSelector, 296 + formatTime(domain.CreatedAt), 297 + ) 298 + if err != nil { 299 + return fmt.Errorf("enroll insert domain: %v", err) 300 + } 301 + 302 + return tx.Commit() 303 + } 304 + 305 + // MemberWithDomainSummary is a member paired with its domain names (no key material). 306 + type MemberWithDomainSummary struct { 307 + Member 308 + Domains []string 309 + } 310 + 311 + // ListMembersWithDomains returns all members with their domain names in a single query. 312 + func (s *Store) ListMembersWithDomains(ctx context.Context) ([]MemberWithDomainSummary, error) { 313 + rows, err := s.db.QueryContext(ctx, 314 + `SELECT m.did, m.status, m.suspend_reason, m.send_count, m.hourly_limit, m.daily_limit, m.created_at, m.updated_at, 315 + COALESCE(GROUP_CONCAT(md.domain, ','), '') 316 + FROM members m 317 + LEFT JOIN member_domains md ON md.did = m.did 318 + GROUP BY m.did 319 + ORDER BY m.created_at ASC`, 186 320 ) 187 - return scanMember(row) 321 + if err != nil { 322 + return nil, fmt.Errorf("list members with domains: %v", err) 323 + } 324 + defer rows.Close() 325 + 326 + var result []MemberWithDomainSummary 327 + for rows.Next() { 328 + var mwd MemberWithDomainSummary 329 + var createdAt, updatedAt, domainCSV string 330 + if err := rows.Scan( 331 + &mwd.DID, &mwd.Status, &mwd.SuspendReason, &mwd.SendCount, 332 + &mwd.HourlyLimit, &mwd.DailyLimit, &createdAt, &updatedAt, &domainCSV, 333 + ); err != nil { 334 + return nil, fmt.Errorf("scan member with domains: %v", err) 335 + } 336 + mwd.CreatedAt = parseTime(createdAt) 337 + mwd.UpdatedAt = parseTime(updatedAt) 338 + if domainCSV != "" { 339 + mwd.Domains = strings.Split(domainCSV, ",") 340 + } 341 + result = append(result, mwd) 342 + } 343 + return result, rows.Err() 188 344 } 189 345 190 - func (s *Store) GetMemberByDomain(ctx context.Context, domain string) (*Member, error) { 346 + func (s *Store) GetMember(ctx context.Context, did string) (*Member, error) { 191 347 row := s.db.QueryRowContext(ctx, 192 - `SELECT did, domain, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at 193 - FROM members WHERE domain = ?`, domain, 348 + `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at 349 + FROM members WHERE did = ?`, did, 194 350 ) 195 351 return scanMember(row) 196 352 } 197 353 198 354 func (s *Store) ListMembers(ctx context.Context) ([]Member, error) { 199 355 rows, err := s.db.QueryContext(ctx, 200 - `SELECT did, domain, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at 356 + `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, created_at, updated_at 201 357 FROM members ORDER BY created_at ASC`, 202 358 ) 203 359 if err != nil { ··· 242 398 var createdAt, updatedAt string 243 399 244 400 err := sc.Scan( 245 - &m.DID, &m.Domain, &m.APIKeyHash, &m.DKIMRSAPriv, &m.DKIMEdPriv, 246 - &m.DKIMSelector, &m.Status, &m.SuspendReason, &m.SendCount, 401 + &m.DID, &m.Status, &m.SuspendReason, &m.SendCount, 247 402 &m.HourlyLimit, &m.DailyLimit, &createdAt, &updatedAt, 248 403 ) 249 404 if err == sql.ErrNoRows { ··· 256 411 m.CreatedAt = parseTime(createdAt) 257 412 m.UpdatedAt = parseTime(updatedAt) 258 413 return &m, nil 414 + } 415 + 416 + // --- Member Domains --- 417 + 418 + func (s *Store) InsertMemberDomain(ctx context.Context, d *MemberDomain) error { 419 + _, err := s.db.ExecContext(ctx, 420 + `INSERT INTO member_domains (domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, created_at) 421 + VALUES (?, ?, ?, ?, ?, ?, ?)`, 422 + d.Domain, d.DID, d.APIKeyHash, d.DKIMRSAPriv, d.DKIMEdPriv, d.DKIMSelector, 423 + formatTime(d.CreatedAt), 424 + ) 425 + if err != nil { 426 + return fmt.Errorf("insert member domain: %v", err) 427 + } 428 + return nil 429 + } 430 + 431 + func (s *Store) GetMemberDomain(ctx context.Context, domain string) (*MemberDomain, error) { 432 + var d MemberDomain 433 + var createdAt string 434 + err := s.db.QueryRowContext(ctx, 435 + `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, created_at 436 + FROM member_domains WHERE domain = ?`, domain, 437 + ).Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &createdAt) 438 + if err == sql.ErrNoRows { 439 + return nil, nil 440 + } 441 + if err != nil { 442 + return nil, fmt.Errorf("get member domain: %v", err) 443 + } 444 + d.CreatedAt = parseTime(createdAt) 445 + return &d, nil 446 + } 447 + 448 + func (s *Store) ListMemberDomains(ctx context.Context, did string) ([]MemberDomain, error) { 449 + rows, err := s.db.QueryContext(ctx, 450 + `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, created_at 451 + FROM member_domains WHERE did = ? ORDER BY created_at ASC`, did, 452 + ) 453 + if err != nil { 454 + return nil, fmt.Errorf("list member domains: %v", err) 455 + } 456 + defer rows.Close() 457 + 458 + var domains []MemberDomain 459 + for rows.Next() { 460 + var d MemberDomain 461 + var createdAt string 462 + if err := rows.Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &createdAt); err != nil { 463 + return nil, fmt.Errorf("scan member domain: %v", err) 464 + } 465 + d.CreatedAt = parseTime(createdAt) 466 + domains = append(domains, d) 467 + } 468 + return domains, rows.Err() 469 + } 470 + 471 + // GetMemberByDomain returns the member and domain record for a given domain name. 472 + // Returns (nil, nil, nil) if the domain is not found. 473 + func (s *Store) GetMemberByDomain(ctx context.Context, domain string) (*Member, *MemberDomain, error) { 474 + var m Member 475 + var d MemberDomain 476 + var mCreatedAt, mUpdatedAt, dCreatedAt string 477 + 478 + err := s.db.QueryRowContext(ctx, 479 + `SELECT m.did, m.status, m.suspend_reason, m.send_count, m.hourly_limit, m.daily_limit, m.created_at, m.updated_at, 480 + d.domain, d.did, d.api_key_hash, d.dkim_rsa_privkey, d.dkim_ed_privkey, d.dkim_selector, d.created_at 481 + FROM member_domains d JOIN members m ON d.did = m.did 482 + WHERE d.domain = ?`, domain, 483 + ).Scan( 484 + &m.DID, &m.Status, &m.SuspendReason, &m.SendCount, &m.HourlyLimit, &m.DailyLimit, &mCreatedAt, &mUpdatedAt, 485 + &d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &dCreatedAt, 486 + ) 487 + if err == sql.ErrNoRows { 488 + return nil, nil, nil 489 + } 490 + if err != nil { 491 + return nil, nil, fmt.Errorf("get member by domain: %v", err) 492 + } 493 + 494 + m.CreatedAt = parseTime(mCreatedAt) 495 + m.UpdatedAt = parseTime(mUpdatedAt) 496 + d.CreatedAt = parseTime(dCreatedAt) 497 + return &m, &d, nil 498 + } 499 + 500 + // GetMemberWithDomains returns a member and all their registered domains. 501 + // Returns (nil, nil, nil) if the DID is not found. 502 + func (s *Store) GetMemberWithDomains(ctx context.Context, did string) (*Member, []MemberDomain, error) { 503 + m, err := s.GetMember(ctx, did) 504 + if err != nil || m == nil { 505 + return nil, nil, err 506 + } 507 + 508 + domains, err := s.ListMemberDomains(ctx, did) 509 + if err != nil { 510 + return nil, nil, err 511 + } 512 + 513 + return m, domains, nil 259 514 } 260 515 261 516 // --- Messages --- ··· 504 759 err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM members`).Scan(&st.Members) 505 760 if err != nil { 506 761 return st, fmt.Errorf("count members: %v", err) 762 + } 763 + err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM member_domains`).Scan(&st.Domains) 764 + if err != nil { 765 + return st, fmt.Errorf("count domains: %v", err) 507 766 } 508 767 err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages`).Scan(&st.Messages) 509 768 if err != nil {
+243 -127
internal/relaystore/store_test.go
··· 16 16 return s 17 17 } 18 18 19 + // insertTestMemberWithDomain is a test helper that creates both the DID-level 20 + // member and a domain record. Most tests need both. 21 + func insertTestMemberWithDomain(t *testing.T, s *Store, did, domain string) { 22 + t.Helper() 23 + ctx := context.Background() 24 + now := time.Now().UTC() 25 + 26 + if err := s.InsertMember(ctx, &Member{ 27 + DID: did, 28 + Status: StatusActive, 29 + HourlyLimit: 100, 30 + DailyLimit: 1000, 31 + CreatedAt: now, 32 + UpdatedAt: now, 33 + }); err != nil { 34 + t.Fatalf("InsertMember %s: %v", did, err) 35 + } 36 + 37 + if err := s.InsertMemberDomain(ctx, &MemberDomain{ 38 + Domain: domain, 39 + DID: did, 40 + APIKeyHash: []byte("hash"), 41 + DKIMRSAPriv: []byte("rsa"), 42 + DKIMEdPriv: []byte("ed"), 43 + DKIMSelector: "sel1", 44 + CreatedAt: now, 45 + }); err != nil { 46 + t.Fatalf("InsertMemberDomain %s: %v", domain, err) 47 + } 48 + } 49 + 19 50 func TestNewCreatesSchema(t *testing.T) { 20 51 s := testStore(t) 21 52 ctx := context.Background() ··· 37 68 ctx := context.Background() 38 69 39 70 m := &Member{ 40 - DID: "did:plc:test123456789012345678", 41 - Domain: "example.com", 42 - APIKeyHash: []byte("bcrypt-hash-placeholder"), 43 - DKIMRSAPriv: []byte("rsa-encrypted-privkey"), 44 - DKIMEdPriv: []byte("ed25519-encrypted-privkey"), 45 - DKIMSelector: "atmos20260406", 46 - Status: StatusActive, 47 - HourlyLimit: 100, 48 - DailyLimit: 1000, 49 - CreatedAt: time.Now().UTC().Truncate(time.Second), 50 - UpdatedAt: time.Now().UTC().Truncate(time.Second), 71 + DID: "did:plc:test123456789012345678", 72 + Status: StatusActive, 73 + HourlyLimit: 100, 74 + DailyLimit: 1000, 75 + CreatedAt: time.Now().UTC().Truncate(time.Second), 76 + UpdatedAt: time.Now().UTC().Truncate(time.Second), 51 77 } 52 78 53 79 if err := s.InsertMember(ctx, m); err != nil { ··· 63 89 } 64 90 if got.DID != m.DID { 65 91 t.Errorf("DID = %q, want %q", got.DID, m.DID) 66 - } 67 - if got.Domain != m.Domain { 68 - t.Errorf("Domain = %q, want %q", got.Domain, m.Domain) 69 - } 70 - if got.DKIMSelector != m.DKIMSelector { 71 - t.Errorf("DKIMSelector = %q, want %q", got.DKIMSelector, m.DKIMSelector) 72 92 } 73 93 if got.Status != StatusActive { 74 94 t.Errorf("Status = %q, want %q", got.Status, StatusActive) ··· 102 122 ctx := context.Background() 103 123 104 124 m := &Member{ 105 - DID: "did:plc:duplicate", 106 - Domain: "example.com", 107 - APIKeyHash: []byte("hash"), 108 - DKIMRSAPriv: []byte("rsa"), 109 - DKIMEdPriv: []byte("ed"), 110 - DKIMSelector: "sel1", 111 - Status: StatusActive, 112 - HourlyLimit: 100, 113 - DailyLimit: 1000, 114 - CreatedAt: time.Now().UTC(), 115 - UpdatedAt: time.Now().UTC(), 125 + DID: "did:plc:duplicate", 126 + Status: StatusActive, 127 + HourlyLimit: 100, 128 + DailyLimit: 1000, 129 + CreatedAt: time.Now().UTC(), 130 + UpdatedAt: time.Now().UTC(), 116 131 } 117 132 118 133 if err := s.InsertMember(ctx, m); err != nil { ··· 129 144 ctx := context.Background() 130 145 131 146 m := &Member{ 132 - DID: "did:plc:statustest", 133 - Domain: "example.com", 134 - APIKeyHash: []byte("hash"), 135 - DKIMRSAPriv: []byte("rsa"), 136 - DKIMEdPriv: []byte("ed"), 137 - DKIMSelector: "sel1", 138 - Status: StatusActive, 139 - HourlyLimit: 100, 140 - DailyLimit: 1000, 141 - CreatedAt: time.Now().UTC(), 142 - UpdatedAt: time.Now().UTC(), 147 + DID: "did:plc:statustest", 148 + Status: StatusActive, 149 + HourlyLimit: 100, 150 + DailyLimit: 1000, 151 + CreatedAt: time.Now().UTC(), 152 + UpdatedAt: time.Now().UTC(), 143 153 } 144 154 if err := s.InsertMember(ctx, m); err != nil { 145 155 t.Fatal(err) ··· 175 185 ctx := context.Background() 176 186 177 187 m := &Member{ 178 - DID: "did:plc:sendcount", 179 - Domain: "example.com", 180 - APIKeyHash: []byte("hash"), 181 - DKIMRSAPriv: []byte("rsa"), 182 - DKIMEdPriv: []byte("ed"), 183 - DKIMSelector: "sel1", 184 - Status: StatusActive, 185 - HourlyLimit: 100, 186 - DailyLimit: 1000, 187 - CreatedAt: time.Now().UTC(), 188 - UpdatedAt: time.Now().UTC(), 188 + DID: "did:plc:sendcount", 189 + Status: StatusActive, 190 + HourlyLimit: 100, 191 + DailyLimit: 1000, 192 + CreatedAt: time.Now().UTC(), 193 + UpdatedAt: time.Now().UTC(), 189 194 } 190 195 if err := s.InsertMember(ctx, m); err != nil { 191 196 t.Fatal(err) ··· 208 213 s := testStore(t) 209 214 ctx := context.Background() 210 215 216 + now := time.Now().UTC() 211 217 for _, did := range []string{"did:plc:aaa", "did:plc:bbb"} { 212 218 if err := s.InsertMember(ctx, &Member{ 213 - DID: did, 214 - Domain: did + ".example.com", 215 - APIKeyHash: []byte("hash"), 216 - DKIMRSAPriv: []byte("rsa"), 217 - DKIMEdPriv: []byte("ed"), 218 - DKIMSelector: "sel1", 219 - Status: StatusActive, 220 - HourlyLimit: 100, 221 - DailyLimit: 1000, 222 - CreatedAt: time.Now().UTC(), 223 - UpdatedAt: time.Now().UTC(), 219 + DID: did, 220 + Status: StatusActive, 221 + HourlyLimit: 100, 222 + DailyLimit: 1000, 223 + CreatedAt: now, 224 + UpdatedAt: now, 224 225 }); err != nil { 225 226 t.Fatal(err) 226 227 } ··· 235 236 } 236 237 } 237 238 239 + // --- Member Domains --- 240 + 241 + func TestMemberDomainInsertAndGet(t *testing.T) { 242 + s := testStore(t) 243 + ctx := context.Background() 244 + now := time.Now().UTC().Truncate(time.Second) 245 + 246 + // Insert member first (FK) 247 + if err := s.InsertMember(ctx, &Member{ 248 + DID: "did:plc:domtest", Status: StatusActive, 249 + HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now, 250 + }); err != nil { 251 + t.Fatal(err) 252 + } 253 + 254 + d := &MemberDomain{ 255 + Domain: "example.com", 256 + DID: "did:plc:domtest", 257 + APIKeyHash: []byte("bcrypt-hash"), 258 + DKIMRSAPriv: []byte("rsa-key"), 259 + DKIMEdPriv: []byte("ed-key"), 260 + DKIMSelector: "atmos20260406", 261 + CreatedAt: now, 262 + } 263 + if err := s.InsertMemberDomain(ctx, d); err != nil { 264 + t.Fatalf("InsertMemberDomain: %v", err) 265 + } 266 + 267 + got, err := s.GetMemberDomain(ctx, "example.com") 268 + if err != nil { 269 + t.Fatalf("GetMemberDomain: %v", err) 270 + } 271 + if got == nil { 272 + t.Fatal("GetMemberDomain returned nil") 273 + } 274 + if got.Domain != "example.com" { 275 + t.Errorf("Domain = %q, want %q", got.Domain, "example.com") 276 + } 277 + if got.DID != "did:plc:domtest" { 278 + t.Errorf("DID = %q, want %q", got.DID, "did:plc:domtest") 279 + } 280 + if got.DKIMSelector != "atmos20260406" { 281 + t.Errorf("DKIMSelector = %q, want %q", got.DKIMSelector, "atmos20260406") 282 + } 283 + } 284 + 285 + func TestMemberDomainNotFound(t *testing.T) { 286 + s := testStore(t) 287 + got, err := s.GetMemberDomain(context.Background(), "nope.com") 288 + if err != nil { 289 + t.Fatal(err) 290 + } 291 + if got != nil { 292 + t.Errorf("expected nil, got %+v", got) 293 + } 294 + } 295 + 296 + func TestMemberDomainDuplicateDomain(t *testing.T) { 297 + s := testStore(t) 298 + ctx := context.Background() 299 + insertTestMemberWithDomain(t, s, "did:plc:dupdom", "dup.example.com") 300 + 301 + // Second domain insert with same domain should fail (PRIMARY KEY) 302 + err := s.InsertMemberDomain(ctx, &MemberDomain{ 303 + Domain: "dup.example.com", DID: "did:plc:dupdom", 304 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 305 + DKIMSelector: "s", CreatedAt: time.Now().UTC(), 306 + }) 307 + if err == nil { 308 + t.Fatal("expected error on duplicate domain insert") 309 + } 310 + } 311 + 312 + func TestListMemberDomains(t *testing.T) { 313 + s := testStore(t) 314 + ctx := context.Background() 315 + now := time.Now().UTC() 316 + 317 + // Create member 318 + if err := s.InsertMember(ctx, &Member{ 319 + DID: "did:plc:multi", Status: StatusActive, 320 + HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now, 321 + }); err != nil { 322 + t.Fatal(err) 323 + } 324 + 325 + // Add two domains 326 + for _, domain := range []string{"alpha.com", "beta.com"} { 327 + if err := s.InsertMemberDomain(ctx, &MemberDomain{ 328 + Domain: domain, DID: "did:plc:multi", 329 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 330 + DKIMSelector: "s", CreatedAt: now, 331 + }); err != nil { 332 + t.Fatal(err) 333 + } 334 + } 335 + 336 + domains, err := s.ListMemberDomains(ctx, "did:plc:multi") 337 + if err != nil { 338 + t.Fatal(err) 339 + } 340 + if len(domains) != 2 { 341 + t.Errorf("ListMemberDomains returned %d, want 2", len(domains)) 342 + } 343 + 344 + // Empty for unknown DID 345 + domains, err = s.ListMemberDomains(ctx, "did:plc:nonexistent") 346 + if err != nil { 347 + t.Fatal(err) 348 + } 349 + if len(domains) != 0 { 350 + t.Errorf("expected 0 domains for unknown DID, got %d", len(domains)) 351 + } 352 + } 353 + 238 354 func TestGetMemberByDomain(t *testing.T) { 239 355 s := testStore(t) 240 356 ctx := context.Background() 357 + insertTestMemberWithDomain(t, s, "did:plc:domaintest", "mysite.com") 241 358 359 + member, domain, err := s.GetMemberByDomain(ctx, "mysite.com") 360 + if err != nil { 361 + t.Fatal(err) 362 + } 363 + if member == nil || domain == nil { 364 + t.Fatal("GetMemberByDomain returned nil") 365 + } 366 + if member.DID != "did:plc:domaintest" { 367 + t.Errorf("member DID = %q, want did:plc:domaintest", member.DID) 368 + } 369 + if domain.Domain != "mysite.com" { 370 + t.Errorf("domain = %q, want mysite.com", domain.Domain) 371 + } 372 + 373 + // Not found 374 + m, d, err := s.GetMemberByDomain(ctx, "nope.com") 375 + if err != nil { 376 + t.Fatal(err) 377 + } 378 + if m != nil || d != nil { 379 + t.Errorf("expected nils for unknown domain, got %+v, %+v", m, d) 380 + } 381 + } 382 + 383 + func TestGetMemberWithDomains(t *testing.T) { 384 + s := testStore(t) 385 + ctx := context.Background() 386 + now := time.Now().UTC() 387 + 388 + // Create member with two domains 242 389 if err := s.InsertMember(ctx, &Member{ 243 - DID: "did:plc:domaintest", 244 - Domain: "mysite.com", 245 - APIKeyHash: []byte("hash"), 246 - DKIMRSAPriv: []byte("rsa"), 247 - DKIMEdPriv: []byte("ed"), 248 - DKIMSelector: "sel1", 249 - Status: StatusActive, 250 - HourlyLimit: 100, 251 - DailyLimit: 1000, 252 - CreatedAt: time.Now().UTC(), 253 - UpdatedAt: time.Now().UTC(), 390 + DID: "did:plc:withdoms", Status: StatusActive, 391 + HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now, 254 392 }); err != nil { 255 393 t.Fatal(err) 256 394 } 395 + for _, domain := range []string{"one.com", "two.com"} { 396 + if err := s.InsertMemberDomain(ctx, &MemberDomain{ 397 + Domain: domain, DID: "did:plc:withdoms", 398 + APIKeyHash: []byte("h-" + domain), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 399 + DKIMSelector: "s", CreatedAt: now, 400 + }); err != nil { 401 + t.Fatal(err) 402 + } 403 + } 257 404 258 - got, err := s.GetMemberByDomain(ctx, "mysite.com") 405 + member, domains, err := s.GetMemberWithDomains(ctx, "did:plc:withdoms") 259 406 if err != nil { 260 407 t.Fatal(err) 261 408 } 262 - if got == nil || got.DID != "did:plc:domaintest" { 263 - t.Errorf("GetMemberByDomain = %v, want did:plc:domaintest", got) 409 + if member == nil { 410 + t.Fatal("GetMemberWithDomains returned nil member") 411 + } 412 + if len(domains) != 2 { 413 + t.Errorf("got %d domains, want 2", len(domains)) 264 414 } 265 415 266 - got, err = s.GetMemberByDomain(ctx, "nope.com") 416 + // Not found 417 + m, d, err := s.GetMemberWithDomains(ctx, "did:plc:nonexistent") 267 418 if err != nil { 268 419 t.Fatal(err) 269 420 } 270 - if got != nil { 271 - t.Errorf("expected nil for unknown domain, got %+v", got) 421 + if m != nil || d != nil { 422 + t.Errorf("expected nils for nonexistent DID") 272 423 } 273 424 } 274 425 ··· 280 431 281 432 // Need a member first (foreign key) 282 433 if err := s.InsertMember(ctx, &Member{ 283 - DID: "did:plc:msgtest", 284 - Domain: "example.com", 285 - APIKeyHash: []byte("hash"), 286 - DKIMRSAPriv: []byte("rsa"), 287 - DKIMEdPriv: []byte("ed"), 288 - DKIMSelector: "sel1", 289 - Status: StatusActive, 290 - HourlyLimit: 100, 291 - DailyLimit: 1000, 292 - CreatedAt: time.Now().UTC(), 293 - UpdatedAt: time.Now().UTC(), 434 + DID: "did:plc:msgtest", Status: StatusActive, 435 + HourlyLimit: 100, DailyLimit: 1000, 436 + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), 294 437 }); err != nil { 295 438 t.Fatal(err) 296 439 } ··· 337 480 ctx := context.Background() 338 481 339 482 if err := s.InsertMember(ctx, &Member{ 340 - DID: "did:plc:midtest", 341 - Domain: "example.com", 342 - APIKeyHash: []byte("hash"), 343 - DKIMRSAPriv: []byte("rsa"), 344 - DKIMEdPriv: []byte("ed"), 345 - DKIMSelector: "sel1", 346 - Status: StatusActive, 347 - HourlyLimit: 100, 348 - DailyLimit: 1000, 349 - CreatedAt: time.Now().UTC(), 350 - UpdatedAt: time.Now().UTC(), 483 + DID: "did:plc:midtest", Status: StatusActive, 484 + HourlyLimit: 100, DailyLimit: 1000, 485 + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), 351 486 }); err != nil { 352 487 t.Fatal(err) 353 488 } ··· 476 611 if err != nil { 477 612 t.Fatal(err) 478 613 } 479 - if stats.Members != 0 || stats.Messages != 0 { 614 + if stats.Members != 0 || stats.Domains != 0 || stats.Messages != 0 { 480 615 t.Errorf("empty stats = %+v, want zeros", stats) 481 616 } 482 617 483 - if err := s.InsertMember(ctx, &Member{ 484 - DID: "did:plc:stats", 485 - Domain: "example.com", 486 - APIKeyHash: []byte("hash"), 487 - DKIMRSAPriv: []byte("rsa"), 488 - DKIMEdPriv: []byte("ed"), 489 - DKIMSelector: "sel1", 490 - Status: StatusActive, 491 - HourlyLimit: 100, 492 - DailyLimit: 1000, 493 - CreatedAt: time.Now().UTC(), 494 - UpdatedAt: time.Now().UTC(), 495 - }); err != nil { 496 - t.Fatal(err) 497 - } 618 + insertTestMemberWithDomain(t, s, "did:plc:stats", "example.com") 498 619 499 620 stats, _ = s.Stats(ctx) 500 621 if stats.Members != 1 { 501 622 t.Errorf("Members = %d, want 1", stats.Members) 502 623 } 624 + if stats.Domains != 1 { 625 + t.Errorf("Domains = %d, want 1", stats.Domains) 626 + } 503 627 } 504 628 505 629 func TestPing(t *testing.T) { ··· 517 641 518 642 // Create a member for foreign key 519 643 if err := s.InsertMember(ctx, &Member{ 520 - DID: "did:plc:purgetest123456789012", 521 - Domain: "purge.example.com", 522 - APIKeyHash: []byte("hash"), 523 - DKIMRSAPriv: []byte("rsa"), 524 - DKIMEdPriv: []byte("ed"), 525 - DKIMSelector: "sel1", 526 - Status: StatusActive, 527 - HourlyLimit: 100, 528 - DailyLimit: 1000, 529 - CreatedAt: time.Now().UTC(), 530 - UpdatedAt: time.Now().UTC(), 644 + DID: "did:plc:purgetest123456789012", Status: StatusActive, 645 + HourlyLimit: 100, DailyLimit: 1000, 646 + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), 531 647 }); err != nil { 532 648 t.Fatal(err) 533 649 }
+3
vendor/github.com/a-h/templ/.dockerignore
··· 1 + .git 2 + Dockerfile 3 + .dockerignore
+1
vendor/github.com/a-h/templ/.envrc
··· 1 + use flake
+40
vendor/github.com/a-h/templ/.gitignore
··· 1 + # Output. 2 + cmd/templ/templ 3 + 4 + # Logs. 5 + cmd/templ/lspcmd/*log.txt 6 + 7 + # Go code coverage. 8 + coverage.out 9 + coverage 10 + 11 + # Mac filesystem jank. 12 + .DS_Store 13 + 14 + # Docusaurus. 15 + docs/build/ 16 + docs/resources/_gen/ 17 + node_modules/ 18 + dist/ 19 + 20 + # Nix artifacts. 21 + result 22 + 23 + # Editors 24 + ## nvim 25 + .null-ls* 26 + # vscode 27 + .vscode/ 28 + 29 + # Go workspace. 30 + go.work 31 + 32 + # direnv 33 + .direnv 34 + 35 + # templ txt files. 36 + *_templ.txt 37 + 38 + # Example output binaries. 39 + /examples/integration-gin/integration-gin 40 + /examples/integration-echo/integration-echo
+72
vendor/github.com/a-h/templ/.goreleaser.yaml
··· 1 + builds: 2 + - env: 3 + - CGO_ENABLED=0 4 + dir: cmd/templ 5 + mod_timestamp: '{{ .CommitTimestamp }}' 6 + flags: 7 + - -trimpath 8 + ldflags: 9 + - -s -w 10 + goos: 11 + - linux 12 + - windows 13 + - darwin 14 + 15 + checksum: 16 + name_template: 'checksums.txt' 17 + 18 + signs: 19 + - id: checksums 20 + cmd: cosign 21 + stdin: '{{ .Env.COSIGN_PASSWORD }}' 22 + output: true 23 + artifacts: checksum 24 + args: 25 + - sign-blob 26 + - --yes 27 + - --key 28 + - env://COSIGN_PRIVATE_KEY 29 + - '--output-certificate=${certificate}' 30 + - '--output-signature=${signature}' 31 + - '${artifact}' 32 + 33 + archives: 34 + - format: tar.gz 35 + name_template: >- 36 + {{ .ProjectName }}_ 37 + {{- title .Os }}_ 38 + {{- if eq .Arch "amd64" }}x86_64 39 + {{- else if eq .Arch "386" }}i386 40 + {{- else }}{{ .Arch }}{{ end }} 41 + {{- if .Arm }}v{{ .Arm }}{{ end }} 42 + 43 + kos: 44 + - repository: ghcr.io/a-h/templ 45 + platforms: 46 + - linux/amd64 47 + - linux/arm64 48 + tags: 49 + - latest 50 + - '{{.Tag}}' 51 + bare: true 52 + 53 + docker_signs: 54 + - cmd: cosign 55 + artifacts: all 56 + output: true 57 + args: 58 + - sign 59 + - --yes 60 + - --key 61 + - env://COSIGN_PRIVATE_KEY 62 + - '${artifact}' 63 + 64 + snapshot: 65 + name_template: "{{ incpatch .Version }}-next" 66 + 67 + changelog: 68 + sort: asc 69 + filters: 70 + exclude: 71 + - '^docs:' 72 + - '^test:'
+9
vendor/github.com/a-h/templ/.ignore
··· 1 + *_templ.go 2 + examples/integration-ct/static/index.js 3 + examples/counter/assets/css/bulma.* 4 + examples/counter/assets/js/htmx.min.js 5 + examples/counter-basic/assets/css/bulma.* 6 + examples/typescript/assets/index.js 7 + package-lock.json 8 + go.sum 9 + docs/static/llms.md
+1
vendor/github.com/a-h/templ/.version
··· 1 + 0.3.1001
+128
vendor/github.com/a-h/templ/CODE_OF_CONDUCT.md
··· 1 + # Contributor Covenant Code of Conduct 2 + 3 + ## Our Pledge 4 + 5 + We as members, contributors, and leaders pledge to make participation in our 6 + community a harassment-free experience for everyone, regardless of age, body 7 + size, visible or invisible disability, ethnicity, sex characteristics, gender 8 + identity and expression, level of experience, education, socio-economic status, 9 + nationality, personal appearance, race, religion, or sexual identity 10 + and orientation. 11 + 12 + We pledge to act and interact in ways that contribute to an open, welcoming, 13 + diverse, inclusive, and healthy community. 14 + 15 + ## Our Standards 16 + 17 + Examples of behavior that contributes to a positive environment for our 18 + community include: 19 + 20 + * Demonstrating empathy and kindness toward other people 21 + * Being respectful of differing opinions, viewpoints, and experiences 22 + * Giving and gracefully accepting constructive feedback 23 + * Accepting responsibility and apologizing to those affected by our mistakes, 24 + and learning from the experience 25 + * Focusing on what is best not just for us as individuals, but for the 26 + overall community 27 + 28 + Examples of unacceptable behavior include: 29 + 30 + * The use of sexualized language or imagery, and sexual attention or 31 + advances of any kind 32 + * Trolling, insulting or derogatory comments, and personal or political attacks 33 + * Public or private harassment 34 + * Publishing others' private information, such as a physical or email 35 + address, without their explicit permission 36 + * Other conduct which could reasonably be considered inappropriate in a 37 + professional setting 38 + 39 + ## Enforcement Responsibilities 40 + 41 + Community leaders are responsible for clarifying and enforcing our standards of 42 + acceptable behavior and will take appropriate and fair corrective action in 43 + response to any behavior that they deem inappropriate, threatening, offensive, 44 + or harmful. 45 + 46 + Community leaders have the right and responsibility to remove, edit, or reject 47 + comments, commits, code, wiki edits, issues, and other contributions that are 48 + not aligned to this Code of Conduct, and will communicate reasons for moderation 49 + decisions when appropriate. 50 + 51 + ## Scope 52 + 53 + This Code of Conduct applies within all community spaces, and also applies when 54 + an individual is officially representing the community in public spaces. 55 + Examples of representing our community include using an official e-mail address, 56 + posting via an official social media account, or acting as an appointed 57 + representative at an online or offline event. 58 + 59 + ## Enforcement 60 + 61 + Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 + reported to the community leaders responsible for enforcement at 63 + adrianhesketh@hushail.com. 64 + All complaints will be reviewed and investigated promptly and fairly. 65 + 66 + All community leaders are obligated to respect the privacy and security of the 67 + reporter of any incident. 68 + 69 + ## Enforcement Guidelines 70 + 71 + Community leaders will follow these Community Impact Guidelines in determining 72 + the consequences for any action they deem in violation of this Code of Conduct: 73 + 74 + ### 1. Correction 75 + 76 + **Community Impact**: Use of inappropriate language or other behavior deemed 77 + unprofessional or unwelcome in the community. 78 + 79 + **Consequence**: A private, written warning from community leaders, providing 80 + clarity around the nature of the violation and an explanation of why the 81 + behavior was inappropriate. A public apology may be requested. 82 + 83 + ### 2. Warning 84 + 85 + **Community Impact**: A violation through a single incident or series 86 + of actions. 87 + 88 + **Consequence**: A warning with consequences for continued behavior. No 89 + interaction with the people involved, including unsolicited interaction with 90 + those enforcing the Code of Conduct, for a specified period of time. This 91 + includes avoiding interactions in community spaces as well as external channels 92 + like social media. Violating these terms may lead to a temporary or 93 + permanent ban. 94 + 95 + ### 3. Temporary Ban 96 + 97 + **Community Impact**: A serious violation of community standards, including 98 + sustained inappropriate behavior. 99 + 100 + **Consequence**: A temporary ban from any sort of interaction or public 101 + communication with the community for a specified period of time. No public or 102 + private interaction with the people involved, including unsolicited interaction 103 + with those enforcing the Code of Conduct, is allowed during this period. 104 + Violating these terms may lead to a permanent ban. 105 + 106 + ### 4. Permanent Ban 107 + 108 + **Community Impact**: Demonstrating a pattern of violation of community 109 + standards, including sustained inappropriate behavior, harassment of an 110 + individual, or aggression toward or disparagement of classes of individuals. 111 + 112 + **Consequence**: A permanent ban from any sort of public interaction within 113 + the community. 114 + 115 + ## Attribution 116 + 117 + This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 + version 2.0, available at 119 + https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 + 121 + Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 + enforcement ladder](https://github.com/mozilla/diversity). 123 + 124 + [homepage]: https://www.contributor-covenant.org 125 + 126 + For answers to common questions about this code of conduct, see the FAQ at 127 + https://www.contributor-covenant.org/faq. Translations are available at 128 + https://www.contributor-covenant.org/translations.
+247
vendor/github.com/a-h/templ/CONTRIBUTING.md
··· 1 + # Contributing to templ 2 + 3 + ## Vision 4 + 5 + Enable Go developers to build strongly typed, component-based HTML user interfaces with first-class developer tooling, and a short learning curve. 6 + 7 + ## Come up with a design and share it 8 + 9 + Before starting work on any major pull requests or code changes, start a discussion at https://github.com/a-h/templ/discussions or raise an issue. 10 + 11 + We don't want you to spend time on a PR or feature that ultimately doesn't get merged because it doesn't fit with the project goals, or the design doesn't work for some reason. 12 + 13 + For issues, it really helps if you provide a reproduction repo, or can create a failing unit test to describe the behaviour. 14 + 15 + In designs, we need to consider: 16 + 17 + * Backwards compatibility - Not changing the public API between releases, introducing gradual deprecation - don't break people's code. 18 + * Correctness over time - How can we reduce the risk of defects both now, and in future releases? 19 + * Threat model - How could each change be used to inject vulnerabilities into web pages? 20 + * Go version - We target the oldest supported version of Go as per https://go.dev/doc/devel/release 21 + * Automatic migration - If we need to force through a change. 22 + * Compile time vs runtime errors - Prefer compile time. 23 + * Documentation - New features are only useful if people can understand the new feature, what would the documentation look like? 24 + * Examples - How will we demonstrate the feature? 25 + 26 + ## Project structure 27 + 28 + templ is structured into a few areas: 29 + 30 + ### Parser `./parser` 31 + 32 + The parser directory currently contains both v1 and v2 parsers. 33 + 34 + The v1 parser is not maintained, it's only used to migrate v1 code over to the v2 syntax. 35 + 36 + The parser is responsible for parsing templ files into an object model. The types that make up the object model are in `types.go`. Automatic formatting of the types is tested in `types_test.go`. 37 + 38 + A templ file is parsed into the `TemplateFile` struct object model. 39 + 40 + ```go 41 + type TemplateFile struct { 42 + // Header contains comments or whitespace at the top of the file. 43 + Header []GoExpression 44 + // Package expression. 45 + Package Package 46 + // Nodes in the file. 47 + Nodes []TemplateFileNode 48 + } 49 + ``` 50 + 51 + Parsers are individually tested using two types of unit test. 52 + 53 + One test covers the successful parsing of text into an object. For example, the `HTMLCommentParser` test checks for successful patterns. 54 + 55 + ```go 56 + func TestHTMLCommentParser(t *testing.T) { 57 + var tests = []struct { 58 + name string 59 + input string 60 + expected HTMLComment 61 + }{ 62 + { 63 + name: "comment - single line", 64 + input: `<!-- single line comment -->`, 65 + expected: HTMLComment{ 66 + Contents: " single line comment ", 67 + }, 68 + }, 69 + { 70 + name: "comment - no whitespace", 71 + input: `<!--no whitespace between sequence open and close-->`, 72 + expected: HTMLComment{ 73 + Contents: "no whitespace between sequence open and close", 74 + }, 75 + }, 76 + { 77 + name: "comment - multiline", 78 + input: `<!-- multiline 79 + comment 80 + -->`, 81 + expected: HTMLComment{ 82 + Contents: ` multiline 83 + comment 84 + `, 85 + }, 86 + }, 87 + { 88 + name: "comment - with tag", 89 + input: `<!-- <p class="test">tag</p> -->`, 90 + expected: HTMLComment{ 91 + Contents: ` <p class="test">tag</p> `, 92 + }, 93 + }, 94 + { 95 + name: "comments can contain tags", 96 + input: `<!-- <div> hello world </div> -->`, 97 + expected: HTMLComment{ 98 + Contents: ` <div> hello world </div> `, 99 + }, 100 + }, 101 + } 102 + for _, tt := range tests { 103 + tt := tt 104 + t.Run(tt.name, func(t *testing.T) { 105 + input := parse.NewInput(tt.input) 106 + result, ok, err := htmlComment.Parse(input) 107 + if err != nil { 108 + t.Fatalf("parser error: %v", err) 109 + } 110 + if !ok { 111 + t.Fatalf("failed to parse at %d", input.Index()) 112 + } 113 + if diff := cmp.Diff(tt.expected, result); diff != "" { 114 + t.Errorf(diff) 115 + } 116 + }) 117 + } 118 + } 119 + ``` 120 + 121 + Alongside each success test, is a similar test to check that invalid syntax is detected. 122 + 123 + ```go 124 + func TestHTMLCommentParserErrors(t *testing.T) { 125 + var tests = []struct { 126 + name string 127 + input string 128 + expected error 129 + }{ 130 + { 131 + name: "unclosed HTML comment", 132 + input: `<!-- unclosed HTML comment`, 133 + expected: parse.Error("expected end comment literal '-->' not found", 134 + parse.Position{ 135 + Index: 26, 136 + Line: 0, 137 + Col: 26, 138 + }), 139 + }, 140 + { 141 + name: "comment in comment", 142 + input: `<!-- <-- other --> -->`, 143 + expected: parse.Error("comment contains invalid sequence '--'", parse.Position{ 144 + Index: 8, 145 + Line: 0, 146 + Col: 8, 147 + }), 148 + }, 149 + } 150 + for _, tt := range tests { 151 + tt := tt 152 + t.Run(tt.name, func(t *testing.T) { 153 + input := parse.NewInput(tt.input) 154 + _, _, err := htmlComment.Parse(input) 155 + if diff := cmp.Diff(tt.expected, err); diff != "" { 156 + t.Error(diff) 157 + } 158 + }) 159 + } 160 + } 161 + ``` 162 + 163 + ### Generator 164 + 165 + The generator takes the object model and writes out Go code that produces the expected output. Any changes to Go code output by templ are made in this area. 166 + 167 + Testing of the generator is carried out by creating a templ file, and a matching expected output file. 168 + 169 + For example, `./generator/test-a-href` contains a templ file of: 170 + 171 + ```templ 172 + package testahref 173 + 174 + templ render() { 175 + <a href="javascript:alert(&#39;unaffected&#39;);">Ignored</a> 176 + <a href={ templ.URL("javascript:alert('should be sanitized')") }>Sanitized</a> 177 + <a href={ templ.SafeURL("javascript:alert('should not be sanitized')") }>Unsanitized</a> 178 + } 179 + ``` 180 + 181 + It also contains an expected output file. 182 + 183 + ```html 184 + <a href="javascript:alert(&#39;unaffected&#39;);">Ignored</a> 185 + <a href="about:invalid#TemplFailedSanitizationURL">Sanitized</a> 186 + <a href="javascript:alert(&#39;should not be sanitized&#39;)">Unsanitized</a> 187 + ``` 188 + 189 + These tests contribute towards the code coverage metrics by building an instrumented test CLI program. See the `test-cover` task in the `README.md` file. 190 + 191 + ### CLI 192 + 193 + The command line interface for templ is used to generate Go code from templ files, format templ files, and run the LSP. 194 + 195 + The code for this is at `./cmd/templ`. 196 + 197 + Testing of the templ command line is done with unit tests to check the argument parsing. 198 + 199 + The `templ generate` command is tested by generating templ files in the project, and testing that the expected output HTML is present. 200 + 201 + ### Runtime 202 + 203 + The runtime is used by generated code, and by template authors, to serve template content over HTTP, and to carry out various operations. 204 + 205 + It is in the root directory of the project at `./runtime.go`. The runtime is unit tested, as well as being tested as part of the `generate` tests. 206 + 207 + ### LSP 208 + 209 + The LSP is structured within the command line interface, and proxies commands through to the `gopls` LSP. 210 + 211 + ### Docs 212 + 213 + The docs are a Docusaurus project at `./docs`. 214 + 215 + ## Coding 216 + 217 + ### Build tasks 218 + 219 + templ uses the `xc` task runner - https://github.com/joerdav/xc 220 + 221 + If you run `xc` you can get see a list of the development tasks that can be run, or you can read the `README.md` file and see the `Tasks` section. 222 + 223 + The most useful tasks for local development are: 224 + 225 + * `install-snapshot` - this builds the templ CLI and installs it into `~/bin`. Ensure that this is in your path. 226 + * `test` - this regenerates all templates, and runs the unit tests. 227 + * `fmt` - run the `gofmt` tool to format all Go code. 228 + * `lint` - run the same linting as run in the CI process. 229 + * `docs-run` - run the Docusaurus documentation site. 230 + 231 + ### Commit messages 232 + 233 + The project using https://www.conventionalcommits.org/en/v1.0.0/ 234 + 235 + Examples: 236 + 237 + * `feat: support Go comments in templates, fixes #234"` 238 + 239 + ### Coding style 240 + 241 + * Reduce nesting - i.e. prefer early returns over an `else` block, as per https://danp.net/posts/reducing-go-nesting/ or https://go.dev/doc/effective_go#if 242 + * Use line breaks to separate "paragraphs" of code - don't use line breaks in between lines, or at the start/end of functions etc. 243 + * Use the `fmt` and `lint` build tasks to format and lint your code before submitting a PR. 244 + 245 + ### LLM instructions 246 + 247 + See additional coding standards at `.github/copilot-instructions.md`
+21
vendor/github.com/a-h/templ/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2021 Adrian Hesketh 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+194
vendor/github.com/a-h/templ/README.md
··· 1 + ![templ](https://github.com/a-h/templ/raw/main/templ.png) 2 + 3 + ## An HTML templating language for Go that has great developer tooling. 4 + 5 + ![templ](ide-demo.gif) 6 + 7 + 8 + ## Documentation 9 + 10 + See user documentation at https://templ.guide 11 + 12 + <p align="center"> 13 + <a href="https://pkg.go.dev/github.com/a-h/templ"><img src="https://pkg.go.dev/badge/github.com/a-h/templ.svg" alt="Go Reference" /></a> 14 + <a href="https://xcfile.dev"><img src="https://xcfile.dev/badge.svg" alt="xc compatible" /></a> 15 + <a href="https://raw.githack.com/wiki/a-h/templ/coverage.html"><img src="https://github.com/a-h/templ/wiki/coverage.svg" alt="Go Coverage" /></a> 16 + <a href="https://goreportcard.com/report/github.com/a-h/templ"><img src="https://goreportcard.com/badge/github.com/a-h/templ" alt="Go Report Card" /></a> 17 + </p> 18 + 19 + ## Tasks 20 + 21 + ### version-set 22 + 23 + Set the version of templ to the current version. 24 + 25 + ```sh 26 + version set --template="0.3.%d" 27 + ``` 28 + 29 + ### build 30 + 31 + Build a local version. 32 + 33 + ```sh 34 + version set --template="0.3.%d" 35 + cd cmd/templ 36 + go build 37 + ``` 38 + 39 + ### install-snapshot 40 + 41 + Build and install current version. 42 + 43 + ```sh 44 + # Remove templ from the non-standard ~/bin/templ path 45 + # that this command previously used. 46 + rm -f ~/bin/templ 47 + # Clear LSP logs. 48 + rm -f cmd/templ/lspcmd/*.txt 49 + # Update version. 50 + version set --template="0.3.%d" 51 + # Install to $GOPATH/bin or $HOME/go/bin 52 + cd cmd/templ && go install 53 + ``` 54 + 55 + ### build-snapshot 56 + 57 + Use goreleaser to build the command line binary using goreleaser. 58 + 59 + ```sh 60 + goreleaser build --snapshot --clean 61 + ``` 62 + 63 + ### generate 64 + 65 + Run templ generate using local version. 66 + 67 + ```sh 68 + go run ./cmd/templ generate -include-version=false 69 + ``` 70 + 71 + ### test 72 + 73 + Run Go tests. 74 + 75 + ```sh 76 + version set --template="0.3.%d" 77 + go run ./cmd/templ generate -include-version=false 78 + go test ./... 79 + ``` 80 + 81 + ### test-short 82 + 83 + Run Go tests. 84 + 85 + ```sh 86 + version set --template="0.3.%d" 87 + go run ./cmd/templ generate -include-version=false 88 + go test ./... -short 89 + ``` 90 + 91 + ### test-cover 92 + 93 + Run Go tests. 94 + 95 + ```sh 96 + # Create test profile directories. 97 + mkdir -p coverage/fmt 98 + mkdir -p coverage/generate 99 + mkdir -p coverage/version 100 + mkdir -p coverage/unit 101 + # Build the test binary. 102 + go build -cover -o ./coverage/templ-cover ./cmd/templ 103 + # Run the covered generate command. 104 + GOCOVERDIR=coverage/fmt ./coverage/templ-cover fmt . 105 + GOCOVERDIR=coverage/generate ./coverage/templ-cover generate -include-version=false 106 + GOCOVERDIR=coverage/version ./coverage/templ-cover version 107 + # Run the unit tests. 108 + go test -cover ./... -coverpkg ./... -args -test.gocoverdir="$PWD/coverage/unit" 109 + # Display the combined percentage. 110 + go tool covdata percent -i=./coverage/fmt,./coverage/generate,./coverage/version,./coverage/unit 111 + # Generate a text coverage profile for tooling to use. 112 + go tool covdata textfmt -i=./coverage/fmt,./coverage/generate,./coverage/version,./coverage/unit -o coverage.out 113 + # Print total 114 + go tool cover -func coverage.out | grep total 115 + ``` 116 + 117 + ### test-cover-watch 118 + 119 + interactive: true 120 + 121 + ```sh 122 + gotestsum --watch -- -coverprofile=coverage.out 123 + ``` 124 + 125 + ### test-fuzz 126 + 127 + ```sh 128 + ./parser/v2/fuzz.sh 129 + ./parser/v2/goexpression/fuzz.sh 130 + ``` 131 + 132 + ### benchmark 133 + 134 + Run benchmarks. 135 + 136 + ```sh 137 + go run ./cmd/templ generate -include-version=false && go test ./... -bench=. -benchmem 138 + ``` 139 + 140 + ### fmt 141 + 142 + Format all Go and templ code. 143 + 144 + ```sh 145 + gofmt -s -w . 146 + go run ./cmd/templ fmt . 147 + ``` 148 + 149 + ### lint 150 + 151 + Run the lint operations that are run as part of the CI. 152 + 153 + ```sh 154 + golangci-lint run --verbose 155 + ``` 156 + 157 + ### ensure-generated 158 + 159 + Ensure that templ files have been generated with the local version of templ, and that those files have been added to git. 160 + 161 + Requires: generate 162 + 163 + ```sh 164 + git diff --exit-code 165 + ``` 166 + 167 + ### push-release-tag 168 + 169 + Push a semantic version number to GitHub to trigger the release process. 170 + 171 + ```sh 172 + version push --template="0.3.%d" --prefix="v" 173 + ``` 174 + 175 + ### docs-run 176 + 177 + Run the development server. 178 + 179 + Directory: docs 180 + 181 + ```sh 182 + npm run start 183 + ``` 184 + 185 + ### docs-build 186 + 187 + Build production docs site. 188 + 189 + Directory: docs 190 + 191 + ```sh 192 + npm run build 193 + ``` 194 +
+9
vendor/github.com/a-h/templ/SECURITY.md
··· 1 + # Security Policy 2 + 3 + ## Supported Versions 4 + 5 + The latest version of templ is supported. 6 + 7 + ## Reporting a Vulnerability 8 + 9 + Use the "Security" tab in GitHub and fill out the "Report a vulnerability" form.
+4
vendor/github.com/a-h/templ/cosign.pub
··· 1 + -----BEGIN PUBLIC KEY----- 2 + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqHp75uAj8XqKrLO2YvY0M2EddckH 3 + evQnNAj+0GmBptqdf3NJcUCjL6w4z2Ikh/Zb8lh6b13akAwO/dJQaMLoMA== 4 + -----END PUBLIC KEY-----
+87
vendor/github.com/a-h/templ/flake.lock
··· 1 + { 2 + "nodes": { 3 + "gitignore": { 4 + "inputs": { 5 + "nixpkgs": [ 6 + "nixpkgs" 7 + ] 8 + }, 9 + "locked": { 10 + "lastModified": 1762808025, 11 + "narHash": "sha256-XmjITeZNMTQXGhhww6ed/Wacy2KzD6svioyCX7pkUu4=", 12 + "owner": "hercules-ci", 13 + "repo": "gitignore.nix", 14 + "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", 15 + "type": "github" 16 + }, 17 + "original": { 18 + "owner": "hercules-ci", 19 + "repo": "gitignore.nix", 20 + "type": "github" 21 + } 22 + }, 23 + "nixpkgs": { 24 + "locked": { 25 + "lastModified": 1772047000, 26 + "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", 27 + "owner": "NixOS", 28 + "repo": "nixpkgs", 29 + "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", 30 + "type": "github" 31 + }, 32 + "original": { 33 + "owner": "NixOS", 34 + "ref": "nixos-25.11", 35 + "repo": "nixpkgs", 36 + "type": "github" 37 + } 38 + }, 39 + "nixpkgs-unstable": { 40 + "locked": { 41 + "lastModified": 1771848320, 42 + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", 43 + "owner": "NixOS", 44 + "repo": "nixpkgs", 45 + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", 46 + "type": "github" 47 + }, 48 + "original": { 49 + "owner": "NixOS", 50 + "ref": "nixos-unstable", 51 + "repo": "nixpkgs", 52 + "type": "github" 53 + } 54 + }, 55 + "root": { 56 + "inputs": { 57 + "gitignore": "gitignore", 58 + "nixpkgs": "nixpkgs", 59 + "nixpkgs-unstable": "nixpkgs-unstable", 60 + "version": "version" 61 + } 62 + }, 63 + "version": { 64 + "inputs": { 65 + "nixpkgs": [ 66 + "nixpkgs" 67 + ] 68 + }, 69 + "locked": { 70 + "lastModified": 1749991223, 71 + "narHash": "sha256-K6OM2m+Bdkbq7MvTIwI1t0aPIwmkLUDeUfev5VHpiwg=", 72 + "owner": "a-h", 73 + "repo": "version", 74 + "rev": "da721166410c6e7e2bea37cf3dee3948b5d0c83f", 75 + "type": "github" 76 + }, 77 + "original": { 78 + "owner": "a-h", 79 + "ref": "0.0.10", 80 + "repo": "version", 81 + "type": "github" 82 + } 83 + } 84 + }, 85 + "root": "root", 86 + "version": 7 87 + }
+99
vendor/github.com/a-h/templ/flake.nix
··· 1 + { 2 + description = "templ"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; 6 + nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; 7 + gitignore = { 8 + url = "github:hercules-ci/gitignore.nix"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + }; 11 + version = { 12 + url = "github:a-h/version/0.0.10"; 13 + inputs.nixpkgs.follows = "nixpkgs"; 14 + }; 15 + }; 16 + 17 + outputs = { self, nixpkgs, nixpkgs-unstable, gitignore, version }: 18 + let 19 + allSystems = [ 20 + "x86_64-linux" # 64-bit Intel/AMD Linux 21 + "aarch64-linux" # 64-bit ARM Linux 22 + "x86_64-darwin" # 64-bit Intel macOS 23 + "aarch64-darwin" # 64-bit ARM macOS 24 + ]; 25 + forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f { 26 + inherit system; 27 + pkgs = 28 + let 29 + pkgs-unstable = import nixpkgs-unstable { inherit system; }; 30 + in 31 + import nixpkgs { 32 + inherit system; 33 + overlays = [ 34 + (final: prev: { 35 + gopls = pkgs-unstable.gopls; 36 + version = version.packages.${system}.default; # Used to apply version numbers to the repo. 37 + }) 38 + ]; 39 + }; 40 + }); 41 + in 42 + { 43 + packages = forAllSystems ({ pkgs, ... }: 44 + rec { 45 + default = templ; 46 + 47 + templ = pkgs.buildGoModule { 48 + name = "templ"; 49 + subPackages = [ "cmd/templ" ]; 50 + src = gitignore.lib.gitignoreSource ./.; 51 + vendorHash = "sha256-pVZjZCXT/xhBCMyZdR7kEmB9jqhTwRISFp63bQf6w5A="; 52 + env = { 53 + CGO_ENABLED = 0; 54 + }; 55 + flags = [ 56 + "-trimpath" 57 + ]; 58 + ldflags = [ 59 + "-s" 60 + "-w" 61 + "-extldflags -static" 62 + ]; 63 + }; 64 + }); 65 + 66 + # `nix develop` provides a shell containing development tools. 67 + devShell = forAllSystems ({ pkgs, ... }: 68 + pkgs.mkShell { 69 + buildInputs = [ 70 + pkgs.golangci-lint 71 + pkgs.cosign # Used to sign container images. 72 + pkgs.esbuild # Used to package JS examples. 73 + pkgs.go 74 + pkgs.gopls 75 + pkgs.goreleaser 76 + pkgs.gotestsum 77 + pkgs.govulncheck 78 + pkgs.ko # Used to build Docker images. 79 + pkgs.nodejs # Used to build templ-docs. 80 + pkgs.nodePackages.prettier # Used for formatting JS and CSS. 81 + pkgs.version 82 + pkgs.xc 83 + ]; 84 + }); 85 + 86 + # This flake outputs an overlay that can be used to add templ and 87 + # templ-docs to nixpkgs as per https://templ.guide/quick-start/installation/#nix 88 + # 89 + # Example usage: 90 + # 91 + # nixpkgs.overlays = [ 92 + # inputs.templ.overlays.default 93 + # ]; 94 + overlays.default = final: prev: { 95 + templ = self.packages.${final.stdenv.system}.templ; 96 + }; 97 + }; 98 + } 99 +
+36
vendor/github.com/a-h/templ/flush.go
··· 1 + package templ 2 + 3 + import ( 4 + "context" 5 + "io" 6 + ) 7 + 8 + // Flush flushes the output buffer after all its child components have been rendered. 9 + func Flush() FlushComponent { 10 + return FlushComponent{} 11 + } 12 + 13 + type FlushComponent struct { 14 + } 15 + 16 + type flusherError interface { 17 + Flush() error 18 + } 19 + 20 + type flusher interface { 21 + Flush() 22 + } 23 + 24 + func (f FlushComponent) Render(ctx context.Context, w io.Writer) (err error) { 25 + if err = GetChildren(ctx).Render(ctx, w); err != nil { 26 + return err 27 + } 28 + switch w := w.(type) { 29 + case flusher: 30 + w.Flush() 31 + return nil 32 + case flusherError: 33 + return w.Flush() 34 + } 35 + return nil 36 + }
+70
vendor/github.com/a-h/templ/fragment.go
··· 1 + package templ 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "slices" 7 + ) 8 + 9 + // RenderFragments renders the specified fragments to w. 10 + func RenderFragments(ctx context.Context, w io.Writer, c Component, ids ...any) error { 11 + ctx = context.WithValue(ctx, fragmentContextKey, &FragmentContext{ 12 + W: w, 13 + IDs: ids, 14 + }) 15 + return c.Render(ctx, io.Discard) 16 + } 17 + 18 + type fragmentContextKeyType int 19 + 20 + const fragmentContextKey fragmentContextKeyType = iota 21 + 22 + // FragmentContext is used to control rendering of fragments within a template. 23 + type FragmentContext struct { 24 + W io.Writer 25 + IDs []any 26 + Active bool 27 + } 28 + 29 + // Fragment defines a fragment within a template that can be rendered conditionally based on the id. 30 + // You can use it to render a specific part of a page, e.g. to reduce the amount of HTML returned from a htmx-initiated request. 31 + // Any non-matching contents of the template are rendered, but discarded by the FramentWriter. 32 + func Fragment(id any) Component { 33 + return &fragment{ 34 + ID: id, 35 + } 36 + } 37 + 38 + type fragment struct { 39 + ID any 40 + } 41 + 42 + func (f *fragment) Render(ctx context.Context, w io.Writer) (err error) { 43 + // If not in a fragment context, if we're a child fragment, or in a mismatching fragment context, render children normally. 44 + fragmentCtx := getFragmentContext(ctx) 45 + if fragmentCtx == nil || fragmentCtx.Active || !slices.Contains(fragmentCtx.IDs, f.ID) { 46 + return GetChildren(ctx).Render(ctx, w) 47 + } 48 + 49 + // Instruct child fragments to render their contents normally, because the writer 50 + // passed to them is already the FragmentContext's writer. 51 + fragmentCtx.Active = true 52 + defer func() { 53 + fragmentCtx.Active = false 54 + }() 55 + return GetChildren(ctx).Render(ctx, fragmentCtx.W) 56 + } 57 + 58 + // getFragmentContext retrieves the FragmentContext from the provided context. It returns nil if no 59 + // FragmentContext is found or if the context value is of an unexpected type. 60 + func getFragmentContext(ctx context.Context) *FragmentContext { 61 + ctxValue := ctx.Value(fragmentContextKey) 62 + if ctxValue == nil { 63 + return nil 64 + } 65 + v, ok := ctxValue.(*FragmentContext) 66 + if !ok { 67 + return nil 68 + } 69 + return v 70 + }
+163
vendor/github.com/a-h/templ/handler.go
··· 1 + package templ 2 + 3 + import ( 4 + "net/http" 5 + ) 6 + 7 + // ComponentHandler is a http.Handler that renders components. 8 + type ComponentHandler struct { 9 + Component Component 10 + Status int 11 + ContentType string 12 + ErrorHandler func(r *http.Request, err error) http.Handler 13 + StreamResponse bool 14 + FragmentIDs []any 15 + } 16 + 17 + const componentHandlerErrorMessage = "templ: failed to render template" 18 + 19 + func (ch *ComponentHandler) handleRenderErr(w http.ResponseWriter, r *http.Request, err error) { 20 + if ch.ErrorHandler != nil { 21 + w.Header().Set("Content-Type", ch.ContentType) 22 + ch.ErrorHandler(r, err).ServeHTTP(w, r) 23 + return 24 + } 25 + http.Error(w, componentHandlerErrorMessage, http.StatusInternalServerError) 26 + } 27 + 28 + func (ch *ComponentHandler) ServeHTTPBufferedFragment(w http.ResponseWriter, r *http.Request) { 29 + // Since the component may error, write to a buffer first. 30 + // This prevents partial responses from being written to the client. 31 + buf := GetBuffer() 32 + defer ReleaseBuffer(buf) 33 + 34 + // Render the component into io.Discard, but use the buffer for fragments. 35 + if err := RenderFragments(r.Context(), buf, ch.Component, ch.FragmentIDs...); err != nil { 36 + ch.handleRenderErr(w, r, err) 37 + return 38 + } 39 + 40 + // The component rendered successfully, we can write the Content-Type and Status. 41 + w.Header().Set("Content-Type", ch.ContentType) 42 + if ch.Status != 0 { 43 + w.WriteHeader(ch.Status) 44 + } 45 + // Ignore write error like http.Error() does, because there is 46 + // no way to recover at this point. 47 + _, _ = w.Write(buf.Bytes()) 48 + } 49 + 50 + func (ch *ComponentHandler) ServeHTTPBufferedComplete(w http.ResponseWriter, r *http.Request) { 51 + // Since the component may error, write to a buffer first. 52 + // This prevents partial responses from being written to the client. 53 + buf := GetBuffer() 54 + defer ReleaseBuffer(buf) 55 + 56 + // Render the component into the buffer. 57 + if err := ch.Component.Render(r.Context(), buf); err != nil { 58 + ch.handleRenderErr(w, r, err) 59 + return 60 + } 61 + 62 + // The component rendered successfully, we can write the Content-Type and Status. 63 + w.Header().Set("Content-Type", ch.ContentType) 64 + if ch.Status != 0 { 65 + w.WriteHeader(ch.Status) 66 + } 67 + // Ignore write error like http.Error() does, because there is 68 + // no way to recover at this point. 69 + _, _ = w.Write(buf.Bytes()) 70 + } 71 + 72 + func (ch *ComponentHandler) ServeHTTPBuffered(w http.ResponseWriter, r *http.Request) { 73 + // If fragments are specified, render only those. 74 + if len(ch.FragmentIDs) > 0 { 75 + ch.ServeHTTPBufferedFragment(w, r) 76 + return 77 + } 78 + 79 + // Otherwise, render the complete component. 80 + ch.ServeHTTPBufferedComplete(w, r) 81 + } 82 + 83 + func (ch *ComponentHandler) ServeHTTPStreamed(w http.ResponseWriter, r *http.Request) { 84 + // If streaming, we do not buffer the response, so set the headers immediately. 85 + w.Header().Set("Content-Type", ch.ContentType) 86 + if ch.Status != 0 { 87 + w.WriteHeader(ch.Status) 88 + } 89 + 90 + // Pass fragment names to the context if specified. 91 + if len(ch.FragmentIDs) > 0 { 92 + 93 + // Render the component into io.Discard, but use the buffer for fragments. 94 + if err := RenderFragments(r.Context(), w, ch.Component, ch.FragmentIDs...); err != nil { 95 + ch.handleRenderErr(w, r, err) 96 + return 97 + } 98 + return 99 + } 100 + 101 + // Render the component into the buffer. 102 + if err := ch.Component.Render(r.Context(), w); err != nil { 103 + ch.handleRenderErr(w, r, err) 104 + return 105 + } 106 + } 107 + 108 + // ServeHTTP implements the http.Handler interface. 109 + func (ch ComponentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 110 + if ch.StreamResponse { 111 + ch.ServeHTTPStreamed(w, r) 112 + return 113 + } 114 + ch.ServeHTTPBuffered(w, r) 115 + } 116 + 117 + // Handler creates a http.Handler that renders the template. 118 + func Handler(c Component, options ...func(*ComponentHandler)) *ComponentHandler { 119 + ch := &ComponentHandler{ 120 + Component: c, 121 + ContentType: "text/html; charset=utf-8", 122 + } 123 + for _, o := range options { 124 + o(ch) 125 + } 126 + return ch 127 + } 128 + 129 + // WithStatus sets the HTTP status code returned by the ComponentHandler. 130 + func WithStatus(status int) func(*ComponentHandler) { 131 + return func(ch *ComponentHandler) { 132 + ch.Status = status 133 + } 134 + } 135 + 136 + // WithContentType sets the Content-Type header returned by the ComponentHandler. 137 + func WithContentType(contentType string) func(*ComponentHandler) { 138 + return func(ch *ComponentHandler) { 139 + ch.ContentType = contentType 140 + } 141 + } 142 + 143 + // WithErrorHandler sets the error handler used if rendering fails. 144 + func WithErrorHandler(eh func(r *http.Request, err error) http.Handler) func(*ComponentHandler) { 145 + return func(ch *ComponentHandler) { 146 + ch.ErrorHandler = eh 147 + } 148 + } 149 + 150 + // WithStreaming sets the ComponentHandler to stream the response instead of buffering it. 151 + func WithStreaming() func(*ComponentHandler) { 152 + return func(ch *ComponentHandler) { 153 + ch.StreamResponse = true 154 + } 155 + } 156 + 157 + // WithFragments sets the ids of the fragments to render. 158 + // If not set, all content is rendered. 159 + func WithFragments(ids ...any) func(*ComponentHandler) { 160 + return func(ch *ComponentHandler) { 161 + ch.FragmentIDs = ids 162 + } 163 + }
vendor/github.com/a-h/templ/ide-demo.gif

This is a binary file and will not be displayed.

+19
vendor/github.com/a-h/templ/join.go
··· 1 + package templ 2 + 3 + import ( 4 + "context" 5 + "io" 6 + ) 7 + 8 + // Join returns a single `templ.Component` that will render provided components in order. 9 + // If any of the components return an error the Join component will immediately return with the error. 10 + func Join(components ...Component) Component { 11 + return ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 12 + for _, c := range components { 13 + if err = c.Render(ctx, w); err != nil { 14 + return err 15 + } 16 + } 17 + return nil 18 + }) 19 + }
+40
vendor/github.com/a-h/templ/js.go
··· 1 + package templ 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + "html" 7 + ) 8 + 9 + // JSUnsafeFuncCall calls arbitrary JavaScript in the js parameter. 10 + // 11 + // Use of this function presents a security risk - the JavaScript must come 12 + // from a trusted source, because it will be included as-is in the output. 13 + func JSUnsafeFuncCall[T ~string](js T) ComponentScript { 14 + sum := sha256.Sum256([]byte(js)) 15 + return ComponentScript{ 16 + Name: "jsUnsafeFuncCall_" + hex.EncodeToString(sum[:]), 17 + // Function is empty because the body of the function is defined elsewhere, 18 + // e.g. in a <script> tag within a templ.Once block. 19 + Function: "", 20 + Call: html.EscapeString(string(js)), 21 + CallInline: string(js), 22 + } 23 + } 24 + 25 + // JSFuncCall calls a JavaScript function with the given arguments. 26 + // 27 + // It can be used in event handlers, e.g. onclick, onhover, etc. or 28 + // directly in HTML. 29 + func JSFuncCall[T ~string](functionName T, args ...any) ComponentScript { 30 + call := SafeScript(string(functionName), args...) 31 + sum := sha256.Sum256([]byte(call)) 32 + return ComponentScript{ 33 + Name: "jsFuncCall_" + hex.EncodeToString(sum[:]), 34 + // Function is empty because the body of the function is defined elsewhere, 35 + // e.g. in a <script> tag within a templ.Once block. 36 + Function: "", 37 + Call: call, 38 + CallInline: SafeScriptInline(string(functionName), args...), 39 + } 40 + }
+85
vendor/github.com/a-h/templ/jsonscript.go
··· 1 + package templ 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + ) 9 + 10 + var _ Component = JSONScriptElement{} 11 + 12 + // JSONScript renders a JSON object inside a script element. 13 + // e.g. <script type="application/json">{"foo":"bar"}</script> 14 + func JSONScript(id string, data any) JSONScriptElement { 15 + return JSONScriptElement{ 16 + ID: id, 17 + Type: "application/json", 18 + Data: data, 19 + Nonce: GetNonce, 20 + } 21 + } 22 + 23 + // WithType sets the value of the type attribute of the script element. 24 + func (j JSONScriptElement) WithType(t string) JSONScriptElement { 25 + j.Type = t 26 + return j 27 + } 28 + 29 + // WithNonceFromString sets the value of the nonce attribute of the script element to the given string. 30 + func (j JSONScriptElement) WithNonceFromString(nonce string) JSONScriptElement { 31 + j.Nonce = func(context.Context) string { 32 + return nonce 33 + } 34 + return j 35 + } 36 + 37 + // WithNonceFrom sets the value of the nonce attribute of the script element to the value returned by the given function. 38 + func (j JSONScriptElement) WithNonceFrom(f func(context.Context) string) JSONScriptElement { 39 + j.Nonce = f 40 + return j 41 + } 42 + 43 + type JSONScriptElement struct { 44 + // ID of the element in the DOM. 45 + ID string 46 + // Type of the script element, defaults to "application/json". 47 + Type string 48 + // Data that will be encoded as JSON. 49 + Data any 50 + // Nonce is a function that returns a CSP nonce. 51 + // Defaults to CSPNonceFromContext. 52 + // See https://content-security-policy.com/nonce for more information. 53 + Nonce func(ctx context.Context) string 54 + } 55 + 56 + func (j JSONScriptElement) Render(ctx context.Context, w io.Writer) (err error) { 57 + if _, err = io.WriteString(w, "<script"); err != nil { 58 + return err 59 + } 60 + if j.ID != "" { 61 + if _, err = fmt.Fprintf(w, " id=\"%s\"", EscapeString(j.ID)); err != nil { 62 + return err 63 + } 64 + } 65 + if j.Type != "" { 66 + if _, err = fmt.Fprintf(w, " type=\"%s\"", EscapeString(j.Type)); err != nil { 67 + return err 68 + } 69 + } 70 + if nonce := j.Nonce(ctx); nonce != "" { 71 + if _, err = fmt.Fprintf(w, " nonce=\"%s\"", EscapeString(nonce)); err != nil { 72 + return err 73 + } 74 + } 75 + if _, err = io.WriteString(w, ">"); err != nil { 76 + return err 77 + } 78 + if err = json.NewEncoder(w).Encode(j.Data); err != nil { 79 + return err 80 + } 81 + if _, err = io.WriteString(w, "</script>"); err != nil { 82 + return err 83 + } 84 + return nil 85 + }
+14
vendor/github.com/a-h/templ/jsonstring.go
··· 1 + package templ 2 + 3 + import ( 4 + "encoding/json" 5 + ) 6 + 7 + // JSONString returns a JSON encoded string of v. 8 + func JSONString(v any) (string, error) { 9 + b, err := json.Marshal(v) 10 + if err != nil { 11 + return "", err 12 + } 13 + return string(b), nil 14 + }
+64
vendor/github.com/a-h/templ/once.go
··· 1 + package templ 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "sync/atomic" 7 + ) 8 + 9 + // onceHandleIndex is used to identify unique once handles in a program run. 10 + var onceHandleIndex int64 11 + 12 + type OnceOpt func(*OnceHandle) 13 + 14 + // WithOnceComponent sets the component to be rendered once per context. 15 + // This can be used instead of setting the children of the `Once` method, 16 + // for example, if creating a code component outside of a templ HTML template. 17 + func WithComponent(c Component) OnceOpt { 18 + return func(o *OnceHandle) { 19 + o.c = c 20 + } 21 + } 22 + 23 + // NewOnceHandle creates a OnceHandle used to ensure that the children of its 24 + // `Once` method are only rendered once per context. 25 + func NewOnceHandle(opts ...OnceOpt) *OnceHandle { 26 + oh := &OnceHandle{ 27 + id: atomic.AddInt64(&onceHandleIndex, 1), 28 + } 29 + for _, opt := range opts { 30 + opt(oh) 31 + } 32 + return oh 33 + } 34 + 35 + // OnceHandle is used to ensure that the children of its `Once` method are are only 36 + // rendered once per context. 37 + type OnceHandle struct { 38 + // id is used to identify which instance of the OnceHandle is being used. 39 + // The OnceHandle can't be an empty struct, because: 40 + // 41 + // | Two distinct zero-size variables may 42 + // | have the same address in memory 43 + // 44 + // https://go.dev/ref/spec#Size_and_alignment_guarantees 45 + id int64 46 + // c is the component to be rendered once per context. 47 + // if c is nil, the children of the `Once` method are rendered. 48 + c Component 49 + } 50 + 51 + // Once returns a component that renders its children once per context. 52 + func (o *OnceHandle) Once() Component { 53 + return ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 54 + _, v := getContext(ctx) 55 + if v.getHasBeenRendered(o) { 56 + return nil 57 + } 58 + v.setHasBeenRendered(o) 59 + if o.c != nil { 60 + return o.c.Render(ctx, w) 61 + } 62 + return GetChildren(ctx).Render(ctx, w) 63 + }) 64 + }
+714
vendor/github.com/a-h/templ/runtime.go
··· 1 + package templ 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/sha256" 7 + "encoding/hex" 8 + "errors" 9 + "fmt" 10 + "html" 11 + "html/template" 12 + "io" 13 + "net/http" 14 + "reflect" 15 + "sort" 16 + "strings" 17 + "sync" 18 + 19 + "github.com/a-h/templ/safehtml" 20 + ) 21 + 22 + // Types exposed by all components. 23 + 24 + // Component is the interface that all templates implement. 25 + type Component interface { 26 + // Render the template. 27 + Render(ctx context.Context, w io.Writer) error 28 + } 29 + 30 + // ComponentFunc converts a function that matches the Component interface's 31 + // Render method into a Component. 32 + type ComponentFunc func(ctx context.Context, w io.Writer) error 33 + 34 + // Render the template. 35 + func (cf ComponentFunc) Render(ctx context.Context, w io.Writer) error { 36 + return cf(ctx, w) 37 + } 38 + 39 + // WithNonce sets a CSP nonce on the context and returns it. 40 + func WithNonce(ctx context.Context, nonce string) context.Context { 41 + ctx, v := getContext(ctx) 42 + v.nonce = nonce 43 + return ctx 44 + } 45 + 46 + // GetNonce returns the CSP nonce value set with WithNonce, or an 47 + // empty string if none has been set. 48 + func GetNonce(ctx context.Context) (nonce string) { 49 + if ctx == nil { 50 + return "" 51 + } 52 + _, v := getContext(ctx) 53 + return v.nonce 54 + } 55 + 56 + func WithChildren(ctx context.Context, children Component) context.Context { 57 + ctx, v := getContext(ctx) 58 + v.children = &children 59 + return ctx 60 + } 61 + 62 + func ClearChildren(ctx context.Context) context.Context { 63 + _, v := getContext(ctx) 64 + v.children = nil 65 + return ctx 66 + } 67 + 68 + // NopComponent is a component that doesn't render anything. 69 + var NopComponent = ComponentFunc(func(ctx context.Context, w io.Writer) error { return nil }) 70 + 71 + // GetChildren from the context. 72 + func GetChildren(ctx context.Context) Component { 73 + _, v := getContext(ctx) 74 + if v.children == nil { 75 + return NopComponent 76 + } 77 + return *v.children 78 + } 79 + 80 + // EscapeString escapes HTML text within templates. 81 + func EscapeString[T ~string](s T) string { 82 + return html.EscapeString(string(s)) 83 + } 84 + 85 + // Bool attribute value. 86 + func Bool(value bool) bool { 87 + return value 88 + } 89 + 90 + // Classes for CSS. 91 + // Supported types are string, ConstantCSSClass, ComponentCSSClass, map[string]bool. 92 + func Classes(classes ...any) CSSClasses { 93 + return CSSClasses(classes) 94 + } 95 + 96 + // CSSClasses is a slice of CSS classes. 97 + type CSSClasses []any 98 + 99 + // String returns the names of all CSS classes. 100 + func (classes CSSClasses) String() string { 101 + if len(classes) == 0 { 102 + return "" 103 + } 104 + cp := newCSSProcessor() 105 + for _, v := range classes { 106 + cp.Add(v) 107 + } 108 + return cp.String() 109 + } 110 + 111 + func newCSSProcessor() *cssProcessor { 112 + return &cssProcessor{ 113 + classNameToEnabled: make(map[string]bool), 114 + } 115 + } 116 + 117 + type cssProcessor struct { 118 + classNameToEnabled map[string]bool 119 + orderedNames []string 120 + } 121 + 122 + func (cp *cssProcessor) Add(item any) { 123 + switch c := item.(type) { 124 + case []string: 125 + for _, className := range c { 126 + cp.AddClassName(className, true) 127 + } 128 + case string: 129 + cp.AddClassName(c, true) 130 + case ConstantCSSClass: 131 + cp.AddClassName(c.ClassName(), true) 132 + case ComponentCSSClass: 133 + cp.AddClassName(c.ClassName(), true) 134 + case map[string]bool: 135 + // In Go, map keys are iterated in a randomized order. 136 + // So the keys in the map must be sorted to produce consistent output. 137 + keys := make([]string, len(c)) 138 + var i int 139 + for key := range c { 140 + keys[i] = key 141 + i++ 142 + } 143 + sort.Strings(keys) 144 + for _, className := range keys { 145 + cp.AddClassName(className, c[className]) 146 + } 147 + case []KeyValue[string, bool]: 148 + for _, kv := range c { 149 + cp.AddClassName(kv.Key, kv.Value) 150 + } 151 + case KeyValue[string, bool]: 152 + cp.AddClassName(c.Key, c.Value) 153 + case []KeyValue[CSSClass, bool]: 154 + for _, kv := range c { 155 + cp.AddClassName(kv.Key.ClassName(), kv.Value) 156 + } 157 + case KeyValue[CSSClass, bool]: 158 + cp.AddClassName(c.Key.ClassName(), c.Value) 159 + case CSSClasses: 160 + for _, item := range c { 161 + cp.Add(item) 162 + } 163 + case []CSSClass: 164 + for _, item := range c { 165 + cp.Add(item) 166 + } 167 + case func() CSSClass: 168 + cp.AddClassName(c().ClassName(), true) 169 + default: 170 + cp.AddClassName(unknownTypeClassName, true) 171 + } 172 + } 173 + 174 + func (cp *cssProcessor) AddClassName(className string, enabled bool) { 175 + cp.classNameToEnabled[className] = enabled 176 + cp.orderedNames = append(cp.orderedNames, className) 177 + } 178 + 179 + func (cp *cssProcessor) String() string { 180 + // Order the outputs according to how they were input, and remove disabled names. 181 + rendered := make(map[string]any, len(cp.classNameToEnabled)) 182 + var names []string 183 + for _, name := range cp.orderedNames { 184 + if enabled := cp.classNameToEnabled[name]; !enabled { 185 + continue 186 + } 187 + if _, hasBeenRendered := rendered[name]; hasBeenRendered { 188 + continue 189 + } 190 + names = append(names, name) 191 + rendered[name] = struct{}{} 192 + } 193 + 194 + return strings.Join(names, " ") 195 + } 196 + 197 + // KeyValue is a key and value pair. 198 + type KeyValue[TKey comparable, TValue any] struct { 199 + Key TKey `json:"name"` 200 + Value TValue `json:"value"` 201 + } 202 + 203 + // KV creates a new key/value pair from the input key and value. 204 + func KV[TKey comparable, TValue any](key TKey, value TValue) KeyValue[TKey, TValue] { 205 + return KeyValue[TKey, TValue]{ 206 + Key: key, 207 + Value: value, 208 + } 209 + } 210 + 211 + const unknownTypeClassName = "--templ-css-class-unknown-type" 212 + 213 + // Class returns a CSS class name. 214 + // Deprecated: use a string instead. 215 + func Class(name string) CSSClass { 216 + return SafeClass(name) 217 + } 218 + 219 + // SafeClass bypasses CSS class name validation. 220 + // Deprecated: use a string instead. 221 + func SafeClass(name string) CSSClass { 222 + return ConstantCSSClass(name) 223 + } 224 + 225 + // CSSClass provides a class name. 226 + type CSSClass interface { 227 + ClassName() string 228 + } 229 + 230 + // ConstantCSSClass is a string constant of a CSS class name. 231 + // Deprecated: use a string instead. 232 + type ConstantCSSClass string 233 + 234 + // ClassName of the CSS class. 235 + func (css ConstantCSSClass) ClassName() string { 236 + return string(css) 237 + } 238 + 239 + // ComponentCSSClass is a templ.CSS 240 + type ComponentCSSClass struct { 241 + // ID of the class, will be autogenerated. 242 + ID string 243 + // Definition of the CSS. 244 + Class SafeCSS 245 + } 246 + 247 + // ClassName of the CSS class. 248 + func (css ComponentCSSClass) ClassName() string { 249 + return css.ID 250 + } 251 + 252 + // CSSID calculates an ID. 253 + func CSSID(name string, css string) string { 254 + sum := sha256.Sum256([]byte(css)) 255 + hs := hex.EncodeToString(sum[:])[0:8] // NOTE: See issue #978. Minimum recommended hs length is 6. 256 + // Benchmarking showed this was fastest, and with fewest allocations (1). 257 + // Using strings.Builder (2 allocs). 258 + // Using fmt.Sprintf (3 allocs). 259 + return name + "_" + hs 260 + } 261 + 262 + // NewCSSMiddleware creates HTTP middleware that renders a global stylesheet of ComponentCSSClass 263 + // CSS if the request path matches, or updates the HTTP context to ensure that any handlers that 264 + // use templ.Components skip rendering <style> elements for classes that are included in the global 265 + // stylesheet. By default, the stylesheet path is /styles/templ.css 266 + func NewCSSMiddleware(next http.Handler, classes ...CSSClass) CSSMiddleware { 267 + return CSSMiddleware{ 268 + Path: "/styles/templ.css", 269 + CSSHandler: NewCSSHandler(classes...), 270 + Next: next, 271 + } 272 + } 273 + 274 + // CSSMiddleware renders a global stylesheet. 275 + type CSSMiddleware struct { 276 + Path string 277 + CSSHandler CSSHandler 278 + Next http.Handler 279 + } 280 + 281 + func (cssm CSSMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { 282 + if r.URL.Path == cssm.Path { 283 + cssm.CSSHandler.ServeHTTP(w, r) 284 + return 285 + } 286 + // Add registered classes to the context. 287 + ctx, v := getContext(r.Context()) 288 + for _, c := range cssm.CSSHandler.Classes { 289 + v.addClass(c.ID) 290 + } 291 + // Serve the request. Templ components will use the updated context 292 + // to know to skip rendering <style> elements for any component CSS 293 + // classes that have been included in the global stylesheet. 294 + cssm.Next.ServeHTTP(w, r.WithContext(ctx)) 295 + } 296 + 297 + // NewCSSHandler creates a handler that serves a stylesheet containing the CSS of the 298 + // classes passed in. This is used by the CSSMiddleware to provide global stylesheets 299 + // for templ components. 300 + func NewCSSHandler(classes ...CSSClass) CSSHandler { 301 + ccssc := make([]ComponentCSSClass, 0, len(classes)) 302 + for _, c := range classes { 303 + ccss, ok := c.(ComponentCSSClass) 304 + if !ok { 305 + continue 306 + } 307 + ccssc = append(ccssc, ccss) 308 + } 309 + return CSSHandler{ 310 + Classes: ccssc, 311 + } 312 + } 313 + 314 + // CSSHandler is a HTTP handler that serves CSS. 315 + type CSSHandler struct { 316 + Logger func(err error) 317 + Classes []ComponentCSSClass 318 + } 319 + 320 + func (cssh CSSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 321 + w.Header().Set("Content-Type", "text/css") 322 + for _, c := range cssh.Classes { 323 + _, err := w.Write([]byte(c.Class)) 324 + if err != nil && cssh.Logger != nil { 325 + cssh.Logger(err) 326 + } 327 + } 328 + } 329 + 330 + // RenderCSSItems renders the CSS to the writer, if the items haven't already been rendered. 331 + func RenderCSSItems(ctx context.Context, w io.Writer, classes ...any) (err error) { 332 + if len(classes) == 0 { 333 + return nil 334 + } 335 + _, v := getContext(ctx) 336 + sb := new(strings.Builder) 337 + renderCSSItemsToBuilder(sb, v, classes...) 338 + if sb.Len() == 0 { 339 + return nil 340 + } 341 + if _, err = io.WriteString(w, `<style type="text/css"`); err != nil { 342 + return err 343 + } 344 + if v.nonce != "" { 345 + if err = writeStrings(w, ` nonce="`, EscapeString(v.nonce), `"`); err != nil { 346 + return err 347 + } 348 + } 349 + return writeStrings(w, `>`, sb.String(), `</style>`) 350 + } 351 + 352 + func renderCSSItemsToBuilder(sb *strings.Builder, v *contextValue, classes ...any) { 353 + for _, c := range classes { 354 + switch ccc := c.(type) { 355 + case ComponentCSSClass: 356 + if !v.hasClassBeenRendered(ccc.ID) { 357 + sb.WriteString(string(ccc.Class)) 358 + v.addClass(ccc.ID) 359 + } 360 + case KeyValue[ComponentCSSClass, bool]: 361 + if !ccc.Value { 362 + continue 363 + } 364 + renderCSSItemsToBuilder(sb, v, ccc.Key) 365 + case KeyValue[CSSClass, bool]: 366 + if !ccc.Value { 367 + continue 368 + } 369 + renderCSSItemsToBuilder(sb, v, ccc.Key) 370 + case CSSClasses: 371 + renderCSSItemsToBuilder(sb, v, ccc...) 372 + case []CSSClass: 373 + for _, item := range ccc { 374 + renderCSSItemsToBuilder(sb, v, item) 375 + } 376 + case func() CSSClass: 377 + renderCSSItemsToBuilder(sb, v, ccc()) 378 + case []string: 379 + // Skip. These are class names, not CSS classes. 380 + case string: 381 + // Skip. This is a class name, not a CSS class. 382 + case ConstantCSSClass: 383 + // Skip. This is a class name, not a CSS class. 384 + case CSSClass: 385 + // Skip. This is a class name, not a CSS class. 386 + case map[string]bool: 387 + // Skip. These are class names, not CSS classes. 388 + case KeyValue[string, bool]: 389 + // Skip. These are class names, not CSS classes. 390 + case []KeyValue[string, bool]: 391 + // Skip. These are class names, not CSS classes. 392 + case KeyValue[ConstantCSSClass, bool]: 393 + // Skip. These are class names, not CSS classes. 394 + case []KeyValue[ConstantCSSClass, bool]: 395 + // Skip. These are class names, not CSS classes. 396 + } 397 + } 398 + } 399 + 400 + // SafeCSS is CSS that has been sanitized. 401 + type SafeCSS string 402 + 403 + type SafeCSSProperty string 404 + 405 + var safeCSSPropertyType = reflect.TypeOf(SafeCSSProperty("")) 406 + 407 + // SanitizeCSS sanitizes CSS properties to ensure that they are safe. 408 + func SanitizeCSS[T ~string](property string, value T) SafeCSS { 409 + if reflect.TypeOf(value) == safeCSSPropertyType { 410 + return SafeCSS(safehtml.SanitizeCSSProperty(property) + ":" + string(value) + ";") 411 + } 412 + p, v := safehtml.SanitizeCSS(property, string(value)) 413 + return SafeCSS(p + ":" + v + ";") 414 + } 415 + 416 + type Attributer interface { 417 + Items() []KeyValue[string, any] 418 + } 419 + 420 + // Attributes is an alias to map[string]any made for spread attributes. 421 + type Attributes map[string]any 422 + 423 + var _ Attributer = Attributes{} 424 + 425 + // Returns the items of the attributes map in key sorted order. 426 + func (a Attributes) Items() []KeyValue[string, any] { 427 + var ( 428 + items = make([]KeyValue[string, any], len(a)) 429 + i int 430 + ) 431 + for k, v := range a { 432 + items[i] = KeyValue[string, any]{Key: k, Value: v} 433 + i++ 434 + } 435 + sort.Slice(items, func(i, j int) bool { 436 + return items[i].Key < items[j].Key 437 + }) 438 + return items 439 + } 440 + 441 + // OrderedAttributes stores attributes in order of insertion. 442 + type OrderedAttributes []KeyValue[string, any] 443 + 444 + var _ Attributer = OrderedAttributes{} 445 + 446 + func (a OrderedAttributes) Items() []KeyValue[string, any] { 447 + return a 448 + } 449 + 450 + func writeStrings(w io.Writer, ss ...string) (err error) { 451 + for _, s := range ss { 452 + if _, err = io.WriteString(w, s); err != nil { 453 + return err 454 + } 455 + } 456 + return nil 457 + } 458 + 459 + func RenderAttributes(ctx context.Context, w io.Writer, attributes Attributer) (err error) { 460 + for _, item := range attributes.Items() { 461 + key := item.Key 462 + value := item.Value 463 + switch value := value.(type) { 464 + case string: 465 + if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(value), `"`); err != nil { 466 + return err 467 + } 468 + case *string: 469 + if value == nil { 470 + continue 471 + } 472 + if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(*value), `"`); err != nil { 473 + return err 474 + } 475 + case bool: 476 + if !value { 477 + continue 478 + } 479 + if err = writeStrings(w, ` `, EscapeString(key)); err != nil { 480 + return err 481 + } 482 + case *bool: 483 + if value == nil || !*value { 484 + continue 485 + } 486 + if err = writeStrings(w, ` `, EscapeString(key)); err != nil { 487 + return err 488 + } 489 + case int, int8, int16, int32, int64, 490 + uint, uint8, uint16, uint32, uint64, uintptr, 491 + float32, float64, complex64, complex128: 492 + if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(fmt.Sprint(value)), `"`); err != nil { 493 + return err 494 + } 495 + case *int, *int8, *int16, *int32, *int64, 496 + *uint, *uint8, *uint16, *uint32, *uint64, *uintptr, 497 + *float32, *float64, *complex64, *complex128: 498 + value = ptrValue(value) 499 + if value == nil { 500 + continue 501 + } 502 + if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(fmt.Sprint(value)), `"`); err != nil { 503 + return err 504 + } 505 + case KeyValue[string, bool]: 506 + if !value.Value { 507 + continue 508 + } 509 + if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(value.Key), `"`); err != nil { 510 + return err 511 + } 512 + case KeyValue[bool, bool]: 513 + if !value.Value || !value.Key { 514 + continue 515 + } 516 + if err = writeStrings(w, ` `, EscapeString(key)); err != nil { 517 + return err 518 + } 519 + case func() bool: 520 + if !value() { 521 + continue 522 + } 523 + if err = writeStrings(w, ` `, EscapeString(key)); err != nil { 524 + return err 525 + } 526 + } 527 + } 528 + return nil 529 + } 530 + 531 + func ptrValue(v any) any { 532 + if v == nil { 533 + return nil 534 + } 535 + rv := reflect.ValueOf(v) 536 + if rv.Kind() != reflect.Ptr { 537 + return v 538 + } 539 + if rv.IsNil() { 540 + return nil 541 + } 542 + return rv.Elem().Interface() 543 + } 544 + 545 + // Context. 546 + 547 + type contextKeyType int 548 + 549 + const contextKey = contextKeyType(0) 550 + 551 + type contextValue struct { 552 + ss map[string]struct{} 553 + onceHandles map[*OnceHandle]struct{} 554 + children *Component 555 + nonce string 556 + } 557 + 558 + func (v *contextValue) setHasBeenRendered(h *OnceHandle) { 559 + if v.onceHandles == nil { 560 + v.onceHandles = map[*OnceHandle]struct{}{} 561 + } 562 + v.onceHandles[h] = struct{}{} 563 + } 564 + 565 + func (v *contextValue) getHasBeenRendered(h *OnceHandle) (ok bool) { 566 + if v.onceHandles == nil { 567 + v.onceHandles = map[*OnceHandle]struct{}{} 568 + } 569 + _, ok = v.onceHandles[h] 570 + return 571 + } 572 + 573 + func (v *contextValue) addScript(s string) { 574 + if v.ss == nil { 575 + v.ss = map[string]struct{}{} 576 + } 577 + v.ss["script_"+s] = struct{}{} 578 + } 579 + 580 + func (v *contextValue) hasScriptBeenRendered(s string) (ok bool) { 581 + if v.ss == nil { 582 + v.ss = map[string]struct{}{} 583 + } 584 + _, ok = v.ss["script_"+s] 585 + return 586 + } 587 + 588 + func (v *contextValue) addClass(s string) { 589 + if v.ss == nil { 590 + v.ss = map[string]struct{}{} 591 + } 592 + v.ss["class_"+s] = struct{}{} 593 + } 594 + 595 + func (v *contextValue) hasClassBeenRendered(s string) (ok bool) { 596 + if v.ss == nil { 597 + v.ss = map[string]struct{}{} 598 + } 599 + _, ok = v.ss["class_"+s] 600 + return 601 + } 602 + 603 + // InitializeContext initializes context used to store internal state used during rendering. 604 + func InitializeContext(ctx context.Context) context.Context { 605 + if _, ok := ctx.Value(contextKey).(*contextValue); ok { 606 + return ctx 607 + } 608 + v := &contextValue{} 609 + ctx = context.WithValue(ctx, contextKey, v) 610 + return ctx 611 + } 612 + 613 + func getContext(ctx context.Context) (context.Context, *contextValue) { 614 + v, ok := ctx.Value(contextKey).(*contextValue) 615 + if !ok { 616 + ctx = InitializeContext(ctx) 617 + v = ctx.Value(contextKey).(*contextValue) 618 + } 619 + return ctx, v 620 + } 621 + 622 + var bufferPool = sync.Pool{ 623 + New: func() any { 624 + return new(bytes.Buffer) 625 + }, 626 + } 627 + 628 + func GetBuffer() *bytes.Buffer { 629 + return bufferPool.Get().(*bytes.Buffer) 630 + } 631 + 632 + func ReleaseBuffer(b *bytes.Buffer) { 633 + b.Reset() 634 + bufferPool.Put(b) 635 + } 636 + 637 + type ints interface { 638 + ~int | ~int8 | ~int16 | ~int32 | ~int64 639 + } 640 + 641 + type uints interface { 642 + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr 643 + } 644 + 645 + type floats interface { 646 + ~float32 | ~float64 647 + } 648 + 649 + type complexNumbers interface { 650 + ~complex64 | ~complex128 651 + } 652 + 653 + type stringable interface { 654 + ints | uints | floats | complexNumbers | ~string | ~bool 655 + } 656 + 657 + // JoinStringErrs joins an optional list of errors. 658 + func JoinStringErrs[T stringable](s T, errs ...error) (string, error) { 659 + return fmt.Sprint(s), errors.Join(errs...) 660 + } 661 + 662 + // Error returned during template rendering. 663 + type Error struct { 664 + Err error 665 + // FileName of the template file. 666 + FileName string 667 + // Line index of the error. 668 + Line int 669 + // Col index of the error. 670 + Col int 671 + } 672 + 673 + func (e Error) Error() string { 674 + if e.FileName == "" { 675 + e.FileName = "templ" 676 + } 677 + return fmt.Sprintf("%s: error at line %d, col %d: %v", e.FileName, e.Line, e.Col, e.Err) 678 + } 679 + 680 + func (e Error) Unwrap() error { 681 + return e.Err 682 + } 683 + 684 + // Raw renders the input HTML to the output without applying HTML escaping. 685 + // 686 + // Use of this component presents a security risk - the HTML should come from 687 + // a trusted source, because it will be included as-is in the output. 688 + func Raw[T ~string](html T, errs ...error) Component { 689 + return ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 690 + if err = errors.Join(errs...); err != nil { 691 + return err 692 + } 693 + _, err = io.WriteString(w, string(html)) 694 + return err 695 + }) 696 + } 697 + 698 + // FromGoHTML creates a templ Component from a Go html/template template. 699 + func FromGoHTML(t *template.Template, data any) Component { 700 + return ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 701 + return t.Execute(w, data) 702 + }) 703 + } 704 + 705 + // ToGoHTML renders the component to a Go html/template template.HTML string. 706 + func ToGoHTML(ctx context.Context, c Component) (s template.HTML, err error) { 707 + b := GetBuffer() 708 + defer ReleaseBuffer(b) 709 + if err = c.Render(ctx, b); err != nil { 710 + return 711 + } 712 + s = template.HTML(b.String()) 713 + return 714 + }
+62
vendor/github.com/a-h/templ/runtime/buffer.go
··· 1 + package runtime 2 + 3 + import ( 4 + "bufio" 5 + "io" 6 + "net/http" 7 + ) 8 + 9 + // DefaultBufferSize is the default size of buffers. It is set to 4KB by default, which is the 10 + // same as the default buffer size of bufio.Writer. 11 + var DefaultBufferSize = 4 * 1024 // 4KB 12 + 13 + // Buffer is a wrapper around bufio.Writer that enables flushing and closing of 14 + // the underlying writer. 15 + type Buffer struct { 16 + Underlying io.Writer 17 + b *bufio.Writer 18 + } 19 + 20 + // Write the contents of p into the buffer. 21 + func (b *Buffer) Write(p []byte) (n int, err error) { 22 + return b.b.Write(p) 23 + } 24 + 25 + // Flush writes any buffered data to the underlying io.Writer and 26 + // calls the Flush method of the underlying http.Flusher if it implements it. 27 + func (b *Buffer) Flush() error { 28 + if err := b.b.Flush(); err != nil { 29 + return err 30 + } 31 + if f, ok := b.Underlying.(http.Flusher); ok { 32 + f.Flush() 33 + } 34 + return nil 35 + } 36 + 37 + // Close closes the buffer and the underlying io.Writer if it implements io.Closer. 38 + func (b *Buffer) Close() error { 39 + if c, ok := b.Underlying.(io.Closer); ok { 40 + return c.Close() 41 + } 42 + return nil 43 + } 44 + 45 + // Reset sets the underlying io.Writer to w and resets the buffer. 46 + func (b *Buffer) Reset(w io.Writer) { 47 + if b.b == nil { 48 + b.b = bufio.NewWriterSize(b, DefaultBufferSize) 49 + } 50 + b.Underlying = w 51 + b.b.Reset(w) 52 + } 53 + 54 + // Size returns the size of the underlying buffer in bytes. 55 + func (b *Buffer) Size() int { 56 + return b.b.Size() 57 + } 58 + 59 + // WriteString writes the contents of s into the buffer. 60 + func (b *Buffer) WriteString(s string) (n int, err error) { 61 + return b.b.WriteString(s) 62 + }
+38
vendor/github.com/a-h/templ/runtime/bufferpool.go
··· 1 + package runtime 2 + 3 + import ( 4 + "io" 5 + "sync" 6 + ) 7 + 8 + var bufferPool = sync.Pool{ 9 + New: func() any { 10 + return new(Buffer) 11 + }, 12 + } 13 + 14 + // GetBuffer creates and returns a new buffer if the writer is not already a buffer, 15 + // or returns the existing buffer if it is. 16 + func GetBuffer(w io.Writer) (b *Buffer, existing bool) { 17 + if w == nil { 18 + return nil, false 19 + } 20 + b, ok := w.(*Buffer) 21 + if ok { 22 + return b, true 23 + } 24 + b = bufferPool.Get().(*Buffer) 25 + b.Reset(w) 26 + return b, false 27 + } 28 + 29 + // ReleaseBuffer flushes the buffer and returns it to the pool. 30 + func ReleaseBuffer(w io.Writer) (err error) { 31 + b, ok := w.(*Buffer) 32 + if !ok { 33 + return nil 34 + } 35 + err = b.Flush() 36 + bufferPool.Put(b) 37 + return err 38 + }
+8
vendor/github.com/a-h/templ/runtime/builder.go
··· 1 + package runtime 2 + 3 + import "strings" 4 + 5 + // GetBuilder returns a strings.Builder. 6 + func GetBuilder() (sb strings.Builder) { 7 + return sb 8 + }
+21
vendor/github.com/a-h/templ/runtime/runtime.go
··· 1 + package runtime 2 + 3 + import ( 4 + "context" 5 + "io" 6 + 7 + "github.com/a-h/templ" 8 + ) 9 + 10 + // GeneratedComponentInput is used to avoid generated code needing to import the `context` and `io` packages. 11 + type GeneratedComponentInput struct { 12 + Context context.Context 13 + Writer io.Writer 14 + } 15 + 16 + // GeneratedTemplate is used to avoid generated code needing to import the `context` and `io` packages. 17 + func GeneratedTemplate(f func(GeneratedComponentInput) error) templ.Component { 18 + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { 19 + return f(GeneratedComponentInput{ctx, w}) 20 + }) 21 + }
+107
vendor/github.com/a-h/templ/runtime/scriptelement.go
··· 1 + package runtime 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "strings" 7 + "unicode/utf8" 8 + ) 9 + 10 + func ScriptContentInsideStringLiteral[T any](v T, errs ...error) (string, error) { 11 + return scriptContent(v, true, errs...) 12 + } 13 + 14 + func ScriptContentOutsideStringLiteral[T any](v T, errs ...error) (string, error) { 15 + return scriptContent(v, false, errs...) 16 + } 17 + 18 + func scriptContent[T any](v T, insideStringLiteral bool, errs ...error) (string, error) { 19 + if errors.Join(errs...) != nil { 20 + return "", errors.Join(errs...) 21 + } 22 + if vs, ok := any(v).(string); ok && insideStringLiteral { 23 + return replace(vs, jsStrReplacementTable), nil 24 + } 25 + jd, err := json.Marshal(v) 26 + if err != nil { 27 + return "", err 28 + } 29 + if insideStringLiteral { 30 + return replace(string(jd), jsStrReplacementTable), nil 31 + } 32 + return string(jd), nil 33 + } 34 + 35 + // See https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/html/template/js.go 36 + 37 + // replace replaces each rune r of s with replacementTable[r], provided that 38 + // r < len(replacementTable). If replacementTable[r] is the empty string then 39 + // no replacement is made. 40 + // It also replaces runes U+2028 and U+2029 with the raw strings `\u2028` and 41 + // `\u2029`. 42 + func replace(s string, replacementTable []string) string { 43 + var b strings.Builder 44 + r, w, written := rune(0), 0, 0 45 + for i := 0; i < len(s); i += w { 46 + // See comment in htmlEscaper. 47 + r, w = utf8.DecodeRuneInString(s[i:]) 48 + var repl string 49 + switch { 50 + case int(r) < len(lowUnicodeReplacementTable): 51 + repl = lowUnicodeReplacementTable[r] 52 + case int(r) < len(replacementTable) && replacementTable[r] != "": 53 + repl = replacementTable[r] 54 + case r == '\u2028': 55 + repl = `\u2028` 56 + case r == '\u2029': 57 + repl = `\u2029` 58 + default: 59 + continue 60 + } 61 + if written == 0 { 62 + b.Grow(len(s)) 63 + } 64 + b.WriteString(s[written:i]) 65 + b.WriteString(repl) 66 + written = i + w 67 + } 68 + if written == 0 { 69 + return s 70 + } 71 + b.WriteString(s[written:]) 72 + return b.String() 73 + } 74 + 75 + var lowUnicodeReplacementTable = []string{ 76 + 0: `\u0000`, 1: `\u0001`, 2: `\u0002`, 3: `\u0003`, 4: `\u0004`, 5: `\u0005`, 6: `\u0006`, 77 + '\a': `\u0007`, 78 + '\b': `\u0008`, 79 + '\t': `\t`, 80 + '\n': `\n`, 81 + '\v': `\u000b`, // "\v" == "v" on IE 6. 82 + '\f': `\f`, 83 + '\r': `\r`, 84 + 0xe: `\u000e`, 0xf: `\u000f`, 0x10: `\u0010`, 0x11: `\u0011`, 0x12: `\u0012`, 0x13: `\u0013`, 85 + 0x14: `\u0014`, 0x15: `\u0015`, 0x16: `\u0016`, 0x17: `\u0017`, 0x18: `\u0018`, 0x19: `\u0019`, 86 + 0x1a: `\u001a`, 0x1b: `\u001b`, 0x1c: `\u001c`, 0x1d: `\u001d`, 0x1e: `\u001e`, 0x1f: `\u001f`, 87 + } 88 + 89 + var jsStrReplacementTable = []string{ 90 + 0: `\u0000`, 91 + '\t': `\t`, 92 + '\n': `\n`, 93 + '\v': `\u000b`, // "\v" == "v" on IE 6. 94 + '\f': `\f`, 95 + '\r': `\r`, 96 + // Encode HTML specials as hex so the output can be embedded 97 + // in HTML attributes without further encoding. 98 + '"': `\u0022`, 99 + '`': `\u0060`, 100 + '&': `\u0026`, 101 + '\'': `\u0027`, 102 + '+': `\u002b`, 103 + '/': `\/`, 104 + '<': `\u003c`, 105 + '>': `\u003e`, 106 + '\\': `\\`, 107 + }
+217
vendor/github.com/a-h/templ/runtime/styleattribute.go
··· 1 + package runtime 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "html" 7 + "maps" 8 + "reflect" 9 + "slices" 10 + "strings" 11 + 12 + "github.com/a-h/templ" 13 + "github.com/a-h/templ/safehtml" 14 + ) 15 + 16 + // SanitizeStyleAttributeValues renders a style attribute value. 17 + // The supported types are: 18 + // - string 19 + // - templ.SafeCSS 20 + // - map[string]string 21 + // - map[string]templ.SafeCSSProperty 22 + // - templ.KeyValue[string, string] - A map of key/values where the key is the CSS property name and the value is the CSS property value. 23 + // - templ.KeyValue[string, templ.SafeCSSProperty] - A map of key/values where the key is the CSS property name and the value is the CSS property value. 24 + // - templ.KeyValue[string, bool] - The bool determines whether the value should be included. 25 + // - templ.KeyValue[templ.SafeCSS, bool] - The bool determines whether the value should be included. 26 + // - func() (anyOfTheAboveTypes) 27 + // - func() (anyOfTheAboveTypes, error) 28 + // - []anyOfTheAboveTypes 29 + // 30 + // In the above, templ.SafeCSS and templ.SafeCSSProperty are types that are used to indicate that the value is safe to render as CSS without sanitization. 31 + // All other types are sanitized before rendering. 32 + // 33 + // If an error is returned by any function, or a non-nil error is included in the input, the error is returned. 34 + func SanitizeStyleAttributeValues(values ...any) (string, error) { 35 + if err := getJoinedErrorsFromValues(values...); err != nil { 36 + return "", err 37 + } 38 + sb := new(strings.Builder) 39 + for _, v := range values { 40 + if v == nil { 41 + continue 42 + } 43 + if err := sanitizeStyleAttributeValue(sb, v); err != nil { 44 + return "", err 45 + } 46 + } 47 + return sb.String(), nil 48 + } 49 + 50 + func sanitizeStyleAttributeValue(sb *strings.Builder, v any) error { 51 + // Process concrete types. 52 + switch v := v.(type) { 53 + case string: 54 + return processString(sb, v) 55 + 56 + case templ.SafeCSS: 57 + return processSafeCSS(sb, v) 58 + 59 + case map[string]string: 60 + return processStringMap(sb, v) 61 + 62 + case map[string]templ.SafeCSSProperty: 63 + return processSafeCSSPropertyMap(sb, v) 64 + 65 + case templ.KeyValue[string, string]: 66 + return processStringKV(sb, v) 67 + 68 + case templ.KeyValue[string, bool]: 69 + if v.Value { 70 + return processString(sb, v.Key) 71 + } 72 + return nil 73 + 74 + case templ.KeyValue[templ.SafeCSS, bool]: 75 + if v.Value { 76 + return processSafeCSS(sb, v.Key) 77 + } 78 + return nil 79 + } 80 + 81 + // Fall back to reflection. 82 + 83 + // Handle functions first using reflection. 84 + if handled, err := handleFuncWithReflection(sb, v); handled { 85 + return err 86 + } 87 + 88 + // Handle slices using reflection before concrete types. 89 + if handled, err := handleSliceWithReflection(sb, v); handled { 90 + return err 91 + } 92 + 93 + _, err := sb.WriteString(TemplUnsupportedStyleAttributeValue) 94 + return err 95 + } 96 + 97 + func processSafeCSS(sb *strings.Builder, v templ.SafeCSS) error { 98 + if v == "" { 99 + return nil 100 + } 101 + sb.WriteString(html.EscapeString(string(v))) 102 + if !strings.HasSuffix(string(v), ";") { 103 + sb.WriteRune(';') 104 + } 105 + return nil 106 + } 107 + 108 + func processString(sb *strings.Builder, v string) error { 109 + if v == "" { 110 + return nil 111 + } 112 + sanitized := strings.TrimSpace(safehtml.SanitizeStyleValue(v)) 113 + sb.WriteString(html.EscapeString(sanitized)) 114 + if !strings.HasSuffix(sanitized, ";") { 115 + sb.WriteRune(';') 116 + } 117 + return nil 118 + } 119 + 120 + var ErrInvalidStyleAttributeFunctionSignature = errors.New("invalid function signature, should be in the form func() (string, error)") 121 + 122 + // handleFuncWithReflection handles functions using reflection. 123 + func handleFuncWithReflection(sb *strings.Builder, v any) (bool, error) { 124 + rv := reflect.ValueOf(v) 125 + if rv.Kind() != reflect.Func { 126 + return false, nil 127 + } 128 + 129 + t := rv.Type() 130 + if t.NumIn() != 0 || (t.NumOut() != 1 && t.NumOut() != 2) { 131 + return false, ErrInvalidStyleAttributeFunctionSignature 132 + } 133 + 134 + // Check the types of the return values 135 + if t.NumOut() == 2 { 136 + // Ensure the second return value is of type `error` 137 + secondReturnType := t.Out(1) 138 + if !secondReturnType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { 139 + return false, fmt.Errorf("second return value must be of type error, got %v", secondReturnType) 140 + } 141 + } 142 + 143 + results := rv.Call(nil) 144 + 145 + if t.NumOut() == 2 { 146 + // Check if the second return value is an error 147 + if errVal := results[1].Interface(); errVal != nil { 148 + if err, ok := errVal.(error); ok && err != nil { 149 + return true, err 150 + } 151 + } 152 + } 153 + 154 + return true, sanitizeStyleAttributeValue(sb, results[0].Interface()) 155 + } 156 + 157 + // handleSliceWithReflection handles slices using reflection. 158 + func handleSliceWithReflection(sb *strings.Builder, v any) (bool, error) { 159 + rv := reflect.ValueOf(v) 160 + if rv.Kind() != reflect.Slice { 161 + return false, nil 162 + } 163 + for i := range rv.Len() { 164 + elem := rv.Index(i).Interface() 165 + if err := sanitizeStyleAttributeValue(sb, elem); err != nil { 166 + return true, err 167 + } 168 + } 169 + return true, nil 170 + } 171 + 172 + // processStringMap processes a map[string]string. 173 + func processStringMap(sb *strings.Builder, m map[string]string) error { 174 + for _, name := range slices.Sorted(maps.Keys(m)) { 175 + name, value := safehtml.SanitizeCSS(name, m[name]) 176 + sb.WriteString(html.EscapeString(name)) 177 + sb.WriteRune(':') 178 + sb.WriteString(html.EscapeString(value)) 179 + sb.WriteRune(';') 180 + } 181 + return nil 182 + } 183 + 184 + // processSafeCSSPropertyMap processes a map[string]templ.SafeCSSProperty. 185 + func processSafeCSSPropertyMap(sb *strings.Builder, m map[string]templ.SafeCSSProperty) error { 186 + for _, name := range slices.Sorted(maps.Keys(m)) { 187 + sb.WriteString(html.EscapeString(safehtml.SanitizeCSSProperty(name))) 188 + sb.WriteRune(':') 189 + sb.WriteString(html.EscapeString(string(m[name]))) 190 + sb.WriteRune(';') 191 + } 192 + return nil 193 + } 194 + 195 + // processStringKV processes a templ.KeyValue[string, string]. 196 + func processStringKV(sb *strings.Builder, kv templ.KeyValue[string, string]) error { 197 + name, value := safehtml.SanitizeCSS(kv.Key, kv.Value) 198 + sb.WriteString(html.EscapeString(name)) 199 + sb.WriteRune(':') 200 + sb.WriteString(html.EscapeString(value)) 201 + sb.WriteRune(';') 202 + return nil 203 + } 204 + 205 + // getJoinedErrorsFromValues collects and joins errors from the input values. 206 + func getJoinedErrorsFromValues(values ...any) error { 207 + var errs []error 208 + for _, v := range values { 209 + if err, ok := v.(error); ok { 210 + errs = append(errs, err) 211 + } 212 + } 213 + return errors.Join(errs...) 214 + } 215 + 216 + // TemplUnsupportedStyleAttributeValue is the default value returned for unsupported types. 217 + var TemplUnsupportedStyleAttributeValue = "zTemplUnsupportedStyleAttributeValue:Invalid;"
+181
vendor/github.com/a-h/templ/runtime/watchmode.go
··· 1 + package runtime 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "os" 10 + "path/filepath" 11 + "runtime" 12 + "strconv" 13 + "strings" 14 + "sync" 15 + "time" 16 + ) 17 + 18 + var developmentMode = os.Getenv("TEMPL_DEV_MODE") == "true" 19 + 20 + var stringLoaderOnce = sync.OnceValue(func() *StringLoader { 21 + return NewStringLoader(os.Getenv("TEMPL_DEV_MODE_WATCH_ROOT")) 22 + }) 23 + 24 + // WriteString writes the string to the writer. If development mode is enabled 25 + // s is replaced with the string at the index in the _templ.txt file. 26 + func WriteString(w io.Writer, index int, s string) (err error) { 27 + if developmentMode { 28 + _, path, _, _ := runtime.Caller(1) 29 + if !strings.HasSuffix(path, "_templ.go") { 30 + return errors.New("templ: attempt to use WriteString from a non templ file") 31 + } 32 + s, err = stringLoaderOnce().GetWatchedString(path, index, s) 33 + if err != nil { 34 + return fmt.Errorf("templ: failed to get watched string: %w", err) 35 + } 36 + } 37 + _, err = io.WriteString(w, s) 38 + return err 39 + } 40 + 41 + func GetDevModeTextFileName(templFileName string) string { 42 + if prefix, ok := strings.CutSuffix(templFileName, "_templ.go"); ok { 43 + templFileName = prefix + ".templ" 44 + } 45 + absFileName, err := filepath.Abs(templFileName) 46 + if err != nil { 47 + absFileName = templFileName 48 + } 49 + absFileName, err = filepath.EvalSymlinks(absFileName) 50 + if err != nil { 51 + absFileName = templFileName 52 + } 53 + absFileName = normalizePath(absFileName) 54 + 55 + hashedFileName := sha256.Sum256([]byte(absFileName)) 56 + outputFileName := fmt.Sprintf("templ_%s.txt", hex.EncodeToString(hashedFileName[:])) 57 + 58 + root := os.TempDir() 59 + if os.Getenv("TEMPL_DEV_MODE_ROOT") != "" { 60 + root = os.Getenv("TEMPL_DEV_MODE_ROOT") 61 + } 62 + 63 + return filepath.Join(root, outputFileName) 64 + } 65 + 66 + // normalizePath converts Windows paths to Unix style paths. 67 + func normalizePath(p string) string { 68 + p = strings.ReplaceAll(filepath.Clean(p), `\`, `/`) 69 + parts := strings.SplitN(p, ":", 2) 70 + if len(parts) == 2 && len(parts[0]) == 1 { 71 + drive := strings.ToLower(parts[0]) 72 + p = "/" + drive + parts[1] 73 + } 74 + return p 75 + } 76 + 77 + type watchState struct { 78 + modTime time.Time 79 + strings []string 80 + } 81 + 82 + type StringLoader struct { 83 + watchModeRoot string 84 + watchModeRootErr error 85 + cache map[string]watchState 86 + cacheMutex sync.Mutex 87 + } 88 + 89 + func NewStringLoader(devModeWatchRootPath string) (sl *StringLoader) { 90 + sl = &StringLoader{ 91 + cache: make(map[string]watchState), 92 + } 93 + if devModeWatchRootPath == "" { 94 + return sl 95 + } 96 + resolvedRoot, err := filepath.EvalSymlinks(devModeWatchRootPath) 97 + if err != nil { 98 + sl.watchModeRootErr = fmt.Errorf("templ: failed to eval symlinks for watch mode root %q: %w", devModeWatchRootPath, err) 99 + return sl 100 + } 101 + sl.watchModeRoot = resolvedRoot 102 + return sl 103 + } 104 + 105 + func (sl *StringLoader) GetWatchedString(templFilePath string, index int, defaultValue string) (string, error) { 106 + if sl.watchModeRootErr != nil { 107 + return "", sl.watchModeRootErr 108 + } 109 + path, err := filepath.EvalSymlinks(templFilePath) 110 + if err != nil { 111 + return "", fmt.Errorf("templ: failed to eval symlinks for %q: %w", path, err) 112 + } 113 + // If the file is outside the watch mode root, write the string directly. 114 + // If watch mode root is not set, fall back to the previous behaviour to avoid breaking existing setups. 115 + if sl.watchModeRoot != "" && !strings.HasPrefix(path, sl.watchModeRoot) { 116 + return defaultValue, nil 117 + } 118 + 119 + txtFilePath := GetDevModeTextFileName(path) 120 + literals, err := sl.getWatchedStrings(txtFilePath) 121 + if err != nil { 122 + return "", fmt.Errorf("templ: failed to get watched strings for %q: %w", path, err) 123 + } 124 + if index > len(literals) { 125 + return "", fmt.Errorf("templ: failed to find line %d in %s", index, txtFilePath) 126 + } 127 + return strconv.Unquote(`"` + literals[index-1] + `"`) 128 + } 129 + 130 + func (sl *StringLoader) getWatchedStrings(txtFilePath string) ([]string, error) { 131 + sl.cacheMutex.Lock() 132 + defer sl.cacheMutex.Unlock() 133 + 134 + state, cached := sl.cache[txtFilePath] 135 + if !cached { 136 + return sl.cacheStrings(txtFilePath) 137 + } 138 + 139 + if time.Since(state.modTime) < time.Millisecond*100 { 140 + return state.strings, nil 141 + } 142 + 143 + info, err := os.Stat(txtFilePath) 144 + if err != nil { 145 + return nil, fmt.Errorf("templ: failed to stat %s: %w", txtFilePath, err) 146 + } 147 + 148 + if !info.ModTime().After(state.modTime) { 149 + return state.strings, nil 150 + } 151 + 152 + return sl.cacheStrings(txtFilePath) 153 + } 154 + 155 + func (sl *StringLoader) cacheStrings(txtFilePath string) ([]string, error) { 156 + txtFile, err := os.Open(txtFilePath) 157 + if err != nil { 158 + return nil, fmt.Errorf("templ: failed to open %s: %w", txtFilePath, err) 159 + } 160 + defer func() { 161 + _ = txtFile.Close() 162 + }() 163 + 164 + info, err := txtFile.Stat() 165 + if err != nil { 166 + return nil, fmt.Errorf("templ: failed to stat %s: %w", txtFilePath, err) 167 + } 168 + 169 + all, err := io.ReadAll(txtFile) 170 + if err != nil { 171 + return nil, fmt.Errorf("templ: failed to read %s: %w", txtFilePath, err) 172 + } 173 + 174 + literals := strings.Split(string(all), "\n") 175 + sl.cache[txtFilePath] = watchState{ 176 + modTime: info.ModTime(), 177 + strings: literals, 178 + } 179 + 180 + return literals, nil 181 + }
+199
vendor/github.com/a-h/templ/safehtml/style.go
··· 1 + // Adapted from https://raw.githubusercontent.com/google/safehtml/3c4cd5b5d8c9a6c5882fba099979e9f50b65c876/style.go 2 + 3 + // Copyright (c) 2017 The Go Authors. All rights reserved. 4 + // 5 + // Use of this source code is governed by a BSD-style 6 + // license that can be found in the LICENSE file or at 7 + // https://developers.google.com/open-source/licenses/bsd 8 + 9 + package safehtml 10 + 11 + import ( 12 + "bytes" 13 + "fmt" 14 + "net/url" 15 + "regexp" 16 + "strings" 17 + ) 18 + 19 + // SanitizeCSS attempts to sanitize CSS properties. 20 + func SanitizeCSS(property, value string) (string, string) { 21 + property = SanitizeCSSProperty(property) 22 + if property == InnocuousPropertyName { 23 + return InnocuousPropertyName, InnocuousPropertyValue 24 + } 25 + return property, SanitizeCSSValue(property, value) 26 + } 27 + 28 + func SanitizeCSSValue(property, value string) string { 29 + if sanitizer, ok := cssPropertyNameToValueSanitizer[property]; ok { 30 + return sanitizer(value) 31 + } 32 + return sanitizeRegular(value) 33 + } 34 + 35 + func SanitizeCSSProperty(property string) string { 36 + if !identifierPattern.MatchString(property) { 37 + return InnocuousPropertyName 38 + } 39 + return strings.ToLower(property) 40 + } 41 + 42 + // identifierPattern matches a subset of valid <ident-token> values defined in 43 + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram. This pattern matches all generic family name 44 + // keywords defined in https://drafts.csswg.org/css-fonts-3/#family-name-value. 45 + var identifierPattern = regexp.MustCompile(`^[-a-zA-Z]+$`) 46 + 47 + var cssPropertyNameToValueSanitizer = map[string]func(string) string{ 48 + "background-image": sanitizeBackgroundImage, 49 + "font-family": sanitizeFontFamily, 50 + "display": sanitizeEnum, 51 + "background-color": sanitizeRegular, 52 + "background-position": sanitizeRegular, 53 + "background-repeat": sanitizeRegular, 54 + "background-size": sanitizeRegular, 55 + "color": sanitizeRegular, 56 + "height": sanitizeRegular, 57 + "width": sanitizeRegular, 58 + "left": sanitizeRegular, 59 + "right": sanitizeRegular, 60 + "top": sanitizeRegular, 61 + "bottom": sanitizeRegular, 62 + "font-weight": sanitizeRegular, 63 + "padding": sanitizeRegular, 64 + "z-index": sanitizeRegular, 65 + } 66 + 67 + var validURLPrefixes = []string{ 68 + `url("`, 69 + `url('`, 70 + `url(`, 71 + } 72 + 73 + var validURLSuffixes = []string{ 74 + `")`, 75 + `')`, 76 + `)`, 77 + } 78 + 79 + func sanitizeBackgroundImage(v string) string { 80 + // Check for <> as per https://github.com/google/safehtml/blob/be23134998433fcf0135dda53593fc8f8bf4df7c/style.go#L87C2-L89C3 81 + if strings.ContainsAny(v, "<>") { 82 + return InnocuousPropertyValue 83 + } 84 + for _, u := range strings.Split(v, ",") { 85 + u = strings.TrimSpace(u) 86 + var found bool 87 + for i, prefix := range validURLPrefixes { 88 + if strings.HasPrefix(u, prefix) && strings.HasSuffix(u, validURLSuffixes[i]) { 89 + found = true 90 + u = strings.TrimPrefix(u, validURLPrefixes[i]) 91 + u = strings.TrimSuffix(u, validURLSuffixes[i]) 92 + break 93 + } 94 + } 95 + if !found || !urlIsSafe(u) { 96 + return InnocuousPropertyValue 97 + } 98 + } 99 + return v 100 + } 101 + 102 + func urlIsSafe(s string) bool { 103 + u, err := url.Parse(s) 104 + if err != nil { 105 + return false 106 + } 107 + if u.IsAbs() { 108 + if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") || strings.EqualFold(u.Scheme, "mailto") { 109 + return true 110 + } 111 + return false 112 + } 113 + return true 114 + } 115 + 116 + var genericFontFamilyName = regexp.MustCompile(`^[a-zA-Z][- a-zA-Z]+$`) 117 + 118 + func sanitizeFontFamily(s string) string { 119 + for _, f := range strings.Split(s, ",") { 120 + f = strings.TrimSpace(f) 121 + if strings.HasPrefix(f, `"`) { 122 + if !strings.HasSuffix(f, `"`) { 123 + return InnocuousPropertyValue 124 + } 125 + continue 126 + } 127 + if !genericFontFamilyName.MatchString(f) { 128 + return InnocuousPropertyValue 129 + } 130 + } 131 + return s 132 + } 133 + 134 + func sanitizeEnum(s string) string { 135 + if !safeEnumPropertyValuePattern.MatchString(s) { 136 + return InnocuousPropertyValue 137 + } 138 + return s 139 + } 140 + 141 + func sanitizeRegular(s string) string { 142 + if !safeRegularPropertyValuePattern.MatchString(s) { 143 + return InnocuousPropertyValue 144 + } 145 + return s 146 + } 147 + 148 + // InnocuousPropertyName is an innocuous property generated by a sanitizer when its input is unsafe. 149 + const InnocuousPropertyName = "zTemplUnsafeCSSPropertyName" 150 + 151 + // InnocuousPropertyValue is an innocuous property generated by a sanitizer when its input is unsafe. 152 + const InnocuousPropertyValue = "zTemplUnsafeCSSPropertyValue" 153 + 154 + // safeRegularPropertyValuePattern matches strings that are safe to use as property values. 155 + // Specifically, it matches string where every '*' or '/' is followed by end-of-text or a safe rune 156 + // (i.e. alphanumerics or runes in the set [+-.!#%_ \t]). This regex ensures that the following 157 + // are disallowed: 158 + // - "/*" and "*/", which are CSS comment markers. 159 + // - "//", even though this is not a comment marker in the CSS specification. Disallowing 160 + // this string minimizes the chance that browser peculiarities or parsing bugs will allow 161 + // sanitization to be bypassed. 162 + // - '(' and ')', which can be used to call functions. 163 + // - ',', since it can be used to inject extra values into a property. 164 + // - Runes which could be matched on CSS error recovery of a previously malformed token, such as '@' 165 + // and ':'. See http://www.w3.org/TR/css3-syntax/#error-handling. 166 + var safeRegularPropertyValuePattern = regexp.MustCompile(`^(?:[*/]?(?:[0-9a-zA-Z+-.!#%_ \t]|$))*$`) 167 + 168 + // safeEnumPropertyValuePattern matches strings that are safe to use as enumerated property values. 169 + // Specifically, it matches strings that contain only alphabetic and '-' runes. 170 + var safeEnumPropertyValuePattern = regexp.MustCompile(`^[a-zA-Z-]*$`) 171 + 172 + // SanitizeStyleValue escapes s so that it is safe to put between "" to form a CSS <string-token>. 173 + // See syntax at https://www.w3.org/TR/css-syntax-3/#string-token-diagram. 174 + // 175 + // On top of the escape sequences required in <string-token>, this function also escapes 176 + // control runes to minimize the risk of these runes triggering browser-specific bugs. 177 + // Taken from cssEscapeString in safehtml package. 178 + func SanitizeStyleValue(s string) string { 179 + var b bytes.Buffer 180 + b.Grow(len(s)) 181 + for _, c := range s { 182 + switch { 183 + case c == '\u0000': 184 + // Replace the NULL byte according to https://www.w3.org/TR/css-syntax-3/#input-preprocessing. 185 + // We take this extra precaution in case the user agent fails to handle NULL properly. 186 + b.WriteString("\uFFFD") 187 + case c == '<', // Prevents breaking out of a style element with `</style>`. Escape this in case the Style user forgets to. 188 + c == '"', c == '\\', // Must be CSS-escaped in <string-token>. U+000A line feed is handled in the next case. 189 + c <= '\u001F', c == '\u007F', // C0 control codes 190 + c >= '\u0080' && c <= '\u009F', // C1 control codes 191 + c == '\u2028', c == '\u2029': // Unicode newline characters 192 + // See CSS escape sequence syntax at https://www.w3.org/TR/css-syntax-3/#escape-diagram. 193 + fmt.Fprintf(&b, "\\%06X", c) 194 + default: 195 + b.WriteRune(c) 196 + } 197 + } 198 + return b.String() 199 + }
+151
vendor/github.com/a-h/templ/scripttemplate.go
··· 1 + package templ 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "html" 8 + "io" 9 + "regexp" 10 + "strings" 11 + ) 12 + 13 + // ComponentScript is a templ Script template. 14 + type ComponentScript struct { 15 + // Name of the script, e.g. print. 16 + Name string 17 + // Function to render. 18 + Function string 19 + // Call of the function in JavaScript syntax, including parameters, and 20 + // ensures parameters are HTML escaped; useful for injecting into HTML 21 + // attributes like onclick, onhover, etc. 22 + // 23 + // Given: 24 + // functionName("some string",12345) 25 + // It would render: 26 + // __templ_functionName_sha(&#34;some string&#34;,12345)) 27 + // 28 + // This is can be injected into HTML attributes: 29 + // <button onClick="__templ_functionName_sha(&#34;some string&#34;,12345))">Click Me</button> 30 + Call string 31 + // Call of the function in JavaScript syntax, including parameters. It 32 + // does not HTML escape parameters; useful for directly calling in script 33 + // elements. 34 + // 35 + // Given: 36 + // functionName("some string",12345) 37 + // It would render: 38 + // __templ_functionName_sha("some string",12345)) 39 + // 40 + // This is can be used to call the function inside a script tag: 41 + // <script>__templ_functionName_sha("some string",12345))</script> 42 + CallInline string 43 + } 44 + 45 + var _ Component = ComponentScript{} 46 + 47 + func writeScriptHeader(ctx context.Context, w io.Writer) (err error) { 48 + var nonceAttr string 49 + if nonce := GetNonce(ctx); nonce != "" { 50 + nonceAttr = " nonce=\"" + EscapeString(nonce) + "\"" 51 + } 52 + _, err = fmt.Fprintf(w, `<script%s>`, nonceAttr) 53 + return err 54 + } 55 + 56 + func (c ComponentScript) Render(ctx context.Context, w io.Writer) error { 57 + err := RenderScriptItems(ctx, w, c) 58 + if err != nil { 59 + return err 60 + } 61 + if len(c.Call) > 0 { 62 + if err = writeScriptHeader(ctx, w); err != nil { 63 + return err 64 + } 65 + if _, err = io.WriteString(w, c.CallInline); err != nil { 66 + return err 67 + } 68 + if _, err = io.WriteString(w, `</script>`); err != nil { 69 + return err 70 + } 71 + } 72 + return nil 73 + } 74 + 75 + // RenderScriptItems renders a <script> element, if the script has not already been rendered. 76 + func RenderScriptItems(ctx context.Context, w io.Writer, scripts ...ComponentScript) (err error) { 77 + if len(scripts) == 0 { 78 + return nil 79 + } 80 + _, v := getContext(ctx) 81 + sb := new(strings.Builder) 82 + for _, s := range scripts { 83 + if !v.hasScriptBeenRendered(s.Name) { 84 + sb.WriteString(s.Function) 85 + v.addScript(s.Name) 86 + } 87 + } 88 + if sb.Len() > 0 { 89 + if err = writeScriptHeader(ctx, w); err != nil { 90 + return err 91 + } 92 + if _, err = io.WriteString(w, sb.String()); err != nil { 93 + return err 94 + } 95 + if _, err = io.WriteString(w, `</script>`); err != nil { 96 + return err 97 + } 98 + } 99 + return nil 100 + } 101 + 102 + // JSExpression represents a JavaScript expression intended for use as an argument for script templates. 103 + // The string value of JSExpression will be inserted directly as JavaScript code in function call arguments. 104 + type JSExpression string 105 + 106 + // SafeScript encodes unknown parameters for safety for inside HTML attributes. 107 + func SafeScript(functionName string, params ...any) string { 108 + if !jsFunctionName.MatchString(functionName) { 109 + functionName = "__templ_invalid_js_function_name" 110 + } 111 + sb := new(strings.Builder) 112 + sb.WriteString(html.EscapeString(functionName)) 113 + sb.WriteRune('(') 114 + for i, p := range params { 115 + sb.WriteString(EscapeString(jsonEncodeParam(p))) 116 + if i < len(params)-1 { 117 + sb.WriteRune(',') 118 + } 119 + } 120 + sb.WriteRune(')') 121 + return sb.String() 122 + } 123 + 124 + // SafeScript encodes unknown parameters for safety for inline scripts. 125 + func SafeScriptInline(functionName string, params ...any) string { 126 + if !jsFunctionName.MatchString(functionName) { 127 + functionName = "__templ_invalid_js_function_name" 128 + } 129 + sb := new(strings.Builder) 130 + sb.WriteString(functionName) 131 + sb.WriteRune('(') 132 + for i, p := range params { 133 + sb.WriteString(jsonEncodeParam(p)) 134 + if i < len(params)-1 { 135 + sb.WriteRune(',') 136 + } 137 + } 138 + sb.WriteRune(')') 139 + return sb.String() 140 + } 141 + 142 + func jsonEncodeParam(param any) string { 143 + if val, ok := param.(JSExpression); ok { 144 + return string(val) 145 + } 146 + enc, _ := json.Marshal(param) 147 + return string(enc) 148 + } 149 + 150 + // isValidJSFunctionName returns true if the given string is a valid JavaScript function name, e.g. console.log, alert, etc. 151 + var jsFunctionName = regexp.MustCompile(`^([$_a-zA-Z][$_a-zA-Z0-9]+\.?)+$`)
vendor/github.com/a-h/templ/templ.png

This is a binary file and will not be displayed.

+31
vendor/github.com/a-h/templ/url.go
··· 1 + package templ 2 + 3 + import ( 4 + "errors" 5 + "strings" 6 + ) 7 + 8 + // FailedSanitizationURL is returned if a URL fails sanitization checks. 9 + const FailedSanitizationURL = SafeURL("about:invalid#TemplFailedSanitizationURL") 10 + 11 + // URL sanitizes the input string s and returns a SafeURL. 12 + func URL(s string) SafeURL { 13 + if i := strings.IndexRune(s, ':'); i >= 0 && !strings.ContainsRune(s[:i], '/') { 14 + protocol := s[:i] 15 + if !strings.EqualFold(protocol, "http") && !strings.EqualFold(protocol, "https") && !strings.EqualFold(protocol, "mailto") && !strings.EqualFold(protocol, "tel") && !strings.EqualFold(protocol, "ftp") && !strings.EqualFold(protocol, "ftps") { 16 + return FailedSanitizationURL 17 + } 18 + } 19 + return SafeURL(s) 20 + } 21 + 22 + // SafeURL is a URL that has been sanitized. 23 + type SafeURL string 24 + 25 + // JoinURLErrs joins an optional list of errors and returns a sanitized SafeURL. 26 + func JoinURLErrs[T ~string](s T, errs ...error) (SafeURL, error) { 27 + if safeURL, ok := any(s).(SafeURL); ok { 28 + return safeURL, errors.Join(errs...) 29 + } 30 + return URL(string(s)), errors.Join(errs...) 31 + }
+10
vendor/github.com/a-h/templ/version.go
··· 1 + package templ 2 + 3 + import _ "embed" 4 + 5 + //go:embed .version 6 + var version string 7 + 8 + func Version() string { 9 + return "v" + version 10 + }
+105
vendor/github.com/a-h/templ/watchmode.go
··· 1 + package templ 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io" 7 + "os" 8 + "runtime" 9 + "strconv" 10 + "strings" 11 + "sync" 12 + "time" 13 + ) 14 + 15 + // WriteWatchModeString is used when rendering templates in development mode. 16 + // the generator would have written non-go code to the _templ.txt file, which 17 + // is then read by this function and written to the output. 18 + // 19 + // Deprecated: since templ v0.3.x generated code uses WriteString. 20 + func WriteWatchModeString(w io.Writer, lineNum int) error { 21 + _, path, _, _ := runtime.Caller(1) 22 + if !strings.HasSuffix(path, "_templ.go") { 23 + return errors.New("templ: WriteWatchModeString can only be called from _templ.go") 24 + } 25 + txtFilePath := strings.Replace(path, "_templ.go", "_templ.txt", 1) 26 + 27 + literals, err := getWatchedStrings(txtFilePath) 28 + if err != nil { 29 + return fmt.Errorf("templ: failed to cache strings: %w", err) 30 + } 31 + 32 + if lineNum > len(literals) { 33 + return fmt.Errorf("templ: failed to find line %d in %s", lineNum, txtFilePath) 34 + } 35 + 36 + s, err := strconv.Unquote(`"` + literals[lineNum-1] + `"`) 37 + if err != nil { 38 + return err 39 + } 40 + _, err = io.WriteString(w, s) 41 + return err 42 + } 43 + 44 + var ( 45 + watchModeCache = map[string]watchState{} 46 + watchStateMutex sync.Mutex 47 + ) 48 + 49 + type watchState struct { 50 + modTime time.Time 51 + strings []string 52 + } 53 + 54 + func getWatchedStrings(txtFilePath string) ([]string, error) { 55 + watchStateMutex.Lock() 56 + defer watchStateMutex.Unlock() 57 + 58 + state, cached := watchModeCache[txtFilePath] 59 + if !cached { 60 + return cacheStrings(txtFilePath) 61 + } 62 + 63 + if time.Since(state.modTime) < time.Millisecond*100 { 64 + return state.strings, nil 65 + } 66 + 67 + info, err := os.Stat(txtFilePath) 68 + if err != nil { 69 + return nil, fmt.Errorf("templ: failed to stat %s: %w", txtFilePath, err) 70 + } 71 + 72 + if !info.ModTime().After(state.modTime) { 73 + return state.strings, nil 74 + } 75 + 76 + return cacheStrings(txtFilePath) 77 + } 78 + 79 + func cacheStrings(txtFilePath string) ([]string, error) { 80 + txtFile, err := os.Open(txtFilePath) 81 + if err != nil { 82 + return nil, fmt.Errorf("templ: failed to open %s: %w", txtFilePath, err) 83 + } 84 + defer func() { 85 + _ = txtFile.Close() 86 + }() 87 + 88 + info, err := txtFile.Stat() 89 + if err != nil { 90 + return nil, fmt.Errorf("templ: failed to stat %s: %w", txtFilePath, err) 91 + } 92 + 93 + all, err := io.ReadAll(txtFile) 94 + if err != nil { 95 + return nil, fmt.Errorf("templ: failed to read %s: %w", txtFilePath, err) 96 + } 97 + 98 + literals := strings.Split(string(all), "\n") 99 + watchModeCache[txtFilePath] = watchState{ 100 + modTime: info.ModTime(), 101 + strings: literals, 102 + } 103 + 104 + return literals, nil 105 + }
+5
vendor/modules.txt
··· 1 + # github.com/a-h/templ v0.3.1001 2 + ## explicit; go 1.23.0 3 + github.com/a-h/templ 4 + github.com/a-h/templ/runtime 5 + github.com/a-h/templ/safehtml 1 6 # github.com/beorn7/perks v1.0.1 2 7 ## explicit; go 1.11 3 8 github.com/beorn7/perks/quantile