@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.
- package/lib/build/build.js +147 -88
- package/lib/build/iiif.js +67 -9
- package/package.json +1 -1
package/lib/build/build.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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 =
|
|
51
|
-
const DEFAULT_FETCH_CONCURRENCY =
|
|
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)
|
|
62
|
-
|
|
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 =
|
|
1449
|
-
|
|
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);
|