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 var style *chroma.Style = styles.Get("catpuccin-latte") 501 502 if params.ShowRendered { 503 switch markup.GetFormat(params.Path) { 504 case markup.FormatMarkdown: 505 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents)) 506 } 507 } 508 509 if params.Lines < 5000 { 510 c := params.Contents 511 formatter := chromahtml.New( 512 chromahtml.InlineCode(false), 513 chromahtml.WithLineNumbers(true), 514 chromahtml.WithLinkableLineNumbers(true, "L"), 515 chromahtml.Standalone(false), 516 chromahtml.WithClasses(true), 517 ) 518 519 lexer := lexers.Get(filepath.Base(params.Path)) 520 if lexer == nil { 521 lexer = lexers.Fallback 522 } 523 524 iterator, err := lexer.Tokenise(nil, c) 525 if err != nil { 526 return fmt.Errorf("chroma tokenize: %w", err) 527 } 528 529 var code bytes.Buffer 530 err = formatter.Format(&code, style, iterator) 531 if err != nil { 532 return fmt.Errorf("chroma format: %w", err) 533 } 534 535 params.Contents = code.String() 536 } 537 538 params.Active = "overview" 539 return p.executeRepo("repo/blob", w, params) 540} 541 542type Collaborator struct { 543 Did string 544 Handle string 545 Role string 546} 547 548type RepoSettingsParams struct { 549 LoggedInUser *auth.User 550 RepoInfo RepoInfo 551 Collaborators []Collaborator 552 Active string 553 Branches []string 554 DefaultBranch string 555 // TODO: use repoinfo.roles 556 IsCollaboratorInviteAllowed bool 557} 558 559func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 560 params.Active = "settings" 561 return p.executeRepo("repo/settings", w, params) 562} 563 564type RepoIssuesParams struct { 565 LoggedInUser *auth.User 566 RepoInfo RepoInfo 567 Active string 568 Issues []db.Issue 569 DidHandleMap map[string]string 570 571 FilteringByOpen bool 572} 573 574func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 575 params.Active = "issues" 576 return p.executeRepo("repo/issues/issues", w, params) 577} 578 579type RepoSingleIssueParams struct { 580 LoggedInUser *auth.User 581 RepoInfo RepoInfo 582 Active string 583 Issue db.Issue 584 Comments []db.Comment 585 IssueOwnerHandle string 586 DidHandleMap map[string]string 587 588 State string 589} 590 591func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 592 params.Active = "issues" 593 if params.Issue.Open { 594 params.State = "open" 595 } else { 596 params.State = "closed" 597 } 598 return p.execute("repo/issues/issue", w, params) 599} 600 601type RepoNewIssueParams struct { 602 LoggedInUser *auth.User 603 RepoInfo RepoInfo 604 Active string 605} 606 607func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 608 params.Active = "issues" 609 return p.executeRepo("repo/issues/new", w, params) 610} 611 612type EditIssueCommentParams struct { 613 LoggedInUser *auth.User 614 RepoInfo RepoInfo 615 Issue *db.Issue 616 Comment *db.Comment 617} 618 619func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 620 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 621} 622 623type SingleIssueCommentParams struct { 624 LoggedInUser *auth.User 625 DidHandleMap map[string]string 626 RepoInfo RepoInfo 627 Issue *db.Issue 628 Comment *db.Comment 629} 630 631func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 632 return p.executePlain("repo/issues/fragments/issueComment", w, params) 633} 634 635type RepoNewPullParams struct { 636 LoggedInUser *auth.User 637 RepoInfo RepoInfo 638 Branches []types.Branch 639 Active string 640} 641 642func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 643 params.Active = "pulls" 644 return p.executeRepo("repo/pulls/new", w, params) 645} 646 647type RepoPullsParams struct { 648 LoggedInUser *auth.User 649 RepoInfo RepoInfo 650 Pulls []*db.Pull 651 Active string 652 DidHandleMap map[string]string 653 FilteringBy db.PullState 654} 655 656func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 657 params.Active = "pulls" 658 return p.executeRepo("repo/pulls/pulls", w, params) 659} 660 661type ResubmitResult uint64 662 663const ( 664 ShouldResubmit ResubmitResult = iota 665 ShouldNotResubmit 666 Unknown 667) 668 669func (r ResubmitResult) Yes() bool { 670 return r == ShouldResubmit 671} 672func (r ResubmitResult) No() bool { 673 return r == ShouldNotResubmit 674} 675func (r ResubmitResult) Unknown() bool { 676 return r == Unknown 677} 678 679type RepoSinglePullParams struct { 680 LoggedInUser *auth.User 681 RepoInfo RepoInfo 682 Active string 683 DidHandleMap map[string]string 684 Pull *db.Pull 685 PullSourceRepo *db.Repo 686 MergeCheck types.MergeCheckResponse 687 ResubmitCheck ResubmitResult 688} 689 690func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 691 params.Active = "pulls" 692 return p.executeRepo("repo/pulls/pull", w, params) 693} 694 695type RepoPullPatchParams struct { 696 LoggedInUser *auth.User 697 DidHandleMap map[string]string 698 RepoInfo RepoInfo 699 Pull *db.Pull 700 Diff types.NiceDiff 701 Round int 702 Submission *db.PullSubmission 703} 704 705// this name is a mouthful 706func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 707 return p.execute("repo/pulls/patch", w, params) 708} 709 710type PullPatchUploadParams struct { 711 RepoInfo RepoInfo 712} 713 714func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 715 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 716} 717 718type PullCompareBranchesParams struct { 719 RepoInfo RepoInfo 720 Branches []types.Branch 721} 722 723func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 724 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 725} 726 727type PullCompareForkParams struct { 728 RepoInfo RepoInfo 729 Forks []db.Repo 730} 731 732func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 733 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 734} 735 736type PullCompareForkBranchesParams struct { 737 RepoInfo RepoInfo 738 SourceBranches []types.Branch 739 TargetBranches []types.Branch 740} 741 742func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 743 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 744} 745 746type PullResubmitParams struct { 747 LoggedInUser *auth.User 748 RepoInfo RepoInfo 749 Pull *db.Pull 750 SubmissionId int 751} 752 753func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 754 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 755} 756 757type PullActionsParams struct { 758 LoggedInUser *auth.User 759 RepoInfo RepoInfo 760 Pull *db.Pull 761 RoundNumber int 762 MergeCheck types.MergeCheckResponse 763 ResubmitCheck ResubmitResult 764} 765 766func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 767 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 768} 769 770type PullNewCommentParams struct { 771 LoggedInUser *auth.User 772 RepoInfo RepoInfo 773 Pull *db.Pull 774 RoundNumber int 775} 776 777func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 778 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 779} 780 781func (p *Pages) Static() http.Handler { 782 sub, err := fs.Sub(Files, "static") 783 if err != nil { 784 log.Fatalf("no static dir found? that's crazy: %v", err) 785 } 786 // Custom handler to apply Cache-Control headers for font files 787 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 788} 789 790func Cache(h http.Handler) http.Handler { 791 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 792 path := strings.Split(r.URL.Path, "?")[0] 793 794 if strings.HasSuffix(path, ".css") { 795 // on day for css files 796 w.Header().Set("Cache-Control", "public, max-age=86400") 797 } else { 798 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 799 } 800 h.ServeHTTP(w, r) 801 }) 802} 803 804func CssContentHash() string { 805 cssFile, err := Files.Open("static/tw.css") 806 if err != nil { 807 log.Printf("Error opening CSS file: %v", err) 808 return "" 809 } 810 defer cssFile.Close() 811 812 hasher := sha256.New() 813 if _, err := io.Copy(hasher, cssFile); err != nil { 814 log.Printf("Error hashing CSS file: %v", err) 815 return "" 816 } 817 818 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 819} 820 821func (p *Pages) Error500(w io.Writer) error { 822 return p.execute("errors/500", w, nil) 823} 824 825func (p *Pages) Error404(w io.Writer) error { 826 return p.execute("errors/404", w, nil) 827} 828 829func (p *Pages) Error503(w io.Writer) error { 830 return p.execute("errors/503", w, nil) 831}