@canopy-iiif/app 0.10.2 → 0.10.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.
package/lib/build/iiif.js CHANGED
@@ -15,10 +15,13 @@ const {
15
15
  } = require("../common");
16
16
  const mdx = require("./mdx");
17
17
  const {log, logLine, logResponse} = require("./log");
18
+ const { getPageContext } = require("../page-context");
19
+ const PageContext = getPageContext();
18
20
  const {
19
21
  getThumbnail,
20
22
  getRepresentativeImage,
21
23
  buildIiifImageUrlFromService,
24
+ buildIiifImageUrlForDimensions,
22
25
  findPrimaryCanvasImage,
23
26
  buildIiifImageSrcset,
24
27
  } = require("../iiif/thumbnail");
@@ -44,6 +47,9 @@ const DEFAULT_CHUNK_SIZE = 20;
44
47
  const DEFAULT_FETCH_CONCURRENCY = 5;
45
48
  const HERO_THUMBNAIL_SIZE = 800;
46
49
  const HERO_IMAGE_SIZES_ATTR = "(min-width: 1024px) 1280px, 100vw";
50
+ const OG_IMAGE_WIDTH = 1200;
51
+ const OG_IMAGE_HEIGHT = 630;
52
+ const HERO_REPRESENTATIVE_SIZE = Math.max(HERO_THUMBNAIL_SIZE, OG_IMAGE_WIDTH);
47
53
 
48
54
  function resolvePositiveInteger(value, fallback) {
49
55
  const num = Number(value);
@@ -69,6 +75,73 @@ function resolveThumbnailPreferences() {
69
75
  };
70
76
  }
71
77
 
78
+ async function resolveHeroMedia(manifest) {
79
+ if (!manifest) return null;
80
+ try {
81
+ const heroSource = (() => {
82
+ if (manifest && manifest.thumbnail) {
83
+ const clone = { ...manifest };
84
+ try {
85
+ delete clone.thumbnail;
86
+ } catch (_) {
87
+ clone.thumbnail = undefined;
88
+ }
89
+ return clone;
90
+ }
91
+ return manifest;
92
+ })();
93
+ const heroRep = await getRepresentativeImage(
94
+ heroSource || manifest,
95
+ HERO_REPRESENTATIVE_SIZE,
96
+ true
97
+ );
98
+ const canvasImage = findPrimaryCanvasImage(manifest);
99
+ const heroService =
100
+ (canvasImage && canvasImage.service) ||
101
+ (heroRep && heroRep.service);
102
+ const heroPreferred = buildIiifImageUrlFromService(
103
+ heroService,
104
+ HERO_THUMBNAIL_SIZE
105
+ );
106
+ const heroFallbackId = (() => {
107
+ if (canvasImage && canvasImage.id) return String(canvasImage.id);
108
+ if (heroRep && heroRep.id) return String(heroRep.id);
109
+ return '';
110
+ })();
111
+ const heroWidth = (() => {
112
+ if (canvasImage && typeof canvasImage.width === 'number')
113
+ return canvasImage.width;
114
+ if (heroRep && typeof heroRep.width === 'number') return heroRep.width;
115
+ return undefined;
116
+ })();
117
+ const heroHeight = (() => {
118
+ if (canvasImage && typeof canvasImage.height === 'number')
119
+ return canvasImage.height;
120
+ if (heroRep && typeof heroRep.height === 'number')
121
+ return heroRep.height;
122
+ return undefined;
123
+ })();
124
+ const heroSrcset = buildIiifImageSrcset(heroService);
125
+ const ogImage = heroService
126
+ ? buildIiifImageUrlForDimensions(
127
+ heroService,
128
+ OG_IMAGE_WIDTH,
129
+ OG_IMAGE_HEIGHT
130
+ )
131
+ : '';
132
+ return {
133
+ heroThumbnail: heroPreferred || heroFallbackId || '',
134
+ heroThumbnailWidth: heroWidth,
135
+ heroThumbnailHeight: heroHeight,
136
+ heroThumbnailSrcset: heroSrcset || '',
137
+ heroThumbnailSizes: heroSrcset ? HERO_IMAGE_SIZES_ATTR : '',
138
+ ogImage: ogImage || '',
139
+ };
140
+ } catch (_) {
141
+ return null;
142
+ }
143
+ }
144
+
72
145
  function firstLabelString(label) {
73
146
  if (!label) return "Untitled";
74
147
  if (typeof label === "string") return label;
@@ -133,6 +206,19 @@ function extractSummaryValues(manifest) {
133
206
  return unique.join(" ");
134
207
  }
135
208
 
209
+ function normalizeSummaryText(value) {
210
+ if (!value) return "";
211
+ return String(value).replace(/\s+/g, " ").trim();
212
+ }
213
+
214
+ function truncateSummary(value, max = 240) {
215
+ const normalized = normalizeSummaryText(value);
216
+ if (!normalized) return "";
217
+ if (normalized.length <= max) return normalized;
218
+ const slice = normalized.slice(0, Math.max(0, max - 3)).trimEnd();
219
+ return `${slice}...`;
220
+ }
221
+
136
222
  function stripHtml(value) {
137
223
  try {
138
224
  return String(value || "")
@@ -655,104 +741,59 @@ async function ensureFeaturedInCache(cfg) {
655
741
  }
656
742
 
657
743
  try {
658
- const heroSource = (() => {
659
- if (manifest && manifest.thumbnail) {
660
- const clone = {...manifest};
661
- try {
662
- delete clone.thumbnail;
663
- } catch (_) {
664
- clone.thumbnail = undefined;
665
- }
666
- return clone;
667
- }
668
- return manifest;
669
- })();
670
- const heroRep = await getRepresentativeImage(
671
- heroSource || manifest,
672
- HERO_THUMBNAIL_SIZE,
673
- true
674
- );
675
- const canvasImage = findPrimaryCanvasImage(manifest);
676
- const heroService =
677
- (canvasImage && canvasImage.service) ||
678
- (heroRep && heroRep.service);
679
- const preferredHeroThumbnail = buildIiifImageUrlFromService(
680
- heroService,
681
- HERO_THUMBNAIL_SIZE
682
- );
683
- const heroSrcset = buildIiifImageSrcset(heroService);
684
- const heroFallbackId = (() => {
685
- if (canvasImage && canvasImage.id) return String(canvasImage.id);
686
- if (heroRep && heroRep.id) return String(heroRep.id);
687
- return "";
688
- })();
689
- const heroWidth = (() => {
690
- if (canvasImage && typeof canvasImage.width === "number") {
691
- return canvasImage.width;
692
- }
693
- if (heroRep && typeof heroRep.width === "number") {
694
- return heroRep.width;
695
- }
696
- return undefined;
697
- })();
698
- const heroHeight = (() => {
699
- if (canvasImage && typeof canvasImage.height === "number") {
700
- return canvasImage.height;
701
- }
702
- if (heroRep && typeof heroRep.height === "number") {
703
- return heroRep.height;
704
- }
705
- return undefined;
706
- })();
707
- if (preferredHeroThumbnail || heroFallbackId) {
708
- const nextHero = preferredHeroThumbnail || heroFallbackId;
709
- if (entry.heroThumbnail !== nextHero) {
710
- entry.heroThumbnail = nextHero;
744
+ const heroMedia = await resolveHeroMedia(manifest);
745
+ if (heroMedia && heroMedia.heroThumbnail) {
746
+ if (entry.heroThumbnail !== heroMedia.heroThumbnail) {
747
+ entry.heroThumbnail = heroMedia.heroThumbnail;
711
748
  touched = true;
712
749
  }
713
- if (heroSrcset) {
714
- if (entry.heroThumbnailSrcset !== heroSrcset) touched = true;
715
- entry.heroThumbnailSrcset = heroSrcset;
716
- if (entry.heroThumbnailSizes !== HERO_IMAGE_SIZES_ATTR) touched = true;
717
- entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
718
- } else {
719
- if (entry.heroThumbnailSrcset !== undefined) {
720
- delete entry.heroThumbnailSrcset;
721
- touched = true;
722
- }
723
- if (entry.heroThumbnailSizes !== undefined) {
724
- delete entry.heroThumbnailSizes;
725
- touched = true;
726
- }
727
- }
728
- if (typeof heroWidth === "number") {
729
- if (entry.heroThumbnailWidth !== heroWidth) touched = true;
730
- entry.heroThumbnailWidth = heroWidth;
731
- } else if (entry.heroThumbnailWidth !== undefined) {
732
- delete entry.heroThumbnailWidth;
750
+ } else if (entry.heroThumbnail !== undefined) {
751
+ delete entry.heroThumbnail;
752
+ touched = true;
753
+ }
754
+ if (heroMedia && typeof heroMedia.heroThumbnailWidth === "number") {
755
+ if (entry.heroThumbnailWidth !== heroMedia.heroThumbnailWidth)
733
756
  touched = true;
734
- }
735
- if (typeof heroHeight === "number") {
736
- if (entry.heroThumbnailHeight !== heroHeight) touched = true;
737
- entry.heroThumbnailHeight = heroHeight;
738
- } else if (entry.heroThumbnailHeight !== undefined) {
739
- delete entry.heroThumbnailHeight;
757
+ entry.heroThumbnailWidth = heroMedia.heroThumbnailWidth;
758
+ } else if (entry.heroThumbnailWidth !== undefined) {
759
+ delete entry.heroThumbnailWidth;
760
+ touched = true;
761
+ }
762
+ if (heroMedia && typeof heroMedia.heroThumbnailHeight === "number") {
763
+ if (entry.heroThumbnailHeight !== heroMedia.heroThumbnailHeight)
740
764
  touched = true;
741
- }
742
- } else {
743
- if (entry.heroThumbnail !== undefined) {
744
- delete entry.heroThumbnail;
765
+ entry.heroThumbnailHeight = heroMedia.heroThumbnailHeight;
766
+ } else if (entry.heroThumbnailHeight !== undefined) {
767
+ delete entry.heroThumbnailHeight;
768
+ touched = true;
769
+ }
770
+ if (heroMedia && heroMedia.heroThumbnailSrcset) {
771
+ if (entry.heroThumbnailSrcset !== heroMedia.heroThumbnailSrcset)
745
772
  touched = true;
746
- }
747
- if (entry.heroThumbnailWidth !== undefined) {
748
- delete entry.heroThumbnailWidth;
773
+ entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
774
+ if (entry.heroThumbnailSizes !== HERO_IMAGE_SIZES_ATTR) touched = true;
775
+ entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
776
+ } else {
777
+ if (entry.heroThumbnailSrcset !== undefined) {
778
+ delete entry.heroThumbnailSrcset;
749
779
  touched = true;
750
780
  }
751
- if (entry.heroThumbnailHeight !== undefined) {
752
- delete entry.heroThumbnailHeight;
781
+ if (entry.heroThumbnailSizes !== undefined) {
782
+ delete entry.heroThumbnailSizes;
753
783
  touched = true;
754
784
  }
755
785
  }
786
+ if (heroMedia && heroMedia.ogImage) {
787
+ if (entry.ogImage !== heroMedia.ogImage) touched = true;
788
+ entry.ogImage = heroMedia.ogImage;
789
+ entry.ogImageWidth = OG_IMAGE_WIDTH;
790
+ entry.ogImageHeight = OG_IMAGE_HEIGHT;
791
+ } else if (entry.ogImage !== undefined) {
792
+ delete entry.ogImage;
793
+ delete entry.ogImageWidth;
794
+ delete entry.ogImageHeight;
795
+ touched = true;
796
+ }
756
797
  } catch (_) {}
757
798
 
758
799
  if (touched) await saveManifestIndex(idx);
@@ -962,58 +1003,22 @@ async function rebuildManifestIndexFromCache() {
962
1003
  }
963
1004
  } catch (_) {}
964
1005
  try {
965
- const heroSource = (() => {
966
- if (manifest && manifest.thumbnail) {
967
- const clone = {...manifest};
968
- try {
969
- delete clone.thumbnail;
970
- } catch (_) {
971
- clone.thumbnail = undefined;
972
- }
973
- return clone;
974
- }
975
- return manifest;
976
- })();
977
- const heroRep = await getRepresentativeImage(
978
- heroSource || manifest,
979
- HERO_THUMBNAIL_SIZE,
980
- true
981
- );
982
- const canvasImage = findPrimaryCanvasImage(manifest);
983
- const heroService =
984
- (canvasImage && canvasImage.service) ||
985
- (heroRep && heroRep.service);
986
- const preferredHero = buildIiifImageUrlFromService(
987
- heroService,
988
- HERO_THUMBNAIL_SIZE
989
- );
990
- const heroFallbackId = (() => {
991
- if (canvasImage && canvasImage.id) return String(canvasImage.id);
992
- if (heroRep && heroRep.id) return String(heroRep.id);
993
- return "";
994
- })();
995
- const heroWidth = (() => {
996
- if (canvasImage && typeof canvasImage.width === "number")
997
- return canvasImage.width;
998
- if (heroRep && typeof heroRep.width === "number") return heroRep.width;
999
- return undefined;
1000
- })();
1001
- const heroHeight = (() => {
1002
- if (canvasImage && typeof canvasImage.height === "number")
1003
- return canvasImage.height;
1004
- if (heroRep && typeof heroRep.height === "number")
1005
- return heroRep.height;
1006
- return undefined;
1007
- })();
1008
- if (preferredHero || heroFallbackId) {
1009
- entry.heroThumbnail = preferredHero || heroFallbackId;
1010
- if (typeof heroWidth === "number") entry.heroThumbnailWidth = heroWidth;
1011
- if (typeof heroHeight === "number") entry.heroThumbnailHeight = heroHeight;
1012
- const heroSrcset = buildIiifImageSrcset(heroService);
1013
- if (heroSrcset) {
1014
- entry.heroThumbnailSrcset = heroSrcset;
1006
+ const heroMedia = await resolveHeroMedia(manifest);
1007
+ if (heroMedia && heroMedia.heroThumbnail) {
1008
+ entry.heroThumbnail = heroMedia.heroThumbnail;
1009
+ if (typeof heroMedia.heroThumbnailWidth === "number")
1010
+ entry.heroThumbnailWidth = heroMedia.heroThumbnailWidth;
1011
+ if (typeof heroMedia.heroThumbnailHeight === "number")
1012
+ entry.heroThumbnailHeight = heroMedia.heroThumbnailHeight;
1013
+ if (heroMedia.heroThumbnailSrcset) {
1014
+ entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
1015
1015
  entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
1016
1016
  }
1017
+ if (heroMedia.ogImage) {
1018
+ entry.ogImage = heroMedia.ogImage;
1019
+ entry.ogImageWidth = OG_IMAGE_WIDTH;
1020
+ entry.ogImageHeight = OG_IMAGE_HEIGHT;
1021
+ }
1017
1022
  }
1018
1023
  } catch (_) {}
1019
1024
  nextIndex.byId.push(entry);
@@ -1297,6 +1302,13 @@ async function buildIiifCollectionPages(CONFIG) {
1297
1302
  if (!manifest) continue;
1298
1303
  manifest = await normalizeToV3(manifest);
1299
1304
  const title = firstLabelString(manifest.label);
1305
+ let summaryRaw = '';
1306
+ try {
1307
+ summaryRaw = extractSummaryValues(manifest);
1308
+ } catch (_) {
1309
+ summaryRaw = '';
1310
+ }
1311
+ const summaryForMeta = truncateSummary(summaryRaw || title);
1300
1312
  const baseSlug =
1301
1313
  slugify(title || "untitled", {
1302
1314
  lower: true,
@@ -1354,26 +1366,74 @@ async function buildIiifCollectionPages(CONFIG) {
1354
1366
  const {loadAppWrapper} = require("./mdx");
1355
1367
  const app = await loadAppWrapper();
1356
1368
 
1369
+ let heroMedia = null;
1370
+ try {
1371
+ heroMedia = await resolveHeroMedia(manifest);
1372
+ } catch (_) {
1373
+ heroMedia = null;
1374
+ }
1375
+ const normalizedHref = href.split(path.sep).join("/");
1376
+ const pageHref = rootRelativeHref(normalizedHref);
1377
+ const pageDescription = summaryForMeta || title;
1378
+ const pageDetails = {
1379
+ title,
1380
+ href: pageHref,
1381
+ url: pageHref,
1382
+ slug,
1383
+ type: "work",
1384
+ description: pageDescription,
1385
+ meta: {
1386
+ title,
1387
+ description: pageDescription,
1388
+ type: "work",
1389
+ url: pageHref,
1390
+ },
1391
+ };
1392
+ const ogImageForPage = heroMedia && heroMedia.ogImage ? heroMedia.ogImage : '';
1393
+ if (ogImageForPage) {
1394
+ pageDetails.image = ogImageForPage;
1395
+ pageDetails.ogImage = ogImageForPage;
1396
+ pageDetails.meta.image = ogImageForPage;
1397
+ pageDetails.meta.ogImage = ogImageForPage;
1398
+ }
1399
+ const pageContextValue = { navigation: null, page: pageDetails };
1357
1400
  const mdxContent = React.createElement(WorksLayoutComp, {manifest});
1358
- const siteTree = app && app.App ? mdxContent : mdxContent;
1401
+ const siteTree = mdxContent;
1359
1402
  const wrappedApp =
1360
1403
  app && app.App
1361
1404
  ? React.createElement(app.App, null, siteTree)
1362
1405
  : siteTree;
1406
+ const withContext =
1407
+ PageContext && pageContextValue
1408
+ ? React.createElement(
1409
+ PageContext.Provider,
1410
+ { value: pageContextValue },
1411
+ wrappedApp
1412
+ )
1413
+ : wrappedApp;
1363
1414
  const page = MDXProvider
1364
1415
  ? React.createElement(
1365
1416
  MDXProvider,
1366
1417
  {components: compMap},
1367
- wrappedApp
1418
+ withContext
1368
1419
  )
1369
- : wrappedApp;
1420
+ : withContext;
1370
1421
  const body = ReactDOMServer.renderToStaticMarkup(page);
1371
- const head =
1372
- app && app.Head
1373
- ? ReactDOMServer.renderToStaticMarkup(
1374
- React.createElement(app.Head)
1422
+ let head = "";
1423
+ if (app && app.Head) {
1424
+ const headElement = React.createElement(app.Head, {
1425
+ page: pageContextValue.page,
1426
+ navigation: null,
1427
+ });
1428
+ const wrappedHead = PageContext
1429
+ ? React.createElement(
1430
+ PageContext.Provider,
1431
+ { value: pageContextValue },
1432
+ headElement
1375
1433
  )
1376
- : "";
1434
+ : headElement;
1435
+ head = ReactDOMServer.renderToStaticMarkup(wrappedHead);
1436
+ }
1377
1437
  const needsHydrateViewer =
1378
1438
  body.includes("data-canopy-viewer") ||
1379
1439
  body.includes("data-canopy-scroll") ||
@@ -1438,7 +1498,7 @@ async function buildIiifCollectionPages(CONFIG) {
1438
1498
  else if (needsRelated && sliderRel) jsRel = sliderRel;
1439
1499
  else if (viewerRel) jsRel = viewerRel;
1440
1500
 
1441
- let headExtra = head;
1501
+ const headSegments = [head];
1442
1502
  const needsReact = !!(
1443
1503
  needsHydrateViewer ||
1444
1504
  needsRelated
@@ -1474,7 +1534,7 @@ async function buildIiifCollectionPages(CONFIG) {
1474
1534
  if (searchFormRel && jsRel !== searchFormRel)
1475
1535
  extraScripts.push(`<script defer src="${searchFormRel}"></script>`);
1476
1536
  if (extraScripts.length)
1477
- headExtra = extraScripts.join("") + headExtra;
1537
+ headSegments.push(extraScripts.join(""));
1478
1538
  try {
1479
1539
  const {BASE_PATH} = require("../common");
1480
1540
  if (BASE_PATH)
@@ -1484,12 +1544,13 @@ async function buildIiifCollectionPages(CONFIG) {
1484
1544
  )}</script>` + vendorTag;
1485
1545
  } catch (_) {}
1486
1546
  let pageBody = body;
1547
+ const headExtra = headSegments.join("") + vendorTag;
1487
1548
  let html = htmlShell({
1488
1549
  title,
1489
1550
  body: pageBody,
1490
1551
  cssHref: null,
1491
1552
  scriptHref: jsRel,
1492
- headExtra: vendorTag + headExtra,
1553
+ headExtra,
1493
1554
  });
1494
1555
  try {
1495
1556
  html = require("../common").applyBaseToHtml(html);
@@ -1516,14 +1577,38 @@ async function buildIiifCollectionPages(CONFIG) {
1516
1577
  e.id === String(manifest.id || id) &&
1517
1578
  e.type === "Manifest"
1518
1579
  );
1519
- if (entry) {
1520
- entry.thumbnail = String(thumbUrl);
1521
- if (typeof thumbWidth === "number")
1522
- entry.thumbnailWidth = thumbWidth;
1523
- if (typeof thumbHeight === "number")
1524
- entry.thumbnailHeight = thumbHeight;
1525
- await saveManifestIndex(idx);
1580
+ if (entry) {
1581
+ entry.thumbnail = String(thumbUrl);
1582
+ if (typeof thumbWidth === "number")
1583
+ entry.thumbnailWidth = thumbWidth;
1584
+ if (typeof thumbHeight === "number")
1585
+ entry.thumbnailHeight = thumbHeight;
1586
+ if (heroMedia && heroMedia.heroThumbnail) {
1587
+ entry.heroThumbnail = heroMedia.heroThumbnail;
1588
+ if (typeof heroMedia.heroThumbnailWidth === "number")
1589
+ entry.heroThumbnailWidth = heroMedia.heroThumbnailWidth;
1590
+ if (typeof heroMedia.heroThumbnailHeight === "number")
1591
+ entry.heroThumbnailHeight = heroMedia.heroThumbnailHeight;
1592
+ if (heroMedia.heroThumbnailSrcset) {
1593
+ entry.heroThumbnailSrcset = heroMedia.heroThumbnailSrcset;
1594
+ entry.heroThumbnailSizes = HERO_IMAGE_SIZES_ATTR;
1595
+ }
1596
+ }
1597
+ if (heroMedia && heroMedia.ogImage) {
1598
+ entry.ogImage = heroMedia.ogImage;
1599
+ entry.ogImageWidth = OG_IMAGE_WIDTH;
1600
+ entry.ogImageHeight = OG_IMAGE_HEIGHT;
1601
+ } else {
1602
+ try {
1603
+ if (entry.ogImage !== undefined) delete entry.ogImage;
1604
+ if (entry.ogImageWidth !== undefined)
1605
+ delete entry.ogImageWidth;
1606
+ if (entry.ogImageHeight !== undefined)
1607
+ delete entry.ogImageHeight;
1608
+ } catch (_) {}
1526
1609
  }
1610
+ await saveManifestIndex(idx);
1611
+ }
1527
1612
  }
1528
1613
  }
1529
1614
  } catch (_) {}
@@ -1538,11 +1623,7 @@ async function buildIiifCollectionPages(CONFIG) {
1538
1623
  }
1539
1624
  }
1540
1625
  if (summaryOptions && summaryOptions.enabled) {
1541
- try {
1542
- summaryValue = extractSummaryValues(manifest);
1543
- } catch (_) {
1544
- summaryValue = "";
1545
- }
1626
+ summaryValue = summaryRaw || "";
1546
1627
  }
1547
1628
  if (annotationsOptions && annotationsOptions.enabled) {
1548
1629
  try {
package/lib/build/mdx.js CHANGED
@@ -471,10 +471,17 @@ async function compileMdxFile(filePath, outPath, Layout, extraProps = {}) {
471
471
  ? React.createElement(MDXProvider, { components: compMap }, withApp)
472
472
  : withApp;
473
473
  const body = ReactDOMServer.renderToStaticMarkup(page);
474
- const head =
475
- app && app.Head
476
- ? ReactDOMServer.renderToStaticMarkup(React.createElement(app.Head))
477
- : "";
474
+ let head = '';
475
+ if (app && app.Head) {
476
+ const headElement = React.createElement(app.Head, {
477
+ page: contextValue.page,
478
+ navigation: contextValue.navigation,
479
+ });
480
+ const wrappedHead = PageContext
481
+ ? React.createElement(PageContext.Provider, { value: contextValue }, headElement)
482
+ : headElement;
483
+ head = ReactDOMServer.renderToStaticMarkup(wrappedHead);
484
+ }
478
485
  return { body, head };
479
486
  }
480
487
 
@@ -3,6 +3,31 @@ const { log } = require('./log');
3
3
  const mdx = require('./mdx');
4
4
  const navigation = require('../components/navigation');
5
5
 
6
+ function normalizeWhitespace(value) {
7
+ if (!value) return '';
8
+ return String(value).replace(/\s+/g, ' ').trim();
9
+ }
10
+
11
+ function truncateDescription(value, max = 240) {
12
+ const normalized = normalizeWhitespace(value);
13
+ if (!normalized) return '';
14
+ if (normalized.length <= max) return normalized;
15
+ const slice = normalized.slice(0, Math.max(0, max - 3)).trimEnd();
16
+ return `${slice}...`;
17
+ }
18
+
19
+ function isPlainObject(value) {
20
+ return !!value && typeof value === 'object' && !Array.isArray(value);
21
+ }
22
+
23
+ function readFrontmatterString(data, key) {
24
+ if (!data) return '';
25
+ const raw = data[key];
26
+ if (raw == null) return '';
27
+ if (typeof raw === 'string') return raw.trim();
28
+ return '';
29
+ }
30
+
6
31
  // Cache: dir -> frontmatter data for _layout.mdx in that dir
7
32
  const LAYOUT_META = new Map();
8
33
 
@@ -50,11 +75,60 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
50
75
  const pageInfo = navigation.getPageInfo(normalizedRel);
51
76
  const navData = navigation.buildNavigationForFile(normalizedRel);
52
77
  const mergedProps = { ...(extraProps || {}) };
78
+ const frontmatter =
79
+ typeof mdx.parseFrontmatter === 'function'
80
+ ? mdx.parseFrontmatter(source)
81
+ : { data: null, content: source };
82
+ const frontmatterData = frontmatter && isPlainObject(frontmatter.data) ? frontmatter.data : null;
83
+ let layoutMeta = null;
84
+ try {
85
+ layoutMeta = await getNearestDirLayoutMeta(filePath);
86
+ } catch (_) {
87
+ layoutMeta = null;
88
+ }
89
+ const directType = frontmatterData && typeof frontmatterData.type === 'string' ? frontmatterData.type.trim() : '';
90
+ const layoutType = layoutMeta && typeof layoutMeta.type === 'string' ? String(layoutMeta.type).trim() : '';
91
+ const resolvedType = directType || layoutType || (!frontmatterData ? 'page' : '');
92
+ const frontmatterDescription = frontmatterData && typeof frontmatterData.description === 'string'
93
+ ? truncateDescription(frontmatterData.description)
94
+ : '';
95
+ const extractedPlain = typeof mdx.extractPlainText === 'function'
96
+ ? mdx.extractPlainText(source)
97
+ : '';
98
+ const derivedDescription = truncateDescription(extractedPlain);
99
+ const resolvedDescription = frontmatterDescription || derivedDescription;
100
+ const ogImageFrontmatter =
101
+ readFrontmatterString(frontmatterData, 'og:image') ||
102
+ readFrontmatterString(frontmatterData, 'ogImage');
103
+ const genericImage = readFrontmatterString(frontmatterData, 'image');
104
+ const resolvedImage = ogImageFrontmatter || genericImage;
105
+ const frontmatterMeta = frontmatterData && isPlainObject(frontmatterData.meta) ? frontmatterData.meta : null;
53
106
  const headings = mdx.extractHeadings(source);
54
- if (pageInfo) {
107
+ const basePage = pageInfo ? { ...pageInfo } : {};
108
+ if (title) basePage.title = title;
109
+ if (resolvedType) basePage.type = resolvedType;
110
+ if (resolvedDescription) basePage.description = resolvedDescription;
111
+ if (basePage.href && !basePage.url) basePage.url = basePage.href;
112
+ if (resolvedImage) {
113
+ basePage.image = resolvedImage;
114
+ basePage.ogImage = ogImageFrontmatter || resolvedImage;
115
+ }
116
+ const existingMeta = basePage.meta && typeof basePage.meta === 'object' ? basePage.meta : {};
117
+ const pageMeta = { ...existingMeta };
118
+ if (title) pageMeta.title = title;
119
+ if (resolvedDescription) pageMeta.description = resolvedDescription;
120
+ if (resolvedType) pageMeta.type = resolvedType;
121
+ if (basePage.url || basePage.href) pageMeta.url = basePage.url || basePage.href || pageMeta.url;
122
+ if (resolvedImage) {
123
+ pageMeta.image = resolvedImage;
124
+ if (!pageMeta.ogImage) pageMeta.ogImage = resolvedImage;
125
+ }
126
+ if (frontmatterMeta) Object.assign(pageMeta, frontmatterMeta);
127
+ if (Object.keys(pageMeta).length) basePage.meta = pageMeta;
128
+ if (Object.keys(basePage).length) {
55
129
  mergedProps.page = mergedProps.page
56
- ? { ...pageInfo, ...mergedProps.page }
57
- : pageInfo;
130
+ ? { ...basePage, ...mergedProps.page }
131
+ : basePage;
58
132
  }
59
133
  if (navData && !mergedProps.navigation) {
60
134
  mergedProps.navigation = navData;
@@ -116,7 +190,7 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
116
190
  const { BASE_PATH } = require('../common');
117
191
  if (BASE_PATH) vendorTag = `<script>window.CANOPY_BASE_PATH=${JSON.stringify(BASE_PATH)}</script>` + vendorTag;
118
192
  } catch (_) {}
119
- let headExtra = head;
193
+ const headSegments = [head];
120
194
  const extraScripts = [];
121
195
  if (heroRel && jsRel !== heroRel) extraScripts.push(`<script defer src="${heroRel}"></script>`);
122
196
  if (facetsRel && jsRel !== facetsRel) extraScripts.push(`<script defer src="${facetsRel}"></script>`);
@@ -133,9 +207,10 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
133
207
  } catch (_) {}
134
208
  extraStyles.push(`<link rel="stylesheet" href="${rel}">`);
135
209
  }
136
- if (extraStyles.length) headExtra = extraStyles.join('') + headExtra;
137
- if (extraScripts.length) headExtra = extraScripts.join('') + headExtra;
138
- const html = htmlShell({ title, body, cssHref: null, scriptHref: jsRel, headExtra: vendorTag + headExtra });
210
+ if (extraStyles.length) headSegments.push(extraStyles.join(''));
211
+ if (extraScripts.length) headSegments.push(extraScripts.join(''));
212
+ const headExtra = headSegments.join('') + vendorTag;
213
+ const html = htmlShell({ title, body, cssHref: null, scriptHref: jsRel, headExtra });
139
214
  const { applyBaseToHtml } = require('../common');
140
215
  return applyBaseToHtml(html);
141
216
  }
package/lib/common.js CHANGED
@@ -70,7 +70,9 @@ function htmlShell({ title, body, cssHref, scriptHref, headExtra }) {
70
70
  const cssTag = cssHref ? `<link rel="stylesheet" href="${cssHref}">` : '';
71
71
  const appearance = resolveThemeAppearance();
72
72
  const htmlClass = appearance === 'dark' ? ' class="dark"' : '';
73
- return `<!doctype html><html lang="en"${htmlClass}><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${title}</title>${extra}${cssTag}${scriptTag}</head><body>${body}</body></html>`;
73
+ const hasCustomTitle = /<title\b/i.test(extra);
74
+ const titleTag = hasCustomTitle ? '' : `<title>${title}</title>`;
75
+ return `<!doctype html><html lang="en"${htmlClass}><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>${titleTag}${extra}${cssTag}${scriptTag}</head><body>${body}</body></html>`;
74
76
  }
75
77
 
76
78
  function withBase(href) {
package/lib/head.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const React = require('react');
2
- const { withBase, rootRelativeHref } = require('./common');
2
+ const { withBase, rootRelativeHref, absoluteUrl } = require('./common');
3
+ const { getPageContext } = require('./page-context');
3
4
 
4
5
  const DEFAULT_STYLESHEET_PATH = '/styles/styles.css';
5
6
 
@@ -14,8 +15,97 @@ function Stylesheet(props = {}) {
14
15
  return React.createElement('link', { rel, href: resolved, ...rest });
15
16
  }
16
17
 
18
+ function normalizeText(value) {
19
+ if (!value) return '';
20
+ return String(value).replace(/\s+/g, ' ').trim();
21
+ }
22
+
23
+ function truncateText(value, max = 240) {
24
+ const normalized = normalizeText(value);
25
+ if (!normalized) return '';
26
+ if (normalized.length <= max) return normalized;
27
+ const slice = normalized.slice(0, Math.max(0, max - 3)).trimEnd();
28
+ return `${slice}...`;
29
+ }
30
+
31
+ function resolveOgType(pageType) {
32
+ const type = String(pageType || '').toLowerCase();
33
+ if (type === 'work' || type === 'article') return 'article';
34
+ if (type === 'docs' || type === 'documentation') return 'article';
35
+ return 'website';
36
+ }
37
+
38
+ function resolveUrl(value) {
39
+ if (!value) return '';
40
+ const raw = typeof value === 'string' ? value.trim() : String(value || '');
41
+ if (!raw) return '';
42
+ return absoluteUrl(raw);
43
+ }
44
+
45
+ function Meta(props = {}) {
46
+ const PageContext = getPageContext();
47
+ const context = PageContext ? React.useContext(PageContext) : null;
48
+ const ctxPage = context && context.page ? context.page : null;
49
+ const explicitPage = props.page || null;
50
+ const page = explicitPage || ctxPage || {};
51
+ const fallbackTitle = ctxPage && ctxPage.title ? ctxPage.title : '';
52
+ const metaFromPage = page && page.meta && typeof page.meta === 'object' ? page.meta : null;
53
+ const rawTitle =
54
+ props.title ||
55
+ (metaFromPage && metaFromPage.title) ||
56
+ page.title ||
57
+ fallbackTitle;
58
+ const pageTitle = normalizeText(rawTitle);
59
+ const siteTitle = normalizeText(props.siteTitle) || '';
60
+ const defaultTitle = siteTitle || 'Canopy IIIF';
61
+ const title = pageTitle ? pageTitle : defaultTitle;
62
+ const fullTitle = siteTitle ? (pageTitle ? `${pageTitle} | ${siteTitle}` : siteTitle) : title;
63
+ const rawDescription =
64
+ props.description ||
65
+ (metaFromPage && metaFromPage.description) ||
66
+ page.description ||
67
+ '';
68
+ const description = truncateText(rawDescription);
69
+ const resolvedType = props.type || (metaFromPage && metaFromPage.type) || page.type || '';
70
+ const ogType = resolveOgType(resolvedType);
71
+ const relativeUrl =
72
+ props.url ||
73
+ (metaFromPage && metaFromPage.url) ||
74
+ page.url ||
75
+ page.href ||
76
+ '';
77
+ const absolute = relativeUrl ? absoluteUrl(relativeUrl) : '';
78
+ const ogImageRaw =
79
+ props.image ||
80
+ props.ogImage ||
81
+ (metaFromPage && (metaFromPage.ogImage || metaFromPage.image)) ||
82
+ page.ogImage ||
83
+ page.image ||
84
+ '';
85
+ const image = ogImageRaw ? resolveUrl(ogImageRaw) : '';
86
+ const twitterImageRaw = props.twitterImage || ogImageRaw;
87
+ const twitterImage = twitterImageRaw ? resolveUrl(twitterImageRaw) : '';
88
+ const twitterCard = props.twitterCard || (twitterImage ? 'summary_large_image' : 'summary');
89
+
90
+ const nodes = [];
91
+ if (fullTitle) nodes.push(React.createElement('title', { key: 'meta-title' }, fullTitle));
92
+ if (description) nodes.push(React.createElement('meta', { key: 'meta-description', name: 'description', content: description }));
93
+ if (fullTitle) nodes.push(React.createElement('meta', { key: 'og-title', property: 'og:title', content: fullTitle }));
94
+ if (description) nodes.push(React.createElement('meta', { key: 'og-description', property: 'og:description', content: description }));
95
+ if (absolute) nodes.push(React.createElement('meta', { key: 'og-url', property: 'og:url', content: absolute }));
96
+ if (ogType) nodes.push(React.createElement('meta', { key: 'og-type', property: 'og:type', content: ogType }));
97
+ if (image) nodes.push(React.createElement('meta', { key: 'og-image', property: 'og:image', content: image }));
98
+ if (twitterCard) nodes.push(React.createElement('meta', { key: 'twitter-card', name: 'twitter:card', content: twitterCard }));
99
+ if (fullTitle) nodes.push(React.createElement('meta', { key: 'twitter-title', name: 'twitter:title', content: fullTitle }));
100
+ if (description) nodes.push(React.createElement('meta', { key: 'twitter-description', name: 'twitter:description', content: description }));
101
+ if (twitterImage) nodes.push(React.createElement('meta', { key: 'twitter-image', name: 'twitter:image', content: twitterImage }));
102
+
103
+ return React.createElement(React.Fragment, null, nodes);
104
+ }
105
+
17
106
  module.exports = {
18
107
  stylesheetHref,
19
108
  Stylesheet,
20
109
  DEFAULT_STYLESHEET_PATH,
110
+ Meta,
21
111
  };
@@ -199,6 +199,18 @@ function buildIiifImageUrlFromService(service, preferredSize = 800) {
199
199
  return buildIiifImageUrlFromNormalizedService(normalized, preferredSize);
200
200
  }
201
201
 
202
+ function buildIiifImageUrlForDimensions(service, width = 1200, height = 630) {
203
+ const normalized = normalizeImageServiceCandidate(service);
204
+ if (!normalized || !isIiifImageService(normalized)) return '';
205
+ const baseId = normalizeServiceBaseId(normalized.id);
206
+ if (!baseId) return '';
207
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 0));
208
+ const safeHeight = Math.max(1, Math.floor(Number(height) || 0));
209
+ const quality = selectServiceQuality(normalized);
210
+ const format = selectServiceFormat(normalized);
211
+ return `${baseId}/full/!${safeWidth},${safeHeight}/0/${quality}.${format}`;
212
+ }
213
+
202
214
  function buildIiifImageSrcset(service, steps = [360, 640, 960, 1280, 1600]) {
203
215
  const normalized = normalizeImageServiceCandidate(service);
204
216
  if (!normalized || !isIiifImageService(normalized)) return '';
@@ -339,6 +351,7 @@ module.exports = {
339
351
  getThumbnail,
340
352
  getThumbnailUrl,
341
353
  buildIiifImageUrlFromService,
354
+ buildIiifImageUrlForDimensions,
342
355
  findPrimaryCanvasImage,
343
356
  buildIiifImageSrcset,
344
357
  };
package/lib/index.js CHANGED
@@ -1,8 +1,9 @@
1
- const { stylesheetHref, Stylesheet } = require('./head');
1
+ const { stylesheetHref, Stylesheet, Meta } = require('./head');
2
2
 
3
3
  module.exports = {
4
4
  build: require('./build/build').build,
5
5
  dev: require('./build/dev').dev,
6
6
  stylesheetHref,
7
7
  Stylesheet,
8
+ Meta,
8
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-iiif/app",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Mat Jordan <mat@northwestern.edu>",
@@ -33,7 +33,11 @@ const resolveProjectRoot = (metaUrl, explicitRoot) => {
33
33
  const href = typeof metaUrl === "string" ? metaUrl : metaUrl.href;
34
34
  if (href) {
35
35
  const fromUrl = fileURLToPath(href);
36
- return path.join(path.dirname(fromUrl), "..", "..");
36
+ const dir = path.dirname(fromUrl);
37
+ if (dir.includes(`${path.sep}node_modules${path.sep}`)) {
38
+ return process.cwd();
39
+ }
40
+ return dir;
37
41
  }
38
42
  }
39
43
  return process.cwd();