@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.
@@ -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 { SearchFormUI, SearchResultsUI } from '@canopy-iiif/app/ui';
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 version for cache-busting; fall back to hash of JSON
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
- mountAt('[data-canopy-search-form]', FormMount);
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
- mountAt('[data-canopy-search-total]', TotalMount);
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 });
@@ -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 { SearchFormUI, SearchResultsUI } from '@canopy-iiif/app/ui';
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
- function TotalMount() {
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
- mountAt('[data-canopy-search-total]', TotalMount);
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
- 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
- }
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
- // If the author provided content/search/_layout.mdx, render it via MDX; otherwise fall back.
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
- try {
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",