@tribe-nest/forge 1.13.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 +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- package/src/utils/structuredData.ts +126 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Forge server entry — framework-agnostic, React-free data fetchers for SSR.
|
|
2
|
+
//
|
|
3
|
+
// Route `loader`s run OUTSIDE React, so they can't use the `useForge()` context;
|
|
4
|
+
// these functions take the per-tenant config (apiUrl / profileId / …) as plain
|
|
5
|
+
// arguments instead. Import from `@tribe-nest/forge/server` (kept separate from
|
|
6
|
+
// the main entry so no React/JSX is pulled into the server bundle). Because this
|
|
7
|
+
// lives in the versioned Forge package, fetch logic + fixes propagate to every
|
|
8
|
+
// deployed site via a Forge version bump — never freeze a server call into a
|
|
9
|
+
// site's own files.
|
|
10
|
+
//
|
|
11
|
+
// Each fetcher hits a public endpoint with plain `fetch`, so it works in both
|
|
12
|
+
// the SSR pass and client-side navigation. Failures degrade to a safe fallback
|
|
13
|
+
// (`null` / an empty document) rather than throwing out of the loader.
|
|
14
|
+
|
|
15
|
+
// App background-job runtime (no-config wrapper over the JobsDO). Server-only.
|
|
16
|
+
export * from "./jobs";
|
|
17
|
+
|
|
18
|
+
import { emptyContentDocument, type ContentDocument } from "../content/types";
|
|
19
|
+
import type {
|
|
20
|
+
IEvent,
|
|
21
|
+
IPublicProduct,
|
|
22
|
+
PublicCourse,
|
|
23
|
+
CoachingProduct,
|
|
24
|
+
BlogPost,
|
|
25
|
+
BlogCategory,
|
|
26
|
+
PodcastShow,
|
|
27
|
+
Collection,
|
|
28
|
+
CollectionEntry,
|
|
29
|
+
CollectionQueryParams,
|
|
30
|
+
CollectionQuery,
|
|
31
|
+
CollectionSearchResult,
|
|
32
|
+
} from "../types/models";
|
|
33
|
+
import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
|
|
34
|
+
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
35
|
+
|
|
36
|
+
/** GET a public endpoint and parse JSON, returning `null` on any failure. */
|
|
37
|
+
async function getJson<T>(apiUrl: string, path: string, params?: Record<string, string | undefined>): Promise<T | null> {
|
|
38
|
+
try {
|
|
39
|
+
const qs = new URLSearchParams();
|
|
40
|
+
for (const [k, v] of Object.entries(params ?? {})) if (v != null) qs.set(k, v);
|
|
41
|
+
const query = qs.toString();
|
|
42
|
+
const res = await fetch(`${apiUrl}${path}${query ? `?${query}` : ""}`);
|
|
43
|
+
if (!res.ok) return null;
|
|
44
|
+
return (await res.json()) as T;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** POST a JSON body to a public endpoint and parse JSON, `null` on any failure. */
|
|
51
|
+
async function postJson<T>(apiUrl: string, path: string, body: unknown): Promise<T | null> {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${apiUrl}${path}`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) return null;
|
|
59
|
+
return (await res.json()) as T;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fetch a site's content document for SSR (version + state). */
|
|
66
|
+
export async function fetchContentDocument(opts: {
|
|
67
|
+
apiUrl: string;
|
|
68
|
+
websiteVersionId?: string;
|
|
69
|
+
state?: "draft" | "published";
|
|
70
|
+
}): Promise<ContentDocument> {
|
|
71
|
+
const { apiUrl, websiteVersionId, state = "published" } = opts;
|
|
72
|
+
if (!websiteVersionId) return emptyContentDocument();
|
|
73
|
+
const doc = await getJson<ContentDocument>(apiUrl, "/public/websites/content", {
|
|
74
|
+
websiteVersionId,
|
|
75
|
+
state,
|
|
76
|
+
});
|
|
77
|
+
return doc ?? emptyContentDocument();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fetch a tenant's runtime config (settlement currency, Meta Pixel id, payment
|
|
82
|
+
* provider) for SSR by profile id. Seed the result into `<ForgeProvider
|
|
83
|
+
* initialSiteConfig={…}>` so `useSiteConfig()` has it on the first paint — no
|
|
84
|
+
* client fetch flash, no hydration mismatch. Returns `null` on failure.
|
|
85
|
+
*/
|
|
86
|
+
export function fetchSiteConfig(opts: { apiUrl: string; profileId?: string }): Promise<SiteConfig | null> {
|
|
87
|
+
if (!opts.profileId) return Promise.resolve(null);
|
|
88
|
+
return getJson<SiteConfig>(opts.apiUrl, "/public/websites/site-config", { profileId: opts.profileId });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fetch a single event by id or slug for SSR. */
|
|
92
|
+
export function fetchEventServer(opts: { apiUrl: string; profileId?: string; idOrSlug: string }): Promise<IEvent | null> {
|
|
93
|
+
return getJson<IEvent>(opts.apiUrl, `/public/events/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
94
|
+
profileId: opts.profileId,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Fetch a single product by id or slug for SSR. */
|
|
99
|
+
export function fetchProductServer(opts: {
|
|
100
|
+
apiUrl: string;
|
|
101
|
+
profileId?: string;
|
|
102
|
+
idOrSlug: string;
|
|
103
|
+
}): Promise<IPublicProduct | null> {
|
|
104
|
+
return getJson<IPublicProduct>(opts.apiUrl, `/public/products/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
105
|
+
profileId: opts.profileId,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fetch a single course by id or slug for SSR. */
|
|
110
|
+
export function fetchCourseServer(opts: {
|
|
111
|
+
apiUrl: string;
|
|
112
|
+
profileId?: string;
|
|
113
|
+
idOrSlug: string;
|
|
114
|
+
}): Promise<PublicCourse | null> {
|
|
115
|
+
return getJson<PublicCourse>(opts.apiUrl, `/public/courses/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
116
|
+
profileId: opts.profileId,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Fetch a single coaching product by id or slug for SSR. */
|
|
121
|
+
export function fetchCoachingProductServer(opts: {
|
|
122
|
+
apiUrl: string;
|
|
123
|
+
profileId?: string;
|
|
124
|
+
idOrSlug: string;
|
|
125
|
+
}): Promise<CoachingProduct | null> {
|
|
126
|
+
return getJson<CoachingProduct>(opts.apiUrl, `/public/coaching/products/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
127
|
+
profileId: opts.profileId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fetch a single blog post by id or slug for SSR. */
|
|
132
|
+
export function fetchBlogPostServer(opts: {
|
|
133
|
+
apiUrl: string;
|
|
134
|
+
profileId?: string;
|
|
135
|
+
idOrSlug: string;
|
|
136
|
+
}): Promise<BlogPost | null> {
|
|
137
|
+
return getJson<BlogPost>(opts.apiUrl, `/public/blog/posts/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
138
|
+
profileId: opts.profileId,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Fetch a single blog category by slug for SSR. */
|
|
143
|
+
export function fetchBlogCategoryServer(opts: {
|
|
144
|
+
apiUrl: string;
|
|
145
|
+
profileId?: string;
|
|
146
|
+
slug: string;
|
|
147
|
+
}): Promise<BlogCategory | null> {
|
|
148
|
+
return getJson<BlogCategory>(opts.apiUrl, `/public/blog/posts/categories/${encodeURIComponent(opts.slug)}`, {
|
|
149
|
+
profileId: opts.profileId,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Podcasts (SSR) — podcasts are podcast-type blog categories ---
|
|
154
|
+
|
|
155
|
+
/** All podcast shows for the profile, for SSR of the podcasts index. */
|
|
156
|
+
export async function fetchPodcastsServer(opts: { apiUrl: string; profileId?: string }): Promise<PodcastShow[]> {
|
|
157
|
+
return (await getJson<PodcastShow[]>(opts.apiUrl, `/public/blog/posts/podcasts`, { profileId: opts.profileId })) ?? [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A show's episodes (audio posts) in feed order, for SSR of a show/episode page. */
|
|
161
|
+
export async function fetchPodcastEpisodesServer(opts: {
|
|
162
|
+
apiUrl: string;
|
|
163
|
+
profileId?: string;
|
|
164
|
+
slug: string;
|
|
165
|
+
}): Promise<BlogPost[]> {
|
|
166
|
+
const res = await getJson<{ data: BlogPost[] }>(
|
|
167
|
+
opts.apiUrl,
|
|
168
|
+
`/public/blog/posts/categories/${encodeURIComponent(opts.slug)}/posts`,
|
|
169
|
+
{ profileId: opts.profileId, page: "1", limit: "100" },
|
|
170
|
+
);
|
|
171
|
+
return (res?.data ?? []).filter((p) => (p.media ?? []).some((m) => (m.type || "").startsWith("audio")));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- CMS collections (SSR) ---
|
|
175
|
+
|
|
176
|
+
/** Fetch a collection's definition + field schema by slug for SSR. */
|
|
177
|
+
export function fetchCollectionServer(opts: {
|
|
178
|
+
apiUrl: string;
|
|
179
|
+
profileId?: string;
|
|
180
|
+
slug: string;
|
|
181
|
+
}): Promise<Collection | null> {
|
|
182
|
+
return getJson<Collection>(opts.apiUrl, `/public/collections/${encodeURIComponent(opts.slug)}/schema`, {
|
|
183
|
+
profileId: opts.profileId,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Search/filter/sort/geo over a collection's published entries for SSR (GET).
|
|
189
|
+
* @deprecated Prefer {@link fetchCollectionQueryServer} (the full query engine).
|
|
190
|
+
* Kept for backwards compatibility.
|
|
191
|
+
*/
|
|
192
|
+
export function fetchCollectionEntriesServer(opts: {
|
|
193
|
+
apiUrl: string;
|
|
194
|
+
profileId?: string;
|
|
195
|
+
slug: string;
|
|
196
|
+
params?: CollectionQueryParams;
|
|
197
|
+
}): Promise<CollectionSearchResult | null> {
|
|
198
|
+
return getJson<CollectionSearchResult>(
|
|
199
|
+
opts.apiUrl,
|
|
200
|
+
`/public/collections/${encodeURIComponent(opts.slug)}/entries`,
|
|
201
|
+
collectionParamsToQuery(opts.profileId, opts.params),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Rich structured query over a collection for SSR (POST /query). */
|
|
206
|
+
export function fetchCollectionQueryServer(opts: {
|
|
207
|
+
apiUrl: string;
|
|
208
|
+
profileId?: string;
|
|
209
|
+
slug: string;
|
|
210
|
+
query?: CollectionQuery;
|
|
211
|
+
}): Promise<CollectionSearchResult | null> {
|
|
212
|
+
const { scope, query } = splitCollectionQuery(opts.query);
|
|
213
|
+
return postJson<CollectionSearchResult>(
|
|
214
|
+
opts.apiUrl,
|
|
215
|
+
`/public/collections/${encodeURIComponent(opts.slug)}/query`,
|
|
216
|
+
{ profileId: opts.profileId, scope, query },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Fetch a single published entry by slug for SSR. */
|
|
221
|
+
export function fetchCollectionEntryServer(opts: {
|
|
222
|
+
apiUrl: string;
|
|
223
|
+
profileId?: string;
|
|
224
|
+
slug: string;
|
|
225
|
+
entrySlug: string;
|
|
226
|
+
}): Promise<CollectionEntry | null> {
|
|
227
|
+
return getJson<CollectionEntry>(
|
|
228
|
+
opts.apiUrl,
|
|
229
|
+
`/public/collections/${encodeURIComponent(opts.slug)}/entries/${encodeURIComponent(opts.entrySlug)}`,
|
|
230
|
+
{ profileId: opts.profileId },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- PWA (installable code sites) — React-free manifest/SW/head builders ---
|
|
235
|
+
export {
|
|
236
|
+
buildWebManifest,
|
|
237
|
+
forgeServiceWorkerScript,
|
|
238
|
+
buildPwaHead,
|
|
239
|
+
FORGE_PWA_VERSION,
|
|
240
|
+
type WebManifest,
|
|
241
|
+
type WebManifestIcon,
|
|
242
|
+
type WebManifestShortcut,
|
|
243
|
+
type BuildWebManifestOptions,
|
|
244
|
+
type BuildPwaHeadOptions,
|
|
245
|
+
type PwaHead,
|
|
246
|
+
type HeadLink,
|
|
247
|
+
type HeadMeta,
|
|
248
|
+
} from "./pwa";
|
|
249
|
+
|
|
250
|
+
/** Strip HTML tags + collapse whitespace for a plain-text SEO meta description. */
|
|
251
|
+
export function toPlainText(html?: string, max = 200): string | undefined {
|
|
252
|
+
if (!html) return undefined;
|
|
253
|
+
const text = html
|
|
254
|
+
.replace(/<[^>]*>/g, " ")
|
|
255
|
+
.replace(/&[a-z]+;/gi, " ")
|
|
256
|
+
.replace(/\s+/g, " ")
|
|
257
|
+
.trim();
|
|
258
|
+
if (!text) return undefined;
|
|
259
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
260
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// App background-job runtime — the thin, no-config wrapper over the host-owned
|
|
2
|
+
// JobsDO (bound as `env.JOBS` at deploy; see apps/dispatch-worker/jobsDO.ts and
|
|
3
|
+
// docs/cms-mini-apps-scaffold-and-recipe.md). The app author only ever touches
|
|
4
|
+
// these three functions + a job map — the DO id-ing, dedup hashing, and callback
|
|
5
|
+
// verification are hidden here. All are SERVER-ONLY (they need the Worker `env`).
|
|
6
|
+
|
|
7
|
+
// The bits of the app Worker's `env` this runtime needs. Injected at deploy:
|
|
8
|
+
// JOBS (the DO binding), plus the app's identity + script name + API base.
|
|
9
|
+
export interface AppJobsEnv {
|
|
10
|
+
JOBS: {
|
|
11
|
+
idFromName: (name: string) => unknown;
|
|
12
|
+
get: (id: unknown) => AppJobsStub;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AppJobsStub {
|
|
17
|
+
reconcile: (script: string, appId: string, desired: JobSchedule[], hash: string) => Promise<{ changed: boolean }>;
|
|
18
|
+
enqueue: (script: string, appId: string, name: string, payload: unknown, delayMs: number) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface JobSchedule {
|
|
22
|
+
/** Stable job name — also the key in your job map + the `scheduleId` in the token. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** How often to run, in milliseconds (e.g. 60_000 = every minute). */
|
|
25
|
+
intervalMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type JobHandler = (payload: unknown, env: unknown) => Promise<void> | void;
|
|
29
|
+
|
|
30
|
+
export interface AppJobsConfig {
|
|
31
|
+
/** The app id (VITE_APP_ID). */
|
|
32
|
+
appId: string;
|
|
33
|
+
/** This Worker's script name in the dispatch namespace (VITE_APP_SCRIPT). */
|
|
34
|
+
script: string;
|
|
35
|
+
/** The TribeNest public API base (VITE_API_URL) — used to verify the callback token. */
|
|
36
|
+
apiUrl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const stub = (env: AppJobsEnv, appId: string): AppJobsStub => env.JOBS.get(env.JOBS.idFromName(appId));
|
|
40
|
+
|
|
41
|
+
// A stable fingerprint of the desired schedule set, so the DO skips reconcile
|
|
42
|
+
// when nothing changed (sorted by name → order-independent).
|
|
43
|
+
function scheduleHash(schedules: JobSchedule[]): string {
|
|
44
|
+
return JSON.stringify([...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((s) => [s.name, s.intervalMs]));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reconcile the app's recurring schedules with the DO — call ONCE per deploy
|
|
49
|
+
* (guard with a module flag + ctx.waitUntil so it isn't per-request). Idempotent:
|
|
50
|
+
* unchanged schedules keep their timing, a changed interval re-times only that
|
|
51
|
+
* one, a removed one is pruned.
|
|
52
|
+
*/
|
|
53
|
+
export async function reconcileAppJobs(env: AppJobsEnv, cfg: AppJobsConfig, schedules: JobSchedule[]): Promise<void> {
|
|
54
|
+
await stub(env, cfg.appId).reconcile(cfg.script, cfg.appId, schedules, scheduleHash(schedules));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Enqueue a durable one-time job (survives redeploys, retried on failure). */
|
|
58
|
+
export async function enqueueAppJob(
|
|
59
|
+
env: AppJobsEnv,
|
|
60
|
+
cfg: AppJobsConfig,
|
|
61
|
+
name: string,
|
|
62
|
+
payload?: unknown,
|
|
63
|
+
delayMs = 0,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
await stub(env, cfg.appId).enqueue(cfg.script, cfg.appId, name, payload ?? null, delayMs);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handle the DO's alarm callback (`POST /_jobs/run`). Verifies the platform
|
|
70
|
+
* token, looks up the named handler in your job map, and runs it with the app's
|
|
71
|
+
* `env` (so `env.SHOPIFY_TOKEN` etc. are available). Wire your `/_jobs/run` route
|
|
72
|
+
* to this.
|
|
73
|
+
*/
|
|
74
|
+
export async function handleAppJobRun(
|
|
75
|
+
request: Request,
|
|
76
|
+
env: unknown,
|
|
77
|
+
cfg: AppJobsConfig,
|
|
78
|
+
jobs: Record<string, JobHandler>,
|
|
79
|
+
): Promise<Response> {
|
|
80
|
+
const token = request.headers.get("x-app-sync-token") ?? "";
|
|
81
|
+
let valid = false;
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/verify-sync`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ token }),
|
|
87
|
+
});
|
|
88
|
+
valid = (await res.json())?.valid === true;
|
|
89
|
+
} catch {
|
|
90
|
+
valid = false;
|
|
91
|
+
}
|
|
92
|
+
if (!valid) return new Response("unauthorized", { status: 401 });
|
|
93
|
+
|
|
94
|
+
const body = (await request.json().catch(() => ({}))) as { job?: string; payload?: unknown };
|
|
95
|
+
const handler = body.job ? jobs[body.job] : undefined;
|
|
96
|
+
if (!handler) return new Response("unknown job", { status: 404 });
|
|
97
|
+
try {
|
|
98
|
+
await handler(body.payload ?? null, env);
|
|
99
|
+
return new Response("ok");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Non-2xx → the DO retries (one-time) / re-fires next interval (recurring).
|
|
102
|
+
return new Response(`job failed: ${(err as Error).message}`, { status: 500 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// PWA server helpers — framework-agnostic, React-free (imported from
|
|
2
|
+
// `@tribe-nest/forge/server`). These build the web app manifest, the service
|
|
3
|
+
// worker source, and the head <link>/<meta> tags for an installable code site.
|
|
4
|
+
//
|
|
5
|
+
// Everything lives here (the versioned Forge package), NOT in per-site files, so
|
|
6
|
+
// a fix propagates to every deployed site on its next Forge version bump — the
|
|
7
|
+
// core Forge invariant ("never freeze logic into site files"). The site carries
|
|
8
|
+
// only three thin route shells (`/manifest.webmanifest`, `/sw.js`, `/offline`)
|
|
9
|
+
// that call these functions.
|
|
10
|
+
//
|
|
11
|
+
// See docs/pwa-custom-code-initiative.md (locked decisions).
|
|
12
|
+
|
|
13
|
+
import type { ContentDocument } from "../content/types";
|
|
14
|
+
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
15
|
+
|
|
16
|
+
// The Forge package version, baked into the SW cache name so a version bump
|
|
17
|
+
// purges old caches on `activate`. Bumped with the package; a literal here keeps
|
|
18
|
+
// this module dependency-free (no JSON import in the SSR/Workers bundle).
|
|
19
|
+
export const FORGE_PWA_VERSION = "1.1.0";
|
|
20
|
+
|
|
21
|
+
// Forge baseline theme colors (kept in sync with `defaultForgeTheme` in
|
|
22
|
+
// ui/theme/ForgeThemeProvider — duplicated here as literals so this server
|
|
23
|
+
// module stays React-free and never pulls the UI layer into the SSR bundle).
|
|
24
|
+
const DEFAULT_PRIMARY = "#6d28d9";
|
|
25
|
+
const DEFAULT_BACKGROUND = "#ffffff";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a `color`-typed field's value from the content document, tolerating both
|
|
29
|
+
* localized and bare storage. Returns `undefined` when the field is unset so the
|
|
30
|
+
* caller can fall back. Content-doc keys are already camelCase/dotted (theme.*),
|
|
31
|
+
* so no jsonb-key mangling applies here.
|
|
32
|
+
*/
|
|
33
|
+
function readThemeColor(doc: ContentDocument | undefined | null, path: string): string | undefined {
|
|
34
|
+
const rec = doc?.fields?.[path];
|
|
35
|
+
if (!rec) return undefined;
|
|
36
|
+
const v = rec.value;
|
|
37
|
+
if (typeof v === "string" && v) return v;
|
|
38
|
+
// Localized value map — take the default locale, then any first entry.
|
|
39
|
+
if (v && typeof v === "object") {
|
|
40
|
+
const map = v as Record<string, unknown>;
|
|
41
|
+
const dl = doc?.defaultLocale;
|
|
42
|
+
const pick = (dl && map[dl]) || Object.values(map)[0];
|
|
43
|
+
if (typeof pick === "string" && pick) return pick;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WebManifestIcon {
|
|
49
|
+
src: string;
|
|
50
|
+
sizes: string;
|
|
51
|
+
type: string;
|
|
52
|
+
purpose?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface WebManifestShortcut {
|
|
56
|
+
name: string;
|
|
57
|
+
short_name?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
url: string;
|
|
60
|
+
icons?: { src: string; sizes: string }[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface WebManifest {
|
|
64
|
+
name: string;
|
|
65
|
+
short_name?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
start_url: string;
|
|
68
|
+
scope: string;
|
|
69
|
+
display: string;
|
|
70
|
+
orientation?: string;
|
|
71
|
+
background_color?: string;
|
|
72
|
+
theme_color?: string;
|
|
73
|
+
lang?: string;
|
|
74
|
+
dir?: string;
|
|
75
|
+
prefer_related_applications?: boolean;
|
|
76
|
+
icons: WebManifestIcon[];
|
|
77
|
+
categories?: string[];
|
|
78
|
+
shortcuts?: WebManifestShortcut[];
|
|
79
|
+
screenshots?: {
|
|
80
|
+
src: string;
|
|
81
|
+
sizes: string;
|
|
82
|
+
type: string;
|
|
83
|
+
form_factor?: string;
|
|
84
|
+
label?: string;
|
|
85
|
+
}[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface BuildWebManifestOptions {
|
|
89
|
+
siteConfig: SiteConfig | null | undefined;
|
|
90
|
+
contentDoc?: ContentDocument | null;
|
|
91
|
+
overrides?: Partial<WebManifest>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the web app manifest for a code site from the extended `site-config`
|
|
96
|
+
* `pwa` block + the content document's `theme.*` colors. When the creator has no
|
|
97
|
+
* `pwaConfig` (`siteConfig.pwa` is null), returns a minimal name-only manifest
|
|
98
|
+
* (no icons) — not installable, parity with the client's `{}` behavior — so the
|
|
99
|
+
* route always returns valid JSON and an admin nudge can prompt setup.
|
|
100
|
+
*
|
|
101
|
+
* - `theme_color` = content `theme.primary`, `background_color` = content
|
|
102
|
+
* `theme.background` (code sites' theme source of truth), Forge defaults
|
|
103
|
+
* otherwise. Improves on the client's primary-for-both.
|
|
104
|
+
* - `screenshots` omitted entirely when the creator hasn't uploaded them.
|
|
105
|
+
* - No hardcoded shortcuts — pass `overrides.shortcuts` (the starter points
|
|
106
|
+
* them at `/i/*` pages).
|
|
107
|
+
*/
|
|
108
|
+
export function buildWebManifest(opts: BuildWebManifestOptions): WebManifest {
|
|
109
|
+
const { siteConfig, contentDoc, overrides } = opts;
|
|
110
|
+
const pwa = siteConfig?.pwa ?? null;
|
|
111
|
+
|
|
112
|
+
const themeColor = readThemeColor(contentDoc, "theme.primary") ?? DEFAULT_PRIMARY;
|
|
113
|
+
const backgroundColor = readThemeColor(contentDoc, "theme.background") ?? DEFAULT_BACKGROUND;
|
|
114
|
+
|
|
115
|
+
const name = pwa?.name || siteConfig?.siteName || "App";
|
|
116
|
+
|
|
117
|
+
// No PWA identity → minimal, non-installable manifest (valid JSON, no icons).
|
|
118
|
+
if (!pwa) {
|
|
119
|
+
return {
|
|
120
|
+
name,
|
|
121
|
+
short_name: siteConfig?.siteName || name,
|
|
122
|
+
start_url: "/",
|
|
123
|
+
scope: "/",
|
|
124
|
+
display: "standalone",
|
|
125
|
+
background_color: backgroundColor,
|
|
126
|
+
theme_color: themeColor,
|
|
127
|
+
icons: [],
|
|
128
|
+
...overrides,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const icons: WebManifestIcon[] = [];
|
|
133
|
+
if (pwa.icon192) icons.push({ src: pwa.icon192, sizes: "192x192", type: "image/png", purpose: "maskable" });
|
|
134
|
+
if (pwa.icon512) icons.push({ src: pwa.icon512, sizes: "512x512", type: "image/png", purpose: "any" });
|
|
135
|
+
|
|
136
|
+
const screenshots: NonNullable<WebManifest["screenshots"]> = [];
|
|
137
|
+
if (pwa.screenshotWide1280X720)
|
|
138
|
+
screenshots.push({
|
|
139
|
+
src: pwa.screenshotWide1280X720,
|
|
140
|
+
sizes: "1280x720",
|
|
141
|
+
type: "image/png",
|
|
142
|
+
form_factor: "wide",
|
|
143
|
+
label: "Desktop view",
|
|
144
|
+
});
|
|
145
|
+
if (pwa.screenshotNarrow750X1334)
|
|
146
|
+
screenshots.push({
|
|
147
|
+
src: pwa.screenshotNarrow750X1334,
|
|
148
|
+
sizes: "750x1334",
|
|
149
|
+
type: "image/png",
|
|
150
|
+
form_factor: "narrow",
|
|
151
|
+
label: "Mobile view",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const manifest: WebManifest = {
|
|
155
|
+
name: pwa.name || name,
|
|
156
|
+
short_name: pwa.shortName || pwa.name || name,
|
|
157
|
+
description: pwa.description || undefined,
|
|
158
|
+
start_url: "/",
|
|
159
|
+
scope: "/",
|
|
160
|
+
display: "standalone",
|
|
161
|
+
orientation: "portrait-primary",
|
|
162
|
+
background_color: backgroundColor,
|
|
163
|
+
theme_color: themeColor,
|
|
164
|
+
lang: "en",
|
|
165
|
+
dir: "ltr",
|
|
166
|
+
prefer_related_applications: false,
|
|
167
|
+
icons,
|
|
168
|
+
categories: ["social", "business", "productivity"],
|
|
169
|
+
// `screenshots` omitted entirely when absent — installability never required it.
|
|
170
|
+
...(screenshots.length ? { screenshots } : {}),
|
|
171
|
+
...overrides,
|
|
172
|
+
};
|
|
173
|
+
return manifest;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface BuildPwaHeadOptions {
|
|
177
|
+
siteConfig: SiteConfig | null | undefined;
|
|
178
|
+
contentDoc?: ContentDocument | null;
|
|
179
|
+
/** Where the manifest route lives (default `/manifest.webmanifest`). */
|
|
180
|
+
manifestHref?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A TanStack `head()`-shaped link entry. */
|
|
184
|
+
export interface HeadLink {
|
|
185
|
+
rel: string;
|
|
186
|
+
href: string;
|
|
187
|
+
sizes?: string;
|
|
188
|
+
type?: string;
|
|
189
|
+
}
|
|
190
|
+
/** A TanStack `head()`-shaped meta entry. */
|
|
191
|
+
export interface HeadMeta {
|
|
192
|
+
name?: string;
|
|
193
|
+
content: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface PwaHead {
|
|
197
|
+
links: HeadLink[];
|
|
198
|
+
metas: HeadMeta[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The head <link>/<meta> entries an installable code site needs: manifest link,
|
|
203
|
+
* `apple-touch-icon` (icon192 — the client app is missing this; don't repeat
|
|
204
|
+
* that bug), `theme-color`, and the iOS standalone metas. Merge `links`/`metas`
|
|
205
|
+
* into the route's `head()` return. iOS 16.4+ web push only works from the
|
|
206
|
+
* installed (Home Screen) PWA — `<InstallBanner/>` is the gateway on iOS.
|
|
207
|
+
*/
|
|
208
|
+
export function buildPwaHead(opts: BuildPwaHeadOptions): PwaHead {
|
|
209
|
+
const { siteConfig, contentDoc } = opts;
|
|
210
|
+
const manifestHref = opts.manifestHref ?? "/manifest.webmanifest";
|
|
211
|
+
const themeColor = readThemeColor(contentDoc, "theme.primary") ?? DEFAULT_PRIMARY;
|
|
212
|
+
const appleIcon = siteConfig?.pwa?.icon192 || siteConfig?.pwa?.icon512;
|
|
213
|
+
|
|
214
|
+
const links: HeadLink[] = [{ rel: "manifest", href: manifestHref }];
|
|
215
|
+
if (appleIcon) links.push({ rel: "apple-touch-icon", href: appleIcon, sizes: "192x192" });
|
|
216
|
+
|
|
217
|
+
const metas: HeadMeta[] = [
|
|
218
|
+
{ name: "theme-color", content: themeColor },
|
|
219
|
+
{ name: "mobile-web-app-capable", content: "yes" },
|
|
220
|
+
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
|
221
|
+
{ name: "apple-mobile-web-app-status-bar-style", content: "default" },
|
|
222
|
+
{ name: "apple-mobile-web-app-title", content: siteConfig?.pwa?.shortName || siteConfig?.pwa?.name || siteConfig?.siteName || "App" },
|
|
223
|
+
];
|
|
224
|
+
return { links, metas };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The service-worker source, as a dependency-free vanilla-JS string served by
|
|
229
|
+
* the starter's `/sw.js` route (Content-Type: application/javascript). No
|
|
230
|
+
* Workbox, no vite-plugin-pwa — SSR sites want the network, so the SW earns its
|
|
231
|
+
* keep on offline UX + push, not shell caching:
|
|
232
|
+
*
|
|
233
|
+
* - Navigations: network-first, no caching of SSR HTML; `/offline` on failure.
|
|
234
|
+
* - `/assets/*` (hashed, immutable): cache-first.
|
|
235
|
+
* - `push` + `notificationclick`: the FROZEN payload contract shared with the
|
|
236
|
+
* client SW ({ type, title, body, url, profileId, channelId?, ...icon }).
|
|
237
|
+
* - Cache name includes the Forge version; old caches purged on `activate`.
|
|
238
|
+
* - `skipWaiting()` + `clients.claim()` (safe — no HTML precache).
|
|
239
|
+
*
|
|
240
|
+
* The `/offline` page is precached on install so the fallback renders with no
|
|
241
|
+
* network.
|
|
242
|
+
*/
|
|
243
|
+
export function forgeServiceWorkerScript(version: string = FORGE_PWA_VERSION): string {
|
|
244
|
+
const cacheName = `forge-pwa-v${version}`;
|
|
245
|
+
return `// Auto-generated Forge service worker (v${version}). Do not edit — served by /sw.js.
|
|
246
|
+
const CACHE = ${JSON.stringify(cacheName)};
|
|
247
|
+
const OFFLINE_URL = "/offline";
|
|
248
|
+
|
|
249
|
+
self.addEventListener("install", (event) => {
|
|
250
|
+
event.waitUntil(
|
|
251
|
+
caches.open(CACHE).then((cache) => cache.addAll([OFFLINE_URL])).catch(() => {})
|
|
252
|
+
);
|
|
253
|
+
self.skipWaiting();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
self.addEventListener("activate", (event) => {
|
|
257
|
+
event.waitUntil(
|
|
258
|
+
caches.keys().then((keys) =>
|
|
259
|
+
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
|
260
|
+
).then(() => self.clients.claim())
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
self.addEventListener("fetch", (event) => {
|
|
265
|
+
const req = event.request;
|
|
266
|
+
if (req.method !== "GET") return;
|
|
267
|
+
const url = new URL(req.url);
|
|
268
|
+
if (url.origin !== self.location.origin) return;
|
|
269
|
+
|
|
270
|
+
// Hashed, immutable build assets: cache-first.
|
|
271
|
+
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/_build/")) {
|
|
272
|
+
event.respondWith(
|
|
273
|
+
caches.open(CACHE).then((cache) =>
|
|
274
|
+
cache.match(req).then((hit) =>
|
|
275
|
+
hit ||
|
|
276
|
+
fetch(req).then((res) => {
|
|
277
|
+
if (res && res.status === 200) cache.put(req, res.clone());
|
|
278
|
+
return res;
|
|
279
|
+
})
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Navigations (SSR HTML): network-first, no HTML caching, offline fallback.
|
|
287
|
+
if (req.mode === "navigate") {
|
|
288
|
+
event.respondWith(
|
|
289
|
+
fetch(req).catch(() =>
|
|
290
|
+
caches.open(CACHE).then((cache) => cache.match(OFFLINE_URL)).then((r) => r || Response.error())
|
|
291
|
+
)
|
|
292
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
self.addEventListener("push", (event) => {
|
|
298
|
+
if (!event.data) return;
|
|
299
|
+
try {
|
|
300
|
+
const data = event.data.json();
|
|
301
|
+
const { title, body, type, url, profileId, channelId, spaceId, icon } = data;
|
|
302
|
+
const options = {
|
|
303
|
+
body: body,
|
|
304
|
+
icon: icon || undefined,
|
|
305
|
+
tag:
|
|
306
|
+
type === "chat_message" || type === "dm"
|
|
307
|
+
? "chat-" + (channelId || profileId)
|
|
308
|
+
: spaceId
|
|
309
|
+
? "space-" + spaceId
|
|
310
|
+
: "post-" + profileId,
|
|
311
|
+
data: { url: url, type: type },
|
|
312
|
+
};
|
|
313
|
+
event.waitUntil(self.registration.showNotification(title, options));
|
|
314
|
+
} catch (err) {
|
|
315
|
+
// swallow — a malformed push must not crash the SW
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
self.addEventListener("notificationclick", (event) => {
|
|
320
|
+
event.notification.close();
|
|
321
|
+
const target = (event.notification.data && event.notification.data.url) || "/";
|
|
322
|
+
event.waitUntil(
|
|
323
|
+
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
|
324
|
+
for (const client of clientList) {
|
|
325
|
+
if (client.url.indexOf(target) !== -1 && "focus" in client) return client.focus();
|
|
326
|
+
}
|
|
327
|
+
if (self.clients.openWindow) return self.clients.openWindow(target);
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
`;
|
|
332
|
+
}
|