summaryrefslogtreecommitdiffstats
path: root/internal/builder/builder.go
diff options
context:
space:
mode:
authorIvar Løvlie <38570165+ivarlovlie@users.noreply.github.com>2026-03-31 12:27:46 +0200
committerGitHub <noreply@github.com>2026-03-31 12:27:46 +0200
commit8d7cda6e578e684483c0b5c7391c48e5b9ac5192 (patch)
treed2b6506db2de72b3a6982cfbe69925b88936de90 /internal/builder/builder.go
parent33f214f6cd9729473bb55fd7b3b923d5d960bb98 (diff)
parent3cb7c82cf7c4e050148f69be23590a7fbe587a27 (diff)
downloadnebbet.no-8d7cda6e578e684483c0b5c7391c48e5b9ac5192.tar.xz
nebbet.no-8d7cda6e578e684483c0b5c7391c48e5b9ac5192.zip
Merge pull request #1 from ivarlovlie/claude/static-site-sqlite-setup-mrcAr
Diffstat (limited to 'internal/builder/builder.go')
-rw-r--r--internal/builder/builder.go283
1 files changed, 283 insertions, 0 deletions
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 <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
+ }
+ 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
+ })
+}