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