@canopy-iiif/app 0.6.28 → 0.7.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.
@@ -11,7 +11,7 @@ const {
11
11
  CONTENT_DIR,
12
12
  ensureDirSync,
13
13
  htmlShell,
14
- } = require("./common");
14
+ } = require("../common");
15
15
  const mdx = require("./mdx");
16
16
  const { log, logLine, logResponse } = require("./log");
17
17
 
@@ -88,7 +88,6 @@ async function readJsonFromUri(uri, options = { log: false }) {
88
88
  if (options && options.log) {
89
89
  try {
90
90
  if (res && res.ok) {
91
- // Bold success line for Collection fetches
92
91
  logLine(`✓ ${String(uri)} ➜ ${res.status}`, "yellow", {
93
92
  bright: true,
94
93
  });
@@ -247,13 +246,18 @@ function computeUniqueSlug(index, baseSlug, id, type) {
247
246
  function ensureBaseSlugFor(index, baseSlug, id, type) {
248
247
  try {
249
248
  const byId = Array.isArray(index && index.byId) ? index.byId : [];
250
- const normId = normalizeIiifId(String(id || ''));
249
+ const normId = normalizeIiifId(String(id || ""));
251
250
  const existingWithBase = byId.find(
252
251
  (e) => e && e.type === type && String(e.slug) === String(baseSlug)
253
252
  );
254
253
  if (existingWithBase && normalizeIiifId(existingWithBase.id) !== normId) {
255
254
  // Reassign the existing entry to the next available suffix to free the base
256
- const newSlug = computeUniqueSlug(index, baseSlug, existingWithBase.id, type);
255
+ const newSlug = computeUniqueSlug(
256
+ index,
257
+ baseSlug,
258
+ existingWithBase.id,
259
+ type
260
+ );
257
261
  if (newSlug && newSlug !== baseSlug) existingWithBase.slug = newSlug;
258
262
  }
259
263
  } catch (_) {}
@@ -476,351 +480,152 @@ async function saveCachedCollection(collection, id, parentId) {
476
480
  }
477
481
 
478
482
  async function loadConfig() {
479
- let CONFIG = {
480
- collection: {
481
- uri: "https://iiif.io/api/cookbook/recipe/0032-collection/collection.json",
482
- },
483
- iiif: { chunkSize: 10, concurrency: 6 },
484
- metadata: [],
485
- };
486
- const overrideConfigPath = process.env.CANOPY_CONFIG;
487
- const configPath = path.resolve(overrideConfigPath || "canopy.yml");
488
- if (fs.existsSync(configPath)) {
489
- try {
490
- const raw = await fsp.readFile(configPath, "utf8");
491
- const data = yaml.load(raw) || {};
492
- const d = data || {};
493
- const di = d.iiif || {};
494
- CONFIG = {
495
- collection: {
496
- uri: (d.collection && d.collection.uri) || CONFIG.collection.uri,
497
- },
498
- iiif: {
499
- chunkSize:
500
- Number(di.chunkSize || CONFIG.iiif.chunkSize) ||
501
- CONFIG.iiif.chunkSize,
502
- concurrency:
503
- Number(di.concurrency || CONFIG.iiif.concurrency) ||
504
- CONFIG.iiif.concurrency,
505
- thumbnails: {
506
- unsafe: !!(di.thumbnails && di.thumbnails.unsafe === true),
507
- preferredSize:
508
- Number(di.thumbnails && di.thumbnails.preferredSize) || 1200,
509
- },
510
- },
511
- metadata: Array.isArray(d.metadata)
512
- ? d.metadata.map((s) => String(s)).filter(Boolean)
513
- : [],
514
- };
515
- const src = overrideConfigPath ? overrideConfigPath : "canopy.yml";
516
- logLine(`[canopy] Loaded config from ${src}...`, "white");
517
- } catch (e) {
518
- console.warn(
519
- "[canopy] Failed to read",
520
- overrideConfigPath ? overrideConfigPath : "canopy.yml",
521
- e.message
522
- );
523
- }
524
- }
525
- if (process.env.CANOPY_COLLECTION_URI) {
526
- CONFIG.collection.uri = String(process.env.CANOPY_COLLECTION_URI);
527
- console.log("Using collection URI from CANOPY_COLLECTION_URI");
483
+ const cfgPath = path.resolve("canopy.yml");
484
+ if (!fs.existsSync(cfgPath)) return {};
485
+ const raw = await fsp.readFile(cfgPath, "utf8");
486
+ let cfg = {};
487
+ try {
488
+ cfg = yaml.load(raw) || {};
489
+ } catch (_) {
490
+ cfg = {};
528
491
  }
529
- return CONFIG;
492
+ return cfg || {};
530
493
  }
531
494
 
495
+ // Traverse IIIF collection, cache manifests/collections, and render pages
532
496
  async function buildIiifCollectionPages(CONFIG) {
533
- const worksDir = path.join(CONTENT_DIR, "works");
534
- const worksLayoutPath = path.join(worksDir, "_layout.mdx");
535
- if (!fs.existsSync(worksLayoutPath)) {
536
- console.log(
537
- "IIIF: No content/works/_layout.mdx found; skipping IIIF page build."
538
- );
539
- return { searchRecords: [] };
540
- }
541
- ensureDirSync(IIIF_CACHE_MANIFESTS_DIR);
542
- ensureDirSync(IIIF_CACHE_COLLECTIONS_DIR);
543
- // Debug: list current collections cache contents
544
- try {
545
- if (process.env.CANOPY_IIIF_DEBUG === "1") {
546
- const { logLine } = require("./log");
547
- try {
548
- const files = fs.existsSync(IIIF_CACHE_COLLECTIONS_DIR)
549
- ? fs
550
- .readdirSync(IIIF_CACHE_COLLECTIONS_DIR)
551
- .filter((n) => /\.json$/i.test(n))
552
- : [];
553
- const head = files.slice(0, 8).join(", ");
554
- logLine(
555
- `IIIF: cache/collections (start): ${files.length} file(s)` +
556
- (head ? ` [${head}${files.length > 8 ? ", …" : ""}]` : ""),
557
- "blue",
558
- { dim: true }
559
- );
560
- } catch (_) {}
561
- }
562
- } catch (_) {}
497
+ const cfg = CONFIG || (await loadConfig());
563
498
  const collectionUri =
564
- (CONFIG && CONFIG.collection && CONFIG.collection.uri) || null;
565
- try {
566
- try {
567
- if (collectionUri) {
568
- logLine(`${collectionUri}`, "white", {
569
- dim: true,
570
- });
571
- }
572
- } catch (_) {}
573
- console.log("");
574
- } catch (_) {}
575
- // Decide cache policy/log before any fetch
576
- let index = await loadManifestIndex();
577
- const prevUri = (index && index.collection && index.collection.uri) || "";
578
- const uriChanged = !!(collectionUri && prevUri && prevUri !== collectionUri);
579
- if (uriChanged) {
580
- try {
581
- const { logLine } = require("./log");
582
- logLine("IIIF Collection changed. Resetting cache.\n", "cyan");
583
- } catch (_) {}
584
- await flushManifestCache();
585
- index.byId = [];
586
- } else {
587
- try {
588
- const { logLine } = require("./log");
589
- logLine(
590
- "IIIF Collection unchanged. Preserving cache. Detecting cached resources...\n",
591
- "cyan"
592
- );
593
- } catch (_) {}
594
- }
595
- let collection = null;
596
- if (collectionUri) {
597
- // Try cached root collection first
598
- collection = await loadCachedCollectionById(collectionUri);
599
- if (collection) {
600
- try {
601
- const { logLine } = require("./log");
602
- logLine(`✓ ${String(collectionUri)} ➜ Cached`, "yellow");
603
- } catch (_) {}
604
- } else {
605
- collection = await readJsonFromUri(collectionUri, { log: true });
606
- if (collection) {
607
- try {
608
- await saveCachedCollection(
609
- collection,
610
- String(collection.id || collection["@id"] || collectionUri),
611
- ""
612
- );
613
- } catch (_) {}
614
- }
615
- }
616
- }
617
- if (!collection) {
618
- console.warn("IIIF: No collection available; skipping.");
619
- // Still write/update global index with configured URI, so other steps can rely on it
620
- try {
621
- const index = await loadManifestIndex();
622
- index.collection = {
623
- uri: String(collectionUri || ""),
624
- hash: "",
625
- updatedAt: new Date().toISOString(),
626
- };
627
- await saveManifestIndex(index);
628
- } catch (_) {}
499
+ (cfg && cfg.collection && cfg.collection.uri) ||
500
+ process.env.CANOPY_COLLECTION_URI ||
501
+ "";
502
+ if (!collectionUri) return { searchRecords: [] };
503
+
504
+ // Fetch top-level collection
505
+ logLine("IIIF: Fetching collection...", "blue", { bright: true });
506
+ const root = await readJsonFromUri(collectionUri, { log: true });
507
+ if (!root) {
508
+ logLine("IIIF: Failed to fetch collection", "red");
629
509
  return { searchRecords: [] };
630
510
  }
631
- // Normalize to Presentation 3 to ensure a consistent structure (items array, types)
632
- const collectionForHash = await normalizeToV3(collection);
633
- const currentSig = {
634
- uri: String(collectionUri || ""),
635
- hash: computeHash(collectionForHash),
636
- };
637
- index.collection = { ...currentSig, updatedAt: new Date().toISOString() };
638
- // Upsert root collection entry in index.byId
511
+ const normalizedRoot = await normalizeToV3(root);
512
+ // Save collection cache
639
513
  try {
640
- const rootId = normalizeIiifId(
641
- String(collection.id || collection["@id"] || collectionUri || "")
642
- );
643
- const title = firstLabelString(collection && collection.label);
644
- const baseSlug = slugify(title || "collection", { lower: true, strict: true, trim: true }) || "collection";
645
- index.byId = Array.isArray(index.byId) ? index.byId : [];
646
- const slug = ensureBaseSlugFor(index, baseSlug, rootId, 'Collection');
647
- const existingIdx = index.byId.findIndex(
648
- (e) => e && normalizeIiifId(e.id) === rootId && e.type === "Collection"
514
+ await saveCachedCollection(
515
+ normalizedRoot,
516
+ normalizedRoot.id || collectionUri,
517
+ ""
649
518
  );
650
- const entry = { id: rootId, type: "Collection", slug, parent: "" };
651
- if (existingIdx >= 0) index.byId[existingIdx] = entry;
652
- else index.byId.push(entry);
653
- try { (RESERVED_SLUGS && RESERVED_SLUGS.Collection || new Set()).add(slug); } catch (_) {}
654
519
  } catch (_) {}
655
- await saveManifestIndex(index);
656
520
 
657
- const WorksLayout = await mdx.compileMdxToComponent(worksLayoutPath);
658
- // Recursively collect manifests across subcollections
521
+ // Recursively traverse Collections and gather all Manifest tasks
659
522
  const tasks = [];
660
- async function loadOrFetchCollectionById(id, lns, fetchAllowed = true) {
661
- let c = await loadCachedCollectionById(String(id));
662
- if (c) {
663
- try {
664
- // Emit a standard Cached line so it mirrors network logs
665
- const { logLine } = require("./log");
666
- logLine(`✓ ${String(id)} ➜ Cached`, "yellow");
667
- } catch (_) {}
668
- if (lns) lns.push([`✓ ${String(id)} ➜ Cached`, "yellow"]);
669
- // Always normalize to Presentation 3 for consistent traversal
670
- return await normalizeToV3(c);
671
- }
672
- if (!fetchAllowed) return null;
673
- const fetched = await readJsonFromUri(String(id), { log: true });
674
- const normalized = await normalizeToV3(fetched);
523
+ const visitedCollections = new Set(); // normalized ids
524
+ const norm = (x) => {
675
525
  try {
676
- await saveCachedCollection(
677
- normalized || fetched,
678
- String(((normalized || fetched) && ((normalized || fetched).id || (normalized || fetched)["@id"])) || id),
679
- ""
680
- );
681
- } catch (_) {}
682
- return normalized || fetched;
683
- }
684
- async function collectTasksFromCollection(colObj, parentUri, visited) {
685
- if (!colObj) return;
686
- // Prefer the URI we traversed from (parentUri) to work around sources
687
- // that report the same id for multiple pages (e.g., Internet Archive).
688
- const colId = String(parentUri || colObj.id || colObj["@id"] || "");
689
- visited = visited || new Set();
690
- if (colId) {
691
- if (visited.has(colId)) return;
692
- visited.add(colId);
526
+ return normalizeIiifId(String(x || ""));
527
+ } catch (_) {
528
+ return String(x || "");
693
529
  }
694
- // Traverse explicit sub-collections under items only (no paging semantics)
695
- const items = Array.isArray(colObj && colObj.items) ? colObj.items : [];
696
- for (const it of items) {
697
- if (!it) continue;
698
- const t = String(it.type || it["@type"] || "");
699
- const id = it.id || it["@id"] || "";
700
- if (t.includes("Manifest")) {
701
- tasks.push({
702
- id: String(id),
703
- parent: String(colId || parentUri || ""),
704
- });
705
- } else if (t.includes("Collection")) {
706
- let subRaw = await loadOrFetchCollectionById(String(id));
707
- try {
708
- await saveCachedCollection(
709
- subRaw,
710
- String(id),
711
- String(colId || parentUri || "")
712
- );
713
- } catch (_) {}
714
- try {
715
- const title = firstLabelString(subRaw && subRaw.label);
716
- const baseSlug = slugify(title || "collection", { lower: true, strict: true, trim: true }) || "collection";
717
- const idx = await loadManifestIndex();
718
- idx.byId = Array.isArray(idx.byId) ? idx.byId : [];
719
- const subIdNorm = normalizeIiifId(String(id));
720
- const slug = computeUniqueSlug(idx, baseSlug, subIdNorm, 'Collection');
721
- const entry = {
722
- id: subIdNorm,
723
- type: "Collection",
724
- slug,
725
- parent: normalizeIiifId(String(colId || parentUri || "")),
726
- };
727
- const existing = idx.byId.findIndex(
728
- (e) =>
729
- e &&
730
- normalizeIiifId(e.id) === subIdNorm &&
731
- e.type === "Collection"
732
- );
733
- if (existing >= 0) idx.byId[existing] = entry;
734
- else idx.byId.push(entry);
735
- await saveManifestIndex(idx);
736
- } catch (_) {}
737
- await collectTasksFromCollection(subRaw, String(id), visited);
738
- } else if (/^https?:\/\//i.test(String(id || ""))) {
739
- let norm = await loadOrFetchCollectionById(String(id));
740
- const nt = String((norm && (norm.type || norm["@type"])) || "");
741
- if (nt.includes("Collection")) {
742
- try {
743
- const title = firstLabelString(norm && norm.label);
744
- const baseSlug = slugify(title || "collection", { lower: true, strict: true, trim: true }) || "collection";
745
- const idx = await loadManifestIndex();
746
- idx.byId = Array.isArray(idx.byId) ? idx.byId : [];
747
- const normId = normalizeIiifId(String(id));
748
- const slug = computeUniqueSlug(idx, baseSlug, normId, 'Collection');
749
- const entry = {
750
- id: normId,
751
- type: "Collection",
752
- slug,
753
- parent: normalizeIiifId(String(colId || parentUri || "")),
754
- };
755
- const existing = idx.byId.findIndex(
756
- (e) => e && normalizeIiifId(e.id) === normId && e.type === "Collection"
757
- );
758
- if (existing >= 0) idx.byId[existing] = entry;
759
- else idx.byId.push(entry);
760
- await saveManifestIndex(idx);
761
- } catch (_) {}
762
- try {
763
- await saveCachedCollection(
764
- norm,
765
- String(id),
766
- String(colId || parentUri || "")
767
- );
768
- } catch (_) {}
769
- await collectTasksFromCollection(norm, String(id), visited);
770
- } else if (nt.includes("Manifest")) {
771
- tasks.push({
772
- id: String(id),
773
- parent: String(colId || parentUri || ""),
774
- });
530
+ };
531
+ async function gatherFromCollection(colLike, parentId) {
532
+ try {
533
+ // Resolve uri/id
534
+ const uri =
535
+ typeof colLike === "string"
536
+ ? colLike
537
+ : (colLike && (colLike.id || colLike["@id"])) || "";
538
+ const col =
539
+ typeof colLike === "object" && colLike && colLike.items
540
+ ? colLike
541
+ : await readJsonFromUri(uri, { log: true });
542
+ if (!col) return;
543
+ const ncol = await normalizeToV3(col);
544
+ const colId = String((ncol && (ncol.id || uri)) || uri);
545
+ const nid = norm(colId);
546
+ if (visitedCollections.has(nid)) return; // avoid cycles
547
+ visitedCollections.add(nid);
548
+ try {
549
+ await saveCachedCollection(ncol, colId, parentId || "");
550
+ } catch (_) {}
551
+ const itemsArr = Array.isArray(ncol.items) ? ncol.items : [];
552
+ for (const entry of itemsArr) {
553
+ if (!entry) continue;
554
+ const t = String(entry.type || entry["@type"] || "").toLowerCase();
555
+ const entryId = entry.id || entry["@id"] || "";
556
+ if (t === "manifest") {
557
+ tasks.push({ id: entryId, parent: colId });
558
+ } else if (t === "collection") {
559
+ await gatherFromCollection(entryId, colId);
775
560
  }
776
561
  }
777
- }
562
+ // Traverse strictly by parent/child hierarchy (Presentation 3): items → Manifest or Collection
563
+ } catch (_) {}
778
564
  }
779
- // Use normalized root collection for traversal to guarantee v3 shape
780
- const collectionV3 = await normalizeToV3(collection);
781
- await collectTasksFromCollection(
782
- collectionV3,
783
- String((collectionV3 && (collectionV3.id || collectionV3["@id"])) || collectionUri || ""),
784
- new Set()
785
- );
565
+ await gatherFromCollection(normalizedRoot, "");
566
+ if (!tasks.length) return { searchRecords: [] };
567
+
568
+ // Split into chunks and process with limited concurrency
786
569
  const chunkSize = Math.max(
787
570
  1,
788
571
  Number(
789
- process.env.CANOPY_CHUNK_SIZE ||
790
- (CONFIG.iiif && CONFIG.iiif.chunkSize) ||
791
- 10
572
+ process.env.CANOPY_CHUNK_SIZE || (cfg.iiif && cfg.iiif.chunkSize) || 20
792
573
  )
793
574
  );
794
- const chunks = Math.max(1, Math.ceil(tasks.length / chunkSize));
795
- try {
796
- logLine(
797
- `\nAggregating ${tasks.length} Manifest(s) in ${chunks} chunk(s)...`,
798
- "cyan"
799
- );
800
- } catch (_) {}
575
+ const chunks = Math.ceil(tasks.length / chunkSize);
801
576
  const searchRecords = [];
802
577
  const unsafeThumbs = !!(
803
- CONFIG &&
804
- CONFIG.iiif &&
805
- CONFIG.iiif.thumbnails &&
806
- CONFIG.iiif.thumbnails.unsafe === true
578
+ cfg &&
579
+ cfg.iiif &&
580
+ cfg.iiif.thumbnails &&
581
+ cfg.iiif.thumbnails.unsafe === true
807
582
  );
808
583
  const thumbSize =
809
- (CONFIG &&
810
- CONFIG.iiif &&
811
- CONFIG.iiif.thumbnails &&
812
- CONFIG.iiif.thumbnails.preferredSize) ||
584
+ (cfg &&
585
+ cfg.iiif &&
586
+ cfg.iiif.thumbnails &&
587
+ cfg.iiif.thumbnails.preferredSize) ||
813
588
  1200;
589
+
590
+ // Compile the works layout component once per run
591
+ let WorksLayoutComp = null;
592
+ try {
593
+ const worksLayoutPath = path.join(CONTENT_DIR, "works", "_layout.mdx");
594
+ WorksLayoutComp = await mdx.compileMdxToComponent(worksLayoutPath);
595
+ } catch (_) {
596
+ // Minimal fallback layout if missing or fails to compile
597
+ WorksLayoutComp = function FallbackWorksLayout({ manifest }) {
598
+ const title = firstLabelString(manifest && manifest.label);
599
+ return React.createElement(
600
+ "div",
601
+ { className: "content" },
602
+ React.createElement("h1", null, title || "Untitled"),
603
+ // Render viewer placeholder for hydration
604
+ React.createElement(
605
+ "div",
606
+ { "data-canopy-viewer": "1" },
607
+ React.createElement("script", {
608
+ type: "application/json",
609
+ dangerouslySetInnerHTML: {
610
+ __html: JSON.stringify({
611
+ iiifContent: manifest && (manifest.id || ""),
612
+ }),
613
+ },
614
+ })
615
+ )
616
+ );
617
+ };
618
+ }
619
+
814
620
  for (let ci = 0; ci < chunks; ci++) {
815
621
  const chunk = tasks.slice(ci * chunkSize, (ci + 1) * chunkSize);
816
- try {
817
- logLine(`\nChunk (${ci + 1}/${chunks})\n`, "magenta");
818
- } catch (_) {}
622
+ logLine(`\nChunk (${ci + 1}/${chunks})`, "white", { dim: true });
623
+
819
624
  const concurrency = Math.max(
820
625
  1,
821
626
  Number(
822
627
  process.env.CANOPY_FETCH_CONCURRENCY ||
823
- (CONFIG.iiif && CONFIG.iiif.concurrency) ||
628
+ (cfg.iiif && cfg.iiif.concurrency) ||
824
629
  6
825
630
  )
826
631
  );
@@ -848,9 +653,7 @@ async function buildIiifCollectionPages(CONFIG) {
848
653
  const idx = next - 1;
849
654
  const id = it.id || it["@id"] || "";
850
655
  let manifest = await loadCachedManifestById(id);
851
- // Buffer logs for ordered output
852
656
  const lns = [];
853
- // Logging: cached or fetched
854
657
  if (manifest) {
855
658
  lns.push([`✓ ${String(id)} ➜ Cached`, "yellow"]);
856
659
  } else if (/^https?:\/\//i.test(String(id || ""))) {
@@ -880,7 +683,6 @@ async function buildIiifCollectionPages(CONFIG) {
880
683
  continue;
881
684
  }
882
685
  } else if (/^file:\/\//i.test(String(id || ""))) {
883
- // Support local manifests via file:// in dev
884
686
  try {
885
687
  const local = await readJsonFromUri(String(id), { log: false });
886
688
  if (!local) {
@@ -900,48 +702,58 @@ async function buildIiifCollectionPages(CONFIG) {
900
702
  continue;
901
703
  }
902
704
  } else {
903
- // Unsupported scheme; skip
904
705
  lns.push([`✗ ${String(id)} ➜ SKIP`, "red"]);
905
706
  continue;
906
707
  }
907
708
  if (!manifest) continue;
908
709
  manifest = await normalizeToV3(manifest);
909
710
  const title = firstLabelString(manifest.label);
910
- const baseSlug = slugify(title || "untitled", { lower: true, strict: true, trim: true }) || 'untitled';
711
+ const baseSlug =
712
+ slugify(title || "untitled", {
713
+ lower: true,
714
+ strict: true,
715
+ trim: true,
716
+ }) || "untitled";
911
717
  const nid = normalizeIiifId(String(manifest.id || id));
912
- // Use existing mapping if present; otherwise allocate and persist
913
718
  let idxMap = await loadManifestIndex();
914
719
  idxMap.byId = Array.isArray(idxMap.byId) ? idxMap.byId : [];
915
- let mEntry = idxMap.byId.find((e) => e && e.type === 'Manifest' && normalizeIiifId(e.id) === nid);
720
+ let mEntry = idxMap.byId.find(
721
+ (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid
722
+ );
916
723
  let slug = mEntry && mEntry.slug;
917
724
  if (!slug) {
918
- // Prefer base-first; if base is free, use it, else suffix
919
- slug = computeUniqueSlug(idxMap, baseSlug, nid, 'Manifest');
920
- const parentNorm = normalizeIiifId(String(it.parent || ''));
921
- const newEntry = { id: nid, type: 'Manifest', slug, parent: parentNorm };
922
- const existingIdx = idxMap.byId.findIndex((e) => e && e.type === 'Manifest' && normalizeIiifId(e.id) === nid);
923
- if (existingIdx >= 0) idxMap.byId[existingIdx] = newEntry; else idxMap.byId.push(newEntry);
725
+ slug = computeUniqueSlug(idxMap, baseSlug, nid, "Manifest");
726
+ const parentNorm = normalizeIiifId(String(it.parent || ""));
727
+ const newEntry = {
728
+ id: nid,
729
+ type: "Manifest",
730
+ slug,
731
+ parent: parentNorm,
732
+ };
733
+ const existingIdx = idxMap.byId.findIndex(
734
+ (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid
735
+ );
736
+ if (existingIdx >= 0) idxMap.byId[existingIdx] = newEntry;
737
+ else idxMap.byId.push(newEntry);
924
738
  await saveManifestIndex(idxMap);
925
739
  }
926
740
  const href = path.join("works", slug + ".html");
927
741
  const outPath = path.join(OUT_DIR, href);
928
742
  ensureDirSync(path.dirname(outPath));
929
743
  try {
930
- // Provide MDX components mapping so tags like <Viewer/> and <HelloWorld/> resolve
931
744
  let components = {};
932
745
  try {
933
746
  components = await import("@canopy-iiif/app/ui");
934
747
  } catch (_) {
935
748
  components = {};
936
749
  }
937
- const { withBase } = require("./common");
750
+ const { withBase } = require("../common");
938
751
  const Anchor = function A(props) {
939
752
  let { href = "", ...rest } = props || {};
940
753
  href = withBase(href);
941
754
  return React.createElement("a", { href, ...rest }, props.children);
942
755
  };
943
756
  const compMap = { ...components, a: Anchor };
944
- // Gracefully handle HelloWorld if not provided anywhere
945
757
  if (!components.HelloWorld) {
946
758
  components.HelloWorld = components.Fallback
947
759
  ? (props) =>
@@ -961,7 +773,7 @@ async function buildIiifCollectionPages(CONFIG) {
961
773
  const { loadAppWrapper } = require("./mdx");
962
774
  const app = await loadAppWrapper();
963
775
 
964
- const mdxContent = React.createElement(WorksLayout, { manifest });
776
+ const mdxContent = React.createElement(WorksLayoutComp, { manifest });
965
777
  const siteTree = app && app.App ? mdxContent : mdxContent;
966
778
  const wrappedApp =
967
779
  app && app.App
@@ -988,59 +800,95 @@ async function buildIiifCollectionPages(CONFIG) {
988
800
  )
989
801
  .split(path.sep)
990
802
  .join("/");
991
- // Detect placeholders to decide which runtimes to inject
992
803
  const needsHydrateViewer = body.includes("data-canopy-viewer");
993
804
  const needsRelated = body.includes("data-canopy-related-items");
994
- const needsCommand = body.includes('data-canopy-command');
995
- const needsHydrate = body.includes("data-canopy-hydrate") || needsHydrateViewer || needsRelated || needsCommand;
996
-
997
- // Client runtimes (viewer/slider/facets) are prepared once up-front by the
998
- // top-level build orchestrator. Avoid rebundling in per-manifest workers.
805
+ const needsCommand = body.includes("data-canopy-command");
806
+ const needsHydrate =
807
+ body.includes("data-canopy-hydrate") ||
808
+ needsHydrateViewer ||
809
+ needsRelated ||
810
+ needsCommand;
999
811
 
1000
- // Compute script paths relative to the output HTML
1001
812
  const viewerRel = needsHydrateViewer
1002
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-viewer.js')).split(path.sep).join('/')
813
+ ? path
814
+ .relative(
815
+ path.dirname(outPath),
816
+ path.join(OUT_DIR, "scripts", "canopy-viewer.js")
817
+ )
818
+ .split(path.sep)
819
+ .join("/")
1003
820
  : null;
1004
821
  const sliderRel = needsRelated
1005
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-slider.js')).split(path.sep).join('/')
822
+ ? path
823
+ .relative(
824
+ path.dirname(outPath),
825
+ path.join(OUT_DIR, "scripts", "canopy-slider.js")
826
+ )
827
+ .split(path.sep)
828
+ .join("/")
1006
829
  : null;
1007
830
  const relatedRel = needsRelated
1008
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-related-items.js')).split(path.sep).join('/')
831
+ ? path
832
+ .relative(
833
+ path.dirname(outPath),
834
+ path.join(OUT_DIR, "scripts", "canopy-related-items.js")
835
+ )
836
+ .split(path.sep)
837
+ .join("/")
1009
838
  : null;
1010
839
  const commandRel = needsCommand
1011
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-command.js')).split(path.sep).join('/')
840
+ ? path
841
+ .relative(
842
+ path.dirname(outPath),
843
+ path.join(OUT_DIR, "scripts", "canopy-command.js")
844
+ )
845
+ .split(path.sep)
846
+ .join("/")
1012
847
  : null;
1013
848
 
1014
- // Choose a main script so execution order is preserved (builder before slider)
1015
849
  let jsRel = null;
1016
850
  if (needsRelated && sliderRel) jsRel = sliderRel;
1017
851
  else if (viewerRel) jsRel = viewerRel;
1018
852
 
1019
- // Include hydration scripts via htmlShell
1020
853
  let headExtra = head;
1021
- // Ensure React globals are present only when client React is needed
1022
854
  const needsReact = !!(needsHydrateViewer || needsRelated);
1023
- let vendorTag = '';
855
+ let vendorTag = "";
1024
856
  if (needsReact) {
1025
857
  try {
1026
- const vendorAbs = path.join(OUT_DIR, 'scripts', 'react-globals.js');
1027
- let vendorRel = path.relative(path.dirname(outPath), vendorAbs).split(path.sep).join('/');
1028
- try { const stv = fs.statSync(vendorAbs); vendorRel += `?v=${Math.floor(stv.mtimeMs || Date.now())}`; } catch (_) {}
858
+ const vendorAbs = path.join(
859
+ OUT_DIR,
860
+ "scripts",
861
+ "react-globals.js"
862
+ );
863
+ let vendorRel = path
864
+ .relative(path.dirname(outPath), vendorAbs)
865
+ .split(path.sep)
866
+ .join("/");
867
+ try {
868
+ const stv = fs.statSync(vendorAbs);
869
+ vendorRel += `?v=${Math.floor(stv.mtimeMs || Date.now())}`;
870
+ } catch (_) {}
1029
871
  vendorTag = `<script src="${vendorRel}"></script>`;
1030
872
  } catch (_) {}
1031
873
  }
1032
- // Prepend additional scripts so the selected jsRel runs last
1033
874
  const extraScripts = [];
1034
- if (relatedRel && jsRel !== relatedRel) extraScripts.push(`<script defer src="${relatedRel}"></script>`);
1035
- if (viewerRel && jsRel !== viewerRel) extraScripts.push(`<script defer src="${viewerRel}"></script>`);
1036
- if (sliderRel && jsRel !== sliderRel) extraScripts.push(`<script defer src="${sliderRel}"></script>`);
1037
- // Include command runtime once
1038
- if (commandRel && jsRel !== commandRel) extraScripts.push(`<script defer src="${commandRel}"></script>`);
1039
- if (extraScripts.length) headExtra = extraScripts.join('') + headExtra;
1040
- // Expose base path to browser for runtime code to build URLs
875
+ if (relatedRel && jsRel !== relatedRel)
876
+ extraScripts.push(`<script defer src="${relatedRel}"></script>`);
877
+ if (viewerRel && jsRel !== viewerRel)
878
+ extraScripts.push(`<script defer src="${viewerRel}"></script>`);
879
+ if (sliderRel && jsRel !== sliderRel)
880
+ extraScripts.push(`<script defer src="${sliderRel}"></script>`);
881
+ if (commandRel && jsRel !== commandRel)
882
+ extraScripts.push(`<script defer src="${commandRel}"></script>`);
883
+ if (extraScripts.length)
884
+ headExtra = extraScripts.join("") + headExtra;
1041
885
  try {
1042
- const { BASE_PATH } = require('./common');
1043
- if (BASE_PATH) vendorTag = `<script>window.CANOPY_BASE_PATH=${JSON.stringify(BASE_PATH)}</script>` + vendorTag;
886
+ const { BASE_PATH } = require("../common");
887
+ if (BASE_PATH)
888
+ vendorTag =
889
+ `<script>window.CANOPY_BASE_PATH=${JSON.stringify(
890
+ BASE_PATH
891
+ )}</script>` + vendorTag;
1044
892
  } catch (_) {}
1045
893
  let pageBody = body;
1046
894
  let html = htmlShell({
@@ -1051,24 +899,23 @@ async function buildIiifCollectionPages(CONFIG) {
1051
899
  headExtra: vendorTag + headExtra,
1052
900
  });
1053
901
  try {
1054
- html = require("./common").applyBaseToHtml(html);
902
+ html = require("../common").applyBaseToHtml(html);
1055
903
  } catch (_) {}
1056
904
  await fsp.writeFile(outPath, html, "utf8");
1057
905
  lns.push([
1058
906
  `✓ Created ${path.relative(process.cwd(), outPath)}`,
1059
907
  "green",
1060
908
  ]);
1061
- // Resolve thumbnail URL and dimensions for this manifest (safe by default; expanded "unsafe" if configured)
1062
909
  let thumbUrl = "";
1063
910
  let thumbWidth = undefined;
1064
911
  let thumbHeight = undefined;
1065
912
  try {
1066
- const { getThumbnail } = require("./thumbnail");
913
+ const { getThumbnail } = require("../iiif/thumbnail");
1067
914
  const t = await getThumbnail(manifest, thumbSize, unsafeThumbs);
1068
915
  if (t && t.url) {
1069
916
  thumbUrl = String(t.url);
1070
- thumbWidth = typeof t.width === 'number' ? t.width : undefined;
1071
- thumbHeight = typeof t.height === 'number' ? t.height : undefined;
917
+ thumbWidth = typeof t.width === "number" ? t.width : undefined;
918
+ thumbHeight = typeof t.height === "number" ? t.height : undefined;
1072
919
  const idx = await loadManifestIndex();
1073
920
  if (Array.isArray(idx.byId)) {
1074
921
  const entry = idx.byId.find(
@@ -1079,23 +926,25 @@ async function buildIiifCollectionPages(CONFIG) {
1079
926
  );
1080
927
  if (entry) {
1081
928
  entry.thumbnail = String(thumbUrl);
1082
- // Persist thumbnail dimensions for aspect ratio calculations when available
1083
- if (typeof thumbWidth === 'number') entry.thumbnailWidth = thumbWidth;
1084
- if (typeof thumbHeight === 'number') entry.thumbnailHeight = thumbHeight;
929
+ if (typeof thumbWidth === "number")
930
+ entry.thumbnailWidth = thumbWidth;
931
+ if (typeof thumbHeight === "number")
932
+ entry.thumbnailHeight = thumbHeight;
1085
933
  await saveManifestIndex(idx);
1086
934
  }
1087
935
  }
1088
936
  }
1089
937
  } catch (_) {}
1090
- // Push search record including thumbnail (if available)
1091
938
  searchRecords.push({
1092
939
  id: String(manifest.id || id),
1093
940
  title,
1094
941
  href: href.split(path.sep).join("/"),
1095
942
  type: "work",
1096
943
  thumbnail: thumbUrl || undefined,
1097
- thumbnailWidth: typeof thumbWidth === 'number' ? thumbWidth : undefined,
1098
- thumbnailHeight: typeof thumbHeight === 'number' ? thumbHeight : undefined,
944
+ thumbnailWidth:
945
+ typeof thumbWidth === "number" ? thumbWidth : undefined,
946
+ thumbnailHeight:
947
+ typeof thumbHeight === "number" ? thumbHeight : undefined,
1099
948
  });
1100
949
  } catch (e) {
1101
950
  lns.push([
@@ -1119,10 +968,10 @@ async function buildIiifCollectionPages(CONFIG) {
1119
968
  module.exports = {
1120
969
  buildIiifCollectionPages,
1121
970
  loadConfig,
1122
- // Expose for other build steps that need to annotate cache metadata
1123
971
  loadManifestIndex,
1124
972
  saveManifestIndex,
1125
973
  };
974
+
1126
975
  // Debug: list collections cache after traversal
1127
976
  try {
1128
977
  if (process.env.CANOPY_IIIF_DEBUG === "1") {