@canopy-iiif/app 1.6.0 → 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
  }
@@ -113,6 +121,18 @@ function normalizeManifestConfig(cfg) {
113
121
  return normalizeCollectionUris(entries);
114
122
  }
115
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
+
116
136
  function clampSlugLength(slug, limit = MAX_ENTRY_SLUG_LENGTH) {
117
137
  if (!slug) return "";
118
138
  const max = Math.max(1, limit);
@@ -146,8 +166,8 @@ function extractHomepageId(resource) {
146
166
  const list = Array.isArray(homepageRaw)
147
167
  ? homepageRaw
148
168
  : homepageRaw
149
- ? [homepageRaw]
150
- : [];
169
+ ? [homepageRaw]
170
+ : [];
151
171
  for (const entry of list) {
152
172
  if (!entry) continue;
153
173
  if (typeof entry === "string") {
@@ -221,7 +241,7 @@ function resolveThumbnailPreferences() {
221
241
  return {
222
242
  size: resolvePositiveInteger(
223
243
  process.env.CANOPY_THUMBNAIL_SIZE,
224
- DEFAULT_THUMBNAIL_SIZE
244
+ DEFAULT_THUMBNAIL_SIZE,
225
245
  ),
226
246
  unsafe: resolveBoolean(process.env.CANOPY_THUMBNAILS_UNSAFE),
227
247
  };
@@ -269,7 +289,7 @@ async function resolveHeroMedia(manifest) {
269
289
  const manifestThumb = extractResourceThumbnail(manifest);
270
290
  const heroSource = (() => {
271
291
  if (manifest && manifest.thumbnail) {
272
- const clone = { ...manifest };
292
+ const clone = {...manifest};
273
293
  try {
274
294
  delete clone.thumbnail;
275
295
  } catch (_) {
@@ -282,53 +302,49 @@ async function resolveHeroMedia(manifest) {
282
302
  const heroRep = await getRepresentativeImage(
283
303
  heroSource || manifest,
284
304
  HERO_REPRESENTATIVE_SIZE,
285
- true
305
+ true,
286
306
  );
287
307
  const canvasImage = findPrimaryCanvasImage(manifest);
288
308
  const heroService =
289
- (canvasImage && canvasImage.service) ||
290
- (heroRep && heroRep.service);
309
+ (canvasImage && canvasImage.service) || (heroRep && heroRep.service);
291
310
  const serviceIsLevel0 = isLevel0Service(heroService);
292
311
  const heroPreferred = buildIiifImageUrlFromService(
293
312
  serviceIsLevel0 ? null : heroService,
294
- HERO_THUMBNAIL_SIZE
313
+ HERO_THUMBNAIL_SIZE,
295
314
  );
296
315
  const heroWidth = (() => {
297
- if (canvasImage && typeof canvasImage.width === 'number')
316
+ if (canvasImage && typeof canvasImage.width === "number")
298
317
  return canvasImage.width;
299
- if (heroRep && typeof heroRep.width === 'number') return heroRep.width;
318
+ if (heroRep && typeof heroRep.width === "number") return heroRep.width;
300
319
  return undefined;
301
320
  })();
302
321
  const heroHeight = (() => {
303
- if (canvasImage && typeof canvasImage.height === 'number')
322
+ if (canvasImage && typeof canvasImage.height === "number")
304
323
  return canvasImage.height;
305
- if (heroRep && typeof heroRep.height === 'number')
306
- return heroRep.height;
324
+ if (heroRep && typeof heroRep.height === "number") return heroRep.height;
307
325
  return undefined;
308
326
  })();
309
- const heroSrcset = serviceIsLevel0
310
- ? ''
311
- : buildIiifImageSrcset(heroService);
327
+ const heroSrcset = serviceIsLevel0 ? "" : buildIiifImageSrcset(heroService);
312
328
  const ogFromService =
313
329
  !serviceIsLevel0 && heroService
314
330
  ? buildIiifImageUrlForDimensions(
315
331
  heroService,
316
332
  OG_IMAGE_WIDTH,
317
- OG_IMAGE_HEIGHT
333
+ OG_IMAGE_HEIGHT,
318
334
  )
319
- : '';
335
+ : "";
320
336
  const annotationImageId =
321
337
  canvasImage && canvasImage.isImageBody && canvasImage.id
322
338
  ? String(canvasImage.id)
323
- : '';
324
- let heroThumbnail = heroPreferred || '';
339
+ : "";
340
+ let heroThumbnail = heroPreferred || "";
325
341
  let heroThumbWidth = heroWidth;
326
342
  let heroThumbHeight = heroHeight;
327
343
  if (!heroThumbnail && manifestThumb && manifestThumb.url) {
328
344
  heroThumbnail = manifestThumb.url;
329
- if (typeof manifestThumb.width === 'number')
345
+ if (typeof manifestThumb.width === "number")
330
346
  heroThumbWidth = manifestThumb.width;
331
- if (typeof manifestThumb.height === 'number')
347
+ if (typeof manifestThumb.height === "number")
332
348
  heroThumbHeight = manifestThumb.height;
333
349
  }
334
350
  if (!heroThumbnail) {
@@ -338,7 +354,7 @@ async function resolveHeroMedia(manifest) {
338
354
  heroThumbnail = String(heroRep.id);
339
355
  }
340
356
  }
341
- let ogImage = '';
357
+ let ogImage = "";
342
358
  let ogImageWidth;
343
359
  let ogImageHeight;
344
360
  if (ogFromService) {
@@ -347,16 +363,16 @@ async function resolveHeroMedia(manifest) {
347
363
  ogImageHeight = OG_IMAGE_HEIGHT;
348
364
  } else if (heroThumbnail) {
349
365
  ogImage = heroThumbnail;
350
- if (typeof heroThumbWidth === 'number') ogImageWidth = heroThumbWidth;
351
- if (typeof heroThumbHeight === 'number') ogImageHeight = heroThumbHeight;
366
+ if (typeof heroThumbWidth === "number") ogImageWidth = heroThumbWidth;
367
+ if (typeof heroThumbHeight === "number") ogImageHeight = heroThumbHeight;
352
368
  }
353
369
  return {
354
- heroThumbnail: heroThumbnail || '',
370
+ heroThumbnail: heroThumbnail || "",
355
371
  heroThumbnailWidth: heroThumbWidth,
356
372
  heroThumbnailHeight: heroThumbHeight,
357
- heroThumbnailSrcset: heroSrcset || '',
358
- heroThumbnailSizes: heroSrcset ? HERO_IMAGE_SIZES_ATTR : '',
359
- ogImage: ogImage || '',
373
+ heroThumbnailSrcset: heroSrcset || "",
374
+ heroThumbnailSizes: heroSrcset ? HERO_IMAGE_SIZES_ATTR : "",
375
+ ogImage: ogImage || "",
360
376
  ogImageWidth,
361
377
  ogImageHeight,
362
378
  };
@@ -423,7 +439,7 @@ function extractSummaryValues(manifest) {
423
439
  flattenMetadataValue(manifest && manifest.summary, values, 0);
424
440
  } catch (_) {}
425
441
  const unique = Array.from(
426
- new Set(values.map((val) => String(val || "").trim()).filter(Boolean))
442
+ new Set(values.map((val) => String(val || "").trim()).filter(Boolean)),
427
443
  );
428
444
  if (!unique.length) return "";
429
445
  return unique.join(" ");
@@ -641,7 +657,7 @@ function normalizeIiifId(raw) {
641
657
  if (!/^https?:\/\//i.test(s)) return s;
642
658
  const u = new URL(s);
643
659
  const entries = Array.from(u.searchParams.entries()).sort(
644
- (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]),
645
661
  );
646
662
  u.search = "";
647
663
  for (const [k, v] of entries) u.searchParams.append(k, v);
@@ -855,18 +871,21 @@ function computeUniqueSlug(index, baseSlug, id, type) {
855
871
  const byId = Array.isArray(index && index.byId) ? index.byId : [];
856
872
  const normId = normalizeIiifId(String(id || ""));
857
873
  const fallbackBase = type === "Manifest" ? "untitled" : "collection";
858
- const normalizedBase = normalizeSlugBase(baseSlug || fallbackBase, fallbackBase);
874
+ const normalizedBase = normalizeSlugBase(
875
+ baseSlug || fallbackBase,
876
+ fallbackBase,
877
+ );
859
878
  const used = new Set(
860
879
  byId
861
880
  .filter((e) => e && e.slug && e.type === type)
862
- .map((e) => String(e.slug))
881
+ .map((e) => String(e.slug)),
863
882
  );
864
883
  const reserved = RESERVED_SLUGS[type] || new Set();
865
884
  let slug = normalizedBase;
866
885
  let i = 1;
867
886
  for (;;) {
868
887
  const existing = byId.find(
869
- (e) => e && e.type === type && String(e.slug) === String(slug)
888
+ (e) => e && e.type === type && String(e.slug) === String(slug),
870
889
  );
871
890
  if (existing) {
872
891
  // If this slug already maps to this id, reuse it and reserve.
@@ -888,9 +907,12 @@ function ensureBaseSlugFor(index, baseSlug, id, type) {
888
907
  const byId = Array.isArray(index && index.byId) ? index.byId : [];
889
908
  const normId = normalizeIiifId(String(id || ""));
890
909
  const fallbackBase = type === "Manifest" ? "untitled" : "collection";
891
- const normalizedBase = normalizeSlugBase(baseSlug || fallbackBase, fallbackBase);
910
+ const normalizedBase = normalizeSlugBase(
911
+ baseSlug || fallbackBase,
912
+ fallbackBase,
913
+ );
892
914
  const existingWithBase = byId.find(
893
- (e) => e && e.type === type && String(e.slug) === String(normalizedBase)
915
+ (e) => e && e.type === type && String(e.slug) === String(normalizedBase),
894
916
  );
895
917
  if (existingWithBase && normalizeIiifId(existingWithBase.id) !== normId) {
896
918
  // Reassign the existing entry to the next available suffix to free the base
@@ -898,9 +920,10 @@ function ensureBaseSlugFor(index, baseSlug, id, type) {
898
920
  index,
899
921
  normalizedBase,
900
922
  existingWithBase.id,
901
- type
923
+ type,
902
924
  );
903
- if (newSlug && newSlug !== normalizedBase) existingWithBase.slug = newSlug;
925
+ if (newSlug && newSlug !== normalizedBase)
926
+ existingWithBase.slug = newSlug;
904
927
  }
905
928
  } catch (_) {}
906
929
  return baseSlug;
@@ -917,7 +940,7 @@ async function findSlugByIdFromDisk(id) {
917
940
  const raw = await fsp.readFile(p, "utf8");
918
941
  const obj = JSON.parse(raw);
919
942
  const mid = normalizeIiifId(
920
- String((obj && (obj.id || obj["@id"])) || "")
943
+ String((obj && (obj.id || obj["@id"])) || ""),
921
944
  );
922
945
  if (mid && mid === normalizeIiifId(String(id))) {
923
946
  const slug = name.replace(/\.json$/i, "");
@@ -937,7 +960,7 @@ async function loadCachedManifestById(id) {
937
960
  if (Array.isArray(index.byId)) {
938
961
  const nid = normalizeIiifId(id);
939
962
  const entry = index.byId.find(
940
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
963
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
941
964
  );
942
965
  slug = entry && entry.slug;
943
966
  }
@@ -957,7 +980,8 @@ async function loadCachedManifestById(id) {
957
980
  index.byId = Array.isArray(index.byId) ? index.byId : [];
958
981
  const nid = normalizeIiifId(id);
959
982
  const existingEntryIdx = index.byId.findIndex(
960
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
983
+ (e) =>
984
+ e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
961
985
  );
962
986
  const entry = {
963
987
  id: String(nid),
@@ -977,7 +1001,8 @@ async function loadCachedManifestById(id) {
977
1001
  const p = path.join(IIIF_CACHE_MANIFESTS_DIR, slug + ".json");
978
1002
  if (!fs.existsSync(p)) return null;
979
1003
  const raw = await readJson(p);
980
- const {manifest: normalized, changed} = await ensurePresentation3Manifest(raw);
1004
+ const {manifest: normalized, changed} =
1005
+ await ensurePresentation3Manifest(raw);
981
1006
  if (changed) {
982
1007
  try {
983
1008
  await fsp.writeFile(p, JSON.stringify(normalized, null, 2), "utf8");
@@ -987,12 +1012,17 @@ async function loadCachedManifestById(id) {
987
1012
  index.byId = Array.isArray(index.byId) ? index.byId : [];
988
1013
  const nid = normalizeIiifId(id);
989
1014
  const existingEntryIdx = index.byId.findIndex(
990
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
1015
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
991
1016
  );
992
1017
  if (existingEntryIdx >= 0) {
993
1018
  const entry = index.byId[existingEntryIdx];
994
- const prevCanonical = entry && entry.canonical ? String(entry.canonical) : "";
995
- 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
+ );
996
1026
  if (nextCanonical !== prevCanonical) {
997
1027
  await saveManifestIndex(index);
998
1028
  }
@@ -1005,21 +1035,28 @@ async function loadCachedManifestById(id) {
1005
1035
  }
1006
1036
 
1007
1037
  async function saveCachedManifest(manifest, id, parentId) {
1008
- const {manifest: normalizedManifest} = await ensurePresentation3Manifest(manifest);
1038
+ const {manifest: normalizedManifest} =
1039
+ await ensurePresentation3Manifest(manifest);
1009
1040
  try {
1010
1041
  const index = await loadManifestIndex();
1011
- const title = firstLabelString(normalizedManifest && normalizedManifest.label);
1042
+ const title = firstLabelString(
1043
+ normalizedManifest && normalizedManifest.label,
1044
+ );
1012
1045
  const baseSlug =
1013
1046
  slugify(title || "untitled", {lower: true, strict: true, trim: true}) ||
1014
1047
  "untitled";
1015
1048
  const slug = computeUniqueSlug(index, baseSlug, id, "Manifest");
1016
1049
  ensureDirSync(IIIF_CACHE_MANIFESTS_DIR);
1017
1050
  const dest = path.join(IIIF_CACHE_MANIFESTS_DIR, slug + ".json");
1018
- 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
+ );
1019
1056
  index.byId = Array.isArray(index.byId) ? index.byId : [];
1020
1057
  const nid = normalizeIiifId(id);
1021
1058
  const existingEntryIdx = index.byId.findIndex(
1022
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
1059
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest",
1023
1060
  );
1024
1061
  const entry = {
1025
1062
  id: String(nid),
@@ -1070,7 +1107,7 @@ async function ensureFeaturedInCache(cfg) {
1070
1107
  e &&
1071
1108
  e.type === "Manifest" &&
1072
1109
  normalizeIiifId(String(e.id)) ===
1073
- normalizeIiifId(String(manifest.id))
1110
+ normalizeIiifId(String(manifest.id)),
1074
1111
  );
1075
1112
  if (!entry) continue;
1076
1113
 
@@ -1122,7 +1159,8 @@ async function ensureFeaturedInCache(cfg) {
1122
1159
  if (entry.heroThumbnailSrcset !== heroMedia.heroThumbnailSrcset)
1123
1160
  touched = true;
1124
1161
  entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
1125
- if (entry.heroThumbnailSizes !== HERO_IMAGE_SIZES_ATTR) touched = true;
1162
+ if (entry.heroThumbnailSizes !== HERO_IMAGE_SIZES_ATTR)
1163
+ touched = true;
1126
1164
  entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
1127
1165
  } else {
1128
1166
  if (entry.heroThumbnailSrcset !== undefined) {
@@ -1139,15 +1177,16 @@ async function ensureFeaturedInCache(cfg) {
1139
1177
  entry.ogImage = heroMedia.ogImage;
1140
1178
  touched = true;
1141
1179
  }
1142
- if (typeof heroMedia.ogImageWidth === 'number') {
1180
+ if (typeof heroMedia.ogImageWidth === "number") {
1143
1181
  if (entry.ogImageWidth !== heroMedia.ogImageWidth) touched = true;
1144
1182
  entry.ogImageWidth = heroMedia.ogImageWidth;
1145
1183
  } else if (entry.ogImageWidth !== undefined) {
1146
1184
  delete entry.ogImageWidth;
1147
1185
  touched = true;
1148
1186
  }
1149
- if (typeof heroMedia.ogImageHeight === 'number') {
1150
- if (entry.ogImageHeight !== heroMedia.ogImageHeight) touched = true;
1187
+ if (typeof heroMedia.ogImageHeight === "number") {
1188
+ if (entry.ogImageHeight !== heroMedia.ogImageHeight)
1189
+ touched = true;
1151
1190
  entry.ogImageHeight = heroMedia.ogImageHeight;
1152
1191
  } else if (entry.ogImageHeight !== undefined) {
1153
1192
  delete entry.ogImageHeight;
@@ -1164,7 +1203,7 @@ async function ensureFeaturedInCache(cfg) {
1164
1203
  entry,
1165
1204
  heroMedia && heroMedia.heroThumbnail,
1166
1205
  heroMedia && heroMedia.heroThumbnailWidth,
1167
- heroMedia && heroMedia.heroThumbnailHeight
1206
+ heroMedia && heroMedia.heroThumbnailHeight,
1168
1207
  )
1169
1208
  ) {
1170
1209
  touched = true;
@@ -1201,7 +1240,7 @@ async function loadCachedCollectionById(id) {
1201
1240
  if (Array.isArray(index.byId)) {
1202
1241
  const nid = normalizeIiifId(id);
1203
1242
  const entry = index.byId.find(
1204
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
1243
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection",
1205
1244
  );
1206
1245
  slug = entry && entry.slug;
1207
1246
  }
@@ -1218,7 +1257,7 @@ async function loadCachedCollectionById(id) {
1218
1257
  const raw = await fsp.readFile(p, "utf8");
1219
1258
  const obj = JSON.parse(raw);
1220
1259
  const cid = normalizeIiifId(
1221
- String((obj && (obj.id || obj["@id"])) || "")
1260
+ String((obj && (obj.id || obj["@id"])) || ""),
1222
1261
  );
1223
1262
  if (cid && cid === normalizeIiifId(String(id))) {
1224
1263
  const candidate = name.replace(/\.json$/i, "");
@@ -1235,7 +1274,7 @@ async function loadCachedCollectionById(id) {
1235
1274
  (e) =>
1236
1275
  e &&
1237
1276
  normalizeIiifId(e.id) === nid &&
1238
- e.type === "Collection"
1277
+ e.type === "Collection",
1239
1278
  );
1240
1279
  const entry = {
1241
1280
  id: String(nid),
@@ -1263,11 +1302,12 @@ async function loadCachedCollectionById(id) {
1263
1302
  index.byId = Array.isArray(index.byId) ? index.byId : [];
1264
1303
  const nid = normalizeIiifId(id);
1265
1304
  const existingEntryIdx = index.byId.findIndex(
1266
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
1305
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection",
1267
1306
  );
1268
1307
  if (existingEntryIdx >= 0) {
1269
1308
  const entry = index.byId[existingEntryIdx];
1270
- const prevCanonical = entry && entry.canonical ? String(entry.canonical) : "";
1309
+ const prevCanonical =
1310
+ entry && entry.canonical ? String(entry.canonical) : "";
1271
1311
  const nextCanonical = applyCollectionEntryCanonical(entry, data);
1272
1312
  if (nextCanonical !== prevCanonical) {
1273
1313
  await saveManifestIndex(index);
@@ -1285,7 +1325,9 @@ async function saveCachedCollection(collection, id, parentId) {
1285
1325
  const normalizedCollection = await upgradeIiifResource(collection);
1286
1326
  ensureDirSync(IIIF_CACHE_COLLECTIONS_DIR);
1287
1327
  const index = await loadManifestIndex();
1288
- const title = firstLabelString(normalizedCollection && normalizedCollection.label);
1328
+ const title = firstLabelString(
1329
+ normalizedCollection && normalizedCollection.label,
1330
+ );
1289
1331
  const baseSlug =
1290
1332
  slugify(title || "collection", {
1291
1333
  lower: true,
@@ -1294,7 +1336,11 @@ async function saveCachedCollection(collection, id, parentId) {
1294
1336
  }) || "collection";
1295
1337
  const slug = computeUniqueSlug(index, baseSlug, id, "Collection");
1296
1338
  const dest = path.join(IIIF_CACHE_COLLECTIONS_DIR, slug + ".json");
1297
- 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
+ );
1298
1344
  try {
1299
1345
  if (process.env.CANOPY_IIIF_DEBUG === "1") {
1300
1346
  const {logLine} = require("./log");
@@ -1304,7 +1350,7 @@ async function saveCachedCollection(collection, id, parentId) {
1304
1350
  index.byId = Array.isArray(index.byId) ? index.byId : [];
1305
1351
  const nid = normalizeIiifId(id);
1306
1352
  const existingEntryIdx = index.byId.findIndex(
1307
- (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
1353
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection",
1308
1354
  );
1309
1355
  const entry = {
1310
1356
  id: String(nid),
@@ -1319,133 +1365,96 @@ async function saveCachedCollection(collection, id, parentId) {
1319
1365
  } catch (_) {}
1320
1366
  }
1321
1367
 
1322
- async function rebuildManifestIndexFromCache() {
1323
- try {
1324
- const previous = await loadManifestIndex();
1325
- const previousEntries = Array.isArray(previous.byId) ? previous.byId : [];
1326
- const priorMap = new Map();
1327
- for (const entry of previousEntries) {
1328
- if (!entry || !entry.id) continue;
1329
- const type = entry.type || "Manifest";
1330
- const key = `${type}:${normalizeIiifId(entry.id)}`;
1331
- priorMap.set(key, entry);
1332
- }
1333
- const nextIndex = {
1334
- byId: [],
1335
- collection: previous.collection || null,
1336
- };
1337
- const collectionFiles = fs.existsSync(IIIF_CACHE_COLLECTIONS_DIR)
1338
- ? (await fsp.readdir(IIIF_CACHE_COLLECTIONS_DIR))
1339
- .filter((name) => name && name.toLowerCase().endsWith(".json"))
1340
- .sort()
1341
- : [];
1342
- const manifestFiles = fs.existsSync(IIIF_CACHE_MANIFESTS_DIR)
1343
- ? (await fsp.readdir(IIIF_CACHE_MANIFESTS_DIR))
1344
- .filter((name) => name && name.toLowerCase().endsWith(".json"))
1345
- .sort()
1346
- : [];
1347
- const {size: thumbSize, unsafe: unsafeThumbs} =
1348
- resolveThumbnailPreferences();
1349
-
1350
- for (const name of collectionFiles) {
1351
- const slug = name.replace(/\.json$/i, "");
1352
- const fp = path.join(IIIF_CACHE_COLLECTIONS_DIR, name);
1353
- let data = null;
1354
- try {
1355
- data = await readJson(fp);
1356
- } catch (_) {
1357
- data = null;
1358
- }
1359
- if (!data) continue;
1360
- const id = data.id || data["@id"];
1361
- if (!id) continue;
1362
- const nid = normalizeIiifId(String(id));
1363
- const key = `Collection:${nid}`;
1364
- const fallback = priorMap.get(key) || {};
1365
- const parent = resolveParentFromPartOf(data) || fallback.parent || "";
1366
- const entry = {
1367
- id: String(nid),
1368
- type: "Collection",
1369
- slug,
1370
- parent,
1371
- };
1372
- applyCollectionEntryCanonical(entry, data);
1373
- nextIndex.byId.push(entry);
1374
- }
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;
1375
1386
 
1376
- for (const name of manifestFiles) {
1377
- 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;
1378
1392
  const fp = path.join(IIIF_CACHE_MANIFESTS_DIR, name);
1379
1393
  let manifest = null;
1380
1394
  try {
1381
1395
  manifest = await readJson(fp);
1382
- } catch (_) {
1383
- manifest = null;
1384
- }
1385
- if (!manifest) continue;
1386
- const id = manifest.id || manifest["@id"];
1387
- if (!id) continue;
1388
- const nid = normalizeIiifId(String(id));
1389
- MEMO_ID_TO_SLUG.set(String(id), slug);
1390
- const key = `Manifest:${nid}`;
1391
- const fallback = priorMap.get(key) || {};
1392
- const parent = resolveParentFromPartOf(manifest) || fallback.parent || "";
1393
- const entry = {
1394
- id: String(nid),
1395
- type: "Manifest",
1396
- slug,
1397
- parent,
1398
- };
1399
- applyManifestEntryCanonical(entry, manifest, slug);
1400
- try {
1401
- const thumb = await getThumbnail(manifest, thumbSize, unsafeThumbs);
1402
- if (thumb && thumb.url) {
1403
- entry.thumbnail = String(thumb.url);
1404
- if (typeof thumb.width === "number") entry.thumbnailWidth = thumb.width;
1405
- if (typeof thumb.height === "number") entry.thumbnailHeight = thumb.height;
1406
- }
1407
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;
1408
1419
  try {
1409
- const heroMedia = await resolveHeroMedia(manifest);
1410
- if (heroMedia && heroMedia.heroThumbnail) {
1411
- entry.heroThumbnail = heroMedia.heroThumbnail;
1412
- if (typeof heroMedia.heroThumbnailWidth === "number")
1413
- entry.heroThumbnailWidth = heroMedia.heroThumbnailWidth;
1414
- if (typeof heroMedia.heroThumbnailHeight === "number")
1415
- entry.heroThumbnailHeight = heroMedia.heroThumbnailHeight;
1416
- if (heroMedia.heroThumbnailSrcset) {
1417
- entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
1418
- entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
1419
- }
1420
- if (heroMedia.ogImage) {
1421
- entry.ogImage = heroMedia.ogImage;
1422
- if (typeof heroMedia.ogImageWidth === 'number')
1423
- entry.ogImageWidth = heroMedia.ogImageWidth;
1424
- else delete entry.ogImageWidth;
1425
- if (typeof heroMedia.ogImageHeight === 'number')
1426
- entry.ogImageHeight = heroMedia.ogImageHeight;
1427
- else delete entry.ogImageHeight;
1428
- }
1429
- ensureThumbnailValue(
1430
- entry,
1431
- heroMedia.heroThumbnail,
1432
- heroMedia.heroThumbnailWidth,
1433
- heroMedia.heroThumbnailHeight
1434
- );
1435
- }
1420
+ collection = await readJson(fp);
1436
1421
  } catch (_) {}
1437
- 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
+ }
1438
1434
  }
1439
-
1440
- await saveManifestIndex(nextIndex);
1441
- try {
1442
- logLine("✓ Rebuilt IIIF cache index", "cyan");
1443
- } catch (_) {}
1444
- } catch (err) {
1445
- try {
1446
- logLine("! Skipped IIIF index rebuild", "yellow");
1447
- } catch (_) {}
1448
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 (_) {}
1449
1458
  }
1450
1459
 
1451
1460
  async function loadConfig() {
@@ -1465,14 +1474,9 @@ async function loadConfig() {
1465
1474
  async function buildIiifCollectionPages(CONFIG) {
1466
1475
  const cfg = CONFIG || (await loadConfig());
1467
1476
 
1468
- let collectionUris = normalizeCollectionUris(cfg && cfg.collection);
1469
- if (!collectionUris.length) {
1470
- collectionUris = normalizeCollectionUris(
1471
- process.env.CANOPY_COLLECTION_URI || ""
1472
- );
1473
- }
1474
- const manifestUris = normalizeManifestConfig(cfg);
1475
- if (!collectionUris.length && !manifestUris.length) return {searchRecords: []};
1477
+ const {collections: collectionUris, manifests: manifestUris} =
1478
+ resolveIiifSources(cfg);
1479
+ if (!collectionUris.length && !manifestUris.length) return {iiifRecords: []};
1476
1480
 
1477
1481
  const searchIndexCfg = (cfg && cfg.search && cfg.search.index) || {};
1478
1482
  const metadataCfg = (searchIndexCfg && searchIndexCfg.metadata) || {};
@@ -1498,7 +1502,7 @@ async function buildIiifCollectionPages(CONFIG) {
1498
1502
  const metadataLabelSet = new Set(
1499
1503
  metadataLabelsRaw
1500
1504
  .map((label) => normalizeMetadataLabel(String(label || "")))
1501
- .filter(Boolean)
1505
+ .filter(Boolean),
1502
1506
  );
1503
1507
  const metadataFacetLabels = (() => {
1504
1508
  if (!Array.isArray(metadataLabelsRaw) || !metadataLabelsRaw.length)
@@ -1506,7 +1510,8 @@ async function buildIiifCollectionPages(CONFIG) {
1506
1510
  const seen = new Set();
1507
1511
  const entries = [];
1508
1512
  for (const label of metadataLabelsRaw) {
1509
- const raw = typeof label === "string" ? label.trim() : String(label || "");
1513
+ const raw =
1514
+ typeof label === "string" ? label.trim() : String(label || "");
1510
1515
  if (!raw) continue;
1511
1516
  const normalized = normalizeMetadataLabel(raw);
1512
1517
  if (!normalized || seen.has(normalized)) continue;
@@ -1529,8 +1534,8 @@ async function buildIiifCollectionPages(CONFIG) {
1529
1534
  };
1530
1535
  const annotationMotivations = new Set(
1531
1536
  normalizeStringList(annotationsCfg && annotationsCfg.motivation).map((m) =>
1532
- m.toLowerCase()
1533
- )
1537
+ m.toLowerCase(),
1538
+ ),
1534
1539
  );
1535
1540
  const annotationsOptions = {
1536
1541
  enabled: annotationsEnabled,
@@ -1539,8 +1544,11 @@ async function buildIiifCollectionPages(CONFIG) {
1539
1544
 
1540
1545
  // Recursively traverse Collections and gather all Manifest tasks
1541
1546
  const tasks = [];
1547
+ let manifestTasksFromCollections = 0;
1548
+ let manifestTasksFromConfig = 0;
1542
1549
  const queuedManifestIds = new Set();
1543
1550
  const visitedCollections = new Set(); // normalized ids
1551
+ const renderedManifestIds = new Set();
1544
1552
  const norm = (x) => {
1545
1553
  try {
1546
1554
  return normalizeIiifId(String(x || ""));
@@ -1566,9 +1574,8 @@ async function buildIiifCollectionPages(CONFIG) {
1566
1574
  const ncol = await upgradeIiifResource(col);
1567
1575
  const reportedId = String(
1568
1576
  (ncol && (ncol.id || ncol["@id"])) ||
1569
- (typeof colLike === "object" &&
1570
- (colLike.id || colLike["@id"])) ||
1571
- ""
1577
+ (typeof colLike === "object" && (colLike.id || colLike["@id"])) ||
1578
+ "",
1572
1579
  );
1573
1580
  const effectiveId = String(uri || reportedId || "");
1574
1581
  const collectionKey = effectiveId || reportedId || uri || "";
@@ -1589,6 +1596,7 @@ async function buildIiifCollectionPages(CONFIG) {
1589
1596
  if (queuedManifestIds.has(dedupeKey)) continue;
1590
1597
  queuedManifestIds.add(dedupeKey);
1591
1598
  tasks.push({id: entryId, parent: collectionKey});
1599
+ manifestTasksFromCollections += 1;
1592
1600
  } else if (entryType === "collection") {
1593
1601
  await gatherFromCollection(entry.raw || entryId, collectionKey);
1594
1602
  }
@@ -1623,34 +1631,44 @@ async function buildIiifCollectionPages(CONFIG) {
1623
1631
  if (!dedupeKey || queuedManifestIds.has(dedupeKey)) continue;
1624
1632
  queuedManifestIds.add(dedupeKey);
1625
1633
  tasks.push({id: uri, parent: ""});
1634
+ manifestTasksFromConfig += 1;
1626
1635
  }
1627
1636
  }
1628
- if (!tasks.length) return {searchRecords: []};
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
+ );
1629
1648
 
1630
1649
  // Split into chunks and process with limited concurrency
1631
1650
  const chunkSize = resolvePositiveInteger(
1632
1651
  process.env.CANOPY_CHUNK_SIZE,
1633
- DEFAULT_CHUNK_SIZE
1652
+ DEFAULT_CHUNK_SIZE,
1634
1653
  );
1635
1654
  const chunks = Math.ceil(tasks.length / chunkSize);
1636
1655
  const requestedConcurrency = resolvePositiveInteger(
1637
1656
  process.env.CANOPY_FETCH_CONCURRENCY,
1638
1657
  DEFAULT_FETCH_CONCURRENCY,
1639
- {allowZero: true}
1658
+ {allowZero: true},
1640
1659
  );
1641
1660
  // Summary before processing chunks
1642
1661
  try {
1643
- const collectionsCount = visitedCollections.size || 0;
1644
1662
  logLine(
1645
- `• Fetching ${tasks.length} Manifest(s) in ${chunks} chunk(s) across ${collectionsCount} Collection(s)`,
1663
+ `• Fetching ${tasks.length} Manifest(s) in ${chunks} chunk(s)`,
1646
1664
  "blue",
1647
- {dim: true}
1665
+ {dim: true},
1648
1666
  );
1649
1667
  const concurrencySummary =
1650
1668
  requestedConcurrency === 0
1651
1669
  ? "auto (no explicit cap)"
1652
1670
  : String(requestedConcurrency);
1653
- logLine(`• Fetch concurrency: ${concurrencySummary}`, "blue", { dim: true });
1671
+ logLine(`• Fetch concurrency: ${concurrencySummary}`, "blue", {dim: true});
1654
1672
  } catch (_) {}
1655
1673
  const iiifRecords = [];
1656
1674
  const navPlaceRecords = [];
@@ -1660,7 +1678,7 @@ async function buildIiifCollectionPages(CONFIG) {
1660
1678
  const worksLayoutPath = path.join(CONTENT_DIR, "works", "_layout.mdx");
1661
1679
  if (!fs.existsSync(worksLayoutPath)) {
1662
1680
  throw new Error(
1663
- "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.",
1664
1682
  );
1665
1683
  }
1666
1684
  let WorksLayoutComp = null;
@@ -1680,7 +1698,9 @@ async function buildIiifCollectionPages(CONFIG) {
1680
1698
  const chunkStart = Date.now();
1681
1699
 
1682
1700
  const concurrency =
1683
- requestedConcurrency === 0 ? Math.max(1, chunk.length) : requestedConcurrency;
1701
+ requestedConcurrency === 0
1702
+ ? Math.max(1, chunk.length)
1703
+ : requestedConcurrency;
1684
1704
  let next = 0;
1685
1705
  const logs = new Array(chunk.length);
1686
1706
  let nextPrint = 0;
@@ -1720,7 +1740,7 @@ async function buildIiifCollectionPages(CONFIG) {
1720
1740
  const saved = await saveCachedManifest(
1721
1741
  manifest,
1722
1742
  String(id),
1723
- String(it.parent || "")
1743
+ String(it.parent || ""),
1724
1744
  );
1725
1745
  manifest = saved || manifest;
1726
1746
  const cached = await loadCachedManifestById(String(id));
@@ -1747,7 +1767,7 @@ async function buildIiifCollectionPages(CONFIG) {
1747
1767
  const saved = await saveCachedManifest(
1748
1768
  manifest,
1749
1769
  String(id),
1750
- String(it.parent || "")
1770
+ String(it.parent || ""),
1751
1771
  );
1752
1772
  manifest = saved || manifest;
1753
1773
  const cached = await loadCachedManifestById(String(id));
@@ -1765,11 +1785,13 @@ async function buildIiifCollectionPages(CONFIG) {
1765
1785
  const ensured = await ensurePresentation3Manifest(manifest);
1766
1786
  manifest = ensured.manifest;
1767
1787
  const title = firstLabelString(manifest.label);
1768
- let summaryRaw = '';
1788
+ const manifestLabel = title || String(manifest.id || id);
1789
+ logDebug(`Preparing manifest ${manifestLabel}`);
1790
+ let summaryRaw = "";
1769
1791
  try {
1770
1792
  summaryRaw = extractSummaryValues(manifest);
1771
1793
  } catch (_) {
1772
- summaryRaw = '';
1794
+ summaryRaw = "";
1773
1795
  }
1774
1796
  const summaryForMeta = truncateSummary(summaryRaw || title);
1775
1797
  const baseSlug =
@@ -1782,7 +1804,7 @@ async function buildIiifCollectionPages(CONFIG) {
1782
1804
  let idxMap = await loadManifestIndex();
1783
1805
  idxMap.byId = Array.isArray(idxMap.byId) ? idxMap.byId : [];
1784
1806
  let mEntry = idxMap.byId.find(
1785
- (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid
1807
+ (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid,
1786
1808
  );
1787
1809
  let slug = mEntry && mEntry.slug;
1788
1810
  if (isSlugTooLong(slug)) slug = null;
@@ -1797,7 +1819,7 @@ async function buildIiifCollectionPages(CONFIG) {
1797
1819
  };
1798
1820
  applyManifestEntryCanonical(newEntry, manifest, slug);
1799
1821
  const existingIdx = idxMap.byId.findIndex(
1800
- (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid
1822
+ (e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid,
1801
1823
  );
1802
1824
  if (existingIdx >= 0) idxMap.byId[existingIdx] = newEntry;
1803
1825
  else idxMap.byId.push(newEntry);
@@ -1805,12 +1827,19 @@ async function buildIiifCollectionPages(CONFIG) {
1805
1827
  mEntry = newEntry;
1806
1828
  } else if (mEntry) {
1807
1829
  const prevCanonical = mEntry.canonical || "";
1808
- const nextCanonical = applyManifestEntryCanonical(mEntry, manifest, slug);
1830
+ const nextCanonical = applyManifestEntryCanonical(
1831
+ mEntry,
1832
+ manifest,
1833
+ slug,
1834
+ );
1809
1835
  if (nextCanonical !== prevCanonical) {
1810
1836
  await saveManifestIndex(idxMap);
1811
1837
  }
1812
1838
  }
1813
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}`);
1814
1843
  const references = referenced.getReferencesForManifest(manifestId);
1815
1844
  const href = path.join("works", slug + ".html");
1816
1845
  const outPath = path.join(OUT_DIR, href);
@@ -1872,7 +1901,8 @@ async function buildIiifCollectionPages(CONFIG) {
1872
1901
  canonical,
1873
1902
  },
1874
1903
  };
1875
- const ogImageForPage = heroMedia && heroMedia.ogImage ? heroMedia.ogImage : '';
1904
+ const ogImageForPage =
1905
+ heroMedia && heroMedia.ogImage ? heroMedia.ogImage : "";
1876
1906
  if (ogImageForPage) {
1877
1907
  pageDetails.image = ogImageForPage;
1878
1908
  pageDetails.ogImage = ogImageForPage;
@@ -1880,11 +1910,19 @@ async function buildIiifCollectionPages(CONFIG) {
1880
1910
  pageDetails.meta.ogImage = ogImageForPage;
1881
1911
  }
1882
1912
  const navigationRoots = navigation.buildNavigationRoots(slug || "");
1883
- const navigationContext = navigationRoots && Object.keys(navigationRoots).length
1884
- ? { allRoots: navigationRoots }
1885
- : null;
1886
- const pageContextValue = { navigation: navigationContext, page: pageDetails };
1887
- 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
+ ) {
1888
1926
  try {
1889
1927
  Object.defineProperty(manifest, "__canopyMetadataFacets", {
1890
1928
  configurable: true,
@@ -1910,15 +1948,15 @@ async function buildIiifCollectionPages(CONFIG) {
1910
1948
  PageContext && pageContextValue
1911
1949
  ? React.createElement(
1912
1950
  PageContext.Provider,
1913
- { value: pageContextValue },
1914
- wrappedApp
1951
+ {value: pageContextValue},
1952
+ wrappedApp,
1915
1953
  )
1916
1954
  : wrappedApp;
1917
1955
  const page = MDXProvider
1918
1956
  ? React.createElement(
1919
1957
  MDXProvider,
1920
1958
  {components: compMap},
1921
- withContext
1959
+ withContext,
1922
1960
  )
1923
1961
  : withContext;
1924
1962
  const body = ReactDOMServer.renderToStaticMarkup(page);
@@ -1931,8 +1969,8 @@ async function buildIiifCollectionPages(CONFIG) {
1931
1969
  const wrappedHead = PageContext
1932
1970
  ? React.createElement(
1933
1971
  PageContext.Provider,
1934
- { value: pageContextValue },
1935
- headElement
1972
+ {value: pageContextValue},
1973
+ headElement,
1936
1974
  )
1937
1975
  : headElement;
1938
1976
  head = ReactDOMServer.renderToStaticMarkup(wrappedHead);
@@ -1956,7 +1994,7 @@ async function buildIiifCollectionPages(CONFIG) {
1956
1994
  ? path
1957
1995
  .relative(
1958
1996
  path.dirname(outPath),
1959
- path.join(OUT_DIR, "scripts", "canopy-viewer.js")
1997
+ path.join(OUT_DIR, "scripts", "canopy-viewer.js"),
1960
1998
  )
1961
1999
  .split(path.sep)
1962
2000
  .join("/")
@@ -1965,7 +2003,7 @@ async function buildIiifCollectionPages(CONFIG) {
1965
2003
  ? path
1966
2004
  .relative(
1967
2005
  path.dirname(outPath),
1968
- path.join(OUT_DIR, "scripts", "canopy-slider.js")
2006
+ path.join(OUT_DIR, "scripts", "canopy-slider.js"),
1969
2007
  )
1970
2008
  .split(path.sep)
1971
2009
  .join("/")
@@ -1974,7 +2012,7 @@ async function buildIiifCollectionPages(CONFIG) {
1974
2012
  ? path
1975
2013
  .relative(
1976
2014
  path.dirname(outPath),
1977
- path.join(OUT_DIR, "scripts", "canopy-timeline.js")
2015
+ path.join(OUT_DIR, "scripts", "canopy-timeline.js"),
1978
2016
  )
1979
2017
  .split(path.sep)
1980
2018
  .join("/")
@@ -1983,7 +2021,7 @@ async function buildIiifCollectionPages(CONFIG) {
1983
2021
  ? path
1984
2022
  .relative(
1985
2023
  path.dirname(outPath),
1986
- path.join(OUT_DIR, "scripts", "canopy-map.js")
2024
+ path.join(OUT_DIR, "scripts", "canopy-map.js"),
1987
2025
  )
1988
2026
  .split(path.sep)
1989
2027
  .join("/")
@@ -1992,7 +2030,7 @@ async function buildIiifCollectionPages(CONFIG) {
1992
2030
  ? path
1993
2031
  .relative(
1994
2032
  path.dirname(outPath),
1995
- path.join(OUT_DIR, "scripts", "canopy-map.css")
2033
+ path.join(OUT_DIR, "scripts", "canopy-map.css"),
1996
2034
  )
1997
2035
  .split(path.sep)
1998
2036
  .join("/")
@@ -2001,7 +2039,7 @@ async function buildIiifCollectionPages(CONFIG) {
2001
2039
  ? path
2002
2040
  .relative(
2003
2041
  path.dirname(outPath),
2004
- path.join(OUT_DIR, "scripts", "canopy-hero-slider.js")
2042
+ path.join(OUT_DIR, "scripts", "canopy-hero-slider.js"),
2005
2043
  )
2006
2044
  .split(path.sep)
2007
2045
  .join("/")
@@ -2010,7 +2048,7 @@ async function buildIiifCollectionPages(CONFIG) {
2010
2048
  ? path
2011
2049
  .relative(
2012
2050
  path.dirname(outPath),
2013
- path.join(OUT_DIR, "scripts", "canopy-related-items.js")
2051
+ path.join(OUT_DIR, "scripts", "canopy-related-items.js"),
2014
2052
  )
2015
2053
  .split(path.sep)
2016
2054
  .join("/")
@@ -2019,7 +2057,7 @@ async function buildIiifCollectionPages(CONFIG) {
2019
2057
  ? path
2020
2058
  .relative(
2021
2059
  path.dirname(outPath),
2022
- path.join(OUT_DIR, "scripts", "canopy-search-form.js")
2060
+ path.join(OUT_DIR, "scripts", "canopy-search-form.js"),
2023
2061
  )
2024
2062
  .split(path.sep)
2025
2063
  .join("/")
@@ -2040,7 +2078,7 @@ async function buildIiifCollectionPages(CONFIG) {
2040
2078
  jsRel = primaryClassicScripts.shift();
2041
2079
  }
2042
2080
  const classicScriptRels = primaryClassicScripts.concat(
2043
- secondaryClassicScripts
2081
+ secondaryClassicScripts,
2044
2082
  );
2045
2083
 
2046
2084
  const headSegments = [head];
@@ -2056,7 +2094,7 @@ async function buildIiifCollectionPages(CONFIG) {
2056
2094
  const vendorAbs = path.join(
2057
2095
  OUT_DIR,
2058
2096
  "scripts",
2059
- "react-globals.js"
2097
+ "react-globals.js",
2060
2098
  );
2061
2099
  let vendorRel = path
2062
2100
  .relative(path.dirname(outPath), vendorAbs)
@@ -2085,7 +2123,7 @@ async function buildIiifCollectionPages(CONFIG) {
2085
2123
  if (BASE_PATH)
2086
2124
  vendorTag =
2087
2125
  `<script>window.CANOPY_BASE_PATH=${JSON.stringify(
2088
- BASE_PATH
2126
+ BASE_PATH,
2089
2127
  )}</script>` + vendorTag;
2090
2128
  } catch (_) {}
2091
2129
  let pageBody = body;
@@ -2117,6 +2155,9 @@ async function buildIiifCollectionPages(CONFIG) {
2117
2155
  html = require("../common").applyBaseToHtml(html);
2118
2156
  } catch (_) {}
2119
2157
  await fsp.writeFile(outPath, html, "utf8");
2158
+ logDebug(
2159
+ `Wrote work page → ${path.relative(process.cwd(), outPath)}`,
2160
+ );
2120
2161
  lns.push([
2121
2162
  `✔ Created ${path.relative(process.cwd(), outPath)}`,
2122
2163
  "green",
@@ -2130,6 +2171,9 @@ async function buildIiifCollectionPages(CONFIG) {
2130
2171
  thumbUrl = String(t.url);
2131
2172
  thumbWidth = typeof t.width === "number" ? t.width : undefined;
2132
2173
  thumbHeight = typeof t.height === "number" ? t.height : undefined;
2174
+ logDebug(
2175
+ `Thumbnail resolved for ${manifestLabel}: ${thumbUrl} (${thumbWidth || "auto"}×${thumbHeight || "auto"})`,
2176
+ );
2133
2177
  }
2134
2178
  } catch (_) {}
2135
2179
  try {
@@ -2139,7 +2183,7 @@ async function buildIiifCollectionPages(CONFIG) {
2139
2183
  (e) =>
2140
2184
  e &&
2141
2185
  e.id === String(manifest.id || id) &&
2142
- e.type === "Manifest"
2186
+ e.type === "Manifest",
2143
2187
  );
2144
2188
  if (entry) {
2145
2189
  let touched = false;
@@ -2165,6 +2209,9 @@ async function buildIiifCollectionPages(CONFIG) {
2165
2209
  }
2166
2210
  }
2167
2211
  if (heroMedia && heroMedia.heroThumbnail) {
2212
+ logDebug(
2213
+ `Hero thumbnail cached for ${manifestLabel}: ${heroMedia.heroThumbnail}`,
2214
+ );
2168
2215
  if (entry.heroThumbnail !== heroMedia.heroThumbnail) {
2169
2216
  entry.heroThumbnail = heroMedia.heroThumbnail;
2170
2217
  touched = true;
@@ -2185,7 +2232,8 @@ async function buildIiifCollectionPages(CONFIG) {
2185
2232
  }
2186
2233
  if (heroMedia.heroThumbnailSrcset) {
2187
2234
  if (
2188
- entry.heroThumbnailSrcset !== heroMedia.heroThumbnailSrcset
2235
+ entry.heroThumbnailSrcset !==
2236
+ heroMedia.heroThumbnailSrcset
2189
2237
  ) {
2190
2238
  entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
2191
2239
  touched = true;
@@ -2194,6 +2242,9 @@ async function buildIiifCollectionPages(CONFIG) {
2194
2242
  entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
2195
2243
  touched = true;
2196
2244
  }
2245
+ logDebug(
2246
+ `Hero srcset cached for ${manifestLabel} (${heroMedia.heroThumbnailSrcset.length} chars)`,
2247
+ );
2197
2248
  }
2198
2249
  } else {
2199
2250
  if (entry.heroThumbnail !== undefined) {
@@ -2218,19 +2269,24 @@ async function buildIiifCollectionPages(CONFIG) {
2218
2269
  }
2219
2270
  }
2220
2271
  if (heroMedia && heroMedia.ogImage) {
2272
+ logDebug(
2273
+ `OG image cached for ${manifestLabel}: ${heroMedia.ogImage}`,
2274
+ );
2221
2275
  if (entry.ogImage !== heroMedia.ogImage) {
2222
2276
  entry.ogImage = heroMedia.ogImage;
2223
2277
  touched = true;
2224
2278
  }
2225
- if (typeof heroMedia.ogImageWidth === 'number') {
2226
- if (entry.ogImageWidth !== heroMedia.ogImageWidth) touched = true;
2279
+ if (typeof heroMedia.ogImageWidth === "number") {
2280
+ if (entry.ogImageWidth !== heroMedia.ogImageWidth)
2281
+ touched = true;
2227
2282
  entry.ogImageWidth = heroMedia.ogImageWidth;
2228
2283
  } else if (entry.ogImageWidth !== undefined) {
2229
2284
  delete entry.ogImageWidth;
2230
2285
  touched = true;
2231
2286
  }
2232
- if (typeof heroMedia.ogImageHeight === 'number') {
2233
- if (entry.ogImageHeight !== heroMedia.ogImageHeight) touched = true;
2287
+ if (typeof heroMedia.ogImageHeight === "number") {
2288
+ if (entry.ogImageHeight !== heroMedia.ogImageHeight)
2289
+ touched = true;
2234
2290
  entry.ogImageHeight = heroMedia.ogImageHeight;
2235
2291
  } else if (entry.ogImageHeight !== undefined) {
2236
2292
  delete entry.ogImageHeight;
@@ -2257,7 +2313,7 @@ async function buildIiifCollectionPages(CONFIG) {
2257
2313
  entry,
2258
2314
  heroMedia && heroMedia.heroThumbnail,
2259
2315
  heroMedia && heroMedia.heroThumbnailWidth,
2260
- heroMedia && heroMedia.heroThumbnailHeight
2316
+ heroMedia && heroMedia.heroThumbnailHeight,
2261
2317
  )
2262
2318
  ) {
2263
2319
  touched = true;
@@ -2283,7 +2339,7 @@ async function buildIiifCollectionPages(CONFIG) {
2283
2339
  try {
2284
2340
  annotationValue = extractAnnotationText(
2285
2341
  manifest,
2286
- annotationsOptions
2342
+ annotationsOptions,
2287
2343
  );
2288
2344
  } catch (_) {
2289
2345
  annotationValue = "";
@@ -2326,9 +2382,13 @@ async function buildIiifCollectionPages(CONFIG) {
2326
2382
  type: "work",
2327
2383
  thumbnail: recordThumbnail || undefined,
2328
2384
  thumbnailWidth:
2329
- typeof recordThumbWidth === "number" ? recordThumbWidth : undefined,
2385
+ typeof recordThumbWidth === "number"
2386
+ ? recordThumbWidth
2387
+ : undefined,
2330
2388
  thumbnailHeight:
2331
- typeof recordThumbHeight === "number" ? recordThumbHeight : undefined,
2389
+ typeof recordThumbHeight === "number"
2390
+ ? recordThumbHeight
2391
+ : undefined,
2332
2392
  searchMetadataValues:
2333
2393
  metadataValues && metadataValues.length
2334
2394
  ? metadataValues
@@ -2340,6 +2400,11 @@ async function buildIiifCollectionPages(CONFIG) {
2340
2400
  ? annotationValue
2341
2401
  : undefined,
2342
2402
  });
2403
+ logDebug(
2404
+ `Search record queued for ${manifestLabel}: ${pageHref} (metadata values ${
2405
+ metadataValues ? metadataValues.length : 0
2406
+ })`,
2407
+ );
2343
2408
  } catch (e) {
2344
2409
  lns.push([
2345
2410
  `IIIF: failed to render for ${id || "<unknown>"} — ${e.message}`,
@@ -2352,7 +2417,7 @@ async function buildIiifCollectionPages(CONFIG) {
2352
2417
  }
2353
2418
  const workers = Array.from(
2354
2419
  {length: Math.min(concurrency, chunk.length)},
2355
- () => worker()
2420
+ () => worker(),
2356
2421
  );
2357
2422
  await Promise.all(workers);
2358
2423
  tryFlush();
@@ -2361,48 +2426,58 @@ async function buildIiifCollectionPages(CONFIG) {
2361
2426
  index: ci + 1,
2362
2427
  count: chunk.length,
2363
2428
  durationMs: chunkDuration,
2364
- concurrency,
2365
2429
  });
2366
2430
  try {
2367
- const concurrencyLabel =
2368
- requestedConcurrency === 0 ? `${concurrency} (auto)` : String(concurrency);
2369
2431
  logLine(
2370
- `⏱ 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)}`,
2371
2433
  "cyan",
2372
- { dim: true }
2434
+ {dim: true},
2373
2435
  );
2374
2436
  } catch (_) {}
2375
2437
  }
2376
2438
  if (chunkMetrics.length) {
2377
2439
  const totalDuration = chunkMetrics.reduce(
2378
2440
  (sum, entry) => sum + (entry.durationMs || 0),
2379
- 0
2441
+ 0,
2442
+ );
2443
+ const totalItems = chunkMetrics.reduce(
2444
+ (sum, entry) => sum + (entry.count || 0),
2445
+ 0,
2380
2446
  );
2381
- const totalItems = chunkMetrics.reduce((sum, entry) => sum + (entry.count || 0), 0);
2382
2447
  const avgDuration = chunkMetrics.length
2383
2448
  ? totalDuration / chunkMetrics.length
2384
2449
  : 0;
2385
2450
  const rate = totalDuration > 0 ? totalItems / (totalDuration / 1000) : 0;
2386
2451
  try {
2387
- const rateLabel = rate ? `${rate.toFixed(1)} manifest(s)/s` : 'n/a';
2452
+ const rateLabel = rate ? `${rate.toFixed(1)} manifest(s)/s` : "n/a";
2388
2453
  logLine(
2389
2454
  `IIIF chunk summary: ${totalItems} Manifest(s) in ${formatDurationMs(totalDuration)} (avg chunk ${formatDurationMs(avgDuration)}, ${rateLabel})`,
2390
2455
  "cyan",
2391
- { dim: true }
2456
+ {dim: true},
2392
2457
  );
2393
2458
  } catch (_) {}
2394
2459
  }
2395
2460
  try {
2396
2461
  await navPlace.writeNavPlaceDataset(navPlaceRecords);
2462
+ try {
2463
+ logLine(
2464
+ `✓ Wrote navPlace dataset (${navPlaceRecords.length} record(s))`,
2465
+ "cyan",
2466
+ );
2467
+ } catch (_) {}
2397
2468
  } catch (error) {
2398
2469
  try {
2399
2470
  console.warn(
2400
- '[canopy][navPlace] failed to write dataset:',
2401
- error && error.message ? error.message : error
2471
+ "[canopy][navPlace] failed to write dataset:",
2472
+ error && error.message ? error.message : error,
2402
2473
  );
2403
2474
  } catch (_) {}
2404
2475
  }
2405
- return {iiifRecords};
2476
+ return {
2477
+ iiifRecords,
2478
+ manifestIds: Array.from(renderedManifestIds),
2479
+ collectionIds: Array.from(visitedCollections),
2480
+ };
2406
2481
  }
2407
2482
 
2408
2483
  module.exports = {
@@ -2410,11 +2485,12 @@ module.exports = {
2410
2485
  loadConfig,
2411
2486
  loadManifestIndex,
2412
2487
  saveManifestIndex,
2488
+ resolveIiifSources,
2413
2489
  // Expose helpers used by build for cache warming
2414
2490
  loadCachedManifestById,
2415
2491
  saveCachedManifest,
2416
2492
  ensureFeaturedInCache,
2417
- rebuildManifestIndexFromCache,
2493
+ cleanupIiifCache,
2418
2494
  };
2419
2495
 
2420
2496
  // Expose a stable set of pure helper utilities for unit testing.
@@ -2465,7 +2541,7 @@ try {
2465
2541
  `IIIF: cache/collections (end): ${files.length} file(s)` +
2466
2542
  (head ? ` [${head}${files.length > 8 ? ", …" : ""}]` : ""),
2467
2543
  "blue",
2468
- {dim: true}
2544
+ {dim: true},
2469
2545
  );
2470
2546
  } catch (_) {}
2471
2547
  }