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.
3.8 KiB
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:
// web/src/admin.ts
Alpine.data("admin", () => ({ /* state + methods */ }));
<!-- 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:
<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:
Alpine.store("bookPicker", {
isOpen: false,
selectedBooks: new Set<string>(),
open() { /* … */ },
toggleBook(id: string) { /* … */ },
});
<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:
<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 carrydata-*attributes and bind listeners after swap (seecustom-section-builder.ts). - No
hx-on*attributes and nojs:-prefixedhx-vals— both evaluate strings, which the CSP build cannot do. Plain JSONhx-valsonly (templates/utils.gohas the helper). - No inline
<script>outsidetheme_flash_guard.templ(the one sanctioned pre-paint script); everything else bundles fromweb/src.
Division of labor
The full ownership rule lives in
Writing UI documentation without screenshots 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.