@canopy-iiif/app 0.10.19 → 0.10.21

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
@@ -247,6 +247,21 @@ function extractPlainText(mdxSource) {
247
247
  return content;
248
248
  }
249
249
 
250
+ function extractMarkdownSummary(mdxSource) {
251
+ let { content } = parseFrontmatter(String(mdxSource || ''));
252
+ if (!content) return '';
253
+ content = content.replace(/^import[^\n]+$/gm, ' ');
254
+ content = content.replace(/^export[^\n]+$/gm, ' ');
255
+ content = content.replace(/```[\s\S]*?```/g, ' ');
256
+ content = content.replace(/<[A-Za-z][^>]*?>[\s\S]*?<\/[A-Za-z][^>]*?>/g, ' ');
257
+ content = content.replace(/<[A-Za-z][^>]*?\/>/g, ' ');
258
+ content = content.replace(/\{\/[A-Za-z0-9_.-]+\}/g, ' ');
259
+ content = content.replace(/\{[^{}]*\}/g, ' ');
260
+ content = content.replace(/^#{1,6}\s+.*$/gm, ' ');
261
+ content = content.replace(/\s+/g, ' ').trim();
262
+ return content;
263
+ }
264
+
250
265
  function extractTitle(mdxSource) {
251
266
  const { data, content } = parseFrontmatter(String(mdxSource || ""));
252
267
  if (data && typeof data.title === "string" && data.title.trim()) {
@@ -1006,6 +1021,7 @@ module.exports = {
1006
1021
  extractTitle,
1007
1022
  extractHeadings,
1008
1023
  extractPlainText,
1024
+ extractMarkdownSummary,
1009
1025
  isReservedFile,
1010
1026
  parseFrontmatter,
1011
1027
  compileMdxFile,
@@ -8,12 +8,17 @@ function pagesToRecords(pageRecords) {
8
8
  .filter((p) => p && p.href && p.searchInclude)
9
9
  .map((p) => {
10
10
  const summary = typeof p.searchSummary === 'string' ? p.searchSummary.trim() : '';
11
+ const summaryMarkdown =
12
+ typeof p.searchSummaryMarkdown === 'string'
13
+ ? p.searchSummaryMarkdown.trim()
14
+ : '';
11
15
  const record = {
12
16
  title: p.title || p.href,
13
17
  href: rootRelativeHref(p.href),
14
18
  type: p.searchType || 'page',
15
19
  };
16
20
  if (summary) record.summaryValue = summary;
21
+ if (summaryMarkdown) record.summaryMarkdown = summaryMarkdown;
17
22
  return record;
18
23
  });
19
24
  }
@@ -210,6 +210,7 @@ async function collectMdxPageRecords() {
210
210
  if (base !== 'sitemap.mdx') {
211
211
  const href = rootRelativeHref(rel.split(path.sep).join('/'));
212
212
  const plainText = mdx.extractPlainText(src);
213
+ const markdownSummary = mdx.extractMarkdownSummary(src);
213
214
  const summary = plainText || '';
214
215
  const underSearch = /^search\//i.test(href) || href.toLowerCase() === 'search.html';
215
216
  let include = !underSearch;
@@ -234,6 +235,7 @@ async function collectMdxPageRecords() {
234
235
  searchInclude: include && !!trimmedType,
235
236
  searchType: trimmedType || undefined,
236
237
  searchSummary: summary,
238
+ searchSummaryMarkdown: markdownSummary,
237
239
  });
238
240
  }
239
241
  }
@@ -5,6 +5,7 @@ import {
5
5
  SearchTabsUI,
6
6
  SearchFiltersDialog,
7
7
  } from "@canopy-iiif/app/ui";
8
+ import resultTemplates from "__CANOPY_SEARCH_RESULT_TEMPLATES__";
8
9
 
9
10
  function readBasePath() {
10
11
  const normalize = (val) => {
@@ -160,6 +161,7 @@ function createSearchStore() {
160
161
  filters: {},
161
162
  activeFilterCount: 0,
162
163
  annotationsEnabled: false,
164
+ resultSettings: {},
163
165
  };
164
166
  const listeners = new Set();
165
167
  function notify() {
@@ -447,24 +449,65 @@ function createSearchStore() {
447
449
  let version = "";
448
450
  let tabsOrder = [];
449
451
  let annotationsAssetPath = "";
452
+ let resultsOrder = [];
453
+ let resultLayoutMap = {};
450
454
  try {
451
455
  const meta = await fetch("./api/index.json")
452
456
  .then((r) => (r && r.ok ? r.json() : null))
453
457
  .catch(() => null);
454
458
  if (meta && typeof meta.version === "string") version = meta.version;
459
+ const searchMeta = meta && meta.search ? meta.search : {};
455
460
  const ord =
456
- meta &&
457
- meta.search &&
458
- meta.search.tabs &&
459
- Array.isArray(meta.search.tabs.order)
460
- ? meta.search.tabs.order
461
+ searchMeta &&
462
+ searchMeta.tabs &&
463
+ Array.isArray(searchMeta.tabs.order)
464
+ ? searchMeta.tabs.order
461
465
  : [];
462
- tabsOrder = ord.map((s) => String(s)).filter(Boolean);
466
+ tabsOrder = ord
467
+ .map((s) => String(s).trim().toLowerCase())
468
+ .filter(Boolean);
469
+ const resultsMeta =
470
+ searchMeta && searchMeta.results ? searchMeta.results : null;
471
+ if (resultsMeta) {
472
+ const rawOrder = Array.isArray(resultsMeta.order)
473
+ ? resultsMeta.order
474
+ : [];
475
+ const rawSettings =
476
+ resultsMeta.settings && typeof resultsMeta.settings === "object"
477
+ ? resultsMeta.settings
478
+ : {};
479
+ const normalizedOrder = [];
480
+ const normalizedMap = {};
481
+ const seenTypes = new Set();
482
+ const resolveEntry = (value) => {
483
+ const type = String(value || "")
484
+ .trim()
485
+ .toLowerCase();
486
+ if (!type || seenTypes.has(type)) return;
487
+ const cfg = rawSettings[type] || rawSettings[value] || {};
488
+ const layout =
489
+ String((cfg && cfg.layout) || "").toLowerCase() === "grid"
490
+ ? "grid"
491
+ : "list";
492
+ const result =
493
+ String((cfg && cfg.result) || "").toLowerCase() === "figure"
494
+ ? "figure"
495
+ : "article";
496
+ normalizedMap[type] = { layout, result };
497
+ normalizedOrder.push(type);
498
+ seenTypes.add(type);
499
+ };
500
+ rawOrder.forEach(resolveEntry);
501
+ Object.keys(rawSettings || {}).forEach(resolveEntry);
502
+ if (normalizedOrder.length) {
503
+ resultsOrder = normalizedOrder;
504
+ resultLayoutMap = normalizedMap;
505
+ }
506
+ }
463
507
  const annotationsAsset =
464
- meta &&
465
- meta.search &&
466
- meta.search.assets &&
467
- meta.search.assets.annotations;
508
+ searchMeta &&
509
+ searchMeta.assets &&
510
+ searchMeta.assets.annotations;
468
511
  if (annotationsAsset && annotationsAsset.path) {
469
512
  annotationsAssetPath = String(annotationsAsset.path);
470
513
  }
@@ -661,7 +704,9 @@ function createSearchStore() {
661
704
  if (annotationsRecords.length && !ts.includes("annotation"))
662
705
  ts.push("annotation");
663
706
  const order =
664
- Array.isArray(tabsOrder) && tabsOrder.length
707
+ Array.isArray(resultsOrder) && resultsOrder.length
708
+ ? resultsOrder
709
+ : Array.isArray(tabsOrder) && tabsOrder.length
665
710
  ? tabsOrder
666
711
  : ["work", "docs", "page"];
667
712
  ts.sort((a, b) => {
@@ -693,6 +738,7 @@ function createSearchStore() {
693
738
  types: ts,
694
739
  loading: false,
695
740
  annotationsEnabled: annotationsRecords.length > 0,
741
+ resultSettings: resultLayoutMap,
696
742
  });
697
743
  await hydrateFacets();
698
744
  } catch (_) {
@@ -754,15 +800,30 @@ function useStore() {
754
800
  }
755
801
 
756
802
  function ResultsMount(props = {}) {
757
- const {results, type, loading, query} = useStore();
803
+ const {results, type, loading, query, resultSettings} = useStore();
758
804
  if (loading) return <div className="text-slate-600">Loading…</div>;
759
- const layout = (props && props.layout) || "grid";
805
+ const normalizedType = String(type || "").trim().toLowerCase();
806
+ const hasOverrides =
807
+ resultSettings && Object.keys(resultSettings || {}).length > 0;
808
+ const typeSettings =
809
+ (normalizedType && resultSettings && resultSettings[normalizedType]) || null;
810
+ let layout = (props && props.layout) || "grid";
811
+ let variant = "auto";
812
+ if (typeSettings) {
813
+ layout = typeSettings.layout || "list";
814
+ variant = typeSettings.result || "article";
815
+ } else if (hasOverrides) {
816
+ layout = "list";
817
+ variant = "article";
818
+ }
760
819
  return (
761
820
  <SearchResultsUI
762
821
  results={results}
763
822
  type={type}
764
823
  layout={layout}
765
824
  query={query}
825
+ templates={resultTemplates}
826
+ variant={variant}
766
827
  />
767
828
  );
768
829
  }
@@ -2,18 +2,94 @@ const React = require('react');
2
2
  const ReactDOMServer = require('react-dom/server');
3
3
  const crypto = require('crypto');
4
4
  const {
5
+ fs,
6
+ fsp,
5
7
  path,
6
- withBase,
8
+ CONTENT_DIR,
7
9
  rootRelativeHref,
8
10
  ensureDirSync,
9
11
  OUT_DIR,
10
12
  htmlShell,
11
- fsp,
12
13
  canopyBodyClassForType,
13
14
  } = require('../common');
14
15
 
16
+ const SEARCH_TEMPLATES_ALIAS = '__CANOPY_SEARCH_RESULT_TEMPLATES__';
17
+ const SEARCH_TEMPLATES_CACHE_DIR = path.resolve('.cache/search');
18
+ const SEARCH_TEMPLATE_FILES = [
19
+ { key: 'figure', filename: '_result-figure.mdx' },
20
+ { key: 'article', filename: '_result-article.mdx' },
21
+ ];
22
+
23
+ function resolveSearchTemplatePath(filename) {
24
+ try {
25
+ const candidate = path.join(CONTENT_DIR, 'search', filename);
26
+ if (fs.existsSync(candidate)) return candidate;
27
+ } catch (_) {}
28
+ return null;
29
+ }
30
+
31
+ async function buildSearchTemplatesModule() {
32
+ ensureDirSync(SEARCH_TEMPLATES_CACHE_DIR);
33
+ const outPath = path.join(SEARCH_TEMPLATES_CACHE_DIR, 'result-templates-entry.js');
34
+ const fallback = resolveSearchTemplatePath('_result.mdx');
35
+ const lines = [
36
+ '// Auto-generated search result templates map',
37
+ ];
38
+ for (const spec of SEARCH_TEMPLATE_FILES) {
39
+ const templateName = `${spec.key}Template`;
40
+ const specific = resolveSearchTemplatePath(spec.filename);
41
+ const resolved = specific || fallback;
42
+ if (resolved) {
43
+ lines.push(`import ${templateName} from ${JSON.stringify(resolved)};`);
44
+ } else {
45
+ lines.push(`const ${templateName} = null;`);
46
+ }
47
+ lines.push(`export const ${spec.key} = ${templateName};`);
48
+ }
49
+ lines.push(`export default { ${SEARCH_TEMPLATE_FILES.map((spec) => spec.key).join(', ')} };`);
50
+ await fsp.writeFile(outPath, lines.join('\n'), 'utf8');
51
+ return outPath;
52
+ }
53
+
54
+ function createResultTemplatesAliasPlugin(entryPath) {
55
+ return {
56
+ name: 'canopy-search-result-templates',
57
+ setup(build) {
58
+ build.onResolve({ filter: new RegExp(`^${SEARCH_TEMPLATES_ALIAS.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) }, () => ({
59
+ path: entryPath,
60
+ }));
61
+ },
62
+ };
63
+ }
64
+
65
+ function createSearchMdxPlugin() {
66
+ return {
67
+ name: 'canopy-search-mdx',
68
+ setup(build) {
69
+ build.onResolve({ filter: /\.mdx$/ }, (args) => ({
70
+ path: path.resolve(args.resolveDir, args.path),
71
+ namespace: 'canopy-search-mdx',
72
+ }));
73
+ build.onLoad({ filter: /.*/, namespace: 'canopy-search-mdx' }, async (args) => {
74
+ const { compile } = await import('@mdx-js/mdx');
75
+ const source = await fsp.readFile(args.path, 'utf8');
76
+ const compiled = await compile(source, {
77
+ jsx: true,
78
+ development: false,
79
+ providerImportSource: '@mdx-js/react',
80
+ format: 'mdx',
81
+ });
82
+ return {
83
+ contents: String(compiled),
84
+ loader: 'jsx',
85
+ resolveDir: path.dirname(args.path),
86
+ };
87
+ });
88
+ },
89
+ };
90
+ }
91
+
15
92
  async function ensureSearchRuntime() {
16
- const { fs, path } = require('../common');
17
93
  ensureDirSync(OUT_DIR);
18
94
  let esbuild = null;
19
95
  try { esbuild = require('../../ui/node_modules/esbuild'); } catch (_) { try { esbuild = require('esbuild'); } catch (_) {} }
@@ -22,6 +98,9 @@ async function ensureSearchRuntime() {
22
98
  const scriptsDir = path.join(OUT_DIR, 'scripts');
23
99
  ensureDirSync(scriptsDir);
24
100
  const outFile = path.join(scriptsDir, 'search.js');
101
+ const templatesEntry = await buildSearchTemplatesModule();
102
+ const templatesPlugin = createResultTemplatesAliasPlugin(templatesEntry);
103
+ const mdxPlugin = createSearchMdxPlugin();
25
104
  // Ensure a global React shim is available to reduce search.js size
26
105
  try {
27
106
  const scriptsDir = path.join(OUT_DIR, 'scripts');
@@ -93,6 +172,30 @@ async function ensureSearchRuntime() {
93
172
  ].join(''),
94
173
  loader: 'js',
95
174
  }));
175
+ build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ path: 'react/jsx-runtime', namespace: 'react-jsx-shim' }));
176
+ build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ path: 'react/jsx-dev-runtime', namespace: 'react-jsx-shim' }));
177
+ build.onLoad({ filter: /.*/, namespace: 'react-jsx-shim' }, () => ({
178
+ contents: [
179
+ "const R = (typeof window!=='undefined' && window.React) || {};\n",
180
+ "const Fragment = R.Fragment || 'div';\n",
181
+ "const createElement = typeof R.createElement === 'function' ? R.createElement.bind(R) : null;\n",
182
+ "function normalizeProps(props, key){\n",
183
+ " if (key !== undefined && key !== null) {\n",
184
+ " props = props && typeof props === 'object' ? { ...props, key } : { key };\n",
185
+ " }\n",
186
+ " return props || {};\n",
187
+ "}\n",
188
+ "function jsx(type, props, key){\n",
189
+ " if (!createElement) return null;\n",
190
+ " return createElement(type, normalizeProps(props, key));\n",
191
+ "}\n",
192
+ "const jsxs = jsx;\n",
193
+ "const jsxDEV = jsx;\n",
194
+ "export { jsx, jsxs, jsxDEV, Fragment };\n",
195
+ "export default { jsx, jsxs, jsxDEV, Fragment };\n",
196
+ ].join(''),
197
+ loader: 'js',
198
+ }));
96
199
  build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ path: 'react-dom/client', namespace: 'rdc-shim' }));
97
200
  build.onLoad({ filter: /.*/, namespace: 'rdc-shim' }, () => ({
98
201
  contents: [
@@ -124,7 +227,7 @@ async function ensureSearchRuntime() {
124
227
  sourcemap: true,
125
228
  target: ['es2018'],
126
229
  logLevel: 'silent',
127
- plugins: [shimReactPlugin],
230
+ plugins: [shimReactPlugin, templatesPlugin, mdxPlugin],
128
231
  external: ['@samvera/clover-iiif/*'],
129
232
  };
130
233
  if (!entryExists) throw new Error('Search runtime entry missing: ' + entry);
@@ -251,6 +354,15 @@ function sanitizeRecordForDisplay(r) {
251
354
  const out = { ...base };
252
355
  if (out.metadata) delete out.metadata;
253
356
  if (out.summary) out.summary = toSafeString(out.summary, '');
357
+ const summaryMarkdown = toSafeString(
358
+ (r && r.summaryMarkdown) ||
359
+ (r && r.searchSummaryMarkdown) ||
360
+ (r && r.search && r.search.summaryMarkdown),
361
+ ''
362
+ ).trim();
363
+ if (summaryMarkdown) {
364
+ out.summaryMarkdown = summaryMarkdown;
365
+ }
254
366
  const hrefRaw = toSafeString(r && r.href, '');
255
367
  out.href = rootRelativeHref(hrefRaw);
256
368
  const thumbnail = toSafeString(r && r.thumbnail, '');
@@ -327,8 +439,9 @@ async function writeSearchIndex(records) {
327
439
  // Also write a small metadata file with a stable version hash for cache-busting and IDB keying
328
440
  try {
329
441
  const version = crypto.createHash('sha256').update(indexJson).digest('hex');
330
- // Read optional search tabs order from canopy.yml
442
+ // Read optional search configuration from canopy.yml
331
443
  let tabsOrder = [];
444
+ let resultsConfigEntries = [];
332
445
  try {
333
446
  const yaml = require('js-yaml');
334
447
  const common = require('../common');
@@ -339,24 +452,60 @@ async function writeSearchIndex(records) {
339
452
  const searchCfg = data && data.search ? data.search : {};
340
453
  const tabs = searchCfg && searchCfg.tabs ? searchCfg.tabs : {};
341
454
  const order = Array.isArray(tabs && tabs.order) ? tabs.order : [];
342
- tabsOrder = order.map((s) => String(s)).filter(Boolean);
455
+ tabsOrder = order
456
+ .map((s) => String(s).trim().toLowerCase())
457
+ .filter(Boolean);
458
+ const resultsCfg =
459
+ searchCfg && searchCfg.results && typeof searchCfg.results === 'object'
460
+ ? searchCfg.results
461
+ : null;
462
+ if (resultsCfg) {
463
+ const entries = [];
464
+ Object.keys(resultsCfg).forEach((key) => {
465
+ if (!key) return;
466
+ const type = String(key).trim().toLowerCase();
467
+ if (!type) return;
468
+ if (entries.find((entry) => entry.type === type)) return;
469
+ const cfg = resultsCfg[key] && typeof resultsCfg[key] === 'object' ? resultsCfg[key] : {};
470
+ const layoutRaw = cfg && cfg.layout ? String(cfg.layout).toLowerCase() : '';
471
+ const resultRaw = cfg && cfg.result ? String(cfg.result).toLowerCase() : '';
472
+ const layout = layoutRaw === 'grid' ? 'grid' : 'list';
473
+ const result = resultRaw === 'figure' ? 'figure' : 'article';
474
+ entries.push({ type, layout, result });
475
+ });
476
+ if (entries.length) {
477
+ resultsConfigEntries = entries;
478
+ tabsOrder = entries.map((entry) => entry.type);
479
+ }
480
+ }
343
481
  }
344
482
  } catch (_) {}
483
+ const searchMeta = {
484
+ tabs: { order: tabsOrder },
485
+ assets: {
486
+ display: { path: 'search-records.json', bytes: displayBytes },
487
+ ...(annotationRecords.length
488
+ ? { annotations: { path: 'search-index-annotations.json', bytes: annotationsBytes } }
489
+ : {}),
490
+ },
491
+ };
492
+ if (resultsConfigEntries.length) {
493
+ const resultSettings = resultsConfigEntries.reduce((acc, entry) => {
494
+ acc[entry.type] = { layout: entry.layout, result: entry.result };
495
+ return acc;
496
+ }, {});
497
+ searchMeta.results = {
498
+ order: resultsConfigEntries.map((entry) => entry.type),
499
+ settings: resultSettings,
500
+ };
501
+ }
345
502
  const meta = {
346
503
  version,
347
504
  records: indexRecords.length,
348
505
  bytes: approxBytes,
349
506
  updatedAt: new Date().toISOString(),
350
507
  // Expose optional search config to the client runtime
351
- search: {
352
- tabs: { order: tabsOrder },
353
- assets: {
354
- display: { path: 'search-records.json', bytes: displayBytes },
355
- ...(annotationRecords.length
356
- ? { annotations: { path: 'search-index-annotations.json', bytes: annotationsBytes } }
357
- : {}),
358
- },
359
- },
508
+ search: searchMeta,
360
509
  };
361
510
  const metaPath = path.join(apiDir, 'index.json');
362
511
  await fsp.writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "0.10.19",
3
+ "version": "0.10.21",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",