@canopy-iiif/app 1.5.17 → 1.6.1

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/iiif.js CHANGED
@@ -17,7 +17,7 @@ const {
17
17
  const {resolveCanopyConfigPath} = require("../config-path");
18
18
  const mdx = require("./mdx");
19
19
  const {log, logLine, logResponse} = require("./log");
20
- const { getPageContext } = require("../page-context");
20
+ const {getPageContext} = require("../page-context");
21
21
  const PageContext = getPageContext();
22
22
  const referenced = require("../components/referenced");
23
23
  const navPlace = require("../components/nav-place");
@@ -41,11 +41,11 @@ const IIIF_CACHE_INDEX = path.join(IIIF_CACHE_DIR, "index.json");
41
41
  // Additional legacy locations kept for backward compatibility (read + optional write)
42
42
  const IIIF_CACHE_INDEX_LEGACY = path.join(
43
43
  IIIF_CACHE_DIR,
44
- "manifest-index.json"
44
+ "manifest-index.json",
45
45
  );
46
46
  const IIIF_CACHE_INDEX_MANIFESTS = path.join(
47
47
  IIIF_CACHE_MANIFESTS_DIR,
48
- "manifest-index.json"
48
+ "manifest-index.json",
49
49
  );
50
50
 
51
51
  const DEFAULT_THUMBNAIL_SIZE = 400;
@@ -57,6 +57,14 @@ const OG_IMAGE_WIDTH = 1200;
57
57
  const OG_IMAGE_HEIGHT = 630;
58
58
  const HERO_REPRESENTATIVE_SIZE = Math.max(HERO_THUMBNAIL_SIZE, OG_IMAGE_WIDTH);
59
59
  const MAX_ENTRY_SLUG_LENGTH = 50;
60
+ const DEBUG_IIIF = process.env.CANOPY_IIIF_DEBUG === "1";
61
+
62
+ function logDebug(message) {
63
+ if (!DEBUG_IIIF) return;
64
+ try {
65
+ logLine(`[IIIF][debug] ${message}`, "magenta", {dim: true});
66
+ } catch (_) {}
67
+ }
60
68
 
61
69
  function resolvePositiveInteger(value, fallback, options = {}) {
62
70
  const allowZero = Boolean(options && options.allowZero);
@@ -71,7 +79,7 @@ function resolvePositiveInteger(value, fallback, options = {}) {
71
79
  }
72
80
 
73
81
  function formatDurationMs(ms) {
74
- if (!Number.isFinite(ms) || ms < 0) return '0ms';
82
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
75
83
  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
76
84
  return `${Math.round(ms)}ms`;
77
85
  }
@@ -99,6 +107,32 @@ function normalizeCollectionUris(value) {
99
107
  return uris;
100
108
  }
101
109
 
110
+ function normalizeManifestConfig(cfg) {
111
+ if (!cfg || typeof cfg !== "object") return [];
112
+ const entries = [];
113
+ const push = (value) => {
114
+ if (value === undefined || value === null) return;
115
+ if (Array.isArray(value)) entries.push(...value);
116
+ else entries.push(value);
117
+ };
118
+ push(cfg.manifest);
119
+ push(cfg.manifests);
120
+ if (!entries.length) return [];
121
+ return normalizeCollectionUris(entries);
122
+ }
123
+
124
+ function resolveIiifSources(cfg) {
125
+ const safeCfg = cfg && typeof cfg === "object" ? cfg : {};
126
+ let collectionUris = normalizeCollectionUris(safeCfg.collection);
127
+ if (!collectionUris.length) {
128
+ collectionUris = normalizeCollectionUris(
129
+ process.env.CANOPY_COLLECTION_URI || "",
130
+ );
131
+ }
132
+ const manifestUris = normalizeManifestConfig(safeCfg);
133
+ return {collections: collectionUris, manifests: manifestUris};
134
+ }
135
+
102
136
  function clampSlugLength(slug, limit = MAX_ENTRY_SLUG_LENGTH) {
103
137
  if (!slug) return "";
104
138
  const max = Math.max(1, limit);
@@ -132,8 +166,8 @@ function extractHomepageId(resource) {
132
166
  const list = Array.isArray(homepageRaw)
133
167
  ? homepageRaw
134
168
  : homepageRaw
135
- ? [homepageRaw]
136
- : [];
169
+ ? [homepageRaw]
170
+ : [];
137
171
  for (const entry of list) {
138
172
  if (!entry) continue;
139
173
  if (typeof entry === "string") {
@@ -207,7 +241,7 @@ function resolveThumbnailPreferences() {
207
241
  return {
208
242
  size: resolvePositiveInteger(
209
243
  process.env.CANOPY_THUMBNAIL_SIZE,
210
- DEFAULT_THUMBNAIL_SIZE
244
+ DEFAULT_THUMBNAIL_SIZE,
211
245
  ),
212
246
  unsafe: resolveBoolean(process.env.CANOPY_THUMBNAILS_UNSAFE),
213
247
  };
@@ -255,7 +289,7 @@ async function resolveHeroMedia(manifest) {
255
289
  const manifestThumb = extractResourceThumbnail(manifest);
256
290
  const heroSource = (() => {
257
291
  if (manifest && manifest.thumbnail) {
258
- const clone = { ...manifest };
292
+ const clone = {...manifest};
259
293
  try {
260
294
  delete clone.thumbnail;
261
295
  } catch (_) {
@@ -268,53 +302,49 @@ async function resolveHeroMedia(manifest) {
268
302
  const heroRep = await getRepresentativeImage(
269
303
  heroSource || manifest,
270
304
  HERO_REPRESENTATIVE_SIZE,
271
- true
305
+ true,
272
306
  );
273
307
  const canvasImage = findPrimaryCanvasImage(manifest);
274
308
  const heroService =
275
- (canvasImage && canvasImage.service) ||
276
- (heroRep && heroRep.service);
309
+ (canvasImage && canvasImage.service) || (heroRep && heroRep.service);
277
310
  const serviceIsLevel0 = isLevel0Service(heroService);
278
311
  const heroPreferred = buildIiifImageUrlFromService(
279
312
  serviceIsLevel0 ? null : heroService,
280
- HERO_THUMBNAIL_SIZE
313
+ HERO_THUMBNAIL_SIZE,
281
314
  );
282
315
  const heroWidth = (() => {
283
- if (canvasImage && typeof canvasImage.width === 'number')
316
+ if (canvasImage && typeof canvasImage.width === "number")
284
317
  return canvasImage.width;
285
- if (heroRep && typeof heroRep.width === 'number') return heroRep.width;
318
+ if (heroRep && typeof heroRep.width === "number") return heroRep.width;
286
319
  return undefined;
287
320
  })();
288
321
  const heroHeight = (() => {
289
- if (canvasImage && typeof canvasImage.height === 'number')
322
+ if (canvasImage && typeof canvasImage.height === "number")
290
323
  return canvasImage.height;
291
- if (heroRep && typeof heroRep.height === 'number')
292
- return heroRep.height;
324
+ if (heroRep && typeof heroRep.height === "number") return heroRep.height;
293
325
  return undefined;
294
326
  })();
295
- const heroSrcset = serviceIsLevel0
296
- ? ''
297
- : buildIiifImageSrcset(heroService);
327
+ const heroSrcset = serviceIsLevel0 ? "" : buildIiifImageSrcset(heroService);
298
328
  const ogFromService =
299
329
  !serviceIsLevel0 && heroService
300
330
  ? buildIiifImageUrlForDimensions(
301
331
  heroService,
302
332
  OG_IMAGE_WIDTH,
303
- OG_IMAGE_HEIGHT
333
+ OG_IMAGE_HEIGHT,
304
334
  )
305
- : '';
335
+ : "";
306
336
  const annotationImageId =
307
337
  canvasImage && canvasImage.isImageBody && canvasImage.id
308
338
  ? String(canvasImage.id)
309
- : '';
310
- let heroThumbnail = heroPreferred || '';
339
+ : "";
340
+ let heroThumbnail = heroPreferred || "";
311
341
  let heroThumbWidth = heroWidth;
312
342
  let heroThumbHeight = heroHeight;
313
343
  if (!heroThumbnail && manifestThumb && manifestThumb.url) {
314
344
  heroThumbnail = manifestThumb.url;
315
- if (typeof manifestThumb.width === 'number')
345
+ if (typeof manifestThumb.width === "number")
316
346
  heroThumbWidth = manifestThumb.width;
317
- if (typeof manifestThumb.height === 'number')
347
+ if (typeof manifestThumb.height === "number")
318
348
  heroThumbHeight = manifestThumb.height;
319
349
  }
320
350
  if (!heroThumbnail) {
@@ -324,7 +354,7 @@ async function resolveHeroMedia(manifest) {
324
354
  heroThumbnail = String(heroRep.id);
325
355
  }
326
356
  }
327
- let ogImage = '';
357
+ let ogImage = "";
328
358
  let ogImageWidth;
329
359
  let ogImageHeight;
330
360
  if (ogFromService) {
@@ -333,16 +363,16 @@ async function resolveHeroMedia(manifest) {
333
363
  ogImageHeight = OG_IMAGE_HEIGHT;
334
364
  } else if (heroThumbnail) {
335
365
  ogImage = heroThumbnail;
336
- if (typeof heroThumbWidth === 'number') ogImageWidth = heroThumbWidth;
337
- if (typeof heroThumbHeight === 'number') ogImageHeight = heroThumbHeight;
366
+ if (typeof heroThumbWidth === "number") ogImageWidth = heroThumbWidth;
367
+ if (typeof heroThumbHeight === "number") ogImageHeight = heroThumbHeight;
338
368
  }
339
369
  return {
340
- heroThumbnail: heroThumbnail || '',
370
+ heroThumbnail: heroThumbnail || "",
341
371
  heroThumbnailWidth: heroThumbWidth,
342
372
  heroThumbnailHeight: heroThumbHeight,
343
- heroThumbnailSrcset: heroSrcset || '',
344
- heroThumbnailSizes: heroSrcset ? HERO_IMAGE_SIZES_ATTR : '',
345
- ogImage: ogImage || '',
373
+ heroThumbnailSrcset: heroSrcset || "",
374
+ heroThumbnailSizes: heroSrcset ? HERO_IMAGE_SIZES_ATTR : "",
375
+ ogImage: ogImage || "",
346
376
  ogImageWidth,
347
377
  ogImageHeight,
348
378
  };
@@ -409,7 +439,7 @@ function extractSummaryValues(manifest) {
409
439
  flattenMetadataValue(manifest && manifest.summary, values, 0);
410
440
  } catch (_) {}
411
441
  const unique = Array.from(
412
- new Set(values.map((val) => String(val || "").trim()).filter(Boolean))
442
+ new Set(values.map((val) => String(val || "").trim()).filter(Boolean)),
413
443
  );
414
444
  if (!unique.length) return "";
415
445
  return unique.join(" ");
@@ -627,7 +657,7 @@ function normalizeIiifId(raw) {
627
657
  if (!/^https?:\/\//i.test(s)) return s;
628
658
  const u = new URL(s);
629
659
  const entries = Array.from(u.searchParams.entries()).sort(
630
- (a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])
660
+ (a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]),
631
661
  );
632
662
  u.search = "";
633
663
  for (const [k, v] of entries) u.searchParams.append(k, v);
@@ -841,18 +871,21 @@ function computeUniqueSlug(index, baseSlug, id, type) {
841
871
  const byId = Array.isArray(index && index.byId) ? index.byId : [];
842
872
  const normId = normalizeIiifId(String(id || ""));
843
873
  const fallbackBase = type === "Manifest" ? "untitled" : "collection";
844
- const normalizedBase = normalizeSlugBase(baseSlug || fallbackBase, fallbackBase);
874
+ const normalizedBase = normalizeSlugBase(
875
+ baseSlug || fallbackBase,
876
+ fallbackBase,
877
+ );
845
878
  const used = new Set(
846
879
  byId
847
880
  .filter((e) => e && e.slug && e.type === type)
848
- .map((e) => String(e.slug))
881
+ .map((e) => String(e.slug)),
849
882
  );
850
883
  const reserved = RESERVED_SLUGS[type] || new Set();
851
884
  let slug = normalizedBase;
852
885
  let i = 1;
853
886
  for (;;) {
854
887
  const existing = byId.find(
855
- (e) => e && e.type === type && String(e.slug) === String(slug)
888
+ (e) => e && e.type === type && String(e.slug) === String(slug),
856
889
  );
857
890
  if (existing) {
858
891
  // If this slug already maps to this id, reuse it and reserve.
@@ -874,9 +907,12 @@ function ensureBaseSlugFor(index, baseSlug, id, type) {
874
907
  const byId = Array.isArray(index && index.byId) ? index.byId : [];
875
908
  const normId = normalizeIiifId(String(id || ""));
876
909
  const fallbackBase = type === "Manifest" ? "untitled" : "collection";
877
- const normalizedBase = normalizeSlugBase(baseSlug || fallbackBase, fallbackBase);
910
+ const normalizedBase = normalizeSlugBase(
911
+ baseSlug || fallbackBase,
912
+ fallbackBase,
913
+ );
878
914
  const existingWithBase = byId.find(
879
- (e) => e && e.type === type && String(e.slug) === String(normalizedBase)
915
+ (e) => e && e.type === type && String(e.slug) === String(normalizedBase),
880
916
  );
881
917
  if (existingWithBase && normalizeIiifId(existingWithBase.id) !== normId) {
882
918
  // Reassign the existing entry to the next available suffix to free the base
@@ -884,9 +920,10 @@ function ensureBaseSlugFor(index, baseSlug, id, type) {
884
920
  index,
885
921
  normalizedBase,
886
922
  existingWithBase.id,
887
- type
923
+ type,
888
924
  );
889
- if (newSlug && newSlug !== normalizedBase) existingWithBase.slug = newSlug;
925
+ if (newSlug && newSlug !== normalizedBase)
926
+ existingWithBase.slug = newSlug;
890
927
  }
891
928
  } catch (_) {}
892
929
  return baseSlug;
@@ -903,7 +940,7 @@ async function findSlugByIdFromDisk(id) {
903
940
  const raw = await fsp.readFile(p, "utf8");
904
941
  const obj = JSON.parse(raw);
905
942
  const mid = normalizeIiifId(
906
- String((obj && (obj.id || obj["@id"])) || "")
943
+ String((obj && (obj.id || obj["@id"])) || ""),
907
944
  );
908
945
  if (mid && mid === normalizeIiifId(String(id))) {
909
946
  const slug = name.replace(/\.json$/i, "");
@@ -923,7 +960,7 @@ async function loadCachedManifestById(id) {
923
960
  if (Array.isArray(index.byId)) {
924
961
  const nid = normalizeIiifId(id);
925
962
  const entry = index.byId.find(
926
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
963
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
927
964
  );
928
965
  slug = entry && entry.slug;
929
966
  }
@@ -943,7 +980,8 @@ async function loadCachedManifestById(id) {
943
980
  index.byId = Array.isArray(index.byId) ? index.byId : [];
944
981
  const nid = normalizeIiifId(id);
945
982
  const existingEntryIdx = index.byId.findIndex(
946
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
983
+ (e) =>
984
+ e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
947
985
  );
948
986
  const entry = {
949
987
  id: String(nid),
@@ -963,7 +1001,8 @@ async function loadCachedManifestById(id) {
963
1001
  const p = path.join(IIIF_CACHE_MANIFESTS_DIR, slug + ".json");
964
1002
  if (!fs.existsSync(p)) return null;
965
1003
  const raw = await readJson(p);
966
- const {manifest: normalized, changed} = await ensurePresentation3Manifest(raw);
1004
+ const {manifest: normalized, changed} =
1005
+ await ensurePresentation3Manifest(raw);
967
1006
  if (changed) {
968
1007
  try {
969
1008
  await fsp.writeFile(p, JSON.stringify(normalized, null, 2), "utf8");
@@ -973,12 +1012,17 @@ async function loadCachedManifestById(id) {
973
1012
  index.byId = Array.isArray(index.byId) ? index.byId : [];
974
1013
  const nid = normalizeIiifId(id);
975
1014
  const existingEntryIdx = index.byId.findIndex(
976
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
1015
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
977
1016
  );
978
1017
  if (existingEntryIdx >= 0) {
979
1018
  const entry = index.byId[existingEntryIdx];
980
- const prevCanonical = entry && entry.canonical ? String(entry.canonical) : "";
981
- const nextCanonical = applyManifestEntryCanonical(entry, normalized, slug);
1019
+ const prevCanonical =
1020
+ entry && entry.canonical ? String(entry.canonical) : "";
1021
+ const nextCanonical = applyManifestEntryCanonical(
1022
+ entry,
1023
+ normalized,
1024
+ slug,
1025
+ );
982
1026
  if (nextCanonical !== prevCanonical) {
983
1027
  await saveManifestIndex(index);
984
1028
  }
@@ -991,21 +1035,28 @@ async function loadCachedManifestById(id) {
991
1035
  }
992
1036
 
993
1037
  async function saveCachedManifest(manifest, id, parentId) {
994
- const {manifest: normalizedManifest} = await ensurePresentation3Manifest(manifest);
1038
+ const {manifest: normalizedManifest} =
1039
+ await ensurePresentation3Manifest(manifest);
995
1040
  try {
996
1041
  const index = await loadManifestIndex();
997
- const title = firstLabelString(normalizedManifest && normalizedManifest.label);
1042
+ const title = firstLabelString(
1043
+ normalizedManifest && normalizedManifest.label,
1044
+ );
998
1045
  const baseSlug =
999
1046
  slugify(title || "untitled", {lower: true, strict: true, trim: true}) ||
1000
1047
  "untitled";
1001
1048
  const slug = computeUniqueSlug(index, baseSlug, id, "Manifest");
1002
1049
  ensureDirSync(IIIF_CACHE_MANIFESTS_DIR);
1003
1050
  const dest = path.join(IIIF_CACHE_MANIFESTS_DIR, slug + ".json");
1004
- await fsp.writeFile(dest, JSON.stringify(normalizedManifest, null, 2), "utf8");
1051
+ await fsp.writeFile(
1052
+ dest,
1053
+ JSON.stringify(normalizedManifest, null, 2),
1054
+ "utf8",
1055
+ );
1005
1056
  index.byId = Array.isArray(index.byId) ? index.byId : [];
1006
1057
  const nid = normalizeIiifId(id);
1007
1058
  const existingEntryIdx = index.byId.findIndex(
1008
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
1059
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
1009
1060
  );
1010
1061
  const entry = {
1011
1062
  id: String(nid),
@@ -1056,7 +1107,7 @@ async function ensureFeaturedInCache(cfg) {
1056
1107
  e &&
1057
1108
  e.type === "Manifest" &&
1058
1109
  normalizeIiifId(String(e.id)) ===
1059
- normalizeIiifId(String(manifest.id))
1110
+ normalizeIiifId(String(manifest.id)),
1060
1111
  );
1061
1112
  if (!entry) continue;
1062
1113
 
@@ -1108,7 +1159,8 @@ async function ensureFeaturedInCache(cfg) {
1108
1159
  if (entry.heroThumbnailSrcset !== heroMedia.heroThumbnailSrcset)
1109
1160
  touched = true;
1110
1161
  entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
1111
- if (entry.heroThumbnailSizes !== HERO_IMAGE_SIZES_ATTR) touched = true;
1162
+ if (entry.heroThumbnailSizes !== HERO_IMAGE_SIZES_ATTR)
1163
+ touched = true;
1112
1164
  entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
1113
1165
  } else {
1114
1166
  if (entry.heroThumbnailSrcset !== undefined) {
@@ -1125,15 +1177,16 @@ async function ensureFeaturedInCache(cfg) {
1125
1177
  entry.ogImage = heroMedia.ogImage;
1126
1178
  touched = true;
1127
1179
  }
1128
- if (typeof heroMedia.ogImageWidth === 'number') {
1180
+ if (typeof heroMedia.ogImageWidth === "number") {
1129
1181
  if (entry.ogImageWidth !== heroMedia.ogImageWidth) touched = true;
1130
1182
  entry.ogImageWidth = heroMedia.ogImageWidth;
1131
1183
  } else if (entry.ogImageWidth !== undefined) {
1132
1184
  delete entry.ogImageWidth;
1133
1185
  touched = true;
1134
1186
  }
1135
- if (typeof heroMedia.ogImageHeight === 'number') {
1136
- if (entry.ogImageHeight !== heroMedia.ogImageHeight) touched = true;
1187
+ if (typeof heroMedia.ogImageHeight === "number") {
1188
+ if (entry.ogImageHeight !== heroMedia.ogImageHeight)
1189
+ touched = true;
1137
1190
  entry.ogImageHeight = heroMedia.ogImageHeight;
1138
1191
  } else if (entry.ogImageHeight !== undefined) {
1139
1192
  delete entry.ogImageHeight;
@@ -1150,7 +1203,7 @@ async function ensureFeaturedInCache(cfg) {
1150
1203
  entry,
1151
1204
  heroMedia && heroMedia.heroThumbnail,
1152
1205
  heroMedia && heroMedia.heroThumbnailWidth,
1153
- heroMedia && heroMedia.heroThumbnailHeight
1206
+ heroMedia && heroMedia.heroThumbnailHeight,
1154
1207
  )
1155
1208
  ) {
1156
1209
  touched = true;
@@ -1187,7 +1240,7 @@ async function loadCachedCollectionById(id) {
1187
1240
  if (Array.isArray(index.byId)) {
1188
1241
  const nid = normalizeIiifId(id);
1189
1242
  const entry = index.byId.find(
1190
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
1243
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection",
1191
1244
  );
1192
1245
  slug = entry && entry.slug;
1193
1246
  }
@@ -1204,7 +1257,7 @@ async function loadCachedCollectionById(id) {
1204
1257
  const raw = await fsp.readFile(p, "utf8");
1205
1258
  const obj = JSON.parse(raw);
1206
1259
  const cid = normalizeIiifId(
1207
- String((obj && (obj.id || obj["@id"])) || "")
1260
+ String((obj && (obj.id || obj["@id"])) || ""),
1208
1261
  );
1209
1262
  if (cid && cid === normalizeIiifId(String(id))) {
1210
1263
  const candidate = name.replace(/\.json$/i, "");
@@ -1221,7 +1274,7 @@ async function loadCachedCollectionById(id) {
1221
1274
  (e) =>
1222
1275
  e &&
1223
1276
  normalizeIiifId(e.id) === nid &&
1224
- e.type === "Collection"
1277
+ e.type === "Collection",
1225
1278
  );
1226
1279
  const entry = {
1227
1280
  id: String(nid),
@@ -1249,11 +1302,12 @@ async function loadCachedCollectionById(id) {
1249
1302
  index.byId = Array.isArray(index.byId) ? index.byId : [];
1250
1303
  const nid = normalizeIiifId(id);
1251
1304
  const existingEntryIdx = index.byId.findIndex(
1252
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
1305
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection",
1253
1306
  );
1254
1307
  if (existingEntryIdx >= 0) {
1255
1308
  const entry = index.byId[existingEntryIdx];
1256
- const prevCanonical = entry && entry.canonical ? String(entry.canonical) : "";
1309
+ const prevCanonical =
1310
+ entry && entry.canonical ? String(entry.canonical) : "";
1257
1311
  const nextCanonical = applyCollectionEntryCanonical(entry, data);
1258
1312
  if (nextCanonical !== prevCanonical) {
1259
1313
  await saveManifestIndex(index);
@@ -1271,7 +1325,9 @@ async function saveCachedCollection(collection, id, parentId) {
1271
1325
  const normalizedCollection = await upgradeIiifResource(collection);
1272
1326
  ensureDirSync(IIIF_CACHE_COLLECTIONS_DIR);
1273
1327
  const index = await loadManifestIndex();
1274
- const title = firstLabelString(normalizedCollection && normalizedCollection.label);
1328
+ const title = firstLabelString(
1329
+ normalizedCollection && normalizedCollection.label,
1330
+ );
1275
1331
  const baseSlug =
1276
1332
  slugify(title || "collection", {
1277
1333
  lower: true,
@@ -1280,7 +1336,11 @@ async function saveCachedCollection(collection, id, parentId) {
1280
1336
  }) || "collection";
1281
1337
  const slug = computeUniqueSlug(index, baseSlug, id, "Collection");
1282
1338
  const dest = path.join(IIIF_CACHE_COLLECTIONS_DIR, slug + ".json");
1283
- await fsp.writeFile(dest, JSON.stringify(normalizedCollection, null, 2), "utf8");
1339
+ await fsp.writeFile(
1340
+ dest,
1341
+ JSON.stringify(normalizedCollection, null, 2),
1342
+ "utf8",
1343
+ );
1284
1344
  try {
1285
1345
  if (process.env.CANOPY_IIIF_DEBUG === "1") {
1286
1346
  const {logLine} = require("./log");
@@ -1290,7 +1350,7 @@ async function saveCachedCollection(collection, id, parentId) {
1290
1350
  index.byId = Array.isArray(index.byId) ? index.byId : [];
1291
1351
  const nid = normalizeIiifId(id);
1292
1352
  const existingEntryIdx = index.byId.findIndex(
1293
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
1353
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection",
1294
1354
  );
1295
1355
  const entry = {
1296
1356
  id: String(nid),
@@ -1305,133 +1365,96 @@ async function saveCachedCollection(collection, id, parentId) {
1305
1365
  } catch (_) {}
1306
1366
  }
1307
1367
 
1308
- async function rebuildManifestIndexFromCache() {
1309
- try {
1310
- const previous = await loadManifestIndex();
1311
- const previousEntries = Array.isArray(previous.byId) ? previous.byId : [];
1312
- const priorMap = new Map();
1313
- for (const entry of previousEntries) {
1314
- if (!entry || !entry.id) continue;
1315
- const type = entry.type || "Manifest";
1316
- const key = `${type}:${normalizeIiifId(entry.id)}`;
1317
- priorMap.set(key, entry);
1318
- }
1319
- const nextIndex = {
1320
- byId: [],
1321
- collection: previous.collection || null,
1322
- };
1323
- const collectionFiles = fs.existsSync(IIIF_CACHE_COLLECTIONS_DIR)
1324
- ? (await fsp.readdir(IIIF_CACHE_COLLECTIONS_DIR))
1325
- .filter((name) => name && name.toLowerCase().endsWith(".json"))
1326
- .sort()
1327
- : [];
1328
- const manifestFiles = fs.existsSync(IIIF_CACHE_MANIFESTS_DIR)
1329
- ? (await fsp.readdir(IIIF_CACHE_MANIFESTS_DIR))
1330
- .filter((name) => name && name.toLowerCase().endsWith(".json"))
1331
- .sort()
1332
- : [];
1333
- const {size: thumbSize, unsafe: unsafeThumbs} =
1334
- resolveThumbnailPreferences();
1335
-
1336
- for (const name of collectionFiles) {
1337
- const slug = name.replace(/\.json$/i, "");
1338
- const fp = path.join(IIIF_CACHE_COLLECTIONS_DIR, name);
1339
- let data = null;
1340
- try {
1341
- data = await readJson(fp);
1342
- } catch (_) {
1343
- data = null;
1344
- }
1345
- if (!data) continue;
1346
- const id = data.id || data["@id"];
1347
- if (!id) continue;
1348
- const nid = normalizeIiifId(String(id));
1349
- const key = `Collection:${nid}`;
1350
- const fallback = priorMap.get(key) || {};
1351
- const parent = resolveParentFromPartOf(data) || fallback.parent || "";
1352
- const entry = {
1353
- id: String(nid),
1354
- type: "Collection",
1355
- slug,
1356
- parent,
1357
- };
1358
- applyCollectionEntryCanonical(entry, data);
1359
- nextIndex.byId.push(entry);
1360
- }
1368
+ async function cleanupIiifCache(options = {}) {
1369
+ const allowedManifestIds = Array.isArray(options.allowedManifestIds)
1370
+ ? options.allowedManifestIds
1371
+ : [];
1372
+ const allowedCollectionIds = Array.isArray(options.allowedCollectionIds)
1373
+ ? options.allowedCollectionIds
1374
+ : [];
1375
+ const manifestSet = new Set(
1376
+ allowedManifestIds
1377
+ .map((id) => normalizeIiifId(String(id || "")))
1378
+ .filter(Boolean),
1379
+ );
1380
+ const collectionSet = new Set(
1381
+ allowedCollectionIds
1382
+ .map((id) => normalizeIiifId(String(id || "")))
1383
+ .filter(Boolean),
1384
+ );
1385
+ if (!manifestSet.size && !collectionSet.size) return;
1361
1386
 
1362
- for (const name of manifestFiles) {
1363
- const slug = name.replace(/\.json$/i, "");
1387
+ let removedManifestFiles = 0;
1388
+ if (fs.existsSync(IIIF_CACHE_MANIFESTS_DIR)) {
1389
+ const files = await fsp.readdir(IIIF_CACHE_MANIFESTS_DIR);
1390
+ for (const name of files) {
1391
+ if (!name || !name.toLowerCase().endsWith(".json")) continue;
1364
1392
  const fp = path.join(IIIF_CACHE_MANIFESTS_DIR, name);
1365
1393
  let manifest = null;
1366
1394
  try {
1367
1395
  manifest = await readJson(fp);
1368
- } catch (_) {
1369
- manifest = null;
1370
- }
1371
- if (!manifest) continue;
1372
- const id = manifest.id || manifest["@id"];
1373
- if (!id) continue;
1374
- const nid = normalizeIiifId(String(id));
1375
- MEMO_ID_TO_SLUG.set(String(id), slug);
1376
- const key = `Manifest:${nid}`;
1377
- const fallback = priorMap.get(key) || {};
1378
- const parent = resolveParentFromPartOf(manifest) || fallback.parent || "";
1379
- const entry = {
1380
- id: String(nid),
1381
- type: "Manifest",
1382
- slug,
1383
- parent,
1384
- };
1385
- applyManifestEntryCanonical(entry, manifest, slug);
1386
- try {
1387
- const thumb = await getThumbnail(manifest, thumbSize, unsafeThumbs);
1388
- if (thumb && thumb.url) {
1389
- entry.thumbnail = String(thumb.url);
1390
- if (typeof thumb.width === "number") entry.thumbnailWidth = thumb.width;
1391
- if (typeof thumb.height === "number") entry.thumbnailHeight = thumb.height;
1392
- }
1393
1396
  } catch (_) {}
1397
+ const nid = normalizeIiifId(
1398
+ String(
1399
+ (manifest && (manifest.id || manifest["@id"])) ||
1400
+ name.replace(/\.json$/i, ""),
1401
+ ),
1402
+ );
1403
+ if (!manifestSet.has(nid)) {
1404
+ try {
1405
+ await fsp.rm(fp, {force: true});
1406
+ removedManifestFiles += 1;
1407
+ } catch (_) {}
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ let removedCollectionFiles = 0;
1413
+ if (fs.existsSync(IIIF_CACHE_COLLECTIONS_DIR)) {
1414
+ const files = await fsp.readdir(IIIF_CACHE_COLLECTIONS_DIR);
1415
+ for (const name of files) {
1416
+ if (!name || !name.toLowerCase().endsWith(".json")) continue;
1417
+ const fp = path.join(IIIF_CACHE_COLLECTIONS_DIR, name);
1418
+ let collection = null;
1394
1419
  try {
1395
- const heroMedia = await resolveHeroMedia(manifest);
1396
- if (heroMedia && heroMedia.heroThumbnail) {
1397
- entry.heroThumbnail = heroMedia.heroThumbnail;
1398
- if (typeof heroMedia.heroThumbnailWidth === "number")
1399
- entry.heroThumbnailWidth = heroMedia.heroThumbnailWidth;
1400
- if (typeof heroMedia.heroThumbnailHeight === "number")
1401
- entry.heroThumbnailHeight = heroMedia.heroThumbnailHeight;
1402
- if (heroMedia.heroThumbnailSrcset) {
1403
- entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
1404
- entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
1405
- }
1406
- if (heroMedia.ogImage) {
1407
- entry.ogImage = heroMedia.ogImage;
1408
- if (typeof heroMedia.ogImageWidth === 'number')
1409
- entry.ogImageWidth = heroMedia.ogImageWidth;
1410
- else delete entry.ogImageWidth;
1411
- if (typeof heroMedia.ogImageHeight === 'number')
1412
- entry.ogImageHeight = heroMedia.ogImageHeight;
1413
- else delete entry.ogImageHeight;
1414
- }
1415
- ensureThumbnailValue(
1416
- entry,
1417
- heroMedia.heroThumbnail,
1418
- heroMedia.heroThumbnailWidth,
1419
- heroMedia.heroThumbnailHeight
1420
- );
1421
- }
1420
+ collection = await readJson(fp);
1422
1421
  } catch (_) {}
1423
- nextIndex.byId.push(entry);
1422
+ const nid = normalizeIiifId(
1423
+ String(
1424
+ (collection && (collection.id || collection["@id"])) ||
1425
+ name.replace(/\.json$/i, ""),
1426
+ ),
1427
+ );
1428
+ if (!collectionSet.has(nid)) {
1429
+ try {
1430
+ await fsp.rm(fp, {force: true});
1431
+ removedCollectionFiles += 1;
1432
+ } catch (_) {}
1433
+ }
1424
1434
  }
1425
-
1426
- await saveManifestIndex(nextIndex);
1427
- try {
1428
- logLine("✓ Rebuilt IIIF cache index", "cyan");
1429
- } catch (_) {}
1430
- } catch (err) {
1431
- try {
1432
- logLine("! Skipped IIIF index rebuild", "yellow");
1433
- } catch (_) {}
1434
1435
  }
1436
+
1437
+ try {
1438
+ const index = await loadManifestIndex();
1439
+ if (Array.isArray(index.byId)) {
1440
+ index.byId = index.byId.filter((entry) => {
1441
+ if (!entry || !entry.id) return false;
1442
+ const nid = normalizeIiifId(String(entry.id));
1443
+ if (entry.type === "Manifest") return manifestSet.has(nid);
1444
+ if (entry.type === "Collection") return collectionSet.has(nid);
1445
+ return true;
1446
+ });
1447
+ await saveManifestIndex(index);
1448
+ }
1449
+ } catch (_) {}
1450
+
1451
+ try {
1452
+ logLine(
1453
+ `• Cleaned IIIF cache (${removedManifestFiles} Manifest file(s) removed, ${removedCollectionFiles} Collection file(s) removed)`,
1454
+ "blue",
1455
+ {dim: true},
1456
+ );
1457
+ } catch (_) {}
1435
1458
  }
1436
1459
 
1437
1460
  async function loadConfig() {
@@ -1451,13 +1474,9 @@ async function loadConfig() {
1451
1474
  async function buildIiifCollectionPages(CONFIG) {
1452
1475
  const cfg = CONFIG || (await loadConfig());
1453
1476
 
1454
- let collectionUris = normalizeCollectionUris(cfg && cfg.collection);
1455
- if (!collectionUris.length) {
1456
- collectionUris = normalizeCollectionUris(
1457
- process.env.CANOPY_COLLECTION_URI || ""
1458
- );
1459
- }
1460
- if (!collectionUris.length) return {searchRecords: []};
1477
+ const {collections: collectionUris, manifests: manifestUris} =
1478
+ resolveIiifSources(cfg);
1479
+ if (!collectionUris.length && !manifestUris.length) return {iiifRecords: []};
1461
1480
 
1462
1481
  const searchIndexCfg = (cfg && cfg.search && cfg.search.index) || {};
1463
1482
  const metadataCfg = (searchIndexCfg && searchIndexCfg.metadata) || {};
@@ -1483,7 +1502,7 @@ async function buildIiifCollectionPages(CONFIG) {
1483
1502
  const metadataLabelSet = new Set(
1484
1503
  metadataLabelsRaw
1485
1504
  .map((label) => normalizeMetadataLabel(String(label || "")))
1486
- .filter(Boolean)
1505
+ .filter(Boolean),
1487
1506
  );
1488
1507
  const metadataFacetLabels = (() => {
1489
1508
  if (!Array.isArray(metadataLabelsRaw) || !metadataLabelsRaw.length)
@@ -1491,7 +1510,8 @@ async function buildIiifCollectionPages(CONFIG) {
1491
1510
  const seen = new Set();
1492
1511
  const entries = [];
1493
1512
  for (const label of metadataLabelsRaw) {
1494
- const raw = typeof label === "string" ? label.trim() : String(label || "");
1513
+ const raw =
1514
+ typeof label === "string" ? label.trim() : String(label || "");
1495
1515
  if (!raw) continue;
1496
1516
  const normalized = normalizeMetadataLabel(raw);
1497
1517
  if (!normalized || seen.has(normalized)) continue;
@@ -1514,8 +1534,8 @@ async function buildIiifCollectionPages(CONFIG) {
1514
1534
  };
1515
1535
  const annotationMotivations = new Set(
1516
1536
  normalizeStringList(annotationsCfg && annotationsCfg.motivation).map((m) =>
1517
- m.toLowerCase()
1518
- )
1537
+ m.toLowerCase(),
1538
+ ),
1519
1539
  );
1520
1540
  const annotationsOptions = {
1521
1541
  enabled: annotationsEnabled,
@@ -1524,7 +1544,11 @@ async function buildIiifCollectionPages(CONFIG) {
1524
1544
 
1525
1545
  // Recursively traverse Collections and gather all Manifest tasks
1526
1546
  const tasks = [];
1547
+ let manifestTasksFromCollections = 0;
1548
+ let manifestTasksFromConfig = 0;
1549
+ const queuedManifestIds = new Set();
1527
1550
  const visitedCollections = new Set(); // normalized ids
1551
+ const renderedManifestIds = new Set();
1528
1552
  const norm = (x) => {
1529
1553
  try {
1530
1554
  return normalizeIiifId(String(x || ""));
@@ -1550,9 +1574,8 @@ async function buildIiifCollectionPages(CONFIG) {
1550
1574
  const ncol = await upgradeIiifResource(col);
1551
1575
  const reportedId = String(
1552
1576
  (ncol && (ncol.id || ncol["@id"])) ||
1553
- (typeof colLike === "object" &&
1554
- (colLike.id || colLike["@id"])) ||
1555
- ""
1577
+ (typeof colLike === "object" && (colLike.id || colLike["@id"])) ||
1578
+ "",
1556
1579
  );
1557
1580
  const effectiveId = String(uri || reportedId || "");
1558
1581
  const collectionKey = effectiveId || reportedId || uri || "";
@@ -1567,8 +1590,13 @@ async function buildIiifCollectionPages(CONFIG) {
1567
1590
  const entryId = entry && entry.id;
1568
1591
  if (!entryId) continue;
1569
1592
  const entryType = normalizeIiifType(entry.type || entry.fallback || "");
1593
+ const dedupeKey = norm(entryId) || String(entryId || "");
1594
+ if (!dedupeKey) continue;
1570
1595
  if (entryType === "manifest") {
1596
+ if (queuedManifestIds.has(dedupeKey)) continue;
1597
+ queuedManifestIds.add(dedupeKey);
1571
1598
  tasks.push({id: entryId, parent: collectionKey});
1599
+ manifestTasksFromCollections += 1;
1572
1600
  } else if (entryType === "collection") {
1573
1601
  await gatherFromCollection(entry.raw || entryId, collectionKey);
1574
1602
  }
@@ -1597,32 +1625,50 @@ async function buildIiifCollectionPages(CONFIG) {
1597
1625
  } catch (_) {}
1598
1626
  await gatherFromCollection(normalizedRoot, "");
1599
1627
  }
1600
- if (!tasks.length) return {searchRecords: []};
1628
+ if (manifestUris.length) {
1629
+ for (const uri of manifestUris) {
1630
+ const dedupeKey = norm(uri) || String(uri || "");
1631
+ if (!dedupeKey || queuedManifestIds.has(dedupeKey)) continue;
1632
+ queuedManifestIds.add(dedupeKey);
1633
+ tasks.push({id: uri, parent: ""});
1634
+ manifestTasksFromConfig += 1;
1635
+ }
1636
+ }
1637
+ if (!tasks.length) return {iiifRecords: []};
1638
+ try {
1639
+ logLine(
1640
+ `• Processing ${tasks.length} Manifest(s) (${manifestTasksFromCollections} from collections, ${manifestTasksFromConfig} direct)`,
1641
+ "blue",
1642
+ {dim: true},
1643
+ );
1644
+ } catch (_) {}
1645
+ logDebug(
1646
+ `Queued ${tasks.length} Manifest task(s) (${manifestTasksFromCollections} from collections, ${manifestTasksFromConfig} direct)`,
1647
+ );
1601
1648
 
1602
1649
  // Split into chunks and process with limited concurrency
1603
1650
  const chunkSize = resolvePositiveInteger(
1604
1651
  process.env.CANOPY_CHUNK_SIZE,
1605
- DEFAULT_CHUNK_SIZE
1652
+ DEFAULT_CHUNK_SIZE,
1606
1653
  );
1607
1654
  const chunks = Math.ceil(tasks.length / chunkSize);
1608
1655
  const requestedConcurrency = resolvePositiveInteger(
1609
1656
  process.env.CANOPY_FETCH_CONCURRENCY,
1610
1657
  DEFAULT_FETCH_CONCURRENCY,
1611
- {allowZero: true}
1658
+ {allowZero: true},
1612
1659
  );
1613
1660
  // Summary before processing chunks
1614
1661
  try {
1615
- const collectionsCount = visitedCollections.size || 0;
1616
1662
  logLine(
1617
- `• Fetching ${tasks.length} Manifest(s) in ${chunks} chunk(s) across ${collectionsCount} Collection(s)`,
1663
+ `• Fetching ${tasks.length} Manifest(s) in ${chunks} chunk(s)`,
1618
1664
  "blue",
1619
- {dim: true}
1665
+ {dim: true},
1620
1666
  );
1621
1667
  const concurrencySummary =
1622
1668
  requestedConcurrency === 0
1623
1669
  ? "auto (no explicit cap)"
1624
1670
  : String(requestedConcurrency);
1625
- logLine(`• Fetch concurrency: ${concurrencySummary}`, "blue", { dim: true });
1671
+ logLine(`• Fetch concurrency: ${concurrencySummary}`, "blue", {dim: true});
1626
1672
  } catch (_) {}
1627
1673
  const iiifRecords = [];
1628
1674
  const navPlaceRecords = [];
@@ -1632,7 +1678,7 @@ async function buildIiifCollectionPages(CONFIG) {
1632
1678
  const worksLayoutPath = path.join(CONTENT_DIR, "works", "_layout.mdx");
1633
1679
  if (!fs.existsSync(worksLayoutPath)) {
1634
1680
  throw new Error(
1635
- "IIIF build requires content/works/_layout.mdx. Create the layout instead of relying on generated output."
1681
+ "IIIF build requires content/works/_layout.mdx. Create the layout instead of relying on generated output.",
1636
1682
  );
1637
1683
  }
1638
1684
  let WorksLayoutComp = null;
@@ -1652,7 +1698,9 @@ async function buildIiifCollectionPages(CONFIG) {
1652
1698
  const chunkStart = Date.now();
1653
1699
 
1654
1700
  const concurrency =
1655
- requestedConcurrency === 0 ? Math.max(1, chunk.length) : requestedConcurrency;
1701
+ requestedConcurrency === 0
1702
+ ? Math.max(1, chunk.length)
1703
+ : requestedConcurrency;
1656
1704
  let next = 0;
1657
1705
  const logs = new Array(chunk.length);
1658
1706
  let nextPrint = 0;
@@ -1692,7 +1740,7 @@ async function buildIiifCollectionPages(CONFIG) {
1692
1740
  const saved = await saveCachedManifest(
1693
1741
  manifest,
1694
1742
  String(id),
1695
- String(it.parent || "")
1743
+ String(it.parent || ""),
1696
1744
  );
1697
1745
  manifest = saved || manifest;
1698
1746
  const cached = await loadCachedManifestById(String(id));
@@ -1719,7 +1767,7 @@ async function buildIiifCollectionPages(CONFIG) {
1719
1767
  const saved = await saveCachedManifest(
1720
1768
  manifest,
1721
1769
  String(id),
1722
- String(it.parent || "")
1770
+ String(it.parent || ""),
1723
1771
  );
1724
1772
  manifest = saved || manifest;
1725
1773
  const cached = await loadCachedManifestById(String(id));
@@ -1737,11 +1785,13 @@ async function buildIiifCollectionPages(CONFIG) {
1737
1785
  const ensured = await ensurePresentation3Manifest(manifest);
1738
1786
  manifest = ensured.manifest;
1739
1787
  const title = firstLabelString(manifest.label);
1740
- let summaryRaw = '';
1788
+ const manifestLabel = title || String(manifest.id || id);
1789
+ logDebug(`Preparing manifest ${manifestLabel}`);
1790
+ let summaryRaw = "";
1741
1791
  try {
1742
1792
  summaryRaw = extractSummaryValues(manifest);
1743
1793
  } catch (_) {
1744
- summaryRaw = '';
1794
+ summaryRaw = "";
1745
1795
  }
1746
1796
  const summaryForMeta = truncateSummary(summaryRaw || title);
1747
1797
  const baseSlug =
@@ -1754,7 +1804,7 @@ async function buildIiifCollectionPages(CONFIG) {
1754
1804
  let idxMap = await loadManifestIndex();
1755
1805
  idxMap.byId = Array.isArray(idxMap.byId) ? idxMap.byId : [];
1756
1806
  let mEntry = idxMap.byId.find(
1757
- (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid
1807
+ (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid,
1758
1808
  );
1759
1809
  let slug = mEntry && mEntry.slug;
1760
1810
  if (isSlugTooLong(slug)) slug = null;
@@ -1769,7 +1819,7 @@ async function buildIiifCollectionPages(CONFIG) {
1769
1819
  };
1770
1820
  applyManifestEntryCanonical(newEntry, manifest, slug);
1771
1821
  const existingIdx = idxMap.byId.findIndex(
1772
- (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid
1822
+ (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid,
1773
1823
  );
1774
1824
  if (existingIdx >= 0) idxMap.byId[existingIdx] = newEntry;
1775
1825
  else idxMap.byId.push(newEntry);
@@ -1777,12 +1827,19 @@ async function buildIiifCollectionPages(CONFIG) {
1777
1827
  mEntry = newEntry;
1778
1828
  } else if (mEntry) {
1779
1829
  const prevCanonical = mEntry.canonical || "";
1780
- const nextCanonical = applyManifestEntryCanonical(mEntry, manifest, slug);
1830
+ const nextCanonical = applyManifestEntryCanonical(
1831
+ mEntry,
1832
+ manifest,
1833
+ slug,
1834
+ );
1781
1835
  if (nextCanonical !== prevCanonical) {
1782
1836
  await saveManifestIndex(idxMap);
1783
1837
  }
1784
1838
  }
1785
1839
  const manifestId = manifest && manifest.id ? manifest.id : id;
1840
+ const normalizedManifestId = normalizeIiifId(String(manifestId || id));
1841
+ if (normalizedManifestId) renderedManifestIds.add(normalizedManifestId);
1842
+ logDebug(`Resolved slug ${slug} for ${manifestLabel}`);
1786
1843
  const references = referenced.getReferencesForManifest(manifestId);
1787
1844
  const href = path.join("works", slug + ".html");
1788
1845
  const outPath = path.join(OUT_DIR, href);
@@ -1844,7 +1901,8 @@ async function buildIiifCollectionPages(CONFIG) {
1844
1901
  canonical,
1845
1902
  },
1846
1903
  };
1847
- const ogImageForPage = heroMedia && heroMedia.ogImage ? heroMedia.ogImage : '';
1904
+ const ogImageForPage =
1905
+ heroMedia && heroMedia.ogImage ? heroMedia.ogImage : "";
1848
1906
  if (ogImageForPage) {
1849
1907
  pageDetails.image = ogImageForPage;
1850
1908
  pageDetails.ogImage = ogImageForPage;
@@ -1852,11 +1910,19 @@ async function buildIiifCollectionPages(CONFIG) {
1852
1910
  pageDetails.meta.ogImage = ogImageForPage;
1853
1911
  }
1854
1912
  const navigationRoots = navigation.buildNavigationRoots(slug || "");
1855
- const navigationContext = navigationRoots && Object.keys(navigationRoots).length
1856
- ? { allRoots: navigationRoots }
1857
- : null;
1858
- const pageContextValue = { navigation: navigationContext, page: pageDetails };
1859
- if (metadataFacetLabels.length && manifest && typeof manifest === "object") {
1913
+ const navigationContext =
1914
+ navigationRoots && Object.keys(navigationRoots).length
1915
+ ? {allRoots: navigationRoots}
1916
+ : null;
1917
+ const pageContextValue = {
1918
+ navigation: navigationContext,
1919
+ page: pageDetails,
1920
+ };
1921
+ if (
1922
+ metadataFacetLabels.length &&
1923
+ manifest &&
1924
+ typeof manifest === "object"
1925
+ ) {
1860
1926
  try {
1861
1927
  Object.defineProperty(manifest, "__canopyMetadataFacets", {
1862
1928
  configurable: true,
@@ -1882,15 +1948,15 @@ async function buildIiifCollectionPages(CONFIG) {
1882
1948
  PageContext && pageContextValue
1883
1949
  ? React.createElement(
1884
1950
  PageContext.Provider,
1885
- { value: pageContextValue },
1886
- wrappedApp
1951
+ {value: pageContextValue},
1952
+ wrappedApp,
1887
1953
  )
1888
1954
  : wrappedApp;
1889
1955
  const page = MDXProvider
1890
1956
  ? React.createElement(
1891
1957
  MDXProvider,
1892
1958
  {components: compMap},
1893
- withContext
1959
+ withContext,
1894
1960
  )
1895
1961
  : withContext;
1896
1962
  const body = ReactDOMServer.renderToStaticMarkup(page);
@@ -1903,8 +1969,8 @@ async function buildIiifCollectionPages(CONFIG) {
1903
1969
  const wrappedHead = PageContext
1904
1970
  ? React.createElement(
1905
1971
  PageContext.Provider,
1906
- { value: pageContextValue },
1907
- headElement
1972
+ {value: pageContextValue},
1973
+ headElement,
1908
1974
  )
1909
1975
  : headElement;
1910
1976
  head = ReactDOMServer.renderToStaticMarkup(wrappedHead);
@@ -1928,7 +1994,7 @@ async function buildIiifCollectionPages(CONFIG) {
1928
1994
  ? path
1929
1995
  .relative(
1930
1996
  path.dirname(outPath),
1931
- path.join(OUT_DIR, "scripts", "canopy-viewer.js")
1997
+ path.join(OUT_DIR, "scripts", "canopy-viewer.js"),
1932
1998
  )
1933
1999
  .split(path.sep)
1934
2000
  .join("/")
@@ -1937,7 +2003,7 @@ async function buildIiifCollectionPages(CONFIG) {
1937
2003
  ? path
1938
2004
  .relative(
1939
2005
  path.dirname(outPath),
1940
- path.join(OUT_DIR, "scripts", "canopy-slider.js")
2006
+ path.join(OUT_DIR, "scripts", "canopy-slider.js"),
1941
2007
  )
1942
2008
  .split(path.sep)
1943
2009
  .join("/")
@@ -1946,7 +2012,7 @@ async function buildIiifCollectionPages(CONFIG) {
1946
2012
  ? path
1947
2013
  .relative(
1948
2014
  path.dirname(outPath),
1949
- path.join(OUT_DIR, "scripts", "canopy-timeline.js")
2015
+ path.join(OUT_DIR, "scripts", "canopy-timeline.js"),
1950
2016
  )
1951
2017
  .split(path.sep)
1952
2018
  .join("/")
@@ -1955,7 +2021,7 @@ async function buildIiifCollectionPages(CONFIG) {
1955
2021
  ? path
1956
2022
  .relative(
1957
2023
  path.dirname(outPath),
1958
- path.join(OUT_DIR, "scripts", "canopy-map.js")
2024
+ path.join(OUT_DIR, "scripts", "canopy-map.js"),
1959
2025
  )
1960
2026
  .split(path.sep)
1961
2027
  .join("/")
@@ -1964,7 +2030,7 @@ async function buildIiifCollectionPages(CONFIG) {
1964
2030
  ? path
1965
2031
  .relative(
1966
2032
  path.dirname(outPath),
1967
- path.join(OUT_DIR, "scripts", "canopy-map.css")
2033
+ path.join(OUT_DIR, "scripts", "canopy-map.css"),
1968
2034
  )
1969
2035
  .split(path.sep)
1970
2036
  .join("/")
@@ -1973,7 +2039,7 @@ async function buildIiifCollectionPages(CONFIG) {
1973
2039
  ? path
1974
2040
  .relative(
1975
2041
  path.dirname(outPath),
1976
- path.join(OUT_DIR, "scripts", "canopy-hero-slider.js")
2042
+ path.join(OUT_DIR, "scripts", "canopy-hero-slider.js"),
1977
2043
  )
1978
2044
  .split(path.sep)
1979
2045
  .join("/")
@@ -1982,7 +2048,7 @@ async function buildIiifCollectionPages(CONFIG) {
1982
2048
  ? path
1983
2049
  .relative(
1984
2050
  path.dirname(outPath),
1985
- path.join(OUT_DIR, "scripts", "canopy-related-items.js")
2051
+ path.join(OUT_DIR, "scripts", "canopy-related-items.js"),
1986
2052
  )
1987
2053
  .split(path.sep)
1988
2054
  .join("/")
@@ -1991,7 +2057,7 @@ async function buildIiifCollectionPages(CONFIG) {
1991
2057
  ? path
1992
2058
  .relative(
1993
2059
  path.dirname(outPath),
1994
- path.join(OUT_DIR, "scripts", "canopy-search-form.js")
2060
+ path.join(OUT_DIR, "scripts", "canopy-search-form.js"),
1995
2061
  )
1996
2062
  .split(path.sep)
1997
2063
  .join("/")
@@ -2012,7 +2078,7 @@ async function buildIiifCollectionPages(CONFIG) {
2012
2078
  jsRel = primaryClassicScripts.shift();
2013
2079
  }
2014
2080
  const classicScriptRels = primaryClassicScripts.concat(
2015
- secondaryClassicScripts
2081
+ secondaryClassicScripts,
2016
2082
  );
2017
2083
 
2018
2084
  const headSegments = [head];
@@ -2028,7 +2094,7 @@ async function buildIiifCollectionPages(CONFIG) {
2028
2094
  const vendorAbs = path.join(
2029
2095
  OUT_DIR,
2030
2096
  "scripts",
2031
- "react-globals.js"
2097
+ "react-globals.js",
2032
2098
  );
2033
2099
  let vendorRel = path
2034
2100
  .relative(path.dirname(outPath), vendorAbs)
@@ -2057,7 +2123,7 @@ async function buildIiifCollectionPages(CONFIG) {
2057
2123
  if (BASE_PATH)
2058
2124
  vendorTag =
2059
2125
  `<script>window.CANOPY_BASE_PATH=${JSON.stringify(
2060
- BASE_PATH
2126
+ BASE_PATH,
2061
2127
  )}</script>` + vendorTag;
2062
2128
  } catch (_) {}
2063
2129
  let pageBody = body;
@@ -2089,6 +2155,9 @@ async function buildIiifCollectionPages(CONFIG) {
2089
2155
  html = require("../common").applyBaseToHtml(html);
2090
2156
  } catch (_) {}
2091
2157
  await fsp.writeFile(outPath, html, "utf8");
2158
+ logDebug(
2159
+ `Wrote work page → ${path.relative(process.cwd(), outPath)}`,
2160
+ );
2092
2161
  lns.push([
2093
2162
  `✔ Created ${path.relative(process.cwd(), outPath)}`,
2094
2163
  "green",
@@ -2102,6 +2171,9 @@ async function buildIiifCollectionPages(CONFIG) {
2102
2171
  thumbUrl = String(t.url);
2103
2172
  thumbWidth = typeof t.width === "number" ? t.width : undefined;
2104
2173
  thumbHeight = typeof t.height === "number" ? t.height : undefined;
2174
+ logDebug(
2175
+ `Thumbnail resolved for ${manifestLabel}: ${thumbUrl} (${thumbWidth || "auto"}×${thumbHeight || "auto"})`,
2176
+ );
2105
2177
  }
2106
2178
  } catch (_) {}
2107
2179
  try {
@@ -2111,7 +2183,7 @@ async function buildIiifCollectionPages(CONFIG) {
2111
2183
  (e) =>
2112
2184
  e &&
2113
2185
  e.id === String(manifest.id || id) &&
2114
- e.type === "Manifest"
2186
+ e.type === "Manifest",
2115
2187
  );
2116
2188
  if (entry) {
2117
2189
  let touched = false;
@@ -2137,6 +2209,9 @@ async function buildIiifCollectionPages(CONFIG) {
2137
2209
  }
2138
2210
  }
2139
2211
  if (heroMedia && heroMedia.heroThumbnail) {
2212
+ logDebug(
2213
+ `Hero thumbnail cached for ${manifestLabel}: ${heroMedia.heroThumbnail}`,
2214
+ );
2140
2215
  if (entry.heroThumbnail !== heroMedia.heroThumbnail) {
2141
2216
  entry.heroThumbnail = heroMedia.heroThumbnail;
2142
2217
  touched = true;
@@ -2157,7 +2232,8 @@ async function buildIiifCollectionPages(CONFIG) {
2157
2232
  }
2158
2233
  if (heroMedia.heroThumbnailSrcset) {
2159
2234
  if (
2160
- entry.heroThumbnailSrcset !== heroMedia.heroThumbnailSrcset
2235
+ entry.heroThumbnailSrcset !==
2236
+ heroMedia.heroThumbnailSrcset
2161
2237
  ) {
2162
2238
  entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
2163
2239
  touched = true;
@@ -2166,6 +2242,9 @@ async function buildIiifCollectionPages(CONFIG) {
2166
2242
  entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
2167
2243
  touched = true;
2168
2244
  }
2245
+ logDebug(
2246
+ `Hero srcset cached for ${manifestLabel} (${heroMedia.heroThumbnailSrcset.length} chars)`,
2247
+ );
2169
2248
  }
2170
2249
  } else {
2171
2250
  if (entry.heroThumbnail !== undefined) {
@@ -2190,19 +2269,24 @@ async function buildIiifCollectionPages(CONFIG) {
2190
2269
  }
2191
2270
  }
2192
2271
  if (heroMedia && heroMedia.ogImage) {
2272
+ logDebug(
2273
+ `OG image cached for ${manifestLabel}: ${heroMedia.ogImage}`,
2274
+ );
2193
2275
  if (entry.ogImage !== heroMedia.ogImage) {
2194
2276
  entry.ogImage = heroMedia.ogImage;
2195
2277
  touched = true;
2196
2278
  }
2197
- if (typeof heroMedia.ogImageWidth === 'number') {
2198
- if (entry.ogImageWidth !== heroMedia.ogImageWidth) touched = true;
2279
+ if (typeof heroMedia.ogImageWidth === "number") {
2280
+ if (entry.ogImageWidth !== heroMedia.ogImageWidth)
2281
+ touched = true;
2199
2282
  entry.ogImageWidth = heroMedia.ogImageWidth;
2200
2283
  } else if (entry.ogImageWidth !== undefined) {
2201
2284
  delete entry.ogImageWidth;
2202
2285
  touched = true;
2203
2286
  }
2204
- if (typeof heroMedia.ogImageHeight === 'number') {
2205
- if (entry.ogImageHeight !== heroMedia.ogImageHeight) touched = true;
2287
+ if (typeof heroMedia.ogImageHeight === "number") {
2288
+ if (entry.ogImageHeight !== heroMedia.ogImageHeight)
2289
+ touched = true;
2206
2290
  entry.ogImageHeight = heroMedia.ogImageHeight;
2207
2291
  } else if (entry.ogImageHeight !== undefined) {
2208
2292
  delete entry.ogImageHeight;
@@ -2229,7 +2313,7 @@ async function buildIiifCollectionPages(CONFIG) {
2229
2313
  entry,
2230
2314
  heroMedia && heroMedia.heroThumbnail,
2231
2315
  heroMedia && heroMedia.heroThumbnailWidth,
2232
- heroMedia && heroMedia.heroThumbnailHeight
2316
+ heroMedia && heroMedia.heroThumbnailHeight,
2233
2317
  )
2234
2318
  ) {
2235
2319
  touched = true;
@@ -2255,7 +2339,7 @@ async function buildIiifCollectionPages(CONFIG) {
2255
2339
  try {
2256
2340
  annotationValue = extractAnnotationText(
2257
2341
  manifest,
2258
- annotationsOptions
2342
+ annotationsOptions,
2259
2343
  );
2260
2344
  } catch (_) {
2261
2345
  annotationValue = "";
@@ -2298,9 +2382,13 @@ async function buildIiifCollectionPages(CONFIG) {
2298
2382
  type: "work",
2299
2383
  thumbnail: recordThumbnail || undefined,
2300
2384
  thumbnailWidth:
2301
- typeof recordThumbWidth === "number" ? recordThumbWidth : undefined,
2385
+ typeof recordThumbWidth === "number"
2386
+ ? recordThumbWidth
2387
+ : undefined,
2302
2388
  thumbnailHeight:
2303
- typeof recordThumbHeight === "number" ? recordThumbHeight : undefined,
2389
+ typeof recordThumbHeight === "number"
2390
+ ? recordThumbHeight
2391
+ : undefined,
2304
2392
  searchMetadataValues:
2305
2393
  metadataValues && metadataValues.length
2306
2394
  ? metadataValues
@@ -2312,6 +2400,11 @@ async function buildIiifCollectionPages(CONFIG) {
2312
2400
  ? annotationValue
2313
2401
  : undefined,
2314
2402
  });
2403
+ logDebug(
2404
+ `Search record queued for ${manifestLabel}: ${pageHref} (metadata values ${
2405
+ metadataValues ? metadataValues.length : 0
2406
+ })`,
2407
+ );
2315
2408
  } catch (e) {
2316
2409
  lns.push([
2317
2410
  `IIIF: failed to render for ${id || "<unknown>"} — ${e.message}`,
@@ -2324,7 +2417,7 @@ async function buildIiifCollectionPages(CONFIG) {
2324
2417
  }
2325
2418
  const workers = Array.from(
2326
2419
  {length: Math.min(concurrency, chunk.length)},
2327
- () => worker()
2420
+ () => worker(),
2328
2421
  );
2329
2422
  await Promise.all(workers);
2330
2423
  tryFlush();
@@ -2333,48 +2426,58 @@ async function buildIiifCollectionPages(CONFIG) {
2333
2426
  index: ci + 1,
2334
2427
  count: chunk.length,
2335
2428
  durationMs: chunkDuration,
2336
- concurrency,
2337
2429
  });
2338
2430
  try {
2339
- const concurrencyLabel =
2340
- requestedConcurrency === 0 ? `${concurrency} (auto)` : String(concurrency);
2341
2431
  logLine(
2342
- `⏱ Chunk ${ci + 1}/${chunks}: processed ${chunk.length} Manifest(s) in ${formatDurationMs(chunkDuration)} (concurrency ${concurrencyLabel})`,
2432
+ `⏱ Chunk ${ci + 1}/${chunks}: processed ${chunk.length} Manifest(s) in ${formatDurationMs(chunkDuration)}`,
2343
2433
  "cyan",
2344
- { dim: true }
2434
+ {dim: true},
2345
2435
  );
2346
2436
  } catch (_) {}
2347
2437
  }
2348
2438
  if (chunkMetrics.length) {
2349
2439
  const totalDuration = chunkMetrics.reduce(
2350
2440
  (sum, entry) => sum + (entry.durationMs || 0),
2351
- 0
2441
+ 0,
2442
+ );
2443
+ const totalItems = chunkMetrics.reduce(
2444
+ (sum, entry) => sum + (entry.count || 0),
2445
+ 0,
2352
2446
  );
2353
- const totalItems = chunkMetrics.reduce((sum, entry) => sum + (entry.count || 0), 0);
2354
2447
  const avgDuration = chunkMetrics.length
2355
2448
  ? totalDuration / chunkMetrics.length
2356
2449
  : 0;
2357
2450
  const rate = totalDuration > 0 ? totalItems / (totalDuration / 1000) : 0;
2358
2451
  try {
2359
- const rateLabel = rate ? `${rate.toFixed(1)} manifest(s)/s` : 'n/a';
2452
+ const rateLabel = rate ? `${rate.toFixed(1)} manifest(s)/s` : "n/a";
2360
2453
  logLine(
2361
2454
  `IIIF chunk summary: ${totalItems} Manifest(s) in ${formatDurationMs(totalDuration)} (avg chunk ${formatDurationMs(avgDuration)}, ${rateLabel})`,
2362
2455
  "cyan",
2363
- { dim: true }
2456
+ {dim: true},
2364
2457
  );
2365
2458
  } catch (_) {}
2366
2459
  }
2367
2460
  try {
2368
2461
  await navPlace.writeNavPlaceDataset(navPlaceRecords);
2462
+ try {
2463
+ logLine(
2464
+ `✓ Wrote navPlace dataset (${navPlaceRecords.length} record(s))`,
2465
+ "cyan",
2466
+ );
2467
+ } catch (_) {}
2369
2468
  } catch (error) {
2370
2469
  try {
2371
2470
  console.warn(
2372
- '[canopy][navPlace] failed to write dataset:',
2373
- error && error.message ? error.message : error
2471
+ "[canopy][navPlace] failed to write dataset:",
2472
+ error && error.message ? error.message : error,
2374
2473
  );
2375
2474
  } catch (_) {}
2376
2475
  }
2377
- return {iiifRecords};
2476
+ return {
2477
+ iiifRecords,
2478
+ manifestIds: Array.from(renderedManifestIds),
2479
+ collectionIds: Array.from(visitedCollections),
2480
+ };
2378
2481
  }
2379
2482
 
2380
2483
  module.exports = {
@@ -2382,11 +2485,12 @@ module.exports = {
2382
2485
  loadConfig,
2383
2486
  loadManifestIndex,
2384
2487
  saveManifestIndex,
2488
+ resolveIiifSources,
2385
2489
  // Expose helpers used by build for cache warming
2386
2490
  loadCachedManifestById,
2387
2491
  saveCachedManifest,
2388
2492
  ensureFeaturedInCache,
2389
- rebuildManifestIndexFromCache,
2493
+ cleanupIiifCache,
2390
2494
  };
2391
2495
 
2392
2496
  // Expose a stable set of pure helper utilities for unit testing.
@@ -2395,6 +2499,7 @@ module.exports.__TESTING__ = {
2395
2499
  formatDurationMs,
2396
2500
  resolveBoolean,
2397
2501
  normalizeCollectionUris,
2502
+ normalizeManifestConfig,
2398
2503
  clampSlugLength,
2399
2504
  isSlugTooLong,
2400
2505
  normalizeSlugBase,
@@ -2436,7 +2541,7 @@ try {
2436
2541
  `IIIF: cache/collections (end): ${files.length} file(s)` +
2437
2542
  (head ? ` [${head}${files.length > 8 ? ", …" : ""}]` : ""),
2438
2543
  "blue",
2439
- {dim: true}
2544
+ {dim: true},
2440
2545
  );
2441
2546
  } catch (_) {}
2442
2547
  }