From 3cb7c82cf7c4e050148f69be23590a7fbe587a27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 10:11:18 +0000 Subject: Add static site builder: SQLite-backed MD→HTML pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/nebbet: CLI with build [--watch] and user add/passwd/delete/list - internal/builder: markdown→HTML, component injection via HTML comments, auto importmap from lib/, fsnotify watch with 150ms debounce - internal/db: meta.db (page index, tag queries) + search.db (FTS5) - internal/sqlitedrv: minimal CGO database/sql driver for system libsqlite3 - internal/auth: htpasswd-compatible bcrypt password file management - templates/base.html + admin.html, styles/main.css + admin.css - nginx.conf with auth_basic for /admin, clean URLs, gzip - nebbet.service systemd unit for watch daemon - Example content/index.md and components/site-greeting.js https://claude.ai/code/session_01HTc1BCBCiMTEB54XQP1Wz9 --- internal/db/meta.go | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++ internal/db/search.go | 113 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 internal/db/meta.go create mode 100644 internal/db/search.go (limited to 'internal/db') diff --git a/internal/db/meta.go b/internal/db/meta.go new file mode 100644 index 0000000..4857234 --- /dev/null +++ b/internal/db/meta.go @@ -0,0 +1,117 @@ +package db + +import ( + "database/sql" + "encoding/json" + "strings" + "time" + + _ "nebbet.no/internal/sqlitedrv" +) + +type MetaDB struct { + db *sql.DB +} + +type PageMeta struct { + Path string + HTMLPath string + Title string + Date string + Tags []string + UpdatedAt time.Time +} + +func OpenMeta(path string) (*MetaDB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + html_path TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + date TEXT DEFAULT '', + tags TEXT DEFAULT '[]', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_pages_path ON pages(path); + CREATE INDEX IF NOT EXISTS idx_pages_date ON pages(date); + `) + if err != nil { + return nil, err + } + return &MetaDB{db: db}, nil +} + +func (m *MetaDB) Close() error { return m.db.Close() } + +func (m *MetaDB) UpsertPage(p PageMeta) error { + tags, _ := json.Marshal(p.Tags) + _, err := m.db.Exec(` + INSERT INTO pages (path, html_path, title, date, tags, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + html_path = excluded.html_path, + title = excluded.title, + date = excluded.date, + tags = excluded.tags, + updated_at = excluded.updated_at + `, p.Path, p.HTMLPath, p.Title, p.Date, string(tags), p.UpdatedAt.UTC()) + return err +} + +func (m *MetaDB) DeletePage(path string) error { + _, err := m.db.Exec(`DELETE FROM pages WHERE path = ?`, path) + return err +} + +func (m *MetaDB) GetPage(path string) (*PageMeta, error) { + row := m.db.QueryRow( + `SELECT path, html_path, title, date, tags FROM pages WHERE path = ?`, path) + var p PageMeta + var tagsJSON string + if err := row.Scan(&p.Path, &p.HTMLPath, &p.Title, &p.Date, &tagsJSON); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) + return &p, nil +} + +func (m *MetaDB) ListPages() ([]PageMeta, error) { + rows, err := m.db.Query( + `SELECT path, html_path, title, date, tags FROM pages ORDER BY date DESC, path`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPages(rows) +} + +func (m *MetaDB) ListByTag(tag string) ([]PageMeta, error) { + // JSON array contains check via LIKE — sufficient for simple tag strings. + needle := `%"` + strings.ReplaceAll(tag, `"`, `\"`) + `"%` + rows, err := m.db.Query( + `SELECT path, html_path, title, date, tags FROM pages WHERE tags LIKE ? ORDER BY date DESC`, needle) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPages(rows) +} + +func scanPages(rows *sql.Rows) ([]PageMeta, error) { + var pages []PageMeta + for rows.Next() { + var p PageMeta + var tagsJSON string + if err := rows.Scan(&p.Path, &p.HTMLPath, &p.Title, &p.Date, &tagsJSON); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) + pages = append(pages, p) + } + return pages, rows.Err() +} diff --git a/internal/db/search.go b/internal/db/search.go new file mode 100644 index 0000000..b2c9b49 --- /dev/null +++ b/internal/db/search.go @@ -0,0 +1,113 @@ +package db + +import ( + "database/sql" + + _ "nebbet.no/internal/sqlitedrv" +) + +type SearchDB struct { + db *sql.DB +} + +type SearchPage struct { + Path string + Title string + Content string +} + +type SearchResult struct { + Path string + Title string + Snippet string +} + +func OpenSearch(path string) (*SearchDB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS indexed_pages ( + path TEXT NOT NULL PRIMARY KEY, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5( + path UNINDEXED, + title, + content, + tokenize = 'porter unicode61' + ); + `) + if err != nil { + return nil, err + } + return &SearchDB{db: db}, nil +} + +func (s *SearchDB) Close() error { return s.db.Close() } + +func (s *SearchDB) IndexPage(p SearchPage) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err = tx.Exec(`DELETE FROM pages_fts WHERE path = ?`, p.Path); err != nil { + return err + } + if _, err = tx.Exec( + `INSERT INTO pages_fts (path, title, content) VALUES (?, ?, ?)`, + p.Path, p.Title, p.Content, + ); err != nil { + return err + } + if _, err = tx.Exec(` + INSERT INTO indexed_pages (path, updated_at) VALUES (?, CURRENT_TIMESTAMP) + ON CONFLICT(path) DO UPDATE SET updated_at = CURRENT_TIMESTAMP + `, p.Path); err != nil { + return err + } + return tx.Commit() +} + +func (s *SearchDB) DeletePage(path string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`DELETE FROM pages_fts WHERE path = ?`, path); err != nil { + return err + } + if _, err = tx.Exec(`DELETE FROM indexed_pages WHERE path = ?`, path); err != nil { + return err + } + return tx.Commit() +} + +// Search runs a full-text query and returns up to 20 results with snippets. +func (s *SearchDB) Search(query string) ([]SearchResult, error) { + rows, err := s.db.Query(` + SELECT path, title, + snippet(pages_fts, 2, '', '', '...', 20) + FROM pages_fts + WHERE pages_fts MATCH ? + ORDER BY rank + LIMIT 20 + `, query) + if err != nil { + return nil, err + } + defer rows.Close() + var results []SearchResult + for rows.Next() { + var r SearchResult + if err := rows.Scan(&r.Path, &r.Title, &r.Snippet); err != nil { + return nil, err + } + results = append(results, r) + } + return results, rows.Err() +} -- cgit v1.3