diff --git a/docs/developer/alpine-patterns.md b/docs/developer/alpine-patterns.md index be622e05..d4a0f6de 100644 --- a/docs/developer/alpine-patterns.md +++ b/docs/developer/alpine-patterns.md @@ -1,92 +1,103 @@ -# Alpine.js Patterns in Bookhoard -## Book Picker Modal Pattern (SSR + HTMX + Alpine.store) -For modals that need state persistence across HTMX swaps: -### TypeScript + + +# 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 + + +``` + +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 +
+``` + +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 -// web/src/bookPicker.ts Alpine.store("bookPicker", { isOpen: false, selectedBooks: new Set(), - - open() { - 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; - } + open() { /* … */ }, + toggleBook(id: string) { /* … */ }, }); -Template - +``` + +```html - -
- - - - - -
- -
- - - - - - -
-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) + +``` + +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
-
- -
+
…
-Best Practices -- ✅ State lives in template (x-data) -- ✅ UI updates automatically (x-show) -- ✅ No manual DOM manipulation in TypeScript -- ✅ Pure business logic only in TypeScript functions -- ✅ Use Alpine.store for global state -- ✅ Use local x-data for component state -- ✅ Use x-init for setup only (no data fetch for SSR pages) -- ❌ Never use classList in TypeScript -- ❌ Never use getElementById for show/hide -- ❌ Never fetch data in x-init (for SSR pages) +``` + +## 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 `