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