# 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** ```bash 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`: ```elisp (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`: ```elisp (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** ```bash emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el ``` Expected: exits cleanly. - [ ] **Step 6: Verify template renders** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash curl -s http://localhost:8080/index | grep -oP 'ti-book|ti-headphones|ti-device-tablet' ``` - [ ] **Step 5: Verify expanded detail** ```bash curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline" ``` Expected: `3` (three reading notes). - [ ] **Step 6: Kill server** ```bash kill %1 2>/dev/null ``` --- ### Task 3: ERT tests and final verification **Files:** - No changes — verification only. - [ ] **Step 1: Run ERT tests** ```bash 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** ```bash git status --short git log --oneline -3 ```