@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.
Files changed (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@tribe-nest/forge",
3
+ "version": "1.13.0",
4
+ "publishConfig": { "access": "public" },
5
+ "description": "Forge — the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./ui": "./src/ui/index.ts",
9
+ "./ui/headless": "./src/ui/headless/index.ts",
10
+ "./server": "./src/server/index.ts"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "check-types": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "dependencies": {
21
+ "@radix-ui/react-dialog": "^1.1.14",
22
+ "@radix-ui/react-slot": "^1.2.3",
23
+ "@stripe/react-stripe-js": "^3.8.0",
24
+ "@stripe/stripe-js": "^7.6.1",
25
+ "@tanstack/react-query": "^5.62.7",
26
+ "axios": "^1.7.9",
27
+ "lucide-react": "^0.510.0",
28
+ "react-hook-form": "^7.60.0",
29
+ "react-markdown": "^10.1.0",
30
+ "remark-gfm": "^4.0.1",
31
+ "socket.io-client": "^4.8.1"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=18",
35
+ "react-dom": ">=18"
36
+ },
37
+ "devDependencies": {
38
+ "@types/react": "^19.1.2",
39
+ "typescript": "~5.8.3"
40
+ },
41
+ "license": "ISC"
42
+ }
@@ -0,0 +1,54 @@
1
+ import axios, { type AxiosInstance } from "axios";
2
+
3
+ export interface ForgeClientOptions {
4
+ /** Base URL of the TribeNest public API, e.g. https://api.tribenest.co */
5
+ baseURL: string;
6
+ /**
7
+ * Returns the current bearer token (member session), or null when anonymous.
8
+ * Read per-request so the client never has to be recreated when the token changes.
9
+ */
10
+ getToken?: () => string | null;
11
+ /**
12
+ * Per-tenant publishable key (`pk_site_…`). When the hardened public API is in
13
+ * place (Component 1) this is sent on every request to scope reads to the tenant.
14
+ */
15
+ publishableKey?: string;
16
+ }
17
+
18
+ /**
19
+ * Creates the Axios instance Forge uses to talk to the TribeNest public API.
20
+ *
21
+ * Replaces the old env-pinned singleton in `apps/client/src/services/httpClient.ts`:
22
+ * the base URL is injected (no `process.env`), the token is read per-request via a
23
+ * closure, and `localStorage` access is SSR-guarded so the client is safe on Workers.
24
+ */
25
+ export const createForgeClient = ({ baseURL, getToken, publishableKey }: ForgeClientOptions): AxiosInstance => {
26
+ const client = axios.create({ baseURL });
27
+
28
+ client.interceptors.request.use((config) => {
29
+ config.headers["x-timezone-offset"] = new Date().getTimezoneOffset();
30
+
31
+ if (typeof localStorage !== "undefined") {
32
+ config.headers["x-user-selected-currency"] = localStorage.getItem("userSelectedCurrency");
33
+ }
34
+
35
+ if (publishableKey) {
36
+ config.headers["x-forge-key"] = publishableKey;
37
+ }
38
+
39
+ const token = getToken?.();
40
+ if (token) {
41
+ config.headers["authorization"] = `Bearer ${token}`;
42
+ }
43
+
44
+ return config;
45
+ });
46
+
47
+ return client;
48
+ };
49
+
50
+ /**
51
+ * Derives the WebSocket URL from the API base URL (http→ws, https→wss).
52
+ * Replaces the hardcoded `wss://api.tribenest.co` in the old httpClient.
53
+ */
54
+ export const getForgeWebSocketUrl = (baseURL: string): string => baseURL.replace(/^http/i, "ws");
@@ -0,0 +1,118 @@
1
+ import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import type { ContentDocument, FieldPatch, FieldType, ForgeLocale } from "./types";
3
+ import { emptyContentDocument } from "./types";
4
+ import { applyPatch } from "./resolve";
5
+ import { FORGE_EDIT } from "./editFlag";
6
+ import { ForgeEditBridge } from "./ForgeEditBridge";
7
+
8
+ /**
9
+ * A live record of a field discovered at render. Schema-from-code (LOCKED): the
10
+ * `useField` call *is* the schema, so the registry is built by rendering, not by
11
+ * a separate schema file. Held in a ref (never state) because hundreds of fields
12
+ * re-registering on every render would otherwise cause render storms.
13
+ */
14
+ export type RegistryEntry = {
15
+ path: string;
16
+ type: FieldType;
17
+ localized: boolean;
18
+ refKind?: string;
19
+ /** The code-provided default — the value shown until the creator edits. */
20
+ default: unknown;
21
+ /** Optional human label for the field panel; falls back to the path. */
22
+ label?: string;
23
+ /** How many times this path was rendered this pass (N>1 = aliased/repeated). */
24
+ instances: number;
25
+ };
26
+
27
+ export type ForgeContentValue = {
28
+ doc: ContentDocument;
29
+ activeLocale: ForgeLocale;
30
+ editable: boolean;
31
+ /** Idempotently record a field's schema during render. */
32
+ register: (entry: Omit<RegistryEntry, "instances">) => void;
33
+ /** Snapshot the registry (for the field panel + overlay bridge). */
34
+ getFields: () => RegistryEntry[];
35
+ /** Apply an in-memory edit (instant preview update; durability is handled by the admin). */
36
+ patch: (patch: FieldPatch) => void;
37
+ };
38
+
39
+ // An inert fallback so `useField` works even with no provider (e.g. the legacy
40
+ // apps/client, or a block rendered in isolation) — it just returns code
41
+ // defaults and registers nothing.
42
+ const INERT: ForgeContentValue = {
43
+ doc: emptyContentDocument(),
44
+ activeLocale: "en",
45
+ editable: false,
46
+ register: () => {},
47
+ getFields: () => [],
48
+ patch: () => {},
49
+ };
50
+
51
+ const ForgeContentContext = createContext<ForgeContentValue | null>(null);
52
+
53
+ export type ForgeContentProviderProps = {
54
+ /** The document fetched at SSR time (published for prod, draft for preview). */
55
+ initialDoc?: ContentDocument;
56
+ /** Active locale; defaults to the document's default locale. */
57
+ locale?: ForgeLocale;
58
+ /**
59
+ * Whether editing affordances (`data-forge-field`, the overlay bridge) are
60
+ * active. Defaults to the `__FORGE_EDIT__` build flag so prod is off by
61
+ * construction; the admin preview can force it on.
62
+ */
63
+ editable?: boolean;
64
+ children: ReactNode;
65
+ };
66
+
67
+ export function ForgeContentProvider({ initialDoc, locale, editable, children }: ForgeContentProviderProps) {
68
+ const [doc, setDoc] = useState<ContentDocument>(() => initialDoc ?? emptyContentDocument());
69
+
70
+ // Registry is per-provider-instance (so it's request-scoped during SSR — a new
71
+ // instance per render, no cross-request bleed in a long-lived Worker isolate).
72
+ const registry = useRef<Map<string, RegistryEntry>>(new Map());
73
+ // Reset instance counts at the start of each commit so stale paths don't linger.
74
+ registry.current.forEach((e) => (e.instances = 0));
75
+
76
+ const register = useCallback((entry: Omit<RegistryEntry, "instances">) => {
77
+ const prev = registry.current.get(entry.path);
78
+ if (prev) {
79
+ prev.instances += 1;
80
+ // Keep the first-seen schema; later renders only bump the instance count.
81
+ return;
82
+ }
83
+ registry.current.set(entry.path, { ...entry, instances: 1 });
84
+ }, []);
85
+
86
+ const getFields = useCallback(() => Array.from(registry.current.values()), []);
87
+
88
+ const patch = useCallback((p: FieldPatch) => {
89
+ setDoc((d) => {
90
+ const meta = registry.current.get(p.path);
91
+ return applyPatch(d, p, meta ? { type: meta.type, localized: meta.localized, refKind: meta.refKind } : undefined);
92
+ });
93
+ }, []);
94
+
95
+ const activeLocale = locale ?? doc.defaultLocale;
96
+ const isEditable = editable ?? FORGE_EDIT;
97
+
98
+ const value = useMemo<ForgeContentValue>(
99
+ () => ({ doc, activeLocale, editable: isEditable, register, getFields, patch }),
100
+ [doc, activeLocale, isEditable, register, getFields, patch],
101
+ );
102
+
103
+ return (
104
+ <ForgeContentContext.Provider value={value}>
105
+ {children}
106
+ {/* Mount the editing bridge when this site is editable. Driven by the
107
+ runtime `editable` flag (the host passes VITE_FORGE_EDIT) so it can't be
108
+ silently disabled by a stale build-time define. In a published site
109
+ `editable` is false → the bridge never mounts and emits nothing. */}
110
+ {isEditable ? <ForgeEditBridge /> : null}
111
+ </ForgeContentContext.Provider>
112
+ );
113
+ }
114
+
115
+ /** Access the content context. Returns an inert default outside a provider. */
116
+ export function useContent(): ForgeContentValue {
117
+ return useContext(ForgeContentContext) ?? INERT;
118
+ }
@@ -0,0 +1,134 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useContent } from "./ForgeContentProvider";
3
+
4
+ // The in-iframe half of the editing loop. The previewed site is cross-origin to
5
+ // the admin, so the admin can't read its DOM — instead this bridge reports the
6
+ // rendered field list (and their on-screen rects) out via postMessage and
7
+ // applies edits in memory (instant, no reload). Durable saves are the admin's
8
+ // job over its authenticated session.
9
+ //
10
+ // Mounted only when the `__FORGE_EDIT__` build flag is set (the editor sandbox),
11
+ // so it is dead-code-eliminated from the published build.
12
+
13
+ export type BridgeField = {
14
+ path: string;
15
+ type: string;
16
+ refKind?: string;
17
+ localized: boolean;
18
+ label?: string;
19
+ /** Current stored value (locale map or scalar) or the code default. */
20
+ value: unknown;
21
+ };
22
+
23
+ export type FieldRect = { path: string; rect: { x: number; y: number; width: number; height: number } };
24
+
25
+ type ForgeOutMessage =
26
+ | { source: "forge"; type: "ready" }
27
+ | { source: "forge"; type: "fields"; fields: BridgeField[]; locales: string[]; defaultLocale: string }
28
+ | { source: "forge"; type: "rects"; rects: FieldRect[] };
29
+
30
+ type ForgeInMessage =
31
+ | { source: "forge"; type: "request-fields" }
32
+ | { source: "forge"; type: "request-rects" }
33
+ | { source: "forge"; type: "patch"; path: string; locale?: string; value: unknown };
34
+
35
+ export function ForgeEditBridge() {
36
+ const ctx = useContent();
37
+ // Hold the latest context in a ref so the message listener (mounted once) sees
38
+ // current fields/doc without re-subscribing on every render.
39
+ const ctxRef = useRef(ctx);
40
+ ctxRef.current = ctx;
41
+ const parentOrigin = useRef<string>("*");
42
+
43
+ const postFields = () => {
44
+ if (typeof window === "undefined" || window.parent === window) return;
45
+ const c = ctxRef.current;
46
+ const fields: BridgeField[] = c.getFields().map((e) => ({
47
+ path: e.path,
48
+ type: e.type,
49
+ refKind: e.refKind,
50
+ localized: e.localized,
51
+ label: e.label,
52
+ value: c.doc.fields[e.path]?.value ?? e.default,
53
+ }));
54
+ const msg: ForgeOutMessage = {
55
+ source: "forge",
56
+ type: "fields",
57
+ fields,
58
+ locales: c.doc.locales,
59
+ defaultLocale: c.doc.defaultLocale,
60
+ };
61
+ window.parent.postMessage(msg, parentOrigin.current);
62
+ };
63
+ const postFieldsRef = useRef(postFields);
64
+ postFieldsRef.current = postFields;
65
+
66
+ // Report the on-screen rect of every rendered field (located by the
67
+ // `data-forge-field` marker), so the admin can draw the editing overlay in its
68
+ // own coordinate space. A path rendered N times yields N entries.
69
+ const postRects = () => {
70
+ if (typeof window === "undefined" || window.parent === window || typeof document === "undefined") return;
71
+ const rects: FieldRect[] = Array.from(document.querySelectorAll("[data-forge-field]")).map((el) => {
72
+ const r = (el as HTMLElement).getBoundingClientRect();
73
+ return {
74
+ path: el.getAttribute("data-forge-field") || "",
75
+ rect: { x: r.x, y: r.y, width: r.width, height: r.height },
76
+ };
77
+ });
78
+ const msg: ForgeOutMessage = { source: "forge", type: "rects", rects };
79
+ window.parent.postMessage(msg, parentOrigin.current);
80
+ };
81
+ const postRectsRef = useRef(postRects);
82
+ postRectsRef.current = postRects;
83
+
84
+ // Mount the listener + announce readiness once.
85
+ useEffect(() => {
86
+ if (typeof window === "undefined" || window.parent === window) return;
87
+
88
+ const onMessage = (ev: MessageEvent) => {
89
+ const data = ev.data as ForgeInMessage | undefined;
90
+ if (!data || data.source !== "forge") return;
91
+ parentOrigin.current = ev.origin || "*";
92
+ if (data.type === "request-fields") {
93
+ postFieldsRef.current();
94
+ postRectsRef.current();
95
+ } else if (data.type === "request-rects") {
96
+ postRectsRef.current();
97
+ } else if (data.type === "patch") {
98
+ ctxRef.current.patch({ path: data.path, locale: data.locale, value: data.value });
99
+ }
100
+ };
101
+
102
+ // Re-report rects on scroll/resize (throttled to a frame) so the overlay
103
+ // tracks the page.
104
+ let raf = 0;
105
+ const onViewportChange = () => {
106
+ if (raf) return;
107
+ raf = window.requestAnimationFrame(() => {
108
+ raf = 0;
109
+ postRectsRef.current();
110
+ });
111
+ };
112
+
113
+ window.addEventListener("message", onMessage);
114
+ window.addEventListener("scroll", onViewportChange, true);
115
+ window.addEventListener("resize", onViewportChange);
116
+ const ready: ForgeOutMessage = { source: "forge", type: "ready" };
117
+ window.parent.postMessage(ready, "*");
118
+ return () => {
119
+ window.removeEventListener("message", onMessage);
120
+ window.removeEventListener("scroll", onViewportChange, true);
121
+ window.removeEventListener("resize", onViewportChange);
122
+ if (raf) window.cancelAnimationFrame(raf);
123
+ };
124
+ }, []);
125
+
126
+ // Re-announce the field list + rects whenever the document changes (e.g. after
127
+ // a patch) so the admin panel and overlay reflect current values/positions.
128
+ useEffect(() => {
129
+ postFieldsRef.current();
130
+ postRectsRef.current();
131
+ }, [ctx.doc]);
132
+
133
+ return null;
134
+ }
@@ -0,0 +1,23 @@
1
+ // Edit-mode flag — whether the content overlay/bridge should run.
2
+ //
3
+ // Primary source: Vite injects `import.meta.env.VITE_FORGE_EDIT` when it bundles
4
+ // Forge into a site (the in-editor preview sets it "true"; published sites never
5
+ // set it). Reading it here means the overlay turns on directly from the env, so
6
+ // it can't be silently disabled by a stale host build-define or root wiring.
7
+ //
8
+ // Fallbacks: the `__FORGE_EDIT__` build literal (if a host still defines it),
9
+ // else false (tests / non-Vite contexts).
10
+
11
+ declare const __FORGE_EDIT__: boolean | undefined;
12
+
13
+ function resolveForgeEdit(): boolean {
14
+ try {
15
+ const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
16
+ if (env && env.VITE_FORGE_EDIT != null) return env.VITE_FORGE_EDIT === "true";
17
+ } catch {
18
+ // import.meta unavailable (tests / non-Vite) — fall through.
19
+ }
20
+ return typeof __FORGE_EDIT__ !== "undefined" ? !!__FORGE_EDIT__ : false;
21
+ }
22
+
23
+ export const FORGE_EDIT: boolean = resolveForgeEdit();
@@ -0,0 +1,28 @@
1
+ // The content layer — typed, versioned, editable, localizable site content read
2
+ // at render via hooks and stored decoupled from the deployed Worker.
3
+ // See docs/code-websites-initiative.md (Component 7).
4
+
5
+ export { ForgeContentProvider, useContent } from "./ForgeContentProvider";
6
+ export type { ForgeContentProviderProps, ForgeContentValue, RegistryEntry } from "./ForgeContentProvider";
7
+
8
+ export { useField, useFieldRaw } from "./useField";
9
+ export type { UseFieldOptions, UseFieldResult } from "./useField";
10
+
11
+ export { ForgeEditBridge, type BridgeField, type FieldRect } from "./ForgeEditBridge";
12
+
13
+ export { resolveFieldValue, applyPatch, applyPatches } from "./resolve";
14
+ export { FORGE_EDIT } from "./editFlag";
15
+
16
+ export {
17
+ emptyContentDocument,
18
+ defaultLocalizedFor,
19
+ type ContentDocument,
20
+ type FieldRecord,
21
+ type FieldType,
22
+ type FieldPatch,
23
+ type ForgeLocale,
24
+ type LocaleMap,
25
+ type ImageValue,
26
+ type LinkValue,
27
+ type ReferenceValue,
28
+ } from "./types";
@@ -0,0 +1,67 @@
1
+ import type { ContentDocument, FieldPatch, FieldRecord, ForgeLocale, LocaleMap } from "./types";
2
+
3
+ // Pure resolution + patch helpers — shared by the reader hooks and unit tests,
4
+ // and free of React so they can run anywhere.
5
+
6
+ /**
7
+ * Resolve a field to its effective value. Resolution order (the single source of
8
+ * truth, used identically in SSR and on the client so hydration matches):
9
+ * localized: value[activeLocale] → value[defaultLocale] → codeDefault
10
+ * non-localized: value → codeDefault
11
+ * A missing record (unedited field) falls straight through to the code default.
12
+ */
13
+ export function resolveFieldValue<T>(
14
+ record: FieldRecord | undefined,
15
+ activeLocale: ForgeLocale,
16
+ defaultLocale: ForgeLocale,
17
+ codeDefault: T,
18
+ ): T {
19
+ if (!record || record.value == null) return codeDefault;
20
+ if (!record.localized) return record.value as T;
21
+
22
+ const map = record.value as LocaleMap<T>;
23
+ const active = map?.[activeLocale];
24
+ if (active != null) return active;
25
+ const fallback = map?.[defaultLocale];
26
+ if (fallback != null) return fallback;
27
+ return codeDefault;
28
+ }
29
+
30
+ /**
31
+ * Apply one patch to a document immutably, returning a new document. The
32
+ * `localized`/`type`/`refKind` metadata for an as-yet-unstored field comes from
33
+ * the registry entry that triggered the edit (passed via `meta`), so the first
34
+ * write to a field records its shape. Per-path patches are commutative, which is
35
+ * what makes the draft save rebase trivially on a version mismatch.
36
+ */
37
+ export function applyPatch(
38
+ doc: ContentDocument,
39
+ patch: FieldPatch,
40
+ meta?: { type: FieldRecord["type"]; localized: boolean; refKind?: string },
41
+ ): ContentDocument {
42
+ const existing = doc.fields[patch.path];
43
+ const type = existing?.type ?? meta?.type ?? "text";
44
+ const localized = existing?.localized ?? meta?.localized ?? false;
45
+ const refKind = existing?.refKind ?? meta?.refKind;
46
+
47
+ let value: unknown;
48
+ if (localized) {
49
+ const locale = patch.locale ?? doc.defaultLocale;
50
+ const prev = (existing?.value as LocaleMap<unknown> | undefined) ?? {};
51
+ value = { ...prev, [locale]: patch.value };
52
+ } else {
53
+ value = patch.value;
54
+ }
55
+
56
+ const next: FieldRecord = { type, localized, ...(refKind ? { refKind } : {}), value };
57
+ return { ...doc, fields: { ...doc.fields, [patch.path]: next } };
58
+ }
59
+
60
+ /** Apply a batch of patches in order (used by the backend draft PUT and tests). */
61
+ export function applyPatches(
62
+ doc: ContentDocument,
63
+ patches: FieldPatch[],
64
+ metaFor?: (path: string) => { type: FieldRecord["type"]; localized: boolean; refKind?: string } | undefined,
65
+ ): ContentDocument {
66
+ return patches.reduce((acc, p) => applyPatch(acc, p, metaFor?.(p.path)), doc);
67
+ }
@@ -0,0 +1,97 @@
1
+ // The content layer's data model — a typed, versioned content document per site.
2
+ //
3
+ // Core principle (LOCKED): editable = explicitly bound fields. Nothing is
4
+ // editable unless a developer (or the AI) binds it to a named field path via
5
+ // `useField` / `<EditableText field>`. The document is read at render and stored
6
+ // decoupled from the deployed Worker, so content edits are instant and never
7
+ // require a rebuild. See docs/code-websites-initiative.md (Component 7).
8
+
9
+ /** A BCP-47-ish locale tag, e.g. "en", "fr", "pt-BR". */
10
+ export type ForgeLocale = string;
11
+
12
+ /** The scalar field kinds shipped in this increment. Arrays/slots come later. */
13
+ export type FieldType =
14
+ | "text"
15
+ | "richtext"
16
+ | "number"
17
+ | "boolean"
18
+ | "image"
19
+ | "color"
20
+ | "link"
21
+ | "reference"
22
+ | "date";
23
+
24
+ /**
25
+ * A per-locale value map. Localized fields store one entry per translated
26
+ * locale; the reader resolves the active locale with a fallback chain. The
27
+ * locale dimension is baked into the shape now even though the translation UI
28
+ * ships later — retrofitting it would be a value migration.
29
+ */
30
+ export type LocaleMap<T> = { [locale: string]: T };
31
+
32
+ export type ImageValue = {
33
+ mediaId?: string;
34
+ url: string;
35
+ /** Alt text is itself localizable (a localized leaf inside a non-localized field). */
36
+ alt?: LocaleMap<string> | string;
37
+ };
38
+
39
+ export type LinkValue = {
40
+ href: string;
41
+ label?: LocaleMap<string> | string;
42
+ newTab?: boolean;
43
+ };
44
+
45
+ /** A reference stores only the entity id; the block resolves it via the matching Forge hook. */
46
+ export type ReferenceValue = { id: string };
47
+
48
+ /**
49
+ * One stored field. `localized` is persisted per-record (copied from the field
50
+ * registry at write time) so the reader can resolve a value without the registry
51
+ * loaded, and flipping a type's localized flag later is an explicit migration
52
+ * rather than a silent reinterpretation.
53
+ *
54
+ * `value` is a `LocaleMap<…>` when `localized` is true, otherwise the bare value.
55
+ */
56
+ export type FieldRecord = {
57
+ type: FieldType;
58
+ localized: boolean;
59
+ /** For `reference` fields: which entity kind ("donation" | "product" | …). */
60
+ refKind?: string;
61
+ value: unknown;
62
+ };
63
+
64
+ /**
65
+ * The full content document for one site version + state (draft|published).
66
+ * A flat map of dotted field paths → records: O(1) read/write and orphan
67
+ * detection (registered-paths minus document-keys). Defaults are NEVER stored —
68
+ * an unedited field is simply absent and renders the code-provided default.
69
+ */
70
+ export type ContentDocument = {
71
+ /** Monotonic version for optimistic concurrency on draft writes. */
72
+ version: number;
73
+ /** Hash of the registered {path:type} set at last publish; powers migration detection. */
74
+ schemaFingerprint?: string;
75
+ defaultLocale: ForgeLocale;
76
+ locales: ForgeLocale[];
77
+ fields: Record<string, FieldRecord>;
78
+ };
79
+
80
+ /** A single field edit, the unit of the patch-based draft save + the overlay loop. */
81
+ export type FieldPatch = {
82
+ path: string;
83
+ /** The locale this value targets; ignored for non-localized fields. */
84
+ locale?: ForgeLocale;
85
+ /** The new raw value (scalar, or the full value object for image/link/reference). */
86
+ value: unknown;
87
+ };
88
+
89
+ /** An empty draft document for a brand-new site. */
90
+ export function emptyContentDocument(defaultLocale: ForgeLocale = "en"): ContentDocument {
91
+ return { version: 0, defaultLocale, locales: [defaultLocale], fields: {} };
92
+ }
93
+
94
+ /** Which field types are localized by default (LOCKED): prose is, structured data isn't. */
95
+ export function defaultLocalizedFor(type: FieldType): boolean {
96
+ return type === "text" || type === "richtext";
97
+ }
@@ -0,0 +1,64 @@
1
+ import { useContent } from "./ForgeContentProvider";
2
+ import { resolveFieldValue } from "./resolve";
3
+ import { defaultLocalizedFor, type FieldType, type LocaleMap } from "./types";
4
+
5
+ export type UseFieldOptions<T> = {
6
+ /** The value rendered until the creator edits this field. */
7
+ default?: T;
8
+ /** Field kind — drives the editor widget + whether it localizes. Defaults to "text". */
9
+ type?: FieldType;
10
+ /** For `reference` fields: which entity kind ("donation" | "product" | …). */
11
+ refKind?: string;
12
+ /** Override the default localization for the type. */
13
+ localized?: boolean;
14
+ /** Human label for the field panel. */
15
+ label?: string;
16
+ };
17
+
18
+ export type UseFieldResult<T> = {
19
+ /** The resolved value (active locale → default locale → code default). */
20
+ value: T;
21
+ /** The raw stored value (locale map for localized fields) — for editors. */
22
+ raw: unknown;
23
+ /** Spread onto the rendered element so the overlay can locate this field. Empty in published mode. */
24
+ fieldProps: { "data-forge-field": string } | Record<string, never>;
25
+ };
26
+
27
+ /**
28
+ * Read a bound content field. Calling `useField` both *registers* the field
29
+ * (schema-from-code) and *resolves* its value. The returned `fieldProps` carry
30
+ * the `data-forge-field` marker only in edit mode — in the published build it is
31
+ * an empty object, so nothing leaks to prod.
32
+ */
33
+ export function useField<T = string>(path: string, opts?: UseFieldOptions<T>): UseFieldResult<T> {
34
+ const ctx = useContent();
35
+ const type = opts?.type ?? "text";
36
+ const localized = opts?.localized ?? defaultLocalizedFor(type);
37
+
38
+ ctx.register({
39
+ path,
40
+ type,
41
+ localized,
42
+ refKind: opts?.refKind,
43
+ default: opts?.default,
44
+ label: opts?.label,
45
+ });
46
+
47
+ const record = ctx.doc.fields[path];
48
+ const value = resolveFieldValue<T>(record, ctx.activeLocale, ctx.doc.defaultLocale, opts?.default as T);
49
+
50
+ return {
51
+ value,
52
+ raw: record?.value,
53
+ fieldProps: ctx.editable ? { "data-forge-field": path } : {},
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Read the full localized value map for a field (for translation-aware editors).
59
+ * Returns undefined for an unedited field.
60
+ */
61
+ export function useFieldRaw(path: string): LocaleMap<unknown> | unknown {
62
+ const ctx = useContent();
63
+ return ctx.doc.fields[path]?.value;
64
+ }