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