From 3cb7c82cf7c4e050148f69be23590a7fbe587a27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 10:11:18 +0000 Subject: Add static site builder: SQLite-backed MD→HTML pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/nebbet: CLI with build [--watch] and user add/passwd/delete/list - internal/builder: markdown→HTML, component injection via HTML comments, auto importmap from lib/, fsnotify watch with 150ms debounce - internal/db: meta.db (page index, tag queries) + search.db (FTS5) - internal/sqlitedrv: minimal CGO database/sql driver for system libsqlite3 - internal/auth: htpasswd-compatible bcrypt password file management - templates/base.html + admin.html, styles/main.css + admin.css - nginx.conf with auth_basic for /admin, clean URLs, gzip - nebbet.service systemd unit for watch daemon - Example content/index.md and components/site-greeting.js https://claude.ai/code/session_01HTc1BCBCiMTEB54XQP1Wz9 --- internal/builder/builder.go | 283 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 internal/builder/builder.go (limited to 'internal/builder/builder.go') diff --git a/internal/builder/builder.go b/internal/builder/builder.go new file mode 100644 index 0000000..40be377 --- /dev/null +++ b/internal/builder/builder.go @@ -0,0 +1,283 @@ +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 + }) +} -- cgit v1.3