@canopy-iiif/app 0.8.2 → 0.8.4

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.
Files changed (37) hide show
  1. package/lib/AGENTS.md +1 -1
  2. package/lib/build/build.js +10 -0
  3. package/lib/build/dev.js +87 -40
  4. package/lib/build/iiif.js +124 -43
  5. package/lib/build/mdx.js +14 -4
  6. package/lib/build/pages.js +23 -9
  7. package/lib/build/runtimes.js +6 -6
  8. package/lib/build/search.js +7 -10
  9. package/lib/build/verify.js +9 -9
  10. package/lib/components/featured.js +17 -3
  11. package/lib/components/navigation.js +308 -0
  12. package/lib/page-context.js +14 -0
  13. package/lib/search/search-app.jsx +2 -2
  14. package/lib/search/{command-runtime.js → search-form-runtime.js} +9 -9
  15. package/lib/search/search.js +2 -2
  16. package/package.json +1 -1
  17. package/ui/dist/index.mjs +76 -63
  18. package/ui/dist/index.mjs.map +3 -3
  19. package/ui/dist/server.mjs +170 -67
  20. package/ui/dist/server.mjs.map +4 -4
  21. package/ui/styles/base/_common.scss +19 -6
  22. package/ui/styles/base/_heading.scss +17 -0
  23. package/ui/styles/base/index.scss +2 -1
  24. package/ui/styles/components/_sub-navigation.scss +76 -0
  25. package/ui/styles/components/header/_header.scss +13 -0
  26. package/ui/styles/components/header/_logo.scss +20 -0
  27. package/ui/styles/components/header/_navbar.scss +15 -0
  28. package/ui/styles/components/header/index.scss +3 -0
  29. package/ui/styles/components/index.scss +5 -4
  30. package/ui/styles/components/search/_filters.scss +265 -0
  31. package/ui/styles/components/search/_form.scss +171 -0
  32. package/ui/styles/components/search/_results.scss +158 -0
  33. package/ui/styles/components/search/index.scss +3 -0
  34. package/ui/styles/index.css +584 -71
  35. package/ui/styles/variables.scss +15 -5
  36. package/ui/styles/components/_command.scss +0 -164
  37. package/ui/styles/components/_header.scss +0 -0
@@ -21,10 +21,10 @@ function verifyHomepageElements(outDir) {
21
21
  const idx = path.join(outDir, 'index.html');
22
22
  const html = readFileSafe(idx);
23
23
  const okHero = /class=\"[^\"]*canopy-hero/.test(html) || /<div[^>]+canopy-hero/.test(html);
24
- const okCommand = /data-canopy-command=/.test(html);
25
- const okCommandTrigger = /data-canopy-command-trigger/.test(html);
26
- const okCommandScriptRef = /<script[^>]+canopy-command\.js/.test(html);
27
- return { okHero, okCommand, okCommandTrigger, okCommandScriptRef, htmlPath: idx };
24
+ const okSearchForm = /data-canopy-search-form=/.test(html);
25
+ const okSearchFormTrigger = /data-canopy-search-form-trigger/.test(html);
26
+ const okSearchFormScriptRef = /<script[^>]+canopy-search-form\.js/.test(html);
27
+ return { okHero, okSearchForm, okSearchFormTrigger, okSearchFormScriptRef, htmlPath: idx };
28
28
  }
29
29
 
30
30
  function verifyBuildOutput(options = {}) {
@@ -34,7 +34,7 @@ function verifyBuildOutput(options = {}) {
34
34
  const okAny = total > 0;
35
35
  const indexPath = path.join(outDir, 'index.html');
36
36
  const hasIndex = fs.existsSync(indexPath) && fs.statSync(indexPath).size > 0;
37
- const { okHero, okCommand, okCommandTrigger, okCommandScriptRef } = verifyHomepageElements(outDir);
37
+ const { okHero, okSearchForm, okSearchFormTrigger, okSearchFormScriptRef } = verifyHomepageElements(outDir);
38
38
 
39
39
  const ck = (label, ok, extra) => {
40
40
  const status = ok ? '✓' : '✗';
@@ -44,12 +44,12 @@ function verifyBuildOutput(options = {}) {
44
44
  ck('HTML pages exist', okAny, okAny ? `(${total})` : '');
45
45
  ck('homepage exists', hasIndex, hasIndex ? `(${indexPath})` : '');
46
46
  ck('homepage: Hero present', okHero);
47
- ck('homepage: Command present', okCommand);
48
- ck('homepage: Command trigger present', okCommandTrigger);
49
- ck('homepage: Command script referenced', okCommandScriptRef);
47
+ ck('homepage: Search form present', okSearchForm);
48
+ ck('homepage: Search form trigger present', okSearchFormTrigger);
49
+ ck('homepage: Search form script referenced', okSearchFormScriptRef);
50
50
 
51
51
  // Do not fail build on missing SSR trigger; the client runtime injects a default.
52
- const ok = okAny && hasIndex && okHero && okCommand && okCommandScriptRef;
52
+ const ok = okAny && hasIndex && okHero && okSearchForm && okSearchFormScriptRef;
53
53
  if (!ok) {
54
54
  const err = new Error('Build verification failed');
55
55
  err.outDir = outDir;
@@ -105,9 +105,23 @@ function readFeaturedFromCacheSync() {
105
105
  href: rootRelativeHref(path.join('works', slug + '.html').split(path.sep).join('/')),
106
106
  type: 'work',
107
107
  };
108
- if (entry && entry.thumbnail) rec.thumbnail = String(entry.thumbnail);
109
- if (entry && typeof entry.thumbnailWidth === 'number') rec.thumbnailWidth = entry.thumbnailWidth;
110
- if (entry && typeof entry.thumbnailHeight === 'number') rec.thumbnailHeight = entry.thumbnailHeight;
108
+ if (entry && entry.heroThumbnail) {
109
+ rec.thumbnail = String(entry.heroThumbnail);
110
+ if (typeof entry.heroThumbnailWidth === 'number') {
111
+ rec.thumbnailWidth = entry.heroThumbnailWidth;
112
+ } else if (typeof entry.thumbnailWidth === 'number') {
113
+ rec.thumbnailWidth = entry.thumbnailWidth;
114
+ }
115
+ if (typeof entry.heroThumbnailHeight === 'number') {
116
+ rec.thumbnailHeight = entry.heroThumbnailHeight;
117
+ } else if (typeof entry.thumbnailHeight === 'number') {
118
+ rec.thumbnailHeight = entry.thumbnailHeight;
119
+ }
120
+ } else {
121
+ if (entry && entry.thumbnail) rec.thumbnail = String(entry.thumbnail);
122
+ if (entry && typeof entry.thumbnailWidth === 'number') rec.thumbnailWidth = entry.thumbnailWidth;
123
+ if (entry && typeof entry.thumbnailHeight === 'number') rec.thumbnailHeight = entry.thumbnailHeight;
124
+ }
111
125
  if (!rec.thumbnail) {
112
126
  try {
113
127
  const t = m && m.thumbnail;
@@ -0,0 +1,308 @@
1
+ const {fs, path, CONTENT_DIR, rootRelativeHref} = require("../common");
2
+ const mdx = require("../build/mdx.js");
3
+ const {getPageContext} = require("../page-context");
4
+
5
+ const EXCLUDED_ROOTS = new Set(["works", "search"]);
6
+
7
+ let NAV_CACHE = null;
8
+
9
+ function normalizeRelativePath(rel) {
10
+ if (!rel) return "";
11
+ let normalized = String(rel).replace(/\\+/g, "/");
12
+ while (normalized.startsWith("./")) normalized = normalized.slice(2);
13
+ while (normalized.startsWith("../")) normalized = normalized.slice(3);
14
+ if (normalized.startsWith("/")) {
15
+ normalized = normalized.replace(/^\/+/, "");
16
+ }
17
+ return normalized;
18
+ }
19
+
20
+ function humanizeSegment(seg) {
21
+ if (!seg) return "";
22
+ const cleaned = String(seg).replace(/[-_]+/g, " ");
23
+ return cleaned.replace(/(^|\s)([a-z])/g, (match) => match.toUpperCase());
24
+ }
25
+
26
+ function slugFromRelative(relativePath) {
27
+ const normalized = normalizeRelativePath(relativePath);
28
+ if (!normalized) {
29
+ return {slug: "", segments: [], isIndex: false};
30
+ }
31
+ const parts = normalized.split("/");
32
+ const fileName = parts.pop() || "";
33
+ const baseName = fileName.replace(/\.mdx$/i, "");
34
+ const isIndex = baseName.toLowerCase() === "index";
35
+ const dirSegments = parts.filter(Boolean);
36
+ const segments = isIndex ? dirSegments : dirSegments.concat(baseName);
37
+ const slug = segments.join("/");
38
+ return {slug, segments, isIndex};
39
+ }
40
+
41
+ function pageSortKey(relativePath) {
42
+ const normalized = normalizeRelativePath(relativePath).toLowerCase();
43
+ if (!normalized) return "";
44
+ return normalized.replace(/(^|\/)index\.mdx$/i, "$1-index.mdx");
45
+ }
46
+
47
+ function extractTitleSafe(raw) {
48
+ try {
49
+ return mdx.extractTitle(raw);
50
+ } catch (_) {
51
+ return "Untitled";
52
+ }
53
+ }
54
+
55
+ function collectPagesSync() {
56
+ const pages = [];
57
+
58
+ function walk(dir) {
59
+ let entries = [];
60
+ try {
61
+ entries = fs.readdirSync(dir, {withFileTypes: true});
62
+ } catch (_) {
63
+ return;
64
+ }
65
+ for (const entry of entries) {
66
+ if (!entry) continue;
67
+ const name = entry.name || "";
68
+ if (!name) continue;
69
+ if (name.startsWith(".")) continue;
70
+ const absPath = path.join(dir, name);
71
+ const relPath = path.relative(CONTENT_DIR, absPath);
72
+ const normalizedRel = normalizeRelativePath(relPath);
73
+ if (!normalizedRel) continue;
74
+ const segments = normalizedRel.split("/");
75
+ const firstRaw = segments[0] || "";
76
+ const firstSegment = firstRaw.replace(/\.mdx$/i, "");
77
+ if (EXCLUDED_ROOTS.has(firstSegment)) continue;
78
+ if (segments.some((segment) => segment.startsWith("_"))) continue;
79
+ if (entry.isDirectory()) {
80
+ walk(absPath);
81
+ continue;
82
+ }
83
+ if (!entry.isFile()) continue;
84
+ if (!/\.mdx$/i.test(name)) continue;
85
+ let raw = "";
86
+ try {
87
+ raw = fs.readFileSync(absPath, "utf8");
88
+ } catch (_) {
89
+ raw = "";
90
+ }
91
+ const {
92
+ slug,
93
+ segments: slugSegments,
94
+ isIndex,
95
+ } = slugFromRelative(normalizedRel);
96
+ const titleRaw = extractTitleSafe(raw);
97
+ const fallbackTitle = humanizeSegment(
98
+ slugSegments.slice(-1)[0] || firstSegment || ""
99
+ );
100
+ const title =
101
+ titleRaw && titleRaw !== "Untitled"
102
+ ? titleRaw
103
+ : fallbackTitle || titleRaw;
104
+ const htmlRel = normalizedRel.replace(/\.mdx$/i, ".html");
105
+ const href = rootRelativeHref(htmlRel);
106
+ const page = {
107
+ filePath: absPath,
108
+ relativePath: normalizedRel,
109
+ slug,
110
+ segments: slugSegments,
111
+ isIndex,
112
+ href,
113
+ title,
114
+ fallbackTitle,
115
+ sortKey: pageSortKey(normalizedRel),
116
+ topSegment: slugSegments[0] || firstSegment || "",
117
+ };
118
+ pages.push(page);
119
+ }
120
+ }
121
+
122
+ walk(CONTENT_DIR);
123
+ pages.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
124
+ return pages;
125
+ }
126
+
127
+ function createNode(slug) {
128
+ const segments = slug ? slug.split("/") : [];
129
+ const name = segments.slice(-1)[0] || "";
130
+ return {
131
+ slug,
132
+ segments,
133
+ name,
134
+ title: humanizeSegment(name),
135
+ href: null,
136
+ hasContent: false,
137
+ relativePath: null,
138
+ sortKey: slug || name,
139
+ sourcePage: null,
140
+ children: [],
141
+ };
142
+ }
143
+
144
+ function getNavigationCache() {
145
+ if (NAV_CACHE) return NAV_CACHE;
146
+ const pages = collectPagesSync();
147
+ const pagesByRelative = new Map();
148
+ const nodes = new Map();
149
+
150
+ for (const page of pages) {
151
+ const {slug, segments} = page;
152
+ pagesByRelative.set(page.relativePath, page);
153
+ if (!segments.length) continue;
154
+ for (let i = 0; i < segments.length; i += 1) {
155
+ const key = segments.slice(0, i + 1).join("/");
156
+ if (key && !nodes.has(key)) {
157
+ nodes.set(key, createNode(key));
158
+ }
159
+ }
160
+ }
161
+
162
+ for (const page of pages) {
163
+ if (!page.slug) continue;
164
+ const node = nodes.get(page.slug);
165
+ if (!node) continue;
166
+ if (
167
+ !node.sourcePage ||
168
+ (node.sourcePage && node.sourcePage.isIndex && !page.isIndex)
169
+ ) {
170
+ node.sourcePage = page;
171
+ node.title = page.title || node.title;
172
+ node.href = page.href || node.href;
173
+ node.relativePath = page.relativePath;
174
+ node.sortKey = page.sortKey || node.sortKey;
175
+ node.hasContent = true;
176
+ }
177
+ }
178
+
179
+ for (const node of nodes.values()) {
180
+ const {segments} = node;
181
+ if (!segments.length) continue;
182
+ const parentSlug = segments.slice(0, -1).join("/");
183
+ if (!parentSlug) continue;
184
+ const parent = nodes.get(parentSlug);
185
+ if (!parent) continue;
186
+ if (!parent.children.some((child) => child.slug === node.slug)) {
187
+ parent.children.push(node);
188
+ }
189
+ }
190
+
191
+ const sortChildren = (node) => {
192
+ node.children.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
193
+ for (const child of node.children) sortChildren(child);
194
+ };
195
+ for (const node of nodes.values()) {
196
+ sortChildren(node);
197
+ }
198
+
199
+ const roots = new Map();
200
+ for (const node of nodes.values()) {
201
+ if (node.segments.length === 1) {
202
+ roots.set(node.slug, node);
203
+ }
204
+ }
205
+
206
+ NAV_CACHE = {
207
+ pages,
208
+ pagesByRelative,
209
+ nodes,
210
+ roots,
211
+ };
212
+ return NAV_CACHE;
213
+ }
214
+
215
+ function cloneNode(node, currentSlug) {
216
+ if (!node) return null;
217
+ const slug = node.slug;
218
+ const isActive = currentSlug && slug === currentSlug;
219
+ const isAncestor = !!(
220
+ currentSlug &&
221
+ slug &&
222
+ slug.length < currentSlug.length &&
223
+ currentSlug.startsWith(slug + "/")
224
+ );
225
+ const children = node.children
226
+ .map((child) => cloneNode(child, currentSlug))
227
+ .filter(Boolean);
228
+ if (!node.hasContent && !children.length) {
229
+ return null;
230
+ }
231
+ return {
232
+ slug,
233
+ title: node.title,
234
+ href: node.href,
235
+ segments: node.segments.slice(),
236
+ depth: Math.max(0, node.segments.length - 1),
237
+ isActive: !!isActive,
238
+ isAncestor,
239
+ isExpanded: !!(isActive || isAncestor),
240
+ hasContent: node.hasContent,
241
+ relativePath: node.relativePath,
242
+ children,
243
+ };
244
+ }
245
+
246
+ function getPageInfo(relativePath) {
247
+ const cache = getNavigationCache();
248
+ const normalized = normalizeRelativePath(relativePath);
249
+ const page = cache.pagesByRelative.get(normalized);
250
+ if (page) {
251
+ return {
252
+ title: page.title,
253
+ href: page.href,
254
+ slug: page.slug,
255
+ segments: page.segments.slice(),
256
+ relativePath: page.relativePath,
257
+ rootSegment: page.segments[0] || "",
258
+ isIndex: page.isIndex,
259
+ };
260
+ }
261
+ const {slug, segments} = slugFromRelative(normalized);
262
+ const htmlRel = normalized.replace(/\.mdx$/i, ".html");
263
+ return {
264
+ title: humanizeSegment(segments.slice(-1)[0] || slug || ""),
265
+ href: rootRelativeHref(htmlRel),
266
+ slug,
267
+ segments,
268
+ relativePath: normalized,
269
+ rootSegment: segments[0] || "",
270
+ isIndex: false,
271
+ };
272
+ }
273
+
274
+ function buildNavigationForFile(relativePath) {
275
+ const cache = getNavigationCache();
276
+ const normalized = normalizeRelativePath(relativePath);
277
+ const page = cache.pagesByRelative.get(normalized);
278
+ const fallback = slugFromRelative(normalized);
279
+ const slug = page ? page.slug : fallback.slug;
280
+ const rootSegment = page
281
+ ? page.segments[0] || ""
282
+ : fallback.segments[0] || "";
283
+ if (!slug || !rootSegment || EXCLUDED_ROOTS.has(rootSegment)) {
284
+ return null;
285
+ }
286
+ const rootNode = cache.roots.get(rootSegment);
287
+ if (!rootNode) return null;
288
+ const cloned = cloneNode(rootNode, slug);
289
+ if (!cloned) return null;
290
+ return {
291
+ rootSegment,
292
+ currentSlug: slug,
293
+ root: cloned,
294
+ title: cloned.title,
295
+ };
296
+ }
297
+
298
+ function resetNavigationCache() {
299
+ NAV_CACHE = null;
300
+ }
301
+
302
+ module.exports = {
303
+ normalizeRelativePath,
304
+ getPageInfo,
305
+ buildNavigationForFile,
306
+ resetNavigationCache,
307
+ getPageContext,
308
+ };
@@ -0,0 +1,14 @@
1
+ const React = require('react');
2
+
3
+ let PageContext = null;
4
+
5
+ function getPageContext() {
6
+ if (!PageContext) {
7
+ PageContext = React.createContext({ navigation: null, page: null });
8
+ }
9
+ return PageContext;
10
+ }
11
+
12
+ module.exports = {
13
+ getPageContext,
14
+ };
@@ -681,7 +681,7 @@ function parseProps(el) {
681
681
  function bindSearchInputToStore() {
682
682
  if (!store || typeof document === "undefined") return;
683
683
  try {
684
- const input = document.querySelector("[data-canopy-command-input]");
684
+ const input = document.querySelector("[data-canopy-search-form-input]");
685
685
  if (!input || input.dataset.canopySearchSync === "1") return;
686
686
  input.dataset.canopySearchSync = "1";
687
687
 
@@ -764,7 +764,7 @@ if (typeof document !== "undefined") {
764
764
  const q =
765
765
  ev && ev.detail && typeof ev.detail.query === "string"
766
766
  ? ev.detail.query
767
- : document.querySelector("[data-canopy-command-input]")?.value ||
767
+ : document.querySelector("[data-canopy-search-form-input]")?.value ||
768
768
  "";
769
769
  if (typeof q === "string") store.setQuery(q);
770
770
  } catch (_) {}
@@ -217,7 +217,7 @@ function bindKeyboardNavigation({ input, list, panel }) {
217
217
  });
218
218
  }
219
219
 
220
- async function attachCommand(host) {
220
+ async function attachSearchForm(host) {
221
221
  const config = parseProps(host) || {};
222
222
  const maxResults = Number(config.maxResults || 8) || 8;
223
223
  const groupOrder = Array.isArray(config.groupOrder) ? config.groupOrder : ['work', 'page'];
@@ -225,14 +225,14 @@ async function attachCommand(host) {
225
225
  const onSearchPage = isOnSearchPage();
226
226
 
227
227
  const panel = (() => {
228
- try { return host.querySelector('[data-canopy-command-panel]'); } catch (_) { return null; }
228
+ try { return host.querySelector('[data-canopy-search-form-panel]'); } catch (_) { return null; }
229
229
  })();
230
230
  if (!panel) return;
231
231
 
232
232
  if (!onSearchPage) {
233
233
  try {
234
234
  const wrapper = host.querySelector('.relative');
235
- if (wrapper) wrapper.setAttribute('data-canopy-panel-auto', '1');
235
+ if (wrapper) wrapper.setAttribute('data-canopy-search-form-auto', '1');
236
236
  } catch (_) {}
237
237
  }
238
238
 
@@ -244,7 +244,7 @@ async function attachCommand(host) {
244
244
  if (!list) return;
245
245
 
246
246
  const input = (() => {
247
- try { return host.querySelector('[data-canopy-command-input]'); } catch (_) { return null; }
247
+ try { return host.querySelector('[data-canopy-search-form-input]'); } catch (_) { return null; }
248
248
  })();
249
249
  if (!input) return;
250
250
 
@@ -345,9 +345,9 @@ async function attachCommand(host) {
345
345
  }
346
346
 
347
347
  host.addEventListener('click', (event) => {
348
- const trigger = event.target && event.target.closest && event.target.closest('[data-canopy-command-trigger]');
348
+ const trigger = event.target && event.target.closest && event.target.closest('[data-canopy-search-form-trigger]');
349
349
  if (!trigger) return;
350
- const mode = (trigger.dataset && trigger.dataset.canopyCommandTrigger) || '';
350
+ const mode = (trigger.dataset && trigger.dataset.canopySearchFormTrigger) || '';
351
351
  if (mode === 'submit' || mode === 'form') return;
352
352
  event.preventDefault();
353
353
  openPanel();
@@ -359,12 +359,12 @@ async function attachCommand(host) {
359
359
  }
360
360
 
361
361
  ready(() => {
362
- const hosts = Array.from(document.querySelectorAll('[data-canopy-command]'));
362
+ const hosts = Array.from(document.querySelectorAll('[data-canopy-search-form]'));
363
363
  if (!hosts.length) return;
364
364
  hosts.forEach((host) => {
365
- attachCommand(host).catch((err) => {
365
+ attachSearchForm(host).catch((err) => {
366
366
  try {
367
- console.warn('[canopy][command] failed to initialise', err && (err.message || err));
367
+ console.warn('[canopy][search-form] failed to initialise', err && (err.message || err));
368
368
  } catch (_) {}
369
369
  });
370
370
  });
@@ -158,13 +158,13 @@ async function buildSearchPage() {
158
158
  // Include react-globals vendor shim before search.js to provide window.React globals
159
159
  const vendorReactAbs = path.join(OUT_DIR, 'scripts', 'react-globals.js');
160
160
  const vendorFlexAbs = path.join(OUT_DIR, 'scripts', 'flexsearch-globals.js');
161
- const vendorCommandAbs = path.join(OUT_DIR, 'scripts', 'canopy-command.js');
161
+ const vendorSearchFormAbs = path.join(OUT_DIR, 'scripts', 'canopy-search-form.js');
162
162
  function verRel(abs) {
163
163
  let rel = path.relative(path.dirname(outPath), abs).split(path.sep).join('/');
164
164
  try { const st = require('fs').statSync(abs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
165
165
  return rel;
166
166
  }
167
- const vendorTags = `<script src="${verRel(vendorReactAbs)}"></script><script src="${verRel(vendorFlexAbs)}"></script><script src="${verRel(vendorCommandAbs)}"></script>`;
167
+ const vendorTags = `<script src="${verRel(vendorReactAbs)}"></script><script src="${verRel(vendorFlexAbs)}"></script><script src="${verRel(vendorSearchFormAbs)}"></script>`;
168
168
  let headExtra = vendorTags + head + importMap;
169
169
  try {
170
170
  const { BASE_PATH } = require('../common');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",