@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.
- package/lib/build/build.js +2 -0
- package/lib/build/dev.js +38 -22
- package/lib/build/iiif.js +359 -83
- package/lib/build/mdx.js +12 -2
- package/lib/build/pages.js +15 -1
- package/lib/build/styles.js +53 -1
- package/lib/common.js +28 -6
- package/lib/components/navigation.js +308 -0
- package/lib/page-context.js +14 -0
- package/lib/search/search-app.jsx +177 -25
- package/lib/search/search-form-runtime.js +126 -19
- package/lib/search/search.js +130 -18
- package/package.json +4 -1
- package/ui/dist/index.mjs +204 -101
- package/ui/dist/index.mjs.map +4 -4
- package/ui/dist/server.mjs +167 -59
- package/ui/dist/server.mjs.map +4 -4
- package/ui/styles/_variables.scss +1 -0
- package/ui/styles/base/_common.scss +27 -5
- package/ui/styles/base/_heading.scss +2 -4
- package/ui/styles/base/index.scss +1 -0
- package/ui/styles/components/_card.scss +47 -4
- package/ui/styles/components/_sub-navigation.scss +76 -0
- package/ui/styles/components/header/_header.scss +1 -4
- package/ui/styles/components/header/_logo.scss +33 -10
- package/ui/styles/components/index.scss +1 -0
- package/ui/styles/components/search/_filters.scss +5 -7
- package/ui/styles/components/search/_form.scss +55 -17
- package/ui/styles/components/search/_results.scss +49 -14
- package/ui/styles/index.css +344 -56
- package/ui/styles/index.scss +2 -4
- package/ui/tailwind-canopy-iiif-plugin.js +10 -2
- package/ui/tailwind-canopy-iiif-preset.js +21 -19
- package/ui/theme.js +303 -0
- package/ui/styles/variables.emit.scss +0 -72
- 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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
408
|
-
|
|
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
|
|
482
|
+
const indexRecords = Array.isArray(parsed)
|
|
420
483
|
? parsed
|
|
421
484
|
: parsed && parsed.records
|
|
422
485
|
? parsed.records
|
|
423
486
|
: [];
|
|
424
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
133
|
-
const
|
|
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.
|
|
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.
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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);
|
package/lib/search/search.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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,
|
|
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
|
|
213
|
-
const
|
|
214
|
-
|
|
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,
|
|
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(
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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:
|
|
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: {
|
|
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)} (${
|
|
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 {
|
|
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 };
|