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:
1 parent
4bd2788c7d
commit
35b210951a
1 file changed
+90
-79
@@ -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.
|
||||||
Reference in new issue
Block a user