@phillipsharring/graspr-framework 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # Graspr Framework
2
+
3
+ A frontend framework for building server-driven web applications with **HTMX + Handlebars + Tailwind CSS**.
4
+
5
+ Graspr handles the hard parts of HTMX-based apps: boosted navigation, auth-gated widgets, modal/toast systems, CSRF token management, form error handling, and client-side template rendering — so you can focus on your pages and domain logic.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @phillipsharring/graspr-framework
11
+ ```
12
+
13
+ Peer dependencies (your app must install these):
14
+ ```bash
15
+ npm install htmx.org handlebars sortablejs
16
+ ```
17
+
18
+ For a ready-to-go project structure, use the [Graspr App Skeleton](https://github.com/phillipsharring/graspr-app-skeleton).
19
+
20
+ ## What's Included
21
+
22
+ ### Core Infrastructure (`core/`)
23
+ - **boosted-nav** — fixes HTMX boosted navigation edge cases (inherited targets, `hx-select` overrides, layout mismatch detection)
24
+ - **csrf** — global `fetch()` interceptor + HTMX hook for automatic CSRF token headers
25
+ - **auth-state** — auth-gated UI orchestration (`auth-load` events, permission gating, login modal, 401/403 handling)
26
+ - **forms** — inline form error display, modal form lifecycle, success redirect/refresh patterns
27
+ - **pagination** — paginated table controls with URL param syncing
28
+ - **search** — debounced search input with HTMX integration
29
+ - **sortable** — drag-and-drop reordering via SortableJS wrapper
30
+ - **table-sort** — clickable column header sorting with URL persistence
31
+ - **navigation** — URL helpers, active nav highlighting
32
+
33
+ ### UI Widgets (`ui/`)
34
+ - **modal** — global modal state machine with focus management and overlay/escape handling
35
+ - **modal-form** — modal form populator (set fields, method, clear errors, focus)
36
+ - **toast** — toast notification system with auto-dismiss
37
+ - **confirm-dialog** — confirmation dialog with optional progress mode for batch operations
38
+ - **typeahead** — autocomplete widget factory with keyboard navigation
39
+ - **click-burst** — visual click feedback animation
40
+
41
+ ### HTMX Extensions (`lib/`)
42
+ - **json-enc** — JSON encoding extension for HTMX requests
43
+ - **client-side-templates** — Handlebars template rendering for HTMX JSON responses
44
+
45
+ ### Helpers (`helpers/`)
46
+ - **handlebars-helpers** — generic Handlebars helpers (eq, neq, and, or, truncate, timeAgo, formatDateTime, json, treeIndent, etc.)
47
+ - **escape-html** — HTML escape utility
48
+ - **populate-select** — `<select>` field populator
49
+ - **route-params** — URL parameter extraction for dynamic routes (`[id]` patterns)
50
+ - **debounce** — debounce utility + search input sanitization
51
+
52
+ ### Auth (`auth.js`)
53
+ - Single `/api/auth/me` call per page load, cached
54
+ - `checkAuth()` — returns `Promise<boolean>`
55
+ - `getAuthData()` — returns full auth response with permissions
56
+ - `refreshAuthData()` — invalidates cache and re-fetches
57
+
58
+ ### API Client (`fetch-client.js`)
59
+ - `apiFetch(url, options)` — wraps `fetch()` with CSRF headers, JSON content type, body serialization
60
+
61
+ ### Styles (`styles/base.css`)
62
+ - Form error styles, HTMX request dimming, modal/takeover animations, sortable drag-and-drop, table sort headers, confirm dialog, active nav highlighting
63
+
64
+ ## Usage
65
+
66
+ ### Import everything at once
67
+ ```js
68
+ import {
69
+ GrasprToast, openFormModal, GrasprConfirm,
70
+ initPagination, initTableSort,
71
+ getRouteParams, escapeHtml,
72
+ } from '@phillipsharring/graspr-framework';
73
+ ```
74
+
75
+ ### Side-effect initialization
76
+ ```js
77
+ // Registers CSRF interceptors, boosted-nav handlers, auth-state listeners,
78
+ // form error handling, search, and sortable — in the correct order.
79
+ import '@phillipsharring/graspr-framework/init';
80
+ ```
81
+
82
+ ### HTMX extensions (import after setting window.Handlebars)
83
+ ```js
84
+ import '@phillipsharring/graspr-framework/src/lib/json-enc.js';
85
+ import '@phillipsharring/graspr-framework/src/lib/client-side-templates.js';
86
+ ```
87
+
88
+ ### Styles
89
+ ```css
90
+ @import 'tailwindcss';
91
+ @source "../../content/**/*.html";
92
+ @source "../**/*.js";
93
+ @import '@phillipsharring/graspr-framework/styles/base.css';
94
+ ```
95
+
96
+ ### Configurable auth permissions
97
+ ```js
98
+ import { registerAdminPermissionPrefixes } from '@phillipsharring/graspr-framework';
99
+
100
+ registerAdminPermissionPrefixes([
101
+ ['/admin/design/', 'design.access'],
102
+ ['/admin/story/', 'story.access'],
103
+ ['/admin/', 'admin.access'],
104
+ ]);
105
+ ```
106
+
107
+ ## Designed For
108
+
109
+ Graspr is the frontend companion to [Handlr Framework](https://github.com/phillipsharring/handlr-framework) (PHP backend), but works with any backend that serves JSON APIs and HTML pages. The auth system expects a `/api/auth/me` endpoint; everything else is configurable.
110
+
111
+ ## Requirements
112
+
113
+ - Vite 7+
114
+ - Tailwind CSS 4+
115
+ - Node.js 18+
116
+
117
+ ## License
118
+
119
+ MIT
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@phillipsharring/graspr-framework",
3
+ "version": "0.1.0",
4
+ "description": "HTMX + Handlebars + Tailwind frontend framework",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./init": "./src/init.js",
9
+ "./styles/*": "./styles/*",
10
+ "./src/*": "./src/*"
11
+ },
12
+ "author": "Phillip Harrington <phillip@phillipharrington.com> (https://phillipharrington.com/)",
13
+ "license": "MIT",
14
+ "peerDependencies": {
15
+ "handlebars": "^4.7.0",
16
+ "htmx.org": "^2.0.0",
17
+ "sortablejs": "^1.15.0"
18
+ }
19
+ }
package/src/auth.js ADDED
@@ -0,0 +1,47 @@
1
+ // ---------------------------
2
+ // Centralized Auth Check
3
+ // ---------------------------
4
+ // Makes a single /api/auth/me call per page load and caches the result.
5
+ // Import `checkAuth` for a boolean, or `getAuthData` for the full response.
6
+ // Call `refreshAuthData()` to invalidate the cache and re-fetch.
7
+
8
+ function fetchAuthData() {
9
+ return fetch('/api/auth/me', { credentials: 'same-origin' })
10
+ .then(r => r.json())
11
+ .then(data => ({
12
+ authenticated: !!data.data?.authenticated,
13
+ username: data.data?.user?.username || null,
14
+ permissions: data.meta?.permissions || [],
15
+ }))
16
+ .catch(() => ({ authenticated: false, username: null, permissions: [] }));
17
+ }
18
+
19
+ let authPromise = fetchAuthData();
20
+
21
+ /**
22
+ * Resolves with `true` when the user is authenticated, `false` otherwise.
23
+ * The check runs once per page load; subsequent calls return the cached result.
24
+ * @returns {Promise<boolean>}
25
+ */
26
+ export function checkAuth() {
27
+ return authPromise.then(d => d.authenticated);
28
+ }
29
+
30
+ /**
31
+ * Resolves with the full auth data object: { authenticated, username, permissions }.
32
+ * Same cache as checkAuth — no extra network call.
33
+ * @returns {Promise<{authenticated: boolean, username: string|null, permissions: string[]}>}
34
+ */
35
+ export function getAuthData() {
36
+ return authPromise;
37
+ }
38
+
39
+ /**
40
+ * Invalidates the cached auth data and re-fetches from /api/auth/me.
41
+ * Returns the fresh auth data promise.
42
+ * @returns {Promise<{authenticated: boolean, username: string|null, permissions: string[]}>}
43
+ */
44
+ export function refreshAuthData() {
45
+ authPromise = fetchAuthData();
46
+ return authPromise;
47
+ }
@@ -0,0 +1,227 @@
1
+ // ---------------------------
2
+ // Auth-gated UI
3
+ // ---------------------------
4
+ // Manages authentication state visibility, auth-gated widget triggering,
5
+ // admin permission checks, login modal, and 401 interception.
6
+ // Fully self-registering — import for side effects only.
7
+
8
+ import htmx from '../lib/htmx.js';
9
+ import { checkAuth, getAuthData, refreshAuthData } from '../auth.js';
10
+ import { isGlobalModalOpen } from '../ui/index.js';
11
+ import { openFormModal } from '../ui/modal-form.js';
12
+ import { GrasprToast } from '../ui/toast.js';
13
+
14
+ // ---------------------------
15
+ // Header widget refresh
16
+ // ---------------------------
17
+
18
+ function refreshHeaderWidgets({ selector = '[data-header-widget]', eventName = 'refresh' } = {}) {
19
+ // Header widgets live outside #app, so they won't be re-initialized by page swaps.
20
+ // Mark any widget with `data-header-widget` and give it `hx-trigger="auth-load, refresh"`.
21
+ if (typeof htmx?.trigger !== 'function') return;
22
+ document.querySelectorAll(selector).forEach((el) => {
23
+ if (el instanceof HTMLElement) {
24
+ htmx.trigger(el, eventName);
25
+ }
26
+ });
27
+ }
28
+
29
+ // ---------------------------
30
+ // Auth state application
31
+ // ---------------------------
32
+ // Reveals auth-dependent elements. Idempotent — safe to call after every swap.
33
+
34
+ const authPending = new WeakSet();
35
+
36
+ function applyAuthState(authData) {
37
+ const authenticated = typeof authData === 'boolean' ? authData : authData.authenticated;
38
+ const username = typeof authData === 'object' ? authData.username : null;
39
+
40
+ // Auth links — both start hidden; reveal the correct one.
41
+ const loginLink = document.querySelector('[data-auth-login]');
42
+ const logoutLink = document.querySelector('[data-auth-logout]');
43
+
44
+ if (authenticated) {
45
+ logoutLink?.removeAttribute('hidden');
46
+ // Show username in the user menu button
47
+ const usernameEl = document.getElementById('user-menu-username');
48
+ if (usernameEl && username) {
49
+ usernameEl.textContent = username;
50
+ }
51
+ } else {
52
+ loginLink?.removeAttribute('hidden');
53
+ }
54
+
55
+ // Widgets that require an authenticated session.
56
+ // Track triggered elements in a WeakSet so each element only fires once,
57
+ // even if multiple afterSwap/afterSettle handlers call applyAuthState while
58
+ // requests are still in flight. New elements (e.g. after boosted nav swaps
59
+ // #app) won't be in the set and will be triggered normally.
60
+ if (authenticated && typeof htmx?.trigger === 'function') {
61
+ document.querySelectorAll('[data-requires-auth]').forEach(el => {
62
+ if (!el.children.length && !authPending.has(el)) {
63
+ authPending.add(el);
64
+ htmx.trigger(el, 'auth-load');
65
+ }
66
+ });
67
+ }
68
+
69
+ // Permission-gated elements — reveal if user has the required permission.
70
+ // Uses cached getAuthData(), no extra network call.
71
+ if (authenticated) {
72
+ applyPermissionGating();
73
+ }
74
+
75
+ // If not authenticated and the page has auth-required content inside #app,
76
+ // prompt the user to log in. (showLoginModal clears #app opacity itself.)
77
+ if (!authenticated && document.querySelector('#app [data-requires-auth]')) {
78
+ showLoginModal();
79
+ return;
80
+ }
81
+
82
+ // Clear auth opacity gate for non-admin layouts.
83
+ // Admin has its own permission-aware clearing in checkAdminPermissions().
84
+ const app = document.getElementById('app');
85
+ if (app && app.dataset?.layout !== 'admin') {
86
+ app.style.opacity = '';
87
+ }
88
+ }
89
+
90
+ // ---------------------------
91
+ // Permission-gated elements
92
+ // ---------------------------
93
+ // Elements with data-requires-permission="some.permission" start hidden
94
+ // and are revealed only if the user has that permission.
95
+
96
+ function applyPermissionGating() {
97
+ const els = document.querySelectorAll('[data-requires-permission]');
98
+ if (!els.length) return;
99
+
100
+ getAuthData().then(({ permissions }) => {
101
+ els.forEach(el => {
102
+ if (permissions.includes(el.dataset.requiresPermission)) {
103
+ el.removeAttribute('hidden');
104
+ }
105
+ });
106
+ });
107
+ }
108
+
109
+ // Initial auth check + apply (pass full data for username).
110
+ getAuthData().then(applyAuthState);
111
+
112
+ // ---------------------------
113
+ // Admin permission checks
114
+ // ---------------------------
115
+ // Derives required permission from URL prefix — no per-page attributes needed.
116
+ // Apps register their own prefixes via registerAdminPermissionPrefixes().
117
+
118
+ let adminPermissionPrefixes = [];
119
+
120
+ /**
121
+ * Register URL-prefix → permission mappings for admin permission checks.
122
+ * More specific prefixes should come first (e.g. '/admin/design/' before '/admin/').
123
+ * @param {Array<[string, string]>} prefixes - Array of [urlPrefix, permissionName] pairs
124
+ */
125
+ export function registerAdminPermissionPrefixes(prefixes) {
126
+ adminPermissionPrefixes = prefixes;
127
+ }
128
+
129
+ function checkAdminPermissions(appEl) {
130
+ if (appEl.dataset?.layout !== 'admin') return;
131
+
132
+ const path = window.location.pathname;
133
+ const required = adminPermissionPrefixes.find(([prefix]) => path.startsWith(prefix))?.[1];
134
+ if (!required) return;
135
+
136
+ getAuthData().then(({ authenticated, permissions }) => {
137
+ if (!authenticated) {
138
+ showLoginModal();
139
+ } else if (!permissions.includes(required)) {
140
+ window.location.href = '/game/';
141
+ return; // don't reveal — redirecting
142
+ }
143
+ appEl.style.opacity = '';
144
+ });
145
+ }
146
+
147
+ // ---------------------------
148
+ // 401 / Unauthenticated → Login Modal
149
+ // ---------------------------
150
+
151
+ function showLoginModal() {
152
+ // Drop a login link into #app so there's something to click if the modal is dismissed
153
+ const app = document.getElementById('app');
154
+ if (app && !app.querySelector('[data-login-prompt]')) {
155
+ app.innerHTML = `
156
+ <div data-login-prompt class="flex items-center justify-center min-h-[60vh]">
157
+ <a href="/login/"
158
+ class="rounded px-6 py-3 bg-slate-900 text-white hover:bg-slate-800 text-lg no-underline"
159
+ >Login</a>
160
+ </div>`;
161
+ app.style.opacity = '';
162
+ }
163
+
164
+ if (isGlobalModalOpen()) return;
165
+ openFormModal({
166
+ templateId: 'login-form-template',
167
+ title: 'Login',
168
+ size: 'sm',
169
+ });
170
+ }
171
+
172
+ // Catch 401/403 responses before swap
173
+ document.body.addEventListener('htmx:beforeSwap', (e) => {
174
+ const xhr = e.detail?.xhr;
175
+ if (xhr?.status === 401) {
176
+ e.detail.shouldSwap = false;
177
+ showLoginModal();
178
+ } else if (xhr?.status === 403) {
179
+ // CSRF failure — the response includes a fresh token (captured by
180
+ // the htmx:afterRequest hook in csrf.js), so the next request will work.
181
+ e.detail.shouldSwap = false;
182
+ GrasprToast.show({ message: 'Session expired, please try again.', status: 'warning' });
183
+ }
184
+ }, true); // Use capture to run before other beforeSwap handlers
185
+
186
+ // ---------------------------
187
+ // Self-registered lifecycle handlers
188
+ // ---------------------------
189
+
190
+ // Check admin permissions on full page load.
191
+ document.addEventListener('DOMContentLoaded', () => {
192
+ const app = document.getElementById('app');
193
+ if (app) checkAdminPermissions(app);
194
+ });
195
+
196
+ // On #app swap: refresh header widgets + re-check admin permissions.
197
+ document.body.addEventListener('htmx:afterSwap', (e) => {
198
+ const target = e.detail?.target;
199
+
200
+ if (target && target.id === 'app') {
201
+ checkAuth().then(auth => { if (auth) refreshHeaderWidgets(); });
202
+ // outerHTML swap detaches the old target — grab the live element from DOM
203
+ const app = document.getElementById('app');
204
+ if (app) checkAdminPermissions(app);
205
+ }
206
+ });
207
+
208
+ // Fire auth-load AFTER settle, not after swap.
209
+ // HTMX lifecycle: beforeSwap → DOM swap → afterSwap → settle (processes hx-trigger
210
+ // etc. on new elements) → afterSettle. Firing auth-load in afterSwap meant the
211
+ // custom event fired before HTMX had wired up hx-trigger="auth-load" on the new
212
+ // elements — so nothing was listening yet.
213
+ document.body.addEventListener('htmx:afterSettle', (e) => {
214
+ const target = e.detail?.target;
215
+
216
+ // Skip widget responses — otherwise the first widget's afterSettle would
217
+ // re-trigger auth-load on sibling widgets that haven't loaded yet.
218
+ if (!target?.closest('[data-requires-auth]')) {
219
+ getAuthData().then(applyAuthState);
220
+ }
221
+ });
222
+
223
+ // Re-fetch /api/auth/me and re-apply auth state (updates header username, etc.).
224
+ // Any code can fire this: document.body.dispatchEvent(new CustomEvent('auth-refresh'))
225
+ document.body.addEventListener('auth-refresh', () => {
226
+ refreshAuthData().then(applyAuthState);
227
+ });
@@ -0,0 +1,94 @@
1
+ // ----------------------------
2
+ // Boosted-nav infrastructure
3
+ // ----------------------------
4
+ // Handles three concerns for HTMX apps with hx-boost="true" on <body>:
5
+ //
6
+ // 1. hx-select override: <body> has hx-select="#app" for boosted nav. Non-boosted
7
+ // elements (buttons, forms) inherit it, but their JSON/template responses don't
8
+ // contain #app — so hx-select filters everything out. Fix: clear inherited
9
+ // hx-select for non-boosted requests via selectOverride = 'unset'.
10
+ //
11
+ // 2. Inherited target fix: Self-loading elements (tbody, detail divs) use
12
+ // hx-target="this". Boosted <a> links inside them inherit that target, causing
13
+ // the next page to load inside the table/div. Detects and redirects to #app.
14
+ //
15
+ // 3. Layout mismatch: When boosted nav crosses layouts (e.g. game → admin), only
16
+ // #app swaps — the chrome stays wrong. Detects layout differences and forces a
17
+ // full page reload.
18
+
19
+ /**
20
+ * Check if an element is a boosted page navigation link (not an explicit hx-get/hx-post).
21
+ */
22
+ export function isBoostedPageNav(elt) {
23
+ return elt instanceof HTMLAnchorElement
24
+ && !elt.hasAttribute('hx-get')
25
+ && !elt.hasAttribute('hx-post');
26
+ }
27
+
28
+ function extractLayoutFromResponse(html) {
29
+ const match = html.match(/id=["']app["'][^>]*data-layout=["']([^"']+)["']/);
30
+ return match?.[1] ?? null;
31
+ }
32
+
33
+ // hx-select override for non-boosted elements
34
+ document.body.addEventListener('htmx:beforeSwap', (e) => {
35
+ const elt = e.detail.requestConfig?.elt;
36
+ if (!elt || isBoostedPageNav(elt)) return;
37
+
38
+ // Don't override if already set by another handler or the server
39
+ if (e.detail.selectOverride) return;
40
+
41
+ // Don't override if the element explicitly declares hx-select
42
+ if (elt.hasAttribute('hx-select')) return;
43
+
44
+ e.detail.selectOverride = 'unset';
45
+ });
46
+
47
+ // Boosted-nav interceptor + Layout mismatch detection
48
+ document.body.addEventListener('htmx:beforeSwap', (e) => {
49
+ const detail = e.detail || {};
50
+ const elt = detail.requestConfig?.elt;
51
+
52
+ if (!elt || !isBoostedPageNav(elt)) return;
53
+
54
+ const app = document.getElementById('app');
55
+ if (!app) return;
56
+
57
+ // --- Fix inherited target ---
58
+ if (detail.target !== app) {
59
+ const parser = new DOMParser();
60
+ const doc = parser.parseFromString(detail.serverResponse, 'text/html');
61
+ const newApp = doc.getElementById('app');
62
+
63
+ if (!newApp) return;
64
+
65
+ detail.target = app;
66
+ detail.serverResponse = newApp.outerHTML;
67
+ detail.swapOverride = 'outerHTML';
68
+ }
69
+
70
+ // --- Strip admin opacity gate ---
71
+ // Admin layout uses style="opacity: 0" on #app to prevent content flash before
72
+ // auth check on full page load. On boosted nav auth is already verified, so strip
73
+ // it from the response before HTMX swaps it in.
74
+ if (detail.serverResponse) {
75
+ detail.serverResponse = detail.serverResponse.replace(
76
+ /(<[^>]*id=["']app["'][^>]*)\s*style=["']opacity:\s*0["']/,
77
+ '$1'
78
+ );
79
+ }
80
+
81
+ // --- Layout mismatch detection ---
82
+ const currentLayout = app.dataset?.layout;
83
+ if (!currentLayout) return;
84
+
85
+ const incomingLayout = extractLayoutFromResponse(detail.serverResponse);
86
+
87
+ if (incomingLayout && incomingLayout !== currentLayout) {
88
+ detail.shouldSwap = false;
89
+ const path = detail.pathInfo?.requestPath || detail.xhr?.responseURL;
90
+ if (path) {
91
+ window.location.href = path;
92
+ }
93
+ }
94
+ });
@@ -0,0 +1,63 @@
1
+ // ---------------------------
2
+ // CSRF Protection
3
+ // ---------------------------
4
+ // Self-registering module — import for side effects only.
5
+ // Must be imported BEFORE auth.js so the fetch interceptor captures
6
+ // the initial /api/auth/me request.
7
+ //
8
+ // Two layers:
9
+ // 1. Global fetch() interceptor — protects all existing fetch() calls
10
+ // 2. HTMX event hooks — protects all HTMX-driven requests
11
+ //
12
+ // Token is stored in a cookie (XSRF-TOKEN) set by the backend.
13
+ // All tabs share the same cookie, preventing multi-tab desync.
14
+
15
+ const CSRF_HEADER = 'X-CSRF-Token';
16
+ const CSRF_COOKIE = 'XSRF-TOKEN';
17
+
18
+ function readTokenFromCookie() {
19
+ const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${CSRF_COOKIE}=([^;]*)`));
20
+ return match ? decodeURIComponent(match[1]) : null;
21
+ }
22
+
23
+ export function getCsrfToken() {
24
+ return readTokenFromCookie();
25
+ }
26
+
27
+ export function setCsrfToken(_token) {
28
+ // No-op — token is managed via Set-Cookie by the backend.
29
+ // Kept for API compatibility (fetch-client.js imports this).
30
+ }
31
+
32
+ // ---------------------------
33
+ // Global fetch interceptor
34
+ // ---------------------------
35
+ // Patches window.fetch to inject CSRF header on /api/ requests.
36
+
37
+ const originalFetch = window.fetch.bind(window);
38
+
39
+ window.fetch = function (input, init = {}) {
40
+ const url = typeof input === 'string' ? input : input?.url || '';
41
+
42
+ if (url.startsWith('/api/')) {
43
+ const headers = new Headers(init.headers || {});
44
+ const token = readTokenFromCookie();
45
+ if (token) {
46
+ headers.set(CSRF_HEADER, token);
47
+ }
48
+ init = { ...init, headers };
49
+ }
50
+
51
+ return originalFetch(input, init);
52
+ };
53
+
54
+ // ---------------------------
55
+ // HTMX hooks
56
+ // ---------------------------
57
+
58
+ document.body.addEventListener('htmx:configRequest', (e) => {
59
+ const token = readTokenFromCookie();
60
+ if (token) {
61
+ e.detail.headers[CSRF_HEADER] = token;
62
+ }
63
+ });