diff --git a/docs/plans/2026-06-20-mustache-templating.md b/docs/plans/2026-06-20-mustache-templating.md new file mode 100644 index 0000000..bc26d1c --- /dev/null +++ b/docs/plans/2026-06-20-mustache-templating.md @@ -0,0 +1,238 @@ +# 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 +```