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
}
// 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(
"")
}
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
})
}