@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/build.js +762 -0
- package/lib/common.js +124 -0
- package/lib/components/IIIFCard.js +102 -0
- package/lib/dev.js +721 -0
- package/lib/devtoast.config.json +6 -0
- package/lib/devtoast.css +14 -0
- package/lib/iiif.js +1145 -0
- package/lib/index.js +5 -0
- package/lib/log.js +64 -0
- package/lib/mdx.js +690 -0
- package/lib/runtime/command-entry.jsx +44 -0
- package/lib/search-app.jsx +273 -0
- package/lib/search.js +477 -0
- package/lib/thumbnail.js +87 -0
- package/package.json +50 -0
- package/ui/dist/index.mjs +692 -0
- package/ui/dist/index.mjs.map +7 -0
- package/ui/dist/server.mjs +344 -0
- package/ui/dist/server.mjs.map +7 -0
- package/ui/styles/components/_card.scss +69 -0
- package/ui/styles/components/_command.scss +80 -0
- package/ui/styles/components/index.scss +5 -0
- package/ui/styles/index.css +127 -0
- package/ui/styles/index.scss +3 -0
- package/ui/styles/variables.emit.scss +72 -0
- package/ui/styles/variables.scss +66 -0
- package/ui/tailwind-canopy-iiif-plugin.js +35 -0
- package/ui/tailwind-canopy-iiif-preset.js +105 -0
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 (_) {}
|