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}