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

138 lines
4.3 KiB
Markdown

# 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:
```elisp
(spine-index-model books &optional selected-id) ht
```
```json
{
"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
```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)))))
```
`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.mustache``templates/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)