@canopy-iiif/app 0.6.28
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/build.js +762 -0
- package/lib/common.js +124 -0
- package/lib/components/IIIFCard.js +102 -0
- package/lib/dev.js +721 -0
- package/lib/devtoast.config.json +6 -0
- package/lib/devtoast.css +14 -0
- package/lib/iiif.js +1145 -0
- package/lib/index.js +5 -0
- package/lib/log.js +64 -0
- package/lib/mdx.js +690 -0
- package/lib/runtime/command-entry.jsx +44 -0
- package/lib/search-app.jsx +273 -0
- package/lib/search.js +477 -0
- package/lib/thumbnail.js +87 -0
- package/package.json +50 -0
- package/ui/dist/index.mjs +692 -0
- package/ui/dist/index.mjs.map +7 -0
- package/ui/dist/server.mjs +344 -0
- package/ui/dist/server.mjs.map +7 -0
- package/ui/styles/components/_card.scss +69 -0
- package/ui/styles/components/_command.scss +80 -0
- package/ui/styles/components/index.scss +5 -0
- package/ui/styles/index.css +127 -0
- package/ui/styles/index.scss +3 -0
- package/ui/styles/variables.emit.scss +72 -0
- package/ui/styles/variables.scss +66 -0
- package/ui/tailwind-canopy-iiif-plugin.js +35 -0
- package/ui/tailwind-canopy-iiif-preset.js +105 -0
package/lib/search.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
const React = require('react');
|
|
2
|
+
const ReactDOMServer = require('react-dom/server');
|
|
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 { SearchFormUI, 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 FormMount() {
|
|
157
|
+
const { query, setQuery, type, setType, types, counts } = useStore();
|
|
158
|
+
return <SearchFormUI query={query} onQueryChange={setQuery} type={type} onTypeChange={setType} types={types} counts={counts} />;
|
|
159
|
+
}
|
|
160
|
+
function ResultsMount() {
|
|
161
|
+
const { results, type, loading } = useStore();
|
|
162
|
+
if (loading) return <div className=\"text-slate-600\">Loading…</div>;
|
|
163
|
+
return <SearchResultsUI results={results} type={type} />;
|
|
164
|
+
}
|
|
165
|
+
function SummaryMount() {
|
|
166
|
+
const { query, type, shown, total } = useStore();
|
|
167
|
+
const text = useMemo(() => {
|
|
168
|
+
if (!query) return \`Showing \${shown} of \${total} items\`;
|
|
169
|
+
return \`Found \${shown} of \${total} in \${type === 'all' ? 'all types' : type} for \"\${query}\"\`;
|
|
170
|
+
}, [query, type, shown, total]);
|
|
171
|
+
return <div className=\"text-sm text-slate-600\">{text}</div>;
|
|
172
|
+
}
|
|
173
|
+
function TotalMount() {
|
|
174
|
+
const { shown } = useStore();
|
|
175
|
+
return <span>{shown}</span>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mountAt(selector, Comp) {
|
|
179
|
+
const nodes = document.querySelectorAll(selector);
|
|
180
|
+
nodes.forEach((n) => {
|
|
181
|
+
try {
|
|
182
|
+
const root = createRoot(n);
|
|
183
|
+
root.render(<Comp />);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
try { console.error('[Search] mount error at', selector, e && e.message ? e.message : e); } catch (_) {}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (typeof document !== 'undefined') {
|
|
191
|
+
const run = () => {
|
|
192
|
+
mountAt('[data-canopy-search-form]', FormMount);
|
|
193
|
+
mountAt('[data-canopy-search-results]', ResultsMount);
|
|
194
|
+
mountAt('[data-canopy-search-summary]', SummaryMount);
|
|
195
|
+
mountAt('[data-canopy-search-total]', TotalMount);
|
|
196
|
+
};
|
|
197
|
+
if (document.readyState !== 'loading') run();
|
|
198
|
+
else document.addEventListener('DOMContentLoaded', run, { once: true });
|
|
199
|
+
}
|
|
200
|
+
`;
|
|
201
|
+
|
|
202
|
+
async function ensureSearchRuntime() {
|
|
203
|
+
const { fs, path } = require('./common');
|
|
204
|
+
ensureDirSync(OUT_DIR);
|
|
205
|
+
let esbuild = null;
|
|
206
|
+
try { esbuild = require('../ui/node_modules/esbuild'); } catch (_) { try { esbuild = require('esbuild'); } catch (_) {} }
|
|
207
|
+
if (!esbuild) { console.warn('Search: skipped bundling (no esbuild)'); return; }
|
|
208
|
+
const entry = path.join(__dirname, 'search-app.jsx');
|
|
209
|
+
const scriptsDir = path.join(OUT_DIR, 'scripts');
|
|
210
|
+
ensureDirSync(scriptsDir);
|
|
211
|
+
const outFile = path.join(scriptsDir, 'search.js');
|
|
212
|
+
// Ensure a global React shim is available to reduce search.js size
|
|
213
|
+
try {
|
|
214
|
+
const scriptsDir = path.join(OUT_DIR, 'scripts');
|
|
215
|
+
ensureDirSync(scriptsDir);
|
|
216
|
+
const vendorFile = path.join(scriptsDir, 'react-globals.js');
|
|
217
|
+
const globalsEntry = `
|
|
218
|
+
import React from 'react';
|
|
219
|
+
import * as ReactDOM from 'react-dom';
|
|
220
|
+
import { createRoot, hydrateRoot } from 'react-dom/client';
|
|
221
|
+
(function(){ try{ window.React = React; window.ReactDOM = ReactDOM; window.ReactDOMClient = { createRoot, hydrateRoot }; }catch(e){} })();
|
|
222
|
+
`;
|
|
223
|
+
await esbuild.build({
|
|
224
|
+
stdin: { contents: globalsEntry, resolveDir: process.cwd(), loader: 'js', sourcefile: 'react-globals-entry.js' },
|
|
225
|
+
outfile: vendorFile,
|
|
226
|
+
platform: 'browser',
|
|
227
|
+
format: 'iife',
|
|
228
|
+
bundle: true,
|
|
229
|
+
sourcemap: false,
|
|
230
|
+
target: ['es2018'],
|
|
231
|
+
logLevel: 'silent',
|
|
232
|
+
minify: true,
|
|
233
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
234
|
+
});
|
|
235
|
+
// Build FlexSearch globals shim
|
|
236
|
+
const flexFile = path.join(scriptsDir, 'flexsearch-globals.js');
|
|
237
|
+
const flexEntry = `import Flex from 'flexsearch';(function(){try{window.FlexSearch=Flex;}catch(e){}})();`;
|
|
238
|
+
await esbuild.build({
|
|
239
|
+
stdin: { contents: flexEntry, resolveDir: process.cwd(), loader: 'js', sourcefile: 'flexsearch-globals-entry.js' },
|
|
240
|
+
outfile: flexFile,
|
|
241
|
+
platform: 'browser',
|
|
242
|
+
format: 'iife',
|
|
243
|
+
bundle: true,
|
|
244
|
+
sourcemap: false,
|
|
245
|
+
target: ['es2018'],
|
|
246
|
+
logLevel: 'silent',
|
|
247
|
+
minify: true,
|
|
248
|
+
external: [],
|
|
249
|
+
});
|
|
250
|
+
} catch (_) {}
|
|
251
|
+
const shimReactPlugin = {
|
|
252
|
+
name: 'shim-react-globals',
|
|
253
|
+
setup(build) {
|
|
254
|
+
build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'react-shim' }));
|
|
255
|
+
build.onLoad({ filter: /.*/, namespace: 'react-shim' }, () => ({
|
|
256
|
+
contents: [
|
|
257
|
+
"const R = (typeof window!=='undefined' && window.React) || {};\n",
|
|
258
|
+
"export default R;\n",
|
|
259
|
+
// Common hooks and APIs used by deps
|
|
260
|
+
"export const Children = R.Children;\n",
|
|
261
|
+
"export const Component = R.Component;\n",
|
|
262
|
+
"export const Fragment = R.Fragment;\n",
|
|
263
|
+
"export const createElement = R.createElement;\n",
|
|
264
|
+
"export const cloneElement = R.cloneElement;\n",
|
|
265
|
+
"export const createContext = R.createContext;\n",
|
|
266
|
+
"export const forwardRef = R.forwardRef;\n",
|
|
267
|
+
"export const memo = R.memo;\n",
|
|
268
|
+
"export const startTransition = R.startTransition;\n",
|
|
269
|
+
"export const isValidElement = R.isValidElement;\n",
|
|
270
|
+
"export const useEffect = R.useEffect;\n",
|
|
271
|
+
"export const useLayoutEffect = R.useLayoutEffect;\n",
|
|
272
|
+
"export const useMemo = R.useMemo;\n",
|
|
273
|
+
"export const useState = R.useState;\n",
|
|
274
|
+
"export const useRef = R.useRef;\n",
|
|
275
|
+
"export const useCallback = R.useCallback;\n",
|
|
276
|
+
"export const useContext = R.useContext;\n",
|
|
277
|
+
"export const useReducer = R.useReducer;\n",
|
|
278
|
+
"export const useId = R.useId;\n",
|
|
279
|
+
"export const useSyncExternalStore = R.useSyncExternalStore;\n",
|
|
280
|
+
].join(''),
|
|
281
|
+
loader: 'js',
|
|
282
|
+
}));
|
|
283
|
+
build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ path: 'react-dom/client', namespace: 'rdc-shim' }));
|
|
284
|
+
build.onLoad({ filter: /.*/, namespace: 'rdc-shim' }, () => ({
|
|
285
|
+
contents: [
|
|
286
|
+
"const C = (typeof window!=='undefined' && window.ReactDOMClient) || {};\n",
|
|
287
|
+
"export const createRoot = C.createRoot;\n",
|
|
288
|
+
"export const hydrateRoot = C.hydrateRoot;\n",
|
|
289
|
+
].join(''),
|
|
290
|
+
loader: 'js',
|
|
291
|
+
}));
|
|
292
|
+
build.onResolve({ filter: /^react-dom$/ }, () => ({ path: 'react-dom', namespace: 'rd-shim' }));
|
|
293
|
+
build.onLoad({ filter: /.*/, namespace: 'rd-shim' }, () => ({
|
|
294
|
+
contents: "export default (typeof window!=='undefined' && window.ReactDOM) || {};\n",
|
|
295
|
+
loader: 'js',
|
|
296
|
+
}));
|
|
297
|
+
build.onResolve({ filter: /^flexsearch$/ }, () => ({ path: 'flexsearch', namespace: 'flex-shim' }));
|
|
298
|
+
build.onLoad({ filter: /.*/, namespace: 'flex-shim' }, () => ({
|
|
299
|
+
contents: "export default (typeof window!=='undefined' && window.FlexSearch) || {};\n",
|
|
300
|
+
loader: 'js',
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
const entryExists = (() => { try { return require('fs').existsSync(entry); } catch (_) { return false; } })();
|
|
306
|
+
const commonBuild = {
|
|
307
|
+
outfile: outFile,
|
|
308
|
+
platform: 'browser',
|
|
309
|
+
format: 'iife',
|
|
310
|
+
bundle: true,
|
|
311
|
+
sourcemap: true,
|
|
312
|
+
target: ['es2018'],
|
|
313
|
+
logLevel: 'silent',
|
|
314
|
+
plugins: [shimReactPlugin],
|
|
315
|
+
external: ['@samvera/clover-iiif/*'],
|
|
316
|
+
};
|
|
317
|
+
if (entryExists) {
|
|
318
|
+
await esbuild.build({ entryPoints: [entry], ...commonBuild });
|
|
319
|
+
} else {
|
|
320
|
+
await esbuild.build({
|
|
321
|
+
stdin: { contents: FALLBACK_SEARCH_APP, resolveDir: process.cwd(), loader: 'jsx', sourcefile: 'fallback-search-app.jsx' },
|
|
322
|
+
...commonBuild,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
} catch (e) {
|
|
326
|
+
console.error('Search: bundle error:', e && e.message ? e.message : e);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const { logLine } = require('./log');
|
|
331
|
+
let size = 0; try { const st = fs.statSync(outFile); size = st.size || 0; } catch (_) {}
|
|
332
|
+
const kb = size ? ` (${(size/1024).toFixed(1)} KB)` : '';
|
|
333
|
+
const rel = path.relative(process.cwd(), outFile).split(path.sep).join('/');
|
|
334
|
+
logLine(`✓ Wrote ${rel}${kb}`, 'cyan');
|
|
335
|
+
} catch (_) {}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function buildSearchPage() {
|
|
339
|
+
try {
|
|
340
|
+
const outPath = path.join(OUT_DIR, 'search.html');
|
|
341
|
+
ensureDirSync(path.dirname(outPath));
|
|
342
|
+
// If the author provided content/search/_layout.mdx, render it via MDX; otherwise fall back.
|
|
343
|
+
const searchLayoutPath = path.join(path.resolve('content'), 'search', '_layout.mdx');
|
|
344
|
+
let body = '';
|
|
345
|
+
let head = '';
|
|
346
|
+
if (require('./common').fs.existsSync(searchLayoutPath)) {
|
|
347
|
+
try {
|
|
348
|
+
const mdx = require('./mdx');
|
|
349
|
+
const rendered = await mdx.compileMdxFile(searchLayoutPath, outPath, {});
|
|
350
|
+
body = rendered && rendered.body ? rendered.body : '';
|
|
351
|
+
head = rendered && rendered.head ? rendered.head : '';
|
|
352
|
+
} catch (e) {
|
|
353
|
+
console.warn('Search: Failed to render content/search/_layout.mdx, falling back:', e && e.message ? e.message : e);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!body) {
|
|
357
|
+
// Minimal mount container; React SearchApp mounts into #search-root
|
|
358
|
+
let content = React.createElement(
|
|
359
|
+
'div',
|
|
360
|
+
null,
|
|
361
|
+
React.createElement('h1', null, 'Search'),
|
|
362
|
+
React.createElement('div', { id: 'search-root' })
|
|
363
|
+
);
|
|
364
|
+
const { loadAppWrapper } = require('./mdx');
|
|
365
|
+
const app = await loadAppWrapper();
|
|
366
|
+
const wrappedApp = app && app.App ? React.createElement(app.App, null, content) : content;
|
|
367
|
+
body = ReactDOMServer.renderToStaticMarkup(wrappedApp);
|
|
368
|
+
head = app && app.Head ? ReactDOMServer.renderToStaticMarkup(React.createElement(app.Head)) : '';
|
|
369
|
+
}
|
|
370
|
+
const importMap = '';
|
|
371
|
+
const cssRel = path.relative(path.dirname(outPath), path.join(OUT_DIR, 'styles', 'styles.css')).split(path.sep).join('/');
|
|
372
|
+
const jsAbs = path.join(OUT_DIR, 'scripts', 'search.js');
|
|
373
|
+
let jsRel = path.relative(path.dirname(outPath), jsAbs).split(path.sep).join('/');
|
|
374
|
+
let v = '';
|
|
375
|
+
try { const st = require('fs').statSync(jsAbs); v = `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
|
|
376
|
+
jsRel = jsRel + v;
|
|
377
|
+
// Include react-globals vendor shim before search.js to provide window.React globals
|
|
378
|
+
const vendorReactAbs = path.join(OUT_DIR, 'scripts', 'react-globals.js');
|
|
379
|
+
const vendorFlexAbs = path.join(OUT_DIR, 'scripts', 'flexsearch-globals.js');
|
|
380
|
+
const vendorCommandAbs = path.join(OUT_DIR, 'scripts', 'canopy-command.js');
|
|
381
|
+
function verRel(abs) {
|
|
382
|
+
let rel = path.relative(path.dirname(outPath), abs).split(path.sep).join('/');
|
|
383
|
+
try { const st = require('fs').statSync(abs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
|
|
384
|
+
return rel;
|
|
385
|
+
}
|
|
386
|
+
const vendorTags = `<script src="${verRel(vendorReactAbs)}"></script><script src="${verRel(vendorFlexAbs)}"></script><script src="${verRel(vendorCommandAbs)}"></script>`;
|
|
387
|
+
let html = htmlShell({ title: 'Search', body, cssHref: cssRel || 'styles.css', scriptHref: jsRel, headExtra: vendorTags + head + importMap });
|
|
388
|
+
try { html = require('./common').applyBaseToHtml(html); } catch (_) {}
|
|
389
|
+
await fsp.writeFile(outPath, html, 'utf8');
|
|
390
|
+
console.log('Search: Built', path.relative(process.cwd(), outPath));
|
|
391
|
+
} catch (e) {
|
|
392
|
+
console.warn('Search: Failed to build page', e.message);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function toSafeString(val, fallback = '') { try { return String(val == null ? fallback : val); } catch (_) { return fallback; } }
|
|
397
|
+
function sanitizeRecord(r) {
|
|
398
|
+
const title = toSafeString(r && r.title, '');
|
|
399
|
+
const href = toSafeString(r && r.href, '');
|
|
400
|
+
const type = toSafeString(r && r.type, 'page');
|
|
401
|
+
const thumbnail = toSafeString(r && r.thumbnail, '');
|
|
402
|
+
const safeTitle = title.length > 300 ? title.slice(0, 300) + '…' : title;
|
|
403
|
+
const out = { title: safeTitle, href, type };
|
|
404
|
+
if (thumbnail) out.thumbnail = thumbnail;
|
|
405
|
+
// Preserve optional thumbnail dimensions for aspect ratio calculations in the UI
|
|
406
|
+
try {
|
|
407
|
+
const tw = Number(r && r.thumbnailWidth);
|
|
408
|
+
const th = Number(r && r.thumbnailHeight);
|
|
409
|
+
if (Number.isFinite(tw) && tw > 0) out.thumbnailWidth = tw;
|
|
410
|
+
if (Number.isFinite(th) && th > 0) out.thumbnailHeight = th;
|
|
411
|
+
} catch (_) {}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function writeSearchIndex(records) {
|
|
416
|
+
const apiDir = path.join(OUT_DIR, 'api');
|
|
417
|
+
ensureDirSync(apiDir);
|
|
418
|
+
const idxPath = path.join(apiDir, 'search-index.json');
|
|
419
|
+
const list = Array.isArray(records) ? records : [];
|
|
420
|
+
const safe = list.map(sanitizeRecord);
|
|
421
|
+
const json = JSON.stringify(safe, null, 2);
|
|
422
|
+
const approxBytes = Buffer.byteLength(json, 'utf8');
|
|
423
|
+
if (approxBytes > 10 * 1024 * 1024) {
|
|
424
|
+
console.warn('Search: index size is large (', Math.round(approxBytes / (1024 * 1024)), 'MB ). Consider narrowing sources.');
|
|
425
|
+
}
|
|
426
|
+
await fsp.writeFile(idxPath, json, 'utf8');
|
|
427
|
+
// Also write a small metadata file with a stable version hash for cache-busting and IDB keying
|
|
428
|
+
try {
|
|
429
|
+
const version = crypto.createHash('sha256').update(json).digest('hex');
|
|
430
|
+
// Read optional search tabs order from canopy.yml
|
|
431
|
+
let tabsOrder = [];
|
|
432
|
+
try {
|
|
433
|
+
const yaml = require('js-yaml');
|
|
434
|
+
const cfgPath = require('./common').path.resolve(process.env.CANOPY_CONFIG || 'canopy.yml');
|
|
435
|
+
if (require('./common').fs.existsSync(cfgPath)) {
|
|
436
|
+
const raw = require('./common').fs.readFileSync(cfgPath, 'utf8');
|
|
437
|
+
const data = yaml.load(raw) || {};
|
|
438
|
+
const searchCfg = data && data.search ? data.search : {};
|
|
439
|
+
const tabs = searchCfg && searchCfg.tabs ? searchCfg.tabs : {};
|
|
440
|
+
const order = Array.isArray(tabs && tabs.order) ? tabs.order : [];
|
|
441
|
+
tabsOrder = order.map((s) => String(s)).filter(Boolean);
|
|
442
|
+
}
|
|
443
|
+
} catch (_) {}
|
|
444
|
+
const meta = {
|
|
445
|
+
version,
|
|
446
|
+
records: safe.length,
|
|
447
|
+
bytes: approxBytes,
|
|
448
|
+
updatedAt: new Date().toISOString(),
|
|
449
|
+
// Expose optional search config to the client runtime
|
|
450
|
+
search: { tabs: { order: tabsOrder } },
|
|
451
|
+
};
|
|
452
|
+
const metaPath = path.join(apiDir, 'index.json');
|
|
453
|
+
await fsp.writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf8');
|
|
454
|
+
try {
|
|
455
|
+
const { logLine } = require('./log');
|
|
456
|
+
logLine(`✓ Search index version ${version.slice(0, 8)} (${safe.length} records)`, 'cyan');
|
|
457
|
+
} catch (_) {}
|
|
458
|
+
// Propagate version into IIIF cache index for a single, shared build identifier
|
|
459
|
+
try {
|
|
460
|
+
const { loadManifestIndex, saveManifestIndex } = require('./iiif');
|
|
461
|
+
const iiifIdx = await loadManifestIndex();
|
|
462
|
+
iiifIdx.version = version;
|
|
463
|
+
await saveManifestIndex(iiifIdx);
|
|
464
|
+
try {
|
|
465
|
+
const { logLine } = require('./log');
|
|
466
|
+
logLine(`• IIIF cache updated with version ${version.slice(0, 8)}`, 'blue');
|
|
467
|
+
} catch (_) {}
|
|
468
|
+
} catch (_) {}
|
|
469
|
+
} catch (_) {}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Compatibility: keep ensureResultTemplate as a no-op builder (template unused by React search)
|
|
473
|
+
async function ensureResultTemplate() {
|
|
474
|
+
try { const { path } = require('./common'); const p = path.join(OUT_DIR, 'search-result.html'); await fsp.writeFile(p, '', 'utf8'); } catch (_) {}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
module.exports = { ensureSearchRuntime, ensureResultTemplate, buildSearchPage, writeSearchIndex };
|
package/lib/thumbnail.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Note: Do not shorten naming conventions here. Keep things readable and
|
|
4
|
+
// semantic. This module deliberately uses full words like "thumbnail" instead
|
|
5
|
+
// of abbreviations like "thumb".
|
|
6
|
+
|
|
7
|
+
// Helper for resolving a representative thumbnail for an IIIF resource.
|
|
8
|
+
// Uses @iiif/helpers#createThumbnailHelper to choose an appropriate size.
|
|
9
|
+
|
|
10
|
+
async function getRepresentativeImage(resource, preferredSize = 1200, unsafe = false) {
|
|
11
|
+
// Fast path: if resource already contains a thumbnail, return it without any helper work
|
|
12
|
+
try {
|
|
13
|
+
const t = resource && resource.thumbnail;
|
|
14
|
+
if (t) {
|
|
15
|
+
const first = Array.isArray(t) ? t[0] : t;
|
|
16
|
+
if (first && (first.id || first['@id'])) {
|
|
17
|
+
return {
|
|
18
|
+
id: String(first.id || first['@id']),
|
|
19
|
+
width: typeof first.width === 'number' ? first.width : undefined,
|
|
20
|
+
height: typeof first.height === 'number' ? first.height : undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch (_) {}
|
|
25
|
+
|
|
26
|
+
// Avoid potentially long-running network calls in safe mode
|
|
27
|
+
if (!unsafe) return null;
|
|
28
|
+
|
|
29
|
+
// Unsafe mode: attempt helper with a timeout so builds never hang
|
|
30
|
+
try {
|
|
31
|
+
const mod = await import('@iiif/helpers');
|
|
32
|
+
const createThumbnailHelper = mod.createThumbnailHelper || (mod.default && mod.default.createThumbnailHelper);
|
|
33
|
+
if (!createThumbnailHelper) return null;
|
|
34
|
+
const helper = createThumbnailHelper();
|
|
35
|
+
const task = helper.getBestThumbnailAtSize(
|
|
36
|
+
resource,
|
|
37
|
+
{ width: preferredSize, height: preferredSize, minWidth: preferredSize, minHeight: preferredSize },
|
|
38
|
+
true,
|
|
39
|
+
[],
|
|
40
|
+
{ width: preferredSize, height: preferredSize }
|
|
41
|
+
);
|
|
42
|
+
const timeoutMs = Number(process.env.CANOPY_THUMB_TIMEOUT || 2000);
|
|
43
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
|
|
44
|
+
const result = await Promise.race([task, timeout]);
|
|
45
|
+
if (!result) return null;
|
|
46
|
+
const id = String(result.id || result['@id'] || '');
|
|
47
|
+
const width = typeof result.width === 'number' ? result.width : undefined;
|
|
48
|
+
const height = typeof result.height === 'number' ? result.height : undefined;
|
|
49
|
+
return id ? { id, width, height } : null;
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function getThumbnail(resource, preferredSize = 1200, unsafe = false) {
|
|
56
|
+
// Prefer embedded thumbnail with available dimensions
|
|
57
|
+
try {
|
|
58
|
+
const t = resource && resource.thumbnail;
|
|
59
|
+
const first = Array.isArray(t) ? t[0] : t;
|
|
60
|
+
if (first && (first.id || first['@id'])) {
|
|
61
|
+
const url = String(first.id || first['@id']);
|
|
62
|
+
const width = typeof first.width === 'number' ? first.width : undefined;
|
|
63
|
+
const height = typeof first.height === 'number' ? first.height : undefined;
|
|
64
|
+
return { url, width, height };
|
|
65
|
+
}
|
|
66
|
+
} catch (_) {}
|
|
67
|
+
// Fall back to helper (unsafe mode only) with timeout
|
|
68
|
+
try {
|
|
69
|
+
const rep = await getRepresentativeImage(resource, preferredSize, unsafe);
|
|
70
|
+
if (rep && (rep.id || rep['@id'])) {
|
|
71
|
+
return {
|
|
72
|
+
url: String(rep.id || rep['@id']),
|
|
73
|
+
width: typeof rep.width === 'number' ? rep.width : undefined,
|
|
74
|
+
height: typeof rep.height === 'number' ? rep.height : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
} catch (_) {}
|
|
78
|
+
return { url: '' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getThumbnailUrl(resource, preferredSize = 1200, unsafe = false) {
|
|
82
|
+
const res = await getThumbnail(resource, preferredSize, unsafe);
|
|
83
|
+
return res && res.url ? String(res.url) : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { getRepresentativeImage, getThumbnail, getThumbnailUrl };
|
|
87
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@canopy-iiif/app",
|
|
3
|
+
"version": "0.6.28",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mat Jordan <mat@northwestern.edu>",
|
|
7
|
+
"homepage": "https://canopy-iiif.github.io/docs/",
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"main": "./lib/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./lib/index.js",
|
|
12
|
+
"./ui": "./ui/dist/index.mjs",
|
|
13
|
+
"./ui/server": "./ui/dist/server.mjs",
|
|
14
|
+
"./ui/styles/index.css": "./ui/styles/index.css",
|
|
15
|
+
"./ui/canopy-iiif-plugin": "./ui/tailwind-canopy-iiif-plugin.js",
|
|
16
|
+
"./ui/canopy-iiif-preset": "./ui/tailwind-canopy-iiif-preset.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/**",
|
|
20
|
+
"ui/dist/**",
|
|
21
|
+
"ui/styles/**",
|
|
22
|
+
"ui/tailwind-canopy-iiif-plugin.js",
|
|
23
|
+
"ui/tailwind-canopy-iiif-preset.js"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@iiif/helpers": "^1.4.0",
|
|
30
|
+
"@mdx-js/mdx": "^3.1.0",
|
|
31
|
+
"@mdx-js/react": "^3.1.0",
|
|
32
|
+
"cmdk": "^1.1.1",
|
|
33
|
+
"js-yaml": "^4.1.0",
|
|
34
|
+
"react-masonry-css": "^1.0.16",
|
|
35
|
+
"sass": "^1.77.0",
|
|
36
|
+
"slugify": "^1.6.6"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"react": "^18.2.0 || ^19.0.0",
|
|
40
|
+
"react-dom": "^18.2.0 || ^19.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"esbuild": "^0.21.4"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"ui:build": "node ./ui/scripts/build-ui.mjs",
|
|
47
|
+
"ui:watch": "WATCH=1 node ./ui/scripts/build-ui.mjs",
|
|
48
|
+
"prepublishOnly": "npm run ui:build"
|
|
49
|
+
}
|
|
50
|
+
}
|