@canopy-iiif/app 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ const { fs, path, OUT_DIR } = require('../common');
2
+ const { logLine } = require('./log');
3
+
4
+ function readFileSafe(p) {
5
+ try { return fs.readFileSync(p, 'utf8'); } catch (_) { return ''; }
6
+ }
7
+
8
+ function hasHtmlFiles(dir) {
9
+ let count = 0;
10
+ if (!fs.existsSync(dir)) return 0;
11
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
12
+ for (const e of entries) {
13
+ const p = path.join(dir, e.name);
14
+ if (e.isDirectory()) count += hasHtmlFiles(p);
15
+ else if (e.isFile() && p.toLowerCase().endsWith('.html')) count++;
16
+ }
17
+ return count;
18
+ }
19
+
20
+ function verifyHomepageElements(outDir) {
21
+ const idx = path.join(outDir, 'index.html');
22
+ const html = readFileSafe(idx);
23
+ const okHero = /class=\"[^\"]*canopy-hero/.test(html) || /<div[^>]+canopy-hero/.test(html);
24
+ const okCommand = /data-canopy-command=/.test(html);
25
+ const okCommandTrigger = /data-canopy-command-trigger/.test(html);
26
+ const okCommandScriptRef = /<script[^>]+canopy-command\.js/.test(html);
27
+ return { okHero, okCommand, okCommandTrigger, okCommandScriptRef, htmlPath: idx };
28
+ }
29
+
30
+ function verifyBuildOutput(options = {}) {
31
+ const outDir = path.resolve(options.outDir || OUT_DIR);
32
+ logLine("\nVerify build output", "magenta", { bright: true, underscore: true });
33
+ const total = hasHtmlFiles(outDir);
34
+ const okAny = total > 0;
35
+ const indexPath = path.join(outDir, 'index.html');
36
+ const hasIndex = fs.existsSync(indexPath) && fs.statSync(indexPath).size > 0;
37
+ const { okHero, okCommand, okCommandTrigger, okCommandScriptRef } = verifyHomepageElements(outDir);
38
+
39
+ const ck = (label, ok, extra) => {
40
+ const status = ok ? '✓' : '✗';
41
+ logLine(`${status} ${label}${extra ? ` ${extra}` : ''}`, ok ? 'green' : 'red');
42
+ };
43
+
44
+ ck('HTML pages exist', okAny, okAny ? `(${total})` : '');
45
+ ck('homepage exists', hasIndex, hasIndex ? `(${indexPath})` : '');
46
+ ck('homepage: Hero present', okHero);
47
+ ck('homepage: Command present', okCommand);
48
+ ck('homepage: Command trigger present', okCommandTrigger);
49
+ ck('homepage: Command script referenced', okCommandScriptRef);
50
+
51
+ // Do not fail build on missing SSR trigger; the client runtime injects a default.
52
+ const ok = okAny && hasIndex && okHero && okCommand && okCommandScriptRef;
53
+ if (!ok) {
54
+ const err = new Error('Build verification failed');
55
+ err.outDir = outDir;
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ module.exports = { verifyBuildOutput };
@@ -0,0 +1,144 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+
5
+ function firstLabelString(label) {
6
+ if (!label) return 'Untitled';
7
+ if (typeof label === 'string') return label;
8
+ try {
9
+ const keys = Object.keys(label || {});
10
+ if (!keys.length) return 'Untitled';
11
+ const arr = label[keys[0]];
12
+ if (Array.isArray(arr) && arr.length) return String(arr[0]);
13
+ } catch (_) {}
14
+ return 'Untitled';
15
+ }
16
+
17
+ function normalizeIiifId(raw) {
18
+ try {
19
+ const s = String(raw || '');
20
+ if (!/^https?:\/\//i.test(s)) return s;
21
+ const u = new URL(s);
22
+ const entries = Array.from(u.searchParams.entries()).sort(
23
+ (a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])
24
+ );
25
+ u.search = '';
26
+ for (const [k, v] of entries) u.searchParams.append(k, v);
27
+ return u.toString();
28
+ } catch (_) {
29
+ return String(raw || '');
30
+ }
31
+ }
32
+
33
+ function equalIiifId(a, b) {
34
+ try {
35
+ const an = normalizeIiifId(a);
36
+ const bn = normalizeIiifId(b);
37
+ if (an === bn) return true;
38
+ const ua = new URL(an);
39
+ const ub = new URL(bn);
40
+ return ua.origin === ub.origin && ua.pathname === ub.pathname;
41
+ } catch (_) {
42
+ return String(a || '') === String(b || '');
43
+ }
44
+ }
45
+
46
+ function readYaml(p) {
47
+ try {
48
+ if (!fs.existsSync(p)) return null;
49
+ const raw = fs.readFileSync(p, 'utf8');
50
+ return yaml.load(raw) || null;
51
+ } catch (_) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function readJson(p) {
57
+ try {
58
+ if (!fs.existsSync(p)) return null;
59
+ const raw = fs.readFileSync(p, 'utf8');
60
+ return JSON.parse(raw);
61
+ } catch (_) {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function findSlugByIdFromDiskSync(nid) {
67
+ try {
68
+ const dir = path.resolve('.cache/iiif/manifests');
69
+ if (!fs.existsSync(dir)) return null;
70
+ const names = fs.readdirSync(dir);
71
+ for (const name of names) {
72
+ if (!name || !name.toLowerCase().endsWith('.json')) continue;
73
+ const fp = path.join(dir, name);
74
+ try {
75
+ const obj = readJson(fp);
76
+ const mid = normalizeIiifId(String((obj && (obj.id || obj['@id'])) || ''));
77
+ if (mid && equalIiifId(mid, nid)) return name.replace(/\.json$/i, '');
78
+ } catch (_) {}
79
+ }
80
+ } catch (_) {}
81
+ return null;
82
+ }
83
+
84
+ function readFeaturedFromCacheSync() {
85
+ try {
86
+ const debug = !!process.env.CANOPY_DEBUG_FEATURED;
87
+ const cfg = readYaml(path.resolve('canopy.yml')) || {};
88
+ const featured = Array.isArray(cfg && cfg.featured) ? cfg.featured : [];
89
+ if (!featured.length) return [];
90
+ const idx = readJson(path.resolve('.cache/iiif/index.json')) || {};
91
+ const byId = Array.isArray(idx && idx.byId) ? idx.byId : [];
92
+ const out = [];
93
+ for (const id of featured) {
94
+ const nid = normalizeIiifId(id);
95
+ if (debug) { try { console.log('[featured] id:', id); } catch (_) {} }
96
+ const entry = byId.find((e) => e && e.type === 'Manifest' && equalIiifId(e.id, nid));
97
+ const slug = entry && entry.slug ? String(entry.slug) : findSlugByIdFromDiskSync(nid);
98
+ if (debug) { try { console.log('[featured] - slug:', slug || '(none)'); } catch (_) {} }
99
+ if (!slug) continue;
100
+ const m = readJson(path.resolve('.cache/iiif/manifests', slug + '.json'));
101
+ if (!m) continue;
102
+ const rec = {
103
+ title: firstLabelString(m && m.label),
104
+ href: path.join('works', slug + '.html').split(path.sep).join('/'),
105
+ type: 'work',
106
+ };
107
+ if (entry && entry.thumbnail) rec.thumbnail = String(entry.thumbnail);
108
+ if (entry && typeof entry.thumbnailWidth === 'number') rec.thumbnailWidth = entry.thumbnailWidth;
109
+ if (entry && typeof entry.thumbnailHeight === 'number') rec.thumbnailHeight = entry.thumbnailHeight;
110
+ if (!rec.thumbnail) {
111
+ try {
112
+ const t = m && m.thumbnail;
113
+ if (Array.isArray(t) && t.length) {
114
+ const first = t[0] || {};
115
+ const tid = first.id || first['@id'] || first.url || '';
116
+ if (tid) rec.thumbnail = String(tid);
117
+ if (typeof first.width === 'number') rec.thumbnailWidth = first.width;
118
+ if (typeof first.height === 'number') rec.thumbnailHeight = first.height;
119
+ } else if (t && typeof t === 'object') {
120
+ const tid = t.id || t['@id'] || t.url || '';
121
+ if (tid) rec.thumbnail = String(tid);
122
+ if (typeof t.width === 'number') rec.thumbnailWidth = t.width;
123
+ if (typeof t.height === 'number') rec.thumbnailHeight = t.height;
124
+ }
125
+ } catch (_) {}
126
+ }
127
+ out.push(rec);
128
+ }
129
+ if (debug) { try { console.log('[featured] total:', out.length); } catch (_) {} }
130
+ return out;
131
+ } catch (_) {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ module.exports = {
137
+ firstLabelString,
138
+ normalizeIiifId,
139
+ equalIiifId,
140
+ readYaml,
141
+ readJson,
142
+ findSlugByIdFromDiskSync,
143
+ readFeaturedFromCacheSync,
144
+ };
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
- import { SearchFormUI, SearchResultsUI } from '@canopy-iiif/app/ui';
3
+ import { SearchFormUI, SearchResultsUI, SearchTabsUI } from '@canopy-iiif/app/ui';
4
4
 
5
5
  // Lightweight IndexedDB utilities (no deps) with graceful fallback
6
6
  function hasIDB() {
@@ -133,11 +133,14 @@ function createSearchStore() {
133
133
  // Broadcast new index installs to other tabs
134
134
  let bc = null;
135
135
  try { if (typeof BroadcastChannel !== 'undefined') bc = new BroadcastChannel('canopy-search'); } catch (_) {}
136
- // Try to load meta version for cache-busting; fall back to hash of JSON
136
+ // Try to load meta for cache-busting and tab order; fall back to hash of JSON
137
137
  let version = '';
138
+ let tabsOrder = [];
138
139
  try {
139
140
  const meta = await fetch('./api/index.json').then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
140
141
  if (meta && typeof meta.version === 'string') version = meta.version;
142
+ const ord = meta && meta.search && meta.search.tabs && Array.isArray(meta.search.tabs.order) ? meta.search.tabs.order : [];
143
+ tabsOrder = ord.map((s) => String(s)).filter(Boolean);
141
144
  } catch (_) {}
142
145
  const res = await fetch('./api/search-index.json' + (version ? `?v=${encodeURIComponent(version)}` : ''));
143
146
  const text = await res.text();
@@ -198,8 +201,22 @@ function createSearchStore() {
198
201
  } catch (_) {}
199
202
 
200
203
  const ts = Array.from(new Set(data.map((r) => String((r && r.type) || 'page'))));
201
- const order = ['work', 'docs', 'page'];
204
+ const order = Array.isArray(tabsOrder) && tabsOrder.length ? tabsOrder : ['work', 'docs', 'page'];
202
205
  ts.sort((a, b) => { const ia = order.indexOf(a); const ib = order.indexOf(b); return (ia<0?99:ia)-(ib<0?99:ib) || a.localeCompare(b); });
206
+ // Default to configured first tab if no type param present
207
+ try {
208
+ const p = new URLSearchParams(location.search);
209
+ const hasType = p.has('type');
210
+ if (!hasType) {
211
+ let def = (order && order.length ? order[0] : 'all');
212
+ if (!ts.includes(def)) def = ts[0] || 'all';
213
+ if (def && def !== 'all') {
214
+ p.set('type', def);
215
+ history.replaceState(null, '', `${location.pathname}?${p.toString()}`);
216
+ }
217
+ set({ type: def });
218
+ }
219
+ } catch (_) {}
203
220
  set({ index: idx, records: data, types: ts, loading: false });
204
221
  } catch (_) { set({ loading: false }); }
205
222
  })();
@@ -226,6 +243,10 @@ function ResultsMount(props = {}) {
226
243
  const layout = (props && props.layout) || 'grid';
227
244
  return <SearchResultsUI results={results} type={type} layout={layout} />;
228
245
  }
246
+ function TabsMount() {
247
+ const { type, setType, types, counts } = useStore();
248
+ return <SearchTabsUI type={type} onTypeChange={setType} types={types} counts={counts} />;
249
+ }
229
250
  function SummaryMount() {
230
251
  const { query, type, shown, total } = useStore();
231
252
  const text = useMemo(() => {
@@ -263,10 +284,20 @@ function mountAt(selector, Comp) {
263
284
 
264
285
  if (typeof document !== 'undefined') {
265
286
  const run = () => {
287
+ // Mount tabs (preferred) or full form if present, plus summary/total/results
288
+ mountAt('[data-canopy-search-tabs]', TabsMount);
266
289
  mountAt('[data-canopy-search-form]', FormMount);
267
290
  mountAt('[data-canopy-search-results]', ResultsMount);
268
291
  mountAt('[data-canopy-search-summary]', SummaryMount);
269
292
  mountAt('[data-canopy-search-total]', TotalMount);
293
+ try {
294
+ window.addEventListener('canopy:search:setQuery', (ev) => {
295
+ try {
296
+ const q = ev && ev.detail && typeof ev.detail.query === 'string' ? ev.detail.query : (document.querySelector('[data-canopy-command-input]')?.value || '');
297
+ if (typeof q === 'string') store.setQuery(q);
298
+ } catch (_) {}
299
+ });
300
+ } catch (_) {}
270
301
  };
271
302
  if (document.readyState !== 'loading') run();
272
303
  else document.addEventListener('DOMContentLoaded', run, { once: true });
@@ -361,10 +361,19 @@ async function buildSearchPage() {
361
361
  React.createElement('h1', null, 'Search'),
362
362
  React.createElement('div', { id: 'search-root' })
363
363
  );
364
- const { loadAppWrapper } = require('../build/mdx');
364
+ const { loadAppWrapper, getMdxProvider, loadUiComponents } = require('../build/mdx');
365
365
  const app = await loadAppWrapper();
366
366
  const wrappedApp = app && app.App ? React.createElement(app.App, null, content) : content;
367
- body = ReactDOMServer.renderToStaticMarkup(wrappedApp);
367
+ // Ensure MDX components like <SearchPanel /> resolve when rendering App wrapper
368
+ let page = wrappedApp;
369
+ try {
370
+ const MDXProvider = await getMdxProvider();
371
+ const components = await loadUiComponents();
372
+ if (MDXProvider && components) {
373
+ page = React.createElement(MDXProvider, { components }, wrappedApp);
374
+ }
375
+ } catch (_) { /* render without provider on failure */ }
376
+ body = ReactDOMServer.renderToStaticMarkup(page);
368
377
  head = app && app.Head ? ReactDOMServer.renderToStaticMarkup(React.createElement(app.Head)) : '';
369
378
  }
370
379
  const importMap = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",
@@ -30,7 +30,6 @@
30
30
  "@mdx-js/mdx": "^3.1.0",
31
31
  "@mdx-js/react": "^3.1.0",
32
32
  "charm": "^1.0.2",
33
- "cmdk": "^1.1.1",
34
33
  "js-yaml": "^4.1.0",
35
34
  "react-masonry-css": "^1.0.16",
36
35
  "sass": "^1.77.0",
package/ui/dist/index.mjs CHANGED
@@ -341,12 +341,24 @@ function SearchTotal(props) {
341
341
  return /* @__PURE__ */ React11.createElement("div", { "data-canopy-search-total": "1" }, /* @__PURE__ */ React11.createElement("script", { type: "application/json", dangerouslySetInnerHTML: { __html: json } }));
342
342
  }
343
343
 
344
- // ui/src/search/SearchForm.jsx
344
+ // ui/src/search/MdxSearchTabs.jsx
345
345
  import React12 from "react";
346
+ function MdxSearchTabs(props) {
347
+ let json = "{}";
348
+ try {
349
+ json = JSON.stringify(props || {});
350
+ } catch (_) {
351
+ json = "{}";
352
+ }
353
+ return /* @__PURE__ */ React12.createElement("div", { "data-canopy-search-tabs": "1" }, /* @__PURE__ */ React12.createElement("script", { type: "application/json", dangerouslySetInnerHTML: { __html: json } }));
354
+ }
355
+
356
+ // ui/src/search/SearchForm.jsx
357
+ import React13 from "react";
346
358
  function SearchForm({ query, onQueryChange, type = "all", onTypeChange, types = [], counts = {} }) {
347
359
  const orderedTypes = Array.isArray(types) ? types : [];
348
360
  const toLabel = (t) => t && t.length ? t.charAt(0).toUpperCase() + t.slice(1) : "";
349
- return /* @__PURE__ */ React12.createElement("form", { onSubmit: (e) => e.preventDefault(), className: "space-y-3" }, /* @__PURE__ */ React12.createElement(
361
+ return /* @__PURE__ */ React13.createElement("form", { onSubmit: (e) => e.preventDefault(), className: "space-y-3" }, /* @__PURE__ */ React13.createElement(
350
362
  "input",
351
363
  {
352
364
  id: "search-input",
@@ -356,11 +368,11 @@ function SearchForm({ query, onQueryChange, type = "all", onTypeChange, types =
356
368
  onChange: (e) => onQueryChange && onQueryChange(e.target.value),
357
369
  className: "w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
358
370
  }
359
- ), /* @__PURE__ */ React12.createElement("div", { role: "tablist", "aria-label": "Search types", className: "flex items-center gap-2 border-b border-slate-200" }, orderedTypes.map((t) => {
371
+ ), /* @__PURE__ */ React13.createElement("div", { role: "tablist", "aria-label": "Search types", className: "flex items-center gap-2 border-b border-slate-200" }, orderedTypes.map((t) => {
360
372
  const active = String(type).toLowerCase() === String(t).toLowerCase();
361
373
  const cRaw = counts && Object.prototype.hasOwnProperty.call(counts, t) ? counts[t] : void 0;
362
374
  const c = Number.isFinite(Number(cRaw)) ? Number(cRaw) : 0;
363
- return /* @__PURE__ */ React12.createElement(
375
+ return /* @__PURE__ */ React13.createElement(
364
376
  "button",
365
377
  {
366
378
  key: t,
@@ -379,27 +391,27 @@ function SearchForm({ query, onQueryChange, type = "all", onTypeChange, types =
379
391
  }
380
392
 
381
393
  // ui/src/search/SearchResults.jsx
382
- import React13 from "react";
394
+ import React14 from "react";
383
395
  function SearchResults({
384
396
  results = [],
385
397
  type = "all",
386
398
  layout = "grid"
387
399
  }) {
388
400
  if (!results.length) {
389
- return /* @__PURE__ */ React13.createElement("div", { className: "text-slate-600" }, /* @__PURE__ */ React13.createElement("em", null, "No results"));
401
+ return /* @__PURE__ */ React14.createElement("div", { className: "text-slate-600" }, /* @__PURE__ */ React14.createElement("em", null, "No results"));
390
402
  }
391
403
  if (layout === "list") {
392
- return /* @__PURE__ */ React13.createElement("ul", { id: "search-results", className: "space-y-3" }, results.map((r, i) => {
404
+ return /* @__PURE__ */ React14.createElement("ul", { id: "search-results", className: "space-y-3" }, results.map((r, i) => {
393
405
  const hasDims = Number.isFinite(Number(r.thumbnailWidth)) && Number(r.thumbnailWidth) > 0 && Number.isFinite(Number(r.thumbnailHeight)) && Number(r.thumbnailHeight) > 0;
394
406
  const aspect = hasDims ? Number(r.thumbnailWidth) / Number(r.thumbnailHeight) : void 0;
395
- return /* @__PURE__ */ React13.createElement(
407
+ return /* @__PURE__ */ React14.createElement(
396
408
  "li",
397
409
  {
398
410
  key: i,
399
411
  className: `search-result ${r.type}`,
400
412
  "data-thumbnail-aspect-ratio": aspect
401
413
  },
402
- /* @__PURE__ */ React13.createElement(
414
+ /* @__PURE__ */ React14.createElement(
403
415
  Card,
404
416
  {
405
417
  href: r.href,
@@ -413,17 +425,17 @@ function SearchResults({
413
425
  );
414
426
  }));
415
427
  }
416
- return /* @__PURE__ */ React13.createElement("div", { id: "search-results" }, /* @__PURE__ */ React13.createElement(Grid, null, results.map((r, i) => {
428
+ return /* @__PURE__ */ React14.createElement("div", { id: "search-results" }, /* @__PURE__ */ React14.createElement(Grid, null, results.map((r, i) => {
417
429
  const hasDims = Number.isFinite(Number(r.thumbnailWidth)) && Number(r.thumbnailWidth) > 0 && Number.isFinite(Number(r.thumbnailHeight)) && Number(r.thumbnailHeight) > 0;
418
430
  const aspect = hasDims ? Number(r.thumbnailWidth) / Number(r.thumbnailHeight) : void 0;
419
- return /* @__PURE__ */ React13.createElement(
431
+ return /* @__PURE__ */ React14.createElement(
420
432
  GridItem,
421
433
  {
422
434
  key: i,
423
435
  className: `search-result ${r.type}`,
424
436
  "data-thumbnail-aspect-ratio": aspect
425
437
  },
426
- /* @__PURE__ */ React13.createElement(
438
+ /* @__PURE__ */ React14.createElement(
427
439
  Card,
428
440
  {
429
441
  href: r.href,
@@ -438,6 +450,33 @@ function SearchResults({
438
450
  })));
439
451
  }
440
452
 
453
+ // ui/src/search/SearchTabs.jsx
454
+ import React15 from "react";
455
+ function SearchTabs({ type = "all", onTypeChange, types = [], counts = {} }) {
456
+ const orderedTypes = Array.isArray(types) ? types : [];
457
+ const toLabel = (t) => t && t.length ? t.charAt(0).toUpperCase() + t.slice(1) : "";
458
+ return /* @__PURE__ */ React15.createElement("div", { role: "tablist", "aria-label": "Search types", className: "flex items-center gap-2 border-b border-slate-200" }, orderedTypes.map((t) => {
459
+ const active = String(type).toLowerCase() === String(t).toLowerCase();
460
+ const cRaw = counts && Object.prototype.hasOwnProperty.call(counts, t) ? counts[t] : void 0;
461
+ const c = Number.isFinite(Number(cRaw)) ? Number(cRaw) : 0;
462
+ return /* @__PURE__ */ React15.createElement(
463
+ "button",
464
+ {
465
+ key: t,
466
+ role: "tab",
467
+ "aria-selected": active,
468
+ type: "button",
469
+ onClick: () => onTypeChange && onTypeChange(t),
470
+ className: "px-3 py-1.5 text-sm rounded-t-md border-b-2 -mb-px transition-colors " + (active ? "border-brand-600 text-brand-700" : "border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300")
471
+ },
472
+ toLabel(t),
473
+ " (",
474
+ c,
475
+ ")"
476
+ );
477
+ }));
478
+ }
479
+
441
480
  // ui/src/search/useSearch.js
442
481
  import { useEffect as useEffect4, useMemo, useRef as useRef2, useState as useState4 } from "react";
443
482
  function useSearch(query, type) {
@@ -508,7 +547,7 @@ function useSearch(query, type) {
508
547
  }
509
548
 
510
549
  // ui/src/search/Search.jsx
511
- import React14 from "react";
550
+ import React16 from "react";
512
551
  function Search(props) {
513
552
  let json = "{}";
514
553
  try {
@@ -516,7 +555,7 @@ function Search(props) {
516
555
  } catch (_) {
517
556
  json = "{}";
518
557
  }
519
- return /* @__PURE__ */ React14.createElement("div", { "data-canopy-search": "1", className: "not-prose" }, /* @__PURE__ */ React14.createElement(
558
+ return /* @__PURE__ */ React16.createElement("div", { "data-canopy-search": "1", className: "not-prose" }, /* @__PURE__ */ React16.createElement(
520
559
  "script",
521
560
  {
522
561
  type: "application/json",
@@ -526,7 +565,7 @@ function Search(props) {
526
565
  }
527
566
 
528
567
  // ui/src/command/MdxCommandPalette.jsx
529
- import React15 from "react";
568
+ import React17 from "react";
530
569
  function MdxCommandPalette(props = {}) {
531
570
  const {
532
571
  placeholder = "Search\u2026",
@@ -534,145 +573,29 @@ function MdxCommandPalette(props = {}) {
534
573
  maxResults = 8,
535
574
  groupOrder = ["work", "page"],
536
575
  button = true,
537
- buttonLabel = "Search"
576
+ // kept for backward compat; ignored by teaser form
577
+ buttonLabel = "Search",
578
+ label,
579
+ searchPath = "/search"
538
580
  } = props || {};
539
- const data = { placeholder, hotkey, maxResults, groupOrder };
540
- return /* @__PURE__ */ React15.createElement("div", { "data-canopy-command": true }, button && /* @__PURE__ */ React15.createElement(
541
- "button",
581
+ const text = typeof label === "string" && label.trim() ? label.trim() : buttonLabel;
582
+ const data = { placeholder, hotkey, maxResults, groupOrder, label: text, searchPath };
583
+ return /* @__PURE__ */ React17.createElement("div", { "data-canopy-command": true, className: "flex-1 min-w-0" }, /* @__PURE__ */ React17.createElement("div", { className: "relative w-full" }, /* @__PURE__ */ React17.createElement("style", null, `.relative[data-canopy-panel-auto='1']:focus-within [data-canopy-command-panel]{display:block}`), /* @__PURE__ */ React17.createElement("form", { action: searchPath, method: "get", role: "search", className: "group flex items-center gap-2 px-2 py-1.5 rounded-lg border border-slate-300 bg-white/95 backdrop-blur text-slate-700 shadow-sm hover:shadow transition w-full focus-within:ring-2 focus-within:ring-brand-500" }, /* @__PURE__ */ React17.createElement("svg", { "aria-hidden": true, viewBox: "0 0 20 20", fill: "none", className: "w-4 h-4 text-slate-500" }, /* @__PURE__ */ React17.createElement("path", { stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", d: "m19 19-4-4m-2.5-6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z" })), /* @__PURE__ */ React17.createElement(
584
+ "input",
542
585
  {
543
- type: "button",
544
- "data-canopy-command-trigger": true,
545
- className: "inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-300 text-slate-700 hover:bg-slate-50",
546
- "aria-label": "Open search"
547
- },
548
- /* @__PURE__ */ React15.createElement("span", { "aria-hidden": true }, "\u2318K"),
549
- /* @__PURE__ */ React15.createElement("span", { className: "sr-only" }, buttonLabel)
550
- ), /* @__PURE__ */ React15.createElement("script", { type: "application/json", dangerouslySetInnerHTML: { __html: JSON.stringify(data) } }));
551
- }
552
-
553
- // ui/src/command/CommandApp.jsx
554
- import React16, { useEffect as useEffect5, useMemo as useMemo2, useState as useState5 } from "react";
555
- import { Command } from "cmdk";
556
- function normalize(s) {
557
- try {
558
- return String(s || "").toLowerCase();
559
- } catch (e) {
560
- return "";
561
- }
562
- }
563
- function groupLabel(t) {
564
- const type = String(t || "").toLowerCase();
565
- if (type === "work") return "Works";
566
- if (type === "page") return "Pages";
567
- return type.charAt(0).toUpperCase() + type.slice(1);
568
- }
569
- function CommandPaletteApp(props) {
570
- const {
571
- records = [],
572
- loading = false,
573
- open: controlledOpen,
574
- onOpenChange,
575
- onSelect = () => {
576
- },
577
- config = {}
578
- } = props || {};
579
- const {
580
- placeholder = "Search\u2026",
581
- hotkey = "mod+k",
582
- maxResults = 8,
583
- groupOrder = ["work", "page"],
584
- button = true,
585
- buttonLabel = "Search"
586
- } = config || {};
587
- const [open, setOpen] = useState5(!!controlledOpen);
588
- useEffect5(() => {
589
- if (typeof controlledOpen === "boolean") setOpen(controlledOpen);
590
- }, [controlledOpen]);
591
- const setOpenBoth = (v) => {
592
- setOpen(!!v);
593
- if (onOpenChange) onOpenChange(!!v);
594
- };
595
- const [q, setQ] = useState5("");
596
- useEffect5(() => {
597
- function handler(e) {
598
- try {
599
- const hk = String(hotkey || "mod+k").toLowerCase();
600
- const isMod = hk.includes("mod+");
601
- const key = hk.split("+").pop();
602
- if ((isMod ? e.metaKey || e.ctrlKey : true) && e.key.toLowerCase() === String(key || "k")) {
603
- e.preventDefault();
604
- setOpenBoth(true);
605
- }
606
- } catch (e2) {
607
- }
608
- }
609
- document.addEventListener("keydown", handler);
610
- return () => document.removeEventListener("keydown", handler);
611
- }, [hotkey]);
612
- useEffect5(() => {
613
- function onKey(e) {
614
- if (e.key === "Escape") setOpenBoth(false);
615
- }
616
- document.addEventListener("keydown", onKey);
617
- return () => document.removeEventListener("keydown", onKey);
618
- }, []);
619
- const results = useMemo2(() => {
620
- if (!q) return [];
621
- const qq = normalize(q);
622
- const out = [];
623
- for (const r of records || []) {
624
- const title = String(r && r.title || "");
625
- if (!title) continue;
626
- if (normalize(title).includes(qq)) out.push(r);
627
- if (out.length >= Math.max(1, Number(maxResults) || 8)) break;
628
- }
629
- return out;
630
- }, [q, records, maxResults]);
631
- const grouped = useMemo2(() => {
632
- const map = /* @__PURE__ */ new Map();
633
- for (const r of results) {
634
- const t = String(r && r.type || "page");
635
- if (!map.has(t)) map.set(t, []);
636
- map.get(t).push(r);
637
- }
638
- return map;
639
- }, [results]);
640
- const onOverlayMouseDown = (e) => {
641
- if (e.target === e.currentTarget) setOpenBoth(false);
642
- };
643
- const onItemSelect = (href) => {
644
- try {
645
- onSelect(String(href || ""));
646
- setOpenBoth(false);
647
- } catch (e) {
586
+ type: "search",
587
+ name: "q",
588
+ inputMode: "search",
589
+ "data-canopy-command-input": true,
590
+ placeholder,
591
+ className: "flex-1 bg-transparent outline-none placeholder:text-slate-400 py-0.5 min-w-0",
592
+ "aria-label": "Search"
648
593
  }
649
- };
650
- return /* @__PURE__ */ React16.createElement("div", { className: "canopy-cmdk" }, button && /* @__PURE__ */ React16.createElement(
651
- "button",
652
- {
653
- type: "button",
654
- className: "canopy-cmdk__trigger",
655
- onClick: () => setOpenBoth(true),
656
- "aria-label": "Open search",
657
- "data-canopy-command-trigger": true
658
- },
659
- /* @__PURE__ */ React16.createElement("span", { "aria-hidden": true }, "\u2318K"),
660
- /* @__PURE__ */ React16.createElement("span", { className: "sr-only" }, buttonLabel)
661
- ), /* @__PURE__ */ React16.createElement(
662
- "div",
663
- {
664
- className: "canopy-cmdk__overlay",
665
- "data-open": open ? "1" : "0",
666
- onMouseDown: onOverlayMouseDown,
667
- style: { display: open ? "flex" : "none" }
668
- },
669
- /* @__PURE__ */ React16.createElement("div", { className: "canopy-cmdk__panel" }, /* @__PURE__ */ React16.createElement("button", { className: "canopy-cmdk__close", "aria-label": "Close", onClick: () => setOpenBoth(false) }, "\xD7"), /* @__PURE__ */ React16.createElement("div", { className: "canopy-cmdk__inputWrap" }, /* @__PURE__ */ React16.createElement(Command, null, /* @__PURE__ */ React16.createElement(Command.Input, { autoFocus: true, value: q, onValueChange: setQ, placeholder, className: "canopy-cmdk__input" }), /* @__PURE__ */ React16.createElement(Command.List, { className: "canopy-cmdk__list" }, loading && /* @__PURE__ */ React16.createElement(Command.Loading, null, "Hang on\u2026"), /* @__PURE__ */ React16.createElement(Command.Empty, null, "No results found."), (Array.isArray(groupOrder) ? groupOrder : []).map((t) => grouped.has(t) ? /* @__PURE__ */ React16.createElement(Command.Group, { key: t, heading: groupLabel(t) }, grouped.get(t).map((r, i) => /* @__PURE__ */ React16.createElement(Command.Item, { key: t + "-" + i, onSelect: () => onItemSelect(r.href) }, /* @__PURE__ */ React16.createElement("div", { className: "canopy-cmdk__item" }, String(r.type || "") === "work" && r.thumbnail ? /* @__PURE__ */ React16.createElement("img", { className: "canopy-cmdk__thumb", src: r.thumbnail, alt: "" }) : null, /* @__PURE__ */ React16.createElement("span", { className: "canopy-cmdk__title" }, r.title))))) : null), Array.from(grouped.keys()).filter((t) => !(groupOrder || []).includes(t)).map((t) => /* @__PURE__ */ React16.createElement(Command.Group, { key: t, heading: groupLabel(t) }, grouped.get(t).map((r, i) => /* @__PURE__ */ React16.createElement(Command.Item, { key: t + "-x-" + i, onSelect: () => onItemSelect(r.href) }, /* @__PURE__ */ React16.createElement("div", { className: "canopy-cmdk__item" }, String(r.type || "") === "work" && r.thumbnail ? /* @__PURE__ */ React16.createElement("img", { className: "canopy-cmdk__thumb", src: r.thumbnail, alt: "" }) : null, /* @__PURE__ */ React16.createElement("span", { className: "canopy-cmdk__title" }, r.title))))))))))
670
- ));
594
+ ), /* @__PURE__ */ React17.createElement("button", { type: "submit", "data-canopy-command-link": true, className: "inline-flex items-center gap-1 px-2 py-1 rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100 text-slate-700" }, /* @__PURE__ */ React17.createElement("span", null, text))), /* @__PURE__ */ React17.createElement("div", { "data-canopy-command-panel": true, style: { display: "none", position: "absolute", left: 0, right: 0, top: "calc(100% + 4px)", background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8, boxShadow: "0 10px 25px rgba(0,0,0,0.12)", zIndex: 1e3, overflow: "auto", maxHeight: "60vh" } }, /* @__PURE__ */ React17.createElement("div", { id: "cplist" }))), /* @__PURE__ */ React17.createElement("script", { type: "application/json", dangerouslySetInnerHTML: { __html: JSON.stringify(data) } }));
671
595
  }
672
596
  export {
673
597
  Card,
674
598
  MdxCommandPalette as CommandPalette,
675
- CommandPaletteApp,
676
599
  Fallback,
677
600
  Grid,
678
601
  GridItem,
@@ -681,9 +604,12 @@ export {
681
604
  Search,
682
605
  MdxSearchForm as SearchForm,
683
606
  SearchForm as SearchFormUI,
607
+ MdxCommandPalette as SearchPanel,
684
608
  MdxSearchResults as SearchResults,
685
609
  SearchResults as SearchResultsUI,
686
610
  SearchSummary,
611
+ MdxSearchTabs as SearchTabs,
612
+ SearchTabs as SearchTabsUI,
687
613
  SearchTotal,
688
614
  Slider,
689
615
  Viewer,