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

4.3 KiB

Spine — Mustache Template Rendering

Replaces inline HTML generation with Mustache template rendering via spine-render. The mockup-A mustache template becomes the canonical index page template. A view-model builder function transforms spine-books plists into the template's expected context shape.

Architecture

spine.org ──→ [spine-books] ──→ book plists
                                    │
                    [spine-index-model] ──→ view model (ht)
                                    │
templates/index.mustache ───────────┼──→ [spine-render] ──→ HTML

Template

spine-mockup-a-agenda.mustache moves to templates/index.mustache.

The template already contains:

  • Pico CSS v2.1.1 CDN link
  • Tabler Icons CDN link
  • All custom CSS in <style> block (agenda layout, pills, detail, dark mode)
  • Mustache sections: {{#groups}}, {{#books}}, {{#detail}}, {{#notes}}, {{#minibuffer}}
  • minibuffer section kept in the template but not populated (empty context → section omitted)

View model

A new function spine-index-model takes the book plists and an optional selected-id, returns an ht hash table shaped for the template:

(spine-index-model books &optional selected-id)  ht
{
  "app_title": "spine",
  "total_books": 5,
  "reading_count": 1,
  "groups": [
    {
      "label": "on deck",
      "count": 3,
      "books": [
        {
          "format_icon": "ti-book",
          "status_class": "want",
          "status_label": "want",
          "title": "The Left Hand of Darkness",
          "author": "Ursula K. Le Guin",
          "tags_inline": null,
          "rec_via": "Alex",
          "rating_display": null,
          "selected": false,
          "detail": null
        }
      ]
    }
  ]
}

Field mappings

Plist key Template field Transform
:status groups[].label WANT→"on deck", READING→"reading", READ→"read", ABANDONED→"abandoned"
:status status_class / status_label lowercased
:format format_icon hardcover→ti-book, ebook→ti-device-tablet, audiobook→ti-headphones, nil→""
:tags tags_inline ("scifi" "literary")":scifi:literary:" (string), nil→nil
:rec_by rec_via direct string, nil→nil
:rating rating_display "5"→"★★★★★", nil→nil
:id = selected-id selected boolean
:notes detail.notes list of {date, text}
:added + :format detail.meta composite string

Detail meta format

"started [2026-02-20] · hardcover"     (WANT/READING)
"finished [2026-01-10] · ebook"         (READ, use most recent LOGBOOK date)
""                                      (no dates)

Group ordering

on deck (WANT) → reading (READING) → read (READ) → abandoned (ABANDONED). Zero-book groups omitted.

Handler

(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)))))

spine-render-empty-state keeps its current Pico-styled inline HTML (no Mustache needed for a single static message).

Templates directory

spine-mockup-a-agenda.mustachetemplates/index.mustache

spine-mockup-b-journal.mustache stays in root as a reference/mockup for the future detail view.

Files changed

File Change
templates/index.mustache New — moved from root mockup
spine.el Add spine-index-model, update handler, remove old spine-render-index
spine-mockup-a-agenda.mustache Deleted (moved)

Out of scope

  • Populating the minibuffer section (capture UI)
  • Mockup B / journal detail view
  • Cover image display

Acceptance

  • Index page renders via Mustache, not inline HTML
  • Pico CSS + Tabler Icons CDN links present
  • Groups: on deck, reading, read — correct order, zero-book groups omitted
  • Format shown as Tabler icon glyph
  • Status pills have distinct colors per state
  • ?id=... expands matching book detail
  • Recommendation appears as "via {name}"
  • ERT tests still pass (model layer unchanged)