package builder import ( "fmt" "html/template" "os" "path/filepath" "strings" "time" "github.com/fsnotify/fsnotify" "nebbet.no/internal/db" ) // Builder orchestrates the markdown → HTML build pipeline. type Builder struct { ContentDir string OutputDir string TemplateDir string ComponentDir string LibDir string MetaDB *db.MetaDB SearchDB *db.SearchDB tmpl *template.Template } func New(contentDir, outputDir string, meta *db.MetaDB, search *db.SearchDB) *Builder { return &Builder{ ContentDir: contentDir, OutputDir: outputDir, TemplateDir: "templates", ComponentDir: "components", LibDir: "lib", MetaDB: meta, SearchDB: search, } } // PageData is passed to HTML templates. type PageData struct { Title string Content template.HTML // ImportMapTag is the full block, // pre-rendered as safe HTML so the JSON inside is never entity-escaped. ImportMapTag template.HTML ComponentScripts []string Date string Tags []string Path string } // BuildAll performs a full site build. func (b *Builder) BuildAll() error { if err := b.loadTemplates(); err != nil { return fmt.Errorf("load templates: %w", err) } importMap, err := GenerateImportMap(b.LibDir) if err != nil { return fmt.Errorf("importmap: %w", err) } return filepath.WalkDir(b.ContentDir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { return err } return b.BuildFile(path, importMap) }) } // BuildFile converts a single markdown file and updates both databases. func (b *Builder) BuildFile(mdPath, importMap string) error { data, err := os.ReadFile(mdPath) if err != nil { return err } fm, body := ParseFrontmatter(string(data)) if fm.Draft { fmt.Printf("skip draft: %s\n", mdPath) return nil } htmlBody, err := MarkdownToHTML(body) if err != nil { return fmt.Errorf("markdown: %w", err) } htmlBody = ProcessComponents(htmlBody) scripts := FindComponentScripts(htmlBody, b.ComponentDir) // Derive URL path and output file path from content-relative path. rel, _ := filepath.Rel(b.ContentDir, mdPath) urlPath := "/" + filepath.ToSlash(strings.TrimSuffix(rel, ".md")) // /index → / and /section/index → /section switch { case urlPath == "/index": urlPath = "/" case strings.HasSuffix(urlPath, "/index"): urlPath = strings.TrimSuffix(urlPath, "/index") } outPath := filepath.Join(b.OutputDir, filepath.FromSlash( strings.TrimSuffix(filepath.ToSlash(rel), ".md")+".html")) if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { return err } var importMapTag template.HTML if importMap != "" { importMapTag = template.HTML( "") } page := PageData{ Title: fm.Title, Content: template.HTML(htmlBody), ImportMapTag: importMapTag, ComponentScripts: scripts, Date: fm.Date, Tags: fm.Tags, Path: urlPath, } tmplName := fm.Layout + ".html" f, err := os.Create(outPath) if err != nil { return err } defer f.Close() if err := b.tmpl.ExecuteTemplate(f, tmplName, page); err != nil { return fmt.Errorf("template %s: %w", tmplName, err) } if err := b.MetaDB.UpsertPage(db.PageMeta{ Path: urlPath, HTMLPath: outPath, Title: fm.Title, Date: fm.Date, Tags: fm.Tags, UpdatedAt: time.Now(), }); err != nil { return fmt.Errorf("meta db: %w", err) } if err := b.SearchDB.IndexPage(db.SearchPage{ Path: urlPath, Title: fm.Title, Content: StripHTML(htmlBody), }); err != nil { return fmt.Errorf("search db: %w", err) } fmt.Printf("built %s → %s\n", mdPath, outPath) return nil } // RemovePage deletes the built HTML and removes the page from both databases. func (b *Builder) RemovePage(mdPath string) error { rel, _ := filepath.Rel(b.ContentDir, mdPath) urlPath := "/" + filepath.ToSlash(strings.TrimSuffix(rel, ".md")) switch { case urlPath == "/index": urlPath = "/" case strings.HasSuffix(urlPath, "/index"): urlPath = strings.TrimSuffix(urlPath, "/index") } outPath := filepath.Join(b.OutputDir, filepath.FromSlash( strings.TrimSuffix(filepath.ToSlash(rel), ".md")+".html")) _ = os.Remove(outPath) _ = b.MetaDB.DeletePage(urlPath) _ = b.SearchDB.DeletePage(urlPath) fmt.Printf("removed %s\n", outPath) return nil } func (b *Builder) loadTemplates() error { tmpl, err := template.ParseGlob(filepath.Join(b.TemplateDir, "*.html")) if err != nil { return err } b.tmpl = tmpl return nil } // Watch monitors source directories and rebuilds on changes. // A 150 ms debounce prevents redundant rebuilds when many files change at once. func (b *Builder) Watch() error { watcher, err := fsnotify.NewWatcher() if err != nil { return err } defer watcher.Close() // Add all dirs (including nested content subdirs) to watcher. watchDirs := []string{b.ContentDir, b.TemplateDir, b.ComponentDir, b.LibDir, "styles"} for _, dir := range watchDirs { if err := addDirRecursive(watcher, dir); err != nil && !os.IsNotExist(err) { return err } } fmt.Println("watching for changes — Ctrl+C to stop") var ( debounce = time.NewTimer(0) pendingMD = "" // non-empty → rebuild only this file fullBuild = false ) <-debounce.C // drain initial tick for { select { case event, ok := <-watcher.Events: if !ok { return nil } if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) && !event.Has(fsnotify.Remove) { continue } // If a new directory appears, start watching it. if event.Has(fsnotify.Create) { if info, err := os.Stat(event.Name); err == nil && info.IsDir() { _ = watcher.Add(event.Name) } } isMD := strings.HasSuffix(event.Name, ".md") isContentMD := isMD && strings.HasPrefix( filepath.ToSlash(event.Name), filepath.ToSlash(b.ContentDir), ) if isContentMD && !fullBuild { if event.Has(fsnotify.Remove) { b.RemovePage(event.Name) pendingMD = "" } else if pendingMD == "" { pendingMD = event.Name } else if pendingMD != event.Name { // Multiple different md files → full rebuild. fullBuild = true pendingMD = "" } } else { // Templates, styles, components, lib, or multiple md changed. fullBuild = true pendingMD = "" } debounce.Reset(150 * time.Millisecond) case <-debounce.C: importMap, _ := GenerateImportMap(b.LibDir) if fullBuild { if err := b.loadTemplates(); err == nil { _ = b.BuildAll() } fullBuild = false } else if pendingMD != "" { if err := b.loadTemplates(); err == nil { _ = b.BuildFile(pendingMD, importMap) } pendingMD = "" } case err, ok := <-watcher.Errors: if !ok { return nil } fmt.Fprintf(os.Stderr, "watch error: %v\n", err) } } } func addDirRecursive(w *fsnotify.Watcher, root string) error { return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return nil // skip unreadable entries } if d.IsDir() { return w.Add(path) } return nil }) }