@canopy-iiif/app 0.10.11 → 0.10.12
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 +110 -37
- package/lib/build/search.js +29 -4
- package/package.json +1 -1
package/lib/build/iiif.js
CHANGED
|
@@ -50,6 +50,7 @@ const HERO_IMAGE_SIZES_ATTR = "(min-width: 1024px) 1280px, 100vw";
|
|
|
50
50
|
const OG_IMAGE_WIDTH = 1200;
|
|
51
51
|
const OG_IMAGE_HEIGHT = 630;
|
|
52
52
|
const HERO_REPRESENTATIVE_SIZE = Math.max(HERO_THUMBNAIL_SIZE, OG_IMAGE_WIDTH);
|
|
53
|
+
const MAX_ENTRY_SLUG_LENGTH = 50;
|
|
53
54
|
|
|
54
55
|
function resolvePositiveInteger(value, fallback) {
|
|
55
56
|
const num = Number(value);
|
|
@@ -65,6 +66,64 @@ function resolveBoolean(value) {
|
|
|
65
66
|
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function normalizeCollectionUris(value) {
|
|
70
|
+
if (value === undefined || value === null) return [];
|
|
71
|
+
const rawValues = Array.isArray(value) ? value : [value];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const uris = [];
|
|
74
|
+
for (const entry of rawValues) {
|
|
75
|
+
if (typeof entry !== "string") continue;
|
|
76
|
+
const trimmed = entry.trim();
|
|
77
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
78
|
+
seen.add(trimmed);
|
|
79
|
+
uris.push(trimmed);
|
|
80
|
+
}
|
|
81
|
+
return uris;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clampSlugLength(slug, limit = MAX_ENTRY_SLUG_LENGTH) {
|
|
85
|
+
if (!slug) return "";
|
|
86
|
+
const max = Math.max(1, limit);
|
|
87
|
+
if (slug.length <= max) return slug;
|
|
88
|
+
const slice = slug.slice(0, max);
|
|
89
|
+
const trimmed = slice.replace(/-+$/g, "");
|
|
90
|
+
return trimmed || slice || slug.slice(0, 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isSlugTooLong(value) {
|
|
94
|
+
return typeof value === "string" && value.length > MAX_ENTRY_SLUG_LENGTH;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeSlugBase(value, fallback) {
|
|
98
|
+
const safeFallback = fallback || "entry";
|
|
99
|
+
const base = typeof value === "string" ? value : String(value || "");
|
|
100
|
+
const clamped = clampSlugLength(base, MAX_ENTRY_SLUG_LENGTH);
|
|
101
|
+
if (clamped) return clamped;
|
|
102
|
+
return clampSlugLength(safeFallback, MAX_ENTRY_SLUG_LENGTH) || safeFallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildSlugWithSuffix(base, fallback, counter) {
|
|
106
|
+
const suffix = `-${counter}`;
|
|
107
|
+
const baseLimit = Math.max(1, MAX_ENTRY_SLUG_LENGTH - suffix.length);
|
|
108
|
+
const trimmedBase =
|
|
109
|
+
clampSlugLength(base, baseLimit) ||
|
|
110
|
+
clampSlugLength(fallback, baseLimit) ||
|
|
111
|
+
fallback.slice(0, baseLimit);
|
|
112
|
+
return `${trimmedBase}${suffix}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeStringList(value) {
|
|
116
|
+
if (value === undefined || value === null) return [];
|
|
117
|
+
const rawValues = Array.isArray(value) ? value : [value];
|
|
118
|
+
return rawValues
|
|
119
|
+
.map((entry) => {
|
|
120
|
+
if (typeof entry === "string") return entry.trim();
|
|
121
|
+
if (entry === undefined || entry === null) return "";
|
|
122
|
+
return String(entry).trim();
|
|
123
|
+
})
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
|
|
68
127
|
function resolveThumbnailPreferences() {
|
|
69
128
|
return {
|
|
70
129
|
size: resolvePositiveInteger(
|
|
@@ -537,13 +596,15 @@ const RESERVED_SLUGS = {Manifest: new Set(), Collection: new Set()};
|
|
|
537
596
|
function computeUniqueSlug(index, baseSlug, id, type) {
|
|
538
597
|
const byId = Array.isArray(index && index.byId) ? index.byId : [];
|
|
539
598
|
const normId = normalizeIiifId(String(id || ""));
|
|
599
|
+
const fallbackBase = type === "Manifest" ? "untitled" : "collection";
|
|
600
|
+
const normalizedBase = normalizeSlugBase(baseSlug || fallbackBase, fallbackBase);
|
|
540
601
|
const used = new Set(
|
|
541
602
|
byId
|
|
542
603
|
.filter((e) => e && e.slug && e.type === type)
|
|
543
604
|
.map((e) => String(e.slug))
|
|
544
605
|
);
|
|
545
606
|
const reserved = RESERVED_SLUGS[type] || new Set();
|
|
546
|
-
let slug =
|
|
607
|
+
let slug = normalizedBase;
|
|
547
608
|
let i = 1;
|
|
548
609
|
for (;;) {
|
|
549
610
|
const existing = byId.find(
|
|
@@ -560,7 +621,7 @@ function computeUniqueSlug(index, baseSlug, id, type) {
|
|
|
560
621
|
reserved.add(slug);
|
|
561
622
|
return slug;
|
|
562
623
|
}
|
|
563
|
-
slug =
|
|
624
|
+
slug = buildSlugWithSuffix(normalizedBase, fallbackBase, i++);
|
|
564
625
|
}
|
|
565
626
|
}
|
|
566
627
|
|
|
@@ -568,18 +629,20 @@ function ensureBaseSlugFor(index, baseSlug, id, type) {
|
|
|
568
629
|
try {
|
|
569
630
|
const byId = Array.isArray(index && index.byId) ? index.byId : [];
|
|
570
631
|
const normId = normalizeIiifId(String(id || ""));
|
|
632
|
+
const fallbackBase = type === "Manifest" ? "untitled" : "collection";
|
|
633
|
+
const normalizedBase = normalizeSlugBase(baseSlug || fallbackBase, fallbackBase);
|
|
571
634
|
const existingWithBase = byId.find(
|
|
572
|
-
(e) => e && e.type === type && String(e.slug) === String(
|
|
635
|
+
(e) => e && e.type === type && String(e.slug) === String(normalizedBase)
|
|
573
636
|
);
|
|
574
637
|
if (existingWithBase && normalizeIiifId(existingWithBase.id) !== normId) {
|
|
575
638
|
// Reassign the existing entry to the next available suffix to free the base
|
|
576
639
|
const newSlug = computeUniqueSlug(
|
|
577
640
|
index,
|
|
578
|
-
|
|
641
|
+
normalizedBase,
|
|
579
642
|
existingWithBase.id,
|
|
580
643
|
type
|
|
581
644
|
);
|
|
582
|
-
if (newSlug && newSlug !==
|
|
645
|
+
if (newSlug && newSlug !== normalizedBase) existingWithBase.slug = newSlug;
|
|
583
646
|
}
|
|
584
647
|
} catch (_) {}
|
|
585
648
|
return baseSlug;
|
|
@@ -620,13 +683,15 @@ async function loadCachedManifestById(id) {
|
|
|
620
683
|
);
|
|
621
684
|
slug = entry && entry.slug;
|
|
622
685
|
}
|
|
686
|
+
if (isSlugTooLong(slug)) slug = null;
|
|
623
687
|
if (!slug) {
|
|
624
688
|
// Try an on-disk scan to recover mapping if index is missing/out-of-sync
|
|
625
689
|
const memo = MEMO_ID_TO_SLUG.get(String(id));
|
|
626
690
|
if (memo) slug = memo;
|
|
691
|
+
if (isSlugTooLong(slug)) slug = null;
|
|
627
692
|
if (!slug) {
|
|
628
693
|
const found = await findSlugByIdFromDisk(id);
|
|
629
|
-
if (found) {
|
|
694
|
+
if (found && !isSlugTooLong(found)) {
|
|
630
695
|
slug = found;
|
|
631
696
|
MEMO_ID_TO_SLUG.set(String(id), slug);
|
|
632
697
|
try {
|
|
@@ -830,6 +895,7 @@ async function loadCachedCollectionById(id) {
|
|
|
830
895
|
);
|
|
831
896
|
slug = entry && entry.slug;
|
|
832
897
|
}
|
|
898
|
+
if (isSlugTooLong(slug)) slug = null;
|
|
833
899
|
if (!slug) {
|
|
834
900
|
// Scan collections dir if mapping missing
|
|
835
901
|
try {
|
|
@@ -845,7 +911,12 @@ async function loadCachedCollectionById(id) {
|
|
|
845
911
|
String((obj && (obj.id || obj["@id"])) || "")
|
|
846
912
|
);
|
|
847
913
|
if (cid && cid === normalizeIiifId(String(id))) {
|
|
848
|
-
|
|
914
|
+
const candidate = name.replace(/\.json$/i, "");
|
|
915
|
+
if (isSlugTooLong(candidate)) {
|
|
916
|
+
slug = null;
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
slug = candidate;
|
|
849
920
|
// heal mapping
|
|
850
921
|
try {
|
|
851
922
|
index.byId = Array.isArray(index.byId) ? index.byId : [];
|
|
@@ -1052,9 +1123,13 @@ async function loadConfig() {
|
|
|
1052
1123
|
async function buildIiifCollectionPages(CONFIG) {
|
|
1053
1124
|
const cfg = CONFIG || (await loadConfig());
|
|
1054
1125
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1126
|
+
let collectionUris = normalizeCollectionUris(cfg && cfg.collection);
|
|
1127
|
+
if (!collectionUris.length) {
|
|
1128
|
+
collectionUris = normalizeCollectionUris(
|
|
1129
|
+
process.env.CANOPY_COLLECTION_URI || ""
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
if (!collectionUris.length) return {searchRecords: []};
|
|
1058
1133
|
|
|
1059
1134
|
const searchIndexCfg = (cfg && cfg.search && cfg.search.index) || {};
|
|
1060
1135
|
const metadataCfg = (searchIndexCfg && searchIndexCfg.metadata) || {};
|
|
@@ -1093,38 +1168,15 @@ async function buildIiifCollectionPages(CONFIG) {
|
|
|
1093
1168
|
enabled: summaryEnabled,
|
|
1094
1169
|
};
|
|
1095
1170
|
const annotationMotivations = new Set(
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
String(m || "")
|
|
1100
|
-
.trim()
|
|
1101
|
-
.toLowerCase()
|
|
1102
|
-
)
|
|
1103
|
-
.filter(Boolean)
|
|
1104
|
-
: []
|
|
1171
|
+
normalizeStringList(annotationsCfg && annotationsCfg.motivation).map((m) =>
|
|
1172
|
+
m.toLowerCase()
|
|
1173
|
+
)
|
|
1105
1174
|
);
|
|
1106
1175
|
const annotationsOptions = {
|
|
1107
1176
|
enabled: annotationsEnabled,
|
|
1108
1177
|
motivations: annotationMotivations,
|
|
1109
1178
|
};
|
|
1110
1179
|
|
|
1111
|
-
// Fetch top-level collection
|
|
1112
|
-
logLine("• Traversing IIIF Collection(s)", "blue", {dim: true});
|
|
1113
|
-
const root = await readJsonFromUri(collectionUri, {log: true});
|
|
1114
|
-
if (!root) {
|
|
1115
|
-
logLine("IIIF: Failed to fetch collection", "red");
|
|
1116
|
-
return {searchRecords: []};
|
|
1117
|
-
}
|
|
1118
|
-
const normalizedRoot = await normalizeToV3(root);
|
|
1119
|
-
// Save collection cache
|
|
1120
|
-
try {
|
|
1121
|
-
await saveCachedCollection(
|
|
1122
|
-
normalizedRoot,
|
|
1123
|
-
normalizedRoot.id || collectionUri,
|
|
1124
|
-
""
|
|
1125
|
-
);
|
|
1126
|
-
} catch (_) {}
|
|
1127
|
-
|
|
1128
1180
|
// Recursively traverse Collections and gather all Manifest tasks
|
|
1129
1181
|
const tasks = [];
|
|
1130
1182
|
const visitedCollections = new Set(); // normalized ids
|
|
@@ -1179,7 +1231,27 @@ async function buildIiifCollectionPages(CONFIG) {
|
|
|
1179
1231
|
// Traverse strictly by parent/child hierarchy (Presentation 3): items → Manifest or Collection
|
|
1180
1232
|
} catch (_) {}
|
|
1181
1233
|
}
|
|
1182
|
-
|
|
1234
|
+
// Fetch each configured collection and queue manifests from all of them
|
|
1235
|
+
logLine("• Traversing IIIF Collection(s)", "blue", {dim: true});
|
|
1236
|
+
for (const uri of collectionUris) {
|
|
1237
|
+
let root = null;
|
|
1238
|
+
try {
|
|
1239
|
+
root = await readJsonFromUri(uri, {log: true});
|
|
1240
|
+
} catch (_) {
|
|
1241
|
+
root = null;
|
|
1242
|
+
}
|
|
1243
|
+
if (!root) {
|
|
1244
|
+
try {
|
|
1245
|
+
logLine(`IIIF: Failed to fetch collection → ${uri}`, "red");
|
|
1246
|
+
} catch (_) {}
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const normalizedRoot = await normalizeToV3(root);
|
|
1250
|
+
try {
|
|
1251
|
+
await saveCachedCollection(normalizedRoot, normalizedRoot.id || uri, "");
|
|
1252
|
+
} catch (_) {}
|
|
1253
|
+
await gatherFromCollection(normalizedRoot, "");
|
|
1254
|
+
}
|
|
1183
1255
|
if (!tasks.length) return {searchRecords: []};
|
|
1184
1256
|
|
|
1185
1257
|
// Split into chunks and process with limited concurrency
|
|
@@ -1322,6 +1394,7 @@ async function buildIiifCollectionPages(CONFIG) {
|
|
|
1322
1394
|
(e) => e && e.type === "Manifest" && normalizeIiifId(e.id) === nid
|
|
1323
1395
|
);
|
|
1324
1396
|
let slug = mEntry && mEntry.slug;
|
|
1397
|
+
if (isSlugTooLong(slug)) slug = null;
|
|
1325
1398
|
if (!slug) {
|
|
1326
1399
|
slug = computeUniqueSlug(idxMap, baseSlug, nid, "Manifest");
|
|
1327
1400
|
const parentNorm = normalizeIiifId(String(it.parent || ""));
|
package/lib/build/search.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
const { fs, fsp, path, OUT_DIR, ensureDirSync, absoluteUrl } = require('../common');
|
|
2
2
|
const slugify = require('slugify');
|
|
3
3
|
|
|
4
|
+
function normalizeMetadataLabel(label) {
|
|
5
|
+
if (typeof label !== 'string') return '';
|
|
6
|
+
return label
|
|
7
|
+
.trim()
|
|
8
|
+
.replace(/[:\s]+$/g, '')
|
|
9
|
+
.toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
function firstI18nString(x) {
|
|
5
13
|
if (!x) return '';
|
|
6
14
|
if (typeof x === 'string') return x;
|
|
@@ -17,7 +25,11 @@ async function buildFacetsForWorks(combined, labelWhitelist) {
|
|
|
17
25
|
const facetsDir = path.resolve('.cache/iiif');
|
|
18
26
|
ensureDirSync(facetsDir);
|
|
19
27
|
const map = new Map(); // label -> Map(value -> Set(docIdx))
|
|
20
|
-
const
|
|
28
|
+
const normalizedLabels = new Set(
|
|
29
|
+
(Array.isArray(labelWhitelist) ? labelWhitelist : [])
|
|
30
|
+
.map((label) => normalizeMetadataLabel(String(label || '')))
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
);
|
|
21
33
|
if (!Array.isArray(combined)) combined = [];
|
|
22
34
|
for (let i = 0; i < combined.length; i++) {
|
|
23
35
|
const rec = combined[i];
|
|
@@ -37,7 +49,12 @@ async function buildFacetsForWorks(combined, labelWhitelist) {
|
|
|
37
49
|
const label = firstI18nString(entry.label);
|
|
38
50
|
const valueRaw = entry.value && (typeof entry.value === 'string' ? entry.value : firstI18nString(entry.value));
|
|
39
51
|
if (!label || !valueRaw) continue;
|
|
40
|
-
if (
|
|
52
|
+
if (
|
|
53
|
+
normalizedLabels.size &&
|
|
54
|
+
!normalizedLabels.has(normalizeMetadataLabel(label))
|
|
55
|
+
) {
|
|
56
|
+
continue; // only configured labels
|
|
57
|
+
}
|
|
41
58
|
const values = [];
|
|
42
59
|
try {
|
|
43
60
|
if (typeof entry.value === 'string') values.push(entry.value);
|
|
@@ -79,11 +96,19 @@ async function writeFacetCollections(labelWhitelist, combined) {
|
|
|
79
96
|
if (!fs.existsSync(facetsPath)) return;
|
|
80
97
|
let facets = [];
|
|
81
98
|
try { facets = JSON.parse(fs.readFileSync(facetsPath, 'utf8')) || []; } catch (_) { facets = []; }
|
|
82
|
-
const
|
|
99
|
+
const normalizedLabels = new Set(
|
|
100
|
+
(Array.isArray(labelWhitelist) ? labelWhitelist : [])
|
|
101
|
+
.map((label) => normalizeMetadataLabel(String(label || '')))
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
);
|
|
83
104
|
const apiRoot = path.join(OUT_DIR, 'api');
|
|
84
105
|
const facetRoot = path.join(apiRoot, 'facet');
|
|
85
106
|
ensureDirSync(facetRoot);
|
|
86
|
-
const list = (Array.isArray(facets) ? facets : []).filter((f) =>
|
|
107
|
+
const list = (Array.isArray(facets) ? facets : []).filter((f) => {
|
|
108
|
+
if (!normalizedLabels.size) return true;
|
|
109
|
+
const normalized = normalizeMetadataLabel(String((f && f.label) || ''));
|
|
110
|
+
return normalized ? normalizedLabels.has(normalized) : false;
|
|
111
|
+
});
|
|
87
112
|
const labelIndexItems = [];
|
|
88
113
|
for (const f of list) {
|
|
89
114
|
if (!f || !f.label || !Array.isArray(f.values)) continue;
|