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) } }