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