7.6 KiB
Mustache Template Rendering — Implementation Plan
Goal: Render the index page via Mustache (spine-render) using the mockup-A template, replacing inline HTML generation.
Architecture: Move spine-mockup-a-agenda.mustache → templates/index.mustache. Add spine-index-model function to build the view model ht. Update the index handler. Remove old spine-render-index.
Tech Stack: Emacs Lisp, mustache, ht.
File structure
| File | Responsibility |
|---|---|
templates/index.mustache |
Index page Mustache template (moved from root) |
spine.el |
Add spine-index-model, update handler, remove spine-render-index |
Task 1: Move template and add spine-index-model
Files:
-
Create:
templates/index.mustache -
Modify:
spine.el -
Delete:
spine-mockup-a-agenda.mustache -
Step 1: Move the template
mv spine-mockup-a-agenda.mustache templates/index.mustache
- Step 2: Add
spine-index-modelfunction tospine.el
Insert after spine--h (after the HTML escaping helper), before spine-render-index:
(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))
;; Count reading books
(dolist (book books)
(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))))
;; Build groups list for template
(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 (t) (concat ":" t ":"))
tags " "))))
("rec_via" (plist-get book :rec_by))
("rating_display"
(when (and rating (> (length rating) 0))
(let ((n (string-to-number rating)))
(make-string n ?★))))
("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))))))
- Step 3: Remove old
spine-render-indexfunction
Delete the spine-render-index function entirely.
- Step 4: Update the index handler
Replace the handler's call from spine-render-index to spine-render + spine-index-model:
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
- Step 5: Verify batch load
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
Expected: exits cleanly.
- Step 6: Verify template renders
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el \
--eval "(progn (message \"HTML length: %d\" (length (spine-render \"index.mustache\" (spine-index-model (spine-books) nil)))))"
Expected: non-zero length, no errors.
- Step 7: Commit
git add templates/index.mustache spine.el
git rm spine-mockup-a-agenda.mustache
git commit -m "feat: render index via Mustache template with spine-index-model"
Task 2: Smoke test end-to-end
Files:
-
No changes — verification only.
-
Step 1: Start server with sample data
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
pkill -f "emacs --quick" 2>/dev/null
sleep 1
emacs --quick --eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" --load spine.el &
sleep 5
- Step 2: Verify Pico + Tabler CDN links
curl -s http://localhost:8080/index | grep -c "picocss"
curl -s http://localhost:8080/index | grep -c "tabler"
Expected: 1 and 1.
- Step 3: Verify groups and content
curl -s http://localhost:8080/index | grep -oP 'on deck|reading|read'
Expected: on deck, reading, read (no abandoned).
- Step 4: Verify Tabler icon glyphs
curl -s http://localhost:8080/index | grep -oP 'ti-book|ti-headphones|ti-device-tablet'
- Step 5: Verify expanded detail
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline"
Expected: 3 (three reading notes).
- Step 6: Kill server
kill %1 2>/dev/null
Task 3: ERT tests and final verification
Files:
-
No changes — verification only.
-
Step 1: Run ERT tests
emacs --quick --batch --directory "$(pwd)" --load test/spine-books-test.el -f ert-run-tests-batch-and-exit
Expected: all 6 tests pass.
- Step 2: Git status
git status --short
git log --oneline -3