@canopy-iiif/app 0.8.4 → 0.8.6

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/mdx.js CHANGED
@@ -358,6 +358,7 @@ async function ensureClientRuntime() {
358
358
  const outFile = path.join(scriptsDir, "canopy-viewer.js");
359
359
  const entry = `
360
360
  import CloverViewer from '@samvera/clover-iiif/viewer';
361
+ import CloverScroll from '@samvera/clover-iiif/scroll';
361
362
 
362
363
  function ready(fn) {
363
364
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
@@ -373,22 +374,29 @@ async function ensureClientRuntime() {
373
374
  } catch (_) { return {}; }
374
375
  }
375
376
 
376
- ready(function() {
377
+ function mountAll(selector, Component) {
377
378
  try {
378
- const nodes = document.querySelectorAll('[data-canopy-viewer]');
379
- if (!nodes || !nodes.length) return;
379
+ const nodes = document.querySelectorAll(selector);
380
+ if (!nodes || !nodes.length || !Component) return;
381
+ const React = (window && window.React) || null;
382
+ const ReactDOMClient = (window && window.ReactDOMClient) || null;
383
+ const createRoot = ReactDOMClient && ReactDOMClient.createRoot;
384
+ if (!React || !createRoot) return;
380
385
  for (const el of nodes) {
381
386
  try {
387
+ if (el.__canopyHydrated) continue;
382
388
  const props = parseProps(el);
383
- const React = (window && window.React) || null;
384
- const ReactDOMClient = (window && window.ReactDOMClient) || null;
385
- const createRoot = ReactDOMClient && ReactDOMClient.createRoot;
386
- if (!React || !createRoot) continue;
387
389
  const root = createRoot(el);
388
- root.render(React.createElement(CloverViewer, props));
390
+ root.render(React.createElement(Component, props));
391
+ el.__canopyHydrated = true;
389
392
  } catch (_) { /* skip */ }
390
393
  }
391
394
  } catch (_) { /* no-op */ }
395
+ }
396
+
397
+ ready(function() {
398
+ mountAll('[data-canopy-viewer]', CloverViewer);
399
+ mountAll('[data-canopy-scroll]', CloverScroll);
392
400
  });
393
401
  `;
394
402
  const reactShim = `
@@ -59,7 +59,8 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
59
59
  mergedProps.navigation = navData;
60
60
  }
61
61
  const { body, head } = await mdx.compileMdxFile(filePath, outPath, null, mergedProps);
62
- const needsHydrateViewer = body.includes('data-canopy-viewer');
62
+ const needsHydrateViewer =
63
+ body.includes('data-canopy-viewer') || body.includes('data-canopy-scroll');
63
64
  const needsHydrateSlider = body.includes('data-canopy-slider');
64
65
  const needsSearchForm = true; // search form runtime is global
65
66
  const needsFacets = body.includes('data-canopy-related-items');
@@ -97,6 +97,52 @@ async function ensureStyles() {
97
97
  }
98
98
  }
99
99
 
100
+ function injectThemeTokens(targetPath) {
101
+ try {
102
+ const { loadCanopyTheme } = require("@canopy-iiif/app/ui/theme");
103
+ const theme = loadCanopyTheme();
104
+ const themeCss = theme && theme.css ? theme.css.trim() : "";
105
+ if (!themeCss) return;
106
+
107
+ let existing = "";
108
+ try {
109
+ existing = fs.readFileSync(targetPath, "utf8");
110
+ } catch (_) {}
111
+
112
+ const marker = "/* canopy-theme */";
113
+ const markerEnd = "/* canopy-theme:end */";
114
+ const markerRegex = new RegExp(`${marker}[\\s\\S]*?${markerEnd}\\n?`, "g");
115
+ const sanitized = existing.replace(markerRegex, "");
116
+
117
+ const layerRegex = /@layer properties\{([\s\S]*?)\}(?=@|$)/;
118
+ const match = layerRegex.exec(sanitized);
119
+ let before = sanitized;
120
+ let after = "";
121
+ let customRulesBlock = "";
122
+
123
+ if (match) {
124
+ before = sanitized.slice(0, match.index);
125
+ after = sanitized.slice(match.index + match[0].length);
126
+ const layerBody = match[1] || "";
127
+ const boundaryMatch = /}\s*:/.exec(layerBody);
128
+ if (boundaryMatch) {
129
+ const start = boundaryMatch.index + boundaryMatch[0].length - 1;
130
+ const customSegment = layerBody.slice(start).trim();
131
+ if (customSegment) {
132
+ const normalized = customSegment.endsWith("}")
133
+ ? customSegment
134
+ : `${customSegment}}`;
135
+ customRulesBlock = `@layer properties {\n ${normalized}\n}\n`;
136
+ }
137
+ }
138
+ }
139
+
140
+ const themeBlock = `${marker}\n${themeCss}\n${markerEnd}\n`;
141
+ const next = `${before}${themeBlock}${customRulesBlock}${after}`;
142
+ fs.writeFileSync(targetPath, next, "utf8");
143
+ } catch (_) {}
144
+ }
145
+
100
146
  if (configPath && (inputCss || generatedInput)) {
101
147
  const ok = buildTailwindCli({
102
148
  input: inputCss || generatedInput,
@@ -104,7 +150,10 @@ async function ensureStyles() {
104
150
  config: configPath,
105
151
  minify: true,
106
152
  });
107
- if (ok) return; // Tailwind compiled CSS
153
+ if (ok) {
154
+ injectThemeTokens(dest);
155
+ return; // Tailwind compiled CSS
156
+ }
108
157
  }
109
158
 
110
159
  function isTailwindSource(p) {
@@ -118,18 +167,21 @@ async function ensureStyles() {
118
167
  if (fs.existsSync(customAppCss)) {
119
168
  if (!isTailwindSource(customAppCss)) {
120
169
  await fsp.copyFile(customAppCss, dest);
170
+ injectThemeTokens(dest);
121
171
  return;
122
172
  }
123
173
  }
124
174
  if (fs.existsSync(customContentCss)) {
125
175
  if (!isTailwindSource(customContentCss)) {
126
176
  await fsp.copyFile(customContentCss, dest);
177
+ injectThemeTokens(dest);
127
178
  return;
128
179
  }
129
180
  }
130
181
 
131
182
  const css = `:root{--max-w:760px;--muted:#6b7280}*{box-sizing:border-box}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial,sans-serif;max-width:var(--max-w);margin:2rem auto;padding:0 1rem;line-height:1.6}a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}.site-header,.site-footer{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:1rem 0;border-bottom:1px solid #e5e7eb}.site-footer{border-bottom:0;border-top:1px solid #e5e7eb;color:var(--muted)}.brand{font-weight:600}.content pre{background:#f6f8fa;padding:1rem;overflow:auto}.content code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;background:#f6f8fa;padding:.1rem .3rem;border-radius:4px}.tabs{display:flex;gap:.5rem;align-items:center;border-bottom:1px solid #e5e7eb;margin:.5rem 0}.tab{background:none;border:0;color:#374151;padding:.25rem .5rem;border-radius:.375rem;cursor:pointer}.tab:hover{color:#111827}.tab-active{color:#2563eb;border:1px solid #e5e7eb;border-bottom:0;background:#fff}.masonry{column-gap:1rem;column-count:1}@media(min-width:768px){.masonry{column-count:2}}@media(min-width:1024px){.masonry{column-count:3}}.masonry>*{break-inside:avoid;margin-bottom:1rem;display:block}[data-grid-variant=masonry]{column-gap:var(--grid-gap,1rem);column-count:var(--cols-base,1)}@media(min-width:768px){[data-grid-variant=masonry]{column-count:var(--cols-md,2)}}@media(min-width:1024px){[data-grid-variant=masonry]{column-count:var(--cols-lg,3)}}[data-grid-variant=masonry]>*{break-inside:avoid;margin-bottom:var(--grid-gap,1rem);display:block}[data-grid-variant=grid]{display:grid;grid-template-columns:repeat(var(--cols-base,1),minmax(0,1fr));gap:var(--grid-gap,1rem)}@media(min-width:768px){[data-grid-variant=grid]{grid-template-columns:repeat(var(--cols-md,2),minmax(0,1fr))}}@media(min-width:1024px){[data-grid-variant=grid]{grid-template-columns:repeat(var(--cols-lg,3),minmax(0,1fr))}}`;
132
183
  await fsp.writeFile(dest, css, "utf8");
184
+ injectThemeTokens(dest);
133
185
  }
134
186
 
135
187
  module.exports = { ensureStyles };
package/lib/common.js CHANGED
@@ -8,6 +8,23 @@ const CACHE_DIR = path.resolve('.cache/mdx');
8
8
  const ASSETS_DIR = path.resolve('assets');
9
9
 
10
10
  const BASE_PATH = String(process.env.CANOPY_BASE_PATH || '').replace(/\/$/, '');
11
+ let cachedAppearance = null;
12
+
13
+ function resolveThemeAppearance() {
14
+ if (cachedAppearance) return cachedAppearance;
15
+ cachedAppearance = 'light';
16
+ try {
17
+ const { loadCanopyTheme } = require('@canopy-iiif/app/ui/theme');
18
+ if (typeof loadCanopyTheme === 'function') {
19
+ const theme = loadCanopyTheme();
20
+ const appearance = theme && theme.appearance ? String(theme.appearance) : '';
21
+ if (appearance.toLowerCase() === 'dark') {
22
+ cachedAppearance = 'dark';
23
+ }
24
+ }
25
+ } catch (_) {}
26
+ return cachedAppearance;
27
+ }
11
28
 
12
29
  function readYamlConfigBaseUrl() {
13
30
  try {
@@ -51,7 +68,9 @@ function htmlShell({ title, body, cssHref, scriptHref, headExtra }) {
51
68
  const scriptTag = scriptHref ? `<script defer src="${scriptHref}"></script>` : '';
52
69
  const extra = headExtra ? String(headExtra) : '';
53
70
  const cssTag = cssHref ? `<link rel="stylesheet" href="${cssHref}">` : '';
54
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${title}</title>${extra}${cssTag}${scriptTag}</head><body>${body}</body></html>`;
71
+ const appearance = resolveThemeAppearance();
72
+ const htmlClass = appearance === 'dark' ? ' class="dark"' : '';
73
+ return `<!doctype html><html lang="en"${htmlClass}><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${title}</title>${extra}${cssTag}${scriptTag}</head><body>${body}</body></html>`;
55
74
  }
56
75
 
57
76
  function withBase(href) {
@@ -111,16 +130,19 @@ function absoluteUrl(p) {
111
130
  }
112
131
  }
113
132
 
114
- // Apply BASE_PATH to any absolute href/src attributes found in an HTML string.
133
+ // Apply BASE_PATH to key URL-bearing attributes (href/src/action/formaction) in an HTML string.
115
134
  function applyBaseToHtml(html) {
116
135
  if (!BASE_PATH) return html;
117
136
  try {
118
137
  const out = String(html || '');
119
- const normalizedBase = BASE_PATH.startsWith('/')
120
- ? BASE_PATH.replace(/\/$/, '')
121
- : `/${BASE_PATH.replace(/\/$/, '')}`;
138
+ const baseRaw = BASE_PATH.startsWith('/') ? BASE_PATH : `/${BASE_PATH}`;
139
+ const normalizedBase = baseRaw.replace(/\/$/, '');
122
140
  if (!normalizedBase || normalizedBase === '/') return out;
123
- const pattern = /(href|src)=(['"])(\/(?!\/)[^'"\s]*)\2/g;
141
+
142
+ const attrPattern = '(?:href|src|action|formaction)';
143
+ const pathPattern = "\\/(?!\\/)[^'\"\\s<]*";
144
+ const pattern = new RegExp(`(${attrPattern})=(["'])((${pathPattern}))\\2`, 'g');
145
+
124
146
  return out.replace(pattern, (match, attr, quote, path) => {
125
147
  if (path === normalizedBase || path.startsWith(`${normalizedBase}/`)) {
126
148
  return match;
@@ -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);