@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
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildBlogPostingSchema,
|
|
4
|
+
buildBreadcrumbSchema,
|
|
5
|
+
buildCourseSchema,
|
|
6
|
+
buildEventSchema,
|
|
7
|
+
buildFilmSchema,
|
|
8
|
+
buildIdentitySchema,
|
|
9
|
+
buildPodcastEpisodeSchema,
|
|
10
|
+
buildPodcastSeriesSchema,
|
|
11
|
+
buildProductSchema,
|
|
12
|
+
buildServiceSchema,
|
|
13
|
+
buildWebSiteSchema,
|
|
14
|
+
seoBrandName,
|
|
15
|
+
withSeoBrand,
|
|
16
|
+
type EventSchemaSource,
|
|
17
|
+
} from "../structuredData";
|
|
18
|
+
|
|
19
|
+
afterEach(() => vi.useRealTimers());
|
|
20
|
+
|
|
21
|
+
const URL_ = "https://claystudio.com/i/events/late-show";
|
|
22
|
+
|
|
23
|
+
const EVENT: EventSchemaSource = {
|
|
24
|
+
title: "Late Show",
|
|
25
|
+
description: "<p>An evening of <b>clay</b>.</p>",
|
|
26
|
+
dateTime: "2026-11-01T19:00:00.000Z",
|
|
27
|
+
endDateTime: "2026-11-01T22:00:00.000Z",
|
|
28
|
+
doorsOpenAt: "2026-11-01T18:30:00.000Z",
|
|
29
|
+
status: "on_sale",
|
|
30
|
+
cancelledAt: null,
|
|
31
|
+
type: "physical",
|
|
32
|
+
address: { name: "The Hall", street: "1 Mare St", city: "London", country: "GB", zipCode: "E8 1AA" },
|
|
33
|
+
media: [{ url: "https://cdn.example/poster.jpg", type: "image" }],
|
|
34
|
+
tickets: [
|
|
35
|
+
{ title: "General", price: 15, quantity: 100, sold: 10 },
|
|
36
|
+
{ title: "VIP", price: 40, quantity: 10, sold: 10 },
|
|
37
|
+
{ title: "Hidden", price: 1, quantity: 10, sold: 0, isHidden: true },
|
|
38
|
+
{ title: "Members", price: 5, quantity: 10, sold: 0, membershipGate: { allowed: false } as never },
|
|
39
|
+
],
|
|
40
|
+
bookingFee: { currency: "GBP" },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("buildEventSchema", () => {
|
|
44
|
+
it("describes a physical event with a Place, status, mode and one offer per public tier", () => {
|
|
45
|
+
const s = buildEventSchema(EVENT, { url: URL_ })!;
|
|
46
|
+
expect(s).toMatchObject({
|
|
47
|
+
"@context": "https://schema.org",
|
|
48
|
+
"@type": "Event",
|
|
49
|
+
name: "Late Show",
|
|
50
|
+
startDate: EVENT.dateTime,
|
|
51
|
+
endDate: EVENT.endDateTime,
|
|
52
|
+
doorTime: EVENT.doorsOpenAt,
|
|
53
|
+
eventStatus: "https://schema.org/EventScheduled",
|
|
54
|
+
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
|
55
|
+
description: "An evening of clay .",
|
|
56
|
+
image: "https://cdn.example/poster.jpg",
|
|
57
|
+
url: URL_,
|
|
58
|
+
location: {
|
|
59
|
+
"@type": "Place",
|
|
60
|
+
name: "The Hall",
|
|
61
|
+
address: {
|
|
62
|
+
"@type": "PostalAddress",
|
|
63
|
+
streetAddress: "1 Mare St",
|
|
64
|
+
addressLocality: "London",
|
|
65
|
+
postalCode: "E8 1AA",
|
|
66
|
+
addressCountry: "GB",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(s.offers).toEqual([
|
|
71
|
+
{ "@type": "Offer", name: "General", price: 15, priceCurrency: "GBP", availability: "https://schema.org/InStock", url: URL_ },
|
|
72
|
+
{ "@type": "Offer", name: "VIP", price: 40, priceCurrency: "GBP", availability: "https://schema.org/SoldOut", url: URL_ },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("gives a virtual event a VirtualLocation and a hybrid event both", () => {
|
|
77
|
+
const v = buildEventSchema({ ...EVENT, type: "virtual" }, { url: URL_ })!;
|
|
78
|
+
expect(v.eventAttendanceMode).toBe("https://schema.org/OnlineEventAttendanceMode");
|
|
79
|
+
expect(v.location).toEqual({ "@type": "VirtualLocation", url: URL_ });
|
|
80
|
+
const h = buildEventSchema({ ...EVENT, type: "hybrid" }, { url: URL_ })!;
|
|
81
|
+
expect(h.eventAttendanceMode).toBe("https://schema.org/MixedEventAttendanceMode");
|
|
82
|
+
expect((h.location as unknown[]).map((l) => (l as { "@type": string })["@type"])).toEqual(["Place", "VirtualLocation"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("marks a cancelled event and sells nothing", () => {
|
|
86
|
+
const s = buildEventSchema({ ...EVENT, status: "cancelled" })!;
|
|
87
|
+
expect(s.eventStatus).toBe("https://schema.org/EventCancelled");
|
|
88
|
+
expect(s).not.toHaveProperty("offers");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("emits no offers without a currency, and none for a members-only event", () => {
|
|
92
|
+
expect(buildEventSchema({ ...EVENT, bookingFee: null })).not.toHaveProperty("offers");
|
|
93
|
+
expect(buildEventSchema({ ...EVENT, bookingFee: null }, { currency: "USD" })!.offers).toBeDefined();
|
|
94
|
+
expect(buildEventSchema({ ...EVENT, membershipGate: { allowed: false } })).not.toHaveProperty("offers");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("says PreOrder for a tier that is not on sale yet", () => {
|
|
98
|
+
vi.useFakeTimers();
|
|
99
|
+
vi.setSystemTime(new Date("2026-10-01T00:00:00.000Z"));
|
|
100
|
+
const s = buildEventSchema({
|
|
101
|
+
...EVENT,
|
|
102
|
+
tickets: [{ title: "Early", price: 10, quantity: 5, sold: 0, availableFrom: "2026-10-10T00:00:00.000Z" }],
|
|
103
|
+
})!;
|
|
104
|
+
expect(s.offers).toMatchObject({ availability: "https://schema.org/PreOrder", validFrom: "2026-10-10T00:00:00.000Z" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("names the organizer from the identity", () => {
|
|
108
|
+
const s = buildEventSchema(EVENT, { organizer: { kind: "person", name: "Ada" }, origin: "https://claystudio.com" })!;
|
|
109
|
+
expect(s.organizer).toEqual({ "@type": "Person", name: "Ada", url: "https://claystudio.com" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns null with no title or no start", () => {
|
|
113
|
+
expect(buildEventSchema({ ...EVENT, title: " " })).toBeNull();
|
|
114
|
+
expect(buildEventSchema({ ...EVENT, dateTime: "" })).toBeNull();
|
|
115
|
+
expect(buildEventSchema(null)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("buildBlogPostingSchema", () => {
|
|
120
|
+
it("builds a BlogPosting with author and publisher from an organization identity", () => {
|
|
121
|
+
const s = buildBlogPostingSchema(
|
|
122
|
+
{
|
|
123
|
+
title: "Glazes",
|
|
124
|
+
content: "<p>How we glaze.</p>",
|
|
125
|
+
publishedAt: "2026-09-01T00:00:00.000Z",
|
|
126
|
+
media: [{ url: "https://cdn.example/g.jpg", type: "image/jpeg" }, { url: "https://cdn.example/a.mp3", type: "audio/mpeg" }],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
url: "https://claystudio.com/i/blog/glazes",
|
|
130
|
+
identity: { kind: "organization", name: "Clay Studio", logo: "https://cdn.example/logo.png" },
|
|
131
|
+
origin: "https://claystudio.com",
|
|
132
|
+
},
|
|
133
|
+
)!;
|
|
134
|
+
const org = { "@type": "Organization", name: "Clay Studio", url: "https://claystudio.com", logo: "https://cdn.example/logo.png" };
|
|
135
|
+
expect(s).toEqual({
|
|
136
|
+
"@context": "https://schema.org",
|
|
137
|
+
"@type": "BlogPosting",
|
|
138
|
+
headline: "Glazes",
|
|
139
|
+
description: "How we glaze.",
|
|
140
|
+
datePublished: "2026-09-01T00:00:00.000Z",
|
|
141
|
+
dateModified: "2026-09-01T00:00:00.000Z",
|
|
142
|
+
image: "https://cdn.example/g.jpg",
|
|
143
|
+
url: "https://claystudio.com/i/blog/glazes",
|
|
144
|
+
mainEntityOfPage: { "@type": "WebPage", "@id": "https://claystudio.com/i/blog/glazes" },
|
|
145
|
+
author: org,
|
|
146
|
+
publisher: org,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("uses a person as author only, and clamps a long headline", () => {
|
|
151
|
+
const s = buildBlogPostingSchema({ title: "x".repeat(200) }, { identity: { kind: "person", name: "Ada" } })!;
|
|
152
|
+
expect((s.headline as string).length).toBe(110);
|
|
153
|
+
expect(s.author).toEqual({ "@type": "Person", name: "Ada" });
|
|
154
|
+
expect(s).not.toHaveProperty("publisher");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("podcast schemas", () => {
|
|
159
|
+
it("builds a PodcastSeries, preferring the show's own author", () => {
|
|
160
|
+
expect(
|
|
161
|
+
buildPodcastSeriesSchema(
|
|
162
|
+
{ title: "Kiln Talk", description: "Weekly.", coverImage: { url: "https://cdn.example/c.jpg" }, author: "Ada" },
|
|
163
|
+
{ url: "https://claystudio.com/i/podcasts/kiln-talk" },
|
|
164
|
+
),
|
|
165
|
+
).toEqual({
|
|
166
|
+
"@context": "https://schema.org",
|
|
167
|
+
"@type": "PodcastSeries",
|
|
168
|
+
name: "Kiln Talk",
|
|
169
|
+
description: "Weekly.",
|
|
170
|
+
image: "https://cdn.example/c.jpg",
|
|
171
|
+
url: "https://claystudio.com/i/podcasts/kiln-talk",
|
|
172
|
+
author: { "@type": "Person", name: "Ada" },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("builds a PodcastEpisode with its audio, duration and series", () => {
|
|
177
|
+
expect(
|
|
178
|
+
buildPodcastEpisodeSchema(
|
|
179
|
+
{
|
|
180
|
+
title: "Ep 1",
|
|
181
|
+
content: "<p>Hello</p>",
|
|
182
|
+
publishedAt: "2026-09-02T00:00:00.000Z",
|
|
183
|
+
media: [{ url: "https://cdn.example/1.mp3", type: "audio/mpeg", durationSec: 3725 }],
|
|
184
|
+
},
|
|
185
|
+
{ url: "https://claystudio.com/i/podcasts/kiln-talk/ep-1", series: { name: "Kiln Talk", url: "https://claystudio.com/i/podcasts/kiln-talk" } },
|
|
186
|
+
),
|
|
187
|
+
).toEqual({
|
|
188
|
+
"@context": "https://schema.org",
|
|
189
|
+
"@type": "PodcastEpisode",
|
|
190
|
+
name: "Ep 1",
|
|
191
|
+
description: "Hello",
|
|
192
|
+
datePublished: "2026-09-02T00:00:00.000Z",
|
|
193
|
+
url: "https://claystudio.com/i/podcasts/kiln-talk/ep-1",
|
|
194
|
+
timeRequired: "PT1H2M",
|
|
195
|
+
associatedMedia: { "@type": "MediaObject", contentUrl: "https://cdn.example/1.mp3", encodingFormat: "audio/mpeg", duration: "PT1H2M" },
|
|
196
|
+
partOfSeries: { "@type": "PodcastSeries", name: "Kiln Talk", url: "https://claystudio.com/i/podcasts/kiln-talk" },
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("site schemas", () => {
|
|
202
|
+
it("builds Person, Organization and LocalBusiness identities", () => {
|
|
203
|
+
expect(buildIdentitySchema({ kind: "person", name: "Ada", logo: "https://x/a.jpg", sameAs: [] })).toEqual({
|
|
204
|
+
"@context": "https://schema.org",
|
|
205
|
+
"@type": "Person",
|
|
206
|
+
name: "Ada",
|
|
207
|
+
image: "https://x/a.jpg",
|
|
208
|
+
});
|
|
209
|
+
expect(buildIdentitySchema({ kind: "organization", name: "Clay", logo: "https://x/l.png", sameAs: ["https://x.com/clay"] }, { origin: "https://clay.com/" })).toEqual({
|
|
210
|
+
"@context": "https://schema.org",
|
|
211
|
+
"@type": "Organization",
|
|
212
|
+
"@id": "https://clay.com/#identity",
|
|
213
|
+
url: "https://clay.com",
|
|
214
|
+
name: "Clay",
|
|
215
|
+
logo: "https://x/l.png",
|
|
216
|
+
sameAs: ["https://x.com/clay"],
|
|
217
|
+
});
|
|
218
|
+
expect(buildIdentitySchema({ kind: "local_business", name: "Clay", sameAs: [], address: {}, phone: "123" })).toEqual({
|
|
219
|
+
"@context": "https://schema.org",
|
|
220
|
+
"@type": "LocalBusiness",
|
|
221
|
+
name: "Clay",
|
|
222
|
+
telephone: "123",
|
|
223
|
+
});
|
|
224
|
+
expect(buildIdentitySchema({ kind: "person", name: "", sameAs: [] })).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("builds a WebSite linked to its publisher", () => {
|
|
228
|
+
expect(buildWebSiteSchema({ name: "Clay", url: "https://clay.com/", publisherId: "https://clay.com/#identity" })).toEqual({
|
|
229
|
+
"@context": "https://schema.org",
|
|
230
|
+
"@type": "WebSite",
|
|
231
|
+
name: "Clay",
|
|
232
|
+
url: "https://clay.com/",
|
|
233
|
+
publisher: { "@id": "https://clay.com/#identity" },
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("builds a BreadcrumbList, and nothing for a single crumb", () => {
|
|
238
|
+
expect(
|
|
239
|
+
buildBreadcrumbSchema([
|
|
240
|
+
{ name: "Clay", url: "https://clay.com/" },
|
|
241
|
+
{ name: "Store", url: "https://clay.com/i/store" },
|
|
242
|
+
]),
|
|
243
|
+
).toEqual({
|
|
244
|
+
"@context": "https://schema.org",
|
|
245
|
+
"@type": "BreadcrumbList",
|
|
246
|
+
itemListElement: [
|
|
247
|
+
{ "@type": "ListItem", position: 1, name: "Clay", item: "https://clay.com/" },
|
|
248
|
+
{ "@type": "ListItem", position: 2, name: "Store", item: "https://clay.com/i/store" },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
expect(buildBreadcrumbSchema([{ name: "Clay", url: "https://clay.com/" }])).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("the business name in structured data", () => {
|
|
256
|
+
it("is the SEO identity's name, then the SEO site name, then the profile's", () => {
|
|
257
|
+
const seo = { identity: { name: " Clay Studio Ltd " }, siteName: "Clay Studio" };
|
|
258
|
+
expect(seoBrandName(seo, "Ada Potter")).toBe("Clay Studio Ltd");
|
|
259
|
+
expect(seoBrandName({ identity: null, siteName: "Clay Studio" }, "Ada Potter")).toBe("Clay Studio");
|
|
260
|
+
expect(seoBrandName({ identity: { name: " " }, siteName: "" }, "Ada Potter")).toBe("Ada Potter");
|
|
261
|
+
expect(seoBrandName(null, "Ada Potter")).toBe("Ada Potter");
|
|
262
|
+
expect(seoBrandName(null, null)).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("names Product brand and Course, Service and Film provider the same way", () => {
|
|
266
|
+
const context = withSeoBrand({ currency: "GBP", brand: "Ada Potter" }, { identity: { name: "Clay Studio Ltd" }, siteName: "Clay Studio" });
|
|
267
|
+
expect(context).toEqual({ currency: "GBP", brand: "Clay Studio Ltd" });
|
|
268
|
+
|
|
269
|
+
const product = buildProductSchema({ title: "Mug", variants: [] } as never, context);
|
|
270
|
+
expect(product?.brand).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
|
|
271
|
+
const course = buildCourseSchema({ title: "Wheel basics" } as never, context);
|
|
272
|
+
expect(course?.provider).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
|
|
273
|
+
const service = buildServiceSchema({ title: "Studio hour" } as never, context);
|
|
274
|
+
expect(service?.provider).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
|
|
275
|
+
const film = buildFilmSchema({ title: "Throwing", kind: "film" } as never, context);
|
|
276
|
+
expect(film?.provider).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("keeps the profile name when there is no SEO record", () => {
|
|
280
|
+
expect(withSeoBrand({ currency: "GBP", brand: "Ada Potter" }, null)).toEqual({ currency: "GBP", brand: "Ada Potter" });
|
|
281
|
+
expect(withSeoBrand(null, null)).toEqual({ brand: undefined });
|
|
282
|
+
});
|
|
283
|
+
});
|
package/src/utils/landing.ts
CHANGED
|
@@ -103,18 +103,31 @@ export function captureLandingFromUrl(): boolean {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** The pathname of a stored landing URL, or undefined when it cannot be read. */
|
|
107
|
+
function landingPathOf(landingUrl: string | undefined): string | undefined {
|
|
108
|
+
if (!landingUrl) return undefined;
|
|
109
|
+
try {
|
|
110
|
+
const path = new URL(landingUrl).pathname;
|
|
111
|
+
return path.startsWith("/") ? path : undefined;
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
/**
|
|
107
|
-
* Returns the first-touch `landingUtm` bag
|
|
108
|
-
*
|
|
109
|
-
*
|
|
118
|
+
* Returns the first-touch `landingUtm` bag, `referrer` and `landingPath` (the
|
|
119
|
+
* pathname of the first page, so a sale can be credited to the blog post that
|
|
120
|
+
* brought the buyer) for the checkout payload, or null when nothing is stored /
|
|
121
|
+
* it expired. `landingUtm` is undefined when the bag is empty so we don't ship `{}`.
|
|
110
122
|
*/
|
|
111
|
-
export function readLanding(): { landingUtm?: Record<string, string>; referrer?: string } | null {
|
|
123
|
+
export function readLanding(): { landingUtm?: Record<string, string>; referrer?: string; landingPath?: string } | null {
|
|
112
124
|
const snapshot = readSnapshot();
|
|
113
125
|
if (!snapshot) return null;
|
|
114
126
|
const hasParams = Object.keys(snapshot.landingUtm).length > 0;
|
|
115
127
|
return {
|
|
116
128
|
landingUtm: hasParams ? snapshot.landingUtm : undefined,
|
|
117
129
|
referrer: snapshot.referrer || undefined,
|
|
130
|
+
landingPath: landingPathOf(snapshot.landingUrl),
|
|
118
131
|
};
|
|
119
132
|
}
|
|
120
133
|
|