forked from tangled.org/core
this repo has no description
at file-tree 23 kB view raw
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" 13 "net/http" 14 "os" 15 "path" 16 "path/filepath" 17 "slices" 18 "strings" 19 20 "tangled.sh/tangled.sh/core/appview/auth" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 "tangled.sh/tangled.sh/core/appview/state/userutil" 24 "tangled.sh/tangled.sh/core/patchutil" 25 "tangled.sh/tangled.sh/core/types" 26 27 "github.com/alecthomas/chroma/v2" 28 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 29 "github.com/alecthomas/chroma/v2/lexers" 30 "github.com/alecthomas/chroma/v2/styles" 31 "github.com/bluesky-social/indigo/atproto/syntax" 32 "github.com/microcosm-cc/bluemonday" 33) 34 35//go:embed templates/* static 36var Files embed.FS 37 38type Pages struct { 39 t map[string]*template.Template 40 dev bool 41 embedFS embed.FS 42 templateDir string // Path to templates on disk for dev mode 43} 44 45func NewPages(dev bool) *Pages { 46 p := &Pages{ 47 t: make(map[string]*template.Template), 48 dev: dev, 49 embedFS: Files, 50 templateDir: "appview/pages", 51 } 52 53 // Initial load of all templates 54 p.loadAllTemplates() 55 56 return p 57} 58 59func (p *Pages) loadAllTemplates() { 60 templates := make(map[string]*template.Template) 61 var fragmentPaths []string 62 63 // Use embedded FS for initial loading 64 // First, collect all fragment paths 65 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 66 if err != nil { 67 return err 68 } 69 if d.IsDir() { 70 return nil 71 } 72 if !strings.HasSuffix(path, ".html") { 73 return nil 74 } 75 if !strings.Contains(path, "fragments/") { 76 return nil 77 } 78 name := strings.TrimPrefix(path, "templates/") 79 name = strings.TrimSuffix(name, ".html") 80 tmpl, err := template.New(name). 81 Funcs(funcMap()). 82 ParseFS(p.embedFS, path) 83 if err != nil { 84 log.Fatalf("setting up fragment: %v", err) 85 } 86 templates[name] = tmpl 87 fragmentPaths = append(fragmentPaths, path) 88 log.Printf("loaded fragment: %s", name) 89 return nil 90 }) 91 if err != nil { 92 log.Fatalf("walking template dir for fragments: %v", err) 93 } 94 95 // Then walk through and setup the rest of the templates 96 err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 97 if err != nil { 98 return err 99 } 100 if d.IsDir() { 101 return nil 102 } 103 if !strings.HasSuffix(path, "html") { 104 return nil 105 } 106 // Skip fragments as they've already been loaded 107 if strings.Contains(path, "fragments/") { 108 return nil 109 } 110 // Skip layouts 111 if strings.Contains(path, "layouts/") { 112 return nil 113 } 114 name := strings.TrimPrefix(path, "templates/") 115 name = strings.TrimSuffix(name, ".html") 116 // Add the page template on top of the base 117 allPaths := []string{} 118 allPaths = append(allPaths, "templates/layouts/*.html") 119 allPaths = append(allPaths, fragmentPaths...) 120 allPaths = append(allPaths, path) 121 tmpl, err := template.New(name). 122 Funcs(funcMap()). 123 ParseFS(p.embedFS, allPaths...) 124 if err != nil { 125 return fmt.Errorf("setting up template: %w", err) 126 } 127 templates[name] = tmpl 128 log.Printf("loaded template: %s", name) 129 return nil 130 }) 131 if err != nil { 132 log.Fatalf("walking template dir: %v", err) 133 } 134 135 log.Printf("total templates loaded: %d", len(templates)) 136 p.t = templates 137} 138 139// loadTemplateFromDisk loads a template from the filesystem in dev mode 140func (p *Pages) loadTemplateFromDisk(name string) error { 141 if !p.dev { 142 return nil 143 } 144 145 log.Printf("reloading template from disk: %s", name) 146 147 // Find all fragments first 148 var fragmentPaths []string 149 err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 150 if err != nil { 151 return err 152 } 153 if d.IsDir() { 154 return nil 155 } 156 if !strings.HasSuffix(path, ".html") { 157 return nil 158 } 159 if !strings.Contains(path, "fragments/") { 160 return nil 161 } 162 fragmentPaths = append(fragmentPaths, path) 163 return nil 164 }) 165 if err != nil { 166 return fmt.Errorf("walking disk template dir for fragments: %w", err) 167 } 168 169 // Find the template path on disk 170 templatePath := filepath.Join(p.templateDir, "templates", name+".html") 171 if _, err := os.Stat(templatePath); os.IsNotExist(err) { 172 return fmt.Errorf("template not found on disk: %s", name) 173 } 174 175 // Create a new template 176 tmpl := template.New(name).Funcs(funcMap()) 177 178 // Parse layouts 179 layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 180 layouts, err := filepath.Glob(layoutGlob) 181 if err != nil { 182 return fmt.Errorf("finding layout templates: %w", err) 183 } 184 185 // Create paths for parsing 186 allFiles := append(layouts, fragmentPaths...) 187 allFiles = append(allFiles, templatePath) 188 189 // Parse all templates 190 tmpl, err = tmpl.ParseFiles(allFiles...) 191 if err != nil { 192 return fmt.Errorf("parsing template files: %w", err) 193 } 194 195 // Update the template in the map 196 p.t[name] = tmpl 197 log.Printf("template reloaded from disk: %s", name) 198 return nil 199} 200 201func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 202 // In dev mode, reload the template from disk before executing 203 if p.dev { 204 if err := p.loadTemplateFromDisk(templateName); err != nil { 205 log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 206 // Continue with the existing template 207 } 208 } 209 210 tmpl, exists := p.t[templateName] 211 if !exists { 212 return fmt.Errorf("template not found: %s", templateName) 213 } 214 215 if base == "" { 216 return tmpl.Execute(w, params) 217 } else { 218 return tmpl.ExecuteTemplate(w, base, params) 219 } 220} 221 222func (p *Pages) execute(name string, w io.Writer, params any) error { 223 return p.executeOrReload(name, w, "layouts/base", params) 224} 225 226func (p *Pages) executePlain(name string, w io.Writer, params any) error { 227 return p.executeOrReload(name, w, "", params) 228} 229 230func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 231 return p.executeOrReload(name, w, "layouts/repobase", params) 232} 233 234type LoginParams struct { 235} 236 237func (p *Pages) Login(w io.Writer, params LoginParams) error { 238 return p.executePlain("user/login", w, params) 239} 240 241type TimelineParams struct { 242 LoggedInUser *auth.User 243 Timeline []db.TimelineEvent 244 DidHandleMap map[string]string 245} 246 247func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 248 return p.execute("timeline", w, params) 249} 250 251type SettingsParams struct { 252 LoggedInUser *auth.User 253 PubKeys []db.PublicKey 254 Emails []db.Email 255} 256 257func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 258 return p.execute("settings", w, params) 259} 260 261type KnotsParams struct { 262 LoggedInUser *auth.User 263 Registrations []db.Registration 264} 265 266func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 267 return p.execute("knots", w, params) 268} 269 270type KnotParams struct { 271 LoggedInUser *auth.User 272 DidHandleMap map[string]string 273 Registration *db.Registration 274 Members []string 275 IsOwner bool 276} 277 278func (p *Pages) Knot(w io.Writer, params KnotParams) error { 279 return p.execute("knot", w, params) 280} 281 282type NewRepoParams struct { 283 LoggedInUser *auth.User 284 Knots []string 285} 286 287func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 288 return p.execute("repo/new", w, params) 289} 290 291type ForkRepoParams struct { 292 LoggedInUser *auth.User 293 Knots []string 294 RepoInfo RepoInfo 295} 296 297func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 298 return p.execute("repo/fork", w, params) 299} 300 301type ProfilePageParams struct { 302 LoggedInUser *auth.User 303 UserDid string 304 UserHandle string 305 Repos []db.Repo 306 CollaboratingRepos []db.Repo 307 ProfileStats ProfileStats 308 FollowStatus db.FollowStatus 309 AvatarUri string 310 ProfileTimeline *db.ProfileTimeline 311 312 DidHandleMap map[string]string 313} 314 315type ProfileStats struct { 316 Followers int 317 Following int 318} 319 320func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 321 return p.execute("user/profile", w, params) 322} 323 324type FollowFragmentParams struct { 325 UserDid string 326 FollowStatus db.FollowStatus 327} 328 329func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 330 return p.executePlain("user/fragments/follow", w, params) 331} 332 333type RepoActionsFragmentParams struct { 334 IsStarred bool 335 RepoAt syntax.ATURI 336 Stats db.RepoStats 337} 338 339func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 340 return p.executePlain("repo/fragments/repoActions", w, params) 341} 342 343type RepoDescriptionParams struct { 344 RepoInfo RepoInfo 345} 346 347func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 348 return p.executePlain("repo/fragments/editRepoDescription", w, params) 349} 350 351func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 352 return p.executePlain("repo/fragments/repoDescription", w, params) 353} 354 355type RepoInfo struct { 356 Name string 357 OwnerDid string 358 OwnerHandle string 359 Description string 360 Knot string 361 RepoAt syntax.ATURI 362 IsStarred bool 363 Stats db.RepoStats 364 Roles RolesInRepo 365 Source *db.Repo 366 SourceHandle string 367 DisableFork bool 368} 369 370type RolesInRepo struct { 371 Roles []string 372} 373 374func (r RolesInRepo) SettingsAllowed() bool { 375 return slices.Contains(r.Roles, "repo:settings") 376} 377 378func (r RolesInRepo) CollaboratorInviteAllowed() bool { 379 return slices.Contains(r.Roles, "repo:invite") 380} 381 382func (r RolesInRepo) RepoDeleteAllowed() bool { 383 return slices.Contains(r.Roles, "repo:delete") 384} 385 386func (r RolesInRepo) IsOwner() bool { 387 return slices.Contains(r.Roles, "repo:owner") 388} 389 390func (r RolesInRepo) IsCollaborator() bool { 391 return slices.Contains(r.Roles, "repo:collaborator") 392} 393 394func (r RolesInRepo) IsPushAllowed() bool { 395 return slices.Contains(r.Roles, "repo:push") 396} 397 398func (r RepoInfo) OwnerWithAt() string { 399 if r.OwnerHandle != "" { 400 return fmt.Sprintf("@%s", r.OwnerHandle) 401 } else { 402 return r.OwnerDid 403 } 404} 405 406func (r RepoInfo) FullName() string { 407 return path.Join(r.OwnerWithAt(), r.Name) 408} 409 410func (r RepoInfo) OwnerWithoutAt() string { 411 if strings.HasPrefix(r.OwnerWithAt(), "@") { 412 return strings.TrimPrefix(r.OwnerWithAt(), "@") 413 } else { 414 return userutil.FlattenDid(r.OwnerDid) 415 } 416} 417 418func (r RepoInfo) FullNameWithoutAt() string { 419 return path.Join(r.OwnerWithoutAt(), r.Name) 420} 421 422func (r RepoInfo) GetTabs() [][]string { 423 tabs := [][]string{ 424 {"overview", "/", "square-chart-gantt"}, 425 {"issues", "/issues", "circle-dot"}, 426 {"pulls", "/pulls", "git-pull-request"}, 427 } 428 429 if r.Roles.SettingsAllowed() { 430 tabs = append(tabs, []string{"settings", "/settings", "cog"}) 431 } 432 433 return tabs 434} 435 436// each tab on a repo could have some metadata: 437// 438// issues -> number of open issues etc. 439// settings -> a warning icon to setup branch protection? idk 440// 441// we gather these bits of info here, because go templates 442// are difficult to program in 443func (r RepoInfo) TabMetadata() map[string]any { 444 meta := make(map[string]any) 445 446 if r.Stats.PullCount.Open > 0 { 447 meta["pulls"] = r.Stats.PullCount.Open 448 } 449 450 if r.Stats.IssueCount.Open > 0 { 451 meta["issues"] = r.Stats.IssueCount.Open 452 } 453 454 // more stuff? 455 456 return meta 457} 458 459type RepoIndexParams struct { 460 LoggedInUser *auth.User 461 RepoInfo RepoInfo 462 Active string 463 TagMap map[string][]string 464 types.RepoIndexResponse 465 HTMLReadme template.HTML 466 Raw bool 467 EmailToDidOrHandle map[string]string 468} 469 470func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 471 params.Active = "overview" 472 if params.IsEmpty { 473 return p.executeRepo("repo/empty", w, params) 474 } 475 476 if params.ReadmeFileName != "" { 477 var htmlString string 478 ext := filepath.Ext(params.ReadmeFileName) 479 switch ext { 480 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 481 htmlString = markup.RenderMarkdown(params.Readme) 482 params.Raw = false 483 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 484 default: 485 htmlString = string(params.Readme) 486 params.Raw = true 487 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 488 } 489 } 490 491 return p.executeRepo("repo/index", w, params) 492} 493 494type RepoLogParams struct { 495 LoggedInUser *auth.User 496 RepoInfo RepoInfo 497 types.RepoLogResponse 498 Active string 499 EmailToDidOrHandle map[string]string 500} 501 502func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 503 params.Active = "overview" 504 return p.execute("repo/log", w, params) 505} 506 507type RepoCommitParams struct { 508 LoggedInUser *auth.User 509 RepoInfo RepoInfo 510 Active string 511 EmailToDidOrHandle map[string]string 512 513 types.RepoCommitResponse 514} 515 516func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 517 params.Active = "overview" 518 return p.executeRepo("repo/commit", w, params) 519} 520 521type RepoTreeParams struct { 522 LoggedInUser *auth.User 523 RepoInfo RepoInfo 524 Active string 525 BreadCrumbs [][]string 526 BaseTreeLink string 527 BaseBlobLink string 528 types.RepoTreeResponse 529} 530 531type RepoTreeStats struct { 532 NumFolders uint64 533 NumFiles uint64 534} 535 536func (r RepoTreeParams) TreeStats() RepoTreeStats { 537 numFolders, numFiles := 0, 0 538 for _, f := range r.Files { 539 if !f.IsFile { 540 numFolders += 1 541 } else if f.IsFile { 542 numFiles += 1 543 } 544 } 545 546 return RepoTreeStats{ 547 NumFolders: uint64(numFolders), 548 NumFiles: uint64(numFiles), 549 } 550} 551 552func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 553 params.Active = "overview" 554 return p.execute("repo/tree", w, params) 555} 556 557type RepoBranchesParams struct { 558 LoggedInUser *auth.User 559 RepoInfo RepoInfo 560 types.RepoBranchesResponse 561} 562 563func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 564 return p.executeRepo("repo/branches", w, params) 565} 566 567type RepoTagsParams struct { 568 LoggedInUser *auth.User 569 RepoInfo RepoInfo 570 types.RepoTagsResponse 571} 572 573func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 574 return p.executeRepo("repo/tags", w, params) 575} 576 577type RepoBlobParams struct { 578 LoggedInUser *auth.User 579 RepoInfo RepoInfo 580 Active string 581 BreadCrumbs [][]string 582 ShowRendered bool 583 RenderToggle bool 584 RenderedContents template.HTML 585 types.RepoBlobResponse 586} 587 588func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 589 var style *chroma.Style = styles.Get("catpuccin-latte") 590 591 if params.ShowRendered { 592 switch markup.GetFormat(params.Path) { 593 case markup.FormatMarkdown: 594 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents)) 595 } 596 } 597 598 if params.Lines < 5000 { 599 c := params.Contents 600 formatter := chromahtml.New( 601 chromahtml.InlineCode(false), 602 chromahtml.WithLineNumbers(true), 603 chromahtml.WithLinkableLineNumbers(true, "L"), 604 chromahtml.Standalone(false), 605 chromahtml.WithClasses(true), 606 ) 607 608 lexer := lexers.Get(filepath.Base(params.Path)) 609 if lexer == nil { 610 lexer = lexers.Fallback 611 } 612 613 iterator, err := lexer.Tokenise(nil, c) 614 if err != nil { 615 return fmt.Errorf("chroma tokenize: %w", err) 616 } 617 618 var code bytes.Buffer 619 err = formatter.Format(&code, style, iterator) 620 if err != nil { 621 return fmt.Errorf("chroma format: %w", err) 622 } 623 624 params.Contents = code.String() 625 } 626 627 params.Active = "overview" 628 return p.executeRepo("repo/blob", w, params) 629} 630 631type Collaborator struct { 632 Did string 633 Handle string 634 Role string 635} 636 637type RepoSettingsParams struct { 638 LoggedInUser *auth.User 639 RepoInfo RepoInfo 640 Collaborators []Collaborator 641 Active string 642 Branches []string 643 DefaultBranch string 644 // TODO: use repoinfo.roles 645 IsCollaboratorInviteAllowed bool 646} 647 648func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 649 params.Active = "settings" 650 return p.executeRepo("repo/settings", w, params) 651} 652 653type RepoIssuesParams struct { 654 LoggedInUser *auth.User 655 RepoInfo RepoInfo 656 Active string 657 Issues []db.Issue 658 DidHandleMap map[string]string 659 660 FilteringByOpen bool 661} 662 663func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 664 params.Active = "issues" 665 return p.executeRepo("repo/issues/issues", w, params) 666} 667 668type RepoSingleIssueParams struct { 669 LoggedInUser *auth.User 670 RepoInfo RepoInfo 671 Active string 672 Issue db.Issue 673 Comments []db.Comment 674 IssueOwnerHandle string 675 DidHandleMap map[string]string 676 677 State string 678} 679 680func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 681 params.Active = "issues" 682 if params.Issue.Open { 683 params.State = "open" 684 } else { 685 params.State = "closed" 686 } 687 return p.execute("repo/issues/issue", w, params) 688} 689 690type RepoNewIssueParams struct { 691 LoggedInUser *auth.User 692 RepoInfo RepoInfo 693 Active string 694} 695 696func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 697 params.Active = "issues" 698 return p.executeRepo("repo/issues/new", w, params) 699} 700 701type EditIssueCommentParams struct { 702 LoggedInUser *auth.User 703 RepoInfo RepoInfo 704 Issue *db.Issue 705 Comment *db.Comment 706} 707 708func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 709 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 710} 711 712type SingleIssueCommentParams struct { 713 LoggedInUser *auth.User 714 DidHandleMap map[string]string 715 RepoInfo RepoInfo 716 Issue *db.Issue 717 Comment *db.Comment 718} 719 720func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 721 return p.executePlain("repo/issues/fragments/issueComment", w, params) 722} 723 724type RepoNewPullParams struct { 725 LoggedInUser *auth.User 726 RepoInfo RepoInfo 727 Branches []types.Branch 728 Active string 729} 730 731func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 732 params.Active = "pulls" 733 return p.executeRepo("repo/pulls/new", w, params) 734} 735 736type RepoPullsParams struct { 737 LoggedInUser *auth.User 738 RepoInfo RepoInfo 739 Pulls []*db.Pull 740 Active string 741 DidHandleMap map[string]string 742 FilteringBy db.PullState 743} 744 745func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 746 params.Active = "pulls" 747 return p.executeRepo("repo/pulls/pulls", w, params) 748} 749 750type ResubmitResult uint64 751 752const ( 753 ShouldResubmit ResubmitResult = iota 754 ShouldNotResubmit 755 Unknown 756) 757 758func (r ResubmitResult) Yes() bool { 759 return r == ShouldResubmit 760} 761func (r ResubmitResult) No() bool { 762 return r == ShouldNotResubmit 763} 764func (r ResubmitResult) Unknown() bool { 765 return r == Unknown 766} 767 768type RepoSinglePullParams struct { 769 LoggedInUser *auth.User 770 RepoInfo RepoInfo 771 Active string 772 DidHandleMap map[string]string 773 Pull *db.Pull 774 MergeCheck types.MergeCheckResponse 775 ResubmitCheck ResubmitResult 776} 777 778func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 779 params.Active = "pulls" 780 return p.executeRepo("repo/pulls/pull", w, params) 781} 782 783type RepoPullPatchParams struct { 784 LoggedInUser *auth.User 785 DidHandleMap map[string]string 786 RepoInfo RepoInfo 787 Pull *db.Pull 788 Diff *types.NiceDiff 789 Round int 790 Submission *db.PullSubmission 791} 792 793// this name is a mouthful 794func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 795 return p.execute("repo/pulls/patch", w, params) 796} 797 798type RepoPullInterdiffParams struct { 799 LoggedInUser *auth.User 800 DidHandleMap map[string]string 801 RepoInfo RepoInfo 802 Pull *db.Pull 803 Round int 804 Interdiff *patchutil.InterdiffResult 805} 806 807// this name is a mouthful 808func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 809 return p.execute("repo/pulls/interdiff", w, params) 810} 811 812type PullPatchUploadParams struct { 813 RepoInfo RepoInfo 814} 815 816func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 817 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 818} 819 820type PullCompareBranchesParams struct { 821 RepoInfo RepoInfo 822 Branches []types.Branch 823} 824 825func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 826 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 827} 828 829type PullCompareForkParams struct { 830 RepoInfo RepoInfo 831 Forks []db.Repo 832} 833 834func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 835 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 836} 837 838type PullCompareForkBranchesParams struct { 839 RepoInfo RepoInfo 840 SourceBranches []types.Branch 841 TargetBranches []types.Branch 842} 843 844func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 845 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 846} 847 848type PullResubmitParams struct { 849 LoggedInUser *auth.User 850 RepoInfo RepoInfo 851 Pull *db.Pull 852 SubmissionId int 853} 854 855func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 856 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 857} 858 859type PullActionsParams struct { 860 LoggedInUser *auth.User 861 RepoInfo RepoInfo 862 Pull *db.Pull 863 RoundNumber int 864 MergeCheck types.MergeCheckResponse 865 ResubmitCheck ResubmitResult 866} 867 868func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 869 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 870} 871 872type PullNewCommentParams struct { 873 LoggedInUser *auth.User 874 RepoInfo RepoInfo 875 Pull *db.Pull 876 RoundNumber int 877} 878 879func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 880 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 881} 882 883func (p *Pages) Static() http.Handler { 884 if p.dev { 885 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 886 } 887 888 sub, err := fs.Sub(Files, "static") 889 if err != nil { 890 log.Fatalf("no static dir found? that's crazy: %v", err) 891 } 892 // Custom handler to apply Cache-Control headers for font files 893 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 894} 895 896func Cache(h http.Handler) http.Handler { 897 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 898 path := strings.Split(r.URL.Path, "?")[0] 899 900 if strings.HasSuffix(path, ".css") { 901 // on day for css files 902 w.Header().Set("Cache-Control", "public, max-age=86400") 903 } else { 904 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 905 } 906 h.ServeHTTP(w, r) 907 }) 908} 909 910func CssContentHash() string { 911 cssFile, err := Files.Open("static/tw.css") 912 if err != nil { 913 log.Printf("Error opening CSS file: %v", err) 914 return "" 915 } 916 defer cssFile.Close() 917 918 hasher := sha256.New() 919 if _, err := io.Copy(hasher, cssFile); err != nil { 920 log.Printf("Error hashing CSS file: %v", err) 921 return "" 922 } 923 924 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 925} 926 927func (p *Pages) Error500(w io.Writer) error { 928 return p.execute("errors/500", w, nil) 929} 930 931func (p *Pages) Error404(w io.Writer) error { 932 return p.execute("errors/404", w, nil) 933} 934 935func (p *Pages) Error503(w io.Writer) error { 936 return p.execute("errors/503", w, nil) 937}