@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.
@@ -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 graceful fallback
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
- // Fallback: simple non-crypto hash
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) => {
@@ -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) { console.warn('Search: skipped bundling (no esbuild)'); return; }
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, fallback = '') { try { return String(val == null ? fallback : val); } catch (_) { return fallback; } }
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 href = toSafeString(r && r.href, '');
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.13",
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"