Files
bookhoard/docs/developer/alpine-patterns.md
T
John O'Keefe 35b210951a docs(dev): alpine-patterns rewritten around the CSP build
The March-era page had the right instincts (store state across swaps,
no manual DOM manipulation) but predated the @alpinejs/csp conversion
and never said the load-bearing fact: inline expressions are not
evaluated, so every x-data must reference an Alpine.data component —
which is why the whole attribute value is an expression and why
dynamic arguments go through JSONArg or data-attributes. Rewritten
with the banned list spelled out (inline handlers, hx-on*, js:
hx-vals, stray inline <script>) and each rule tied to the source that
enforces or embodies it (alpine.ts import, header.templ
whole-expression x-data, custom-section-builder data-attribute
pattern, bookPicker store as the living example). Every code sample
verified present in web/src.
2026-11-02 14:56:39 -05:00

104 lines
3.8 KiB
Markdown

<!-- Ground truth: web/src/alpine.ts (the @alpinejs/csp build — this is
load-bearing for every rule below), component registrations across
web/src (Alpine.data in admin.ts, api.ts, audio.ts, book-detail.ts,
bookPicker.ts), the bookPicker store (web/src/bookPicker.ts), the
dynamic-attribute patterns in templates/header.templ
(x-data="sidebarShell('…')" whole-expression values) and
web/src/custom-section-builder.ts (data-attributes instead of inline
handlers — the CSP build refuses them). Re-verify when the frontend
stack changes. -->
# Alpine.js patterns in Bookhoard
Bookhoard's Alpine runs the **`@alpinejs/csp` build** — the CSP build, so
the app's `script-src` can stay a hash allow-list with no
`'unsafe-eval'`. That single fact dictates most of this page: inline
JavaScript in HTML is not evaluated, so everything expressive lives in
registered TypeScript components.
## Rule one: components are registered, not inlined
Every `x-data` references a component registered with `Alpine.data` in
`web/src` — the attribute value is the component name (optionally with
arguments), never an object literal:
```typescript
// web/src/admin.ts
Alpine.data("admin", () => ({ /* state + methods */ }));
```
```html
<!-- templates/admin.templ -->
<body x-data="admin" class="theme-tokyo-night">
```
Arguments work, but the **whole attribute value must be the expression**
— templ does not interpolate `{ expr }` inside quoted attribute values,
so `x-data="sidebarShell('{ col.ID }')"` would ship a literal `{ col.ID }`
to the browser:
```html
<div x-data={ "sidebarShell('" + string(mode) + "')" }>
```
Free-text arguments that could contain quotes go through
`templates.JSONArg`, never string concatenation.
## Stores: state that survives htmx swaps
Alpine's reactivity is DOM-local; when htmx swaps a node, its `x-data`
state dies with it. State that must survive swaps lives in a store —
`web/src/bookPicker.ts` is the canonical example:
```typescript
Alpine.store("bookPicker", {
isOpen: false,
selectedBooks: new Set<string>(),
open() { /* … */ },
toggleBook(id: string) { /* … */ },
});
```
```html
<button @click="$store.bookPicker.open()">Add Books</button>
<span x-text="$store.bookPicker.selectedBooks.size"></span>
```
The server returns HTML (checked checkboxes included); the store tracks
selection across swaps; nobody rebuilds markup from JSON.
## Local state: plain `x-data` on the smallest element
Ephemeral UI (a dropdown, a panel toggle) needs no store and no
round-trip:
```html
<div x-data="{ dropdownOpen: false }">
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
<div x-show="dropdownOpen" @click.outside="dropdownOpen = false">…</div>
</div>
```
## The banned list — all enforced by the CSP build
- **No inline handlers**, in any form: `onclick=`, `onerror=`,
templ-brace variants — hash-mode CSP refuses them unconditionally.
Rows generated in TypeScript carry `data-*` attributes and bind
listeners after swap (see `custom-section-builder.ts`).
- **No `hx-on*` attributes and no `js:`-prefixed `hx-vals`** — both
evaluate strings, which the CSP build cannot do. Plain JSON `hx-vals`
only (`templates/utils.go` has the helper).
- **No inline `<script>` outside `theme_flash_guard.templ`** (the one
sanctioned pre-paint script); everything else bundles from `web/src`.
## Division of labor
The full ownership rule lives in
[Writing UI documentation without screenshots](writing-ui-docs.md) and
the repo's AGENTS.md: **templ decides what things look like; TypeScript
decides what shows.** Content derived from server data is rendered by
templ (fetch a partial with `htmx.ajax` when it changes); TypeScript
handles pure client-local state — open/closed panels, validation,
toasts. `classList`/`getElementById` show-hide from TypeScript is a
defect, not a shortcut.