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(`