@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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { SeoHeadContext, SeoPageOverride, SeoPageType, SeoRecord } from "./types";
|
|
2
|
+
|
|
3
|
+
// Pure resolution: one place decides what a page's SEO is, so the server render,
|
|
4
|
+
// the hydrated page and an in-site navigation all compute the same tags from
|
|
5
|
+
// the same serialized record.
|
|
6
|
+
//
|
|
7
|
+
// The order, field by field:
|
|
8
|
+
// 1. the creator's approved override for this exact pathname,
|
|
9
|
+
// 2. then the override for the route pattern ("/team/$slug"),
|
|
10
|
+
// 3. then what the code passed in,
|
|
11
|
+
// 4. then the page type's template,
|
|
12
|
+
// 5. then the site defaults.
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_TITLE_TEMPLATE = "{title} | {site}";
|
|
15
|
+
|
|
16
|
+
/** "/about/" and "/about" are one page. The root stays "/". */
|
|
17
|
+
export function normalizePathname(pathname: string | undefined | null): string {
|
|
18
|
+
if (!pathname) return "/";
|
|
19
|
+
const bare = pathname.split(/[?#]/)[0] || "/";
|
|
20
|
+
const withSlash = bare.startsWith("/") ? bare : `/${bare}`;
|
|
21
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") || "/" : withSlash;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Hosted pages render centrally and emit their own canonical. */
|
|
25
|
+
export function isHostedPath(pathname: string): boolean {
|
|
26
|
+
const p = normalizePathname(pathname);
|
|
27
|
+
return p === "/i" || p.startsWith("/i/");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The record a site's root loader returned, read from the root match.
|
|
32
|
+
*
|
|
33
|
+
* `undefined` and `null` both mean "no record": an old backend, a failed fetch,
|
|
34
|
+
* or a site whose root loader predates the field. Every caller degrades to the
|
|
35
|
+
* code's own values in that case.
|
|
36
|
+
*/
|
|
37
|
+
export function seoRecordFromContext(ctx: SeoHeadContext | undefined | null): SeoRecord | null {
|
|
38
|
+
const root = ctx?.matches?.[0]?.loaderData as { seo?: SeoRecord | null } | null | undefined;
|
|
39
|
+
const seo = root?.seo;
|
|
40
|
+
return seo && typeof seo === "object" && typeof seo.siteName === "string" ? seo : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The leaf pathname: the last match is the page being rendered. */
|
|
44
|
+
export function pathnameFromContext(ctx: SeoHeadContext | undefined | null): string {
|
|
45
|
+
const matches = ctx?.matches ?? [];
|
|
46
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
47
|
+
const pathname = matches[i]?.pathname;
|
|
48
|
+
if (pathname) return normalizePathname(pathname);
|
|
49
|
+
}
|
|
50
|
+
return normalizePathname(ctx?.match?.pathname);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The route pattern of the match whose head() is running ("/team/$slug"). */
|
|
54
|
+
export function routePatternFromContext(ctx: SeoHeadContext | undefined | null): string | undefined {
|
|
55
|
+
const match = ctx?.match;
|
|
56
|
+
const pattern = match?.fullPath ?? match?.routeId;
|
|
57
|
+
return pattern ? normalizePathname(pattern) : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The route pattern of the leaf match, for the root, which renders above it. */
|
|
61
|
+
export function leafRoutePatternFromContext(ctx: SeoHeadContext | undefined | null): string | undefined {
|
|
62
|
+
const matches = ctx?.matches ?? [];
|
|
63
|
+
const leaf = matches[matches.length - 1];
|
|
64
|
+
const pattern = leaf?.fullPath ?? leaf?.routeId;
|
|
65
|
+
return pattern ? normalizePathname(pattern) : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const present = <T>(value: T | null | undefined): value is T =>
|
|
69
|
+
value !== null && value !== undefined && !(typeof value === "string" && value.trim() === "");
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The override for one page, merged field by field: the exact pathname wins,
|
|
73
|
+
* the route pattern fills what it leaves unset.
|
|
74
|
+
*/
|
|
75
|
+
export function findOverride(
|
|
76
|
+
seo: SeoRecord | null | undefined,
|
|
77
|
+
pathname: string,
|
|
78
|
+
routePattern?: string,
|
|
79
|
+
): SeoPageOverride | null {
|
|
80
|
+
if (!seo?.pages) return null;
|
|
81
|
+
const exact = seo.pages[normalizePathname(pathname)];
|
|
82
|
+
const pattern = routePattern ? seo.pages[normalizePathname(routePattern)] : undefined;
|
|
83
|
+
if (!exact && !pattern) return null;
|
|
84
|
+
const merged: SeoPageOverride = {};
|
|
85
|
+
for (const key of ["title", "description", "image", "canonical", "noindex"] as const) {
|
|
86
|
+
const value = present(exact?.[key]) ? exact?.[key] : pattern?.[key];
|
|
87
|
+
if (present(value)) (merged as Record<string, unknown>)[key] = value;
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Fill `{title}` and `{site}` (or `{site.name}`) in a template. */
|
|
93
|
+
export function fillTemplate(template: string, values: { title: string; site: string }): string {
|
|
94
|
+
return template
|
|
95
|
+
.replace(/\{title\}/g, values.title)
|
|
96
|
+
.replace(/\{site(?:\.name)?\}/g, values.site)
|
|
97
|
+
.trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The document title for a code title.
|
|
102
|
+
*
|
|
103
|
+
* The template is skipped when the title already names the site, so a code
|
|
104
|
+
* title of "Clay Studio | Pottery in Hackney" does not become
|
|
105
|
+
* "Clay Studio | Pottery in Hackney | Clay Studio".
|
|
106
|
+
*/
|
|
107
|
+
export function applyTitleTemplate(title: string, siteName: string, template?: string | null): string {
|
|
108
|
+
const site = siteName.trim();
|
|
109
|
+
const base = title.trim();
|
|
110
|
+
if (!site) return base;
|
|
111
|
+
if (!base) return site;
|
|
112
|
+
if (base.toLowerCase().includes(site.toLowerCase())) return base;
|
|
113
|
+
const filled = fillTemplate(template?.trim() || DEFAULT_TITLE_TEMPLATE, { title: base, site });
|
|
114
|
+
return filled || base;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The canonical URL for a page: the override's, else origin + pathname. */
|
|
118
|
+
export function canonicalFor(
|
|
119
|
+
seo: SeoRecord,
|
|
120
|
+
pathname: string,
|
|
121
|
+
override?: SeoPageOverride | null,
|
|
122
|
+
options?: { entity?: boolean },
|
|
123
|
+
): string {
|
|
124
|
+
if (present(override?.canonical)) return override!.canonical!;
|
|
125
|
+
const origin = (options?.entity ? seo.defaultWebsiteOrigin || seo.origin : seo.origin).replace(/\/+$/, "");
|
|
126
|
+
const path = normalizePathname(pathname);
|
|
127
|
+
return path === "/" ? `${origin}/` : `${origin}${path}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type ResolvedPageSeo = {
|
|
131
|
+
/** The document title, template applied. */
|
|
132
|
+
title: string;
|
|
133
|
+
/** The title for share cards, without the site suffix. */
|
|
134
|
+
shareTitle: string;
|
|
135
|
+
description: string;
|
|
136
|
+
image?: string;
|
|
137
|
+
noindex: boolean;
|
|
138
|
+
/** Null when there is no record, so no canonical can be stated. */
|
|
139
|
+
canonical: string | null;
|
|
140
|
+
/** True when a creator override supplied at least one field. */
|
|
141
|
+
overridden: boolean;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type ResolveInput = {
|
|
145
|
+
pathname: string;
|
|
146
|
+
routePattern?: string;
|
|
147
|
+
type?: SeoPageType;
|
|
148
|
+
title?: string;
|
|
149
|
+
description?: string;
|
|
150
|
+
image?: string;
|
|
151
|
+
noindex?: boolean;
|
|
152
|
+
/** Entity pages canonicalize to the profile's default website. */
|
|
153
|
+
entity?: boolean;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export function resolvePageSeo(seo: SeoRecord | null | undefined, input: ResolveInput): ResolvedPageSeo {
|
|
157
|
+
const codeTitle = input.title?.trim() ?? "";
|
|
158
|
+
|
|
159
|
+
if (!seo) {
|
|
160
|
+
// No record: today's behaviour, exactly. The code's values stand as given.
|
|
161
|
+
const title = codeTitle;
|
|
162
|
+
return {
|
|
163
|
+
title,
|
|
164
|
+
shareTitle: title,
|
|
165
|
+
description: input.description || title,
|
|
166
|
+
image: input.image || undefined,
|
|
167
|
+
noindex: !!input.noindex,
|
|
168
|
+
canonical: null,
|
|
169
|
+
overridden: false,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const override = findOverride(seo, input.pathname, input.routePattern);
|
|
174
|
+
const typeTemplate = input.type ? seo.typeTemplates?.[input.type] : undefined;
|
|
175
|
+
const site = seo.siteName ?? "";
|
|
176
|
+
|
|
177
|
+
let title: string;
|
|
178
|
+
let shareTitle: string;
|
|
179
|
+
if (present(override?.title)) {
|
|
180
|
+
// An approved title is what the creator signed off on, so it is used
|
|
181
|
+
// verbatim: no template, no suffix.
|
|
182
|
+
title = override!.title!.trim();
|
|
183
|
+
shareTitle = title;
|
|
184
|
+
} else {
|
|
185
|
+
shareTitle = codeTitle || site;
|
|
186
|
+
title = applyTitleTemplate(shareTitle, site, typeTemplate?.title || seo.titleTemplate);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const description =
|
|
190
|
+
(present(override?.description) ? override!.description! : undefined) ||
|
|
191
|
+
input.description ||
|
|
192
|
+
(typeTemplate?.description ? fillTemplate(typeTemplate.description, { title: shareTitle, site }) : undefined) ||
|
|
193
|
+
seo.defaultDescription ||
|
|
194
|
+
shareTitle;
|
|
195
|
+
|
|
196
|
+
const image = (present(override?.image) ? override!.image! : undefined) || input.image || seo.defaultImage || undefined;
|
|
197
|
+
|
|
198
|
+
const noindex =
|
|
199
|
+
seo.indexing === false ||
|
|
200
|
+
(typeof override?.noindex === "boolean" ? override.noindex : !!input.noindex);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
title,
|
|
204
|
+
shareTitle,
|
|
205
|
+
description,
|
|
206
|
+
image,
|
|
207
|
+
noindex,
|
|
208
|
+
canonical: canonicalFor(seo, input.pathname, override, { entity: input.entity }),
|
|
209
|
+
overridden: !!override && Object.keys(override).length > 0,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pathnameFromContext,
|
|
3
|
+
resolvePageSeo,
|
|
4
|
+
routePatternFromContext,
|
|
5
|
+
seoRecordFromContext,
|
|
6
|
+
type ResolvedPageSeo,
|
|
7
|
+
} from "./resolve";
|
|
8
|
+
import type { SeoHeadContext, SeoHeadInput, SeoMetaEntry } from "./types";
|
|
9
|
+
|
|
10
|
+
/** JSON-LD nodes as head() meta entries. TanStack renders each as a script tag. */
|
|
11
|
+
export function jsonLdMeta(jsonLd?: object | object[] | null): SeoMetaEntry[] {
|
|
12
|
+
const nodes = Array.isArray(jsonLd) ? jsonLd : jsonLd ? [jsonLd] : [];
|
|
13
|
+
return nodes.filter((n) => !!n && typeof n === "object").map((n) => ({ "script:ld+json": n }));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The per-page share and robots tags for one resolved page. */
|
|
17
|
+
export function pageMeta(resolved: ResolvedPageSeo, ogType = "website"): SeoMetaEntry[] {
|
|
18
|
+
const { title, shareTitle, description, image, noindex, canonical } = resolved;
|
|
19
|
+
return [
|
|
20
|
+
{ title },
|
|
21
|
+
{ name: "description", content: description },
|
|
22
|
+
{ property: "og:title", content: shareTitle },
|
|
23
|
+
{ property: "og:description", content: description },
|
|
24
|
+
{ property: "og:type", content: ogType },
|
|
25
|
+
...(canonical ? [{ property: "og:url", content: canonical }] : []),
|
|
26
|
+
...(image ? [{ property: "og:image", content: image }] : []),
|
|
27
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
28
|
+
{ name: "twitter:title", content: shareTitle },
|
|
29
|
+
{ name: "twitter:description", content: description },
|
|
30
|
+
...(image ? [{ name: "twitter:image", content: image }] : []),
|
|
31
|
+
...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A route's SEO, declared in code with the creator's edits resolved over it.
|
|
37
|
+
*
|
|
38
|
+
* head: (ctx) => seoHead(ctx, { title: "About Clay Studio", description: "..." })
|
|
39
|
+
*
|
|
40
|
+
* Reads the site's SEO record from the root match (the root loader fetched it
|
|
41
|
+
* with `fetchSiteBootstrap`) and returns `{ meta }` ONLY. Never links: TanStack
|
|
42
|
+
* concatenates links across every match, so a canonical here would sit beside
|
|
43
|
+
* the root's and the page would carry two. The root's `siteSeoHead` is the one
|
|
44
|
+
* place a canonical comes from.
|
|
45
|
+
*
|
|
46
|
+
* With no record (an older backend, or a failed fetch) it emits what
|
|
47
|
+
* `buildHeadMeta` always did, minus the links.
|
|
48
|
+
*/
|
|
49
|
+
export function seoHead(ctx: SeoHeadContext | undefined | null, input: SeoHeadInput): { meta: SeoMetaEntry[] } {
|
|
50
|
+
const seo = seoRecordFromContext(ctx);
|
|
51
|
+
const resolved = resolvePageSeo(seo, {
|
|
52
|
+
pathname: pathnameFromContext(ctx),
|
|
53
|
+
routePattern: routePatternFromContext(ctx),
|
|
54
|
+
type: input.type ?? "page",
|
|
55
|
+
title: input.title,
|
|
56
|
+
description: input.description,
|
|
57
|
+
image: input.image,
|
|
58
|
+
noindex: input.noindex,
|
|
59
|
+
});
|
|
60
|
+
return { meta: [...pageMeta(resolved), ...jsonLdMeta(input.jsonLd)] };
|
|
61
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { buildIdentitySchema, buildWebSiteSchema, identitySchemaId } from "../utils/structuredData";
|
|
2
|
+
import {
|
|
3
|
+
canonicalFor,
|
|
4
|
+
findOverride,
|
|
5
|
+
isHostedPath,
|
|
6
|
+
leafRoutePatternFromContext,
|
|
7
|
+
pathnameFromContext,
|
|
8
|
+
seoRecordFromContext,
|
|
9
|
+
} from "./resolve";
|
|
10
|
+
import { jsonLdMeta } from "./seoHead";
|
|
11
|
+
import type { SeoHeadContext, SeoLinkEntry, SeoMetaEntry } from "./types";
|
|
12
|
+
|
|
13
|
+
export type SiteSeoFallback = {
|
|
14
|
+
/** Used when there is no SEO record, e.g. the site config's name. */
|
|
15
|
+
siteName?: string | null;
|
|
16
|
+
description?: string | null;
|
|
17
|
+
image?: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Site-wide head tags, for the ROOT route's `head()`.
|
|
22
|
+
*
|
|
23
|
+
* head: (ctx) => siteSeoHead(ctx)
|
|
24
|
+
*
|
|
25
|
+
* Every tag here is a fallback a page can replace (TanStack keeps the deepest
|
|
26
|
+
* title and the deepest meta of each name), except two things only the root
|
|
27
|
+
* may emit:
|
|
28
|
+
*
|
|
29
|
+
* - the canonical link, because links are concatenated across matches rather
|
|
30
|
+
* than replaced. It is skipped under `/i/`, where the hosted page emits its
|
|
31
|
+
* own (an entity's canonical is on the default website, which only that page
|
|
32
|
+
* knows to say);
|
|
33
|
+
* - the WebSite and identity JSON-LD, on "/" only.
|
|
34
|
+
*
|
|
35
|
+
* `robots` is set here when the whole site is noindex or this path's override
|
|
36
|
+
* says so, so it holds even on a page whose code never calls `seoHead`.
|
|
37
|
+
*/
|
|
38
|
+
export function siteSeoHead(
|
|
39
|
+
ctx: SeoHeadContext | undefined | null,
|
|
40
|
+
fallback: SiteSeoFallback = {},
|
|
41
|
+
): { meta: SeoMetaEntry[]; links: SeoLinkEntry[] } {
|
|
42
|
+
const seo = seoRecordFromContext(ctx);
|
|
43
|
+
const pathname = pathnameFromContext(ctx);
|
|
44
|
+
|
|
45
|
+
if (!seo) {
|
|
46
|
+
const name = fallback.siteName?.trim();
|
|
47
|
+
return {
|
|
48
|
+
meta: [
|
|
49
|
+
...(name ? [{ title: name }, { property: "og:site_name", content: name }] : []),
|
|
50
|
+
...(fallback.description ? [{ name: "description", content: fallback.description }] : []),
|
|
51
|
+
...(fallback.image ? [{ property: "og:image", content: fallback.image }] : []),
|
|
52
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
53
|
+
],
|
|
54
|
+
links: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const override = findOverride(seo, pathname, leafRoutePatternFromContext(ctx));
|
|
59
|
+
const description = seo.defaultDescription || fallback.description || undefined;
|
|
60
|
+
const image = seo.defaultImage || fallback.image || undefined;
|
|
61
|
+
const noindex = seo.indexing === false || override?.noindex === true;
|
|
62
|
+
|
|
63
|
+
const meta: SeoMetaEntry[] = [
|
|
64
|
+
{ title: seo.siteName },
|
|
65
|
+
...(description ? [{ name: "description", content: description }] : []),
|
|
66
|
+
{ property: "og:site_name", content: seo.siteName },
|
|
67
|
+
...(image ? [{ property: "og:image", content: image }] : []),
|
|
68
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
69
|
+
...(seo.verification?.google ? [{ name: "google-site-verification", content: seo.verification.google }] : []),
|
|
70
|
+
...(seo.verification?.bing ? [{ name: "msvalidate.01", content: seo.verification.bing }] : []),
|
|
71
|
+
...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
if (pathname === "/") {
|
|
75
|
+
const origin = seo.origin.replace(/\/+$/, "");
|
|
76
|
+
const identity = buildIdentitySchema(seo.identity, { origin });
|
|
77
|
+
meta.push(
|
|
78
|
+
...jsonLdMeta(
|
|
79
|
+
[
|
|
80
|
+
buildWebSiteSchema({
|
|
81
|
+
name: seo.siteName,
|
|
82
|
+
url: `${origin}/`,
|
|
83
|
+
description: seo.defaultDescription,
|
|
84
|
+
publisherId: identity ? identitySchemaId(origin) : null,
|
|
85
|
+
}),
|
|
86
|
+
identity,
|
|
87
|
+
].filter((n): n is Record<string, unknown> => !!n),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const links: SeoLinkEntry[] = isHostedPath(pathname)
|
|
93
|
+
? []
|
|
94
|
+
: [{ rel: "canonical", href: canonicalFor(seo, pathname, override) }];
|
|
95
|
+
|
|
96
|
+
return { meta, links };
|
|
97
|
+
}
|
package/src/seo/types.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The SEO record, as `GET /public/websites/seo` returns it.
|
|
2
|
+
//
|
|
3
|
+
// The backend owns the data (settings, templates, the creator's approved page
|
|
4
|
+
// overrides); code owns the defaults. These types are the seam between the two,
|
|
5
|
+
// and the backend returns exactly this shape. Change one side, change both.
|
|
6
|
+
|
|
7
|
+
export type SeoPageType =
|
|
8
|
+
| "page"
|
|
9
|
+
| "product"
|
|
10
|
+
| "event"
|
|
11
|
+
| "course"
|
|
12
|
+
| "coaching"
|
|
13
|
+
| "blog_post"
|
|
14
|
+
| "blog_category"
|
|
15
|
+
| "podcast"
|
|
16
|
+
| "podcast_episode"
|
|
17
|
+
| "community"
|
|
18
|
+
| "series"
|
|
19
|
+
| "film";
|
|
20
|
+
|
|
21
|
+
export type SeoPageOverride = {
|
|
22
|
+
title?: string | null;
|
|
23
|
+
description?: string | null;
|
|
24
|
+
image?: string | null;
|
|
25
|
+
/** Absolute URL. */
|
|
26
|
+
canonical?: string | null;
|
|
27
|
+
noindex?: boolean | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SeoIdentity = {
|
|
31
|
+
kind: "person" | "organization" | "local_business";
|
|
32
|
+
name: string;
|
|
33
|
+
logo?: string | null;
|
|
34
|
+
sameAs: string[];
|
|
35
|
+
address?: { street?: string; locality?: string; region?: string; postalCode?: string; country?: string } | null;
|
|
36
|
+
phone?: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SeoRecord = {
|
|
40
|
+
websiteId: string;
|
|
41
|
+
/** "https://claystudio.com": the primary verified custom domain, else https://{subdomain}.{ROOT_DOMAIN}. */
|
|
42
|
+
origin: string;
|
|
43
|
+
isDefaultWebsite: boolean;
|
|
44
|
+
/** Origin of the profile's default website. Entity canonicals point here. */
|
|
45
|
+
defaultWebsiteOrigin: string;
|
|
46
|
+
siteName: string;
|
|
47
|
+
defaultDescription: string | null;
|
|
48
|
+
defaultImage: string | null;
|
|
49
|
+
/** Default "{title} | {site}". */
|
|
50
|
+
titleTemplate: string;
|
|
51
|
+
typeTemplates: Partial<Record<SeoPageType, { title?: string; description?: string }>>;
|
|
52
|
+
identity: SeoIdentity | null;
|
|
53
|
+
verification: { google?: string | null; bing?: string | null };
|
|
54
|
+
/** false = the whole site is noindex. */
|
|
55
|
+
indexing: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Key = exact pathname ("/about", "/i/store/mug") OR route pattern
|
|
58
|
+
* ("/team/$slug"). Entity overrides arrive already expanded to their /i/
|
|
59
|
+
* pathname.
|
|
60
|
+
*/
|
|
61
|
+
pages: Record<string, SeoPageOverride>;
|
|
62
|
+
/** Max updatedAt of the inputs, ISO. */
|
|
63
|
+
version: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** What a route's code says its SEO is, before any creator edit. */
|
|
67
|
+
export type SeoHeadInput = {
|
|
68
|
+
title: string;
|
|
69
|
+
description?: string;
|
|
70
|
+
image?: string;
|
|
71
|
+
noindex?: boolean;
|
|
72
|
+
type?: SeoPageType;
|
|
73
|
+
jsonLd?: object | object[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The part of TanStack's `head()` context Forge reads.
|
|
78
|
+
*
|
|
79
|
+
* Structural rather than imported, so Forge does not pin a router version and a
|
|
80
|
+
* site can pass `ctx` straight through.
|
|
81
|
+
*/
|
|
82
|
+
export type SeoMatchLike = {
|
|
83
|
+
pathname?: string;
|
|
84
|
+
routeId?: string;
|
|
85
|
+
fullPath?: string;
|
|
86
|
+
loaderData?: unknown;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type SeoHeadContext = {
|
|
90
|
+
matches?: ReadonlyArray<SeoMatchLike | undefined>;
|
|
91
|
+
match?: SeoMatchLike;
|
|
92
|
+
params?: Record<string, string>;
|
|
93
|
+
loaderData?: unknown;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** One `head()` meta entry. A JSON-LD node rides as `{ "script:ld+json": obj }`. */
|
|
97
|
+
export type SeoMetaEntry = Record<string, unknown>;
|
|
98
|
+
|
|
99
|
+
export type SeoLinkEntry = { rel: string; href: string };
|
|
@@ -129,3 +129,68 @@ describe("fetchSiteBootstrap", () => {
|
|
|
129
129
|
expect(r.diagnostics?.probe).toBeUndefined();
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("fetchSiteBootstrap: the SEO record", () => {
|
|
134
|
+
const RECORD = {
|
|
135
|
+
websiteId: "w1",
|
|
136
|
+
origin: "https://clay.example",
|
|
137
|
+
isDefaultWebsite: true,
|
|
138
|
+
defaultWebsiteOrigin: "https://clay.example",
|
|
139
|
+
siteName: "Clay",
|
|
140
|
+
defaultDescription: null,
|
|
141
|
+
defaultImage: null,
|
|
142
|
+
titleTemplate: "{title} | {site}",
|
|
143
|
+
typeTemplates: {},
|
|
144
|
+
identity: null,
|
|
145
|
+
verification: {},
|
|
146
|
+
indexing: true,
|
|
147
|
+
pages: {},
|
|
148
|
+
version: "2026-10-01T00:00:00.000Z",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
it("fetches it alongside the rest when a websiteId is given", async () => {
|
|
152
|
+
const seen: string[] = [];
|
|
153
|
+
stubFetch((url) => {
|
|
154
|
+
seen.push(url);
|
|
155
|
+
if (url.includes("/websites/seo")) return json(RECORD);
|
|
156
|
+
return url.includes("/content") ? json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} }) : json({});
|
|
157
|
+
});
|
|
158
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteId: "w1", websiteVersionId: "v1" });
|
|
159
|
+
expect(seen).toContain(`${API}/public/websites/seo?websiteId=w1`);
|
|
160
|
+
expect(r.seo).toEqual(RECORD);
|
|
161
|
+
expect(r.diagnostics).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does not ask for it without a websiteId", async () => {
|
|
165
|
+
const seen: string[] = [];
|
|
166
|
+
stubFetch((url) => {
|
|
167
|
+
seen.push(url);
|
|
168
|
+
return json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} });
|
|
169
|
+
});
|
|
170
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, websiteVersionId: "v1" });
|
|
171
|
+
expect(seen.some((u) => u.includes("/websites/seo"))).toBe(false);
|
|
172
|
+
expect(r.seo).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("is null on a 404, a network error or a body that is not a record, and never a diagnostics failure", async () => {
|
|
176
|
+
for (const fail of [
|
|
177
|
+
() => new Response("no", { status: 404 }),
|
|
178
|
+
() => {
|
|
179
|
+
throw new TypeError("fetch failed");
|
|
180
|
+
},
|
|
181
|
+
() => json({ error: "nope" }),
|
|
182
|
+
]) {
|
|
183
|
+
stubFetch((url) =>
|
|
184
|
+
url.includes("/websites/seo")
|
|
185
|
+
? fail()
|
|
186
|
+
: url.includes("/content")
|
|
187
|
+
? json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} })
|
|
188
|
+
: json({}),
|
|
189
|
+
);
|
|
190
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteId: "w1", websiteVersionId: "v1" });
|
|
191
|
+
expect(r.seo).toBeNull();
|
|
192
|
+
expect(r.diagnostics).toBeNull();
|
|
193
|
+
vi.unstubAllGlobals();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -56,6 +56,8 @@ import { seoContextFromSiteConfig, type ReviewSchemaReview, type SiteSeoContext
|
|
|
56
56
|
// each route that needs share tags.
|
|
57
57
|
import { buildHeadMeta } from "../utils/headMeta";
|
|
58
58
|
export type { SiteSeoContext };
|
|
59
|
+
import type { SeoRecord } from "../seo/types";
|
|
60
|
+
export type { SeoRecord };
|
|
59
61
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
60
62
|
// Type-only, so nothing from the React data layer reaches this React-free entry.
|
|
61
63
|
// Re-exported so a route loader can name the shape it got back without importing
|
|
@@ -241,20 +243,31 @@ export async function probeApi(apiUrl: string, opts?: { timeoutMs?: number }): P
|
|
|
241
243
|
export async function fetchSiteBootstrap(opts: {
|
|
242
244
|
apiUrl: string;
|
|
243
245
|
profileId?: string;
|
|
246
|
+
/**
|
|
247
|
+
* The website being rendered. When given, the SEO record is fetched in the
|
|
248
|
+
* same round trip and returned as `seo`, for `siteSeoHead` / `seoHead`.
|
|
249
|
+
*/
|
|
250
|
+
websiteId?: string;
|
|
244
251
|
websiteVersionId?: string;
|
|
245
252
|
state?: "draft" | "published";
|
|
246
253
|
/** Set false to skip the reachability probe even when a fetch failed. */
|
|
247
254
|
probe?: boolean;
|
|
248
|
-
}): Promise<{
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
255
|
+
}): Promise<{
|
|
256
|
+
contentDoc: ContentDocument;
|
|
257
|
+
siteConfig: SiteConfig | null;
|
|
258
|
+
seo: SeoRecord | null;
|
|
259
|
+
diagnostics: ForgeSsrDiagnostics | null;
|
|
260
|
+
}> {
|
|
261
|
+
const { apiUrl, profileId, websiteId, websiteVersionId, state = "published" } = opts;
|
|
262
|
+
|
|
263
|
+
const [content, siteConfig, seo] = await Promise.all([
|
|
252
264
|
websiteVersionId
|
|
253
265
|
? getJsonResult<ContentDocument>(apiUrl, "/public/websites/content", { websiteVersionId, state })
|
|
254
266
|
: Promise.resolve<JsonResult<ContentDocument>>({ ok: true, data: emptyContentDocument() }),
|
|
255
267
|
profileId
|
|
256
268
|
? getJsonResult<SiteConfig>(apiUrl, "/public/websites/site-config", { profileId })
|
|
257
269
|
: Promise.resolve<JsonResult<SiteConfig | null>>({ ok: true, data: null }),
|
|
270
|
+
websiteId ? fetchSeoRecordServer({ apiUrl, websiteId }) : Promise.resolve(null),
|
|
258
271
|
]);
|
|
259
272
|
|
|
260
273
|
// A deploy with no version / no profile baked in isn't a failure — there was
|
|
@@ -270,10 +283,26 @@ export async function fetchSiteBootstrap(opts: {
|
|
|
270
283
|
return {
|
|
271
284
|
contentDoc: content.ok ? (content.data ?? emptyContentDocument()) : emptyContentDocument(),
|
|
272
285
|
siteConfig: siteConfig.ok ? (siteConfig.data ?? null) : null,
|
|
286
|
+
// Not a diagnostics failure: a site renders correctly without it, on the
|
|
287
|
+
// code's own SEO, and an older API simply does not have the route.
|
|
288
|
+
seo,
|
|
273
289
|
diagnostics,
|
|
274
290
|
};
|
|
275
291
|
}
|
|
276
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The website's SEO record (settings, templates, approved page overrides).
|
|
295
|
+
*
|
|
296
|
+
* `null` on ANY failure, including a 404 from an API that predates the route
|
|
297
|
+
* and a body that is not a record: every SEO function treats `null` as "use
|
|
298
|
+
* the code's own values", so a missing record degrades to today's tags rather
|
|
299
|
+
* than to a broken page.
|
|
300
|
+
*/
|
|
301
|
+
export async function fetchSeoRecordServer(opts: { apiUrl: string; websiteId: string }): Promise<SeoRecord | null> {
|
|
302
|
+
const record = await getJson<SeoRecord>(opts.apiUrl, "/public/websites/seo", { websiteId: opts.websiteId });
|
|
303
|
+
return record && typeof record === "object" && typeof record.siteName === "string" ? record : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
277
306
|
/** Fetch a single event by id or slug for SSR. */
|
|
278
307
|
/**
|
|
279
308
|
* Every published event, for SSR of the events list.
|
package/src/types/models.ts
CHANGED
|
@@ -474,6 +474,11 @@ export interface PublicAuthUser {
|
|
|
474
474
|
updatedAt: string;
|
|
475
475
|
id: string;
|
|
476
476
|
membership?: Membership;
|
|
477
|
+
/**
|
|
478
|
+
* Set when this is a read-only "Preview as client" session: a creator viewing
|
|
479
|
+
* the portal as this person. Null (or absent on older APIs) otherwise.
|
|
480
|
+
*/
|
|
481
|
+
preview?: { previewedByName: string; expiresAt: string } | null;
|
|
477
482
|
}
|
|
478
483
|
|
|
479
484
|
export type UpdateLocalUserPayload = Pick<PublicAuthUser, "firstName">;
|
|
@@ -550,6 +555,12 @@ export interface PublicAuthContextType extends PublicAuthState {
|
|
|
550
555
|
validateLoginCredentials: (data: LoginInput & { profileId: string }) => Promise<string>;
|
|
551
556
|
verifyLoginCode: (email: string, code: string, profileId: string) => Promise<{ smartLinkPath: string | undefined }>;
|
|
552
557
|
resendLoginCode: (email: string) => Promise<void>;
|
|
558
|
+
/**
|
|
559
|
+
* Sign in with a session another call already minted, e.g. the email-first
|
|
560
|
+
* flow's `signed_in` answer: stores the token pair exactly as the code
|
|
561
|
+
* sign-ins do and sets the user.
|
|
562
|
+
*/
|
|
563
|
+
adoptSession: (session: { token: string; refreshToken?: string; account: PublicAuthUser }) => void;
|
|
553
564
|
logout: (persist?: boolean) => Promise<void>;
|
|
554
565
|
updateLocalUser: (data: UpdateLocalUserPayload) => void;
|
|
555
566
|
clearErrorMessage: () => void;
|