@canopy-iiif/app 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/build/dev.js CHANGED
@@ -15,7 +15,9 @@ const {
15
15
  ASSETS_DIR,
16
16
  ensureDirSync,
17
17
  } = require("../common");
18
- function resolveTailwindCli() {
18
+ const APP_COMPONENTS_DIR = path.join(process.cwd(), "app", "components");
19
+
20
+ function resolveTailwindCli() {
19
21
  const bin = path.join(
20
22
  process.cwd(),
21
23
  "node_modules",
@@ -35,6 +37,11 @@ const UI_DIST_DIR = path.resolve(path.join(__dirname, "../../ui/dist"));
35
37
  const APP_PACKAGE_ROOT = path.resolve(path.join(__dirname, "..", ".."));
36
38
  const APP_LIB_DIR = path.join(APP_PACKAGE_ROOT, "lib");
37
39
  const APP_UI_DIR = path.join(APP_PACKAGE_ROOT, "ui");
40
+ const CUSTOM_COMPONENT_EXTENSIONS = new Set(
41
+ [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"].map((
42
+ ext
43
+ ) => ext.toLowerCase())
44
+ );
38
45
  let loadUiTheme = null;
39
46
  try {
40
47
  const uiTheme = require(path.join(APP_UI_DIR, "theme.js"));
@@ -338,6 +345,74 @@ function watchAssetsPerDir() {
338
345
  };
339
346
  }
340
347
 
348
+ function handleCustomComponentChange(fullPath, eventType) {
349
+ if (!fullPath) return;
350
+ const ext = path.extname(fullPath).toLowerCase();
351
+ if (ext && !CUSTOM_COMPONENT_EXTENSIONS.has(ext)) return;
352
+ try {
353
+ console.log(`[components] ${eventType}: ${prettyPath(fullPath)}`);
354
+ } catch (_) {}
355
+ nextBuildSkipIiif = true;
356
+ try {
357
+ onBuildStart();
358
+ } catch (_) {}
359
+ debounceBuild();
360
+ }
361
+
362
+ function tryRecursiveWatchAppComponents(dir) {
363
+ try {
364
+ return fs.watch(dir, { recursive: true }, (eventType, filename) => {
365
+ if (!filename) return;
366
+ const full = path.join(dir, filename);
367
+ handleCustomComponentChange(full, eventType);
368
+ });
369
+ } catch (_) {
370
+ return null;
371
+ }
372
+ }
373
+
374
+ function watchAppComponentsPerDir(dir) {
375
+ const watchers = new Map();
376
+
377
+ function watchDir(target) {
378
+ if (watchers.has(target)) return;
379
+ try {
380
+ const watcher = fs.watch(target, (eventType, filename) => {
381
+ const full = filename ? path.join(target, filename) : target;
382
+ handleCustomComponentChange(full, eventType);
383
+ scan(target);
384
+ });
385
+ watchers.set(target, watcher);
386
+ } catch (_) {}
387
+ }
388
+
389
+ function scan(target) {
390
+ let entries = [];
391
+ try {
392
+ entries = fs.readdirSync(target, { withFileTypes: true });
393
+ } catch (_) {
394
+ return;
395
+ }
396
+ for (const entry of entries) {
397
+ if (!entry.isDirectory()) continue;
398
+ const next = path.join(target, entry.name);
399
+ watchDir(next);
400
+ scan(next);
401
+ }
402
+ }
403
+
404
+ watchDir(dir);
405
+ scan(dir);
406
+
407
+ return () => {
408
+ for (const watcher of watchers.values()) {
409
+ try {
410
+ watcher.close();
411
+ } catch (_) {}
412
+ }
413
+ };
414
+ }
415
+
341
416
  // Watch @canopy-iiif/app/ui dist output to enable live reload for UI edits during dev.
342
417
  // When UI dist changes, rebuild the search runtime bundle and trigger a browser reload.
343
418
  async function rebuildSearchBundle() {
@@ -1280,6 +1355,15 @@ async function dev() {
1280
1355
  const urw = tryRecursiveWatchUiDist();
1281
1356
  if (!urw) watchUiDistPerDir();
1282
1357
  }
1358
+ if (fs.existsSync(APP_COMPONENTS_DIR)) {
1359
+ console.log(
1360
+ "[Watching]",
1361
+ prettyPath(APP_COMPONENTS_DIR),
1362
+ "(app components)"
1363
+ );
1364
+ const crw = tryRecursiveWatchAppComponents(APP_COMPONENTS_DIR);
1365
+ if (!crw) watchAppComponentsPerDir(APP_COMPONENTS_DIR);
1366
+ }
1283
1367
  if (HAS_APP_WORKSPACE) {
1284
1368
  watchAppSources();
1285
1369
  }
package/lib/build/mdx.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const React = require("react");
2
2
  const ReactDOMServer = require("react-dom/server");
3
3
  const {pathToFileURL} = require("url");
4
+ const crypto = require("crypto");
4
5
  const {
5
6
  fs,
6
7
  fsp,
@@ -114,9 +115,221 @@ async function getMdxProvider() {
114
115
  let UI_COMPONENTS = null;
115
116
  let UI_COMPONENTS_PATH = "";
116
117
  let UI_COMPONENTS_MTIME = 0;
118
+ let MERGED_UI_COMPONENTS = null;
119
+ let MERGED_UI_KEY = "";
117
120
  const DEBUG =
118
121
  process.env.CANOPY_DEBUG === "1" || process.env.CANOPY_DEBUG === "true";
119
- async function loadUiComponents() {
122
+ const APP_COMPONENTS_DIR = path.join(process.cwd(), "app", "components");
123
+ const CUSTOM_COMPONENT_ENTRY_CANDIDATES = [
124
+ "mdx.tsx",
125
+ "mdx.ts",
126
+ "mdx.mts",
127
+ "mdx.cts",
128
+ "mdx.jsx",
129
+ "mdx.js",
130
+ "mdx.mjs",
131
+ "mdx.cjs",
132
+ "mdx-components.tsx",
133
+ "mdx-components.ts",
134
+ "mdx-components.mts",
135
+ "mdx-components.cts",
136
+ "mdx-components.jsx",
137
+ "mdx-components.js",
138
+ "mdx-components.mjs",
139
+ "mdx-components.cjs",
140
+ ];
141
+ const CUSTOM_COMPONENT_EXTENSIONS = new Set(
142
+ [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"].map(
143
+ (ext) => ext.toLowerCase()
144
+ )
145
+ );
146
+ let CUSTOM_MDX_COMPONENTS = null;
147
+ let CUSTOM_MDX_SIGNATURE = "";
148
+ let CUSTOM_CLIENT_COMPONENT_ENTRIES = [];
149
+ let CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS = new Map();
150
+ let SERVER_COMPONENT_CACHE = new Map();
151
+
152
+ function isPlainObject(val) {
153
+ if (!val || typeof val !== "object") return false;
154
+ const proto = Object.getPrototypeOf(val);
155
+ return !proto || proto === Object.prototype;
156
+ }
157
+
158
+ function serializeClientValue(value, depth = 0) {
159
+ if (value == null) return null;
160
+ if (depth > 4) return null;
161
+ const type = typeof value;
162
+ if (type === "string" || type === "number" || type === "boolean") {
163
+ return value;
164
+ }
165
+ if (Array.isArray(value)) {
166
+ const items = value
167
+ .map((entry) => serializeClientValue(entry, depth + 1))
168
+ .filter((entry) => typeof entry !== "undefined" && entry !== null);
169
+ return items;
170
+ }
171
+ if (React.isValidElement && React.isValidElement(value)) {
172
+ return null;
173
+ }
174
+ if (isPlainObject(value)) {
175
+ const out = {};
176
+ Object.keys(value).forEach((key) => {
177
+ const result = serializeClientValue(value[key], depth + 1);
178
+ if (typeof result !== "undefined" && result !== null) {
179
+ out[key] = result;
180
+ }
181
+ });
182
+ return out;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function serializeClientProps(props) {
188
+ if (!props || typeof props !== "object") return {};
189
+ const out = {};
190
+ Object.keys(props).forEach((key) => {
191
+ if (key === "children") {
192
+ const child = props[key];
193
+ if (
194
+ typeof child === "string" ||
195
+ typeof child === "number" ||
196
+ typeof child === "boolean"
197
+ ) {
198
+ out[key] = child;
199
+ }
200
+ return;
201
+ }
202
+ const serialized = serializeClientValue(props[key]);
203
+ if (typeof serialized !== "undefined" && serialized !== null) {
204
+ out[key] = serialized;
205
+ }
206
+ });
207
+ return out;
208
+ }
209
+
210
+ function createClientComponentPlaceholder(name) {
211
+ const safeName = String(name || "");
212
+ return function ClientComponentPlaceholder(props = {}) {
213
+ let payload = {};
214
+ try {
215
+ payload = serializeClientProps(props);
216
+ } catch (_) {
217
+ payload = {};
218
+ }
219
+ let json = "{}";
220
+ try {
221
+ json = JSON.stringify(payload || {});
222
+ } catch (_) {
223
+ json = "{}";
224
+ }
225
+ return React.createElement(
226
+ "div",
227
+ {
228
+ "data-canopy-client-component": safeName,
229
+ },
230
+ React.createElement("script", {type: "application/json"}, json)
231
+ );
232
+ };
233
+ }
234
+
235
+ function getClientComponentPlaceholder(name) {
236
+ const cached = CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS.get(name);
237
+ if (cached) return cached;
238
+ const placeholder = createClientComponentPlaceholder(name);
239
+ CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS.set(name, placeholder);
240
+ return placeholder;
241
+ }
242
+
243
+ async function loadServerComponentFromPath(name, spec) {
244
+ if (!spec || typeof spec !== "string") return null;
245
+ const esbuild = resolveEsbuild();
246
+ if (!esbuild)
247
+ throw new Error(
248
+ "Custom MDX components require esbuild. Install dependencies before building."
249
+ );
250
+ let resolved = spec;
251
+ if (!path.isAbsolute(resolved)) {
252
+ resolved = path.resolve(APP_COMPONENTS_DIR, spec);
253
+ }
254
+ if (!fs.existsSync(resolved)) {
255
+ throw new Error(
256
+ '[canopy][mdx] Component "' + String(name) + '" not found at ' + String(spec) + '. Ensure the file exists under app/components.'
257
+ );
258
+ }
259
+ let mtime = 0;
260
+ try {
261
+ const st = fs.statSync(resolved);
262
+ mtime = Math.floor(st.mtimeMs || 0);
263
+ } catch (_) {}
264
+ const cacheKey = resolved;
265
+ const cached = SERVER_COMPONENT_CACHE.get(cacheKey);
266
+ if (cached && cached.mtime === mtime && cached.component) {
267
+ return cached.component;
268
+ }
269
+ ensureDirSync(CACHE_DIR);
270
+ const hash = crypto.createHash("sha1").update(resolved).digest("hex");
271
+ const outFile = path.join(CACHE_DIR, "server-comp-" + hash + ".mjs");
272
+ await esbuild.build({
273
+ entryPoints: [resolved],
274
+ outfile: outFile,
275
+ bundle: true,
276
+ platform: "node",
277
+ target: "node18",
278
+ format: "esm",
279
+ jsx: "automatic",
280
+ jsxImportSource: "react",
281
+ sourcemap: false,
282
+ logLevel: "silent",
283
+ external: [
284
+ "react",
285
+ "react-dom",
286
+ "react-dom/server",
287
+ "react-dom/client",
288
+ "@canopy-iiif/app",
289
+ "@canopy-iiif/app/*",
290
+ ],
291
+ allowOverwrite: true,
292
+ });
293
+ const bust = mtime || Date.now();
294
+ const mod = await import(pathToFileURL(outFile).href + "?v=" + bust);
295
+ let component = null;
296
+ if (mod && typeof mod === "object") {
297
+ component = mod.default || mod[name] || null;
298
+ if (!isComponentLike(component)) {
299
+ component = Object.keys(mod)
300
+ .map((key) => mod[key])
301
+ .find((value) => isComponentLike(value));
302
+ }
303
+ }
304
+ if (!isComponentLike(component)) {
305
+ throw new Error(
306
+ '[canopy][mdx] Component "' +
307
+ String(name) +
308
+ '" from ' +
309
+ String(spec) +
310
+ ' did not export a valid React component. Ensure the module exports a default component or named export matching the key.'
311
+ );
312
+ }
313
+ SERVER_COMPONENT_CACHE.set(cacheKey, {mtime, component});
314
+ return component;
315
+ }
316
+
317
+ async function resolveServerComponentMap(source) {
318
+ const entries = Object.entries(source || {});
319
+ if (!entries.length) return {};
320
+ const resolved = {};
321
+ for (const [key, value] of entries) {
322
+ if (value == null) continue;
323
+ if (typeof value === "string") {
324
+ const component = await loadServerComponentFromPath(key, value);
325
+ if (component) resolved[key] = component;
326
+ } else {
327
+ resolved[key] = value;
328
+ }
329
+ }
330
+ return resolved;
331
+ }
332
+ async function loadCoreUiComponents() {
120
333
  // Do not rely on a cached mapping; re-import each time to avoid transient races.
121
334
  try {
122
335
  // Prefer the workspace dist path during dev to avoid export-map resolution issues
@@ -179,10 +392,10 @@ async function loadUiComponents() {
179
392
  const attempts = 5;
180
393
  for (let i = 0; i < attempts && !mod; i++) {
181
394
  const bustVal = currentMtime
182
- ? `${currentMtime}-${i}`
183
- : `${Date.now()}-${i}`;
395
+ ? String(currentMtime) + '-' + String(i)
396
+ : String(Date.now()) + '-' + String(i);
184
397
  try {
185
- mod = await import(fileUrl + `?v=${bustVal}`);
398
+ mod = await import(fileUrl + '?v=' + bustVal);
186
399
  } catch (e) {
187
400
  importErr = e;
188
401
  if (DEBUG) {
@@ -271,6 +484,250 @@ async function loadUiComponents() {
271
484
  return UI_COMPONENTS;
272
485
  }
273
486
 
487
+ function resolveCustomComponentsEntry() {
488
+ const baseDir = APP_COMPONENTS_DIR;
489
+ if (!baseDir) return null;
490
+ try {
491
+ if (!fs.existsSync(baseDir)) return null;
492
+ } catch (_) {
493
+ return null;
494
+ }
495
+ for (const candidate of CUSTOM_COMPONENT_ENTRY_CANDIDATES) {
496
+ const full = path.join(baseDir, candidate);
497
+ try {
498
+ const st = fs.statSync(full);
499
+ if (st && st.isFile()) return full;
500
+ } catch (_) {}
501
+ }
502
+ return null;
503
+ }
504
+
505
+ function computeCustomComponentsSignature(entryPath) {
506
+ const baseDir = fs.existsSync(APP_COMPONENTS_DIR)
507
+ ? APP_COMPONENTS_DIR
508
+ : path.dirname(entryPath);
509
+ let newest = 0;
510
+ const stack = [];
511
+ if (baseDir && fs.existsSync(baseDir)) stack.push(baseDir);
512
+ while (stack.length) {
513
+ const dir = stack.pop();
514
+ let entries = [];
515
+ try {
516
+ entries = fs.readdirSync(dir, {withFileTypes: true});
517
+ } catch (_) {
518
+ continue;
519
+ }
520
+ for (const entry of entries) {
521
+ const full = path.join(dir, entry.name);
522
+ if (entry.isDirectory()) {
523
+ stack.push(full);
524
+ continue;
525
+ }
526
+ const ext = path.extname(full).toLowerCase();
527
+ if (!CUSTOM_COMPONENT_EXTENSIONS.has(ext)) continue;
528
+ try {
529
+ const st = fs.statSync(full);
530
+ const mtime = st && st.mtimeMs ? Math.floor(st.mtimeMs) : 0;
531
+ if (mtime > newest) newest = mtime;
532
+ } catch (_) {}
533
+ }
534
+ }
535
+ const entryKey = (() => {
536
+ try {
537
+ return path.resolve(entryPath);
538
+ } catch (_) {
539
+ return entryPath;
540
+ }
541
+ })();
542
+ return String(entryKey) + ':' + String(newest);
543
+ }
544
+
545
+ async function compileCustomComponentModule(entryPath, signature) {
546
+ const esbuild = resolveEsbuild();
547
+ if (!esbuild)
548
+ throw new Error(
549
+ "Custom MDX components require esbuild. Install dependencies before building."
550
+ );
551
+ ensureDirSync(CACHE_DIR);
552
+ const rel = (() => {
553
+ try {
554
+ return path
555
+ .relative(process.cwd(), entryPath)
556
+ .split(path.sep)
557
+ .join("/");
558
+ } catch (_) {
559
+ return entryPath;
560
+ }
561
+ })();
562
+ const tmpFile = path.join(CACHE_DIR, "custom-mdx-components.mjs");
563
+ try {
564
+ await esbuild.build({
565
+ entryPoints: [entryPath],
566
+ outfile: tmpFile,
567
+ bundle: true,
568
+ platform: "node",
569
+ target: "node18",
570
+ format: "esm",
571
+ sourcemap: false,
572
+ logLevel: "silent",
573
+ jsx: "automatic",
574
+ jsxImportSource: "react",
575
+ external: [
576
+ "react",
577
+ "react-dom",
578
+ "react-dom/server",
579
+ "react-dom/client",
580
+ "@canopy-iiif/app",
581
+ "@canopy-iiif/app/*",
582
+ "@canopy-iiif/app/ui",
583
+ "@canopy-iiif/app/ui/*",
584
+ ],
585
+ allowOverwrite: true,
586
+ });
587
+ } catch (err) {
588
+ const msg = err && (err.message || err.stack) ? err.message || err.stack : err;
589
+ throw new Error(
590
+ 'Failed to compile custom MDX components (' +
591
+ rel +
592
+ ').\n' +
593
+ (msg || 'Unknown error')
594
+ );
595
+ }
596
+ const cacheBust = signature || String(Date.now());
597
+ return import(pathToFileURL(tmpFile).href + '?custom=' + cacheBust);
598
+ }
599
+
600
+ function isComponentLike(value) {
601
+ if (value == null) return false;
602
+ if (typeof value === "function") return true;
603
+ if (typeof value === "object") {
604
+ if (value.$$typeof) return true;
605
+ if (value.render && typeof value.render === "function") return true;
606
+ }
607
+ return false;
608
+ }
609
+
610
+ function cloneComponentMap(source) {
611
+ const out = {};
612
+ Object.keys(source || {}).forEach((key) => {
613
+ const value = source[key];
614
+ if (typeof value === "undefined" || value === null) return;
615
+ out[key] = value;
616
+ });
617
+ return out;
618
+ }
619
+
620
+ async function normalizeCustomComponentExports(mod) {
621
+ if (!mod || typeof mod !== "object") return {components: {}, clientEntries: []};
622
+ const candidateKeys = ["components", "mdxComponents", "MDXComponents"];
623
+ let components = {};
624
+ for (const key of candidateKeys) {
625
+ if (mod[key] && typeof mod[key] === "object") {
626
+ const cloned = cloneComponentMap(mod[key]);
627
+ if (Object.keys(cloned).length) {
628
+ components = cloned;
629
+ break;
630
+ }
631
+ }
632
+ }
633
+ if (!Object.keys(components).length && mod.default && typeof mod.default === "object") {
634
+ const cloned = cloneComponentMap(mod.default);
635
+ if (Object.keys(cloned).length) components = cloned;
636
+ }
637
+ if (!Object.keys(components).length) {
638
+ const fallback = {};
639
+ Object.keys(mod).forEach((key) => {
640
+ if (key === "default" || key === "__esModule") return;
641
+ const value = mod[key];
642
+ if (!isComponentLike(value)) return;
643
+ fallback[key] = value;
644
+ });
645
+ components = fallback;
646
+ }
647
+ if (Object.keys(components).length) {
648
+ components = await resolveServerComponentMap(components);
649
+ }
650
+ const clientEntries = [];
651
+ const rawClient = mod && typeof mod === "object" ? mod.clientComponents : null;
652
+ if (rawClient && typeof rawClient === "object") {
653
+ Object.keys(rawClient).forEach((key) => {
654
+ const spec = rawClient[key];
655
+ if (typeof spec !== "string" || !spec.trim()) return;
656
+ let resolved = spec.trim();
657
+ if (!path.isAbsolute(resolved)) {
658
+ resolved = path.resolve(APP_COMPONENTS_DIR, resolved);
659
+ }
660
+ if (!fs.existsSync(resolved)) {
661
+ throw new Error(
662
+ `[canopy][mdx] Client component "${key}" not found at ${spec}. Ensure the file exists under app/components.`
663
+ );
664
+ }
665
+ clientEntries.push({
666
+ name: String(key),
667
+ source: spec,
668
+ path: resolved,
669
+ });
670
+ });
671
+ }
672
+ if (clientEntries.length) {
673
+ clientEntries.forEach((entry) => {
674
+ components[entry.name] = getClientComponentPlaceholder(entry.name);
675
+ });
676
+ }
677
+ return {components, clientEntries};
678
+ }
679
+
680
+ async function loadCustomMdxComponents() {
681
+ const entry = resolveCustomComponentsEntry();
682
+ if (!entry) {
683
+ CUSTOM_MDX_COMPONENTS = null;
684
+ CUSTOM_MDX_SIGNATURE = "";
685
+ CUSTOM_CLIENT_COMPONENT_ENTRIES = [];
686
+ return null;
687
+ }
688
+ const signature = computeCustomComponentsSignature(entry);
689
+ if (
690
+ CUSTOM_MDX_COMPONENTS &&
691
+ CUSTOM_MDX_SIGNATURE &&
692
+ CUSTOM_MDX_SIGNATURE === signature
693
+ ) {
694
+ return {
695
+ components: CUSTOM_MDX_COMPONENTS,
696
+ signature,
697
+ clientEntries: CUSTOM_CLIENT_COMPONENT_ENTRIES.slice(),
698
+ };
699
+ }
700
+ const mod = await compileCustomComponentModule(entry, signature);
701
+ const {components, clientEntries} = await normalizeCustomComponentExports(mod);
702
+ CUSTOM_MDX_COMPONENTS = components;
703
+ CUSTOM_MDX_SIGNATURE = signature;
704
+ CUSTOM_CLIENT_COMPONENT_ENTRIES = Array.isArray(clientEntries)
705
+ ? clientEntries.slice()
706
+ : [];
707
+ return {components, signature, clientEntries: CUSTOM_CLIENT_COMPONENT_ENTRIES};
708
+ }
709
+
710
+ async function loadUiComponents() {
711
+ const baseComponents = await loadCoreUiComponents();
712
+ const custom = await loadCustomMdxComponents();
713
+ const customKey = custom && custom.signature ? custom.signature : "";
714
+ const compositeKey = `${UI_COMPONENTS_PATH || ""}:${
715
+ UI_COMPONENTS_MTIME || 0
716
+ }:${customKey}`;
717
+ if (MERGED_UI_COMPONENTS && MERGED_UI_KEY === compositeKey) {
718
+ return MERGED_UI_COMPONENTS;
719
+ }
720
+ const merged =
721
+ custom &&
722
+ custom.components &&
723
+ Object.keys(custom.components).length > 0
724
+ ? {...baseComponents, ...custom.components}
725
+ : baseComponents;
726
+ MERGED_UI_COMPONENTS = merged;
727
+ MERGED_UI_KEY = compositeKey;
728
+ return MERGED_UI_COMPONENTS;
729
+ }
730
+
274
731
  function slugifyHeading(text) {
275
732
  try {
276
733
  return String(text || "")
@@ -835,6 +1292,116 @@ async function ensureClientRuntime() {
835
1292
  return cloverRuntimePromise;
836
1293
  }
837
1294
 
1295
+ function getCustomClientComponentEntries() {
1296
+ return Array.isArray(CUSTOM_CLIENT_COMPONENT_ENTRIES)
1297
+ ? CUSTOM_CLIENT_COMPONENT_ENTRIES.slice()
1298
+ : [];
1299
+ }
1300
+
1301
+ let customClientRuntimePromise = null;
1302
+ let customClientRuntimeSignature = "";
1303
+
1304
+ function computeClientRuntimeSignature(entries) {
1305
+ if (!entries || !entries.length) return "";
1306
+ const parts = entries
1307
+ .map((entry) => {
1308
+ const abs = entry && entry.path ? entry.path : "";
1309
+ let mtime = 0;
1310
+ if (abs) {
1311
+ try {
1312
+ const st = fs.statSync(abs);
1313
+ mtime = Math.floor(st.mtimeMs || 0);
1314
+ } catch (_) {}
1315
+ }
1316
+ return `${entry.name || ""}:${abs}:${mtime}`;
1317
+ })
1318
+ .sort();
1319
+ return parts.join("|");
1320
+ }
1321
+
1322
+ async function buildCustomClientRuntime(entries) {
1323
+ const esbuild = resolveEsbuild();
1324
+ if (!esbuild) {
1325
+ throw new Error(
1326
+ "Custom client component hydration requires esbuild. Install dependencies before building."
1327
+ );
1328
+ }
1329
+ ensureDirSync(OUT_DIR);
1330
+ const scriptsDir = path.join(OUT_DIR, "scripts");
1331
+ ensureDirSync(scriptsDir);
1332
+ const outFile = path.join(scriptsDir, "canopy-custom-components.js");
1333
+ const imports = entries
1334
+ .map((entry, index) => {
1335
+ const ident = `Component${index}`;
1336
+ return `import ${ident} from ${JSON.stringify(entry.path)}; registry.set(${JSON.stringify(
1337
+ entry.name
1338
+ )}, ${ident});`;
1339
+ })
1340
+ .join("\n");
1341
+ const runtimeSource = `
1342
+ import React from 'react';
1343
+ import { createRoot } from 'react-dom/client';
1344
+ const registry = new Map();
1345
+ ${imports}
1346
+ function ready(fn){ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', fn, { once: true }); } else { fn(); } }
1347
+ function parseProps(node){ try { const script = node.querySelector('script[type="application/json"]'); if (!script) return {}; const text = script.textContent || '{}'; const data = JSON.parse(text); if (script.parentNode) { script.parentNode.removeChild(script); } return (data && typeof data === 'object') ? data : {}; } catch (_) { return {}; } }
1348
+ const roots = new WeakMap();
1349
+ function mount(node, Component){ if(!node || !Component) return; const props = parseProps(node); let root = roots.get(node); if(!root){ root = createRoot(node); roots.set(node, root); } root.render(React.createElement(Component, props)); }
1350
+ function hydrateAll(){
1351
+ const nodes = document.querySelectorAll('[data-canopy-client-component]');
1352
+ nodes.forEach((node) => {
1353
+ if (!node || node.__canopyClientMounted) return;
1354
+ const name = node.getAttribute('data-canopy-client-component');
1355
+ const Component = registry.get(name);
1356
+ if (!Component) return;
1357
+ mount(node, Component);
1358
+ node.__canopyClientMounted = true;
1359
+ });
1360
+ }
1361
+ ready(hydrateAll);
1362
+ `;
1363
+ await esbuild.build({
1364
+ stdin: {
1365
+ contents: runtimeSource,
1366
+ resolveDir: process.cwd(),
1367
+ sourcefile: "canopy-custom-client-runtime.js",
1368
+ loader: "js",
1369
+ },
1370
+ outfile: outFile,
1371
+ bundle: true,
1372
+ platform: "browser",
1373
+ target: ["es2018"],
1374
+ format: "esm",
1375
+ sourcemap: false,
1376
+ minify: true,
1377
+ logLevel: "silent",
1378
+ plugins: [createReactShimPlugin()],
1379
+ });
1380
+ try {
1381
+ const {logLine} = require("./log");
1382
+ let size = 0;
1383
+ try {
1384
+ const st = fs.statSync(outFile);
1385
+ size = st && st.size ? st.size : 0;
1386
+ } catch (_) {}
1387
+ const kb = size ? ` (${(size / 1024).toFixed(1)} KB)` : "";
1388
+ const rel = path.relative(process.cwd(), outFile).split(path.sep).join("/");
1389
+ logLine(`✓ Wrote ${rel}${kb}`, "cyan");
1390
+ } catch (_) {}
1391
+ }
1392
+
1393
+ async function ensureCustomClientRuntime() {
1394
+ const entries = getCustomClientComponentEntries();
1395
+ if (!entries.length) return null;
1396
+ const signature = computeClientRuntimeSignature(entries);
1397
+ if (customClientRuntimePromise && customClientRuntimeSignature === signature) {
1398
+ return customClientRuntimePromise;
1399
+ }
1400
+ customClientRuntimeSignature = signature;
1401
+ customClientRuntimePromise = buildCustomClientRuntime(entries);
1402
+ return customClientRuntimePromise;
1403
+ }
1404
+
838
1405
  // Facets runtime: fetches /api/search/facets.json, picks a value per label (random from top 3),
839
1406
  // and renders a Slider for each.
840
1407
  async function ensureFacetsRuntime() {
@@ -1187,12 +1754,32 @@ module.exports = {
1187
1754
  ensureHeroRuntime,
1188
1755
  ensureFacetsRuntime,
1189
1756
  ensureReactGlobals,
1757
+ ensureCustomClientRuntime,
1190
1758
  resetMdxCaches: function () {
1191
1759
  try {
1192
1760
  DIR_LAYOUTS.clear();
1193
1761
  } catch (_) {}
1194
1762
  APP_WRAPPER = null;
1195
1763
  UI_COMPONENTS = null;
1764
+ UI_COMPONENTS_PATH = "";
1765
+ UI_COMPONENTS_MTIME = 0;
1766
+ MERGED_UI_COMPONENTS = null;
1767
+ MERGED_UI_KEY = "";
1768
+ CUSTOM_MDX_COMPONENTS = null;
1769
+ CUSTOM_MDX_SIGNATURE = "";
1770
+ CUSTOM_CLIENT_COMPONENT_ENTRIES = [];
1771
+ try {
1772
+ CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS.clear();
1773
+ } catch (_) {
1774
+ CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS = new Map();
1775
+ }
1776
+ try {
1777
+ SERVER_COMPONENT_CACHE.clear();
1778
+ } catch (_) {
1779
+ SERVER_COMPONENT_CACHE = new Map();
1780
+ }
1781
+ customClientRuntimePromise = null;
1782
+ customClientRuntimeSignature = "";
1196
1783
  cloverRuntimePromise = null;
1197
1784
  },
1198
1785
  };
@@ -178,6 +178,7 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}, source
178
178
  const needsTimeline = body.includes('data-canopy-timeline');
179
179
  const needsSearchForm = true; // search form runtime is global
180
180
  const needsFacets = body.includes('data-canopy-related-items');
181
+ const needsCustomClients = body.includes('data-canopy-client-component');
181
182
  const viewerRel = needsHydrateViewer
182
183
  ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-viewer.js')).split(path.sep).join('/')
183
184
  : null;
@@ -203,9 +204,25 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}, source
203
204
  try { const st = fs.statSync(runtimeAbs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
204
205
  searchFormRel = rel;
205
206
  }
207
+ let customClientRel = null;
208
+ if (needsCustomClients) {
209
+ try {
210
+ await mdx.ensureCustomClientRuntime();
211
+ const customAbs = path.join(OUT_DIR, 'scripts', 'canopy-custom-components.js');
212
+ let rel = path.relative(path.dirname(outPath), customAbs).split(path.sep).join('/');
213
+ try {
214
+ const st = fs.statSync(customAbs);
215
+ rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`;
216
+ } catch (_) {}
217
+ customClientRel = rel;
218
+ } catch (e) {
219
+ console.warn('[canopy][mdx] failed to build custom client runtime:', e && e.message ? e.message : e);
220
+ }
221
+ }
206
222
  const moduleScriptRels = [];
207
223
  if (viewerRel) moduleScriptRels.push(viewerRel);
208
224
  if (sliderRel) moduleScriptRels.push(sliderRel);
225
+ if (customClientRel) moduleScriptRels.push(customClientRel);
209
226
  const primaryClassicScripts = [];
210
227
  if (heroRel) primaryClassicScripts.push(heroRel);
211
228
  if (timelineRel) primaryClassicScripts.push(timelineRel);
@@ -217,7 +234,13 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}, source
217
234
  jsRel = primaryClassicScripts.shift();
218
235
  }
219
236
  const classicScriptRels = primaryClassicScripts.concat(secondaryClassicScripts);
220
- const needsReact = !!(needsHydrateViewer || needsHydrateSlider || needsFacets || needsTimeline);
237
+ const needsReact = !!(
238
+ needsHydrateViewer ||
239
+ needsHydrateSlider ||
240
+ needsFacets ||
241
+ needsTimeline ||
242
+ (customClientRel && needsCustomClients)
243
+ );
221
244
  let vendorTag = '';
222
245
  if (needsReact) {
223
246
  try {
@@ -278,7 +278,19 @@ async function buildSearchPage() {
278
278
  return rel;
279
279
  }
280
280
  const vendorTags = `<script src="${verRel(vendorReactAbs)}"></script><script src="${verRel(vendorFlexAbs)}"></script><script src="${verRel(vendorSearchFormAbs)}"></script>`;
281
- let headExtra = vendorTags + head + importMap;
281
+ let customRuntimeTag = '';
282
+ if (body && body.indexOf('data-canopy-client-component') !== -1) {
283
+ try {
284
+ await mdx.ensureCustomClientRuntime();
285
+ const runtimeAbs = path.join(OUT_DIR, 'scripts', 'canopy-custom-components.js');
286
+ let rel = path.relative(path.dirname(outPath), runtimeAbs).split(path.sep).join('/');
287
+ try { const st = require('fs').statSync(runtimeAbs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
288
+ customRuntimeTag = `<script type="module" src="${rel}"></script>`;
289
+ } catch (e) {
290
+ console.warn('[search] failed to build custom client runtime:', e && (e.message || e));
291
+ }
292
+ }
293
+ let headExtra = vendorTags + head + importMap + customRuntimeTag;
282
294
  try {
283
295
  const { BASE_PATH } = require('../common');
284
296
  if (BASE_PATH) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",