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.
This commit is contained in:
John O'Keefe committed 2026-11-02 14:56:39 -05:00
1 parent 4bd2788c7d
commit 35b210951a
1 file changed
+90 -79
+90 -79
View File
@@ -1,92 +1,103 @@
# Alpine.js Patterns in Bookhoard <!-- Ground truth: web/src/alpine.ts (the @alpinejs/csp build — this is
## Book Picker Modal Pattern (SSR + HTMX + Alpine.store) load-bearing for every rule below), component registrations across
For modals that need state persistence across HTMX swaps: web/src (Alpine.data in admin.ts, api.ts, audio.ts, book-detail.ts,
### TypeScript 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 ```typescript
// web/src/bookPicker.ts
Alpine.store("bookPicker", { Alpine.store("bookPicker", {
isOpen: false, isOpen: false,
selectedBooks: new Set<string>(), selectedBooks: new Set<string>(),
open() { /* … */ },
open() { toggleBook(id: string) { /* … */ },
this.isOpen = true;
this.selectedBooks.clear();
this.loadBooks();
},
close() {
this.isOpen = false;
this.selectedBooks.clear();
},
toggleBook(id: string) {
if (this.selectedBooks.has(id)) {
this.selectedBooks.delete(id);
} else {
this.selectedBooks.add(id);
}
// Trigger reactivity
this.selectedBooks = new Set(this.selectedBooks);
},
isSelected(id: string): boolean {
return this.selectedBooks.has(id);
},
get selectedCount(): number {
return this.selectedBooks.size;
}
}); });
Template ```
<!-- Open button -->
```html
<button @click="$store.bookPicker.open()">Add Books</button> <button @click="$store.bookPicker.open()">Add Books</button>
<!-- Modal --> <span x-text="$store.bookPicker.selectedBooks.size"></span>
<div x-show="$store.bookPicker.isOpen" ```
x-transition
@click.self="$store.bookPicker.close()"
style="display: none;">
<!-- Search with HTMX --> The server returns HTML (checked checkboxes included); the store tracks
<input @input.debounce.300ms="searchBooks($el.value)" selection across swaps; nobody rebuilds markup from JSON.
hx-get="/api/media-items/filtered"
hx-target="#book-picker-grid"
hx-include="#filter-form" />
<!-- Book grid with checkboxes --> ## Local state: plain `x-data` on the smallest element
<div id="book-picker-grid" x-init="loadBooks()">
<!-- Server returns HTML with checkboxes -->
</div>
<!-- Selected count --> Ephemeral UI (a dropdown, a panel toggle) needs no store and no
<span x-text="$store.bookPicker.selectedCount"></span> round-trip:
<!-- Submit --> ```html
<button @click="$store.bookPicker.submit()">Add Selected</button>
</div>
Key Principles
- ✅ Alpine.store persists state across HTMX DOM swaps
- ✅ Server returns HTML with checkbox :checked attributes
- ✅ No client-side rendering or manual DOM manipulation
- ✅ HTMX handles dynamic content updates
- ✅ Alpine manages UI state only
Dropdown Pattern (Local x-data)
<div x-data="{ dropdownOpen: false }"> <div x-data="{ dropdownOpen: false }">
<button @click="dropdownOpen = !dropdownOpen">Toggle</button> <button @click="dropdownOpen = !dropdownOpen">Toggle</button>
<div x-show="dropdownOpen" <div x-show="dropdownOpen" @click.outside="dropdownOpen = false">…</div>
@click.outside="dropdownOpen = false"
x-transition
style="display: none;">
<!-- Dropdown content -->
</div>
</div> </div>
Best Practices ```
- ✅ State lives in template (x-data)
- ✅ UI updates automatically (x-show) ## The banned list — all enforced by the CSP build
- ✅ No manual DOM manipulation in TypeScript
- ✅ Pure business logic only in TypeScript functions - **No inline handlers**, in any form: `onclick=`, `onerror=`,
- ✅ Use Alpine.store for global state templ-brace variants — hash-mode CSP refuses them unconditionally.
- ✅ Use local x-data for component state Rows generated in TypeScript carry `data-*` attributes and bind
- ✅ Use x-init for setup only (no data fetch for SSR pages) listeners after swap (see `custom-section-builder.ts`).
- ❌ Never use classList in TypeScript - **No `hx-on*` attributes and no `js:`-prefixed `hx-vals`** — both
- ❌ Never use getElementById for show/hide evaluate strings, which the CSP build cannot do. Plain JSON `hx-vals`
- ❌ Never fetch data in x-init (for SSR pages) 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.