Add a templates package and struct

Squashed commit of the following:

commit 7c5b6bf95b14e402b68b141a7d60ccb1468f12b3
Author: facundoolano <facundo.olano@gmail.com>
Date:   Sun Feb 11 13:15:08 2024 -0300

    restore other tests

commit 5cf5c43856fc1a9e8f23dc74b81607ab7387f4c3
Author: facundoolano <facundo.olano@gmail.com>
Date:   Sat Feb 10 23:13:31 2024 -0300

    restore a test

commit acca0936a42b8b915c25f96c6b435887d7235c23
Author: facundoolano <facundo.olano@gmail.com>
Date:   Sat Feb 10 22:52:41 2024 -0300

    fix a bunch of bugs

commit 6f8074402338194ebebaaf929a1d85fdbf0d5e22
Author: facundoolano <facundo.olano@gmail.com>
Date:   Sat Feb 10 22:00:43 2024 -0300

    implement methods

commit 5cfeb1ea8600317d8849c6dca63a009237e033af
Author: facundoolano <facundo.olano@gmail.com>
Date:   Sat Feb 10 20:16:10 2024 -0300

    add template package and struct

commit 7a7b79e006ff6629cbf9445927e84e9c1600667b
Author: facundoolano <facundo.olano@gmail.com>
Date:   Sat Feb 10 20:08:43 2024 -0300

    stub template interface
This commit is contained in:
facundoolano 2024-02-11 13:16:10 -03:00
parent 9650d0b6e9
commit 73bbaa2c50
6 changed files with 277 additions and 208 deletions

View file

@ -3,9 +3,13 @@ package commands
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/facundoolano/blorg/templates"
)
func Init() error {
@ -36,24 +40,34 @@ func Build() error {
// 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)
if entry.IsDir() {
subpath, _ := filepath.Rel("src", path)
targetSubpath := filepath.Join("target", subpath)
os.MkdirAll(targetSubpath, FILE_MODE)
os.MkdirAll(targetPath, FILE_MODE)
} else {
// FIXME what if non text file?
data, targetPath, err := render(path)
template, err := templates.Parse(path)
if err != nil {
return fmt.Errorf("failed to render %s", path)
return err
}
// write the file contents over to target at the same location
err = os.WriteFile(targetPath, []byte(data), FILE_MODE)
if err != nil {
return fmt.Errorf("failed to load %s", targetPath)
if template != nil {
// if a template was found at source, render it
targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + template.Ext()
content, err := template.Render()
if err != nil {
return err
}
// write the file contents over to target at the same location
fmt.Println("writing ", targetPath)
return os.WriteFile(targetPath, content, FILE_MODE)
} else {
// if a non template was found, copy file as is
fmt.Println("writing ", targetPath)
return copyFile(path, targetPath)
}
fmt.Printf("wrote %v\n", targetPath)
}
return nil
@ -62,6 +76,28 @@ func Build() error {
return err
}
func copyFile(source string, target string) error {
// does this need to be so verbose?
srcFile, err := os.Open(source)
if err != nil {
return err
}
defer srcFile.Close()
targetFile, _ := os.Create(target)
if err != nil {
return err
}
defer targetFile.Close()
_, err = io.Copy(targetFile, srcFile)
if err != nil {
return err
}
return targetFile.Sync()
}
func New() error {
// prompt for title
// slugify

View file

@ -1,102 +0,0 @@
// TODO consider making this another package
package commands
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/niklasfasching/go-org/org"
"gopkg.in/yaml.v3"
)
// TODO move elsewhere?
// TODO doc
func render(sourcePath string) (string, string, error) {
// FIXME remove src target knowledge
subpath, _ := filepath.Rel("src", sourcePath)
targetPath := filepath.Join("target", subpath)
isOrgFile := filepath.Ext(sourcePath) == ".org"
if isOrgFile {
targetPath = strings.TrimSuffix(targetPath, "org") + "html"
}
file, err := os.Open(sourcePath)
if err != nil {
return "", "", err
}
defer file.Close()
fileContent, _, err := extractFrontMatter(file)
if err != nil {
return "", "", (fmt.Errorf("error in %s: %s", sourcePath, err))
}
var html string
// FIXME this should be renamed to .html
// (in general, the render process should be able to instruct a differnt target path)
if isOrgFile {
doc := org.New().Parse(bytes.NewReader(fileContent), sourcePath)
html, err = doc.Write(org.NewHTMLWriter())
if err != nil {
return "", "", err
}
} else {
// TODO render liquid template
html = string(fileContent)
}
// TODO if yaml contains layout, pass to parent
// TODO minify
return html, targetPath, nil
}
func extractFrontMatter(file io.Reader) ([]byte, map[string]interface{}, error) {
const FM_SEPARATOR = "---"
var outContent, yamlContent []byte
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// if line starts front matter, write lines to yaml until front matter is closed
if strings.TrimSpace(line) == FM_SEPARATOR {
closed := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == FM_SEPARATOR {
closed = true
break
}
yamlContent = append(yamlContent, []byte(line+"\n")...)
}
if !closed {
return nil, nil, errors.New("front matter not closed")
}
} else {
// non front matter/yaml content goes to the output slice
outContent = append(outContent, []byte(line+"\n")...)
}
}
// drop the extraneous last line break
outContent = bytes.TrimRight(outContent, "\n")
var frontMatter map[string]interface{}
if len(yamlContent) != 0 {
err := yaml.Unmarshal([]byte(yamlContent), &frontMatter)
if err != nil {
return nil, nil, fmt.Errorf("invalid yaml: %s", err)
}
}
return outContent, frontMatter, nil
}

View file

@ -1,89 +0,0 @@
package commands
import (
"strings"
"testing"
)
func TestExtractFrontMatter(t *testing.T) {
input := `---
title: my new post
subtitle: a blog post
tags: ["software", "web"]
---
<p>Hello World!</p>`
outContent, yaml, err := extractFrontMatter(strings.NewReader(input))
assertEqual(t, err, nil)
assertEqual(t, string(outContent), "<p>Hello World!</p>")
assertEqual(t, yaml["title"], "my new post")
assertEqual(t, yaml["subtitle"], "a blog post")
assertEqual(t, yaml["tags"].([]interface{})[0], "software")
assertEqual(t, yaml["tags"].([]interface{})[1], "web")
}
func TestNonFrontMatterDelimiter(t *testing.T) {
// not identified as front matter, leaving file as is
input := `+++
title: my new post
subtitle: a blog post
+++
<p>Hello World!</p>`
out, yaml, err := extractFrontMatter(strings.NewReader(input))
assertEqual(t, string(out), input)
assertEqual(t, err, nil)
assertEqual(t, len(yaml), 0)
// not first thing in file, leaving as is
input = `#+OPTIONS: toc:nil num:nil
---
title: my new post
subtitle: a blog post
tags: ["software", "web"]
---
<p>Hello World!</p>`
out, yaml, err = extractFrontMatter(strings.NewReader(input))
assertEqual(t, string(out), input)
assertEqual(t, err, nil)
assertEqual(t, len(yaml), 0)
}
func TestInvalidFrontMatterYaml(t *testing.T) {
input := `---
title: my new post
subtitle: a blog post
tags: ["software", "web"]
`
_, _, err := extractFrontMatter(strings.NewReader(input))
assertEqual(t, err.Error(), "front matter not closed")
input = `---
title
tags: ["software", "web"]
---
<p>Hello World!</p>`
_, _, err = extractFrontMatter(strings.NewReader(input))
msg := strings.Split(err.Error(), ":")[0]
assertEqual(t, msg, "invalid yaml")
}
func TestRenderHtml(t *testing.T) {
// TODO
}
func TestRenderOrg(t *testing.T) {
// TODO
}
// TODO move to assert package
func assertEqual(t *testing.T, a interface{}, b interface{}) {
if a != b {
t.Fatalf("%v != %v", a, b)
}
}

View file

@ -32,17 +32,16 @@ func run(args []string) error {
switch os.Args[1] {
case "init":
initCmd.Parse(os.Args[2:])
commands.Init()
return commands.Init()
case "build":
commands.Build()
return commands.Build()
case "new":
newCmd.Parse(os.Args[2:])
commands.New()
return commands.New()
case "serve":
commands.Serve()
return commands.Serve()
default:
// TODO print usage
return errors.New("unknown subcommand")
}
return nil
}

105
templates/templates.go Normal file
View file

@ -0,0 +1,105 @@
// TODO consider making this another package
package templates
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/niklasfasching/go-org/org"
"gopkg.in/yaml.v3"
)
const FM_SEPARATOR = "---"
type Template struct {
srcPath string
Metadata map[string]interface{}
}
func Parse(path string) (*Template, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Scan()
line := scanner.Text()
// 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
}
// read and parse the yaml from the front matter
var yamlContent []byte
closed := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == FM_SEPARATOR {
closed = true
break
}
yamlContent = append(yamlContent, []byte(line+"\n")...)
}
if !closed {
return nil, errors.New("front matter not closed")
}
var metadata map[string]interface{}
if len(yamlContent) != 0 {
err := yaml.Unmarshal([]byte(yamlContent), &metadata)
if err != nil {
return nil, fmt.Errorf("invalid yaml: %s", err)
}
}
return &Template{srcPath: path, Metadata: metadata}, nil
}
func (templ Template) Ext() string {
return filepath.Ext(templ.srcPath)
}
func (templ Template) Render() ([]byte, error) {
file, _ := os.Open(templ.srcPath)
defer file.Close()
scanner := bufio.NewScanner(file)
// first line is the front matter delimiter, Scan to skip
// and keep skipping until the closing delimiter
scanner.Scan()
scanner.Scan()
for scanner.Text() != FM_SEPARATOR {
scanner.Scan()
}
// now read the proper template contents to memory
var contents []byte
for scanner.Scan() {
contents = append(contents, scanner.Text()+"\n"...)
}
if templ.Ext() == ".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 {
// TODO for other file types, assume a liquid template
}
// TODO: if layout in metadata, pass the result to the rendered parent
return contents, nil
}

120
templates/templates_test.go Normal file
View file

@ -0,0 +1,120 @@
package templates
import (
"os"
"strings"
"testing"
)
func TestParseTemplate(t *testing.T) {
input := `---
title: my new post
subtitle: a blog post
tags: ["software", "web"]
---
<p>Hello World!</p>
`
file := newFile("test*.html", input)
defer os.Remove(file.Name())
templ, err := Parse(file.Name())
assertEqual(t, err, nil)
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()
assertEqual(t, err, nil)
assertEqual(t, string(content), "<p>Hello World!</p>\n")
}
func TestNonTemplate(t *testing.T) {
// not identified as front matter, leaving file as is
input := `+++
title: my new post
subtitle: a blog post
+++
<p>Hello World!</p>`
file := newFile("test*.html", input)
defer os.Remove(file.Name())
templ, err := Parse(file.Name())
assertEqual(t, err, nil)
assertEqual(t, templ, (*Template)(nil))
// not first thing in file, leaving as is
input = `#+OPTIONS: toc:nil num:nil
---
title: my new post
subtitle: a blog post
tags: ["software", "web"]
---
<p>Hello World!</p>`
file = newFile("test*.html", input)
defer os.Remove(file.Name())
templ, err = Parse(file.Name())
assertEqual(t, err, nil)
assertEqual(t, templ, (*Template)(nil))
}
func TestInvalidFrontMatter(t *testing.T) {
input := `---
title: my new post
subtitle: a blog post
tags: ["software", "web"]
`
file := newFile("test*.html", input)
defer os.Remove(file.Name())
_, err := Parse(file.Name())
assertEqual(t, err.Error(), "front matter not closed")
input = `---
title
tags: ["software", "web"]
---
<p>Hello World!</p>`
file = newFile("test*.html", input)
defer os.Remove(file.Name())
_, err = Parse(file.Name())
assert(t, strings.Contains(err.Error(), "invalid yaml"))
}
func TestRenderLiquid(t *testing.T) {
// TODO
}
func TestRenderOrg(t *testing.T) {
// TODO
}
// ------ HELPERS --------
func newFile(name string, contents string) *os.File {
file, _ := os.CreateTemp("", name)
file.WriteString(contents)
return file
}
// TODO move to assert package
func assert(t *testing.T, cond bool) {
t.Helper()
if !cond {
t.Fatalf("%v is false", cond)
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}) {
t.Helper()
if a != b {
t.Fatalf("%v != %v", a, b)
}
}