@canopy-iiif/app 0.6.28
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 +762 -0
- package/lib/common.js +124 -0
- package/lib/components/IIIFCard.js +102 -0
- package/lib/dev.js +721 -0
- package/lib/devtoast.config.json +6 -0
- package/lib/devtoast.css +14 -0
- package/lib/iiif.js +1145 -0
- package/lib/index.js +5 -0
- package/lib/log.js +64 -0
- package/lib/mdx.js +690 -0
- package/lib/runtime/command-entry.jsx +44 -0
- package/lib/search-app.jsx +273 -0
- package/lib/search.js +477 -0
- package/lib/thumbnail.js +87 -0
- package/package.json +50 -0
- package/ui/dist/index.mjs +692 -0
- package/ui/dist/index.mjs.map +7 -0
- package/ui/dist/server.mjs +344 -0
- package/ui/dist/server.mjs.map +7 -0
- package/ui/styles/components/_card.scss +69 -0
- package/ui/styles/components/_command.scss +80 -0
- package/ui/styles/components/index.scss +5 -0
- package/ui/styles/index.css +127 -0
- package/ui/styles/index.scss +3 -0
- package/ui/styles/variables.emit.scss +72 -0
- package/ui/styles/variables.scss +66 -0
- package/ui/tailwind-canopy-iiif-plugin.js +35 -0
- package/ui/tailwind-canopy-iiif-preset.js +105 -0
package/lib/build.js
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
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">×</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
|
+
}
|