@canopy-iiif/app 0.7.13 → 0.7.16
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/lib/AGENTS.md +64 -0
- package/lib/build/dev.js +251 -354
- package/lib/build/iiif.js +20 -43
- package/lib/build/mdx.js +37 -74
- package/lib/build/runtimes.js +40 -85
- package/lib/build/search-index.js +6 -5
- package/lib/build/search.js +4 -3
- package/lib/build/styles.js +9 -15
- package/lib/common.js +20 -0
- package/lib/components/featured.js +2 -1
- package/lib/search/command-runtime.js +370 -0
- package/lib/search/search-app.jsx +46 -2
- package/lib/search/search.js +7 -192
- package/package.json +8 -2
- package/ui/dist/index.mjs +116 -72
- package/ui/dist/index.mjs.map +4 -4
- package/ui/dist/server.mjs +89 -38
- package/ui/dist/server.mjs.map +4 -4
- package/ui/tailwind-canopy-iiif-plugin.js +7 -12
- package/ui/tailwind-canopy-iiif-preset.js +15 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
function ready(fn) {
|
|
2
|
+
if (document.readyState === 'loading') {
|
|
3
|
+
document.addEventListener('DOMContentLoaded', fn, { once: true });
|
|
4
|
+
} else {
|
|
5
|
+
fn();
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseProps(el) {
|
|
10
|
+
try {
|
|
11
|
+
const script = el.querySelector('script[type="application/json"]');
|
|
12
|
+
if (script) return JSON.parse(script.textContent || '{}');
|
|
13
|
+
} catch (_) {}
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toLower(val) {
|
|
18
|
+
try {
|
|
19
|
+
return String(val || '').toLowerCase();
|
|
20
|
+
} catch (_) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getBasePath() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : '';
|
|
28
|
+
if (!raw) return '';
|
|
29
|
+
return raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withBase(href) {
|
|
36
|
+
try {
|
|
37
|
+
let raw = href == null ? '' : String(href);
|
|
38
|
+
raw = raw.trim();
|
|
39
|
+
if (!raw) return raw;
|
|
40
|
+
const basePath = getBasePath();
|
|
41
|
+
if (!basePath) {
|
|
42
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//') || raw.startsWith('#') || raw.startsWith('?')) {
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
45
|
+
let cleaned = raw.replace(/^\/+/, '');
|
|
46
|
+
while (cleaned.startsWith('./')) cleaned = cleaned.slice(2);
|
|
47
|
+
while (cleaned.startsWith('../')) cleaned = cleaned.slice(3);
|
|
48
|
+
if (!cleaned) return '/';
|
|
49
|
+
return '/' + cleaned;
|
|
50
|
+
}
|
|
51
|
+
if (/^https?:/i.test(raw)) return raw;
|
|
52
|
+
const cleaned = raw.replace(/^\/+/, '');
|
|
53
|
+
return `${basePath}/${cleaned}`;
|
|
54
|
+
} catch (_) {
|
|
55
|
+
return href;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rootBase() {
|
|
60
|
+
return getBasePath();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isOnSearchPage() {
|
|
64
|
+
try {
|
|
65
|
+
const base = rootBase();
|
|
66
|
+
let path = String(location.pathname || '');
|
|
67
|
+
if (base && path.startsWith(base)) path = path.slice(base.length);
|
|
68
|
+
if (path.endsWith('/')) path = path.slice(0, -1);
|
|
69
|
+
return path === '/search';
|
|
70
|
+
} catch (_) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let recordsPromise = null;
|
|
76
|
+
async function loadRecords() {
|
|
77
|
+
if (!recordsPromise) {
|
|
78
|
+
recordsPromise = (async () => {
|
|
79
|
+
try {
|
|
80
|
+
const base = rootBase();
|
|
81
|
+
let version = '';
|
|
82
|
+
try {
|
|
83
|
+
const meta = await fetch(`${base}/api/index.json`).then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
|
|
84
|
+
if (meta && typeof meta.version === 'string') version = meta.version;
|
|
85
|
+
} catch (_) {}
|
|
86
|
+
const suffix = version ? `?v=${encodeURIComponent(version)}` : '';
|
|
87
|
+
const response = await fetch(`${base}/api/search-index.json${suffix}`).catch(() => null);
|
|
88
|
+
if (!response || !response.ok) return [];
|
|
89
|
+
const json = await response.json().catch(() => []);
|
|
90
|
+
if (Array.isArray(json)) return json;
|
|
91
|
+
if (json && Array.isArray(json.records)) return json.records;
|
|
92
|
+
return [];
|
|
93
|
+
} catch (_) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
}
|
|
98
|
+
return recordsPromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function groupLabel(type) {
|
|
102
|
+
if (type === 'work') return 'Works';
|
|
103
|
+
if (type === 'page') return 'Pages';
|
|
104
|
+
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getItems(list) {
|
|
108
|
+
try {
|
|
109
|
+
return Array.prototype.slice.call(list.querySelectorAll('[data-canopy-item]'));
|
|
110
|
+
} catch (_) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderList(list, records, groupOrder) {
|
|
116
|
+
list.innerHTML = '';
|
|
117
|
+
if (!records.length) return;
|
|
118
|
+
const groups = new Map();
|
|
119
|
+
records.forEach((record) => {
|
|
120
|
+
const type = String(record && record.type || 'page');
|
|
121
|
+
if (!groups.has(type)) groups.set(type, []);
|
|
122
|
+
groups.get(type).push(record);
|
|
123
|
+
});
|
|
124
|
+
const desiredOrder = Array.isArray(groupOrder) ? groupOrder : [];
|
|
125
|
+
const orderedKeys = [...desiredOrder.filter((key) => groups.has(key)), ...Array.from(groups.keys()).filter((key) => !desiredOrder.includes(key))];
|
|
126
|
+
orderedKeys.forEach((key) => {
|
|
127
|
+
const header = document.createElement('div');
|
|
128
|
+
header.textContent = groupLabel(key);
|
|
129
|
+
header.style.cssText = 'padding:6px 12px;font-weight:600;color:#374151';
|
|
130
|
+
list.appendChild(header);
|
|
131
|
+
const entries = groups.get(key) || [];
|
|
132
|
+
entries.forEach((record, index) => {
|
|
133
|
+
const item = document.createElement('div');
|
|
134
|
+
item.setAttribute('data-canopy-item', '');
|
|
135
|
+
item.tabIndex = 0;
|
|
136
|
+
item.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;outline:none;';
|
|
137
|
+
const showThumb = String(record && record.type || '') === 'work' && record && record.thumbnail;
|
|
138
|
+
if (showThumb) {
|
|
139
|
+
const img = document.createElement('img');
|
|
140
|
+
img.src = record.thumbnail;
|
|
141
|
+
img.alt = '';
|
|
142
|
+
img.style.cssText = 'width:40px;height:40px;object-fit:cover;border-radius:4px';
|
|
143
|
+
item.appendChild(img);
|
|
144
|
+
}
|
|
145
|
+
const span = document.createElement('span');
|
|
146
|
+
span.textContent = record.title || record.href || '';
|
|
147
|
+
item.appendChild(span);
|
|
148
|
+
item.onmouseenter = () => { item.style.background = '#f8fafc'; };
|
|
149
|
+
item.onmouseleave = () => { item.style.background = 'transparent'; };
|
|
150
|
+
item.onfocus = () => {
|
|
151
|
+
item.style.background = '#eef2ff';
|
|
152
|
+
try { item.scrollIntoView({ block: 'nearest' }); } catch (_) {}
|
|
153
|
+
};
|
|
154
|
+
item.onblur = () => { item.style.background = 'transparent'; };
|
|
155
|
+
item.onclick = () => {
|
|
156
|
+
try { window.location.href = withBase(String(record.href || '')); } catch (_) {}
|
|
157
|
+
};
|
|
158
|
+
list.appendChild(item);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function focusFirst(list) {
|
|
164
|
+
const items = getItems(list);
|
|
165
|
+
if (!items.length) return;
|
|
166
|
+
items[0].focus();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function focusLast(list, resetTarget) {
|
|
170
|
+
const items = getItems(list);
|
|
171
|
+
if (!items.length) {
|
|
172
|
+
if (resetTarget && resetTarget.focus) resetTarget.focus();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
items[items.length - 1].focus();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function bindKeyboardNavigation({ input, list, panel }) {
|
|
179
|
+
input.addEventListener('keydown', (event) => {
|
|
180
|
+
if (event.key === 'ArrowDown') {
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
panel.style.display = 'block';
|
|
183
|
+
focusFirst(list);
|
|
184
|
+
} else if (event.key === 'ArrowUp') {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
panel.style.display = 'block';
|
|
187
|
+
focusLast(list, input);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
list.addEventListener('keydown', (event) => {
|
|
192
|
+
const current = event.target && event.target.closest && event.target.closest('[data-canopy-item]');
|
|
193
|
+
if (!current) return;
|
|
194
|
+
if (event.key === 'ArrowDown') {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
const items = getItems(list);
|
|
197
|
+
const idx = items.indexOf(current);
|
|
198
|
+
const next = items[Math.min(items.length - 1, idx + 1)] || current;
|
|
199
|
+
next.focus();
|
|
200
|
+
} else if (event.key === 'ArrowUp') {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
const items = getItems(list);
|
|
203
|
+
const idx = items.indexOf(current);
|
|
204
|
+
if (idx <= 0) {
|
|
205
|
+
input && input.focus && input.focus();
|
|
206
|
+
} else {
|
|
207
|
+
const prev = items[idx - 1];
|
|
208
|
+
;(prev || current).focus();
|
|
209
|
+
}
|
|
210
|
+
} else if (event.key === 'Enter') {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
try { current.click(); } catch (_) {}
|
|
213
|
+
} else if (event.key === 'Escape') {
|
|
214
|
+
panel.style.display = 'none';
|
|
215
|
+
try { input && input.focus && input.focus(); } catch (_) {}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function attachCommand(host) {
|
|
221
|
+
const config = parseProps(host) || {};
|
|
222
|
+
const maxResults = Number(config.maxResults || 8) || 8;
|
|
223
|
+
const groupOrder = Array.isArray(config.groupOrder) ? config.groupOrder : ['work', 'page'];
|
|
224
|
+
const hotkey = typeof config.hotkey === 'string' ? config.hotkey : '';
|
|
225
|
+
const onSearchPage = isOnSearchPage();
|
|
226
|
+
|
|
227
|
+
const panel = (() => {
|
|
228
|
+
try { return host.querySelector('[data-canopy-command-panel]'); } catch (_) { return null; }
|
|
229
|
+
})();
|
|
230
|
+
if (!panel) return;
|
|
231
|
+
|
|
232
|
+
if (!onSearchPage) {
|
|
233
|
+
try {
|
|
234
|
+
const wrapper = host.querySelector('.relative');
|
|
235
|
+
if (wrapper) wrapper.setAttribute('data-canopy-panel-auto', '1');
|
|
236
|
+
} catch (_) {}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (onSearchPage) panel.style.display = 'none';
|
|
240
|
+
|
|
241
|
+
const list = (() => {
|
|
242
|
+
try { return panel.querySelector('#cplist'); } catch (_) { return null; }
|
|
243
|
+
})();
|
|
244
|
+
if (!list) return;
|
|
245
|
+
|
|
246
|
+
const input = (() => {
|
|
247
|
+
try { return host.querySelector('[data-canopy-command-input]'); } catch (_) { return null; }
|
|
248
|
+
})();
|
|
249
|
+
if (!input) return;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const params = new URLSearchParams(location.search || '');
|
|
253
|
+
const qp = params.get('q');
|
|
254
|
+
if (qp) input.value = qp;
|
|
255
|
+
} catch (_) {}
|
|
256
|
+
|
|
257
|
+
const records = await loadRecords();
|
|
258
|
+
|
|
259
|
+
function render(items) {
|
|
260
|
+
list.innerHTML = '';
|
|
261
|
+
if (!items.length) {
|
|
262
|
+
panel.style.display = onSearchPage ? 'none' : 'block';
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
renderList(list, items, groupOrder);
|
|
266
|
+
panel.style.display = 'block';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function filterAndShow(query) {
|
|
270
|
+
try {
|
|
271
|
+
const q = toLower(query);
|
|
272
|
+
if (!q) {
|
|
273
|
+
list.innerHTML = '';
|
|
274
|
+
panel.style.display = onSearchPage ? 'none' : 'block';
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const out = [];
|
|
278
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
279
|
+
const record = records[i];
|
|
280
|
+
const title = String(record && record.title || '');
|
|
281
|
+
if (!title) continue;
|
|
282
|
+
if (toLower(title).includes(q)) out.push(record);
|
|
283
|
+
if (out.length >= maxResults) break;
|
|
284
|
+
}
|
|
285
|
+
render(out);
|
|
286
|
+
} catch (_) {}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
input.addEventListener('input', () => {
|
|
290
|
+
if (onSearchPage) {
|
|
291
|
+
try {
|
|
292
|
+
const ev = new CustomEvent('canopy:search:setQuery', { detail: { query: input.value || '' } });
|
|
293
|
+
window.dispatchEvent(ev);
|
|
294
|
+
} catch (_) {}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
filterAndShow(input.value || '');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
bindKeyboardNavigation({ input, list, panel });
|
|
301
|
+
|
|
302
|
+
document.addEventListener('keydown', (event) => {
|
|
303
|
+
if (event.key === 'Escape') {
|
|
304
|
+
panel.style.display = 'none';
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
document.addEventListener('mousedown', (event) => {
|
|
309
|
+
try {
|
|
310
|
+
if (!panel.contains(event.target) && !host.contains(event.target)) {
|
|
311
|
+
panel.style.display = 'none';
|
|
312
|
+
}
|
|
313
|
+
} catch (_) {}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (hotkey) {
|
|
317
|
+
document.addEventListener('keydown', (event) => {
|
|
318
|
+
try {
|
|
319
|
+
const want = hotkey.toLowerCase();
|
|
320
|
+
if (!want) return;
|
|
321
|
+
const isMod = event.metaKey || event.ctrlKey;
|
|
322
|
+
const isCmd = (want === 'mod+k' || want === 'cmd+k' || want === 'ctrl+k') && (event.key === 'k' || event.key === 'K');
|
|
323
|
+
if (isCmd && isMod) {
|
|
324
|
+
event.preventDefault();
|
|
325
|
+
if (onSearchPage) {
|
|
326
|
+
try { window.dispatchEvent(new CustomEvent('canopy:search:setQuery', { detail: { hotkey: true } })); } catch (_) {}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
panel.style.display = 'block';
|
|
330
|
+
if (input && input.focus) input.focus();
|
|
331
|
+
filterAndShow(input && input.value || '');
|
|
332
|
+
}
|
|
333
|
+
} catch (_) {}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function openPanel() {
|
|
338
|
+
if (onSearchPage) {
|
|
339
|
+
try { window.dispatchEvent(new CustomEvent('canopy:search:setQuery', { detail: {} })); } catch (_) {}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
panel.style.display = 'block';
|
|
343
|
+
if (input && input.focus) input.focus();
|
|
344
|
+
filterAndShow(input && input.value || '');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
host.addEventListener('click', (event) => {
|
|
348
|
+
const trigger = event.target && event.target.closest && event.target.closest('[data-canopy-command-trigger]');
|
|
349
|
+
if (trigger) {
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
openPanel();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
input.addEventListener('focus', () => { openPanel(); });
|
|
357
|
+
} catch (_) {}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
ready(() => {
|
|
361
|
+
const hosts = Array.from(document.querySelectorAll('[data-canopy-command]'));
|
|
362
|
+
if (!hosts.length) return;
|
|
363
|
+
hosts.forEach((host) => {
|
|
364
|
+
attachCommand(host).catch((err) => {
|
|
365
|
+
try {
|
|
366
|
+
console.warn('[canopy][command] failed to initialise', err && (err.message || err));
|
|
367
|
+
} catch (_) {}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react
|
|
|
2
2
|
import { createRoot } from 'react-dom/client';
|
|
3
3
|
import { SearchResultsUI, SearchTabsUI } from '@canopy-iiif/app/ui';
|
|
4
4
|
|
|
5
|
-
// Lightweight IndexedDB utilities (no deps) with
|
|
5
|
+
// Lightweight IndexedDB utilities (no deps) with defensive guards
|
|
6
6
|
function hasIDB() {
|
|
7
7
|
try { return typeof indexedDB !== 'undefined'; } catch (_) { return false; }
|
|
8
8
|
}
|
|
@@ -70,7 +70,7 @@ async function sha256Hex(str) {
|
|
|
70
70
|
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
71
71
|
}
|
|
72
72
|
} catch (_) {}
|
|
73
|
-
//
|
|
73
|
+
// Defensive: simple non-crypto hash when Web Crypto is unavailable
|
|
74
74
|
try {
|
|
75
75
|
let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i);
|
|
76
76
|
return (h >>> 0).toString(16);
|
|
@@ -260,6 +260,49 @@ function parseProps(el) {
|
|
|
260
260
|
return {};
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
function bindSearchInputToStore() {
|
|
264
|
+
if (!store || typeof document === 'undefined') return;
|
|
265
|
+
try {
|
|
266
|
+
const input = document.querySelector('[data-canopy-command-input]');
|
|
267
|
+
if (!input || input.dataset.canopySearchSync === '1') return;
|
|
268
|
+
input.dataset.canopySearchSync = '1';
|
|
269
|
+
|
|
270
|
+
const syncFromStore = () => {
|
|
271
|
+
try {
|
|
272
|
+
const snap = store.getSnapshot();
|
|
273
|
+
const nextVal = (snap && typeof snap.query === 'string') ? snap.query : '';
|
|
274
|
+
if (input.value !== nextVal) input.value = nextVal;
|
|
275
|
+
} catch (_) {}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const onInput = (event) => {
|
|
279
|
+
try {
|
|
280
|
+
const val = event && event.target && typeof event.target.value === 'string' ? event.target.value : '';
|
|
281
|
+
const current = (() => {
|
|
282
|
+
try {
|
|
283
|
+
const snap = store.getSnapshot();
|
|
284
|
+
return snap && typeof snap.query === 'string' ? snap.query : '';
|
|
285
|
+
} catch (_) {
|
|
286
|
+
return '';
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
if (val === current) return;
|
|
290
|
+
store.setQuery(val);
|
|
291
|
+
} catch (_) {}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
input.addEventListener('input', onInput);
|
|
295
|
+
const unsubscribe = store.subscribe(syncFromStore);
|
|
296
|
+
syncFromStore();
|
|
297
|
+
|
|
298
|
+
const cleanup = () => {
|
|
299
|
+
try { input.removeEventListener('input', onInput); } catch (_) {}
|
|
300
|
+
try { if (typeof unsubscribe === 'function') unsubscribe(); } catch (_) {}
|
|
301
|
+
};
|
|
302
|
+
window.addEventListener('beforeunload', cleanup, { once: true });
|
|
303
|
+
} catch (_) {}
|
|
304
|
+
}
|
|
305
|
+
|
|
263
306
|
function mountAt(selector, Comp) {
|
|
264
307
|
const nodes = document.querySelectorAll(selector);
|
|
265
308
|
nodes.forEach((n) => {
|
|
@@ -280,6 +323,7 @@ if (typeof document !== 'undefined') {
|
|
|
280
323
|
mountAt('[data-canopy-search-tabs]', TabsMount);
|
|
281
324
|
mountAt('[data-canopy-search-results]', ResultsMount);
|
|
282
325
|
mountAt('[data-canopy-search-summary]', SummaryMount);
|
|
326
|
+
bindSearchInputToStore();
|
|
283
327
|
// Total mount removed
|
|
284
328
|
try {
|
|
285
329
|
window.addEventListener('canopy:search:setQuery', (ev) => {
|
package/lib/search/search.js
CHANGED
|
@@ -1,202 +1,14 @@
|
|
|
1
1
|
const React = require('react');
|
|
2
2
|
const ReactDOMServer = require('react-dom/server');
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
-
const { path, withBase } = require('../common');
|
|
5
|
-
const { ensureDirSync, OUT_DIR, htmlShell, fsp } = require('../common');
|
|
6
|
-
|
|
7
|
-
const FALLBACK_SEARCH_APP = `import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react';
|
|
8
|
-
import { createRoot } from 'react-dom/client';
|
|
9
|
-
import { SearchResultsUI } from '@canopy-iiif/app/ui';
|
|
10
|
-
|
|
11
|
-
function hasIDB(){ try { return typeof indexedDB !== 'undefined'; } catch (_) { return false; } }
|
|
12
|
-
function idbOpen(){ return new Promise((resolve)=>{ if(!hasIDB()) return resolve(null); try{ const req = indexedDB.open('canopy-search',1); req.onupgradeneeded=()=>{ const db=req.result; if(!db.objectStoreNames.contains('indexes')) db.createObjectStore('indexes',{keyPath:'version'}); }; req.onsuccess=()=>resolve(req.result); req.onerror=()=>resolve(null);}catch(_){ resolve(null);} }); }
|
|
13
|
-
async function idbGet(store,key){ const db = await idbOpen(); if(!db) return null; return new Promise((resolve)=>{ try{ const tx=db.transaction(store,'readonly'); const st=tx.objectStore(store); const req=st.get(key); req.onsuccess=()=>resolve(req.result||null); req.onerror=()=>resolve(null);}catch(_){ resolve(null);} }); }
|
|
14
|
-
async function idbPut(store,value){ const db = await idbOpen(); if(!db) return false; return new Promise((resolve)=>{ try{ const tx=db.transaction(store,'readwrite'); const st=tx.objectStore(store); st.put(value); tx.oncomplete=()=>resolve(true); tx.onerror=()=>resolve(false);}catch(_){ resolve(false);} }); }
|
|
15
|
-
async function idbPruneOld(store,keep){ const db=await idbOpen(); if(!db) return false; return new Promise((resolve)=>{ try{ const tx=db.transaction(store,'readwrite'); const st=tx.objectStore(store); const req=st.getAllKeys(); req.onsuccess=()=>{ try{ (req.result||[]).forEach((k)=>{ if(k!==keep) st.delete(k); }); }catch(_){} resolve(true); }; req.onerror=()=>resolve(false);}catch(_){ resolve(false);} }); }
|
|
16
|
-
async function sha256Hex(str){ try{ if(typeof crypto!=='undefined' && crypto.subtle){ const data=new TextEncoder().encode(str); const d=await crypto.subtle.digest('SHA-256',data); return Array.from(new Uint8Array(d)).map((b)=>b.toString(16).padStart(2,'0')).join(''); } }catch(_){} try{ let h=5381; for(let i=0;i<str.length;i++) h=((h<<5)+h)^str.charCodeAt(i); return (h>>>0).toString(16); }catch(_){ return String(str&&str.length?str.length:0); }}
|
|
17
|
-
|
|
18
|
-
function createSearchStore() {
|
|
19
|
-
let state = {
|
|
20
|
-
query: new URLSearchParams(location.search).get('q') || '',
|
|
21
|
-
type: new URLSearchParams(location.search).get('type') || 'all',
|
|
22
|
-
loading: true,
|
|
23
|
-
records: [],
|
|
24
|
-
types: [],
|
|
25
|
-
index: null,
|
|
26
|
-
counts: {},
|
|
27
|
-
};
|
|
28
|
-
const listeners = new Set();
|
|
29
|
-
function notify() { listeners.forEach((fn) => { try { fn(); } catch (_) {} }); }
|
|
30
|
-
// Keep a memoized snapshot so getSnapshot returns stable references
|
|
31
|
-
let snapshot = null;
|
|
32
|
-
function recomputeSnapshot() {
|
|
33
|
-
const { index, records, query, type } = state;
|
|
34
|
-
let base = [];
|
|
35
|
-
let results = [];
|
|
36
|
-
let totalForType = Array.isArray(records) ? records.length : 0;
|
|
37
|
-
let counts = {};
|
|
38
|
-
if (records && records.length) {
|
|
39
|
-
if (!query) {
|
|
40
|
-
base = records;
|
|
41
|
-
} else {
|
|
42
|
-
try { const ids = index && index.search(query, { limit: 200 }) || []; base = ids.map((i) => records[i]).filter(Boolean); } catch (_) { base = []; }
|
|
43
|
-
}
|
|
44
|
-
try {
|
|
45
|
-
counts = base.reduce((acc, r) => { const t = String((r && r.type) || 'page').toLowerCase(); acc[t] = (acc[t] || 0) + 1; return acc; }, {});
|
|
46
|
-
} catch (_) { counts = {}; }
|
|
47
|
-
results = type === 'all' ? base : base.filter((r) => String(r.type).toLowerCase() === String(type).toLowerCase());
|
|
48
|
-
if (type !== 'all') {
|
|
49
|
-
try { totalForType = records.filter((r) => String(r.type).toLowerCase() === String(type).toLowerCase()).length; } catch (_) {}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
snapshot = { ...state, results, total: totalForType, shown: results.length, counts };
|
|
53
|
-
}
|
|
54
|
-
function set(partial) { state = { ...state, ...partial }; recomputeSnapshot(); notify(); }
|
|
55
|
-
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
56
|
-
function getSnapshot() { return snapshot; }
|
|
57
|
-
// Initialize snapshot
|
|
58
|
-
recomputeSnapshot();
|
|
59
|
-
// init
|
|
60
|
-
(async () => {
|
|
61
|
-
try {
|
|
62
|
-
const DEBUG = (() => { try { const p = new URLSearchParams(location.search); return p.has('searchDebug') || localStorage.CANOPY_SEARCH_DEBUG === '1'; } catch (_) { return false; } })();
|
|
63
|
-
const Flex = (window && window.FlexSearch) || (await import('flexsearch')).default;
|
|
64
|
-
// Broadcast new index installs to other tabs
|
|
65
|
-
let bc = null; try { if (typeof BroadcastChannel !== 'undefined') bc = new BroadcastChannel('canopy-search'); } catch (_) {}
|
|
66
|
-
// Try to get a meta version and search config from ./api/index.json for cache-busting and tabs
|
|
67
|
-
let version = '';
|
|
68
|
-
let tabsOrder = [];
|
|
69
|
-
try {
|
|
70
|
-
const meta = await fetch('./api/index.json').then((r)=>r&&r.ok?r.json():null).catch(()=>null);
|
|
71
|
-
if (meta && typeof meta.version === 'string') version = meta.version;
|
|
72
|
-
const ord = meta && meta.search && meta.search.tabs && Array.isArray(meta.search.tabs.order) ? meta.search.tabs.order : [];
|
|
73
|
-
tabsOrder = ord.map((s)=>String(s)).filter(Boolean);
|
|
74
|
-
} catch (_) {}
|
|
75
|
-
const res = await fetch('./api/search-index.json' + (version ? ('?v=' + encodeURIComponent(version)) : ''));
|
|
76
|
-
const text = await res.text();
|
|
77
|
-
const parsed = (() => { try { return JSON.parse(text); } catch { return []; } })();
|
|
78
|
-
const data = Array.isArray(parsed) ? parsed : (parsed && parsed.records ? parsed.records : []);
|
|
79
|
-
if (!version) version = (parsed && parsed.version) || (await sha256Hex(text));
|
|
80
|
-
|
|
81
|
-
const idx = new Flex.Index({ tokenize: 'forward' });
|
|
82
|
-
let hydrated = false;
|
|
83
|
-
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
84
|
-
try {
|
|
85
|
-
const cached = await idbGet('indexes', version);
|
|
86
|
-
if (cached && cached.exportData) {
|
|
87
|
-
try {
|
|
88
|
-
const dataObj = cached.exportData || {};
|
|
89
|
-
for (const k in dataObj) {
|
|
90
|
-
if (Object.prototype.hasOwnProperty.call(dataObj, k)) {
|
|
91
|
-
try { idx.import(k, dataObj[k]); } catch (_) {}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
hydrated = true;
|
|
95
|
-
} catch (_) {}
|
|
96
|
-
}
|
|
97
|
-
} catch (_) {}
|
|
98
|
-
if (!hydrated) {
|
|
99
|
-
data.forEach((rec, i) => { try { idx.add(i, rec && rec.title ? String(rec.title) : ''); } catch (_) {} });
|
|
100
|
-
try {
|
|
101
|
-
const dump = {};
|
|
102
|
-
try { await idx.export((key, val) => { dump[key] = val; }); } catch (_) {}
|
|
103
|
-
await idbPut('indexes', { version, exportData: dump, ts: Date.now() });
|
|
104
|
-
await idbPruneOld('indexes', version);
|
|
105
|
-
try { if (bc) bc.postMessage({ type: 'search-index-installed', version }); } catch (_) {}
|
|
106
|
-
} catch (_) {}
|
|
107
|
-
if (DEBUG) {
|
|
108
|
-
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
109
|
-
// eslint-disable-next-line no-console
|
|
110
|
-
console.info('[Search] Index built in ' + Math.round(t1 - t0) + 'ms (records=' + data.length + ') v=' + String(version).slice(0,8));
|
|
111
|
-
}
|
|
112
|
-
} else if (DEBUG) {
|
|
113
|
-
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
114
|
-
// eslint-disable-next-line no-console
|
|
115
|
-
console.info('[Search] Index imported from IndexedDB in ' + Math.round(t1 - t0) + 'ms v=' + String(version).slice(0,8));
|
|
116
|
-
}
|
|
117
|
-
// Optional: debug-listen for install events from other tabs
|
|
118
|
-
try { if (bc && DEBUG) { bc.onmessage = (ev) => { try { const msg = ev && ev.data; if (msg && msg.type === 'search-index-installed' && msg.version && msg.version !== version) console.info('[Search] Another tab installed version ' + String(msg.version).slice(0,8)); } catch (_) {} }; } } catch (_) {}
|
|
119
|
-
|
|
120
|
-
const ts = Array.from(new Set(data.map((r) => String((r && r.type) || 'page'))));
|
|
121
|
-
const order = Array.isArray(tabsOrder) && tabsOrder.length ? tabsOrder : ['work', 'docs', 'page'];
|
|
122
|
-
// Sort types using configured order; unknown types appended alphabetically
|
|
123
|
-
ts.sort((a, b) => { const ia = order.indexOf(a); const ib = order.indexOf(b); return (ia<0?99:ia)-(ib<0?99:ib) || a.localeCompare(b); });
|
|
124
|
-
// Determine default type from config when no explicit type is present in URL
|
|
125
|
-
let defaultType = (Array.isArray(order) && order.length ? order[0] : 'all');
|
|
126
|
-
try {
|
|
127
|
-
const p = new URLSearchParams(location.search);
|
|
128
|
-
const hasTypeParam = p.has('type');
|
|
129
|
-
if (!hasTypeParam) {
|
|
130
|
-
// If default type is not present in available types, fall back to first available
|
|
131
|
-
if (!ts.includes(defaultType)) defaultType = ts[0] || 'all';
|
|
132
|
-
if (defaultType && defaultType !== 'all') {
|
|
133
|
-
p.set('type', defaultType);
|
|
134
|
-
// Avoid nested template literals inside fallback string; use concatenation
|
|
135
|
-
history.replaceState(null, '', (location.pathname + '?' + p.toString()));
|
|
136
|
-
}
|
|
137
|
-
set({ type: defaultType });
|
|
138
|
-
}
|
|
139
|
-
} catch (_) {}
|
|
140
|
-
set({ index: idx, records: data, types: ts, loading: false });
|
|
141
|
-
} catch (_) { set({ loading: false }); }
|
|
142
|
-
})();
|
|
143
|
-
// API
|
|
144
|
-
function setQuery(q) { set({ query: q }); const u = new URL(location.href); u.searchParams.set('q', q); history.replaceState(null, '', u); }
|
|
145
|
-
function setType(t) { set({ type: t }); const u = new URL(location.href); u.searchParams.set('type', t); history.replaceState(null, '', u); }
|
|
146
|
-
return { subscribe, getSnapshot, setQuery, setType };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const store = typeof window !== 'undefined' ? createSearchStore() : null;
|
|
150
|
-
|
|
151
|
-
function useStore() {
|
|
152
|
-
const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
153
|
-
return { ...snap, setQuery: store.setQuery, setType: store.setType };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function ResultsMount() {
|
|
157
|
-
const { results, type, loading } = useStore();
|
|
158
|
-
if (loading) return <div className=\"text-slate-600\">Loading…</div>;
|
|
159
|
-
return <SearchResultsUI results={results} type={type} />;
|
|
160
|
-
}
|
|
161
|
-
function SummaryMount() {
|
|
162
|
-
const { query, type, shown, total } = useStore();
|
|
163
|
-
const text = useMemo(() => {
|
|
164
|
-
if (!query) return \`Showing \${shown} of \${total} items\`;
|
|
165
|
-
return \`Found \${shown} of \${total} in \${type === 'all' ? 'all types' : type} for \"\${query}\"\`;
|
|
166
|
-
}, [query, type, shown, total]);
|
|
167
|
-
return <div className=\"text-sm text-slate-600\">{text}</div>;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
function mountAt(selector, Comp) {
|
|
172
|
-
const nodes = document.querySelectorAll(selector);
|
|
173
|
-
nodes.forEach((n) => {
|
|
174
|
-
try {
|
|
175
|
-
const root = createRoot(n);
|
|
176
|
-
root.render(<Comp />);
|
|
177
|
-
} catch (e) {
|
|
178
|
-
try { console.error('[Search] mount error at', selector, e && e.message ? e.message : e); } catch (_) {}
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (typeof document !== 'undefined') {
|
|
184
|
-
const run = () => {
|
|
185
|
-
mountAt('[data-canopy-search-results]', ResultsMount);
|
|
186
|
-
mountAt('[data-canopy-search-summary]', SummaryMount);
|
|
187
|
-
|
|
188
|
-
};
|
|
189
|
-
if (document.readyState !== 'loading') run();
|
|
190
|
-
else document.addEventListener('DOMContentLoaded', run, { once: true });
|
|
191
|
-
}
|
|
192
|
-
`;
|
|
4
|
+
const { path, withBase, rootRelativeHref, ensureDirSync, OUT_DIR, htmlShell, fsp } = require('../common');
|
|
193
5
|
|
|
194
6
|
async function ensureSearchRuntime() {
|
|
195
7
|
const { fs, path } = require('../common');
|
|
196
8
|
ensureDirSync(OUT_DIR);
|
|
197
9
|
let esbuild = null;
|
|
198
10
|
try { esbuild = require('../../ui/node_modules/esbuild'); } catch (_) { try { esbuild = require('esbuild'); } catch (_) {} }
|
|
199
|
-
if (!esbuild)
|
|
11
|
+
if (!esbuild) throw new Error('Search runtime bundling requires esbuild. Install dependencies before building.');
|
|
200
12
|
const entry = path.join(__dirname, 'search-app.jsx');
|
|
201
13
|
const scriptsDir = path.join(OUT_DIR, 'scripts');
|
|
202
14
|
ensureDirSync(scriptsDir);
|
|
@@ -364,10 +176,13 @@ async function buildSearchPage() {
|
|
|
364
176
|
}
|
|
365
177
|
}
|
|
366
178
|
|
|
367
|
-
function toSafeString(val,
|
|
179
|
+
function toSafeString(val, defaultValue = '') {
|
|
180
|
+
try { return String(val == null ? defaultValue : val); } catch (_) { return defaultValue; }
|
|
181
|
+
}
|
|
368
182
|
function sanitizeRecord(r) {
|
|
369
183
|
const title = toSafeString(r && r.title, '');
|
|
370
|
-
const
|
|
184
|
+
const hrefRaw = toSafeString(r && r.href, '');
|
|
185
|
+
const href = rootRelativeHref(hrefRaw);
|
|
371
186
|
const type = toSafeString(r && r.type, 'page');
|
|
372
187
|
const thumbnail = toSafeString(r && r.thumbnail, '');
|
|
373
188
|
const safeTitle = title.length > 300 ? title.slice(0, 300) + '…' : title;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canopy-iiif/app",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mat Jordan <mat@northwestern.edu>",
|
|
@@ -38,7 +38,13 @@
|
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^18.2.0 || ^19.0.0",
|
|
41
|
-
"react-dom": "^18.2.0 || ^19.0.0"
|
|
41
|
+
"react-dom": "^18.2.0 || ^19.0.0",
|
|
42
|
+
"tailwindcss": "^4.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"tailwindcss": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
42
48
|
},
|
|
43
49
|
"devDependencies": {
|
|
44
50
|
"esbuild": "^0.21.4"
|