package main import ( "bytes" "fmt" "html/template" "io/ioutil" "os" "path/filepath" "regexp" "strings" "time" "sort" "io/fs" "github.com/russross/blackfriday/v2" "gopkg.in/yaml.v3" ) type NavItem struct { Title string URL string Position int } type PageMeta struct { Title string `yaml:"title"` NavTitle string `yaml:"navTitle"` ShowInNav *bool `yaml:"showInNav"` NavPosition *int `yaml:"navPosition"` Description string `yaml:"description"` Date string `yaml:"date"` FormattedDate string `yaml:"-"` Categories []string `yaml:"categories"` Slug string } var slugRegex = regexp.MustCompile(`[^a-z0-9]+`) func main() { contentDir := "./content" outputDir := "./public" tpl, err := loadTemplates() if err != nil { fmt.Printf("Template parsing error: %v\n", err) return } files, err := ioutil.ReadDir(contentDir) if err != nil { fmt.Printf("Failed to read content directory: %v\n", err) return } entries, _ := os.ReadDir(contentDir) nav := buildNav(entries, contentDir) renderStaticPages(files, contentDir, outputDir, tpl, nav) blogPosts, categoryMap := processBlogPosts(contentDir, outputDir, tpl, nav) renderContactPage(contentDir, outputDir, tpl, nav) renderThanksPage(contentDir, outputDir, tpl, nav) buildBlogIndex(blogPosts, tpl, outputDir, nav) buildCategoryPages(categoryMap, tpl, outputDir, nav) fmt.Println("✅ Site generation complete.") } func loadTemplates() (*template.Template, error) { return template.New("").Funcs(templateFuncs()).ParseGlob("templates/*.html") } func buildNav(entries []fs.DirEntry, contentDir string) []NavItem { var nav []NavItem for _, entry := range entries { if entry.Type().IsDir() || !strings.HasSuffix(entry.Name(), ".md") { continue } name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) path := filepath.Join(contentDir, entry.Name()) rawContent, err := os.ReadFile(path) if err != nil { fmt.Printf("Failed to read %s: %v\n", entry.Name(), err) continue } meta, _ := parseFrontMatter(rawContent) // Skip pages with showInNav: false if meta.ShowInNav != nil && !*meta.ShowInNav { continue } // Pull title info title := strings.TrimSpace(meta.NavTitle) if title == "" { title = meta.Title } if title == "" && name == "index" { title = "Home" } else if title == "" { title = strings.Title(name) } // Build URL url := "/" if name != "index" { url = "/" + name + "/" } // Pull nav position regardless of title logic position := 999 if meta.NavPosition != nil { position = *meta.NavPosition } nav = append(nav, NavItem{ Title: title, URL: url, Position: position, }) } // Optional: add Blog manually (or skip if you handle it as a page) nav = append(nav, NavItem{ Title: "Blog", URL: "/blog/", Position: 3, }) // Sort by position sort.SliceStable(nav, func(i, j int) bool { return nav[i].Position < nav[j].Position }) return nav } func renderStaticPages(files []os.FileInfo, contentDir, outputDir string, tpl *template.Template, nav []NavItem) { for _, file := range files { if filepath.Ext(file.Name()) == ".md" { renderStaticPage(file, contentDir, outputDir, tpl, nav) } } } func renderStaticPage(file os.FileInfo, contentDir, outputDir string, tpl *template.Template, nav []NavItem) { name := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) title := strings.Title(name) mdPath := filepath.Join(contentDir, file.Name()) rawContent, err := ioutil.ReadFile(mdPath) if err != nil { fmt.Printf("Failed to read %s: %v\n", file.Name(), err) return } meta, content := parseFrontMatter(rawContent) htmlContent := blackfriday.Run(content) if meta.Title == "" { meta.Title = title } var outPath string if name == "index" { outPath = filepath.Join(outputDir, "index.html") } else { subDir := filepath.Join(outputDir, name) os.MkdirAll(subDir, os.ModePerm) outPath = filepath.Join(subDir, "index.html") } outFile, err := os.Create(outPath) if err != nil { fmt.Printf("Failed to create %s: %v\n", outPath, err) return } tpl.ExecuteTemplate(outFile, "static_page", map[string]interface{}{ "Title": meta.Title, "Description": meta.Description, "Date": meta.Date, "Categories": meta.Categories, "Content": template.HTML(htmlContent), "Nav": nav, "Year": time.Now().Year(), "PageTemplate": "static", }) fmt.Printf("Generated: %s\n", outPath) } func renderContactPage(contentDir, outputDir string, tpl *template.Template, nav []NavItem) { contactPath := filepath.Join(contentDir, "contact.md") rawContent, err := os.ReadFile(contactPath) if err != nil { fmt.Printf("Contact page not found: %v\n", err) return } meta, _ := parseFrontMatter(rawContent) outPath := filepath.Join(outputDir, "contact", "index.html") os.MkdirAll(filepath.Dir(outPath), os.ModePerm) outFile, err := os.Create(outPath) if err != nil { fmt.Printf("Failed to create contact page: %v\n", err) return } tpl.ExecuteTemplate(outFile, "contact_page", map[string]interface{}{ "Title": meta.Title, "Description": meta.Description, "Nav": nav, "Year": time.Now().Year(), "PageTemplate": "contact_page", }) fmt.Println("Generated: /contact/") } func renderThanksPage(contentDir, outputDir string, tpl *template.Template, nav []NavItem) { path := filepath.Join(contentDir, "thanks.md") rawContent, err := os.ReadFile(path) if err != nil { fmt.Printf("Thanks page not found: %v\n", err) return } meta, _ := parseFrontMatter(rawContent) outPath := filepath.Join(outputDir, "thanks", "index.html") os.MkdirAll(filepath.Dir(outPath), os.ModePerm) outFile, err := os.Create(outPath) if err != nil { fmt.Printf("Failed to create thanks page: %v\n", err) return } tpl.ExecuteTemplate(outFile, "thanks_page", map[string]interface{}{ "Title": meta.Title, "Description": meta.Description, "Nav": nav, "Year": time.Now().Year(), "PageTemplate": "thanks_page", }) fmt.Println("Generated: /thanks/") } func processBlogPosts(contentDir, outputDir string, tpl *template.Template, nav []NavItem) ([]PageMeta, map[string][]PageMeta) { var blogPosts []PageMeta categoryMap := make(map[string][]PageMeta) blogDir := filepath.Join(contentDir, "blog") blogFiles, _ := ioutil.ReadDir(blogDir) for _, file := range blogFiles { if filepath.Ext(file.Name()) != ".md" { continue } processBlogPost(file, blogDir, outputDir, tpl, nav, &blogPosts, categoryMap) } return blogPosts, categoryMap } func processBlogPost(file os.FileInfo, blogDir, outputDir string, tpl *template.Template, nav []NavItem, blogPosts *[]PageMeta, categoryMap map[string][]PageMeta) { slug := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) blogPath := filepath.Join(blogDir, file.Name()) rawContent, err := ioutil.ReadFile(blogPath) if err != nil { fmt.Printf("Failed to read blog post %s: %v\n", file.Name(), err) return } meta, content := parseFrontMatter(rawContent) html := blackfriday.Run(content) meta.Slug = slug if meta.Title == "" { meta.Title = strings.Title(slug) } for _, cat := range meta.Categories { slug := slugify(cat) categoryMap[slug] = append(categoryMap[slug], meta) } outPath := filepath.Join(outputDir, "blog", slug, "index.html") os.MkdirAll(filepath.Dir(outPath), os.ModePerm) outFile, err := os.Create(outPath) if err != nil { fmt.Printf("Failed to create blog file %s: %v\n", outPath, err) return } tpl.ExecuteTemplate(outFile, "blog_post_page", map[string]interface{}{ "Title": meta.Title, "Description": meta.Description, "Date": meta.Date, "Categories": meta.Categories, "Content": template.HTML(html), "Nav": nav, "Year": time.Now().Year(), "PageTemplate": "blog_post", }) meta.FormattedDate = meta.Date.Format(time.RFC1123Z) *blogPosts = append(*blogPosts, meta) } func buildCategoryPages(categoryMap map[string][]PageMeta, tpl *template.Template, outputDir string, nav []NavItem) { for catSlug, posts := range categoryMap { outDir := filepath.Join(outputDir, "blog", "category", catSlug) os.MkdirAll(outDir, os.ModePerm) outPath := filepath.Join(outDir, "index.html") outFile, err := os.Create(outPath) if err != nil { fmt.Printf("Failed to create category page for %s: %v\n", catSlug, err) continue } tpl.ExecuteTemplate(outFile, "category_page", map[string]interface{}{ "Title": "Category: " + strings.Title(strings.ReplaceAll(catSlug, "-", " ")), "Posts": posts, "Nav": nav, "Year": time.Now().Year(), "PageTemplate": "category_page", }) fmt.Printf("Generated category: /blog/category/%s/\n", catSlug) } } func parseFrontMatter(raw []byte) (PageMeta, []byte) { var meta PageMeta raw = bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n")) if bytes.HasPrefix(raw, []byte("---\n")) { parts := bytes.SplitN(raw, []byte("---\n"), 3) if len(parts) == 3 { err := yaml.Unmarshal(parts[1], &meta) if err != nil { fmt.Printf("⚠️ Failed to parse front matter: %v\n", err) } return meta, parts[2] } } return meta, raw } func buildBlogIndex(posts []PageMeta, tpl *template.Template, outputDir string, nav []NavItem) { outPath := filepath.Join(outputDir, "blog", "index.html") os.MkdirAll(filepath.Dir(outPath), os.ModePerm) outFile, err := os.Create(outPath) if err != nil { fmt.Println("Failed to create blog index:", err) return } tpl.ExecuteTemplate(outFile, "blog_index_page", map[string]interface{}{ "Title": "Blog Index", "Posts": posts, "Nav": nav, "Year": time.Now().Year(), "PageTemplate": "blog_index", }) } func slugify(s string) string { s = strings.ToLower(s) s = slugRegex.ReplaceAllString(s, "-") return strings.Trim(s, "-") } func templateFuncs() template.FuncMap { return template.FuncMap{ "lower": strings.ToLower, "slugify": slugify, "urlquery": template.URLQueryEscaper, } }