@canopy-iiif/app 1.6.0 → 1.6.2

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