Initial commit
This commit is contained in:
commit
77d3ff4852
20 changed files with 645 additions and 0 deletions
159
providers/sitebuilder/builder.go
Normal file
159
providers/sitebuilder/builder.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package sitebuilder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
)
|
||||
|
||||
type Builder struct {
|
||||
site models.Site
|
||||
gmMarkdown goldmark.Markdown
|
||||
opts Options
|
||||
tmpls *template.Template
|
||||
}
|
||||
|
||||
func New(site models.Site, opts Options) (*Builder, error) {
|
||||
tmpls, err := template.New("").
|
||||
Funcs(templateFns(site, opts)).
|
||||
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Builder{
|
||||
site: site,
|
||||
opts: opts,
|
||||
tmpls: tmpls,
|
||||
gmMarkdown: goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithHardWraps(),
|
||||
html.WithUnsafe(),
|
||||
),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Builder) BuildSite(outDir string) error {
|
||||
buildCtx := buildContext{outDir: outDir}
|
||||
|
||||
if err := os.RemoveAll(outDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, post := range b.site.Posts {
|
||||
if err := b.writePost(buildCtx, post); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.renderPostList(buildCtx, b.site.Posts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Builder) renderPostList(ctx buildContext, postList []*models.Post) error {
|
||||
// TODO: paging
|
||||
postCopy := make([]*models.Post, len(postList))
|
||||
copy(postCopy, postList)
|
||||
|
||||
sort.Slice(postCopy, func(i, j int) bool {
|
||||
return postCopy[i].Meta.Date.After(postCopy[j].Meta.Date)
|
||||
})
|
||||
|
||||
pl := postListData{}
|
||||
for _, post := range postCopy {
|
||||
rp, err := b.renderPost(post)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pl.Posts = append(pl.Posts, rp)
|
||||
}
|
||||
|
||||
return b.createAtPath(ctx, "", func(f io.Writer) error {
|
||||
return b.renderTemplate(f, tmplNamePostList, pl)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
|
||||
postPath := post.Meta.Slug
|
||||
if b.opts.BasePosts != "" {
|
||||
postPath = filepath.Join(b.opts.BasePosts, strings.TrimPrefix(postPath, "/"))
|
||||
}
|
||||
|
||||
var md bytes.Buffer
|
||||
if err := b.gmMarkdown.Convert([]byte(post.Content), &md); err != nil {
|
||||
return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Meta.Slug, err)
|
||||
}
|
||||
|
||||
return postSingleData{
|
||||
Path: postPath,
|
||||
Meta: post.Meta,
|
||||
HTML: template.HTML(md.String()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Builder) writePost(ctx buildContext, post *models.Post) error {
|
||||
rp, err := b.renderPost(post)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.createAtPath(ctx, rp.Path, func(f io.Writer) error {
|
||||
return b.renderTemplate(f, tmplNamePostSingle, rp)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Builder) createAtPath(ctx buildContext, path string, fn func(f io.Writer) error) error {
|
||||
outFile := filepath.Join(ctx.outDir, strings.TrimPrefix(path, "/"))
|
||||
if filepath.Ext(outFile) == "" {
|
||||
outFile = filepath.Join(outFile, "index.html")
|
||||
}
|
||||
log.Printf("Writing %s\n", outFile)
|
||||
|
||||
// Render it within the template
|
||||
if err := os.MkdirAll(filepath.Dir(outFile), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return fn(f)
|
||||
}
|
||||
|
||||
func (b *Builder) renderTemplate(w io.Writer, name string, data interface{}) error {
|
||||
var buf bytes.Buffer
|
||||
if err := b.tmpls.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.tmpls.ExecuteTemplate(w, tmplNameLayoutMain, layoutData{
|
||||
Body: template.HTML(buf.String()),
|
||||
})
|
||||
}
|
||||
|
||||
type buildContext struct {
|
||||
outDir string
|
||||
}
|
||||
67
providers/sitebuilder/builder_test.go
Normal file
67
providers/sitebuilder/builder_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package sitebuilder_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
"lmika.dev/lmika/weiro/providers/sitebuilder"
|
||||
)
|
||||
|
||||
func TestBuilder_BuildSite(t *testing.T) {
|
||||
t.Run("build site", func(t *testing.T) {
|
||||
tmpls := fstest.MapFS{
|
||||
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
|
||||
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Meta.Title}}</a>,{{ end }}`)},
|
||||
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
|
||||
}
|
||||
|
||||
site := models.Site{
|
||||
Meta: models.SiteMeta{
|
||||
BaseURL: "https://example.com",
|
||||
},
|
||||
Posts: []*models.Post{
|
||||
{
|
||||
Meta: models.PostMeta{
|
||||
Title: "Test Post",
|
||||
Slug: "/2026/02/18/test-post",
|
||||
},
|
||||
Content: "This is a test post",
|
||||
},
|
||||
{
|
||||
Meta: models.PostMeta{
|
||||
Title: "Another Post",
|
||||
Slug: "/2026/02/20/another-post",
|
||||
},
|
||||
Content: "This is **another** test post",
|
||||
},
|
||||
},
|
||||
}
|
||||
wantFiles := map[string]string{
|
||||
"2026/02/18/test-post/index.html": "<p>This is a test post</p>\n",
|
||||
"2026/02/20/another-post/index.html": "<p>This is <strong>another</strong> test post</p>\n",
|
||||
"index.html": "<a href=\"https://example.com/2026/02/18/test-post\">Test Post</a>,<a href=\"https://example.com/2026/02/20/another-post\">Another Post</a>,",
|
||||
}
|
||||
|
||||
outDir := t.TempDir()
|
||||
|
||||
b, err := sitebuilder.New(site, sitebuilder.Options{
|
||||
TemplatesFS: tmpls,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = b.BuildSite(outDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for file, content := range wantFiles {
|
||||
filePath := filepath.Join(outDir, file)
|
||||
fileContent, err := os.ReadFile(filePath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, string(fileContent))
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
34
providers/sitebuilder/tmplfns.go
Normal file
34
providers/sitebuilder/tmplfns.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package sitebuilder
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
)
|
||||
|
||||
func templateFns(site models.Site, opts Options) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"url_abs": func(basePath string) (string, error) {
|
||||
if site.Meta.BaseURL == "" {
|
||||
return basePath, nil
|
||||
}
|
||||
|
||||
pu, err := url.Parse(site.Meta.BaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pu.Path = filepath.Join(pu.Path, basePath)
|
||||
return pu.String(), nil
|
||||
},
|
||||
"format_date": func(date time.Time) string {
|
||||
loc := opts.RenderTZ
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
return date.In(loc).Format("02 Jan 2006")
|
||||
},
|
||||
}
|
||||
}
|
||||
48
providers/sitebuilder/tmpls.go
Normal file
48
providers/sitebuilder/tmpls.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package sitebuilder
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"time"
|
||||
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
)
|
||||
|
||||
const (
|
||||
// Template names
|
||||
|
||||
// tmplNamePostSingle is the template for single post (postSingleData)
|
||||
tmplNamePostSingle = "posts_single.html"
|
||||
|
||||
// tmplNamePostList is the template for list of posts (postListData)
|
||||
tmplNamePostList = "posts_list.html"
|
||||
|
||||
// tmplNameLayoutMain is the template for the main layout (layoutMainData)
|
||||
tmplNameLayoutMain = "layout_main.html"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
SiteMeta models.SiteMeta
|
||||
|
||||
// BasePosts is the base path for posts.
|
||||
BasePosts string
|
||||
|
||||
// TemplatesFS provides the raw templates for rendering the site.
|
||||
TemplatesFS fs.FS
|
||||
|
||||
RenderTZ *time.Location
|
||||
}
|
||||
|
||||
type postSingleData struct {
|
||||
Meta models.PostMeta
|
||||
HTML template.HTML
|
||||
Path string
|
||||
}
|
||||
|
||||
type postListData struct {
|
||||
Posts []postSingleData
|
||||
}
|
||||
|
||||
type layoutData struct {
|
||||
Body template.HTML
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue