@canopy-iiif/app 0.8.3 → 0.8.5

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.
Files changed (36) hide show
  1. package/lib/build/build.js +2 -0
  2. package/lib/build/dev.js +38 -22
  3. package/lib/build/iiif.js +359 -83
  4. package/lib/build/mdx.js +12 -2
  5. package/lib/build/pages.js +15 -1
  6. package/lib/build/styles.js +53 -1
  7. package/lib/common.js +28 -6
  8. package/lib/components/navigation.js +308 -0
  9. package/lib/page-context.js +14 -0
  10. package/lib/search/search-app.jsx +177 -25
  11. package/lib/search/search-form-runtime.js +126 -19
  12. package/lib/search/search.js +130 -18
  13. package/package.json +4 -1
  14. package/ui/dist/index.mjs +204 -101
  15. package/ui/dist/index.mjs.map +4 -4
  16. package/ui/dist/server.mjs +167 -59
  17. package/ui/dist/server.mjs.map +4 -4
  18. package/ui/styles/_variables.scss +1 -0
  19. package/ui/styles/base/_common.scss +27 -5
  20. package/ui/styles/base/_heading.scss +2 -4
  21. package/ui/styles/base/index.scss +1 -0
  22. package/ui/styles/components/_card.scss +47 -4
  23. package/ui/styles/components/_sub-navigation.scss +76 -0
  24. package/ui/styles/components/header/_header.scss +1 -4
  25. package/ui/styles/components/header/_logo.scss +33 -10
  26. package/ui/styles/components/index.scss +1 -0
  27. package/ui/styles/components/search/_filters.scss +5 -7
  28. package/ui/styles/components/search/_form.scss +55 -17
  29. package/ui/styles/components/search/_results.scss +49 -14
  30. package/ui/styles/index.css +344 -56
  31. package/ui/styles/index.scss +2 -4
  32. package/ui/tailwind-canopy-iiif-plugin.js +10 -2
  33. package/ui/tailwind-canopy-iiif-preset.js +21 -19
  34. package/ui/theme.js +303 -0
  35. package/ui/styles/variables.emit.scss +0 -72
  36. package/ui/styles/variables.scss +0 -76
@@ -6,6 +6,49 @@ import {
6
6
  SearchFiltersDialog,
7
7
  } from "@canopy-iiif/app/ui";
8
8
 
9
+ function readBasePath() {
10
+ const normalize = (val) => {
11
+ const raw = typeof val === "string" ? val.trim() : "";
12
+ if (!raw) return "";
13
+ const withLead = raw.startsWith("/") ? raw : `/${raw}`;
14
+ return withLead.replace(/\/+$/, "");
15
+ };
16
+ try {
17
+ if (typeof window !== "undefined" && window.CANOPY_BASE_PATH != null) {
18
+ const fromWindow = normalize(window.CANOPY_BASE_PATH);
19
+ if (fromWindow) return fromWindow;
20
+ }
21
+ } catch (_) {}
22
+ try {
23
+ if (typeof globalThis !== "undefined" && globalThis.CANOPY_BASE_PATH != null) {
24
+ const fromGlobal = normalize(globalThis.CANOPY_BASE_PATH);
25
+ if (fromGlobal) return fromGlobal;
26
+ }
27
+ } catch (_) {}
28
+ try {
29
+ if (typeof process !== "undefined" && process.env && process.env.CANOPY_BASE_PATH) {
30
+ const fromEnv = normalize(process.env.CANOPY_BASE_PATH);
31
+ if (fromEnv) return fromEnv;
32
+ }
33
+ } catch (_) {}
34
+ return "";
35
+ }
36
+
37
+ function withBasePath(href) {
38
+ try {
39
+ const raw = typeof href === "string" ? href.trim() : "";
40
+ if (!raw) return href;
41
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(raw)) return raw;
42
+ if (!raw.startsWith("/")) return raw;
43
+ const base = readBasePath();
44
+ if (!base || base === "/") return raw;
45
+ if (raw === base || raw.startsWith(`${base}/`)) return raw;
46
+ return `${base}${raw}`;
47
+ } catch (_) {
48
+ return href;
49
+ }
50
+ }
51
+
9
52
  // Lightweight IndexedDB utilities (no deps) with defensive guards
10
53
  function hasIDB() {
11
54
  try {
@@ -116,6 +159,7 @@ function createSearchStore() {
116
159
  facetsDocsMap: {},
117
160
  filters: {},
118
161
  activeFilterCount: 0,
162
+ annotationsEnabled: false,
119
163
  };
120
164
  const listeners = new Set();
121
165
  function notify() {
@@ -209,22 +253,34 @@ function createSearchStore() {
209
253
  counts = {};
210
254
  }
211
255
 
212
- results =
213
- type === "all"
214
- ? base
215
- : base.filter(
216
- (r) => String(r.type).toLowerCase() === String(type).toLowerCase()
217
- );
218
- if (shouldFilterWorks && allowed) {
219
- results = results.filter((r) => allowed.has(r && r.__docIndex));
220
- }
256
+ const annotationMatches = base.filter((r) => r && r.annotation);
257
+ if (annotationMatches.length)
258
+ counts.annotation = annotationMatches.length;
221
259
 
222
- if (type !== "all") {
223
- try {
224
- totalForType = records.filter(
225
- (r) => String(r.type).toLowerCase() === String(type).toLowerCase()
226
- ).length;
227
- } catch (_) {}
260
+ const typeLower = String(type).toLowerCase();
261
+ if (typeLower === "annotation") {
262
+ results = annotationMatches.map((r) => ({
263
+ ...r,
264
+ type: "annotation",
265
+ originalType: r ? r.type : undefined,
266
+ }));
267
+ totalForType = annotationMatches.length;
268
+ } else {
269
+ results =
270
+ typeLower === "all"
271
+ ? base
272
+ : base.filter((r) => String(r.type).toLowerCase() === typeLower);
273
+ if (shouldFilterWorks && allowed) {
274
+ results = results.filter((r) => allowed.has(r && r.__docIndex));
275
+ }
276
+
277
+ if (typeLower !== "all") {
278
+ try {
279
+ totalForType = records.filter(
280
+ (r) => String(r.type).toLowerCase() === typeLower
281
+ ).length;
282
+ } catch (_) {}
283
+ }
228
284
  }
229
285
  }
230
286
  const {facetsDocsMap: _fMap, ...publicState} = state;
@@ -390,6 +446,7 @@ function createSearchStore() {
390
446
  // Try to load meta for cache-busting and tab order; fall back to hash of JSON
391
447
  let version = "";
392
448
  let tabsOrder = [];
449
+ let annotationsAssetPath = "";
393
450
  try {
394
451
  const meta = await fetch("./api/index.json")
395
452
  .then((r) => (r && r.ok ? r.json() : null))
@@ -403,11 +460,17 @@ function createSearchStore() {
403
460
  ? meta.search.tabs.order
404
461
  : [];
405
462
  tabsOrder = ord.map((s) => String(s)).filter(Boolean);
463
+ const annotationsAsset =
464
+ meta &&
465
+ meta.search &&
466
+ meta.search.assets &&
467
+ meta.search.assets.annotations;
468
+ if (annotationsAsset && annotationsAsset.path) {
469
+ annotationsAssetPath = String(annotationsAsset.path);
470
+ }
406
471
  } catch (_) {}
407
- const res = await fetch(
408
- "./api/search-index.json" +
409
- (version ? `?v=${encodeURIComponent(version)}` : "")
410
- );
472
+ const suffix = version ? `?v=${encodeURIComponent(version)}` : "";
473
+ const res = await fetch(`./api/search-index.json${suffix}`);
411
474
  const text = await res.text();
412
475
  const parsed = (() => {
413
476
  try {
@@ -416,16 +479,89 @@ function createSearchStore() {
416
479
  return [];
417
480
  }
418
481
  })();
419
- const rawData = Array.isArray(parsed)
482
+ const indexRecords = Array.isArray(parsed)
420
483
  ? parsed
421
484
  : parsed && parsed.records
422
485
  ? parsed.records
423
486
  : [];
424
- const data = rawData.map((rec, i) => ({...(rec || {}), __docIndex: i}));
487
+
488
+ let displayRecords = [];
489
+ try {
490
+ const displayRes = await fetch(`./api/search-records.json${suffix}`);
491
+ if (displayRes && displayRes.ok) {
492
+ const displayJson = await displayRes.json().catch(() => []);
493
+ displayRecords = Array.isArray(displayJson)
494
+ ? displayJson
495
+ : displayJson && Array.isArray(displayJson.records)
496
+ ? displayJson.records
497
+ : [];
498
+ }
499
+ } catch (_) {}
500
+
501
+ const displayMap = new Map();
502
+ displayRecords.forEach((rec) => {
503
+ if (!rec || typeof rec !== "object") return;
504
+ const key = rec.id ? String(rec.id) : rec.href ? String(rec.href) : "";
505
+ if (!key) return;
506
+ displayMap.set(key, rec);
507
+ });
508
+
509
+ let annotationsRecords = [];
510
+ if (annotationsAssetPath) {
511
+ try {
512
+ const annotationsUrl = `./api/${annotationsAssetPath.replace(/^\/+/, '')}` + (version ? `?v=${encodeURIComponent(version)}` : "");
513
+ const annotationsRes = await fetch(annotationsUrl);
514
+ if (annotationsRes && annotationsRes.ok) {
515
+ const annotationsJson = await annotationsRes.json().catch(() => []);
516
+ annotationsRecords = Array.isArray(annotationsJson)
517
+ ? annotationsJson
518
+ : annotationsJson && Array.isArray(annotationsJson.records)
519
+ ? annotationsJson.records
520
+ : [];
521
+ }
522
+ } catch (_) {}
523
+ }
524
+
525
+ const annotationsMap = new Map();
526
+ annotationsRecords.forEach((rec, idx) => {
527
+ if (!rec || typeof rec !== "object") return;
528
+ const key = rec.id ? String(rec.id) : String(idx);
529
+ const text = rec.annotation ? String(rec.annotation) : rec.text ? String(rec.text) : "";
530
+ if (!key || !text) return;
531
+ annotationsMap.set(key, text);
532
+ });
533
+
534
+ const data = indexRecords.map((rec, i) => {
535
+ const key = rec && rec.id ? String(rec.id) : String(i);
536
+ const display = key ? displayMap.get(key) : null;
537
+ const merged = { ...(display || {}), ...(rec || {}), __docIndex: i };
538
+ if (!merged.id && key) merged.id = key;
539
+ if (!merged.href && display && display.href) merged.href = String(display.href);
540
+ if (merged.href) merged.href = withBasePath(merged.href);
541
+ if (!merged.title) merged.title = merged.href || "";
542
+ if (!Array.isArray(merged.metadata)) {
543
+ const meta = Array.isArray(rec && rec.metadata) ? rec.metadata : [];
544
+ merged.metadata = meta;
545
+ }
546
+ if (annotationsMap.has(key) && !merged.annotation)
547
+ merged.annotation = annotationsMap.get(key);
548
+ return merged;
549
+ });
425
550
  if (!version)
426
551
  version = (parsed && parsed.version) || (await sha256Hex(text));
427
552
 
428
553
  const idx = new Flex.Index({tokenize: "forward"});
554
+ const collectSearchText = (rec) => {
555
+ const parts = [];
556
+ if (rec && rec.title) parts.push(String(rec.title));
557
+ const metadata = Array.isArray(rec && rec.metadata) ? rec.metadata : [];
558
+ for (const value of metadata) {
559
+ if (value) parts.push(String(value));
560
+ }
561
+ if (rec && rec.summary) parts.push(String(rec.summary));
562
+ if (rec && rec.annotation) parts.push(String(rec.annotation));
563
+ return parts.join(" ").trim();
564
+ };
429
565
  let hydrated = false;
430
566
  const t0 =
431
567
  typeof performance !== "undefined" && performance.now
@@ -455,7 +591,8 @@ function createSearchStore() {
455
591
  if (!hydrated) {
456
592
  data.forEach((rec, i) => {
457
593
  try {
458
- idx.add(i, rec && rec.title ? String(rec.title) : "");
594
+ const payload = collectSearchText(rec);
595
+ idx.add(i, payload);
459
596
  } catch (_) {}
460
597
  });
461
598
  try {
@@ -521,6 +658,8 @@ function createSearchStore() {
521
658
  const ts = Array.from(
522
659
  new Set(data.map((r) => String((r && r.type) || "page")))
523
660
  );
661
+ if (annotationsRecords.length && !ts.includes("annotation"))
662
+ ts.push("annotation");
524
663
  const order =
525
664
  Array.isArray(tabsOrder) && tabsOrder.length
526
665
  ? tabsOrder
@@ -548,7 +687,13 @@ function createSearchStore() {
548
687
  set({type: def});
549
688
  }
550
689
  } catch (_) {}
551
- set({index: idx, records: data, types: ts, loading: false});
690
+ set({
691
+ index: idx,
692
+ records: data,
693
+ types: ts,
694
+ loading: false,
695
+ annotationsEnabled: annotationsRecords.length > 0,
696
+ });
552
697
  await hydrateFacets();
553
698
  } catch (_) {
554
699
  set({loading: false});
@@ -609,10 +754,17 @@ function useStore() {
609
754
  }
610
755
 
611
756
  function ResultsMount(props = {}) {
612
- const {results, type, loading} = useStore();
757
+ const {results, type, loading, query} = useStore();
613
758
  if (loading) return <div className="text-slate-600">Loading…</div>;
614
759
  const layout = (props && props.layout) || "grid";
615
- return <SearchResultsUI results={results} type={type} layout={layout} />;
760
+ return (
761
+ <SearchResultsUI
762
+ results={results}
763
+ type={type}
764
+ layout={layout}
765
+ query={query}
766
+ />
767
+ );
616
768
  }
617
769
  function TabsMount() {
618
770
  const {
@@ -79,17 +79,99 @@ async function loadRecords() {
79
79
  try {
80
80
  const base = rootBase();
81
81
  let version = '';
82
+ let annotationsAssetPath = '';
82
83
  try {
83
84
  const meta = await fetch(`${base}/api/index.json`).then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
84
85
  if (meta && typeof meta.version === 'string') version = meta.version;
86
+ const annotationsAsset =
87
+ meta &&
88
+ meta.search &&
89
+ meta.search.assets &&
90
+ meta.search.assets.annotations;
91
+ if (annotationsAsset && annotationsAsset.path) {
92
+ annotationsAssetPath = String(annotationsAsset.path);
93
+ }
85
94
  } catch (_) {}
86
95
  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 [];
96
+ const indexUrl = `${base}/api/search-index.json${suffix}`;
97
+ const recordsUrl = `${base}/api/search-records.json${suffix}`;
98
+ const [indexRes, displayRes] = await Promise.all([
99
+ fetch(indexUrl).catch(() => null),
100
+ fetch(recordsUrl).catch(() => null),
101
+ ]);
102
+ let indexRecords = [];
103
+ if (indexRes && indexRes.ok) {
104
+ try {
105
+ const raw = await indexRes.json();
106
+ indexRecords = Array.isArray(raw)
107
+ ? raw
108
+ : raw && Array.isArray(raw.records)
109
+ ? raw.records
110
+ : [];
111
+ } catch (_) {
112
+ indexRecords = [];
113
+ }
114
+ }
115
+ let displayRecords = [];
116
+ if (displayRes && displayRes.ok) {
117
+ try {
118
+ const raw = await displayRes.json();
119
+ displayRecords = Array.isArray(raw)
120
+ ? raw
121
+ : raw && Array.isArray(raw.records)
122
+ ? raw.records
123
+ : [];
124
+ } catch (_) {
125
+ displayRecords = [];
126
+ }
127
+ }
128
+ let annotationsRecords = [];
129
+ if (annotationsAssetPath) {
130
+ try {
131
+ const annotationsUrl = `${base}/api/${annotationsAssetPath.replace(/^\/+/, '')}${suffix}`;
132
+ const annotationsRes = await fetch(annotationsUrl).catch(() => null);
133
+ if (annotationsRes && annotationsRes.ok) {
134
+ const raw = await annotationsRes.json().catch(() => []);
135
+ annotationsRecords = Array.isArray(raw)
136
+ ? raw
137
+ : raw && Array.isArray(raw.records)
138
+ ? raw.records
139
+ : [];
140
+ }
141
+ } catch (_) {}
142
+ }
143
+ if (!indexRecords.length && displayRecords.length) {
144
+ return displayRecords;
145
+ }
146
+ const displayMap = new Map();
147
+ displayRecords.forEach((rec) => {
148
+ if (!rec || typeof rec !== 'object') return;
149
+ const key = rec.id ? String(rec.id) : rec.href ? String(rec.href) : '';
150
+ if (!key) return;
151
+ displayMap.set(key, rec);
152
+ });
153
+ const annotationsMap = new Map();
154
+ annotationsRecords.forEach((rec, idx) => {
155
+ if (!rec || typeof rec !== 'object') return;
156
+ const key = rec.id ? String(rec.id) : String(idx);
157
+ const text = rec.annotation ? String(rec.annotation) : rec.text ? String(rec.text) : '';
158
+ if (!key || !text) return;
159
+ annotationsMap.set(key, text);
160
+ });
161
+ return indexRecords.map((rec, idx) => {
162
+ const key = rec && rec.id ? String(rec.id) : String(idx);
163
+ const display = key ? displayMap.get(key) : null;
164
+ const merged = { ...(display || {}), ...(rec || {}) };
165
+ if (!merged.id && key) merged.id = key;
166
+ if (!merged.href && display && display.href) merged.href = String(display.href);
167
+ if (!Array.isArray(merged.metadata)) {
168
+ const meta = Array.isArray(rec && rec.metadata) ? rec.metadata : [];
169
+ merged.metadata = meta;
170
+ }
171
+ if (annotationsMap.has(key) && !merged.annotation)
172
+ merged.annotation = annotationsMap.get(key);
173
+ return merged;
174
+ });
93
175
  } catch (_) {
94
176
  return [];
95
177
  }
@@ -129,22 +211,43 @@ function renderList(list, records, groupOrder) {
129
211
  header.style.cssText = 'padding:6px 12px;font-weight:600;color:#374151';
130
212
  list.appendChild(header);
131
213
  const entries = groups.get(key) || [];
132
- entries.forEach((record, index) => {
133
- const item = document.createElement('div');
214
+ entries.forEach((record) => {
215
+ const href = withBase(String(record && record.href || ''));
216
+ const item = document.createElement('a');
134
217
  item.setAttribute('data-canopy-item', '');
218
+ item.href = href;
135
219
  item.tabIndex = 0;
136
- item.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;outline:none;';
220
+ item.className = 'canopy-card canopy-card--teaser';
221
+ item.style.cssText = 'display:flex;gap:12px;padding:8px 12px;text-decoration:none;color:#030712;border-radius:8px;align-items:center;outline:none;';
222
+
137
223
  const showThumb = String(record && record.type || '') === 'work' && record && record.thumbnail;
138
224
  if (showThumb) {
225
+ const media = document.createElement('div');
226
+ media.style.cssText = 'flex:0 0 48px;height:48px;border-radius:6px;overflow:hidden;background:#f1f5f9;display:flex;align-items:center;justify-content:center;';
139
227
  const img = document.createElement('img');
140
228
  img.src = record.thumbnail;
141
229
  img.alt = '';
142
- img.style.cssText = 'width:40px;height:40px;object-fit:cover;border-radius:4px';
143
- item.appendChild(img);
230
+ img.loading = 'lazy';
231
+ img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
232
+ media.appendChild(img);
233
+ item.appendChild(media);
234
+ }
235
+
236
+ const textWrap = document.createElement('div');
237
+ textWrap.style.cssText = 'display:flex;flex-direction:column;gap:2px;min-width:0;';
238
+ const title = document.createElement('span');
239
+ title.textContent = record.title || record.href || '';
240
+ title.style.cssText = 'font-weight:600;font-size:0.95rem;line-height:1.3;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
241
+ textWrap.appendChild(title);
242
+ const meta = Array.isArray(record && record.metadata) ? record.metadata : [];
243
+ if (meta.length) {
244
+ const metaLine = document.createElement('span');
245
+ metaLine.textContent = meta.slice(0, 2).join(' • ');
246
+ metaLine.style.cssText = 'font-size:0.8rem;color:#475569;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
247
+ textWrap.appendChild(metaLine);
144
248
  }
145
- const span = document.createElement('span');
146
- span.textContent = record.title || record.href || '';
147
- item.appendChild(span);
249
+ item.appendChild(textWrap);
250
+
148
251
  item.onmouseenter = () => { item.style.background = '#f8fafc'; };
149
252
  item.onmouseleave = () => { item.style.background = 'transparent'; };
150
253
  item.onfocus = () => {
@@ -152,9 +255,6 @@ function renderList(list, records, groupOrder) {
152
255
  try { item.scrollIntoView({ block: 'nearest' }); } catch (_) {}
153
256
  };
154
257
  item.onblur = () => { item.style.background = 'transparent'; };
155
- item.onclick = () => {
156
- try { window.location.href = withBase(String(record.href || '')); } catch (_) {}
157
- };
158
258
  list.appendChild(item);
159
259
  });
160
260
  });
@@ -278,8 +378,15 @@ async function attachSearchForm(host) {
278
378
  for (let i = 0; i < records.length; i += 1) {
279
379
  const record = records[i];
280
380
  const title = String(record && record.title || '');
281
- if (!title) continue;
282
- if (toLower(title).includes(q)) out.push(record);
381
+ const parts = [toLower(title)];
382
+ const metadata = Array.isArray(record && record.metadata) ? record.metadata : [];
383
+ for (const value of metadata) {
384
+ parts.push(toLower(value));
385
+ }
386
+ if (record && record.summary) parts.push(toLower(record.summary));
387
+ if (record && record.annotation) parts.push(toLower(record.annotation));
388
+ const haystack = parts.join(' ');
389
+ if (haystack.includes(q)) out.push(record);
283
390
  if (out.length >= maxResults) break;
284
391
  }
285
392
  render(out);
@@ -173,7 +173,7 @@ async function buildSearchPage() {
173
173
  }
174
174
  } catch (_) {}
175
175
  let html = htmlShell({ title: 'Search', body, cssHref: null, scriptHref: jsRel, headExtra });
176
- try { html = require('./common').applyBaseToHtml(html); } catch (_) {}
176
+ try { html = require('../common').applyBaseToHtml(html); } catch (_) {}
177
177
  await fsp.writeFile(outPath, html, 'utf8');
178
178
  console.log('Search: Built', path.relative(process.cwd(), outPath));
179
179
  } catch (e) {
@@ -185,14 +185,66 @@ async function buildSearchPage() {
185
185
  function toSafeString(val, defaultValue = '') {
186
186
  try { return String(val == null ? defaultValue : val); } catch (_) { return defaultValue; }
187
187
  }
188
- function sanitizeRecord(r) {
188
+
189
+ function sanitizeMetadataValues(list) {
190
+ const arr = Array.isArray(list) ? list : [];
191
+ const out = [];
192
+ const seen = new Set();
193
+ for (const val of arr) {
194
+ if (val && typeof val === 'object' && Array.isArray(val.values)) {
195
+ for (const v of val.values) {
196
+ const str = toSafeString(v, '').trim();
197
+ if (!str) continue;
198
+ const clipped = str.length > 500 ? str.slice(0, 500) + '…' : str;
199
+ if (seen.has(clipped)) continue;
200
+ seen.add(clipped);
201
+ out.push(clipped);
202
+ }
203
+ continue;
204
+ }
205
+ const str = toSafeString(val, '').trim();
206
+ if (!str) continue;
207
+ const clipped = str.length > 500 ? str.slice(0, 500) + '…' : str;
208
+ if (seen.has(clipped)) continue;
209
+ seen.add(clipped);
210
+ out.push(clipped);
211
+ }
212
+ return out;
213
+ }
214
+
215
+ function sanitizeRecordForIndex(r) {
189
216
  const title = toSafeString(r && r.title, '');
190
- const hrefRaw = toSafeString(r && r.href, '');
191
- const href = rootRelativeHref(hrefRaw);
192
217
  const type = toSafeString(r && r.type, 'page');
193
- const thumbnail = toSafeString(r && r.thumbnail, '');
194
218
  const safeTitle = title.length > 300 ? title.slice(0, 300) + '…' : title;
195
- const out = { title: safeTitle, href, type };
219
+ const out = { title: safeTitle, type };
220
+ const metadataSource =
221
+ (r && r.metadataValues) ||
222
+ (r && r.searchMetadataValues) ||
223
+ (r && r.search && r.search.metadata) ||
224
+ [];
225
+ const metadata = sanitizeMetadataValues(metadataSource);
226
+ if (metadata.length) out.metadata = metadata;
227
+ const summaryVal = toSafeString(
228
+ (r && r.summaryValue) ||
229
+ (r && r.searchSummary) ||
230
+ (r && r.search && r.search.summary),
231
+ ''
232
+ ).trim();
233
+ if (summaryVal) {
234
+ const clipped = summaryVal.length > 1000 ? summaryVal.slice(0, 1000) + '…' : summaryVal;
235
+ out.summary = clipped;
236
+ }
237
+ return out;
238
+ }
239
+
240
+ function sanitizeRecordForDisplay(r) {
241
+ const base = sanitizeRecordForIndex(r);
242
+ const out = { ...base };
243
+ if (out.metadata) delete out.metadata;
244
+ if (out.summary) out.summary = toSafeString(out.summary, '');
245
+ const hrefRaw = toSafeString(r && r.href, '');
246
+ out.href = rootRelativeHref(hrefRaw);
247
+ const thumbnail = toSafeString(r && r.thumbnail, '');
196
248
  if (thumbnail) out.thumbnail = thumbnail;
197
249
  // Preserve optional thumbnail dimensions for aspect ratio calculations in the UI
198
250
  try {
@@ -204,28 +256,76 @@ function sanitizeRecord(r) {
204
256
  return out;
205
257
  }
206
258
 
259
+ /**
260
+ * Write search datasets consumed by the runtime layers.
261
+ *
262
+ * Outputs:
263
+ * - search-index.json: compact payload (title/href/type/metadata + stable id) for FlexSearch
264
+ * - search-records.json: richer display data (thumbnail/dimensions + id) for UI rendering
265
+ */
207
266
  async function writeSearchIndex(records) {
208
267
  const apiDir = path.join(OUT_DIR, 'api');
209
268
  ensureDirSync(apiDir);
210
269
  const idxPath = path.join(apiDir, 'search-index.json');
211
270
  const list = Array.isArray(records) ? records : [];
212
- const safe = list.map(sanitizeRecord);
213
- const json = JSON.stringify(safe, null, 2);
214
- const approxBytes = Buffer.byteLength(json, 'utf8');
271
+ const indexRecords = list.map(sanitizeRecordForIndex);
272
+ const displayRecords = list.map(sanitizeRecordForDisplay);
273
+ for (let i = 0; i < indexRecords.length; i += 1) {
274
+ const id = String(i);
275
+ if (indexRecords[i]) indexRecords[i].id = id;
276
+ if (displayRecords[i]) displayRecords[i].id = id;
277
+ if (displayRecords[i] && !displayRecords[i].href) {
278
+ const original = list[i];
279
+ const href = original && original.href ? rootRelativeHref(String(original.href)) : '';
280
+ if (href) displayRecords[i].href = href;
281
+ }
282
+ }
283
+ const annotationsPath = path.join(apiDir, 'search-index-annotations.json');
284
+ const annotationRecords = [];
285
+ for (let i = 0; i < list.length; i += 1) {
286
+ const raw = list[i];
287
+ const annotationVal = toSafeString(
288
+ (raw && raw.annotationValue) ||
289
+ (raw && raw.searchAnnotation) ||
290
+ (raw && raw.search && raw.search.annotation),
291
+ ''
292
+ ).trim();
293
+ if (!annotationVal) continue;
294
+ annotationRecords.push({ id: String(i), annotation: annotationVal });
295
+ }
296
+
297
+ const indexJson = JSON.stringify(indexRecords);
298
+ const approxBytes = Buffer.byteLength(indexJson, 'utf8');
215
299
  if (approxBytes > 10 * 1024 * 1024) {
216
300
  console.warn('Search: index size is large (', Math.round(approxBytes / (1024 * 1024)), 'MB ). Consider narrowing sources.');
217
301
  }
218
- await fsp.writeFile(idxPath, json, 'utf8');
302
+ await fsp.writeFile(idxPath, indexJson, 'utf8');
303
+
304
+ const displayPath = path.join(apiDir, 'search-records.json');
305
+ const displayJson = JSON.stringify(displayRecords);
306
+ const displayBytes = Buffer.byteLength(displayJson, 'utf8');
307
+ await fsp.writeFile(displayPath, displayJson, 'utf8');
308
+ let annotationsBytes = 0;
309
+ if (annotationRecords.length) {
310
+ const annotationsJson = JSON.stringify(annotationRecords);
311
+ annotationsBytes = Buffer.byteLength(annotationsJson, 'utf8');
312
+ await fsp.writeFile(annotationsPath, annotationsJson, 'utf8');
313
+ } else {
314
+ try {
315
+ if (fs.existsSync(annotationsPath)) await fsp.unlink(annotationsPath);
316
+ } catch (_) {}
317
+ }
219
318
  // Also write a small metadata file with a stable version hash for cache-busting and IDB keying
220
319
  try {
221
- const version = crypto.createHash('sha256').update(json).digest('hex');
320
+ const version = crypto.createHash('sha256').update(indexJson).digest('hex');
222
321
  // Read optional search tabs order from canopy.yml
223
322
  let tabsOrder = [];
224
323
  try {
225
324
  const yaml = require('js-yaml');
226
- const cfgPath = require('./common').path.resolve(process.env.CANOPY_CONFIG || 'canopy.yml');
227
- if (require('./common').fs.existsSync(cfgPath)) {
228
- const raw = require('./common').fs.readFileSync(cfgPath, 'utf8');
325
+ const common = require('../common');
326
+ const cfgPath = common.path.resolve(process.env.CANOPY_CONFIG || 'canopy.yml');
327
+ if (common.fs.existsSync(cfgPath)) {
328
+ const raw = common.fs.readFileSync(cfgPath, 'utf8');
229
329
  const data = yaml.load(raw) || {};
230
330
  const searchCfg = data && data.search ? data.search : {};
231
331
  const tabs = searchCfg && searchCfg.tabs ? searchCfg.tabs : {};
@@ -235,17 +335,25 @@ async function writeSearchIndex(records) {
235
335
  } catch (_) {}
236
336
  const meta = {
237
337
  version,
238
- records: safe.length,
338
+ records: indexRecords.length,
239
339
  bytes: approxBytes,
240
340
  updatedAt: new Date().toISOString(),
241
341
  // Expose optional search config to the client runtime
242
- search: { tabs: { order: tabsOrder } },
342
+ search: {
343
+ tabs: { order: tabsOrder },
344
+ assets: {
345
+ display: { path: 'search-records.json', bytes: displayBytes },
346
+ ...(annotationRecords.length
347
+ ? { annotations: { path: 'search-index-annotations.json', bytes: annotationsBytes } }
348
+ : {}),
349
+ },
350
+ },
243
351
  };
244
352
  const metaPath = path.join(apiDir, 'index.json');
245
353
  await fsp.writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf8');
246
354
  try {
247
355
  const { logLine } = require('./log');
248
- logLine(`✓ Search index version ${version.slice(0, 8)} (${safe.length} records)`, 'cyan');
356
+ logLine(`✓ Search index version ${version.slice(0, 8)} (${indexRecords.length} records)`, 'cyan');
249
357
  } catch (_) {}
250
358
  // Propagate version into IIIF cache index for a single, shared build identifier
251
359
  try {
@@ -263,7 +371,11 @@ async function writeSearchIndex(records) {
263
371
 
264
372
  // Compatibility: keep ensureResultTemplate as a no-op builder (template unused by React search)
265
373
  async function ensureResultTemplate() {
266
- try { const { path } = require('./common'); const p = path.join(OUT_DIR, 'search-result.html'); await fsp.writeFile(p, '', 'utf8'); } catch (_) {}
374
+ try {
375
+ const { path } = require('../common');
376
+ const p = path.join(OUT_DIR, 'search-result.html');
377
+ await fsp.writeFile(p, '', 'utf8');
378
+ } catch (_) {}
267
379
  }
268
380
 
269
381
  module.exports = { ensureSearchRuntime, ensureResultTemplate, buildSearchPage, writeSearchIndex };