@tribe-nest/forge 3.61.0 → 3.63.0
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/package.json +4 -2
- package/src/index.ts +3 -0
- package/src/seo/_tests/seo.spec.ts +326 -0
- package/src/seo/applySeoToHead.ts +98 -0
- package/src/seo/index.ts +9 -0
- package/src/seo/resolve.ts +211 -0
- package/src/seo/seoHead.ts +61 -0
- package/src/seo/siteSeoHead.ts +97 -0
- package/src/seo/types.ts +99 -0
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/ui/analytics/ForgeAnalytics.tsx +70 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
- package/src/utils/landing.ts +17 -4
- package/src/utils/structuredData.ts +339 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { WebPage } from "../types";
|
|
1
|
+
import type { IEvent, ITicket, WebPage } from "../types";
|
|
2
|
+
import type { SeoIdentity } from "../seo/types";
|
|
3
|
+
import { ticketSeatsUnsold } from "../ui/format/ticketAvailability";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Generates Organization/Person JSON-LD for every page.
|
|
@@ -529,3 +531,339 @@ export function jsonLdScripts(
|
|
|
529
531
|
.filter((s): s is Record<string, unknown> => !!s)
|
|
530
532
|
.map((s) => ({ type: "application/ld+json", children: JSON.stringify(s) }));
|
|
531
533
|
}
|
|
534
|
+
|
|
535
|
+
// ---- Events, posts, podcasts, the site and who runs it --------------------------
|
|
536
|
+
//
|
|
537
|
+
// Same rule as the sellables above: say only what the page itself says. A
|
|
538
|
+
// cancelled event is still described (so a search result can show it as
|
|
539
|
+
// cancelled) but sells nothing; a tier nobody may buy anonymously gets no
|
|
540
|
+
// offer; a missing currency drops the offers rather than guessing one.
|
|
541
|
+
|
|
542
|
+
/** A post's or episode's author, and the site's publisher, as the SEO record names them. */
|
|
543
|
+
export type SchemaIdentityInput = Pick<SeoIdentity, "kind" | "name"> & Partial<Omit<SeoIdentity, "kind" | "name">>;
|
|
544
|
+
|
|
545
|
+
const schemaTypeForIdentity = (kind: SeoIdentity["kind"]) =>
|
|
546
|
+
kind === "person" ? "Person" : kind === "local_business" ? "LocalBusiness" : "Organization";
|
|
547
|
+
|
|
548
|
+
const cleanUrls = (urls?: (string | null | undefined)[] | null) =>
|
|
549
|
+
(urls ?? []).filter((u): u is string => typeof u === "string" && /^https?:\/\//i.test(u.trim())).map((u) => u.trim());
|
|
550
|
+
|
|
551
|
+
function postalAddress(address?: SeoIdentity["address"]): Record<string, unknown> | undefined {
|
|
552
|
+
if (!address) return undefined;
|
|
553
|
+
const node = {
|
|
554
|
+
...(address.street ? { streetAddress: address.street } : {}),
|
|
555
|
+
...(address.locality ? { addressLocality: address.locality } : {}),
|
|
556
|
+
...(address.region ? { addressRegion: address.region } : {}),
|
|
557
|
+
...(address.postalCode ? { postalCode: address.postalCode } : {}),
|
|
558
|
+
...(address.country ? { addressCountry: address.country } : {}),
|
|
559
|
+
};
|
|
560
|
+
return Object.keys(node).length ? { "@type": "PostalAddress", ...node } : undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** A short reference to the identity, for `author` / `publisher` inside another node. */
|
|
564
|
+
function identityRef(identity?: SchemaIdentityInput | null, origin?: string | null): Record<string, unknown> | undefined {
|
|
565
|
+
const name = identity?.name?.trim();
|
|
566
|
+
if (!identity || !name) return undefined;
|
|
567
|
+
return {
|
|
568
|
+
"@type": schemaTypeForIdentity(identity.kind),
|
|
569
|
+
name,
|
|
570
|
+
...(origin ? { url: origin } : {}),
|
|
571
|
+
...(identity.logo && identity.kind !== "person" ? { logo: identity.logo } : {}),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The `@id` the WebSite node's `publisher` points at, so the two link up. */
|
|
576
|
+
export const identitySchemaId = (origin: string) => `${origin.replace(/\/+$/, "")}/#identity`;
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* schema.org Person, Organization or LocalBusiness for whoever runs the site.
|
|
580
|
+
*
|
|
581
|
+
* `logo` is an Organization property; a Person carries the same picture as
|
|
582
|
+
* `image`. `address` and `telephone` are emitted for any kind that has them,
|
|
583
|
+
* because a coach working from a studio is as findable by address as a shop.
|
|
584
|
+
*/
|
|
585
|
+
export function buildIdentitySchema(
|
|
586
|
+
identity: SchemaIdentityInput | null | undefined,
|
|
587
|
+
options: { origin?: string | null } = {},
|
|
588
|
+
): Record<string, unknown> | null {
|
|
589
|
+
const name = identity?.name?.trim();
|
|
590
|
+
if (!identity || !name) return null;
|
|
591
|
+
const origin = options.origin?.replace(/\/+$/, "");
|
|
592
|
+
const sameAs = cleanUrls(identity.sameAs);
|
|
593
|
+
const address = postalAddress(identity.address);
|
|
594
|
+
return {
|
|
595
|
+
"@context": "https://schema.org",
|
|
596
|
+
"@type": schemaTypeForIdentity(identity.kind),
|
|
597
|
+
...(origin ? { "@id": identitySchemaId(origin), url: origin } : {}),
|
|
598
|
+
name,
|
|
599
|
+
...(identity.logo ? (identity.kind === "person" ? { image: identity.logo } : { logo: identity.logo }) : {}),
|
|
600
|
+
...(sameAs.length ? { sameAs } : {}),
|
|
601
|
+
...(address ? { address } : {}),
|
|
602
|
+
...(identity.phone ? { telephone: identity.phone } : {}),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** schema.org/WebSite for the home page. */
|
|
607
|
+
export function buildWebSiteSchema(input: {
|
|
608
|
+
name: string;
|
|
609
|
+
url: string;
|
|
610
|
+
description?: string | null;
|
|
611
|
+
/** Links the site to its identity node by `@id`. */
|
|
612
|
+
publisherId?: string | null;
|
|
613
|
+
}): Record<string, unknown> | null {
|
|
614
|
+
const name = input.name?.trim();
|
|
615
|
+
if (!name || !input.url) return null;
|
|
616
|
+
return {
|
|
617
|
+
"@context": "https://schema.org",
|
|
618
|
+
"@type": "WebSite",
|
|
619
|
+
name,
|
|
620
|
+
url: input.url,
|
|
621
|
+
...(input.description ? { description: input.description } : {}),
|
|
622
|
+
...(input.publisherId ? { publisher: { "@id": input.publisherId } } : {}),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** schema.org/BreadcrumbList. Each item needs an absolute URL, so pass full ones. */
|
|
627
|
+
export function buildBreadcrumbSchema(items: { name: string; url: string }[]): Record<string, unknown> | null {
|
|
628
|
+
const list = items.filter((i) => i.name?.trim() && i.url);
|
|
629
|
+
if (list.length < 2) return null;
|
|
630
|
+
return {
|
|
631
|
+
"@context": "https://schema.org",
|
|
632
|
+
"@type": "BreadcrumbList",
|
|
633
|
+
itemListElement: list.map((item, index) => ({
|
|
634
|
+
"@type": "ListItem",
|
|
635
|
+
position: index + 1,
|
|
636
|
+
name: item.name.trim(),
|
|
637
|
+
item: item.url,
|
|
638
|
+
})),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Structural view of `GET /public/events/:id`. `IEvent` satisfies it. */
|
|
643
|
+
export interface EventSchemaSource {
|
|
644
|
+
title: string;
|
|
645
|
+
description?: string | null;
|
|
646
|
+
dateTime: string;
|
|
647
|
+
endDateTime?: string | null;
|
|
648
|
+
doorsOpenAt?: string | null;
|
|
649
|
+
status?: IEvent["status"];
|
|
650
|
+
cancelledAt?: string | null;
|
|
651
|
+
type: IEvent["type"];
|
|
652
|
+
address?: IEvent["address"] | null;
|
|
653
|
+
media?: { url: string; type?: string }[] | null;
|
|
654
|
+
tickets?: Pick<
|
|
655
|
+
ITicket,
|
|
656
|
+
"title" | "price" | "quantity" | "sold" | "availableFrom" | "archivedAt" | "isHidden" | "membershipGate"
|
|
657
|
+
>[];
|
|
658
|
+
membershipGate?: { allowed: boolean } | null;
|
|
659
|
+
bookingFee?: { currency: string } | null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const ATTENDANCE_MODE: Record<IEvent["type"], string> = {
|
|
663
|
+
physical: "https://schema.org/OfflineEventAttendanceMode",
|
|
664
|
+
virtual: "https://schema.org/OnlineEventAttendanceMode",
|
|
665
|
+
hybrid: "https://schema.org/MixedEventAttendanceMode",
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* schema.org/Event.
|
|
670
|
+
*
|
|
671
|
+
* `offers` is one Offer per public ticket tier, priced in the event's own
|
|
672
|
+
* settlement currency. A tier is left out when it is hidden, archived or
|
|
673
|
+
* members-only; a members-only EVENT sells nothing to the anonymous visitor
|
|
674
|
+
* Googlebot is, so it has no offers at all. Neither has a cancelled or finished
|
|
675
|
+
* event: there is nothing left to buy.
|
|
676
|
+
*
|
|
677
|
+
* A virtual event's location is a VirtualLocation at the event page itself,
|
|
678
|
+
* which is where a ticket-holder is let in. A hybrid event carries both.
|
|
679
|
+
*/
|
|
680
|
+
export function buildEventSchema(
|
|
681
|
+
event: EventSchemaSource | null | undefined,
|
|
682
|
+
options: SellableSchemaOptions & { organizer?: SchemaIdentityInput | null; origin?: string | null } = {},
|
|
683
|
+
): Record<string, unknown> | null {
|
|
684
|
+
const name = event?.title?.trim();
|
|
685
|
+
if (!event || !name || !event.dateTime) return null;
|
|
686
|
+
|
|
687
|
+
const cancelled = event.status === "cancelled" || !!event.cancelledAt;
|
|
688
|
+
const description = toSchemaText(event.description);
|
|
689
|
+
const images = imageUrls(event.media);
|
|
690
|
+
|
|
691
|
+
const place = (() => {
|
|
692
|
+
if (event.type === "virtual" || !event.address) return undefined;
|
|
693
|
+
const a = event.address;
|
|
694
|
+
const address = postalAddress({
|
|
695
|
+
street: a.street || undefined,
|
|
696
|
+
locality: a.city || undefined,
|
|
697
|
+
region: a.state || undefined,
|
|
698
|
+
postalCode: a.zipCode || undefined,
|
|
699
|
+
country: a.country || undefined,
|
|
700
|
+
});
|
|
701
|
+
return { "@type": "Place", ...(a.name ? { name: a.name } : {}), ...(address ? { address } : {}) };
|
|
702
|
+
})();
|
|
703
|
+
const virtual =
|
|
704
|
+
event.type !== "physical" && options.url ? { "@type": "VirtualLocation", url: options.url } : undefined;
|
|
705
|
+
const locations = [place, virtual].filter(Boolean);
|
|
706
|
+
|
|
707
|
+
const currency = event.bookingFee?.currency || options.currency || undefined;
|
|
708
|
+
const sellable =
|
|
709
|
+
!!currency &&
|
|
710
|
+
!cancelled &&
|
|
711
|
+
event.status !== "completed" &&
|
|
712
|
+
!(event.membershipGate && event.membershipGate.allowed === false);
|
|
713
|
+
const now = Date.now();
|
|
714
|
+
const offers = sellable
|
|
715
|
+
? (event.tickets ?? [])
|
|
716
|
+
.filter((t) => !t.archivedAt && !t.isHidden && !(t.membershipGate && t.membershipGate.allowed === false))
|
|
717
|
+
.map((t) => {
|
|
718
|
+
const price = toPrice(t.price);
|
|
719
|
+
if (price === undefined) return null;
|
|
720
|
+
const notYet = !!t.availableFrom && Date.parse(t.availableFrom) > now;
|
|
721
|
+
const soldOut = event.status === "sold_out" || ticketSeatsUnsold(t) <= 0;
|
|
722
|
+
return {
|
|
723
|
+
"@type": "Offer",
|
|
724
|
+
...(t.title ? { name: t.title } : {}),
|
|
725
|
+
price: money(price),
|
|
726
|
+
priceCurrency: currency,
|
|
727
|
+
availability: `https://schema.org/${soldOut ? "SoldOut" : notYet ? "PreOrder" : "InStock"}`,
|
|
728
|
+
...(t.availableFrom ? { validFrom: t.availableFrom } : {}),
|
|
729
|
+
...(options.url ? { url: options.url } : {}),
|
|
730
|
+
};
|
|
731
|
+
})
|
|
732
|
+
.filter((o): o is NonNullable<typeof o> => !!o)
|
|
733
|
+
: [];
|
|
734
|
+
|
|
735
|
+
const organizer =
|
|
736
|
+
identityRef(options.organizer, options.origin) ??
|
|
737
|
+
(options.brand ? { "@type": "Organization", name: options.brand } : undefined);
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
"@context": "https://schema.org",
|
|
741
|
+
"@type": "Event",
|
|
742
|
+
name,
|
|
743
|
+
startDate: event.dateTime,
|
|
744
|
+
...(event.endDateTime ? { endDate: event.endDateTime } : {}),
|
|
745
|
+
...(event.doorsOpenAt ? { doorTime: event.doorsOpenAt } : {}),
|
|
746
|
+
eventStatus: `https://schema.org/${cancelled ? "EventCancelled" : "EventScheduled"}`,
|
|
747
|
+
eventAttendanceMode: ATTENDANCE_MODE[event.type] ?? ATTENDANCE_MODE.physical,
|
|
748
|
+
...(locations.length ? { location: locations.length === 1 ? locations[0] : locations } : {}),
|
|
749
|
+
...(description ? { description } : {}),
|
|
750
|
+
...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
|
|
751
|
+
...(options.url ? { url: options.url } : {}),
|
|
752
|
+
...(organizer ? { organizer } : {}),
|
|
753
|
+
...(offers.length ? { offers: offers.length === 1 ? offers[0] : offers } : {}),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Structural view of `GET /public/blog/posts/:id`. `BlogPost` satisfies it. */
|
|
758
|
+
export interface BlogPostingSchemaSource {
|
|
759
|
+
title: string;
|
|
760
|
+
content?: string | null;
|
|
761
|
+
publishedAt?: string | null;
|
|
762
|
+
updatedAt?: string | null;
|
|
763
|
+
media?: { url: string; type?: string }[] | null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** schema.org/BlogPosting. `author` and `publisher` both come from the site's identity. */
|
|
767
|
+
export function buildBlogPostingSchema(
|
|
768
|
+
post: BlogPostingSchemaSource | null | undefined,
|
|
769
|
+
options: { url?: string | null; identity?: SchemaIdentityInput | null; origin?: string | null } = {},
|
|
770
|
+
): Record<string, unknown> | null {
|
|
771
|
+
const headline = post?.title?.trim();
|
|
772
|
+
if (!post || !headline) return null;
|
|
773
|
+
const description = toSchemaText(post.content, 300);
|
|
774
|
+
const images = imageUrls((post.media ?? []).filter((m) => (m.type ?? "").startsWith("image")));
|
|
775
|
+
const author = identityRef(options.identity, options.origin);
|
|
776
|
+
return {
|
|
777
|
+
"@context": "https://schema.org",
|
|
778
|
+
"@type": "BlogPosting",
|
|
779
|
+
// Google truncates a headline past 110 characters; clamping keeps it valid.
|
|
780
|
+
headline: headline.length > 110 ? `${headline.slice(0, 109).trimEnd()}…` : headline,
|
|
781
|
+
...(description ? { description } : {}),
|
|
782
|
+
...(post.publishedAt ? { datePublished: post.publishedAt } : {}),
|
|
783
|
+
...(post.updatedAt || post.publishedAt ? { dateModified: post.updatedAt || post.publishedAt } : {}),
|
|
784
|
+
...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
|
|
785
|
+
...(options.url ? { url: options.url, mainEntityOfPage: { "@type": "WebPage", "@id": options.url } } : {}),
|
|
786
|
+
...(author ? { author } : {}),
|
|
787
|
+
...(author && options.identity?.kind !== "person" ? { publisher: author } : {}),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Structural view of a podcast show. `PodcastShow` and `BlogCategory` both satisfy it. */
|
|
792
|
+
export interface PodcastSeriesSchemaSource {
|
|
793
|
+
title: string;
|
|
794
|
+
description?: string | null;
|
|
795
|
+
coverImage?: { url: string } | null;
|
|
796
|
+
author?: string | null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** schema.org/PodcastSeries. */
|
|
800
|
+
export function buildPodcastSeriesSchema(
|
|
801
|
+
show: PodcastSeriesSchemaSource | null | undefined,
|
|
802
|
+
options: { url?: string | null; identity?: SchemaIdentityInput | null; origin?: string | null } = {},
|
|
803
|
+
): Record<string, unknown> | null {
|
|
804
|
+
const name = show?.title?.trim();
|
|
805
|
+
if (!show || !name) return null;
|
|
806
|
+
const description = toSchemaText(show.description);
|
|
807
|
+
const author = show.author?.trim()
|
|
808
|
+
? { "@type": "Person", name: show.author.trim() }
|
|
809
|
+
: identityRef(options.identity, options.origin);
|
|
810
|
+
return {
|
|
811
|
+
"@context": "https://schema.org",
|
|
812
|
+
"@type": "PodcastSeries",
|
|
813
|
+
name,
|
|
814
|
+
...(description ? { description } : {}),
|
|
815
|
+
...(show.coverImage?.url ? { image: show.coverImage.url } : {}),
|
|
816
|
+
...(options.url ? { url: options.url } : {}),
|
|
817
|
+
...(author ? { author } : {}),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Structural view of a podcast episode (a blog post with an audio medium). */
|
|
822
|
+
export interface PodcastEpisodeSchemaSource {
|
|
823
|
+
title: string;
|
|
824
|
+
content?: string | null;
|
|
825
|
+
publishedAt?: string | null;
|
|
826
|
+
media?: { url: string; type?: string; durationSec?: number | null }[] | null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** schema.org/PodcastEpisode, linked to its series. */
|
|
830
|
+
export function buildPodcastEpisodeSchema(
|
|
831
|
+
episode: PodcastEpisodeSchemaSource | null | undefined,
|
|
832
|
+
options: { url?: string | null; series?: { name: string; url?: string | null } | null } = {},
|
|
833
|
+
): Record<string, unknown> | null {
|
|
834
|
+
const name = episode?.title?.trim();
|
|
835
|
+
if (!episode || !name) return null;
|
|
836
|
+
const description = toSchemaText(episode.content);
|
|
837
|
+
const audio = (episode.media ?? []).find((m) => (m.type ?? "").startsWith("audio"));
|
|
838
|
+
const image = (episode.media ?? []).find((m) => (m.type ?? "").startsWith("image"))?.url;
|
|
839
|
+
const duration = isoDuration(audio?.durationSec);
|
|
840
|
+
return {
|
|
841
|
+
"@context": "https://schema.org",
|
|
842
|
+
"@type": "PodcastEpisode",
|
|
843
|
+
name,
|
|
844
|
+
...(description ? { description } : {}),
|
|
845
|
+
...(episode.publishedAt ? { datePublished: episode.publishedAt } : {}),
|
|
846
|
+
...(options.url ? { url: options.url } : {}),
|
|
847
|
+
...(image ? { image } : {}),
|
|
848
|
+
...(duration ? { timeRequired: duration } : {}),
|
|
849
|
+
...(audio?.url
|
|
850
|
+
? {
|
|
851
|
+
associatedMedia: {
|
|
852
|
+
"@type": "MediaObject",
|
|
853
|
+
contentUrl: audio.url,
|
|
854
|
+
...(audio.type ? { encodingFormat: audio.type } : {}),
|
|
855
|
+
...(duration ? { duration } : {}),
|
|
856
|
+
},
|
|
857
|
+
}
|
|
858
|
+
: {}),
|
|
859
|
+
...(options.series?.name
|
|
860
|
+
? {
|
|
861
|
+
partOfSeries: {
|
|
862
|
+
"@type": "PodcastSeries",
|
|
863
|
+
name: options.series.name,
|
|
864
|
+
...(options.series.url ? { url: options.series.url } : {}),
|
|
865
|
+
},
|
|
866
|
+
}
|
|
867
|
+
: {}),
|
|
868
|
+
};
|
|
869
|
+
}
|