forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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}