From e5d4e493905bc068468a0f0669e4067211a7fca5 Mon Sep 17 00:00:00 2001 From: Gabe Ortiz Date: Thu, 2 Jan 2025 13:26:46 -0700 Subject: [PATCH] add site gen, move files --- .gitignore | 1 + recipe-site.go | 655 ++++++++++++++++++ biscochitos.md => recipes/biscochitos.md | 0 .../green_chile_stew.md | 0 home_fries.md => recipes/home_fries.md | 0 oat_bars.md => recipes/oat_bars.md | 0 papitas.md => recipes/papitas.md | 0 posole.md => recipes/posole.md | 0 pumpkin_pie.md => recipes/pumpkin_pie.md | 0 red_chile.md => recipes/red_chile.md | 0 .../red_chile_enchiladas.md | 0 refritos.md => recipes/refritos.md | 0 .../shokupan_sopapillas.md | 0 spanish_rice.md => recipes/spanish_rice.md | 0 tamales.md => recipes/tamales.md | 0 15 files changed, 656 insertions(+) create mode 100644 .gitignore create mode 100644 recipe-site.go rename biscochitos.md => recipes/biscochitos.md (100%) rename green_chile_stew.md => recipes/green_chile_stew.md (100%) rename home_fries.md => recipes/home_fries.md (100%) rename oat_bars.md => recipes/oat_bars.md (100%) rename papitas.md => recipes/papitas.md (100%) rename posole.md => recipes/posole.md (100%) rename pumpkin_pie.md => recipes/pumpkin_pie.md (100%) rename red_chile.md => recipes/red_chile.md (100%) rename red_chile_enchiladas.md => recipes/red_chile_enchiladas.md (100%) rename refritos.md => recipes/refritos.md (100%) rename shokupan_sopapillas.md => recipes/shokupan_sopapillas.md (100%) rename spanish_rice.md => recipes/spanish_rice.md (100%) rename tamales.md => recipes/tamales.md (100%) 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