@canopy-iiif/app 0.6.28

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/iiif.js ADDED
@@ -0,0 +1,1145 @@
1
+ const React = require("react");
2
+ const ReactDOMServer = require("react-dom/server");
3
+ const crypto = require("crypto");
4
+ const slugify = require("slugify");
5
+ const yaml = require("js-yaml");
6
+ const {
7
+ fs,
8
+ fsp,
9
+ path,
10
+ OUT_DIR,
11
+ CONTENT_DIR,
12
+ ensureDirSync,
13
+ htmlShell,
14
+ } = require("./common");
15
+ const mdx = require("./mdx");
16
+ const { log, logLine, logResponse } = require("./log");
17
+
18
+ const IIIF_CACHE_DIR = path.resolve(".cache/iiif");
19
+ const IIIF_CACHE_MANIFESTS_DIR = path.join(IIIF_CACHE_DIR, "manifests");
20
+ const IIIF_CACHE_COLLECTIONS_DIR = path.join(IIIF_CACHE_DIR, "collections");
21
+ const IIIF_CACHE_COLLECTION = path.join(IIIF_CACHE_DIR, "collection.json");
22
+ // Primary global index location
23
+ const IIIF_CACHE_INDEX = path.join(IIIF_CACHE_DIR, "index.json");
24
+ // Legacy locations kept for backward compatibility (read + optional write)
25
+ const IIIF_CACHE_INDEX_LEGACY = path.join(
26
+ IIIF_CACHE_DIR,
27
+ "manifest-index.json"
28
+ );
29
+ const IIIF_CACHE_INDEX_MANIFESTS = path.join(
30
+ IIIF_CACHE_MANIFESTS_DIR,
31
+ "manifest-index.json"
32
+ );
33
+
34
+ function firstLabelString(label) {
35
+ if (!label) return "Untitled";
36
+ if (typeof label === "string") return label;
37
+ const keys = Object.keys(label || {});
38
+ if (!keys.length) return "Untitled";
39
+ const arr = label[keys[0]];
40
+ if (Array.isArray(arr) && arr.length) return String(arr[0]);
41
+ return "Untitled";
42
+ }
43
+
44
+ async function normalizeToV3(resource) {
45
+ try {
46
+ const helpers = await import("@iiif/helpers");
47
+ if (helpers && typeof helpers.toPresentation3 === "function") {
48
+ return helpers.toPresentation3(resource);
49
+ }
50
+ if (helpers && typeof helpers.normalize === "function") {
51
+ return helpers.normalize(resource);
52
+ }
53
+ if (helpers && typeof helpers.upgradeToV3 === "function") {
54
+ return helpers.upgradeToV3(resource);
55
+ }
56
+ } catch (_) {}
57
+ return resource;
58
+ }
59
+
60
+ async function readJson(p) {
61
+ const raw = await fsp.readFile(p, "utf8");
62
+ return JSON.parse(raw);
63
+ }
64
+
65
+ function normalizeIiifId(raw) {
66
+ try {
67
+ const s = String(raw || "");
68
+ if (!/^https?:\/\//i.test(s)) return s;
69
+ const u = new URL(s);
70
+ const entries = Array.from(u.searchParams.entries()).sort(
71
+ (a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])
72
+ );
73
+ u.search = "";
74
+ for (const [k, v] of entries) u.searchParams.append(k, v);
75
+ return u.toString();
76
+ } catch (_) {
77
+ return String(raw || "");
78
+ }
79
+ }
80
+
81
+ async function readJsonFromUri(uri, options = { log: false }) {
82
+ try {
83
+ if (/^https?:\/\//i.test(uri)) {
84
+ if (typeof fetch !== "function") return null;
85
+ const res = await fetch(uri, {
86
+ headers: { Accept: "application/json" },
87
+ }).catch(() => null);
88
+ if (options && options.log) {
89
+ try {
90
+ if (res && res.ok) {
91
+ // Bold success line for Collection fetches
92
+ logLine(`✓ ${String(uri)} ➜ ${res.status}`, "yellow", {
93
+ bright: true,
94
+ });
95
+ } else {
96
+ const code = res ? res.status : "ERR";
97
+ logLine(`✗ ${String(uri)} ➜ ${code}`, "red", { bright: true });
98
+ }
99
+ } catch (_) {}
100
+ }
101
+ if (!res || !res.ok) return null;
102
+ return await res.json();
103
+ }
104
+ const p = uri.startsWith("file://") ? new URL(uri) : { pathname: uri };
105
+ const localPath = uri.startsWith("file://")
106
+ ? p.pathname
107
+ : path.resolve(String(p.pathname));
108
+ return await readJson(localPath);
109
+ } catch (_) {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function computeHash(obj) {
115
+ try {
116
+ const json = JSON.stringify(deepSort(obj));
117
+ return crypto.createHash("sha256").update(json).digest("hex");
118
+ } catch (_) {
119
+ return "";
120
+ }
121
+ }
122
+
123
+ function deepSort(value) {
124
+ if (Array.isArray(value)) return value.map(deepSort);
125
+ if (value && typeof value === "object") {
126
+ const out = {};
127
+ for (const key of Object.keys(value).sort())
128
+ out[key] = deepSort(value[key]);
129
+ return out;
130
+ }
131
+ return value;
132
+ }
133
+
134
+ async function loadManifestIndex() {
135
+ try {
136
+ // Try primary path first
137
+ if (fs.existsSync(IIIF_CACHE_INDEX)) {
138
+ const idx = await readJson(IIIF_CACHE_INDEX);
139
+ if (idx && typeof idx === "object") {
140
+ const byId = Array.isArray(idx.byId)
141
+ ? idx.byId
142
+ : idx.byId && typeof idx.byId === "object"
143
+ ? Object.keys(idx.byId).map((k) => ({
144
+ id: k,
145
+ type: "Manifest",
146
+ slug: String(idx.byId[k] || ""),
147
+ parent: (idx.parents && idx.parents[k]) || "",
148
+ }))
149
+ : [];
150
+ return { byId, collection: idx.collection || null };
151
+ }
152
+ }
153
+ // Fallback: legacy in .cache/iiif
154
+ if (fs.existsSync(IIIF_CACHE_INDEX_LEGACY)) {
155
+ const idx = await readJson(IIIF_CACHE_INDEX_LEGACY);
156
+ if (idx && typeof idx === "object") {
157
+ const byId = Array.isArray(idx.byId)
158
+ ? idx.byId
159
+ : idx.byId && typeof idx.byId === "object"
160
+ ? Object.keys(idx.byId).map((k) => ({
161
+ id: k,
162
+ type: "Manifest",
163
+ slug: String(idx.byId[k] || ""),
164
+ parent: (idx.parents && idx.parents[k]) || "",
165
+ }))
166
+ : [];
167
+ return { byId, collection: idx.collection || null };
168
+ }
169
+ }
170
+ // Fallback: legacy in manifests subdir
171
+ if (fs.existsSync(IIIF_CACHE_INDEX_MANIFESTS)) {
172
+ const idx = await readJson(IIIF_CACHE_INDEX_MANIFESTS);
173
+ if (idx && typeof idx === "object") {
174
+ const byId = Array.isArray(idx.byId)
175
+ ? idx.byId
176
+ : idx.byId && typeof idx.byId === "object"
177
+ ? Object.keys(idx.byId).map((k) => ({
178
+ id: k,
179
+ type: "Manifest",
180
+ slug: String(idx.byId[k] || ""),
181
+ parent: (idx.parents && idx.parents[k]) || "",
182
+ }))
183
+ : [];
184
+ return { byId, collection: idx.collection || null };
185
+ }
186
+ }
187
+ } catch (_) {}
188
+ return { byId: [], collection: null };
189
+ }
190
+
191
+ async function saveManifestIndex(index) {
192
+ try {
193
+ ensureDirSync(IIIF_CACHE_DIR);
194
+ const out = {
195
+ byId: Array.isArray(index.byId) ? index.byId : [],
196
+ collection: index.collection || null,
197
+ // Optional build/search version; consumers may ignore
198
+ version: index.version || undefined,
199
+ };
200
+ await fsp.writeFile(IIIF_CACHE_INDEX, JSON.stringify(out, null, 2), "utf8");
201
+ // Remove legacy files to avoid confusion
202
+ try {
203
+ await fsp.rm(IIIF_CACHE_INDEX_LEGACY, { force: true });
204
+ } catch (_) {}
205
+ try {
206
+ await fsp.rm(IIIF_CACHE_INDEX_MANIFESTS, { force: true });
207
+ } catch (_) {}
208
+ } catch (_) {}
209
+ }
210
+
211
+ // In-memory memo to avoid repeated FS scans when index mapping is missing
212
+ const MEMO_ID_TO_SLUG = new Map();
213
+ // Track slugs chosen during this run to avoid collisions when multiple
214
+ // collections/manifests share the same base title but mappings aren't yet saved.
215
+ const RESERVED_SLUGS = { Manifest: new Set(), Collection: new Set() };
216
+
217
+ function computeUniqueSlug(index, baseSlug, id, type) {
218
+ const byId = Array.isArray(index && index.byId) ? index.byId : [];
219
+ const normId = normalizeIiifId(String(id || ""));
220
+ const used = new Set(
221
+ byId
222
+ .filter((e) => e && e.slug && e.type === type)
223
+ .map((e) => String(e.slug))
224
+ );
225
+ const reserved = RESERVED_SLUGS[type] || new Set();
226
+ let slug = baseSlug || (type === "Manifest" ? "untitled" : "collection");
227
+ let i = 1;
228
+ for (;;) {
229
+ const existing = byId.find(
230
+ (e) => e && e.type === type && String(e.slug) === String(slug)
231
+ );
232
+ if (existing) {
233
+ // If this slug already maps to this id, reuse it and reserve.
234
+ if (normalizeIiifId(existing.id) === normId) {
235
+ reserved.add(slug);
236
+ return slug;
237
+ }
238
+ }
239
+ if (!used.has(slug) && !reserved.has(slug)) {
240
+ reserved.add(slug);
241
+ return slug;
242
+ }
243
+ slug = `${baseSlug}-${i++}`;
244
+ }
245
+ }
246
+
247
+ function ensureBaseSlugFor(index, baseSlug, id, type) {
248
+ try {
249
+ const byId = Array.isArray(index && index.byId) ? index.byId : [];
250
+ const normId = normalizeIiifId(String(id || ''));
251
+ const existingWithBase = byId.find(
252
+ (e) => e && e.type === type && String(e.slug) === String(baseSlug)
253
+ );
254
+ if (existingWithBase && normalizeIiifId(existingWithBase.id) !== normId) {
255
+ // Reassign the existing entry to the next available suffix to free the base
256
+ const newSlug = computeUniqueSlug(index, baseSlug, existingWithBase.id, type);
257
+ if (newSlug && newSlug !== baseSlug) existingWithBase.slug = newSlug;
258
+ }
259
+ } catch (_) {}
260
+ return baseSlug;
261
+ }
262
+
263
+ async function findSlugByIdFromDisk(id) {
264
+ try {
265
+ if (!fs.existsSync(IIIF_CACHE_MANIFESTS_DIR)) return null;
266
+ const files = await fsp.readdir(IIIF_CACHE_MANIFESTS_DIR);
267
+ for (const name of files) {
268
+ if (!name || !name.toLowerCase().endsWith(".json")) continue;
269
+ const p = path.join(IIIF_CACHE_MANIFESTS_DIR, name);
270
+ try {
271
+ const raw = await fsp.readFile(p, "utf8");
272
+ const obj = JSON.parse(raw);
273
+ const mid = normalizeIiifId(
274
+ String((obj && (obj.id || obj["@id"])) || "")
275
+ );
276
+ if (mid && mid === normalizeIiifId(String(id))) {
277
+ const slug = name.replace(/\.json$/i, "");
278
+ return slug;
279
+ }
280
+ } catch (_) {}
281
+ }
282
+ } catch (_) {}
283
+ return null;
284
+ }
285
+
286
+ async function loadCachedManifestById(id) {
287
+ if (!id) return null;
288
+ try {
289
+ const index = await loadManifestIndex();
290
+ let slug = null;
291
+ if (Array.isArray(index.byId)) {
292
+ const nid = normalizeIiifId(id);
293
+ const entry = index.byId.find(
294
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
295
+ );
296
+ slug = entry && entry.slug;
297
+ }
298
+ if (!slug) {
299
+ // Try an on-disk scan to recover mapping if index is missing/out-of-sync
300
+ const memo = MEMO_ID_TO_SLUG.get(String(id));
301
+ if (memo) slug = memo;
302
+ if (!slug) {
303
+ const found = await findSlugByIdFromDisk(id);
304
+ if (found) {
305
+ slug = found;
306
+ MEMO_ID_TO_SLUG.set(String(id), slug);
307
+ try {
308
+ // Heal index mapping for future runs
309
+ index.byId = Array.isArray(index.byId) ? index.byId : [];
310
+ const nid = normalizeIiifId(id);
311
+ const existingEntryIdx = index.byId.findIndex(
312
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
313
+ );
314
+ const entry = {
315
+ id: String(nid),
316
+ type: "Manifest",
317
+ slug,
318
+ parent: "",
319
+ };
320
+ if (existingEntryIdx >= 0) index.byId[existingEntryIdx] = entry;
321
+ else index.byId.push(entry);
322
+ await saveManifestIndex(index);
323
+ } catch (_) {}
324
+ }
325
+ }
326
+ }
327
+ if (!slug) return null;
328
+ const p = path.join(IIIF_CACHE_MANIFESTS_DIR, slug + ".json");
329
+ if (!fs.existsSync(p)) return null;
330
+ return await readJson(p);
331
+ } catch (_) {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ async function saveCachedManifest(manifest, id, parentId) {
337
+ try {
338
+ const index = await loadManifestIndex();
339
+ const title = firstLabelString(manifest && manifest.label);
340
+ const baseSlug =
341
+ slugify(title || "untitled", { lower: true, strict: true, trim: true }) ||
342
+ "untitled";
343
+ const slug = computeUniqueSlug(index, baseSlug, id, "Manifest");
344
+ ensureDirSync(IIIF_CACHE_MANIFESTS_DIR);
345
+ const dest = path.join(IIIF_CACHE_MANIFESTS_DIR, slug + ".json");
346
+ await fsp.writeFile(dest, JSON.stringify(manifest, null, 2), "utf8");
347
+ index.byId = Array.isArray(index.byId) ? index.byId : [];
348
+ const nid = normalizeIiifId(id);
349
+ const existingEntryIdx = index.byId.findIndex(
350
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Manifest"
351
+ );
352
+ const entry = {
353
+ id: String(nid),
354
+ type: "Manifest",
355
+ slug,
356
+ parent: parentId ? String(parentId) : "",
357
+ };
358
+ if (existingEntryIdx >= 0) index.byId[existingEntryIdx] = entry;
359
+ else index.byId.push(entry);
360
+ await saveManifestIndex(index);
361
+ } catch (_) {}
362
+ }
363
+
364
+ async function flushManifestCache() {
365
+ try {
366
+ await fsp.rm(IIIF_CACHE_MANIFESTS_DIR, { recursive: true, force: true });
367
+ } catch (_) {}
368
+ ensureDirSync(IIIF_CACHE_MANIFESTS_DIR);
369
+ ensureDirSync(IIIF_CACHE_COLLECTIONS_DIR);
370
+ try {
371
+ await fsp.rm(IIIF_CACHE_COLLECTIONS_DIR, { recursive: true, force: true });
372
+ } catch (_) {}
373
+ ensureDirSync(IIIF_CACHE_COLLECTIONS_DIR);
374
+ }
375
+
376
+ // Collections cache helpers
377
+ async function loadCachedCollectionById(id) {
378
+ if (!id) return null;
379
+ try {
380
+ const index = await loadManifestIndex();
381
+ let slug = null;
382
+ if (Array.isArray(index.byId)) {
383
+ const nid = normalizeIiifId(id);
384
+ const entry = index.byId.find(
385
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
386
+ );
387
+ slug = entry && entry.slug;
388
+ }
389
+ if (!slug) {
390
+ // Scan collections dir if mapping missing
391
+ try {
392
+ if (fs.existsSync(IIIF_CACHE_COLLECTIONS_DIR)) {
393
+ const files = await fsp.readdir(IIIF_CACHE_COLLECTIONS_DIR);
394
+ for (const name of files) {
395
+ if (!name || !name.toLowerCase().endsWith(".json")) continue;
396
+ const p = path.join(IIIF_CACHE_COLLECTIONS_DIR, name);
397
+ try {
398
+ const raw = await fsp.readFile(p, "utf8");
399
+ const obj = JSON.parse(raw);
400
+ const cid = normalizeIiifId(
401
+ String((obj && (obj.id || obj["@id"])) || "")
402
+ );
403
+ if (cid && cid === normalizeIiifId(String(id))) {
404
+ slug = name.replace(/\.json$/i, "");
405
+ // heal mapping
406
+ try {
407
+ index.byId = Array.isArray(index.byId) ? index.byId : [];
408
+ const nid = normalizeIiifId(id);
409
+ const existing = index.byId.findIndex(
410
+ (e) =>
411
+ e &&
412
+ normalizeIiifId(e.id) === nid &&
413
+ e.type === "Collection"
414
+ );
415
+ const entry = {
416
+ id: String(nid),
417
+ type: "Collection",
418
+ slug,
419
+ parent: "",
420
+ };
421
+ if (existing >= 0) index.byId[existing] = entry;
422
+ else index.byId.push(entry);
423
+ await saveManifestIndex(index);
424
+ } catch (_) {}
425
+ break;
426
+ }
427
+ } catch (_) {}
428
+ }
429
+ }
430
+ } catch (_) {}
431
+ }
432
+ if (!slug) return null;
433
+ const p = path.join(IIIF_CACHE_COLLECTIONS_DIR, slug + ".json");
434
+ if (!fs.existsSync(p)) return null;
435
+ return await readJson(p);
436
+ } catch (_) {
437
+ return null;
438
+ }
439
+ }
440
+
441
+ async function saveCachedCollection(collection, id, parentId) {
442
+ try {
443
+ ensureDirSync(IIIF_CACHE_COLLECTIONS_DIR);
444
+ const index = await loadManifestIndex();
445
+ const title = firstLabelString(collection && collection.label);
446
+ const baseSlug =
447
+ slugify(title || "collection", {
448
+ lower: true,
449
+ strict: true,
450
+ trim: true,
451
+ }) || "collection";
452
+ const slug = computeUniqueSlug(index, baseSlug, id, "Collection");
453
+ const dest = path.join(IIIF_CACHE_COLLECTIONS_DIR, slug + ".json");
454
+ await fsp.writeFile(dest, JSON.stringify(collection, null, 2), "utf8");
455
+ try {
456
+ if (process.env.CANOPY_IIIF_DEBUG === "1") {
457
+ const { logLine } = require("./log");
458
+ logLine(`IIIF: saved collection ➜ ${slug}.json`, "cyan", { dim: true });
459
+ }
460
+ } catch (_) {}
461
+ index.byId = Array.isArray(index.byId) ? index.byId : [];
462
+ const nid = normalizeIiifId(id);
463
+ const existingEntryIdx = index.byId.findIndex(
464
+ (e) => e && normalizeIiifId(e.id) === nid && e.type === "Collection"
465
+ );
466
+ const entry = {
467
+ id: String(nid),
468
+ type: "Collection",
469
+ slug,
470
+ parent: parentId ? String(parentId) : "",
471
+ };
472
+ if (existingEntryIdx >= 0) index.byId[existingEntryIdx] = entry;
473
+ else index.byId.push(entry);
474
+ await saveManifestIndex(index);
475
+ } catch (_) {}
476
+ }
477
+
478
+ async function loadConfig() {
479
+ let CONFIG = {
480
+ collection: {
481
+ uri: "https://iiif.io/api/cookbook/recipe/0032-collection/collection.json",
482
+ },
483
+ iiif: { chunkSize: 10, concurrency: 6 },
484
+ metadata: [],
485
+ };
486
+ const overrideConfigPath = process.env.CANOPY_CONFIG;
487
+ const configPath = path.resolve(overrideConfigPath || "canopy.yml");
488
+ if (fs.existsSync(configPath)) {
489
+ try {
490
+ const raw = await fsp.readFile(configPath, "utf8");
491
+ const data = yaml.load(raw) || {};
492
+ const d = data || {};
493
+ const di = d.iiif || {};
494
+ CONFIG = {
495
+ collection: {
496
+ uri: (d.collection && d.collection.uri) || CONFIG.collection.uri,
497
+ },
498
+ iiif: {
499
+ chunkSize:
500
+ Number(di.chunkSize || CONFIG.iiif.chunkSize) ||
501
+ CONFIG.iiif.chunkSize,
502
+ concurrency:
503
+ Number(di.concurrency || CONFIG.iiif.concurrency) ||
504
+ CONFIG.iiif.concurrency,
505
+ thumbnails: {
506
+ unsafe: !!(di.thumbnails && di.thumbnails.unsafe === true),
507
+ preferredSize:
508
+ Number(di.thumbnails && di.thumbnails.preferredSize) || 1200,
509
+ },
510
+ },
511
+ metadata: Array.isArray(d.metadata)
512
+ ? d.metadata.map((s) => String(s)).filter(Boolean)
513
+ : [],
514
+ };
515
+ const src = overrideConfigPath ? overrideConfigPath : "canopy.yml";
516
+ logLine(`[canopy] Loaded config from ${src}...`, "white");
517
+ } catch (e) {
518
+ console.warn(
519
+ "[canopy] Failed to read",
520
+ overrideConfigPath ? overrideConfigPath : "canopy.yml",
521
+ e.message
522
+ );
523
+ }
524
+ }
525
+ if (process.env.CANOPY_COLLECTION_URI) {
526
+ CONFIG.collection.uri = String(process.env.CANOPY_COLLECTION_URI);
527
+ console.log("Using collection URI from CANOPY_COLLECTION_URI");
528
+ }
529
+ return CONFIG;
530
+ }
531
+
532
+ async function buildIiifCollectionPages(CONFIG) {
533
+ const worksDir = path.join(CONTENT_DIR, "works");
534
+ const worksLayoutPath = path.join(worksDir, "_layout.mdx");
535
+ if (!fs.existsSync(worksLayoutPath)) {
536
+ console.log(
537
+ "IIIF: No content/works/_layout.mdx found; skipping IIIF page build."
538
+ );
539
+ return { searchRecords: [] };
540
+ }
541
+ ensureDirSync(IIIF_CACHE_MANIFESTS_DIR);
542
+ ensureDirSync(IIIF_CACHE_COLLECTIONS_DIR);
543
+ // Debug: list current collections cache contents
544
+ try {
545
+ if (process.env.CANOPY_IIIF_DEBUG === "1") {
546
+ const { logLine } = require("./log");
547
+ try {
548
+ const files = fs.existsSync(IIIF_CACHE_COLLECTIONS_DIR)
549
+ ? fs
550
+ .readdirSync(IIIF_CACHE_COLLECTIONS_DIR)
551
+ .filter((n) => /\.json$/i.test(n))
552
+ : [];
553
+ const head = files.slice(0, 8).join(", ");
554
+ logLine(
555
+ `IIIF: cache/collections (start): ${files.length} file(s)` +
556
+ (head ? ` [${head}${files.length > 8 ? ", …" : ""}]` : ""),
557
+ "blue",
558
+ { dim: true }
559
+ );
560
+ } catch (_) {}
561
+ }
562
+ } catch (_) {}
563
+ const collectionUri =
564
+ (CONFIG && CONFIG.collection && CONFIG.collection.uri) || null;
565
+ try {
566
+ try {
567
+ if (collectionUri) {
568
+ logLine(`${collectionUri}`, "white", {
569
+ dim: true,
570
+ });
571
+ }
572
+ } catch (_) {}
573
+ console.log("");
574
+ } catch (_) {}
575
+ // Decide cache policy/log before any fetch
576
+ let index = await loadManifestIndex();
577
+ const prevUri = (index && index.collection && index.collection.uri) || "";
578
+ const uriChanged = !!(collectionUri && prevUri && prevUri !== collectionUri);
579
+ if (uriChanged) {
580
+ try {
581
+ const { logLine } = require("./log");
582
+ logLine("IIIF Collection changed. Resetting cache.\n", "cyan");
583
+ } catch (_) {}
584
+ await flushManifestCache();
585
+ index.byId = [];
586
+ } else {
587
+ try {
588
+ const { logLine } = require("./log");
589
+ logLine(
590
+ "IIIF Collection unchanged. Preserving cache. Detecting cached resources...\n",
591
+ "cyan"
592
+ );
593
+ } catch (_) {}
594
+ }
595
+ let collection = null;
596
+ if (collectionUri) {
597
+ // Try cached root collection first
598
+ collection = await loadCachedCollectionById(collectionUri);
599
+ if (collection) {
600
+ try {
601
+ const { logLine } = require("./log");
602
+ logLine(`✓ ${String(collectionUri)} ➜ Cached`, "yellow");
603
+ } catch (_) {}
604
+ } else {
605
+ collection = await readJsonFromUri(collectionUri, { log: true });
606
+ if (collection) {
607
+ try {
608
+ await saveCachedCollection(
609
+ collection,
610
+ String(collection.id || collection["@id"] || collectionUri),
611
+ ""
612
+ );
613
+ } catch (_) {}
614
+ }
615
+ }
616
+ }
617
+ if (!collection) {
618
+ console.warn("IIIF: No collection available; skipping.");
619
+ // Still write/update global index with configured URI, so other steps can rely on it
620
+ try {
621
+ const index = await loadManifestIndex();
622
+ index.collection = {
623
+ uri: String(collectionUri || ""),
624
+ hash: "",
625
+ updatedAt: new Date().toISOString(),
626
+ };
627
+ await saveManifestIndex(index);
628
+ } catch (_) {}
629
+ return { searchRecords: [] };
630
+ }
631
+ // Normalize to Presentation 3 to ensure a consistent structure (items array, types)
632
+ const collectionForHash = await normalizeToV3(collection);
633
+ const currentSig = {
634
+ uri: String(collectionUri || ""),
635
+ hash: computeHash(collectionForHash),
636
+ };
637
+ index.collection = { ...currentSig, updatedAt: new Date().toISOString() };
638
+ // Upsert root collection entry in index.byId
639
+ try {
640
+ const rootId = normalizeIiifId(
641
+ String(collection.id || collection["@id"] || collectionUri || "")
642
+ );
643
+ const title = firstLabelString(collection && collection.label);
644
+ const baseSlug = slugify(title || "collection", { lower: true, strict: true, trim: true }) || "collection";
645
+ index.byId = Array.isArray(index.byId) ? index.byId : [];
646
+ const slug = ensureBaseSlugFor(index, baseSlug, rootId, 'Collection');
647
+ const existingIdx = index.byId.findIndex(
648
+ (e) => e && normalizeIiifId(e.id) === rootId && e.type === "Collection"
649
+ );
650
+ const entry = { id: rootId, type: "Collection", slug, parent: "" };
651
+ if (existingIdx >= 0) index.byId[existingIdx] = entry;
652
+ else index.byId.push(entry);
653
+ try { (RESERVED_SLUGS && RESERVED_SLUGS.Collection || new Set()).add(slug); } catch (_) {}
654
+ } catch (_) {}
655
+ await saveManifestIndex(index);
656
+
657
+ const WorksLayout = await mdx.compileMdxToComponent(worksLayoutPath);
658
+ // Recursively collect manifests across subcollections
659
+ const tasks = [];
660
+ async function loadOrFetchCollectionById(id, lns, fetchAllowed = true) {
661
+ let c = await loadCachedCollectionById(String(id));
662
+ if (c) {
663
+ try {
664
+ // Emit a standard Cached line so it mirrors network logs
665
+ const { logLine } = require("./log");
666
+ logLine(`✓ ${String(id)} ➜ Cached`, "yellow");
667
+ } catch (_) {}
668
+ if (lns) lns.push([`✓ ${String(id)} ➜ Cached`, "yellow"]);
669
+ // Always normalize to Presentation 3 for consistent traversal
670
+ return await normalizeToV3(c);
671
+ }
672
+ if (!fetchAllowed) return null;
673
+ const fetched = await readJsonFromUri(String(id), { log: true });
674
+ const normalized = await normalizeToV3(fetched);
675
+ try {
676
+ await saveCachedCollection(
677
+ normalized || fetched,
678
+ String(((normalized || fetched) && ((normalized || fetched).id || (normalized || fetched)["@id"])) || id),
679
+ ""
680
+ );
681
+ } catch (_) {}
682
+ return normalized || fetched;
683
+ }
684
+ async function collectTasksFromCollection(colObj, parentUri, visited) {
685
+ if (!colObj) return;
686
+ // Prefer the URI we traversed from (parentUri) to work around sources
687
+ // that report the same id for multiple pages (e.g., Internet Archive).
688
+ const colId = String(parentUri || colObj.id || colObj["@id"] || "");
689
+ visited = visited || new Set();
690
+ if (colId) {
691
+ if (visited.has(colId)) return;
692
+ visited.add(colId);
693
+ }
694
+ // Traverse explicit sub-collections under items only (no paging semantics)
695
+ const items = Array.isArray(colObj && colObj.items) ? colObj.items : [];
696
+ for (const it of items) {
697
+ if (!it) continue;
698
+ const t = String(it.type || it["@type"] || "");
699
+ const id = it.id || it["@id"] || "";
700
+ if (t.includes("Manifest")) {
701
+ tasks.push({
702
+ id: String(id),
703
+ parent: String(colId || parentUri || ""),
704
+ });
705
+ } else if (t.includes("Collection")) {
706
+ let subRaw = await loadOrFetchCollectionById(String(id));
707
+ try {
708
+ await saveCachedCollection(
709
+ subRaw,
710
+ String(id),
711
+ String(colId || parentUri || "")
712
+ );
713
+ } catch (_) {}
714
+ try {
715
+ const title = firstLabelString(subRaw && subRaw.label);
716
+ const baseSlug = slugify(title || "collection", { lower: true, strict: true, trim: true }) || "collection";
717
+ const idx = await loadManifestIndex();
718
+ idx.byId = Array.isArray(idx.byId) ? idx.byId : [];
719
+ const subIdNorm = normalizeIiifId(String(id));
720
+ const slug = computeUniqueSlug(idx, baseSlug, subIdNorm, 'Collection');
721
+ const entry = {
722
+ id: subIdNorm,
723
+ type: "Collection",
724
+ slug,
725
+ parent: normalizeIiifId(String(colId || parentUri || "")),
726
+ };
727
+ const existing = idx.byId.findIndex(
728
+ (e) =>
729
+ e &&
730
+ normalizeIiifId(e.id) === subIdNorm &&
731
+ e.type === "Collection"
732
+ );
733
+ if (existing >= 0) idx.byId[existing] = entry;
734
+ else idx.byId.push(entry);
735
+ await saveManifestIndex(idx);
736
+ } catch (_) {}
737
+ await collectTasksFromCollection(subRaw, String(id), visited);
738
+ } else if (/^https?:\/\//i.test(String(id || ""))) {
739
+ let norm = await loadOrFetchCollectionById(String(id));
740
+ const nt = String((norm && (norm.type || norm["@type"])) || "");
741
+ if (nt.includes("Collection")) {
742
+ try {
743
+ const title = firstLabelString(norm && norm.label);
744
+ const baseSlug = slugify(title || "collection", { lower: true, strict: true, trim: true }) || "collection";
745
+ const idx = await loadManifestIndex();
746
+ idx.byId = Array.isArray(idx.byId) ? idx.byId : [];
747
+ const normId = normalizeIiifId(String(id));
748
+ const slug = computeUniqueSlug(idx, baseSlug, normId, 'Collection');
749
+ const entry = {
750
+ id: normId,
751
+ type: "Collection",
752
+ slug,
753
+ parent: normalizeIiifId(String(colId || parentUri || "")),
754
+ };
755
+ const existing = idx.byId.findIndex(
756
+ (e) => e && normalizeIiifId(e.id) === normId && e.type === "Collection"
757
+ );
758
+ if (existing >= 0) idx.byId[existing] = entry;
759
+ else idx.byId.push(entry);
760
+ await saveManifestIndex(idx);
761
+ } catch (_) {}
762
+ try {
763
+ await saveCachedCollection(
764
+ norm,
765
+ String(id),
766
+ String(colId || parentUri || "")
767
+ );
768
+ } catch (_) {}
769
+ await collectTasksFromCollection(norm, String(id), visited);
770
+ } else if (nt.includes("Manifest")) {
771
+ tasks.push({
772
+ id: String(id),
773
+ parent: String(colId || parentUri || ""),
774
+ });
775
+ }
776
+ }
777
+ }
778
+ }
779
+ // Use normalized root collection for traversal to guarantee v3 shape
780
+ const collectionV3 = await normalizeToV3(collection);
781
+ await collectTasksFromCollection(
782
+ collectionV3,
783
+ String((collectionV3 && (collectionV3.id || collectionV3["@id"])) || collectionUri || ""),
784
+ new Set()
785
+ );
786
+ const chunkSize = Math.max(
787
+ 1,
788
+ Number(
789
+ process.env.CANOPY_CHUNK_SIZE ||
790
+ (CONFIG.iiif && CONFIG.iiif.chunkSize) ||
791
+ 10
792
+ )
793
+ );
794
+ const chunks = Math.max(1, Math.ceil(tasks.length / chunkSize));
795
+ try {
796
+ logLine(
797
+ `\nAggregating ${tasks.length} Manifest(s) in ${chunks} chunk(s)...`,
798
+ "cyan"
799
+ );
800
+ } catch (_) {}
801
+ const searchRecords = [];
802
+ const unsafeThumbs = !!(
803
+ CONFIG &&
804
+ CONFIG.iiif &&
805
+ CONFIG.iiif.thumbnails &&
806
+ CONFIG.iiif.thumbnails.unsafe === true
807
+ );
808
+ const thumbSize =
809
+ (CONFIG &&
810
+ CONFIG.iiif &&
811
+ CONFIG.iiif.thumbnails &&
812
+ CONFIG.iiif.thumbnails.preferredSize) ||
813
+ 1200;
814
+ for (let ci = 0; ci < chunks; ci++) {
815
+ const chunk = tasks.slice(ci * chunkSize, (ci + 1) * chunkSize);
816
+ try {
817
+ logLine(`\nChunk (${ci + 1}/${chunks})\n`, "magenta");
818
+ } catch (_) {}
819
+ const concurrency = Math.max(
820
+ 1,
821
+ Number(
822
+ process.env.CANOPY_FETCH_CONCURRENCY ||
823
+ (CONFIG.iiif && CONFIG.iiif.concurrency) ||
824
+ 6
825
+ )
826
+ );
827
+ let next = 0;
828
+ const logs = new Array(chunk.length);
829
+ let nextPrint = 0;
830
+ function tryFlush() {
831
+ try {
832
+ while (nextPrint < logs.length && logs[nextPrint]) {
833
+ const lines = logs[nextPrint];
834
+ for (const [txt, color, opts] of lines) {
835
+ try {
836
+ logLine(txt, color, opts);
837
+ } catch (_) {}
838
+ }
839
+ logs[nextPrint] = null;
840
+ nextPrint++;
841
+ }
842
+ } catch (_) {}
843
+ }
844
+ async function worker() {
845
+ for (;;) {
846
+ const it = chunk[next++];
847
+ if (!it) break;
848
+ const idx = next - 1;
849
+ const id = it.id || it["@id"] || "";
850
+ let manifest = await loadCachedManifestById(id);
851
+ // Buffer logs for ordered output
852
+ const lns = [];
853
+ // Logging: cached or fetched
854
+ if (manifest) {
855
+ lns.push([`✓ ${String(id)} ➜ Cached`, "yellow"]);
856
+ } else if (/^https?:\/\//i.test(String(id || ""))) {
857
+ try {
858
+ const res = await fetch(String(id), {
859
+ headers: { Accept: "application/json" },
860
+ }).catch(() => null);
861
+ if (res && res.ok) {
862
+ lns.push([`✓ ${String(id)} ➜ ${res.status}`, "yellow"]);
863
+ const remote = await res.json();
864
+ const norm = await normalizeToV3(remote);
865
+ manifest = norm;
866
+ await saveCachedManifest(
867
+ manifest,
868
+ String(id),
869
+ String(it.parent || "")
870
+ );
871
+ } else {
872
+ lns.push([
873
+ `✗ ${String(id)} ➜ ${res ? res.status : "ERR"}`,
874
+ "red",
875
+ ]);
876
+ continue;
877
+ }
878
+ } catch (e) {
879
+ lns.push([`✗ ${String(id)} ➜ ERR`, "red"]);
880
+ continue;
881
+ }
882
+ } else if (/^file:\/\//i.test(String(id || ""))) {
883
+ // Support local manifests via file:// in dev
884
+ try {
885
+ const local = await readJsonFromUri(String(id), { log: false });
886
+ if (!local) {
887
+ lns.push([`✗ ${String(id)} ➜ ERR`, "red"]);
888
+ continue;
889
+ }
890
+ const norm = await normalizeToV3(local);
891
+ manifest = norm;
892
+ await saveCachedManifest(
893
+ manifest,
894
+ String(id),
895
+ String(it.parent || "")
896
+ );
897
+ lns.push([`✓ ${String(id)} ➜ Cached`, "yellow"]);
898
+ } catch (_) {
899
+ lns.push([`✗ ${String(id)} ➜ ERR`, "red"]);
900
+ continue;
901
+ }
902
+ } else {
903
+ // Unsupported scheme; skip
904
+ lns.push([`✗ ${String(id)} ➜ SKIP`, "red"]);
905
+ continue;
906
+ }
907
+ if (!manifest) continue;
908
+ manifest = await normalizeToV3(manifest);
909
+ const title = firstLabelString(manifest.label);
910
+ const baseSlug = slugify(title || "untitled", { lower: true, strict: true, trim: true }) || 'untitled';
911
+ const nid = normalizeIiifId(String(manifest.id || id));
912
+ // Use existing mapping if present; otherwise allocate and persist
913
+ let idxMap = await loadManifestIndex();
914
+ idxMap.byId = Array.isArray(idxMap.byId) ? idxMap.byId : [];
915
+ let mEntry = idxMap.byId.find((e) => e && e.type === 'Manifest' && normalizeIiifId(e.id) === nid);
916
+ let slug = mEntry && mEntry.slug;
917
+ if (!slug) {
918
+ // Prefer base-first; if base is free, use it, else suffix
919
+ slug = computeUniqueSlug(idxMap, baseSlug, nid, 'Manifest');
920
+ const parentNorm = normalizeIiifId(String(it.parent || ''));
921
+ const newEntry = { id: nid, type: 'Manifest', slug, parent: parentNorm };
922
+ const existingIdx = idxMap.byId.findIndex((e) => e && e.type === 'Manifest' && normalizeIiifId(e.id) === nid);
923
+ if (existingIdx >= 0) idxMap.byId[existingIdx] = newEntry; else idxMap.byId.push(newEntry);
924
+ await saveManifestIndex(idxMap);
925
+ }
926
+ const href = path.join("works", slug + ".html");
927
+ const outPath = path.join(OUT_DIR, href);
928
+ ensureDirSync(path.dirname(outPath));
929
+ try {
930
+ // Provide MDX components mapping so tags like <Viewer/> and <HelloWorld/> resolve
931
+ let components = {};
932
+ try {
933
+ components = await import("@canopy-iiif/app/ui");
934
+ } catch (_) {
935
+ components = {};
936
+ }
937
+ const { withBase } = require("./common");
938
+ const Anchor = function A(props) {
939
+ let { href = "", ...rest } = props || {};
940
+ href = withBase(href);
941
+ return React.createElement("a", { href, ...rest }, props.children);
942
+ };
943
+ const compMap = { ...components, a: Anchor };
944
+ // Gracefully handle HelloWorld if not provided anywhere
945
+ if (!components.HelloWorld) {
946
+ components.HelloWorld = components.Fallback
947
+ ? (props) =>
948
+ React.createElement(components.Fallback, {
949
+ name: "HelloWorld",
950
+ ...props,
951
+ })
952
+ : () => null;
953
+ }
954
+ let MDXProvider = null;
955
+ try {
956
+ const mod = await import("@mdx-js/react");
957
+ MDXProvider = mod.MDXProvider || mod.default || null;
958
+ } catch (_) {
959
+ MDXProvider = null;
960
+ }
961
+ const { loadAppWrapper } = require("./mdx");
962
+ const app = await loadAppWrapper();
963
+
964
+ const mdxContent = React.createElement(WorksLayout, { manifest });
965
+ const siteTree = app && app.App ? mdxContent : mdxContent;
966
+ const wrappedApp =
967
+ app && app.App
968
+ ? React.createElement(app.App, null, siteTree)
969
+ : siteTree;
970
+ const page = MDXProvider
971
+ ? React.createElement(
972
+ MDXProvider,
973
+ { components: compMap },
974
+ wrappedApp
975
+ )
976
+ : wrappedApp;
977
+ const body = ReactDOMServer.renderToStaticMarkup(page);
978
+ const head =
979
+ app && app.Head
980
+ ? ReactDOMServer.renderToStaticMarkup(
981
+ React.createElement(app.Head)
982
+ )
983
+ : "";
984
+ const cssRel = path
985
+ .relative(
986
+ path.dirname(outPath),
987
+ path.join(OUT_DIR, "styles", "styles.css")
988
+ )
989
+ .split(path.sep)
990
+ .join("/");
991
+ // Detect placeholders to decide which runtimes to inject
992
+ const needsHydrateViewer = body.includes("data-canopy-viewer");
993
+ const needsRelated = body.includes("data-canopy-related-items");
994
+ const needsCommand = body.includes('data-canopy-command');
995
+ const needsHydrate = body.includes("data-canopy-hydrate") || needsHydrateViewer || needsRelated || needsCommand;
996
+
997
+ // Client runtimes (viewer/slider/facets) are prepared once up-front by the
998
+ // top-level build orchestrator. Avoid rebundling in per-manifest workers.
999
+
1000
+ // Compute script paths relative to the output HTML
1001
+ const viewerRel = needsHydrateViewer
1002
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-viewer.js')).split(path.sep).join('/')
1003
+ : null;
1004
+ const sliderRel = needsRelated
1005
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-slider.js')).split(path.sep).join('/')
1006
+ : null;
1007
+ const relatedRel = needsRelated
1008
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-related-items.js')).split(path.sep).join('/')
1009
+ : null;
1010
+ const commandRel = needsCommand
1011
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-command.js')).split(path.sep).join('/')
1012
+ : null;
1013
+
1014
+ // Choose a main script so execution order is preserved (builder before slider)
1015
+ let jsRel = null;
1016
+ if (needsRelated && sliderRel) jsRel = sliderRel;
1017
+ else if (viewerRel) jsRel = viewerRel;
1018
+
1019
+ // Include hydration scripts via htmlShell
1020
+ let headExtra = head;
1021
+ // Ensure React globals are present only when client React is needed
1022
+ const needsReact = !!(needsHydrateViewer || needsRelated);
1023
+ let vendorTag = '';
1024
+ if (needsReact) {
1025
+ try {
1026
+ const vendorAbs = path.join(OUT_DIR, 'scripts', 'react-globals.js');
1027
+ let vendorRel = path.relative(path.dirname(outPath), vendorAbs).split(path.sep).join('/');
1028
+ try { const stv = fs.statSync(vendorAbs); vendorRel += `?v=${Math.floor(stv.mtimeMs || Date.now())}`; } catch (_) {}
1029
+ vendorTag = `<script src="${vendorRel}"></script>`;
1030
+ } catch (_) {}
1031
+ }
1032
+ // Prepend additional scripts so the selected jsRel runs last
1033
+ const extraScripts = [];
1034
+ if (relatedRel && jsRel !== relatedRel) extraScripts.push(`<script defer src="${relatedRel}"></script>`);
1035
+ if (viewerRel && jsRel !== viewerRel) extraScripts.push(`<script defer src="${viewerRel}"></script>`);
1036
+ if (sliderRel && jsRel !== sliderRel) extraScripts.push(`<script defer src="${sliderRel}"></script>`);
1037
+ // Include command runtime once
1038
+ if (commandRel && jsRel !== commandRel) extraScripts.push(`<script defer src="${commandRel}"></script>`);
1039
+ if (extraScripts.length) headExtra = extraScripts.join('') + headExtra;
1040
+ // Expose base path to browser for runtime code to build URLs
1041
+ try {
1042
+ const { BASE_PATH } = require('./common');
1043
+ if (BASE_PATH) vendorTag = `<script>window.CANOPY_BASE_PATH=${JSON.stringify(BASE_PATH)}</script>` + vendorTag;
1044
+ } catch (_) {}
1045
+ let pageBody = body;
1046
+ let html = htmlShell({
1047
+ title,
1048
+ body: pageBody,
1049
+ cssHref: cssRel || "styles/styles.css",
1050
+ scriptHref: jsRel,
1051
+ headExtra: vendorTag + headExtra,
1052
+ });
1053
+ try {
1054
+ html = require("./common").applyBaseToHtml(html);
1055
+ } catch (_) {}
1056
+ await fsp.writeFile(outPath, html, "utf8");
1057
+ lns.push([
1058
+ `✓ Created ${path.relative(process.cwd(), outPath)}`,
1059
+ "green",
1060
+ ]);
1061
+ // Resolve thumbnail URL and dimensions for this manifest (safe by default; expanded "unsafe" if configured)
1062
+ let thumbUrl = "";
1063
+ let thumbWidth = undefined;
1064
+ let thumbHeight = undefined;
1065
+ try {
1066
+ const { getThumbnail } = require("./thumbnail");
1067
+ const t = await getThumbnail(manifest, thumbSize, unsafeThumbs);
1068
+ if (t && t.url) {
1069
+ thumbUrl = String(t.url);
1070
+ thumbWidth = typeof t.width === 'number' ? t.width : undefined;
1071
+ thumbHeight = typeof t.height === 'number' ? t.height : undefined;
1072
+ const idx = await loadManifestIndex();
1073
+ if (Array.isArray(idx.byId)) {
1074
+ const entry = idx.byId.find(
1075
+ (e) =>
1076
+ e &&
1077
+ e.id === String(manifest.id || id) &&
1078
+ e.type === "Manifest"
1079
+ );
1080
+ if (entry) {
1081
+ entry.thumbnail = String(thumbUrl);
1082
+ // Persist thumbnail dimensions for aspect ratio calculations when available
1083
+ if (typeof thumbWidth === 'number') entry.thumbnailWidth = thumbWidth;
1084
+ if (typeof thumbHeight === 'number') entry.thumbnailHeight = thumbHeight;
1085
+ await saveManifestIndex(idx);
1086
+ }
1087
+ }
1088
+ }
1089
+ } catch (_) {}
1090
+ // Push search record including thumbnail (if available)
1091
+ searchRecords.push({
1092
+ id: String(manifest.id || id),
1093
+ title,
1094
+ href: href.split(path.sep).join("/"),
1095
+ type: "work",
1096
+ thumbnail: thumbUrl || undefined,
1097
+ thumbnailWidth: typeof thumbWidth === 'number' ? thumbWidth : undefined,
1098
+ thumbnailHeight: typeof thumbHeight === 'number' ? thumbHeight : undefined,
1099
+ });
1100
+ } catch (e) {
1101
+ lns.push([
1102
+ `IIIF: failed to render for ${id || "<unknown>"} — ${e.message}`,
1103
+ "red",
1104
+ ]);
1105
+ }
1106
+ logs[idx] = lns;
1107
+ tryFlush();
1108
+ }
1109
+ }
1110
+ const workers = Array.from(
1111
+ { length: Math.min(concurrency, chunk.length) },
1112
+ () => worker()
1113
+ );
1114
+ await Promise.all(workers);
1115
+ }
1116
+ return { searchRecords };
1117
+ }
1118
+
1119
+ module.exports = {
1120
+ buildIiifCollectionPages,
1121
+ loadConfig,
1122
+ // Expose for other build steps that need to annotate cache metadata
1123
+ loadManifestIndex,
1124
+ saveManifestIndex,
1125
+ };
1126
+ // Debug: list collections cache after traversal
1127
+ try {
1128
+ if (process.env.CANOPY_IIIF_DEBUG === "1") {
1129
+ const { logLine } = require("./log");
1130
+ try {
1131
+ const files = fs.existsSync(IIIF_CACHE_COLLECTIONS_DIR)
1132
+ ? fs
1133
+ .readdirSync(IIIF_CACHE_COLLECTIONS_DIR)
1134
+ .filter((n) => /\.json$/i.test(n))
1135
+ : [];
1136
+ const head = files.slice(0, 8).join(", ");
1137
+ logLine(
1138
+ `IIIF: cache/collections (end): ${files.length} file(s)` +
1139
+ (head ? ` [${head}${files.length > 8 ? ", …" : ""}]` : ""),
1140
+ "blue",
1141
+ { dim: true }
1142
+ );
1143
+ } catch (_) {}
1144
+ }
1145
+ } catch (_) {}