diff options
| author | ivar <i@oiee.no> | 2026-04-04 16:34:46 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-04 16:34:46 +0200 |
| commit | a6355e7a6530af3335c4cd8af05f1e9c8b978169 (patch) | |
| tree | c9d920d1e996ef1c42d3455825731598df6b56c2 /internal/builder | |
| parent | 8a093aacd162d3fd9f142b53aab9edfa061fd66a (diff) | |
| download | nebbet.no-a6355e7a6530af3335c4cd8af05f1e9c8b978169.tar.xz nebbet.no-a6355e7a6530af3335c4cd8af05f1e9c8b978169.zip | |
.
Diffstat (limited to 'internal/builder')
| -rw-r--r-- | internal/builder/builder.go | 288 | ||||
| -rw-r--r-- | internal/builder/components.go | 97 | ||||
| -rw-r--r-- | internal/builder/editorjs.go | 247 | ||||
| -rw-r--r-- | internal/builder/frontmatter.go | 64 | ||||
| -rw-r--r-- | internal/builder/importmap.go | 58 | ||||
| -rw-r--r-- | internal/builder/markdown.go | 49 |
6 files changed, 247 insertions, 556 deletions
diff --git a/internal/builder/builder.go b/internal/builder/builder.go deleted file mode 100644 index 59bb71b..0000000 --- a/internal/builder/builder.go +++ /dev/null @@ -1,288 +0,0 @@ -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 - } - // 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( - "<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 deleted file mode 100644 index 54a226a..0000000 --- a/internal/builder/components.go +++ /dev/null @@ -1,97 +0,0 @@ -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, `"`, `"`))) - 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/editorjs.go b/internal/builder/editorjs.go new file mode 100644 index 0000000..4db4d84 --- /dev/null +++ b/internal/builder/editorjs.go @@ -0,0 +1,247 @@ +package builder + +import ( + "encoding/json" + "fmt" + "html/template" + "strings" +) + +// PageData is passed to HTML templates when rendering posts. +type PageData struct { + Title string + Content template.HTML + ComponentScripts []string + Date string + Tags []string + Path string +} + +type EditorDocument struct { + Version string `json:"version"` + Time int64 `json:"time"` + Blocks []EditorBlock `json:"blocks"` +} + +type EditorBlock struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// RenderEditorJS converts EditorJS block JSON to HTML and extracts script URLs. +// Returns the rendered HTML body, script URLs, and any error. +func RenderEditorJS(blocksJSON string) (html string, scripts []string, err error) { + if blocksJSON == "" || blocksJSON == "[]" { + return "", nil, nil + } + + var doc EditorDocument + if err := json.Unmarshal([]byte(blocksJSON), &doc); err != nil { + return "", nil, err + } + + var buf strings.Builder + var scriptURLs []string + + for _, block := range doc.Blocks { + blockHTML, blockScripts, err := renderBlock(block) + if err != nil { + return "", nil, err + } + if blockHTML != "" { + buf.WriteString(blockHTML) + } + scriptURLs = append(scriptURLs, blockScripts...) + } + + return buf.String(), scriptURLs, nil +} + +func renderBlock(block EditorBlock) (html string, scripts []string, err error) { + switch block.Type { + case "paragraph": + return renderParagraph(block.Data) + case "header": + return renderHeader(block.Data) + case "image": + return renderImage(block.Data) + case "list": + return renderList(block.Data) + case "code": + return renderCode(block.Data) + case "quote": + return renderQuote(block.Data) + case "script": + return renderScript(block.Data) + case "component": + return renderComponent(block.Data) + default: + // Silently ignore unknown block types + return "", nil, nil + } +} + +func renderParagraph(data json.RawMessage) (string, []string, error) { + var d struct { + Text string `json:"text"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if strings.TrimSpace(d.Text) == "" { + return "", nil, nil + } + // EditorJS stores inline HTML; wrap in template.HTML to avoid re-escaping + html := fmt.Sprintf("<p>%s</p>\n", template.HTML(d.Text)) + return html, nil, nil +} + +func renderHeader(data json.RawMessage) (string, []string, error) { + var d struct { + Text string `json:"text"` + Level int `json:"level"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if d.Level < 1 || d.Level > 6 { + d.Level = 2 + } + if strings.TrimSpace(d.Text) == "" { + return "", nil, nil + } + html := fmt.Sprintf("<h%d>%s</h%d>\n", d.Level, template.HTML(d.Text), d.Level) + return html, nil, nil +} + +func renderImage(data json.RawMessage) (string, []string, error) { + var d struct { + File struct { + URL string `json:"url"` + } `json:"file"` + Caption string `json:"caption"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if d.File.URL == "" { + return "", nil, nil + } + alt := d.Caption + if alt == "" { + alt = "image" + } + html := fmt.Sprintf( + "<figure><img src=\"%s\" alt=\"%s\"><figcaption>%s</figcaption></figure>\n", + template.HTMLEscapeString(d.File.URL), + template.HTMLEscapeString(alt), + template.HTML(d.Caption), + ) + return html, nil, nil +} + +type listItem struct { + Content string `json:"content"` + Items []listItem `json:"items"` +} + +func renderList(data json.RawMessage) (string, []string, error) { + var d struct { + Style string `json:"style"` // "ordered" or "unordered" + Items []listItem `json:"items"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if len(d.Items) == 0 { + return "", nil, nil + } + tag := "ul" + if d.Style == "ordered" { + tag = "ol" + } + var buf strings.Builder + renderListItems(&buf, d.Items, tag) + return buf.String(), nil, nil +} + +func renderListItems(buf *strings.Builder, items []listItem, tag string) { + fmt.Fprintf(buf, "<%s>\n", tag) + for _, item := range items { + fmt.Fprintf(buf, "<li>%s", template.HTML(item.Content)) + if len(item.Items) > 0 { + renderListItems(buf, item.Items, tag) + } + buf.WriteString("</li>\n") + } + fmt.Fprintf(buf, "</%s>\n", tag) +} + +func renderCode(data json.RawMessage) (string, []string, error) { + var d struct { + Code string `json:"code"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if strings.TrimSpace(d.Code) == "" { + return "", nil, nil + } + html := fmt.Sprintf("<pre><code>%s</code></pre>\n", template.HTMLEscapeString(d.Code)) + return html, nil, nil +} + +func renderQuote(data json.RawMessage) (string, []string, error) { + var d struct { + Text string `json:"text"` + Caption string `json:"caption"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if strings.TrimSpace(d.Text) == "" { + return "", nil, nil + } + var buf strings.Builder + fmt.Fprintf(&buf, "<blockquote>\n<p>%s</p>\n", template.HTML(d.Text)) + if d.Caption != "" { + fmt.Fprintf(&buf, "<cite>%s</cite>\n", template.HTML(d.Caption)) + } + buf.WriteString("</blockquote>\n") + return buf.String(), nil, nil +} + +func renderScript(data json.RawMessage) (string, []string, error) { + var d struct { + Src string `json:"src"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if d.Src == "" { + return "", nil, nil + } + // script blocks don't render inline HTML; return the URL for the template to inject + return "", []string{d.Src}, nil +} + +func renderComponent(data json.RawMessage) (string, []string, error) { + var d struct { + Name string `json:"name"` + Props map[string]string `json:"props"` + } + if err := json.Unmarshal(data, &d); err != nil { + return "", nil, err + } + if d.Name == "" { + return "", nil, nil + } + var buf strings.Builder + fmt.Fprintf(&buf, "<%s", template.HTMLEscapeString(d.Name)) + for k, v := range d.Props { + fmt.Fprintf(&buf, " %s=\"%s\"", template.HTMLEscapeString(k), template.HTMLEscapeString(v)) + } + fmt.Fprintf(&buf, "></%s>\n", template.HTMLEscapeString(d.Name)) + script := "/assets/components/" + d.Name + ".js" + return buf.String(), []string{script}, nil +} diff --git a/internal/builder/frontmatter.go b/internal/builder/frontmatter.go deleted file mode 100644 index 34de484..0000000 --- a/internal/builder/frontmatter.go +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 8445411..0000000 --- a/internal/builder/importmap.go +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 4e00ca3..0000000 --- a/internal/builder/markdown.go +++ /dev/null @@ -1,49 +0,0 @@ -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) -} |
