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.
104 lines
3.8 KiB
Markdown
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.
|