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 Backlinks []models.RichReferenceLink 979 LabelDefs map[string]*models.LabelDefinition 980 981 OrderedReactionKinds []models.ReactionKind 982 Reactions map[models.ReactionKind]models.ReactionDisplayData 983 UserReacted map[models.ReactionKind]bool 984} 985 986func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 987 params.Active = "issues" 988 return p.executeRepo("repo/issues/issue", w, params) 989} 990 991type EditIssueParams struct { 992 LoggedInUser *oauth.User 993 RepoInfo repoinfo.RepoInfo 994 Issue *models.Issue 995 Action string 996} 997 998func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 999 params.Action = "edit" 1000 return p.executePlain("repo/issues/fragments/putIssue", w, params) 1001} 1002 1003type ThreadReactionFragmentParams struct { 1004 ThreadAt syntax.ATURI 1005 Kind models.ReactionKind 1006 Count int 1007 Users []string 1008 IsReacted bool 1009} 1010 1011func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 1012 return p.executePlain("repo/fragments/reaction", w, params) 1013} 1014 1015type RepoNewIssueParams struct { 1016 LoggedInUser *oauth.User 1017 RepoInfo repoinfo.RepoInfo 1018 Issue *models.Issue // existing issue if any -- passed when editing 1019 Active string 1020 Action string 1021} 1022 1023func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 1024 params.Active = "issues" 1025 params.Action = "create" 1026 return p.executeRepo("repo/issues/new", w, params) 1027} 1028 1029type EditIssueCommentParams struct { 1030 LoggedInUser *oauth.User 1031 RepoInfo repoinfo.RepoInfo 1032 Issue *models.Issue 1033 Comment *models.IssueComment 1034} 1035 1036func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 1037 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 1038} 1039 1040type ReplyIssueCommentPlaceholderParams struct { 1041 LoggedInUser *oauth.User 1042 RepoInfo repoinfo.RepoInfo 1043 Issue *models.Issue 1044 Comment *models.IssueComment 1045} 1046 1047func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 1048 return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 1049} 1050 1051type ReplyIssueCommentParams struct { 1052 LoggedInUser *oauth.User 1053 RepoInfo repoinfo.RepoInfo 1054 Issue *models.Issue 1055 Comment *models.IssueComment 1056} 1057 1058func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 1059 return p.executePlain("repo/issues/fragments/replyComment", w, params) 1060} 1061 1062type IssueCommentBodyParams struct { 1063 LoggedInUser *oauth.User 1064 RepoInfo repoinfo.RepoInfo 1065 Issue *models.Issue 1066 Comment *models.IssueComment 1067} 1068 1069func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 1070 return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 1071} 1072 1073type RepoNewPullParams struct { 1074 LoggedInUser *oauth.User 1075 RepoInfo repoinfo.RepoInfo 1076 Branches []types.Branch 1077 Strategy string 1078 SourceBranch string 1079 TargetBranch string 1080 Title string 1081 Body string 1082 Active string 1083} 1084 1085func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1086 params.Active = "pulls" 1087 return p.executeRepo("repo/pulls/new", w, params) 1088} 1089 1090type RepoPullsParams struct { 1091 LoggedInUser *oauth.User 1092 RepoInfo repoinfo.RepoInfo 1093 Pulls []*models.Pull 1094 Active string 1095 FilteringBy models.PullState 1096 FilterQuery string 1097 Stacks map[string]models.Stack 1098 Pipelines map[string]models.Pipeline 1099 LabelDefs map[string]*models.LabelDefinition 1100} 1101 1102func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 1103 params.Active = "pulls" 1104 return p.executeRepo("repo/pulls/pulls", w, params) 1105} 1106 1107type ResubmitResult uint64 1108 1109const ( 1110 ShouldResubmit ResubmitResult = iota 1111 ShouldNotResubmit 1112 Unknown 1113) 1114 1115func (r ResubmitResult) Yes() bool { 1116 return r == ShouldResubmit 1117} 1118func (r ResubmitResult) No() bool { 1119 return r == ShouldNotResubmit 1120} 1121func (r ResubmitResult) Unknown() bool { 1122 return r == Unknown 1123} 1124 1125type RepoSinglePullParams struct { 1126 LoggedInUser *oauth.User 1127 RepoInfo repoinfo.RepoInfo 1128 Active string 1129 Pull *models.Pull 1130 Stack models.Stack 1131 AbandonedPulls []*models.Pull 1132 Backlinks []models.RichReferenceLink 1133 BranchDeleteStatus *models.BranchDeleteStatus 1134 MergeCheck types.MergeCheckResponse 1135 ResubmitCheck ResubmitResult 1136 Pipelines map[string]models.Pipeline 1137 1138 OrderedReactionKinds []models.ReactionKind 1139 Reactions map[models.ReactionKind]models.ReactionDisplayData 1140 UserReacted map[models.ReactionKind]bool 1141 1142 LabelDefs map[string]*models.LabelDefinition 1143} 1144 1145func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1146 params.Active = "pulls" 1147 return p.executeRepo("repo/pulls/pull", w, params) 1148} 1149 1150type RepoPullPatchParams struct { 1151 LoggedInUser *oauth.User 1152 RepoInfo repoinfo.RepoInfo 1153 Pull *models.Pull 1154 Stack models.Stack 1155 Diff *types.NiceDiff 1156 Round int 1157 Submission *models.PullSubmission 1158 OrderedReactionKinds []models.ReactionKind 1159 DiffOpts types.DiffOpts 1160} 1161 1162// this name is a mouthful 1163func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 1164 return p.execute("repo/pulls/patch", w, params) 1165} 1166 1167type RepoPullInterdiffParams struct { 1168 LoggedInUser *oauth.User 1169 RepoInfo repoinfo.RepoInfo 1170 Pull *models.Pull 1171 Round int 1172 Interdiff *patchutil.InterdiffResult 1173 OrderedReactionKinds []models.ReactionKind 1174 DiffOpts types.DiffOpts 1175} 1176 1177// this name is a mouthful 1178func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 1179 return p.execute("repo/pulls/interdiff", w, params) 1180} 1181 1182type PullPatchUploadParams struct { 1183 RepoInfo repoinfo.RepoInfo 1184} 1185 1186func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 1187 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 1188} 1189 1190type PullCompareBranchesParams struct { 1191 RepoInfo repoinfo.RepoInfo 1192 Branches []types.Branch 1193 SourceBranch string 1194} 1195 1196func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 1197 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 1198} 1199 1200type PullCompareForkParams struct { 1201 RepoInfo repoinfo.RepoInfo 1202 Forks []models.Repo 1203 Selected string 1204} 1205 1206func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 1207 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 1208} 1209 1210type PullCompareForkBranchesParams struct { 1211 RepoInfo repoinfo.RepoInfo 1212 SourceBranches []types.Branch 1213 TargetBranches []types.Branch 1214} 1215 1216func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 1217 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 1218} 1219 1220type PullResubmitParams struct { 1221 LoggedInUser *oauth.User 1222 RepoInfo repoinfo.RepoInfo 1223 Pull *models.Pull 1224 SubmissionId int 1225} 1226 1227func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 1228 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 1229} 1230 1231type PullActionsParams struct { 1232 LoggedInUser *oauth.User 1233 RepoInfo repoinfo.RepoInfo 1234 Pull *models.Pull 1235 RoundNumber int 1236 MergeCheck types.MergeCheckResponse 1237 ResubmitCheck ResubmitResult 1238 BranchDeleteStatus *models.BranchDeleteStatus 1239 Stack models.Stack 1240} 1241 1242func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 1243 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 1244} 1245 1246type PullNewCommentParams struct { 1247 LoggedInUser *oauth.User 1248 RepoInfo repoinfo.RepoInfo 1249 Pull *models.Pull 1250 RoundNumber int 1251} 1252 1253func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 1254 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 1255} 1256 1257type RepoCompareParams struct { 1258 LoggedInUser *oauth.User 1259 RepoInfo repoinfo.RepoInfo 1260 Forks []models.Repo 1261 Branches []types.Branch 1262 Tags []*types.TagReference 1263 Base string 1264 Head string 1265 Diff *types.NiceDiff 1266 DiffOpts types.DiffOpts 1267 1268 Active string 1269} 1270 1271func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 1272 params.Active = "overview" 1273 return p.executeRepo("repo/compare/compare", w, params) 1274} 1275 1276type RepoCompareNewParams struct { 1277 LoggedInUser *oauth.User 1278 RepoInfo repoinfo.RepoInfo 1279 Forks []models.Repo 1280 Branches []types.Branch 1281 Tags []*types.TagReference 1282 Base string 1283 Head string 1284 1285 Active string 1286} 1287 1288func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 1289 params.Active = "overview" 1290 return p.executeRepo("repo/compare/new", w, params) 1291} 1292 1293type RepoCompareAllowPullParams struct { 1294 LoggedInUser *oauth.User 1295 RepoInfo repoinfo.RepoInfo 1296 Base string 1297 Head string 1298} 1299 1300func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error { 1301 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1302} 1303 1304type RepoCompareDiffParams struct { 1305 LoggedInUser *oauth.User 1306 RepoInfo repoinfo.RepoInfo 1307 Diff types.NiceDiff 1308} 1309 1310func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1311 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1312} 1313 1314type LabelPanelParams struct { 1315 LoggedInUser *oauth.User 1316 RepoInfo repoinfo.RepoInfo 1317 Defs map[string]*models.LabelDefinition 1318 Subject string 1319 State models.LabelState 1320} 1321 1322func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1323 return p.executePlain("repo/fragments/labelPanel", w, params) 1324} 1325 1326type EditLabelPanelParams struct { 1327 LoggedInUser *oauth.User 1328 RepoInfo repoinfo.RepoInfo 1329 Defs map[string]*models.LabelDefinition 1330 Subject string 1331 State models.LabelState 1332} 1333 1334func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1335 return p.executePlain("repo/fragments/editLabelPanel", w, params) 1336} 1337 1338type PipelinesParams struct { 1339 LoggedInUser *oauth.User 1340 RepoInfo repoinfo.RepoInfo 1341 Pipelines []models.Pipeline 1342 Active string 1343} 1344 1345func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error { 1346 params.Active = "pipelines" 1347 return p.executeRepo("repo/pipelines/pipelines", w, params) 1348} 1349 1350type LogBlockParams struct { 1351 Id int 1352 Name string 1353 Command string 1354 Collapsed bool 1355 StartTime time.Time 1356} 1357 1358func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1359 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1360} 1361 1362type LogBlockEndParams struct { 1363 Id int 1364 StartTime time.Time 1365 EndTime time.Time 1366} 1367 1368func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1369 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1370} 1371 1372type LogLineParams struct { 1373 Id int 1374 Content string 1375} 1376 1377func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { 1378 return p.executePlain("repo/pipelines/fragments/logLine", w, params) 1379} 1380 1381type WorkflowParams struct { 1382 LoggedInUser *oauth.User 1383 RepoInfo repoinfo.RepoInfo 1384 Pipeline models.Pipeline 1385 Workflow string 1386 LogUrl string 1387 Active string 1388} 1389 1390func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1391 params.Active = "pipelines" 1392 return p.executeRepo("repo/pipelines/workflow", w, params) 1393} 1394 1395type PutStringParams struct { 1396 LoggedInUser *oauth.User 1397 Action string 1398 1399 // this is supplied in the case of editing an existing string 1400 String models.String 1401} 1402 1403func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1404 return p.execute("strings/put", w, params) 1405} 1406 1407type StringsDashboardParams struct { 1408 LoggedInUser *oauth.User 1409 Card ProfileCard 1410 Strings []models.String 1411} 1412 1413func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1414 return p.execute("strings/dashboard", w, params) 1415} 1416 1417type StringTimelineParams struct { 1418 LoggedInUser *oauth.User 1419 Strings []models.String 1420} 1421 1422func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1423 return p.execute("strings/timeline", w, params) 1424} 1425 1426type SingleStringParams struct { 1427 LoggedInUser *oauth.User 1428 ShowRendered bool 1429 RenderToggle bool 1430 RenderedContents template.HTML 1431 String models.String 1432 Stats models.StringStats 1433 Owner identity.Identity 1434} 1435 1436func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1437 var style *chroma.Style = styles.Get("catpuccin-latte") 1438 1439 if params.ShowRendered { 1440 switch markup.GetFormat(params.String.Filename) { 1441 case markup.FormatMarkdown: 1442 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1443 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1444 sanitized := p.rctx.SanitizeDefault(htmlString) 1445 params.RenderedContents = template.HTML(sanitized) 1446 } 1447 } 1448 1449 c := params.String.Contents 1450 formatter := chromahtml.New( 1451 chromahtml.InlineCode(false), 1452 chromahtml.WithLineNumbers(true), 1453 chromahtml.WithLinkableLineNumbers(true, "L"), 1454 chromahtml.Standalone(false), 1455 chromahtml.WithClasses(true), 1456 ) 1457 1458 lexer := lexers.Get(filepath.Base(params.String.Filename)) 1459 if lexer == nil { 1460 lexer = lexers.Fallback 1461 } 1462 1463 iterator, err := lexer.Tokenise(nil, c) 1464 if err != nil { 1465 return fmt.Errorf("chroma tokenize: %w", err) 1466 } 1467 1468 var code bytes.Buffer 1469 err = formatter.Format(&code, style, iterator) 1470 if err != nil { 1471 return fmt.Errorf("chroma format: %w", err) 1472 } 1473 1474 params.String.Contents = code.String() 1475 return p.execute("strings/string", w, params) 1476} 1477 1478func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1479 return p.execute("timeline/home", w, params) 1480} 1481 1482func (p *Pages) Static() http.Handler { 1483 if p.dev { 1484 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1485 } 1486 1487 sub, err := fs.Sub(p.embedFS, "static") 1488 if err != nil { 1489 p.logger.Error("no static dir found? that's crazy", "err", err) 1490 panic(err) 1491 } 1492 // Custom handler to apply Cache-Control headers for font files 1493 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 1494} 1495 1496func Cache(h http.Handler) http.Handler { 1497 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1498 path := strings.Split(r.URL.Path, "?")[0] 1499 1500 if strings.HasSuffix(path, ".css") { 1501 // on day for css files 1502 w.Header().Set("Cache-Control", "public, max-age=86400") 1503 } else { 1504 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 1505 } 1506 h.ServeHTTP(w, r) 1507 }) 1508} 1509 1510func (p *Pages) CssContentHash() string { 1511 cssFile, err := p.embedFS.Open("static/tw.css") 1512 if err != nil { 1513 slog.Debug("Error opening CSS file", "err", err) 1514 return "" 1515 } 1516 defer cssFile.Close() 1517 1518 hasher := sha256.New() 1519 if _, err := io.Copy(hasher, cssFile); err != nil { 1520 slog.Debug("Error hashing CSS file", "err", err) 1521 return "" 1522 } 1523 1524 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1525} 1526 1527func (p *Pages) Error500(w io.Writer) error { 1528 return p.execute("errors/500", w, nil) 1529} 1530 1531func (p *Pages) Error404(w io.Writer) error { 1532 return p.execute("errors/404", w, nil) 1533} 1534 1535func (p *Pages) ErrorKnot404(w io.Writer) error { 1536 return p.execute("errors/knot404", w, nil) 1537} 1538 1539func (p *Pages) Error503(w io.Writer) error { 1540 return p.execute("errors/503", w, nil) 1541}