Initial commit
This commit is contained in:
commit
70b8cdb514
7 changed files with 260 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/dist/
|
||||||
|
/recipes/
|
||||||
|
/sandwitchboard
|
5
Makefile
Normal file
5
Makefile
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o sandwitchboard ./...
|
17
README.md
Normal file
17
README.md
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Sandwitch board
|
||||||
|
|
||||||
|
A simple CLI tool to generate a static website from a
|
||||||
|
[cooklang](https://cooklang.org) recipes directory.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Recursively traverse the `recipes` directory.
|
||||||
|
- [ ] Add a default CSS file.
|
||||||
|
- [ ] Improve the steps rendering logic to avoid doing all of it
|
||||||
|
inside a template function.
|
||||||
|
- [ ] Add image support for steps and the recipe.
|
||||||
|
- [ ] Add an `index.html` file that lists all the recipes.
|
||||||
|
- [ ] Embed the default `style.css` file.
|
||||||
|
- [ ] Add command to copy the embedded templates to an external
|
||||||
|
directory.
|
||||||
|
- [ ] Allow to read the templates from an external directory.
|
5
go.mod
Normal file
5
go.mod
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module git.ctrlz.es/sandwitchboard
|
||||||
|
|
||||||
|
go 1.22.3
|
||||||
|
|
||||||
|
require github.com/aquilax/cooklang-go v0.1.7
|
2
go.sum
Normal file
2
go.sum
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
github.com/aquilax/cooklang-go v0.1.7 h1:ynbGKI3o060m9NTlxnc1k7unjGOtJcfbxKWfiIOSqVo=
|
||||||
|
github.com/aquilax/cooklang-go v0.1.7/go.mod h1:1/65y8LN2Nhr6WNVh64ZqggvZmeymQHKavxUONeJmTk=
|
205
sandwitchboard.go
Normal file
205
sandwitchboard.go
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aquilax/cooklang-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
stepPrefix = "<p>"
|
||||||
|
stepSuffix = "</p>"
|
||||||
|
ingredientPrefix = "<span class=\"ingredient\">"
|
||||||
|
ingredientSuffix = "</span>"
|
||||||
|
cookwarePrefix = "<span class=\"cookware\">"
|
||||||
|
cookwareSuffix = "</span>"
|
||||||
|
timerPrefix = "<span class=\"timer\">"
|
||||||
|
timerSuffix = "</span>"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
var embedTmpl embed.FS
|
||||||
|
var funcMap = template.FuncMap{
|
||||||
|
"recipeSteps": func(r cooklang.Recipe) template.HTML {
|
||||||
|
var str = ""
|
||||||
|
for _, step := range r.Steps {
|
||||||
|
d := step.Directions
|
||||||
|
|
||||||
|
ingredients := make([]string, len(step.Ingredients))
|
||||||
|
for i, ingredient := range step.Ingredients {
|
||||||
|
ingredients[i] = ingredient.Name
|
||||||
|
}
|
||||||
|
d = hydrate(d, ingredients, ingredientPrefix, ingredientSuffix)
|
||||||
|
|
||||||
|
cookwareEls := make([]string, len(step.Cookware))
|
||||||
|
for i, cookware := range step.Cookware {
|
||||||
|
cookwareEls[i] = cookware.Name
|
||||||
|
}
|
||||||
|
d = hydrate(d, cookwareEls, cookwarePrefix, cookwareSuffix)
|
||||||
|
|
||||||
|
timers := make([]string, len(step.Timers))
|
||||||
|
for i, timer := range step.Timers {
|
||||||
|
timers[i] = fmt.Sprintf("%v %s", timer.Duration, timer.Unit)
|
||||||
|
}
|
||||||
|
d = hydrate(d, timers, timerPrefix, timerSuffix)
|
||||||
|
|
||||||
|
str += stepPrefix + d + stepSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.HTML(str)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrate(text string, elements []string, prefix, suffix string) string {
|
||||||
|
cursor := 0
|
||||||
|
for _, el := range elements {
|
||||||
|
slog.Debug("Chasing element", "cursor", cursor, "element", el)
|
||||||
|
idx := strings.Index(text[cursor:], el)
|
||||||
|
first := text[:cursor+idx]
|
||||||
|
rest := text[cursor+idx+len(el):]
|
||||||
|
text = first + prefix + el + suffix + rest
|
||||||
|
cursor += idx + len(prefix+el+suffix)
|
||||||
|
slog.Debug("Parsed element", "newCursor", cursor, "first", first, "rest", rest)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeTemplate(name string, data any) ([]byte, error) {
|
||||||
|
path := filepath.Join("templates", fmt.Sprintf("%s.html.tmpl", name))
|
||||||
|
b, err := embedTmpl.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read embedded file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("").Funcs(funcMap).Parse(string(b))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse template %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var strb bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&strb, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot execute template %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strb.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeTemplateToFile(tmplName string, data any, filename string) error {
|
||||||
|
contents, err := executeTemplate(tmplName, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot execute %q template: %w", tmplName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filename, contents, 0755); err != nil {
|
||||||
|
return fmt.Errorf("cannot write %q contents to %q: %w", tmplName, filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(srcpath, dstpath string) error {
|
||||||
|
src, err := os.Open(srcpath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot open src file %q: %w", srcpath, err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create(dstpath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot open dst file %q: %w", dstpath, err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return fmt.Errorf("cannot copy file from %q to %q: %w", srcpath, dstpath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
recipesFlag := flag.String("recipes", "recipes", "Path to the directory with the recipes.")
|
||||||
|
outputFlag := flag.String("output", "dist", "Path to the directory where the files will be generated.")
|
||||||
|
debugFlag := flag.Bool("debug", false, "Enables debug information.")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *debugFlag {
|
||||||
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(*outputFlag)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !os.IsNotExist(err) && !fi.IsDir() {
|
||||||
|
panic(fmt.Errorf("path %q is not a directory", *outputFlag))
|
||||||
|
} else {
|
||||||
|
if err := os.RemoveAll(*outputFlag); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(*outputFlag, 0755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
walkErr := filepath.WalkDir(*recipesFlag, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
slog.Debug("Walking through file", "path", path)
|
||||||
|
relpath := filepath.Join(strings.Split(path, "/")[1:]...)
|
||||||
|
ext := filepath.Ext(d.Name())
|
||||||
|
recipeName := strings.TrimSuffix(d.Name(), ext)
|
||||||
|
if d.IsDir() {
|
||||||
|
dirpath := filepath.Join(*outputFlag, relpath)
|
||||||
|
slog.Debug("Directory found, creating output dir", "path", path, "dirpath", dirpath)
|
||||||
|
if err := os.MkdirAll(dirpath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("cannot create directory %s: %w", dirpath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext != ".cook" {
|
||||||
|
dstPath := filepath.Join(*outputFlag, relpath)
|
||||||
|
slog.Debug("Non-recipe file found, copying", "src", path, "dst", dstPath)
|
||||||
|
if err := copyFile(path, dstPath); err != nil {
|
||||||
|
return fmt.Errorf("cannot copy file from %q to %q: %w", path, dstPath, err)
|
||||||
|
}
|
||||||
|
slog.Debug("File copy successful", "src", path, "dst", dstPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Parsing file", "name", path)
|
||||||
|
recipe, err := cooklang.ParseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recipe.Steps) == 0 {
|
||||||
|
slog.Debug("File is not a recipe", "name", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Parsed file", "path", path, "steps", len(recipe.Steps))
|
||||||
|
recipeDistPath := filepath.Join(*outputFlag, filepath.Dir(relpath), fmt.Sprintf("%s.html", recipeName))
|
||||||
|
data := map[string]any{"name": recipeName, "recipe": recipe}
|
||||||
|
slog.Debug("Executing template", "recipeName", recipeName, "recipeWebPath", recipeDistPath)
|
||||||
|
if err := executeTemplateToFile("recipe", data, recipeDistPath); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if walkErr != nil {
|
||||||
|
panic(walkErr)
|
||||||
|
}
|
||||||
|
}
|
23
templates/recipe.html.tmpl
Normal file
23
templates/recipe.html.tmpl
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{.name}}</title>
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{.name}}</h1>
|
||||||
|
|
||||||
|
{{if ne (len .recipe.Steps) 0}}
|
||||||
|
<ul>
|
||||||
|
{{range $k, $v := .recipe.Metadata}}
|
||||||
|
<li><em>{{$k}}:</em> {{$v}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{recipeSteps .recipe}}
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue