@canopy-iiif/app 1.4.17 → 1.5.0

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) {
@@ -1411,6 +1423,11 @@ async function buildIiifCollectionPages(CONFIG) {
1411
1423
  DEFAULT_CHUNK_SIZE
1412
1424
  );
1413
1425
  const chunks = Math.ceil(tasks.length / chunkSize);
1426
+ const requestedConcurrency = resolvePositiveInteger(
1427
+ process.env.CANOPY_FETCH_CONCURRENCY,
1428
+ DEFAULT_FETCH_CONCURRENCY,
1429
+ {allowZero: true}
1430
+ );
1414
1431
  // Summary before processing chunks
1415
1432
  try {
1416
1433
  const collectionsCount = visitedCollections.size || 0;
@@ -1419,6 +1436,11 @@ async function buildIiifCollectionPages(CONFIG) {
1419
1436
  "blue",
1420
1437
  {dim: true}
1421
1438
  );
1439
+ const concurrencySummary =
1440
+ requestedConcurrency === 0
1441
+ ? "auto (no explicit cap)"
1442
+ : String(requestedConcurrency);
1443
+ logLine(`• Fetch concurrency: ${concurrencySummary}`, "blue", { dim: true });
1422
1444
  } catch (_) {}
1423
1445
  const iiifRecords = [];
1424
1446
  const navPlaceRecords = [];
@@ -1441,14 +1463,14 @@ async function buildIiifCollectionPages(CONFIG) {
1441
1463
 
1442
1464
  referenced.ensureReferenceIndex();
1443
1465
 
1466
+ const chunkMetrics = [];
1444
1467
  for (let ci = 0; ci < chunks; ci++) {
1445
1468
  const chunk = tasks.slice(ci * chunkSize, (ci + 1) * chunkSize);
1446
1469
  logLine(`• Chunk ${ci + 1}/${chunks}`, "blue", {dim: true});
1470
+ const chunkStart = Date.now();
1447
1471
 
1448
- const concurrency = resolvePositiveInteger(
1449
- process.env.CANOPY_FETCH_CONCURRENCY,
1450
- DEFAULT_FETCH_CONCURRENCY
1451
- );
1472
+ const concurrency =
1473
+ requestedConcurrency === 0 ? Math.max(1, chunk.length) : requestedConcurrency;
1452
1474
  let next = 0;
1453
1475
  const logs = new Array(chunk.length);
1454
1476
  let nextPrint = 0;
@@ -2102,6 +2124,42 @@ async function buildIiifCollectionPages(CONFIG) {
2102
2124
  () => worker()
2103
2125
  );
2104
2126
  await Promise.all(workers);
2127
+ tryFlush();
2128
+ const chunkDuration = Date.now() - chunkStart;
2129
+ chunkMetrics.push({
2130
+ index: ci + 1,
2131
+ count: chunk.length,
2132
+ durationMs: chunkDuration,
2133
+ concurrency,
2134
+ });
2135
+ try {
2136
+ const concurrencyLabel =
2137
+ requestedConcurrency === 0 ? `${concurrency} (auto)` : String(concurrency);
2138
+ logLine(
2139
+ `⏱ Chunk ${ci + 1}/${chunks}: processed ${chunk.length} Manifest(s) in ${formatDurationMs(chunkDuration)} (concurrency ${concurrencyLabel})`,
2140
+ "cyan",
2141
+ { dim: true }
2142
+ );
2143
+ } catch (_) {}
2144
+ }
2145
+ if (chunkMetrics.length) {
2146
+ const totalDuration = chunkMetrics.reduce(
2147
+ (sum, entry) => sum + (entry.durationMs || 0),
2148
+ 0
2149
+ );
2150
+ const totalItems = chunkMetrics.reduce((sum, entry) => sum + (entry.count || 0), 0);
2151
+ const avgDuration = chunkMetrics.length
2152
+ ? totalDuration / chunkMetrics.length
2153
+ : 0;
2154
+ const rate = totalDuration > 0 ? totalItems / (totalDuration / 1000) : 0;
2155
+ try {
2156
+ const rateLabel = rate ? `${rate.toFixed(1)} manifest(s)/s` : 'n/a';
2157
+ logLine(
2158
+ `IIIF chunk summary: ${totalItems} Manifest(s) in ${formatDurationMs(totalDuration)} (avg chunk ${formatDurationMs(avgDuration)}, ${rateLabel})`,
2159
+ "cyan",
2160
+ { dim: true }
2161
+ );
2162
+ } catch (_) {}
2105
2163
  }
2106
2164
  try {
2107
2165
  await navPlace.writeNavPlaceDataset(navPlaceRecords);
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.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",