@canopy-iiif/app 0.6.28 → 0.7.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.js DELETED
@@ -1,762 +0,0 @@
1
- const {
2
- fs,
3
- fsp,
4
- path,
5
- CONTENT_DIR,
6
- OUT_DIR,
7
- ASSETS_DIR,
8
- ensureDirSync,
9
- cleanDir,
10
- htmlShell,
11
- } = require("./common");
12
- const mdx = require("./mdx");
13
- const slugify = require("slugify");
14
- const iiif = require("./iiif");
15
- const search = require("./search");
16
- const { log, logLine } = require("./log");
17
-
18
- // Cache IIIF search records between builds (dev mode) so MDX-only rebuilds
19
- // can skip re-fetching IIIF while still keeping search results for works.
20
- let IIIF_RECORDS_CACHE = [];
21
-
22
- let PAGES = [];
23
- const LAYOUT_META = new Map(); // cache: dir -> frontmatter data for _layout.mdx in that dir
24
-
25
- async function getNearestLayoutMeta(filePath) {
26
- const startDir = path.dirname(filePath);
27
- let dir = startDir;
28
- while (dir && dir.startsWith(CONTENT_DIR)) {
29
- const key = path.resolve(dir);
30
- if (LAYOUT_META.has(key)) {
31
- const cached = LAYOUT_META.get(key);
32
- if (cached) return cached;
33
- }
34
- const candidate = path.join(dir, "_layout.mdx");
35
- if (fs.existsSync(candidate)) {
36
- try {
37
- const raw = await fsp.readFile(candidate, "utf8");
38
- const fm = mdx.parseFrontmatter(raw);
39
- const data = fm && fm.data ? fm.data : null;
40
- LAYOUT_META.set(key, data);
41
- if (data) return data;
42
- } catch (_) {
43
- LAYOUT_META.set(key, null);
44
- }
45
- } else {
46
- LAYOUT_META.set(key, null);
47
- }
48
- const parent = path.dirname(dir);
49
- if (parent === dir) break;
50
- dir = parent;
51
- }
52
- return null;
53
- }
54
-
55
- function mapOutPath(filePath) {
56
- const rel = path.relative(CONTENT_DIR, filePath);
57
- const outRel = rel.replace(/\.mdx$/i, ".html");
58
- return path.join(OUT_DIR, outRel);
59
- }
60
-
61
- async function ensureStyles() {
62
- const stylesDir = path.join(OUT_DIR, 'styles');
63
- const dest = path.join(stylesDir, 'styles.css');
64
- const customContentCss = path.join(CONTENT_DIR, '_styles.css');
65
- const appStylesDir = path.join(process.cwd(), 'app', 'styles');
66
- const customAppCss = path.join(appStylesDir, 'index.css');
67
- ensureDirSync(stylesDir);
68
-
69
- // If Tailwind config exists and CLI is available, compile using app/styles or content css
70
- const root = process.cwd();
71
- const twConfigsRoot = [
72
- path.join(root, 'tailwind.config.js'),
73
- path.join(root, 'tailwind.config.cjs'),
74
- path.join(root, 'tailwind.config.mjs'),
75
- path.join(root, 'tailwind.config.ts'),
76
- ];
77
- const twConfigsApp = [
78
- path.join(appStylesDir, 'tailwind.config.js'),
79
- path.join(appStylesDir, 'tailwind.config.cjs'),
80
- path.join(appStylesDir, 'tailwind.config.mjs'),
81
- path.join(appStylesDir, 'tailwind.config.ts'),
82
- ];
83
- let configPath = [...twConfigsApp, ...twConfigsRoot].find((p) => {
84
- try { return fs.existsSync(p); } catch (_) { return false; }
85
- });
86
- // If no explicit config, generate a minimal default under .cache
87
- if (!configPath) {
88
- try {
89
- const { CACHE_DIR } = require('./common');
90
- const genDir = path.join(CACHE_DIR, 'tailwind');
91
- ensureDirSync(genDir);
92
- const genCfg = path.join(genDir, 'tailwind.config.js');
93
- const cfg = `module.exports = {\n presets: [require('@canopy-iiif/app/ui/canopy-iiif-preset')],\n content: [\n './content/**/*.{mdx,html}',\n './site/**/*.html',\n './site/**/*.js',\n './packages/app/ui/**/*.{js,jsx,ts,tsx}',\n './packages/app/lib/components/**/*.{js,jsx}',\n ],\n theme: { extend: {} },\n plugins: [require('@canopy-iiif/app/ui/canopy-iiif-plugin')],\n};\n`;
94
- fs.writeFileSync(genCfg, cfg, 'utf8');
95
- configPath = genCfg;
96
- } catch (_) { configPath = null; }
97
- }
98
- const inputCss = fs.existsSync(customAppCss)
99
- ? customAppCss
100
- : (fs.existsSync(customContentCss) ? customContentCss : null);
101
- // If no input CSS present, generate a small default in cache
102
- let generatedInput = null;
103
- if (!inputCss) {
104
- try {
105
- const { CACHE_DIR } = require('./common');
106
- const genDir = path.join(CACHE_DIR, 'tailwind');
107
- ensureDirSync(genDir);
108
- generatedInput = path.join(genDir, 'index.css');
109
- const css = `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n`;
110
- fs.writeFileSync(generatedInput, css, 'utf8');
111
- } catch (_) { generatedInput = null; }
112
- }
113
- // Local helper to invoke Tailwind CLI without cross-package require
114
- function resolveTailwindCli() {
115
- try {
116
- const cliJs = require.resolve('tailwindcss/lib/cli.js');
117
- return { cmd: process.execPath, args: [cliJs] };
118
- } catch (_) {}
119
- try {
120
- const localBin = path.join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'tailwindcss.cmd' : 'tailwindcss');
121
- if (fs.existsSync(localBin)) return { cmd: localBin, args: [] };
122
- } catch (_) {}
123
- return null;
124
- }
125
- function buildTailwindCli({ input, output, config, minify = true }) {
126
- try {
127
- const cli = resolveTailwindCli();
128
- if (!cli) return false;
129
- const { spawnSync } = require('child_process');
130
- const args = ['-i', input, '-o', output];
131
- if (config) args.push('-c', config);
132
- if (minify) args.push('--minify');
133
- const res = spawnSync(cli.cmd, [...cli.args, ...args], { stdio: 'inherit', env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: '1' } });
134
- return !!res && res.status === 0;
135
- } catch (_) { return false; }
136
- }
137
- if (configPath && (inputCss || generatedInput)) {
138
- const ok = buildTailwindCli({ input: inputCss || generatedInput, output: dest, config: configPath, minify: true });
139
- if (ok) return; // Tailwind compiled CSS
140
- }
141
-
142
- // If a custom CSS exists (non-TW or TW not available), copy it as-is.
143
- function isTailwindSource(p) {
144
- try { const s = fs.readFileSync(p, 'utf8'); return /@tailwind\s+(base|components|utilities)/.test(s); } catch (_) { return false; }
145
- }
146
- if (fs.existsSync(customAppCss)) {
147
- if (!isTailwindSource(customAppCss)) {
148
- await fsp.copyFile(customAppCss, dest);
149
- return;
150
- }
151
- }
152
- if (fs.existsSync(customContentCss)) {
153
- if (!isTailwindSource(customContentCss)) {
154
- await fsp.copyFile(customContentCss, dest);
155
- return;
156
- }
157
- }
158
-
159
- // Default minimal CSS
160
- const css = `:root{--max-w:760px;--muted:#6b7280}*{box-sizing:border-box}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial,sans-serif;max-width:var(--max-w);margin:2rem auto;padding:0 1rem;line-height:1.6}a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}.site-header,.site-footer{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:1rem 0;border-bottom:1px solid #e5e7eb}.site-footer{border-bottom:0;border-top:1px solid #e5e7eb;color:var(--muted)}.brand{font-weight:600}.content pre{background:#f6f8fa;padding:1rem;overflow:auto}.content code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;background:#f6f8fa;padding:.1rem .3rem;border-radius:4px}.tabs{display:flex;gap:.5rem;align-items:center;border-bottom:1px solid #e5e7eb;margin:.5rem 0}.tab{background:none;border:0;color:#374151;padding:.25rem .5rem;border-radius:.375rem;cursor:pointer}.tab:hover{color:#111827}.tab-active{color:#2563eb;border:1px solid #e5e7eb;border-bottom:0;background:#fff}.masonry{column-gap:1rem;column-count:1}@media(min-width:768px){.masonry{column-count:2}}@media(min-width:1024px){.masonry{column-count:3}}.masonry>*{break-inside:avoid;margin-bottom:1rem;display:block}[data-grid-variant=masonry]{column-gap:var(--grid-gap,1rem);column-count:var(--cols-base,1)}@media(min-width:768px){[data-grid-variant=masonry]{column-count:var(--cols-md,2)}}@media(min-width:1024px){[data-grid-variant=masonry]{column-count:var(--cols-lg,3)}}[data-grid-variant=masonry]>*{break-inside:avoid;margin-bottom:var(--grid-gap,1rem);display:block}[data-grid-variant=grid]{display:grid;grid-template-columns:repeat(var(--cols-base,1),minmax(0,1fr));gap:var(--grid-gap,1rem)}@media(min-width:768px){[data-grid-variant=grid]{grid-template-columns:repeat(var(--cols-md,2),minmax(0,1fr))}}@media(min-width:1024px){[data-grid-variant=grid]{grid-template-columns:repeat(var(--cols-lg,3),minmax(0,1fr))}}`;
161
- await fsp.writeFile(dest, css, 'utf8');
162
- }
163
-
164
- async function compileMdxFile(filePath, outPath, extraProps = {}) {
165
- const source = await fsp.readFile(filePath, "utf8");
166
- const title = mdx.extractTitle(source);
167
- const { body, head } = await mdx.compileMdxFile(
168
- filePath,
169
- outPath,
170
- null,
171
- extraProps
172
- );
173
- const cssRel = path
174
- .relative(path.dirname(outPath), path.join(OUT_DIR, "styles", "styles.css"))
175
- .split(path.sep)
176
- .join("/");
177
- const needsHydrateViewer = body.includes('data-canopy-viewer');
178
- const needsHydrateSlider = body.includes('data-canopy-slider');
179
- // Command palette is globally available in the App; include its runtime unconditionally
180
- const needsCommand = true;
181
- // Detect both legacy and new placeholders
182
- const needsFacets = body.includes('data-canopy-related-items');
183
- const needsHydrate = body.includes('data-canopy-hydrate') || needsHydrateViewer || needsHydrateSlider || needsFacets || needsCommand;
184
- const viewerRel = needsHydrateViewer
185
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-viewer.js')).split(path.sep).join('/')
186
- : null;
187
- const sliderRel = (needsHydrateSlider || needsFacets)
188
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-slider.js')).split(path.sep).join('/')
189
- : null;
190
- const facetsRel = needsFacets
191
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-related-items.js')).split(path.sep).join('/')
192
- : null;
193
- const commandRel = needsCommand
194
- ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-command.js')).split(path.sep).join('/')
195
- : null;
196
- // Ensure facets runs before slider: make slider the main script so it executes last
197
- let jsRel = null;
198
- if (needsFacets && sliderRel) jsRel = sliderRel;
199
- else if (viewerRel) jsRel = viewerRel;
200
- else if (sliderRel) jsRel = sliderRel;
201
- else if (facetsRel) jsRel = facetsRel;
202
- // Detect pages that require client-side React (viewer/slider/related items)
203
- const needsReact = !!(needsHydrateViewer || needsHydrateSlider || needsFacets);
204
- let vendorTag = '';
205
- if (needsReact) {
206
- try {
207
- await mdx.ensureReactGlobals();
208
- const vendorAbs = path.join(OUT_DIR, 'scripts', 'react-globals.js');
209
- let vendorRel = path.relative(path.dirname(outPath), vendorAbs).split(path.sep).join('/');
210
- try { const stv = fs.statSync(vendorAbs); vendorRel += `?v=${Math.floor(stv.mtimeMs || Date.now())}`; } catch (_) {}
211
- vendorTag = `<script src="${vendorRel}"></script>`;
212
- } catch (_) {}
213
- }
214
- // Expose CANOPY_BASE_PATH to the browser for runtime fetch/link building
215
- try {
216
- const { BASE_PATH } = require('./common');
217
- if (BASE_PATH) vendorTag = `<script>window.CANOPY_BASE_PATH=${JSON.stringify(BASE_PATH)}</script>` + vendorTag;
218
- } catch (_) {}
219
- // If hydration needed, include hydration script
220
- let headExtra = head;
221
- const extraScripts = [];
222
- // Load facets before slider so placeholders exist
223
- if (facetsRel && jsRel !== facetsRel) extraScripts.push(`<script defer src="${facetsRel}"></script>`);
224
- if (viewerRel && jsRel !== viewerRel) extraScripts.push(`<script defer src="${viewerRel}"></script>`);
225
- if (sliderRel && jsRel !== sliderRel) extraScripts.push(`<script defer src="${sliderRel}"></script>`);
226
- if (commandRel && jsRel !== commandRel) extraScripts.push(`<script defer src="${commandRel}"></script>`);
227
- if (extraScripts.length) headExtra = extraScripts.join('') + headExtra;
228
- const bodyWithScript = body;
229
- const html = htmlShell({
230
- title,
231
- body: bodyWithScript,
232
- cssHref: cssRel || "styles.css",
233
- scriptHref: jsRel,
234
- headExtra: vendorTag + headExtra,
235
- });
236
- const { applyBaseToHtml } = require("./common");
237
- return applyBaseToHtml(html);
238
- }
239
-
240
- async function processEntry(absPath) {
241
- const stat = await fsp.stat(absPath);
242
- if (stat.isDirectory()) return;
243
- if (/\.mdx$/i.test(absPath)) {
244
- if (mdx.isReservedFile(absPath)) return;
245
- const outPath = mapOutPath(absPath);
246
- ensureDirSync(path.dirname(outPath));
247
- try {
248
- try {
249
- log(`• Processing MDX ${absPath}\n`, "blue");
250
- } catch (_) {}
251
- const base = path.basename(absPath);
252
- const extra =
253
- base.toLowerCase() === "sitemap.mdx" ? { pages: PAGES } : {};
254
- const html = await compileMdxFile(absPath, outPath, extra);
255
- await fsp.writeFile(outPath, html || "", "utf8");
256
- try {
257
- log(`✓ Built ${path.relative(process.cwd(), outPath)}\n`, "green");
258
- } catch (_) {}
259
- } catch (err) {
260
- console.error("MDX build failed for", absPath, "\n", err.message);
261
- }
262
- } else {
263
- const rel = path.relative(CONTENT_DIR, absPath);
264
- const outPath = path.join(OUT_DIR, rel);
265
- ensureDirSync(path.dirname(outPath));
266
- await fsp.copyFile(absPath, outPath);
267
- try {
268
- log(`• Copied ${path.relative(process.cwd(), outPath)}\n`, "cyan", {
269
- dim: true,
270
- });
271
- } catch (_) {}
272
- }
273
- }
274
-
275
- async function walk(dir) {
276
- const entries = await fsp.readdir(dir, { withFileTypes: true });
277
- for (const e of entries) {
278
- const p = path.join(dir, e.name);
279
- if (e.isDirectory()) await walk(p);
280
- else if (e.isFile()) await processEntry(p);
281
- }
282
- }
283
-
284
- async function copyAssets() {
285
- try {
286
- if (!fs.existsSync(ASSETS_DIR)) return;
287
- } catch (_) {
288
- return;
289
- }
290
- async function walkAssets(dir) {
291
- const entries = await fsp.readdir(dir, { withFileTypes: true });
292
- for (const e of entries) {
293
- const src = path.join(dir, e.name);
294
- const rel = path.relative(ASSETS_DIR, src);
295
- const dest = path.join(OUT_DIR, rel);
296
- if (e.isDirectory()) {
297
- ensureDirSync(dest);
298
- await walkAssets(src);
299
- } else if (e.isFile()) {
300
- ensureDirSync(path.dirname(dest));
301
- await fsp.copyFile(src, dest);
302
- try {
303
- log(`• Asset ${path.relative(process.cwd(), dest)}\n`, "cyan", {
304
- dim: true,
305
- });
306
- } catch (_) {}
307
- }
308
- }
309
- }
310
- try {
311
- logLine("• Copying assets...", "blue", { bright: true });
312
- } catch (_) {}
313
- await walkAssets(ASSETS_DIR);
314
- try {
315
- logLine("✓ Assets copied\n", "green");
316
- } catch (_) {}
317
- }
318
-
319
- // No global default layout; directory-scoped layouts are resolved per-page
320
-
321
- async function build(options = {}) {
322
- const opt = options || {};
323
- const skipIiif = !!opt.skipIiif;
324
- if (!fs.existsSync(CONTENT_DIR)) {
325
- console.error("No content directory found at", CONTENT_DIR);
326
- process.exit(1);
327
- }
328
- // Reset MDX and layout metadata caches for accurate dev rebuilds
329
- try {
330
- if (mdx && typeof mdx.resetMdxCaches === "function") mdx.resetMdxCaches();
331
- } catch (_) {}
332
- try {
333
- if (
334
- typeof LAYOUT_META !== "undefined" &&
335
- LAYOUT_META &&
336
- typeof LAYOUT_META.clear === "function"
337
- )
338
- LAYOUT_META.clear();
339
- } catch (_) {}
340
- if (!skipIiif) {
341
- await cleanDir(OUT_DIR);
342
- logLine("✓ Cleaned output directory\n", "cyan");
343
- } else {
344
- try { logLine("• Incremental rebuild (skip IIIF, no clean)\n", "blue"); } catch (_) {}
345
- }
346
- // Defer styles until after pages are generated so Tailwind can scan site HTML
347
- await mdx.ensureClientRuntime();
348
- try { if (typeof mdx.ensureSliderRuntime === 'function') await mdx.ensureSliderRuntime(); } catch (_) {}
349
- try { if (typeof mdx.ensureFacetsRuntime === 'function') await mdx.ensureFacetsRuntime(); } catch (_) {}
350
- try { if (typeof mdx.ensureCommandRuntime === 'function') await mdx.ensureCommandRuntime(); } catch (_) {}
351
- try { if (typeof mdx.ensureReactGlobals === 'function') await mdx.ensureReactGlobals(); } catch (_) {}
352
- // Always use lightweight command runtime to keep payload small
353
- try {
354
- const cmdOut = path.join(OUT_DIR, 'scripts', 'canopy-command.js');
355
- ensureDirSync(path.dirname(cmdOut));
356
- {
357
- const fallback = `
358
- (function(){
359
- function ready(fn){ if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn(); }
360
- function parseProps(el){ try{ const s = el.querySelector('script[type="application/json"]'); if(s) return JSON.parse(s.textContent||'{}'); }catch(_){ } return {}; }
361
- function norm(s){ try{ return String(s||'').toLowerCase(); }catch(_){ return ''; } }
362
- function withBase(href){ try{ var bp = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : ''; if(!bp) return href; if(/^https?:/i.test(href)) return href; var clean = href.replace(/^\\/+/, ''); return (bp.endsWith('/') ? bp.slice(0,-1) : bp) + '/' + clean; } catch(_){ return href; } }
363
- function rootBase(){ try { var bp = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : ''; return bp && bp.endsWith('/') ? bp.slice(0,-1) : bp; } catch(_) { return ''; } }
364
- function createUI(){ var root=document.createElement('div'); root.setAttribute('data-canopy-command-fallback',''); root.style.cssText='position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;background:rgba(0,0,0,0.3);z-index:9999;padding-top:10vh;'; root.innerHTML='<div style="position:relative;background:#fff;min-width:320px;max-width:720px;width:90%;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.2);overflow:hidden;font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial,sans-serif"><button id="cpclose" aria-label="Close" style="position:absolute;top:8px;right:8px;border:1px solid #e5e7eb;background:#fff;border-radius:6px;padding:2px 6px;cursor:pointer">&times;</button><div style="padding:10px 12px;border-bottom:1px solid #e5e7eb"><input id="cpq" type="text" placeholder="Search…" style="width:100%;padding:8px 10px;border:1px solid #e5e7eb;border-radius:6px;outline:none"/></div><div id="cplist" style="max-height:50vh;overflow:auto;padding:6px 0"></div></div>'; document.body.appendChild(root); return root; }
365
- async function loadRecords(){ try{ var v=''; try{ var m = await fetch(rootBase() + '/api/index.json').then(function(r){return r&&r.ok?r.json():null;}).catch(function(){return null;}); v=(m&&m.version)||''; }catch(_){} var res = await fetch(rootBase() + '/api/search-index.json' + (v?('?v='+encodeURIComponent(v)):'')).catch(function(){return null;}); var j = res && res.ok ? await res.json().catch(function(){return[];}) : []; return Array.isArray(j) ? j : (j && j.records) || []; } catch(_){ return []; } }
366
- ready(async function(){ var host=document.querySelector('[data-canopy-command]'); if(!host) return; var cfg=parseProps(host)||{}; var maxResults = Number(cfg.maxResults||8)||8; var groupOrder = Array.isArray(cfg.groupOrder)?cfg.groupOrder:['work','page']; var overlay=createUI(); var input=overlay.querySelector('#cpq'); var list=overlay.querySelector('#cplist'); var btnClose=overlay.querySelector('#cpclose'); var records = await loadRecords(); function render(items){ list.innerHTML=''; if(!items.length){ list.innerHTML='<div style="padding:10px 12px;color:#6b7280">No results found.</div>'; return; } var groups=new Map(); items.forEach(function(r){ var t=String(r.type||'page'); if(!groups.has(t)) groups.set(t, []); groups.get(t).push(r); }); function gl(t){ if(t==='work') return 'Works'; if(t==='page') return 'Pages'; return t.charAt(0).toUpperCase()+t.slice(1);} var ordered=[].concat(groupOrder.filter(function(t){return groups.has(t);})).concat(Array.from(groups.keys()).filter(function(t){return groupOrder.indexOf(t)===-1;})); ordered.forEach(function(t){ var hdr=document.createElement('div'); hdr.textContent=gl(t); hdr.style.cssText='padding:6px 12px;font-weight:600;color:#374151'; list.appendChild(hdr); groups.get(t).forEach(function(r){ var it=document.createElement('div'); it.tabIndex=0; it.style.cssText='display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer'; var thumb=(String(r.type||'')==='work' && r.thumbnail)?r.thumbnail:''; if(thumb){ var img=document.createElement('img'); img.src=thumb; img.alt=''; img.style.cssText='width:40px;height:40px;object-fit:cover;border-radius:4px'; it.appendChild(img);} var span=document.createElement('span'); span.textContent=r.title||r.href; it.appendChild(span); it.onmouseenter=function(){ it.style.background=\"#f3f4f6\"; }; it.onmouseleave=function(){ it.style.background=\"#fff\"; }; it.onclick=function(){ window.location.href = withBase(String(r.href||'')); }; list.appendChild(it); }); }); }
367
- function filterAndShow(q){ var qq=norm(q); if(!qq){ list.innerHTML='<div style="padding:10px 12px;color:#6b7280">Type to search…</div>'; return; } var out=[]; for(var i=0;i<records.length;i++){ var r=records[i]; var t=String(r.title||''); if(t && norm(t).includes(qq)) out.push(r); if(out.length>=maxResults) break; } render(out); }
368
- document.addEventListener('keydown', function(e){ var hk=String(cfg.hotkey||'mod+k').toLowerCase(); var isMod=hk.indexOf('mod+')!==-1; var key=hk.split('+').pop(); if ((isMod ? (e.metaKey||e.ctrlKey) : true) && e.key.toLowerCase()===String(key||'k')){ e.preventDefault(); overlay.style.display='flex'; input.focus(); filterAndShow(input.value||''); } if(e.key==='Escape' && overlay.style.display!=='none'){ e.preventDefault(); overlay.style.display='none'; }});
369
- overlay.addEventListener('click', function(e){ if(e.target===overlay){ overlay.style.display='none'; }});
370
- input.addEventListener('input', function(){ filterAndShow(input.value||''); });
371
- if (btnClose) { btnClose.addEventListener('click', function(){ overlay.style.display='none'; }); }
372
- var btn = document.querySelector('[data-canopy-command-trigger]'); if(btn){ btn.addEventListener('click', function(){ overlay.style.display='flex'; input.focus(); filterAndShow(input.value||''); }); }
373
- });
374
- })();
375
- `;
376
- await fsp.writeFile(cmdOut, fallback, 'utf8');
377
- try { logLine(`✓ Wrote ${path.relative(process.cwd(), cmdOut)} (fallback)`, 'cyan'); } catch (_) {}
378
- }
379
- } catch (_) {}
380
- logLine("✓ Prepared client hydration runtimes\n", "cyan", { dim: true });
381
- // Copy assets from assets/ to site/
382
- await copyAssets();
383
- // No-op: global layout removed
384
-
385
- // Build IIIF works + collect search records (or reuse cache in incremental mode)
386
- let searchRecords = [];
387
- if (!skipIiif) {
388
- const CONFIG = await iiif.loadConfig();
389
- const res = await iiif.buildIiifCollectionPages(CONFIG);
390
- searchRecords = Array.isArray(res && res.searchRecords) ? res.searchRecords : [];
391
- IIIF_RECORDS_CACHE = searchRecords;
392
- } else {
393
- searchRecords = Array.isArray(IIIF_RECORDS_CACHE) ? IIIF_RECORDS_CACHE : [];
394
- }
395
-
396
- // Collect pages metadata for sitemap injection
397
- const pages = [];
398
- async function collect(dir) {
399
- const entries = await fsp.readdir(dir, { withFileTypes: true });
400
- for (const e of entries) {
401
- const p = path.join(dir, e.name);
402
- if (e.isDirectory()) await collect(p);
403
- else if (e.isFile() && /\.mdx$/i.test(p) && !mdx.isReservedFile(p)) {
404
- const base = path.basename(p).toLowerCase();
405
- const src = await fsp.readFile(p, "utf8");
406
- const fm = mdx.parseFrontmatter(src);
407
- const title = mdx.extractTitle(src);
408
- const rel = path.relative(CONTENT_DIR, p).replace(/\.mdx$/i, ".html");
409
- if (base !== "sitemap.mdx") {
410
- // Determine search inclusion/type via frontmatter on page and nearest directory layout
411
- const href = rel.split(path.sep).join("/");
412
- const underSearch =
413
- /^search\//i.test(href) || href.toLowerCase() === "search.html";
414
- let include = !underSearch;
415
- let resolvedType = null;
416
- const pageFm = fm && fm.data ? fm.data : null;
417
- if (pageFm) {
418
- if (pageFm.search === false) include = false;
419
- if (Object.prototype.hasOwnProperty.call(pageFm, "type")) {
420
- if (pageFm.type) resolvedType = String(pageFm.type);
421
- else include = false; // explicit empty/null type excludes
422
- } else {
423
- // Frontmatter present but no type => exclude per policy
424
- include = false;
425
- }
426
- }
427
- if (include && !resolvedType) {
428
- // Inherit from nearest _layout.mdx frontmatter if available
429
- const layoutMeta = await getNearestLayoutMeta(p);
430
- if (layoutMeta && layoutMeta.type)
431
- resolvedType = String(layoutMeta.type);
432
- }
433
- if (include && !resolvedType) {
434
- // No page/layout frontmatter; default generic page
435
- if (!pageFm) resolvedType = "page";
436
- }
437
- pages.push({
438
- title,
439
- href,
440
- searchInclude: include && !!resolvedType,
441
- searchType: resolvedType || undefined,
442
- });
443
- }
444
- }
445
- }
446
- }
447
- await collect(CONTENT_DIR);
448
- PAGES = pages;
449
- // Build all MDX and assets
450
- logLine("\n• Building MDX pages...", "blue", { bright: true });
451
- await walk(CONTENT_DIR);
452
- logLine("✓ MDX pages built\n", "green");
453
-
454
- // Ensure search artifacts
455
- try {
456
- const searchPath = path.join(OUT_DIR, "search.html");
457
- const needCreatePage = !fs.existsSync(searchPath);
458
- if (needCreatePage) {
459
- try {
460
- logLine("• Preparing search (initial)...", "blue", { bright: true });
461
- } catch (_) {}
462
- // Build result item template (if present) up-front so it can be inlined
463
- try { await search.ensureResultTemplate(); } catch (_) {}
464
- try {
465
- logLine(" - Writing empty index...", "blue");
466
- } catch (_) {}
467
- await search.writeSearchIndex([]);
468
- try { logLine(" - Writing runtime...", "blue"); } catch (_) {}
469
- {
470
- const timeoutMs = Number(process.env.CANOPY_BUNDLE_TIMEOUT || 10000);
471
- let timedOut = false;
472
- await Promise.race([
473
- search.ensureSearchRuntime(),
474
- new Promise((_, reject) => setTimeout(() => { timedOut = true; reject(new Error('timeout')); }, timeoutMs))
475
- ]).catch(() => {
476
- try { console.warn('Search: Bundling runtime timed out (initial), continuing'); } catch (_) {}
477
- });
478
- if (timedOut) {
479
- try { logLine('! Search runtime skipped (initial timeout)\n', 'yellow'); } catch (_) {}
480
- }
481
- }
482
- try {
483
- logLine(" - Building search.html...", "blue");
484
- } catch (_) {}
485
- await search.buildSearchPage();
486
- logLine("✓ Created search page", "cyan");
487
- }
488
- // Always (re)write the search index combining IIIF and MDX pages
489
- let mdxRecords = (PAGES || [])
490
- .filter((p) => p && p.href && p.searchInclude)
491
- .map((p) => ({
492
- title: p.title || p.href,
493
- href: p.href,
494
- type: p.searchType || "page",
495
- }));
496
- const iiifRecords = Array.isArray(searchRecords) ? searchRecords : [];
497
- let combined = [...iiifRecords, ...mdxRecords];
498
- // Optional: generate a mock search index for testing (no network needed)
499
- if (process.env.CANOPY_MOCK_SEARCH === '1') {
500
- const mock = [];
501
- const svg = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300"><rect width="400" height="300" fill="#dbeafe"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="24" fill="#1d4ed8">Mock</text></svg>');
502
- const thumb = `data:image/svg+xml;charset=utf-8,${svg}`;
503
- for (let i = 1; i <= 120; i++) {
504
- mock.push({ title: `Mock Work #${i}`, href: `works/mock-${i}.html`, type: 'work', thumbnail: thumb });
505
- }
506
- mock.push({ title: 'Mock Doc A', href: 'getting-started/index.html', type: 'docs' });
507
- mock.push({ title: 'Mock Doc B', href: 'getting-started/example.html', type: 'docs' });
508
- mock.push({ title: 'Mock Page', href: 'index.html', type: 'page' });
509
- combined = mock;
510
- }
511
- try {
512
- logLine(`• Updating search index (${combined.length})...`, "blue");
513
- } catch (_) {}
514
- await search.writeSearchIndex(combined);
515
- // Build facets for IIIF works based on configured metadata labels
516
- try {
517
- const { loadConfig } = require('./iiif');
518
- const cfg = await loadConfig();
519
- const labels = Array.isArray(cfg && cfg.metadata) ? cfg.metadata : [];
520
- await buildFacetsForWorks(combined, labels);
521
- await writeFacetCollections(labels, combined);
522
- await writeFacetsSearchApi();
523
- } catch (_) {}
524
- try { logLine("• Writing search runtime (final)...", "blue", { bright: true }); } catch (_) {}
525
- {
526
- const timeoutMs = Number(process.env.CANOPY_BUNDLE_TIMEOUT || 10000);
527
- let timedOut = false;
528
- await Promise.race([
529
- search.ensureSearchRuntime(),
530
- new Promise((_, reject) => setTimeout(() => { timedOut = true; reject(new Error('timeout')); }, timeoutMs))
531
- ]).catch(() => {
532
- try { console.warn('Search: Bundling runtime timed out (final), skipping'); } catch (_) {}
533
- });
534
- if (timedOut) {
535
- try { logLine('! Search runtime not bundled (final timeout)\n', 'yellow'); } catch (_) {}
536
- }
537
- }
538
- // Rebuild result item template after content processing to capture latest
539
- try { await search.ensureResultTemplate(); } catch (_) {}
540
- // Rebuild search.html to inline the latest result template
541
- try { logLine('• Updating search.html...', 'blue'); } catch (_) {}
542
- await search.buildSearchPage();
543
- try { logLine('✓ Search page updated', 'cyan'); } catch (_) {}
544
- // Itemize counts by type for a clearer summary
545
- const counts = new Map();
546
- for (const r of combined) {
547
- const t = String((r && r.type) || "page").toLowerCase();
548
- counts.set(t, (counts.get(t) || 0) + 1);
549
- }
550
- const parts = Array.from(counts.entries())
551
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
552
- .map(([t, n]) => `${t}: ${n}`);
553
- const breakdown = parts.length ? `: ${parts.join(", ")}` : "";
554
- logLine(
555
- `✓ Search index: ${combined.length} total records${breakdown}\n`,
556
- "cyan"
557
- );
558
- } catch (_) {}
559
-
560
- // Now that HTML/JS artifacts exist (including search.js), build styles so Tailwind can
561
- // pick up classes from generated output and runtime bundles.
562
- if (!process.env.CANOPY_SKIP_STYLES) {
563
- await ensureStyles();
564
- logLine("✓ Wrote styles.css\n", "cyan");
565
- }
566
- }
567
-
568
- module.exports = { build };
569
-
570
- if (require.main === module) {
571
- build().catch((e) => {
572
- console.error(e);
573
- process.exit(1);
574
- });
575
- }
576
- // After building search and pages, write styles so Tailwind can scan generated assets
577
-
578
- // Helpers for facets
579
- function firstI18nString(x) {
580
- if (!x) return '';
581
- if (typeof x === 'string') return x;
582
- try {
583
- const keys = Object.keys(x || {});
584
- if (!keys.length) return '';
585
- const arr = x[keys[0]];
586
- if (Array.isArray(arr) && arr.length) return String(arr[0]);
587
- } catch (_) {}
588
- return '';
589
- }
590
-
591
- async function buildFacetsForWorks(combined, labelWhitelist) {
592
- const { fs, fsp, path, ensureDirSync } = require('./common');
593
- const facetsDir = path.resolve('.cache/iiif');
594
- ensureDirSync(facetsDir);
595
- const map = new Map(); // label -> Map(value -> Set(docIdx))
596
- const labels = Array.isArray(labelWhitelist) ? labelWhitelist.map(String) : [];
597
- if (!Array.isArray(combined)) combined = [];
598
- for (let i = 0; i < combined.length; i++) {
599
- const rec = combined[i];
600
- if (!rec || String(rec.type) !== 'work') continue;
601
- const href = String(rec.href || '');
602
- const m = href.match(/^works\/(.+)\.html$/i);
603
- if (!m) continue;
604
- const slug = m[1];
605
- const p = path.resolve('.cache/iiif/manifests', slug + '.json');
606
- if (!fs.existsSync(p)) continue;
607
- let manifest = null;
608
- try { manifest = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { manifest = null; }
609
- const meta = Array.isArray(manifest && manifest.metadata) ? manifest.metadata : [];
610
- for (const entry of meta) {
611
- if (!entry) continue;
612
- const label = firstI18nString(entry.label);
613
- const valueRaw = entry.value && (typeof entry.value === 'string' ? entry.value : firstI18nString(entry.value));
614
- if (!label || !valueRaw) continue;
615
- if (labels.length && !labels.includes(label)) continue; // only configured labels
616
- const values = [];
617
- try {
618
- if (typeof entry.value === 'string') values.push(entry.value);
619
- else {
620
- const obj = entry.value || {};
621
- for (const k of Object.keys(obj)) {
622
- const arr = Array.isArray(obj[k]) ? obj[k] : [];
623
- for (const v of arr) if (v) values.push(String(v));
624
- }
625
- }
626
- } catch (_) { values.push(valueRaw); }
627
- if (!map.has(label)) map.set(label, new Map());
628
- const vmap = map.get(label);
629
- for (const v of values) {
630
- const key = String(v);
631
- if (!vmap.has(key)) vmap.set(key, new Set());
632
- vmap.get(key).add(i); // doc index in combined
633
- }
634
- }
635
- }
636
- const out = [];
637
- for (const [label, vmap] of map.entries()) {
638
- const labelSlug = slugify(label || 'label', { lower: true, strict: true, trim: true });
639
- const values = [];
640
- for (const [value, set] of vmap.entries()) {
641
- const docs = Array.from(set.values()).sort((a, b) => a - b);
642
- values.push({
643
- value,
644
- slug: slugify(value || 'value', { lower: true, strict: true, trim: true }),
645
- doc_count: docs.length,
646
- docs,
647
- });
648
- }
649
- // sort values by doc_count desc then alpha
650
- values.sort((a, b) => b.doc_count - a.doc_count || String(a.value).localeCompare(String(b.value)));
651
- out.push({ label, slug: labelSlug, values });
652
- }
653
- // stable sort labels alpha
654
- out.sort((a, b) => String(a.label).localeCompare(String(b.label)));
655
- const dest = path.join(facetsDir, 'facets.json');
656
- await fsp.writeFile(dest, JSON.stringify(out, null, 2), 'utf8');
657
- }
658
-
659
- async function writeFacetCollections(labelWhitelist, combined) {
660
- const { fs, fsp, path, OUT_DIR, ensureDirSync, absoluteUrl, withBase } = require('./common');
661
- const facetsPath = path.resolve('.cache/iiif/facets.json');
662
- if (!fs.existsSync(facetsPath)) return;
663
- let facets = [];
664
- try { facets = JSON.parse(fs.readFileSync(facetsPath, 'utf8')) || []; } catch (_) { facets = []; }
665
- const labels = new Set((Array.isArray(labelWhitelist) ? labelWhitelist : []).map(String));
666
- const apiRoot = path.join(OUT_DIR, 'api');
667
- const facetRoot = path.join(apiRoot, 'facet');
668
- ensureDirSync(facetRoot);
669
- const list = (Array.isArray(facets) ? facets : []).filter((f) => !labels.size || labels.has(String(f && f.label)));
670
- const labelIndexItems = [];
671
- for (const f of list) {
672
- if (!f || !f.label || !Array.isArray(f.values)) continue;
673
- const label = String(f.label);
674
- const labelSlug = slugify(label || 'label', { lower: true, strict: true, trim: true });
675
- const labelDir = path.join(facetRoot, labelSlug);
676
- ensureDirSync(labelDir);
677
- // Child value collections
678
- for (const v of f.values) {
679
- if (!v || typeof v !== 'object') continue;
680
- const value = String(v.value || '');
681
- const valueSlug = slugify(value || 'value', { lower: true, strict: true, trim: true });
682
- const dest = path.join(labelDir, valueSlug + '.json');
683
- const docIdxs = Array.isArray(v.docs) ? v.docs : [];
684
- const items = [];
685
- for (const idx of docIdxs) {
686
- const rec = combined && Array.isArray(combined) ? combined[idx] : null;
687
- if (!rec || String(rec.type) !== 'work') continue;
688
- const id = String(rec.id || '');
689
- const title = String(rec.title || rec.href || '');
690
- const thumb = String(rec.thumbnail || '');
691
- const href = String(rec.href || '');
692
- const homepageId = absoluteUrl('/' + href.replace(/^\/?/, ''));
693
- const item = {
694
- id,
695
- type: 'Manifest',
696
- label: { none: [title] },
697
- };
698
- if (thumb) item.thumbnail = [{ id: thumb, type: 'Image' }];
699
- item.homepage = [{ id: homepageId, type: 'Text', label: { none: [title] } }];
700
- items.push(item);
701
- }
702
- const selfId = absoluteUrl(`/api/facet/${labelSlug}/${valueSlug}.json`);
703
- const parentId = absoluteUrl(`/api/facet/${labelSlug}.json`);
704
- const homepage = absoluteUrl(`/search?${encodeURIComponent(labelSlug)}=${encodeURIComponent(valueSlug)}`);
705
- const col = {
706
- '@context': 'https://iiif.io/api/presentation/3/context.json',
707
- id: selfId,
708
- type: 'Collection',
709
- label: { none: [value] },
710
- items,
711
- partOf: [{ id: parentId, type: 'Collection' }],
712
- summary: { none: [label] },
713
- homepage: [{ id: homepage, type: 'Text', label: { none: [value] } }],
714
- };
715
- await fsp.writeFile(dest, JSON.stringify(col, null, 2), 'utf8');
716
- }
717
- // Label-level collection
718
- const labelIndexDest = path.join(facetRoot, labelSlug + '.json');
719
- const labelItems = (f.values || []).map((v) => {
720
- const value = String(v && v.value || '');
721
- const valueSlug = slugify(value || 'value', { lower: true, strict: true, trim: true });
722
- return {
723
- id: absoluteUrl(`/api/facet/${labelSlug}/${valueSlug}.json`),
724
- type: 'Collection',
725
- label: { none: [value] },
726
- summary: { none: [label] },
727
- };
728
- });
729
- const labelIndex = {
730
- '@context': 'https://iiif.io/api/presentation/3/context.json',
731
- id: absoluteUrl(`/api/facet/${labelSlug}.json`),
732
- type: 'Collection',
733
- label: { none: [label] },
734
- items: labelItems,
735
- };
736
- await fsp.writeFile(labelIndexDest, JSON.stringify(labelIndex, null, 2), 'utf8');
737
- // Add to top-level facets index
738
- labelIndexItems.push({ id: absoluteUrl(`/api/facet/${labelSlug}.json`), type: 'Collection', label: { none: [label] } });
739
- }
740
- // Write top-level facets index
741
- const facetIndex = {
742
- '@context': 'https://iiif.io/api/presentation/3/context.json',
743
- id: absoluteUrl('/api/facet/index.json'),
744
- type: 'Collection',
745
- label: { none: ['Facets'] },
746
- items: labelIndexItems,
747
- };
748
- await fsp.writeFile(path.join(facetRoot, 'index.json'), JSON.stringify(facetIndex, null, 2), 'utf8');
749
- }
750
-
751
- async function writeFacetsSearchApi() {
752
- const { fs, fsp, path, OUT_DIR, ensureDirSync } = require('./common');
753
- const src = path.resolve('.cache/iiif/facets.json');
754
- if (!fs.existsSync(src)) return;
755
- let data = null;
756
- try { data = JSON.parse(fs.readFileSync(src, 'utf8')); } catch (_) { data = null; }
757
- if (!data) return;
758
- const destDir = path.join(OUT_DIR, 'api', 'search');
759
- ensureDirSync(destDir);
760
- const dest = path.join(destDir, 'facets.json');
761
- await fsp.writeFile(dest, JSON.stringify(data, null, 2), 'utf8');
762
- }