forked from tangled.org/core
this repo has no description
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 "github.com/alecthomas/chroma/v2" 20 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 21 "github.com/alecthomas/chroma/v2/lexers" 22 "github.com/alecthomas/chroma/v2/styles" 23 "github.com/bluesky-social/indigo/atproto/syntax" 24 "github.com/microcosm-cc/bluemonday" 25 "tangled.sh/tangled.sh/core/appview/auth" 26 "tangled.sh/tangled.sh/core/appview/db" 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 "tangled.sh/tangled.sh/core/appview/state/userutil" 29 "tangled.sh/tangled.sh/core/types" 30) 31 32//go:embed templates/* static 33var Files embed.FS 34 35type Pages struct { 36 t map[string]*template.Template 37} 38 39func NewPages() *Pages { 40 templates := make(map[string]*template.Template) 41 42 var fragmentPaths []string 43 // First, collect all fragment paths 44 err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 45 if err != nil { 46 return err 47 } 48 49 if d.IsDir() { 50 return nil 51 } 52 53 if !strings.HasSuffix(path, ".html") { 54 return nil 55 } 56 57 if !strings.Contains(path, "fragments/") { 58 return nil 59 } 60 61 name := strings.TrimPrefix(path, "templates/") 62 name = strings.TrimSuffix(name, ".html") 63 64 tmpl, err := template.New(name). 65 Funcs(funcMap()). 66 ParseFS(Files, path) 67 if err != nil { 68 log.Fatalf("setting up fragment: %v", err) 69 } 70 71 templates[name] = tmpl 72 fragmentPaths = append(fragmentPaths, path) 73 log.Printf("loaded fragment: %s", name) 74 return nil 75 }) 76 if err != nil { 77 log.Fatalf("walking template dir for fragments: %v", err) 78 } 79 80 // Then walk through and setup the rest of the templates 81 err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 82 if err != nil { 83 return err 84 } 85 86 if d.IsDir() { 87 return nil 88 } 89 90 if !strings.HasSuffix(path, "html") { 91 return nil 92 } 93 94 // Skip fragments as they've already been loaded 95 if strings.Contains(path, "fragments/") { 96 return nil 97 } 98 99 // Skip layouts 100 if strings.Contains(path, "layouts/") { 101 return nil 102 } 103 104 name := strings.TrimPrefix(path, "templates/") 105 name = strings.TrimSuffix(name, ".html") 106 107 // Add the page template on top of the base 108 allPaths := []string{} 109 allPaths = append(allPaths, "templates/layouts/*.html") 110 allPaths = append(allPaths, fragmentPaths...) 111 allPaths = append(allPaths, path) 112 tmpl, err := template.New(name). 113 Funcs(funcMap()). 114 ParseFS(Files, allPaths...) 115 if err != nil { 116 return fmt.Errorf("setting up template: %w", err) 117 } 118 119 templates[name] = tmpl 120 log.Printf("loaded template: %s", name) 121 return nil 122 }) 123 if err != nil { 124 log.Fatalf("walking template dir: %v", err) 125 } 126 127 log.Printf("total templates loaded: %d", len(templates)) 128 129 return &Pages{ 130 t: templates, 131 } 132} 133 134type LoginParams struct { 135} 136 137func (p *Pages) execute(name string, w io.Writer, params any) error { 138 return p.t[name].ExecuteTemplate(w, "layouts/base", params) 139} 140 141func (p *Pages) executePlain(name string, w io.Writer, params any) error { 142 return p.t[name].Execute(w, params) 143} 144 145func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 146 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 147} 148 149func (p *Pages) Login(w io.Writer, params LoginParams) error { 150 return p.executePlain("user/login", w, params) 151} 152 153type TimelineParams struct { 154 LoggedInUser *auth.User 155 Timeline []db.TimelineEvent 156 DidHandleMap map[string]string 157} 158 159func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 160 return p.execute("timeline", w, params) 161} 162 163type SettingsParams struct { 164 LoggedInUser *auth.User 165 PubKeys []db.PublicKey 166 Emails []db.Email 167} 168 169func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 170 return p.execute("settings", w, params) 171} 172 173type KnotsParams struct { 174 LoggedInUser *auth.User 175 Registrations []db.Registration 176} 177 178func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 179 return p.execute("knots", w, params) 180} 181 182type KnotParams struct { 183 LoggedInUser *auth.User 184 DidHandleMap map[string]string 185 Registration *db.Registration 186 Members []string 187 IsOwner bool 188} 189 190func (p *Pages) Knot(w io.Writer, params KnotParams) error { 191 return p.execute("knot", w, params) 192} 193 194type NewRepoParams struct { 195 LoggedInUser *auth.User 196 Knots []string 197} 198 199func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 200 return p.execute("repo/new", w, params) 201} 202 203type ForkRepoParams struct { 204 LoggedInUser *auth.User 205 Knots []string 206 RepoInfo RepoInfo 207} 208 209func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 210 return p.execute("repo/fork", w, params) 211} 212 213type ProfilePageParams struct { 214 LoggedInUser *auth.User 215 UserDid string 216 UserHandle string 217 Repos []db.Repo 218 CollaboratingRepos []db.Repo 219 ProfileStats ProfileStats 220 FollowStatus db.FollowStatus 221 AvatarUri string 222 ProfileTimeline *db.ProfileTimeline 223 224 DidHandleMap map[string]string 225} 226 227type ProfileStats struct { 228 Followers int 229 Following int 230} 231 232func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 233 return p.execute("user/profile", w, params) 234} 235 236type FollowFragmentParams struct { 237 UserDid string 238 FollowStatus db.FollowStatus 239} 240 241func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 242 return p.executePlain("user/fragments/follow", w, params) 243} 244 245type RepoActionsFragmentParams struct { 246 IsStarred bool 247 RepoAt syntax.ATURI 248 Stats db.RepoStats 249} 250 251func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 252 return p.executePlain("repo/fragments/repoActions", w, params) 253} 254 255type RepoDescriptionParams struct { 256 RepoInfo RepoInfo 257} 258 259func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 260 return p.executePlain("repo/fragments/editRepoDescription", w, params) 261} 262 263func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 264 return p.executePlain("repo/fragments/repoDescription", w, params) 265} 266 267type RepoInfo struct { 268 Name string 269 OwnerDid string 270 OwnerHandle string 271 Description string 272 Knot string 273 RepoAt syntax.ATURI 274 IsStarred bool 275 Stats db.RepoStats 276 Roles RolesInRepo 277 Source *db.Repo 278 SourceHandle string 279 DisableFork bool 280} 281 282type RolesInRepo struct { 283 Roles []string 284} 285 286func (r RolesInRepo) SettingsAllowed() bool { 287 return slices.Contains(r.Roles, "repo:settings") 288} 289 290func (r RolesInRepo) CollaboratorInviteAllowed() bool { 291 return slices.Contains(r.Roles, "repo:invite") 292} 293 294func (r RolesInRepo) RepoDeleteAllowed() bool { 295 return slices.Contains(r.Roles, "repo:delete") 296} 297 298func (r RolesInRepo) IsOwner() bool { 299 return slices.Contains(r.Roles, "repo:owner") 300} 301 302func (r RolesInRepo) IsCollaborator() bool { 303 return slices.Contains(r.Roles, "repo:collaborator") 304} 305 306func (r RolesInRepo) IsPushAllowed() bool { 307 return slices.Contains(r.Roles, "repo:push") 308} 309 310func (r RepoInfo) OwnerWithAt() string { 311 if r.OwnerHandle != "" { 312 return fmt.Sprintf("@%s", r.OwnerHandle) 313 } else { 314 return r.OwnerDid 315 } 316} 317 318func (r RepoInfo) FullName() string { 319 return path.Join(r.OwnerWithAt(), r.Name) 320} 321 322func (r RepoInfo) OwnerWithoutAt() string { 323 if strings.HasPrefix(r.OwnerWithAt(), "@") { 324 return strings.TrimPrefix(r.OwnerWithAt(), "@") 325 } else { 326 return userutil.FlattenDid(r.OwnerDid) 327 } 328} 329 330func (r RepoInfo) FullNameWithoutAt() string { 331 return path.Join(r.OwnerWithoutAt(), r.Name) 332} 333 334func (r RepoInfo) GetTabs() [][]string { 335 tabs := [][]string{ 336 {"overview", "/", "square-chart-gantt"}, 337 {"issues", "/issues", "circle-dot"}, 338 {"pulls", "/pulls", "git-pull-request"}, 339 } 340 341 if r.Roles.SettingsAllowed() { 342 tabs = append(tabs, []string{"settings", "/settings", "cog"}) 343 } 344 345 return tabs 346} 347 348// each tab on a repo could have some metadata: 349// 350// issues -> number of open issues etc. 351// settings -> a warning icon to setup branch protection? idk 352// 353// we gather these bits of info here, because go templates 354// are difficult to program in 355func (r RepoInfo) TabMetadata() map[string]any { 356 meta := make(map[string]any) 357 358 if r.Stats.PullCount.Open > 0 { 359 meta["pulls"] = r.Stats.PullCount.Open 360 } 361 362 if r.Stats.IssueCount.Open > 0 { 363 meta["issues"] = r.Stats.IssueCount.Open 364 } 365 366 // more stuff? 367 368 return meta 369} 370 371type RepoIndexParams struct { 372 LoggedInUser *auth.User 373 RepoInfo RepoInfo 374 Active string 375 TagMap map[string][]string 376 types.RepoIndexResponse 377 HTMLReadme template.HTML 378 Raw bool 379 EmailToDidOrHandle map[string]string 380} 381 382func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 383 params.Active = "overview" 384 if params.IsEmpty { 385 return p.executeRepo("repo/empty", w, params) 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 = markup.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 409 types.RepoLogResponse 410 Active string 411 EmailToDidOrHandle map[string]string 412} 413 414func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 415 params.Active = "overview" 416 return p.execute("repo/log", w, params) 417} 418 419type RepoCommitParams struct { 420 LoggedInUser *auth.User 421 RepoInfo RepoInfo 422 Active string 423 types.RepoCommitResponse 424 EmailToDidOrHandle map[string]string 425} 426 427func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 428 params.Active = "overview" 429 return p.executeRepo("repo/commit", w, params) 430} 431 432type RepoTreeParams struct { 433 LoggedInUser *auth.User 434 RepoInfo RepoInfo 435 Active string 436 BreadCrumbs [][]string 437 BaseTreeLink string 438 BaseBlobLink string 439 types.RepoTreeResponse 440} 441 442type RepoTreeStats struct { 443 NumFolders uint64 444 NumFiles uint64 445} 446 447func (r RepoTreeParams) TreeStats() RepoTreeStats { 448 numFolders, numFiles := 0, 0 449 for _, f := range r.Files { 450 if !f.IsFile { 451 numFolders += 1 452 } else if f.IsFile { 453 numFiles += 1 454 } 455 } 456 457 return RepoTreeStats{ 458 NumFolders: uint64(numFolders), 459 NumFiles: uint64(numFiles), 460 } 461} 462 463func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 464 params.Active = "overview" 465 return p.execute("repo/tree", w, params) 466} 467 468type RepoBranchesParams struct { 469 LoggedInUser *auth.User 470 RepoInfo RepoInfo 471 types.RepoBranchesResponse 472} 473 474func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 475 return p.executeRepo("repo/branches", w, params) 476} 477 478type RepoTagsParams struct { 479 LoggedInUser *auth.User 480 RepoInfo RepoInfo 481 types.RepoTagsResponse 482} 483 484func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 485 return p.executeRepo("repo/tags", w, params) 486} 487 488type RepoBlobParams struct { 489 LoggedInUser *auth.User 490 RepoInfo RepoInfo 491 Active string 492 BreadCrumbs [][]string 493 ShowRendered bool 494 RenderToggle bool 495 RenderedContents template.HTML 496 types.RepoBlobResponse 497} 498 499func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 500 style := styles.Get("bw") 501 b := style.Builder() 502 b.Add(chroma.LiteralString, "noitalic") 503 style, _ = b.Build() 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 ) 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 PullPatchUploadParams struct { 713 RepoInfo RepoInfo 714} 715 716func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 717 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 718} 719 720type PullCompareBranchesParams struct { 721 RepoInfo RepoInfo 722 Branches []types.Branch 723} 724 725func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 726 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 727} 728 729type PullCompareForkParams struct { 730 RepoInfo RepoInfo 731 Forks []db.Repo 732} 733 734func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 735 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 736} 737 738type PullCompareForkBranchesParams struct { 739 RepoInfo RepoInfo 740 SourceBranches []types.Branch 741 TargetBranches []types.Branch 742} 743 744func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 745 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 746} 747 748type PullResubmitParams struct { 749 LoggedInUser *auth.User 750 RepoInfo RepoInfo 751 Pull *db.Pull 752 SubmissionId int 753} 754 755func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 756 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 757} 758 759type PullActionsParams struct { 760 LoggedInUser *auth.User 761 RepoInfo RepoInfo 762 Pull *db.Pull 763 RoundNumber int 764 MergeCheck types.MergeCheckResponse 765 ResubmitCheck ResubmitResult 766} 767 768func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 769 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 770} 771 772type PullNewCommentParams struct { 773 LoggedInUser *auth.User 774 RepoInfo RepoInfo 775 Pull *db.Pull 776 RoundNumber int 777} 778 779func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 780 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 781} 782 783func (p *Pages) Static() http.Handler { 784 sub, err := fs.Sub(Files, "static") 785 if err != nil { 786 log.Fatalf("no static dir found? that's crazy: %v", err) 787 } 788 // Custom handler to apply Cache-Control headers for font files 789 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 790} 791 792func Cache(h http.Handler) http.Handler { 793 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 794 path := strings.Split(r.URL.Path, "?")[0] 795 796 if strings.HasSuffix(path, ".css") { 797 // on day for css files 798 w.Header().Set("Cache-Control", "public, max-age=86400") 799 } else { 800 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 801 } 802 h.ServeHTTP(w, r) 803 }) 804} 805 806func CssContentHash() string { 807 cssFile, err := Files.Open("static/tw.css") 808 if err != nil { 809 log.Printf("Error opening CSS file: %v", err) 810 return "" 811 } 812 defer cssFile.Close() 813 814 hasher := sha256.New() 815 if _, err := io.Copy(hasher, cssFile); err != nil { 816 log.Printf("Error hashing CSS file: %v", err) 817 return "" 818 } 819 820 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 821} 822 823func (p *Pages) Error500(w io.Writer) error { 824 return p.execute("errors/500", w, nil) 825} 826 827func (p *Pages) Error404(w io.Writer) error { 828 return p.execute("errors/404", w, nil) 829} 830 831func (p *Pages) Error503(w io.Writer) error { 832 return p.execute("errors/503", w, nil) 833}