From 77d3ff4852d84f4ea2750c7e748d2ad771151fca Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Feb 2026 22:07:18 +1100 Subject: [PATCH] Initial commit --- .gitignore | 1 + .idea/.gitignore | 10 ++ .idea/go.imports.xml | 11 ++ .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + .idea/weiro.iml | 9 ++ go.mod | 11 ++ go.sum | 11 ++ main.go | 32 ++++++ models/sites.go | 27 +++++ providers/sitebuilder/builder.go | 159 ++++++++++++++++++++++++++ providers/sitebuilder/builder_test.go | 67 +++++++++++ providers/sitebuilder/tmplfns.go | 34 ++++++ providers/sitebuilder/tmpls.go | 48 ++++++++ providers/sitereader/provider.go | 72 ++++++++++++ providers/sitereader/provider_test.go | 106 +++++++++++++++++ site_templates/fs.go | 6 + site_templates/layout_main.html | 21 ++++ site_templates/posts_list.html | 4 + site_templates/posts_single.html | 2 + 20 files changed, 645 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/go.imports.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/weiro.iml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 models/sites.go create mode 100644 providers/sitebuilder/builder.go create mode 100644 providers/sitebuilder/builder_test.go create mode 100644 providers/sitebuilder/tmplfns.go create mode 100644 providers/sitebuilder/tmpls.go create mode 100644 providers/sitereader/provider.go create mode 100644 providers/sitereader/provider_test.go create mode 100644 site_templates/fs.go create mode 100644 site_templates/layout_main.html create mode 100644 site_templates/posts_list.html create mode 100644 site_templates/posts_single.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2ea3503 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/weiro.iml b/.idea/weiro.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/weiro.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0465f3a --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module lmika.dev/lmika/weiro + +go 1.24.3 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/yuin/goldmark v1.7.16 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7e80bf7 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9f0ba46 --- /dev/null +++ b/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + + "lmika.dev/lmika/weiro/providers/sitebuilder" + "lmika.dev/lmika/weiro/providers/sitereader" + "lmika.dev/lmika/weiro/site_templates" +) + +func main() { + sr := sitereader.New(os.DirFS("build/test-site")) + sb, err := sitebuilder.New("build/out", sitebuilder.Options{ + BasePosts: "/posts", + TemplatesFS: site_templates.FS, + }) + if err != nil { + log.Fatal(err) + } + + site, err := sr.ReadSite() + if err != nil { + log.Fatal(err) + } + + if err := sb.BuildSite(site); err != nil { + log.Fatal(err) + } + + log.Println("Done") +} diff --git a/models/sites.go b/models/sites.go new file mode 100644 index 0000000..0f7e1ef --- /dev/null +++ b/models/sites.go @@ -0,0 +1,27 @@ +package models + +import "time" + +type Site struct { + Meta SiteMeta + Posts []*Post +} + +type SiteMeta struct { + Title string + Tagline string + BaseURL string +} + +type PostMeta struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Date time.Time `yaml:"date"` + Tags []string `yaml:"tags"` + Slug string `yaml:"slug"` +} + +type Post struct { + Meta PostMeta + Content string +} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go new file mode 100644 index 0000000..4836398 --- /dev/null +++ b/providers/sitebuilder/builder.go @@ -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 +} diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go new file mode 100644 index 0000000..d92c713 --- /dev/null +++ b/providers/sitebuilder/builder_test.go @@ -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}}{{.Meta.Title}},{{ 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": "

This is a test post

\n", + "2026/02/20/another-post/index.html": "

This is another test post

\n", + "index.html": "Test Post,Another Post,", + } + + 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)) + } + }) + +} diff --git a/providers/sitebuilder/tmplfns.go b/providers/sitebuilder/tmplfns.go new file mode 100644 index 0000000..abbf60d --- /dev/null +++ b/providers/sitebuilder/tmplfns.go @@ -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") + }, + } +} diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go new file mode 100644 index 0000000..a425d7e --- /dev/null +++ b/providers/sitebuilder/tmpls.go @@ -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 +} diff --git a/providers/sitereader/provider.go b/providers/sitereader/provider.go new file mode 100644 index 0000000..9e12d8b --- /dev/null +++ b/providers/sitereader/provider.go @@ -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 +} diff --git a/providers/sitereader/provider_test.go b/providers/sitereader/provider_test.go new file mode 100644 index 0000000..9c48dfe --- /dev/null +++ b/providers/sitereader/provider_test.go @@ -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) +} diff --git a/site_templates/fs.go b/site_templates/fs.go new file mode 100644 index 0000000..461cea4 --- /dev/null +++ b/site_templates/fs.go @@ -0,0 +1,6 @@ +package site_templates + +import "embed" + +//go:embed *.html +var FS embed.FS diff --git a/site_templates/layout_main.html b/site_templates/layout_main.html new file mode 100644 index 0000000..cc5f93f --- /dev/null +++ b/site_templates/layout_main.html @@ -0,0 +1,21 @@ + + + + + + My New Website + + + +
+

Hello, world

+

Welcome to my website!

+
+ +{{ .Body }} + + + + \ No newline at end of file diff --git a/site_templates/posts_list.html b/site_templates/posts_list.html new file mode 100644 index 0000000..1f9bdcc --- /dev/null +++ b/site_templates/posts_list.html @@ -0,0 +1,4 @@ +{{ range .Posts }} + {{ .HTML }} + {{ format_date .Meta.Date }} +{{ end }} \ No newline at end of file diff --git a/site_templates/posts_single.html b/site_templates/posts_single.html new file mode 100644 index 0000000..189ef93 --- /dev/null +++ b/site_templates/posts_single.html @@ -0,0 +1,2 @@ +{{ .HTML }} +{{ format_date .Meta.Date }} \ No newline at end of file