diff options
| author | Ivar Løvlie <38570165+ivarlovlie@users.noreply.github.com> | 2026-03-31 12:49:52 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-31 12:49:52 +0200 |
| commit | 6eb034f132bcc521b0d088c538bbdc2ce9fc1190 (patch) | |
| tree | c4f4abfafb6e8e47a1ab505fe1253b951ccf77c9 | |
| parent | 8d7cda6e578e684483c0b5c7391c48e5b9ac5192 (diff) | |
| parent | aa23774abb90c168c9ba2559d6bf381bc9fc55ba (diff) | |
| download | nebbet.no-6eb034f132bcc521b0d088c538bbdc2ce9fc1190.tar.xz nebbet.no-6eb034f132bcc521b0d088c538bbdc2ce9fc1190.zip | |
Merge pull request #2 from ivarlovlie/claude/add-post-management-rqIiO
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | cmd/nebbet/main.go | 229 | ||||
| -rw-r--r-- | compat/sqlite/go.mod | 3 | ||||
| -rw-r--r-- | compat/sqlite/sqlite.go | 265 | ||||
| -rw-r--r-- | content/admin/index.md | 2 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | internal/admin/server.go | 476 | ||||
| -rw-r--r-- | internal/builder/builder.go | 5 | ||||
| -rw-r--r-- | internal/db/meta.go | 4 | ||||
| -rw-r--r-- | internal/db/search.go | 4 |
10 files changed, 987 insertions, 6 deletions
@@ -1,4 +1,4 @@ public/ data/ .passwords -nebbet +/nebbet diff --git a/cmd/nebbet/main.go b/cmd/nebbet/main.go new file mode 100644 index 0000000..1d8b3c0 --- /dev/null +++ b/cmd/nebbet/main.go @@ -0,0 +1,229 @@ +// Command nebbet is the CLI for the nebbet.no static site generator. +// +// Usage: +// +// nebbet build – build all pages once +// nebbet watch – watch for changes and rebuild +// nebbet serve [--port N] – serve with live admin UI + watch mode +// nebbet user add <name> – add a user to the passwords file +// nebbet user list – list users +// nebbet user delete <name> – remove a user +// nebbet user passwd <name> – change a user's password +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + + "nebbet.no/internal/admin" + "nebbet.no/internal/auth" + "nebbet.no/internal/builder" + "nebbet.no/internal/db" +) + +const ( + contentDir = "content" + outputDir = "public" + postsDir = "content/posts" + dataDir = "data" + metaDBPath = "data/meta.db" + searchDBPath = "data/search.db" + passwordFile = ".passwords" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + switch os.Args[1] { + case "build": + cmdBuild(os.Args[2:]) + case "watch": + cmdWatch() + case "serve": + cmdServe(os.Args[2:]) + case "user": + cmdUser(os.Args[2:]) + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1]) + usage() + os.Exit(1) + } +} + +// ── build ───────────────────────────────────────────────────────────────────── + +func cmdBuild(args []string) { + fs := flag.NewFlagSet("build", flag.ExitOnError) + watch := fs.Bool("watch", false, "watch for changes and rebuild") + _ = fs.Parse(args) + + b := mustBuilder() + defer b.MetaDB.Close() + defer b.SearchDB.Close() + + if err := b.BuildAll(); err != nil { + fmt.Fprintln(os.Stderr, "build error:", err) + os.Exit(1) + } + if *watch { + if err := b.Watch(); err != nil { + fmt.Fprintln(os.Stderr, "watch error:", err) + os.Exit(1) + } + } +} + +// ── watch ───────────────────────────────────────────────────────────────────── + +func cmdWatch() { + b := mustBuilder() + defer b.MetaDB.Close() + defer b.SearchDB.Close() + if err := b.BuildAll(); err != nil { + fmt.Fprintln(os.Stderr, "build error:", err) + os.Exit(1) + } + if err := b.Watch(); err != nil { + fmt.Fprintln(os.Stderr, "watch error:", err) + os.Exit(1) + } +} + +// ── serve ───────────────────────────────────────────────────────────────────── + +func cmdServe(args []string) { + fs := flag.NewFlagSet("serve", flag.ExitOnError) + port := fs.String("port", "8080", "port to listen on") + _ = fs.Parse(args) + + b := mustBuilder() + defer b.MetaDB.Close() + defer b.SearchDB.Close() + + // Initial build. + if err := b.BuildAll(); err != nil { + fmt.Fprintln(os.Stderr, "build error:", err) + os.Exit(1) + } + + // Watch in background. + go func() { + if err := b.Watch(); err != nil { + fmt.Fprintln(os.Stderr, "watch error:", err) + } + }() + + adminSrv := &admin.Server{ + PostsDir: postsDir, + AuthFile: passwordFile, + Builder: b, + } + + mux := http.NewServeMux() + // Admin routes — handled dynamically, never from the static output dir. + mux.Handle("/admin/", http.StripPrefix("/admin", adminSrv)) + // Everything else — serve the pre-built static files. + mux.Handle("/", http.FileServer(http.Dir(outputDir))) + + addr := ":" + *port + fmt.Printf("listening on http://localhost%s\n", addr) + fmt.Printf(" public site: http://localhost%s/\n", addr) + fmt.Printf(" admin UI: http://localhost%s/admin/\n", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// ── user ───────────────────────────────────────────────────────────────────── + +func cmdUser(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: nebbet user <add|list|delete|passwd> [username]") + os.Exit(1) + } + a := auth.New(passwordFile) + switch args[0] { + case "add": + requireArg(args, 1, "nebbet user add <username>") + if err := a.AddUser(args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("user added:", args[1]) + case "list": + users, err := a.ListUsers() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(users) == 0 { + fmt.Println("(no users)") + return + } + for _, u := range users { + fmt.Println(u) + } + case "delete": + requireArg(args, 1, "nebbet user delete <username>") + if err := a.DeleteUser(args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("user deleted:", args[1]) + case "passwd": + requireArg(args, 1, "nebbet user passwd <username>") + if err := a.ChangePassword(args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("password updated:", args[1]) + default: + fmt.Fprintf(os.Stderr, "unknown user subcommand %q\n", args[0]) + os.Exit(1) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func mustBuilder() *builder.Builder { + if err := os.MkdirAll(dataDir, 0755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + meta, err := db.OpenMeta(metaDBPath) + if err != nil { + fmt.Fprintln(os.Stderr, "meta db:", err) + os.Exit(1) + } + search, err := db.OpenSearch(searchDBPath) + if err != nil { + fmt.Fprintln(os.Stderr, "search db:", err) + os.Exit(1) + } + return builder.New(contentDir, outputDir, meta, search) +} + +func requireArg(args []string, n int, usage string) { + if len(args) <= n { + fmt.Fprintln(os.Stderr, "usage:", usage) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, `nebbet — static site generator with admin + +Commands: + build [--watch] build all pages (optionally watch for changes) + watch watch for changes and rebuild + serve [--port N] serve site + admin UI (default port 8080) + user add <name> add user to password file + user list list users + user delete <name> remove user + user passwd <name> change user password`) +} diff --git a/compat/sqlite/go.mod b/compat/sqlite/go.mod new file mode 100644 index 0000000..8779817 --- /dev/null +++ b/compat/sqlite/go.mod @@ -0,0 +1,3 @@ +module modernc.org/sqlite + +go 1.22 diff --git a/compat/sqlite/sqlite.go b/compat/sqlite/sqlite.go new file mode 100644 index 0000000..be8c0c9 --- /dev/null +++ b/compat/sqlite/sqlite.go @@ -0,0 +1,265 @@ +// Package sqlite is a CGO-backed compatibility shim that provides the same +// driver registration as modernc.org/sqlite: it registers a "sqlite3" +// database/sql driver backed by the system libsqlite3. +// +// When network access is available this package can be replaced with the real +// modernc.org/sqlite (pure-Go, no CGO) by removing the replace directive from +// the root go.mod and running: go get modernc.org/sqlite +package sqlite + +/* +#cgo pkg-config: sqlite3 +#include <sqlite3.h> +#include <stdlib.h> + +static int bind_text(sqlite3_stmt *s, int i, const char *v) { + return sqlite3_bind_text(s, i, v, -1, SQLITE_TRANSIENT); +} +static void enable_wal(sqlite3 *db) { + sqlite3_exec(db, "PRAGMA journal_mode=WAL", NULL, NULL, NULL); + sqlite3_exec(db, "PRAGMA synchronous=NORMAL", NULL, NULL, NULL); +} +*/ +import "C" + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "time" + "unsafe" +) + +func init() { + sql.Register("sqlite3", &sqliteDriver{}) +} + +// ── Driver ──────────────────────────────────────────────────────────────────── + +type sqliteDriver struct{} + +func (*sqliteDriver) Open(name string) (driver.Conn, error) { + cname := C.CString(name) + defer C.free(unsafe.Pointer(cname)) + + var db *C.sqlite3 + flags := C.int(C.SQLITE_OPEN_READWRITE | C.SQLITE_OPEN_CREATE | C.SQLITE_OPEN_FULLMUTEX) + if rc := C.sqlite3_open_v2(cname, &db, flags, nil); rc != C.SQLITE_OK { + msg := C.GoString(C.sqlite3_errmsg(db)) + C.sqlite3_close(db) + return nil, fmt.Errorf("sqlite open %s: %s", name, msg) + } + C.enable_wal(db) + return &conn{db: db}, nil +} + +// ── Conn ───────────────────────────────────────────────────────────────────── + +type conn struct{ db *C.sqlite3 } + +func (c *conn) Close() error { + C.sqlite3_close(c.db) + return nil +} + +func (c *conn) Begin() (driver.Tx, error) { + if err := c.execRaw("BEGIN"); err != nil { + return nil, err + } + return &tx{c}, nil +} + +func (c *conn) Exec(query string, args []driver.Value) (driver.Result, error) { + if len(args) == 0 { + cq := C.CString(query) + defer C.free(unsafe.Pointer(cq)) + var cerr *C.char + if rc := C.sqlite3_exec(c.db, cq, nil, nil, &cerr); rc != C.SQLITE_OK { + msg := C.GoString(cerr) + C.sqlite3_free(unsafe.Pointer(cerr)) + return nil, errors.New(msg) + } + return &result{ + lastID: int64(C.sqlite3_last_insert_rowid(c.db)), + affected: int64(C.sqlite3_changes(c.db)), + }, nil + } + st, err := c.Prepare(query) + if err != nil { + return nil, err + } + defer st.Close() + return st.Exec(args) +} + +func (c *conn) Prepare(query string) (driver.Stmt, error) { + cq := C.CString(query) + defer C.free(unsafe.Pointer(cq)) + var s *C.sqlite3_stmt + if rc := C.sqlite3_prepare_v2(c.db, cq, -1, &s, nil); rc != C.SQLITE_OK { + return nil, fmt.Errorf("prepare: %s", C.GoString(C.sqlite3_errmsg(c.db))) + } + return &stmt{c: c, s: s}, nil +} + +func (c *conn) execRaw(q string) error { + cq := C.CString(q) + defer C.free(unsafe.Pointer(cq)) + var cerr *C.char + if rc := C.sqlite3_exec(c.db, cq, nil, nil, &cerr); rc != C.SQLITE_OK { + msg := C.GoString(cerr) + C.sqlite3_free(unsafe.Pointer(cerr)) + return errors.New(msg) + } + return nil +} + +// ── Tx ─────────────────────────────────────────────────────────────────────── + +type tx struct{ c *conn } + +func (t *tx) Commit() error { return t.c.execRaw("COMMIT") } +func (t *tx) Rollback() error { return t.c.execRaw("ROLLBACK") } + +// ── Stmt ───────────────────────────────────────────────────────────────────── + +type stmt struct { + c *conn + s *C.sqlite3_stmt +} + +func (st *stmt) Close() error { + C.sqlite3_finalize(st.s) + return nil +} + +func (st *stmt) NumInput() int { return int(C.sqlite3_bind_parameter_count(st.s)) } + +func (st *stmt) Exec(args []driver.Value) (driver.Result, error) { + C.sqlite3_reset(st.s) + if err := st.bind(args); err != nil { + return nil, err + } + rc := C.sqlite3_step(st.s) + if rc != C.SQLITE_DONE && rc != C.SQLITE_ROW { + return nil, fmt.Errorf("exec: %s", C.GoString(C.sqlite3_errmsg(st.c.db))) + } + return &result{ + lastID: int64(C.sqlite3_last_insert_rowid(st.c.db)), + affected: int64(C.sqlite3_changes(st.c.db)), + }, nil +} + +func (st *stmt) Query(args []driver.Value) (driver.Rows, error) { + C.sqlite3_reset(st.s) + if err := st.bind(args); err != nil { + return nil, err + } + ncols := int(C.sqlite3_column_count(st.s)) + cols := make([]string, ncols) + for i := range cols { + cols[i] = C.GoString(C.sqlite3_column_name(st.s, C.int(i))) + } + return &rows{st: st, cols: cols}, nil +} + +func (st *stmt) bind(args []driver.Value) error { + for i, arg := range args { + n := C.int(i + 1) + var rc C.int + switch v := arg.(type) { + case nil: + rc = C.sqlite3_bind_null(st.s, n) + case int64: + rc = C.sqlite3_bind_int64(st.s, n, C.sqlite3_int64(v)) + case float64: + rc = C.sqlite3_bind_double(st.s, n, C.double(v)) + case bool: + b := C.int(0) + if v { + b = 1 + } + rc = C.sqlite3_bind_int(st.s, n, b) + case string: + cs := C.CString(v) + rc = C.bind_text(st.s, n, cs) + C.free(unsafe.Pointer(cs)) + case []byte: + if len(v) == 0 { + rc = C.sqlite3_bind_null(st.s, n) + } else { + rc = C.sqlite3_bind_blob(st.s, n, + unsafe.Pointer(&v[0]), C.int(len(v)), C.SQLITE_TRANSIENT) + } + case time.Time: + s := v.UTC().Format(time.RFC3339) + cs := C.CString(s) + rc = C.bind_text(st.s, n, cs) + C.free(unsafe.Pointer(cs)) + default: + return fmt.Errorf("unsupported bind type %T at index %d", arg, i) + } + if rc != C.SQLITE_OK { + return fmt.Errorf("bind[%d]: %s", i, C.GoString(C.sqlite3_errmsg(st.c.db))) + } + } + return nil +} + +// ── Rows ───────────────────────────────────────────────────────────────────── + +type rows struct { + st *stmt + cols []string +} + +func (r *rows) Columns() []string { return r.cols } + +func (r *rows) Close() error { + C.sqlite3_reset(r.st.s) + return nil +} + +func (r *rows) Next(dest []driver.Value) error { + rc := C.sqlite3_step(r.st.s) + if rc == C.SQLITE_DONE { + return io.EOF + } + if rc != C.SQLITE_ROW { + return fmt.Errorf("next: %s", C.GoString(C.sqlite3_errmsg(r.st.c.db))) + } + for i := range dest { + switch C.sqlite3_column_type(r.st.s, C.int(i)) { + case C.SQLITE_INTEGER: + dest[i] = int64(C.sqlite3_column_int64(r.st.s, C.int(i))) + case C.SQLITE_FLOAT: + dest[i] = float64(C.sqlite3_column_double(r.st.s, C.int(i))) + case C.SQLITE_TEXT: + dest[i] = C.GoString((*C.char)(unsafe.Pointer( + C.sqlite3_column_text(r.st.s, C.int(i))))) + case C.SQLITE_BLOB: + sz := int(C.sqlite3_column_bytes(r.st.s, C.int(i))) + b := make([]byte, sz) + if sz > 0 { + ptr := C.sqlite3_column_blob(r.st.s, C.int(i)) + copy(b, (*[1 << 28]byte)(ptr)[:sz:sz]) + } + dest[i] = b + default: // SQLITE_NULL + dest[i] = nil + } + } + return nil +} + +// ── Result ──────────────────────────────────────────────────────────────────── + +type result struct { + lastID int64 + affected int64 +} + +func (r *result) LastInsertId() (int64, error) { return r.lastID, nil } +func (r *result) RowsAffected() (int64, error) { return r.affected, nil } diff --git a/content/admin/index.md b/content/admin/index.md index bd5fe23..c3badbb 100644 --- a/content/admin/index.md +++ b/content/admin/index.md @@ -1,7 +1,7 @@ --- title: Dashboard layout: admin -draft: false +draft: true --- # Admin Dashboard @@ -7,6 +7,9 @@ require ( github.com/yuin/goldmark v1.7.4 golang.org/x/crypto v0.26.0 golang.org/x/term v0.23.0 + modernc.org/sqlite v0.0.0-00010101000000-000000000000 ) require golang.org/x/sys v0.23.0 // indirect + +replace modernc.org/sqlite => ./compat/sqlite diff --git a/internal/admin/server.go b/internal/admin/server.go new file mode 100644 index 0000000..858d498 --- /dev/null +++ b/internal/admin/server.go @@ -0,0 +1,476 @@ +// Package admin provides an HTTP server for managing posts via a web UI. +// Admin pages are served dynamically and are never written to the static +// output directory — they are intentionally excluded from the site generator. +package admin + +import ( + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "nebbet.no/internal/auth" + "nebbet.no/internal/builder" +) + +// Server is an http.Handler that serves the admin post-management UI. +type Server struct { + // PostsDir is the directory where post markdown files are stored, + // e.g. "content/posts". It is created on first use if it doesn't exist. + PostsDir string + // AuthFile is the path to the htpasswd-compatible passwords file. + // Authentication is skipped when AuthFile is empty or the file doesn't exist. + AuthFile string + // Builder is used to rebuild pages after create/edit/delete operations. + Builder *builder.Builder + + tmpl *template.Template +} + +// post holds the metadata and content of a single post. +type post struct { + Slug string + Title string + Date string + Tags string // comma-separated + Content string // raw markdown body +} + +// ServeHTTP implements http.Handler. Expected to be mounted with a stripped +// prefix, e.g.: http.StripPrefix("/admin", srv) +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(w, r) { + return + } + + if s.tmpl == nil { + s.tmpl = mustParseTemplates() + } + + path := strings.TrimRight(r.URL.Path, "/") + + switch { + case path == "" || path == "/": + s.handleList(w, r) + case path == "/new": + s.handleNew(w, r) + case strings.HasSuffix(path, "/edit"): + slug := strings.TrimPrefix(strings.TrimSuffix(path, "/edit"), "/") + s.handleEdit(w, r, slug) + case strings.HasSuffix(path, "/delete"): + slug := strings.TrimPrefix(strings.TrimSuffix(path, "/delete"), "/") + s.handleDelete(w, r, slug) + default: + http.NotFound(w, r) + } +} + +// checkAuth performs HTTP Basic authentication against the passwords file. +// Returns true if the request is authorised (or if auth is disabled). +func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request) bool { + if s.AuthFile == "" { + return true + } + if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) { + return true // no passwords file → no auth required + } + a := auth.New(s.AuthFile) + username, password, ok := r.BasicAuth() + if ok { + if valid, err := a.Verify(username, password); err == nil && valid { + return true + } + } + w.Header().Set("WWW-Authenticate", `Basic realm="Admin"`) + http.Error(w, "Unauthorised", http.StatusUnauthorized) + return false +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + posts, err := s.listPosts() + if err != nil { + http.Error(w, "Failed to list posts: "+err.Error(), http.StatusInternalServerError) + return + } + s.render(w, "list", map[string]any{"Posts": posts}) +} + +func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + s.handleNewPost(w, r) + return + } + s.render(w, "form", map[string]any{ + "Title": "New Post", + "Action": "/admin/new", + "Post": post{Date: time.Now().Format("2006-01-02")}, + "IsNew": true, + }) +} + +func (s *Server) handleNewPost(w http.ResponseWriter, r *http.Request) { + p := postFromForm(r) + if p.Title == "" { + s.renderError(w, "Title is required") + return + } + if p.Slug == "" { + p.Slug = slugify(p.Title) + } + + mdPath := filepath.Join(s.PostsDir, p.Slug+".md") + if err := os.MkdirAll(s.PostsDir, 0755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if _, err := os.Stat(mdPath); err == nil { + s.renderError(w, fmt.Sprintf("Post %q already exists", p.Slug)) + return + } + if err := writePostFile(mdPath, p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.rebuild(mdPath) + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request, slug string) { + mdPath := filepath.Join(s.PostsDir, slug+".md") + p, err := readPostFile(mdPath, slug) + if err != nil { + http.NotFound(w, r) + return + } + if r.Method == http.MethodPost { + updated := postFromForm(r) + updated.Slug = slug // slug is immutable after creation + if updated.Title == "" { + s.renderError(w, "Title is required") + return + } + if err := writePostFile(mdPath, updated); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.rebuild(mdPath) + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + s.render(w, "form", map[string]any{ + "Title": "Edit Post", + "Action": "/admin/" + slug + "/edit", + "Post": p, + "IsNew": false, + }) +} + +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, slug string) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + mdPath := filepath.Join(s.PostsDir, slug+".md") + if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if s.Builder != nil { + _ = s.Builder.RemovePage(mdPath) + } + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func (s *Server) rebuild(mdPath string) { + if s.Builder == nil { + return + } + importMap, _ := builder.GenerateImportMap(s.Builder.LibDir) + _ = s.Builder.BuildFile(mdPath, importMap) +} + +func (s *Server) listPosts() ([]post, error) { + if err := os.MkdirAll(s.PostsDir, 0755); err != nil { + return nil, err + } + entries, err := os.ReadDir(s.PostsDir) + if err != nil { + return nil, err + } + var posts []post + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + slug := strings.TrimSuffix(e.Name(), ".md") + p, err := readPostFile(filepath.Join(s.PostsDir, e.Name()), slug) + if err == nil { + posts = append(posts, p) + } + } + return posts, nil +} + +func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (s *Server) renderError(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = s.tmpl.ExecuteTemplate(w, "error", map[string]any{"Message": msg}) +} + +// postFromForm reads a post from an HTTP form submission. +func postFromForm(r *http.Request) post { + _ = r.ParseForm() + return post{ + Title: strings.TrimSpace(r.FormValue("title")), + Date: strings.TrimSpace(r.FormValue("date")), + Tags: strings.TrimSpace(r.FormValue("tags")), + Content: r.FormValue("content"), + } +} + +// readPostFile reads and parses a markdown file into a post struct. +func readPostFile(path, slug string) (post, error) { + data, err := os.ReadFile(path) + if err != nil { + return post{}, err + } + p := post{Slug: slug} + body := string(data) + + // Parse frontmatter manually — keep it simple. + if strings.HasPrefix(body, "---\n") { + end := strings.Index(body[4:], "\n---\n") + if end >= 0 { + fm := body[4 : end+4] + p.Content = strings.TrimSpace(body[end+9:]) + for _, line := range strings.Split(fm, "\n") { + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + switch k { + case "title": + p.Title = v + case "date": + p.Date = v + case "tags": + p.Tags = v + } + } + } + } else { + p.Content = body + } + return p, nil +} + +// writePostFile writes a post to disk as a markdown file with frontmatter. +func writePostFile(path string, p post) error { + date := p.Date + if date == "" { + date = time.Now().Format("2006-01-02") + } + content := fmt.Sprintf("---\ntitle: %s\ndate: %s\ntags: %s\n---\n%s\n", + p.Title, date, p.Tags, p.Content) + return os.WriteFile(path, []byte(content), 0644) +} + +// slugify converts a title to a URL-safe slug. +var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) + +func slugify(title string) string { + s := strings.ToLower(title) + s = nonAlnum.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if s == "" { + s = fmt.Sprintf("post-%d", time.Now().Unix()) + } + return s +} + +// ── Templates ───────────────────────────────────────────────────────────────── + +const adminCSS = ` +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; } +.layout { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; } +nav { display: flex; align-items: center; gap: 1.5rem; padding: 0.75rem 0; + border-bottom: 1px solid #ddd; margin-bottom: 2rem; } +nav a { text-decoration: none; color: #555; font-size: 0.9rem; } +nav a:hover { color: #000; } +nav .brand { font-weight: 700; color: #000; font-size: 1rem; } +h1 { font-size: 1.4rem; margin-bottom: 1.25rem; } +h2 { font-size: 1.1rem; margin-bottom: 0.75rem; } +table { width: 100%; border-collapse: collapse; background: #fff; + border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); } +th { background: #f0f0f0; font-size: 0.8rem; text-transform: uppercase; + letter-spacing: .04em; color: #666; padding: 0.6rem 0.9rem; text-align: left; } +td { padding: 0.7rem 0.9rem; border-top: 1px solid #eee; vertical-align: middle; } +tr:hover td { background: #fafafa; } +.tags { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.tag { background: #e8f0fe; color: #2d5fc4; font-size: 0.75rem; + padding: 0.1rem 0.45rem; border-radius: 3px; } +.actions { display: flex; gap: 0.5rem; } +a.btn, button.btn { display: inline-block; padding: 0.35rem 0.75rem; font-size: 0.82rem; + border: none; border-radius: 4px; cursor: pointer; text-decoration: none; + font-family: inherit; } +.btn-primary { background: #2d5fc4; color: #fff; } +.btn-primary:hover { background: #1e4bad; } +.btn-secondary { background: #e0e0e0; color: #333; } +.btn-secondary:hover { background: #ccc; } +.btn-danger { background: #dc2626; color: #fff; } +.btn-danger:hover { background: #b91c1c; } +.empty { text-align: center; padding: 3rem; color: #999; font-size: 0.9rem; } +form { background: #fff; padding: 1.5rem; border-radius: 6px; + box-shadow: 0 1px 3px rgba(0,0,0,.08); max-width: 760px; } +label { display: block; font-size: 0.85rem; font-weight: 600; + color: #444; margin-bottom: 0.3rem; margin-top: 1rem; } +label:first-child { margin-top: 0; } +input[type=text], input[type=date], textarea { + width: 100%; padding: 0.5rem 0.65rem; border: 1px solid #ccc; + border-radius: 4px; font-size: 0.9rem; font-family: inherit; } +input[type=text]:focus, input[type=date]:focus, textarea:focus { + outline: none; border-color: #2d5fc4; box-shadow: 0 0 0 2px rgba(45,95,196,.15); } +textarea { font-family: 'Menlo', 'Consolas', monospace; font-size: 0.85rem; + height: 340px; resize: vertical; line-height: 1.5; } +.hint { font-size: 0.75rem; color: #888; margin-top: 0.25rem; } +.form-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; } +.alert { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; + padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; } +` + +const baseTemplate = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Admin — {{.Title}}</title> + <style>` + adminCSS + `</style> +</head> +<body> +<div class="layout"> + <nav> + <span class="brand">Admin</span> + <a href="/admin/">Posts</a> + <a href="/admin/new">New Post</a> + </nav> + {{block "content" .}}{{end}} +</div> +</body> +</html>` + +const listTemplate = `{{define "list"}}` + baseTemplate + `{{end}} +{{define "content"}} + <h1>Posts</h1> + {{if .Posts}} + <table> + <thead> + <tr> + <th>Title</th> + <th>Date</th> + <th>Tags</th> + <th></th> + </tr> + </thead> + <tbody> + {{range .Posts}} + <tr> + <td>{{.Title}}</td> + <td>{{.Date}}</td> + <td> + {{if .Tags}} + <div class="tags"> + {{range (splitTags .Tags)}}<span class="tag">{{.}}</span>{{end}} + </div> + {{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> + </div> + </td> + </tr> + {{end}} + </tbody> + </table> + {{else}} + <div class="empty">No posts yet. <a href="/admin/new">Create one.</a></div> + {{end}} +{{end}}` + +const formTemplate = `{{define "form"}}` + baseTemplate + `{{end}} +{{define "content"}} + <h1>{{.Title}}</h1> + <form method="POST" action="{{.Action}}"> + <label for="title">Title</label> + <input type="text" id="title" name="title" value="{{.Post.Title}}" required autofocus> + + <label for="date">Date</label> + <input type="date" id="date" name="date" value="{{.Post.Date}}"> + + <label for="tags">Tags</label> + <input type="text" id="tags" name="tags" value="{{.Post.Tags}}" + placeholder="tag1, tag2, tag3"> + <p class="hint">Comma-separated list of tags.</p> + + <label for="content">Content (Markdown)</label> + <textarea id="content" name="content">{{.Post.Content}}</textarea> + + <div class="form-actions"> + <button type="submit" class="btn btn-primary"> + {{if .IsNew}}Create Post{{else}}Save Changes{{end}} + </button> + <a href="/admin/" class="btn btn-secondary">Cancel</a> + </div> + </form> +{{end}}` + +const errorTemplate = `{{define "error"}}<!DOCTYPE html> +<html><head><meta charset="UTF-8"><title>Error</title> +<style>body{font-family:system-ui;max-width:600px;margin:3rem auto;padding:0 1rem}</style> +</head><body> +<h2>Error</h2> +<p>{{.Message}}</p> +<p><a href="javascript:history.back()">Go back</a></p> +</body></html>{{end}}` + +func mustParseTemplates() *template.Template { + funcs := template.FuncMap{ + "splitTags": func(s string) []string { + var tags []string + for _, t := range strings.Split(s, ",") { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + return tags + }, + } + return template.Must( + template.New("admin").Funcs(funcs).Parse( + listTemplate + formTemplate + errorTemplate, + ), + ) +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 40be377..59bb71b 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -62,6 +62,11 @@ func (b *Builder) BuildAll() 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) }) } diff --git a/internal/db/meta.go b/internal/db/meta.go index 4857234..33e0da3 100644 --- a/internal/db/meta.go +++ b/internal/db/meta.go @@ -6,7 +6,7 @@ import ( "strings" "time" - _ "nebbet.no/internal/sqlitedrv" + _ "modernc.org/sqlite" ) type MetaDB struct { @@ -23,7 +23,7 @@ type PageMeta struct { } func OpenMeta(path string) (*MetaDB, error) { - db, err := sql.Open("sqlite", path) + db, err := sql.Open("sqlite3", path) if err != nil { return nil, err } diff --git a/internal/db/search.go b/internal/db/search.go index b2c9b49..545645e 100644 --- a/internal/db/search.go +++ b/internal/db/search.go @@ -3,7 +3,7 @@ package db import ( "database/sql" - _ "nebbet.no/internal/sqlitedrv" + _ "modernc.org/sqlite" ) type SearchDB struct { @@ -23,7 +23,7 @@ type SearchResult struct { } func OpenSearch(path string) (*SearchDB, error) { - db, err := sql.Open("sqlite", path) + db, err := sql.Open("sqlite3", path) if err != nil { return nil, err } |
