forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package pages 2 3import ( 4 "bytes" 5 "crypto/sha256" 6 "embed" 7 "encoding/hex" 8 "fmt" 9 "html/template" 10 "io" 11 "io/fs" 12 "log/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/commitverify" 22 "tangled.org/core/appview/config" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/oauth" 25 "tangled.org/core/appview/pages/markup" 26 "tangled.org/core/appview/pages/repoinfo" 27 "tangled.org/core/appview/pagination" 28 "tangled.org/core/idresolver" 29 "tangled.org/core/patchutil" 30 "tangled.org/core/types" 31 32 "github.com/alecthomas/chroma/v2" 33 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 34 "github.com/alecthomas/chroma/v2/lexers" 35 "github.com/alecthomas/chroma/v2/styles" 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/go-git/go-git/v5/plumbing/object" 40) 41 42//go:embed templates/* static legal 43var Files embed.FS 44 45type Pages struct { 46 mu sync.RWMutex 47 cache *TmplCache[string, *template.Template] 48 49 avatar config.AvatarConfig 50 resolver *idresolver.Resolver 51 dev bool 52 embedFS fs.FS 53 templateDir string // Path to templates on disk for dev mode 54 rctx *markup.RenderContext 55 logger *slog.Logger 56} 57 58func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 59 // initialized with safe defaults, can be overriden per use 60 rctx := &markup.RenderContext{ 61 IsDev: config.Core.Dev, 62 CamoUrl: config.Camo.Host, 63 CamoSecret: config.Camo.SharedSecret, 64 Sanitizer: markup.NewSanitizer(), 65 Files: Files, 66 } 67 68 p := &Pages{ 69 mu: sync.RWMutex{}, 70 cache: NewTmplCache[string, *template.Template](), 71 dev: config.Core.Dev, 72 avatar: config.Avatar, 73 rctx: rctx, 74 resolver: res, 75 templateDir: "appview/pages", 76 logger: logger, 77 } 78 79 if p.dev { 80 p.embedFS = os.DirFS(p.templateDir) 81 } else { 82 p.embedFS = Files 83 } 84 85 return p 86} 87 88// reverse of pathToName 89func (p *Pages) nameToPath(s string) string { 90 return "templates/" + s + ".html" 91} 92 93func (p *Pages) fragmentPaths() ([]string, error) { 94 var fragmentPaths []string 95 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 96 if err != nil { 97 return err 98 } 99 if d.IsDir() { 100 return nil 101 } 102 if !strings.HasSuffix(path, ".html") { 103 return nil 104 } 105 if !strings.Contains(path, "fragments/") { 106 return nil 107 } 108 fragmentPaths = append(fragmentPaths, path) 109 return nil 110 }) 111 if err != nil { 112 return nil, err 113 } 114 115 return fragmentPaths, nil 116} 117 118// parse without memoization 119func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 120 paths, err := p.fragmentPaths() 121 if err != nil { 122 return nil, err 123 } 124 for _, s := range stack { 125 paths = append(paths, p.nameToPath(s)) 126 } 127 128 funcs := p.funcMap() 129 top := stack[len(stack)-1] 130 parsed, err := template.New(top). 131 Funcs(funcs). 132 ParseFS(p.embedFS, paths...) 133 if err != nil { 134 return nil, err 135 } 136 137 return parsed, nil 138} 139 140func (p *Pages) parse(stack ...string) (*template.Template, error) { 141 key := strings.Join(stack, "|") 142 143 // never cache in dev mode 144 if cached, exists := p.cache.Get(key); !p.dev && exists { 145 return cached, nil 146 } 147 148 result, err := p.rawParse(stack...) 149 if err != nil { 150 return nil, err 151 } 152 153 p.cache.Set(key, result) 154 return result, nil 155} 156 157func (p *Pages) parseBase(top string) (*template.Template, error) { 158 stack := []string{ 159 "layouts/base", 160 top, 161 } 162 return p.parse(stack...) 163} 164 165func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 166 stack := []string{ 167 "layouts/base", 168 "layouts/repobase", 169 top, 170 } 171 return p.parse(stack...) 172} 173 174func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 175 stack := []string{ 176 "layouts/base", 177 "layouts/profilebase", 178 top, 179 } 180 return p.parse(stack...) 181} 182 183func (p *Pages) executePlain(name string, w io.Writer, params any) error { 184 tpl, err := p.parse(name) 185 if err != nil { 186 return err 187 } 188 189 return tpl.Execute(w, params) 190} 191 192func (p *Pages) execute(name string, w io.Writer, params any) error { 193 tpl, err := p.parseBase(name) 194 if err != nil { 195 return err 196 } 197 198 return tpl.ExecuteTemplate(w, "layouts/base", params) 199} 200 201func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 202 tpl, err := p.parseRepoBase(name) 203 if err != nil { 204 return err 205 } 206 207 return tpl.ExecuteTemplate(w, "layouts/base", params) 208} 209 210func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 211 tpl, err := p.parseProfileBase(name) 212 if err != nil { 213 return err 214 } 215 216 return tpl.ExecuteTemplate(w, "layouts/base", params) 217} 218 219func (p *Pages) Favicon(w io.Writer) error { 220 return p.executePlain("fragments/dolly/silhouette", w, nil) 221} 222 223type LoginParams struct { 224 ReturnUrl string 225 ErrorCode string 226} 227 228func (p *Pages) Login(w io.Writer, params LoginParams) error { 229 return p.executePlain("user/login", w, params) 230} 231 232type SignupParams struct { 233 CloudflareSiteKey string 234} 235 236func (p *Pages) Signup(w io.Writer, params SignupParams) error { 237 return p.executePlain("user/signup", w, params) 238} 239 240func (p *Pages) CompleteSignup(w io.Writer) error { 241 return p.executePlain("user/completeSignup", w, nil) 242} 243 244type TermsOfServiceParams struct { 245 LoggedInUser *oauth.User 246 Content template.HTML 247} 248 249func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 250 filename := "terms.md" 251 filePath := filepath.Join("legal", filename) 252 253 file, err := p.embedFS.Open(filePath) 254 if err != nil { 255 return fmt.Errorf("failed to read %s: %w", filename, err) 256 } 257 defer file.Close() 258 259 markdownBytes, err := io.ReadAll(file) 260 if err != nil { 261 return fmt.Errorf("failed to read %s: %w", filename, err) 262 } 263 264 p.rctx.RendererType = markup.RendererTypeDefault 265 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 266 sanitized := p.rctx.SanitizeDefault(htmlString) 267 params.Content = template.HTML(sanitized) 268 269 return p.execute("legal/terms", w, params) 270} 271 272type PrivacyPolicyParams struct { 273 LoggedInUser *oauth.User 274 Content template.HTML 275} 276 277func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 278 filename := "privacy.md" 279 filePath := filepath.Join("legal", filename) 280 281 file, err := p.embedFS.Open(filePath) 282 if err != nil { 283 return fmt.Errorf("failed to read %s: %w", filename, err) 284 } 285 defer file.Close() 286 287 markdownBytes, err := io.ReadAll(file) 288 if err != nil { 289 return fmt.Errorf("failed to read %s: %w", filename, err) 290 } 291 292 p.rctx.RendererType = markup.RendererTypeDefault 293 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 294 sanitized := p.rctx.SanitizeDefault(htmlString) 295 params.Content = template.HTML(sanitized) 296 297 return p.execute("legal/privacy", w, params) 298} 299 300type BrandParams struct { 301 LoggedInUser *oauth.User 302} 303 304func (p *Pages) Brand(w io.Writer, params BrandParams) error { 305 return p.execute("brand/brand", w, params) 306} 307 308type TimelineParams struct { 309 LoggedInUser *oauth.User 310 Timeline []models.TimelineEvent 311 Repos []models.Repo 312 GfiLabel *models.LabelDefinition 313} 314 315func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 316 return p.execute("timeline/timeline", w, params) 317} 318 319type GoodFirstIssuesParams struct { 320 LoggedInUser *oauth.User 321 Issues []models.Issue 322 RepoGroups []*models.RepoGroup 323 LabelDefs map[string]*models.LabelDefinition 324 GfiLabel *models.LabelDefinition 325 Page pagination.Page 326} 327 328func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 329 return p.execute("goodfirstissues/index", w, params) 330} 331 332type UserProfileSettingsParams struct { 333 LoggedInUser *oauth.User 334 Tabs []map[string]any 335 Tab string 336} 337 338func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 339 return p.execute("user/settings/profile", w, params) 340} 341 342type NotificationsParams struct { 343 LoggedInUser *oauth.User 344 Notifications []*models.NotificationWithEntity 345 UnreadCount int 346 Page pagination.Page 347 Total int64 348} 349 350func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 351 return p.execute("notifications/list", w, params) 352} 353 354type NotificationItemParams struct { 355 Notification *models.Notification 356} 357 358func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 359 return p.executePlain("notifications/fragments/item", w, params) 360} 361 362type NotificationCountParams struct { 363 Count int64 364} 365 366func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 367 return p.executePlain("notifications/fragments/count", w, params) 368} 369 370type UserKeysSettingsParams struct { 371 LoggedInUser *oauth.User 372 PubKeys []models.PublicKey 373 Tabs []map[string]any 374 Tab string 375} 376 377func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 378 return p.execute("user/settings/keys", w, params) 379} 380 381type UserEmailsSettingsParams struct { 382 LoggedInUser *oauth.User 383 Emails []models.Email 384 Tabs []map[string]any 385 Tab string 386} 387 388func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 389 return p.execute("user/settings/emails", w, params) 390} 391 392type UserNotificationSettingsParams struct { 393 LoggedInUser *oauth.User 394 Preferences *models.NotificationPreferences 395 Tabs []map[string]any 396 Tab string 397} 398 399func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 400 return p.execute("user/settings/notifications", w, params) 401} 402 403type UpgradeBannerParams struct { 404 Registrations []models.Registration 405 Spindles []models.Spindle 406} 407 408func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 409 return p.executePlain("banner", w, params) 410} 411 412type KnotsParams struct { 413 LoggedInUser *oauth.User 414 Registrations []models.Registration 415} 416 417func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 418 return p.execute("knots/index", w, params) 419} 420 421type KnotParams struct { 422 LoggedInUser *oauth.User 423 Registration *models.Registration 424 Members []string 425 Repos map[string][]models.Repo 426 IsOwner bool 427} 428 429func (p *Pages) Knot(w io.Writer, params KnotParams) error { 430 return p.execute("knots/dashboard", w, params) 431} 432 433type KnotListingParams struct { 434 *models.Registration 435} 436 437func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 438 return p.executePlain("knots/fragments/knotListing", w, params) 439} 440 441type SpindlesParams struct { 442 LoggedInUser *oauth.User 443 Spindles []models.Spindle 444} 445 446func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { 447 return p.execute("spindles/index", w, params) 448} 449 450type SpindleListingParams struct { 451 models.Spindle 452} 453 454func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 455 return p.executePlain("spindles/fragments/spindleListing", w, params) 456} 457 458type SpindleDashboardParams struct { 459 LoggedInUser *oauth.User 460 Spindle models.Spindle 461 Members []string 462 Repos map[string][]models.Repo 463} 464 465func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 466 return p.execute("spindles/dashboard", w, params) 467} 468 469type NewRepoParams struct { 470 LoggedInUser *oauth.User 471 Knots []string 472} 473 474func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 475 return p.execute("repo/new", w, params) 476} 477 478type ForkRepoParams struct { 479 LoggedInUser *oauth.User 480 Knots []string 481 RepoInfo repoinfo.RepoInfo 482} 483 484func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 485 return p.execute("repo/fork", w, params) 486} 487 488type ProfileCard struct { 489 UserDid string 490 UserHandle string 491 FollowStatus models.FollowStatus 492 Punchcard *models.Punchcard 493 Profile *models.Profile 494 Stats ProfileStats 495 Active string 496} 497 498type ProfileStats struct { 499 RepoCount int64 500 StarredCount int64 501 StringCount int64 502 FollowersCount int64 503 FollowingCount int64 504} 505 506func (p *ProfileCard) GetTabs() [][]any { 507 tabs := [][]any{ 508 {"overview", "overview", "square-chart-gantt", nil}, 509 {"repos", "repos", "book-marked", p.Stats.RepoCount}, 510 {"starred", "starred", "star", p.Stats.StarredCount}, 511 {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 512 } 513 514 return tabs 515} 516 517type ProfileOverviewParams struct { 518 LoggedInUser *oauth.User 519 Repos []models.Repo 520 CollaboratingRepos []models.Repo 521 ProfileTimeline *models.ProfileTimeline 522 Card *ProfileCard 523 Active string 524} 525 526func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 527 params.Active = "overview" 528 return p.executeProfile("user/overview", w, params) 529} 530 531type ProfileReposParams struct { 532 LoggedInUser *oauth.User 533 Repos []models.Repo 534 Card *ProfileCard 535 Active string 536} 537 538func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 539 params.Active = "repos" 540 return p.executeProfile("user/repos", w, params) 541} 542 543type ProfileStarredParams struct { 544 LoggedInUser *oauth.User 545 Repos []models.Repo 546 Card *ProfileCard 547 Active string 548} 549 550func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 551 params.Active = "starred" 552 return p.executeProfile("user/starred", w, params) 553} 554 555type ProfileStringsParams struct { 556 LoggedInUser *oauth.User 557 Strings []models.String 558 Card *ProfileCard 559 Active string 560} 561 562func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 563 params.Active = "strings" 564 return p.executeProfile("user/strings", w, params) 565} 566 567type FollowCard struct { 568 UserDid string 569 LoggedInUser *oauth.User 570 FollowStatus models.FollowStatus 571 FollowersCount int64 572 FollowingCount int64 573 Profile *models.Profile 574} 575 576type ProfileFollowersParams struct { 577 LoggedInUser *oauth.User 578 Followers []FollowCard 579 Card *ProfileCard 580 Active string 581} 582 583func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 584 params.Active = "overview" 585 return p.executeProfile("user/followers", w, params) 586} 587 588type ProfileFollowingParams struct { 589 LoggedInUser *oauth.User 590 Following []FollowCard 591 Card *ProfileCard 592 Active string 593} 594 595func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 596 params.Active = "overview" 597 return p.executeProfile("user/following", w, params) 598} 599 600type FollowFragmentParams struct { 601 UserDid string 602 FollowStatus models.FollowStatus 603} 604 605func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 606 return p.executePlain("user/fragments/follow", w, params) 607} 608 609type EditBioParams struct { 610 LoggedInUser *oauth.User 611 Profile *models.Profile 612} 613 614func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { 615 return p.executePlain("user/fragments/editBio", w, params) 616} 617 618type EditPinsParams struct { 619 LoggedInUser *oauth.User 620 Profile *models.Profile 621 AllRepos []PinnedRepo 622} 623 624type PinnedRepo struct { 625 IsPinned bool 626 models.Repo 627} 628 629func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { 630 return p.executePlain("user/fragments/editPins", w, params) 631} 632 633type RepoStarFragmentParams struct { 634 IsStarred bool 635 RepoAt syntax.ATURI 636 Stats models.RepoStats 637} 638 639func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 640 return p.executePlain("repo/fragments/repoStar", w, params) 641} 642 643type RepoDescriptionParams struct { 644 RepoInfo repoinfo.RepoInfo 645} 646 647func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 648 return p.executePlain("repo/fragments/editRepoDescription", w, params) 649} 650 651func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 652 return p.executePlain("repo/fragments/repoDescription", w, params) 653} 654 655type RepoIndexParams struct { 656 LoggedInUser *oauth.User 657 RepoInfo repoinfo.RepoInfo 658 Active string 659 TagMap map[string][]string 660 CommitsTrunc []*object.Commit 661 TagsTrunc []*types.TagReference 662 BranchesTrunc []types.Branch 663 // ForkInfo *types.ForkInfo 664 HTMLReadme template.HTML 665 Raw bool 666 EmailToDid map[string]string 667 VerifiedCommits commitverify.VerifiedCommits 668 Languages []types.RepoLanguageDetails 669 Pipelines map[string]models.Pipeline 670 NeedsKnotUpgrade bool 671 types.RepoIndexResponse 672} 673 674func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 675 params.Active = "overview" 676 if params.IsEmpty { 677 return p.executeRepo("repo/empty", w, params) 678 } 679 680 if params.NeedsKnotUpgrade { 681 return p.executeRepo("repo/needsUpgrade", w, params) 682 } 683 684 p.rctx.RepoInfo = params.RepoInfo 685 p.rctx.RepoInfo.Ref = params.Ref 686 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 687 688 if params.ReadmeFileName != "" { 689 ext := filepath.Ext(params.ReadmeFileName) 690 switch ext { 691 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 692 params.Raw = false 693 htmlString := p.rctx.RenderMarkdown(params.Readme) 694 sanitized := p.rctx.SanitizeDefault(htmlString) 695 params.HTMLReadme = template.HTML(sanitized) 696 default: 697 params.Raw = true 698 } 699 } 700 701 return p.executeRepo("repo/index", w, params) 702} 703 704type RepoLogParams struct { 705 LoggedInUser *oauth.User 706 RepoInfo repoinfo.RepoInfo 707 TagMap map[string][]string 708 Active string 709 EmailToDid map[string]string 710 VerifiedCommits commitverify.VerifiedCommits 711 Pipelines map[string]models.Pipeline 712 713 types.RepoLogResponse 714} 715 716func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 717 params.Active = "overview" 718 return p.executeRepo("repo/log", w, params) 719} 720 721type RepoCommitParams struct { 722 LoggedInUser *oauth.User 723 RepoInfo repoinfo.RepoInfo 724 Active string 725 EmailToDid map[string]string 726 Pipeline *models.Pipeline 727 DiffOpts types.DiffOpts 728 729 // singular because it's always going to be just one 730 VerifiedCommit commitverify.VerifiedCommits 731 732 types.RepoCommitResponse 733} 734 735func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 736 params.Active = "overview" 737 return p.executeRepo("repo/commit", w, params) 738} 739 740type RepoTreeParams struct { 741 LoggedInUser *oauth.User 742 RepoInfo repoinfo.RepoInfo 743 Active string 744 BreadCrumbs [][]string 745 TreePath string 746 Raw bool 747 HTMLReadme template.HTML 748 types.RepoTreeResponse 749} 750 751type RepoTreeStats struct { 752 NumFolders uint64 753 NumFiles uint64 754} 755 756func (r RepoTreeParams) TreeStats() RepoTreeStats { 757 numFolders, numFiles := 0, 0 758 for _, f := range r.Files { 759 if !f.IsFile { 760 numFolders += 1 761 } else if f.IsFile { 762 numFiles += 1 763 } 764 } 765 766 return RepoTreeStats{ 767 NumFolders: uint64(numFolders), 768 NumFiles: uint64(numFiles), 769 } 770} 771 772func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 773 params.Active = "overview" 774 775 p.rctx.RepoInfo = params.RepoInfo 776 p.rctx.RepoInfo.Ref = params.Ref 777 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 778 779 if params.ReadmeFileName != "" { 780 ext := filepath.Ext(params.ReadmeFileName) 781 switch ext { 782 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 783 params.Raw = false 784 htmlString := p.rctx.RenderMarkdown(params.Readme) 785 sanitized := p.rctx.SanitizeDefault(htmlString) 786 params.HTMLReadme = template.HTML(sanitized) 787 default: 788 params.Raw = true 789 } 790 } 791 792 return p.executeRepo("repo/tree", w, params) 793} 794 795type RepoBranchesParams struct { 796 LoggedInUser *oauth.User 797 RepoInfo repoinfo.RepoInfo 798 Active string 799 types.RepoBranchesResponse 800} 801 802func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 803 params.Active = "overview" 804 return p.executeRepo("repo/branches", w, params) 805} 806 807type RepoTagsParams struct { 808 LoggedInUser *oauth.User 809 RepoInfo repoinfo.RepoInfo 810 Active string 811 types.RepoTagsResponse 812 ArtifactMap map[plumbing.Hash][]models.Artifact 813 DanglingArtifacts []models.Artifact 814} 815 816func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 817 params.Active = "overview" 818 return p.executeRepo("repo/tags", w, params) 819} 820 821type RepoArtifactParams struct { 822 LoggedInUser *oauth.User 823 RepoInfo repoinfo.RepoInfo 824 Artifact models.Artifact 825} 826 827func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { 828 return p.executePlain("repo/fragments/artifact", w, params) 829} 830 831type RepoBlobParams struct { 832 LoggedInUser *oauth.User 833 RepoInfo repoinfo.RepoInfo 834 Active string 835 Unsupported bool 836 IsImage bool 837 IsVideo bool 838 ContentSrc string 839 BreadCrumbs [][]string 840 ShowRendered bool 841 RenderToggle bool 842 RenderedContents template.HTML 843 *tangled.RepoBlob_Output 844 // Computed fields for template compatibility 845 Contents string 846 Lines int 847 SizeHint uint64 848 IsBinary bool 849} 850 851func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 852 var style *chroma.Style = styles.Get("catpuccin-latte") 853 854 if params.ShowRendered { 855 switch markup.GetFormat(params.Path) { 856 case markup.FormatMarkdown: 857 p.rctx.RepoInfo = params.RepoInfo 858 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 859 htmlString := p.rctx.RenderMarkdown(params.Contents) 860 sanitized := p.rctx.SanitizeDefault(htmlString) 861 params.RenderedContents = template.HTML(sanitized) 862 } 863 } 864 865 c := params.Contents 866 formatter := chromahtml.New( 867 chromahtml.InlineCode(false), 868 chromahtml.WithLineNumbers(true), 869 chromahtml.WithLinkableLineNumbers(true, "L"), 870 chromahtml.Standalone(false), 871 chromahtml.WithClasses(true), 872 ) 873 874 lexer := lexers.Get(filepath.Base(params.Path)) 875 if lexer == nil { 876 lexer = lexers.Fallback 877 } 878 879 iterator, err := lexer.Tokenise(nil, c) 880 if err != nil { 881 return fmt.Errorf("chroma tokenize: %w", err) 882 } 883 884 var code bytes.Buffer 885 err = formatter.Format(&code, style, iterator) 886 if err != nil { 887 return fmt.Errorf("chroma format: %w", err) 888 } 889 890 params.Contents = code.String() 891 params.Active = "overview" 892 return p.executeRepo("repo/blob", w, params) 893} 894 895type Collaborator struct { 896 Did string 897 Handle string 898 Role string 899} 900 901type RepoSettingsParams struct { 902 LoggedInUser *oauth.User 903 RepoInfo repoinfo.RepoInfo 904 Collaborators []Collaborator 905 Active string 906 Branches []types.Branch 907 Spindles []string 908 CurrentSpindle string 909 Secrets []*tangled.RepoListSecrets_Secret 910 911 // TODO: use repoinfo.roles 912 IsCollaboratorInviteAllowed bool 913} 914 915func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 916 params.Active = "settings" 917 return p.executeRepo("repo/settings", w, params) 918} 919 920type RepoGeneralSettingsParams struct { 921 LoggedInUser *oauth.User 922 RepoInfo repoinfo.RepoInfo 923 Labels []models.LabelDefinition 924 DefaultLabels []models.LabelDefinition 925 SubscribedLabels map[string]struct{} 926 ShouldSubscribeAll bool 927 Active string 928 Tabs []map[string]any 929 Tab string 930 Branches []types.Branch 931} 932 933func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 934 params.Active = "settings" 935 return p.executeRepo("repo/settings/general", w, params) 936} 937 938type RepoAccessSettingsParams struct { 939 LoggedInUser *oauth.User 940 RepoInfo repoinfo.RepoInfo 941 Active string 942 Tabs []map[string]any 943 Tab string 944 Collaborators []Collaborator 945} 946 947func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 948 params.Active = "settings" 949 return p.executeRepo("repo/settings/access", w, params) 950} 951 952type RepoPipelineSettingsParams struct { 953 LoggedInUser *oauth.User 954 RepoInfo repoinfo.RepoInfo 955 Active string 956 Tabs []map[string]any 957 Tab string 958 Spindles []string 959 CurrentSpindle string 960 Secrets []map[string]any 961} 962 963func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 964 params.Active = "settings" 965 return p.executeRepo("repo/settings/pipelines", w, params) 966} 967 968type RepoIssuesParams struct { 969 LoggedInUser *oauth.User 970 RepoInfo repoinfo.RepoInfo 971 Active string 972 Issues []models.Issue 973 LabelDefs map[string]*models.LabelDefinition 974 Page pagination.Page 975 FilteringByOpen bool 976 FilterQuery string 977} 978 979func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 980 params.Active = "issues" 981 return p.executeRepo("repo/issues/issues", w, params) 982} 983 984type RepoSingleIssueParams struct { 985 LoggedInUser *oauth.User 986 RepoInfo repoinfo.RepoInfo 987 Active string 988 Issue *models.Issue 989 CommentList []models.CommentListItem 990 LabelDefs map[string]*models.LabelDefinition 991 992 OrderedReactionKinds []models.ReactionKind 993 Reactions map[models.ReactionKind]models.ReactionDisplayData 994 UserReacted map[models.ReactionKind]bool 995} 996 997func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 998 params.Active = "issues" 999 return p.executeRepo("repo/issues/issue", w, params) 1000} 1001 1002type EditIssueParams struct { 1003 LoggedInUser *oauth.User 1004 RepoInfo repoinfo.RepoInfo 1005 Issue *models.Issue 1006 Action string 1007} 1008 1009func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 1010 params.Action = "edit" 1011 return p.executePlain("repo/issues/fragments/putIssue", w, params) 1012} 1013 1014type ThreadReactionFragmentParams struct { 1015 ThreadAt syntax.ATURI 1016 Kind models.ReactionKind 1017 Count int 1018 Users []string 1019 IsReacted bool 1020} 1021 1022func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 1023 return p.executePlain("repo/fragments/reaction", w, params) 1024} 1025 1026type RepoNewIssueParams struct { 1027 LoggedInUser *oauth.User 1028 RepoInfo repoinfo.RepoInfo 1029 Issue *models.Issue // existing issue if any -- passed when editing 1030 Active string 1031 Action string 1032} 1033 1034func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 1035 params.Active = "issues" 1036 params.Action = "create" 1037 return p.executeRepo("repo/issues/new", w, params) 1038} 1039 1040type EditIssueCommentParams struct { 1041 LoggedInUser *oauth.User 1042 RepoInfo repoinfo.RepoInfo 1043 Issue *models.Issue 1044 Comment *models.IssueComment 1045} 1046 1047func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 1048 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 1049} 1050 1051type ReplyIssueCommentPlaceholderParams struct { 1052 LoggedInUser *oauth.User 1053 RepoInfo repoinfo.RepoInfo 1054 Issue *models.Issue 1055 Comment *models.IssueComment 1056} 1057 1058func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 1059 return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 1060} 1061 1062type ReplyIssueCommentParams struct { 1063 LoggedInUser *oauth.User 1064 RepoInfo repoinfo.RepoInfo 1065 Issue *models.Issue 1066 Comment *models.IssueComment 1067} 1068 1069func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 1070 return p.executePlain("repo/issues/fragments/replyComment", w, params) 1071} 1072 1073type IssueCommentBodyParams struct { 1074 LoggedInUser *oauth.User 1075 RepoInfo repoinfo.RepoInfo 1076 Issue *models.Issue 1077 Comment *models.IssueComment 1078} 1079 1080func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 1081 return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 1082} 1083 1084type RepoNewPullParams struct { 1085 LoggedInUser *oauth.User 1086 RepoInfo repoinfo.RepoInfo 1087 Branches []types.Branch 1088 Strategy string 1089 SourceBranch string 1090 TargetBranch string 1091 Title string 1092 Body string 1093 Active string 1094} 1095 1096func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1097 params.Active = "pulls" 1098 return p.executeRepo("repo/pulls/new", w, params) 1099} 1100 1101type RepoPullsParams struct { 1102 LoggedInUser *oauth.User 1103 RepoInfo repoinfo.RepoInfo 1104 Pulls []*models.Pull 1105 Active string 1106 FilteringBy models.PullState 1107 FilterQuery string 1108 Stacks map[string]models.Stack 1109 Pipelines map[string]models.Pipeline 1110 LabelDefs map[string]*models.LabelDefinition 1111} 1112 1113func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 1114 params.Active = "pulls" 1115 return p.executeRepo("repo/pulls/pulls", w, params) 1116} 1117 1118type ResubmitResult uint64 1119 1120const ( 1121 ShouldResubmit ResubmitResult = iota 1122 ShouldNotResubmit 1123 Unknown 1124) 1125 1126func (r ResubmitResult) Yes() bool { 1127 return r == ShouldResubmit 1128} 1129func (r ResubmitResult) No() bool { 1130 return r == ShouldNotResubmit 1131} 1132func (r ResubmitResult) Unknown() bool { 1133 return r == Unknown 1134} 1135 1136type RepoSinglePullParams struct { 1137 LoggedInUser *oauth.User 1138 RepoInfo repoinfo.RepoInfo 1139 Active string 1140 Pull *models.Pull 1141 Stack models.Stack 1142 AbandonedPulls []*models.Pull 1143 BranchDeleteStatus *models.BranchDeleteStatus 1144 MergeCheck types.MergeCheckResponse 1145 ResubmitCheck ResubmitResult 1146 Pipelines map[string]models.Pipeline 1147 1148 OrderedReactionKinds []models.ReactionKind 1149 Reactions map[models.ReactionKind]models.ReactionDisplayData 1150 UserReacted map[models.ReactionKind]bool 1151 1152 LabelDefs map[string]*models.LabelDefinition 1153} 1154 1155func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1156 params.Active = "pulls" 1157 return p.executeRepo("repo/pulls/pull", w, params) 1158} 1159 1160type RepoPullPatchParams struct { 1161 LoggedInUser *oauth.User 1162 RepoInfo repoinfo.RepoInfo 1163 Pull *models.Pull 1164 Stack models.Stack 1165 Diff *types.NiceDiff 1166 Round int 1167 Submission *models.PullSubmission 1168 OrderedReactionKinds []models.ReactionKind 1169 DiffOpts types.DiffOpts 1170} 1171 1172// this name is a mouthful 1173func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 1174 return p.execute("repo/pulls/patch", w, params) 1175} 1176 1177type RepoPullInterdiffParams struct { 1178 LoggedInUser *oauth.User 1179 RepoInfo repoinfo.RepoInfo 1180 Pull *models.Pull 1181 Round int 1182 Interdiff *patchutil.InterdiffResult 1183 OrderedReactionKinds []models.ReactionKind 1184 DiffOpts types.DiffOpts 1185} 1186 1187// this name is a mouthful 1188func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 1189 return p.execute("repo/pulls/interdiff", w, params) 1190} 1191 1192type PullPatchUploadParams struct { 1193 RepoInfo repoinfo.RepoInfo 1194} 1195 1196func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 1197 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 1198} 1199 1200type PullCompareBranchesParams struct { 1201 RepoInfo repoinfo.RepoInfo 1202 Branches []types.Branch 1203 SourceBranch string 1204} 1205 1206func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 1207 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 1208} 1209 1210type PullCompareForkParams struct { 1211 RepoInfo repoinfo.RepoInfo 1212 Forks []models.Repo 1213 Selected string 1214} 1215 1216func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 1217 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 1218} 1219 1220type PullCompareForkBranchesParams struct { 1221 RepoInfo repoinfo.RepoInfo 1222 SourceBranches []types.Branch 1223 TargetBranches []types.Branch 1224} 1225 1226func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 1227 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 1228} 1229 1230type PullResubmitParams struct { 1231 LoggedInUser *oauth.User 1232 RepoInfo repoinfo.RepoInfo 1233 Pull *models.Pull 1234 SubmissionId int 1235} 1236 1237func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 1238 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 1239} 1240 1241type PullActionsParams struct { 1242 LoggedInUser *oauth.User 1243 RepoInfo repoinfo.RepoInfo 1244 Pull *models.Pull 1245 RoundNumber int 1246 MergeCheck types.MergeCheckResponse 1247 ResubmitCheck ResubmitResult 1248 BranchDeleteStatus *models.BranchDeleteStatus 1249 Stack models.Stack 1250} 1251 1252func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 1253 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 1254} 1255 1256type PullNewCommentParams struct { 1257 LoggedInUser *oauth.User 1258 RepoInfo repoinfo.RepoInfo 1259 Pull *models.Pull 1260 RoundNumber int 1261} 1262 1263func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 1264 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 1265} 1266 1267type RepoCompareParams struct { 1268 LoggedInUser *oauth.User 1269 RepoInfo repoinfo.RepoInfo 1270 Forks []models.Repo 1271 Branches []types.Branch 1272 Tags []*types.TagReference 1273 Base string 1274 Head string 1275 Diff *types.NiceDiff 1276 DiffOpts types.DiffOpts 1277 1278 Active string 1279} 1280 1281func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 1282 params.Active = "overview" 1283 return p.executeRepo("repo/compare/compare", w, params) 1284} 1285 1286type RepoCompareNewParams struct { 1287 LoggedInUser *oauth.User 1288 RepoInfo repoinfo.RepoInfo 1289 Forks []models.Repo 1290 Branches []types.Branch 1291 Tags []*types.TagReference 1292 Base string 1293 Head string 1294 1295 Active string 1296} 1297 1298func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 1299 params.Active = "overview" 1300 return p.executeRepo("repo/compare/new", w, params) 1301} 1302 1303type RepoCompareAllowPullParams struct { 1304 LoggedInUser *oauth.User 1305 RepoInfo repoinfo.RepoInfo 1306 Base string 1307 Head string 1308} 1309 1310func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error { 1311 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1312} 1313 1314type RepoCompareDiffParams struct { 1315 LoggedInUser *oauth.User 1316 RepoInfo repoinfo.RepoInfo 1317 Diff types.NiceDiff 1318} 1319 1320func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1321 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1322} 1323 1324type LabelPanelParams struct { 1325 LoggedInUser *oauth.User 1326 RepoInfo repoinfo.RepoInfo 1327 Defs map[string]*models.LabelDefinition 1328 Subject string 1329 State models.LabelState 1330} 1331 1332func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1333 return p.executePlain("repo/fragments/labelPanel", w, params) 1334} 1335 1336type EditLabelPanelParams struct { 1337 LoggedInUser *oauth.User 1338 RepoInfo repoinfo.RepoInfo 1339 Defs map[string]*models.LabelDefinition 1340 Subject string 1341 State models.LabelState 1342} 1343 1344func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1345 return p.executePlain("repo/fragments/editLabelPanel", w, params) 1346} 1347 1348type PipelinesParams struct { 1349 LoggedInUser *oauth.User 1350 RepoInfo repoinfo.RepoInfo 1351 Pipelines []models.Pipeline 1352 Active string 1353} 1354 1355func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error { 1356 params.Active = "pipelines" 1357 return p.executeRepo("repo/pipelines/pipelines", w, params) 1358} 1359 1360type LogBlockParams struct { 1361 Id int 1362 Name string 1363 Command string 1364 Collapsed bool 1365 StartTime time.Time 1366} 1367 1368func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1369 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1370} 1371 1372type LogBlockEndParams struct { 1373 Id int 1374 StartTime time.Time 1375 EndTime time.Time 1376} 1377 1378func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1379 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1380} 1381 1382type LogLineParams struct { 1383 Id int 1384 Content string 1385} 1386 1387func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { 1388 return p.executePlain("repo/pipelines/fragments/logLine", w, params) 1389} 1390 1391type WorkflowParams struct { 1392 LoggedInUser *oauth.User 1393 RepoInfo repoinfo.RepoInfo 1394 Pipeline models.Pipeline 1395 Workflow string 1396 LogUrl string 1397 Active string 1398} 1399 1400func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1401 params.Active = "pipelines" 1402 return p.executeRepo("repo/pipelines/workflow", w, params) 1403} 1404 1405type PutStringParams struct { 1406 LoggedInUser *oauth.User 1407 Action string 1408 1409 // this is supplied in the case of editing an existing string 1410 String models.String 1411} 1412 1413func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1414 return p.execute("strings/put", w, params) 1415} 1416 1417type StringsDashboardParams struct { 1418 LoggedInUser *oauth.User 1419 Card ProfileCard 1420 Strings []models.String 1421} 1422 1423func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1424 return p.execute("strings/dashboard", w, params) 1425} 1426 1427type StringTimelineParams struct { 1428 LoggedInUser *oauth.User 1429 Strings []models.String 1430} 1431 1432func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1433 return p.execute("strings/timeline", w, params) 1434} 1435 1436type SingleStringParams struct { 1437 LoggedInUser *oauth.User 1438 ShowRendered bool 1439 RenderToggle bool 1440 RenderedContents template.HTML 1441 String models.String 1442 Stats models.StringStats 1443 Owner identity.Identity 1444} 1445 1446func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1447 var style *chroma.Style = styles.Get("catpuccin-latte") 1448 1449 if params.ShowRendered { 1450 switch markup.GetFormat(params.String.Filename) { 1451 case markup.FormatMarkdown: 1452 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1453 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1454 sanitized := p.rctx.SanitizeDefault(htmlString) 1455 params.RenderedContents = template.HTML(sanitized) 1456 } 1457 } 1458 1459 c := params.String.Contents 1460 formatter := chromahtml.New( 1461 chromahtml.InlineCode(false), 1462 chromahtml.WithLineNumbers(true), 1463 chromahtml.WithLinkableLineNumbers(true, "L"), 1464 chromahtml.Standalone(false), 1465 chromahtml.WithClasses(true), 1466 ) 1467 1468 lexer := lexers.Get(filepath.Base(params.String.Filename)) 1469 if lexer == nil { 1470 lexer = lexers.Fallback 1471 } 1472 1473 iterator, err := lexer.Tokenise(nil, c) 1474 if err != nil { 1475 return fmt.Errorf("chroma tokenize: %w", err) 1476 } 1477 1478 var code bytes.Buffer 1479 err = formatter.Format(&code, style, iterator) 1480 if err != nil { 1481 return fmt.Errorf("chroma format: %w", err) 1482 } 1483 1484 params.String.Contents = code.String() 1485 return p.execute("strings/string", w, params) 1486} 1487 1488func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1489 return p.execute("timeline/home", w, params) 1490} 1491 1492func (p *Pages) Static() http.Handler { 1493 if p.dev { 1494 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1495 } 1496 1497 sub, err := fs.Sub(p.embedFS, "static") 1498 if err != nil { 1499 p.logger.Error("no static dir found? that's crazy", "err", err) 1500 panic(err) 1501 } 1502 // Custom handler to apply Cache-Control headers for font files 1503 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 1504} 1505 1506func Cache(h http.Handler) http.Handler { 1507 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1508 path := strings.Split(r.URL.Path, "?")[0] 1509 1510 if strings.HasSuffix(path, ".css") { 1511 // on day for css files 1512 w.Header().Set("Cache-Control", "public, max-age=86400") 1513 } else { 1514 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 1515 } 1516 h.ServeHTTP(w, r) 1517 }) 1518} 1519 1520func (p *Pages) CssContentHash() string { 1521 cssFile, err := p.embedFS.Open("static/tw.css") 1522 if err != nil { 1523 slog.Debug("Error opening CSS file", "err", err) 1524 return "" 1525 } 1526 defer cssFile.Close() 1527 1528 hasher := sha256.New() 1529 if _, err := io.Copy(hasher, cssFile); err != nil { 1530 slog.Debug("Error hashing CSS file", "err", err) 1531 return "" 1532 } 1533 1534 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1535} 1536 1537func (p *Pages) Error500(w io.Writer) error { 1538 return p.execute("errors/500", w, nil) 1539} 1540 1541func (p *Pages) Error404(w io.Writer) error { 1542 return p.execute("errors/404", w, nil) 1543} 1544 1545func (p *Pages) ErrorKnot404(w io.Writer) error { 1546 return p.execute("errors/knot404", w, nil) 1547} 1548 1549func (p *Pages) Error503(w io.Writer) error { 1550 return p.execute("errors/503", w, nil) 1551}