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 Knot string
203 RepoAt syntax.ATURI
204 SettingsAllowed bool
205 IsStarred bool
206 Stats db.RepoStats
207}
208
209func (r RepoInfo) OwnerWithAt() string {
210 if r.OwnerHandle != "" {
211 return fmt.Sprintf("@%s", r.OwnerHandle)
212 } else {
213 return r.OwnerDid
214 }
215}
216
217func (r RepoInfo) FullName() string {
218 return path.Join(r.OwnerWithAt(), r.Name)
219}
220
221func (r RepoInfo) GetTabs() [][]string {
222 tabs := [][]string{
223 {"overview", "/"},
224 {"issues", "/issues"},
225 {"pulls", "/pulls"},
226 }
227
228 if r.SettingsAllowed {
229 tabs = append(tabs, []string{"settings", "/settings"})
230 }
231
232 return tabs
233}
234
235// each tab on a repo could have some metadata:
236//
237// issues -> number of open issues etc.
238// settings -> a warning icon to setup branch protection? idk
239//
240// we gather these bits of info here, because go templates
241// are difficult to program in
242func (r RepoInfo) TabMetadata() map[string]any {
243 meta := make(map[string]any)
244
245 meta["issues"] = r.Stats.IssueCount.Open
246
247 // more stuff?
248
249 return meta
250}
251
252type RepoIndexParams struct {
253 LoggedInUser *auth.User
254 RepoInfo RepoInfo
255 Active string
256 TagMap map[string][]string
257 types.RepoIndexResponse
258 HTMLReadme template.HTML
259 Raw bool
260}
261
262func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
263 params.Active = "overview"
264 if params.IsEmpty {
265 return p.executeRepo("repo/empty", w, params)
266 }
267
268 if params.ReadmeFileName != "" {
269 var htmlString string
270 ext := filepath.Ext(params.ReadmeFileName)
271 switch ext {
272 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
273 htmlString = renderMarkdown(params.Readme)
274 params.Raw = false
275 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
276 default:
277 htmlString = string(params.Readme)
278 params.Raw = true
279 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
280 }
281 }
282
283 return p.executeRepo("repo/index", w, params)
284}
285
286type RepoLogParams struct {
287 LoggedInUser *auth.User
288 RepoInfo RepoInfo
289 types.RepoLogResponse
290 Active string
291}
292
293func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
294 params.Active = "overview"
295 return p.execute("repo/log", w, params)
296}
297
298type RepoCommitParams struct {
299 LoggedInUser *auth.User
300 RepoInfo RepoInfo
301 Active string
302 types.RepoCommitResponse
303}
304
305func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
306 params.Active = "overview"
307 return p.executeRepo("repo/commit", w, params)
308}
309
310type RepoTreeParams struct {
311 LoggedInUser *auth.User
312 RepoInfo RepoInfo
313 Active string
314 BreadCrumbs [][]string
315 BaseTreeLink string
316 BaseBlobLink string
317 types.RepoTreeResponse
318}
319
320type RepoTreeStats struct {
321 NumFolders uint64
322 NumFiles uint64
323}
324
325func (r RepoTreeParams) TreeStats() RepoTreeStats {
326 numFolders, numFiles := 0, 0
327 for _, f := range r.Files {
328 if !f.IsFile {
329 numFolders += 1
330 } else if f.IsFile {
331 numFiles += 1
332 }
333 }
334
335 return RepoTreeStats{
336 NumFolders: uint64(numFolders),
337 NumFiles: uint64(numFiles),
338 }
339}
340
341func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
342 params.Active = "overview"
343 return p.execute("repo/tree", w, params)
344}
345
346type RepoBranchesParams struct {
347 LoggedInUser *auth.User
348 RepoInfo RepoInfo
349 types.RepoBranchesResponse
350}
351
352func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
353 return p.executeRepo("repo/branches", w, params)
354}
355
356type RepoTagsParams struct {
357 LoggedInUser *auth.User
358 RepoInfo RepoInfo
359 types.RepoTagsResponse
360}
361
362func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
363 return p.executeRepo("repo/tags", w, params)
364}
365
366type RepoBlobParams struct {
367 LoggedInUser *auth.User
368 RepoInfo RepoInfo
369 Active string
370 BreadCrumbs [][]string
371 types.RepoBlobResponse
372}
373
374func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
375 style := styles.Get("bw")
376 b := style.Builder()
377 b.Add(chroma.LiteralString, "noitalic")
378 style, _ = b.Build()
379
380 if params.Lines < 5000 {
381 c := params.Contents
382 formatter := chromahtml.New(
383 chromahtml.InlineCode(true),
384 chromahtml.WithLineNumbers(true),
385 chromahtml.WithLinkableLineNumbers(true, "L"),
386 chromahtml.Standalone(false),
387 )
388
389 lexer := lexers.Get(filepath.Base(params.Path))
390 if lexer == nil {
391 lexer = lexers.Fallback
392 }
393
394 iterator, err := lexer.Tokenise(nil, c)
395 if err != nil {
396 return fmt.Errorf("chroma tokenize: %w", err)
397 }
398
399 var code bytes.Buffer
400 err = formatter.Format(&code, style, iterator)
401 if err != nil {
402 return fmt.Errorf("chroma format: %w", err)
403 }
404
405 params.Contents = code.String()
406 }
407
408 params.Active = "overview"
409 return p.executeRepo("repo/blob", w, params)
410}
411
412type Collaborator struct {
413 Did string
414 Handle string
415 Role string
416}
417
418type RepoSettingsParams struct {
419 LoggedInUser *auth.User
420 RepoInfo RepoInfo
421 Collaborators []Collaborator
422 Active string
423 IsCollaboratorInviteAllowed bool
424}
425
426func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
427 params.Active = "settings"
428 return p.executeRepo("repo/settings", w, params)
429}
430
431type RepoIssuesParams struct {
432 LoggedInUser *auth.User
433 RepoInfo RepoInfo
434 Active string
435 Issues []db.Issue
436 DidHandleMap map[string]string
437}
438
439func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
440 params.Active = "issues"
441 return p.executeRepo("repo/issues/issues", w, params)
442}
443
444type RepoSingleIssueParams struct {
445 LoggedInUser *auth.User
446 RepoInfo RepoInfo
447 Active string
448 Issue db.Issue
449 Comments []db.Comment
450 IssueOwnerHandle string
451 DidHandleMap map[string]string
452
453 State string
454}
455
456func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
457 params.Active = "issues"
458 if params.Issue.Open {
459 params.State = "open"
460 } else {
461 params.State = "closed"
462 }
463 return p.execute("repo/issues/issue", w, params)
464}
465
466type RepoNewIssueParams struct {
467 LoggedInUser *auth.User
468 RepoInfo RepoInfo
469 Active string
470}
471
472func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
473 params.Active = "issues"
474 return p.executeRepo("repo/issues/new", w, params)
475}
476
477type RepoPullsParams struct {
478 LoggedInUser *auth.User
479 RepoInfo RepoInfo
480 Active string
481}
482
483func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
484 params.Active = "pulls"
485 return p.executeRepo("repo/pulls/pulls", w, params)
486}
487
488func (p *Pages) Static() http.Handler {
489 sub, err := fs.Sub(files, "static")
490 if err != nil {
491 log.Fatalf("no static dir found? that's crazy: %v", err)
492 }
493 // Custom handler to apply Cache-Control headers for font files
494 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
495}
496
497func Cache(h http.Handler) http.Handler {
498 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
499 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
500 h.ServeHTTP(w, r)
501 })
502}
503
504func (p *Pages) Error500(w io.Writer) error {
505 return p.execute("errors/500", w, nil)
506}
507
508func (p *Pages) Error404(w io.Writer) error {
509 return p.execute("errors/404", w, nil)
510}
511
512func (p *Pages) Error503(w io.Writer) error {
513 return p.execute("errors/503", w, nil)
514}