summaryrefslogtreecommitdiffstats
path: root/internal/builder
diff options
context:
space:
mode:
Diffstat (limited to 'internal/builder')
-rw-r--r--internal/builder/builder.go283
-rw-r--r--internal/builder/components.go97
-rw-r--r--internal/builder/frontmatter.go64
-rw-r--r--internal/builder/importmap.go58
-rw-r--r--internal/builder/markdown.go49
5 files changed, 551 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
+ })
+}
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 <!-- component:tag-name { ...json... } -->
+// Props JSON is optional.
+var componentRe = regexp.MustCompile(
+ `<!--\s*component:([a-z][a-z0-9-]*)\s*(\{[^}]*\})?\s*-->`)
+
+// 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.
+//
+// <!-- component:my-counter {"start": 5, "label": "Count"} -->
+// → <my-counter start="5" label="Count"></my-counter>
+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></%s>`, tagName, attrs, tagName)
+ }
+ return fmt.Sprintf(`<%s></%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, `"`, `&quot;`)))
+ 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/<name>.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)
+}