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