@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 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 = baseSlug || (type === "Manifest" ? "untitled" : "collection");
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 = `${baseSlug}-${i++}`;
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(baseSlug)
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
- baseSlug,
641
+ normalizedBase,
579
642
  existingWithBase.id,
580
643
  type
581
644
  );
582
- if (newSlug && newSlug !== baseSlug) existingWithBase.slug = 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
- slug = name.replace(/\.json$/i, "");
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
- const collectionUri =
1056
- (cfg && cfg.collection) || process.env.CANOPY_COLLECTION_URI || "";
1057
- if (!collectionUri) return {searchRecords: []};
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
- Array.isArray(annotationsCfg && annotationsCfg.motivation)
1097
- ? annotationsCfg.motivation
1098
- .map((m) =>
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
- await gatherFromCollection(normalizedRoot, "");
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 || ""));
@@ -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 labels = Array.isArray(labelWhitelist) ? labelWhitelist.map(String) : [];
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 (labels.length && !labels.includes(label)) continue; // only configured labels
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 labels = new Set((Array.isArray(labelWhitelist) ? labelWhitelist : []).map(String));
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) => !labels.size || labels.has(String(f && f.label)));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "0.10.11",
3
+ "version": "0.10.12",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",