@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
package/lib/AGENTS.md CHANGED
@@ -56,7 +56,7 @@ Logbook Template
56
56
  Logbook
57
57
  -------
58
58
  - 2025-09-26 / chatgpt: Hardened runtime bundlers to throw when esbuild or source compilation fails and required `content/works/_layout.mdx`; build now aborts instead of silently writing placeholder assets.
59
- - 2025-09-26 / chatgpt: Replaced the legacy command runtime stub with an esbuild-bundled runtime (`search/command-runtime.js`); `prepareCommandRuntime()` now builds `site/scripts/canopy-command.js` and fails if esbuild is missing.
59
+ - 2025-09-26 / chatgpt: Replaced the legacy command runtime stub with an esbuild-bundled runtime (`search/search-form-runtime.js`); `prepareSearchFormRuntime()` now builds `site/scripts/canopy-search-form.js` and fails if esbuild is missing.
60
60
  - 2025-09-27 / chatgpt: Documented Tailwind token flow in `app/styles/tailwind.config.js`, compiled UI Sass variables during config load, and exposed `stylesheetHref`/`Stylesheet` helpers via `@canopy-iiif/app/head` so `_app.mdx` can reference the generated CSS directly.
61
61
  - 2025-09-27 / chatgpt: Expanded search indexing to harvest MDX pages (respecting frontmatter/layout types), injected BASE_PATH hydration data into search.html, and reworked `mdx.extractTitle()` so generated records surface real headings instead of `Untitled`.
62
62
 
@@ -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 });
@@ -62,6 +64,14 @@ async function build(options = {}) {
62
64
  if (!skipIiif) {
63
65
  const results = await iiif.buildIiifCollectionPages(CONFIG);
64
66
  iiifRecords = results?.iiifRecords;
67
+ iiifRecordsCache = Array.isArray(iiifRecords) ? iiifRecords : [];
68
+ } else {
69
+ iiifRecords = Array.isArray(iiifRecordsCache) ? iiifRecordsCache : [];
70
+ logLine(
71
+ `• Reusing cached IIIF search records (${iiifRecords.length})`,
72
+ "blue",
73
+ { dim: true }
74
+ );
65
75
  }
66
76
  // Ensure any configured featured manifests are cached (and thumbnails computed)
67
77
  // so SSR components like <Hero /> can resolve items even if they are not part of
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) {
@@ -969,17 +985,16 @@ async function dev() {
969
985
  child = startTailwindWatcher();
970
986
 
971
987
  const uiPlugin = path.join(
972
- __dirname,
973
- "../ui",
988
+ APP_UI_DIR,
974
989
  "tailwind-canopy-iiif-plugin.js"
975
990
  );
976
991
  const uiPreset = path.join(
977
- __dirname,
978
- "../ui",
992
+ APP_UI_DIR,
979
993
  "tailwind-canopy-iiif-preset.js"
980
994
  );
981
- const uiStylesDir = path.join(__dirname, "../ui", "styles");
982
- const files = [uiPlugin, uiPreset].filter((p) => {
995
+ const uiStylesDir = path.join(APP_UI_DIR, "styles");
996
+ const uiStylesCss = path.join(uiStylesDir, "index.css");
997
+ const pluginFiles = [uiPlugin, uiPreset].filter((p) => {
983
998
  try {
984
999
  return fs.existsSync(p);
985
1000
  } catch (_) {
@@ -987,25 +1002,54 @@ async function dev() {
987
1002
  }
988
1003
  });
989
1004
  let restartTimer = null;
990
- const restart = () => {
1005
+ let uiCssWatcherAttached = false;
1006
+ const scheduleTailwindRestart = (message, compileLabel) => {
991
1007
  clearTimeout(restartTimer);
992
1008
  restartTimer = setTimeout(() => {
993
- console.log(
994
- "[tailwind] detected UI plugin/preset change — restarting Tailwind"
995
- );
1009
+ if (message) console.log(message);
996
1010
  try {
997
1011
  if (child && !child.killed) child.kill();
998
1012
  } catch (_) {}
999
- safeCompile("[tailwind] compile after plugin change failed");
1013
+ safeCompile(compileLabel);
1000
1014
  child = startTailwindWatcher();
1001
1015
  try { onBuildStart(); } catch (_) {}
1002
- }, 50);
1016
+ }, 120);
1003
1017
  };
1004
- for (const f of files) {
1018
+ for (const f of pluginFiles) {
1005
1019
  try {
1006
- fs.watch(f, { persistent: false }, restart);
1020
+ fs.watch(f, { persistent: false }, () => {
1021
+ scheduleTailwindRestart(
1022
+ "[tailwind] detected UI plugin/preset change — restarting Tailwind",
1023
+ "[tailwind] compile after plugin change failed"
1024
+ );
1025
+ });
1007
1026
  } catch (_) {}
1008
1027
  }
1028
+ const attachCssWatcher = () => {
1029
+ if (uiCssWatcherAttached) {
1030
+ if (fs.existsSync(uiStylesCss)) return;
1031
+ uiCssWatcherAttached = false;
1032
+ }
1033
+ if (!fs.existsSync(uiStylesCss)) return;
1034
+ const handler = () =>
1035
+ scheduleTailwindRestart(
1036
+ "[tailwind] detected @canopy-iiif/app/ui styles change — restarting Tailwind",
1037
+ "[tailwind] compile after UI styles change failed"
1038
+ );
1039
+ try {
1040
+ const watcher = fs.watch(uiStylesCss, { persistent: false }, handler);
1041
+ uiCssWatcherAttached = true;
1042
+ watcher.on("close", () => {
1043
+ uiCssWatcherAttached = false;
1044
+ });
1045
+ } catch (_) {
1046
+ try {
1047
+ fs.watchFile(uiStylesCss, { interval: 250 }, handler);
1048
+ uiCssWatcherAttached = true;
1049
+ } catch (_) {}
1050
+ }
1051
+ };
1052
+ attachCssWatcher();
1009
1053
  if (fs.existsSync(uiStylesDir)) {
1010
1054
  try {
1011
1055
  fs.watch(
@@ -1013,7 +1057,7 @@ async function dev() {
1013
1057
  { persistent: false, recursive: true },
1014
1058
  (evt, fn) => {
1015
1059
  try {
1016
- if (fn && /\.s[ac]ss$/i.test(String(fn))) restart();
1060
+ if (fn && /\.s[ac]ss$/i.test(String(fn))) attachCssWatcher();
1017
1061
  } catch (_) {}
1018
1062
  }
1019
1063
  );
@@ -1027,8 +1071,9 @@ async function dev() {
1027
1071
  { persistent: false },
1028
1072
  (evt, fn) => {
1029
1073
  try {
1030
- if (fn && /\.s[ac]ss$/i.test(String(fn))) restart();
1074
+ if (fn && /\.s[ac]ss$/i.test(String(fn))) attachCssWatcher();
1031
1075
  } catch (_) {}
1076
+ scan(dir);
1032
1077
  }
1033
1078
  );
1034
1079
  watchers.set(dir, w);
@@ -1055,8 +1100,10 @@ async function dev() {
1055
1100
  if (fs.existsSync(configPath)) {
1056
1101
  try {
1057
1102
  fs.watch(configPath, { persistent: false }, () => {
1058
- console.log("[tailwind] tailwind.config change — restarting Tailwind");
1059
- restart();
1103
+ scheduleTailwindRestart(
1104
+ "[tailwind] tailwind.config change — restarting Tailwind",
1105
+ "[tailwind] compile after config change failed"
1106
+ );
1060
1107
  });
1061
1108
  } catch (_) {}
1062
1109
  }
package/lib/build/iiif.js CHANGED
@@ -32,6 +32,34 @@ const IIIF_CACHE_INDEX_MANIFESTS = path.join(
32
32
  "manifest-index.json"
33
33
  );
34
34
 
35
+ const DEFAULT_THUMBNAIL_SIZE = 400;
36
+ const DEFAULT_CHUNK_SIZE = 20;
37
+ const DEFAULT_FETCH_CONCURRENCY = 5;
38
+
39
+ function resolvePositiveInteger(value, fallback) {
40
+ const num = Number(value);
41
+ if (Number.isFinite(num) && num > 0) return Math.max(1, Math.floor(num));
42
+ return Math.max(1, Math.floor(fallback));
43
+ }
44
+
45
+ function resolveBoolean(value) {
46
+ if (typeof value === "boolean") return value;
47
+ if (value === undefined || value === null) return false;
48
+ const normalized = String(value).trim().toLowerCase();
49
+ if (!normalized) return false;
50
+ return normalized === "1" || normalized === "true" || normalized === "yes";
51
+ }
52
+
53
+ function resolveThumbnailPreferences() {
54
+ return {
55
+ size: resolvePositiveInteger(
56
+ process.env.CANOPY_THUMBNAIL_SIZE,
57
+ DEFAULT_THUMBNAIL_SIZE
58
+ ),
59
+ unsafe: resolveBoolean(process.env.CANOPY_THUMBNAILS_UNSAFE),
60
+ };
61
+ }
62
+
35
63
  function firstLabelString(label) {
36
64
  if (!label) return "Untitled";
37
65
  if (typeof label === "string") return label;
@@ -373,10 +401,9 @@ async function ensureFeaturedInCache(cfg) {
373
401
  const CONFIG = cfg || (await loadConfig());
374
402
  const featured = Array.isArray(CONFIG && CONFIG.featured) ? CONFIG.featured : [];
375
403
  if (!featured.length) return;
376
- const { getThumbnail } = require("../iiif/thumbnail");
377
- // Thumbnail sizing config
378
- const thumbSize = CONFIG && CONFIG.iiif && CONFIG.iiif.thumbnails && typeof CONFIG.iiif.thumbnails.preferredSize === 'number' ? CONFIG.iiif.thumbnails.preferredSize : 400;
379
- const unsafeThumbs = !!(CONFIG && CONFIG.iiif && CONFIG.iiif.thumbnails && (CONFIG.iiif.thumbnails.unsafe === true || CONFIG.iiif.thumbnails.unsafe === 'true'));
404
+ const { getThumbnail, getRepresentativeImage } = require("../iiif/thumbnail");
405
+ const { size: thumbSize, unsafe: unsafeThumbs } = resolveThumbnailPreferences();
406
+ const HERO_THUMBNAIL_SIZE = 1200;
380
407
  for (const rawId of featured) {
381
408
  const id = normalizeIiifId(String(rawId || ''));
382
409
  if (!id) continue;
@@ -392,18 +419,88 @@ async function ensureFeaturedInCache(cfg) {
392
419
  // Ensure thumbnail fields exist in index for this manifest (if computable)
393
420
  try {
394
421
  const t = await getThumbnail(manifest, thumbSize, unsafeThumbs);
422
+ const idx = await loadManifestIndex();
423
+ if (!Array.isArray(idx.byId)) continue;
424
+ const entry = idx.byId.find(
425
+ (e) =>
426
+ e &&
427
+ e.type === 'Manifest' &&
428
+ normalizeIiifId(String(e.id)) === normalizeIiifId(String(manifest.id))
429
+ );
430
+ if (!entry) continue;
431
+
432
+ let touched = false;
395
433
  if (t && t.url) {
396
- const idx = await loadManifestIndex();
397
- if (Array.isArray(idx.byId)) {
398
- const entry = idx.byId.find((e) => e && e.type === 'Manifest' && normalizeIiifId(String(e.id)) === normalizeIiifId(String(manifest.id)));
399
- if (entry) {
400
- entry.thumbnail = String(t.url);
401
- if (typeof t.width === 'number') entry.thumbnailWidth = t.width;
402
- if (typeof t.height === 'number') entry.thumbnailHeight = t.height;
403
- await saveManifestIndex(idx);
404
- }
434
+ const nextUrl = String(t.url);
435
+ if (entry.thumbnail !== nextUrl) {
436
+ entry.thumbnail = nextUrl;
437
+ touched = true;
438
+ }
439
+ if (typeof t.width === 'number') {
440
+ if (entry.thumbnailWidth !== t.width) touched = true;
441
+ entry.thumbnailWidth = t.width;
442
+ }
443
+ if (typeof t.height === 'number') {
444
+ if (entry.thumbnailHeight !== t.height) touched = true;
445
+ entry.thumbnailHeight = t.height;
405
446
  }
406
447
  }
448
+
449
+ try {
450
+ const heroSource = (() => {
451
+ if (manifest && manifest.thumbnail) {
452
+ const clone = { ...manifest };
453
+ try {
454
+ delete clone.thumbnail;
455
+ } catch (_) {
456
+ clone.thumbnail = undefined;
457
+ }
458
+ return clone;
459
+ }
460
+ return manifest;
461
+ })();
462
+ const heroRep = await getRepresentativeImage(
463
+ heroSource || manifest,
464
+ HERO_THUMBNAIL_SIZE,
465
+ true
466
+ );
467
+ if (heroRep && heroRep.id) {
468
+ const nextHero = String(heroRep.id);
469
+ if (entry.heroThumbnail !== nextHero) {
470
+ entry.heroThumbnail = nextHero;
471
+ touched = true;
472
+ }
473
+ if (typeof heroRep.width === 'number') {
474
+ if (entry.heroThumbnailWidth !== heroRep.width) touched = true;
475
+ entry.heroThumbnailWidth = heroRep.width;
476
+ } else if (entry.heroThumbnailWidth !== undefined) {
477
+ delete entry.heroThumbnailWidth;
478
+ touched = true;
479
+ }
480
+ if (typeof heroRep.height === 'number') {
481
+ if (entry.heroThumbnailHeight !== heroRep.height) touched = true;
482
+ entry.heroThumbnailHeight = heroRep.height;
483
+ } else if (entry.heroThumbnailHeight !== undefined) {
484
+ delete entry.heroThumbnailHeight;
485
+ touched = true;
486
+ }
487
+ } else {
488
+ if (entry.heroThumbnail !== undefined) {
489
+ delete entry.heroThumbnail;
490
+ touched = true;
491
+ }
492
+ if (entry.heroThumbnailWidth !== undefined) {
493
+ delete entry.heroThumbnailWidth;
494
+ touched = true;
495
+ }
496
+ if (entry.heroThumbnailHeight !== undefined) {
497
+ delete entry.heroThumbnailHeight;
498
+ touched = true;
499
+ }
500
+ }
501
+ } catch (_) {}
502
+
503
+ if (touched) await saveManifestIndex(idx);
407
504
  } catch (_) {}
408
505
  }
409
506
  } catch (err) {
@@ -613,11 +710,9 @@ async function buildIiifCollectionPages(CONFIG) {
613
710
  if (!tasks.length) return { searchRecords: [] };
614
711
 
615
712
  // Split into chunks and process with limited concurrency
616
- const chunkSize = Math.max(
617
- 1,
618
- Number(
619
- process.env.CANOPY_CHUNK_SIZE || (cfg.iiif && cfg.iiif.chunkSize) || 20
620
- )
713
+ const chunkSize = resolvePositiveInteger(
714
+ process.env.CANOPY_CHUNK_SIZE,
715
+ DEFAULT_CHUNK_SIZE
621
716
  );
622
717
  const chunks = Math.ceil(tasks.length / chunkSize);
623
718
  // Summary before processing chunks
@@ -630,18 +725,8 @@ async function buildIiifCollectionPages(CONFIG) {
630
725
  );
631
726
  } catch (_) {}
632
727
  const iiifRecords = [];
633
- const unsafeThumbs = !!(
634
- cfg &&
635
- cfg.iiif &&
636
- cfg.iiif.thumbnails &&
637
- cfg.iiif.thumbnails.unsafe === true
638
- );
639
- const thumbSize =
640
- (cfg &&
641
- cfg.iiif &&
642
- cfg.iiif.thumbnails &&
643
- cfg.iiif.thumbnails.preferredSize) ||
644
- 1200;
728
+ const { size: thumbSize, unsafe: unsafeThumbs } =
729
+ resolveThumbnailPreferences();
645
730
 
646
731
  // Compile the works layout component once per run
647
732
  const worksLayoutPath = path.join(CONTENT_DIR, "works", "_layout.mdx");
@@ -664,13 +749,9 @@ async function buildIiifCollectionPages(CONFIG) {
664
749
  const chunk = tasks.slice(ci * chunkSize, (ci + 1) * chunkSize);
665
750
  logLine(`• Chunk ${ci + 1}/${chunks}`, "blue", { dim: true });
666
751
 
667
- const concurrency = Math.max(
668
- 1,
669
- Number(
670
- process.env.CANOPY_FETCH_CONCURRENCY ||
671
- (cfg.iiif && cfg.iiif.concurrency) ||
672
- 6
673
- )
752
+ const concurrency = resolvePositiveInteger(
753
+ process.env.CANOPY_FETCH_CONCURRENCY,
754
+ DEFAULT_FETCH_CONCURRENCY
674
755
  );
675
756
  let next = 0;
676
757
  const logs = new Array(chunk.length);
@@ -831,12 +912,12 @@ async function buildIiifCollectionPages(CONFIG) {
831
912
  const needsHydrateViewer = body.includes("data-canopy-viewer");
832
913
  const needsRelated = body.includes("data-canopy-related-items");
833
914
  const needsHero = body.includes("data-canopy-hero");
834
- const needsCommand = body.includes("data-canopy-command");
915
+ const needsSearchForm = body.includes("data-canopy-search-form");
835
916
  const needsHydrate =
836
917
  body.includes("data-canopy-hydrate") ||
837
918
  needsHydrateViewer ||
838
919
  needsRelated ||
839
- needsCommand;
920
+ needsSearchForm;
840
921
 
841
922
  const viewerRel = needsHydrateViewer
842
923
  ? path
@@ -874,11 +955,11 @@ async function buildIiifCollectionPages(CONFIG) {
874
955
  .split(path.sep)
875
956
  .join("/")
876
957
  : null;
877
- const commandRel = needsCommand
958
+ const searchFormRel = needsSearchForm
878
959
  ? path
879
960
  .relative(
880
961
  path.dirname(outPath),
881
- path.join(OUT_DIR, "scripts", "canopy-command.js")
962
+ path.join(OUT_DIR, "scripts", "canopy-search-form.js")
882
963
  )
883
964
  .split(path.sep)
884
965
  .join("/")
@@ -919,8 +1000,8 @@ async function buildIiifCollectionPages(CONFIG) {
919
1000
  extraScripts.push(`<script defer src="${viewerRel}"></script>`);
920
1001
  if (sliderRel && jsRel !== sliderRel)
921
1002
  extraScripts.push(`<script defer src="${sliderRel}"></script>`);
922
- if (commandRel && jsRel !== commandRel)
923
- extraScripts.push(`<script defer src="${commandRel}"></script>`);
1003
+ if (searchFormRel && jsRel !== searchFormRel)
1004
+ extraScripts.push(`<script defer src="${searchFormRel}"></script>`);
924
1005
  if (extraScripts.length)
925
1006
  headExtra = extraScripts.join("") + headExtra;
926
1007
  try {
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 || "");
@@ -112,7 +113,7 @@ async function loadUiComponents() {
112
113
  }
113
114
  let comp = (mod && typeof mod === 'object') ? mod : {};
114
115
  // Hard-require core exports; do not inject fallbacks
115
- const required = ['SearchPanel', 'CommandPalette', 'SearchResults', 'SearchSummary', 'SearchTabs', 'Viewer', 'Slider', 'RelatedItems', 'Hero', 'FeaturedHero'];
116
+ const required = ['SearchPanel', 'SearchFormModal', 'SearchResults', 'SearchSummary', 'SearchTabs', 'Viewer', 'Slider', 'RelatedItems', 'Hero', 'FeaturedHero'];
116
117
  const missing = required.filter((k) => !comp || !comp[k]);
117
118
  if (missing.length) {
118
119
  throw new Error('[canopy][mdx] Missing UI exports: ' + missing.join(', '));
@@ -123,7 +124,7 @@ async function loadUiComponents() {
123
124
  mtime: currentMtime,
124
125
  hasServerExport: !!mod,
125
126
  hasWorkspace: typeof comp !== 'undefined',
126
- CommandPalette: !!comp.CommandPalette,
127
+ SearchFormModal: !!comp.SearchFormModal,
127
128
  Viewer: !!comp.Viewer,
128
129
  Slider: !!comp.Slider,
129
130
  }); } catch(_){}
@@ -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,10 +45,23 @@ 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
- const needsCommand = true; // command runtime is global
64
+ const needsSearchForm = true; // search form runtime is global
51
65
  const needsFacets = body.includes('data-canopy-related-items');
52
66
  const viewerRel = needsHydrateViewer
53
67
  ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-viewer.js')).split(path.sep).join('/')
@@ -58,12 +72,12 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
58
72
  const facetsRel = needsFacets
59
73
  ? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-related-items.js')).split(path.sep).join('/')
60
74
  : null;
61
- let commandRel = null;
62
- if (needsCommand) {
63
- const cmdAbs = path.join(OUT_DIR, 'scripts', 'canopy-command.js');
64
- let rel = path.relative(path.dirname(outPath), cmdAbs).split(path.sep).join('/');
65
- try { const st = fs.statSync(cmdAbs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
66
- commandRel = rel;
75
+ let searchFormRel = null;
76
+ if (needsSearchForm) {
77
+ const runtimeAbs = path.join(OUT_DIR, 'scripts', 'canopy-search-form.js');
78
+ let rel = path.relative(path.dirname(outPath), runtimeAbs).split(path.sep).join('/');
79
+ try { const st = fs.statSync(runtimeAbs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
80
+ searchFormRel = rel;
67
81
  }
68
82
  let jsRel = null;
69
83
  if (needsFacets && sliderRel) jsRel = sliderRel;
@@ -90,7 +104,7 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
90
104
  if (facetsRel && jsRel !== facetsRel) extraScripts.push(`<script defer src="${facetsRel}"></script>`);
91
105
  if (viewerRel && jsRel !== viewerRel) extraScripts.push(`<script defer src="${viewerRel}"></script>`);
92
106
  if (sliderRel && jsRel !== sliderRel) extraScripts.push(`<script defer src="${sliderRel}"></script>`);
93
- if (commandRel && jsRel !== commandRel) extraScripts.push(`<script defer src="${commandRel}"></script>`);
107
+ if (searchFormRel && jsRel !== searchFormRel) extraScripts.push(`<script defer src="${searchFormRel}"></script>`);
94
108
  if (extraScripts.length) headExtra = extraScripts.join('') + headExtra;
95
109
  const html = htmlShell({ title, body, cssHref: null, scriptHref: jsRel, headExtra: vendorTag + headExtra });
96
110
  const { applyBaseToHtml } = require('../common');
@@ -12,7 +12,7 @@ async function prepareAllRuntimes() {
12
12
  } catch (_) {}
13
13
  try { if (typeof mdx.ensureFacetsRuntime === 'function') await mdx.ensureFacetsRuntime(); } catch (_) {}
14
14
  try { if (typeof mdx.ensureReactGlobals === 'function') await mdx.ensureReactGlobals(); } catch (_) {}
15
- await prepareCommandRuntime();
15
+ await prepareSearchFormRuntime();
16
16
  try { logLine('✓ Prepared client hydration runtimes', 'cyan', { dim: true }); } catch (_) {}
17
17
  }
18
18
 
@@ -24,14 +24,14 @@ async function resolveEsbuild() {
24
24
  }
25
25
  }
26
26
 
27
- async function prepareCommandRuntime() {
27
+ async function prepareSearchFormRuntime() {
28
28
  const esbuild = await resolveEsbuild();
29
- if (!esbuild) throw new Error('Command runtime bundling requires esbuild. Install dependencies before building.');
29
+ if (!esbuild) throw new Error('Search form runtime bundling requires esbuild. Install dependencies before building.');
30
30
  ensureDirSync(OUT_DIR);
31
31
  const scriptsDir = path.join(OUT_DIR, 'scripts');
32
32
  ensureDirSync(scriptsDir);
33
- const entry = path.join(__dirname, '..', 'search', 'command-runtime.js');
34
- const outFile = path.join(scriptsDir, 'canopy-command.js');
33
+ const entry = path.join(__dirname, '..', 'search', 'search-form-runtime.js');
34
+ const outFile = path.join(scriptsDir, 'canopy-search-form.js');
35
35
  await esbuild.build({
36
36
  entryPoints: [entry],
37
37
  outfile: outFile,
@@ -68,4 +68,4 @@ async function prepareSearchRuntime(timeoutMs = 10000, label = '') {
68
68
  }
69
69
  }
70
70
 
71
- module.exports = { prepareAllRuntimes, prepareCommandRuntime, prepareSearchRuntime };
71
+ module.exports = { prepareAllRuntimes, prepareSearchFormRuntime, prepareSearchRuntime };
@@ -188,23 +188,20 @@ async function collectMdxPageRecords() {
188
188
  let include = !underSearch;
189
189
  let resolvedType = null;
190
190
  const pageFm = fm && fm.data ? fm.data : null;
191
- if (pageFm) {
192
- if (pageFm.search === false) include = false;
193
- if (Object.prototype.hasOwnProperty.call(pageFm, 'type')) {
194
- if (pageFm.type) resolvedType = String(pageFm.type);
195
- else include = false;
196
- } else {
197
- include = false; // frontmatter present w/o type => exclude per policy
198
- }
191
+ if (pageFm && pageFm.search === false) include = false;
192
+ if (include && pageFm && Object.prototype.hasOwnProperty.call(pageFm, 'type')) {
193
+ if (pageFm.type) resolvedType = String(pageFm.type);
194
+ else include = false;
199
195
  }
200
196
  if (include && !resolvedType) {
201
197
  const layoutMeta = await pagesHelpers.getNearestDirLayoutMeta(p);
202
198
  if (layoutMeta && layoutMeta.type) resolvedType = String(layoutMeta.type);
203
199
  }
204
200
  if (include && !resolvedType) {
205
- if (!pageFm) resolvedType = 'page';
201
+ resolvedType = 'page';
206
202
  }
207
- pages.push({ title, href, searchInclude: include && !!resolvedType, searchType: resolvedType || undefined });
203
+ const trimmedType = resolvedType && String(resolvedType).trim();
204
+ pages.push({ title, href, searchInclude: include && !!trimmedType, searchType: trimmedType || undefined });
208
205
  }
209
206
  }
210
207
  }