Initial commit

This commit is contained in:
Leon Mika 2026-02-18 22:07:18 +11:00
commit 77d3ff4852
20 changed files with 645 additions and 0 deletions

View 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
}

View 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))
}
})
}

View 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")
},
}
}

View 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
}

View file

@ -0,0 +1,72 @@
package sitereader
import (
"bytes"
"io"
"io/fs"
"gopkg.in/yaml.v3"
"lmika.dev/lmika/weiro/models"
)
type Provider struct {
fs fs.FS
}
func New(fs fs.FS) *Provider {
return &Provider{
fs: fs,
}
}
func (p *Provider) ReadSite() (models.Sites, error) {
posts, err := p.ListPosts()
if err != nil {
return models.Sites{}, err
}
return models.Sites{
Posts: posts,
}, nil
}
func (p *Provider) ListPosts() (posts []*models.Post, err error) {
err = fs.WalkDir(p.fs, "posts", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
} else if d.IsDir() {
return nil
}
post, err := p.ReadPost(path)
if err != nil {
return err
}
posts = append(posts, post)
return nil
})
return posts, err
}
func (p *Provider) ReadPost(path string) (*models.Post, error) {
data, err := fs.ReadFile(p.fs, path)
if err != nil {
return nil, err
}
// Split front matter and content
parts := bytes.SplitN(data, []byte("---"), 3)
if len(parts) < 3 {
return nil, io.ErrUnexpectedEOF
}
var meta models.Meta
if err := yaml.Unmarshal(parts[1], &meta); err != nil {
return nil, err
}
return &models.Post{
Meta: meta,
Content: string(bytes.TrimPrefix(parts[2], []byte("\n"))),
}, nil
}

View file

@ -0,0 +1,106 @@
package sitereader_test
import (
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/sitereader"
)
func TestProvider_ReadPost(t *testing.T) {
t.Run("with meta", func(t *testing.T) {
testFS := fstest.MapFS{
"posts/test.md": {Data: []byte(`---
date: 2026-02-18T19:59:00Z
title: Test Post Here
tags: [test, example]
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
post, err := pr.ReadPost("posts/test.md")
assert.NoError(t, err)
assert.Equal(t, "Test Post Here", post.Meta.Title)
assert.Equal(t, time.Date(2026, 2, 18, 19, 59, 0, 0, time.UTC), post.Meta.Date)
assert.Equal(t, []string{"test", "example"}, post.Meta.Tags)
assert.Equal(t, "This is just a test post.\n", post.Content)
})
t.Run("without meta", func(t *testing.T) {
testFS := fstest.MapFS{
"posts/test.md": {Data: []byte(`---
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
post, err := pr.ReadPost("posts/test.md")
assert.NoError(t, err)
assert.Equal(t, models.Meta{}, post.Meta)
assert.Equal(t, "This is just a test post.\n", post.Content)
})
}
func TestProvider_ListPosts(t *testing.T) {
testFS := fstest.MapFS{
"posts/01-post1.md": {Data: []byte(`---
id: 111
date: 2026-02-18T19:59:00Z
title: Test Post Here
tags: [test, example]
---
This is just a test post.
`)},
"posts/02-post2.md": {Data: []byte(`---
id: 222
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
posts, err := pr.ListPosts()
assert.NoError(t, err)
assert.Equal(t, 2, len(posts))
assert.Equal(t, "111", posts[0].Meta.ID)
assert.Equal(t, "222", posts[1].Meta.ID)
}
func TestProvider_ReadSite(t *testing.T) {
testFS := fstest.MapFS{
"posts/01-post1.md": {Data: []byte(`---
id: 111
date: 2026-02-18T19:59:00Z
title: Test Post Here
tags: [test, example]
---
This is just a test post.
`)},
"posts/02-post2.md": {Data: []byte(`---
id: 222
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
sites, err := pr.ReadSite()
assert.NoError(t, err)
assert.Equal(t, 2, len(sites.Posts))
assert.Equal(t, "111", sites.Posts[0].Meta.ID)
assert.Equal(t, "222", sites.Posts[1].Meta.ID)
}