commit 70b8cdb514c645e28b80e46c78e7b6d6514b97d2 Author: Miguel de la Cruz Date: Tue Jul 2 02:06:25 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5da6101 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/recipes/ +/sandwitchboard \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7c6bbc9 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +fmt: + go fmt ./... + +build: + go build -o sandwitchboard ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecb2d26 --- /dev/null +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c7466ca --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.ctrlz.es/sandwitchboard + +go 1.22.3 + +require github.com/aquilax/cooklang-go v0.1.7 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fabd8fb --- /dev/null +++ b/go.sum @@ -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= diff --git a/sandwitchboard.go b/sandwitchboard.go new file mode 100644 index 0000000..f9771f8 --- /dev/null +++ b/sandwitchboard.go @@ -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 = "

" + stepSuffix = "

" + ingredientPrefix = "" + ingredientSuffix = "" + cookwarePrefix = "" + cookwareSuffix = "" + timerPrefix = "" + timerSuffix = "" +) + +//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) + } +} diff --git a/templates/recipe.html.tmpl b/templates/recipe.html.tmpl new file mode 100644 index 0000000..2f413cf --- /dev/null +++ b/templates/recipe.html.tmpl @@ -0,0 +1,23 @@ + + + + + + {{.name}} + + + + +

{{.name}}

+ + {{if ne (len .recipe.Steps) 0}} + + {{end}} + + {{recipeSteps .recipe}} + +