feat(blog): prototype of using blogo as blog engine
This commit is contained in:
@@ -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() {
|
||||
<div class="w-100% py-10rem flex justify-center">
|
||||
<main class="w-60%">
|
||||
@templ.Raw(string(html))
|
||||
</main>
|
||||
</div>
|
||||
<main class="max-w-50rem p-10rem text-justify">
|
||||
@templ.Raw(content)
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
templ templateList(entries map[string]entry) {
|
||||
templ blogList(content []fs.DirEntry) {
|
||||
@layouts.Page() {
|
||||
<div class="w-100% py-10rem flex justify-center">
|
||||
<main class="w-60%">
|
||||
<h1>Blog</h1>
|
||||
<ul class="list-none">
|
||||
for _, e := range entries {
|
||||
<li>
|
||||
<h2>
|
||||
<a href={ templ.SafeURL(path.Join(".", e.path)) }>
|
||||
@templ.Raw(e.title)
|
||||
</a>
|
||||
</h2>
|
||||
<p>
|
||||
@templ.Raw(e.summary)
|
||||
</p>
|
||||
<p>Created { e.creationDate.Format(time.DateOnly) } • Modified { e.modifiedDate.Format(time.DateOnly) }</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</main>
|
||||
</div>
|
||||
<main class="max-w-50rem p-10rem text-justify">
|
||||
for _, c := range content {
|
||||
<div>
|
||||
<h2><a href={ templ.SafeURL(c.Name()) }>{ c.Name() }</a></h2>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user