this repo has no description
1package pages
2
3import (
4 "bytes"
5 "embed"
6 "fmt"
7 "html/template"
8 "io"
9 "io/fs"
10 "log"
11 "net/http"
12 "path"
13 "path/filepath"
14 "strings"
15
16 "github.com/alecthomas/chroma/v2"
17 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
18 "github.com/alecthomas/chroma/v2/lexers"
19 "github.com/alecthomas/chroma/v2/styles"
20 "github.com/bluesky-social/indigo/atproto/syntax"
21 "github.com/microcosm-cc/bluemonday"
22 "github.com/sotangled/tangled/appview/auth"
23 "github.com/sotangled/tangled/appview/db"
24 "github.com/sotangled/tangled/types"
25)
26
27//go:embed templates/* static/*
28var files embed.FS
29
30type Pages struct {
31 t map[string]*template.Template
32}
33
34func NewPages() *Pages {
35 templates := make(map[string]*template.Template)
36
37 // Walk through embedded templates directory and parse all .html files
38 err := fs.WalkDir(files, "templates", func(path string, d fs.DirEntry, err error) error {
39 if err != nil {
40 return err
41 }
42
43 if !d.IsDir() && strings.HasSuffix(path, ".html") {
44 name := strings.TrimPrefix(path, "templates/")
45 name = strings.TrimSuffix(name, ".html")
46
47 // add fragments as templates
48 if strings.HasPrefix(path, "templates/fragments/") {
49 tmpl, err := template.New(name).
50 Funcs(funcMap()).
51 ParseFS(files, path)
52 if err != nil {
53 return fmt.Errorf("setting up fragment: %w", err)
54 }
55
56 templates[name] = tmpl
57 log.Printf("loaded fragment: %s", name)
58 }
59
60 // layouts and fragments are applied first
61 if !strings.HasPrefix(path, "templates/layouts/") &&
62 !strings.HasPrefix(path, "templates/fragments/") {
63 // Add the page template on top of the base
64 tmpl, err := template.New(name).
65 Funcs(funcMap()).
66 ParseFS(files, "templates/layouts/*.html", "templates/fragments/*.html", path)
67 if err != nil {
68 return fmt.Errorf("setting up template: %w", err)
69 }
70
71 templates[name] = tmpl
72 log.Printf("loaded template: %s", name)
73 }
74
75 return nil
76 }
77 return nil
78 })
79 if err != nil {
80 log.Fatalf("walking template dir: %v", err)
81 }
82
83 log.Printf("total templates loaded: %d", len(templates))
84
85 return &Pages{
86 t: templates,
87 }
88}
89
90type LoginParams struct {
91}
92
93func (p *Pages) execute(name string, w io.Writer, params any) error {
94 return p.t[name].ExecuteTemplate(w, "layouts/base", params)
95}
96
97func (p *Pages) executePlain(name string, w io.Writer, params any) error {
98 return p.t[name].Execute(w, params)
99}
100
101func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
102 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
103}
104
105func (p *Pages) Login(w io.Writer, params LoginParams) error {
106 return p.executePlain("user/login", w, params)
107}
108
109type TimelineParams struct {
110 LoggedInUser *auth.User
111 Timeline []db.TimelineEvent
112 DidHandleMap map[string]string
113}
114
115func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
116 return p.execute("timeline", w, params)
117}
118
119type SettingsParams struct {
120 LoggedInUser *auth.User
121 PubKeys []db.PublicKey
122}
123
124func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
125 return p.execute("settings", w, params)
126}
127
128type KnotsParams struct {
129 LoggedInUser *auth.User
130 Registrations []db.Registration
131}
132
133func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
134 return p.execute("knots", w, params)
135}
136
137type KnotParams struct {
138 LoggedInUser *auth.User
139 Registration *db.Registration
140 Members []string
141 IsOwner bool
142}
143
144func (p *Pages) Knot(w io.Writer, params KnotParams) error {
145 return p.execute("knot", w, params)
146}
147
148type NewRepoParams struct {
149 LoggedInUser *auth.User
150 Knots []string
151}
152
153func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
154 return p.execute("repo/new", w, params)
155}
156
157type ProfilePageParams struct {
158 LoggedInUser *auth.User
159 UserDid string
160 UserHandle string
161 Repos []db.Repo
162 CollaboratingRepos []db.Repo
163 ProfileStats ProfileStats
164 FollowStatus db.FollowStatus
165 DidHandleMap map[string]string
166 AvatarUri string
167}
168
169type ProfileStats struct {
170 Followers int
171 Following int
172}
173
174func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
175 return p.execute("user/profile", w, params)
176}
177
178type FollowFragmentParams struct {
179 UserDid string
180 FollowStatus db.FollowStatus
181}
182
183func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
184 return p.executePlain("fragments/follow", w, params)
185}
186
187type StarFragmentParams struct {
188 IsStarred bool
189 RepoAt syntax.ATURI
190 Stats db.RepoStats
191}
192
193func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error {
194 return p.executePlain("fragments/star", w, params)
195}
196
197type RepoInfo struct {
198 Name string
199 OwnerDid string
200 OwnerHandle string
201 Description string
202 RepoAt syntax.ATURI
203 SettingsAllowed bool
204 IsStarred bool
205 Stats db.RepoStats
206}
207
208func (r RepoInfo) OwnerWithAt() string {
209 if r.OwnerHandle != "" {
210 return fmt.Sprintf("@%s", r.OwnerHandle)
211 } else {
212 return r.OwnerDid
213 }
214}
215
216func (r RepoInfo) FullName() string {
217 return path.Join(r.OwnerWithAt(), r.Name)
218}
219
220func (r RepoInfo) GetTabs() [][]string {
221 tabs := [][]string{
222 {"overview", "/"},
223 {"issues", "/issues"},
224 {"pulls", "/pulls"},
225 }
226
227 if r.SettingsAllowed {
228 tabs = append(tabs, []string{"settings", "/settings"})
229 }
230
231 return tabs
232}
233
234// each tab on a repo could have some metadata:
235//
236// issues -> number of open issues etc.
237// settings -> a warning icon to setup branch protection? idk
238//
239// we gather these bits of info here, because go templates
240// are difficult to program in
241func (r RepoInfo) TabMetadata() map[string]any {
242 meta := make(map[string]any)
243
244 meta["issues"] = r.Stats.IssueCount.Open
245
246 // more stuff?
247
248 return meta
249}
250
251type RepoIndexParams struct {
252 LoggedInUser *auth.User
253 RepoInfo RepoInfo
254 Active string
255 TagMap map[string][]string
256 types.RepoIndexResponse
257 HTMLReadme template.HTML
258 Raw bool
259}
260
261func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
262 params.Active = "overview"
263 if params.IsEmpty {
264 return p.executeRepo("repo/empty", w, params)
265 }
266
267 if params.ReadmeFileName != "" {
268 var htmlString string
269 ext := filepath.Ext(params.ReadmeFileName)
270 switch ext {
271 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
272 htmlString = renderMarkdown(params.Readme)
273 params.Raw = false
274 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
275 default:
276 htmlString = string(params.Readme)
277 params.Raw = true
278 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
279 }
280 }
281
282 return p.executeRepo("repo/index", w, params)
283}
284
285type RepoLogParams struct {
286 LoggedInUser *auth.User
287 RepoInfo RepoInfo
288 types.RepoLogResponse
289 Active string
290}
291
292func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
293 params.Active = "overview"
294 return p.execute("repo/log", w, params)
295}
296
297type RepoCommitParams struct {
298 LoggedInUser *auth.User
299 RepoInfo RepoInfo
300 Active string
301 types.RepoCommitResponse
302}
303
304func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
305 params.Active = "overview"
306 return p.executeRepo("repo/commit", w, params)
307}
308
309type RepoTreeParams struct {
310 LoggedInUser *auth.User
311 RepoInfo RepoInfo
312 Active string
313 BreadCrumbs [][]string
314 BaseTreeLink string
315 BaseBlobLink string
316 types.RepoTreeResponse
317}
318
319type RepoTreeStats struct {
320 NumFolders uint64
321 NumFiles uint64
322}
323
324func (r RepoTreeParams) TreeStats() RepoTreeStats {
325 numFolders, numFiles := 0, 0
326 for _, f := range r.Files {
327 if !f.IsFile {
328 numFolders += 1
329 } else if f.IsFile {
330 numFiles += 1
331 }
332 }
333
334 return RepoTreeStats{
335 NumFolders: uint64(numFolders),
336 NumFiles: uint64(numFiles),
337 }
338}
339
340func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
341 params.Active = "overview"
342 return p.execute("repo/tree", w, params)
343}
344
345type RepoBranchesParams struct {
346 LoggedInUser *auth.User
347 RepoInfo RepoInfo
348 types.RepoBranchesResponse
349}
350
351func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
352 return p.executeRepo("repo/branches", w, params)
353}
354
355type RepoTagsParams struct {
356 LoggedInUser *auth.User
357 RepoInfo RepoInfo
358 types.RepoTagsResponse
359}
360
361func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
362 return p.executeRepo("repo/tags", w, params)
363}
364
365type RepoBlobParams struct {
366 LoggedInUser *auth.User
367 RepoInfo RepoInfo
368 Active string
369 BreadCrumbs [][]string
370 types.RepoBlobResponse
371}
372
373func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
374 style := styles.Get("bw")
375 b := style.Builder()
376 b.Add(chroma.LiteralString, "noitalic")
377 style, _ = b.Build()
378
379 if params.Lines < 5000 {
380 c := params.Contents
381 formatter := chromahtml.New(
382 chromahtml.InlineCode(true),
383 chromahtml.WithLineNumbers(true),
384 chromahtml.WithLinkableLineNumbers(true, "L"),
385 chromahtml.Standalone(false),
386 )
387
388 lexer := lexers.Get(filepath.Base(params.Path))
389 if lexer == nil {
390 lexer = lexers.Fallback
391 }
392
393 iterator, err := lexer.Tokenise(nil, c)
394 if err != nil {
395 return fmt.Errorf("chroma tokenize: %w", err)
396 }
397
398 var code bytes.Buffer
399 err = formatter.Format(&code, style, iterator)
400 if err != nil {
401 return fmt.Errorf("chroma format: %w", err)
402 }
403
404 params.Contents = code.String()
405 }
406
407 params.Active = "overview"
408 return p.executeRepo("repo/blob", w, params)
409}
410
411type Collaborator struct {
412 Did string
413 Handle string
414 Role string
415}
416
417type RepoSettingsParams struct {
418 LoggedInUser *auth.User
419 RepoInfo RepoInfo
420 Collaborators []Collaborator
421 Active string
422 IsCollaboratorInviteAllowed bool
423}
424
425func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
426 params.Active = "settings"
427 return p.executeRepo("repo/settings", w, params)
428}
429
430type RepoIssuesParams struct {
431 LoggedInUser *auth.User
432 RepoInfo RepoInfo
433 Active string
434 Issues []db.Issue
435 DidHandleMap map[string]string
436}
437
438func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
439 params.Active = "issues"
440 return p.executeRepo("repo/issues/issues", w, params)
441}
442
443type RepoSingleIssueParams struct {
444 LoggedInUser *auth.User
445 RepoInfo RepoInfo
446 Active string
447 Issue db.Issue
448 Comments []db.Comment
449 IssueOwnerHandle string
450 DidHandleMap map[string]string
451
452 State string
453}
454
455func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
456 params.Active = "issues"
457 if params.Issue.Open {
458 params.State = "open"
459 } else {
460 params.State = "closed"
461 }
462 return p.execute("repo/issues/issue", w, params)
463}
464
465type RepoNewIssueParams struct {
466 LoggedInUser *auth.User
467 RepoInfo RepoInfo
468 Active string
469}
470
471func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
472 params.Active = "issues"
473 return p.executeRepo("repo/issues/new", w, params)
474}
475
476type RepoPullsParams struct {
477 LoggedInUser *auth.User
478 RepoInfo RepoInfo
479 Active string
480}
481
482func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
483 params.Active = "pulls"
484 return p.executeRepo("repo/pulls/pulls", w, params)
485}
486
487func (p *Pages) Static() http.Handler {
488 sub, err := fs.Sub(files, "static")
489 if err != nil {
490 log.Fatalf("no static dir found? that's crazy: %v", err)
491 }
492 // Custom handler to apply Cache-Control headers for font files
493 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
494}
495
496func Cache(h http.Handler) http.Handler {
497 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
498 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
499 h.ServeHTTP(w, r)
500 })
501}
502
503func (p *Pages) Error500(w io.Writer) error {
504 return p.execute("errors/500", w, nil)
505}
506
507func (p *Pages) Error404(w io.Writer) error {
508 return p.execute("errors/404", w, nil)
509}
510
511func (p *Pages) Error503(w io.Writer) error {
512 return p.execute("errors/503", w, nil)
513}