feat: render index via Mustache template with spine-index-model

This commit is contained in:
2026-06-21 07:38:32 -04:00
parent 67e41ad110
commit 7faf0cd8ac
7 changed files with 722 additions and 124 deletions
+82 -124
View File
@@ -57,6 +57,86 @@ Returns the rendered string."
(setq s (replace-regexp-in-string "\"" """ s))
s)))
(defun spine-index-model (books &optional selected-id)
"Build the view model ht for templates/index.mustache from BOOKS.
SELECTED-ID expands the matching book's detail section."
(let* ((status-labels
'(("WANT" . "on deck")
("READING" . "reading")
("READ" . "read")
("ABANDONED" . "abandoned")))
(format-icons
'(("hardcover" . "ti-book")
("ebook" . "ti-device-tablet")
("audiobook" . "ti-headphones")))
(order '("WANT" "READING" "READ" "ABANDONED"))
(grouped (make-hash-table :test 'equal))
(total (length books))
(reading-count 0))
(dolist (book books)
(when (equal "READING" (plist-get book :status))
(cl-incf reading-count)))
(dolist (book books)
(let ((status (or (plist-get book :status) "WANT")))
(push book (gethash status grouped))))
(let ((groups nil))
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(let ((book-models nil))
(dolist (book group-books)
(let* ((id (plist-get book :id))
(selected (equal id selected-id))
(fmt (plist-get book :format))
(rating (plist-get book :rating))
(notes (plist-get book :notes))
(status (or (plist-get book :status) "WANT"))
(model
(ht
("format_icon"
(or (cdr (assoc fmt format-icons)) ""))
("status_class" (downcase status))
("status_label" (downcase status))
("title" (plist-get book :title))
("author" (or (plist-get book :author) ""))
("tags_inline"
(let ((tags (plist-get book :tags)))
(when tags
(mapconcat (lambda (tag) (concat ":" tag ":"))
tags " "))))
("rec_via" (plist-get book :rec_by))
("rating_display"
(when (and rating (> (length rating) 0))
(make-string (string-to-number rating) ?\u2605)))
("selected" selected)
("detail"
(when selected
(ht
("meta"
(let ((added (plist-get book :added))
(fmt (plist-get book :format)))
(cond
((and added fmt)
(format "started %s · %s" added fmt))
(added (format "started %s" added))
(fmt (format "format: %s" fmt))
(t ""))))
("notes"
(when notes
(cl-loop for (date text) in notes
collect (ht
("date" (format "[%s]" date))
("text" text)))))))))))
(push model book-models)))
(push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books))
("books" (nreverse book-models)))
groups)))))
(ht ("app_title" "spine")
("total_books" total)
("reading_count" reading-count)
("groups" (nreverse groups))))))
(defun spine--extract-notes (hl-begin hl-end)
"Extract reading notes from headline body between HL-BEGIN and HL-END.
Returns list of (date-string text-string) pairs."
@@ -120,129 +200,6 @@ Returns nil if the file is missing or cannot be parsed."
spine-org-file (error-message-string err))
nil))))
(defun spine-render-index (books &optional selected-id)
"Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID."
(let ((order '("WANT" "READING" "READ" "ABANDONED"))
(grouped (make-hash-table :test 'equal))
(total 0)
(reading-count 0))
;; Count totals
(dolist (book books)
(cl-incf total)
(when (equal "READING" (plist-get book :status))
(cl-incf reading-count)))
;; Group books by status
(dolist (book books)
(let* ((status (or (plist-get book :status) "WANT")))
(push book (gethash status grouped))))
;; Render
(with-temp-buffer
(insert "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
(insert "<meta charset=\"utf-8\">\n")
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
(insert "<title>Spine</title>\n")
(insert "<link rel=\"stylesheet\""
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n")
(insert "<style>\n")
(insert ".spine-row{display:flex;align-items:center;gap:0.75rem;")
(insert "padding:0.4rem 0;border-bottom:1px solid var(--pico-muted-border-color)}\n")
(insert ".spine-row:last-child{border-bottom:none}\n")
(insert ".spine-selected{background:var(--pico-secondary-background)}\n")
(insert ".spine-glyph{width:1.5rem;text-align:center;flex-shrink:0}\n")
(insert ".spine-title{flex:1;min-width:0}\n")
(insert ".spine-trail{font-size:0.85em;color:var(--pico-muted-color)}\n")
(insert ".spine-tags{font-size:0.85em;color:var(--pico-muted-color)}\n")
(insert ".spine-detail{margin:0 0 0 2rem;border-left:2px solid var(--pico-primary)}\n")
(insert ".spine-detail p{margin-bottom:0.3rem}\n")
(insert ".spine-detail footer{margin-top:0.5rem}\n")
(insert "mark.want{background:var(--pico-mark-background);"
"color:var(--pico-mark-color)}\n")
(insert "mark.reading{background:#faeeda;color:#854f0b}\n")
(insert "mark.read{background:#e1f5ee;color:#085041}\n")
(insert "mark.abandoned{background:var(--pico-muted-color);"
"color:var(--pico-background-color)}\n")
(insert "article{margin-bottom:0}\n")
(insert "article header{padding-bottom:0.5rem;margin-bottom:0.5rem}\n")
(insert "</style>\n")
(insert "</head>\n<body>\n")
(insert "<main class=\"container\">\n")
(insert "<article>\n")
(insert (format "<header><strong>spine</strong>"
"<small>%d books · %d reading</small></header>\n"
total reading-count))
;; Each group in order
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(insert (format "<hgroup><h2>%s</h2><small>%d</small></hgroup>\n"
status (length group-books)))
(dolist (book group-books)
(let ((selected (equal (plist-get book :id) selected-id)))
(insert (format "<div class=\"spine-row%s\">\n"
(if selected " spine-selected" "")))
;; Format glyph (emoji)
(let ((fmt (plist-get book :format)))
(insert (format "<span class=\"spine-glyph\">%s</span>\n"
(cond ((equal fmt "hardcover") "📖")
((equal fmt "ebook") "📱")
((equal fmt "audiobook") "🎧")
(t "")))))
;; Title + author
(insert "<span class=\"spine-title\">")
(insert (spine--h (plist-get book :title)))
(let ((author (plist-get book :author)))
(when author
(insert (format " <small>%s</small>"
(spine--h author)))))
(insert "</span>\n")
;; Tags
(let ((tags (plist-get book :tags)))
(when tags
(insert (format "<small class=\"spine-tags\">%s</small>\n"
(mapconcat (lambda (tag) (concat ":" tag ":"))
tags " ")))))
;; Recommendation trail
(let ((rec-by (plist-get book :rec_by))
(rec-note (plist-get book :rec_note)))
(when (and rec-by (> (length rec-by) 0))
(insert
(format "<span class=\"spine-trail\">%s%s</span>\n"
(spine--h rec-by)
(if (and rec-note (> (length rec-note) 0))
(concat ": " (spine--h rec-note))
"")))))
;; Status pill
(let ((status (or (plist-get book :status) "WANT")))
(insert (format "<mark class=\"%s\">%s</mark>\n"
(downcase status) (downcase status))))
;; Expanded detail
(when selected
(insert "<blockquote class=\"spine-detail\">\n")
(dolist (note (plist-get book :notes))
(insert (format "<p><small>[%s]</small> %s</p>\n"
(spine--h (car note))
(spine--h (cadr note)))))
(let ((parts nil))
(let ((rating (plist-get book :rating)))
(when (and rating (> (length rating) 0))
(push (format "Rating: %s/5" (spine--h rating)) parts)))
(let ((isbn (plist-get book :isbn)))
(when (and isbn (> (length isbn) 0))
(push (format "ISBN: %s" (spine--h isbn)) parts)))
(let ((fmt (plist-get book :format)))
(when (and fmt (> (length fmt) 0))
(push (format "Format: %s" (spine--h fmt)) parts)))
(let ((added (plist-get book :added)))
(when (and added (> (length added) 0))
(push (format "Added: %s" (spine--h added)) parts)))
(when parts
(insert (format "<footer>%s</footer>\n"
(mapconcat #'identity (nreverse parts) " · ")))))
(insert "</blockquote>\n"))
(insert "</div>\n"))))))
(insert "</article>\n")
(insert "</main>\n</body>\n</html>\n")
(buffer-string))))
(defun spine-render-empty-state ()
"Render a page indicating no books file was found."
@@ -269,7 +226,8 @@ Set by test harnesses that only need the model functions.")
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(if books
(insert (spine-render-index books (cadr (assoc "id" query))))
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
(defun httpd/ (proc uri-path query request)