Introduce config mod and struct (#2)

* outline config mod and struct

* replace site interfaces

* load config from config.yml

* adapt commands to load and pass config

* fix test

* fix tests

* use symlinks for static assets

* implement absolute url filter

* doc comment

* fix tests

* remove outdated TODO comments

* Add go build actions workflow (#3)

* Add go build actions workflow

* set go to 1.22
This commit is contained in:
Facundo Olano 2024-02-16 12:39:19 -03:00 committed by GitHub
parent 859327d4bd
commit a4279aae0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 265 additions and 135 deletions

View file

@ -2,17 +2,11 @@ package commands
import ( import (
"fmt" "fmt"
"path/filepath"
"github.com/facundoolano/blorg/config"
"github.com/facundoolano/blorg/site" "github.com/facundoolano/blorg/site"
) )
const SRC_DIR = "src"
const TARGET_DIR = "target"
const LAYOUTS_DIR = "layouts"
const INCLUDES_DIR = "includes"
const DATA_DIR = "data"
func Init() error { func Init() error {
// get working directory // get working directory
// default to . // default to .
@ -34,15 +28,15 @@ func New() error {
// Read the files in src/ render them and copy the result to target/ // Read the files in src/ render them and copy the result to target/
func Build(root string) error { func Build(root string) error {
src := filepath.Join(root, SRC_DIR) config, err := config.Load(root)
target := filepath.Join(root, TARGET_DIR)
layouts := filepath.Join(root, LAYOUTS_DIR)
data := filepath.Join(root, DATA_DIR)
site, err := site.Load(src, layouts, data)
if err != nil { if err != nil {
return err return err
} }
return site.Build(src, target, true, false) site, err := site.Load(*config)
if err != nil {
return err
}
return site.Build()
} }

View file

@ -8,19 +8,24 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/facundoolano/blorg/config"
"github.com/facundoolano/blorg/site" "github.com/facundoolano/blorg/site"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
) )
// Generate and serve the site, rebuilding when the source files change. // Generate and serve the site, rebuilding when the source files change.
func Serve() error { func Serve(rootDir string) error {
config, err := config.LoadDevServer(rootDir)
if err != nil {
return err
}
if err := rebuild(); err != nil { if err := rebuild(config); err != nil {
return err return err
} }
// watch for changes in src and layouts, and trigger a rebuild // watch for changes in src and layouts, and trigger a rebuild
watcher, err := setupWatcher() watcher, err := setupWatcher(config)
if err != nil { if err != nil {
return err return err
} }
@ -35,13 +40,14 @@ func Serve() error {
return nil return nil
} }
func rebuild() error { func rebuild(config *config.Config) error {
site, err := site.Load(SRC_DIR, LAYOUTS_DIR, DATA_DIR)
site, err := site.Load(*config)
if err != nil { if err != nil {
return err return err
} }
if err := site.Build(SRC_DIR, TARGET_DIR, false, true); err != nil { if err := site.Build(); err != nil {
return err return err
} }
@ -66,7 +72,7 @@ func (d HTMLDir) Open(name string) (http.File, error) {
return f, err return f, err
} }
func setupWatcher() (*fsnotify.Watcher, error) { func setupWatcher(config *config.Config) (*fsnotify.Watcher, error) {
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
return nil, err return nil, err
@ -96,12 +102,12 @@ func setupWatcher() (*fsnotify.Watcher, error) {
// since new nested directories could be triggering this change, and we need to watch those too // since new nested directories could be triggering this change, and we need to watch those too
// and since re-watching files is a noop, I just re-add the entire src everytime there's a change // and since re-watching files is a noop, I just re-add the entire src everytime there's a change
if err := addAll(watcher); err != nil { if err := addAll(watcher, config); err != nil {
fmt.Println("error:", err) fmt.Println("error:", err)
return return
} }
if err := rebuild(); err != nil { if err := rebuild(config); err != nil {
fmt.Println("error:", err) fmt.Println("error:", err)
return return
} }
@ -115,19 +121,19 @@ func setupWatcher() (*fsnotify.Watcher, error) {
} }
}() }()
err = addAll(watcher) err = addAll(watcher, config)
return watcher, err return watcher, err
} }
// Add the layouts and all source directories to the given watcher // Add the layouts and all source directories to the given watcher
func addAll(watcher *fsnotify.Watcher) error { func addAll(watcher *fsnotify.Watcher, config *config.Config) error {
err := watcher.Add(LAYOUTS_DIR) err := watcher.Add(config.LayoutsDir)
err = watcher.Add(DATA_DIR) err = watcher.Add(config.DataDir)
err = watcher.Add(INCLUDES_DIR) err = watcher.Add(config.IncludesDir)
// fsnotify watches all files within a dir, but non recursively // fsnotify watches all files within a dir, but non recursively
// this walks through the src dir and adds watches for each found directory // this walks through the src dir and adds watches for each found directory
filepath.WalkDir(SRC_DIR, func(path string, entry fs.DirEntry, err error) error { filepath.WalkDir(config.SrcDir, func(path string, entry fs.DirEntry, err error) error {
if entry.IsDir() { if entry.IsDir() {
watcher.Add(path) watcher.Add(path)
} }

116
config/config.go Normal file
View file

@ -0,0 +1,116 @@
package config
import (
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"gopkg.in/yaml.v2"
)
// The properties that are depended upon in the source code are declared explicitly in the config struct.
// The constructors will set default values for most.
// Depending on the command, different defaults will be used (serve is assumed to be a "dev" environment
// while build is assumed to be prod)
// Some defaults could be overridden by cli flags (eg disable live reload on serve).
// The user can override some of those via config yaml.
// The non declared values found in config yaml will just be passed as site.config values
type Config struct {
RootDir string
SrcDir string
TargetDir string
LayoutsDir string
IncludesDir string
DataDir string
SiteUrl string
SlugFormat string
Minify bool
LiveReload bool
LinkStatic bool
ServerHost string
ServerPort int
pageDefaults map[string]interface{}
// the user provided overrides, as found in config.yml
// these will passed as found as template context
overrides map[string]interface{}
}
func Load(rootDir string) (*Config, error) {
// TODO allow to disable minify
config := &Config{
RootDir: rootDir,
SrcDir: filepath.Join(rootDir, "src"),
TargetDir: filepath.Join(rootDir, "target"),
LayoutsDir: filepath.Join(rootDir, "layouts"),
IncludesDir: filepath.Join(rootDir, "includes"),
DataDir: filepath.Join(rootDir, "data"),
SlugFormat: ":title",
Minify: true,
LiveReload: false,
LinkStatic: false,
pageDefaults: map[string]interface{}{},
}
// load overrides from config.yml
configPath := filepath.Join(rootDir, "config.yml")
yamlContent, err := os.ReadFile(configPath)
if errors.Is(err, os.ErrNotExist) {
// config file is not mandatory
return config, nil
} else if err != nil {
return nil, err
}
err = yaml.Unmarshal(yamlContent, &config.overrides)
if err != nil {
return nil, err
}
// set user-provided overrides of declared config keys
if url, found := config.overrides["url"]; found {
config.SiteUrl = url.(string)
}
if slug, found := config.overrides["url"]; found {
config.SlugFormat = slug.(string)
}
return config, nil
}
func LoadDevServer(rootDir string) (*Config, error) {
// TODO revisit is this Load vs LoadDevServer is the best way to handle both modes
// TODO some of the options need to be overridable: host, port, live reload at least
config, err := Load(rootDir)
if err != nil {
return nil, err
}
// setup serve command specific overrides (these could be eventually tweaked with flags)
config.ServerHost = "localhost"
config.ServerPort = 4001
config.Minify = false
config.LiveReload = true
config.LinkStatic = true
config.SiteUrl = fmt.Sprintf("http://%s:%d", config.ServerHost, config.ServerPort)
return config, nil
}
func (config Config) AsContext() map[string]interface{} {
context := map[string]interface{}{
"url": config.SiteUrl,
}
maps.Copy(context, config.overrides)
return context
}

9
go.mod
View file

@ -3,18 +3,17 @@ module github.com/facundoolano/blorg
go 1.22.0 go 1.22.0
require ( require (
github.com/elliotchance/orderedmap/v2 v2.2.0
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.7.0
github.com/niklasfasching/go-org v1.7.0 github.com/niklasfasching/go-org v1.7.0
github.com/osteele/liquid v1.3.2 github.com/osteele/liquid v1.3.2
github.com/yuin/goldmark v1.7.0 github.com/yuin/goldmark v1.7.0
gopkg.in/yaml.v3 v3.0.1 golang.org/x/net v0.0.0-20201224014010-6772e930b67b
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
) )
require ( require (
github.com/elliotchance/orderedmap/v2 v2.2.0 // indirect
github.com/osteele/tuesday v1.0.3 // indirect github.com/osteele/tuesday v1.0.3 // indirect
github.com/umpc/go-sortedmap v0.0.0-20180422175548-64ab94c482f4 // indirect
golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect
golang.org/x/sys v0.4.0 // indirect golang.org/x/sys v0.4.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )

9
go.sum
View file

@ -12,11 +12,8 @@ github.com/osteele/tuesday v1.0.3 h1:SrCmo6sWwSgnvs1bivmXLvD7Ko9+aJvvkmDjB5G4FTU
github.com/osteele/tuesday v1.0.3/go.mod h1:pREKpE+L03UFuR+hiznj3q7j3qB1rUZ4XfKejwWFF2M= github.com/osteele/tuesday v1.0.3/go.mod h1:pREKpE+L03UFuR+hiznj3q7j3qB1rUZ4XfKejwWFF2M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/umpc/go-sortedmap v0.0.0-20180422175548-64ab94c482f4 h1:qk1XyC6UGfPa51PGmsTQJavyhfMLScqw97pEV3sFClI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/umpc/go-sortedmap v0.0.0-20180422175548-64ab94c482f4/go.mod h1:X6iKjXCleSyo/LZzKZ9zDF/ZB2L9gC36I5gLMf32w3M=
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA= github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw= golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
@ -31,5 +28,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -43,7 +43,11 @@ func run(args []string) error {
newCmd.Parse(os.Args[2:]) newCmd.Parse(os.Args[2:])
return commands.New() return commands.New()
case "serve": case "serve":
return commands.Serve() rootDir := "."
if len(os.Args) > 2 {
rootDir = os.Args[2]
}
return commands.Serve(rootDir)
default: default:
// TODO print usage // TODO print usage
return errors.New("unknown subcommand") return errors.New("unknown subcommand")

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/facundoolano/blorg/config"
"github.com/facundoolano/blorg/templates" "github.com/facundoolano/blorg/templates"
"golang.org/x/net/html" "golang.org/x/net/html"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -19,7 +20,7 @@ import (
const FILE_RW_MODE = 0777 const FILE_RW_MODE = 0777
type Site struct { type Site struct {
config map[string]string // may need to make this interface{} if config gets sophisticated Config config.Config
layouts map[string]templates.Template layouts map[string]templates.Template
posts []map[string]interface{} posts []map[string]interface{}
pages []map[string]interface{} pages []map[string]interface{}
@ -30,34 +31,33 @@ type Site struct {
templates map[string]*templates.Template templates map[string]*templates.Template
} }
func Load(srcDir string, layoutsDir string, dataDir string) (*Site, error) { func Load(config config.Config) (*Site, error) {
// TODO load config from config.yml
site := Site{ site := Site{
layouts: make(map[string]templates.Template), layouts: make(map[string]templates.Template),
templates: make(map[string]*templates.Template), templates: make(map[string]*templates.Template),
config: make(map[string]string), Config: config,
tags: make(map[string][]map[string]interface{}), tags: make(map[string][]map[string]interface{}),
data: make(map[string]interface{}), data: make(map[string]interface{}),
templateEngine: templates.NewEngine(), templateEngine: templates.NewEngine(config.SiteUrl),
} }
if err := site.loadDataFiles(dataDir); err != nil { if err := site.loadDataFiles(); err != nil {
return nil, err return nil, err
} }
if err := site.loadLayouts(layoutsDir); err != nil { if err := site.loadLayouts(); err != nil {
return nil, err return nil, err
} }
if err := site.loadTemplates(srcDir); err != nil { if err := site.loadTemplates(); err != nil {
return nil, err return nil, err
} }
return &site, nil return &site, nil
} }
func (site *Site) loadLayouts(layoutsDir string) error { func (site *Site) loadLayouts() error {
files, err := os.ReadDir(layoutsDir) files, err := os.ReadDir(site.Config.LayoutsDir)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil return nil
@ -68,7 +68,7 @@ func (site *Site) loadLayouts(layoutsDir string) error {
for _, entry := range files { for _, entry := range files {
if !entry.IsDir() { if !entry.IsDir() {
filename := entry.Name() filename := entry.Name()
path := filepath.Join(layoutsDir, filename) path := filepath.Join(site.Config.LayoutsDir, filename)
templ, err := templates.Parse(site.templateEngine, path) templ, err := templates.Parse(site.templateEngine, path)
if err != nil { if err != nil {
return err return err
@ -82,8 +82,8 @@ func (site *Site) loadLayouts(layoutsDir string) error {
return nil return nil
} }
func (site *Site) loadDataFiles(dataDir string) error { func (site *Site) loadDataFiles() error {
files, err := os.ReadDir(dataDir) files, err := os.ReadDir(site.Config.DataDir)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil return nil
@ -94,7 +94,7 @@ func (site *Site) loadDataFiles(dataDir string) error {
for _, entry := range files { for _, entry := range files {
if !entry.IsDir() { if !entry.IsDir() {
filename := entry.Name() filename := entry.Name()
path := filepath.Join(dataDir, filename) path := filepath.Join(site.Config.DataDir, filename)
yamlContent, err := os.ReadFile(path) yamlContent, err := os.ReadFile(path)
if err != nil { if err != nil {
@ -114,15 +114,15 @@ func (site *Site) loadDataFiles(dataDir string) error {
return nil return nil
} }
func (site *Site) loadTemplates(srcDir string) error { func (site *Site) loadTemplates() error {
_, err := os.ReadDir(srcDir) _, err := os.ReadDir(site.Config.SrcDir)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("missing %s directory", srcDir) return fmt.Errorf("missing %s directory", site.Config.SrcDir)
} else if err != nil { } else if err != nil {
return fmt.Errorf("couldn't read %s", srcDir) return fmt.Errorf("couldn't read %s", site.Config.SrcDir)
} }
err = filepath.WalkDir(srcDir, func(path string, entry fs.DirEntry, err error) error { err = filepath.WalkDir(site.Config.SrcDir, func(path string, entry fs.DirEntry, err error) error {
if !entry.IsDir() { if !entry.IsDir() {
templ, err := templates.Parse(site.templateEngine, path) templ, err := templates.Parse(site.templateEngine, path)
// if something fails or this is not a template, skip // if something fails or this is not a template, skip
@ -131,10 +131,10 @@ func (site *Site) loadTemplates(srcDir string) error {
} }
// set site related (?) metadata. Not sure if this should go elsewhere // set site related (?) metadata. Not sure if this should go elsewhere
relPath, _ := filepath.Rel(srcDir, path) relPath, _ := filepath.Rel(site.Config.SrcDir, path)
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath)) + templ.Ext() relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath)) + templ.Ext()
templ.Metadata["path"] = relPath templ.Metadata["path"] = relPath
templ.Metadata["url"] = "/" + strings.TrimSuffix(relPath, ".html") templ.Metadata["url"] = "/" + strings.TrimSuffix(strings.TrimSuffix(relPath, "index.html"), ".html")
templ.Metadata["dir"] = "/" + filepath.Dir(relPath) templ.Metadata["dir"] = "/" + filepath.Dir(relPath)
// posts are templates that can be chronologically sorted --that have a date. // posts are templates that can be chronologically sorted --that have a date.
@ -182,19 +182,18 @@ func (site *Site) loadTemplates(srcDir string) error {
return nil return nil
} }
// TODO consider making minify and reload site.config values func (site *Site) Build() error {
func (site *Site) Build(srcDir string, targetDir string, minify bool, htmlReload bool) error {
// clear previous target contents // clear previous target contents
os.RemoveAll(targetDir) os.RemoveAll(site.Config.TargetDir)
os.Mkdir(srcDir, FILE_RW_MODE) os.Mkdir(site.Config.SrcDir, FILE_RW_MODE)
// walk the source directory, creating directories and files at the target dir // walk the source directory, creating directories and files at the target dir
return filepath.WalkDir(srcDir, func(path string, entry fs.DirEntry, err error) error { return filepath.WalkDir(site.Config.SrcDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
subpath, _ := filepath.Rel(srcDir, path) subpath, _ := filepath.Rel(site.Config.SrcDir, path)
targetPath := filepath.Join(targetDir, subpath) targetPath := filepath.Join(site.Config.TargetDir, subpath)
// if it's a directory, just create the same at the target // if it's a directory, just create the same at the target
if entry.IsDir() { if entry.IsDir() {
@ -202,7 +201,22 @@ func (site *Site) Build(srcDir string, targetDir string, minify bool, htmlReload
} }
var contentReader io.Reader var contentReader io.Reader
if templ, found := site.templates[path]; found { templ, found := site.templates[path]
if !found {
// if no template found at location, treat the file as static write its contents to target
if site.Config.LinkStatic {
// dev optimization: link static files instead of copying them
abs, _ := filepath.Abs(path)
return os.Symlink(abs, targetPath)
}
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
contentReader = srcFile
} else {
content, err := site.render(templ) content, err := site.render(templ)
if err != nil { if err != nil {
return err return err
@ -210,25 +224,16 @@ func (site *Site) Build(srcDir string, targetDir string, minify bool, htmlReload
targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + templ.Ext() targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + templ.Ext()
contentReader = bytes.NewReader(content) contentReader = bytes.NewReader(content)
} else {
// if no template found at location, treat the file as static
// write its contents to target
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
contentReader = srcFile
} }
targetExt := filepath.Ext(targetPath) targetExt := filepath.Ext(targetPath)
// if live reload is enabled, inject the reload snippet to html files // if live reload is enabled, inject the reload snippet to html files
if htmlReload && targetExt == ".html" { if site.Config.LiveReload && targetExt == ".html" {
// TODO inject live reload snippet // TODO inject live reload snippet
} }
// if enabled, minify web files // if enabled, minify web files
if minify && (targetExt == ".html" || targetExt == ".css" || targetExt == ".js") { if site.Config.Minify && (targetExt == ".html" || targetExt == ".css" || targetExt == ".js") {
// TODO minify output // TODO minify output
} }
@ -241,7 +246,7 @@ func (site *Site) Build(srcDir string, targetDir string, minify bool, htmlReload
func (site Site) render(templ *templates.Template) ([]byte, error) { func (site Site) render(templ *templates.Template) ([]byte, error) {
ctx := map[string]interface{}{ ctx := map[string]interface{}{
"site": map[string]interface{}{ "site": map[string]interface{}{
"config": site.config, "config": site.Config.AsContext(),
"posts": site.posts, "posts": site.posts,
"tags": site.tags, "tags": site.tags,
"pages": site.pages, "pages": site.pages,

View file

@ -1,14 +1,15 @@
package site package site
import ( import (
"github.com/facundoolano/blorg/config"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
func TestLoadAndRenderTemplates(t *testing.T) { func TestLoadAndRenderTemplates(t *testing.T) {
root, layouts, src, data := newProject() config := newProject()
defer os.RemoveAll(root) defer os.RemoveAll(config.RootDir)
// add two layouts // add two layouts
content := `--- content := `---
@ -19,7 +20,7 @@ func TestLoadAndRenderTemplates(t *testing.T) {
{{content}} {{content}}
</body> </body>
</html>` </html>`
file := newFile(layouts, "base.html", content) file := newFile(config.LayoutsDir, "base.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
content = `--- content = `---
@ -28,7 +29,7 @@ layout: base
<h1>{{page.title}}</h1> <h1>{{page.title}}</h1>
<h2>{{page.subtitle}}</h2> <h2>{{page.subtitle}}</h2>
{{content}}` {{content}}`
file = newFile(layouts, "post.html", content) file = newFile(config.LayoutsDir, "post.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
// add two posts // add two posts
@ -39,7 +40,7 @@ subtitle: my first post
date: 2024-01-01 date: 2024-01-01
--- ---
<p>Hello world!</p>` <p>Hello world!</p>`
file = newFile(src, "hello.html", content) file = newFile(config.SrcDir, "hello.html", content)
helloPath := file.Name() helloPath := file.Name()
defer os.Remove(helloPath) defer os.Remove(helloPath)
@ -50,7 +51,7 @@ subtitle: my last post
date: 2024-02-01 date: 2024-02-01
--- ---
<p>goodbye world!</p>` <p>goodbye world!</p>`
file = newFile(src, "goodbye.html", content) file = newFile(config.SrcDir, "goodbye.html", content)
goodbyePath := file.Name() goodbyePath := file.Name()
defer os.Remove(goodbyePath) defer os.Remove(goodbyePath)
@ -60,15 +61,15 @@ layout: base
title: about title: about
--- ---
<p>about this site</p>` <p>about this site</p>`
file = newFile(src, "about.html", content) file = newFile(config.SrcDir, "about.html", content)
aboutPath := file.Name() aboutPath := file.Name()
defer os.Remove(aboutPath) defer os.Remove(aboutPath)
// add a static file (no front matter) // add a static file (no front matter)
content = `go away!` content = `go away!`
file = newFile(src, "robots.txt", content) file = newFile(config.SrcDir, "robots.txt", content)
site, err := Load(src, layouts, data) site, err := Load(*config)
assertEqual(t, err, nil) assertEqual(t, err, nil)
@ -115,15 +116,15 @@ title: about
} }
func TestRenderArchive(t *testing.T) { func TestRenderArchive(t *testing.T) {
root, layouts, src, data := newProject() config := newProject()
defer os.RemoveAll(root) defer os.RemoveAll(config.RootDir)
content := `--- content := `---
title: hello world! title: hello world!
date: 2024-01-01 date: 2024-01-01
--- ---
<p>Hello world!</p>` <p>Hello world!</p>`
file := newFile(src, "hello.html", content) file := newFile(config.SrcDir, "hello.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
content = `--- content = `---
@ -131,7 +132,7 @@ title: goodbye!
date: 2024-02-01 date: 2024-02-01
--- ---
<p>goodbye world!</p>` <p>goodbye world!</p>`
file = newFile(src, "goodbye.html", content) file = newFile(config.SrcDir, "goodbye.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
content = `--- content = `---
@ -139,7 +140,7 @@ title: an oldie!
date: 2023-01-01 date: 2023-01-01
--- ---
<p>oldie</p>` <p>oldie</p>`
file = newFile(src, "an-oldie.html", content) file = newFile(config.SrcDir, "an-oldie.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
// add a page (no date) // add a page (no date)
@ -149,10 +150,10 @@ date: 2023-01-01
<li>{{ post.date | date: "%Y-%m-%d" }} <a href="{{ post.url }}">{{post.title}}</a></li>{%endfor%} <li>{{ post.date | date: "%Y-%m-%d" }} <a href="{{ post.url }}">{{post.title}}</a></li>{%endfor%}
</ul>` </ul>`
file = newFile(src, "about.html", content) file = newFile(config.SrcDir, "about.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
site, err := Load(src, layouts, data) site, err := Load(*config)
output, err := site.render(site.templates[file.Name()]) output, err := site.render(site.templates[file.Name()])
assertEqual(t, err, nil) assertEqual(t, err, nil)
assertEqual(t, string(output), `<ul> assertEqual(t, string(output), `<ul>
@ -163,8 +164,8 @@ date: 2023-01-01
} }
func TestRenderTags(t *testing.T) { func TestRenderTags(t *testing.T) {
root, layouts, src, data := newProject() config := newProject()
defer os.RemoveAll(root) defer os.RemoveAll(config.RootDir)
content := `--- content := `---
title: hello world! title: hello world!
@ -172,7 +173,7 @@ date: 2024-01-01
tags: [web, software] tags: [web, software]
--- ---
<p>Hello world!</p>` <p>Hello world!</p>`
file := newFile(src, "hello.html", content) file := newFile(config.SrcDir, "hello.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
content = `--- content = `---
@ -181,7 +182,7 @@ date: 2024-02-01
tags: [web] tags: [web]
--- ---
<p>goodbye world!</p>` <p>goodbye world!</p>`
file = newFile(src, "goodbye.html", content) file = newFile(config.SrcDir, "goodbye.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
content = `--- content = `---
@ -190,7 +191,7 @@ date: 2023-01-01
tags: [software] tags: [software]
--- ---
<p>oldie</p>` <p>oldie</p>`
file = newFile(src, "an-oldie.html", content) file = newFile(config.SrcDir, "an-oldie.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
// add a page (no date) // add a page (no date)
@ -202,10 +203,10 @@ tags: [software]
{% endfor %} {% endfor %}
` `
file = newFile(src, "about.html", content) file = newFile(config.SrcDir, "about.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
site, err := Load(src, layouts, data) site, err := Load(*config)
output, err := site.render(site.templates[file.Name()]) output, err := site.render(site.templates[file.Name()])
assertEqual(t, err, nil) assertEqual(t, err, nil)
assertEqual(t, string(output), `<h1>software</h1> assertEqual(t, string(output), `<h1>software</h1>
@ -222,28 +223,28 @@ hello world!
} }
func TestRenderPagesInDir(t *testing.T) { func TestRenderPagesInDir(t *testing.T) {
root, layouts, src, data := newProject() config := newProject()
defer os.RemoveAll(root) defer os.RemoveAll(config.RootDir)
content := `--- content := `---
title: "1. hello world!" title: "1. hello world!"
--- ---
<p>Hello world!</p>` <p>Hello world!</p>`
file := newFile(src, "01-hello.html", content) file := newFile(config.SrcDir, "01-hello.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
content = `--- content = `---
title: "3. goodbye!" title: "3. goodbye!"
--- ---
<p>goodbye world!</p>` <p>goodbye world!</p>`
file = newFile(src, "03-goodbye.html", content) file = newFile(config.SrcDir, "03-goodbye.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
content = `--- content = `---
title: "2. an oldie!" title: "2. an oldie!"
--- ---
<p>oldie</p>` <p>oldie</p>`
file = newFile(src, "02-an-oldie.html", content) file = newFile(config.SrcDir, "02-an-oldie.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
// add a page (no date) // add a page (no date)
@ -253,10 +254,10 @@ title: "2. an oldie!"
<li><a href="{{ page.url }}">{{page.title}}</a></li>{%endfor%} <li><a href="{{ page.url }}">{{page.title}}</a></li>{%endfor%}
</ul>` </ul>`
file = newFile(src, "index.html", content) file = newFile(config.SrcDir, "index.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
site, err := Load(src, layouts, data) site, err := Load(*config)
output, err := site.render(site.templates[file.Name()]) output, err := site.render(site.templates[file.Name()])
assertEqual(t, err, nil) assertEqual(t, err, nil)
assertEqual(t, string(output), `<ul> assertEqual(t, string(output), `<ul>
@ -271,8 +272,8 @@ func TestRenderArchiveWithExcerpts(t *testing.T) {
} }
func TestRenderDataFile(t *testing.T) { func TestRenderDataFile(t *testing.T) {
root, layouts, src, data := newProject() config := newProject()
defer os.RemoveAll(root) defer os.RemoveAll(config.RootDir)
content := ` content := `
- name: feedi - name: feedi
@ -280,7 +281,7 @@ func TestRenderDataFile(t *testing.T) {
- name: blorg - name: blorg
url: https://github.com/facundoolano/blorg url: https://github.com/facundoolano/blorg
` `
file := newFile(data, "projects.yml", content) file := newFile(config.DataDir, "projects.yml", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
// add a page (no date) // add a page (no date)
@ -290,10 +291,10 @@ func TestRenderDataFile(t *testing.T) {
<li><a href="{{ project.url }}">{{project.name}}</a></li>{%endfor%} <li><a href="{{ project.url }}">{{project.name}}</a></li>{%endfor%}
</ul>` </ul>`
file = newFile(src, "projects.html", content) file = newFile(config.SrcDir, "projects.html", content)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
site, err := Load(src, layouts, data) site, err := Load(*config)
output, err := site.render(site.templates[file.Name()]) output, err := site.render(site.templates[file.Name()])
assertEqual(t, err, nil) assertEqual(t, err, nil)
assertEqual(t, string(output), `<ul> assertEqual(t, string(output), `<ul>
@ -304,7 +305,7 @@ func TestRenderDataFile(t *testing.T) {
// ------ HELPERS -------- // ------ HELPERS --------
func newProject() (string, string, string, string) { func newProject() *config.Config {
projectDir, _ := os.MkdirTemp("", "root") projectDir, _ := os.MkdirTemp("", "root")
layoutsDir := filepath.Join(projectDir, "layouts") layoutsDir := filepath.Join(projectDir, "layouts")
srcDir := filepath.Join(projectDir, "src") srcDir := filepath.Join(projectDir, "src")
@ -313,7 +314,9 @@ func newProject() (string, string, string, string) {
os.Mkdir(srcDir, 0777) os.Mkdir(srcDir, 0777)
os.Mkdir(dataDir, 0777) os.Mkdir(dataDir, 0777)
return projectDir, layoutsDir, srcDir, dataDir config, _ := config.Load(projectDir)
return config
} }
func newFile(dir string, filename string, contents string) *os.File { func newFile(dir string, filename string, contents string) *os.File {

View file

@ -3,6 +3,8 @@ package templates
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"log"
"net/url"
"reflect" "reflect"
"encoding/xml" "encoding/xml"
@ -18,7 +20,7 @@ import (
// a lot of the filters and tags available at jekyll aren't default liquid manually adding them here // a lot of the filters and tags available at jekyll aren't default liquid manually adding them here
// copied from https://github.com/osteele/gojekyll/blob/f1794a874890bfb601cae767a0cce15d672e9058/filters/filters.go // copied from https://github.com/osteele/gojekyll/blob/f1794a874890bfb601cae767a0cce15d672e9058/filters/filters.go
// MIT License: https://github.com/osteele/gojekyll/blob/f1794a874890bfb601cae767a0cce15d672e9058/LICENSE // MIT License: https://github.com/osteele/gojekyll/blob/f1794a874890bfb601cae767a0cce15d672e9058/LICENSE
func loadJekyllFilters(e *liquid.Engine) { func loadJekyllFilters(e *liquid.Engine, siteUrl string) {
e.RegisterFilter("filter", filter) e.RegisterFilter("filter", filter)
e.RegisterFilter("group_by", groupByFilter) e.RegisterFilter("group_by", groupByFilter)
e.RegisterFilter("group_by_exp", groupByExpFilter) e.RegisterFilter("group_by_exp", groupByExpFilter)
@ -32,15 +34,17 @@ func loadJekyllFilters(e *liquid.Engine) {
var buf bytes.Buffer var buf bytes.Buffer
err := goldmark.Convert([]byte(s), &buf) err := goldmark.Convert([]byte(s), &buf)
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
return buf.String() return buf.String()
}) })
e.RegisterFilter("absolute_url", func(s string) string { e.RegisterFilter("absolute_url", func(path string) string {
// FIXME implement after adding a config struct, using the url url, err := url.JoinPath(siteUrl, path)
// return utils.URLJoin(c.AbsoluteURL, c.BaseURL, s) if err != nil {
return s log.Fatal(err)
}
return url
}) })
e.RegisterFilter("date_to_rfc822", func(date time.Time) string { e.RegisterFilter("date_to_rfc822", func(date time.Time) string {

View file

@ -25,9 +25,11 @@ type Template struct {
liquidTemplate liquid.Template liquidTemplate liquid.Template
} }
func NewEngine() *Engine { // Create a new template engine, with custom liquid filters.
// The `siteUrl` is necessary to provide context for the absolute_url filter.
func NewEngine(siteUrl string) *Engine {
e := liquid.NewEngine() e := liquid.NewEngine()
loadJekyllFilters(e) loadJekyllFilters(e, siteUrl)
return e return e
} }

View file

@ -18,7 +18,7 @@ tags: ["software", "web"]
file := newFile("test*.html", input) file := newFile("test*.html", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
templ, err := Parse(NewEngine(), file.Name()) templ, err := Parse(NewEngine("https://olano.dev"), file.Name())
assertEqual(t, err, nil) assertEqual(t, err, nil)
assertEqual(t, templ.Metadata["title"], "my new post") assertEqual(t, templ.Metadata["title"], "my new post")
@ -42,7 +42,7 @@ subtitle: a blog post
file := newFile("test*.html", input) file := newFile("test*.html", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
_, err := Parse(NewEngine(), file.Name()) _, err := Parse(NewEngine("https://olano.dev"), file.Name())
assertEqual(t, err, nil) assertEqual(t, err, nil)
// not first thing in file, leaving as is // not first thing in file, leaving as is
@ -57,7 +57,7 @@ tags: ["software", "web"]
file = newFile("test*.html", input) file = newFile("test*.html", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
_, err = Parse(NewEngine(), file.Name()) _, err = Parse(NewEngine("https://olano.dev"), file.Name())
assertEqual(t, err, nil) assertEqual(t, err, nil)
} }
@ -69,7 +69,7 @@ tags: ["software", "web"]
` `
file := newFile("test*.html", input) file := newFile("test*.html", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
_, err := Parse(NewEngine(), file.Name()) _, err := Parse(NewEngine("https://olano.dev"), file.Name())
assertEqual(t, err.Error(), "front matter not closed") assertEqual(t, err.Error(), "front matter not closed")
@ -81,7 +81,7 @@ tags: ["software", "web"]
file = newFile("test*.html", input) file = newFile("test*.html", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
_, err = Parse(NewEngine(), file.Name()) _, err = Parse(NewEngine("https://olano.dev"), file.Name())
assert(t, strings.Contains(err.Error(), "invalid yaml")) assert(t, strings.Contains(err.Error(), "invalid yaml"))
} }
@ -100,7 +100,7 @@ tags: ["software", "web"]
file := newFile("test*.html", input) file := newFile("test*.html", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
templ, err := Parse(NewEngine(), file.Name()) templ, err := Parse(NewEngine("https://olano.dev"), file.Name())
assertEqual(t, err, nil) assertEqual(t, err, nil)
ctx := map[string]interface{}{"page": templ.Metadata} ctx := map[string]interface{}{"page": templ.Metadata}
content, err := templ.Render(ctx) content, err := templ.Render(ctx)
@ -130,7 +130,7 @@ tags: ["software", "web"]
file := newFile("test*.org", input) file := newFile("test*.org", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
templ, err := Parse(NewEngine(), file.Name()) templ, err := Parse(NewEngine("https://olano.dev"), file.Name())
assertEqual(t, err, nil) assertEqual(t, err, nil)
content, err := templ.Render(nil) content, err := templ.Render(nil)
@ -172,7 +172,7 @@ tags: ["software", "web"]
file := newFile("test*.md", input) file := newFile("test*.md", input)
defer os.Remove(file.Name()) defer os.Remove(file.Name())
templ, err := Parse(NewEngine(), file.Name()) templ, err := Parse(NewEngine("https://olano.dev"), file.Name())
assertEqual(t, err, nil) assertEqual(t, err, nil)
content, err := templ.Render(nil) content, err := templ.Render(nil)