@canopy-iiif/app 0.6.28

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/mdx.js ADDED
@@ -0,0 +1,690 @@
1
+ const React = require("react");
2
+ const ReactDOMServer = require("react-dom/server");
3
+ const { pathToFileURL } = require("url");
4
+ const {
5
+ fs,
6
+ fsp,
7
+ path,
8
+ CONTENT_DIR,
9
+ OUT_DIR,
10
+ CACHE_DIR,
11
+ ensureDirSync,
12
+ withBase,
13
+ } = require("./common");
14
+ const yaml = require("js-yaml");
15
+
16
+ function parseFrontmatter(src) {
17
+ let input = String(src || "");
18
+ // Strip UTF-8 BOM if present
19
+ if (input.charCodeAt(0) === 0xfeff) input = input.slice(1);
20
+ // Allow a few leading blank lines before frontmatter
21
+ const m = input.match(/^(?:\s*\r?\n)*---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/);
22
+ if (!m) return { data: null, content: input };
23
+ let data = null;
24
+ try {
25
+ data = yaml.load(m[1]) || null;
26
+ } catch (_) {
27
+ data = null;
28
+ }
29
+ const content = input.slice(m[0].length);
30
+ return { data, content };
31
+ }
32
+
33
+ // ESM-only in v3; load dynamically from CJS
34
+ let MDXProviderCached = null;
35
+ async function getMdxProvider() {
36
+ if (MDXProviderCached) return MDXProviderCached;
37
+ try {
38
+ const mod = await import("@mdx-js/react");
39
+ MDXProviderCached = mod.MDXProvider || mod.default;
40
+ } catch (_) {
41
+ MDXProviderCached = null;
42
+ }
43
+ return MDXProviderCached;
44
+ }
45
+
46
+ // Lazily load UI components from the workspace package and cache them.
47
+ let UI_COMPONENTS = null;
48
+ async function loadUiComponents() {
49
+ if (UI_COMPONENTS) return UI_COMPONENTS;
50
+ 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 || {};
54
+ } catch (_) {
55
+ UI_COMPONENTS = {};
56
+ }
57
+ return UI_COMPONENTS;
58
+ }
59
+
60
+ function extractTitle(mdxSource) {
61
+ const { content } = parseFrontmatter(String(mdxSource || ""));
62
+ const m = content.match(/^\s*#\s+(.+)\s*$/m);
63
+ return m ? m[1].trim() : "Untitled";
64
+ }
65
+
66
+ function isReservedFile(p) {
67
+ const base = path.basename(p);
68
+ return base.startsWith("_");
69
+ }
70
+
71
+ // Cache for directory-scoped layouts
72
+ const DIR_LAYOUTS = new Map();
73
+ async function getNearestDirLayout(filePath) {
74
+ const dirStart = path.dirname(filePath);
75
+ let dir = dirStart;
76
+ while (dir && dir.startsWith(CONTENT_DIR)) {
77
+ const key = path.resolve(dir);
78
+ if (DIR_LAYOUTS.has(key)) {
79
+ const cached = DIR_LAYOUTS.get(key);
80
+ if (cached) return cached;
81
+ }
82
+ const candidate = path.join(dir, "_layout.mdx");
83
+ if (fs.existsSync(candidate)) {
84
+ try {
85
+ const Comp = await compileMdxToComponent(candidate);
86
+ DIR_LAYOUTS.set(key, Comp);
87
+ return Comp;
88
+ } catch (_) {
89
+ DIR_LAYOUTS.set(key, null);
90
+ }
91
+ } else {
92
+ DIR_LAYOUTS.set(key, null);
93
+ }
94
+ const parent = path.dirname(dir);
95
+ if (parent === dir) break;
96
+ dir = parent;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ let APP_WRAPPER = null; // { App, Head } or null
102
+ async function loadAppWrapper() {
103
+ if (APP_WRAPPER !== null) return APP_WRAPPER;
104
+ const appPath = path.join(CONTENT_DIR, "_app.mdx");
105
+ if (!fs.existsSync(appPath)) {
106
+ // Keep missing _app as a build-time error as specified
107
+ throw new Error("Missing required file: content/_app.mdx");
108
+ }
109
+ const { compile } = await import("@mdx-js/mdx");
110
+ const raw = await fsp.readFile(appPath, "utf8");
111
+ const { content: source } = parseFrontmatter(raw);
112
+ let code = String(
113
+ await compile(source, {
114
+ jsx: false,
115
+ development: false,
116
+ providerImportSource: "@mdx-js/react",
117
+ jsxImportSource: "react",
118
+ format: "mdx",
119
+ })
120
+ );
121
+ // MDX v3 default export (MDXContent) does not forward external children.
122
+ // When present, expose the underlying layout function as __MDXLayout for wrapping.
123
+ if (
124
+ /\bconst\s+MDXLayout\b/.test(code) &&
125
+ !/export\s+const\s+__MDXLayout\b/.test(code)
126
+ ) {
127
+ code += "\nexport const __MDXLayout = MDXLayout;\n";
128
+ }
129
+ ensureDirSync(CACHE_DIR);
130
+ const tmpFile = path.join(CACHE_DIR, "_app.mjs");
131
+ await fsp.writeFile(tmpFile, code, "utf8");
132
+ const mod = await import(pathToFileURL(tmpFile).href + `?v=${Date.now()}`);
133
+ let App = mod.App || mod.__MDXLayout || mod.default || null;
134
+ const Head = mod.Head || null;
135
+ // Prefer a component that renders its children, but do not hard-fail if probe fails.
136
+ let ok = false;
137
+ try {
138
+ const probe = React.createElement(
139
+ App || (() => null),
140
+ null,
141
+ React.createElement("span", { "data-canopy-probe": "1" })
142
+ );
143
+ const out = ReactDOMServer.renderToStaticMarkup(probe);
144
+ ok = !!(out && out.indexOf("data-canopy-probe") !== -1);
145
+ } catch (_) {
146
+ ok = false;
147
+ }
148
+ if (!ok) {
149
+ // If default export swallowed children, try to recover using __MDXLayout
150
+ if (!App && mod.__MDXLayout) {
151
+ App = mod.__MDXLayout;
152
+ }
153
+ // Fallback to pass-through wrapper to avoid blocking builds
154
+ if (!App) {
155
+ App = function PassThrough(props) {
156
+ return React.createElement(React.Fragment, null, props.children);
157
+ };
158
+ }
159
+ try {
160
+ require("./log").log(
161
+ "! Warning: content/_app.mdx did not clearly render {children}; proceeding with best-effort wrapper\n",
162
+ "yellow"
163
+ );
164
+ } catch (_) {
165
+ console.warn(
166
+ "Warning: content/_app.mdx did not clearly render {children}; proceeding."
167
+ );
168
+ }
169
+ }
170
+ APP_WRAPPER = { App, Head };
171
+ return APP_WRAPPER;
172
+ }
173
+
174
+ async function compileMdxFile(filePath, outPath, Layout, extraProps = {}) {
175
+ const { compile } = await import("@mdx-js/mdx");
176
+ const raw = await fsp.readFile(filePath, "utf8");
177
+ const { content: source } = parseFrontmatter(raw);
178
+ const compiled = await compile(source, {
179
+ jsx: false,
180
+ development: false,
181
+ providerImportSource: "@mdx-js/react",
182
+ jsxImportSource: "react",
183
+ format: "mdx",
184
+ });
185
+ const code = String(compiled);
186
+ ensureDirSync(CACHE_DIR);
187
+ const relCacheName =
188
+ path
189
+ .relative(CONTENT_DIR, filePath)
190
+ .replace(/[\\/]/g, "_")
191
+ .replace(/\.mdx$/i, "") + ".mjs";
192
+ const tmpFile = path.join(CACHE_DIR, relCacheName);
193
+ await fsp.writeFile(tmpFile, code, "utf8");
194
+ // Bust ESM module cache using source mtime
195
+ let bust = "";
196
+ try {
197
+ const st = fs.statSync(filePath);
198
+ bust = `?v=${Math.floor(st.mtimeMs)}`;
199
+ } catch (_) {}
200
+ const mod = await import(pathToFileURL(tmpFile).href + bust);
201
+ const MDXContent = mod.default || mod.MDXContent || mod;
202
+ const components = await loadUiComponents();
203
+ const MDXProvider = await getMdxProvider();
204
+ // Base path support for anchors
205
+ const Anchor = function A(props) {
206
+ let { href = "", ...rest } = props || {};
207
+ href = withBase(href);
208
+ return React.createElement("a", { href, ...rest }, props.children);
209
+ };
210
+ const app = await loadAppWrapper();
211
+ const dirLayout = await getNearestDirLayout(filePath);
212
+ const contentNode = React.createElement(MDXContent, extraProps);
213
+ const withLayout = dirLayout
214
+ ? React.createElement(dirLayout, null, contentNode)
215
+ : contentNode;
216
+ const withApp = React.createElement(app.App, null, withLayout);
217
+ const compMap = { ...components, a: Anchor };
218
+ const page = MDXProvider
219
+ ? React.createElement(MDXProvider, { components: compMap }, withApp)
220
+ : withApp;
221
+ const body = ReactDOMServer.renderToStaticMarkup(page);
222
+ const head =
223
+ app && app.Head
224
+ ? ReactDOMServer.renderToStaticMarkup(React.createElement(app.Head))
225
+ : "";
226
+ return { body, head };
227
+ }
228
+
229
+ async function compileMdxToComponent(filePath) {
230
+ const { compile } = await import("@mdx-js/mdx");
231
+ const raw = await fsp.readFile(filePath, "utf8");
232
+ const { content: source } = parseFrontmatter(raw);
233
+ const compiled = await compile(source, {
234
+ jsx: false,
235
+ development: false,
236
+ providerImportSource: "@mdx-js/react",
237
+ jsxImportSource: "react",
238
+ format: "mdx",
239
+ });
240
+ const code = String(compiled);
241
+ ensureDirSync(CACHE_DIR);
242
+ const relCacheName =
243
+ path
244
+ .relative(CONTENT_DIR, filePath)
245
+ .replace(/[\\/]/g, "_")
246
+ .replace(/\.mdx$/i, "") + ".mjs";
247
+ const tmpFile = path.join(CACHE_DIR, relCacheName);
248
+ await fsp.writeFile(tmpFile, code, "utf8");
249
+ let bust = "";
250
+ try {
251
+ const st = fs.statSync(filePath);
252
+ bust = `?v=${Math.floor(st.mtimeMs)}`;
253
+ } catch (_) {}
254
+ const mod = await import(pathToFileURL(tmpFile).href + bust);
255
+ return mod.default || mod.MDXContent || mod;
256
+ }
257
+
258
+ async function loadCustomLayout(defaultLayout) {
259
+ // Deprecated: directory-scoped layouts handled per-page via getNearestDirLayout
260
+ return defaultLayout;
261
+ }
262
+
263
+ async function ensureClientRuntime() {
264
+ // Bundle a lightweight client runtime to hydrate browser-only components
265
+ // like the Clover Viewer when placeholders are present in the HTML.
266
+ let esbuild = null;
267
+ try {
268
+ esbuild = require("../ui/node_modules/esbuild");
269
+ } catch (_) {
270
+ try {
271
+ esbuild = require("esbuild");
272
+ } catch (_) {}
273
+ }
274
+ if (!esbuild) return;
275
+ ensureDirSync(OUT_DIR);
276
+ const scriptsDir = path.join(OUT_DIR, 'scripts');
277
+ ensureDirSync(scriptsDir);
278
+ const outFile = path.join(scriptsDir, "canopy-viewer.js");
279
+ const entry = `
280
+ import CloverViewer from '@samvera/clover-iiif/viewer';
281
+
282
+ function ready(fn) {
283
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
284
+ else fn();
285
+ }
286
+
287
+ function parseProps(el) {
288
+ try {
289
+ const s = el.querySelector('script[type="application/json"]');
290
+ if (s) return JSON.parse(s.textContent || '{}');
291
+ const raw = el.getAttribute('data-props') || '{}';
292
+ return JSON.parse(raw);
293
+ } catch (_) { return {}; }
294
+ }
295
+
296
+ ready(function() {
297
+ try {
298
+ const nodes = document.querySelectorAll('[data-canopy-viewer]');
299
+ if (!nodes || !nodes.length) return;
300
+ for (const el of nodes) {
301
+ try {
302
+ const props = parseProps(el);
303
+ const React = (window && window.React) || null;
304
+ const ReactDOMClient = (window && window.ReactDOMClient) || null;
305
+ const createRoot = ReactDOMClient && ReactDOMClient.createRoot;
306
+ if (!React || !createRoot) continue;
307
+ const root = createRoot(el);
308
+ root.render(React.createElement(CloverViewer, props));
309
+ } catch (_) { /* skip */ }
310
+ }
311
+ } catch (_) { /* no-op */ }
312
+ });
313
+ `;
314
+ const reactShim = `
315
+ const React = (typeof window !== 'undefined' && window.React) || {};
316
+ export default React;
317
+ export const Children = React.Children;
318
+ export const Component = React.Component;
319
+ export const Fragment = React.Fragment;
320
+ export const createElement = React.createElement;
321
+ export const cloneElement = React.cloneElement;
322
+ export const createContext = React.createContext;
323
+ export const forwardRef = React.forwardRef;
324
+ export const memo = React.memo;
325
+ export const startTransition = React.startTransition;
326
+ export const isValidElement = React.isValidElement;
327
+ export const useEffect = React.useEffect;
328
+ export const useLayoutEffect = React.useLayoutEffect;
329
+ export const useMemo = React.useMemo;
330
+ export const useState = React.useState;
331
+ export const useRef = React.useRef;
332
+ export const useCallback = React.useCallback;
333
+ export const useContext = React.useContext;
334
+ export const useReducer = React.useReducer;
335
+ export const useId = React.useId;
336
+ `;
337
+ const rdomShim = `
338
+ const ReactDOM = (typeof window !== 'undefined' && window.ReactDOM) || {};
339
+ export default ReactDOM;
340
+ export const render = ReactDOM.render;
341
+ export const unmountComponentAtNode = ReactDOM.unmountComponentAtNode;
342
+ export const findDOMNode = ReactDOM.findDOMNode;
343
+ `;
344
+ const rdomClientShim = `
345
+ const RDC = (typeof window !== 'undefined' && window.ReactDOMClient) || {};
346
+ export default RDC;
347
+ export const createRoot = RDC.createRoot;
348
+ export const hydrateRoot = RDC.hydrateRoot;
349
+ `;
350
+ const plugin = {
351
+ name: 'canopy-react-shims',
352
+ setup(build) {
353
+ const ns = 'canopy-shim';
354
+ build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: ns }));
355
+ build.onResolve({ filter: /^react-dom$/ }, () => ({ path: 'react-dom', namespace: ns }));
356
+ build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ path: 'react-dom-client', namespace: ns }));
357
+ build.onLoad({ filter: /^react$/, namespace: ns }, () => ({ contents: reactShim, loader: 'js' }));
358
+ build.onLoad({ filter: /^react-dom$/, namespace: ns }, () => ({ contents: rdomShim, loader: 'js' }));
359
+ build.onLoad({ filter: /^react-dom-client$/, namespace: ns }, () => ({ contents: rdomClientShim, loader: 'js' }));
360
+ }
361
+ };
362
+ await esbuild.build({
363
+ stdin: {
364
+ contents: entry,
365
+ resolveDir: process.cwd(),
366
+ sourcefile: "canopy-viewer-entry.js",
367
+ loader: "js",
368
+ },
369
+ outfile: outFile,
370
+ platform: "browser",
371
+ format: "iife",
372
+ bundle: true,
373
+ sourcemap: false,
374
+ target: ["es2018"],
375
+ logLevel: "silent",
376
+ minify: true,
377
+ plugins: [plugin],
378
+ });
379
+ try {
380
+ const { logLine } = require('./log');
381
+ let size = 0; try { const st = fs.statSync(outFile); size = st && st.size || 0; } catch (_) {}
382
+ const kb = size ? ` (${(size/1024).toFixed(1)} KB)` : '';
383
+ const rel = path.relative(process.cwd(), outFile).split(path.sep).join('/');
384
+ logLine(`✓ Wrote ${rel}${kb}`, 'cyan');
385
+ } catch (_) {}
386
+ }
387
+
388
+ // Facets runtime: fetches /api/search/facets.json, picks a value per label (random from top 3),
389
+ // and renders a Slider for each.
390
+ async function ensureFacetsRuntime() {
391
+ let esbuild = null;
392
+ try { esbuild = require("../ui/node_modules/esbuild"); } catch (_) { try { esbuild = require("esbuild"); } catch (_) {} }
393
+ ensureDirSync(OUT_DIR);
394
+ const scriptsDir = path.join(OUT_DIR, 'scripts');
395
+ ensureDirSync(scriptsDir);
396
+ const outFile = path.join(scriptsDir, 'canopy-related-items.js');
397
+ const entry = `
398
+ function ready(fn){ if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',fn,{once:true}); else fn(); }
399
+ function parseProps(el){ try{ const s=el.querySelector('script[type="application/json"]'); if(s) return JSON.parse(s.textContent||'{}'); }catch(_){ } return {}; }
400
+ function rootBase(){
401
+ try {
402
+ var bp = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : '';
403
+ if (bp && bp.charAt(bp.length - 1) === '/') return bp.slice(0, -1);
404
+ return bp;
405
+ } catch(_){ return ''; }
406
+ }
407
+ function pickRandomTop(values, topN){ const arr=(values||[]).slice().sort((a,b)=> (b.doc_count||0)-(a.doc_count||0) || String(a.value).localeCompare(String(b.value))); const n=Math.min(topN||3, arr.length); if(!n) return null; const i=Math.floor(Math.random()*n); return arr[i]; }
408
+ function makeSliderPlaceholder(props){ try{ const el=document.createElement('div'); el.setAttribute('data-canopy-slider','1'); const s=document.createElement('script'); s.type='application/json'; s.textContent=JSON.stringify(props||{}); el.appendChild(s); return el; }catch(_){ return null; } }
409
+ function slugify(s){ try{ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,''); }catch(_){ return ''; } }
410
+ function firstI18nString(x){ if(!x) return ''; if(typeof x==='string') return x; try{ const keys=Object.keys(x||{}); if(!keys.length) return ''; const arr=x[keys[0]]; if(Array.isArray(arr)&&arr.length) return String(arr[0]); }catch(_){ } return ''; }
411
+ async function fetchManifestValues(iiif){ try{ const res = await fetch(iiif, { headers: { 'Accept': 'application/json' } }).catch(()=>null); if(!res||!res.ok) return null; const m = await res.json().catch(()=>null); if(!m) return null; const meta = Array.isArray(m.metadata)? m.metadata : []; const out = []; for (const entry of meta){ if(!entry) continue; const label = firstI18nString(entry.label); if(!label) continue; const vals = []; try { if (typeof entry.value === 'string') vals.push(entry.value); else { const obj = entry.value || {}; for (const k of Object.keys(obj)) { const arr = Array.isArray(obj[k]) ? obj[k] : []; for (const v of arr) if (v) vals.push(String(v)); } } } catch(_){} if (vals.length) out.push({ label, values: vals.map((v)=>({ value: v, valueSlug: slugify(v) })) }); }
412
+ return out; } catch(_){ return null; } }
413
+ async function getApiVersion(){
414
+ try {
415
+ const u = rootBase() + '/api/index.json';
416
+ const res = await fetch(u).catch(()=>null);
417
+ const j = res && res.ok ? await res.json().catch(()=>null) : null;
418
+ return (j && typeof j.version === 'string') ? j.version : '';
419
+ } catch(_) { return ''; }
420
+ }
421
+ ready(function(){
422
+ const nodes = document.querySelectorAll('[data-canopy-related-items]');
423
+ nodes.forEach(async (el) => {
424
+ try {
425
+ const props = parseProps(el) || {};
426
+ const labelsFilter = Array.isArray(props.labels) ? props.labels.map(String) : null;
427
+ const topN = Number(props.top || 3) || 3;
428
+ const ver = await getApiVersion();
429
+ const verQ = ver ? ('?v=' + encodeURIComponent(ver)) : '';
430
+ const res = await fetch(rootBase() + '/api/search/facets.json' + verQ).catch(()=>null);
431
+ if(!res || !res.ok) return;
432
+ const json = await res.json().catch(()=>null);
433
+ if(!Array.isArray(json)) return;
434
+ // Build lookup for allowed labels and value slugs
435
+ const allowedLabels = new Map(); // label -> { slug, values: Set(valueSlug) }
436
+ json.forEach((f)=>{ if(!f||!f.label||!Array.isArray(f.values)) return; if(labelsFilter && !labelsFilter.includes(String(f.label))) return; const vs = new Set((f.values||[]).map((v)=> String((v && v.slug) || slugify(v && v.value)))); allowedLabels.set(String(f.label), { slug: f.slug || slugify(f.label), values: vs }); });
437
+
438
+ const manifestIiif = props.iiifContent && String(props.iiifContent);
439
+ if (manifestIiif) {
440
+ const mv = await fetchManifestValues(manifestIiif);
441
+ if (!mv || !mv.length) return;
442
+ // For each label present on the manifest, choose ONE value at random (from values also present in the index)
443
+ mv.forEach((entry) => {
444
+ const allow = allowedLabels.get(String(entry.label));
445
+ if (!allow) return; // skip labels not indexed
446
+ const candidates = (entry.values || []).filter((vv) => allow.values.has(vv.valueSlug));
447
+ if (!candidates.length) return;
448
+ const pick = candidates[Math.floor(Math.random() * candidates.length)];
449
+ const wrap = document.createElement('div');
450
+ wrap.setAttribute('data-facet-label', entry.label);
451
+ const ph = makeSliderPlaceholder({ iiifContent: rootBase() + '/api/facet/' + (allow.slug) + '/' + pick.valueSlug + '.json' + verQ });
452
+ if (ph) wrap.appendChild(ph);
453
+ el.appendChild(wrap);
454
+ });
455
+ return;
456
+ }
457
+
458
+ // Homepage/default mode: pick a random top value per allowed label
459
+ const selected = [];
460
+ json.forEach((f) => { if(!f || !f.label || !Array.isArray(f.values)) return; if(labelsFilter && !labelsFilter.includes(String(f.label))) return; const pick = pickRandomTop(f.values, topN); if(pick) selected.push({ label: f.label, labelSlug: f.slug || slugify(f.label), value: pick.value, valueSlug: (pick.slug) || slugify(pick.value) }); });
461
+ selected.forEach((s) => {
462
+ const wrap = document.createElement('div');
463
+ wrap.setAttribute('data-facet-label', s.label);
464
+ const ph = makeSliderPlaceholder({ iiifContent: rootBase() + '/api/facet/' + s.labelSlug + '/' + s.valueSlug + '.json' + verQ });
465
+ if (ph) wrap.appendChild(ph);
466
+ el.appendChild(wrap);
467
+ });
468
+ } catch(_) { }
469
+ });
470
+ });
471
+ `;
472
+ const shim = { name: 'facets-vanilla', setup(){} };
473
+ if (esbuild) {
474
+ try {
475
+ await esbuild.build({ stdin: { contents: entry, resolveDir: process.cwd(), sourcefile: 'canopy-facets-entry.js', loader: 'js' }, outfile: outFile, platform: 'browser', format: 'iife', bundle: true, sourcemap: false, target: ['es2018'], logLevel: 'silent', minify: true, plugins: [shim] });
476
+ } catch(e){ try{ console.error('RelatedItems: bundle error:', e && e.message ? e.message : e); }catch(_){ }
477
+ // Fallback: write the entry script directly so the file exists
478
+ try { fs.writeFileSync(outFile, entry, 'utf8'); } catch(_){}
479
+ return; }
480
+ try { const { logLine } = require('./log'); let size=0; try{ const st = fs.statSync(outFile); size = st && st.size || 0; }catch(_){} const kb = size ? ` (${(size/1024).toFixed(1)} KB)` : ''; const rel = path.relative(process.cwd(), outFile).split(path.sep).join('/'); logLine(`✓ Wrote ${rel}${kb}`, 'cyan'); } catch(_){}
481
+ } else {
482
+ // No esbuild: write a non-bundled version (no imports used)
483
+ try { fs.writeFileSync(outFile, entry, 'utf8'); } catch(_){}
484
+ }
485
+ }
486
+
487
+ // Bundle a separate client runtime for the Clover Slider to keep payloads split.
488
+ async function ensureSliderRuntime() {
489
+ let esbuild = null;
490
+ try {
491
+ esbuild = require("../ui/node_modules/esbuild");
492
+ } catch (_) {
493
+ try { esbuild = require("esbuild"); } catch (_) {}
494
+ }
495
+ if (!esbuild) return;
496
+ ensureDirSync(OUT_DIR);
497
+ const scriptsDir = path.join(OUT_DIR, 'scripts');
498
+ ensureDirSync(scriptsDir);
499
+ const outFile = path.join(scriptsDir, "canopy-slider.js");
500
+ const entry = `
501
+ import CloverSlider from '@samvera/clover-iiif/slider';
502
+ import 'swiper/css';
503
+ import 'swiper/css/navigation';
504
+ import 'swiper/css/pagination';
505
+
506
+ function ready(fn) {
507
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
508
+ else fn();
509
+ }
510
+ function parseProps(el) {
511
+ try {
512
+ const s = el.querySelector('script[type="application/json"]');
513
+ if (s) return JSON.parse(s.textContent || '{}');
514
+ const raw = el.getAttribute('data-props') || '{}';
515
+ return JSON.parse(raw);
516
+ } catch (_) { return {}; }
517
+ }
518
+ function mount(el){
519
+ try{
520
+ if (!el || el.getAttribute('data-canopy-slider-mounted')==='1') return;
521
+ const React = (window && window.React) || null;
522
+ const ReactDOMClient = (window && window.ReactDOMClient) || null;
523
+ const createRoot = ReactDOMClient && ReactDOMClient.createRoot;
524
+ if (!React || !createRoot) return;
525
+ const props = parseProps(el);
526
+ const root = createRoot(el);
527
+ root.render(React.createElement(CloverSlider, props));
528
+ el.setAttribute('data-canopy-slider-mounted','1');
529
+ } catch(_){}
530
+ }
531
+ function scan(){
532
+ try{ document.querySelectorAll('[data-canopy-slider]:not([data-canopy-slider-mounted="1"])').forEach(mount); }catch(_){ }
533
+ }
534
+ function observe(){
535
+ try{
536
+ const obs = new MutationObserver((muts)=>{
537
+ const toMount = [];
538
+ for (const m of muts){
539
+ m.addedNodes && m.addedNodes.forEach((n)=>{
540
+ if (!(n instanceof Element)) return;
541
+ if (n.matches && n.matches('[data-canopy-slider]')) toMount.push(n);
542
+ const inner = n.querySelectorAll ? n.querySelectorAll('[data-canopy-slider]') : [];
543
+ inner && inner.forEach && inner.forEach((x)=> toMount.push(x));
544
+ });
545
+ }
546
+ if (toMount.length) Promise.resolve().then(()=> toMount.forEach(mount));
547
+ });
548
+ obs.observe(document.documentElement || document.body, { childList: true, subtree: true });
549
+ }catch(_){ }
550
+ }
551
+ ready(function(){ scan(); observe(); });
552
+ `;
553
+ const reactShim = `
554
+ const React = (typeof window !== 'undefined' && window.React) || {};
555
+ export default React;
556
+ export const Children = React.Children;
557
+ export const Component = React.Component;
558
+ export const Fragment = React.Fragment;
559
+ export const createElement = React.createElement;
560
+ export const cloneElement = React.cloneElement;
561
+ export const createContext = React.createContext;
562
+ export const forwardRef = React.forwardRef;
563
+ export const memo = React.memo;
564
+ export const startTransition = React.startTransition;
565
+ export const isValidElement = React.isValidElement;
566
+ export const useEffect = React.useEffect;
567
+ export const useLayoutEffect = React.useLayoutEffect;
568
+ export const useMemo = React.useMemo;
569
+ export const useState = React.useState;
570
+ export const useRef = React.useRef;
571
+ export const useCallback = React.useCallback;
572
+ export const useContext = React.useContext;
573
+ export const useReducer = React.useReducer;
574
+ export const useId = React.useId;
575
+ `;
576
+ const rdomClientShim = `
577
+ const RDC = (typeof window !== 'undefined' && window.ReactDOMClient) || {};
578
+ export const createRoot = RDC.createRoot;
579
+ export const hydrateRoot = RDC.hydrateRoot;
580
+ `;
581
+ const plugin = {
582
+ name: 'canopy-react-shims-slider',
583
+ setup(build) {
584
+ const ns = 'canopy-shim';
585
+ build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: ns }));
586
+ build.onResolve({ filter: /^react-dom$/ }, () => ({ path: 'react-dom', namespace: ns }));
587
+ build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ path: 'react-dom-client', namespace: ns }));
588
+ build.onLoad({ filter: /^react$/, namespace: ns }, () => ({ contents: reactShim, loader: 'js' }));
589
+ build.onLoad({ filter: /^react-dom$/, namespace: ns }, () => ({ contents: "export default ((typeof window!=='undefined' && window.ReactDOM) || {});", loader: 'js' }));
590
+ build.onLoad({ filter: /^react-dom-client$/, namespace: ns }, () => ({ contents: rdomClientShim, loader: 'js' }));
591
+ // Inline imported CSS into a <style> tag at runtime so we don't need a separate CSS file
592
+ build.onLoad({ filter: /\.css$/ }, (args) => {
593
+ const fs = require('fs');
594
+ let css = '';
595
+ try { css = fs.readFileSync(args.path, 'utf8'); } catch (_) { css = ''; }
596
+ const js = [
597
+ `var css = ${JSON.stringify(css)};`,
598
+ `(function(){ try { var s = document.createElement('style'); s.setAttribute('data-canopy-slider-css',''); s.textContent = css; document.head.appendChild(s); } catch (e) {} })();`,
599
+ `export default css;`
600
+ ].join('\n');
601
+ return { contents: js, loader: 'js' };
602
+ });
603
+ }
604
+ };
605
+ try {
606
+ await esbuild.build({
607
+ stdin: { contents: entry, resolveDir: process.cwd(), sourcefile: 'canopy-slider-entry.js', loader: 'js' },
608
+ outfile: outFile,
609
+ platform: 'browser',
610
+ format: 'iife',
611
+ bundle: true,
612
+ sourcemap: false,
613
+ target: ['es2018'],
614
+ logLevel: 'silent',
615
+ minify: true,
616
+ plugins: [plugin],
617
+ });
618
+ } catch (e) {
619
+ try { console.error('Slider: bundle error:', e && e.message ? e.message : e); } catch (_) {}
620
+ return;
621
+ }
622
+ try {
623
+ const { logLine } = require('./log');
624
+ let size = 0; try { const st = fs.statSync(outFile); size = st && st.size || 0; } catch (_) {}
625
+ const kb = size ? ` (${(size/1024).toFixed(1)} KB)` : '';
626
+ const rel = path.relative(process.cwd(), outFile).split(path.sep).join('/');
627
+ logLine(`✓ Wrote ${rel}${kb}`, 'cyan');
628
+ } catch (_) {}
629
+ }
630
+
631
+ // Build a small React globals vendor for client-side React pages.
632
+ async function ensureReactGlobals() {
633
+ let esbuild = null;
634
+ try {
635
+ esbuild = require("../ui/node_modules/esbuild");
636
+ } catch (_) {
637
+ try {
638
+ esbuild = require("esbuild");
639
+ } catch (_) {}
640
+ }
641
+ if (!esbuild) return;
642
+ const { path } = require("./common");
643
+ ensureDirSync(OUT_DIR);
644
+ const scriptsDir = path.join(OUT_DIR, "scripts");
645
+ ensureDirSync(scriptsDir);
646
+ const vendorFile = path.join(scriptsDir, "react-globals.js");
647
+ const globalsEntry = `
648
+ import * as React from 'react';
649
+ import * as ReactDOM from 'react-dom';
650
+ import * as ReactDOMClient from 'react-dom/client';
651
+ (function(){ try{ window.React = React; window.ReactDOM = ReactDOM; window.ReactDOMClient = ReactDOMClient; }catch(e){} })();
652
+ `;
653
+ await esbuild.build({
654
+ stdin: {
655
+ contents: globalsEntry,
656
+ resolveDir: process.cwd(),
657
+ loader: "js",
658
+ sourcefile: "react-globals-entry.js",
659
+ },
660
+ outfile: vendorFile,
661
+ platform: "browser",
662
+ format: "iife",
663
+ bundle: true,
664
+ sourcemap: false,
665
+ target: ["es2018"],
666
+ logLevel: "silent",
667
+ minify: true,
668
+ define: { 'process.env.NODE_ENV': '"production"' },
669
+ });
670
+ }
671
+
672
+ module.exports = {
673
+ extractTitle,
674
+ isReservedFile,
675
+ parseFrontmatter,
676
+ compileMdxFile,
677
+ compileMdxToComponent,
678
+ loadCustomLayout,
679
+ loadAppWrapper,
680
+ ensureClientRuntime,
681
+ ensureSliderRuntime,
682
+ ensureFacetsRuntime,
683
+ ensureReactGlobals,
684
+ resetMdxCaches: function () {
685
+ try {
686
+ DIR_LAYOUTS.clear();
687
+ } catch (_) {}
688
+ APP_WRAPPER = null;
689
+ },
690
+ };