@tribe-nest/forge 3.60.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 +5 -2
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +10 -1
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +2 -0
- package/src/data/queries/useCommunity.ts +362 -18
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +6 -1
- package/src/i18n/de.json +746 -1
- package/src/i18n/en.json +746 -1
- package/src/index.ts +20 -0
- package/src/runtime/pages.spec.ts +5 -0
- package/src/runtime/pages.ts +32 -6
- 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/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityHarness.tsx +10 -3
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +20 -1
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/index.ts +41 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +3 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
- package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +13 -0
- package/src/ui/headless/memberHome/index.ts +10 -0
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +712 -352
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +134 -11
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +9 -0
- package/src/ui/styled/communities/shared.tsx +39 -11
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +234 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
- package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
- package/src/utils/landing.ts +36 -10
- package/src/utils/metaEvents.ts +302 -0
- package/src/utils/structuredData.ts +339 -1
package/src/index.ts
CHANGED
|
@@ -138,12 +138,16 @@ export * from "./data/queries/useEngagement";
|
|
|
138
138
|
export * from "./data/queries/usePaymentFlow";
|
|
139
139
|
export * from "./data/queries/useOrders";
|
|
140
140
|
export * from "./data/queries/useAuthActions";
|
|
141
|
+
// Email-first sign-in (`/public/sessions/email/*`): start, verify, complete.
|
|
142
|
+
export * from "./data/queries/useEmailSignIn";
|
|
141
143
|
export * from "./data/queries/useCoupons";
|
|
142
144
|
export * from "./data/queries/useAccountSettings";
|
|
143
145
|
export * from "./data/queries/useDonations";
|
|
144
146
|
export * from "./data/queries/useOffers";
|
|
145
147
|
export * from "./data/queries/useShipping";
|
|
146
148
|
export * from "./data/queries/useWebsite";
|
|
149
|
+
// The site app's settings for this host, and the Home rule (`useAppHomeHref`).
|
|
150
|
+
export * from "./data/queries/useSiteApp";
|
|
147
151
|
export * from "./data/queries/useWebsiteAgent";
|
|
148
152
|
export * from "./data/queries/useCurrencies";
|
|
149
153
|
export * from "./data/queries/useChat";
|
|
@@ -151,6 +155,18 @@ export * from "./data/queries/useCallChat";
|
|
|
151
155
|
export * from "./data/queries/useCallBreakouts";
|
|
152
156
|
export * from "./data/queries/useAnalytics";
|
|
153
157
|
export * from "./data/queries/useCommunity";
|
|
158
|
+
// The community AI on the member side: its info, DM, thumbs, "catch me up".
|
|
159
|
+
export * from "./data/queries/useCommunityAgent";
|
|
160
|
+
// Moderation from the site: the queue, the log, and the moderator actions.
|
|
161
|
+
export * from "./data/queries/useCommunityModeration";
|
|
162
|
+
// The community wiki as members and visitors read it.
|
|
163
|
+
export * from "./data/queries/useCommunityWiki";
|
|
164
|
+
// The member directory and map.
|
|
165
|
+
export * from "./data/queries/useCommunityMembers";
|
|
166
|
+
// The classroom tab: the courses a community's tiers include.
|
|
167
|
+
export * from "./data/queries/useCommunityClassroom";
|
|
168
|
+
// The community calendar: calls, promoted events and going / not going.
|
|
169
|
+
export * from "./data/queries/useCommunityCalendar";
|
|
154
170
|
// Communities: the list, one community, the viewer's state in it, invites,
|
|
155
171
|
// upgrades and the fan's memberships across communities.
|
|
156
172
|
export * from "./data/queries/useCommunities";
|
|
@@ -176,6 +192,7 @@ export * from "./content";
|
|
|
176
192
|
export * from "./utils/attribution";
|
|
177
193
|
export * from "./utils/landing";
|
|
178
194
|
export * from "./utils/metaPixel";
|
|
195
|
+
export * from "./utils/metaEvents";
|
|
179
196
|
export * from "./utils/cookieConsent";
|
|
180
197
|
export * from "./utils/structuredData";
|
|
181
198
|
// Paystack inline checkout: the ONE implementation of the popup, exported so a
|
|
@@ -191,6 +208,9 @@ export * from "./utils/bookingSecret";
|
|
|
191
208
|
// app can use them without inheriting the fan-site route tree, and so a fix
|
|
192
209
|
// reaches existing sites through the normal Forge publish.
|
|
193
210
|
export * from "./utils/headMeta";
|
|
211
|
+
// SEO: seoHead per route, siteSeoHead in the root, applySeoToHead for hosted
|
|
212
|
+
// pages. Also importable on its own from `@tribe-nest/forge/seo`.
|
|
213
|
+
export * from "./seo";
|
|
194
214
|
export * from "./utils/formatDateTime";
|
|
195
215
|
// The ONE membership access predicate + the messaging that is deliberately
|
|
196
216
|
// separate from it. Every surface that gates on a membership imports from here.
|
|
@@ -47,15 +47,20 @@ describe("internal page links", () => {
|
|
|
47
47
|
"communities/$slug/home",
|
|
48
48
|
"communities/$slug/home/calendar",
|
|
49
49
|
"communities/$slug/home/chat",
|
|
50
|
+
"communities/$slug/home/classroom",
|
|
50
51
|
"communities/$slug/home/collections",
|
|
51
52
|
"communities/$slug/home/collections/$collectionId",
|
|
52
53
|
"communities/$slug/home/leaderboard",
|
|
54
|
+
"communities/$slug/home/members",
|
|
55
|
+
"communities/$slug/home/moderation",
|
|
53
56
|
"communities/$slug/home/notifications",
|
|
54
57
|
"communities/$slug/home/posts",
|
|
55
58
|
"communities/$slug/home/profile/$accountId",
|
|
56
59
|
"communities/$slug/home/spaces",
|
|
57
60
|
"communities/$slug/home/spaces/$spaceId",
|
|
58
61
|
"communities/$slug/home/spaces/posts/$postId",
|
|
62
|
+
"communities/$slug/home/wiki",
|
|
63
|
+
"communities/$slug/home/wiki/$page",
|
|
59
64
|
]);
|
|
60
65
|
const removed = INTERNAL_PAGES.filter((page) => /^(membership|members|community|chat)(\/|$)/.test(page));
|
|
61
66
|
expect(removed).toEqual([]);
|
package/src/runtime/pages.ts
CHANGED
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The page list is the CONTRACT between a site and the hosted pages, so it
|
|
11
11
|
* normally only grows: removing a page breaks links on sites already published.
|
|
12
|
-
* The
|
|
12
|
+
* The first deliberate exception is Communities (2026-09-27). A profile can now
|
|
13
13
|
* run several communities, so the single-community member pages (`membership`,
|
|
14
14
|
* `members/*`, `community/*`, `chat`) were removed with no redirects, because
|
|
15
15
|
* nobody used them yet and there is no one community they could redirect to.
|
|
16
16
|
* Every member page now lives under `communities/$slug`.
|
|
17
17
|
*
|
|
18
|
+
* The second is the client portal (2026-10-01). `work/*` and the `?tab=` form
|
|
19
|
+
* of `account` folded into one portal under `account/*`, with no redirects:
|
|
20
|
+
* every producer of those links moved in the same change.
|
|
21
|
+
*
|
|
18
22
|
* <Link {...internalLink("store/$slug", { slug: product.slug })}>Buy</Link>
|
|
19
23
|
* navigate(internalLink("checkout"))
|
|
20
24
|
* const href = internalPath("events/$slug", { slug });
|
|
@@ -29,6 +33,27 @@ export const INTERNAL_ROUTE = "/i/$" as const;
|
|
|
29
33
|
/** Every hosted page, by its path pattern. Generated from the route table. */
|
|
30
34
|
export const INTERNAL_PAGES = [
|
|
31
35
|
"account",
|
|
36
|
+
"account/activity",
|
|
37
|
+
"account/bookings",
|
|
38
|
+
"account/courses",
|
|
39
|
+
"account/documents",
|
|
40
|
+
"account/files",
|
|
41
|
+
"account/films",
|
|
42
|
+
"account/invite",
|
|
43
|
+
"account/invoices",
|
|
44
|
+
"account/memberships",
|
|
45
|
+
"account/messages",
|
|
46
|
+
"account/notifications",
|
|
47
|
+
"account/orders",
|
|
48
|
+
"account/projects",
|
|
49
|
+
"account/projects/$projectId",
|
|
50
|
+
"account/projects/$projectId/tasks/$taskId",
|
|
51
|
+
"account/saved",
|
|
52
|
+
"account/settings",
|
|
53
|
+
"account/tickets",
|
|
54
|
+
"account/time-report/$token",
|
|
55
|
+
"account/waitlist",
|
|
56
|
+
"app",
|
|
32
57
|
"blog",
|
|
33
58
|
"blog-category/$slug",
|
|
34
59
|
"blog/$slug",
|
|
@@ -45,15 +70,20 @@ export const INTERNAL_PAGES = [
|
|
|
45
70
|
"communities/$slug/home",
|
|
46
71
|
"communities/$slug/home/calendar",
|
|
47
72
|
"communities/$slug/home/chat",
|
|
73
|
+
"communities/$slug/home/classroom",
|
|
48
74
|
"communities/$slug/home/collections",
|
|
49
75
|
"communities/$slug/home/collections/$collectionId",
|
|
50
76
|
"communities/$slug/home/leaderboard",
|
|
77
|
+
"communities/$slug/home/members",
|
|
78
|
+
"communities/$slug/home/moderation",
|
|
51
79
|
"communities/$slug/home/notifications",
|
|
52
80
|
"communities/$slug/home/posts",
|
|
53
81
|
"communities/$slug/home/profile/$accountId",
|
|
54
82
|
"communities/$slug/home/spaces",
|
|
55
83
|
"communities/$slug/home/spaces/$spaceId",
|
|
56
84
|
"communities/$slug/home/spaces/posts/$postId",
|
|
85
|
+
"communities/$slug/home/wiki",
|
|
86
|
+
"communities/$slug/home/wiki/$page",
|
|
57
87
|
"confirm-subscription",
|
|
58
88
|
"course-access/$accessId",
|
|
59
89
|
"courses",
|
|
@@ -62,6 +92,7 @@ export const INTERNAL_PAGES = [
|
|
|
62
92
|
"courses/claim",
|
|
63
93
|
"documents/$token",
|
|
64
94
|
"donation",
|
|
95
|
+
"downloads/$token",
|
|
65
96
|
"events",
|
|
66
97
|
"events/$slug",
|
|
67
98
|
"events/$slug/finalise",
|
|
@@ -91,11 +122,6 @@ export const INTERNAL_PAGES = [
|
|
|
91
122
|
"store/$slug",
|
|
92
123
|
"tickets/claim",
|
|
93
124
|
"video-calls/$callId",
|
|
94
|
-
"work",
|
|
95
|
-
"work/accept-invite",
|
|
96
|
-
"work/projects/$projectId",
|
|
97
|
-
"work/projects/$projectId/tasks/$taskId",
|
|
98
|
-
"work/time-report/$token",
|
|
99
125
|
] as const;
|
|
100
126
|
|
|
101
127
|
export type InternalPage = (typeof INTERNAL_PAGES)[number];
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applySeoToHead,
|
|
4
|
+
applyTitleTemplate,
|
|
5
|
+
findOverride,
|
|
6
|
+
normalizePathname,
|
|
7
|
+
resolvePageSeo,
|
|
8
|
+
seoHead,
|
|
9
|
+
siteSeoHead,
|
|
10
|
+
type SeoHeadContext,
|
|
11
|
+
type SeoRecord,
|
|
12
|
+
} from "../index";
|
|
13
|
+
import { buildHeadMeta } from "../../utils/headMeta";
|
|
14
|
+
|
|
15
|
+
const RECORD: SeoRecord = {
|
|
16
|
+
websiteId: "w1",
|
|
17
|
+
origin: "https://claystudio.com",
|
|
18
|
+
isDefaultWebsite: true,
|
|
19
|
+
defaultWebsiteOrigin: "https://claystudio.com",
|
|
20
|
+
siteName: "Clay Studio",
|
|
21
|
+
defaultDescription: "A small pottery studio in Hackney.",
|
|
22
|
+
defaultImage: "https://cdn.example/share.jpg",
|
|
23
|
+
titleTemplate: "{title} | {site}",
|
|
24
|
+
typeTemplates: {},
|
|
25
|
+
identity: {
|
|
26
|
+
kind: "local_business",
|
|
27
|
+
name: "Clay Studio",
|
|
28
|
+
logo: "https://cdn.example/logo.png",
|
|
29
|
+
sameAs: ["https://instagram.com/claystudio", "not a url"],
|
|
30
|
+
address: { street: "1 Mare St", locality: "London", postalCode: "E8 1AA", country: "GB" },
|
|
31
|
+
phone: "+44 20 0000 0000",
|
|
32
|
+
},
|
|
33
|
+
verification: { google: "g-token", bing: "b-token" },
|
|
34
|
+
indexing: true,
|
|
35
|
+
pages: {},
|
|
36
|
+
version: "2026-10-01T00:00:00.000Z",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const record = (patch: Partial<SeoRecord> = {}): SeoRecord => ({ ...RECORD, ...patch });
|
|
40
|
+
|
|
41
|
+
/** A head() context shaped like TanStack's: root match first, leaf last. */
|
|
42
|
+
function ctx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
|
|
43
|
+
const leaf = { pathname, fullPath, routeId: fullPath, loaderData: {} };
|
|
44
|
+
return {
|
|
45
|
+
matches: [{ pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo } }, leaf],
|
|
46
|
+
match: leaf,
|
|
47
|
+
params: {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Title + every name/property entry, flattened for assertions. */
|
|
52
|
+
function tags(meta: Record<string, unknown>[]) {
|
|
53
|
+
const out: Record<string, unknown> = {};
|
|
54
|
+
for (const m of meta) {
|
|
55
|
+
if (typeof m.title === "string") out.title = m.title;
|
|
56
|
+
const key = (m.name ?? m.property) as string | undefined;
|
|
57
|
+
if (key) out[key] = m.content;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const jsonLd = (meta: Record<string, unknown>[]) =>
|
|
62
|
+
meta.filter((m) => "script:ld+json" in m).map((m) => m["script:ld+json"] as Record<string, unknown>);
|
|
63
|
+
|
|
64
|
+
describe("normalizePathname", () => {
|
|
65
|
+
it("treats a trailing slash, a query and a missing slash as the same page", () => {
|
|
66
|
+
expect(normalizePathname("/about/")).toBe("/about");
|
|
67
|
+
expect(normalizePathname("/about?x=1")).toBe("/about");
|
|
68
|
+
expect(normalizePathname("about")).toBe("/about");
|
|
69
|
+
expect(normalizePathname("/")).toBe("/");
|
|
70
|
+
expect(normalizePathname("")).toBe("/");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("applyTitleTemplate", () => {
|
|
75
|
+
it("applies the template", () => {
|
|
76
|
+
expect(applyTitleTemplate("About", "Clay Studio", "{title} | {site}")).toBe("About | Clay Studio");
|
|
77
|
+
});
|
|
78
|
+
it("accepts {site.name} as well as {site}", () => {
|
|
79
|
+
expect(applyTitleTemplate("About", "Clay Studio", "{site.name}: {title}")).toBe("Clay Studio: About");
|
|
80
|
+
});
|
|
81
|
+
it("skips the template when the title already names the site, in any case", () => {
|
|
82
|
+
expect(applyTitleTemplate("clay studio, Hackney", "Clay Studio", "{title} | {site}")).toBe("clay studio, Hackney");
|
|
83
|
+
});
|
|
84
|
+
it("falls back to the default template when the record's is blank", () => {
|
|
85
|
+
expect(applyTitleTemplate("About", "Clay Studio", " ")).toBe("About | Clay Studio");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("findOverride", () => {
|
|
90
|
+
it("prefers the exact pathname and fills gaps from the route pattern", () => {
|
|
91
|
+
const seo = record({
|
|
92
|
+
pages: {
|
|
93
|
+
"/team/ada": { title: "Ada, head potter" },
|
|
94
|
+
"/team/$slug": { title: "Team member", description: "Pattern description", noindex: true },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(findOverride(seo, "/team/ada/", "/team/$slug")).toEqual({
|
|
98
|
+
title: "Ada, head potter",
|
|
99
|
+
description: "Pattern description",
|
|
100
|
+
noindex: true,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ignores blank strings so an emptied field does not erase the code's value", () => {
|
|
105
|
+
const seo = record({ pages: { "/about": { title: " ", description: null } } });
|
|
106
|
+
expect(findOverride(seo, "/about")).toEqual({});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("resolvePageSeo", () => {
|
|
111
|
+
it("with no record, behaves exactly like the code's own values", () => {
|
|
112
|
+
expect(resolvePageSeo(null, { pathname: "/about", title: "About" })).toEqual({
|
|
113
|
+
title: "About",
|
|
114
|
+
shareTitle: "About",
|
|
115
|
+
description: "About",
|
|
116
|
+
image: undefined,
|
|
117
|
+
noindex: false,
|
|
118
|
+
canonical: null,
|
|
119
|
+
overridden: false,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("uses a type template over the site template, and its description when the code has none", () => {
|
|
124
|
+
const seo = record({
|
|
125
|
+
typeTemplates: { event: { title: "{title} tickets | {site}", description: "Book {title} at {site}." } },
|
|
126
|
+
});
|
|
127
|
+
const r = resolvePageSeo(seo, { pathname: "/i/events/gig", title: "Late Show", type: "event", entity: true });
|
|
128
|
+
expect(r.title).toBe("Late Show tickets | Clay Studio");
|
|
129
|
+
expect(r.shareTitle).toBe("Late Show");
|
|
130
|
+
expect(r.description).toBe("Book Late Show at Clay Studio.");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("falls back to site defaults for description and image", () => {
|
|
134
|
+
const r = resolvePageSeo(record(), { pathname: "/about", title: "About" });
|
|
135
|
+
expect(r.description).toBe(RECORD.defaultDescription);
|
|
136
|
+
expect(r.image).toBe(RECORD.defaultImage);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("canonicalizes entities to the default website and pages to their own origin", () => {
|
|
140
|
+
const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
|
|
141
|
+
expect(resolvePageSeo(seo, { pathname: "/i/store/mug", title: "Mug", entity: true }).canonical).toBe(
|
|
142
|
+
"https://claystudio.com/i/store/mug",
|
|
143
|
+
);
|
|
144
|
+
expect(resolvePageSeo(seo, { pathname: "/about", title: "About" }).canonical).toBe("https://shop.example/about");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("noindexes everything when the site is not indexing, whatever the code says", () => {
|
|
148
|
+
expect(resolvePageSeo(record({ indexing: false }), { pathname: "/", title: "Home" }).noindex).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("lets an override's noindex:false beat the code's noindex", () => {
|
|
152
|
+
const seo = record({ pages: { "/thanks": { noindex: false } } });
|
|
153
|
+
expect(resolvePageSeo(seo, { pathname: "/thanks", title: "Thanks", noindex: true }).noindex).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("seoHead", () => {
|
|
158
|
+
it("with no record, emits today's buildHeadMeta tags and never links", () => {
|
|
159
|
+
const out = seoHead(ctx("/about", undefined), { title: "About", description: "Who we are", image: "https://x/i.jpg" });
|
|
160
|
+
expect(out).not.toHaveProperty("links");
|
|
161
|
+
const legacy = buildHeadMeta({ title: "About", description: "Who we are", image: "https://x/i.jpg" });
|
|
162
|
+
expect(tags(out.meta)).toEqual(tags(legacy.meta));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("applies the title template to the code title", () => {
|
|
166
|
+
const out = seoHead(ctx("/about", RECORD), { title: "About", description: "Who we are" });
|
|
167
|
+
const t = tags(out.meta);
|
|
168
|
+
expect(t.title).toBe("About | Clay Studio");
|
|
169
|
+
expect(t["og:title"]).toBe("About");
|
|
170
|
+
expect(t.description).toBe("Who we are");
|
|
171
|
+
expect(t["og:url"]).toBe("https://claystudio.com/about");
|
|
172
|
+
expect(t.robots).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("uses an override for the exact pathname, verbatim", () => {
|
|
176
|
+
const seo = record({ pages: { "/about": { title: "Our story", description: "Since 2019." } } });
|
|
177
|
+
const t = tags(seoHead(ctx("/about", seo), { title: "About", description: "Who we are" }).meta);
|
|
178
|
+
expect(t.title).toBe("Our story");
|
|
179
|
+
expect(t.description).toBe("Since 2019.");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("uses an override keyed by the route pattern for a dynamic route", () => {
|
|
183
|
+
const seo = record({ pages: { "/team/$slug": { description: "One of our potters." } } });
|
|
184
|
+
const t = tags(seoHead(ctx("/team/ada", seo, "/team/$slug"), { title: "Ada" }).meta);
|
|
185
|
+
expect(t.title).toBe("Ada | Clay Studio");
|
|
186
|
+
expect(t.description).toBe("One of our potters.");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("emits robots noindex from the code, from an override, and from a non-indexing site", () => {
|
|
190
|
+
expect(tags(seoHead(ctx("/x", RECORD), { title: "X", noindex: true }).meta).robots).toBe("noindex, nofollow");
|
|
191
|
+
const seo = record({ pages: { "/x": { noindex: true } } });
|
|
192
|
+
expect(tags(seoHead(ctx("/x", seo), { title: "X" }).meta).robots).toBe("noindex, nofollow");
|
|
193
|
+
expect(tags(seoHead(ctx("/x", record({ indexing: false })), { title: "X" }).meta).robots).toBe("noindex, nofollow");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("emits each jsonLd node as a script:ld+json meta entry", () => {
|
|
197
|
+
const nodes = [{ "@type": "Thing", name: "a" }, { "@type": "Thing", name: "b" }];
|
|
198
|
+
expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes }).meta)).toEqual(nodes);
|
|
199
|
+
expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes[0] }).meta)).toEqual([nodes[0]]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("siteSeoHead", () => {
|
|
204
|
+
it("emits the site fallbacks, verification and a canonical for the leaf path", () => {
|
|
205
|
+
const out = siteSeoHead(ctx("/about/", RECORD));
|
|
206
|
+
const t = tags(out.meta);
|
|
207
|
+
expect(t).toMatchObject({
|
|
208
|
+
title: "Clay Studio",
|
|
209
|
+
description: RECORD.defaultDescription,
|
|
210
|
+
"og:site_name": "Clay Studio",
|
|
211
|
+
"og:image": RECORD.defaultImage,
|
|
212
|
+
"twitter:card": "summary_large_image",
|
|
213
|
+
"google-site-verification": "g-token",
|
|
214
|
+
"msvalidate.01": "b-token",
|
|
215
|
+
});
|
|
216
|
+
expect(t.robots).toBeUndefined();
|
|
217
|
+
expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/about" }]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("uses an override canonical", () => {
|
|
221
|
+
const seo = record({ pages: { "/about": { canonical: "https://claystudio.com/story" } } });
|
|
222
|
+
expect(siteSeoHead(ctx("/about", seo)).links).toEqual([{ rel: "canonical", href: "https://claystudio.com/story" }]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("emits no canonical under /i/, where the hosted page emits its own", () => {
|
|
226
|
+
expect(siteSeoHead(ctx("/i/store/mug", RECORD)).links).toEqual([]);
|
|
227
|
+
expect(siteSeoHead(ctx("/i", RECORD)).links).toEqual([]);
|
|
228
|
+
// A creator page that merely starts with "i" is not hosted.
|
|
229
|
+
expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("noindexes when the site is not indexing or the path's override says so", () => {
|
|
233
|
+
expect(tags(siteSeoHead(ctx("/", record({ indexing: false }))).meta).robots).toBe("noindex, nofollow");
|
|
234
|
+
const seo = record({ pages: { "/team/$slug": { noindex: true } } });
|
|
235
|
+
expect(tags(siteSeoHead(ctx("/team/ada", seo, "/team/$slug")).meta).robots).toBe("noindex, nofollow");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("emits WebSite and identity JSON-LD on / only", () => {
|
|
239
|
+
const home = jsonLd(siteSeoHead(ctx("/", RECORD)).meta);
|
|
240
|
+
expect(home.map((n) => n["@type"])).toEqual(["WebSite", "LocalBusiness"]);
|
|
241
|
+
expect(home[0]).toMatchObject({
|
|
242
|
+
name: "Clay Studio",
|
|
243
|
+
url: "https://claystudio.com/",
|
|
244
|
+
publisher: { "@id": "https://claystudio.com/#identity" },
|
|
245
|
+
});
|
|
246
|
+
expect(home[1]).toMatchObject({
|
|
247
|
+
"@id": "https://claystudio.com/#identity",
|
|
248
|
+
telephone: "+44 20 0000 0000",
|
|
249
|
+
sameAs: ["https://instagram.com/claystudio"],
|
|
250
|
+
address: { "@type": "PostalAddress", streetAddress: "1 Mare St", addressCountry: "GB" },
|
|
251
|
+
});
|
|
252
|
+
expect(jsonLd(siteSeoHead(ctx("/about", RECORD)).meta)).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("emits WebSite alone when there is no identity", () => {
|
|
256
|
+
const home = jsonLd(siteSeoHead(ctx("/", record({ identity: null }))).meta);
|
|
257
|
+
expect(home).toHaveLength(1);
|
|
258
|
+
expect(home[0]).not.toHaveProperty("publisher");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("with no record, emits only the fallback it was given and no canonical", () => {
|
|
262
|
+
const out = siteSeoHead(ctx("/about", null), { siteName: "Clay" });
|
|
263
|
+
expect(out.links).toEqual([]);
|
|
264
|
+
expect(tags(out.meta)).toEqual({ title: "Clay", "og:site_name": "Clay", "twitter:card": "summary_large_image" });
|
|
265
|
+
expect(tags(siteSeoHead(ctx("/", undefined)).meta)).toEqual({ "twitter:card": "summary_large_image" });
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("applySeoToHead", () => {
|
|
270
|
+
const productHead = () => ({
|
|
271
|
+
...buildHeadMeta({ title: "Mug", description: "A mug.", image: "https://cdn.example/mug.jpg", ogType: "product" }),
|
|
272
|
+
scripts: [{ type: "application/ld+json", children: "{}" }],
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns the head untouched with no record", () => {
|
|
276
|
+
const head = productHead();
|
|
277
|
+
expect(applySeoToHead(head, null, "/i/store/mug", "product")).toBe(head);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("templates the title, keeps the page's own tags and adds the entity canonical", () => {
|
|
281
|
+
const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
|
|
282
|
+
const out = applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug");
|
|
283
|
+
const t = tags(out.meta!);
|
|
284
|
+
expect(t.title).toBe("Mug | Clay Studio");
|
|
285
|
+
expect(t["og:title"]).toBe("Mug");
|
|
286
|
+
expect(t["og:type"]).toBe("product");
|
|
287
|
+
expect(t["og:site_name"]).toBe("Clay Studio");
|
|
288
|
+
expect(t["og:url"]).toBe("https://claystudio.com/i/store/mug");
|
|
289
|
+
expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/i/store/mug" }]);
|
|
290
|
+
expect(out.scripts).toEqual([{ type: "application/ld+json", children: "{}" }]);
|
|
291
|
+
// Exactly one of each: the old entries were replaced, not appended to.
|
|
292
|
+
expect(out.meta!.filter((m) => m.name === "description")).toHaveLength(1);
|
|
293
|
+
expect(out.meta!.filter((m) => typeof m.title === "string")).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("canonicalizes an index page to its own website", () => {
|
|
297
|
+
const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
|
|
298
|
+
const out = applySeoToHead(buildHeadMeta({ title: "Store" }), seo, "/i/store", "page");
|
|
299
|
+
expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("applies overrides by pathname and by pattern", () => {
|
|
303
|
+
const seo = record({
|
|
304
|
+
pages: {
|
|
305
|
+
"/i/store/mug": { title: "The best mug" },
|
|
306
|
+
"/i/store/$slug": { description: "Handmade in Hackney.", image: "https://cdn.example/o.jpg" },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const t = tags(applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug").meta!);
|
|
310
|
+
expect(t.title).toBe("The best mug");
|
|
311
|
+
expect(t.description).toBe("Handmade in Hackney.");
|
|
312
|
+
expect(t["og:image"]).toBe("https://cdn.example/o.jpg");
|
|
313
|
+
expect(t["twitter:image"]).toBe("https://cdn.example/o.jpg");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("does not let buildHeadMeta's title-as-description beat the site default", () => {
|
|
317
|
+
const t = tags(applySeoToHead(buildHeadMeta({ title: "Events" }), RECORD, "/i/events", "page").meta!);
|
|
318
|
+
expect(t.description).toBe(RECORD.defaultDescription);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("keeps a page's noindex and then states no canonical", () => {
|
|
322
|
+
const out = applySeoToHead(buildHeadMeta({ title: "Login", noindex: true }), RECORD, "/i/login", "page");
|
|
323
|
+
expect(tags(out.meta!).robots).toBe("noindex, nofollow");
|
|
324
|
+
expect(out.links).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { normalizePathname, resolvePageSeo } from "./resolve";
|
|
2
|
+
import type { SeoPageType, SeoRecord } from "./types";
|
|
3
|
+
|
|
4
|
+
type HeadEntry = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
/** The `{ meta, links, scripts }` shape a hosted page's `head()` returns. */
|
|
7
|
+
export type HeadTags = {
|
|
8
|
+
meta?: HeadEntry[];
|
|
9
|
+
links?: HeadEntry[];
|
|
10
|
+
scripts?: HeadEntry[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Meta this function owns and rewrites. Anything else a page set (`og:type`,
|
|
15
|
+
* `og:video`, a custom tag) passes through untouched.
|
|
16
|
+
*/
|
|
17
|
+
const OWNED_META = new Set([
|
|
18
|
+
"description",
|
|
19
|
+
"robots",
|
|
20
|
+
"og:title",
|
|
21
|
+
"og:description",
|
|
22
|
+
"og:image",
|
|
23
|
+
"og:url",
|
|
24
|
+
"og:site_name",
|
|
25
|
+
"twitter:title",
|
|
26
|
+
"twitter:description",
|
|
27
|
+
"twitter:image",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const metaKey = (m: HeadEntry) => m.name ?? m.property;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the SEO record over a hosted `/i/` page's own head.
|
|
34
|
+
*
|
|
35
|
+
* The page's `head()` says what its code defaults are (title, description,
|
|
36
|
+
* image, noindex), exactly as before. This applies, on top of them, the
|
|
37
|
+
* creator's override for the pathname or pattern, the page type's template,
|
|
38
|
+
* the site defaults and the canonical.
|
|
39
|
+
*
|
|
40
|
+
* The canonical is emitted HERE, not by the site's root, because only the page
|
|
41
|
+
* knows it is an entity: a product, event or post canonicalizes to the
|
|
42
|
+
* profile's DEFAULT website (`defaultWebsiteOrigin`), so the same product shown
|
|
43
|
+
* on three websites is one result, not three. An index page (`/i/store`)
|
|
44
|
+
* belongs to the website it is on.
|
|
45
|
+
*
|
|
46
|
+
* With no record, the head is returned unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export function applySeoToHead<T extends HeadTags>(
|
|
49
|
+
head: T,
|
|
50
|
+
seo: SeoRecord | null | undefined,
|
|
51
|
+
pathname: string,
|
|
52
|
+
type: SeoPageType = "page",
|
|
53
|
+
routePattern?: string,
|
|
54
|
+
): T {
|
|
55
|
+
if (!seo) return head;
|
|
56
|
+
|
|
57
|
+
const meta = head.meta ?? [];
|
|
58
|
+
const find = (key: string) => meta.find((m) => metaKey(m) === key)?.content;
|
|
59
|
+
const codeTitle = meta.find((m) => typeof m.title === "string")?.title ?? "";
|
|
60
|
+
const codeNoindex = /noindex/i.test(find("robots") ?? "");
|
|
61
|
+
|
|
62
|
+
const path = normalizePathname(pathname);
|
|
63
|
+
const resolved = resolvePageSeo(seo, {
|
|
64
|
+
pathname: path,
|
|
65
|
+
routePattern,
|
|
66
|
+
type,
|
|
67
|
+
title: codeTitle,
|
|
68
|
+
// `buildHeadMeta` falls back to the title when a page has no description.
|
|
69
|
+
// That fallback is not a description the code chose, so it must not beat
|
|
70
|
+
// the type template or the site default.
|
|
71
|
+
description: find("description") && find("description") !== codeTitle ? find("description") : undefined,
|
|
72
|
+
image: find("og:image"),
|
|
73
|
+
noindex: codeNoindex,
|
|
74
|
+
entity: type !== "page",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const kept = meta.filter((m) => typeof m.title !== "string" && !OWNED_META.has(metaKey(m) ?? ""));
|
|
78
|
+
const nextMeta: HeadEntry[] = [
|
|
79
|
+
{ title: resolved.title },
|
|
80
|
+
{ name: "description", content: resolved.description },
|
|
81
|
+
{ property: "og:title", content: resolved.shareTitle },
|
|
82
|
+
{ property: "og:description", content: resolved.description },
|
|
83
|
+
{ property: "og:site_name", content: seo.siteName },
|
|
84
|
+
...(resolved.canonical && !resolved.noindex ? [{ property: "og:url", content: resolved.canonical }] : []),
|
|
85
|
+
...(resolved.image ? [{ property: "og:image", content: resolved.image }] : []),
|
|
86
|
+
...kept,
|
|
87
|
+
{ name: "twitter:title", content: resolved.shareTitle },
|
|
88
|
+
{ name: "twitter:description", content: resolved.description },
|
|
89
|
+
...(resolved.image ? [{ name: "twitter:image", content: resolved.image }] : []),
|
|
90
|
+
...(resolved.noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// A noindex page states no canonical: there is no indexed copy to point at.
|
|
94
|
+
const links = (head.links ?? []).filter((l) => l.rel !== "canonical");
|
|
95
|
+
if (resolved.canonical && !resolved.noindex) links.push({ rel: "canonical", href: resolved.canonical });
|
|
96
|
+
|
|
97
|
+
return { ...head, meta: nextMeta, links };
|
|
98
|
+
}
|
package/src/seo/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SEO for code sites and hosted pages: code declares the defaults, the
|
|
2
|
+
// creator's approved edits live in the SEO record, and these functions resolve
|
|
3
|
+
// one over the other. Pure, React-free, so it is safe on the server entry and in
|
|
4
|
+
// a route's head().
|
|
5
|
+
export * from "./types";
|
|
6
|
+
export * from "./resolve";
|
|
7
|
+
export * from "./seoHead";
|
|
8
|
+
export * from "./siteSeoHead";
|
|
9
|
+
export * from "./applySeoToHead";
|