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