@canopy-iiif/app 0.7.9 → 0.7.11
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/build.js +20 -1
- package/lib/build/iiif.js +68 -2
- package/lib/build/mdx.js +197 -6
- package/lib/build/pages.js +7 -3
- package/lib/build/runtimes.js +66 -8
- package/lib/build/verify.js +60 -0
- package/lib/components/featured.js +144 -0
- package/lib/search/search-app.jsx +35 -13
- package/lib/search/search.js +15 -44
- package/package.json +1 -1
- package/ui/dist/index.mjs +143 -166
- package/ui/dist/index.mjs.map +4 -4
- package/ui/dist/server.mjs +212 -26
- package/ui/dist/server.mjs.map +4 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const yaml = require('js-yaml');
|
|
4
|
+
|
|
5
|
+
function firstLabelString(label) {
|
|
6
|
+
if (!label) return 'Untitled';
|
|
7
|
+
if (typeof label === 'string') return label;
|
|
8
|
+
try {
|
|
9
|
+
const keys = Object.keys(label || {});
|
|
10
|
+
if (!keys.length) return 'Untitled';
|
|
11
|
+
const arr = label[keys[0]];
|
|
12
|
+
if (Array.isArray(arr) && arr.length) return String(arr[0]);
|
|
13
|
+
} catch (_) {}
|
|
14
|
+
return 'Untitled';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeIiifId(raw) {
|
|
18
|
+
try {
|
|
19
|
+
const s = String(raw || '');
|
|
20
|
+
if (!/^https?:\/\//i.test(s)) return s;
|
|
21
|
+
const u = new URL(s);
|
|
22
|
+
const entries = Array.from(u.searchParams.entries()).sort(
|
|
23
|
+
(a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])
|
|
24
|
+
);
|
|
25
|
+
u.search = '';
|
|
26
|
+
for (const [k, v] of entries) u.searchParams.append(k, v);
|
|
27
|
+
return u.toString();
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return String(raw || '');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function equalIiifId(a, b) {
|
|
34
|
+
try {
|
|
35
|
+
const an = normalizeIiifId(a);
|
|
36
|
+
const bn = normalizeIiifId(b);
|
|
37
|
+
if (an === bn) return true;
|
|
38
|
+
const ua = new URL(an);
|
|
39
|
+
const ub = new URL(bn);
|
|
40
|
+
return ua.origin === ub.origin && ua.pathname === ub.pathname;
|
|
41
|
+
} catch (_) {
|
|
42
|
+
return String(a || '') === String(b || '');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readYaml(p) {
|
|
47
|
+
try {
|
|
48
|
+
if (!fs.existsSync(p)) return null;
|
|
49
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
50
|
+
return yaml.load(raw) || null;
|
|
51
|
+
} catch (_) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJson(p) {
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(p)) return null;
|
|
59
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
60
|
+
return JSON.parse(raw);
|
|
61
|
+
} catch (_) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findSlugByIdFromDiskSync(nid) {
|
|
67
|
+
try {
|
|
68
|
+
const dir = path.resolve('.cache/iiif/manifests');
|
|
69
|
+
if (!fs.existsSync(dir)) return null;
|
|
70
|
+
const names = fs.readdirSync(dir);
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
if (!name || !name.toLowerCase().endsWith('.json')) continue;
|
|
73
|
+
const fp = path.join(dir, name);
|
|
74
|
+
try {
|
|
75
|
+
const obj = readJson(fp);
|
|
76
|
+
const mid = normalizeIiifId(String((obj && (obj.id || obj['@id'])) || ''));
|
|
77
|
+
if (mid && equalIiifId(mid, nid)) return name.replace(/\.json$/i, '');
|
|
78
|
+
} catch (_) {}
|
|
79
|
+
}
|
|
80
|
+
} catch (_) {}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readFeaturedFromCacheSync() {
|
|
85
|
+
try {
|
|
86
|
+
const debug = !!process.env.CANOPY_DEBUG_FEATURED;
|
|
87
|
+
const cfg = readYaml(path.resolve('canopy.yml')) || {};
|
|
88
|
+
const featured = Array.isArray(cfg && cfg.featured) ? cfg.featured : [];
|
|
89
|
+
if (!featured.length) return [];
|
|
90
|
+
const idx = readJson(path.resolve('.cache/iiif/index.json')) || {};
|
|
91
|
+
const byId = Array.isArray(idx && idx.byId) ? idx.byId : [];
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const id of featured) {
|
|
94
|
+
const nid = normalizeIiifId(id);
|
|
95
|
+
if (debug) { try { console.log('[featured] id:', id); } catch (_) {} }
|
|
96
|
+
const entry = byId.find((e) => e && e.type === 'Manifest' && equalIiifId(e.id, nid));
|
|
97
|
+
const slug = entry && entry.slug ? String(entry.slug) : findSlugByIdFromDiskSync(nid);
|
|
98
|
+
if (debug) { try { console.log('[featured] - slug:', slug || '(none)'); } catch (_) {} }
|
|
99
|
+
if (!slug) continue;
|
|
100
|
+
const m = readJson(path.resolve('.cache/iiif/manifests', slug + '.json'));
|
|
101
|
+
if (!m) continue;
|
|
102
|
+
const rec = {
|
|
103
|
+
title: firstLabelString(m && m.label),
|
|
104
|
+
href: path.join('works', slug + '.html').split(path.sep).join('/'),
|
|
105
|
+
type: 'work',
|
|
106
|
+
};
|
|
107
|
+
if (entry && entry.thumbnail) rec.thumbnail = String(entry.thumbnail);
|
|
108
|
+
if (entry && typeof entry.thumbnailWidth === 'number') rec.thumbnailWidth = entry.thumbnailWidth;
|
|
109
|
+
if (entry && typeof entry.thumbnailHeight === 'number') rec.thumbnailHeight = entry.thumbnailHeight;
|
|
110
|
+
if (!rec.thumbnail) {
|
|
111
|
+
try {
|
|
112
|
+
const t = m && m.thumbnail;
|
|
113
|
+
if (Array.isArray(t) && t.length) {
|
|
114
|
+
const first = t[0] || {};
|
|
115
|
+
const tid = first.id || first['@id'] || first.url || '';
|
|
116
|
+
if (tid) rec.thumbnail = String(tid);
|
|
117
|
+
if (typeof first.width === 'number') rec.thumbnailWidth = first.width;
|
|
118
|
+
if (typeof first.height === 'number') rec.thumbnailHeight = first.height;
|
|
119
|
+
} else if (t && typeof t === 'object') {
|
|
120
|
+
const tid = t.id || t['@id'] || t.url || '';
|
|
121
|
+
if (tid) rec.thumbnail = String(tid);
|
|
122
|
+
if (typeof t.width === 'number') rec.thumbnailWidth = t.width;
|
|
123
|
+
if (typeof t.height === 'number') rec.thumbnailHeight = t.height;
|
|
124
|
+
}
|
|
125
|
+
} catch (_) {}
|
|
126
|
+
}
|
|
127
|
+
out.push(rec);
|
|
128
|
+
}
|
|
129
|
+
if (debug) { try { console.log('[featured] total:', out.length); } catch (_) {} }
|
|
130
|
+
return out;
|
|
131
|
+
} catch (_) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
firstLabelString,
|
|
138
|
+
normalizeIiifId,
|
|
139
|
+
equalIiifId,
|
|
140
|
+
readYaml,
|
|
141
|
+
readJson,
|
|
142
|
+
findSlugByIdFromDiskSync,
|
|
143
|
+
readFeaturedFromCacheSync,
|
|
144
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react';
|
|
2
2
|
import { createRoot } from 'react-dom/client';
|
|
3
|
-
import {
|
|
3
|
+
import { SearchResultsUI, SearchTabsUI } from '@canopy-iiif/app/ui';
|
|
4
4
|
|
|
5
5
|
// Lightweight IndexedDB utilities (no deps) with graceful fallback
|
|
6
6
|
function hasIDB() {
|
|
@@ -133,11 +133,14 @@ function createSearchStore() {
|
|
|
133
133
|
// Broadcast new index installs to other tabs
|
|
134
134
|
let bc = null;
|
|
135
135
|
try { if (typeof BroadcastChannel !== 'undefined') bc = new BroadcastChannel('canopy-search'); } catch (_) {}
|
|
136
|
-
// Try to load meta
|
|
136
|
+
// Try to load meta for cache-busting and tab order; fall back to hash of JSON
|
|
137
137
|
let version = '';
|
|
138
|
+
let tabsOrder = [];
|
|
138
139
|
try {
|
|
139
140
|
const meta = await fetch('./api/index.json').then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
|
|
140
141
|
if (meta && typeof meta.version === 'string') version = meta.version;
|
|
142
|
+
const ord = meta && meta.search && meta.search.tabs && Array.isArray(meta.search.tabs.order) ? meta.search.tabs.order : [];
|
|
143
|
+
tabsOrder = ord.map((s) => String(s)).filter(Boolean);
|
|
141
144
|
} catch (_) {}
|
|
142
145
|
const res = await fetch('./api/search-index.json' + (version ? `?v=${encodeURIComponent(version)}` : ''));
|
|
143
146
|
const text = await res.text();
|
|
@@ -198,8 +201,22 @@ function createSearchStore() {
|
|
|
198
201
|
} catch (_) {}
|
|
199
202
|
|
|
200
203
|
const ts = Array.from(new Set(data.map((r) => String((r && r.type) || 'page'))));
|
|
201
|
-
const order = ['work', 'docs', 'page'];
|
|
204
|
+
const order = Array.isArray(tabsOrder) && tabsOrder.length ? tabsOrder : ['work', 'docs', 'page'];
|
|
202
205
|
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); });
|
|
206
|
+
// Default to configured first tab if no type param present
|
|
207
|
+
try {
|
|
208
|
+
const p = new URLSearchParams(location.search);
|
|
209
|
+
const hasType = p.has('type');
|
|
210
|
+
if (!hasType) {
|
|
211
|
+
let def = (order && order.length ? order[0] : 'all');
|
|
212
|
+
if (!ts.includes(def)) def = ts[0] || 'all';
|
|
213
|
+
if (def && def !== 'all') {
|
|
214
|
+
p.set('type', def);
|
|
215
|
+
history.replaceState(null, '', `${location.pathname}?${p.toString()}`);
|
|
216
|
+
}
|
|
217
|
+
set({ type: def });
|
|
218
|
+
}
|
|
219
|
+
} catch (_) {}
|
|
203
220
|
set({ index: idx, records: data, types: ts, loading: false });
|
|
204
221
|
} catch (_) { set({ loading: false }); }
|
|
205
222
|
})();
|
|
@@ -216,16 +233,16 @@ function useStore() {
|
|
|
216
233
|
return { ...snap, setQuery: store.setQuery, setType: store.setType };
|
|
217
234
|
}
|
|
218
235
|
|
|
219
|
-
function FormMount() {
|
|
220
|
-
const { query, setQuery, type, setType, types, counts } = useStore();
|
|
221
|
-
return <SearchFormUI query={query} onQueryChange={setQuery} type={type} onTypeChange={setType} types={types} counts={counts} />;
|
|
222
|
-
}
|
|
223
236
|
function ResultsMount(props = {}) {
|
|
224
237
|
const { results, type, loading } = useStore();
|
|
225
238
|
if (loading) return <div className="text-slate-600">Loading…</div>;
|
|
226
239
|
const layout = (props && props.layout) || 'grid';
|
|
227
240
|
return <SearchResultsUI results={results} type={type} layout={layout} />;
|
|
228
241
|
}
|
|
242
|
+
function TabsMount() {
|
|
243
|
+
const { type, setType, types, counts } = useStore();
|
|
244
|
+
return <SearchTabsUI type={type} onTypeChange={setType} types={types} counts={counts} />;
|
|
245
|
+
}
|
|
229
246
|
function SummaryMount() {
|
|
230
247
|
const { query, type, shown, total } = useStore();
|
|
231
248
|
const text = useMemo(() => {
|
|
@@ -234,10 +251,6 @@ function SummaryMount() {
|
|
|
234
251
|
}, [query, type, shown, total]);
|
|
235
252
|
return <div className="text-sm text-slate-600">{text}</div>;
|
|
236
253
|
}
|
|
237
|
-
function TotalMount() {
|
|
238
|
-
const { shown } = useStore();
|
|
239
|
-
return <span>{shown}</span>;
|
|
240
|
-
}
|
|
241
254
|
|
|
242
255
|
function parseProps(el) {
|
|
243
256
|
try {
|
|
@@ -263,10 +276,19 @@ function mountAt(selector, Comp) {
|
|
|
263
276
|
|
|
264
277
|
if (typeof document !== 'undefined') {
|
|
265
278
|
const run = () => {
|
|
266
|
-
|
|
279
|
+
// Mount tabs and other search UI pieces
|
|
280
|
+
mountAt('[data-canopy-search-tabs]', TabsMount);
|
|
267
281
|
mountAt('[data-canopy-search-results]', ResultsMount);
|
|
268
282
|
mountAt('[data-canopy-search-summary]', SummaryMount);
|
|
269
|
-
|
|
283
|
+
// Total mount removed
|
|
284
|
+
try {
|
|
285
|
+
window.addEventListener('canopy:search:setQuery', (ev) => {
|
|
286
|
+
try {
|
|
287
|
+
const q = ev && ev.detail && typeof ev.detail.query === 'string' ? ev.detail.query : (document.querySelector('[data-canopy-command-input]')?.value || '');
|
|
288
|
+
if (typeof q === 'string') store.setQuery(q);
|
|
289
|
+
} catch (_) {}
|
|
290
|
+
});
|
|
291
|
+
} catch (_) {}
|
|
270
292
|
};
|
|
271
293
|
if (document.readyState !== 'loading') run();
|
|
272
294
|
else document.addEventListener('DOMContentLoaded', run, { once: true });
|
package/lib/search/search.js
CHANGED
|
@@ -6,7 +6,7 @@ const { ensureDirSync, OUT_DIR, htmlShell, fsp } = require('../common');
|
|
|
6
6
|
|
|
7
7
|
const FALLBACK_SEARCH_APP = `import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react';
|
|
8
8
|
import { createRoot } from 'react-dom/client';
|
|
9
|
-
import {
|
|
9
|
+
import { SearchResultsUI } from '@canopy-iiif/app/ui';
|
|
10
10
|
|
|
11
11
|
function hasIDB(){ try { return typeof indexedDB !== 'undefined'; } catch (_) { return false; } }
|
|
12
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);} }); }
|
|
@@ -153,10 +153,6 @@ function useStore() {
|
|
|
153
153
|
return { ...snap, setQuery: store.setQuery, setType: store.setType };
|
|
154
154
|
}
|
|
155
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
156
|
function ResultsMount() {
|
|
161
157
|
const { results, type, loading } = useStore();
|
|
162
158
|
if (loading) return <div className=\"text-slate-600\">Loading…</div>;
|
|
@@ -170,10 +166,7 @@ function SummaryMount() {
|
|
|
170
166
|
}, [query, type, shown, total]);
|
|
171
167
|
return <div className=\"text-sm text-slate-600\">{text}</div>;
|
|
172
168
|
}
|
|
173
|
-
|
|
174
|
-
const { shown } = useStore();
|
|
175
|
-
return <span>{shown}</span>;
|
|
176
|
-
}
|
|
169
|
+
|
|
177
170
|
|
|
178
171
|
function mountAt(selector, Comp) {
|
|
179
172
|
const nodes = document.querySelectorAll(selector);
|
|
@@ -189,10 +182,9 @@ function mountAt(selector, Comp) {
|
|
|
189
182
|
|
|
190
183
|
if (typeof document !== 'undefined') {
|
|
191
184
|
const run = () => {
|
|
192
|
-
mountAt('[data-canopy-search-form]', FormMount);
|
|
193
185
|
mountAt('[data-canopy-search-results]', ResultsMount);
|
|
194
186
|
mountAt('[data-canopy-search-summary]', SummaryMount);
|
|
195
|
-
|
|
187
|
+
|
|
196
188
|
};
|
|
197
189
|
if (document.readyState !== 'loading') run();
|
|
198
190
|
else document.addEventListener('DOMContentLoaded', run, { once: true });
|
|
@@ -314,14 +306,8 @@ async function ensureSearchRuntime() {
|
|
|
314
306
|
plugins: [shimReactPlugin],
|
|
315
307
|
external: ['@samvera/clover-iiif/*'],
|
|
316
308
|
};
|
|
317
|
-
if (entryExists)
|
|
318
|
-
|
|
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
|
-
}
|
|
309
|
+
if (!entryExists) throw new Error('Search runtime entry missing: ' + entry);
|
|
310
|
+
await esbuild.build({ entryPoints: [entry], ...commonBuild });
|
|
325
311
|
} catch (e) {
|
|
326
312
|
console.error('Search: bundle error:', e && e.message ? e.message : e);
|
|
327
313
|
return;
|
|
@@ -339,34 +325,18 @@ async function buildSearchPage() {
|
|
|
339
325
|
try {
|
|
340
326
|
const outPath = path.join(OUT_DIR, 'search.html');
|
|
341
327
|
ensureDirSync(path.dirname(outPath));
|
|
342
|
-
//
|
|
328
|
+
// Require author-provided content/search/_layout.mdx; do not fall back to a generated page.
|
|
343
329
|
const searchLayoutPath = path.join(path.resolve('content'), 'search', '_layout.mdx');
|
|
344
330
|
let body = '';
|
|
345
331
|
let head = '';
|
|
346
|
-
if (require('../common').fs.existsSync(searchLayoutPath)) {
|
|
347
|
-
|
|
348
|
-
const mdx = require('../build/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('../build/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)) : '';
|
|
332
|
+
if (!require('../common').fs.existsSync(searchLayoutPath)) {
|
|
333
|
+
throw new Error('Missing required file: content/search/_layout.mdx');
|
|
369
334
|
}
|
|
335
|
+
const mdx = require('../build/mdx');
|
|
336
|
+
const rendered = await mdx.compileMdxFile(searchLayoutPath, outPath, {});
|
|
337
|
+
body = rendered && rendered.body ? rendered.body : '';
|
|
338
|
+
head = rendered && rendered.head ? rendered.head : '';
|
|
339
|
+
if (!body) throw new Error('Search: content/search/_layout.mdx produced empty output');
|
|
370
340
|
const importMap = '';
|
|
371
341
|
const cssRel = path.relative(path.dirname(outPath), path.join(OUT_DIR, 'styles', 'styles.css')).split(path.sep).join('/');
|
|
372
342
|
const jsAbs = path.join(OUT_DIR, 'scripts', 'search.js');
|
|
@@ -389,7 +359,8 @@ async function buildSearchPage() {
|
|
|
389
359
|
await fsp.writeFile(outPath, html, 'utf8');
|
|
390
360
|
console.log('Search: Built', path.relative(process.cwd(), outPath));
|
|
391
361
|
} catch (e) {
|
|
392
|
-
console.warn('Search: Failed to build page', e.message);
|
|
362
|
+
console.warn('Search: Failed to build page', e && (e.message || e));
|
|
363
|
+
throw e;
|
|
393
364
|
}
|
|
394
365
|
}
|
|
395
366
|
|