@tribe-nest/forge 3.61.0 → 3.64.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.
@@ -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
+ });
@@ -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<{ contentDoc: ContentDocument; siteConfig: SiteConfig | null; diagnostics: ForgeSsrDiagnostics | null }> {
249
- const { apiUrl, profileId, websiteVersionId, state = "published" } = opts;
250
-
251
- const [content, siteConfig] = await Promise.all([
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/server/pwa.ts CHANGED
@@ -178,6 +178,13 @@ export interface BuildPwaHeadOptions {
178
178
  contentDoc?: ContentDocument | null;
179
179
  /** Where the manifest route lives (default `/manifest.webmanifest`). */
180
180
  manifestHref?: string;
181
+ /**
182
+ * The website's SEO record, as the root loader returned it. Its `siteName` is
183
+ * what the creator calls the site in search, so the Home Screen label uses it
184
+ * ahead of the site config's name (the profile's). A PWA name the creator set
185
+ * explicitly still wins: that is the app's own label.
186
+ */
187
+ seo?: { siteName?: string | null } | null;
181
188
  }
182
189
 
183
190
  /** A TanStack `head()`-shaped link entry. */
@@ -219,7 +226,15 @@ export function buildPwaHead(opts: BuildPwaHeadOptions): PwaHead {
219
226
  { name: "mobile-web-app-capable", content: "yes" },
220
227
  { name: "apple-mobile-web-app-capable", content: "yes" },
221
228
  { 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" },
229
+ {
230
+ name: "apple-mobile-web-app-title",
231
+ content:
232
+ siteConfig?.pwa?.shortName ||
233
+ siteConfig?.pwa?.name ||
234
+ opts.seo?.siteName?.trim() ||
235
+ siteConfig?.siteName ||
236
+ "App",
237
+ },
223
238
  ];
224
239
  return { links, metas };
225
240
  }
@@ -6,6 +6,7 @@ import { setMetaPolicy, trackMeta } from "../../utils/metaEvents";
6
6
  import { setCurrentPageEventId, isProfilePixelSuppressed } from "../../utils/analyticsBus";
7
7
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
8
8
  import { createEngagementTracker, scrollDepthOf, type EngagementTracker } from "../../utils/engagementTracker";
9
+ import { onCLS, onINP, onLCP, type Metric } from "web-vitals";
9
10
 
10
11
  export interface ForgeAnalyticsProps {
11
12
  /**
@@ -60,6 +61,61 @@ function currentPage(): string {
60
61
  return window.location.pathname + window.location.search;
61
62
  }
62
63
 
64
+ /**
65
+ * Web Vitals (LCP, INP, CLS), for the SEO tab's page speed report.
66
+ *
67
+ * `web-vitals` callbacks cannot be unregistered, so they are registered once
68
+ * per page load, the first time first-party analytics is allowed, and report
69
+ * through whichever `<ForgeAnalytics />` is mounted now (`vitalsSink`). With
70
+ * none mounted, or analytics switched off, a report is dropped. Observers are
71
+ * buffered, so a visitor who consents after the page painted still reports its
72
+ * LCP.
73
+ *
74
+ * Each metric instance is sent once (its first report): a page view, or a
75
+ * restore from the back/forward cache, which `web-vitals` treats as a new one.
76
+ * The path is the page the metric belongs to: the URL `web-vitals` names for
77
+ * it, else the page the document loaded on, since an SPA navigation does not
78
+ * start a new measurement.
79
+ */
80
+ type VitalReport = { name: Metric["name"]; value: number; rating: Metric["rating"]; path: string };
81
+ let vitalsSink: ((report: VitalReport) => void) | null = null;
82
+ let vitalsRegistered = false;
83
+ const vitalsSent = new Set<string>();
84
+
85
+ function pathOfMetric(metric: Metric, loadPath: string): string {
86
+ try {
87
+ if (metric.navigationURL) return new URL(metric.navigationURL, window.location.href).pathname;
88
+ } catch {
89
+ // fall through to the page the document loaded on
90
+ }
91
+ return loadPath;
92
+ }
93
+
94
+ function registerWebVitals(): void {
95
+ if (vitalsRegistered || typeof window === "undefined") return;
96
+ vitalsRegistered = true;
97
+ const loadPath = currentPathname();
98
+ const report = (metric: Metric) => {
99
+ if (vitalsSent.has(metric.id) || !vitalsSink) return;
100
+ vitalsSent.add(metric.id);
101
+ vitalsSink({ name: metric.name, value: metric.value, rating: metric.rating, path: pathOfMetric(metric, loadPath) });
102
+ };
103
+ try {
104
+ onLCP(report);
105
+ onINP(report);
106
+ onCLS(report);
107
+ } catch {
108
+ // An unsupported browser measures nothing; never break the page for it.
109
+ }
110
+ }
111
+
112
+ /** For tests: forget the registration and what was sent, as a fresh page load would. */
113
+ export function __resetWebVitalsForTests(): void {
114
+ vitalsSink = null;
115
+ vitalsRegistered = false;
116
+ vitalsSent.clear();
117
+ }
118
+
63
119
  /** Engaged means the visitor can see the page and it has focus. */
64
120
  function isEngaged(): boolean {
65
121
  return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
@@ -74,6 +130,8 @@ function isEngaged(): boolean {
74
130
  * - Auto click tracking on links/buttons (opt out with `trackClicks={false}`).
75
131
  * - Time on page and scroll depth, as `engagement` events (engaged time only:
76
132
  * visible and focused).
133
+ * - Page speed: LCP, INP and CLS (Web Vitals), as one `web_vital` event per
134
+ * metric per page view, for the SEO tab.
77
135
  * - Optional Meta Pixel: pass `metaPixelId` to init the pixel and mirror each
78
136
  * page view as a Meta `PageView` (event-id matched for CAPI de-dup).
79
137
  *
@@ -263,6 +321,18 @@ export function ForgeAnalytics({
263
321
  };
264
322
  }, [firstPartyOn]);
265
323
 
324
+ // Web Vitals: first-party only, like the page views they belong to. Sent by
325
+ // beacon, because INP and CLS are reported as the page is hidden.
326
+ useEffect(() => {
327
+ if (!firstPartyOn || typeof window === "undefined") return;
328
+ const sink = (report: VitalReport) => beaconRef.current("web_vital", report);
329
+ vitalsSink = sink;
330
+ registerWebVitals();
331
+ return () => {
332
+ if (vitalsSink === sink) vitalsSink = null;
333
+ };
334
+ }, [firstPartyOn]);
335
+
266
336
  // Click tracking: first-party only, one delegated listener for the document.
267
337
  useEffect(() => {
268
338
  if (!firstPartyOn || !trackClicks || typeof document === "undefined") return;
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { render, act, cleanup } from "@testing-library/react";
4
+
5
+ /**
6
+ * Web Vitals reporting (SEO step 4). `web-vitals` is mocked: each `onX`
7
+ * registration is captured so a test can fire the metric the browser would.
8
+ * What is asserted: one `web_vital` event per metric instance, by beacon, with
9
+ * `{ name, value, rating, path }`; nothing at all without first-party consent;
10
+ * the cookieless mode still reporting; and registration happening once per
11
+ * page load however often the component mounts.
12
+ */
13
+
14
+ type Metric = {
15
+ name: "LCP" | "INP" | "CLS";
16
+ value: number;
17
+ rating: "good" | "needs-improvement" | "poor";
18
+ id: string;
19
+ navigationURL?: string;
20
+ };
21
+ const handlers: Record<string, Array<(m: Metric) => void>> = { LCP: [], INP: [], CLS: [] };
22
+ vi.mock("web-vitals", () => ({
23
+ onLCP: (cb: (m: Metric) => void) => handlers.LCP.push(cb),
24
+ onINP: (cb: (m: Metric) => void) => handlers.INP.push(cb),
25
+ onCLS: (cb: (m: Metric) => void) => handlers.CLS.push(cb),
26
+ }));
27
+
28
+ const calls: { via: "track" | "beacon"; eventType: string; data: Record<string, unknown> }[] = [];
29
+ vi.mock("../../../data/queries/useAnalytics", () => ({
30
+ newEventId: () => "evt",
31
+ useTrackEvent: () => ({
32
+ track: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "track", eventType, data }),
33
+ trackBeacon: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "beacon", eventType, data }),
34
+ }),
35
+ }));
36
+ vi.mock("../../../data/queries/useWebsite", () => ({ useSiteConfig: () => ({ data: undefined }) }));
37
+ const consent = { performance: true, marketing: false };
38
+ vi.mock("../../headless/consent/useCookieConsent", () => ({ useCookieConsent: () => consent }));
39
+
40
+ import { ForgeAnalytics, __resetWebVitalsForTests } from "../ForgeAnalytics";
41
+
42
+ const fire = (metric: Metric) => {
43
+ for (const cb of handlers[metric.name]) cb(metric);
44
+ };
45
+ const vitals = () => calls.filter((c) => c.eventType === "web_vital").map((c) => [c.via, c.data]);
46
+
47
+ describe("<ForgeAnalytics /> web vitals", () => {
48
+ beforeEach(() => {
49
+ calls.length = 0;
50
+ for (const key of Object.keys(handlers)) handlers[key].length = 0;
51
+ consent.performance = true;
52
+ __resetWebVitalsForTests();
53
+ window.history.replaceState({}, "", "/classes?ref=x");
54
+ });
55
+
56
+ afterEach(() => {
57
+ cleanup();
58
+ });
59
+
60
+ it("sends LCP, INP and CLS once each, by beacon, with the page the document loaded on", () => {
61
+ render(<ForgeAnalytics trackClicks={false} />);
62
+ expect(handlers.LCP).toHaveLength(1);
63
+ expect(handlers.INP).toHaveLength(1);
64
+ expect(handlers.CLS).toHaveLength(1);
65
+
66
+ // An SPA navigation does not start a new measurement.
67
+ act(() => window.history.pushState({}, "", "/about"));
68
+ fire({ name: "LCP", value: 1820.4, rating: "good", id: "v5-lcp-1" });
69
+ fire({ name: "CLS", value: 0.04, rating: "good", id: "v5-cls-1" });
70
+ fire({ name: "INP", value: 260, rating: "needs-improvement", id: "v5-inp-1" });
71
+ // A later report of the same instance (CLS grows) is not sent again.
72
+ fire({ name: "CLS", value: 0.3, rating: "poor", id: "v5-cls-1" });
73
+
74
+ expect(vitals()).toEqual([
75
+ ["beacon", { name: "LCP", value: 1820.4, rating: "good", path: "/classes" }],
76
+ ["beacon", { name: "CLS", value: 0.04, rating: "good", path: "/classes" }],
77
+ ["beacon", { name: "INP", value: 260, rating: "needs-improvement", path: "/classes" }],
78
+ ]);
79
+ });
80
+
81
+ it("sends a new instance (a back/forward cache restore) with the URL web-vitals names", () => {
82
+ render(<ForgeAnalytics trackClicks={false} />);
83
+ fire({ name: "LCP", value: 900, rating: "good", id: "a" });
84
+ fire({ name: "LCP", value: 300, rating: "good", id: "b", navigationURL: "https://site.test/events/x?y=1" });
85
+ expect(vitals().map(([, data]) => (data as { path: string }).path)).toEqual(["/classes", "/events/x"]);
86
+ });
87
+
88
+ it("measures nothing without performance consent, and starts once it is given", () => {
89
+ consent.performance = false;
90
+ const { rerender } = render(<ForgeAnalytics trackClicks={false} />);
91
+ expect(handlers.LCP).toHaveLength(0);
92
+ fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
93
+ expect(vitals()).toEqual([]);
94
+
95
+ consent.performance = true;
96
+ rerender(<ForgeAnalytics trackClicks={false} />);
97
+ expect(handlers.LCP).toHaveLength(1);
98
+ // The observers are buffered, so the LCP that painted before consent still reports.
99
+ fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
100
+ expect(vitals()).toEqual([["beacon", { name: "LCP", value: 1000, rating: "good", path: "/classes" }]]);
101
+ });
102
+
103
+ it("reports in cookieless mode without performance consent", () => {
104
+ consent.performance = false;
105
+ render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
106
+ fire({ name: "INP", value: 120, rating: "good", id: "i1" });
107
+ expect(vitals()).toEqual([["beacon", { name: "INP", value: 120, rating: "good", path: "/classes" }]]);
108
+ });
109
+
110
+ it("records nothing when disabled, and nothing after the component unmounts", () => {
111
+ const { unmount } = render(<ForgeAnalytics enabled={false} />);
112
+ expect(handlers.LCP).toHaveLength(0);
113
+ unmount();
114
+
115
+ const mounted = render(<ForgeAnalytics trackClicks={false} />);
116
+ mounted.unmount();
117
+ fire({ name: "LCP", value: 1000, rating: "good", id: "late" });
118
+ expect(vitals()).toEqual([]);
119
+ });
120
+
121
+ it("registers once per page load however often it mounts", () => {
122
+ render(<ForgeAnalytics trackClicks={false} />);
123
+ cleanup();
124
+ render(<ForgeAnalytics trackClicks={false} />);
125
+ expect(handlers.LCP).toHaveLength(1);
126
+ fire({ name: "LCP", value: 700, rating: "good", id: "once" });
127
+ expect(vitals()).toHaveLength(1);
128
+ });
129
+ });
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { readLanding } from "../landing";
4
+
5
+ /**
6
+ * SEO step 6: checkout credits a sale to the page the buyer first landed on.
7
+ * `readLanding` is spread into every Forge checkout body, so `landingPath` riding
8
+ * on it is what puts the pathname on the purchase row.
9
+ */
10
+ function store(snapshot: Record<string, unknown>) {
11
+ window.localStorage.setItem("tn:landing", JSON.stringify(snapshot));
12
+ }
13
+
14
+ describe("readLanding", () => {
15
+ beforeEach(() => window.localStorage.clear());
16
+
17
+ it("returns the pathname of the first landing URL, without its query", () => {
18
+ store({
19
+ landingUtm: { utm_source: "tribenest" },
20
+ referrer: "https://instagram.com/",
21
+ landingUrl: "https://clayroom.example/i/blog/first-class?utm_source=tribenest#top",
22
+ at: Date.now(),
23
+ });
24
+ expect(readLanding()).toEqual({
25
+ landingUtm: { utm_source: "tribenest" },
26
+ referrer: "https://instagram.com/",
27
+ landingPath: "/i/blog/first-class",
28
+ });
29
+ });
30
+
31
+ it("leaves landingPath undefined when the stored URL cannot be parsed", () => {
32
+ store({ landingUtm: {}, referrer: "", landingUrl: "not a url", at: Date.now() });
33
+ expect(readLanding()).toEqual({ landingUtm: undefined, referrer: undefined, landingPath: undefined });
34
+ });
35
+
36
+ it("returns null when nothing is stored", () => {
37
+ expect(readLanding()).toBeNull();
38
+ });
39
+ });