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