Files
spine/docs/plans/2026-06-20-mustache-templating.md
T

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.mustachetemplates/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-model function to spine.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-index function

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