@tribe-nest/forge 3.61.0 → 3.63.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/seo/_tests/seo.spec.ts +326 -0
- package/src/seo/applySeoToHead.ts +98 -0
- package/src/seo/index.ts +9 -0
- package/src/seo/resolve.ts +211 -0
- package/src/seo/seoHead.ts +61 -0
- package/src/seo/siteSeoHead.ts +97 -0
- package/src/seo/types.ts +99 -0
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- 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 +247 -0
- package/src/utils/landing.ts +17 -4
- package/src/utils/structuredData.ts +339 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.63.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./ui": "./src/ui/index.ts",
|
|
11
11
|
"./ui/headless": "./src/ui/headless/index.ts",
|
|
12
12
|
"./server": "./src/server/index.ts",
|
|
13
|
+
"./seo": "./src/seo/index.ts",
|
|
13
14
|
"./media": "./src/ui/media/index.ts",
|
|
14
15
|
"./forge-utilities.css": "./src/ui/styled/forge-utilities.css"
|
|
15
16
|
},
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
"react-hook-form": "^7.60.0",
|
|
42
43
|
"react-markdown": "^10.1.0",
|
|
43
44
|
"remark-gfm": "^4.0.1",
|
|
44
|
-
"socket.io-client": "^4.8.1"
|
|
45
|
+
"socket.io-client": "^4.8.1",
|
|
46
|
+
"web-vitals": "^6.2.2"
|
|
45
47
|
},
|
|
46
48
|
"peerDependencies": {
|
|
47
49
|
"react": ">=18",
|
package/src/index.ts
CHANGED
|
@@ -208,6 +208,9 @@ export * from "./utils/bookingSecret";
|
|
|
208
208
|
// app can use them without inheriting the fan-site route tree, and so a fix
|
|
209
209
|
// reaches existing sites through the normal Forge publish.
|
|
210
210
|
export * from "./utils/headMeta";
|
|
211
|
+
// SEO: seoHead per route, siteSeoHead in the root, applySeoToHead for hosted
|
|
212
|
+
// pages. Also importable on its own from `@tribe-nest/forge/seo`.
|
|
213
|
+
export * from "./seo";
|
|
211
214
|
export * from "./utils/formatDateTime";
|
|
212
215
|
// The ONE membership access predicate + the messaging that is deliberately
|
|
213
216
|
// separate from it. Every surface that gates on a membership imports from here.
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applySeoToHead,
|
|
4
|
+
applyTitleTemplate,
|
|
5
|
+
findOverride,
|
|
6
|
+
normalizePathname,
|
|
7
|
+
resolvePageSeo,
|
|
8
|
+
seoHead,
|
|
9
|
+
siteSeoHead,
|
|
10
|
+
type SeoHeadContext,
|
|
11
|
+
type SeoRecord,
|
|
12
|
+
} from "../index";
|
|
13
|
+
import { buildHeadMeta } from "../../utils/headMeta";
|
|
14
|
+
|
|
15
|
+
const RECORD: SeoRecord = {
|
|
16
|
+
websiteId: "w1",
|
|
17
|
+
origin: "https://claystudio.com",
|
|
18
|
+
isDefaultWebsite: true,
|
|
19
|
+
defaultWebsiteOrigin: "https://claystudio.com",
|
|
20
|
+
siteName: "Clay Studio",
|
|
21
|
+
defaultDescription: "A small pottery studio in Hackney.",
|
|
22
|
+
defaultImage: "https://cdn.example/share.jpg",
|
|
23
|
+
titleTemplate: "{title} | {site}",
|
|
24
|
+
typeTemplates: {},
|
|
25
|
+
identity: {
|
|
26
|
+
kind: "local_business",
|
|
27
|
+
name: "Clay Studio",
|
|
28
|
+
logo: "https://cdn.example/logo.png",
|
|
29
|
+
sameAs: ["https://instagram.com/claystudio", "not a url"],
|
|
30
|
+
address: { street: "1 Mare St", locality: "London", postalCode: "E8 1AA", country: "GB" },
|
|
31
|
+
phone: "+44 20 0000 0000",
|
|
32
|
+
},
|
|
33
|
+
verification: { google: "g-token", bing: "b-token" },
|
|
34
|
+
indexing: true,
|
|
35
|
+
pages: {},
|
|
36
|
+
version: "2026-10-01T00:00:00.000Z",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const record = (patch: Partial<SeoRecord> = {}): SeoRecord => ({ ...RECORD, ...patch });
|
|
40
|
+
|
|
41
|
+
/** A head() context shaped like TanStack's: root match first, leaf last. */
|
|
42
|
+
function ctx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
|
|
43
|
+
const leaf = { pathname, fullPath, routeId: fullPath, loaderData: {} };
|
|
44
|
+
return {
|
|
45
|
+
matches: [{ pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo } }, leaf],
|
|
46
|
+
match: leaf,
|
|
47
|
+
params: {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Title + every name/property entry, flattened for assertions. */
|
|
52
|
+
function tags(meta: Record<string, unknown>[]) {
|
|
53
|
+
const out: Record<string, unknown> = {};
|
|
54
|
+
for (const m of meta) {
|
|
55
|
+
if (typeof m.title === "string") out.title = m.title;
|
|
56
|
+
const key = (m.name ?? m.property) as string | undefined;
|
|
57
|
+
if (key) out[key] = m.content;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const jsonLd = (meta: Record<string, unknown>[]) =>
|
|
62
|
+
meta.filter((m) => "script:ld+json" in m).map((m) => m["script:ld+json"] as Record<string, unknown>);
|
|
63
|
+
|
|
64
|
+
describe("normalizePathname", () => {
|
|
65
|
+
it("treats a trailing slash, a query and a missing slash as the same page", () => {
|
|
66
|
+
expect(normalizePathname("/about/")).toBe("/about");
|
|
67
|
+
expect(normalizePathname("/about?x=1")).toBe("/about");
|
|
68
|
+
expect(normalizePathname("about")).toBe("/about");
|
|
69
|
+
expect(normalizePathname("/")).toBe("/");
|
|
70
|
+
expect(normalizePathname("")).toBe("/");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("applyTitleTemplate", () => {
|
|
75
|
+
it("applies the template", () => {
|
|
76
|
+
expect(applyTitleTemplate("About", "Clay Studio", "{title} | {site}")).toBe("About | Clay Studio");
|
|
77
|
+
});
|
|
78
|
+
it("accepts {site.name} as well as {site}", () => {
|
|
79
|
+
expect(applyTitleTemplate("About", "Clay Studio", "{site.name}: {title}")).toBe("Clay Studio: About");
|
|
80
|
+
});
|
|
81
|
+
it("skips the template when the title already names the site, in any case", () => {
|
|
82
|
+
expect(applyTitleTemplate("clay studio, Hackney", "Clay Studio", "{title} | {site}")).toBe("clay studio, Hackney");
|
|
83
|
+
});
|
|
84
|
+
it("falls back to the default template when the record's is blank", () => {
|
|
85
|
+
expect(applyTitleTemplate("About", "Clay Studio", " ")).toBe("About | Clay Studio");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("findOverride", () => {
|
|
90
|
+
it("prefers the exact pathname and fills gaps from the route pattern", () => {
|
|
91
|
+
const seo = record({
|
|
92
|
+
pages: {
|
|
93
|
+
"/team/ada": { title: "Ada, head potter" },
|
|
94
|
+
"/team/$slug": { title: "Team member", description: "Pattern description", noindex: true },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(findOverride(seo, "/team/ada/", "/team/$slug")).toEqual({
|
|
98
|
+
title: "Ada, head potter",
|
|
99
|
+
description: "Pattern description",
|
|
100
|
+
noindex: true,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ignores blank strings so an emptied field does not erase the code's value", () => {
|
|
105
|
+
const seo = record({ pages: { "/about": { title: " ", description: null } } });
|
|
106
|
+
expect(findOverride(seo, "/about")).toEqual({});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("resolvePageSeo", () => {
|
|
111
|
+
it("with no record, behaves exactly like the code's own values", () => {
|
|
112
|
+
expect(resolvePageSeo(null, { pathname: "/about", title: "About" })).toEqual({
|
|
113
|
+
title: "About",
|
|
114
|
+
shareTitle: "About",
|
|
115
|
+
description: "About",
|
|
116
|
+
image: undefined,
|
|
117
|
+
noindex: false,
|
|
118
|
+
canonical: null,
|
|
119
|
+
overridden: false,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("uses a type template over the site template, and its description when the code has none", () => {
|
|
124
|
+
const seo = record({
|
|
125
|
+
typeTemplates: { event: { title: "{title} tickets | {site}", description: "Book {title} at {site}." } },
|
|
126
|
+
});
|
|
127
|
+
const r = resolvePageSeo(seo, { pathname: "/i/events/gig", title: "Late Show", type: "event", entity: true });
|
|
128
|
+
expect(r.title).toBe("Late Show tickets | Clay Studio");
|
|
129
|
+
expect(r.shareTitle).toBe("Late Show");
|
|
130
|
+
expect(r.description).toBe("Book Late Show at Clay Studio.");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("falls back to site defaults for description and image", () => {
|
|
134
|
+
const r = resolvePageSeo(record(), { pathname: "/about", title: "About" });
|
|
135
|
+
expect(r.description).toBe(RECORD.defaultDescription);
|
|
136
|
+
expect(r.image).toBe(RECORD.defaultImage);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("canonicalizes entities to the default website and pages to their own origin", () => {
|
|
140
|
+
const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
|
|
141
|
+
expect(resolvePageSeo(seo, { pathname: "/i/store/mug", title: "Mug", entity: true }).canonical).toBe(
|
|
142
|
+
"https://claystudio.com/i/store/mug",
|
|
143
|
+
);
|
|
144
|
+
expect(resolvePageSeo(seo, { pathname: "/about", title: "About" }).canonical).toBe("https://shop.example/about");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("noindexes everything when the site is not indexing, whatever the code says", () => {
|
|
148
|
+
expect(resolvePageSeo(record({ indexing: false }), { pathname: "/", title: "Home" }).noindex).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("lets an override's noindex:false beat the code's noindex", () => {
|
|
152
|
+
const seo = record({ pages: { "/thanks": { noindex: false } } });
|
|
153
|
+
expect(resolvePageSeo(seo, { pathname: "/thanks", title: "Thanks", noindex: true }).noindex).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("seoHead", () => {
|
|
158
|
+
it("with no record, emits today's buildHeadMeta tags and never links", () => {
|
|
159
|
+
const out = seoHead(ctx("/about", undefined), { title: "About", description: "Who we are", image: "https://x/i.jpg" });
|
|
160
|
+
expect(out).not.toHaveProperty("links");
|
|
161
|
+
const legacy = buildHeadMeta({ title: "About", description: "Who we are", image: "https://x/i.jpg" });
|
|
162
|
+
expect(tags(out.meta)).toEqual(tags(legacy.meta));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("applies the title template to the code title", () => {
|
|
166
|
+
const out = seoHead(ctx("/about", RECORD), { title: "About", description: "Who we are" });
|
|
167
|
+
const t = tags(out.meta);
|
|
168
|
+
expect(t.title).toBe("About | Clay Studio");
|
|
169
|
+
expect(t["og:title"]).toBe("About");
|
|
170
|
+
expect(t.description).toBe("Who we are");
|
|
171
|
+
expect(t["og:url"]).toBe("https://claystudio.com/about");
|
|
172
|
+
expect(t.robots).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("uses an override for the exact pathname, verbatim", () => {
|
|
176
|
+
const seo = record({ pages: { "/about": { title: "Our story", description: "Since 2019." } } });
|
|
177
|
+
const t = tags(seoHead(ctx("/about", seo), { title: "About", description: "Who we are" }).meta);
|
|
178
|
+
expect(t.title).toBe("Our story");
|
|
179
|
+
expect(t.description).toBe("Since 2019.");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("uses an override keyed by the route pattern for a dynamic route", () => {
|
|
183
|
+
const seo = record({ pages: { "/team/$slug": { description: "One of our potters." } } });
|
|
184
|
+
const t = tags(seoHead(ctx("/team/ada", seo, "/team/$slug"), { title: "Ada" }).meta);
|
|
185
|
+
expect(t.title).toBe("Ada | Clay Studio");
|
|
186
|
+
expect(t.description).toBe("One of our potters.");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("emits robots noindex from the code, from an override, and from a non-indexing site", () => {
|
|
190
|
+
expect(tags(seoHead(ctx("/x", RECORD), { title: "X", noindex: true }).meta).robots).toBe("noindex, nofollow");
|
|
191
|
+
const seo = record({ pages: { "/x": { noindex: true } } });
|
|
192
|
+
expect(tags(seoHead(ctx("/x", seo), { title: "X" }).meta).robots).toBe("noindex, nofollow");
|
|
193
|
+
expect(tags(seoHead(ctx("/x", record({ indexing: false })), { title: "X" }).meta).robots).toBe("noindex, nofollow");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("emits each jsonLd node as a script:ld+json meta entry", () => {
|
|
197
|
+
const nodes = [{ "@type": "Thing", name: "a" }, { "@type": "Thing", name: "b" }];
|
|
198
|
+
expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes }).meta)).toEqual(nodes);
|
|
199
|
+
expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes[0] }).meta)).toEqual([nodes[0]]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("siteSeoHead", () => {
|
|
204
|
+
it("emits the site fallbacks, verification and a canonical for the leaf path", () => {
|
|
205
|
+
const out = siteSeoHead(ctx("/about/", RECORD));
|
|
206
|
+
const t = tags(out.meta);
|
|
207
|
+
expect(t).toMatchObject({
|
|
208
|
+
title: "Clay Studio",
|
|
209
|
+
description: RECORD.defaultDescription,
|
|
210
|
+
"og:site_name": "Clay Studio",
|
|
211
|
+
"og:image": RECORD.defaultImage,
|
|
212
|
+
"twitter:card": "summary_large_image",
|
|
213
|
+
"google-site-verification": "g-token",
|
|
214
|
+
"msvalidate.01": "b-token",
|
|
215
|
+
});
|
|
216
|
+
expect(t.robots).toBeUndefined();
|
|
217
|
+
expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/about" }]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("uses an override canonical", () => {
|
|
221
|
+
const seo = record({ pages: { "/about": { canonical: "https://claystudio.com/story" } } });
|
|
222
|
+
expect(siteSeoHead(ctx("/about", seo)).links).toEqual([{ rel: "canonical", href: "https://claystudio.com/story" }]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("emits no canonical under /i/, where the hosted page emits its own", () => {
|
|
226
|
+
expect(siteSeoHead(ctx("/i/store/mug", RECORD)).links).toEqual([]);
|
|
227
|
+
expect(siteSeoHead(ctx("/i", RECORD)).links).toEqual([]);
|
|
228
|
+
// A creator page that merely starts with "i" is not hosted.
|
|
229
|
+
expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("noindexes when the site is not indexing or the path's override says so", () => {
|
|
233
|
+
expect(tags(siteSeoHead(ctx("/", record({ indexing: false }))).meta).robots).toBe("noindex, nofollow");
|
|
234
|
+
const seo = record({ pages: { "/team/$slug": { noindex: true } } });
|
|
235
|
+
expect(tags(siteSeoHead(ctx("/team/ada", seo, "/team/$slug")).meta).robots).toBe("noindex, nofollow");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("emits WebSite and identity JSON-LD on / only", () => {
|
|
239
|
+
const home = jsonLd(siteSeoHead(ctx("/", RECORD)).meta);
|
|
240
|
+
expect(home.map((n) => n["@type"])).toEqual(["WebSite", "LocalBusiness"]);
|
|
241
|
+
expect(home[0]).toMatchObject({
|
|
242
|
+
name: "Clay Studio",
|
|
243
|
+
url: "https://claystudio.com/",
|
|
244
|
+
publisher: { "@id": "https://claystudio.com/#identity" },
|
|
245
|
+
});
|
|
246
|
+
expect(home[1]).toMatchObject({
|
|
247
|
+
"@id": "https://claystudio.com/#identity",
|
|
248
|
+
telephone: "+44 20 0000 0000",
|
|
249
|
+
sameAs: ["https://instagram.com/claystudio"],
|
|
250
|
+
address: { "@type": "PostalAddress", streetAddress: "1 Mare St", addressCountry: "GB" },
|
|
251
|
+
});
|
|
252
|
+
expect(jsonLd(siteSeoHead(ctx("/about", RECORD)).meta)).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("emits WebSite alone when there is no identity", () => {
|
|
256
|
+
const home = jsonLd(siteSeoHead(ctx("/", record({ identity: null }))).meta);
|
|
257
|
+
expect(home).toHaveLength(1);
|
|
258
|
+
expect(home[0]).not.toHaveProperty("publisher");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("with no record, emits only the fallback it was given and no canonical", () => {
|
|
262
|
+
const out = siteSeoHead(ctx("/about", null), { siteName: "Clay" });
|
|
263
|
+
expect(out.links).toEqual([]);
|
|
264
|
+
expect(tags(out.meta)).toEqual({ title: "Clay", "og:site_name": "Clay", "twitter:card": "summary_large_image" });
|
|
265
|
+
expect(tags(siteSeoHead(ctx("/", undefined)).meta)).toEqual({ "twitter:card": "summary_large_image" });
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("applySeoToHead", () => {
|
|
270
|
+
const productHead = () => ({
|
|
271
|
+
...buildHeadMeta({ title: "Mug", description: "A mug.", image: "https://cdn.example/mug.jpg", ogType: "product" }),
|
|
272
|
+
scripts: [{ type: "application/ld+json", children: "{}" }],
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns the head untouched with no record", () => {
|
|
276
|
+
const head = productHead();
|
|
277
|
+
expect(applySeoToHead(head, null, "/i/store/mug", "product")).toBe(head);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("templates the title, keeps the page's own tags and adds the entity canonical", () => {
|
|
281
|
+
const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
|
|
282
|
+
const out = applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug");
|
|
283
|
+
const t = tags(out.meta!);
|
|
284
|
+
expect(t.title).toBe("Mug | Clay Studio");
|
|
285
|
+
expect(t["og:title"]).toBe("Mug");
|
|
286
|
+
expect(t["og:type"]).toBe("product");
|
|
287
|
+
expect(t["og:site_name"]).toBe("Clay Studio");
|
|
288
|
+
expect(t["og:url"]).toBe("https://claystudio.com/i/store/mug");
|
|
289
|
+
expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/i/store/mug" }]);
|
|
290
|
+
expect(out.scripts).toEqual([{ type: "application/ld+json", children: "{}" }]);
|
|
291
|
+
// Exactly one of each: the old entries were replaced, not appended to.
|
|
292
|
+
expect(out.meta!.filter((m) => m.name === "description")).toHaveLength(1);
|
|
293
|
+
expect(out.meta!.filter((m) => typeof m.title === "string")).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("canonicalizes an index page to its own website", () => {
|
|
297
|
+
const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
|
|
298
|
+
const out = applySeoToHead(buildHeadMeta({ title: "Store" }), seo, "/i/store", "page");
|
|
299
|
+
expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("applies overrides by pathname and by pattern", () => {
|
|
303
|
+
const seo = record({
|
|
304
|
+
pages: {
|
|
305
|
+
"/i/store/mug": { title: "The best mug" },
|
|
306
|
+
"/i/store/$slug": { description: "Handmade in Hackney.", image: "https://cdn.example/o.jpg" },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const t = tags(applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug").meta!);
|
|
310
|
+
expect(t.title).toBe("The best mug");
|
|
311
|
+
expect(t.description).toBe("Handmade in Hackney.");
|
|
312
|
+
expect(t["og:image"]).toBe("https://cdn.example/o.jpg");
|
|
313
|
+
expect(t["twitter:image"]).toBe("https://cdn.example/o.jpg");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("does not let buildHeadMeta's title-as-description beat the site default", () => {
|
|
317
|
+
const t = tags(applySeoToHead(buildHeadMeta({ title: "Events" }), RECORD, "/i/events", "page").meta!);
|
|
318
|
+
expect(t.description).toBe(RECORD.defaultDescription);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("keeps a page's noindex and then states no canonical", () => {
|
|
322
|
+
const out = applySeoToHead(buildHeadMeta({ title: "Login", noindex: true }), RECORD, "/i/login", "page");
|
|
323
|
+
expect(tags(out.meta!).robots).toBe("noindex, nofollow");
|
|
324
|
+
expect(out.links).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { normalizePathname, resolvePageSeo } from "./resolve";
|
|
2
|
+
import type { SeoPageType, SeoRecord } from "./types";
|
|
3
|
+
|
|
4
|
+
type HeadEntry = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
/** The `{ meta, links, scripts }` shape a hosted page's `head()` returns. */
|
|
7
|
+
export type HeadTags = {
|
|
8
|
+
meta?: HeadEntry[];
|
|
9
|
+
links?: HeadEntry[];
|
|
10
|
+
scripts?: HeadEntry[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Meta this function owns and rewrites. Anything else a page set (`og:type`,
|
|
15
|
+
* `og:video`, a custom tag) passes through untouched.
|
|
16
|
+
*/
|
|
17
|
+
const OWNED_META = new Set([
|
|
18
|
+
"description",
|
|
19
|
+
"robots",
|
|
20
|
+
"og:title",
|
|
21
|
+
"og:description",
|
|
22
|
+
"og:image",
|
|
23
|
+
"og:url",
|
|
24
|
+
"og:site_name",
|
|
25
|
+
"twitter:title",
|
|
26
|
+
"twitter:description",
|
|
27
|
+
"twitter:image",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const metaKey = (m: HeadEntry) => m.name ?? m.property;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the SEO record over a hosted `/i/` page's own head.
|
|
34
|
+
*
|
|
35
|
+
* The page's `head()` says what its code defaults are (title, description,
|
|
36
|
+
* image, noindex), exactly as before. This applies, on top of them, the
|
|
37
|
+
* creator's override for the pathname or pattern, the page type's template,
|
|
38
|
+
* the site defaults and the canonical.
|
|
39
|
+
*
|
|
40
|
+
* The canonical is emitted HERE, not by the site's root, because only the page
|
|
41
|
+
* knows it is an entity: a product, event or post canonicalizes to the
|
|
42
|
+
* profile's DEFAULT website (`defaultWebsiteOrigin`), so the same product shown
|
|
43
|
+
* on three websites is one result, not three. An index page (`/i/store`)
|
|
44
|
+
* belongs to the website it is on.
|
|
45
|
+
*
|
|
46
|
+
* With no record, the head is returned unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export function applySeoToHead<T extends HeadTags>(
|
|
49
|
+
head: T,
|
|
50
|
+
seo: SeoRecord | null | undefined,
|
|
51
|
+
pathname: string,
|
|
52
|
+
type: SeoPageType = "page",
|
|
53
|
+
routePattern?: string,
|
|
54
|
+
): T {
|
|
55
|
+
if (!seo) return head;
|
|
56
|
+
|
|
57
|
+
const meta = head.meta ?? [];
|
|
58
|
+
const find = (key: string) => meta.find((m) => metaKey(m) === key)?.content;
|
|
59
|
+
const codeTitle = meta.find((m) => typeof m.title === "string")?.title ?? "";
|
|
60
|
+
const codeNoindex = /noindex/i.test(find("robots") ?? "");
|
|
61
|
+
|
|
62
|
+
const path = normalizePathname(pathname);
|
|
63
|
+
const resolved = resolvePageSeo(seo, {
|
|
64
|
+
pathname: path,
|
|
65
|
+
routePattern,
|
|
66
|
+
type,
|
|
67
|
+
title: codeTitle,
|
|
68
|
+
// `buildHeadMeta` falls back to the title when a page has no description.
|
|
69
|
+
// That fallback is not a description the code chose, so it must not beat
|
|
70
|
+
// the type template or the site default.
|
|
71
|
+
description: find("description") && find("description") !== codeTitle ? find("description") : undefined,
|
|
72
|
+
image: find("og:image"),
|
|
73
|
+
noindex: codeNoindex,
|
|
74
|
+
entity: type !== "page",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const kept = meta.filter((m) => typeof m.title !== "string" && !OWNED_META.has(metaKey(m) ?? ""));
|
|
78
|
+
const nextMeta: HeadEntry[] = [
|
|
79
|
+
{ title: resolved.title },
|
|
80
|
+
{ name: "description", content: resolved.description },
|
|
81
|
+
{ property: "og:title", content: resolved.shareTitle },
|
|
82
|
+
{ property: "og:description", content: resolved.description },
|
|
83
|
+
{ property: "og:site_name", content: seo.siteName },
|
|
84
|
+
...(resolved.canonical && !resolved.noindex ? [{ property: "og:url", content: resolved.canonical }] : []),
|
|
85
|
+
...(resolved.image ? [{ property: "og:image", content: resolved.image }] : []),
|
|
86
|
+
...kept,
|
|
87
|
+
{ name: "twitter:title", content: resolved.shareTitle },
|
|
88
|
+
{ name: "twitter:description", content: resolved.description },
|
|
89
|
+
...(resolved.image ? [{ name: "twitter:image", content: resolved.image }] : []),
|
|
90
|
+
...(resolved.noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// A noindex page states no canonical: there is no indexed copy to point at.
|
|
94
|
+
const links = (head.links ?? []).filter((l) => l.rel !== "canonical");
|
|
95
|
+
if (resolved.canonical && !resolved.noindex) links.push({ rel: "canonical", href: resolved.canonical });
|
|
96
|
+
|
|
97
|
+
return { ...head, meta: nextMeta, links };
|
|
98
|
+
}
|
package/src/seo/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SEO for code sites and hosted pages: code declares the defaults, the
|
|
2
|
+
// creator's approved edits live in the SEO record, and these functions resolve
|
|
3
|
+
// one over the other. Pure, React-free, so it is safe on the server entry and in
|
|
4
|
+
// a route's head().
|
|
5
|
+
export * from "./types";
|
|
6
|
+
export * from "./resolve";
|
|
7
|
+
export * from "./seoHead";
|
|
8
|
+
export * from "./siteSeoHead";
|
|
9
|
+
export * from "./applySeoToHead";
|