@canopy-iiif/app 1.4.17 → 1.5.1

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.
@@ -29,6 +29,35 @@ const bibliography = require("../components/bibliography");
29
29
  let iiifRecordsCache = [];
30
30
  let pageRecords = [];
31
31
 
32
+ function nowMs() {
33
+ try {
34
+ return Number(process.hrtime.bigint()) / 1e6;
35
+ } catch (_) {
36
+ return Date.now();
37
+ }
38
+ }
39
+
40
+ function formatDuration(ms) {
41
+ if (!Number.isFinite(ms) || ms < 0) return '0ms';
42
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
43
+ return `${Math.round(ms)}ms`;
44
+ }
45
+
46
+ async function timeStage(label, store, fn) {
47
+ const start = nowMs();
48
+ try {
49
+ return await fn();
50
+ } finally {
51
+ const durationMs = nowMs() - start;
52
+ if (Array.isArray(store)) store.push({ label, durationMs });
53
+ try {
54
+ logLine(`⏱ ${label} completed in ${formatDuration(durationMs)}`, 'cyan', {
55
+ dim: true,
56
+ });
57
+ } catch (_) {}
58
+ }
59
+ }
60
+
32
61
  async function ensureNoJekyllMarker() {
33
62
  try {
34
63
  await fsp.mkdir(OUT_DIR, { recursive: true });
@@ -43,113 +72,126 @@ async function build(options = {}) {
43
72
  console.error("No content directory found at", CONTENT_DIR);
44
73
  process.exit(1);
45
74
  }
75
+ const stageTimings = [];
76
+ const buildStart = nowMs();
46
77
 
47
78
  /**
48
79
  * Clean and prepare output directory
49
80
  */
50
- logLine("\nClean and prepare directories", "magenta", {
51
- bright: true,
52
- underscore: true,
81
+ await timeStage("Clean and prepare directories", stageTimings, async () => {
82
+ logLine("\nClean and prepare directories", "magenta", {
83
+ bright: true,
84
+ underscore: true,
85
+ });
86
+ logLine("• Reset MDX cache", "blue", { dim: true });
87
+ mdx?.resetMdxCaches();
88
+ navigation?.resetNavigationCache?.();
89
+ referenced?.resetReferenceIndex?.();
90
+ bibliography?.resetBibliographyIndex?.();
91
+ if (!skipIiif) {
92
+ await cleanDir(OUT_DIR);
93
+ await ensureNoJekyllMarker();
94
+ logLine(`• Cleaned output directory`, "blue", { dim: true });
95
+ } else {
96
+ logLine("• Retaining cache (skipping IIIF rebuild)", "blue", { dim: true });
97
+ }
98
+ if (skipIiif) {
99
+ await ensureNoJekyllMarker();
100
+ }
53
101
  });
54
- logLine("• Reset MDX cache", "blue", { dim: true });
55
- mdx?.resetMdxCaches();
56
- navigation?.resetNavigationCache?.();
57
- referenced?.resetReferenceIndex?.();
58
- bibliography?.resetBibliographyIndex?.();
59
- if (!skipIiif) {
60
- await cleanDir(OUT_DIR);
61
- await ensureNoJekyllMarker();
62
- logLine(`• Cleaned output directory`, "blue", { dim: true });
63
- } else {
64
- logLine("• Retaining cache (skipping IIIF rebuild)", "blue", { dim: true });
65
- }
66
- if (skipIiif) {
67
- await ensureNoJekyllMarker();
68
- }
69
102
 
70
103
  /**
71
104
  * Build IIIF Collection content from configured source(s).
72
105
  * This includes building IIIF manifests for works and collections,
73
106
  * as well as collecting search records for works.
74
107
  */
75
- logLine("\nBuild IIIF Collection content", "magenta", {
76
- bright: true,
77
- underscore: true,
78
- });
79
108
  let iiifRecords = [];
80
- const CONFIG = await iiif.loadConfig();
81
- if (!skipIiif) {
82
- const results = await iiif.buildIiifCollectionPages(CONFIG);
83
- iiifRecords = results?.iiifRecords;
84
- iiifRecordsCache = Array.isArray(iiifRecords) ? iiifRecords : [];
85
- } else {
86
- iiifRecords = Array.isArray(iiifRecordsCache) ? iiifRecordsCache : [];
87
- logLine(
88
- `• Reusing cached IIIF search records (${iiifRecords.length})`,
89
- "blue",
90
- { dim: true }
91
- );
92
- }
93
- // Ensure any configured featured manifests are cached (and thumbnails computed)
94
- // so SSR interstitials can resolve items even if they are not part of
95
- // the traversed collection or when IIIF build is skipped during incremental rebuilds.
96
- try { await iiif.ensureFeaturedInCache(CONFIG); } catch (_) {}
97
- try { await iiif.rebuildManifestIndexFromCache(); } catch (_) {}
109
+ let CONFIG;
110
+ await timeStage("Build IIIF Collection content", stageTimings, async () => {
111
+ logLine("\nBuild IIIF Collection content", "magenta", {
112
+ bright: true,
113
+ underscore: true,
114
+ });
115
+ CONFIG = await iiif.loadConfig();
116
+ if (!skipIiif) {
117
+ const results = await iiif.buildIiifCollectionPages(CONFIG);
118
+ iiifRecords = results?.iiifRecords;
119
+ iiifRecordsCache = Array.isArray(iiifRecords) ? iiifRecords : [];
120
+ } else {
121
+ iiifRecords = Array.isArray(iiifRecordsCache) ? iiifRecordsCache : [];
122
+ logLine(
123
+ `• Reusing cached IIIF search records (${iiifRecords.length})`,
124
+ "blue",
125
+ { dim: true }
126
+ );
127
+ }
128
+ // Ensure any configured featured manifests are cached (and thumbnails computed)
129
+ // so SSR interstitials can resolve items even if they are not part of
130
+ // the traversed collection or when IIIF build is skipped during incremental rebuilds.
131
+ try { await iiif.ensureFeaturedInCache(CONFIG); } catch (_) {}
132
+ try { await iiif.rebuildManifestIndexFromCache(); } catch (_) {}
133
+ });
98
134
 
99
135
  /**
100
136
  * Build contextual MDX content from the content directory.
101
137
  * This includes collecting page metadata for sitemap and search index,
102
138
  * as well as building all MDX pages to HTML.
103
139
  */
104
- logLine("\nBuild contextual content from Markdown pages", "magenta", {
105
- bright: true,
106
- underscore: true,
140
+ await timeStage("Build contextual content from Markdown pages", stageTimings, async () => {
141
+ logLine("\nBuild contextual content from Markdown pages", "magenta", {
142
+ bright: true,
143
+ underscore: true,
144
+ });
145
+ // Interstitials read directly from the local IIIF cache; no API file needed
146
+ try {
147
+ bibliography?.buildBibliographyIndexSync?.();
148
+ } catch (err) {
149
+ logLine(
150
+ "• Failed to build bibliography index: " + String(err && err.message ? err.message : err),
151
+ "red",
152
+ { dim: true }
153
+ );
154
+ }
155
+ pageRecords = await searchBuild.collectMdxPageRecords();
156
+ await pages.buildContentTree(CONTENT_DIR, pageRecords);
157
+ logLine("✓ MDX pages built", "green");
107
158
  });
108
- // Interstitials read directly from the local IIIF cache; no API file needed
109
- try {
110
- bibliography?.buildBibliographyIndexSync?.();
111
- } catch (err) {
112
- logLine(
113
- "• Failed to build bibliography index: " + String(err && err.message ? err.message : err),
114
- "red",
115
- { dim: true }
116
- );
117
- }
118
- pageRecords = await searchBuild.collectMdxPageRecords();
119
- await pages.buildContentTree(CONTENT_DIR, pageRecords);
120
- logLine("✓ MDX pages built", "green");
121
159
 
122
160
  /**
123
161
  * Build search index from IIIF and MDX records, then build or update
124
162
  * the search.html page and search runtime bundle.
125
163
  * This is done after all content is built so that the index is comprehensive.
126
164
  */
127
- logLine("\nCreate search indices", "magenta", {
128
- bright: true,
129
- underscore: true,
165
+ await timeStage("Create search indices", stageTimings, async () => {
166
+ logLine("\nCreate search indices", "magenta", {
167
+ bright: true,
168
+ underscore: true,
169
+ });
170
+ try {
171
+ await ensureSearchInitialized();
172
+ const combined = await buildSearchIndex(iiifRecords, pageRecords);
173
+ await finalizeSearch(combined);
174
+ } catch (e) {
175
+ logLine("✗ Search index creation failed", "red", { bright: true });
176
+ logLine(" " + String(e), "red");
177
+ }
130
178
  });
131
- try {
132
- await ensureSearchInitialized();
133
- const combined = await buildSearchIndex(iiifRecords, pageRecords);
134
- await finalizeSearch(combined);
135
- } catch (e) {
136
- logLine("✗ Search index creation failed", "red", { bright: true });
137
- logLine(" " + String(e), "red");
138
- }
139
179
 
140
180
  /**
141
181
  * Generate a sitemap so static hosts can discover every rendered page.
142
182
  */
143
- logLine("\nWrite sitemap", "magenta", {
144
- bright: true,
145
- underscore: true,
183
+ await timeStage("Write sitemap", stageTimings, async () => {
184
+ logLine("\nWrite sitemap", "magenta", {
185
+ bright: true,
186
+ underscore: true,
187
+ });
188
+ try {
189
+ await writeSitemap(iiifRecords, pageRecords);
190
+ } catch (e) {
191
+ logLine("✗ Failed to write sitemap.xml", "red", { bright: true });
192
+ logLine(" " + String(e), "red");
193
+ }
146
194
  });
147
- try {
148
- await writeSitemap(iiifRecords, pageRecords);
149
- } catch (e) {
150
- logLine("✗ Failed to write sitemap.xml", "red", { bright: true });
151
- logLine(" " + String(e), "red");
152
- }
153
195
 
154
196
  // No-op: Featured API file no longer written (SSR reads from cache directly)
155
197
 
@@ -157,24 +199,41 @@ async function build(options = {}) {
157
199
  * Prepare client runtimes (e.g. search) by bundling with esbuild.
158
200
  * This is done early so that MDX content can reference runtime assets if needed.
159
201
  */
160
- logLine("\nPrepare client runtimes and stylesheets", "magenta", {
161
- bright: true,
162
- underscore: true,
202
+ await timeStage("Prepare client runtimes and stylesheets", stageTimings, async () => {
203
+ logLine("\nPrepare client runtimes and stylesheets", "magenta", {
204
+ bright: true,
205
+ underscore: true,
206
+ });
207
+ const tasks = [];
208
+ if (!process.env.CANOPY_SKIP_STYLES) {
209
+ tasks.push(
210
+ (async () => {
211
+ await ensureStyles();
212
+ logLine("✓ Wrote styles.css", "cyan");
213
+ })()
214
+ );
215
+ }
216
+ tasks.push(runtimes.prepareAllRuntimes());
217
+ await Promise.all(tasks);
163
218
  });
164
- if (!process.env.CANOPY_SKIP_STYLES) {
165
- await ensureStyles();
166
- logLine("✓ Wrote styles.css", "cyan");
167
- }
168
- await runtimes.prepareAllRuntimes();
169
219
 
170
220
  /**
171
221
  * Copy static assets from the assets directory to the output directory.
172
222
  */
173
- logLine("\nCopy static assets", "magenta", {
174
- bright: true,
175
- underscore: true,
223
+ await timeStage("Copy static assets", stageTimings, async () => {
224
+ logLine("\nCopy static assets", "magenta", {
225
+ bright: true,
226
+ underscore: true,
227
+ });
228
+ await copyAssets();
176
229
  });
177
- await copyAssets();
230
+
231
+ const totalMs = nowMs() - buildStart;
232
+ logLine("\nBuild phase timings", "magenta", { bright: true, underscore: true });
233
+ for (const { label, durationMs } of stageTimings) {
234
+ logLine(`• ${label}: ${formatDuration(durationMs)}`, "blue", { dim: true });
235
+ }
236
+ logLine(`Total build time: ${formatDuration(totalMs)}`, "green", { bright: true });
178
237
 
179
238
  }
180
239
 
package/lib/build/iiif.js CHANGED
@@ -47,8 +47,8 @@ const IIIF_CACHE_INDEX_MANIFESTS = path.join(
47
47
  );
48
48
 
49
49
  const DEFAULT_THUMBNAIL_SIZE = 400;
50
- const DEFAULT_CHUNK_SIZE = 20;
51
- const DEFAULT_FETCH_CONCURRENCY = 5;
50
+ const DEFAULT_CHUNK_SIZE = 10;
51
+ const DEFAULT_FETCH_CONCURRENCY = 1;
52
52
  const HERO_THUMBNAIL_SIZE = 800;
53
53
  const HERO_IMAGE_SIZES_ATTR = "(min-width: 1024px) 1280px, 100vw";
54
54
  const OG_IMAGE_WIDTH = 1200;
@@ -56,10 +56,22 @@ const OG_IMAGE_HEIGHT = 630;
56
56
  const HERO_REPRESENTATIVE_SIZE = Math.max(HERO_THUMBNAIL_SIZE, OG_IMAGE_WIDTH);
57
57
  const MAX_ENTRY_SLUG_LENGTH = 50;
58
58
 
59
- function resolvePositiveInteger(value, fallback) {
59
+ function resolvePositiveInteger(value, fallback, options = {}) {
60
+ const allowZero = Boolean(options && options.allowZero);
60
61
  const num = Number(value);
61
- if (Number.isFinite(num) && num > 0) return Math.max(1, Math.floor(num));
62
- return Math.max(1, Math.floor(fallback));
62
+ if (Number.isFinite(num)) {
63
+ if (allowZero && num === 0) return 0;
64
+ if (num > 0) return Math.max(1, Math.floor(num));
65
+ }
66
+ const normalizedFallback = Number(fallback);
67
+ if (allowZero && normalizedFallback === 0) return 0;
68
+ return Math.max(1, Math.floor(normalizedFallback));
69
+ }
70
+
71
+ function formatDurationMs(ms) {
72
+ if (!Number.isFinite(ms) || ms < 0) return '0ms';
73
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
74
+ return `${Math.round(ms)}ms`;
63
75
  }
64
76
 
65
77
  function resolveBoolean(value) {
@@ -699,6 +711,15 @@ const MEMO_ID_TO_SLUG = new Map();
699
711
  // collections/manifests share the same base title but mappings aren't yet saved.
700
712
  const RESERVED_SLUGS = {Manifest: new Set(), Collection: new Set()};
701
713
 
714
+ function resetReservedSlugs() {
715
+ try {
716
+ Object.keys(RESERVED_SLUGS).forEach((key) => {
717
+ const set = RESERVED_SLUGS[key];
718
+ if (set && typeof set.clear === "function") set.clear();
719
+ });
720
+ } catch (_) {}
721
+ }
722
+
702
723
  function computeUniqueSlug(index, baseSlug, id, type) {
703
724
  const byId = Array.isArray(index && index.byId) ? index.byId : [];
704
725
  const normId = normalizeIiifId(String(id || ""));
@@ -1411,6 +1432,11 @@ async function buildIiifCollectionPages(CONFIG) {
1411
1432
  DEFAULT_CHUNK_SIZE
1412
1433
  );
1413
1434
  const chunks = Math.ceil(tasks.length / chunkSize);
1435
+ const requestedConcurrency = resolvePositiveInteger(
1436
+ process.env.CANOPY_FETCH_CONCURRENCY,
1437
+ DEFAULT_FETCH_CONCURRENCY,
1438
+ {allowZero: true}
1439
+ );
1414
1440
  // Summary before processing chunks
1415
1441
  try {
1416
1442
  const collectionsCount = visitedCollections.size || 0;
@@ -1419,6 +1445,11 @@ async function buildIiifCollectionPages(CONFIG) {
1419
1445
  "blue",
1420
1446
  {dim: true}
1421
1447
  );
1448
+ const concurrencySummary =
1449
+ requestedConcurrency === 0
1450
+ ? "auto (no explicit cap)"
1451
+ : String(requestedConcurrency);
1452
+ logLine(`• Fetch concurrency: ${concurrencySummary}`, "blue", { dim: true });
1422
1453
  } catch (_) {}
1423
1454
  const iiifRecords = [];
1424
1455
  const navPlaceRecords = [];
@@ -1441,14 +1472,14 @@ async function buildIiifCollectionPages(CONFIG) {
1441
1472
 
1442
1473
  referenced.ensureReferenceIndex();
1443
1474
 
1475
+ const chunkMetrics = [];
1444
1476
  for (let ci = 0; ci < chunks; ci++) {
1445
1477
  const chunk = tasks.slice(ci * chunkSize, (ci + 1) * chunkSize);
1446
1478
  logLine(`• Chunk ${ci + 1}/${chunks}`, "blue", {dim: true});
1479
+ const chunkStart = Date.now();
1447
1480
 
1448
- const concurrency = resolvePositiveInteger(
1449
- process.env.CANOPY_FETCH_CONCURRENCY,
1450
- DEFAULT_FETCH_CONCURRENCY
1451
- );
1481
+ const concurrency =
1482
+ requestedConcurrency === 0 ? Math.max(1, chunk.length) : requestedConcurrency;
1452
1483
  let next = 0;
1453
1484
  const logs = new Array(chunk.length);
1454
1485
  let nextPrint = 0;
@@ -2102,6 +2133,42 @@ async function buildIiifCollectionPages(CONFIG) {
2102
2133
  () => worker()
2103
2134
  );
2104
2135
  await Promise.all(workers);
2136
+ tryFlush();
2137
+ const chunkDuration = Date.now() - chunkStart;
2138
+ chunkMetrics.push({
2139
+ index: ci + 1,
2140
+ count: chunk.length,
2141
+ durationMs: chunkDuration,
2142
+ concurrency,
2143
+ });
2144
+ try {
2145
+ const concurrencyLabel =
2146
+ requestedConcurrency === 0 ? `${concurrency} (auto)` : String(concurrency);
2147
+ logLine(
2148
+ `⏱ Chunk ${ci + 1}/${chunks}: processed ${chunk.length} Manifest(s) in ${formatDurationMs(chunkDuration)} (concurrency ${concurrencyLabel})`,
2149
+ "cyan",
2150
+ { dim: true }
2151
+ );
2152
+ } catch (_) {}
2153
+ }
2154
+ if (chunkMetrics.length) {
2155
+ const totalDuration = chunkMetrics.reduce(
2156
+ (sum, entry) => sum + (entry.durationMs || 0),
2157
+ 0
2158
+ );
2159
+ const totalItems = chunkMetrics.reduce((sum, entry) => sum + (entry.count || 0), 0);
2160
+ const avgDuration = chunkMetrics.length
2161
+ ? totalDuration / chunkMetrics.length
2162
+ : 0;
2163
+ const rate = totalDuration > 0 ? totalItems / (totalDuration / 1000) : 0;
2164
+ try {
2165
+ const rateLabel = rate ? `${rate.toFixed(1)} manifest(s)/s` : 'n/a';
2166
+ logLine(
2167
+ `IIIF chunk summary: ${totalItems} Manifest(s) in ${formatDurationMs(totalDuration)} (avg chunk ${formatDurationMs(avgDuration)}, ${rateLabel})`,
2168
+ "cyan",
2169
+ { dim: true }
2170
+ );
2171
+ } catch (_) {}
2105
2172
  }
2106
2173
  try {
2107
2174
  await navPlace.writeNavPlaceDataset(navPlaceRecords);
@@ -2128,6 +2195,38 @@ module.exports = {
2128
2195
  rebuildManifestIndexFromCache,
2129
2196
  };
2130
2197
 
2198
+ // Expose a stable set of pure helper utilities for unit testing.
2199
+ module.exports.__TESTING__ = {
2200
+ resolvePositiveInteger,
2201
+ formatDurationMs,
2202
+ resolveBoolean,
2203
+ normalizeCollectionUris,
2204
+ clampSlugLength,
2205
+ isSlugTooLong,
2206
+ normalizeSlugBase,
2207
+ buildSlugWithSuffix,
2208
+ normalizeStringList,
2209
+ ensureThumbnailValue,
2210
+ extractSummaryValues,
2211
+ truncateSummary,
2212
+ extractMetadataValues,
2213
+ extractAnnotationText,
2214
+ normalizeIiifId,
2215
+ normalizeIiifType,
2216
+ resolveParentFromPartOf,
2217
+ computeUniqueSlug,
2218
+ ensureBaseSlugFor,
2219
+ resetReservedSlugs,
2220
+ resolveThumbnailPreferences,
2221
+ loadManifestIndex,
2222
+ saveManifestIndex,
2223
+ paths: {
2224
+ IIIF_CACHE_INDEX,
2225
+ IIIF_CACHE_INDEX_LEGACY,
2226
+ IIIF_CACHE_INDEX_MANIFESTS,
2227
+ },
2228
+ };
2229
+
2131
2230
  // Debug: list collections cache after traversal
2132
2231
  try {
2133
2232
  if (process.env.CANOPY_IIIF_DEBUG === "1") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "1.4.17",
3
+ "version": "1.5.1",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",