This repository has no description
0

Configure Feed

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

core / appview / pages / pages.go
59 kB 2350 lines
1package pages 2 3import ( 4 "context" 5 "crypto/sha256" 6 "embed" 7 "encoding/hex" 8 "fmt" 9 "html/template" 10 "io" 11 "io/fs" 12 "log/slog" 13 "net/http" 14 "os" 15 "path/filepath" 16 "strings" 17 "sync" 18 "time" 19 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/cache" 22 "tangled.org/core/appview/commitverify" 23 "tangled.org/core/appview/config" 24 "tangled.org/core/appview/db" 25 "tangled.org/core/appview/filetree" 26 "tangled.org/core/appview/models" 27 "tangled.org/core/appview/oauth" 28 "tangled.org/core/appview/pages/markup" 29 "tangled.org/core/appview/pages/markup/sanitizer" 30 "tangled.org/core/appview/pages/repoinfo" 31 "tangled.org/core/appview/pagination" 32 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 33 "tangled.org/core/idresolver" 34 "tangled.org/core/types" 35 36 "github.com/bluesky-social/indigo/atproto/identity" 37 "github.com/bluesky-social/indigo/atproto/syntax" 38 "github.com/go-git/go-git/v5/plumbing" 39 "github.com/sourcegraph/zoekt" 40) 41 42//go:embed templates/* static legal 43var Files embed.FS 44 45type baseParamsCtxKey struct{} 46 47type BaseParams struct { 48 LoggedInUser *oauth.MultiAccountUser 49 FocusParams FocusParams 50 ThemePreference models.ThemePreference 51} 52 53type FocusParams struct { 54 Focusing bool 55 FocusLink string 56 FocusNotificationID int64 57 CurrentPath string // r.URL.Path, for off-focus detection in templates 58 FocusCount int // total unread focus-eligible items remaining 59} 60 61func (p *Pages) Resolver() *idresolver.Resolver { 62 return p.resolver 63} 64 65func BaseParamsIntoContext(ctx context.Context, bp BaseParams) context.Context { 66 return context.WithValue(ctx, baseParamsCtxKey{}, bp) 67} 68 69func BaseParamsFromContext(ctx context.Context) BaseParams { 70 bp, _ := ctx.Value(baseParamsCtxKey{}).(BaseParams) 71 return bp 72} 73 74type Pages struct { 75 mu sync.RWMutex 76 cache *TmplCache[string, *template.Template] 77 78 avatar config.AvatarConfig 79 pdsCfg config.PdsConfig 80 resolver *idresolver.Resolver 81 db *db.DB 82 rdb *cache.Cache 83 dev bool 84 embedFS fs.FS 85 templateDir string // Path to templates on disk for dev mode 86 rctx *markup.RenderContext 87 logger *slog.Logger 88} 89 90func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb *cache.Cache, logger *slog.Logger) *Pages { 91 // initialized with safe defaults, can be overridden per use 92 rctx := &markup.RenderContext{ 93 IsDev: config.Core.Dev, 94 Hostname: config.Core.AppviewHost, 95 CamoUrl: config.Camo.Host, 96 CamoSecret: config.Camo.SharedSecret, 97 Files: Files, 98 } 99 100 p := &Pages{ 101 mu: sync.RWMutex{}, 102 cache: NewTmplCache[string, *template.Template](), 103 dev: config.Core.Dev, 104 avatar: config.Avatar, 105 pdsCfg: config.Pds, 106 rctx: rctx, 107 resolver: res, 108 db: database, 109 rdb: rdb, 110 templateDir: "appview/pages", 111 logger: logger, 112 } 113 114 if p.dev { 115 p.embedFS = os.DirFS(p.templateDir) 116 } else { 117 p.embedFS = Files 118 } 119 120 return p 121} 122 123// reverse of pathToName 124func (p *Pages) nameToPath(s string) string { 125 return "templates/" + s + ".html" 126} 127 128// FuncMap returns the template function map for use by external template consumers. 129func (p *Pages) FuncMap() template.FuncMap { 130 return p.funcMap() 131} 132 133// FragmentPaths returns all fragment template paths from the embedded FS. 134func (p *Pages) FragmentPaths() ([]string, error) { 135 return p.fragmentPaths() 136} 137 138// EmbedFS returns the embedded filesystem containing templates and static assets. 139func (p *Pages) EmbedFS() fs.FS { 140 return p.embedFS 141} 142 143func (p *Pages) fragmentPaths() ([]string, error) { 144 var fragmentPaths []string 145 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 146 if err != nil { 147 return err 148 } 149 if d.IsDir() { 150 return nil 151 } 152 if !strings.HasSuffix(path, ".html") { 153 return nil 154 } 155 if !strings.Contains(path, "fragments/") { 156 return nil 157 } 158 fragmentPaths = append(fragmentPaths, path) 159 return nil 160 }) 161 if err != nil { 162 return nil, err 163 } 164 165 return fragmentPaths, nil 166} 167 168// parse without memoization 169func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 170 paths, err := p.fragmentPaths() 171 if err != nil { 172 return nil, err 173 } 174 for _, s := range stack { 175 paths = append(paths, p.nameToPath(s)) 176 } 177 178 funcs := p.funcMap() 179 top := stack[len(stack)-1] 180 parsed, err := template.New(top). 181 Funcs(funcs). 182 ParseFS(p.embedFS, paths...) 183 if err != nil { 184 return nil, err 185 } 186 187 return parsed, nil 188} 189 190func (p *Pages) parse(stack ...string) (*template.Template, error) { 191 key := strings.Join(stack, "|") 192 193 // never cache in dev mode 194 if cached, exists := p.cache.Get(key); !p.dev && exists { 195 return cached, nil 196 } 197 198 result, err := p.rawParse(stack...) 199 if err != nil { 200 return nil, err 201 } 202 203 p.cache.Set(key, result) 204 return result, nil 205} 206 207func (p *Pages) parseBase(top string) (*template.Template, error) { 208 stack := []string{ 209 "layouts/base", 210 top, 211 } 212 return p.parse(stack...) 213} 214 215func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 216 stack := []string{ 217 "layouts/base", 218 "layouts/repobase", 219 top, 220 } 221 return p.parse(stack...) 222} 223 224func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 225 stack := []string{ 226 "layouts/base", 227 "layouts/profilebase", 228 top, 229 } 230 return p.parse(stack...) 231} 232 233func (p *Pages) parseLoginBase(top string) (*template.Template, error) { 234 stack := []string{ 235 "layouts/base", 236 "layouts/loginbase", 237 top, 238 } 239 return p.parse(stack...) 240} 241 242func (p *Pages) parseOnboardingBase(top string) (*template.Template, error) { 243 stack := []string{ 244 "layouts/base", 245 "layouts/onboardingbase", 246 top, 247 } 248 return p.parse(stack...) 249} 250 251func (p *Pages) executePlain(name string, w io.Writer, params any) error { 252 tpl, err := p.parse(name) 253 if err != nil { 254 return err 255 } 256 257 err = tpl.Execute(w, params) 258 if err != nil { 259 p.logger.Error("failed to execute template", "template", name, "err", err) 260 } 261 return err 262} 263 264func (p *Pages) executeLogin(name string, w io.Writer, params any) error { 265 tpl, err := p.parseLoginBase(name) 266 if err != nil { 267 return err 268 } 269 270 err = tpl.ExecuteTemplate(w, "layouts/base", params) 271 if err != nil { 272 p.logger.Error("failed to execute login template", "template", name, "err", err) 273 } 274 return err 275} 276 277func (p *Pages) executeOnboarding(name string, w io.Writer, params any) error { 278 tpl, err := p.parseOnboardingBase(name) 279 if err != nil { 280 return err 281 } 282 283 return tpl.ExecuteTemplate(w, "layouts/base", params) 284} 285 286func (p *Pages) execute(name string, w io.Writer, params any) error { 287 tpl, err := p.parseBase(name) 288 if err != nil { 289 return err 290 } 291 292 err = tpl.ExecuteTemplate(w, "layouts/base", params) 293 if err != nil { 294 p.logger.Error("failed to execute template", "template", name, "err", err) 295 } 296 return err 297} 298 299func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 300 tpl, err := p.parseRepoBase(name) 301 if err != nil { 302 return err 303 } 304 305 err = tpl.ExecuteTemplate(w, "layouts/base", params) 306 if err != nil { 307 p.logger.Error("failed to execute repo template", "template", name, "err", err) 308 } 309 return err 310} 311 312func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 313 tpl, err := p.parseProfileBase(name) 314 if err != nil { 315 return err 316 } 317 318 err = tpl.ExecuteTemplate(w, "layouts/base", params) 319 if err != nil { 320 p.logger.Error("failed to execute profile template", "template", name, "err", err) 321 } 322 return err 323} 324 325type DollyParams struct { 326 Classes string 327 FillColor string 328 // Favicon embeds a prefers-color-scheme style block so the SVG 329 // adapts to dark mode when used as a standalone favicon document. 330 Favicon bool 331} 332 333func (p *Pages) Dolly(w io.Writer, params DollyParams) error { 334 return p.executePlain("fragments/dolly/logo", w, params) 335} 336 337func (p *Pages) Favicon(w io.Writer) error { 338 return p.Dolly(w, DollyParams{ 339 Favicon: true, 340 }) 341} 342 343type LoginParams struct { 344 BaseParams 345 ReturnUrl string 346 ErrorCode string 347 AddAccount bool 348 Handle string 349 Accounts []oauth.AccountInfo 350} 351 352func (p *Pages) Login(w io.Writer, params LoginParams) error { 353 return p.executeLogin("user/login", w, params) 354} 355 356type SignupParams struct { 357 BaseParams 358 CloudflareSiteKey string 359 EmailId string 360} 361 362func (p *Pages) Signup(w io.Writer, params SignupParams) error { 363 return p.executeLogin("user/signup", w, params) 364} 365 366func (p *Pages) CompleteSignup(w io.Writer) error { 367 return p.executeLogin("user/completeSignup", w, BaseParams{}) 368} 369 370type SignupSuccessParams struct { 371 Handle string 372} 373 374func (p *Pages) SignupSuccess(w io.Writer, params SignupSuccessParams) error { 375 return p.executePlain("user/fragments/signupSuccess", w, params) 376} 377 378type TermsOfServiceParams struct { 379 BaseParams 380 Content template.HTML 381} 382 383func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 384 filename := "terms.md" 385 filePath := filepath.Join("legal", filename) 386 387 file, err := p.embedFS.Open(filePath) 388 if err != nil { 389 return fmt.Errorf("failed to read %s: %w", filename, err) 390 } 391 defer file.Close() 392 393 markdownBytes, err := io.ReadAll(file) 394 if err != nil { 395 return fmt.Errorf("failed to read %s: %w", filename, err) 396 } 397 398 rctx := p.rctx.Clone() 399 rctx.RendererType = markup.RendererTypeDefault 400 htmlString := rctx.RenderMarkdown(string(markdownBytes)) 401 sanitized := sanitizer.SanitizeDefault(htmlString) 402 params.Content = template.HTML(sanitized) 403 404 return p.execute("legal/terms", w, params) 405} 406 407type PrivacyPolicyParams struct { 408 BaseParams 409 Content template.HTML 410} 411 412func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 413 filename := "privacy.md" 414 filePath := filepath.Join("legal", filename) 415 416 file, err := p.embedFS.Open(filePath) 417 if err != nil { 418 return fmt.Errorf("failed to read %s: %w", filename, err) 419 } 420 defer file.Close() 421 422 markdownBytes, err := io.ReadAll(file) 423 if err != nil { 424 return fmt.Errorf("failed to read %s: %w", filename, err) 425 } 426 427 rctx := p.rctx.Clone() 428 rctx.RendererType = markup.RendererTypeDefault 429 htmlString := rctx.RenderMarkdown(string(markdownBytes)) 430 sanitized := sanitizer.SanitizeDefault(htmlString) 431 params.Content = template.HTML(sanitized) 432 433 return p.execute("legal/privacy", w, params) 434} 435 436type BrandParams struct { 437 BaseParams 438} 439 440func (p *Pages) Brand(w io.Writer, params BrandParams) error { 441 return p.execute("brand/brand", w, params) 442} 443 444type RecentItem struct { 445 Link *models.RecentLink 446 Repo *models.Repo 447 Issue *models.Issue 448 Pull *models.Pull 449} 450 451type BlogPost struct { 452 Slug string 453 Title string 454 Subtitle string 455 Date time.Time 456} 457 458type TimelineParams struct { 459 BaseParams 460 Onboarding models.OnboardingProgress 461 Timeline []models.TimelineGroup 462 Repos []models.Repo 463 GfiLabel *models.LabelDefinition 464 BlueskyPosts []models.BskyPost 465 VouchSuggestions []models.VouchSuggestion 466 Notifications []*models.NotificationWithEntity 467 Recents []RecentItem 468 FollowingOnly bool 469 RecentBlogPosts []BlogPost 470 // ShowNewsletter controls whether the newsletter widget/CTA is rendered. 471 // For logged-in users it reflects their newsletter_preferences row; for 472 // anonymous visitors it is always true (dismissal falls back to 473 // localStorage on the client). 474 ShowNewsletter bool 475 CanFocus bool 476} 477 478func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 479 return p.execute("timeline/timeline", w, params) 480} 481 482type GoodFirstIssuesParams struct { 483 BaseParams 484 Issues []models.Issue 485 RepoGroups []*models.RepoGroup 486 LabelDefs map[string]*models.LabelDefinition 487 GfiLabel *models.LabelDefinition 488 Page pagination.Page 489} 490 491func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 492 return p.execute("goodfirstissues/index", w, params) 493} 494 495type UserProfileSettingsParams struct { 496 BaseParams 497 Tab string 498 PunchcardPreference models.PunchcardPreference 499 IsTnglSh bool 500 IsDeactivated bool 501 HandleOpen bool 502} 503 504func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 505 params.Tab = "profile" 506 return p.execute("user/settings/profile", w, params) 507} 508 509type GroupedNotifications struct { 510 Today []*models.NotificationWithEntity 511 ThisWeek []*models.NotificationWithEntity 512 Older []*models.NotificationWithEntity 513} 514 515func GroupNotificationsByDate(notifs []*models.NotificationWithEntity) GroupedNotifications { 516 now := time.Now() 517 todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 518 weekStart := todayStart.AddDate(0, 0, -6) 519 520 var g GroupedNotifications 521 for _, n := range notifs { 522 switch { 523 case !n.Created.Before(todayStart): 524 g.Today = append(g.Today, n) 525 case !n.Created.Before(weekStart): 526 g.ThisWeek = append(g.ThisWeek, n) 527 default: 528 g.Older = append(g.Older, n) 529 } 530 } 531 return g 532} 533 534type NotificationsParams struct { 535 BaseParams 536 WorkGroups GroupedNotifications 537 SocialGroups GroupedNotifications 538 MobileGroups GroupedNotifications 539 WorkUnreadCount int64 540 SocialUnreadCount int64 541 Page pagination.Page 542 Total int 543 ReadFilter string // "inbox" or "unread" 544 CategoryFilter string // "all", "work", "social" 545 CanFocus bool 546} 547 548func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 549 return p.execute("notifications/list", w, params) 550} 551 552func (p *Pages) NotificationItem(w io.Writer, notif *models.NotificationWithEntity) error { 553 return p.executePlain("notifications/fragments/item", w, notif) 554} 555 556type NotificationCountParams struct { 557 Count int64 558} 559 560func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 561 return p.executePlain("notifications/fragments/count", w, params) 562} 563 564type NotificationPreviewParams struct { 565 BaseParams 566 Notifications []*models.NotificationWithEntity 567 ReadFilter string 568 CategoryFilter string 569 CanFocus bool 570} 571 572func (p *Pages) NotificationPreview(w io.Writer, params NotificationPreviewParams) error { 573 return p.executePlain("notifications/fragments/preview", w, params) 574} 575 576type UserKeysSettingsParams struct { 577 BaseParams 578 PubKeys []models.PublicKey 579 Tab string 580} 581 582func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 583 params.Tab = "keys" 584 return p.execute("user/settings/keys", w, params) 585} 586 587type UserEmailsSettingsParams struct { 588 BaseParams 589 Emails []models.Email 590 Tab string 591} 592 593func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 594 params.Tab = "emails" 595 return p.execute("user/settings/emails", w, params) 596} 597 598type UserNotificationSettingsParams struct { 599 BaseParams 600 Preferences *models.NotificationPreferences 601 HasVerifiedEmail bool 602 Tab string 603} 604 605func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 606 params.Tab = "notifications" 607 return p.execute("user/settings/notifications", w, params) 608} 609 610type UserSiteSettingsParams struct { 611 BaseParams 612 Claim *models.DomainClaim 613 SitesDomain string 614 IsTnglHandle bool 615 Tab string 616} 617 618func (p *Pages) UserSiteSettings(w io.Writer, params UserSiteSettingsParams) error { 619 params.Tab = "sites" 620 return p.execute("user/settings/sites", w, params) 621} 622 623type UpgradeBannerParams struct { 624 Registrations []models.Registration 625 Spindles []models.Spindle 626} 627 628func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 629 return p.executePlain("banner", w, params) 630} 631 632type NewsletterResponseParams struct { 633 // Id identifies the calling form instance; the response span's id will 634 // be "newsletter-msg-<Id>" so it round-trips with the form's hx-target. 635 Id string 636 // Error, when non-empty, switches the template to the error variant. 637 Error string 638} 639 640func (p *Pages) NewsletterResponse(w io.Writer, params NewsletterResponseParams) error { 641 return p.executePlain("timeline/fragments/newsletterResponse", w, params) 642} 643 644type KnotsParams struct { 645 BaseParams 646 Knots []KnotListingParams 647 Tab string 648} 649 650func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 651 params.Tab = "knots" 652 return p.execute("knots/index", w, params) 653} 654 655type KnotParams struct { 656 BaseParams 657 Registration *models.Registration 658 Members []string 659 Repos map[string][]models.Repo 660 IsOwner bool 661 RepoCount int 662 Tab string 663} 664 665func (p *Pages) Knot(w io.Writer, params KnotParams) error { 666 return p.execute("knots/dashboard", w, params) 667} 668 669type KnotListingParams struct { 670 *models.Registration 671 RepoCount int 672} 673 674func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 675 return p.executePlain("knots/fragments/knotListing", w, params) 676} 677 678type SpindlesParams struct { 679 BaseParams 680 Spindles []models.Spindle 681 Tab string 682} 683 684func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { 685 params.Tab = "spindles" 686 return p.execute("spindles/index", w, params) 687} 688 689type SpindleListingParams struct { 690 models.Spindle 691 Tab string 692} 693 694func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 695 return p.executePlain("spindles/fragments/spindleListing", w, params) 696} 697 698type SpindleDashboardParams struct { 699 BaseParams 700 Spindle models.Spindle 701 Members []string 702 Repos map[string][]models.Repo 703 Tab string 704} 705 706func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 707 return p.execute("spindles/dashboard", w, params) 708} 709 710type NewRepoParams struct { 711 BaseParams 712 Knots []string 713 Spindles []string 714} 715 716func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 717 return p.execute("repo/new", w, params) 718} 719 720type ForkRepoParams struct { 721 BaseParams 722 Knots []string 723 Spindles []string 724 RepoInfo repoinfo.RepoInfo 725} 726 727func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 728 return p.execute("repo/fork", w, params) 729} 730 731type ProfileCard struct { 732 UserDid string 733 HasProfile bool 734 IsTangledUser bool 735 FollowStatus models.FollowStatus 736 VouchRelationship *models.VouchRelationship 737 Punchcard *models.Punchcard 738 Profile *models.Profile 739 Stats ProfileStats 740 Active string 741 ProfileScript string 742} 743 744type ProfileStats struct { 745 RepoCount int64 746 StarredCount int64 747 StringCount int64 748 FollowersCount int64 749 FollowingCount int64 750} 751 752func (p *ProfileCard) GetTabs() [][]any { 753 tabs := [][]any{ 754 {"overview", "overview", "square-chart-gantt", nil}, 755 {"repos", "repos", "book-marked", p.Stats.RepoCount}, 756 {"starred", "starred", "star", p.Stats.StarredCount}, 757 {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 758 {"vouches", "vouches", "shield", nil}, 759 } 760 761 return tabs 762} 763 764type ProfileOverviewParams struct { 765 BaseParams 766 Repos []models.Repo 767 CollaboratingRepos []models.Repo 768 ProfileTimeline *models.ProfileTimeline 769 Card *ProfileCard 770 Active string 771 ShowPunchcard bool 772} 773 774func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 775 params.Active = "overview" 776 return p.executeProfile("user/overview", w, params) 777} 778 779type ProfileReposParams struct { 780 BaseParams 781 Repos []models.Repo 782 StarStatuses map[string]bool 783 Card *ProfileCard 784 Active string 785 Page pagination.Page 786 RepoCount int 787 FilterQuery string 788} 789 790func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 791 params.Active = "repos" 792 return p.executeProfile("user/repos", w, params) 793} 794 795type ProfileStarredParams struct { 796 BaseParams 797 Repos []models.Repo 798 Card *ProfileCard 799 Page pagination.Page 800 Total int 801 Active string 802} 803 804func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 805 params.Active = "starred" 806 return p.executeProfile("user/starred", w, params) 807} 808 809type ProfileStringsParams struct { 810 BaseParams 811 Strings []models.String 812 Card *ProfileCard 813 Active string 814} 815 816func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 817 params.Active = "strings" 818 return p.executeProfile("user/strings", w, params) 819} 820 821type ProfileVouchesParams struct { 822 BaseParams 823 Vouches []models.Vouch 824 Suggestions []models.VouchSuggestion 825 Card *ProfileCard 826 Page pagination.Page 827 VouchCount int 828 Active string 829 EvidencePulls map[syntax.ATURI]*models.Pull 830 EvidenceIssues map[syntax.ATURI]*models.Issue 831} 832 833func (p *Pages) ProfileVouches(w io.Writer, params ProfileVouchesParams) error { 834 params.Active = "vouches" 835 return p.executeProfile("user/vouches", w, params) 836} 837 838type FollowCard struct { 839 UserDid string 840 BaseParams 841 FollowStatus models.FollowStatus 842 FollowersCount int64 843 FollowingCount int64 844 Profile *models.Profile 845} 846 847type ProfileFollowersParams struct { 848 BaseParams 849 Followers []FollowCard 850 Card *ProfileCard 851 Active string 852} 853 854func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 855 params.Active = "overview" 856 return p.executeProfile("user/followers", w, params) 857} 858 859type ProfileFollowingParams struct { 860 BaseParams 861 Following []FollowCard 862 Card *ProfileCard 863 Active string 864} 865 866func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 867 params.Active = "overview" 868 return p.executeProfile("user/following", w, params) 869} 870 871type FollowFragmentParams struct { 872 UserDid string 873 FollowStatus models.FollowStatus 874 FollowersCount int64 875 HxSwapOob struct{} // empty struct so always truthy 876} 877 878func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 879 return p.executePlain("user/fragments/follow-oob", w, params) 880} 881 882type ProfilePopoverParams struct { 883 BaseParams 884 UserDid string 885 Profile *models.Profile 886 FollowStatus models.FollowStatus 887 VouchRelationship *models.VouchRelationship 888 Stats ProfilePopoverStats 889} 890 891type ProfilePopoverStats struct { 892 FollowersCount int64 893 FollowingCount int64 894} 895 896func (p *Pages) ProfilePopoverFragment(w io.Writer, params ProfilePopoverParams) error { 897 return p.executePlain("user/fragments/profilePopover", w, params) 898} 899 900type EditBioParams struct { 901 BaseParams 902 Profile *models.Profile 903 AlsoKnownAs []string 904 // Action optionally overrides the form's hx-post target. Defaults to 905 // /profile/bio when empty. Used by the onboarding flow to save + advance. 906 Action string 907} 908 909func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { 910 return p.executePlain("user/fragments/editBio", w, params) 911} 912 913type EditPinsParams struct { 914 BaseParams 915 Profile *models.Profile 916 AllRepos []PinnedRepo 917} 918 919type PinnedRepo struct { 920 IsPinned bool 921 models.Repo 922} 923 924func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { 925 return p.executePlain("user/fragments/editPins", w, params) 926} 927 928type StarBtnFragmentParams struct { 929 IsStarred bool 930 SubjectAt syntax.ATURI 931 StarCount int 932 RepoName string 933 HxSwapOob bool 934} 935 936func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 937 params.HxSwapOob = true 938 return p.executePlain("fragments/starBtn", w, params) 939} 940 941type OnboardingParams struct { 942 BaseParams 943 Step int 944 945 EditBio EditBioParams 946 947 PubKeys []models.PublicKey 948 949 People []FollowCard 950 TrendingRepos []models.Repo 951 StarStatuses map[string]bool 952} 953 954// KeyFragment renders a single SSH key row (used to append a newly added key 955// to the list without a full reload). 956func (p *Pages) KeyFragment(w io.Writer, key models.PublicKey) error { 957 return p.executePlain("user/settings/fragments/keyListing", w, key) 958} 959 960func (p *Pages) Onboarding(w io.Writer, params OnboardingParams) error { 961 return p.executeOnboarding("onboarding/welcome", w, params) 962} 963 964type RepoIndexParams struct { 965 BaseParams 966 RepoInfo repoinfo.RepoInfo 967 Active string 968 TagMap map[string][]string 969 CommitsTrunc []types.Commit 970 TagsTrunc []*types.TagReference 971 BranchesTrunc []types.Branch 972 // ForkInfo *types.ForkInfo 973 HTMLReadme template.HTML 974 Raw bool 975 EmailToDid map[string]string 976 VerifiedCommits commitverify.VerifiedCommits 977 Languages []types.RepoLanguageDetails 978 NeedsKnotUpgrade bool 979 KnotUnreachable bool 980 types.RepoIndexResponse 981} 982 983func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 984 params.Active = "overview" 985 if params.IsEmpty { 986 return p.executeRepo("repo/empty", w, params) 987 } 988 989 if params.NeedsKnotUpgrade { 990 return p.executeRepo("repo/needsUpgrade", w, params) 991 } 992 993 if params.KnotUnreachable { 994 return p.executeRepo("repo/knotUnreachable", w, params) 995 } 996 997 rctx := p.rctx.Clone() 998 rctx.RepoInfo = params.RepoInfo 999 rctx.RepoInfo.Ref = params.Ref 1000 rctx.RendererType = markup.RendererTypeRepoMarkdown 1001 1002 if params.ReadmeFileName != "" { 1003 switch markup.GetFormat(params.ReadmeFileName) { 1004 case markup.FormatMarkdown: 1005 params.Raw = false 1006 htmlString := rctx.RenderMarkdown(params.Readme) 1007 sanitized := sanitizer.SanitizeDefault(htmlString) 1008 params.HTMLReadme = template.HTML(sanitized) 1009 default: 1010 params.Raw = true 1011 } 1012 } 1013 1014 return p.executeRepo("repo/index", w, params) 1015} 1016 1017type RepoSearchParams struct { 1018 BaseParams 1019 RepoInfo repoinfo.RepoInfo 1020 Active string 1021 FilterQuery string 1022} 1023 1024func (p *Pages) RepoSearchPage(w io.Writer, params RepoSearchParams) error { 1025 params.Active = "overview" 1026 return p.executeRepo("repo/search", w, params) 1027} 1028 1029type RepoSearchResultsFragmentParams struct { 1030 Query string 1031 Results []SearchResult 1032 ErrorMsg string 1033} 1034 1035func (p *Pages) RepoSearchResultsFragment(w io.Writer, params RepoSearchResultsFragmentParams) error { 1036 return p.executePlain("repo/fragments/searchResults", w, params) 1037} 1038 1039type RepoLogParams struct { 1040 BaseParams 1041 RepoInfo repoinfo.RepoInfo 1042 TagMap map[string][]string 1043 Active string 1044 EmailToDid map[string]string 1045 VerifiedCommits commitverify.VerifiedCommits 1046 1047 types.RepoLogResponse 1048} 1049 1050func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 1051 params.Active = "overview" 1052 return p.executeRepo("repo/log", w, params) 1053} 1054 1055type RepoCommitParams struct { 1056 BaseParams 1057 RepoInfo repoinfo.RepoInfo 1058 Active string 1059 EmailToDid map[string]string 1060 Pipeline *types.Pipeline 1061 DiffOpts types.DiffOpts 1062 1063 // singular because it's always going to be just one 1064 VerifiedCommit commitverify.VerifiedCommits 1065 1066 types.RepoCommitResponse 1067} 1068 1069func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 1070 params.Active = "overview" 1071 return p.executeRepo("repo/commit", w, params) 1072} 1073 1074type RepoTreeParams struct { 1075 BaseParams 1076 RepoInfo repoinfo.RepoInfo 1077 Active string 1078 BreadCrumbs [][]string 1079 Path string 1080 Raw bool 1081 HTMLReadme template.HTML 1082 EmailToDid map[string]string 1083 LastCommitInfo *types.LastCommitInfo 1084 Ref string 1085 Parent string 1086 DotDot string 1087 Files []types.NiceTree 1088 ReadmeFileName string 1089 Readme string 1090} 1091 1092type RepoTreeStats struct { 1093 NumFolders uint64 1094 NumFiles uint64 1095} 1096 1097func (r RepoTreeParams) TreeStats() RepoTreeStats { 1098 numFolders, numFiles := 0, 0 1099 for _, f := range r.Files { 1100 if !f.IsFile() { 1101 numFolders += 1 1102 } else if f.IsFile() { 1103 numFiles += 1 1104 } 1105 } 1106 1107 return RepoTreeStats{ 1108 NumFolders: uint64(numFolders), 1109 NumFiles: uint64(numFiles), 1110 } 1111} 1112 1113func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 1114 params.Active = "overview" 1115 1116 rctx := p.rctx.Clone() 1117 rctx.RepoInfo = params.RepoInfo 1118 rctx.RepoInfo.Ref = params.Ref 1119 rctx.RendererType = markup.RendererTypeRepoMarkdown 1120 1121 if params.ReadmeFileName != "" { 1122 switch markup.GetFormat(params.ReadmeFileName) { 1123 case markup.FormatMarkdown: 1124 params.Raw = false 1125 htmlString := rctx.RenderMarkdown(params.Readme) 1126 sanitized := sanitizer.SanitizeDefault(htmlString) 1127 params.HTMLReadme = template.HTML(sanitized) 1128 default: 1129 params.Raw = true 1130 } 1131 } 1132 1133 return p.executeRepo("repo/tree", w, params) 1134} 1135 1136type RepoBranchesParams struct { 1137 BaseParams 1138 RepoInfo repoinfo.RepoInfo 1139 Active string 1140 types.RepoBranchesResponse 1141} 1142 1143func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 1144 params.Active = "overview" 1145 return p.executeRepo("repo/branches", w, params) 1146} 1147 1148type RepoTagsParams struct { 1149 BaseParams 1150 RepoInfo repoinfo.RepoInfo 1151 Active string 1152 types.RepoTagsResponse 1153 ArtifactMap map[plumbing.Hash][]models.Artifact 1154 DanglingArtifacts []models.Artifact 1155} 1156 1157func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 1158 params.Active = "overview" 1159 return p.executeRepo("repo/tags", w, params) 1160} 1161 1162type RepoTagParams struct { 1163 BaseParams 1164 RepoInfo repoinfo.RepoInfo 1165 Active string 1166 types.RepoTagResponse 1167 ArtifactMap map[plumbing.Hash][]models.Artifact 1168 DanglingArtifacts []models.Artifact 1169} 1170 1171func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error { 1172 params.Active = "overview" 1173 return p.executeRepo("repo/tag", w, params) 1174} 1175 1176type RepoArtifactParams struct { 1177 BaseParams 1178 RepoInfo repoinfo.RepoInfo 1179 Artifact models.Artifact 1180} 1181 1182func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { 1183 return p.executePlain("repo/fragments/artifact", w, params) 1184} 1185 1186type RepoBlobParams struct { 1187 BaseParams 1188 RepoInfo repoinfo.RepoInfo 1189 Active string // always "overview" 1190 BreadCrumbs [][]string 1191 BlobView models.BlobView // TODO: expose this struct 1192 ShowRendered bool 1193 EmailToDid map[string]string 1194 LastCommitInfo *types.LastCommitInfo 1195 Ref string 1196 Path string 1197 Language string 1198} 1199 1200func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 1201 params.Active = "overview" 1202 return p.executeRepo("repo/blob", w, params) 1203} 1204 1205type Collaborator struct { 1206 Did string 1207 Role string 1208} 1209 1210type RepoSettingsParams struct { 1211 BaseParams 1212 RepoInfo repoinfo.RepoInfo 1213 Collaborators []Collaborator 1214 Active string 1215 Branches []types.Branch 1216 Spindles []string 1217 CurrentSpindle string 1218 Secrets []*tangled.RepoListSecrets_Secret 1219 1220 // TODO: use repoinfo.roles 1221 IsCollaboratorInviteAllowed bool 1222} 1223 1224func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 1225 params.Active = "settings" 1226 return p.executeRepo("repo/settings", w, params) 1227} 1228 1229type RepoGeneralSettingsParams struct { 1230 BaseParams 1231 RepoInfo repoinfo.RepoInfo 1232 Labels []models.LabelDefinition 1233 DefaultLabels []models.LabelDefinition 1234 SubscribedLabels map[string]struct{} 1235 ShouldSubscribeAll bool 1236 Active string 1237 Tab string 1238 Branches []types.Branch 1239} 1240 1241func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 1242 params.Active = "settings" 1243 params.Tab = "general" 1244 return p.executeRepo("repo/settings/general", w, params) 1245} 1246 1247type RepoAccessSettingsParams struct { 1248 BaseParams 1249 RepoInfo repoinfo.RepoInfo 1250 Active string 1251 Tab string 1252 Collaborators []Collaborator 1253 CanRemoveCollaborator bool 1254} 1255 1256func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 1257 params.Active = "settings" 1258 params.Tab = "access" 1259 return p.executeRepo("repo/settings/access", w, params) 1260} 1261 1262type RepoPipelineSettingsParams struct { 1263 BaseParams 1264 RepoInfo repoinfo.RepoInfo 1265 Active string 1266 Tab string 1267 Spindles []string 1268 CurrentSpindle string 1269 Secrets []map[string]any 1270} 1271 1272func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 1273 params.Active = "settings" 1274 params.Tab = "pipelines" 1275 return p.executeRepo("repo/settings/pipelines", w, params) 1276} 1277 1278type RepoWebhooksSettingsParams struct { 1279 BaseParams 1280 RepoInfo repoinfo.RepoInfo 1281 Active string 1282 Tab string 1283 Webhooks []models.Webhook 1284 WebhookDeliveries map[int64][]models.WebhookDelivery 1285} 1286 1287func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsParams) error { 1288 params.Active = "settings" 1289 params.Tab = "hooks" 1290 return p.executeRepo("repo/settings/hooks", w, params) 1291} 1292 1293type WebhookDeliveriesListParams struct { 1294 BaseParams 1295 RepoInfo repoinfo.RepoInfo 1296 Webhook *models.Webhook 1297 Deliveries []models.WebhookDelivery 1298} 1299 1300func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListParams) error { 1301 tpl, err := p.parse("repo/settings/fragments/webhookDeliveries") 1302 if err != nil { 1303 return err 1304 } 1305 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params) 1306} 1307 1308type RepoSiteSettingsParams struct { 1309 BaseParams 1310 RepoInfo repoinfo.RepoInfo 1311 Active string 1312 Tab string 1313 Branches []types.Branch 1314 SiteConfig *models.RepoSite 1315 OwnerClaim *models.DomainClaim 1316 Deploys []models.SiteDeploy 1317 IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or "" 1318} 1319 1320func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error { 1321 params.Active = "settings" 1322 params.Tab = "sites" 1323 return p.executeRepo("repo/settings/sites", w, params) 1324} 1325 1326type RepoIssuesParams struct { 1327 BaseParams 1328 RepoInfo repoinfo.RepoInfo 1329 Active string 1330 Issues []models.Issue 1331 IssueCount int 1332 LabelDefs map[string]*models.LabelDefinition 1333 Page pagination.Page 1334 FilterState string 1335 FilterQuery string 1336 BaseFilterQuery string 1337 VouchRelationships map[syntax.DID]*models.VouchRelationship 1338} 1339 1340func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 1341 params.Active = "issues" 1342 return p.executeRepo("repo/issues/issues", w, params) 1343} 1344 1345type RepoSingleIssueParams struct { 1346 BaseParams 1347 RepoInfo repoinfo.RepoInfo 1348 Active string 1349 Issue *models.Issue 1350 CommentList []models.CommentListItem 1351 Backlinks []models.RichReferenceLink 1352 LabelDefs map[string]*models.LabelDefinition 1353 1354 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1355 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1356 VouchRelationships map[syntax.DID]*models.VouchRelationship 1357 1358 // IsSubscribed is nil when the user is not logged in, true when subscribed, 1359 // false when explicitly unsubscribed, and nil when no explicit subscription. 1360 IsSubscribed *bool 1361} 1362 1363func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 1364 params.Active = "issues" 1365 return p.executeRepo("repo/issues/issue", w, params) 1366} 1367 1368type IssueSubscribeParams struct { 1369 RepoInfo repoinfo.RepoInfo 1370 IssueId int 1371 IsSubscribed *bool 1372} 1373 1374func (p *Pages) IssueSubscribeFragment(w io.Writer, params IssueSubscribeParams) error { 1375 return p.executePlain("repo/issues/fragments/subscribeButton", w, params) 1376} 1377 1378type PullSubscribeParams struct { 1379 RepoInfo repoinfo.RepoInfo 1380 PullId int64 1381 IsSubscribed *bool 1382} 1383 1384func (p *Pages) PullSubscribeFragment(w io.Writer, params PullSubscribeParams) error { 1385 return p.executePlain("repo/pulls/fragments/subscribeButton", w, params) 1386} 1387 1388type EditIssueParams struct { 1389 BaseParams 1390 RepoInfo repoinfo.RepoInfo 1391 Issue *models.Issue 1392 Action string 1393} 1394 1395func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 1396 params.Action = "edit" 1397 return p.executePlain("repo/issues/fragments/putIssue", w, params) 1398} 1399 1400type ThreadReactionFragmentParams struct { 1401 Kind models.ReactionKind 1402 Count int 1403 Users []string 1404 IsReacted bool 1405 CommentRkey string 1406 SubjectUri string 1407} 1408 1409func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 1410 return p.executePlain("repo/fragments/reaction", w, params) 1411} 1412 1413type RepoNewIssueParams struct { 1414 BaseParams 1415 RepoInfo repoinfo.RepoInfo 1416 Issue *models.Issue // existing issue if any -- passed when editing 1417 Active string 1418 Action string 1419} 1420 1421func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 1422 params.Active = "issues" 1423 params.Action = "create" 1424 return p.executeRepo("repo/issues/new", w, params) 1425} 1426 1427type StackedDiff struct { 1428 Diff *types.NiceDiff 1429 Opts types.DiffOpts 1430} 1431 1432type RepoNewPullParams struct { 1433 BaseParams 1434 RepoInfo repoinfo.RepoInfo 1435 Active string 1436 PrefillError string 1437 1438 // step 1. choose source 1439 // TODO: replace to RepoNewPull_StepSourceParams 1440 Branches []types.Branch 1441 SourceBranches []types.Branch 1442 ForkBranches []types.Branch 1443 Forks []models.Repo 1444 // selected values 1445 Source Source // source kind 1446 TargetBranch string 1447 Fork string // fork repo DID 1448 SourceBranch string 1449 Patch string 1450 1451 // step 2. review changes 1452 StepReviewParams *RepoNewPull_StepReviewParams // optional step 2 params 1453 1454 // step 3. fill details 1455 // TODO: replace to RepoNewPull_StepDetailsParams 1456 Title string 1457 Body string 1458 TitleDirty bool // flag to avoid overwriting users input 1459 BodyDirty bool 1460 MergeCheck MergeCheckParams 1461 LabelDefs map[string]*models.LabelDefinition 1462 LabelState models.LabelState 1463} 1464 1465func (p RepoNewPullParams) SourceRepo() string { 1466 if p.Fork != "" { 1467 return p.Fork 1468 } 1469 return p.RepoInfo.RepoDid 1470} 1471 1472type RepoNewPull_StepSourceParams struct { 1473 Branches []types.Branch 1474 SourceBranches []types.Branch 1475 ForkBranches []types.Branch 1476 Forks []models.Repo 1477 ErrorMsg string 1478 // selected values 1479 Source Source // source kind 1480 TargetBranch string 1481 Fork string // fork repo DID 1482 SourceBranch string 1483 Patch string 1484} 1485 1486type RepoNewPull_StepReviewParams struct { 1487 Commits []types.Commit 1488} 1489 1490type RepoNewPull_StepDetailsParams struct { 1491 Title string 1492 Body string 1493 TitleDirty bool 1494 BodyDirty bool 1495} 1496 1497type MergeCheckParams struct { 1498 IsConflicted bool 1499 Conflicts []*gitmirrorv1.MergeConflict 1500 Error string 1501} 1502 1503func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1504 params.Active = "pulls" 1505 return p.executeRepo("repo/pulls/new", w, params) 1506} 1507 1508func (p *Pages) PullComposeHostFragment(w io.Writer, params RepoNewPullParams) error { 1509 return p.executePlain("repo/pulls/fragments/pullComposeHost", w, params) 1510} 1511 1512func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error { 1513 return p.executePlain("fragments/markdownPreview", w, body) 1514} 1515 1516type EditPullParams struct { 1517 LoggedInUser *oauth.MultiAccountUser 1518 RepoInfo repoinfo.RepoInfo 1519 Pull *models.Pull 1520} 1521 1522func (p *Pages) EditPullFragment(w io.Writer, params EditPullParams) error { 1523 return p.executePlain("repo/pulls/fragments/pullEdit", w, params) 1524} 1525 1526type RepoPullsParams struct { 1527 BaseParams 1528 RepoInfo repoinfo.RepoInfo 1529 Pulls []*models.Pull 1530 Active string 1531 FilterState string 1532 FilterQuery string 1533 BaseFilterQuery string 1534 Pipelines map[string]types.Pipeline 1535 LabelDefs map[string]*models.LabelDefinition 1536 Page pagination.Page 1537 PullCount int 1538 VouchRelationships map[syntax.DID]*models.VouchRelationship 1539} 1540 1541func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 1542 params.Active = "pulls" 1543 return p.executeRepo("repo/pulls/pulls", w, params) 1544} 1545 1546type ResubmitResult uint64 1547 1548const ( 1549 ShouldResubmit ResubmitResult = iota 1550 ShouldNotResubmit 1551 Unknown 1552) 1553 1554func (r ResubmitResult) Yes() bool { 1555 return r == ShouldResubmit 1556} 1557func (r ResubmitResult) No() bool { 1558 return r == ShouldNotResubmit 1559} 1560func (r ResubmitResult) Unknown() bool { 1561 return r == Unknown 1562} 1563 1564type BranchDeleteStatus struct { 1565 Repo *models.Repo 1566 Branch string 1567} 1568 1569type PullPageBaseParams struct { 1570 BaseParams 1571 RepoInfo repoinfo.RepoInfo 1572 Pull *models.Pull 1573 1574 Backlinks []models.RichReferenceLink 1575 Commits []types.Commit // all commits between <target>..<pr/head> 1576 Pipelines map[string]types.Pipeline 1577 1578 MergeCheck MergeCheckParams 1579 ResubmitCheck ResubmitResult 1580 BranchDeleteStatus *BranchDeleteStatus 1581 1582 LabelDefs map[string]*models.LabelDefinition 1583 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1584 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1585 VouchRelationships map[syntax.DID]*models.VouchRelationship 1586 VouchSkips map[syntax.DID]bool 1587 1588 // IsSubscribed is nil when not logged in, true when subscribed, false when explicitly unsubscribed. 1589 IsSubscribed *bool 1590 1591 // diff, branch-delete-status, merge-check, resubmit-check, pipelines will be lazy-loaded. 1592} 1593 1594// /pulls/123/2/1a2b3c..d4e5f6 1595type PullDiffParams struct { 1596 PullPageBaseParams 1597 VersionId int 1598 1599 DiffParams DiffParams_Diff 1600 IsDiffBase bool 1601 IsDiffHead bool 1602 1603 ErrorMsg string 1604} 1605 1606func (p PullDiffParams) ActiveVersionId() int { 1607 return p.VersionId 1608} 1609 1610func (p PullDiffParams) ActiveCommitId() string { 1611 return p.DiffParams.Head 1612} 1613 1614func (p PullDiffParams) IsInterdiff() bool { 1615 return false 1616} 1617 1618func (p PullDiffParams) DisplayDiffBase() string { 1619 if p.IsDiffBase { 1620 return "base" 1621 } 1622 return shortId(p.DiffParams.Base) 1623} 1624 1625func (p PullDiffParams) DisplayDiffHead() string { 1626 if p.IsDiffHead { 1627 return "head" 1628 } 1629 return shortId(p.DiffParams.Head) 1630} 1631 1632// /pulls/123/1..2/abcdef 1633type PullInterdiffParams struct { 1634 PullPageBaseParams 1635 Version1 int 1636 Version2 int 1637 ChangeId string // optional change-id filter 1638 1639 DiffParams DiffParams 1640 ActiveCommitId string 1641 1642 ErrorMsg string 1643} 1644 1645func (p PullInterdiffParams) ActiveVersionId() int { 1646 return p.Version2 1647} 1648 1649func (p PullInterdiffParams) IsInterdiff() bool { 1650 return true 1651} 1652 1653type DiffParams struct { 1654 Diff *DiffParams_Diff 1655 Interdiff *DiffParams_Interdiff 1656} 1657 1658type DiffParams_Diff struct { 1659 Base string 1660 Head string 1661} 1662 1663type DiffParams_Interdiff struct { 1664 From DiffParams_Diff 1665 To DiffParams_Diff 1666} 1667 1668// DiffLine is one row of a unified (inline) diff. Old/New are 1-based line numbers into the 1669// base/head blob, or 0 when that side has no line here. Content is pre-rendered, safe HTML. 1670type DiffLine struct { 1671 Op string // " " context, "-" removed, "+" added 1672 Old int 1673 New int 1674 Content template.HTML 1675} 1676 1677// DiffCell is one side of a side-by-side row. Kind is "ctx", "del", "add", or "empty" (a 1678// blank padding cell). Num is the 1-based line number, or 0 when empty. 1679type DiffCell struct { 1680 Kind string 1681 Num int 1682 Content template.HTML 1683} 1684 1685// DiffRow is one side-by-side row: the left (base) and right (head) cells. 1686type DiffRow struct { 1687 Left DiffCell 1688 Right DiffCell 1689} 1690 1691// DiffHunk holds a hunk's rows; exactly one of Lines (unified) / Rows (split) is populated, 1692// depending on PullDiffFragmentParams.Split. 1693type DiffHunk struct { 1694 Lines []DiffLine 1695 Rows []DiffRow 1696} 1697 1698func (h DiffHunk) AtFileStart() bool { 1699 if len(h.Rows) > 0 { 1700 r := h.Rows[0] 1701 return r.Left.Num == 1 || r.Right.Num == 1 1702 } 1703 if len(h.Lines) > 0 { 1704 l := h.Lines[0] 1705 return l.Old == 1 || l.New == 1 1706 } 1707 return false 1708} 1709 1710// DiffFile is one changed file. Note is set (and Hunks empty) for binary/submodule files. 1711type DiffFile struct { 1712 Path string 1713 Note string 1714 Hunks []DiffHunk 1715} 1716 1717type PullDiffFragmentParams struct { 1718 BaseRepo syntax.DID 1719 HeadRepo syntax.DID 1720 DiffBase string 1721 DiffHead string 1722 DiffUrl string 1723 Unified bool 1724 Files []DiffFile 1725 1726 ErrorMsg string 1727} 1728 1729func (f *DiffFile) Id() string { 1730 return f.Path 1731} 1732 1733func (f *DiffFile) Stats() types.DiffFileStat { 1734 var ins, del int64 1735 for _, hunk := range f.Hunks { 1736 for _, line := range hunk.Lines { 1737 switch line.Op { 1738 case "+": 1739 ins++ 1740 case "-": 1741 del++ 1742 } 1743 } 1744 for _, row := range hunk.Rows { 1745 if row.Left.Kind == "del" { 1746 del++ 1747 } 1748 if row.Right.Kind == "add" { 1749 ins++ 1750 } 1751 } 1752 } 1753 return types.DiffFileStat{ 1754 Insertions: ins, 1755 Deletions: del, 1756 } 1757} 1758 1759func (p PullDiffFragmentParams) FileTree() *filetree.FileTreeNode { 1760 fs := make([]string, len(p.Files)) 1761 for i, s := range p.Files { 1762 fs[i] = s.Id() 1763 } 1764 return filetree.FileTree(fs) 1765} 1766 1767func (p PullDiffFragmentParams) Stats() types.DiffStat { 1768 var stat types.DiffStat 1769 for _, df := range p.Files { 1770 fileStats := df.Stats() 1771 stat.Insertions += fileStats.Insertions 1772 stat.Deletions += fileStats.Deletions 1773 } 1774 stat.FilesChanged = len(p.Files) 1775 return stat 1776} 1777 1778func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { 1779 return p.executeRepo("repo/pulls/single", w, params) 1780} 1781 1782func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error { 1783 return p.executeRepo("repo/pulls/single", w, params) 1784} 1785 1786func (p *Pages) PullDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1787 return p.executePlain("repo/pulls/fragments/diff", w, params) 1788} 1789 1790func (p *Pages) PullComposeDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1791 return p.executePlain("repo/pulls/fragments/composediff", w, params) 1792} 1793 1794type PullActionsParams struct { 1795 BaseParams 1796 RepoInfo repoinfo.RepoInfo 1797 Pull *models.Pull 1798 RoundNumber int 1799 MergeCheck MergeCheckParams 1800 ResubmitCheck ResubmitResult 1801 BranchDeleteStatus *BranchDeleteStatus 1802 1803 // Workflow warning state for fork-based pulls without a pipeline on the 1804 // latest commit. WorkflowsChanged and ChangedWorkflowFiles are computed 1805 // from the latest round's patch. 1806 WorkflowsChanged bool 1807 ChangedWorkflowFiles []string 1808 HasPipeline bool 1809 1810 // renders buttons in a pre-check state and attaches the hx-trigger="load" 1811 // that fetches the real, checked fragment 1812 Loading bool 1813} 1814 1815func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 1816 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 1817} 1818 1819type RepoCompareParams struct { 1820 BaseParams 1821 RepoInfo repoinfo.RepoInfo 1822 Forks []models.Repo 1823 Branches []types.Branch 1824 Tags []*types.TagReference 1825 Base string 1826 Head string 1827 Diff *types.NiceDiff 1828 DiffOpts types.DiffOpts 1829 1830 Active string 1831} 1832 1833func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 1834 params.Active = "overview" 1835 return p.executeRepo("repo/compare/compare", w, params) 1836} 1837 1838type RepoCompareNewParams struct { 1839 BaseParams 1840 RepoInfo repoinfo.RepoInfo 1841 Forks []models.Repo 1842 Branches []types.Branch 1843 Tags []*types.TagReference 1844 Base string 1845 Head string 1846 1847 Active string 1848} 1849 1850func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 1851 params.Active = "overview" 1852 return p.executeRepo("repo/compare/new", w, params) 1853} 1854 1855type RepoCompareAllowPullParams struct { 1856 BaseParams 1857 RepoInfo repoinfo.RepoInfo 1858 Base string 1859 Head string 1860} 1861 1862func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error { 1863 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1864} 1865 1866type RepoCompareDiffFragmentParams struct { 1867 Diff types.NiceDiff 1868 DiffOpts types.DiffOpts 1869} 1870 1871func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error { 1872 return p.executePlain("repo/fragments/diff", w, []any{&params.Diff, &params.DiffOpts}) 1873} 1874 1875type LabelPanelParams struct { 1876 BaseParams 1877 RepoInfo repoinfo.RepoInfo 1878 Defs map[string]*models.LabelDefinition 1879 Subject string 1880 State models.LabelState 1881} 1882 1883func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1884 return p.executePlain("repo/fragments/labelPanel", w, params) 1885} 1886 1887type EditLabelPanelParams struct { 1888 BaseParams 1889 RepoInfo repoinfo.RepoInfo 1890 Defs map[string]*models.LabelDefinition 1891 Subject string 1892 State models.LabelState 1893 Prefix string 1894} 1895 1896func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1897 return p.executePlain("repo/fragments/editLabelPanel", w, params) 1898} 1899 1900type RepoStarsParams struct { 1901 BaseParams 1902 RepoInfo repoinfo.RepoInfo 1903 Active string 1904 Starrers []models.Star 1905 Page pagination.Page 1906 TotalCount int 1907} 1908 1909func (p *Pages) RepoStars(w io.Writer, params RepoStarsParams) error { 1910 params.Active = "overview" 1911 return p.executeRepo("repo/stars", w, params) 1912} 1913 1914type RepoForksParams struct { 1915 BaseParams 1916 RepoInfo repoinfo.RepoInfo 1917 Active string 1918 Forks []models.Repo 1919 Page pagination.Page 1920 TotalCount int 1921} 1922 1923func (p *Pages) RepoForks(w io.Writer, params RepoForksParams) error { 1924 params.Active = "overview" 1925 return p.executeRepo("repo/forks", w, params) 1926} 1927 1928type PipelinesParams struct { 1929 BaseParams 1930 RepoInfo repoinfo.RepoInfo 1931 Pipelines []types.Pipeline 1932 Active string 1933 FilterKind string 1934 Total int64 1935} 1936 1937func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error { 1938 params.Active = "pipelines" 1939 return p.executeRepo("repo/pipelines/pipelines", w, params) 1940} 1941 1942type LogBlockParams struct { 1943 Id int 1944 Name string 1945 Command string 1946 Collapsed bool 1947 StartTime time.Time 1948} 1949 1950func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1951 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1952} 1953 1954type LogBlockEndParams struct { 1955 Id int 1956 StartTime time.Time 1957 EndTime time.Time 1958} 1959 1960func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1961 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1962} 1963 1964type LogLineParams struct { 1965 Id int 1966 Content template.HTML 1967} 1968 1969func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { 1970 return p.executePlain("repo/pipelines/fragments/logLine", w, params) 1971} 1972 1973type WorkflowSymbolOOBParams struct { 1974 Name string 1975 Statuses models.WorkflowStatus 1976} 1977 1978func (p *Pages) WorkflowSymbolOOB(w io.Writer, params WorkflowSymbolOOBParams) error { 1979 return p.executePlain("repo/pipelines/fragments/workflowSymbolOOB", w, params) 1980} 1981 1982type PipelineStatusesParams struct { 1983 RepoInfo repoinfo.RepoInfo 1984 Pipelines map[string]types.Pipeline 1985} 1986 1987func (p *Pages) PipelineStatusesFragment(w io.Writer, params PipelineStatusesParams) error { 1988 return p.executePlain("repo/fragments/pipelineStatuses", w, params) 1989} 1990 1991type WorkflowParams struct { 1992 BaseParams 1993 RepoInfo repoinfo.RepoInfo 1994 Pipeline types.Pipeline 1995 Workflow string 1996 SSHLogCommand string 1997 Active string 1998} 1999 2000func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 2001 params.Active = "pipelines" 2002 return p.executeRepo("repo/pipelines/workflow", w, params) 2003} 2004 2005type PutStringParams struct { 2006 BaseParams 2007 Action string 2008 2009 // this is supplied in the case of editing an existing string 2010 String models.String 2011} 2012 2013func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 2014 return p.execute("strings/put", w, params) 2015} 2016 2017type StringsDashboardParams struct { 2018 BaseParams 2019 Card ProfileCard 2020 Strings []models.String 2021} 2022 2023func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 2024 return p.execute("strings/dashboard", w, params) 2025} 2026 2027type StringTimelineParams struct { 2028 BaseParams 2029 Strings []models.String 2030} 2031 2032func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 2033 return p.execute("strings/timeline", w, params) 2034} 2035 2036type SingleStringParams struct { 2037 BaseParams 2038 ShowRendered bool 2039 RenderToggle bool 2040 RenderedContents template.HTML 2041 String *models.String 2042 Stats models.StringStats 2043 IsStarred bool 2044 StarCount int 2045 Owner identity.Identity 2046 CommentList []models.CommentListItem 2047 2048 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 2049 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 2050 VouchRelationships map[syntax.DID]*models.VouchRelationship 2051} 2052 2053func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 2054 return p.execute("strings/string", w, params) 2055} 2056 2057type SearchReposParams struct { 2058 BaseParams 2059 FilterType string // "repo" | "code" 2060 Repos []SearchResult 2061 Page pagination.Page 2062 ResultCount int 2063 FilterQuery string 2064 SortParam string 2065 TimeTaken time.Duration 2066 DocCount int64 2067 ErrorMsg string 2068} 2069 2070func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error { 2071 params.FilterType = "repo" 2072 return p.execute("search/search", w, params) 2073} 2074 2075type SearchQuickParams struct { 2076 Repos []models.Repo 2077 Query string 2078 Total int 2079} 2080 2081func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error { 2082 return p.executePlain("search/fragments/quick", w, params) 2083} 2084 2085func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error { 2086 tpl, err := p.parse("search/fragments/quick") 2087 if err != nil { 2088 return err 2089 } 2090 return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params) 2091} 2092 2093type SearchResult struct { 2094 RepoDID syntax.DID 2095 Repo *models.Repo 2096 FilePath string 2097 Branches []string 2098 Commit string 2099 Language string 2100 2101 File *CodeSearchResult_File // filename match 2102 Chunks CodeSearchResult_Chunks // content matches 2103} 2104 2105// CodeSearchResult_Chunk is a content match with its lines pre-rendered. 2106type CodeSearchResult_Chunk struct { 2107 Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges 2108 MatchCount int // number of match ranges in this chunk 2109} 2110 2111type CodeSearchResult_Chunks []CodeSearchResult_Chunk 2112 2113func (cs CodeSearchResult_Chunks) MatchCount() int { 2114 count := 0 2115 for _, c := range cs { 2116 count += c.MatchCount 2117 } 2118 return count 2119} 2120 2121type CodeSearchResult_File struct { 2122 NameSpans []ChunkSpan // precomputed from FilePath/Ranges 2123} 2124 2125type ChunkSpan struct { 2126 Text string 2127 Match bool 2128} 2129 2130type ChunkLine struct { 2131 Num int 2132 Spans []ChunkSpan 2133 Highlight bool 2134} 2135 2136// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each 2137// line into matched/unmatched spans using ranges. startLine is the 1-based line 2138// number of the first line. 2139func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine { 2140 if startLine < 1 { 2141 startLine = 1 2142 } 2143 // trim a single trailing newline so we don't emit a spurious empty line 2144 content = strings.TrimSuffix(content, "\n") 2145 lines := strings.Split(content, "\n") 2146 out := make([]ChunkLine, len(lines)) 2147 for i, text := range lines { 2148 num := startLine + i 2149 runes := []rune(text) 2150 2151 // collect matched rune intervals [c0,c1) for this line 2152 var intervals [][2]int 2153 for _, rg := range ranges { 2154 if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) { 2155 continue 2156 } 2157 c0, c1 := 0, len(runes) 2158 if num == int(rg.Start.LineNumber) { 2159 c0 = int(rg.Start.Column) - 1 2160 } 2161 if num == int(rg.End.LineNumber) { 2162 c1 = int(rg.End.Column) - 1 2163 } 2164 c0 = max(0, min(c0, len(runes))) 2165 c1 = max(0, min(c1, len(runes))) 2166 if c0 < c1 { 2167 intervals = append(intervals, [2]int{c0, c1}) 2168 } 2169 } 2170 intervals = mergeIntervals(intervals) 2171 2172 out[i] = ChunkLine{ 2173 Num: num, 2174 Spans: spanRunes(runes, intervals), 2175 Highlight: len(intervals) > 0, 2176 } 2177 } 2178 return out 2179} 2180 2181// FileNameSpans splits a filename into matched/unmatched spans using ranges. 2182// Filename ranges live on line 1; columns are clamped to rune bounds. 2183func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan { 2184 runes := []rune(name) 2185 var intervals [][2]int 2186 for _, rg := range ranges { 2187 if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 { 2188 continue 2189 } 2190 c0 := max(0, min(int(rg.Start.Column)-1, len(runes))) 2191 c1 := max(0, min(int(rg.End.Column)-1, len(runes))) 2192 if c0 < c1 { 2193 intervals = append(intervals, [2]int{c0, c1}) 2194 } 2195 } 2196 return spanRunes(runes, mergeIntervals(intervals)) 2197} 2198 2199type CodeSearchParams struct { 2200 BaseParams 2201 FilterType string // "code" 2202 FilterQuery string 2203 Results []SearchResult 2204 Page pagination.Page 2205 HasMore bool 2206 ErrorMsg string 2207 2208 MatchCount int 2209 FileCount int 2210 TimeTaken time.Duration 2211} 2212 2213func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error { 2214 params.FilterType = "code" 2215 return p.execute("search/search", w, params) 2216} 2217 2218func (p *Pages) Home(w io.Writer, params TimelineParams) error { 2219 return p.execute("timeline/home", w, params) 2220} 2221 2222type CommentBodyFragmentParams struct { 2223 Comment models.Comment 2224 Reactions map[models.ReactionKind]models.ReactionDisplayData 2225 UserReacted map[models.ReactionKind]bool 2226} 2227 2228func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error { 2229 return p.executePlain("fragments/comment/commentBody", w, params) 2230} 2231 2232type PullCommentFragmentParams struct { 2233 LoggedInUser *oauth.MultiAccountUser 2234 Comment models.Comment 2235 Reactions map[models.ReactionKind]models.ReactionDisplayData 2236 UserReacted map[models.ReactionKind]bool 2237 HxSwapOob bool 2238} 2239 2240func (p *Pages) PullCommentFragment(w io.Writer, params PullCommentFragmentParams) error { 2241 return p.executePlain("fragments/comment/pullComment", w, params) 2242} 2243 2244type CommentHeaderFragmentParams struct { 2245 Comment models.Comment 2246 Reactions map[models.ReactionKind]models.ReactionDisplayData 2247 UserReacted map[models.ReactionKind]bool 2248 HxSwapOob bool 2249} 2250 2251func (p *Pages) CommentHeaderFragment(w io.Writer, params CommentHeaderFragmentParams) error { 2252 return p.executePlain("fragments/comment/commentHeader", w, params) 2253} 2254 2255type EditCommentFragmentParams struct { 2256 Comment models.Comment 2257} 2258 2259func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error { 2260 return p.executePlain("fragments/comment/edit", w, params) 2261} 2262 2263type ReplyCommentFragmentParams struct { 2264 BaseParams 2265} 2266 2267func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error { 2268 return p.executePlain("fragments/comment/reply", w, params) 2269} 2270 2271type ReplyPlaceholderFragmentParams struct { 2272 BaseParams 2273} 2274 2275func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error { 2276 return p.executePlain("fragments/comment/replyPlaceholder", w, params) 2277} 2278 2279func (p *Pages) Static() http.Handler { 2280 if p.dev { 2281 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 2282 } 2283 2284 sub, err := fs.Sub(p.embedFS, "static") 2285 if err != nil { 2286 p.logger.Error("no static dir found? that's crazy", "err", err) 2287 panic(err) 2288 } 2289 // Custom handler to apply Cache-Control headers for font files 2290 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 2291} 2292 2293func Cache(h http.Handler) http.Handler { 2294 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2295 path := strings.Split(r.URL.Path, "?")[0] 2296 2297 if strings.HasSuffix(path, ".css") { 2298 // on day for css files 2299 w.Header().Set("Cache-Control", "public, max-age=86400") 2300 } else { 2301 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 2302 } 2303 h.ServeHTTP(w, r) 2304 }) 2305} 2306 2307func (p *Pages) CssContentHash() string { 2308 cssFile, err := p.embedFS.Open("static/tw.css") 2309 if err != nil { 2310 slog.Debug("Error opening CSS file", "err", err) 2311 return "" 2312 } 2313 defer cssFile.Close() 2314 2315 hasher := sha256.New() 2316 if _, err := io.Copy(hasher, cssFile); err != nil { 2317 slog.Debug("Error hashing CSS file", "err", err) 2318 return "" 2319 } 2320 2321 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 2322} 2323 2324func (p *Pages) DangerPasswordTokenStep(w io.Writer) error { 2325 return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil) 2326} 2327 2328func (p *Pages) DangerPasswordSuccess(w io.Writer) error { 2329 return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil) 2330} 2331 2332func (p *Pages) DangerDeleteTokenStep(w io.Writer) error { 2333 return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil) 2334} 2335 2336func (p *Pages) Error500(w io.Writer) error { 2337 return p.execute("errors/500", w, BaseParams{}) 2338} 2339 2340func (p *Pages) Error404(w io.Writer) error { 2341 return p.execute("errors/404", w, BaseParams{}) 2342} 2343 2344func (p *Pages) ErrorKnot404(w io.Writer) error { 2345 return p.execute("errors/knot404", w, BaseParams{}) 2346} 2347 2348func (p *Pages) Error503(w io.Writer) error { 2349 return p.execute("errors/503", w, BaseParams{}) 2350}