@canopy-iiif/app 0.8.3 → 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.
@@ -20,6 +20,7 @@ const { ensureStyles } = require("./styles");
20
20
  const { copyAssets } = require("./assets");
21
21
  const { logLine } = require("./log");
22
22
  const { verifyBuildOutput } = require("./verify");
23
+ const navigation = require("../components/navigation");
23
24
 
24
25
  // hold records between builds if skipping IIIF
25
26
  let iiifRecordsCache = [];
@@ -41,6 +42,7 @@ async function build(options = {}) {
41
42
  });
42
43
  logLine("• Reset MDX cache", "blue", { dim: true });
43
44
  mdx?.resetMdxCaches();
45
+ navigation?.resetNavigationCache?.();
44
46
  if (!skipIiif) {
45
47
  await cleanDir(OUT_DIR);
46
48
  logLine(`• Cleaned output directory`, "blue", { dim: true });
package/lib/build/dev.js CHANGED
@@ -617,29 +617,45 @@ function startServer() {
617
617
  }
618
618
  if (pathname === "/") pathname = "/index.html";
619
619
 
620
- // Resolve candidate paths in order:
621
- // 1) as-is
622
- // 2) add .html for extensionless
623
- // 3) if a directory, use its index.html
624
- let filePath = null;
625
- const candidateA = path.join(OUT_DIR, pathname);
626
- const candidateB = path.join(OUT_DIR, pathname + ".html");
627
- if (fs.existsSync(candidateA)) {
628
- filePath = candidateA;
629
- } else if (fs.existsSync(candidateB)) {
630
- filePath = candidateB;
620
+ function toSitePath(p) {
621
+ const rel = p.startsWith("/") ? `.${p}` : p;
622
+ return path.resolve(OUT_DIR, rel);
631
623
  }
632
- if (!filePath) {
633
- // Try directory index for extensionless or folder routes
634
- const maybeDir = path.join(OUT_DIR, pathname);
635
- if (fs.existsSync(maybeDir)) {
636
- try {
637
- const st = fs.statSync(maybeDir);
638
- if (st.isDirectory()) {
639
- const idx = path.join(maybeDir, "index.html");
640
- if (fs.existsSync(idx)) filePath = idx;
641
- }
642
- } catch (_) {}
624
+
625
+ const ensureLeadingSlash = (p) => (p.startsWith("/") ? p : `/${p}`);
626
+ const basePath = ensureLeadingSlash(pathname);
627
+ const noTrailing = basePath !== "/" ? basePath.replace(/\/+$/, "") : basePath;
628
+ const attempts = [];
629
+
630
+ const primaryPath = noTrailing === "/" ? "/index.html" : noTrailing;
631
+ attempts.push(toSitePath(primaryPath));
632
+
633
+ if (!/\.html$/i.test(primaryPath)) {
634
+ attempts.push(toSitePath(`${primaryPath}.html`));
635
+ }
636
+
637
+ const withoutHtml = primaryPath.replace(/\.html$/i, "");
638
+ attempts.push(toSitePath(`${withoutHtml}/index.html`));
639
+
640
+ let filePath = null;
641
+ for (const candidate of attempts) {
642
+ if (!candidate) continue;
643
+ let stat;
644
+ try {
645
+ stat = fs.statSync(candidate);
646
+ } catch (_) {
647
+ continue;
648
+ }
649
+ if (stat.isFile()) {
650
+ filePath = candidate;
651
+ break;
652
+ }
653
+ if (stat.isDirectory()) {
654
+ const idx = path.join(candidate, "index.html");
655
+ if (fs.existsSync(idx)) {
656
+ filePath = idx;
657
+ break;
658
+ }
643
659
  }
644
660
  }
645
661
  if (!filePath) {
package/lib/build/mdx.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
  withBase,
13
13
  } = require("../common");
14
14
  const yaml = require("js-yaml");
15
+ const { getPageContext } = require("../page-context");
15
16
 
16
17
  function parseFrontmatter(src) {
17
18
  let input = String(src || "");
@@ -280,10 +281,19 @@ async function compileMdxFile(filePath, outPath, Layout, extraProps = {}) {
280
281
  const app = await loadAppWrapper();
281
282
  const dirLayout = await getNearestDirLayout(filePath);
282
283
  const contentNode = React.createElement(MDXContent, extraProps);
284
+ const layoutProps = dirLayout ? { ...extraProps } : null;
283
285
  const withLayout = dirLayout
284
- ? React.createElement(dirLayout, null, contentNode)
286
+ ? React.createElement(dirLayout, layoutProps, contentNode)
285
287
  : contentNode;
286
- const withApp = React.createElement(app.App, null, withLayout);
288
+ const PageContext = getPageContext();
289
+ const contextValue = {
290
+ navigation: extraProps && extraProps.navigation ? extraProps.navigation : null,
291
+ page: extraProps && extraProps.page ? extraProps.page : null,
292
+ };
293
+ const withContext = PageContext
294
+ ? React.createElement(PageContext.Provider, { value: contextValue }, withLayout)
295
+ : withLayout;
296
+ const withApp = React.createElement(app.App, null, withContext);
287
297
  const compMap = { ...components, a: Anchor };
288
298
  const page = MDXProvider
289
299
  ? React.createElement(MDXProvider, { components: compMap }, withApp)
@@ -1,6 +1,7 @@
1
1
  const { fs, fsp, path, CONTENT_DIR, OUT_DIR, ensureDirSync, htmlShell } = require('../common');
2
2
  const { log } = require('./log');
3
3
  const mdx = require('./mdx');
4
+ const navigation = require('../components/navigation');
4
5
 
5
6
  // Cache: dir -> frontmatter data for _layout.mdx in that dir
6
7
  const LAYOUT_META = new Map();
@@ -44,7 +45,20 @@ function mapContentPathToOutput(filePath) {
44
45
  async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
45
46
  const source = await fsp.readFile(filePath, 'utf8');
46
47
  const title = mdx.extractTitle(source);
47
- const { body, head } = await mdx.compileMdxFile(filePath, outPath, null, extraProps);
48
+ const relContentPath = path.relative(CONTENT_DIR, filePath);
49
+ const normalizedRel = navigation.normalizeRelativePath(relContentPath);
50
+ const pageInfo = navigation.getPageInfo(normalizedRel);
51
+ const navData = navigation.buildNavigationForFile(normalizedRel);
52
+ const mergedProps = { ...(extraProps || {}) };
53
+ if (pageInfo) {
54
+ mergedProps.page = mergedProps.page
55
+ ? { ...pageInfo, ...mergedProps.page }
56
+ : pageInfo;
57
+ }
58
+ if (navData && !mergedProps.navigation) {
59
+ mergedProps.navigation = navData;
60
+ }
61
+ const { body, head } = await mdx.compileMdxFile(filePath, outPath, null, mergedProps);
48
62
  const needsHydrateViewer = body.includes('data-canopy-viewer');
49
63
  const needsHydrateSlider = body.includes('data-canopy-slider');
50
64
  const needsSearchForm = true; // search form runtime is global
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",
package/ui/dist/index.mjs CHANGED
@@ -120,7 +120,7 @@ function GridItem({ children, className = "", style = {}, ...rest }) {
120
120
  }
121
121
  function Grid({
122
122
  breakpointCols,
123
- gap = "2rem",
123
+ gap = "1.618rem",
124
124
  paddingY = "0",
125
125
  className = "",
126
126
  style = {},
@@ -391,12 +391,12 @@ function SearchTabs({
391
391
  const toLabel = (t) => t && t.length ? t.charAt(0).toUpperCase() + t.slice(1) : "";
392
392
  const hasFilters = typeof onOpenFilters === "function";
393
393
  const filterBadge = activeFilterCount > 0 ? ` (${activeFilterCount})` : "";
394
- return /* @__PURE__ */ React11.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 pb-1" }, /* @__PURE__ */ React11.createElement(
394
+ return /* @__PURE__ */ React11.createElement("div", { className: "canopy-search-tabs-wrapper" }, /* @__PURE__ */ React11.createElement(
395
395
  "div",
396
396
  {
397
397
  role: "tablist",
398
398
  "aria-label": "Search types",
399
- className: "flex items-center gap-2"
399
+ className: "canopy-search-tabs"
400
400
  },
401
401
  orderedTypes.map((t) => {
402
402
  const active = String(type).toLowerCase() === String(t).toLowerCase();
@@ -409,8 +409,7 @@ function SearchTabs({
409
409
  role: "tab",
410
410
  "aria-selected": active,
411
411
  type: "button",
412
- onClick: () => onTypeChange && onTypeChange(t),
413
- className: "px-3 py-2 text-sm rounded-t-md border-b-2 -mb-px transition-colors " + (active ? "border-brand-600 text-brand-700" : "border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300")
412
+ onClick: () => onTypeChange && onTypeChange(t)
414
413
  },
415
414
  toLabel(t),
416
415
  " (",