@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.
@@ -10,7 +10,7 @@ const {
10
10
  CACHE_DIR,
11
11
  ensureDirSync,
12
12
  withBase,
13
- } = require("./common");
13
+ } = require("../common");
14
14
  const yaml = require("js-yaml");
15
15
 
16
16
  function parseFrontmatter(src) {
@@ -135,11 +135,14 @@ async function loadAppWrapper() {
135
135
  // Prefer a component that renders its children, but do not hard-fail if probe fails.
136
136
  let ok = false;
137
137
  try {
138
- const probe = React.createElement(
139
- App || (() => null),
140
- null,
141
- React.createElement("span", { "data-canopy-probe": "1" })
142
- );
138
+ // Try to render the probe inside an MDXProvider with UI server components
139
+ const components = await loadUiComponents();
140
+ const MDXProvider = await getMdxProvider();
141
+ const probeChild = React.createElement("span", { "data-canopy-probe": "1" });
142
+ const probeTree = React.createElement(App || (() => null), null, probeChild);
143
+ const probe = MDXProvider
144
+ ? React.createElement(MDXProvider, { components }, probeTree)
145
+ : probeTree;
143
146
  const out = ReactDOMServer.renderToStaticMarkup(probe);
144
147
  ok = !!(out && out.indexOf("data-canopy-probe") !== -1);
145
148
  } catch (_) {
@@ -265,7 +268,7 @@ async function ensureClientRuntime() {
265
268
  // like the Clover Viewer when placeholders are present in the HTML.
266
269
  let esbuild = null;
267
270
  try {
268
- esbuild = require("../ui/node_modules/esbuild");
271
+ esbuild = require("../../ui/node_modules/esbuild");
269
272
  } catch (_) {
270
273
  try {
271
274
  esbuild = require("esbuild");
@@ -389,7 +392,7 @@ async function ensureClientRuntime() {
389
392
  // and renders a Slider for each.
390
393
  async function ensureFacetsRuntime() {
391
394
  let esbuild = null;
392
- try { esbuild = require("../ui/node_modules/esbuild"); } catch (_) { try { esbuild = require("esbuild"); } catch (_) {} }
395
+ try { esbuild = require("../../ui/node_modules/esbuild"); } catch (_) { try { esbuild = require("esbuild"); } catch (_) {} }
393
396
  ensureDirSync(OUT_DIR);
394
397
  const scriptsDir = path.join(OUT_DIR, 'scripts');
395
398
  ensureDirSync(scriptsDir);
@@ -632,14 +635,14 @@ async function ensureSliderRuntime() {
632
635
  async function ensureReactGlobals() {
633
636
  let esbuild = null;
634
637
  try {
635
- esbuild = require("../ui/node_modules/esbuild");
638
+ esbuild = require("../../ui/node_modules/esbuild");
636
639
  } catch (_) {
637
640
  try {
638
641
  esbuild = require("esbuild");
639
642
  } catch (_) {}
640
643
  }
641
644
  if (!esbuild) return;
642
- const { path } = require("./common");
645
+ const { path } = require("../common");
643
646
  ensureDirSync(OUT_DIR);
644
647
  const scriptsDir = path.join(OUT_DIR, "scripts");
645
648
  ensureDirSync(scriptsDir);
@@ -0,0 +1,141 @@
1
+ const { fs, fsp, path, CONTENT_DIR, OUT_DIR, ensureDirSync, htmlShell } = require('../common');
2
+ const { log } = require('./log');
3
+ const mdx = require('./mdx');
4
+
5
+ // Cache: dir -> frontmatter data for _layout.mdx in that dir
6
+ const LAYOUT_META = new Map();
7
+
8
+ async function getNearestDirLayoutMeta(filePath) {
9
+ const startDir = path.dirname(filePath);
10
+ let dir = startDir;
11
+ while (dir && dir.startsWith(CONTENT_DIR)) {
12
+ const key = path.resolve(dir);
13
+ if (LAYOUT_META.has(key)) {
14
+ const cached = LAYOUT_META.get(key);
15
+ if (cached) return cached;
16
+ }
17
+ const candidate = path.join(dir, '_layout.mdx');
18
+ if (fs.existsSync(candidate)) {
19
+ try {
20
+ const raw = await fsp.readFile(candidate, 'utf8');
21
+ const fm = mdx.parseFrontmatter(raw);
22
+ const data = fm && fm.data ? fm.data : null;
23
+ LAYOUT_META.set(key, data);
24
+ if (data) return data;
25
+ } catch (_) {
26
+ LAYOUT_META.set(key, null);
27
+ }
28
+ } else {
29
+ LAYOUT_META.set(key, null);
30
+ }
31
+ const parent = path.dirname(dir);
32
+ if (parent === dir) break;
33
+ dir = parent;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function mapContentPathToOutput(filePath) {
39
+ const rel = path.relative(CONTENT_DIR, filePath);
40
+ const outRel = rel.replace(/\.mdx$/i, '.html');
41
+ return path.join(OUT_DIR, outRel);
42
+ }
43
+
44
+ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
45
+ const source = await fsp.readFile(filePath, 'utf8');
46
+ const title = mdx.extractTitle(source);
47
+ const { body, head } = await mdx.compileMdxFile(filePath, outPath, null, extraProps);
48
+ const cssRel = path
49
+ .relative(path.dirname(outPath), path.join(OUT_DIR, 'styles', 'styles.css'))
50
+ .split(path.sep)
51
+ .join('/');
52
+ const needsHydrateViewer = body.includes('data-canopy-viewer');
53
+ const needsHydrateSlider = body.includes('data-canopy-slider');
54
+ const needsCommand = true; // command runtime is global
55
+ const needsFacets = body.includes('data-canopy-related-items');
56
+ const viewerRel = needsHydrateViewer
57
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-viewer.js')).split(path.sep).join('/')
58
+ : null;
59
+ const sliderRel = (needsHydrateSlider || needsFacets)
60
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-slider.js')).split(path.sep).join('/')
61
+ : null;
62
+ const facetsRel = needsFacets
63
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-related-items.js')).split(path.sep).join('/')
64
+ : null;
65
+ const commandRel = needsCommand
66
+ ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-command.js')).split(path.sep).join('/')
67
+ : null;
68
+ let jsRel = null;
69
+ if (needsFacets && sliderRel) jsRel = sliderRel;
70
+ else if (viewerRel) jsRel = viewerRel;
71
+ else if (sliderRel) jsRel = sliderRel;
72
+ else if (facetsRel) jsRel = facetsRel;
73
+ const needsReact = !!(needsHydrateViewer || needsHydrateSlider || needsFacets);
74
+ let vendorTag = '';
75
+ if (needsReact) {
76
+ try {
77
+ await mdx.ensureReactGlobals();
78
+ const vendorAbs = path.join(OUT_DIR, 'scripts', 'react-globals.js');
79
+ let vendorRel = path.relative(path.dirname(outPath), vendorAbs).split(path.sep).join('/');
80
+ try { const stv = fs.statSync(vendorAbs); vendorRel += `?v=${Math.floor(stv.mtimeMs || Date.now())}`; } catch (_) {}
81
+ vendorTag = `<script src="${vendorRel}"></script>`;
82
+ } catch (_) {}
83
+ }
84
+ try {
85
+ const { BASE_PATH } = require('../common');
86
+ if (BASE_PATH) vendorTag = `<script>window.CANOPY_BASE_PATH=${JSON.stringify(BASE_PATH)}</script>` + vendorTag;
87
+ } catch (_) {}
88
+ let headExtra = head;
89
+ const extraScripts = [];
90
+ if (facetsRel && jsRel !== facetsRel) extraScripts.push(`<script defer src="${facetsRel}"></script>`);
91
+ if (viewerRel && jsRel !== viewerRel) extraScripts.push(`<script defer src="${viewerRel}"></script>`);
92
+ if (sliderRel && jsRel !== sliderRel) extraScripts.push(`<script defer src="${sliderRel}"></script>`);
93
+ if (commandRel && jsRel !== commandRel) extraScripts.push(`<script defer src="${commandRel}"></script>`);
94
+ if (extraScripts.length) headExtra = extraScripts.join('') + headExtra;
95
+ const html = htmlShell({ title, body, cssHref: cssRel || 'styles.css', scriptHref: jsRel, headExtra: vendorTag + headExtra });
96
+ const { applyBaseToHtml } = require('../common');
97
+ return applyBaseToHtml(html);
98
+ }
99
+
100
+ async function processContentEntry(absPath, pagesMetadata = []) {
101
+ const stat = await fsp.stat(absPath);
102
+ if (stat.isDirectory()) return;
103
+ if (/\.mdx$/i.test(absPath)) {
104
+ if (mdx.isReservedFile(absPath)) return;
105
+ const outPath = mapContentPathToOutput(absPath);
106
+ ensureDirSync(path.dirname(outPath));
107
+ try {
108
+ try { log(`• Processing MDX ${absPath}\n`, 'blue'); } catch (_) {}
109
+ const base = path.basename(absPath);
110
+ const extra = base.toLowerCase() === 'sitemap.mdx' ? { pages: pagesMetadata } : {};
111
+ const html = await renderContentMdxToHtml(absPath, outPath, extra);
112
+ await fsp.writeFile(outPath, html || '', 'utf8');
113
+ try { log(`✓ Built ${path.relative(process.cwd(), outPath)}\n`, 'green'); } catch (_) {}
114
+ } catch (err) {
115
+ console.error('MDX build failed for', absPath, '\n', err.message);
116
+ }
117
+ } else {
118
+ const rel = path.relative(CONTENT_DIR, absPath);
119
+ const outPath = path.join(OUT_DIR, rel);
120
+ ensureDirSync(path.dirname(outPath));
121
+ await fsp.copyFile(absPath, outPath);
122
+ try { log(`• Copied ${path.relative(process.cwd(), outPath)}\n`, 'cyan', { dim: true }); } catch (_) {}
123
+ }
124
+ }
125
+
126
+ async function buildContentTree(dir, pagesMetadata = []) {
127
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
128
+ for (const e of entries) {
129
+ const p = path.join(dir, e.name);
130
+ if (e.isDirectory()) await buildContentTree(p, pagesMetadata);
131
+ else if (e.isFile()) await processContentEntry(p, pagesMetadata);
132
+ }
133
+ }
134
+
135
+ module.exports = {
136
+ getNearestDirLayoutMeta,
137
+ mapContentPathToOutput,
138
+ renderContentMdxToHtml,
139
+ processContentEntry,
140
+ buildContentTree,
141
+ };
@@ -0,0 +1,58 @@
1
+
2
+ const { logLine } = require('./log');
3
+ const { fs, fsp, path, OUT_DIR, ensureDirSync } = require('../common');
4
+
5
+ async function prepareAllRuntimes() {
6
+ const mdx = require('./mdx');
7
+ try { await mdx.ensureClientRuntime(); } catch (_) {}
8
+ try { if (typeof mdx.ensureSliderRuntime === 'function') await mdx.ensureSliderRuntime(); } catch (_) {}
9
+ try { if (typeof mdx.ensureFacetsRuntime === 'function') await mdx.ensureFacetsRuntime(); } catch (_) {}
10
+ try { if (typeof mdx.ensureReactGlobals === 'function') await mdx.ensureReactGlobals(); } catch (_) {}
11
+ try { await ensureCommandFallback(); } catch (_) {}
12
+ try { logLine('✓ Prepared client hydration runtimes', 'cyan', { dim: true }); } catch (_) {}
13
+ }
14
+
15
+ module.exports = { prepareAllRuntimes };
16
+
17
+ async function ensureCommandFallback() {
18
+ const cmdOut = path.join(OUT_DIR, 'scripts', 'canopy-command.js');
19
+ ensureDirSync(path.dirname(cmdOut));
20
+ const fallback = `
21
+ (function(){
22
+ function ready(fn){ if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn(); }
23
+ function parseProps(el){ try{ const s = el.querySelector('script[type="application/json"]'); if(s) return JSON.parse(s.textContent||'{}'); }catch(_){ } return {}; }
24
+ function norm(s){ try{ return String(s||'').toLowerCase(); }catch(_){ return ''; } }
25
+ 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; } }
26
+ 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 ''; } }
27
+ 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; }
28
+ 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 []; } }
29
+ 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='#f8fafc'; }; it.onmouseleave=function(){ it.style.background='transparent'; }; it.onclick=function(){ try{ window.location.href = withBase(String(r.href||'')); }catch(_){} overlay.style.display='none'; }; list.appendChild(it); }); }); }
30
+ function filterAndShow(q){ try{ var qq=norm(q); if(!qq){ render([]); return; } var out=[]; for(var i=0;i<records.length;i++){ var r=records[i]; var title=String((r&&r.title)||''); if(!title) continue; if(norm(title).indexOf(qq)!==-1) out.push(r); if(out.length>=maxResults) break; } render(out); }catch(_){} }
31
+ btnClose.addEventListener('click', function(){ overlay.style.display='none'; });
32
+ input.addEventListener('input', function(){ filterAndShow(input.value||''); });
33
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape'){ overlay.style.display='none'; }});
34
+ host.addEventListener('click', function(e){ var trg=e.target && e.target.closest && e.target.closest('[data-canopy-command-trigger]'); if(trg){ e.preventDefault(); overlay.style.display='flex'; input.focus(); filterAndShow(input.value||''); }});
35
+ var btn = document.querySelector('[data-canopy-command-trigger]'); if(btn){ btn.addEventListener('click', function(){ overlay.style.display='flex'; input.focus(); filterAndShow(input.value||''); }); }
36
+ });
37
+ })();
38
+ `;
39
+ await fsp.writeFile(cmdOut, fallback, 'utf8');
40
+ try { logLine(`✓ Wrote ${path.relative(process.cwd(), cmdOut)} (fallback)`, 'cyan'); } catch (_) {}
41
+ }
42
+
43
+ async function prepareSearchRuntime(timeoutMs = 10000, label = '') {
44
+ const search = require('../search/search');
45
+ try { logLine(`• Writing search runtime${label ? ' ('+label+')' : ''}...`, 'blue', { bright: true }); } catch (_) {}
46
+ let timedOut = false;
47
+ await Promise.race([
48
+ search.ensureSearchRuntime(),
49
+ new Promise((_, reject) => setTimeout(() => { timedOut = true; reject(new Error('timeout')); }, Number(timeoutMs)))
50
+ ]).catch(() => {
51
+ try { console.warn(`Search: Bundling runtime timed out${label ? ' ('+label+')' : ''}, skipping`); } catch (_) {}
52
+ });
53
+ if (timedOut) {
54
+ try { logLine(`! Search runtime not bundled${label ? ' ('+label+')' : ''}\n`, 'yellow'); } catch (_) {}
55
+ }
56
+ }
57
+
58
+ module.exports = { prepareAllRuntimes, ensureCommandFallback, prepareSearchRuntime };
@@ -0,0 +1,42 @@
1
+
2
+ const { logLine } = require('./log');
3
+
4
+ function pagesToRecords(pageRecords) {
5
+ const list = Array.isArray(pageRecords) ? pageRecords : [];
6
+ return list
7
+ .filter((p) => p && p.href && p.searchInclude)
8
+ .map((p) => ({
9
+ title: p.title || p.href,
10
+ href: p.href,
11
+ type: p.searchType || 'page',
12
+ }));
13
+ }
14
+
15
+ function maybeMockRecords() {
16
+ if (process.env.CANOPY_MOCK_SEARCH !== '1') return null;
17
+ const mock = [];
18
+ 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>');
19
+ const thumb = `data:image/svg+xml;charset=utf-8,${svg}`;
20
+ for (let i = 1; i <= 120; i++) {
21
+ mock.push({ title: `Mock Work #${i}`, href: `works/mock-${i}.html`, type: 'work', thumbnail: thumb });
22
+ }
23
+ mock.push({ title: 'Mock Doc A', href: 'getting-started/index.html', type: 'docs' });
24
+ mock.push({ title: 'Mock Doc B', href: 'getting-started/example.html', type: 'docs' });
25
+ mock.push({ title: 'Mock Page', href: 'index.html', type: 'page' });
26
+ return mock;
27
+ }
28
+
29
+ async function buildSearchIndex(iiifRecords, pageRecords) {
30
+ const search = require('../search/search');
31
+ const iiif = Array.isArray(iiifRecords) ? iiifRecords : [];
32
+ const mdx = pagesToRecords(pageRecords);
33
+ let combined = [...iiif, ...mdx];
34
+ const mock = maybeMockRecords();
35
+ if (mock) combined = mock;
36
+ try { logLine(`• Building search index (${combined.length})...`, 'blue'); } catch (_) {}
37
+ await search.writeSearchIndex(combined);
38
+ try { logLine('✓ Search index built', 'cyan'); } catch (_) {}
39
+ return combined;
40
+ }
41
+
42
+ module.exports = { buildSearchIndex };
@@ -0,0 +1,219 @@
1
+ const { fs, fsp, path, OUT_DIR, ensureDirSync, absoluteUrl } = require('../common');
2
+ const slugify = require('slugify');
3
+
4
+ function firstI18nString(x) {
5
+ if (!x) return '';
6
+ if (typeof x === 'string') return x;
7
+ try {
8
+ const keys = Object.keys(x || {});
9
+ if (!keys.length) return '';
10
+ const arr = x[keys[0]];
11
+ if (Array.isArray(arr) && arr.length) return String(arr[0]);
12
+ } catch (_) {}
13
+ return '';
14
+ }
15
+
16
+ async function buildFacetsForWorks(combined, labelWhitelist) {
17
+ const facetsDir = path.resolve('.cache/iiif');
18
+ ensureDirSync(facetsDir);
19
+ const map = new Map(); // label -> Map(value -> Set(docIdx))
20
+ const labels = Array.isArray(labelWhitelist) ? labelWhitelist.map(String) : [];
21
+ if (!Array.isArray(combined)) combined = [];
22
+ for (let i = 0; i < combined.length; i++) {
23
+ const rec = combined[i];
24
+ if (!rec || String(rec.type) !== 'work') continue;
25
+ const href = String(rec.href || '');
26
+ const m = href.match(/^works\/(.+)\.html$/i);
27
+ if (!m) continue;
28
+ const slug = m[1];
29
+ const p = path.resolve('.cache/iiif/manifests', slug + '.json');
30
+ if (!fs.existsSync(p)) continue;
31
+ let manifest = null;
32
+ try { manifest = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { manifest = null; }
33
+ const meta = Array.isArray(manifest && manifest.metadata) ? manifest.metadata : [];
34
+ for (const entry of meta) {
35
+ if (!entry) continue;
36
+ const label = firstI18nString(entry.label);
37
+ const valueRaw = entry.value && (typeof entry.value === 'string' ? entry.value : firstI18nString(entry.value));
38
+ if (!label || !valueRaw) continue;
39
+ if (labels.length && !labels.includes(label)) continue; // only configured labels
40
+ const values = [];
41
+ try {
42
+ if (typeof entry.value === 'string') values.push(entry.value);
43
+ else {
44
+ const obj = entry.value || {};
45
+ for (const k of Object.keys(obj)) {
46
+ const arr = Array.isArray(obj[k]) ? obj[k] : [];
47
+ for (const v of arr) if (v) values.push(String(v));
48
+ }
49
+ }
50
+ } catch (_) { values.push(valueRaw); }
51
+ if (!map.has(label)) map.set(label, new Map());
52
+ const vmap = map.get(label);
53
+ for (const v of values) {
54
+ const key = String(v);
55
+ if (!vmap.has(key)) vmap.set(key, new Set());
56
+ vmap.get(key).add(i); // doc index in combined
57
+ }
58
+ }
59
+ }
60
+ const out = [];
61
+ for (const [label, vmap] of map.entries()) {
62
+ const labelSlug = slugify(label || 'label', { lower: true, strict: true, trim: true });
63
+ const values = [];
64
+ for (const [value, set] of vmap.entries()) {
65
+ const docs = Array.from(set.values()).sort((a, b) => a - b);
66
+ values.push({ value, slug: slugify(value || 'value', { lower: true, strict: true, trim: true }), doc_count: docs.length, docs });
67
+ }
68
+ values.sort((a, b) => b.doc_count - a.doc_count || String(a.value).localeCompare(String(b.value)));
69
+ out.push({ label, slug: labelSlug, values });
70
+ }
71
+ out.sort((a, b) => String(a.label).localeCompare(String(b.label)));
72
+ const dest = path.join(facetsDir, 'facets.json');
73
+ await fsp.writeFile(dest, JSON.stringify(out, null, 2), 'utf8');
74
+ }
75
+
76
+ async function writeFacetCollections(labelWhitelist, combined) {
77
+ const facetsPath = path.resolve('.cache/iiif/facets.json');
78
+ if (!fs.existsSync(facetsPath)) return;
79
+ let facets = [];
80
+ try { facets = JSON.parse(fs.readFileSync(facetsPath, 'utf8')) || []; } catch (_) { facets = []; }
81
+ const labels = new Set((Array.isArray(labelWhitelist) ? labelWhitelist : []).map(String));
82
+ const apiRoot = path.join(OUT_DIR, 'api');
83
+ const facetRoot = path.join(apiRoot, 'facet');
84
+ ensureDirSync(facetRoot);
85
+ const list = (Array.isArray(facets) ? facets : []).filter((f) => !labels.size || labels.has(String(f && f.label)));
86
+ const labelIndexItems = [];
87
+ for (const f of list) {
88
+ if (!f || !f.label || !Array.isArray(f.values)) continue;
89
+ const label = String(f.label);
90
+ const labelSlug = slugify(label || 'label', { lower: true, strict: true, trim: true });
91
+ const labelDir = path.join(facetRoot, labelSlug);
92
+ ensureDirSync(labelDir);
93
+ for (const v of f.values) {
94
+ if (!v || typeof v !== 'object') continue;
95
+ const value = String(v.value || '');
96
+ const valueSlug = slugify(value || 'value', { lower: true, strict: true, trim: true });
97
+ const dest = path.join(labelDir, valueSlug + '.json');
98
+ const docIdxs = Array.isArray(v.docs) ? v.docs : [];
99
+ const items = [];
100
+ for (const idx of docIdxs) {
101
+ const rec = combined && Array.isArray(combined) ? combined[idx] : null;
102
+ if (!rec || String(rec.type) !== 'work') continue;
103
+ const id = String(rec.id || '');
104
+ const title = String(rec.title || rec.href || '');
105
+ const thumb = String(rec.thumbnail || '');
106
+ const href = String(rec.href || '');
107
+ const homepageId = absoluteUrl('/' + href.replace(/^\/?/, ''));
108
+ const item = { id, type: 'Manifest', label: { none: [title] } };
109
+ if (thumb) item.thumbnail = [{ id: thumb, type: 'Image' }];
110
+ item.homepage = [{ id: homepageId, type: 'Text', label: { none: [title] } }];
111
+ items.push(item);
112
+ }
113
+ const selfId = absoluteUrl(`/api/facet/${labelSlug}/${valueSlug}.json`);
114
+ const parentId = absoluteUrl(`/api/facet/${labelSlug}.json`);
115
+ const homepage = absoluteUrl(`/search?${encodeURIComponent(labelSlug)}=${encodeURIComponent(valueSlug)}`);
116
+ const col = {
117
+ '@context': 'https://iiif.io/api/presentation/3/context.json',
118
+ id: selfId,
119
+ type: 'Collection',
120
+ label: { none: [value] },
121
+ items,
122
+ partOf: [{ id: parentId, type: 'Collection' }],
123
+ summary: { none: [label] },
124
+ homepage: [{ id: homepage, type: 'Text', label: { none: [value] } }],
125
+ };
126
+ await fsp.writeFile(dest, JSON.stringify(col, null, 2), 'utf8');
127
+ }
128
+ const labelIndexDest = path.join(facetRoot, labelSlug + '.json');
129
+ const labelItems = (f.values || []).map((v) => ({
130
+ id: absoluteUrl(`/api/facet/${labelSlug}/${slugify(String(v && v.value || ''), { lower: true, strict: true, trim: true })}.json`),
131
+ type: 'Collection',
132
+ label: { none: [String(v && v.value || '')] },
133
+ summary: { none: [label] },
134
+ }));
135
+ const labelIndex = {
136
+ '@context': 'https://iiif.io/api/presentation/3/context.json',
137
+ id: absoluteUrl(`/api/facet/${labelSlug}.json`),
138
+ type: 'Collection',
139
+ label: { none: [label] },
140
+ items: labelItems,
141
+ };
142
+ await fsp.writeFile(labelIndexDest, JSON.stringify(labelIndex, null, 2), 'utf8');
143
+ labelIndexItems.push({ id: absoluteUrl(`/api/facet/${labelSlug}.json`), type: 'Collection', label: { none: [label] } });
144
+ }
145
+ const facetIndex = {
146
+ '@context': 'https://iiif.io/api/presentation/3/context.json',
147
+ id: absoluteUrl('/api/facet/index.json'),
148
+ type: 'Collection',
149
+ label: { none: ['Facets'] },
150
+ items: labelIndexItems,
151
+ };
152
+ await fsp.writeFile(path.join(facetRoot, 'index.json'), JSON.stringify(facetIndex, null, 2), 'utf8');
153
+ }
154
+
155
+ async function writeFacetsSearchApi() {
156
+ const src = path.resolve('.cache/iiif/facets.json');
157
+ if (!fs.existsSync(src)) return;
158
+ let data = null;
159
+ try { data = JSON.parse(fs.readFileSync(src, 'utf8')); } catch (_) { data = null; }
160
+ if (!data) return;
161
+ const destDir = path.join(OUT_DIR, 'api', 'search');
162
+ ensureDirSync(destDir);
163
+ const dest = path.join(destDir, 'facets.json');
164
+ await fsp.writeFile(dest, JSON.stringify(data, null, 2), 'utf8');
165
+ }
166
+
167
+ module.exports = { buildFacetsForWorks, writeFacetCollections, writeFacetsSearchApi };
168
+
169
+
170
+
171
+ async function collectMdxPageRecords() {
172
+ const { fs, fsp, path, CONTENT_DIR } = require('../common');
173
+ const mdx = require('./mdx');
174
+ const pagesHelpers = require('./pages');
175
+ const pages = [];
176
+ async function walk(dir) {
177
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
178
+ for (const e of entries) {
179
+ const p = path.join(dir, e.name);
180
+ if (e.isDirectory()) await walk(p);
181
+ else if (e.isFile() && /\.mdx$/i.test(p) && !mdx.isReservedFile(p)) {
182
+ const base = path.basename(p).toLowerCase();
183
+ const src = await fsp.readFile(p, 'utf8');
184
+ const fm = mdx.parseFrontmatter(src);
185
+ const title = mdx.extractTitle(src);
186
+ const rel = path.relative(CONTENT_DIR, p).replace(/\.mdx$/i, '.html');
187
+ if (base !== 'sitemap.mdx') {
188
+ const href = rel.split(path.sep).join('/');
189
+ const underSearch = /^search\//i.test(href) || href.toLowerCase() === 'search.html';
190
+ let include = !underSearch;
191
+ let resolvedType = null;
192
+ const pageFm = fm && fm.data ? fm.data : null;
193
+ if (pageFm) {
194
+ if (pageFm.search === false) include = false;
195
+ if (Object.prototype.hasOwnProperty.call(pageFm, 'type')) {
196
+ if (pageFm.type) resolvedType = String(pageFm.type);
197
+ else include = false;
198
+ } else {
199
+ include = false; // frontmatter present w/o type => exclude per policy
200
+ }
201
+ }
202
+ if (include && !resolvedType) {
203
+ const layoutMeta = await pagesHelpers.getNearestDirLayoutMeta(p);
204
+ if (layoutMeta && layoutMeta.type) resolvedType = String(layoutMeta.type);
205
+ }
206
+ if (include && !resolvedType) {
207
+ if (!pageFm) resolvedType = 'page';
208
+ }
209
+ pages.push({ title, href, searchInclude: include && !!resolvedType, searchType: resolvedType || undefined });
210
+ }
211
+ }
212
+ }
213
+ }
214
+ await walk(CONTENT_DIR);
215
+ return pages;
216
+ }
217
+
218
+
219
+ module.exports = { buildFacetsForWorks, writeFacetCollections, writeFacetsSearchApi, collectMdxPageRecords };
@@ -0,0 +1,99 @@
1
+ const { fs, fsp, path, OUT_DIR, CONTENT_DIR, ensureDirSync } = require('../common');
2
+
3
+ async function ensureStyles() {
4
+ const stylesDir = path.join(OUT_DIR, 'styles');
5
+ const dest = path.join(stylesDir, 'styles.css');
6
+ const customContentCss = path.join(CONTENT_DIR, '_styles.css');
7
+ const appStylesDir = path.join(process.cwd(), 'app', 'styles');
8
+ const customAppCss = path.join(appStylesDir, 'index.css');
9
+ ensureDirSync(stylesDir);
10
+
11
+ const root = process.cwd();
12
+ const twConfigsRoot = [
13
+ path.join(root, 'tailwind.config.js'),
14
+ path.join(root, 'tailwind.config.cjs'),
15
+ path.join(root, 'tailwind.config.mjs'),
16
+ path.join(root, 'tailwind.config.ts'),
17
+ ];
18
+ const twConfigsApp = [
19
+ path.join(appStylesDir, 'tailwind.config.js'),
20
+ path.join(appStylesDir, 'tailwind.config.cjs'),
21
+ path.join(appStylesDir, 'tailwind.config.mjs'),
22
+ path.join(appStylesDir, 'tailwind.config.ts'),
23
+ ];
24
+ let configPath = [...twConfigsApp, ...twConfigsRoot].find((p) => {
25
+ try { return fs.existsSync(p); } catch (_) { return false; }
26
+ });
27
+ if (!configPath) {
28
+ try {
29
+ const { CACHE_DIR } = require('../common');
30
+ const genDir = path.join(CACHE_DIR, 'tailwind');
31
+ ensureDirSync(genDir);
32
+ const genCfg = path.join(genDir, 'tailwind.config.js');
33
+ 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/iiif/components/**/*.{js,jsx}',\n ],\n theme: { extend: {} },\n plugins: [require('@canopy-iiif/app/ui/canopy-iiif-plugin')],\n};\n`;
34
+ fs.writeFileSync(genCfg, cfg, 'utf8');
35
+ configPath = genCfg;
36
+ } catch (_) { configPath = null; }
37
+ }
38
+
39
+ const inputCss = fs.existsSync(customAppCss)
40
+ ? customAppCss
41
+ : (fs.existsSync(customContentCss) ? customContentCss : null);
42
+
43
+ let generatedInput = null;
44
+ if (!inputCss) {
45
+ try {
46
+ const { CACHE_DIR } = require('../common');
47
+ const genDir = path.join(CACHE_DIR, 'tailwind');
48
+ ensureDirSync(genDir);
49
+ generatedInput = path.join(genDir, 'index.css');
50
+ const css = `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n`;
51
+ fs.writeFileSync(generatedInput, css, 'utf8');
52
+ } catch (_) { generatedInput = null; }
53
+ }
54
+
55
+ function resolveTailwindCli() {
56
+ try {
57
+ const cliJs = require.resolve('tailwindcss/lib/cli.js');
58
+ return { cmd: process.execPath, args: [cliJs] };
59
+ } catch (_) {}
60
+ try {
61
+ const localBin = path.join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'tailwindcss.cmd' : 'tailwindcss');
62
+ if (fs.existsSync(localBin)) return { cmd: localBin, args: [] };
63
+ } catch (_) {}
64
+ return null;
65
+ }
66
+ function buildTailwindCli({ input, output, config, minify = true }) {
67
+ try {
68
+ const cli = resolveTailwindCli();
69
+ if (!cli) return false;
70
+ const { spawnSync } = require('child_process');
71
+ const args = ['-i', input, '-o', output];
72
+ if (config) args.push('-c', config);
73
+ if (minify) args.push('--minify');
74
+ const res = spawnSync(cli.cmd, [...cli.args, ...args], { stdio: 'inherit', env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: '1' } });
75
+ return !!res && res.status === 0;
76
+ } catch (_) { return false; }
77
+ }
78
+
79
+ if (configPath && (inputCss || generatedInput)) {
80
+ const ok = buildTailwindCli({ input: inputCss || generatedInput, output: dest, config: configPath, minify: true });
81
+ if (ok) return; // Tailwind compiled CSS
82
+ }
83
+
84
+ function isTailwindSource(p) {
85
+ try { const s = fs.readFileSync(p, 'utf8'); return /@tailwind\s+(base|components|utilities)/.test(s); } catch (_) { return false; }
86
+ }
87
+ if (fs.existsSync(customAppCss)) {
88
+ if (!isTailwindSource(customAppCss)) { await fsp.copyFile(customAppCss, dest); return; }
89
+ }
90
+ if (fs.existsSync(customContentCss)) {
91
+ if (!isTailwindSource(customContentCss)) { await fsp.copyFile(customContentCss, dest); return; }
92
+ }
93
+
94
+ 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))}}`;
95
+ await fsp.writeFile(dest, css, 'utf8');
96
+ }
97
+
98
+ module.exports = { ensureStyles };
99
+
@@ -84,4 +84,3 @@ async function getThumbnailUrl(resource, preferredSize = 1200, unsafe = false) {
84
84
  }
85
85
 
86
86
  module.exports = { getRepresentativeImage, getThumbnail, getThumbnailUrl };
87
-
package/lib/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  module.exports = {
2
- build: require('./build').build,
3
- dev: require('./dev').dev
2
+ build: require('./build/build').build,
3
+ dev: require('./build/dev').dev
4
4
  };
5
-