summaryrefslogtreecommitdiffstats
path: root/cmd/nebbet/main.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-03-31 12:44:10 +0200
committerClaude <noreply@anthropic.com>2026-03-31 12:44:10 +0200
commitaa23774abb90c168c9ba2559d6bf381bc9fc55ba (patch)
treec4f4abfafb6e8e47a1ab505fe1253b951ccf77c9 /cmd/nebbet/main.go
parent8d7cda6e578e684483c0b5c7391c48e5b9ac5192 (diff)
downloadnebbet.no-aa23774abb90c168c9ba2559d6bf381bc9fc55ba.tar.xz
nebbet.no-aa23774abb90c168c9ba2559d6bf381bc9fc55ba.zip
Add post management admin UI and switch to modernc.org/sqlite
- Replace custom CGO sqlite driver with modernc.org/sqlite (registered as "sqlite3"); a local compat shim under compat/sqlite/ provides the same import path and WAL-mode behaviour using system libsqlite3 while network access is unavailable — swapping to the real pure-Go package later only requires removing the replace directive and running go get. - Add internal/admin/server.go: HTTP handler for /admin/ that serves a server-side-rendered post management UI (list, new, edit, delete). Posts are stored as Markdown files under content/posts/ and rebuilt via the existing Builder after every write. Basic auth is checked against the .passwords file when it exists. - Add cmd/nebbet/main.go: unified CLI with build, watch, serve (HTTP server with admin + file server + watch), and user subcommands. - Update builder.BuildAll to skip content/admin/ — admin pages are served dynamically and must never appear in the static output directory. - Mark content/admin/index.md as draft so the old static placeholder is not built even if the admin skip logic is bypassed. - Fix .gitignore: use /nebbet (root-only) so the pattern no longer accidentally ignores the cmd/nebbet/ source directory. https://claude.ai/code/session_01WLuSGxJhNs2cFM2zJzSsTx
Diffstat (limited to 'cmd/nebbet/main.go')
-rw-r--r--cmd/nebbet/main.go229
1 files changed, 229 insertions, 0 deletions
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`)
+}