package main import ( "bytes" "embed" "fmt" "html/template" "io" "io/fs" "log/slog" "os" "path/filepath" "strings" "github.com/alecthomas/kong" "github.com/aquilax/cooklang-go" ) const ( stepPrefix = "
" stepSuffix = "
" ingredientPrefix = "" ingredientSuffix = "" cookwarePrefix = "" cookwareSuffix = "" timerPrefix = "" timerSuffix = "" ) //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("", i, recipeImagePath) } str += stepPrefix + d + stepSuffix } return template.HTML(str) }, } 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) } 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 } recipeDistPath := filepath.Join(g.Output, filepath.Dir(relpath), fmt.Sprintf("%s.html", filenameWithoutExt)) data := map[string]any{"name": recipeName, "recipe": recipe, "path": path, "relpath": getRelpath(relpath)} 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) } 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) } return nil }