diff --git a/commands/commands.go b/commands/commands.go index 3678e2b..535700d 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -1,17 +1,20 @@ package commands import ( - "errors" "fmt" + "github.com/facundoolano/blorg/templates" "io" "io/fs" "os" "path/filepath" "strings" - - "github.com/facundoolano/blorg/templates" ) +const SRC_DIR = "src" +const TARGET_DIR = "target" +const LAYOUT_DIR = "layouts" +const FILE_RW_MODE = 0777 + func Init() error { // get working directory // default to . @@ -24,45 +27,109 @@ func Init() error { // Read the files in src/ render them and copy the result to target/ // FIXME pass src and target by arg func Build() error { - const FILE_MODE = 0777 - - // fail if no src dir - _, err := os.ReadDir("src") + _, err := os.ReadDir(SRC_DIR) if os.IsNotExist(err) { - return errors.New("missing src/ directory") + return fmt.Errorf("missing %s directory", SRC_DIR) } else if err != nil { - return errors.New("couldn't read src") + return fmt.Errorf("couldn't read %s", SRC_DIR) } - // clear previous target contents - os.RemoveAll("target") - os.Mkdir("target", FILE_MODE) + site := Site{ + layouts: make(map[string]templates.Template), + } - // render each source file and copy it over to target - err = filepath.WalkDir("src", func(path string, entry fs.DirEntry, err error) error { - subpath, _ := filepath.Rel("src", path) - targetPath := filepath.Join("target", subpath) + // FIXME these sound like they should be site methods too + PHASES := []func(*Site) error{ + loadConfig, + loadLayouts, + loadTemplates, + writeTarget, + } + for _, phaseFun := range PHASES { + if err := phaseFun(&site); err != nil { + return err + } + } - if entry.IsDir() { - os.MkdirAll(targetPath, FILE_MODE) - } else { - template, err := templates.Parse(path) + return err +} + +func loadConfig(site *Site) error { + // context["config"] + return nil +} + +func loadLayouts(site *Site) error { + files, err := os.ReadDir(LAYOUT_DIR) + if os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + + for _, entry := range files { + if !entry.IsDir() { + filename := entry.Name() + path := filepath.Join(LAYOUT_DIR, filename) + templ, err := templates.Parse(path) if err != nil { return err } - if template != nil { - // if a template was found at source, render it - targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + template.Ext() + layout_name := strings.TrimSuffix(filename, filepath.Ext(filename)) + site.layouts[layout_name] = *templ + } + } - content, err := template.Render() + return nil +} + +func loadTemplates(site *Site) error { + return filepath.WalkDir(SRC_DIR, func(path string, entry fs.DirEntry, err error) error { + if !entry.IsDir() { + templ, err := templates.Parse(path) + if err != nil { + return err + } + + switch templ.Type { + case templates.POST: + site.posts = append(site.posts, *templ) + case templates.PAGE: + site.pages = append(site.pages, *templ) + } + // TODO add tags + } + return nil + }) +} + +func writeTarget(site *Site) error { + // clear previous target contents + os.RemoveAll(TARGET_DIR) + os.Mkdir(TARGET_DIR, FILE_RW_MODE) + + // walk the source directory, creating directories and files at the target dir + templIndex := site.templateIndex() + return filepath.WalkDir(SRC_DIR, func(path string, entry fs.DirEntry, err error) error { + subpath, _ := filepath.Rel(SRC_DIR, path) + targetPath := filepath.Join(TARGET_DIR, subpath) + + if entry.IsDir() { + os.MkdirAll(targetPath, FILE_RW_MODE) + } else { + + if templ, ok := templIndex[path]; ok { + // if a template was found at source, render it + content, err := site.render(templ) if err != nil { return err } // write the file contents over to target at the same location + targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + templ.Ext() fmt.Println("writing ", targetPath) - return os.WriteFile(targetPath, content, FILE_MODE) + return os.WriteFile(targetPath, []byte(content), FILE_RW_MODE) } else { // if a non template was found, copy file as is fmt.Println("writing ", targetPath) @@ -72,8 +139,6 @@ func Build() error { return nil }) - - return err } func copyFile(source string, target string) error { diff --git a/commands/site.go b/commands/site.go new file mode 100644 index 0000000..6352911 --- /dev/null +++ b/commands/site.go @@ -0,0 +1,64 @@ +package commands + +import ( + "fmt" + + "github.com/facundoolano/blorg/templates" +) + +// TODO review build and other commands and think what can be brought over here. +// e.g. SRC and TARGET dir knowledge +type Site struct { + config map[string]string // may need to make this interface{} if config gets sophisticated + layouts map[string]templates.Template + posts []templates.Template + pages []templates.Template + tags map[string]*templates.Template + + renderCache map[string]string +} + +func (site Site) render(templ *templates.Template) (string, error) { + ctx := site.contextFor(templ) + content, err := templ.Render(ctx) + if err != nil { + return "", err + } + + // recursively render parent layouts + layout := templ.Metadata["layout"] + for layout != nil && err == nil { + if layout_templ, ok := site.layouts[layout.(string)]; ok { + ctx["content"] = content + content, err = layout_templ.Render(ctx) + layout = layout_templ.Metadata["layout"] + } else { + return "", fmt.Errorf("layout '%s' not found", layout) + } + } + + return content, err +} + +func (site Site) templateIndex() map[string]*templates.Template { + templIndex := make(map[string]*templates.Template) + for _, templ := range append(site.posts, site.pages...) { + templIndex[templ.SrcPath] = &templ + } + return templIndex +} + +func (site Site) contextFor(templ *templates.Template) map[string]interface{} { + bindings := map[string]interface{}{ + "config": site.config, + "posts": site.posts, + "tags": site.tags, + } + if templ.Type == templates.LAYOUT { + bindings["layout"] = templ.Metadata + } else { + // assuming that if it's not a layout then it must be a page + bindings["page"] = templ.Metadata + } + return bindings +} diff --git a/templates/templates.go b/templates/templates.go index b8f0dc3..3440e78 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -3,7 +3,6 @@ package templates import ( "bufio" - "bytes" "errors" "fmt" "os" @@ -17,11 +16,35 @@ import ( const FM_SEPARATOR = "---" +type Type string + +const ( + // a file that doesn't have a front matter header, and thus is not renderable. + STATIC Type = "static" + + // Templates in the root /layouts/ can be used to wrap around other template's content + // by setting the `layout` front matter field. + LAYOUT Type = "layout" + + // A template that has a date, and thus can be ordered chronologically in a directory. + // They can thus be arranged in archives, feeds, etc. + // Posts are also assumed to have a title and can be excerpted. + POST Type = "post" + + // The rest of the templates: no layout and no post + PAGE Type = "page" +) + type Template struct { - srcPath string + Type Type + SrcPath string Metadata map[string]interface{} } +// TODO think about knowledge boundaries +// should this know to tell if its a layout based on srcPath conventions? +// should it be able to detect its own type? does it still make sense to track a template type, +// separate from the site? func Parse(path string) (*Template, error) { file, err := os.Open(path) if err != nil { @@ -35,7 +58,7 @@ func Parse(path string) (*Template, error) { // if the file doesn't start with a front matter delimiter, it's not a template if strings.TrimSpace(line) != FM_SEPARATOR { - return nil, nil + return &Template{Type: STATIC}, nil } // read and parse the yaml from the front matter @@ -61,19 +84,30 @@ func Parse(path string) (*Template, error) { } } - return &Template{srcPath: path, Metadata: metadata}, nil + templ := Template{SrcPath: path, Metadata: metadata} + + // FIXME this also should check that it's in the root folder + if strings.HasSuffix(filepath.Dir(templ.SrcPath), "layouts") { + templ.Type = LAYOUT + } else if _, ok := metadata["date"]; ok { + templ.Type = POST + } else { + templ.Type = PAGE + } + + return &templ, nil } func (templ Template) Ext() string { - ext := filepath.Ext(templ.srcPath) + ext := filepath.Ext(templ.SrcPath) if ext == ".org" { ext = ".html" } return ext } -func (templ Template) Render() ([]byte, error) { - file, _ := os.Open(templ.srcPath) +func (templ Template) Render(context map[string]interface{}) (string, error) { + file, _ := os.Open(templ.SrcPath) defer file.Close() scanner := bufio.NewScanner(file) @@ -86,31 +120,18 @@ func (templ Template) Render() ([]byte, error) { } // now read the proper template contents to memory - var contents []byte + contents := "" for scanner.Scan() { - contents = append(contents, scanner.Text()+"\n"...) + contents += scanner.Text() + "\n" } - if strings.HasSuffix(templ.srcPath, ".org") { + if strings.HasSuffix(templ.SrcPath, ".org") { // if it's an org file, convert to html - doc := org.New().Parse(bytes.NewReader(contents), templ.srcPath) - html, err := doc.Write(org.NewHTMLWriter()) - contents = []byte(html) - if err != nil { - return nil, err - } - - } else { - // for other file types, assume a liquid template - engine := liquid.NewEngine() - out, err := engine.ParseAndRenderString(string(contents), templ.Metadata) - if err != nil { - return nil, err - } - contents = []byte(out) + doc := org.New().Parse(strings.NewReader(contents), templ.SrcPath) + return doc.Write(org.NewHTMLWriter()) } - // TODO: if layout in metadata, pass the result to the rendered parent - - return contents, nil + // for other file types, assume a liquid template + engine := liquid.NewEngine() + return engine.ParseAndRenderString(contents, context) } diff --git a/templates/templates_test.go b/templates/templates_test.go index 6f2c15e..77fc525 100644 --- a/templates/templates_test.go +++ b/templates/templates_test.go @@ -2,6 +2,7 @@ package templates import ( "os" + "path/filepath" "strings" "testing" ) @@ -21,13 +22,14 @@ tags: ["software", "web"] templ, err := Parse(file.Name()) assertEqual(t, err, nil) + assertEqual(t, templ.Type, PAGE) assertEqual(t, templ.Ext(), ".html") assertEqual(t, templ.Metadata["title"], "my new post") assertEqual(t, templ.Metadata["subtitle"], "a blog post") assertEqual(t, templ.Metadata["tags"].([]interface{})[0], "software") assertEqual(t, templ.Metadata["tags"].([]interface{})[1], "web") - content, err := templ.Render() + content, err := templ.Render(nil) assertEqual(t, err, nil) assertEqual(t, string(content), "

Hello World!

\n") } @@ -45,7 +47,7 @@ subtitle: a blog post templ, err := Parse(file.Name()) assertEqual(t, err, nil) - assertEqual(t, templ, (*Template)(nil)) + assertEqual(t, templ.Type, STATIC) // not first thing in file, leaving as is input = `#+OPTIONS: toc:nil num:nil @@ -61,7 +63,7 @@ tags: ["software", "web"] templ, err = Parse(file.Name()) assertEqual(t, err, nil) - assertEqual(t, templ, (*Template)(nil)) + assertEqual(t, templ.Type, STATIC) } func TestInvalidFrontMatter(t *testing.T) { @@ -94,9 +96,9 @@ title: my new post subtitle: a blog post tags: ["software", "web"] --- -

{{ title }}

-

{{ subtitle }}

-