@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.
- package/package.json +4 -2
- package/src/index.ts +3 -0
- package/src/runtime/pages.ts +2 -0
- package/src/seo/_tests/seo.spec.ts +522 -0
- package/src/seo/applySeoToHead.ts +104 -0
- package/src/seo/index.ts +10 -0
- package/src/seo/markdown.ts +76 -0
- package/src/seo/resolve.ts +254 -0
- package/src/seo/seoHead.ts +67 -0
- package/src/seo/siteSeoHead.ts +114 -0
- package/src/seo/types.ts +113 -0
- package/src/server/_tests/buildPwaHead.spec.ts +18 -1
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/server/pwa.ts +16 -1
- package/src/ui/analytics/ForgeAnalytics.tsx +70 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +283 -0
- package/src/utils/landing.ts +17 -4
- package/src/utils/structuredData.ts +363 -1
|
@@ -129,3 +129,68 @@ describe("fetchSiteBootstrap", () => {
|
|
|
129
129
|
expect(r.diagnostics?.probe).toBeUndefined();
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("fetchSiteBootstrap: the SEO record", () => {
|
|
134
|
+
const RECORD = {
|
|
135
|
+
websiteId: "w1",
|
|
136
|
+
origin: "https://clay.example",
|
|
137
|
+
isDefaultWebsite: true,
|
|
138
|
+
defaultWebsiteOrigin: "https://clay.example",
|
|
139
|
+
siteName: "Clay",
|
|
140
|
+
defaultDescription: null,
|
|
141
|
+
defaultImage: null,
|
|
142
|
+
titleTemplate: "{title} | {site}",
|
|
143
|
+
typeTemplates: {},
|
|
144
|
+
identity: null,
|
|
145
|
+
verification: {},
|
|
146
|
+
indexing: true,
|
|
147
|
+
pages: {},
|
|
148
|
+
version: "2026-10-01T00:00:00.000Z",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
it("fetches it alongside the rest when a websiteId is given", async () => {
|
|
152
|
+
const seen: string[] = [];
|
|
153
|
+
stubFetch((url) => {
|
|
154
|
+
seen.push(url);
|
|
155
|
+
if (url.includes("/websites/seo")) return json(RECORD);
|
|
156
|
+
return url.includes("/content") ? json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} }) : json({});
|
|
157
|
+
});
|
|
158
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteId: "w1", websiteVersionId: "v1" });
|
|
159
|
+
expect(seen).toContain(`${API}/public/websites/seo?websiteId=w1`);
|
|
160
|
+
expect(r.seo).toEqual(RECORD);
|
|
161
|
+
expect(r.diagnostics).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does not ask for it without a websiteId", async () => {
|
|
165
|
+
const seen: string[] = [];
|
|
166
|
+
stubFetch((url) => {
|
|
167
|
+
seen.push(url);
|
|
168
|
+
return json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} });
|
|
169
|
+
});
|
|
170
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, websiteVersionId: "v1" });
|
|
171
|
+
expect(seen.some((u) => u.includes("/websites/seo"))).toBe(false);
|
|
172
|
+
expect(r.seo).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("is null on a 404, a network error or a body that is not a record, and never a diagnostics failure", async () => {
|
|
176
|
+
for (const fail of [
|
|
177
|
+
() => new Response("no", { status: 404 }),
|
|
178
|
+
() => {
|
|
179
|
+
throw new TypeError("fetch failed");
|
|
180
|
+
},
|
|
181
|
+
() => json({ error: "nope" }),
|
|
182
|
+
]) {
|
|
183
|
+
stubFetch((url) =>
|
|
184
|
+
url.includes("/websites/seo")
|
|
185
|
+
? fail()
|
|
186
|
+
: url.includes("/content")
|
|
187
|
+
? json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} })
|
|
188
|
+
: json({}),
|
|
189
|
+
);
|
|
190
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteId: "w1", websiteVersionId: "v1" });
|
|
191
|
+
expect(r.seo).toBeNull();
|
|
192
|
+
expect(r.diagnostics).toBeNull();
|
|
193
|
+
vi.unstubAllGlobals();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -56,6 +56,8 @@ import { seoContextFromSiteConfig, type ReviewSchemaReview, type SiteSeoContext
|
|
|
56
56
|
// each route that needs share tags.
|
|
57
57
|
import { buildHeadMeta } from "../utils/headMeta";
|
|
58
58
|
export type { SiteSeoContext };
|
|
59
|
+
import type { SeoRecord } from "../seo/types";
|
|
60
|
+
export type { SeoRecord };
|
|
59
61
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
60
62
|
// Type-only, so nothing from the React data layer reaches this React-free entry.
|
|
61
63
|
// Re-exported so a route loader can name the shape it got back without importing
|
|
@@ -241,20 +243,31 @@ export async function probeApi(apiUrl: string, opts?: { timeoutMs?: number }): P
|
|
|
241
243
|
export async function fetchSiteBootstrap(opts: {
|
|
242
244
|
apiUrl: string;
|
|
243
245
|
profileId?: string;
|
|
246
|
+
/**
|
|
247
|
+
* The website being rendered. When given, the SEO record is fetched in the
|
|
248
|
+
* same round trip and returned as `seo`, for `siteSeoHead` / `seoHead`.
|
|
249
|
+
*/
|
|
250
|
+
websiteId?: string;
|
|
244
251
|
websiteVersionId?: string;
|
|
245
252
|
state?: "draft" | "published";
|
|
246
253
|
/** Set false to skip the reachability probe even when a fetch failed. */
|
|
247
254
|
probe?: boolean;
|
|
248
|
-
}): Promise<{
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
255
|
+
}): Promise<{
|
|
256
|
+
contentDoc: ContentDocument;
|
|
257
|
+
siteConfig: SiteConfig | null;
|
|
258
|
+
seo: SeoRecord | null;
|
|
259
|
+
diagnostics: ForgeSsrDiagnostics | null;
|
|
260
|
+
}> {
|
|
261
|
+
const { apiUrl, profileId, websiteId, websiteVersionId, state = "published" } = opts;
|
|
262
|
+
|
|
263
|
+
const [content, siteConfig, seo] = await Promise.all([
|
|
252
264
|
websiteVersionId
|
|
253
265
|
? getJsonResult<ContentDocument>(apiUrl, "/public/websites/content", { websiteVersionId, state })
|
|
254
266
|
: Promise.resolve<JsonResult<ContentDocument>>({ ok: true, data: emptyContentDocument() }),
|
|
255
267
|
profileId
|
|
256
268
|
? getJsonResult<SiteConfig>(apiUrl, "/public/websites/site-config", { profileId })
|
|
257
269
|
: Promise.resolve<JsonResult<SiteConfig | null>>({ ok: true, data: null }),
|
|
270
|
+
websiteId ? fetchSeoRecordServer({ apiUrl, websiteId }) : Promise.resolve(null),
|
|
258
271
|
]);
|
|
259
272
|
|
|
260
273
|
// A deploy with no version / no profile baked in isn't a failure — there was
|
|
@@ -270,10 +283,26 @@ export async function fetchSiteBootstrap(opts: {
|
|
|
270
283
|
return {
|
|
271
284
|
contentDoc: content.ok ? (content.data ?? emptyContentDocument()) : emptyContentDocument(),
|
|
272
285
|
siteConfig: siteConfig.ok ? (siteConfig.data ?? null) : null,
|
|
286
|
+
// Not a diagnostics failure: a site renders correctly without it, on the
|
|
287
|
+
// code's own SEO, and an older API simply does not have the route.
|
|
288
|
+
seo,
|
|
273
289
|
diagnostics,
|
|
274
290
|
};
|
|
275
291
|
}
|
|
276
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The website's SEO record (settings, templates, approved page overrides).
|
|
295
|
+
*
|
|
296
|
+
* `null` on ANY failure, including a 404 from an API that predates the route
|
|
297
|
+
* and a body that is not a record: every SEO function treats `null` as "use
|
|
298
|
+
* the code's own values", so a missing record degrades to today's tags rather
|
|
299
|
+
* than to a broken page.
|
|
300
|
+
*/
|
|
301
|
+
export async function fetchSeoRecordServer(opts: { apiUrl: string; websiteId: string }): Promise<SeoRecord | null> {
|
|
302
|
+
const record = await getJson<SeoRecord>(opts.apiUrl, "/public/websites/seo", { websiteId: opts.websiteId });
|
|
303
|
+
return record && typeof record === "object" && typeof record.siteName === "string" ? record : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
277
306
|
/** Fetch a single event by id or slug for SSR. */
|
|
278
307
|
/**
|
|
279
308
|
* Every published event, for SSR of the events list.
|
package/src/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
|
-
{
|
|
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
|
+
});
|