@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.
@@ -0,0 +1,111 @@
1
+ // ---------------------------
2
+ // Table column sorting (delegated)
3
+ // ---------------------------
4
+ // Clickable <th data-sort="column"> headers that toggle asc/desc sorting.
5
+ // Sort state is persisted in URL query params (?sort=col&dir=asc).
6
+ // Fully self-registering — import for side effects only.
7
+ // Also exports initTableSort() for use in DOMContentLoaded/afterSwap orchestration.
8
+
9
+ import htmx from '../lib/htmx.js';
10
+ import { setUrlParam, getUrlParam, mergeHxVals } from './navigation.js';
11
+
12
+ /**
13
+ * Apply sort indicator classes to th[data-sort] headers based on current sort state.
14
+ * @param {Document|HTMLElement} root
15
+ */
16
+ export function initTableSort(root = document) {
17
+ const sortCol = getUrlParam('sort');
18
+ const sortDir = getUrlParam('dir');
19
+
20
+ root.querySelectorAll('th[data-sort]').forEach((th) => {
21
+ th.classList.remove('sort-asc', 'sort-desc');
22
+ if (sortCol && th.dataset.sort === sortCol) {
23
+ th.classList.add(sortDir === 'desc' ? 'sort-desc' : 'sort-asc');
24
+ }
25
+ });
26
+
27
+ // Set hx-vals on sortable tbody elements so the initial API request includes sort params
28
+ if (sortCol) {
29
+ root.querySelectorAll('th[data-sort]').forEach((th) => {
30
+ const tbody = th.closest('table')?.querySelector('tbody');
31
+ if (tbody instanceof HTMLElement && !tbody.dataset.sortInit) {
32
+ mergeHxVals(tbody, { sort: sortCol, dir: sortDir || 'asc' });
33
+ tbody.dataset.sortInit = '1';
34
+ }
35
+ });
36
+ }
37
+ }
38
+
39
+ // Delegated click handler for sort headers
40
+ document.addEventListener('click', (e) => {
41
+ const th = e.target.closest('th[data-sort]');
42
+ if (!th) return;
43
+
44
+ const column = th.dataset.sort;
45
+ const tbody = th.closest('table')?.querySelector('tbody');
46
+ if (!(tbody instanceof HTMLElement)) return;
47
+
48
+ // Determine new direction: toggle if same column, else default to asc
49
+ const currentSort = getUrlParam('sort');
50
+ const currentDir = getUrlParam('dir');
51
+ let newDir = 'asc';
52
+ if (column === currentSort) {
53
+ newDir = currentDir === 'asc' ? 'desc' : 'asc';
54
+ }
55
+
56
+ // Update sort indicator classes on all sibling headers
57
+ const thead = th.closest('thead');
58
+ if (thead) {
59
+ thead.querySelectorAll('th[data-sort]').forEach((sibling) => {
60
+ sibling.classList.remove('sort-asc', 'sort-desc');
61
+ });
62
+ }
63
+ th.classList.add(newDir === 'desc' ? 'sort-desc' : 'sort-asc');
64
+
65
+ // Reset pagination to page 1
66
+ const pageParam = tbody.getAttribute('data-pagination-param') || 'page';
67
+ mergeHxVals(tbody, { sort: column, dir: newDir, [pageParam]: 1 });
68
+ setUrlParam(pageParam, null);
69
+
70
+ // Update URL for bookmarkability
71
+ setUrlParam('sort', column);
72
+ setUrlParam('dir', newDir);
73
+
74
+ // Trigger refresh
75
+ htmx.process(tbody);
76
+ if (typeof htmx?.trigger === 'function') {
77
+ htmx.trigger(tbody, 'refresh');
78
+ }
79
+ });
80
+
81
+ // Re-sync sort state on back/forward navigation
82
+ window.addEventListener('popstate', () => {
83
+ const sortCol = getUrlParam('sort');
84
+ const sortDir = getUrlParam('dir');
85
+
86
+ document.querySelectorAll('th[data-sort]').forEach((th) => {
87
+ th.classList.remove('sort-asc', 'sort-desc');
88
+ if (sortCol && th.dataset.sort === sortCol) {
89
+ th.classList.add(sortDir === 'desc' ? 'sort-desc' : 'sort-asc');
90
+ }
91
+ });
92
+
93
+ // Update hx-vals on tbody elements
94
+ document.querySelectorAll('th[data-sort]').forEach((th) => {
95
+ const tbody = th.closest('table')?.querySelector('tbody');
96
+ if (tbody instanceof HTMLElement) {
97
+ if (sortCol) {
98
+ mergeHxVals(tbody, { sort: sortCol, dir: sortDir || 'asc' });
99
+ } else {
100
+ // Remove sort params from hx-vals when URL has none
101
+ let existing = {};
102
+ try {
103
+ existing = JSON.parse(tbody.getAttribute('hx-vals') || '{}');
104
+ } catch { /* ignore */ }
105
+ delete existing.sort;
106
+ delete existing.dir;
107
+ tbody.setAttribute('hx-vals', JSON.stringify(existing));
108
+ }
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,46 @@
1
+ // ---------------------------
2
+ // API Client
3
+ // ---------------------------
4
+ // Convenience wrapper around fetch for /api/ calls.
5
+ // Auto-handles CSRF headers, JSON content type, and body serialization.
6
+ // New code should use this; existing inline fetch() calls are protected
7
+ // by the global interceptor in core/csrf.js.
8
+
9
+ import { getCsrfToken, setCsrfToken } from './core/csrf.js';
10
+
11
+ const CSRF_HEADER = 'X-CSRF-Token';
12
+
13
+ /**
14
+ * Fetch wrapper for API calls.
15
+ * @param {string} url - The API URL (e.g. '/api/users')
16
+ * @param {RequestInit & { body?: object | string }} [options={}]
17
+ * @returns {Promise<Response>}
18
+ */
19
+ export function apiFetch(url, options = {}) {
20
+ const headers = new Headers(options.headers || {});
21
+ const token = getCsrfToken();
22
+
23
+ if (token) {
24
+ headers.set(CSRF_HEADER, token);
25
+ }
26
+
27
+ headers.set('Accept', 'application/json');
28
+
29
+ const method = (options.method || 'GET').toUpperCase();
30
+
31
+ if (method !== 'GET' && method !== 'HEAD') {
32
+ if (!headers.has('Content-Type')) {
33
+ headers.set('Content-Type', 'application/json');
34
+ }
35
+
36
+ if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
37
+ options = { ...options, body: JSON.stringify(options.body) };
38
+ }
39
+ }
40
+
41
+ return fetch(url, { ...options, headers, credentials: 'same-origin' }).then(response => {
42
+ const newToken = response.headers.get(CSRF_HEADER);
43
+ if (newToken) setCsrfToken(newToken);
44
+ return response;
45
+ });
46
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Creates a debounced version of a function.
3
+ * @param {Function} fn - The function to debounce
4
+ * @param {number} delay - Delay in milliseconds
5
+ * @returns {Function} - Debounced function with a .cancel() method
6
+ */
7
+ export function debounce(fn, delay) {
8
+ let timeoutId = null;
9
+
10
+ const debounced = (...args) => {
11
+ if (timeoutId !== null) {
12
+ clearTimeout(timeoutId);
13
+ }
14
+ timeoutId = setTimeout(() => {
15
+ timeoutId = null;
16
+ fn(...args);
17
+ }, delay);
18
+ };
19
+
20
+ debounced.cancel = () => {
21
+ if (timeoutId !== null) {
22
+ clearTimeout(timeoutId);
23
+ timeoutId = null;
24
+ }
25
+ };
26
+
27
+ return debounced;
28
+ }
29
+
30
+ /**
31
+ * Scrub user input for safe use in search queries.
32
+ * Removes potentially dangerous characters while preserving search functionality.
33
+ * @param {string} input - Raw user input
34
+ * @returns {string} - Scrubbed input
35
+ */
36
+ export function scrubSearchInput(input) {
37
+ if (typeof input !== 'string') return '';
38
+
39
+ return input
40
+ // Remove HTML/script injection characters
41
+ .replace(/[<>]/g, '')
42
+ // Remove potential SQL injection characters (belt-and-suspenders with BE)
43
+ .replace(/[;'"\\]/g, '')
44
+ // Collapse multiple spaces into one
45
+ .replace(/\s+/g, ' ')
46
+ // Trim whitespace
47
+ .trim();
48
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Escape a string for safe insertion into HTML.
3
+ * @param {string} str
4
+ * @returns {string}
5
+ */
6
+ export function escapeHtml(str) {
7
+ var div = document.createElement('div');
8
+ div.textContent = str;
9
+ return div.innerHTML;
10
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Register generic/reusable Handlebars helpers.
3
+ * @param {typeof import('handlebars')} Handlebars
4
+ */
5
+ export function registerHandlebarsHelpers(Handlebars) {
6
+ // ─── Partials ───
7
+
8
+ Handlebars.registerPartial('formButtons', `<div class="flex justify-end gap-3 pt-2">
9
+ <button type="button" class="border rounded px-3 py-2 hover:bg-slate-50" data-modal-close>
10
+ Cancel
11
+ </button>
12
+ <button type="submit" class="rounded px-3 py-2 bg-slate-900 text-white hover:bg-slate-800">
13
+ {{label}}
14
+ </button>
15
+ </div>`);
16
+
17
+ Handlebars.registerPartial('copyIdBtn', `<button
18
+ type="button"
19
+ class="copy-id-btn p-1 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600"
20
+ data-copy-value="{{id}}"
21
+ title="Copy ID"
22
+ >
23
+ <svg class="copy-icon w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
24
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
25
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
26
+ </svg>
27
+ <svg class="check-icon w-3.5 h-3.5 hidden text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
28
+ <polyline points="20 6 9 17 4 12"></polyline>
29
+ </svg>
30
+ </button>`);
31
+
32
+ // ─── Utilities ───
33
+
34
+ // Parse a timestamp as UTC. MySQL datetimes arrive as "2026-03-11 14:30:00"
35
+ // with no timezone indicator — JS Date parsing is ambiguous without one.
36
+ // This normalizes to ISO 8601 with Z suffix so it's unambiguously UTC.
37
+ function parseUtcDate(value) {
38
+ if (value === null || value === undefined) return null;
39
+ const s = String(value).trim();
40
+ if (!s) return null;
41
+ // If it already has a timezone indicator (Z, +, -) leave it alone,
42
+ // otherwise treat as UTC by appending Z after converting space to T
43
+ const normalized = /[Z+\-]\d{0,4}:?\d{0,2}$/.test(s) ? s : s.replace(' ', 'T') + 'Z';
44
+ const d = new Date(normalized);
45
+ return Number.isNaN(d.getTime()) ? null : d;
46
+ }
47
+
48
+ // ─── Helpers ───
49
+
50
+ // Equality comparison helper
51
+ Handlebars.registerHelper('eq', (a, b) => a === b);
52
+
53
+ // Inequality comparison helper
54
+ Handlebars.registerHelper('neq', (a, b) => a !== b);
55
+
56
+ // Logical AND helper
57
+ Handlebars.registerHelper('and', function(...args) {
58
+ args.pop(); // Remove Handlebars options object
59
+ return args.every(Boolean);
60
+ });
61
+
62
+ // Logical OR helper
63
+ Handlebars.registerHelper('or', function(...args) {
64
+ args.pop(); // Remove Handlebars options object
65
+ return args.some(Boolean);
66
+ });
67
+
68
+ Handlebars.registerHelper('notin', function(value, ...rest) {
69
+ const options = rest.pop(); // Remove Handlebars options object
70
+ let listArgs = rest;
71
+
72
+ // If a single array was provided from context, use it
73
+ if (listArgs.length === 1 && Array.isArray(listArgs[0])) {
74
+ listArgs = listArgs[0];
75
+ }
76
+
77
+ if (!listArgs || !Array.isArray(listArgs)) {
78
+ return true; // treat missing list as "not in"
79
+ }
80
+
81
+ return listArgs.indexOf(value) === -1;
82
+ });
83
+
84
+ // Truncate a string to a max length, adding ellipsis if truncated.
85
+ Handlebars.registerHelper('truncate', (value, length) => {
86
+ if (value === null || value === undefined) return '';
87
+ const s = String(value);
88
+ if (s.length <= length) return s;
89
+ return s.slice(0, length) + '…';
90
+ });
91
+
92
+ // Make a string all upper case
93
+ Handlebars.registerHelper('upper', (value) => {
94
+ if (value === null || value === undefined) return '';
95
+ const s = String(value);
96
+ return s.toUpperCase();
97
+ });
98
+
99
+ // Convert snake_case to human-readable text: "on_pack_open" → "on pack open"
100
+ Handlebars.registerHelper('humanize', (value) => {
101
+ if (value === null || value === undefined) return '';
102
+ return String(value).replace(/_/g, ' ');
103
+ });
104
+
105
+ // Convert snake_case to Title Case: "level_up" → "Level Up"
106
+ Handlebars.registerHelper('titleHumanize', (value) => {
107
+ if (value === null || value === undefined) return '';
108
+ return String(value).replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
109
+ });
110
+
111
+ // Tree indent indicator — shows depth via left margin + ↳ arrow.
112
+ // sort_key format: "00" (root), "00.01" (depth 1), "00.01.ff" (depth 2), etc.
113
+ Handlebars.registerHelper('treeIndent', (sortKey) => {
114
+ if (!sortKey) return '';
115
+ const depth = (String(sortKey).match(/\./g) || []).length;
116
+ if (depth === 0) return '';
117
+ const ml = depth * 16;
118
+ return new Handlebars.SafeString(
119
+ `<span class="text-slate-300 inline-block" style="margin-left:${ml}px">↳</span> `
120
+ );
121
+ });
122
+
123
+ // Serialize a value to a JSON string (safe for embedding in HTML attributes).
124
+ Handlebars.registerHelper('json', (value) => {
125
+ if (value === null || value === undefined) return 'null';
126
+ return JSON.stringify(value);
127
+ });
128
+
129
+ // Relative time helper: "3 minutes ago", "2 days ago", etc.
130
+ Handlebars.registerHelper('timeAgo', (value) => {
131
+ const d = parseUtcDate(value);
132
+ if (!d) return '';
133
+
134
+ const seconds = Math.floor((Date.now() - d.getTime()) / 1000);
135
+ if (seconds < 0) return 'just now';
136
+
137
+ const intervals = [
138
+ [60, 'second'],
139
+ [60, 'minute'],
140
+ [24, 'hour'],
141
+ [30, 'day'],
142
+ [12, 'month'],
143
+ [Infinity, 'year'],
144
+ ];
145
+
146
+ let remaining = seconds;
147
+ for (const [divisor, unit] of intervals) {
148
+ if (remaining < divisor) {
149
+ if (unit === 'second' && remaining < 10) return 'just now';
150
+ const n = Math.floor(remaining);
151
+ return n + ' ' + unit + (n !== 1 ? 's' : '') + ' ago';
152
+ }
153
+ remaining /= divisor;
154
+ }
155
+ return '';
156
+ });
157
+
158
+ // Format a UTC timestamp to the user's local timezone as a readable datetime string.
159
+ Handlebars.registerHelper('formatDateTime', (value) => {
160
+ const d = parseUtcDate(value);
161
+ if (!d) return '';
162
+
163
+ return new Intl.DateTimeFormat(undefined, {
164
+ year: 'numeric',
165
+ month: 'short',
166
+ day: '2-digit',
167
+ hour: '2-digit',
168
+ minute: '2-digit',
169
+ }).format(d);
170
+ });
171
+ }
@@ -0,0 +1,6 @@
1
+ export { registerHandlebarsHelpers } from './handlebars-helpers.js';
2
+ export { debounce, scrubSearchInput } from './debounce.js';
3
+ export { initCopyIdHandler } from './utils.js';
4
+ export { escapeHtml } from './escape-html.js';
5
+ export { populateSelect } from './populate-select.js';
6
+ export { getRouteParams } from './route-params.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Populate a <select> from a JSON API endpoint.
3
+ * Fetches { data: [{ id, name }, ...] } and appends <option> elements.
4
+ * @param {HTMLSelectElement} select
5
+ * @param {string} endpoint
6
+ * @param {string} [selectedId]
7
+ */
8
+ export function populateSelect(select, endpoint, selectedId) {
9
+ fetch(endpoint)
10
+ .then(function(r) { return r.json(); })
11
+ .then(function(json) {
12
+ if (json.status !== 'success' || !json.data) return;
13
+ json.data.forEach(function(item) {
14
+ var opt = document.createElement('option');
15
+ opt.value = item.id;
16
+ opt.textContent = item.name;
17
+ if (selectedId && item.id === selectedId) opt.selected = true;
18
+ select.appendChild(opt);
19
+ });
20
+ });
21
+ }
@@ -0,0 +1,50 @@
1
+ import { normalizePath } from '../core/navigation.js';
2
+
3
+ /**
4
+ * Extract route parameters from the current URL based on a route pattern.
5
+ * @param {string} pattern - Route pattern with [param] placeholders (e.g., '/series/[seriesId]/collections/')
6
+ * @param {string} [url] - URL to extract from (defaults to current pathname)
7
+ * @returns {Object} - Object mapping parameter names to values
8
+ *
9
+ * @example
10
+ * // On page /series/abc123/collections/
11
+ * getRouteParams('/series/[seriesId]/collections/')
12
+ * // Returns: { seriesId: 'abc123' }
13
+ *
14
+ * @example
15
+ * // Multiple params
16
+ * getRouteParams('/series/[seriesId]/collections/[collectionId]/')
17
+ * // On /series/abc123/collections/xyz789/
18
+ * // Returns: { seriesId: 'abc123', collectionId: 'xyz789' }
19
+ */
20
+ export function getRouteParams(pattern, url = window.location.pathname) {
21
+ const params = {};
22
+
23
+ const urlNorm = normalizePath(url);
24
+ const patternNorm = normalizePath(pattern);
25
+
26
+ const urlSegments = urlNorm.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
27
+ const patternSegments = patternNorm.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
28
+
29
+ if (urlSegments.length !== patternSegments.length) {
30
+ return params; // Pattern doesn't match, return empty object
31
+ }
32
+
33
+ for (let i = 0; i < patternSegments.length; i++) {
34
+ const urlSeg = urlSegments[i];
35
+ const patternSeg = patternSegments[i];
36
+
37
+ // Check if this is a parameter segment
38
+ if (patternSeg.startsWith('[') && patternSeg.endsWith(']')) {
39
+ const paramName = patternSeg.slice(1, -1); // Remove [ and ]
40
+ params[paramName] = urlSeg;
41
+ } else {
42
+ // Static segment should match exactly
43
+ if (patternSeg !== urlSeg) {
44
+ return {}; // Mismatch, return empty object
45
+ }
46
+ }
47
+ }
48
+
49
+ return params;
50
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Initialize copy-to-clipboard button handler (delegated).
3
+ * Buttons should have class `.copy-id-btn` and `data-copy-value` attribute.
4
+ * Icons inside: `.copy-icon` (clipboard) and `.check-icon` (checkmark, hidden).
5
+ */
6
+ export function initCopyIdHandler() {
7
+ document.addEventListener('click', (e) => {
8
+ const btn = e.target.closest('.copy-id-btn');
9
+ if (!btn) return;
10
+
11
+ const value = btn.dataset.copyValue;
12
+ if (!value) return;
13
+
14
+ navigator.clipboard.writeText(value).then(() => {
15
+ const copyIcon = btn.querySelector('.copy-icon');
16
+ const checkIcon = btn.querySelector('.check-icon');
17
+ if (copyIcon && checkIcon) {
18
+ copyIcon.classList.add('hidden');
19
+ checkIcon.classList.remove('hidden');
20
+ setTimeout(() => {
21
+ copyIcon.classList.remove('hidden');
22
+ checkIcon.classList.add('hidden');
23
+ }, 1500);
24
+ }
25
+ });
26
+ });
27
+ }
package/src/index.js ADDED
@@ -0,0 +1,73 @@
1
+ // ---------------------------
2
+ // Graspr Framework — barrel export
3
+ // ---------------------------
4
+ // Public API for apps to import from '@phillipsharring/graspr-framework'.
5
+ // Side-effect modules (csrf, boosted-nav, auth-state, forms, search, sortable)
6
+ // are NOT imported here — use '@phillipsharring/graspr-framework/init' for those.
7
+
8
+ // Auth
9
+ export { checkAuth, getAuthData, refreshAuthData } from './auth.js';
10
+
11
+ // API client
12
+ export { apiFetch } from './fetch-client.js';
13
+
14
+ // Core — pagination & table sort (init functions)
15
+ export { initPagination } from './core/pagination.js';
16
+ export { initTableSort } from './core/table-sort.js';
17
+
18
+ // Core — auth-state (configurable)
19
+ export { registerAdminPermissionPrefixes } from './core/auth-state.js';
20
+
21
+ // Core — navigation utilities
22
+ export {
23
+ normalizePath,
24
+ setActiveNav,
25
+ setUrlParam,
26
+ getUrlParam,
27
+ getUrlParamInt,
28
+ mergeHxVals,
29
+ } from './core/navigation.js';
30
+
31
+ // Core — CSRF token access
32
+ export { getCsrfToken, setCsrfToken } from './core/csrf.js';
33
+
34
+ // UI — toast
35
+ export {
36
+ GrasprToast,
37
+ registerToastHelpers,
38
+ initToastEventHandlers,
39
+ openToast,
40
+ closeToast,
41
+ } from './ui/toast.js';
42
+
43
+ // UI — modal
44
+ export {
45
+ setOnModalCloseWithConfirm,
46
+ getGlobalModal,
47
+ getGlobalModalDialog,
48
+ getGlobalModalHeader,
49
+ getGlobalModalCloseButton,
50
+ isGlobalModalOpen,
51
+ openGlobalModal,
52
+ closeGlobalModal,
53
+ } from './ui/modal.js';
54
+
55
+ // UI — modal form
56
+ export { openFormModal } from './ui/modal-form.js';
57
+
58
+ // UI — confirm dialog
59
+ export { GrasprConfirm } from './ui/confirm-dialog.js';
60
+
61
+ // UI — click burst
62
+ export { createBurst, attachClickBurst, initClickBurst } from './ui/click-burst.js';
63
+
64
+ // UI — typeahead
65
+ export { createTypeahead } from './ui/typeahead.js';
66
+
67
+ // Helpers
68
+ export { registerHandlebarsHelpers } from './helpers/handlebars-helpers.js';
69
+ export { initCopyIdHandler } from './helpers/utils.js';
70
+ export { escapeHtml } from './helpers/escape-html.js';
71
+ export { populateSelect } from './helpers/populate-select.js';
72
+ export { getRouteParams } from './helpers/route-params.js';
73
+ export { debounce, scrubSearchInput } from './helpers/debounce.js';
package/src/init.js ADDED
@@ -0,0 +1,13 @@
1
+ // ---------------------------
2
+ // Graspr Framework initialization (side effects)
3
+ // ---------------------------
4
+ // Import this module to register all framework event listeners,
5
+ // CSRF interceptors, boosted-nav handlers, form error handling, etc.
6
+ // Order matters: csrf must be before auth-state.
7
+
8
+ import './core/csrf.js';
9
+ import './core/boosted-nav.js';
10
+ import './core/auth-state.js';
11
+ import './core/forms.js';
12
+ import './core/search.js';
13
+ import './core/sortable.js';
@@ -0,0 +1,75 @@
1
+ import htmx from './htmx.js';
2
+ import Handlebars from 'handlebars';
3
+
4
+ htmx.defineExtension('client-side-templates', {
5
+ transformResponse(text, xhr, elt) {
6
+ const tplHost = htmx.closest(elt, '[handlebars-template], [handlebars-array-template]');
7
+ if (!tplHost) return text;
8
+
9
+ // Boosted <a> links inside a template-attributed container inherit the
10
+ // attribute, but their responses are full HTML pages — not JSON.
11
+ // Skip transformation so the beforeSwap handler can fix the target.
12
+ if (elt instanceof HTMLAnchorElement && !elt.hasAttribute('hx-get') && !elt.hasAttribute('hx-post')) {
13
+ return text;
14
+ }
15
+
16
+ const isArray = tplHost.hasAttribute('handlebars-array-template');
17
+ const templateId = tplHost.getAttribute(isArray ? 'handlebars-array-template' : 'handlebars-template');
18
+
19
+ const templateEl = htmx.find('#' + templateId);
20
+ if (!templateEl) throw new Error('Unknown handlebars template: ' + templateId);
21
+
22
+ let data;
23
+ try {
24
+ data = JSON.parse(text);
25
+ } catch {
26
+ throw new Error('Response was not valid JSON for handlebars template: ' + templateId);
27
+ }
28
+
29
+ // innerHTML escapes ">" to "&gt;" in text nodes (per the HTML serialization
30
+ // spec), which mangles Handlebars partial calls: {{> partial}} → {{&gt; partial}}.
31
+ // Reverse this specific escaping before compiling.
32
+ const render = Handlebars.compile(templateEl.innerHTML.replaceAll('{{&gt;', '{{>'));
33
+
34
+ // Object templates: spread `data` into the top level so templates can use
35
+ // both {{message}}/{{status}} (for toasts) and {{name}}/{{id}} (for entities).
36
+ // Envelope props (status, message) overlay record props — toasts need
37
+ // {{status}} = "success"/"error". Entity templates that collide with envelope
38
+ // keys (e.g. a `status` column) should use {{data.status}} instead.
39
+ if (!isArray) {
40
+ const hasDataObject = data && typeof data === 'object' && 'data' in data &&
41
+ typeof data.data === 'object' && !Array.isArray(data.data);
42
+ const renderData = hasDataObject
43
+ ? { ...data.data, ...data, data: data.data } // spread data props, then overlay top-level (status/message win)
44
+ : data;
45
+ return render(renderData);
46
+ }
47
+
48
+ // Array templates: normalize to { data: Array, meta?: Object }
49
+ // Supports both raw arrays and { data: [...], meta: {...} } responses
50
+ const isArrayResponse = Array.isArray(data);
51
+ const rows = isArrayResponse ? data : Array.isArray(data?.data) ? data.data : [];
52
+ const meta = !isArrayResponse && data && typeof data === 'object' ? data.meta ?? null : null;
53
+
54
+ // Stash table sort meta on the element for the table-sort module
55
+ if (meta?.table_sorts) {
56
+ tplHost.dataset.tableSorts = JSON.stringify(meta.table_sorts);
57
+ }
58
+
59
+ // Optional: render pagination from meta as a side effect
60
+ const pagTargetSel = tplHost.getAttribute('data-pagination-target');
61
+ const pagTemplateId = tplHost.getAttribute('data-pagination-template');
62
+ const pagSource = tplHost.getAttribute('data-pagination-source') || '';
63
+ const pagParam = tplHost.getAttribute('data-pagination-param') || 'page';
64
+ if (pagTargetSel && pagTemplateId) {
65
+ const targetEl = document.querySelector(pagTargetSel);
66
+ const tpl = document.getElementById(pagTemplateId);
67
+ if (targetEl instanceof HTMLElement && tpl instanceof HTMLTemplateElement) {
68
+ const pagRender = Handlebars.compile(tpl.innerHTML.replaceAll('{{&gt;', '{{>'));
69
+ targetEl.innerHTML = pagRender({ meta, source: pagSource, param: pagParam });
70
+ }
71
+ }
72
+
73
+ return render({ data: rows, meta });
74
+ },
75
+ });
@@ -0,0 +1,7 @@
1
+ import htmx from 'htmx.org';
2
+
3
+ // Ensure there's exactly one shared HTMX instance, available both as an ES module
4
+ // import and as a global for any inline scripts / extensions that expect `window.htmx`.
5
+ window.htmx = window.htmx || htmx;
6
+
7
+ export default window.htmx;