@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/AGENTS.md +66 -0
- package/lib/build/dev.js +252 -355
- package/lib/build/iiif.js +19 -50
- package/lib/build/mdx.js +42 -76
- package/lib/build/pages.js +1 -5
- package/lib/build/runtimes.js +40 -103
- package/lib/build/search.js +2 -5
- package/lib/build/styles.js +9 -15
- package/lib/common.js +16 -8
- package/lib/head.js +21 -0
- package/lib/index.js +5 -1
- package/lib/search/command-runtime.js +370 -0
- package/lib/search/search-app.jsx +2 -2
- package/lib/search/search.js +12 -191
- package/package.json +10 -3
- package/ui/dist/index.mjs +108 -83
- package/ui/dist/index.mjs.map +4 -4
- package/ui/dist/server.mjs +108 -70
- package/ui/dist/server.mjs.map +4 -4
- package/ui/styles/base/_common.scss +8 -0
- package/ui/styles/base/index.scss +1 -0
- package/ui/styles/components/_command.scss +84 -1
- package/ui/styles/components/_header.scss +0 -0
- package/ui/styles/components/_hero.scss +22 -0
- package/ui/styles/components/index.scss +2 -3
- package/ui/styles/index.css +106 -1
- package/ui/styles/index.scss +3 -2
- package/ui/tailwind-canopy-iiif-plugin.js +7 -12
- package/ui/tailwind-canopy-iiif-preset.js +15 -0
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
|
@@ -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);
|
package/lib/search/search.js
CHANGED
|
@@ -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)
|
|
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
|
|
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,
|
|
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, '');
|