forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
this repo has no description
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}