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/state/userutil"
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/microcosm-cc/bluemonday"
32)
33
34//go:embed templates/* static
35var Files embed.FS
36
37type Pages struct {
38 t map[string]*template.Template
39}
40
41func NewPages() *Pages {
42 templates := make(map[string]*template.Template)
43
44 var fragmentPaths []string
45 // First, collect all fragment paths
46 err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
47 if err != nil {
48 return err
49 }
50
51 if d.IsDir() {
52 return nil
53 }
54
55 if !strings.HasSuffix(path, ".html") {
56 return nil
57 }
58
59 if !strings.Contains(path, "fragments/") {
60 return nil
61 }
62
63 name := strings.TrimPrefix(path, "templates/")
64 name = strings.TrimSuffix(name, ".html")
65
66 tmpl, err := template.New(name).
67 Funcs(funcMap()).
68 ParseFS(Files, path)
69 if err != nil {
70 log.Fatalf("setting up fragment: %v", err)
71 }
72
73 templates[name] = tmpl
74 fragmentPaths = append(fragmentPaths, path)
75 log.Printf("loaded fragment: %s", name)
76 return nil
77 })
78 if err != nil {
79 log.Fatalf("walking template dir for fragments: %v", err)
80 }
81
82 // Then walk through and setup the rest of the templates
83 err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
84 if err != nil {
85 return err
86 }
87
88 if d.IsDir() {
89 return nil
90 }
91
92 if !strings.HasSuffix(path, "html") {
93 return nil
94 }
95
96 // Skip fragments as they've already been loaded
97 if strings.Contains(path, "fragments/") {
98 return nil
99 }
100
101 // Skip layouts
102 if strings.Contains(path, "layouts/") {
103 return nil
104 }
105
106 name := strings.TrimPrefix(path, "templates/")
107 name = strings.TrimSuffix(name, ".html")
108
109 // Add the page template on top of the base
110 allPaths := []string{}
111 allPaths = append(allPaths, "templates/layouts/*.html")
112 allPaths = append(allPaths, fragmentPaths...)
113 allPaths = append(allPaths, path)
114 tmpl, err := template.New(name).
115 Funcs(funcMap()).
116 ParseFS(Files, allPaths...)
117 if err != nil {
118 return fmt.Errorf("setting up template: %w", err)
119 }
120
121 templates[name] = tmpl
122 log.Printf("loaded template: %s", name)
123 return nil
124 })
125 if err != nil {
126 log.Fatalf("walking template dir: %v", err)
127 }
128
129 log.Printf("total templates loaded: %d", len(templates))
130
131 return &Pages{
132 t: templates,
133 }
134}
135
136type LoginParams struct {
137}
138
139func (p *Pages) execute(name string, w io.Writer, params any) error {
140 return p.t[name].ExecuteTemplate(w, "layouts/base", params)
141}
142
143func (p *Pages) executePlain(name string, w io.Writer, params any) error {
144 return p.t[name].Execute(w, params)
145}
146
147func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
148 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
149}
150
151func (p *Pages) Login(w io.Writer, params LoginParams) error {
152 return p.executePlain("user/login", w, params)
153}
154
155type TimelineParams struct {
156 LoggedInUser *auth.User
157 Timeline []db.TimelineEvent
158 DidHandleMap map[string]string
159}
160
161func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
162 return p.execute("timeline", w, params)
163}
164
165type SettingsParams struct {
166 LoggedInUser *auth.User
167 PubKeys []db.PublicKey
168 Emails []db.Email
169}
170
171func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
172 return p.execute("settings", w, params)
173}
174
175type KnotsParams struct {
176 LoggedInUser *auth.User
177 Registrations []db.Registration
178}
179
180func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
181 return p.execute("knots", w, params)
182}
183
184type KnotParams struct {
185 LoggedInUser *auth.User
186 DidHandleMap map[string]string
187 Registration *db.Registration
188 Members []string
189 IsOwner bool
190}
191
192func (p *Pages) Knot(w io.Writer, params KnotParams) error {
193 return p.execute("knot", w, params)
194}
195
196type NewRepoParams struct {
197 LoggedInUser *auth.User
198 Knots []string
199}
200
201func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
202 return p.execute("repo/new", w, params)
203}
204
205type ForkRepoParams struct {
206 LoggedInUser *auth.User
207 Knots []string
208 RepoInfo RepoInfo
209}
210
211func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
212 return p.execute("repo/fork", w, params)
213}
214
215type ProfilePageParams struct {
216 LoggedInUser *auth.User
217 UserDid string
218 UserHandle string
219 Repos []db.Repo
220 CollaboratingRepos []db.Repo
221 ProfileStats ProfileStats
222 FollowStatus db.FollowStatus
223 AvatarUri string
224 ProfileTimeline *db.ProfileTimeline
225
226 DidHandleMap map[string]string
227}
228
229type ProfileStats struct {
230 Followers int
231 Following int
232}
233
234func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
235 return p.execute("user/profile", w, params)
236}
237
238type FollowFragmentParams struct {
239 UserDid string
240 FollowStatus db.FollowStatus
241}
242
243func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
244 return p.executePlain("user/fragments/follow", w, params)
245}
246
247type RepoActionsFragmentParams struct {
248 IsStarred bool
249 RepoAt syntax.ATURI
250 Stats db.RepoStats
251}
252
253func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
254 return p.executePlain("repo/fragments/repoActions", w, params)
255}
256
257type RepoDescriptionParams struct {
258 RepoInfo RepoInfo
259}
260
261func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
262 return p.executePlain("repo/fragments/editRepoDescription", w, params)
263}
264
265func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
266 return p.executePlain("repo/fragments/repoDescription", w, params)
267}
268
269type RepoInfo struct {
270 Name string
271 OwnerDid string
272 OwnerHandle string
273 Description string
274 Knot string
275 RepoAt syntax.ATURI
276 IsStarred bool
277 Stats db.RepoStats
278 Roles RolesInRepo
279 Source *db.Repo
280 SourceHandle string
281 DisableFork bool
282}
283
284type RolesInRepo struct {
285 Roles []string
286}
287
288func (r RolesInRepo) SettingsAllowed() bool {
289 return slices.Contains(r.Roles, "repo:settings")
290}
291
292func (r RolesInRepo) CollaboratorInviteAllowed() bool {
293 return slices.Contains(r.Roles, "repo:invite")
294}
295
296func (r RolesInRepo) RepoDeleteAllowed() bool {
297 return slices.Contains(r.Roles, "repo:delete")
298}
299
300func (r RolesInRepo) IsOwner() bool {
301 return slices.Contains(r.Roles, "repo:owner")
302}
303
304func (r RolesInRepo) IsCollaborator() bool {
305 return slices.Contains(r.Roles, "repo:collaborator")
306}
307
308func (r RolesInRepo) IsPushAllowed() bool {
309 return slices.Contains(r.Roles, "repo:push")
310}
311
312func (r RepoInfo) OwnerWithAt() string {
313 if r.OwnerHandle != "" {
314 return fmt.Sprintf("@%s", r.OwnerHandle)
315 } else {
316 return r.OwnerDid
317 }
318}
319
320func (r RepoInfo) FullName() string {
321 return path.Join(r.OwnerWithAt(), r.Name)
322}
323
324func (r RepoInfo) OwnerWithoutAt() string {
325 if strings.HasPrefix(r.OwnerWithAt(), "@") {
326 return strings.TrimPrefix(r.OwnerWithAt(), "@")
327 } else {
328 return userutil.FlattenDid(r.OwnerDid)
329 }
330}
331
332func (r RepoInfo) FullNameWithoutAt() string {
333 return path.Join(r.OwnerWithoutAt(), r.Name)
334}
335
336func (r RepoInfo) GetTabs() [][]string {
337 tabs := [][]string{
338 {"overview", "/", "square-chart-gantt"},
339 {"issues", "/issues", "circle-dot"},
340 {"pulls", "/pulls", "git-pull-request"},
341 }
342
343 if r.Roles.SettingsAllowed() {
344 tabs = append(tabs, []string{"settings", "/settings", "cog"})
345 }
346
347 return tabs
348}
349
350// each tab on a repo could have some metadata:
351//
352// issues -> number of open issues etc.
353// settings -> a warning icon to setup branch protection? idk
354//
355// we gather these bits of info here, because go templates
356// are difficult to program in
357func (r RepoInfo) TabMetadata() map[string]any {
358 meta := make(map[string]any)
359
360 if r.Stats.PullCount.Open > 0 {
361 meta["pulls"] = r.Stats.PullCount.Open
362 }
363
364 if r.Stats.IssueCount.Open > 0 {
365 meta["issues"] = r.Stats.IssueCount.Open
366 }
367
368 // more stuff?
369
370 return meta
371}
372
373type RepoIndexParams struct {
374 LoggedInUser *auth.User
375 RepoInfo RepoInfo
376 Active string
377 TagMap map[string][]string
378 types.RepoIndexResponse
379 HTMLReadme template.HTML
380 Raw bool
381 EmailToDidOrHandle map[string]string
382}
383
384func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
385 params.Active = "overview"
386 if params.IsEmpty {
387 return p.executeRepo("repo/empty", w, params)
388 }
389
390 if params.ReadmeFileName != "" {
391 var htmlString string
392 ext := filepath.Ext(params.ReadmeFileName)
393 switch ext {
394 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
395 htmlString = markup.RenderMarkdown(params.Readme)
396 params.Raw = false
397 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
398 default:
399 htmlString = string(params.Readme)
400 params.Raw = true
401 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
402 }
403 }
404
405 return p.executeRepo("repo/index", w, params)
406}
407
408type RepoLogParams struct {
409 LoggedInUser *auth.User
410 RepoInfo RepoInfo
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.execute("repo/log", w, params)
419}
420
421type RepoCommitParams struct {
422 LoggedInUser *auth.User
423 RepoInfo RepoInfo
424 Active string
425 types.RepoCommitResponse
426 EmailToDidOrHandle map[string]string
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
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
473 types.RepoBranchesResponse
474}
475
476func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
477 return p.executeRepo("repo/branches", w, params)
478}
479
480type RepoTagsParams struct {
481 LoggedInUser *auth.User
482 RepoInfo RepoInfo
483 types.RepoTagsResponse
484}
485
486func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
487 return p.executeRepo("repo/tags", w, params)
488}
489
490type RepoBlobParams struct {
491 LoggedInUser *auth.User
492 RepoInfo RepoInfo
493 Active string
494 BreadCrumbs [][]string
495 ShowRendered bool
496 RenderToggle bool
497 RenderedContents template.HTML
498 types.RepoBlobResponse
499}
500
501func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
502 var style *chroma.Style = styles.Get("catpuccin-latte")
503
504 if params.ShowRendered {
505 switch markup.GetFormat(params.Path) {
506 case markup.FormatMarkdown:
507 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents))
508 }
509 }
510
511 if params.Lines < 5000 {
512 c := params.Contents
513 formatter := chromahtml.New(
514 chromahtml.InlineCode(false),
515 chromahtml.WithLineNumbers(true),
516 chromahtml.WithLinkableLineNumbers(true, "L"),
517 chromahtml.Standalone(false),
518 chromahtml.WithClasses(true),
519 )
520
521 lexer := lexers.Get(filepath.Base(params.Path))
522 if lexer == nil {
523 lexer = lexers.Fallback
524 }
525
526 iterator, err := lexer.Tokenise(nil, c)
527 if err != nil {
528 return fmt.Errorf("chroma tokenize: %w", err)
529 }
530
531 var code bytes.Buffer
532 err = formatter.Format(&code, style, iterator)
533 if err != nil {
534 return fmt.Errorf("chroma format: %w", err)
535 }
536
537 params.Contents = code.String()
538 }
539
540 params.Active = "overview"
541 return p.executeRepo("repo/blob", w, params)
542}
543
544type Collaborator struct {
545 Did string
546 Handle string
547 Role string
548}
549
550type RepoSettingsParams struct {
551 LoggedInUser *auth.User
552 RepoInfo RepoInfo
553 Collaborators []Collaborator
554 Active string
555 Branches []string
556 DefaultBranch string
557 // TODO: use repoinfo.roles
558 IsCollaboratorInviteAllowed bool
559}
560
561func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
562 params.Active = "settings"
563 return p.executeRepo("repo/settings", w, params)
564}
565
566type RepoIssuesParams struct {
567 LoggedInUser *auth.User
568 RepoInfo RepoInfo
569 Active string
570 Issues []db.Issue
571 DidHandleMap map[string]string
572
573 FilteringByOpen bool
574}
575
576func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
577 params.Active = "issues"
578 return p.executeRepo("repo/issues/issues", w, params)
579}
580
581type RepoSingleIssueParams struct {
582 LoggedInUser *auth.User
583 RepoInfo RepoInfo
584 Active string
585 Issue db.Issue
586 Comments []db.Comment
587 IssueOwnerHandle string
588 DidHandleMap map[string]string
589
590 State string
591}
592
593func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
594 params.Active = "issues"
595 if params.Issue.Open {
596 params.State = "open"
597 } else {
598 params.State = "closed"
599 }
600 return p.execute("repo/issues/issue", w, params)
601}
602
603type RepoNewIssueParams struct {
604 LoggedInUser *auth.User
605 RepoInfo RepoInfo
606 Active string
607}
608
609func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
610 params.Active = "issues"
611 return p.executeRepo("repo/issues/new", w, params)
612}
613
614type EditIssueCommentParams struct {
615 LoggedInUser *auth.User
616 RepoInfo RepoInfo
617 Issue *db.Issue
618 Comment *db.Comment
619}
620
621func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
622 return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
623}
624
625type SingleIssueCommentParams struct {
626 LoggedInUser *auth.User
627 DidHandleMap map[string]string
628 RepoInfo RepoInfo
629 Issue *db.Issue
630 Comment *db.Comment
631}
632
633func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
634 return p.executePlain("repo/issues/fragments/issueComment", w, params)
635}
636
637type RepoNewPullParams struct {
638 LoggedInUser *auth.User
639 RepoInfo RepoInfo
640 Branches []types.Branch
641 Active string
642}
643
644func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
645 params.Active = "pulls"
646 return p.executeRepo("repo/pulls/new", w, params)
647}
648
649type RepoPullsParams struct {
650 LoggedInUser *auth.User
651 RepoInfo RepoInfo
652 Pulls []*db.Pull
653 Active string
654 DidHandleMap map[string]string
655 FilteringBy db.PullState
656}
657
658func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
659 params.Active = "pulls"
660 return p.executeRepo("repo/pulls/pulls", w, params)
661}
662
663type ResubmitResult uint64
664
665const (
666 ShouldResubmit ResubmitResult = iota
667 ShouldNotResubmit
668 Unknown
669)
670
671func (r ResubmitResult) Yes() bool {
672 return r == ShouldResubmit
673}
674func (r ResubmitResult) No() bool {
675 return r == ShouldNotResubmit
676}
677func (r ResubmitResult) Unknown() bool {
678 return r == Unknown
679}
680
681type RepoSinglePullParams struct {
682 LoggedInUser *auth.User
683 RepoInfo RepoInfo
684 Active string
685 DidHandleMap map[string]string
686 Pull *db.Pull
687 PullSourceRepo *db.Repo
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}