summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/auth/auth.go3
-rw-r--r--internal/admin/templates/base.html5
-rw-r--r--internal/admin/templates/list.html21
-rw-r--r--internal/builder/builder.go288
-rw-r--r--internal/builder/components.go97
-rw-r--r--internal/builder/editorjs.go247
-rw-r--r--internal/builder/frontmatter.go64
-rw-r--r--internal/builder/importmap.go58
-rw-r--r--internal/builder/markdown.go49
-rw-r--r--internal/server/fileserver.go86
-rw-r--r--internal/server/frontpage.go86
11 files changed, 440 insertions, 564 deletions
diff --git a/internal/admin/auth/auth.go b/internal/admin/auth/auth.go
index b0de7d9..33f13bc 100644
--- a/internal/admin/auth/auth.go
+++ b/internal/admin/auth/auth.go
@@ -25,6 +25,9 @@ func (a *Auth) AddUser(username string) error {
if err != nil && !os.IsNotExist(err) {
return err
}
+ if users == nil {
+ users = make(map[string]string)
+ }
if _, exists := users[username]; exists {
return fmt.Errorf("user %q already exists", username)
}
diff --git a/internal/admin/templates/base.html b/internal/admin/templates/base.html
index f88bba6..083d04b 100644
--- a/internal/admin/templates/base.html
+++ b/internal/admin/templates/base.html
@@ -6,12 +6,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — {{.Title}}</title>
- <link rel="stylesheet" href="/styles/admin.css">
- <link rel="stylesheet" href="/lib/milkdown-crepe/style.css">
+ <link rel="stylesheet" href="/assets/styles/admin.css">
<script type="importmap">
{
"imports": {
- "@milkdown/crepe": "/lib/node_modules/@milkdown/crepe"
+ "editorjs-bundle": "/assets/admin/lib/dist/build.js"
}
}
</script>
diff --git a/internal/admin/templates/list.html b/internal/admin/templates/list.html
index 561e317..b8a3a74 100644
--- a/internal/admin/templates/list.html
+++ b/internal/admin/templates/list.html
@@ -7,6 +7,7 @@
<th>Title</th>
<th>Date</th>
<th>Tags</th>
+ <th>Status</th>
<th></th>
</tr>
</thead>
@@ -18,16 +19,17 @@
<td>
{{if .Tags}}
<div class="tags">
- {{range (splitTags .Tags)}}<span class="tag">{{.}}</span>{{end}}
+ {{range .Tags}}<span class="tag">{{.}}</span>{{end}}
</div>
{{end}}
</td>
<td>
+ {{if .Draft}}<span class="badge badge-draft">Draft</span>{{else}}<span class="badge badge-published">Published</span>{{end}}
+ </td>
+ <td>
<div class="actions">
- <a href="/admin/{{.Slug}}/edit" class="btn btn-secondary">Edit</a>
- <form method="POST" action="/admin/{{.Slug}}/delete" onsubmit="return confirm('Delete {{.Title}}?')">
- <button type="submit" class="btn btn-danger">Delete</button>
- </form>
+ <a href="/admin/{{.Slug}}" class="btn btn-secondary">Edit</a>
+ <button class="btn btn-danger" onclick="deletePost('{{.Slug}}', '{{.Title}}')">Delete</button>
</div>
</td>
</tr>
@@ -37,4 +39,13 @@
{{else}}
<div class="empty">No posts yet. <a href="/admin/new">Create one.</a></div>
{{end}}
+
+ <script>
+ function deletePost(slug, title) {
+ if (!confirm('Delete "' + title + '"?')) return;
+ fetch('/admin/' + slug, { method: 'DELETE' })
+ .then(() => window.location.href = '/admin/')
+ .catch(err => alert('Delete failed: ' + err));
+ }
+ </script>
{{end}}
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, `"`, `&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/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)
-}
diff --git a/internal/server/fileserver.go b/internal/server/fileserver.go
new file mode 100644
index 0000000..04650dc
--- /dev/null
+++ b/internal/server/fileserver.go
@@ -0,0 +1,86 @@
+package server
+
+import (
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AllowedMIMEs maps file extensions (including dot) to Content-Type values.
+type AllowedMIMEs map[string]string
+
+// FileHandler returns a Gin HandlerFunc that serves files from the given root directory.
+// It validates file extensions against the allowed map and rejects directories.
+// The wildcard param name is *filepath.
+func FileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ filepath := c.Param("filepath")
+ serveFile(c, root, filepath, allowed)
+ }
+}
+
+// PublicFileHandler returns a Gin HandlerFunc that serves files using the request URL path.
+// Used for NoRoute fallback to serve public static files.
+func PublicFileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ filepath := c.Request.URL.Path
+ serveFile(c, root, filepath, allowed)
+ }
+}
+
+func serveFile(c *gin.Context, root, requestPath string, allowed AllowedMIMEs) {
+ // Clean the path to prevent directory traversal
+ clean := path.Clean(requestPath)
+ if strings.Contains(clean, "..") {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Remove leading slash for filepath.Join
+ if strings.HasPrefix(clean, "/") {
+ clean = clean[1:]
+ }
+
+ // Map empty path or directory to index.html (clean URLs)
+ if clean == "" || clean == "." {
+ clean = "index.html"
+ }
+
+ fullPath := filepath.Join(root, clean)
+
+ // Check file existence; if directory, try index.html inside it
+ info, err := os.Stat(fullPath)
+ if err == nil && info.IsDir() {
+ fullPath = filepath.Join(fullPath, "index.html")
+ info, err = os.Stat(fullPath)
+ }
+ if err != nil {
+ // Try appending .html for clean URLs (e.g. /about -> /about.html)
+ htmlPath := fullPath + ".html"
+ if info2, err2 := os.Stat(htmlPath); err2 == nil && !info2.IsDir() {
+ fullPath = htmlPath
+ info = info2
+ err = nil
+ }
+ }
+ if err != nil || info.IsDir() {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Check file extension is allowed
+ ext := filepath.Ext(fullPath)
+ mimeType, ok := allowed[ext]
+ if !ok {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Serve the file with proper MIME type
+ c.Header("Content-Type", mimeType)
+ c.File(fullPath)
+}
diff --git a/internal/server/frontpage.go b/internal/server/frontpage.go
new file mode 100644
index 0000000..47a766a
--- /dev/null
+++ b/internal/server/frontpage.go
@@ -0,0 +1,86 @@
+package server
+
+import (
+ "html/template"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "nebbet.no/internal/db"
+)
+
+type FrontpageHandler struct {
+ DB *db.MetaDB
+ SearchDB *db.SearchDB
+ Templates *template.Template
+}
+
+type frontpageData struct {
+ Posts []db.PostRecord
+ Query string
+ ActiveTag string
+}
+
+func NewFrontpageHandler(metaDB *db.MetaDB, searchDB *db.SearchDB, tmpl *template.Template) *FrontpageHandler {
+ return &FrontpageHandler{DB: metaDB, SearchDB: searchDB, Templates: tmpl}
+}
+
+func (h *FrontpageHandler) Serve(c *gin.Context) {
+ query := strings.TrimSpace(c.Query("q"))
+ tag := strings.TrimSpace(c.Query("tag"))
+
+ data := frontpageData{
+ Query: query,
+ ActiveTag: tag,
+ }
+
+ if query != "" {
+ // Full-text search
+ results, err := h.SearchDB.Search(query)
+ if err == nil {
+ data.Posts = searchResultsToPosts(results, h.DB)
+ }
+ } else {
+ posts, err := h.DB.ListPosts(false) // exclude drafts
+ if err == nil {
+ if tag != "" {
+ posts = filterByTag(posts, tag)
+ }
+ data.Posts = posts
+ }
+ }
+
+ c.Header("Content-Type", "text/html; charset=utf-8")
+ if err := h.Templates.ExecuteTemplate(c.Writer, "index.html", data); err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ }
+}
+
+func filterByTag(posts []db.PostRecord, tag string) []db.PostRecord {
+ var filtered []db.PostRecord
+ for _, p := range posts {
+ for _, t := range p.Tags {
+ if strings.EqualFold(t, tag) {
+ filtered = append(filtered, p)
+ break
+ }
+ }
+ }
+ return filtered
+}
+
+func searchResultsToPosts(results []db.SearchResult, metaDB *db.MetaDB) []db.PostRecord {
+ var posts []db.PostRecord
+ for _, r := range results {
+ slug := strings.TrimPrefix(r.Path, "/")
+ if slug == r.Path {
+ continue // not a post
+ }
+ post, err := metaDB.GetPost(slug)
+ if err != nil || post.Draft {
+ continue
+ }
+ posts = append(posts, *post)
+ }
+ return posts
+}