forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package pages 2 3import ( 4 "bytes" 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 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" 21 "tangled.org/core/appview/config" 22 "tangled.org/core/appview/models" 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages/markup" 25 "tangled.org/core/appview/pages/repoinfo" 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/patchutil" 29 "tangled.org/core/types" 30 31 "github.com/alecthomas/chroma/v2" 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 "github.com/alecthomas/chroma/v2/lexers" 34 "github.com/alecthomas/chroma/v2/styles" 35 "github.com/bluesky-social/indigo/atproto/identity" 36 "github.com/bluesky-social/indigo/atproto/syntax" 37 "github.com/go-git/go-git/v5/plumbing" 38 "github.com/go-git/go-git/v5/plumbing/object" 39) 40 41//go:embed templates/* static legal 42var Files embed.FS 43 44type Pages struct { 45 mu sync.RWMutex 46 cache *TmplCache[string, *template.Template] 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver 50 dev bool 51 embedFS fs.FS 52 templateDir string // Path to templates on disk for dev mode 53 rctx *markup.RenderContext 54 logger *slog.Logger 55} 56 57func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 // initialized with safe defaults, can be overriden per use 59 rctx := &markup.RenderContext{ 60 IsDev: config.Core.Dev, 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 Files: Files, 65 } 66 67 p := &Pages{ 68 mu: sync.RWMutex{}, 69 cache: NewTmplCache[string, *template.Template](), 70 dev: config.Core.Dev, 71 avatar: config.Avatar, 72 rctx: rctx, 73 resolver: res, 74 templateDir: "appview/pages", 75 logger: logger, 76 } 77 78 if p.dev { 79 p.embedFS = os.DirFS(p.templateDir) 80 } else { 81 p.embedFS = Files 82 } 83 84 return p 85} 86 87// reverse of pathToName 88func (p *Pages) nameToPath(s string) string { 89 return "templates/" + s + ".html" 90} 91 92func (p *Pages) fragmentPaths() ([]string, error) { 93 var fragmentPaths []string 94 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 95 if err != nil { 96 return err 97 } 98 if d.IsDir() { 99 return nil 100 } 101 if !strings.HasSuffix(path, ".html") { 102 return nil 103 } 104 if !strings.Contains(path, "fragments/") { 105 return nil 106 } 107 fragmentPaths = append(fragmentPaths, path) 108 return nil 109 }) 110 if err != nil { 111 return nil, err 112 } 113 114 return fragmentPaths, nil 115} 116 117// parse without memoization 118func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 119 paths, err := p.fragmentPaths() 120 if err != nil { 121 return nil, err 122 } 123 for _, s := range stack { 124 paths = append(paths, p.nameToPath(s)) 125 } 126 127 funcs := p.funcMap() 128 top := stack[len(stack)-1] 129 parsed, err := template.New(top). 130 Funcs(funcs). 131 ParseFS(p.embedFS, paths...) 132 if err != nil { 133 return nil, err 134 } 135 136 return parsed, nil 137} 138 139func (p *Pages) parse(stack ...string) (*template.Template, error) { 140 key := strings.Join(stack, "|") 141 142 // never cache in dev mode 143 if cached, exists := p.cache.Get(key); !p.dev && exists { 144 return cached, nil 145 } 146 147 result, err := p.rawParse(stack...) 148 if err != nil { 149 return nil, err 150 } 151 152 p.cache.Set(key, result) 153 return result, nil 154} 155 156func (p *Pages) parseBase(top string) (*template.Template, error) { 157 stack := []string{ 158 "layouts/base", 159 top, 160 } 161 return p.parse(stack...) 162} 163 164func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 165 stack := []string{ 166 "layouts/base", 167 "layouts/repobase", 168 top, 169 } 170 return p.parse(stack...) 171} 172 173func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 174 stack := []string{ 175 "layouts/base", 176 "layouts/profilebase", 177 top, 178 } 179 return p.parse(stack...) 180} 181 182func (p *Pages) executePlain(name string, w io.Writer, params any) error { 183 tpl, err := p.parse(name) 184 if err != nil { 185 return err 186 } 187 188 return tpl.Execute(w, params) 189} 190 191func (p *Pages) execute(name string, w io.Writer, params any) error { 192 tpl, err := p.parseBase(name) 193 if err != nil { 194 return err 195 } 196 197 return tpl.ExecuteTemplate(w, "layouts/base", params) 198} 199 200func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 201 tpl, err := p.parseRepoBase(name) 202 if err != nil { 203 return err 204 } 205 206 return tpl.ExecuteTemplate(w, "layouts/base", params) 207} 208 209func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 210 tpl, err := p.parseProfileBase(name) 211 if err != nil { 212 return err 213 } 214 215 return tpl.ExecuteTemplate(w, "layouts/base", params) 216} 217 218func (p *Pages) Favicon(w io.Writer) error { 219 return p.executePlain("fragments/dolly/silhouette", w, nil) 220} 221 222type LoginParams struct { 223 ReturnUrl string 224} 225 226func (p *Pages) Login(w io.Writer, params LoginParams) error { 227 return p.executePlain("user/login", w, params) 228} 229 230type SignupParams struct { 231 CloudflareSiteKey string 232} 233 234func (p *Pages) Signup(w io.Writer, params SignupParams) error { 235 return p.executePlain("user/signup", w, params) 236} 237 238func (p *Pages) CompleteSignup(w io.Writer) error { 239 return p.executePlain("user/completeSignup", w, nil) 240} 241 242type TermsOfServiceParams struct { 243 LoggedInUser *oauth.User 244 Content template.HTML 245} 246 247func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 248 filename := "terms.md" 249 filePath := filepath.Join("legal", filename) 250 251 file, err := p.embedFS.Open(filePath) 252 if err != nil { 253 return fmt.Errorf("failed to read %s: %w", filename, err) 254 } 255 defer file.Close() 256 257 markdownBytes, err := io.ReadAll(file) 258 if err != nil { 259 return fmt.Errorf("failed to read %s: %w", filename, err) 260 } 261 262 p.rctx.RendererType = markup.RendererTypeDefault 263 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 264 sanitized := p.rctx.SanitizeDefault(htmlString) 265 params.Content = template.HTML(sanitized) 266 267 return p.execute("legal/terms", w, params) 268} 269 270type PrivacyPolicyParams struct { 271 LoggedInUser *oauth.User 272 Content template.HTML 273} 274 275func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 276 filename := "privacy.md" 277 filePath := filepath.Join("legal", filename) 278 279 file, err := p.embedFS.Open(filePath) 280 if err != nil { 281 return fmt.Errorf("failed to read %s: %w", filename, err) 282 } 283 defer file.Close() 284 285 markdownBytes, err := io.ReadAll(file) 286 if err != nil { 287 return fmt.Errorf("failed to read %s: %w", filename, err) 288 } 289 290 p.rctx.RendererType = markup.RendererTypeDefault 291 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 292 sanitized := p.rctx.SanitizeDefault(htmlString) 293 params.Content = template.HTML(sanitized) 294 295 return p.execute("legal/privacy", w, params) 296} 297 298type BrandParams struct { 299 LoggedInUser *oauth.User 300} 301 302func (p *Pages) Brand(w io.Writer, params BrandParams) error { 303 return p.execute("brand/brand", w, params) 304} 305 306type TimelineParams struct { 307 LoggedInUser *oauth.User 308 Timeline []models.TimelineEvent 309 Repos []models.Repo 310 GfiLabel *models.LabelDefinition 311} 312 313func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 314 return p.execute("timeline/timeline", w, params) 315} 316 317type GoodFirstIssuesParams struct { 318 LoggedInUser *oauth.User 319 Issues []models.Issue 320 RepoGroups []*models.RepoGroup 321 LabelDefs map[string]*models.LabelDefinition 322 GfiLabel *models.LabelDefinition 323 Page pagination.Page 324} 325 326func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 327 return p.execute("goodfirstissues/index", w, params) 328} 329 330type UserProfileSettingsParams struct { 331 LoggedInUser *oauth.User 332 Tabs []map[string]any 333 Tab string 334} 335 336func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 337 return p.execute("user/settings/profile", w, params) 338} 339 340type NotificationsParams struct { 341 LoggedInUser *oauth.User 342 Notifications []*models.NotificationWithEntity 343 UnreadCount int 344 Page pagination.Page 345 Total int64 346} 347 348func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 349 return p.execute("notifications/list", w, params) 350} 351 352type NotificationItemParams struct { 353 Notification *models.Notification 354} 355 356func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 357 return p.executePlain("notifications/fragments/item", w, params) 358} 359 360type NotificationCountParams struct { 361 Count int64 362} 363 364func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 365 return p.executePlain("notifications/fragments/count", w, params) 366} 367 368type UserKeysSettingsParams struct { 369 LoggedInUser *oauth.User 370 PubKeys []models.PublicKey 371 Tabs []map[string]any 372 Tab string 373} 374 375func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 376 return p.execute("user/settings/keys", w, params) 377} 378 379type UserEmailsSettingsParams struct { 380 LoggedInUser *oauth.User 381 Emails []models.Email 382 Tabs []map[string]any 383 Tab string 384} 385 386func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 387 return p.execute("user/settings/emails", w, params) 388} 389 390type UserNotificationSettingsParams struct { 391 LoggedInUser *oauth.User 392 Preferences *models.NotificationPreferences 393 Tabs []map[string]any 394 Tab string 395} 396 397func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 398 return p.execute("user/settings/notifications", w, params) 399} 400 401type UpgradeBannerParams struct { 402 Registrations []models.Registration 403 Spindles []models.Spindle 404} 405 406func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 407 return p.executePlain("banner", w, params) 408} 409 410type KnotsParams struct { 411 LoggedInUser *oauth.User 412 Registrations []models.Registration 413} 414 415func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 416 return p.execute("knots/index", w, params) 417} 418 419type KnotParams struct { 420 LoggedInUser *oauth.User 421 Registration *models.Registration 422 Members []string 423 Repos map[string][]models.Repo 424 IsOwner bool 425} 426 427func (p *Pages) Knot(w io.Writer, params KnotParams) error { 428 return p.execute("knots/dashboard", w, params) 429} 430 431type KnotListingParams struct { 432 *models.Registration 433} 434 435func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 436 return p.executePlain("knots/fragments/knotListing", w, params) 437} 438 439type SpindlesParams struct { 440 LoggedInUser *oauth.User 441 Spindles []models.Spindle 442} 443 444func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { 445 return p.execute("spindles/index", w, params) 446} 447 448type SpindleListingParams struct { 449 models.Spindle 450} 451 452func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 453 return p.executePlain("spindles/fragments/spindleListing", w, params) 454} 455 456type SpindleDashboardParams struct { 457 LoggedInUser *oauth.User 458 Spindle models.Spindle 459 Members []string 460 Repos map[string][]models.Repo 461} 462 463func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 464 return p.execute("spindles/dashboard", w, params) 465} 466 467type NewRepoParams struct { 468 LoggedInUser *oauth.User 469 Knots []string 470} 471 472func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 473 return p.execute("repo/new", w, params) 474} 475 476type ForkRepoParams struct { 477 LoggedInUser *oauth.User 478 Knots []string 479 RepoInfo repoinfo.RepoInfo 480} 481 482func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 483 return p.execute("repo/fork", w, params) 484} 485 486type ProfileCard struct { 487 UserDid string 488 UserHandle string 489 FollowStatus models.FollowStatus 490 Punchcard *models.Punchcard 491 Profile *models.Profile 492 Stats ProfileStats 493 Active string 494} 495 496type ProfileStats struct { 497 RepoCount int64 498 StarredCount int64 499 StringCount int64 500 FollowersCount int64 501 FollowingCount int64 502} 503 504func (p *ProfileCard) GetTabs() [][]any { 505 tabs := [][]any{ 506 {"overview", "overview", "square-chart-gantt", nil}, 507 {"repos", "repos", "book-marked", p.Stats.RepoCount}, 508 {"starred", "starred", "star", p.Stats.StarredCount}, 509 {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 510 } 511 512 return tabs 513} 514 515type ProfileOverviewParams struct { 516 LoggedInUser *oauth.User 517 Repos []models.Repo 518 CollaboratingRepos []models.Repo 519 ProfileTimeline *models.ProfileTimeline 520 Card *ProfileCard 521 Active string 522} 523 524func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 525 params.Active = "overview" 526 return p.executeProfile("user/overview", w, params) 527} 528 529type ProfileReposParams struct { 530 LoggedInUser *oauth.User 531 Repos []models.Repo 532 Card *ProfileCard 533 Active string 534} 535 536func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 537 params.Active = "repos" 538 return p.executeProfile("user/repos", w, params) 539} 540 541type ProfileStarredParams struct { 542 LoggedInUser *oauth.User 543 Repos []models.Repo 544 Card *ProfileCard 545 Active string 546} 547 548func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 549 params.Active = "starred" 550 return p.executeProfile("user/starred", w, params) 551} 552 553type ProfileStringsParams struct { 554 LoggedInUser *oauth.User 555 Strings []models.String 556 Card *ProfileCard 557 Active string 558} 559 560func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 561 params.Active = "strings" 562 return p.executeProfile("user/strings", w, params) 563} 564 565type FollowCard struct { 566 UserDid string 567 LoggedInUser *oauth.User 568 FollowStatus models.FollowStatus 569 FollowersCount int64 570 FollowingCount int64 571 Profile *models.Profile 572} 573 574type ProfileFollowersParams struct { 575 LoggedInUser *oauth.User 576 Followers []FollowCard 577 Card *ProfileCard 578 Active string 579} 580 581func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 582 params.Active = "overview" 583 return p.executeProfile("user/followers", w, params) 584} 585 586type ProfileFollowingParams struct { 587 LoggedInUser *oauth.User 588 Following []FollowCard 589 Card *ProfileCard 590 Active string 591} 592 593func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 594 params.Active = "overview" 595 return p.executeProfile("user/following", w, params) 596} 597 598type FollowFragmentParams struct { 599 UserDid string 600 FollowStatus models.FollowStatus 601} 602 603func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 604 return p.executePlain("user/fragments/follow", w, params) 605} 606 607type EditBioParams struct { 608 LoggedInUser *oauth.User 609 Profile *models.Profile 610} 611 612func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { 613 return p.executePlain("user/fragments/editBio", w, params) 614} 615 616type EditPinsParams struct { 617 LoggedInUser *oauth.User 618 Profile *models.Profile 619 AllRepos []PinnedRepo 620} 621 622type PinnedRepo struct { 623 IsPinned bool 624 models.Repo 625} 626 627func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { 628 return p.executePlain("user/fragments/editPins", w, params) 629} 630 631type RepoStarFragmentParams struct { 632 IsStarred bool 633 RepoAt syntax.ATURI 634 Stats models.RepoStats 635} 636 637func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 638 return p.executePlain("repo/fragments/repoStar", w, params) 639} 640 641type RepoDescriptionParams struct { 642 RepoInfo repoinfo.RepoInfo 643} 644 645func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 646 return p.executePlain("repo/fragments/editRepoDescription", w, params) 647} 648 649func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 650 return p.executePlain("repo/fragments/repoDescription", w, params) 651} 652 653type RepoIndexParams struct { 654 LoggedInUser *oauth.User 655 RepoInfo repoinfo.RepoInfo 656 Active string 657 TagMap map[string][]string 658 CommitsTrunc []*object.Commit 659 TagsTrunc []*types.TagReference 660 BranchesTrunc []types.Branch 661 // ForkInfo *types.ForkInfo 662 HTMLReadme template.HTML 663 Raw bool 664 EmailToDidOrHandle map[string]string 665 VerifiedCommits commitverify.VerifiedCommits 666 Languages []types.RepoLanguageDetails 667 Pipelines map[string]models.Pipeline 668 NeedsKnotUpgrade bool 669 types.RepoIndexResponse 670} 671 672func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 673 params.Active = "overview" 674 if params.IsEmpty { 675 return p.executeRepo("repo/empty", w, params) 676 } 677 678 if params.NeedsKnotUpgrade { 679 return p.executeRepo("repo/needsUpgrade", w, params) 680 } 681 682 p.rctx.RepoInfo = params.RepoInfo 683 p.rctx.RepoInfo.Ref = params.Ref 684 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 685 686 if params.ReadmeFileName != "" { 687 ext := filepath.Ext(params.ReadmeFileName) 688 switch ext { 689 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 690 params.Raw = false 691 htmlString := p.rctx.RenderMarkdown(params.Readme) 692 sanitized := p.rctx.SanitizeDefault(htmlString) 693 params.HTMLReadme = template.HTML(sanitized) 694 default: 695 params.Raw = true 696 } 697 } 698 699 return p.executeRepo("repo/index", w, params) 700} 701 702type RepoLogParams struct { 703 LoggedInUser *oauth.User 704 RepoInfo repoinfo.RepoInfo 705 TagMap map[string][]string 706 types.RepoLogResponse 707 Active string 708 EmailToDidOrHandle map[string]string 709 VerifiedCommits commitverify.VerifiedCommits 710 Pipelines map[string]models.Pipeline 711} 712 713func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 714 params.Active = "overview" 715 return p.executeRepo("repo/log", w, params) 716} 717 718type RepoCommitParams struct { 719 LoggedInUser *oauth.User 720 RepoInfo repoinfo.RepoInfo 721 Active string 722 EmailToDidOrHandle map[string]string 723 Pipeline *models.Pipeline 724 DiffOpts types.DiffOpts 725 726 // singular because it's always going to be just one 727 VerifiedCommit commitverify.VerifiedCommits 728 729 types.RepoCommitResponse 730} 731 732func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 733 params.Active = "overview" 734 return p.executeRepo("repo/commit", w, params) 735} 736 737type RepoTreeParams struct { 738 LoggedInUser *oauth.User 739 RepoInfo repoinfo.RepoInfo 740 Active string 741 BreadCrumbs [][]string 742 TreePath string 743 Raw bool 744 HTMLReadme template.HTML 745 types.RepoTreeResponse 746} 747 748type RepoTreeStats struct { 749 NumFolders uint64 750 NumFiles uint64 751} 752 753func (r RepoTreeParams) TreeStats() RepoTreeStats { 754 numFolders, numFiles := 0, 0 755 for _, f := range r.Files { 756 if !f.IsFile { 757 numFolders += 1 758 } else if f.IsFile { 759 numFiles += 1 760 } 761 } 762 763 return RepoTreeStats{ 764 NumFolders: uint64(numFolders), 765 NumFiles: uint64(numFiles), 766 } 767} 768 769func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 770 params.Active = "overview" 771 772 p.rctx.RepoInfo = params.RepoInfo 773 p.rctx.RepoInfo.Ref = params.Ref 774 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 775 776 if params.ReadmeFileName != "" { 777 ext := filepath.Ext(params.ReadmeFileName) 778 switch ext { 779 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 780 params.Raw = false 781 htmlString := p.rctx.RenderMarkdown(params.Readme) 782 sanitized := p.rctx.SanitizeDefault(htmlString) 783 params.HTMLReadme = template.HTML(sanitized) 784 default: 785 params.Raw = true 786 } 787 } 788 789 return p.executeRepo("repo/tree", w, params) 790} 791 792type RepoBranchesParams struct { 793 LoggedInUser *oauth.User 794 RepoInfo repoinfo.RepoInfo 795 Active string 796 types.RepoBranchesResponse 797} 798 799func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 800 params.Active = "overview" 801 return p.executeRepo("repo/branches", w, params) 802} 803 804type RepoTagsParams struct { 805 LoggedInUser *oauth.User 806 RepoInfo repoinfo.RepoInfo 807 Active string 808 types.RepoTagsResponse 809 ArtifactMap map[plumbing.Hash][]models.Artifact 810 DanglingArtifacts []models.Artifact 811} 812 813func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 814 params.Active = "overview" 815 return p.executeRepo("repo/tags", w, params) 816} 817 818type RepoArtifactParams struct { 819 LoggedInUser *oauth.User 820 RepoInfo repoinfo.RepoInfo 821 Artifact models.Artifact 822} 823 824func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { 825 return p.executePlain("repo/fragments/artifact", w, params) 826} 827 828type RepoBlobParams struct { 829 LoggedInUser *oauth.User 830 RepoInfo repoinfo.RepoInfo 831 Active string 832 Unsupported bool 833 IsImage bool 834 IsVideo bool 835 ContentSrc string 836 BreadCrumbs [][]string 837 ShowRendered bool 838 RenderToggle bool 839 RenderedContents template.HTML 840 *tangled.RepoBlob_Output 841 // Computed fields for template compatibility 842 Contents string 843 Lines int 844 SizeHint uint64 845 IsBinary bool 846} 847 848func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 849 var style *chroma.Style = styles.Get("catpuccin-latte") 850 851 if params.ShowRendered { 852 switch markup.GetFormat(params.Path) { 853 case markup.FormatMarkdown: 854 p.rctx.RepoInfo = params.RepoInfo 855 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 856 htmlString := p.rctx.RenderMarkdown(params.Contents) 857 sanitized := p.rctx.SanitizeDefault(htmlString) 858 params.RenderedContents = template.HTML(sanitized) 859 } 860 } 861 862 c := params.Contents 863 formatter := chromahtml.New( 864 chromahtml.InlineCode(false), 865 chromahtml.WithLineNumbers(true), 866 chromahtml.WithLinkableLineNumbers(true, "L"), 867 chromahtml.Standalone(false), 868 chromahtml.WithClasses(true), 869 ) 870 871 lexer := lexers.Get(filepath.Base(params.Path)) 872 if lexer == nil { 873 lexer = lexers.Fallback 874 } 875 876 iterator, err := lexer.Tokenise(nil, c) 877 if err != nil { 878 return fmt.Errorf("chroma tokenize: %w", err) 879 } 880 881 var code bytes.Buffer 882 err = formatter.Format(&code, style, iterator) 883 if err != nil { 884 return fmt.Errorf("chroma format: %w", err) 885 } 886 887 params.Contents = code.String() 888 params.Active = "overview" 889 return p.executeRepo("repo/blob", w, params) 890} 891 892type Collaborator struct { 893 Did string 894 Handle string 895 Role string 896} 897 898type RepoSettingsParams struct { 899 LoggedInUser *oauth.User 900 RepoInfo repoinfo.RepoInfo 901 Collaborators []Collaborator 902 Active string 903 Branches []types.Branch 904 Spindles []string 905 CurrentSpindle string 906 Secrets []*tangled.RepoListSecrets_Secret 907 908 // TODO: use repoinfo.roles 909 IsCollaboratorInviteAllowed bool 910} 911 912func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 913 params.Active = "settings" 914 return p.executeRepo("repo/settings", w, params) 915} 916 917type RepoGeneralSettingsParams struct { 918 LoggedInUser *oauth.User 919 RepoInfo repoinfo.RepoInfo 920 Labels []models.LabelDefinition 921 DefaultLabels []models.LabelDefinition 922 SubscribedLabels map[string]struct{} 923 ShouldSubscribeAll bool 924 Active string 925 Tabs []map[string]any 926 Tab string 927 Branches []types.Branch 928} 929 930func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 931 params.Active = "settings" 932 return p.executeRepo("repo/settings/general", w, params) 933} 934 935type RepoAccessSettingsParams struct { 936 LoggedInUser *oauth.User 937 RepoInfo repoinfo.RepoInfo 938 Active string 939 Tabs []map[string]any 940 Tab string 941 Collaborators []Collaborator 942} 943 944func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 945 params.Active = "settings" 946 return p.executeRepo("repo/settings/access", w, params) 947} 948 949type RepoPipelineSettingsParams struct { 950 LoggedInUser *oauth.User 951 RepoInfo repoinfo.RepoInfo 952 Active string 953 Tabs []map[string]any 954 Tab string 955 Spindles []string 956 CurrentSpindle string 957 Secrets []map[string]any 958} 959 960func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 961 params.Active = "settings" 962 return p.executeRepo("repo/settings/pipelines", w, params) 963} 964 965type RepoIssuesParams struct { 966 LoggedInUser *oauth.User 967 RepoInfo repoinfo.RepoInfo 968 Active string 969 Issues []models.Issue 970 LabelDefs map[string]*models.LabelDefinition 971 Page pagination.Page 972 FilteringByOpen bool 973} 974 975func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 976 params.Active = "issues" 977 return p.executeRepo("repo/issues/issues", w, params) 978} 979 980type RepoSingleIssueParams struct { 981 LoggedInUser *oauth.User 982 RepoInfo repoinfo.RepoInfo 983 Active string 984 Issue *models.Issue 985 CommentList []models.CommentListItem 986 LabelDefs map[string]*models.LabelDefinition 987 988 OrderedReactionKinds []models.ReactionKind 989 Reactions map[models.ReactionKind]models.ReactionDisplayData 990 UserReacted map[models.ReactionKind]bool 991} 992 993func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 994 params.Active = "issues" 995 return p.executeRepo("repo/issues/issue", w, params) 996} 997 998type EditIssueParams struct { 999 LoggedInUser *oauth.User 1000 RepoInfo repoinfo.RepoInfo 1001 Issue *models.Issue 1002 Action string 1003} 1004 1005func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 1006 params.Action = "edit" 1007 return p.executePlain("repo/issues/fragments/putIssue", w, params) 1008} 1009 1010type ThreadReactionFragmentParams struct { 1011 ThreadAt syntax.ATURI 1012 Kind models.ReactionKind 1013 Count int 1014 Users []string 1015 IsReacted bool 1016} 1017 1018func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 1019 return p.executePlain("repo/fragments/reaction", w, params) 1020} 1021 1022type RepoNewIssueParams struct { 1023 LoggedInUser *oauth.User 1024 RepoInfo repoinfo.RepoInfo 1025 Issue *models.Issue // existing issue if any -- passed when editing 1026 Active string 1027 Action string 1028} 1029 1030func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 1031 params.Active = "issues" 1032 params.Action = "create" 1033 return p.executeRepo("repo/issues/new", w, params) 1034} 1035 1036type EditIssueCommentParams struct { 1037 LoggedInUser *oauth.User 1038 RepoInfo repoinfo.RepoInfo 1039 Issue *models.Issue 1040 Comment *models.IssueComment 1041} 1042 1043func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 1044 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 1045} 1046 1047type ReplyIssueCommentPlaceholderParams struct { 1048 LoggedInUser *oauth.User 1049 RepoInfo repoinfo.RepoInfo 1050 Issue *models.Issue 1051 Comment *models.IssueComment 1052} 1053 1054func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 1055 return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 1056} 1057 1058type ReplyIssueCommentParams struct { 1059 LoggedInUser *oauth.User 1060 RepoInfo repoinfo.RepoInfo 1061 Issue *models.Issue 1062 Comment *models.IssueComment 1063} 1064 1065func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 1066 return p.executePlain("repo/issues/fragments/replyComment", w, params) 1067} 1068 1069type IssueCommentBodyParams struct { 1070 LoggedInUser *oauth.User 1071 RepoInfo repoinfo.RepoInfo 1072 Issue *models.Issue 1073 Comment *models.IssueComment 1074} 1075 1076func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 1077 return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 1078} 1079 1080type RepoNewPullParams struct { 1081 LoggedInUser *oauth.User 1082 RepoInfo repoinfo.RepoInfo 1083 Branches []types.Branch 1084 Strategy string 1085 SourceBranch string 1086 TargetBranch string 1087 Title string 1088 Body string 1089 Active string 1090} 1091 1092func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1093 params.Active = "pulls" 1094 return p.executeRepo("repo/pulls/new", w, params) 1095} 1096 1097type RepoPullsParams struct { 1098 LoggedInUser *oauth.User 1099 RepoInfo repoinfo.RepoInfo 1100 Pulls []*models.Pull 1101 Active string 1102 FilteringBy models.PullState 1103 Stacks map[string]models.Stack 1104 Pipelines map[string]models.Pipeline 1105 LabelDefs map[string]*models.LabelDefinition 1106} 1107 1108func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 1109 params.Active = "pulls" 1110 return p.executeRepo("repo/pulls/pulls", w, params) 1111} 1112 1113type ResubmitResult uint64 1114 1115const ( 1116 ShouldResubmit ResubmitResult = iota 1117 ShouldNotResubmit 1118 Unknown 1119) 1120 1121func (r ResubmitResult) Yes() bool { 1122 return r == ShouldResubmit 1123} 1124func (r ResubmitResult) No() bool { 1125 return r == ShouldNotResubmit 1126} 1127func (r ResubmitResult) Unknown() bool { 1128 return r == Unknown 1129} 1130 1131type RepoSinglePullParams struct { 1132 LoggedInUser *oauth.User 1133 RepoInfo repoinfo.RepoInfo 1134 Active string 1135 Pull *models.Pull 1136 Stack models.Stack 1137 AbandonedPulls []*models.Pull 1138 BranchDeleteStatus *models.BranchDeleteStatus 1139 MergeCheck types.MergeCheckResponse 1140 ResubmitCheck ResubmitResult 1141 Pipelines map[string]models.Pipeline 1142 1143 OrderedReactionKinds []models.ReactionKind 1144 Reactions map[models.ReactionKind]models.ReactionDisplayData 1145 UserReacted map[models.ReactionKind]bool 1146 1147 LabelDefs map[string]*models.LabelDefinition 1148} 1149 1150func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1151 params.Active = "pulls" 1152 return p.executeRepo("repo/pulls/pull", w, params) 1153} 1154 1155type RepoPullPatchParams struct { 1156 LoggedInUser *oauth.User 1157 RepoInfo repoinfo.RepoInfo 1158 Pull *models.Pull 1159 Stack models.Stack 1160 Diff *types.NiceDiff 1161 Round int 1162 Submission *models.PullSubmission 1163 OrderedReactionKinds []models.ReactionKind 1164 DiffOpts types.DiffOpts 1165} 1166 1167// this name is a mouthful 1168func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 1169 return p.execute("repo/pulls/patch", w, params) 1170} 1171 1172type RepoPullInterdiffParams struct { 1173 LoggedInUser *oauth.User 1174 RepoInfo repoinfo.RepoInfo 1175 Pull *models.Pull 1176 Round int 1177 Interdiff *patchutil.InterdiffResult 1178 OrderedReactionKinds []models.ReactionKind 1179 DiffOpts types.DiffOpts 1180} 1181 1182// this name is a mouthful 1183func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 1184 return p.execute("repo/pulls/interdiff", w, params) 1185} 1186 1187type PullPatchUploadParams struct { 1188 RepoInfo repoinfo.RepoInfo 1189} 1190 1191func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 1192 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 1193} 1194 1195type PullCompareBranchesParams struct { 1196 RepoInfo repoinfo.RepoInfo 1197 Branches []types.Branch 1198 SourceBranch string 1199} 1200 1201func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 1202 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 1203} 1204 1205type PullCompareForkParams struct { 1206 RepoInfo repoinfo.RepoInfo 1207 Forks []models.Repo 1208 Selected string 1209} 1210 1211func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 1212 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 1213} 1214 1215type PullCompareForkBranchesParams struct { 1216 RepoInfo repoinfo.RepoInfo 1217 SourceBranches []types.Branch 1218 TargetBranches []types.Branch 1219} 1220 1221func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 1222 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 1223} 1224 1225type PullResubmitParams struct { 1226 LoggedInUser *oauth.User 1227 RepoInfo repoinfo.RepoInfo 1228 Pull *models.Pull 1229 SubmissionId int 1230} 1231 1232func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 1233 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 1234} 1235 1236type PullActionsParams struct { 1237 LoggedInUser *oauth.User 1238 RepoInfo repoinfo.RepoInfo 1239 Pull *models.Pull 1240 RoundNumber int 1241 MergeCheck types.MergeCheckResponse 1242 ResubmitCheck ResubmitResult 1243 BranchDeleteStatus *models.BranchDeleteStatus 1244 Stack models.Stack 1245} 1246 1247func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 1248 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 1249} 1250 1251type PullNewCommentParams struct { 1252 LoggedInUser *oauth.User 1253 RepoInfo repoinfo.RepoInfo 1254 Pull *models.Pull 1255 RoundNumber int 1256} 1257 1258func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 1259 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 1260} 1261 1262type RepoCompareParams struct { 1263 LoggedInUser *oauth.User 1264 RepoInfo repoinfo.RepoInfo 1265 Forks []models.Repo 1266 Branches []types.Branch 1267 Tags []*types.TagReference 1268 Base string 1269 Head string 1270 Diff *types.NiceDiff 1271 DiffOpts types.DiffOpts 1272 1273 Active string 1274} 1275 1276func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 1277 params.Active = "overview" 1278 return p.executeRepo("repo/compare/compare", w, params) 1279} 1280 1281type RepoCompareNewParams struct { 1282 LoggedInUser *oauth.User 1283 RepoInfo repoinfo.RepoInfo 1284 Forks []models.Repo 1285 Branches []types.Branch 1286 Tags []*types.TagReference 1287 Base string 1288 Head string 1289 1290 Active string 1291} 1292 1293func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 1294 params.Active = "overview" 1295 return p.executeRepo("repo/compare/new", w, params) 1296} 1297 1298type RepoCompareAllowPullParams struct { 1299 LoggedInUser *oauth.User 1300 RepoInfo repoinfo.RepoInfo 1301 Base string 1302 Head string 1303} 1304 1305func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error { 1306 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1307} 1308 1309type RepoCompareDiffParams struct { 1310 LoggedInUser *oauth.User 1311 RepoInfo repoinfo.RepoInfo 1312 Diff types.NiceDiff 1313} 1314 1315func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1316 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1317} 1318 1319type LabelPanelParams struct { 1320 LoggedInUser *oauth.User 1321 RepoInfo repoinfo.RepoInfo 1322 Defs map[string]*models.LabelDefinition 1323 Subject string 1324 State models.LabelState 1325} 1326 1327func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1328 return p.executePlain("repo/fragments/labelPanel", w, params) 1329} 1330 1331type EditLabelPanelParams struct { 1332 LoggedInUser *oauth.User 1333 RepoInfo repoinfo.RepoInfo 1334 Defs map[string]*models.LabelDefinition 1335 Subject string 1336 State models.LabelState 1337} 1338 1339func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1340 return p.executePlain("repo/fragments/editLabelPanel", w, params) 1341} 1342 1343type PipelinesParams struct { 1344 LoggedInUser *oauth.User 1345 RepoInfo repoinfo.RepoInfo 1346 Pipelines []models.Pipeline 1347 Active string 1348} 1349 1350func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error { 1351 params.Active = "pipelines" 1352 return p.executeRepo("repo/pipelines/pipelines", w, params) 1353} 1354 1355type LogBlockParams struct { 1356 Id int 1357 Name string 1358 Command string 1359 Collapsed bool 1360} 1361 1362func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1363 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1364} 1365 1366type LogLineParams struct { 1367 Id int 1368 Content string 1369} 1370 1371func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { 1372 return p.executePlain("repo/pipelines/fragments/logLine", w, params) 1373} 1374 1375type WorkflowParams struct { 1376 LoggedInUser *oauth.User 1377 RepoInfo repoinfo.RepoInfo 1378 Pipeline models.Pipeline 1379 Workflow string 1380 LogUrl string 1381 Active string 1382} 1383 1384func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1385 params.Active = "pipelines" 1386 return p.executeRepo("repo/pipelines/workflow", w, params) 1387} 1388 1389type PutStringParams struct { 1390 LoggedInUser *oauth.User 1391 Action string 1392 1393 // this is supplied in the case of editing an existing string 1394 String models.String 1395} 1396 1397func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1398 return p.execute("strings/put", w, params) 1399} 1400 1401type StringsDashboardParams struct { 1402 LoggedInUser *oauth.User 1403 Card ProfileCard 1404 Strings []models.String 1405} 1406 1407func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1408 return p.execute("strings/dashboard", w, params) 1409} 1410 1411type StringTimelineParams struct { 1412 LoggedInUser *oauth.User 1413 Strings []models.String 1414} 1415 1416func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1417 return p.execute("strings/timeline", w, params) 1418} 1419 1420type SingleStringParams struct { 1421 LoggedInUser *oauth.User 1422 ShowRendered bool 1423 RenderToggle bool 1424 RenderedContents template.HTML 1425 String models.String 1426 Stats models.StringStats 1427 Owner identity.Identity 1428} 1429 1430func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1431 var style *chroma.Style = styles.Get("catpuccin-latte") 1432 1433 if params.ShowRendered { 1434 switch markup.GetFormat(params.String.Filename) { 1435 case markup.FormatMarkdown: 1436 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1437 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1438 sanitized := p.rctx.SanitizeDefault(htmlString) 1439 params.RenderedContents = template.HTML(sanitized) 1440 } 1441 } 1442 1443 c := params.String.Contents 1444 formatter := chromahtml.New( 1445 chromahtml.InlineCode(false), 1446 chromahtml.WithLineNumbers(true), 1447 chromahtml.WithLinkableLineNumbers(true, "L"), 1448 chromahtml.Standalone(false), 1449 chromahtml.WithClasses(true), 1450 ) 1451 1452 lexer := lexers.Get(filepath.Base(params.String.Filename)) 1453 if lexer == nil { 1454 lexer = lexers.Fallback 1455 } 1456 1457 iterator, err := lexer.Tokenise(nil, c) 1458 if err != nil { 1459 return fmt.Errorf("chroma tokenize: %w", err) 1460 } 1461 1462 var code bytes.Buffer 1463 err = formatter.Format(&code, style, iterator) 1464 if err != nil { 1465 return fmt.Errorf("chroma format: %w", err) 1466 } 1467 1468 params.String.Contents = code.String() 1469 return p.execute("strings/string", w, params) 1470} 1471 1472func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1473 return p.execute("timeline/home", w, params) 1474} 1475 1476func (p *Pages) Static() http.Handler { 1477 if p.dev { 1478 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1479 } 1480 1481 sub, err := fs.Sub(p.embedFS, "static") 1482 if err != nil { 1483 p.logger.Error("no static dir found? that's crazy", "err", err) 1484 panic(err) 1485 } 1486 // Custom handler to apply Cache-Control headers for font files 1487 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 1488} 1489 1490func Cache(h http.Handler) http.Handler { 1491 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1492 path := strings.Split(r.URL.Path, "?")[0] 1493 1494 if strings.HasSuffix(path, ".css") { 1495 // on day for css files 1496 w.Header().Set("Cache-Control", "public, max-age=86400") 1497 } else { 1498 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 1499 } 1500 h.ServeHTTP(w, r) 1501 }) 1502} 1503 1504func (p *Pages) CssContentHash() string { 1505 cssFile, err := p.embedFS.Open("static/tw.css") 1506 if err != nil { 1507 slog.Debug("Error opening CSS file", "err", err) 1508 return "" 1509 } 1510 defer cssFile.Close() 1511 1512 hasher := sha256.New() 1513 if _, err := io.Copy(hasher, cssFile); err != nil { 1514 slog.Debug("Error hashing CSS file", "err", err) 1515 return "" 1516 } 1517 1518 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1519} 1520 1521func (p *Pages) Error500(w io.Writer) error { 1522 return p.execute("errors/500", w, nil) 1523} 1524 1525func (p *Pages) Error404(w io.Writer) error { 1526 return p.execute("errors/404", w, nil) 1527} 1528 1529func (p *Pages) ErrorKnot404(w io.Writer) error { 1530 return p.execute("errors/knot404", w, nil) 1531} 1532 1533func (p *Pages) Error503(w io.Writer) error { 1534 return p.execute("errors/503", w, nil) 1535}