This repository has no description
1package pages
2
3import (
4 "context"
5 "crypto/sha256"
6 "embed"
7 "encoding/hex"
8 "fmt"
9 "html/template"
10 "io"
11 "io/fs"
12 "log/slog"
13 "net/http"
14 "os"
15 "path/filepath"
16 "strings"
17 "sync"
18 "time"
19
20 "tangled.org/core/api/tangled"
21 "tangled.org/core/appview/cache"
22 "tangled.org/core/appview/commitverify"
23 "tangled.org/core/appview/config"
24 "tangled.org/core/appview/db"
25 "tangled.org/core/appview/filetree"
26 "tangled.org/core/appview/models"
27 "tangled.org/core/appview/oauth"
28 "tangled.org/core/appview/pages/markup"
29 "tangled.org/core/appview/pages/markup/sanitizer"
30 "tangled.org/core/appview/pages/repoinfo"
31 "tangled.org/core/appview/pagination"
32 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
33 "tangled.org/core/idresolver"
34 "tangled.org/core/types"
35
36 "github.com/bluesky-social/indigo/atproto/identity"
37 "github.com/bluesky-social/indigo/atproto/syntax"
38 "github.com/go-git/go-git/v5/plumbing"
39 "github.com/sourcegraph/zoekt"
40)
41
42//go:embed templates/* static legal
43var Files embed.FS
44
45type baseParamsCtxKey struct{}
46
47type BaseParams struct {
48 LoggedInUser *oauth.MultiAccountUser
49 FocusParams FocusParams
50 ThemePreference models.ThemePreference
51}
52
53type FocusParams struct {
54 Focusing bool
55 FocusLink string
56 FocusNotificationID int64
57 CurrentPath string // r.URL.Path, for off-focus detection in templates
58 FocusCount int // total unread focus-eligible items remaining
59}
60
61func (p *Pages) Resolver() *idresolver.Resolver {
62 return p.resolver
63}
64
65func BaseParamsIntoContext(ctx context.Context, bp BaseParams) context.Context {
66 return context.WithValue(ctx, baseParamsCtxKey{}, bp)
67}
68
69func BaseParamsFromContext(ctx context.Context) BaseParams {
70 bp, _ := ctx.Value(baseParamsCtxKey{}).(BaseParams)
71 return bp
72}
73
74type Pages struct {
75 mu sync.RWMutex
76 cache *TmplCache[string, *template.Template]
77
78 avatar config.AvatarConfig
79 pdsCfg config.PdsConfig
80 resolver *idresolver.Resolver
81 db *db.DB
82 rdb *cache.Cache
83 dev bool
84 embedFS fs.FS
85 templateDir string // Path to templates on disk for dev mode
86 rctx *markup.RenderContext
87 logger *slog.Logger
88}
89
90func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb *cache.Cache, logger *slog.Logger) *Pages {
91 // initialized with safe defaults, can be overridden per use
92 rctx := &markup.RenderContext{
93 IsDev: config.Core.Dev,
94 Hostname: config.Core.AppviewHost,
95 CamoUrl: config.Camo.Host,
96 CamoSecret: config.Camo.SharedSecret,
97 Files: Files,
98 }
99
100 p := &Pages{
101 mu: sync.RWMutex{},
102 cache: NewTmplCache[string, *template.Template](),
103 dev: config.Core.Dev,
104 avatar: config.Avatar,
105 pdsCfg: config.Pds,
106 rctx: rctx,
107 resolver: res,
108 db: database,
109 rdb: rdb,
110 templateDir: "appview/pages",
111 logger: logger,
112 }
113
114 if p.dev {
115 p.embedFS = os.DirFS(p.templateDir)
116 } else {
117 p.embedFS = Files
118 }
119
120 return p
121}
122
123// reverse of pathToName
124func (p *Pages) nameToPath(s string) string {
125 return "templates/" + s + ".html"
126}
127
128// FuncMap returns the template function map for use by external template consumers.
129func (p *Pages) FuncMap() template.FuncMap {
130 return p.funcMap()
131}
132
133// FragmentPaths returns all fragment template paths from the embedded FS.
134func (p *Pages) FragmentPaths() ([]string, error) {
135 return p.fragmentPaths()
136}
137
138// EmbedFS returns the embedded filesystem containing templates and static assets.
139func (p *Pages) EmbedFS() fs.FS {
140 return p.embedFS
141}
142
143func (p *Pages) fragmentPaths() ([]string, error) {
144 var fragmentPaths []string
145 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
146 if err != nil {
147 return err
148 }
149 if d.IsDir() {
150 return nil
151 }
152 if !strings.HasSuffix(path, ".html") {
153 return nil
154 }
155 if !strings.Contains(path, "fragments/") {
156 return nil
157 }
158 fragmentPaths = append(fragmentPaths, path)
159 return nil
160 })
161 if err != nil {
162 return nil, err
163 }
164
165 return fragmentPaths, nil
166}
167
168// parse without memoization
169func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
170 paths, err := p.fragmentPaths()
171 if err != nil {
172 return nil, err
173 }
174 for _, s := range stack {
175 paths = append(paths, p.nameToPath(s))
176 }
177
178 funcs := p.funcMap()
179 top := stack[len(stack)-1]
180 parsed, err := template.New(top).
181 Funcs(funcs).
182 ParseFS(p.embedFS, paths...)
183 if err != nil {
184 return nil, err
185 }
186
187 return parsed, nil
188}
189
190func (p *Pages) parse(stack ...string) (*template.Template, error) {
191 key := strings.Join(stack, "|")
192
193 // never cache in dev mode
194 if cached, exists := p.cache.Get(key); !p.dev && exists {
195 return cached, nil
196 }
197
198 result, err := p.rawParse(stack...)
199 if err != nil {
200 return nil, err
201 }
202
203 p.cache.Set(key, result)
204 return result, nil
205}
206
207func (p *Pages) parseBase(top string) (*template.Template, error) {
208 stack := []string{
209 "layouts/base",
210 top,
211 }
212 return p.parse(stack...)
213}
214
215func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
216 stack := []string{
217 "layouts/base",
218 "layouts/repobase",
219 top,
220 }
221 return p.parse(stack...)
222}
223
224func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
225 stack := []string{
226 "layouts/base",
227 "layouts/profilebase",
228 top,
229 }
230 return p.parse(stack...)
231}
232
233func (p *Pages) parseLoginBase(top string) (*template.Template, error) {
234 stack := []string{
235 "layouts/base",
236 "layouts/loginbase",
237 top,
238 }
239 return p.parse(stack...)
240}
241
242func (p *Pages) parseOnboardingBase(top string) (*template.Template, error) {
243 stack := []string{
244 "layouts/base",
245 "layouts/onboardingbase",
246 top,
247 }
248 return p.parse(stack...)
249}
250
251func (p *Pages) executePlain(name string, w io.Writer, params any) error {
252 tpl, err := p.parse(name)
253 if err != nil {
254 return err
255 }
256
257 err = tpl.Execute(w, params)
258 if err != nil {
259 p.logger.Error("failed to execute template", "template", name, "err", err)
260 }
261 return err
262}
263
264func (p *Pages) executeLogin(name string, w io.Writer, params any) error {
265 tpl, err := p.parseLoginBase(name)
266 if err != nil {
267 return err
268 }
269
270 err = tpl.ExecuteTemplate(w, "layouts/base", params)
271 if err != nil {
272 p.logger.Error("failed to execute login template", "template", name, "err", err)
273 }
274 return err
275}
276
277func (p *Pages) executeOnboarding(name string, w io.Writer, params any) error {
278 tpl, err := p.parseOnboardingBase(name)
279 if err != nil {
280 return err
281 }
282
283 return tpl.ExecuteTemplate(w, "layouts/base", params)
284}
285
286func (p *Pages) execute(name string, w io.Writer, params any) error {
287 tpl, err := p.parseBase(name)
288 if err != nil {
289 return err
290 }
291
292 err = tpl.ExecuteTemplate(w, "layouts/base", params)
293 if err != nil {
294 p.logger.Error("failed to execute template", "template", name, "err", err)
295 }
296 return err
297}
298
299func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
300 tpl, err := p.parseRepoBase(name)
301 if err != nil {
302 return err
303 }
304
305 err = tpl.ExecuteTemplate(w, "layouts/base", params)
306 if err != nil {
307 p.logger.Error("failed to execute repo template", "template", name, "err", err)
308 }
309 return err
310}
311
312func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
313 tpl, err := p.parseProfileBase(name)
314 if err != nil {
315 return err
316 }
317
318 err = tpl.ExecuteTemplate(w, "layouts/base", params)
319 if err != nil {
320 p.logger.Error("failed to execute profile template", "template", name, "err", err)
321 }
322 return err
323}
324
325type DollyParams struct {
326 Classes string
327 FillColor string
328 // Favicon embeds a prefers-color-scheme style block so the SVG
329 // adapts to dark mode when used as a standalone favicon document.
330 Favicon bool
331}
332
333func (p *Pages) Dolly(w io.Writer, params DollyParams) error {
334 return p.executePlain("fragments/dolly/logo", w, params)
335}
336
337func (p *Pages) Favicon(w io.Writer) error {
338 return p.Dolly(w, DollyParams{
339 Favicon: true,
340 })
341}
342
343type LoginParams struct {
344 BaseParams
345 ReturnUrl string
346 ErrorCode string
347 AddAccount bool
348 Handle string
349 Accounts []oauth.AccountInfo
350}
351
352func (p *Pages) Login(w io.Writer, params LoginParams) error {
353 return p.executeLogin("user/login", w, params)
354}
355
356type SignupParams struct {
357 BaseParams
358 CloudflareSiteKey string
359 EmailId string
360}
361
362func (p *Pages) Signup(w io.Writer, params SignupParams) error {
363 return p.executeLogin("user/signup", w, params)
364}
365
366func (p *Pages) CompleteSignup(w io.Writer) error {
367 return p.executeLogin("user/completeSignup", w, BaseParams{})
368}
369
370type SignupSuccessParams struct {
371 Handle string
372}
373
374func (p *Pages) SignupSuccess(w io.Writer, params SignupSuccessParams) error {
375 return p.executePlain("user/fragments/signupSuccess", w, params)
376}
377
378type TermsOfServiceParams struct {
379 BaseParams
380 Content template.HTML
381}
382
383func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
384 filename := "terms.md"
385 filePath := filepath.Join("legal", filename)
386
387 file, err := p.embedFS.Open(filePath)
388 if err != nil {
389 return fmt.Errorf("failed to read %s: %w", filename, err)
390 }
391 defer file.Close()
392
393 markdownBytes, err := io.ReadAll(file)
394 if err != nil {
395 return fmt.Errorf("failed to read %s: %w", filename, err)
396 }
397
398 rctx := p.rctx.Clone()
399 rctx.RendererType = markup.RendererTypeDefault
400 htmlString := rctx.RenderMarkdown(string(markdownBytes))
401 sanitized := sanitizer.SanitizeDefault(htmlString)
402 params.Content = template.HTML(sanitized)
403
404 return p.execute("legal/terms", w, params)
405}
406
407type PrivacyPolicyParams struct {
408 BaseParams
409 Content template.HTML
410}
411
412func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
413 filename := "privacy.md"
414 filePath := filepath.Join("legal", filename)
415
416 file, err := p.embedFS.Open(filePath)
417 if err != nil {
418 return fmt.Errorf("failed to read %s: %w", filename, err)
419 }
420 defer file.Close()
421
422 markdownBytes, err := io.ReadAll(file)
423 if err != nil {
424 return fmt.Errorf("failed to read %s: %w", filename, err)
425 }
426
427 rctx := p.rctx.Clone()
428 rctx.RendererType = markup.RendererTypeDefault
429 htmlString := rctx.RenderMarkdown(string(markdownBytes))
430 sanitized := sanitizer.SanitizeDefault(htmlString)
431 params.Content = template.HTML(sanitized)
432
433 return p.execute("legal/privacy", w, params)
434}
435
436type BrandParams struct {
437 BaseParams
438}
439
440func (p *Pages) Brand(w io.Writer, params BrandParams) error {
441 return p.execute("brand/brand", w, params)
442}
443
444type RecentItem struct {
445 Link *models.RecentLink
446 Repo *models.Repo
447 Issue *models.Issue
448 Pull *models.Pull
449}
450
451type BlogPost struct {
452 Slug string
453 Title string
454 Subtitle string
455 Date time.Time
456}
457
458type TimelineParams struct {
459 BaseParams
460 Onboarding models.OnboardingProgress
461 Timeline []models.TimelineGroup
462 Repos []models.Repo
463 GfiLabel *models.LabelDefinition
464 BlueskyPosts []models.BskyPost
465 VouchSuggestions []models.VouchSuggestion
466 Notifications []*models.NotificationWithEntity
467 Recents []RecentItem
468 FollowingOnly bool
469 RecentBlogPosts []BlogPost
470 // ShowNewsletter controls whether the newsletter widget/CTA is rendered.
471 // For logged-in users it reflects their newsletter_preferences row; for
472 // anonymous visitors it is always true (dismissal falls back to
473 // localStorage on the client).
474 ShowNewsletter bool
475 CanFocus bool
476}
477
478func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
479 return p.execute("timeline/timeline", w, params)
480}
481
482type GoodFirstIssuesParams struct {
483 BaseParams
484 Issues []models.Issue
485 RepoGroups []*models.RepoGroup
486 LabelDefs map[string]*models.LabelDefinition
487 GfiLabel *models.LabelDefinition
488 Page pagination.Page
489}
490
491func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
492 return p.execute("goodfirstissues/index", w, params)
493}
494
495type UserProfileSettingsParams struct {
496 BaseParams
497 Tab string
498 PunchcardPreference models.PunchcardPreference
499 IsTnglSh bool
500 IsDeactivated bool
501 HandleOpen bool
502}
503
504func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
505 params.Tab = "profile"
506 return p.execute("user/settings/profile", w, params)
507}
508
509type GroupedNotifications struct {
510 Today []*models.NotificationWithEntity
511 ThisWeek []*models.NotificationWithEntity
512 Older []*models.NotificationWithEntity
513}
514
515func GroupNotificationsByDate(notifs []*models.NotificationWithEntity) GroupedNotifications {
516 now := time.Now()
517 todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
518 weekStart := todayStart.AddDate(0, 0, -6)
519
520 var g GroupedNotifications
521 for _, n := range notifs {
522 switch {
523 case !n.Created.Before(todayStart):
524 g.Today = append(g.Today, n)
525 case !n.Created.Before(weekStart):
526 g.ThisWeek = append(g.ThisWeek, n)
527 default:
528 g.Older = append(g.Older, n)
529 }
530 }
531 return g
532}
533
534type NotificationsParams struct {
535 BaseParams
536 WorkGroups GroupedNotifications
537 SocialGroups GroupedNotifications
538 MobileGroups GroupedNotifications
539 WorkUnreadCount int64
540 SocialUnreadCount int64
541 Page pagination.Page
542 Total int
543 ReadFilter string // "inbox" or "unread"
544 CategoryFilter string // "all", "work", "social"
545 CanFocus bool
546}
547
548func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
549 return p.execute("notifications/list", w, params)
550}
551
552func (p *Pages) NotificationItem(w io.Writer, notif *models.NotificationWithEntity) error {
553 return p.executePlain("notifications/fragments/item", w, notif)
554}
555
556type NotificationCountParams struct {
557 Count int64
558}
559
560func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
561 return p.executePlain("notifications/fragments/count", w, params)
562}
563
564type NotificationPreviewParams struct {
565 BaseParams
566 Notifications []*models.NotificationWithEntity
567 ReadFilter string
568 CategoryFilter string
569 CanFocus bool
570}
571
572func (p *Pages) NotificationPreview(w io.Writer, params NotificationPreviewParams) error {
573 return p.executePlain("notifications/fragments/preview", w, params)
574}
575
576type UserKeysSettingsParams struct {
577 BaseParams
578 PubKeys []models.PublicKey
579 Tab string
580}
581
582func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
583 params.Tab = "keys"
584 return p.execute("user/settings/keys", w, params)
585}
586
587type UserEmailsSettingsParams struct {
588 BaseParams
589 Emails []models.Email
590 Tab string
591}
592
593func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
594 params.Tab = "emails"
595 return p.execute("user/settings/emails", w, params)
596}
597
598type UserNotificationSettingsParams struct {
599 BaseParams
600 Preferences *models.NotificationPreferences
601 HasVerifiedEmail bool
602 Tab string
603}
604
605func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
606 params.Tab = "notifications"
607 return p.execute("user/settings/notifications", w, params)
608}
609
610type UserSiteSettingsParams struct {
611 BaseParams
612 Claim *models.DomainClaim
613 SitesDomain string
614 IsTnglHandle bool
615 Tab string
616}
617
618func (p *Pages) UserSiteSettings(w io.Writer, params UserSiteSettingsParams) error {
619 params.Tab = "sites"
620 return p.execute("user/settings/sites", w, params)
621}
622
623type UpgradeBannerParams struct {
624 Registrations []models.Registration
625 Spindles []models.Spindle
626}
627
628func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
629 return p.executePlain("banner", w, params)
630}
631
632type NewsletterResponseParams struct {
633 // Id identifies the calling form instance; the response span's id will
634 // be "newsletter-msg-<Id>" so it round-trips with the form's hx-target.
635 Id string
636 // Error, when non-empty, switches the template to the error variant.
637 Error string
638}
639
640func (p *Pages) NewsletterResponse(w io.Writer, params NewsletterResponseParams) error {
641 return p.executePlain("timeline/fragments/newsletterResponse", w, params)
642}
643
644type KnotsParams struct {
645 BaseParams
646 Knots []KnotListingParams
647 Tab string
648}
649
650func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
651 params.Tab = "knots"
652 return p.execute("knots/index", w, params)
653}
654
655type KnotParams struct {
656 BaseParams
657 Registration *models.Registration
658 Members []string
659 Repos map[string][]models.Repo
660 IsOwner bool
661 RepoCount int
662 Tab string
663}
664
665func (p *Pages) Knot(w io.Writer, params KnotParams) error {
666 return p.execute("knots/dashboard", w, params)
667}
668
669type KnotListingParams struct {
670 *models.Registration
671 RepoCount int
672}
673
674func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
675 return p.executePlain("knots/fragments/knotListing", w, params)
676}
677
678type SpindlesParams struct {
679 BaseParams
680 Spindles []models.Spindle
681 Tab string
682}
683
684func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
685 params.Tab = "spindles"
686 return p.execute("spindles/index", w, params)
687}
688
689type SpindleListingParams struct {
690 models.Spindle
691 Tab string
692}
693
694func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
695 return p.executePlain("spindles/fragments/spindleListing", w, params)
696}
697
698type SpindleDashboardParams struct {
699 BaseParams
700 Spindle models.Spindle
701 Members []string
702 Repos map[string][]models.Repo
703 Tab string
704}
705
706func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
707 return p.execute("spindles/dashboard", w, params)
708}
709
710type NewRepoParams struct {
711 BaseParams
712 Knots []string
713 Spindles []string
714}
715
716func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
717 return p.execute("repo/new", w, params)
718}
719
720type ForkRepoParams struct {
721 BaseParams
722 Knots []string
723 Spindles []string
724 RepoInfo repoinfo.RepoInfo
725}
726
727func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
728 return p.execute("repo/fork", w, params)
729}
730
731type ProfileCard struct {
732 UserDid string
733 HasProfile bool
734 IsTangledUser bool
735 FollowStatus models.FollowStatus
736 VouchRelationship *models.VouchRelationship
737 Punchcard *models.Punchcard
738 Profile *models.Profile
739 Stats ProfileStats
740 Active string
741 ProfileScript string
742}
743
744type ProfileStats struct {
745 RepoCount int64
746 StarredCount int64
747 StringCount int64
748 FollowersCount int64
749 FollowingCount int64
750}
751
752func (p *ProfileCard) GetTabs() [][]any {
753 tabs := [][]any{
754 {"overview", "overview", "square-chart-gantt", nil},
755 {"repos", "repos", "book-marked", p.Stats.RepoCount},
756 {"starred", "starred", "star", p.Stats.StarredCount},
757 {"strings", "strings", "line-squiggle", p.Stats.StringCount},
758 {"vouches", "vouches", "shield", nil},
759 }
760
761 return tabs
762}
763
764type ProfileOverviewParams struct {
765 BaseParams
766 Repos []models.Repo
767 CollaboratingRepos []models.Repo
768 ProfileTimeline *models.ProfileTimeline
769 Card *ProfileCard
770 Active string
771 ShowPunchcard bool
772}
773
774func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
775 params.Active = "overview"
776 return p.executeProfile("user/overview", w, params)
777}
778
779type ProfileReposParams struct {
780 BaseParams
781 Repos []models.Repo
782 StarStatuses map[string]bool
783 Card *ProfileCard
784 Active string
785 Page pagination.Page
786 RepoCount int
787 FilterQuery string
788}
789
790func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
791 params.Active = "repos"
792 return p.executeProfile("user/repos", w, params)
793}
794
795type ProfileStarredParams struct {
796 BaseParams
797 Repos []models.Repo
798 Card *ProfileCard
799 Page pagination.Page
800 Total int
801 Active string
802}
803
804func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
805 params.Active = "starred"
806 return p.executeProfile("user/starred", w, params)
807}
808
809type ProfileStringsParams struct {
810 BaseParams
811 Strings []models.String
812 Card *ProfileCard
813 Active string
814}
815
816func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
817 params.Active = "strings"
818 return p.executeProfile("user/strings", w, params)
819}
820
821type ProfileVouchesParams struct {
822 BaseParams
823 Vouches []models.Vouch
824 Suggestions []models.VouchSuggestion
825 Card *ProfileCard
826 Page pagination.Page
827 VouchCount int
828 Active string
829 EvidencePulls map[syntax.ATURI]*models.Pull
830 EvidenceIssues map[syntax.ATURI]*models.Issue
831}
832
833func (p *Pages) ProfileVouches(w io.Writer, params ProfileVouchesParams) error {
834 params.Active = "vouches"
835 return p.executeProfile("user/vouches", w, params)
836}
837
838type FollowCard struct {
839 UserDid string
840 BaseParams
841 FollowStatus models.FollowStatus
842 FollowersCount int64
843 FollowingCount int64
844 Profile *models.Profile
845}
846
847type ProfileFollowersParams struct {
848 BaseParams
849 Followers []FollowCard
850 Card *ProfileCard
851 Active string
852}
853
854func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
855 params.Active = "overview"
856 return p.executeProfile("user/followers", w, params)
857}
858
859type ProfileFollowingParams struct {
860 BaseParams
861 Following []FollowCard
862 Card *ProfileCard
863 Active string
864}
865
866func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
867 params.Active = "overview"
868 return p.executeProfile("user/following", w, params)
869}
870
871type FollowFragmentParams struct {
872 UserDid string
873 FollowStatus models.FollowStatus
874 FollowersCount int64
875 HxSwapOob struct{} // empty struct so always truthy
876}
877
878func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
879 return p.executePlain("user/fragments/follow-oob", w, params)
880}
881
882type ProfilePopoverParams struct {
883 BaseParams
884 UserDid string
885 Profile *models.Profile
886 FollowStatus models.FollowStatus
887 VouchRelationship *models.VouchRelationship
888 Stats ProfilePopoverStats
889}
890
891type ProfilePopoverStats struct {
892 FollowersCount int64
893 FollowingCount int64
894}
895
896func (p *Pages) ProfilePopoverFragment(w io.Writer, params ProfilePopoverParams) error {
897 return p.executePlain("user/fragments/profilePopover", w, params)
898}
899
900type EditBioParams struct {
901 BaseParams
902 Profile *models.Profile
903 AlsoKnownAs []string
904 // Action optionally overrides the form's hx-post target. Defaults to
905 // /profile/bio when empty. Used by the onboarding flow to save + advance.
906 Action string
907}
908
909func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
910 return p.executePlain("user/fragments/editBio", w, params)
911}
912
913type EditPinsParams struct {
914 BaseParams
915 Profile *models.Profile
916 AllRepos []PinnedRepo
917}
918
919type PinnedRepo struct {
920 IsPinned bool
921 models.Repo
922}
923
924func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
925 return p.executePlain("user/fragments/editPins", w, params)
926}
927
928type StarBtnFragmentParams struct {
929 IsStarred bool
930 SubjectAt syntax.ATURI
931 StarCount int
932 RepoName string
933 HxSwapOob bool
934}
935
936func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
937 params.HxSwapOob = true
938 return p.executePlain("fragments/starBtn", w, params)
939}
940
941type OnboardingParams struct {
942 BaseParams
943 Step int
944
945 EditBio EditBioParams
946
947 PubKeys []models.PublicKey
948
949 People []FollowCard
950 TrendingRepos []models.Repo
951 StarStatuses map[string]bool
952}
953
954// KeyFragment renders a single SSH key row (used to append a newly added key
955// to the list without a full reload).
956func (p *Pages) KeyFragment(w io.Writer, key models.PublicKey) error {
957 return p.executePlain("user/settings/fragments/keyListing", w, key)
958}
959
960func (p *Pages) Onboarding(w io.Writer, params OnboardingParams) error {
961 return p.executeOnboarding("onboarding/welcome", w, params)
962}
963
964type RepoIndexParams struct {
965 BaseParams
966 RepoInfo repoinfo.RepoInfo
967 Active string
968 TagMap map[string][]string
969 CommitsTrunc []types.Commit
970 TagsTrunc []*types.TagReference
971 BranchesTrunc []types.Branch
972 // ForkInfo *types.ForkInfo
973 HTMLReadme template.HTML
974 Raw bool
975 EmailToDid map[string]string
976 VerifiedCommits commitverify.VerifiedCommits
977 Languages []types.RepoLanguageDetails
978 NeedsKnotUpgrade bool
979 KnotUnreachable bool
980 types.RepoIndexResponse
981}
982
983func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
984 params.Active = "overview"
985 if params.IsEmpty {
986 return p.executeRepo("repo/empty", w, params)
987 }
988
989 if params.NeedsKnotUpgrade {
990 return p.executeRepo("repo/needsUpgrade", w, params)
991 }
992
993 if params.KnotUnreachable {
994 return p.executeRepo("repo/knotUnreachable", w, params)
995 }
996
997 rctx := p.rctx.Clone()
998 rctx.RepoInfo = params.RepoInfo
999 rctx.RepoInfo.Ref = params.Ref
1000 rctx.RendererType = markup.RendererTypeRepoMarkdown
1001
1002 if params.ReadmeFileName != "" {
1003 switch markup.GetFormat(params.ReadmeFileName) {
1004 case markup.FormatMarkdown:
1005 params.Raw = false
1006 htmlString := rctx.RenderMarkdown(params.Readme)
1007 sanitized := sanitizer.SanitizeDefault(htmlString)
1008 params.HTMLReadme = template.HTML(sanitized)
1009 default:
1010 params.Raw = true
1011 }
1012 }
1013
1014 return p.executeRepo("repo/index", w, params)
1015}
1016
1017type RepoSearchParams struct {
1018 BaseParams
1019 RepoInfo repoinfo.RepoInfo
1020 Active string
1021 FilterQuery string
1022}
1023
1024func (p *Pages) RepoSearchPage(w io.Writer, params RepoSearchParams) error {
1025 params.Active = "overview"
1026 return p.executeRepo("repo/search", w, params)
1027}
1028
1029type RepoSearchResultsFragmentParams struct {
1030 Query string
1031 Results []SearchResult
1032 ErrorMsg string
1033}
1034
1035func (p *Pages) RepoSearchResultsFragment(w io.Writer, params RepoSearchResultsFragmentParams) error {
1036 return p.executePlain("repo/fragments/searchResults", w, params)
1037}
1038
1039type RepoLogParams struct {
1040 BaseParams
1041 RepoInfo repoinfo.RepoInfo
1042 TagMap map[string][]string
1043 Active string
1044 EmailToDid map[string]string
1045 VerifiedCommits commitverify.VerifiedCommits
1046
1047 types.RepoLogResponse
1048}
1049
1050func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
1051 params.Active = "overview"
1052 return p.executeRepo("repo/log", w, params)
1053}
1054
1055type RepoCommitParams struct {
1056 BaseParams
1057 RepoInfo repoinfo.RepoInfo
1058 Active string
1059 EmailToDid map[string]string
1060 Pipeline *types.Pipeline
1061 DiffOpts types.DiffOpts
1062
1063 // singular because it's always going to be just one
1064 VerifiedCommit commitverify.VerifiedCommits
1065
1066 types.RepoCommitResponse
1067}
1068
1069func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
1070 params.Active = "overview"
1071 return p.executeRepo("repo/commit", w, params)
1072}
1073
1074type RepoTreeParams struct {
1075 BaseParams
1076 RepoInfo repoinfo.RepoInfo
1077 Active string
1078 BreadCrumbs [][]string
1079 Path string
1080 Raw bool
1081 HTMLReadme template.HTML
1082 EmailToDid map[string]string
1083 LastCommitInfo *types.LastCommitInfo
1084 Ref string
1085 Parent string
1086 DotDot string
1087 Files []types.NiceTree
1088 ReadmeFileName string
1089 Readme string
1090}
1091
1092type RepoTreeStats struct {
1093 NumFolders uint64
1094 NumFiles uint64
1095}
1096
1097func (r RepoTreeParams) TreeStats() RepoTreeStats {
1098 numFolders, numFiles := 0, 0
1099 for _, f := range r.Files {
1100 if !f.IsFile() {
1101 numFolders += 1
1102 } else if f.IsFile() {
1103 numFiles += 1
1104 }
1105 }
1106
1107 return RepoTreeStats{
1108 NumFolders: uint64(numFolders),
1109 NumFiles: uint64(numFiles),
1110 }
1111}
1112
1113func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
1114 params.Active = "overview"
1115
1116 rctx := p.rctx.Clone()
1117 rctx.RepoInfo = params.RepoInfo
1118 rctx.RepoInfo.Ref = params.Ref
1119 rctx.RendererType = markup.RendererTypeRepoMarkdown
1120
1121 if params.ReadmeFileName != "" {
1122 switch markup.GetFormat(params.ReadmeFileName) {
1123 case markup.FormatMarkdown:
1124 params.Raw = false
1125 htmlString := rctx.RenderMarkdown(params.Readme)
1126 sanitized := sanitizer.SanitizeDefault(htmlString)
1127 params.HTMLReadme = template.HTML(sanitized)
1128 default:
1129 params.Raw = true
1130 }
1131 }
1132
1133 return p.executeRepo("repo/tree", w, params)
1134}
1135
1136type RepoBranchesParams struct {
1137 BaseParams
1138 RepoInfo repoinfo.RepoInfo
1139 Active string
1140 types.RepoBranchesResponse
1141}
1142
1143func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
1144 params.Active = "overview"
1145 return p.executeRepo("repo/branches", w, params)
1146}
1147
1148type RepoTagsParams struct {
1149 BaseParams
1150 RepoInfo repoinfo.RepoInfo
1151 Active string
1152 types.RepoTagsResponse
1153 ArtifactMap map[plumbing.Hash][]models.Artifact
1154 DanglingArtifacts []models.Artifact
1155}
1156
1157func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
1158 params.Active = "overview"
1159 return p.executeRepo("repo/tags", w, params)
1160}
1161
1162type RepoTagParams struct {
1163 BaseParams
1164 RepoInfo repoinfo.RepoInfo
1165 Active string
1166 types.RepoTagResponse
1167 ArtifactMap map[plumbing.Hash][]models.Artifact
1168 DanglingArtifacts []models.Artifact
1169}
1170
1171func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error {
1172 params.Active = "overview"
1173 return p.executeRepo("repo/tag", w, params)
1174}
1175
1176type RepoArtifactParams struct {
1177 BaseParams
1178 RepoInfo repoinfo.RepoInfo
1179 Artifact models.Artifact
1180}
1181
1182func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
1183 return p.executePlain("repo/fragments/artifact", w, params)
1184}
1185
1186type RepoBlobParams struct {
1187 BaseParams
1188 RepoInfo repoinfo.RepoInfo
1189 Active string // always "overview"
1190 BreadCrumbs [][]string
1191 BlobView models.BlobView // TODO: expose this struct
1192 ShowRendered bool
1193 EmailToDid map[string]string
1194 LastCommitInfo *types.LastCommitInfo
1195 Ref string
1196 Path string
1197 Language string
1198}
1199
1200func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
1201 params.Active = "overview"
1202 return p.executeRepo("repo/blob", w, params)
1203}
1204
1205type Collaborator struct {
1206 Did string
1207 Role string
1208}
1209
1210type RepoSettingsParams struct {
1211 BaseParams
1212 RepoInfo repoinfo.RepoInfo
1213 Collaborators []Collaborator
1214 Active string
1215 Branches []types.Branch
1216 Spindles []string
1217 CurrentSpindle string
1218 Secrets []*tangled.RepoListSecrets_Secret
1219
1220 // TODO: use repoinfo.roles
1221 IsCollaboratorInviteAllowed bool
1222}
1223
1224func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
1225 params.Active = "settings"
1226 return p.executeRepo("repo/settings", w, params)
1227}
1228
1229type RepoGeneralSettingsParams struct {
1230 BaseParams
1231 RepoInfo repoinfo.RepoInfo
1232 Labels []models.LabelDefinition
1233 DefaultLabels []models.LabelDefinition
1234 SubscribedLabels map[string]struct{}
1235 ShouldSubscribeAll bool
1236 Active string
1237 Tab string
1238 Branches []types.Branch
1239}
1240
1241func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
1242 params.Active = "settings"
1243 params.Tab = "general"
1244 return p.executeRepo("repo/settings/general", w, params)
1245}
1246
1247type RepoAccessSettingsParams struct {
1248 BaseParams
1249 RepoInfo repoinfo.RepoInfo
1250 Active string
1251 Tab string
1252 Collaborators []Collaborator
1253 CanRemoveCollaborator bool
1254}
1255
1256func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
1257 params.Active = "settings"
1258 params.Tab = "access"
1259 return p.executeRepo("repo/settings/access", w, params)
1260}
1261
1262type RepoPipelineSettingsParams struct {
1263 BaseParams
1264 RepoInfo repoinfo.RepoInfo
1265 Active string
1266 Tab string
1267 Spindles []string
1268 CurrentSpindle string
1269 Secrets []map[string]any
1270}
1271
1272func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
1273 params.Active = "settings"
1274 params.Tab = "pipelines"
1275 return p.executeRepo("repo/settings/pipelines", w, params)
1276}
1277
1278type RepoWebhooksSettingsParams struct {
1279 BaseParams
1280 RepoInfo repoinfo.RepoInfo
1281 Active string
1282 Tab string
1283 Webhooks []models.Webhook
1284 WebhookDeliveries map[int64][]models.WebhookDelivery
1285}
1286
1287func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsParams) error {
1288 params.Active = "settings"
1289 params.Tab = "hooks"
1290 return p.executeRepo("repo/settings/hooks", w, params)
1291}
1292
1293type WebhookDeliveriesListParams struct {
1294 BaseParams
1295 RepoInfo repoinfo.RepoInfo
1296 Webhook *models.Webhook
1297 Deliveries []models.WebhookDelivery
1298}
1299
1300func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListParams) error {
1301 tpl, err := p.parse("repo/settings/fragments/webhookDeliveries")
1302 if err != nil {
1303 return err
1304 }
1305 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params)
1306}
1307
1308type RepoSiteSettingsParams struct {
1309 BaseParams
1310 RepoInfo repoinfo.RepoInfo
1311 Active string
1312 Tab string
1313 Branches []types.Branch
1314 SiteConfig *models.RepoSite
1315 OwnerClaim *models.DomainClaim
1316 Deploys []models.SiteDeploy
1317 IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or ""
1318}
1319
1320func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error {
1321 params.Active = "settings"
1322 params.Tab = "sites"
1323 return p.executeRepo("repo/settings/sites", w, params)
1324}
1325
1326type RepoIssuesParams struct {
1327 BaseParams
1328 RepoInfo repoinfo.RepoInfo
1329 Active string
1330 Issues []models.Issue
1331 IssueCount int
1332 LabelDefs map[string]*models.LabelDefinition
1333 Page pagination.Page
1334 FilterState string
1335 FilterQuery string
1336 BaseFilterQuery string
1337 VouchRelationships map[syntax.DID]*models.VouchRelationship
1338}
1339
1340func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
1341 params.Active = "issues"
1342 return p.executeRepo("repo/issues/issues", w, params)
1343}
1344
1345type RepoSingleIssueParams struct {
1346 BaseParams
1347 RepoInfo repoinfo.RepoInfo
1348 Active string
1349 Issue *models.Issue
1350 CommentList []models.CommentListItem
1351 Backlinks []models.RichReferenceLink
1352 LabelDefs map[string]*models.LabelDefinition
1353
1354 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1355 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1356 VouchRelationships map[syntax.DID]*models.VouchRelationship
1357
1358 // IsSubscribed is nil when the user is not logged in, true when subscribed,
1359 // false when explicitly unsubscribed, and nil when no explicit subscription.
1360 IsSubscribed *bool
1361}
1362
1363func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
1364 params.Active = "issues"
1365 return p.executeRepo("repo/issues/issue", w, params)
1366}
1367
1368type IssueSubscribeParams struct {
1369 RepoInfo repoinfo.RepoInfo
1370 IssueId int
1371 IsSubscribed *bool
1372}
1373
1374func (p *Pages) IssueSubscribeFragment(w io.Writer, params IssueSubscribeParams) error {
1375 return p.executePlain("repo/issues/fragments/subscribeButton", w, params)
1376}
1377
1378type PullSubscribeParams struct {
1379 RepoInfo repoinfo.RepoInfo
1380 PullId int64
1381 IsSubscribed *bool
1382}
1383
1384func (p *Pages) PullSubscribeFragment(w io.Writer, params PullSubscribeParams) error {
1385 return p.executePlain("repo/pulls/fragments/subscribeButton", w, params)
1386}
1387
1388type EditIssueParams struct {
1389 BaseParams
1390 RepoInfo repoinfo.RepoInfo
1391 Issue *models.Issue
1392 Action string
1393}
1394
1395func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
1396 params.Action = "edit"
1397 return p.executePlain("repo/issues/fragments/putIssue", w, params)
1398}
1399
1400type ThreadReactionFragmentParams struct {
1401 Kind models.ReactionKind
1402 Count int
1403 Users []string
1404 IsReacted bool
1405 CommentRkey string
1406 SubjectUri string
1407}
1408
1409func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
1410 return p.executePlain("repo/fragments/reaction", w, params)
1411}
1412
1413type RepoNewIssueParams struct {
1414 BaseParams
1415 RepoInfo repoinfo.RepoInfo
1416 Issue *models.Issue // existing issue if any -- passed when editing
1417 Active string
1418 Action string
1419}
1420
1421func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
1422 params.Active = "issues"
1423 params.Action = "create"
1424 return p.executeRepo("repo/issues/new", w, params)
1425}
1426
1427type StackedDiff struct {
1428 Diff *types.NiceDiff
1429 Opts types.DiffOpts
1430}
1431
1432type RepoNewPullParams struct {
1433 BaseParams
1434 RepoInfo repoinfo.RepoInfo
1435 Active string
1436 PrefillError string
1437
1438 // step 1. choose source
1439 // TODO: replace to RepoNewPull_StepSourceParams
1440 Branches []types.Branch
1441 SourceBranches []types.Branch
1442 ForkBranches []types.Branch
1443 Forks []models.Repo
1444 // selected values
1445 Source Source // source kind
1446 TargetBranch string
1447 Fork string // fork repo DID
1448 SourceBranch string
1449 Patch string
1450
1451 // step 2. review changes
1452 StepReviewParams *RepoNewPull_StepReviewParams // optional step 2 params
1453
1454 // step 3. fill details
1455 // TODO: replace to RepoNewPull_StepDetailsParams
1456 Title string
1457 Body string
1458 TitleDirty bool // flag to avoid overwriting users input
1459 BodyDirty bool
1460 MergeCheck MergeCheckParams
1461 LabelDefs map[string]*models.LabelDefinition
1462 LabelState models.LabelState
1463}
1464
1465func (p RepoNewPullParams) SourceRepo() string {
1466 if p.Fork != "" {
1467 return p.Fork
1468 }
1469 return p.RepoInfo.RepoDid
1470}
1471
1472type RepoNewPull_StepSourceParams struct {
1473 Branches []types.Branch
1474 SourceBranches []types.Branch
1475 ForkBranches []types.Branch
1476 Forks []models.Repo
1477 ErrorMsg string
1478 // selected values
1479 Source Source // source kind
1480 TargetBranch string
1481 Fork string // fork repo DID
1482 SourceBranch string
1483 Patch string
1484}
1485
1486type RepoNewPull_StepReviewParams struct {
1487 Commits []types.Commit
1488}
1489
1490type RepoNewPull_StepDetailsParams struct {
1491 Title string
1492 Body string
1493 TitleDirty bool
1494 BodyDirty bool
1495}
1496
1497type MergeCheckParams struct {
1498 IsConflicted bool
1499 Conflicts []*gitmirrorv1.MergeConflict
1500 Error string
1501}
1502
1503func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
1504 params.Active = "pulls"
1505 return p.executeRepo("repo/pulls/new", w, params)
1506}
1507
1508func (p *Pages) PullComposeHostFragment(w io.Writer, params RepoNewPullParams) error {
1509 return p.executePlain("repo/pulls/fragments/pullComposeHost", w, params)
1510}
1511
1512func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error {
1513 return p.executePlain("fragments/markdownPreview", w, body)
1514}
1515
1516type EditPullParams struct {
1517 LoggedInUser *oauth.MultiAccountUser
1518 RepoInfo repoinfo.RepoInfo
1519 Pull *models.Pull
1520}
1521
1522func (p *Pages) EditPullFragment(w io.Writer, params EditPullParams) error {
1523 return p.executePlain("repo/pulls/fragments/pullEdit", w, params)
1524}
1525
1526type RepoPullsParams struct {
1527 BaseParams
1528 RepoInfo repoinfo.RepoInfo
1529 Pulls []*models.Pull
1530 Active string
1531 FilterState string
1532 FilterQuery string
1533 BaseFilterQuery string
1534 Pipelines map[string]types.Pipeline
1535 LabelDefs map[string]*models.LabelDefinition
1536 Page pagination.Page
1537 PullCount int
1538 VouchRelationships map[syntax.DID]*models.VouchRelationship
1539}
1540
1541func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
1542 params.Active = "pulls"
1543 return p.executeRepo("repo/pulls/pulls", w, params)
1544}
1545
1546type ResubmitResult uint64
1547
1548const (
1549 ShouldResubmit ResubmitResult = iota
1550 ShouldNotResubmit
1551 Unknown
1552)
1553
1554func (r ResubmitResult) Yes() bool {
1555 return r == ShouldResubmit
1556}
1557func (r ResubmitResult) No() bool {
1558 return r == ShouldNotResubmit
1559}
1560func (r ResubmitResult) Unknown() bool {
1561 return r == Unknown
1562}
1563
1564type BranchDeleteStatus struct {
1565 Repo *models.Repo
1566 Branch string
1567}
1568
1569type PullPageBaseParams struct {
1570 BaseParams
1571 RepoInfo repoinfo.RepoInfo
1572 Pull *models.Pull
1573
1574 Backlinks []models.RichReferenceLink
1575 Commits []types.Commit // all commits between <target>..<pr/head>
1576 Pipelines map[string]types.Pipeline
1577
1578 MergeCheck MergeCheckParams
1579 ResubmitCheck ResubmitResult
1580 BranchDeleteStatus *BranchDeleteStatus
1581
1582 LabelDefs map[string]*models.LabelDefinition
1583 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1584 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1585 VouchRelationships map[syntax.DID]*models.VouchRelationship
1586 VouchSkips map[syntax.DID]bool
1587
1588 // IsSubscribed is nil when not logged in, true when subscribed, false when explicitly unsubscribed.
1589 IsSubscribed *bool
1590
1591 // diff, branch-delete-status, merge-check, resubmit-check, pipelines will be lazy-loaded.
1592}
1593
1594// /pulls/123/2/1a2b3c..d4e5f6
1595type PullDiffParams struct {
1596 PullPageBaseParams
1597 VersionId int
1598
1599 DiffParams DiffParams_Diff
1600 IsDiffBase bool
1601 IsDiffHead bool
1602
1603 ErrorMsg string
1604}
1605
1606func (p PullDiffParams) ActiveVersionId() int {
1607 return p.VersionId
1608}
1609
1610func (p PullDiffParams) ActiveCommitId() string {
1611 return p.DiffParams.Head
1612}
1613
1614func (p PullDiffParams) IsInterdiff() bool {
1615 return false
1616}
1617
1618func (p PullDiffParams) DisplayDiffBase() string {
1619 if p.IsDiffBase {
1620 return "base"
1621 }
1622 return shortId(p.DiffParams.Base)
1623}
1624
1625func (p PullDiffParams) DisplayDiffHead() string {
1626 if p.IsDiffHead {
1627 return "head"
1628 }
1629 return shortId(p.DiffParams.Head)
1630}
1631
1632// /pulls/123/1..2/abcdef
1633type PullInterdiffParams struct {
1634 PullPageBaseParams
1635 Version1 int
1636 Version2 int
1637 ChangeId string // optional change-id filter
1638
1639 DiffParams DiffParams
1640 ActiveCommitId string
1641
1642 ErrorMsg string
1643}
1644
1645func (p PullInterdiffParams) ActiveVersionId() int {
1646 return p.Version2
1647}
1648
1649func (p PullInterdiffParams) IsInterdiff() bool {
1650 return true
1651}
1652
1653type DiffParams struct {
1654 Diff *DiffParams_Diff
1655 Interdiff *DiffParams_Interdiff
1656}
1657
1658type DiffParams_Diff struct {
1659 Base string
1660 Head string
1661}
1662
1663type DiffParams_Interdiff struct {
1664 From DiffParams_Diff
1665 To DiffParams_Diff
1666}
1667
1668// DiffLine is one row of a unified (inline) diff. Old/New are 1-based line numbers into the
1669// base/head blob, or 0 when that side has no line here. Content is pre-rendered, safe HTML.
1670type DiffLine struct {
1671 Op string // " " context, "-" removed, "+" added
1672 Old int
1673 New int
1674 Content template.HTML
1675}
1676
1677// DiffCell is one side of a side-by-side row. Kind is "ctx", "del", "add", or "empty" (a
1678// blank padding cell). Num is the 1-based line number, or 0 when empty.
1679type DiffCell struct {
1680 Kind string
1681 Num int
1682 Content template.HTML
1683}
1684
1685// DiffRow is one side-by-side row: the left (base) and right (head) cells.
1686type DiffRow struct {
1687 Left DiffCell
1688 Right DiffCell
1689}
1690
1691// DiffHunk holds a hunk's rows; exactly one of Lines (unified) / Rows (split) is populated,
1692// depending on PullDiffFragmentParams.Split.
1693type DiffHunk struct {
1694 Lines []DiffLine
1695 Rows []DiffRow
1696}
1697
1698func (h DiffHunk) AtFileStart() bool {
1699 if len(h.Rows) > 0 {
1700 r := h.Rows[0]
1701 return r.Left.Num == 1 || r.Right.Num == 1
1702 }
1703 if len(h.Lines) > 0 {
1704 l := h.Lines[0]
1705 return l.Old == 1 || l.New == 1
1706 }
1707 return false
1708}
1709
1710// DiffFile is one changed file. Note is set (and Hunks empty) for binary/submodule files.
1711type DiffFile struct {
1712 Path string
1713 Note string
1714 Hunks []DiffHunk
1715}
1716
1717type PullDiffFragmentParams struct {
1718 BaseRepo syntax.DID
1719 HeadRepo syntax.DID
1720 DiffBase string
1721 DiffHead string
1722 DiffUrl string
1723 Unified bool
1724 Files []DiffFile
1725
1726 ErrorMsg string
1727}
1728
1729func (f *DiffFile) Id() string {
1730 return f.Path
1731}
1732
1733func (f *DiffFile) Stats() types.DiffFileStat {
1734 var ins, del int64
1735 for _, hunk := range f.Hunks {
1736 for _, line := range hunk.Lines {
1737 switch line.Op {
1738 case "+":
1739 ins++
1740 case "-":
1741 del++
1742 }
1743 }
1744 for _, row := range hunk.Rows {
1745 if row.Left.Kind == "del" {
1746 del++
1747 }
1748 if row.Right.Kind == "add" {
1749 ins++
1750 }
1751 }
1752 }
1753 return types.DiffFileStat{
1754 Insertions: ins,
1755 Deletions: del,
1756 }
1757}
1758
1759func (p PullDiffFragmentParams) FileTree() *filetree.FileTreeNode {
1760 fs := make([]string, len(p.Files))
1761 for i, s := range p.Files {
1762 fs[i] = s.Id()
1763 }
1764 return filetree.FileTree(fs)
1765}
1766
1767func (p PullDiffFragmentParams) Stats() types.DiffStat {
1768 var stat types.DiffStat
1769 for _, df := range p.Files {
1770 fileStats := df.Stats()
1771 stat.Insertions += fileStats.Insertions
1772 stat.Deletions += fileStats.Deletions
1773 }
1774 stat.FilesChanged = len(p.Files)
1775 return stat
1776}
1777
1778func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error {
1779 return p.executeRepo("repo/pulls/single", w, params)
1780}
1781
1782func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error {
1783 return p.executeRepo("repo/pulls/single", w, params)
1784}
1785
1786func (p *Pages) PullDiffFragment(w io.Writer, params PullDiffFragmentParams) error {
1787 return p.executePlain("repo/pulls/fragments/diff", w, params)
1788}
1789
1790func (p *Pages) PullComposeDiffFragment(w io.Writer, params PullDiffFragmentParams) error {
1791 return p.executePlain("repo/pulls/fragments/composediff", w, params)
1792}
1793
1794type PullActionsParams struct {
1795 BaseParams
1796 RepoInfo repoinfo.RepoInfo
1797 Pull *models.Pull
1798 RoundNumber int
1799 MergeCheck MergeCheckParams
1800 ResubmitCheck ResubmitResult
1801 BranchDeleteStatus *BranchDeleteStatus
1802
1803 // Workflow warning state for fork-based pulls without a pipeline on the
1804 // latest commit. WorkflowsChanged and ChangedWorkflowFiles are computed
1805 // from the latest round's patch.
1806 WorkflowsChanged bool
1807 ChangedWorkflowFiles []string
1808 HasPipeline bool
1809
1810 // renders buttons in a pre-check state and attaches the hx-trigger="load"
1811 // that fetches the real, checked fragment
1812 Loading bool
1813}
1814
1815func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
1816 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
1817}
1818
1819type RepoCompareParams struct {
1820 BaseParams
1821 RepoInfo repoinfo.RepoInfo
1822 Forks []models.Repo
1823 Branches []types.Branch
1824 Tags []*types.TagReference
1825 Base string
1826 Head string
1827 Diff *types.NiceDiff
1828 DiffOpts types.DiffOpts
1829
1830 Active string
1831}
1832
1833func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
1834 params.Active = "overview"
1835 return p.executeRepo("repo/compare/compare", w, params)
1836}
1837
1838type RepoCompareNewParams struct {
1839 BaseParams
1840 RepoInfo repoinfo.RepoInfo
1841 Forks []models.Repo
1842 Branches []types.Branch
1843 Tags []*types.TagReference
1844 Base string
1845 Head string
1846
1847 Active string
1848}
1849
1850func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
1851 params.Active = "overview"
1852 return p.executeRepo("repo/compare/new", w, params)
1853}
1854
1855type RepoCompareAllowPullParams struct {
1856 BaseParams
1857 RepoInfo repoinfo.RepoInfo
1858 Base string
1859 Head string
1860}
1861
1862func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error {
1863 return p.executePlain("repo/fragments/compareAllowPull", w, params)
1864}
1865
1866type RepoCompareDiffFragmentParams struct {
1867 Diff types.NiceDiff
1868 DiffOpts types.DiffOpts
1869}
1870
1871func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1872 return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1873}
1874
1875type LabelPanelParams struct {
1876 BaseParams
1877 RepoInfo repoinfo.RepoInfo
1878 Defs map[string]*models.LabelDefinition
1879 Subject string
1880 State models.LabelState
1881}
1882
1883func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1884 return p.executePlain("repo/fragments/labelPanel", w, params)
1885}
1886
1887type EditLabelPanelParams struct {
1888 BaseParams
1889 RepoInfo repoinfo.RepoInfo
1890 Defs map[string]*models.LabelDefinition
1891 Subject string
1892 State models.LabelState
1893 Prefix string
1894}
1895
1896func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1897 return p.executePlain("repo/fragments/editLabelPanel", w, params)
1898}
1899
1900type RepoStarsParams struct {
1901 BaseParams
1902 RepoInfo repoinfo.RepoInfo
1903 Active string
1904 Starrers []models.Star
1905 Page pagination.Page
1906 TotalCount int
1907}
1908
1909func (p *Pages) RepoStars(w io.Writer, params RepoStarsParams) error {
1910 params.Active = "overview"
1911 return p.executeRepo("repo/stars", w, params)
1912}
1913
1914type RepoForksParams struct {
1915 BaseParams
1916 RepoInfo repoinfo.RepoInfo
1917 Active string
1918 Forks []models.Repo
1919 Page pagination.Page
1920 TotalCount int
1921}
1922
1923func (p *Pages) RepoForks(w io.Writer, params RepoForksParams) error {
1924 params.Active = "overview"
1925 return p.executeRepo("repo/forks", w, params)
1926}
1927
1928type PipelinesParams struct {
1929 BaseParams
1930 RepoInfo repoinfo.RepoInfo
1931 Pipelines []types.Pipeline
1932 Active string
1933 FilterKind string
1934 Total int64
1935}
1936
1937func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
1938 params.Active = "pipelines"
1939 return p.executeRepo("repo/pipelines/pipelines", w, params)
1940}
1941
1942type LogBlockParams struct {
1943 Id int
1944 Name string
1945 Command string
1946 Collapsed bool
1947 StartTime time.Time
1948}
1949
1950func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1951 return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1952}
1953
1954type LogBlockEndParams struct {
1955 Id int
1956 StartTime time.Time
1957 EndTime time.Time
1958}
1959
1960func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1961 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1962}
1963
1964type LogLineParams struct {
1965 Id int
1966 Content template.HTML
1967}
1968
1969func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
1970 return p.executePlain("repo/pipelines/fragments/logLine", w, params)
1971}
1972
1973type WorkflowSymbolOOBParams struct {
1974 Name string
1975 Statuses models.WorkflowStatus
1976}
1977
1978func (p *Pages) WorkflowSymbolOOB(w io.Writer, params WorkflowSymbolOOBParams) error {
1979 return p.executePlain("repo/pipelines/fragments/workflowSymbolOOB", w, params)
1980}
1981
1982type PipelineStatusesParams struct {
1983 RepoInfo repoinfo.RepoInfo
1984 Pipelines map[string]types.Pipeline
1985}
1986
1987func (p *Pages) PipelineStatusesFragment(w io.Writer, params PipelineStatusesParams) error {
1988 return p.executePlain("repo/fragments/pipelineStatuses", w, params)
1989}
1990
1991type WorkflowParams struct {
1992 BaseParams
1993 RepoInfo repoinfo.RepoInfo
1994 Pipeline types.Pipeline
1995 Workflow string
1996 SSHLogCommand string
1997 Active string
1998}
1999
2000func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
2001 params.Active = "pipelines"
2002 return p.executeRepo("repo/pipelines/workflow", w, params)
2003}
2004
2005type PutStringParams struct {
2006 BaseParams
2007 Action string
2008
2009 // this is supplied in the case of editing an existing string
2010 String models.String
2011}
2012
2013func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
2014 return p.execute("strings/put", w, params)
2015}
2016
2017type StringsDashboardParams struct {
2018 BaseParams
2019 Card ProfileCard
2020 Strings []models.String
2021}
2022
2023func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
2024 return p.execute("strings/dashboard", w, params)
2025}
2026
2027type StringTimelineParams struct {
2028 BaseParams
2029 Strings []models.String
2030}
2031
2032func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
2033 return p.execute("strings/timeline", w, params)
2034}
2035
2036type SingleStringParams struct {
2037 BaseParams
2038 ShowRendered bool
2039 RenderToggle bool
2040 RenderedContents template.HTML
2041 String *models.String
2042 Stats models.StringStats
2043 IsStarred bool
2044 StarCount int
2045 Owner identity.Identity
2046 CommentList []models.CommentListItem
2047
2048 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
2049 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
2050 VouchRelationships map[syntax.DID]*models.VouchRelationship
2051}
2052
2053func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
2054 return p.execute("strings/string", w, params)
2055}
2056
2057type SearchReposParams struct {
2058 BaseParams
2059 FilterType string // "repo" | "code"
2060 Repos []SearchResult
2061 Page pagination.Page
2062 ResultCount int
2063 FilterQuery string
2064 SortParam string
2065 TimeTaken time.Duration
2066 DocCount int64
2067 ErrorMsg string
2068}
2069
2070func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error {
2071 params.FilterType = "repo"
2072 return p.execute("search/search", w, params)
2073}
2074
2075type SearchQuickParams struct {
2076 Repos []models.Repo
2077 Query string
2078 Total int
2079}
2080
2081func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error {
2082 return p.executePlain("search/fragments/quick", w, params)
2083}
2084
2085func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error {
2086 tpl, err := p.parse("search/fragments/quick")
2087 if err != nil {
2088 return err
2089 }
2090 return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params)
2091}
2092
2093type SearchResult struct {
2094 RepoDID syntax.DID
2095 Repo *models.Repo
2096 FilePath string
2097 Branches []string
2098 Commit string
2099 Language string
2100
2101 File *CodeSearchResult_File // filename match
2102 Chunks CodeSearchResult_Chunks // content matches
2103}
2104
2105// CodeSearchResult_Chunk is a content match with its lines pre-rendered.
2106type CodeSearchResult_Chunk struct {
2107 Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges
2108 MatchCount int // number of match ranges in this chunk
2109}
2110
2111type CodeSearchResult_Chunks []CodeSearchResult_Chunk
2112
2113func (cs CodeSearchResult_Chunks) MatchCount() int {
2114 count := 0
2115 for _, c := range cs {
2116 count += c.MatchCount
2117 }
2118 return count
2119}
2120
2121type CodeSearchResult_File struct {
2122 NameSpans []ChunkSpan // precomputed from FilePath/Ranges
2123}
2124
2125type ChunkSpan struct {
2126 Text string
2127 Match bool
2128}
2129
2130type ChunkLine struct {
2131 Num int
2132 Spans []ChunkSpan
2133 Highlight bool
2134}
2135
2136// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each
2137// line into matched/unmatched spans using ranges. startLine is the 1-based line
2138// number of the first line.
2139func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine {
2140 if startLine < 1 {
2141 startLine = 1
2142 }
2143 // trim a single trailing newline so we don't emit a spurious empty line
2144 content = strings.TrimSuffix(content, "\n")
2145 lines := strings.Split(content, "\n")
2146 out := make([]ChunkLine, len(lines))
2147 for i, text := range lines {
2148 num := startLine + i
2149 runes := []rune(text)
2150
2151 // collect matched rune intervals [c0,c1) for this line
2152 var intervals [][2]int
2153 for _, rg := range ranges {
2154 if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) {
2155 continue
2156 }
2157 c0, c1 := 0, len(runes)
2158 if num == int(rg.Start.LineNumber) {
2159 c0 = int(rg.Start.Column) - 1
2160 }
2161 if num == int(rg.End.LineNumber) {
2162 c1 = int(rg.End.Column) - 1
2163 }
2164 c0 = max(0, min(c0, len(runes)))
2165 c1 = max(0, min(c1, len(runes)))
2166 if c0 < c1 {
2167 intervals = append(intervals, [2]int{c0, c1})
2168 }
2169 }
2170 intervals = mergeIntervals(intervals)
2171
2172 out[i] = ChunkLine{
2173 Num: num,
2174 Spans: spanRunes(runes, intervals),
2175 Highlight: len(intervals) > 0,
2176 }
2177 }
2178 return out
2179}
2180
2181// FileNameSpans splits a filename into matched/unmatched spans using ranges.
2182// Filename ranges live on line 1; columns are clamped to rune bounds.
2183func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan {
2184 runes := []rune(name)
2185 var intervals [][2]int
2186 for _, rg := range ranges {
2187 if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 {
2188 continue
2189 }
2190 c0 := max(0, min(int(rg.Start.Column)-1, len(runes)))
2191 c1 := max(0, min(int(rg.End.Column)-1, len(runes)))
2192 if c0 < c1 {
2193 intervals = append(intervals, [2]int{c0, c1})
2194 }
2195 }
2196 return spanRunes(runes, mergeIntervals(intervals))
2197}
2198
2199type CodeSearchParams struct {
2200 BaseParams
2201 FilterType string // "code"
2202 FilterQuery string
2203 Results []SearchResult
2204 Page pagination.Page
2205 HasMore bool
2206 ErrorMsg string
2207
2208 MatchCount int
2209 FileCount int
2210 TimeTaken time.Duration
2211}
2212
2213func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error {
2214 params.FilterType = "code"
2215 return p.execute("search/search", w, params)
2216}
2217
2218func (p *Pages) Home(w io.Writer, params TimelineParams) error {
2219 return p.execute("timeline/home", w, params)
2220}
2221
2222type CommentBodyFragmentParams struct {
2223 Comment models.Comment
2224 Reactions map[models.ReactionKind]models.ReactionDisplayData
2225 UserReacted map[models.ReactionKind]bool
2226}
2227
2228func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error {
2229 return p.executePlain("fragments/comment/commentBody", w, params)
2230}
2231
2232type PullCommentFragmentParams struct {
2233 LoggedInUser *oauth.MultiAccountUser
2234 Comment models.Comment
2235 Reactions map[models.ReactionKind]models.ReactionDisplayData
2236 UserReacted map[models.ReactionKind]bool
2237 HxSwapOob bool
2238}
2239
2240func (p *Pages) PullCommentFragment(w io.Writer, params PullCommentFragmentParams) error {
2241 return p.executePlain("fragments/comment/pullComment", w, params)
2242}
2243
2244type CommentHeaderFragmentParams struct {
2245 Comment models.Comment
2246 Reactions map[models.ReactionKind]models.ReactionDisplayData
2247 UserReacted map[models.ReactionKind]bool
2248 HxSwapOob bool
2249}
2250
2251func (p *Pages) CommentHeaderFragment(w io.Writer, params CommentHeaderFragmentParams) error {
2252 return p.executePlain("fragments/comment/commentHeader", w, params)
2253}
2254
2255type EditCommentFragmentParams struct {
2256 Comment models.Comment
2257}
2258
2259func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error {
2260 return p.executePlain("fragments/comment/edit", w, params)
2261}
2262
2263type ReplyCommentFragmentParams struct {
2264 BaseParams
2265}
2266
2267func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error {
2268 return p.executePlain("fragments/comment/reply", w, params)
2269}
2270
2271type ReplyPlaceholderFragmentParams struct {
2272 BaseParams
2273}
2274
2275func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error {
2276 return p.executePlain("fragments/comment/replyPlaceholder", w, params)
2277}
2278
2279func (p *Pages) Static() http.Handler {
2280 if p.dev {
2281 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
2282 }
2283
2284 sub, err := fs.Sub(p.embedFS, "static")
2285 if err != nil {
2286 p.logger.Error("no static dir found? that's crazy", "err", err)
2287 panic(err)
2288 }
2289 // Custom handler to apply Cache-Control headers for font files
2290 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
2291}
2292
2293func Cache(h http.Handler) http.Handler {
2294 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2295 path := strings.Split(r.URL.Path, "?")[0]
2296
2297 if strings.HasSuffix(path, ".css") {
2298 // on day for css files
2299 w.Header().Set("Cache-Control", "public, max-age=86400")
2300 } else {
2301 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
2302 }
2303 h.ServeHTTP(w, r)
2304 })
2305}
2306
2307func (p *Pages) CssContentHash() string {
2308 cssFile, err := p.embedFS.Open("static/tw.css")
2309 if err != nil {
2310 slog.Debug("Error opening CSS file", "err", err)
2311 return ""
2312 }
2313 defer cssFile.Close()
2314
2315 hasher := sha256.New()
2316 if _, err := io.Copy(hasher, cssFile); err != nil {
2317 slog.Debug("Error hashing CSS file", "err", err)
2318 return ""
2319 }
2320
2321 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
2322}
2323
2324func (p *Pages) DangerPasswordTokenStep(w io.Writer) error {
2325 return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil)
2326}
2327
2328func (p *Pages) DangerPasswordSuccess(w io.Writer) error {
2329 return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil)
2330}
2331
2332func (p *Pages) DangerDeleteTokenStep(w io.Writer) error {
2333 return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil)
2334}
2335
2336func (p *Pages) Error500(w io.Writer) error {
2337 return p.execute("errors/500", w, BaseParams{})
2338}
2339
2340func (p *Pages) Error404(w io.Writer) error {
2341 return p.execute("errors/404", w, BaseParams{})
2342}
2343
2344func (p *Pages) ErrorKnot404(w io.Writer) error {
2345 return p.execute("errors/knot404", w, BaseParams{})
2346}
2347
2348func (p *Pages) Error503(w io.Writer) error {
2349 return p.execute("errors/503", w, BaseParams{})
2350}