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 ++++++++++++++++++++++++++++++++++++++++ internal/builder/components.go | 97 ++++++++++++++ internal/builder/frontmatter.go | 64 +++++++++ internal/builder/importmap.go | 58 ++++++++ internal/builder/markdown.go | 49 +++++++ 5 files changed, 551 insertions(+) create mode 100644 internal/builder/builder.go create mode 100644 internal/builder/components.go create mode 100644 internal/builder/frontmatter.go create mode 100644 internal/builder/importmap.go create mode 100644 internal/builder/markdown.go (limited to 'internal/builder') 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 + }) +} diff --git a/internal/builder/components.go b/internal/builder/components.go new file mode 100644 index 0000000..54a226a --- /dev/null +++ b/internal/builder/components.go @@ -0,0 +1,97 @@ +package builder + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// componentRe matches +// Props JSON is optional. +var componentRe = regexp.MustCompile( + ``) + +// customElementRe matches opening tags for custom elements (name must contain a hyphen). +var customElementRe = regexp.MustCompile(`<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]`) + +// ProcessComponents replaces HTML comment component directives with custom element tags. +// +// +// → +func ProcessComponents(html string) string { + return componentRe.ReplaceAllStringFunc(html, func(match string) string { + subs := componentRe.FindStringSubmatch(match) + if len(subs) < 2 { + return match + } + tagName := subs[1] + attrs := "" + if len(subs) > 2 && subs[2] != "" { + var props map[string]any + if err := json.Unmarshal([]byte(subs[2]), &props); err == nil { + attrs = propsToAttrs(props) + } + } + if attrs != "" { + return fmt.Sprintf(`<%s %s>`, tagName, attrs, tagName) + } + return fmt.Sprintf(`<%s>`, tagName, tagName) + }) +} + +// propsToAttrs converts a JSON props map to an HTML attribute string. +// Keys are emitted in sorted order for deterministic output. +func propsToAttrs(props map[string]any) string { + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + + var parts []string + for _, k := range keys { + v := props[k] + switch val := v.(type) { + case string: + parts = append(parts, fmt.Sprintf(`%s="%s"`, k, strings.ReplaceAll(val, `"`, `"`))) + case bool: + if val { + parts = append(parts, k) // boolean attribute, no value + } + case float64: + if val == float64(int64(val)) { + parts = append(parts, fmt.Sprintf(`%s="%d"`, k, int64(val))) + } else { + parts = append(parts, fmt.Sprintf(`%s="%g"`, k, val)) + } + default: + // Complex value → JSON-encode into single-quoted attribute. + b, _ := json.Marshal(v) + parts = append(parts, fmt.Sprintf(`%s='%s'`, k, string(b))) + } + } + return strings.Join(parts, " ") +} + +// FindComponentScripts scans HTML for used custom elements and returns +// /components/.js paths for any that exist on disk. +func FindComponentScripts(html, componentsDir string) []string { + matches := customElementRe.FindAllStringSubmatch(html, -1) + seen := make(map[string]bool) + var scripts []string + for _, m := range matches { + if len(m) < 2 || seen[m[1]] { + continue + } + seen[m[1]] = true + jsPath := filepath.Join(componentsDir, m[1]+".js") + if _, err := os.Stat(jsPath); err == nil { + scripts = append(scripts, "/components/"+m[1]+".js") + } + } + return scripts +} diff --git a/internal/builder/frontmatter.go b/internal/builder/frontmatter.go new file mode 100644 index 0000000..34de484 --- /dev/null +++ b/internal/builder/frontmatter.go @@ -0,0 +1,64 @@ +package builder + +import ( + "strings" +) + +// Frontmatter holds parsed page metadata from YAML-style front matter. +type Frontmatter struct { + Title string + Date string + Tags []string + Layout string // template name without extension, default "base" + Draft bool +} + +// ParseFrontmatter splits the optional ---...--- block from the markdown body. +// Supports: title, date, tags (comma-list or [a, b]), layout, draft. +func ParseFrontmatter(content string) (Frontmatter, string) { + fm := Frontmatter{Layout: "base"} + if !strings.HasPrefix(content, "---") { + return fm, content + } + // Find closing --- + rest := content[3:] + end := strings.Index(rest, "\n---") + if end == -1 { + return fm, content + } + block := strings.TrimSpace(rest[:end]) + body := strings.TrimSpace(rest[end+4:]) // skip \n--- + + for _, line := range strings.Split(block, "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), ":") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + switch k { + case "title": + fm.Title = strings.Trim(v, `"'`) + case "date": + fm.Date = v + case "layout": + fm.Layout = strings.Trim(v, `"'`) + case "draft": + fm.Draft = v == "true" + case "tags": + fm.Tags = parseTags(v) + } + } + return fm, body +} + +func parseTags(v string) []string { + v = strings.Trim(v, "[] ") + var tags []string + for _, p := range strings.Split(v, ",") { + if t := strings.Trim(strings.TrimSpace(p), `"'`); t != "" { + tags = append(tags, t) + } + } + return tags +} diff --git a/internal/builder/importmap.go b/internal/builder/importmap.go new file mode 100644 index 0000000..8445411 --- /dev/null +++ b/internal/builder/importmap.go @@ -0,0 +1,58 @@ +package builder + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// ImportMap represents a browser importmap. +type ImportMap struct { + Imports map[string]string `json:"imports"` +} + +// GenerateImportMap scans libDir for .js files and produces an importmap JSON string. +// +// Naming rules: +// - lib/chart.js → "chart" +// - lib/icons/index.js → "icons" +// - lib/utils/helpers.js → "utils/helpers" +func GenerateImportMap(libDir string) (string, error) { + imports := make(map[string]string) + + if _, err := os.Stat(libDir); os.IsNotExist(err) { + b, _ := json.MarshalIndent(ImportMap{Imports: imports}, "", " ") + return string(b), nil + } + + err := filepath.WalkDir(libDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".js") { + return err + } + rel, _ := filepath.Rel(libDir, path) + rel = filepath.ToSlash(rel) + + dir := filepath.ToSlash(filepath.Dir(rel)) + base := strings.TrimSuffix(filepath.Base(rel), ".js") + + var importName string + switch { + case dir == ".": + importName = base + case base == "index": + importName = dir + default: + importName = dir + "/" + base + } + + imports[importName] = "/lib/" + rel + return nil + }) + if err != nil { + return "", err + } + + b, err := json.MarshalIndent(ImportMap{Imports: imports}, "", " ") + return string(b), err +} diff --git a/internal/builder/markdown.go b/internal/builder/markdown.go new file mode 100644 index 0000000..4e00ca3 --- /dev/null +++ b/internal/builder/markdown.go @@ -0,0 +1,49 @@ +package builder + +import ( + "bytes" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" +) + +var md = goldmark.New( + goldmark.WithExtensions( + extension.GFM, + extension.Table, + extension.Strikethrough, + extension.TaskList, + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + // Allow raw HTML pass-through so component tags survive round-trip. + html.WithUnsafe(), + ), +) + +// MarkdownToHTML converts a markdown string to an HTML fragment. +func MarkdownToHTML(body string) (string, error) { + var buf bytes.Buffer + if err := md.Convert([]byte(body), &buf); err != nil { + return "", err + } + return buf.String(), nil +} + +var ( + htmlTagRe = regexp.MustCompile(`<[^>]+>`) + multiSpaceRe = regexp.MustCompile(`\s+`) +) + +// StripHTML removes HTML tags and normalises whitespace for search indexing. +func StripHTML(h string) string { + plain := htmlTagRe.ReplaceAllString(h, " ") + plain = multiSpaceRe.ReplaceAllString(plain, " ") + return strings.TrimSpace(plain) +} -- cgit v1.3