@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.
@@ -19,6 +19,7 @@ const {
19
19
  const { ensureStyles } = require("./styles");
20
20
  const { copyAssets } = require("./assets");
21
21
  const { logLine } = require("./log");
22
+ const { verifyBuildOutput } = require("./verify");
22
23
 
23
24
  // hold records between builds if skipping IIIF
24
25
  let iiifRecordsCache = [];
@@ -57,11 +58,15 @@ async function build(options = {}) {
57
58
  underscore: true,
58
59
  });
59
60
  let iiifRecords = [];
61
+ const CONFIG = await iiif.loadConfig();
60
62
  if (!skipIiif) {
61
- const CONFIG = await iiif.loadConfig();
62
63
  const results = await iiif.buildIiifCollectionPages(CONFIG);
63
64
  iiifRecords = results?.iiifRecords;
64
65
  }
66
+ // Ensure any configured featured manifests are cached (and thumbnails computed)
67
+ // so SSR components like <Hero /> can resolve items even if they are not part of
68
+ // the traversed collection or when IIIF build is skipped during incremental rebuilds.
69
+ try { await iiif.ensureFeaturedInCache(CONFIG); } catch (_) {}
65
70
 
66
71
  /**
67
72
  * Build contextual MDX content from the content directory.
@@ -72,6 +77,7 @@ async function build(options = {}) {
72
77
  bright: true,
73
78
  underscore: true,
74
79
  });
80
+ // FeaturedHero now reads directly from the local IIIF cache; no API file needed
75
81
  pageRecords = await searchBuild.collectMdxPageRecords();
76
82
  await pages.buildContentTree(CONTENT_DIR, pageRecords);
77
83
  logLine("✓ MDX pages built", "green");
@@ -94,6 +100,8 @@ async function build(options = {}) {
94
100
  logLine(" " + String(e), "red");
95
101
  }
96
102
 
103
+ // No-op: Featured API file no longer written (SSR reads from cache directly)
104
+
97
105
  /**
98
106
  * Prepare client runtimes (e.g. search) by bundling with esbuild.
99
107
  * This is done early so that MDX content can reference runtime assets if needed.
@@ -116,6 +124,17 @@ async function build(options = {}) {
116
124
  underscore: true,
117
125
  });
118
126
  await copyAssets();
127
+
128
+ /**
129
+ * Final verification (checklist)
130
+ */
131
+ try {
132
+ verifyBuildOutput({ outDir: OUT_DIR });
133
+ } catch (e) {
134
+ logLine("✗ Build verification failed", "red", { bright: true });
135
+ logLine(String(e && e.message ? e.message : e), "red");
136
+ process.exit(1);
137
+ }
119
138
  }
120
139
 
121
140
  module.exports = { build };
package/lib/build/iiif.js CHANGED
@@ -365,6 +365,51 @@ async function saveCachedManifest(manifest, id, parentId) {
365
365
  } catch (_) {}
366
366
  }
367
367
 
368
+ // Ensure any configured featured manifests are present in the local cache
369
+ // (and have thumbnails computed) so SSR components like <Hero /> can read them.
370
+ async function ensureFeaturedInCache(cfg) {
371
+ try {
372
+ const CONFIG = cfg || (await loadConfig());
373
+ const featured = Array.isArray(CONFIG && CONFIG.featured) ? CONFIG.featured : [];
374
+ if (!featured.length) return;
375
+ const { getThumbnail } = require("../iiif/thumbnail");
376
+ // Thumbnail sizing config
377
+ const thumbSize = CONFIG && CONFIG.iiif && CONFIG.iiif.thumbnails && typeof CONFIG.iiif.thumbnails.preferredSize === 'number' ? CONFIG.iiif.thumbnails.preferredSize : 400;
378
+ const unsafeThumbs = !!(CONFIG && CONFIG.iiif && CONFIG.iiif.thumbnails && (CONFIG.iiif.thumbnails.unsafe === true || CONFIG.iiif.thumbnails.unsafe === 'true'));
379
+ for (const rawId of featured) {
380
+ const id = normalizeIiifId(String(rawId || ''));
381
+ if (!id) continue;
382
+ let manifest = await loadCachedManifestById(id);
383
+ if (!manifest) {
384
+ const m = await readJsonFromUri(id).catch(() => null);
385
+ if (!m) continue;
386
+ const v3 = await normalizeToV3(m);
387
+ if (!v3 || !v3.id) continue;
388
+ await saveCachedManifest(v3, id, '');
389
+ manifest = v3;
390
+ }
391
+ // Ensure thumbnail fields exist in index for this manifest (if computable)
392
+ try {
393
+ const t = await getThumbnail(manifest, thumbSize, unsafeThumbs);
394
+ if (t && t.url) {
395
+ const idx = await loadManifestIndex();
396
+ if (Array.isArray(idx.byId)) {
397
+ const entry = idx.byId.find((e) => e && e.type === 'Manifest' && normalizeIiifId(String(e.id)) === normalizeIiifId(String(manifest.id)));
398
+ if (entry) {
399
+ entry.thumbnail = String(t.url);
400
+ if (typeof t.width === 'number') entry.thumbnailWidth = t.width;
401
+ if (typeof t.height === 'number') entry.thumbnailHeight = t.height;
402
+ await saveManifestIndex(idx);
403
+ }
404
+ }
405
+ }
406
+ } catch (_) {}
407
+ }
408
+ } catch (_) {
409
+ // ignore failures; fallback SSR will still render a minimal hero without content
410
+ }
411
+ }
412
+
368
413
  async function flushManifestCache() {
369
414
  try {
370
415
  await fsp.rm(IIIF_CACHE_MANIFESTS_DIR, { recursive: true, force: true });
@@ -762,7 +807,11 @@ async function buildIiifCollectionPages(CONFIG) {
762
807
  href = withBase(href);
763
808
  return React.createElement("a", { href, ...rest }, props.children);
764
809
  };
810
+ // Map exported UI components into MDX, with sensible aliases/fallbacks
765
811
  const compMap = { ...components, a: Anchor };
812
+ if (!compMap.SearchPanel && compMap.CommandPalette) {
813
+ compMap.SearchPanel = compMap.CommandPalette;
814
+ }
766
815
  if (!components.HelloWorld) {
767
816
  components.HelloWorld = components.Fallback
768
817
  ? (props) =>
@@ -811,6 +860,7 @@ async function buildIiifCollectionPages(CONFIG) {
811
860
  .join("/");
812
861
  const needsHydrateViewer = body.includes("data-canopy-viewer");
813
862
  const needsRelated = body.includes("data-canopy-related-items");
863
+ const needsHero = body.includes("data-canopy-hero");
814
864
  const needsCommand = body.includes("data-canopy-command");
815
865
  const needsHydrate =
816
866
  body.includes("data-canopy-hydrate") ||
@@ -836,6 +886,15 @@ async function buildIiifCollectionPages(CONFIG) {
836
886
  .split(path.sep)
837
887
  .join("/")
838
888
  : null;
889
+ const heroRel = needsHero
890
+ ? path
891
+ .relative(
892
+ path.dirname(outPath),
893
+ path.join(OUT_DIR, "scripts", "canopy-hero.js")
894
+ )
895
+ .split(path.sep)
896
+ .join("/")
897
+ : null;
839
898
  const relatedRel = needsRelated
840
899
  ? path
841
900
  .relative(
@@ -856,11 +915,12 @@ async function buildIiifCollectionPages(CONFIG) {
856
915
  : null;
857
916
 
858
917
  let jsRel = null;
859
- if (needsRelated && sliderRel) jsRel = sliderRel;
918
+ if (needsHero && heroRel) jsRel = heroRel;
919
+ else if (needsRelated && sliderRel) jsRel = sliderRel;
860
920
  else if (viewerRel) jsRel = viewerRel;
861
921
 
862
922
  let headExtra = head;
863
- const needsReact = !!(needsHydrateViewer || needsRelated);
923
+ const needsReact = !!(needsHydrateViewer || needsRelated || needsHero);
864
924
  let vendorTag = "";
865
925
  if (needsReact) {
866
926
  try {
@@ -881,6 +941,8 @@ async function buildIiifCollectionPages(CONFIG) {
881
941
  } catch (_) {}
882
942
  }
883
943
  const extraScripts = [];
944
+ if (heroRel && jsRel !== heroRel)
945
+ extraScripts.push(`<script defer src="${heroRel}"></script>`);
884
946
  if (relatedRel && jsRel !== relatedRel)
885
947
  extraScripts.push(`<script defer src="${relatedRel}"></script>`);
886
948
  if (viewerRel && jsRel !== viewerRel)
@@ -979,6 +1041,10 @@ module.exports = {
979
1041
  loadConfig,
980
1042
  loadManifestIndex,
981
1043
  saveManifestIndex,
1044
+ // Expose helpers used by build for cache warming
1045
+ loadCachedManifestById,
1046
+ saveCachedManifest,
1047
+ ensureFeaturedInCache,
982
1048
  };
983
1049
 
984
1050
  // Debug: list collections cache after traversal
package/lib/build/mdx.js CHANGED
@@ -44,15 +44,210 @@ async function getMdxProvider() {
44
44
  }
45
45
 
46
46
  // Lazily load UI components from the workspace package and cache them.
47
+ // Re-import when the built UI server bundle changes on disk.
47
48
  let UI_COMPONENTS = null;
49
+ let UI_COMPONENTS_PATH = '';
50
+ let UI_COMPONENTS_MTIME = 0;
51
+ const DEBUG = process.env.CANOPY_DEBUG === '1' || process.env.CANOPY_DEBUG === 'true';
48
52
  async function loadUiComponents() {
49
- if (UI_COMPONENTS) return UI_COMPONENTS;
53
+ // Do not rely on a cached mapping; re-import each time to avoid transient races.
54
+ const fallbackCommand = function CommandPalette(props = {}) {
55
+ const {
56
+ placeholder = 'Search…',
57
+ hotkey = 'mod+k',
58
+ maxResults = 8,
59
+ groupOrder = ['work', 'page'],
60
+ button = true,
61
+ buttonLabel = 'Search',
62
+ label,
63
+ searchPath = '/search',
64
+ } = props || {};
65
+ const text = (typeof label === 'string' && label.trim()) ? label.trim() : buttonLabel;
66
+ let json = "{}";
67
+ try { json = JSON.stringify({ placeholder, hotkey, maxResults, groupOrder, label: text, searchPath }); } catch (_) { json = "{}"; }
68
+ return React.createElement(
69
+ 'div',
70
+ { 'data-canopy-command': true, className: 'flex-1 min-w-0' },
71
+ // Teaser form fallback
72
+ React.createElement('div', { className: 'relative w-full' },
73
+ React.createElement('style', { dangerouslySetInnerHTML: { __html: ".relative[data-canopy-panel-auto='1']:focus-within [data-canopy-command-panel]{display:block}" } }),
74
+ React.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' },
75
+ // Left icon
76
+ React.createElement('span', { 'aria-hidden': true, className: 'text-slate-500' }, '🔎'),
77
+ // Input teaser
78
+ React.createElement('input', { type: 'search', name: 'q', 'data-canopy-command-input': true, placeholder, 'aria-label': 'Search', className: 'flex-1 bg-transparent outline-none placeholder:text-slate-400 py-0.5 min-w-0' }),
79
+ // Right submit (navigates)
80
+ React.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' }, text)
81
+ ),
82
+ // SSR placeholder panel; runtime will reuse and control visibility
83
+ React.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: '8px', boxShadow: '0 10px 25px rgba(0,0,0,0.12)', zIndex: 1000, overflow: 'auto', maxHeight: '60vh' } },
84
+ React.createElement('div', { id: 'cplist' })
85
+ )
86
+ ),
87
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: json } })
88
+ );
89
+ };
50
90
  try {
51
- // Use server-safe UI subset to avoid importing browser-only components
52
- const mod = await import("@canopy-iiif/app/ui/server");
53
- UI_COMPONENTS = mod || {};
91
+ // Prefer resolving the actual dist file and busting cache via mtime to pick up new exports during dev
92
+ let resolved = null;
93
+ // Prefer explicit dist path to avoid export-map issues
94
+ try { resolved = require.resolve("@canopy-iiif/app/ui/dist/server.mjs"); } catch (_) {
95
+ try { resolved = require.resolve("@canopy-iiif/app/ui/server"); } catch (_) { resolved = null; }
96
+ }
97
+ // Determine current mtime for change detection
98
+ let currentPath = resolved || '';
99
+ let currentMtime = 0;
100
+ if (currentPath) {
101
+ try { const st = fs.statSync(currentPath); currentMtime = Math.floor(st.mtimeMs || 0); } catch (_) { currentMtime = 0; }
102
+ }
103
+ // If we have a cached module and the path/mtime have not changed, return cached
104
+ if (UI_COMPONENTS && UI_COMPONENTS_PATH === currentPath && UI_COMPONENTS_MTIME === currentMtime) {
105
+ if (DEBUG) {
106
+ try { console.log('[canopy][mdx] UI components cache hit:', { path: UI_COMPONENTS_PATH, mtime: UI_COMPONENTS_MTIME }); } catch(_){}
107
+ }
108
+ return UI_COMPONENTS;
109
+ }
110
+ let mod = null;
111
+ if (resolved) {
112
+ const { pathToFileURL } = require("url");
113
+ let bust = currentMtime ? `?v=${currentMtime}` : `?v=${Date.now()}`;
114
+ mod = await import(pathToFileURL(resolved).href + bust).catch((e) => {
115
+ if (DEBUG) { try { console.warn('[canopy][mdx] ESM import failed for', resolved, '\n', e && (e.stack || e.message || String(e))); } catch(_){} }
116
+ return null;
117
+ });
118
+ if (DEBUG) {
119
+ try { console.log('[canopy][mdx] UI components resolved', { path: resolved, mtime: currentMtime, loaded: !!mod }); } catch(_){}
120
+ }
121
+ }
122
+ if (!mod) {
123
+ mod = await import("@canopy-iiif/app/ui/server").catch((e) => {
124
+ if (DEBUG) { try { console.warn('[canopy][mdx] Export-map import failed for @canopy-iiif/app/ui/server', '\n', e && (e.stack || e.message || String(e))); } catch(_){} }
125
+ return null;
126
+ });
127
+ if (DEBUG) {
128
+ try { console.log('[canopy][mdx] UI components fallback import via export map', { ok: !!mod }); } catch(_){}
129
+ }
130
+ }
131
+ let comp = (mod && typeof mod === 'object') ? mod : {};
132
+ // Fallback: import workspace source directly if key exports missing
133
+ const needFallback = !(comp && (comp.CommandPalette || comp.FeaturedHero || comp.Viewer));
134
+ if (needFallback) {
135
+ try {
136
+ const ws = path.join(process.cwd(), 'packages', 'app', 'ui', 'server.js');
137
+ if (fs.existsSync(ws)) {
138
+ const { pathToFileURL } = require('url');
139
+ // Use workspace server.js mtime for busting
140
+ let bustWs = `?v=${Date.now()}`;
141
+ try { const st = fs.statSync(ws); currentPath = ws; currentMtime = Math.floor(st.mtimeMs || 0); bustWs = `?v=${currentMtime}`; } catch (_) {}
142
+ const wsMod = await import(pathToFileURL(ws).href + bustWs).catch(() => null);
143
+ if (wsMod && typeof wsMod === 'object') {
144
+ comp = { ...wsMod, ...comp };
145
+ }
146
+ if (DEBUG) {
147
+ try { console.log('[canopy][mdx] UI components augmented from workspace source', { ws, ok: !!wsMod }); } catch(_){}
148
+ }
149
+ }
150
+ } catch (_) {}
151
+ }
152
+ // Ensure core placeholders exist to avoid MDX compile failures
153
+ if (!comp.CommandPalette) comp.CommandPalette = fallbackCommand;
154
+ if (!comp.SearchPanel) comp.SearchPanel = comp.CommandPalette || fallbackCommand;
155
+ if (DEBUG) {
156
+ try { console.log('[canopy][mdx] UI component sources', {
157
+ path: currentPath,
158
+ mtime: currentMtime,
159
+ hasServerExport: !!mod,
160
+ hasWorkspace: typeof comp !== 'undefined',
161
+ CommandPalette: !!comp.CommandPalette,
162
+ Viewer: !!comp.Viewer,
163
+ Slider: !!comp.Slider,
164
+ }); } catch(_){}
165
+ }
166
+ const mkJson = (props) => {
167
+ try { return JSON.stringify(props || {}); } catch (_) { return '{}'; }
168
+ };
169
+ if (!comp.Viewer) comp.Viewer = function Viewer(props){
170
+ return React.createElement('div', { 'data-canopy-viewer': '1', className: 'not-prose' },
171
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
172
+ );
173
+ };
174
+ if (!comp.Slider) comp.Slider = function Slider(props){
175
+ return React.createElement('div', { 'data-canopy-slider': '1', className: 'not-prose' },
176
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
177
+ );
178
+ };
179
+ if (!comp.RelatedItems) comp.RelatedItems = function RelatedItems(props){
180
+ return React.createElement('div', { 'data-canopy-related-items': '1', className: 'not-prose' },
181
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
182
+ );
183
+ };
184
+ if (!comp.SearchForm) comp.SearchForm = function SearchForm(props){
185
+ return React.createElement('div', { 'data-canopy-search-form': '1' },
186
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
187
+ );
188
+ };
189
+ if (!comp.SearchResults) comp.SearchResults = function SearchResults(props){
190
+ return React.createElement('div', { 'data-canopy-search-results': '1' },
191
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
192
+ );
193
+ };
194
+ if (!comp.SearchSummary) comp.SearchSummary = function SearchSummary(props){
195
+ return React.createElement('div', { 'data-canopy-search-summary': '1' },
196
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
197
+ );
198
+ };
199
+ if (!comp.SearchTotal) comp.SearchTotal = function SearchTotal(props){
200
+ return React.createElement('div', { 'data-canopy-search-total': '1' },
201
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
202
+ );
203
+ };
204
+ if (!comp.SearchTabs) comp.SearchTabs = function SearchTabs(props){
205
+ return React.createElement('div', { 'data-canopy-search-tabs': '1' },
206
+ React.createElement('script', { type: 'application/json', dangerouslySetInnerHTML: { __html: mkJson(props) } })
207
+ );
208
+ };
209
+ // Ensure a minimal SSR Hero exists
210
+ if (!comp.Hero) {
211
+ comp.Hero = function SimpleHero({ height = 360, item, className = '', style = {}, ...rest }){
212
+ const h = typeof height === 'number' ? `${height}px` : String(height || '').trim() || '360px';
213
+ const base = { position: 'relative', width: '100%', height: h, overflow: 'hidden', backgroundColor: 'var(--color-gray-muted)', ...style };
214
+ const title = (item && item.title) || '';
215
+ const href = (item && item.href) || '#';
216
+ const thumbnail = (item && item.thumbnail) || '';
217
+ return React.createElement('div', { className: ['canopy-hero', className].filter(Boolean).join(' '), style: base, ...rest },
218
+ thumbnail ? React.createElement('img', { src: thumbnail, alt: '', 'aria-hidden': 'true', style: { position:'absolute', inset:0, width:'100%', height:'100%', objectFit:'cover', objectPosition:'center', filter:'none' } }) : null,
219
+ React.createElement('div', { className:'canopy-hero-overlay', style: { position:'absolute', left:0, right:0, bottom:0, padding:'1rem', background:'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.35) 55%, rgba(0,0,0,0.65) 100%)', color:'white' } },
220
+ React.createElement('h3', { style: { margin:0, fontSize:'1.5rem', fontWeight:600, lineHeight:1.2, textShadow:'0 1px 3px rgba(0,0,0,0.6)' } },
221
+ React.createElement('a', { href, style:{ color:'inherit', textDecoration:'none' }, className:'canopy-hero-link' }, title)
222
+ )
223
+ )
224
+ );
225
+ };
226
+ }
227
+ // Provide a minimal SSR FeaturedHero fallback if missing
228
+ if (!comp.FeaturedHero) {
229
+ try {
230
+ const helpers = require('../components/featured');
231
+ comp.FeaturedHero = function FeaturedHero(props) {
232
+ try {
233
+ const list = helpers && helpers.readFeaturedFromCacheSync ? helpers.readFeaturedFromCacheSync() : [];
234
+ if (!Array.isArray(list) || list.length === 0) return null;
235
+ const index = (props && typeof props.index === 'number') ? Math.max(0, Math.min(list.length - 1, Math.floor(props.index))) : null;
236
+ const pick = (index != null) ? index : ((props && (props.random === true || props.random === 'true')) ? Math.floor(Math.random() * list.length) : 0);
237
+ const item = list[pick] || list[0];
238
+ return React.createElement(comp.Hero, { ...props, item });
239
+ } catch (_) { return null; }
240
+ };
241
+ } catch (_) { /* ignore */ }
242
+ }
243
+ UI_COMPONENTS = comp;
244
+ UI_COMPONENTS_PATH = currentPath;
245
+ UI_COMPONENTS_MTIME = currentMtime;
54
246
  } catch (_) {
55
- UI_COMPONENTS = {};
247
+ // As a last resort, supply minimal stubs so pages still compile
248
+ UI_COMPONENTS = { CommandPalette: fallbackCommand };
249
+ UI_COMPONENTS_PATH = '';
250
+ UI_COMPONENTS_MTIME = 0;
56
251
  }
57
252
  return UI_COMPONENTS;
58
253
  }
@@ -672,6 +867,79 @@ async function ensureReactGlobals() {
672
867
  });
673
868
  }
674
869
 
870
+ // Bundle a small runtime to hydrate <Hero /> placeholders from featured items
871
+ async function ensureHeroRuntime() {
872
+ let esbuild = null;
873
+ try { esbuild = require("../../ui/node_modules/esbuild"); } catch (_) { try { esbuild = require("esbuild"); } catch (_) {} }
874
+ if (!esbuild) return;
875
+ const { path } = require("../common");
876
+ ensureDirSync(OUT_DIR);
877
+ const scriptsDir = path.join(OUT_DIR, 'scripts');
878
+ ensureDirSync(scriptsDir);
879
+ const outFile = path.join(scriptsDir, 'canopy-hero.js');
880
+ const entry = `
881
+ import { Hero } from '@canopy-iiif/app/ui';
882
+ function ready(fn){ if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn(); }
883
+ function parseProps(el){ try{ const s=el.querySelector('script[type="application/json"]'); if(s) return JSON.parse(s.textContent||'{}'); }catch(_){ } return {}; }
884
+ function rootBase(){ try { var bp=(window&&window.CANOPY_BASE_PATH)?String(window.CANOPY_BASE_PATH):''; return bp && bp.endsWith('/') ? bp.slice(0,-1) : bp; } catch(_){ return ''; } }
885
+ async function getApiVersion(){ try{ const u=rootBase() + '/api/index.json'; const res=await fetch(u).catch(()=>null); const j=res&&res.ok?await res.json().catch(()=>null):null; return (j && j.version) || ''; }catch(_){ return ''; } }
886
+ async function loadFeatured(){ try { const v = await getApiVersion(); const q = v ? ('?v='+encodeURIComponent(v)) : ''; const res = await fetch(rootBase() + '/api/featured.json' + q).catch(()=>null); const j = res && res.ok ? await res.json().catch(()=>[]) : []; return Array.isArray(j) ? j : (j && j.items) || []; } catch(_){ return []; } }
887
+ function mount(el, rec){ try{ const React=(window&&window.React)||null; const RDC=(window&&window.ReactDOMClient)||null; const createRoot = RDC && RDC.createRoot; if(!React||!createRoot) return; const props = parseProps(el) || {}; const height = props.height || 360; const node = React.createElement(Hero, { height, item: rec }); const root=createRoot(el); root.render(node); } catch(_){} }
888
+ ready(async function(){ const hosts = Array.from(document.querySelectorAll('[data-canopy-hero]')); if(!hosts.length) return; const featured = await loadFeatured(); if(!featured.length) return; hosts.forEach((el, i) => { try { const p = parseProps(el) || {}; let idx = 0; if (p && typeof p.index === 'number') idx = Math.max(0, Math.min(featured.length-1, Math.floor(p.index))); else if (p && (p.random===true || p.random==='true')) idx = Math.floor(Math.random() * featured.length); const rec = featured[idx] || featured[0]; if (rec) mount(el, rec); } catch(_){} }); });
889
+ `;
890
+ const reactShim = `
891
+ const React = (typeof window !== 'undefined' && window.React) || {};
892
+ export default React;
893
+ export const Children = React.Children;
894
+ export const Component = React.Component;
895
+ export const Fragment = React.Fragment;
896
+ export const createElement = React.createElement;
897
+ export const cloneElement = React.cloneElement;
898
+ export const createContext = React.createContext;
899
+ export const forwardRef = React.forwardRef;
900
+ export const memo = React.memo;
901
+ export const startTransition = React.startTransition;
902
+ export const isValidElement = React.isValidElement;
903
+ export const useEffect = React.useEffect;
904
+ export const useLayoutEffect = React.useLayoutEffect;
905
+ export const useMemo = React.useMemo;
906
+ export const useState = React.useState;
907
+ export const useRef = React.useRef;
908
+ export const useCallback = React.useCallback;
909
+ export const useContext = React.useContext;
910
+ export const useReducer = React.useReducer;
911
+ export const useId = React.useId;
912
+ `;
913
+ const rdomClientShim = `
914
+ const RDC = (typeof window !== 'undefined' && window.ReactDOMClient) || {};
915
+ export default RDC;
916
+ export const createRoot = RDC.createRoot;
917
+ export const hydrateRoot = RDC.hydrateRoot;
918
+ `;
919
+ const plugin = {
920
+ name: 'canopy-react-shims-hero',
921
+ setup(build) {
922
+ const ns = 'canopy-shim';
923
+ build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: ns }));
924
+ build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ path: 'react-dom-client', namespace: ns }));
925
+ build.onLoad({ filter: /^react$/, namespace: ns }, () => ({ contents: reactShim, loader: 'js' }));
926
+ build.onLoad({ filter: /^react-dom-client$/, namespace: ns }, () => ({ contents: rdomClientShim, loader: 'js' }));
927
+ }
928
+ };
929
+ await esbuild.build({
930
+ stdin: { contents: entry, resolveDir: process.cwd(), sourcefile: 'canopy-hero-entry.js', loader: 'js' },
931
+ outfile: outFile,
932
+ platform: 'browser',
933
+ format: 'iife',
934
+ bundle: true,
935
+ sourcemap: false,
936
+ target: ['es2018'],
937
+ logLevel: 'silent',
938
+ minify: true,
939
+ plugins: [plugin],
940
+ });
941
+ }
942
+
675
943
  module.exports = {
676
944
  extractTitle,
677
945
  isReservedFile,
@@ -682,6 +950,7 @@ module.exports = {
682
950
  loadAppWrapper,
683
951
  ensureClientRuntime,
684
952
  ensureSliderRuntime,
953
+ ensureHeroRuntime,
685
954
  ensureFacetsRuntime,
686
955
  ensureReactGlobals,
687
956
  resetMdxCaches: function () {
@@ -689,5 +958,6 @@ module.exports = {
689
958
  DIR_LAYOUTS.clear();
690
959
  } catch (_) {}
691
960
  APP_WRAPPER = null;
961
+ UI_COMPONENTS = null;
692
962
  },
693
963
  };
@@ -62,9 +62,13 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
62
62
  const facetsRel = needsFacets
63
63
  ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-related-items.js')).split(path.sep).join('/')
64
64
  : null;
65
- const commandRel = needsCommand
66
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-command.js')).split(path.sep).join('/')
67
- : null;
65
+ let commandRel = null;
66
+ if (needsCommand) {
67
+ const cmdAbs = path.join(OUT_DIR, 'scripts', 'canopy-command.js');
68
+ let rel = path.relative(path.dirname(outPath), cmdAbs).split(path.sep).join('/');
69
+ try { const st = fs.statSync(cmdAbs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
70
+ commandRel = rel;
71
+ }
68
72
  let jsRel = null;
69
73
  if (needsFacets && sliderRel) jsRel = sliderRel;
70
74
  else if (viewerRel) jsRel = viewerRel;
@@ -6,6 +6,12 @@ async function prepareAllRuntimes() {
6
6
  const mdx = require('./mdx');
7
7
  try { await mdx.ensureClientRuntime(); } catch (_) {}
8
8
  try { if (typeof mdx.ensureSliderRuntime === 'function') await mdx.ensureSliderRuntime(); } catch (_) {}
9
+ // Optional: Hero runtime is SSR-only by default; enable explicitly to avoid bundling Node deps in browser
10
+ try {
11
+ if (process.env.CANOPY_ENABLE_HERO_RUNTIME === '1' || process.env.CANOPY_ENABLE_HERO_RUNTIME === 'true') {
12
+ if (typeof mdx.ensureHeroRuntime === 'function') await mdx.ensureHeroRuntime();
13
+ }
14
+ } catch (_) {}
9
15
  try { if (typeof mdx.ensureFacetsRuntime === 'function') await mdx.ensureFacetsRuntime(); } catch (_) {}
10
16
  try { if (typeof mdx.ensureReactGlobals === 'function') await mdx.ensureReactGlobals(); } catch (_) {}
11
17
  try { await ensureCommandFallback(); } catch (_) {}
@@ -24,15 +30,63 @@ async function ensureCommandFallback() {
24
30
  function norm(s){ try{ return String(s||'').toLowerCase(); }catch(_){ return ''; } }
25
31
  function withBase(href){ try{ var bp = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : ''; if(!bp) return href; if(/^https?:/i.test(href)) return href; var clean = href.replace(/^\\/+/, ''); return (bp.endsWith('/') ? bp.slice(0,-1) : bp) + '/' + clean; } catch(_){ return href; } }
26
32
  function rootBase(){ try { var bp = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : ''; return bp && bp.endsWith('/') ? bp.slice(0,-1) : bp; } catch(_) { return ''; } }
27
- function createUI(){ var root=document.createElement('div'); root.setAttribute('data-canopy-command-fallback',''); root.style.cssText='position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;background:rgba(0,0,0,0.3);z-index:9999;padding-top:10vh;'; root.innerHTML='<div style="position:relative;background:#fff;min-width:320px;max-width:720px;width:90%;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.2);overflow:hidden;font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial,sans-serif"><button id="cpclose" aria-label="Close" style="position:absolute;top:8px;right:8px;border:1px solid #e5e7eb;background:#fff;border-radius:6px;padding:2px 6px;cursor:pointer">&times;</button><div style="padding:10px 12px;border-bottom:1px solid #e5e7eb"><input id="cpq" type="text" placeholder="Search…" style="width:100%;padding:8px 10px;border:1px solid #e5e7eb;border-radius:6px;outline:none"/></div><div id="cplist" style="max-height:50vh;overflow:auto;padding:6px 0"></div></div>'; document.body.appendChild(root); return root; }
33
+ function isOnSearchPage(){ try{ var base=rootBase(); var p=String(location.pathname||''); if(base && p.startsWith(base)) p=p.slice(base.length); if(p.endsWith('/')) p=p.slice(0,-1); return p==='/search'; }catch(_){ return false; } }
34
+ function createUI(host){ var rel=(function(){ try{ var w=host.querySelector('.relative'); return w||host; }catch(_){ return host; } })(); try{ var cs = window.getComputedStyle(rel); if(!cs || cs.position==='static') rel.style.position='relative'; }catch(_){ } var existing = rel.querySelector('[data-canopy-command-panel]'); if(existing) return existing; var panel=document.createElement('div'); panel.setAttribute('data-canopy-command-panel',''); panel.style.cssText='position:absolute;left:0;right:0;top:calc(100% + 4px);display:none;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,0.12);z-index:1000;overflow:auto;max-height:60vh;'; panel.innerHTML='<div id="cplist"></div>'; rel.appendChild(panel); return panel; }
28
35
  async function loadRecords(){ try{ var v=''; try{ var m = await fetch(rootBase() + '/api/index.json').then(function(r){return r&&r.ok?r.json():null;}).catch(function(){return null;}); v=(m&&m.version)||''; }catch(_){} var res = await fetch(rootBase() + '/api/search-index.json' + (v?('?v='+encodeURIComponent(v)):'')).catch(function(){return null;}); var j = res && res.ok ? await res.json().catch(function(){return[];}) : []; return Array.isArray(j) ? j : (j && j.records) || []; } catch(_){ return []; } }
29
- ready(async function(){ var host=document.querySelector('[data-canopy-command]'); if(!host) return; var cfg=parseProps(host)||{}; var maxResults = Number(cfg.maxResults||8)||8; var groupOrder = Array.isArray(cfg.groupOrder)?cfg.groupOrder:['work','page']; var overlay=createUI(); var input=overlay.querySelector('#cpq'); var list=overlay.querySelector('#cplist'); var btnClose=overlay.querySelector('#cpclose'); var records = await loadRecords(); function render(items){ list.innerHTML=''; if(!items.length){ list.innerHTML='<div style="padding:10px 12px;color:#6b7280">No results found.</div>'; return; } var groups=new Map(); items.forEach(function(r){ var t=String(r.type||'page'); if(!groups.has(t)) groups.set(t, []); groups.get(t).push(r); }); function gl(t){ if(t==='work') return 'Works'; if(t==='page') return 'Pages'; return t.charAt(0).toUpperCase()+t.slice(1);} var ordered=[].concat(groupOrder.filter(function(t){return groups.has(t);})).concat(Array.from(groups.keys()).filter(function(t){return groupOrder.indexOf(t)===-1;})); ordered.forEach(function(t){ var hdr=document.createElement('div'); hdr.textContent=gl(t); hdr.style.cssText='padding:6px 12px;font-weight:600;color:#374151'; list.appendChild(hdr); groups.get(t).forEach(function(r){ var it=document.createElement('div'); it.tabIndex=0; it.style.cssText='display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer'; var thumb=(String(r.type||'')==='work' && r.thumbnail)?r.thumbnail:''; if(thumb){ var img=document.createElement('img'); img.src=thumb; img.alt=''; img.style.cssText='width:40px;height:40px;object-fit:cover;border-radius:4px'; it.appendChild(img);} var span=document.createElement('span'); span.textContent=r.title||r.href; it.appendChild(span); it.onmouseenter=function(){ it.style.background='#f8fafc'; }; it.onmouseleave=function(){ it.style.background='transparent'; }; it.onclick=function(){ try{ window.location.href = withBase(String(r.href||'')); }catch(_){} overlay.style.display='none'; }; list.appendChild(it); }); }); }
30
- function filterAndShow(q){ try{ var qq=norm(q); if(!qq){ render([]); return; } var out=[]; for(var i=0;i<records.length;i++){ var r=records[i]; var title=String((r&&r.title)||''); if(!title) continue; if(norm(title).indexOf(qq)!==-1) out.push(r); if(out.length>=maxResults) break; } render(out); }catch(_){} }
31
- btnClose.addEventListener('click', function(){ overlay.style.display='none'; });
32
- input.addEventListener('input', function(){ filterAndShow(input.value||''); });
33
- document.addEventListener('keydown', function(e){ if(e.key==='Escape'){ overlay.style.display='none'; }});
34
- host.addEventListener('click', function(e){ var trg=e.target && e.target.closest && e.target.closest('[data-canopy-command-trigger]'); if(trg){ e.preventDefault(); overlay.style.display='flex'; input.focus(); filterAndShow(input.value||''); }});
35
- var btn = document.querySelector('[data-canopy-command-trigger]'); if(btn){ btn.addEventListener('click', function(){ overlay.style.display='flex'; input.focus(); filterAndShow(input.value||''); }); }
36
+ ready(async function(){ var host=document.querySelector('[data-canopy-command]'); if(!host) return; var cfg=parseProps(host)||{}; var maxResults = Number(cfg.maxResults||8)||8; var groupOrder = Array.isArray(cfg.groupOrder)?cfg.groupOrder:['work','page']; var onSearchPage = isOnSearchPage(); var panel=createUI(host); try{ var rel=host.querySelector('.relative'); if(rel && !onSearchPage) rel.setAttribute('data-canopy-panel-auto','1'); }catch(_){} if(onSearchPage){ panel.style.display='none'; } var list=panel.querySelector('#cplist'); var teaser = host.querySelector('[data-canopy-command-input]'); var input = teaser; if(!input){ input = document.createElement('input'); input.type='search'; input.placeholder = cfg.placeholder || 'Search…'; input.setAttribute('aria-label','Search'); input.style.cssText='display:block;width:100%;padding:8px 10px;border-bottom:1px solid #e5e7eb;outline:none;'; panel.insertBefore(input, list); }
37
+ // Populate from ?q= URL param if present
38
+ try {
39
+ var sp = new URLSearchParams(location.search || '');
40
+ var qp = sp.get('q');
41
+ if (qp) input.value = qp;
42
+ } catch(_) {}
43
+ // Only inject a legacy trigger button if neither a trigger nor a teaser input exists
44
+ try{ if(!host.querySelector('[data-canopy-command-trigger]') && !host.querySelector('[data-canopy-command-input]')){ var btn=document.createElement('button'); btn.type='button'; btn.setAttribute('data-canopy-command-trigger',''); btn.setAttribute('aria-label','Open search'); btn.className='inline-flex items-center gap-2 px-3 py-1.5 rounded border border-slate-300 text-slate-700 hover:bg-slate-50'; var lbl=((cfg&&cfg.label)||(cfg&&cfg.buttonLabel)||'Search'); var sLbl=document.createElement('span'); sLbl.textContent=String(lbl); var sK=document.createElement('span'); sK.setAttribute('aria-hidden','true'); sK.className='text-slate-500'; sK.textContent='⌘K'; btn.appendChild(sLbl); btn.appendChild(sK); host.appendChild(btn); } }catch(_){ }
45
+ var records = await loadRecords(); function render(items){ list.innerHTML=''; if(!items.length){ panel.style.display='none'; return; } var groups=new Map(); items.forEach(function(r){ var t=String(r.type||'page'); if(!groups.has(t)) groups.set(t, []); groups.get(t).push(r); }); function gl(t){ if(t==='work') return 'Works'; if(t==='page') return 'Pages'; return t.charAt(0).toUpperCase()+t.slice(1);} var ordered=[].concat(groupOrder.filter(function(t){return groups.has(t);})).concat(Array.from(groups.keys()).filter(function(t){return groupOrder.indexOf(t)===-1;})); ordered.forEach(function(t){ var hdr=document.createElement('div'); hdr.textContent=gl(t); hdr.style.cssText='padding:6px 12px;font-weight:600;color:#374151'; list.appendChild(hdr); groups.get(t).forEach(function(r){ var it=document.createElement('div'); it.setAttribute('data-canopy-item',''); it.tabIndex=0; it.style.cssText='display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;outline:none;'; var thumb=(String(r.type||'')==='work' && r.thumbnail)?r.thumbnail:''; if(thumb){ var img=document.createElement('img'); img.src=thumb; img.alt=''; img.style.cssText='width:40px;height:40px;object-fit:cover;border-radius:4px'; it.appendChild(img);} var span=document.createElement('span'); span.textContent=r.title||r.href; it.appendChild(span); it.onmouseenter=function(){ it.style.background='#f8fafc'; }; it.onmouseleave=function(){ it.style.background='transparent'; }; it.onfocus=function(){ it.style.background='#eef2ff'; try{ it.scrollIntoView({ block: 'nearest' }); }catch(_){} }; it.onblur=function(){ it.style.background='transparent'; }; it.onclick=function(){ try{ window.location.href = withBase(String(r.href||'')); }catch(_){} panel.style.display='none'; }; list.appendChild(it); }); }); }
46
+ function getItems(){ try{ return Array.prototype.slice.call(list.querySelectorAll('[data-canopy-item]')); }catch(_){ return []; } }
47
+ function filterAndShow(q){ try{ var qq=norm(q); if(!qq){ try{ panel.style.display='block'; list.innerHTML=''; }catch(_){} return; } var out=[]; for(var i=0;i<records.length;i++){ var r=records[i]; var title=String((r&&r.title)||''); if(!title) continue; if(norm(title).indexOf(qq)!==-1) out.push(r); if(out.length>=maxResults) break; } render(out); }catch(_){} }
48
+ input.addEventListener('input', function(){ if(onSearchPage){ try{ var ev = new CustomEvent('canopy:search:setQuery', { detail: { query: (input.value||'') } }); window.dispatchEvent(ev); }catch(_){ } return; } filterAndShow(input.value||''); });
49
+ // Keyboard navigation: ArrowDown/ArrowUp to move through items; Enter to select
50
+ input.addEventListener('keydown', function(e){
51
+ if(e.key==='ArrowDown'){ e.preventDefault(); try{ var items=getItems(); if(items.length){ panel.style.display='block'; items[0].focus(); } }catch(_){} }
52
+ else if(e.key==='ArrowUp'){ e.preventDefault(); try{ var items2=getItems(); if(items2.length){ panel.style.display='block'; items2[items2.length-1].focus(); } }catch(_){} }
53
+ });
54
+ list.addEventListener('keydown', function(e){
55
+ var cur = e.target && e.target.closest && e.target.closest('[data-canopy-item]');
56
+ if(!cur) return;
57
+ if(e.key==='ArrowDown'){
58
+ e.preventDefault();
59
+ try{ var arr=getItems(); var i=arr.indexOf(cur); var nxt=arr[Math.min(arr.length-1, i+1)]||cur; nxt.focus(); }catch(_){}
60
+ } else if(e.key==='ArrowUp'){
61
+ e.preventDefault();
62
+ try{ var arr2=getItems(); var i2=arr2.indexOf(cur); if(i2<=0){ input && input.focus && input.focus(); } else { var prv=arr2[i2-1]; (prv||cur).focus(); } }catch(_){}
63
+ } else if(e.key==='Enter'){
64
+ e.preventDefault(); try{ cur.click(); }catch(_){}
65
+ } else if(e.key==='Escape'){
66
+ panel.style.display='none'; try{ input && input.focus && input.focus(); }catch(_){}
67
+ }
68
+ });
69
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape'){ panel.style.display='none'; }});
70
+ document.addEventListener('mousedown', function(e){ try{ if(!panel.contains(e.target) && !host.contains(e.target)){ panel.style.display='none'; } }catch(_){} });
71
+ // Hotkey support (e.g., mod+k)
72
+ document.addEventListener('keydown', function(e){
73
+ try {
74
+ var want = String((cfg && cfg.hotkey) || '').toLowerCase();
75
+ if (!want) return;
76
+ var isMod = e.metaKey || e.ctrlKey;
77
+ if ((want === 'mod+k' || want === 'cmd+k' || want === 'ctrl+k') && isMod && (e.key === 'k' || e.key === 'K')) {
78
+ e.preventDefault();
79
+ if(onSearchPage){ try{ var ev2 = new CustomEvent('canopy:search:setQuery', { detail: { hotkey: true } }); window.dispatchEvent(ev2); }catch(_){ } return; }
80
+ panel.style.display='block';
81
+ (input && input.focus && input.focus());
82
+ filterAndShow(input && input.value || '');
83
+ }
84
+ } catch(_) { }
85
+ });
86
+ function openPanel(){ if(onSearchPage){ try{ var ev3 = new CustomEvent('canopy:search:setQuery', { detail: {} }); window.dispatchEvent(ev3); }catch(_){ } return; } panel.style.display='block'; (input && input.focus && input.focus()); filterAndShow(input && input.value || ''); }
87
+ host.addEventListener('click', function(e){ var trg=e.target && e.target.closest && e.target.closest('[data-canopy-command-trigger]'); if(trg){ e.preventDefault(); openPanel(); }});
88
+ var btn = document.querySelector('[data-canopy-command-trigger]'); if(btn){ btn.addEventListener('click', function(){ openPanel(); }); }
89
+ try{ var teaser2 = host.querySelector('[data-canopy-command-input]'); if(teaser2){ teaser2.addEventListener('focus', function(){ openPanel(); }); } }catch(_){}
36
90
  });
37
91
  })();
38
92
  `;