314 lines
8.7 KiB
Go
314 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/alecthomas/kong"
|
|
"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>"
|
|
)
|
|
|
|
type RecipeFile struct {
|
|
Name string
|
|
Path string
|
|
}
|
|
|
|
//go:embed templates
|
|
var embedTmpl embed.FS
|
|
var imageExtensions = []string{".jpg", ".jpeg", ".png"}
|
|
var funcMap = template.FuncMap{
|
|
"recipeImage": recipeImage,
|
|
"recipeSteps": func(r cooklang.Recipe, path string) template.HTML {
|
|
var str = ""
|
|
for i, 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)
|
|
|
|
recipeImagePath := recipeImageForStep(path, i)
|
|
if recipeImagePath != "" {
|
|
str += fmt.Sprintf("<img class=\"stepimage stepimage-%d\" src=\"%s\" />", i, recipeImagePath)
|
|
}
|
|
str += stepPrefix + d + stepSuffix
|
|
}
|
|
|
|
return template.HTML(str)
|
|
},
|
|
"sortedMetadataKeys": func(m cooklang.Metadata) []string {
|
|
keys := []string{}
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
sort.Strings(keys)
|
|
return keys
|
|
},
|
|
}
|
|
|
|
func recipeImageForStep(recipepath string, step int) string {
|
|
ext := filepath.Ext(recipepath)
|
|
imagepath := fmt.Sprintf("%s.%d%s", strings.TrimSuffix(recipepath, ext), step, ext)
|
|
return recipeImage(imagepath)
|
|
}
|
|
|
|
func recipeImage(recipepath string) string {
|
|
pathWithoutExt := strings.TrimSuffix(recipepath, filepath.Ext(recipepath))
|
|
for _, ext := range imageExtensions {
|
|
imagepath := fmt.Sprintf("%s%s", pathWithoutExt, ext)
|
|
if fi, err := os.Stat(imagepath); err == nil && !fi.IsDir() {
|
|
return filepath.Base(imagepath)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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 getRelpath(name string) string {
|
|
return strings.Repeat("../", strings.Count(name, "/"))
|
|
}
|
|
|
|
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()
|
|
|
|
return copyToFile(src, dstpath)
|
|
}
|
|
|
|
func copyToFile(src io.Reader, dstpath string) error {
|
|
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 to file %q: %w", dstpath, err)
|
|
}
|
|
|
|
return dst.Sync()
|
|
}
|
|
|
|
func main() {
|
|
ctx := kong.Parse(&cli)
|
|
if cli.Debug {
|
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
|
}
|
|
|
|
if err := ctx.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func isExcluded(name string) bool {
|
|
return strings.HasPrefix(strings.ToUpper(name), "README")
|
|
}
|
|
|
|
func (g *generateCmd) Run() error {
|
|
fi, err := os.Stat(g.Output)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("cannot stat %q: %w", g.Output, err)
|
|
}
|
|
|
|
if !os.IsNotExist(err) && !fi.IsDir() {
|
|
return fmt.Errorf("path %q is not a directory", g.Output)
|
|
} else {
|
|
if err := os.RemoveAll(g.Output); err != nil {
|
|
return fmt.Errorf("cannot remove %q: %w", g.Output, err)
|
|
}
|
|
}
|
|
if err := os.Mkdir(g.Output, 0755); err != nil {
|
|
return fmt.Errorf("cannot create directory on path %q: %w", g.Output, err)
|
|
}
|
|
|
|
recipeFiles := []*RecipeFile{}
|
|
walkErr := filepath.WalkDir(g.Path, func(path string, d fs.DirEntry, err error) error {
|
|
slog.Debug("Walking through file", "path", path)
|
|
|
|
if isExcluded(d.Name()) {
|
|
slog.Debug("Excluded file, skipping", "name", d.Name())
|
|
return nil
|
|
}
|
|
|
|
apath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get the abs path for %q: %w", path, err)
|
|
}
|
|
|
|
relpath, err := filepath.Rel(g.Path, apath)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get relative path for %s: %w", apath, err)
|
|
}
|
|
|
|
ext := filepath.Ext(d.Name())
|
|
if d.IsDir() {
|
|
dirpath := filepath.Join(g.Output, 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(g.Output, 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 {
|
|
return fmt.Errorf("cannot parse file %q: %w", path, 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))
|
|
|
|
filenameWithoutExt := strings.TrimSuffix(d.Name(), ext)
|
|
recipeName := filenameWithoutExt
|
|
if name, ok := recipe.Metadata[g.NameKey]; ok {
|
|
recipeName = name
|
|
}
|
|
recipeURL := ""
|
|
if url, ok := recipe.Metadata[g.URLKey]; ok {
|
|
recipeURL = url
|
|
}
|
|
|
|
htmlPath := filepath.Join(filepath.Dir(relpath), fmt.Sprintf("%s.html", filenameWithoutExt))
|
|
recipeFiles = append(recipeFiles, &RecipeFile{Name: recipeName, Path: htmlPath})
|
|
recipeDistPath := filepath.Join(g.Output, htmlPath)
|
|
data := map[string]any{
|
|
"name": recipeName,
|
|
"url": recipeURL,
|
|
"recipe": recipe,
|
|
"path": path,
|
|
"relpath": getRelpath(relpath),
|
|
"nameKey": g.NameKey,
|
|
"urlKey": g.URLKey,
|
|
"ingredientsTitle": g.IngredientsTitle,
|
|
}
|
|
slog.Debug("Executing template", "recipeName", recipeName, "recipeWebPath", recipeDistPath)
|
|
if err := executeTemplateToFile("recipe", data, recipeDistPath); err != nil {
|
|
return fmt.Errorf("cannot execute template \"recipe\" to file %q: %w", recipeDistPath, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
return fmt.Errorf("error while walking directory %q: %w", g.Path, walkErr)
|
|
}
|
|
|
|
// copy styles
|
|
styleFile, err := embedTmpl.Open(filepath.Join("templates", "style.css"))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot read style.css embed file: %w", err)
|
|
}
|
|
|
|
if err := copyToFile(styleFile, filepath.Join(g.Output, "style.css")); err != nil {
|
|
return fmt.Errorf("cannot copy style.css: %w", err)
|
|
}
|
|
|
|
// generate index
|
|
data := map[string]any{"title": g.Title, "recipes": recipeFiles}
|
|
indexpath := filepath.Join(g.Output, "index.html")
|
|
slog.Debug("Executing index template", "title", g.Title, "recipes", len(recipeFiles), "indexpath", indexpath)
|
|
if err := executeTemplateToFile("index", data, indexpath); err != nil {
|
|
return fmt.Errorf("cannot execute template \"index\" to file %q: %w", indexpath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|