diff --git a/handlers/pages/blog.templ b/handlers/pages/blog.templ index ced0f63..4b654de 100644 --- a/handlers/pages/blog.templ +++ b/handlers/pages/blog.templ @@ -1,344 +1,27 @@ package pages import ( - "bytes" - "io" - "encoding/json" - "strings" - "errors" - "fmt" - "net/http" - "net/url" - "path" - "log" - "time" + "io/fs" "forge.capytal.company/capytal/www/templates/layouts" - - "forge.capytal.company/loreddev/x/groute/router/rerrors" - "forge.capytal.company/loreddev/x/groute/router" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark-meta" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/ast" ) -type EntryTemplate func([]byte) templ.Component -type EntryListTemplate func(map[string]entry) templ.Component - -type Blog struct { - repo string - owner string - endpoint string - - md goldmark.Markdown - entryTemplate EntryTemplate - entryListTemplate EntryListTemplate - - entries map[string]entry -} - -type entry struct { - title string - path string - summary string - creationDate time.Time - modifiedDate time.Time - contents templ.Component -} - -type BlogOptions struct { - EntryTemplate EntryTemplate -} - -func NewBlog(owner, repo, endpoint string, opts ...BlogOptions) (*Blog, error) { - /* - opt := BlogOptions{} - if len(opts) > 0 { - opt = opts[0] - } - */ - - u, err := url.Parse(endpoint) - if err != nil { - panic(fmt.Sprintf("Blog Forgejo endpoint is not a valid URL: %v", err)) - } - - md := goldmark.New( - goldmark.WithExtensions(extension.GFM, meta.Meta), - goldmark.WithParserOptions(parser.WithAutoHeadingID()), - ) - - blog := &Blog{ - repo: repo, - owner: owner, - endpoint: u.String(), - md: md, - // entryTemplate: opt.EntryTemplate, - entryTemplate: template, - entryListTemplate: templateList, - - entries: map[string]entry{}, - } - - if err := blog.init(); err != nil { - return nil, err - } - - return blog, nil -} - -func (p *Blog) Routes() router.Router { - r := router.NewRouter() - - r.HandleFunc("/{entry...}", func(w http.ResponseWriter, r *http.Request) { - pv := r.PathValue("entry") - if pv == "" { - p.listPosts(w, r) - } else { - p.blogEntry(w, r) - } - }) - - return r -} - -func (p *Blog) init() error { - _, body, rerr := p.get(fmt.Sprintf("/repos/%s/%s/contents/daily-blogs", p.owner, p.repo)) - if rerr != nil { - return rerr - } - - log.Printf("Getting files from repository") - - var list []forgejoFile - err := json.Unmarshal(body, &list) - if err != nil { - return errors.Join(errors.New("failed to parse list of entries"), err) - } - - entries := make(map[string]entry, len(list)) - for _, e := range list { - log.Printf("Getting entry %s", e.Path) - - _, body, rerr := p.get(fmt.Sprintf("/repos/%s/%s/raw/%s", p.owner, p.repo, e.Path)) - if rerr != nil { - return rerr - } - - var buf bytes.Buffer - - ctx := parser.NewContext() - - node := p.md.Parser().Parse(text.NewReader(body), parser.WithContext(ctx)) - - var title, summary string - err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkContinue, nil - } - - if summary != "" && title != "" { - return ast.WalkStop, nil - } - - if n.Kind() == ast.KindHeading && title == "" { - parts := make([]string, 0, n.ChildCount()) - - c := n.FirstChild() - for { - var tbuf bytes.Buffer - - p.md.Renderer().Render(&tbuf, body, c) - parts = append(parts, tbuf.String()) - - if s := c.NextSibling(); s != nil { - c = s - } else { - break - } - } - - title = strings.Join(parts, "") - - log.Printf("FOUND TITLE %q", title) - - return ast.WalkSkipChildren, nil - } - - if n.Kind() == ast.KindParagraph && summary == "" { - var tbuf bytes.Buffer - p.md.Renderer().Render(&tbuf, body, n) - summary = tbuf.String() - - log.Printf("FOUND SUMMARY %q", summary) - - return ast.WalkSkipChildren, nil - } - - return ast.WalkContinue, nil - }) - if err != nil { - return err - } - - meta := meta.Get(ctx) - - if t, ok := meta["title"]; ok && title == "" { - title, ok = t.(string) - if !ok { - title = "failed to concat string" // aka Yaml "yes" as bool being fucking annoying - } - } else if title == "" { - title = fmt.Sprintf("NO TITLE %s", e.Path) - } - - if s, ok := meta["summary"]; ok && summary == "" { - summary, ok = s.(string) - if !ok { - summary = "failed to concat string" // aka Yaml "yes" as bool being fucking annoying - } - } else if summary == "" { - summary = fmt.Sprintf("NO RUMMARY %s", e.Path) - } - - var created, modified time.Time - - if c, ok := meta["created"]; ok { - s, ok := c.(string) - if ok { - s = strings.Split(s, "T")[0] - created, _ = time.Parse(time.DateOnly, s) - } - } - - if m, ok := meta["modified"]; ok { - s, ok := m.(string) - if ok { - s = strings.Split(s, "T")[0] - modified, _ = time.Parse(time.DateOnly, s) - } - } - - err = p.md.Renderer().Render(&buf, body, node) - if err != nil { - return err - } - - html, err := io.ReadAll(&buf) - if err != nil { - return err - } - - comp := p.entryTemplate(html) - - entries[e.Path] = entry{ - title: title, - path: e.Path, - summary: summary, - contents: comp, - creationDate: created, - modifiedDate: modified, - } - } - - p.entries = entries - - return nil -} - -func (p *Blog) listPosts(w http.ResponseWriter, r *http.Request) { - err := p.entryListTemplate(p.entries).Render(r.Context(), w) - if err != nil { - rerrors.InternalError(err).ServeHTTP(w, r) - } -} - -func (p *Blog) blogEntry(w http.ResponseWriter, r *http.Request) { - e, ok := p.entries[r.PathValue("entry")] - if !ok { - rerrors.NotFound().ServeHTTP(w, r) - return - } - - err := e.contents.Render(r.Context(), w) - if err != nil { - rerrors.InternalError(errors.New("failed to write response"), err).ServeHTTP(w, r) - return - } -} - -func (p *Blog) get(endpoint string) (http.Header, []byte, *rerrors.RouteError) { - u, _ := url.Parse(p.endpoint) - u.Path = path.Join(u.Path, endpoint) - - r, err := http.Get(u.String()) - if err != nil { - e := rerrors.InternalError( - fmt.Errorf("failed to make request to endpoint %s", u.String()), - err, - ) - return nil, nil, &e - } - - body, err := io.ReadAll(r.Body) - if err != nil { - e := rerrors.InternalError( - fmt.Errorf("failed to read response body of request to endpoint %s", u.String()), - err, - ) - return nil, nil, &e - } else if r.StatusCode != http.StatusOK { - e := rerrors.InternalError( - fmt.Errorf("request to endpoint %s returned non-200 code %q.\n%s", u.String(), r.Status, string(body)), - ) - return nil, nil, &e - } - - return r.Header, body, nil -} - -type forgejoFile struct { - Name string `json:"name"` - Path string `json:"path"` - Sha string `json:"sha"` - LastCommitSha string `json:"last_commit_sha"` - Type string `json:"type"` -} - -templ template(html []byte) { +templ blogEntry(content string) { @layouts.Page() { -
-
- @templ.Raw(string(html)) -
-
+
+ @templ.Raw(content) +
} } -templ templateList(entries map[string]entry) { +templ blogList(content []fs.DirEntry) { @layouts.Page() { -
-
-

Blog

- -
-
+
+ for _, c := range content { +
+

{ c.Name() }

+
+ } +
} } diff --git a/handlers/pages/routes.go b/handlers/pages/routes.go index 0067cfa..31cb601 100644 --- a/handlers/pages/routes.go +++ b/handlers/pages/routes.go @@ -1,12 +1,24 @@ package pages import ( + "context" + "errors" + "io" + "io/fs" "log/slog" + "net/http" + "forge.capytal.company/loreddev/x/blogo" + "forge.capytal.company/loreddev/x/blogo/plugins" + "forge.capytal.company/loreddev/x/blogo/plugins/gitea" + "forge.capytal.company/loreddev/x/blogo/plugins/markdown" "forge.capytal.company/loreddev/x/groute/router" "forge.capytal.company/loreddev/x/groute/router/rerrors" + "forge.capytal.company/loreddev/x/tinyssert" ) +var assert = tinyssert.NewAssertions() + func Routes(log *slog.Logger) router.Router { r := router.NewRouter() @@ -15,12 +27,78 @@ func Routes(log *slog.Logger) router.Router { r.Handle("/", &IndexPage{}) r.Handle("/about", &AboutPage{}) - b, err := NewBlog("dot013", "blog", "https://forge.capytal.company/api/v1") - if err != nil { - panic(err) - } + // b, err := NewBlog("dot013", "blog", "https://forge.capytal.company/api/v1") + // if err != nil { + // panic(err) + // } - r.Handle("/blog/", b.Routes()) + blog := blogo.New(blogo.Opts{ + Assertions: assert, + Logger: log.WithGroup("blogo"), + }) + + gitea := gitea.New("dot013", "blog", "https://forge.capytal.company", gitea.Opts{ + // Ref: "2025-redesign", + }) + blog.Use(gitea) + + blog.Use(&listTemplater{}) + + rf := plugins.NewFoldingRenderer(plugins.FoldingRendererOpts{ + Assertions: assert, + Logger: log.WithGroup("folding-renderer"), + }) + + markdown := markdown.New() + rf.Use(markdown) + rf.Use(&templater{}) + + blog.Use(rf) + + plaintext := plugins.NewPlainText(plugins.PlainTextOpts{ + Assertions: assert, + }) + blog.Use(plaintext) + + r.Handle("/blog", http.StripPrefix("/blog/", blog)) return r } + +type templater struct{} + +func (t *templater) Name() string { + return "capytal-templater-renderer" +} + +func (t *templater) Render(src fs.File, w io.Writer) error { + c, err := io.ReadAll(src) + if err != nil { + return err + } + + err = blogEntry(string(c)).Render(context.Background(), w) + if err != nil { + return err + } + + return nil +} + +type listTemplater struct{} + +func (t *listTemplater) Name() string { + return "capytal-listtemplater-renderer" +} + +func (t *listTemplater) Render(src fs.File, w io.Writer) error { + if d, ok := src.(fs.ReadDirFile); ok { + entries, err := d.ReadDir(-1) + if err != nil && !errors.Is(err, io.EOF) { + return err + } + + return blogList(entries).Render(context.Background(), w) + } + return errors.New("templater does not support single files") +}