diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..849ddff
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+dist/
diff --git a/recipe-site.go b/recipe-site.go
new file mode 100644
index 0000000..bfcbd7d
--- /dev/null
+++ b/recipe-site.go
@@ -0,0 +1,655 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "flag"
+ "fmt"
+ "html/template"
+ "io/ioutil"
+ "mime"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/gomarkdown/markdown"
+ "github.com/gomarkdown/markdown/parser"
+ "gopkg.in/yaml.v2"
+)
+
+type Recipe struct {
+ Slug string
+ Title string `yaml:"title"`
+ Tags []string `yaml:"tags"`
+ Content string
+ HTMLContent template.HTML
+}
+
+type SiteGenerator struct {
+ RecipesDir string
+ OutputDir string
+ Recipes []Recipe
+ RecipeMap map[string]string // slug -> title mapping
+ s3Client *s3.Client
+ s3Bucket string
+}
+
+func NewSiteGenerator(recipesDir, outputDir string, s3Client *s3.Client, s3Bucket string) *SiteGenerator {
+ return &SiteGenerator{
+ RecipesDir: recipesDir,
+ OutputDir: outputDir,
+ RecipeMap: make(map[string]string),
+ s3Client: s3Client,
+ s3Bucket: s3Bucket,
+ }
+}
+
+func (sg *SiteGenerator) Generate() error {
+ if err := os.MkdirAll(sg.OutputDir, 0755); err != nil {
+ return fmt.Errorf("creating output directory: %w", err)
+ }
+
+ if err := sg.collectRecipes(); err != nil {
+ return fmt.Errorf("collecting recipes: %w", err)
+ }
+
+ if err := sg.generatePages(); err != nil {
+ return fmt.Errorf("generating pages: %w", err)
+ }
+
+ if sg.s3Client != nil {
+ if err := sg.uploadToS3(); err != nil {
+ return fmt.Errorf("uploading to S3: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (sg *SiteGenerator) collectRecipes() error {
+ files, err := ioutil.ReadDir(sg.RecipesDir)
+ if err != nil {
+ return err
+ }
+
+ for _, file := range files {
+ if file.Name() == "README.md" || filepath.Ext(file.Name()) != ".md" {
+ continue
+ }
+
+ content, err := ioutil.ReadFile(filepath.Join(sg.RecipesDir, file.Name()))
+ if err != nil {
+ return err
+ }
+
+ var recipe Recipe
+ recipe.Slug = strings.TrimSuffix(file.Name(), ".md")
+
+ // Replace underscores with spaces in the slug for the default title
+ recipe.Title = strings.ReplaceAll(recipe.Slug, "_", " ")
+
+ // Look for the first heading in the content
+ lines := strings.Split(string(content), "\n")
+ for _, line := range lines {
+ if strings.HasPrefix(line, "# ") {
+ recipe.Title = strings.TrimPrefix(line, "# ")
+ break
+ }
+ }
+
+ // Parse frontmatter if it exists
+ if bytes.HasPrefix(content, []byte("---")) {
+ parts := bytes.SplitN(content, []byte("---"), 3)
+ if len(parts) == 3 {
+ if err := yaml.Unmarshal(parts[1], &recipe); err != nil {
+ fmt.Printf("Warning: Failed to parse frontmatter for %s: %v\n", file.Name(), err)
+ }
+ recipe.Content = string(parts[2])
+ } else {
+ recipe.Content = string(content)
+ }
+ } else {
+ recipe.Content = string(content)
+ }
+ sg.Recipes = append(sg.Recipes, recipe)
+ sg.RecipeMap[recipe.Slug] = recipe.Title
+ }
+
+ return nil
+}
+
+func (sg *SiteGenerator) generatePages() error {
+ for i := range sg.Recipes {
+ if err := sg.generateRecipePage(&sg.Recipes[i]); err != nil {
+ return err
+ }
+ }
+ return sg.generateIndexPage()
+}
+
+func (sg *SiteGenerator) generateRecipePage(recipe *Recipe) error {
+ extensions := parser.CommonExtensions | parser.AutoHeadingIDs
+ parser := parser.NewWithExtensions(extensions)
+ html := markdown.ToHTML([]byte(recipe.Content), parser, nil)
+
+ content := string(html)
+ for slug, title := range sg.RecipeMap {
+ content = strings.ReplaceAll(
+ content,
+ ">"+title+"<",
+ fmt.Sprintf(">%s<", slug, title),
+ )
+ }
+ recipe.HTMLContent = template.HTML(content)
+
+ tmpl := template.Must(template.New("recipe").Parse(`
+
+
+
+
+
+ Gabe's Recipes - {{.Title}}
+
+
+
+
+ {{.Title}}
+
+ {{range .Tags}}{{.}}{{end}}
+
+ {{.HTMLContent}}
+
+`))
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, recipe); err != nil {
+ return err
+ }
+
+ return ioutil.WriteFile(
+ filepath.Join(sg.OutputDir, recipe.Slug+".html"),
+ buf.Bytes(),
+ 0644,
+ )
+}
+
+func (sg *SiteGenerator) generateIndexPage() error {
+ tmpl := template.Must(template.New("index").Parse(`
+
+
+
+
+
+ Gabe's Recipes
+
+
+
+ Gabe's Recipes
+
+
+`))
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, sg); err != nil {
+ return err
+ }
+
+ return ioutil.WriteFile(
+ filepath.Join(sg.OutputDir, "index.html"),
+ buf.Bytes(),
+ 0644,
+ )
+}
+
+func (sg *SiteGenerator) uploadToS3() error {
+ const concurrency = 5
+ files := make(chan string)
+ var wg sync.WaitGroup
+
+ // Start worker pool
+ for i := 0; i < concurrency; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for file := range files {
+ if err := sg.uploadFile(context.TODO(), file); err != nil {
+ fmt.Fprintf(os.Stderr, "Error uploading %s: %v\n", file, err)
+ }
+ }
+ }()
+ }
+
+ // Find files to upload
+ err := filepath.Walk(sg.OutputDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ files <- path
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walking directory: %w", err)
+ }
+
+ close(files)
+ wg.Wait()
+ return nil
+}
+
+func (sg *SiteGenerator) uploadFile(ctx context.Context, filename string) error {
+ file, err := os.Open(filename)
+ if err != nil {
+ return fmt.Errorf("opening file: %w", err)
+ }
+ defer file.Close()
+
+ contentType := mime.TypeByExtension(filepath.Ext(filename))
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+
+ key := strings.TrimPrefix(filename, sg.OutputDir+"/")
+
+ cacheControl := "public, max-age=31536000"
+ if filepath.Ext(filename) == ".html" {
+ cacheControl = "no-cache, no-store, must-revalidate"
+ }
+
+ _, err = sg.s3Client.PutObject(ctx, &s3.PutObjectInput{
+ Bucket: &sg.s3Bucket,
+ Key: &key,
+ Body: file,
+ ContentType: &contentType,
+ CacheControl: &cacheControl,
+ })
+ if err != nil {
+ return fmt.Errorf("uploading to S3: %w", err)
+ }
+
+ fmt.Printf("Uploaded %s\n", key)
+ return nil
+}
+
+func main() {
+ recipesDir := flag.String("recipes", "recipes", "Directory containing recipe markdown files")
+ outputDir := flag.String("output", "dist", "Output directory for generated site")
+ s3Bucket := flag.String("bucket", "", "S3 bucket name (optional)")
+ flag.Parse()
+
+ var s3Client *s3.Client
+ if *s3Bucket != "" {
+ cfg, err := config.LoadDefaultConfig(context.TODO())
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Unable to load SDK config: %v\n", err)
+ os.Exit(1)
+ }
+ s3Client = s3.NewFromConfig(cfg)
+ }
+
+ generator := NewSiteGenerator(*recipesDir, *outputDir, s3Client, *s3Bucket)
+ if err := generator.Generate(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/biscochitos.md b/recipes/biscochitos.md
similarity index 100%
rename from biscochitos.md
rename to recipes/biscochitos.md
diff --git a/green_chile_stew.md b/recipes/green_chile_stew.md
similarity index 100%
rename from green_chile_stew.md
rename to recipes/green_chile_stew.md
diff --git a/home_fries.md b/recipes/home_fries.md
similarity index 100%
rename from home_fries.md
rename to recipes/home_fries.md
diff --git a/oat_bars.md b/recipes/oat_bars.md
similarity index 100%
rename from oat_bars.md
rename to recipes/oat_bars.md
diff --git a/papitas.md b/recipes/papitas.md
similarity index 100%
rename from papitas.md
rename to recipes/papitas.md
diff --git a/posole.md b/recipes/posole.md
similarity index 100%
rename from posole.md
rename to recipes/posole.md
diff --git a/pumpkin_pie.md b/recipes/pumpkin_pie.md
similarity index 100%
rename from pumpkin_pie.md
rename to recipes/pumpkin_pie.md
diff --git a/red_chile.md b/recipes/red_chile.md
similarity index 100%
rename from red_chile.md
rename to recipes/red_chile.md
diff --git a/red_chile_enchiladas.md b/recipes/red_chile_enchiladas.md
similarity index 100%
rename from red_chile_enchiladas.md
rename to recipes/red_chile_enchiladas.md
diff --git a/refritos.md b/recipes/refritos.md
similarity index 100%
rename from refritos.md
rename to recipes/refritos.md
diff --git a/shokupan_sopapillas.md b/recipes/shokupan_sopapillas.md
similarity index 100%
rename from shokupan_sopapillas.md
rename to recipes/shokupan_sopapillas.md
diff --git a/spanish_rice.md b/recipes/spanish_rice.md
similarity index 100%
rename from spanish_rice.md
rename to recipes/spanish_rice.md
diff --git a/tamales.md b/recipes/tamales.md
similarity index 100%
rename from tamales.md
rename to recipes/tamales.md