diff options
Diffstat (limited to 'internal/builder/builder.go')
| -rw-r--r-- | internal/builder/builder.go | 288 |
1 files changed, 0 insertions, 288 deletions
diff --git a/internal/builder/builder.go b/internal/builder/builder.go deleted file mode 100644 index 59bb71b..0000000 --- a/internal/builder/builder.go +++ /dev/null @@ -1,288 +0,0 @@ -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 <script type="importmap">…</script> 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 - } - // Skip content/admin/ — served dynamically by the admin HTTP server. - rel, _ := filepath.Rel(b.ContentDir, path) - if strings.HasPrefix(filepath.ToSlash(rel), "admin/") || filepath.ToSlash(rel) == "admin" { - return nil - } - 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( - "<script type=\"importmap\">" + importMap + "</script>") - } - 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 - }) -} |
