@canopy-iiif/app 0.7.14 → 0.7.17

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/head.js ADDED
@@ -0,0 +1,21 @@
1
+ const React = require('react');
2
+ const { withBase, rootRelativeHref } = require('./common');
3
+
4
+ const DEFAULT_STYLESHEET_PATH = '/styles/styles.css';
5
+
6
+ function stylesheetHref(href = DEFAULT_STYLESHEET_PATH) {
7
+ const normalized = rootRelativeHref(href || DEFAULT_STYLESHEET_PATH);
8
+ return withBase(normalized);
9
+ }
10
+
11
+ function Stylesheet(props = {}) {
12
+ const { href = DEFAULT_STYLESHEET_PATH, rel = 'stylesheet', ...rest } = props;
13
+ const resolved = stylesheetHref(href);
14
+ return React.createElement('link', { rel, href: resolved, ...rest });
15
+ }
16
+
17
+ module.exports = {
18
+ stylesheetHref,
19
+ Stylesheet,
20
+ DEFAULT_STYLESHEET_PATH,
21
+ };
package/lib/index.js CHANGED
@@ -1,4 +1,8 @@
1
+ const { stylesheetHref, Stylesheet } = require('./head');
2
+
1
3
  module.exports = {
2
4
  build: require('./build/build').build,
3
- dev: require('./build/dev').dev
5
+ dev: require('./build/dev').dev,
6
+ stylesheetHref,
7
+ Stylesheet,
4
8
  };
@@ -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);
@@ -3,199 +3,12 @@ const ReactDOMServer = require('react-dom/server');
3
3
  const crypto = require('crypto');
4
4
  const { path, withBase, rootRelativeHref, ensureDirSync, OUT_DIR, htmlShell, fsp } = require('../common');
5
5
 
6
- const FALLBACK_SEARCH_APP = `import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react';
7
- import { createRoot } from 'react-dom/client';
8
- import { SearchResultsUI } from '@canopy-iiif/app/ui';
9
-
10
- function hasIDB(){ try { return typeof indexedDB !== 'undefined'; } catch (_) { return false; } }
11
- 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);} }); }
12
- 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);} }); }
13
- 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);} }); }
14
- 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);} }); }
15
- 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); }}
16
-
17
- function createSearchStore() {
18
- let state = {
19
- query: new URLSearchParams(location.search).get('q') || '',
20
- type: new URLSearchParams(location.search).get('type') || 'all',
21
- loading: true,
22
- records: [],
23
- types: [],
24
- index: null,
25
- counts: {},
26
- };
27
- const listeners = new Set();
28
- function notify() { listeners.forEach((fn) => { try { fn(); } catch (_) {} }); }
29
- // Keep a memoized snapshot so getSnapshot returns stable references
30
- let snapshot = null;
31
- function recomputeSnapshot() {
32
- const { index, records, query, type } = state;
33
- let base = [];
34
- let results = [];
35
- let totalForType = Array.isArray(records) ? records.length : 0;
36
- let counts = {};
37
- if (records && records.length) {
38
- if (!query) {
39
- base = records;
40
- } else {
41
- try { const ids = index && index.search(query, { limit: 200 }) || []; base = ids.map((i) => records[i]).filter(Boolean); } catch (_) { base = []; }
42
- }
43
- try {
44
- counts = base.reduce((acc, r) => { const t = String((r && r.type) || 'page').toLowerCase(); acc[t] = (acc[t] || 0) + 1; return acc; }, {});
45
- } catch (_) { counts = {}; }
46
- results = type === 'all' ? base : base.filter((r) => String(r.type).toLowerCase() === String(type).toLowerCase());
47
- if (type !== 'all') {
48
- try { totalForType = records.filter((r) => String(r.type).toLowerCase() === String(type).toLowerCase()).length; } catch (_) {}
49
- }
50
- }
51
- snapshot = { ...state, results, total: totalForType, shown: results.length, counts };
52
- }
53
- function set(partial) { state = { ...state, ...partial }; recomputeSnapshot(); notify(); }
54
- function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
55
- function getSnapshot() { return snapshot; }
56
- // Initialize snapshot
57
- recomputeSnapshot();
58
- // init
59
- (async () => {
60
- try {
61
- const DEBUG = (() => { try { const p = new URLSearchParams(location.search); return p.has('searchDebug') || localStorage.CANOPY_SEARCH_DEBUG === '1'; } catch (_) { return false; } })();
62
- const Flex = (window && window.FlexSearch) || (await import('flexsearch')).default;
63
- // Broadcast new index installs to other tabs
64
- let bc = null; try { if (typeof BroadcastChannel !== 'undefined') bc = new BroadcastChannel('canopy-search'); } catch (_) {}
65
- // Try to get a meta version and search config from ./api/index.json for cache-busting and tabs
66
- let version = '';
67
- let tabsOrder = [];
68
- try {
69
- const meta = await fetch('./api/index.json').then((r)=>r&&r.ok?r.json():null).catch(()=>null);
70
- if (meta && typeof meta.version === 'string') version = meta.version;
71
- const ord = meta && meta.search && meta.search.tabs && Array.isArray(meta.search.tabs.order) ? meta.search.tabs.order : [];
72
- tabsOrder = ord.map((s)=>String(s)).filter(Boolean);
73
- } catch (_) {}
74
- const res = await fetch('./api/search-index.json' + (version ? ('?v=' + encodeURIComponent(version)) : ''));
75
- const text = await res.text();
76
- const parsed = (() => { try { return JSON.parse(text); } catch { return []; } })();
77
- const data = Array.isArray(parsed) ? parsed : (parsed && parsed.records ? parsed.records : []);
78
- if (!version) version = (parsed && parsed.version) || (await sha256Hex(text));
79
-
80
- const idx = new Flex.Index({ tokenize: 'forward' });
81
- let hydrated = false;
82
- const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
83
- try {
84
- const cached = await idbGet('indexes', version);
85
- if (cached && cached.exportData) {
86
- try {
87
- const dataObj = cached.exportData || {};
88
- for (const k in dataObj) {
89
- if (Object.prototype.hasOwnProperty.call(dataObj, k)) {
90
- try { idx.import(k, dataObj[k]); } catch (_) {}
91
- }
92
- }
93
- hydrated = true;
94
- } catch (_) {}
95
- }
96
- } catch (_) {}
97
- if (!hydrated) {
98
- data.forEach((rec, i) => { try { idx.add(i, rec && rec.title ? String(rec.title) : ''); } catch (_) {} });
99
- try {
100
- const dump = {};
101
- try { await idx.export((key, val) => { dump[key] = val; }); } catch (_) {}
102
- await idbPut('indexes', { version, exportData: dump, ts: Date.now() });
103
- await idbPruneOld('indexes', version);
104
- try { if (bc) bc.postMessage({ type: 'search-index-installed', version }); } catch (_) {}
105
- } catch (_) {}
106
- if (DEBUG) {
107
- const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
108
- // eslint-disable-next-line no-console
109
- console.info('[Search] Index built in ' + Math.round(t1 - t0) + 'ms (records=' + data.length + ') v=' + String(version).slice(0,8));
110
- }
111
- } else if (DEBUG) {
112
- const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
113
- // eslint-disable-next-line no-console
114
- console.info('[Search] Index imported from IndexedDB in ' + Math.round(t1 - t0) + 'ms v=' + String(version).slice(0,8));
115
- }
116
- // Optional: debug-listen for install events from other tabs
117
- 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 (_) {}
118
-
119
- const ts = Array.from(new Set(data.map((r) => String((r && r.type) || 'page'))));
120
- const order = Array.isArray(tabsOrder) && tabsOrder.length ? tabsOrder : ['work', 'docs', 'page'];
121
- // Sort types using configured order; unknown types appended alphabetically
122
- 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); });
123
- // Determine default type from config when no explicit type is present in URL
124
- let defaultType = (Array.isArray(order) && order.length ? order[0] : 'all');
125
- try {
126
- const p = new URLSearchParams(location.search);
127
- const hasTypeParam = p.has('type');
128
- if (!hasTypeParam) {
129
- // If default type is not present in available types, fall back to first available
130
- if (!ts.includes(defaultType)) defaultType = ts[0] || 'all';
131
- if (defaultType && defaultType !== 'all') {
132
- p.set('type', defaultType);
133
- // Avoid nested template literals inside fallback string; use concatenation
134
- history.replaceState(null, '', (location.pathname + '?' + p.toString()));
135
- }
136
- set({ type: defaultType });
137
- }
138
- } catch (_) {}
139
- set({ index: idx, records: data, types: ts, loading: false });
140
- } catch (_) { set({ loading: false }); }
141
- })();
142
- // API
143
- function setQuery(q) { set({ query: q }); const u = new URL(location.href); u.searchParams.set('q', q); history.replaceState(null, '', u); }
144
- function setType(t) { set({ type: t }); const u = new URL(location.href); u.searchParams.set('type', t); history.replaceState(null, '', u); }
145
- return { subscribe, getSnapshot, setQuery, setType };
146
- }
147
-
148
- const store = typeof window !== 'undefined' ? createSearchStore() : null;
149
-
150
- function useStore() {
151
- const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
152
- return { ...snap, setQuery: store.setQuery, setType: store.setType };
153
- }
154
-
155
- function ResultsMount() {
156
- const { results, type, loading } = useStore();
157
- if (loading) return <div className=\"text-slate-600\">Loading…</div>;
158
- return <SearchResultsUI results={results} type={type} />;
159
- }
160
- function SummaryMount() {
161
- const { query, type, shown, total } = useStore();
162
- const text = useMemo(() => {
163
- if (!query) return \`Showing \${shown} of \${total} items\`;
164
- return \`Found \${shown} of \${total} in \${type === 'all' ? 'all types' : type} for \"\${query}\"\`;
165
- }, [query, type, shown, total]);
166
- return <div className=\"text-sm text-slate-600\">{text}</div>;
167
- }
168
-
169
-
170
- function mountAt(selector, Comp) {
171
- const nodes = document.querySelectorAll(selector);
172
- nodes.forEach((n) => {
173
- try {
174
- const root = createRoot(n);
175
- root.render(<Comp />);
176
- } catch (e) {
177
- try { console.error('[Search] mount error at', selector, e && e.message ? e.message : e); } catch (_) {}
178
- }
179
- });
180
- }
181
-
182
- if (typeof document !== 'undefined') {
183
- const run = () => {
184
- mountAt('[data-canopy-search-results]', ResultsMount);
185
- mountAt('[data-canopy-search-summary]', SummaryMount);
186
-
187
- };
188
- if (document.readyState !== 'loading') run();
189
- else document.addEventListener('DOMContentLoaded', run, { once: true });
190
- }
191
- `;
192
-
193
6
  async function ensureSearchRuntime() {
194
7
  const { fs, path } = require('../common');
195
8
  ensureDirSync(OUT_DIR);
196
9
  let esbuild = null;
197
10
  try { esbuild = require('../../ui/node_modules/esbuild'); } catch (_) { try { esbuild = require('esbuild'); } catch (_) {} }
198
- 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.');
199
12
  const entry = path.join(__dirname, 'search-app.jsx');
200
13
  const scriptsDir = path.join(OUT_DIR, 'scripts');
201
14
  ensureDirSync(scriptsDir);
@@ -337,7 +150,6 @@ async function buildSearchPage() {
337
150
  head = rendered && rendered.head ? rendered.head : '';
338
151
  if (!body) throw new Error('Search: content/search/_layout.mdx produced empty output');
339
152
  const importMap = '';
340
- const cssRel = path.relative(path.dirname(outPath), path.join(OUT_DIR, 'styles', 'styles.css')).split(path.sep).join('/');
341
153
  const jsAbs = path.join(OUT_DIR, 'scripts', 'search.js');
342
154
  let jsRel = path.relative(path.dirname(outPath), jsAbs).split(path.sep).join('/');
343
155
  let v = '';
@@ -353,7 +165,14 @@ async function buildSearchPage() {
353
165
  return rel;
354
166
  }
355
167
  const vendorTags = `<script src="${verRel(vendorReactAbs)}"></script><script src="${verRel(vendorFlexAbs)}"></script><script src="${verRel(vendorCommandAbs)}"></script>`;
356
- let html = htmlShell({ title: 'Search', body, cssHref: cssRel || 'styles.css', scriptHref: jsRel, headExtra: vendorTags + head + importMap });
168
+ let headExtra = vendorTags + head + importMap;
169
+ try {
170
+ const { BASE_PATH } = require('../common');
171
+ if (BASE_PATH) {
172
+ headExtra = `<script>window.CANOPY_BASE_PATH=${JSON.stringify(BASE_PATH)}</script>` + headExtra;
173
+ }
174
+ } catch (_) {}
175
+ let html = htmlShell({ title: 'Search', body, cssHref: null, scriptHref: jsRel, headExtra });
357
176
  try { html = require('./common').applyBaseToHtml(html); } catch (_) {}
358
177
  await fsp.writeFile(outPath, html, 'utf8');
359
178
  console.log('Search: Built', path.relative(process.cwd(), outPath));
@@ -363,7 +182,9 @@ async function buildSearchPage() {
363
182
  }
364
183
  }
365
184
 
366
- function toSafeString(val, fallback = '') { try { return String(val == null ? fallback : val); } catch (_) { return fallback; } }
185
+ function toSafeString(val, defaultValue = '') {
186
+ try { return String(val == null ? defaultValue : val); } catch (_) { return defaultValue; }
187
+ }
367
188
  function sanitizeRecord(r) {
368
189
  const title = toSafeString(r && r.title, '');
369
190
  const hrefRaw = toSafeString(r && r.href, '');