@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.61.0",
3
+ "version": "3.64.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.
@@ -108,6 +108,8 @@ export const INTERNAL_PAGES = [
108
108
  "live",
109
109
  "live/$broadcastId",
110
110
  "login",
111
+ // SEO step 7, lane S2: a customer connects their assistant to the site MCP.
112
+ "oauth/authorize",
111
113
  "payment-link/$paymentLinkId",
112
114
  "payment-link/$paymentLinkId/finalise",
113
115
  "podcasts",
@@ -0,0 +1,522 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ applySeoToHead,
4
+ applyTitleTemplate,
5
+ findOverride,
6
+ isMarkdownPath,
7
+ isNotFoundContext,
8
+ markdownAlternateHref,
9
+ normalizePathname,
10
+ pathnameFromContext,
11
+ resolvePageSeo,
12
+ seoHead,
13
+ siteSeoHead,
14
+ type SeoHeadContext,
15
+ type SeoRecord,
16
+ } from "../index";
17
+ import { buildHeadMeta } from "../../utils/headMeta";
18
+
19
+ const RECORD: SeoRecord = {
20
+ websiteId: "w1",
21
+ origin: "https://claystudio.com",
22
+ isDefaultWebsite: true,
23
+ defaultWebsiteOrigin: "https://claystudio.com",
24
+ siteName: "Clay Studio",
25
+ defaultDescription: "A small pottery studio in Hackney.",
26
+ defaultImage: "https://cdn.example/share.jpg",
27
+ titleTemplate: "{title} | {site}",
28
+ typeTemplates: {},
29
+ identity: {
30
+ kind: "local_business",
31
+ name: "Clay Studio",
32
+ logo: "https://cdn.example/logo.png",
33
+ sameAs: ["https://instagram.com/claystudio", "not a url"],
34
+ address: { street: "1 Mare St", locality: "London", postalCode: "E8 1AA", country: "GB" },
35
+ phone: "+44 20 0000 0000",
36
+ },
37
+ verification: { google: "g-token", bing: "b-token" },
38
+ indexing: true,
39
+ pages: {},
40
+ version: "2026-10-01T00:00:00.000Z",
41
+ };
42
+
43
+ const record = (patch: Partial<SeoRecord> = {}): SeoRecord => ({ ...RECORD, ...patch });
44
+
45
+ /** The readable-copy link a page states beside its canonical. */
46
+ const md = (url: string) => ({ rel: "alternate", type: "text/markdown", href: `${url}?md` });
47
+
48
+ /** A head() context shaped like TanStack's: root match first, leaf last. */
49
+ function ctx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
50
+ const leaf = { pathname, fullPath, routeId: fullPath, loaderData: {} };
51
+ return {
52
+ matches: [{ pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo } }, leaf],
53
+ match: leaf,
54
+ params: {},
55
+ };
56
+ }
57
+
58
+ /** Title + every name/property entry, flattened for assertions. */
59
+ function tags(meta: Record<string, unknown>[]) {
60
+ const out: Record<string, unknown> = {};
61
+ for (const m of meta) {
62
+ if (typeof m.title === "string") out.title = m.title;
63
+ const key = (m.name ?? m.property) as string | undefined;
64
+ if (key) out[key] = m.content;
65
+ }
66
+ return out;
67
+ }
68
+ const jsonLd = (meta: Record<string, unknown>[]) =>
69
+ meta.filter((m) => "script:ld+json" in m).map((m) => m["script:ld+json"] as Record<string, unknown>);
70
+
71
+ describe("normalizePathname", () => {
72
+ it("treats a trailing slash, a query and a missing slash as the same page", () => {
73
+ expect(normalizePathname("/about/")).toBe("/about");
74
+ expect(normalizePathname("/about?x=1")).toBe("/about");
75
+ expect(normalizePathname("about")).toBe("/about");
76
+ expect(normalizePathname("/")).toBe("/");
77
+ expect(normalizePathname("")).toBe("/");
78
+ });
79
+ });
80
+
81
+ describe("applyTitleTemplate", () => {
82
+ it("applies the template", () => {
83
+ expect(applyTitleTemplate("About", "Clay Studio", "{title} | {site}")).toBe("About | Clay Studio");
84
+ });
85
+ it("accepts {site.name} as well as {site}", () => {
86
+ expect(applyTitleTemplate("About", "Clay Studio", "{site.name}: {title}")).toBe("Clay Studio: About");
87
+ });
88
+ it("skips the template when the title already names the site, in any case", () => {
89
+ expect(applyTitleTemplate("clay studio, Hackney", "Clay Studio", "{title} | {site}")).toBe("clay studio, Hackney");
90
+ });
91
+ it("falls back to the default template when the record's is blank", () => {
92
+ expect(applyTitleTemplate("About", "Clay Studio", " ")).toBe("About | Clay Studio");
93
+ });
94
+ });
95
+
96
+ describe("a stored title template without {title}", () => {
97
+ it("is ignored for the default rather than printed", () => {
98
+ expect(applyTitleTemplate("About", "Clay Studio", "%s | Clay Studio")).toBe("About | Clay Studio");
99
+ expect(applyTitleTemplate("About", "Clay Studio", "Clay Studio")).toBe("About | Clay Studio");
100
+ // A usable one still applies, with either site placeholder.
101
+ expect(applyTitleTemplate("About", "Clay Studio", "{site.name}: {title}")).toBe("Clay Studio: About");
102
+ });
103
+
104
+ it("on the site falls back to the default, and on a page type falls back to the site's", () => {
105
+ const broken = resolvePageSeo(record({ titleTemplate: "%s | Clay Studio" }), { pathname: "/about", title: "About" });
106
+ expect(broken.title).toBe("About | Clay Studio");
107
+
108
+ const seo = record({ titleTemplate: "{title} - {site}", typeTemplates: { product: { title: "Shop" } } });
109
+ expect(resolvePageSeo(seo, { pathname: "/i/store/mug", type: "product", title: "Mug" }).title).toBe("Mug - Clay Studio");
110
+ const good = record({ typeTemplates: { product: { title: "{title} | Shop | {site}" } } });
111
+ expect(resolvePageSeo(good, { pathname: "/i/store/mug", type: "product", title: "Mug" }).title).toBe("Mug | Shop | Clay Studio");
112
+ });
113
+
114
+ it("never reaches a page's document title through seoHead", () => {
115
+ const t = tags(seoHead(ctx("/about", record({ titleTemplate: "%s | Clay Studio" })), { title: "About" }).meta);
116
+ expect(t.title).toBe("About | Clay Studio");
117
+ expect(t.title).not.toContain("%s");
118
+ });
119
+ });
120
+
121
+ describe("findOverride", () => {
122
+ it("prefers the exact pathname and fills gaps from the route pattern", () => {
123
+ const seo = record({
124
+ pages: {
125
+ "/team/ada": { title: "Ada, head potter" },
126
+ "/team/$slug": { title: "Team member", description: "Pattern description", noindex: true },
127
+ },
128
+ });
129
+ expect(findOverride(seo, "/team/ada/", "/team/$slug")).toEqual({
130
+ title: "Ada, head potter",
131
+ description: "Pattern description",
132
+ noindex: true,
133
+ });
134
+ });
135
+
136
+ it("ignores blank strings so an emptied field does not erase the code's value", () => {
137
+ const seo = record({ pages: { "/about": { title: " ", description: null } } });
138
+ expect(findOverride(seo, "/about")).toEqual({});
139
+ });
140
+ });
141
+
142
+ describe("resolvePageSeo", () => {
143
+ it("with no record, behaves exactly like the code's own values", () => {
144
+ expect(resolvePageSeo(null, { pathname: "/about", title: "About" })).toEqual({
145
+ title: "About",
146
+ shareTitle: "About",
147
+ description: "About",
148
+ image: undefined,
149
+ noindex: false,
150
+ canonical: null,
151
+ overridden: false,
152
+ });
153
+ });
154
+
155
+ it("uses a type template over the site template, and its description when the code has none", () => {
156
+ const seo = record({
157
+ typeTemplates: { event: { title: "{title} tickets | {site}", description: "Book {title} at {site}." } },
158
+ });
159
+ const r = resolvePageSeo(seo, { pathname: "/i/events/gig", title: "Late Show", type: "event", entity: true });
160
+ expect(r.title).toBe("Late Show tickets | Clay Studio");
161
+ expect(r.shareTitle).toBe("Late Show");
162
+ expect(r.description).toBe("Book Late Show at Clay Studio.");
163
+ });
164
+
165
+ it("falls back to site defaults for description and image", () => {
166
+ const r = resolvePageSeo(record(), { pathname: "/about", title: "About" });
167
+ expect(r.description).toBe(RECORD.defaultDescription);
168
+ expect(r.image).toBe(RECORD.defaultImage);
169
+ });
170
+
171
+ it("canonicalizes entities to the default website and pages to their own origin", () => {
172
+ const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
173
+ expect(resolvePageSeo(seo, { pathname: "/i/store/mug", title: "Mug", entity: true }).canonical).toBe(
174
+ "https://claystudio.com/i/store/mug",
175
+ );
176
+ expect(resolvePageSeo(seo, { pathname: "/about", title: "About" }).canonical).toBe("https://shop.example/about");
177
+ });
178
+
179
+ it("noindexes everything when the site is not indexing, whatever the code says", () => {
180
+ expect(resolvePageSeo(record({ indexing: false }), { pathname: "/", title: "Home" }).noindex).toBe(true);
181
+ });
182
+
183
+ it("lets an override's noindex:false beat the code's noindex", () => {
184
+ const seo = record({ pages: { "/thanks": { noindex: false } } });
185
+ expect(resolvePageSeo(seo, { pathname: "/thanks", title: "Thanks", noindex: true }).noindex).toBe(false);
186
+ });
187
+ });
188
+
189
+ describe("seoHead", () => {
190
+ it("with no record, emits today's buildHeadMeta tags and never links", () => {
191
+ const out = seoHead(ctx("/about", undefined), { title: "About", description: "Who we are", image: "https://x/i.jpg" });
192
+ expect(out).not.toHaveProperty("links");
193
+ const legacy = buildHeadMeta({ title: "About", description: "Who we are", image: "https://x/i.jpg" });
194
+ expect(tags(out.meta)).toEqual(tags(legacy.meta));
195
+ });
196
+
197
+ it("applies the title template to the code title", () => {
198
+ const out = seoHead(ctx("/about", RECORD), { title: "About", description: "Who we are" });
199
+ const t = tags(out.meta);
200
+ expect(t.title).toBe("About | Clay Studio");
201
+ expect(t["og:title"]).toBe("About");
202
+ expect(t.description).toBe("Who we are");
203
+ expect(t["og:url"]).toBe("https://claystudio.com/about");
204
+ expect(t.robots).toBeUndefined();
205
+ });
206
+
207
+ it("uses an override for the exact pathname, verbatim", () => {
208
+ const seo = record({ pages: { "/about": { title: "Our story", description: "Since 2019." } } });
209
+ const t = tags(seoHead(ctx("/about", seo), { title: "About", description: "Who we are" }).meta);
210
+ expect(t.title).toBe("Our story");
211
+ expect(t.description).toBe("Since 2019.");
212
+ });
213
+
214
+ it("uses an override keyed by the route pattern for a dynamic route", () => {
215
+ const seo = record({ pages: { "/team/$slug": { description: "One of our potters." } } });
216
+ const t = tags(seoHead(ctx("/team/ada", seo, "/team/$slug"), { title: "Ada" }).meta);
217
+ expect(t.title).toBe("Ada | Clay Studio");
218
+ expect(t.description).toBe("One of our potters.");
219
+ });
220
+
221
+ it("emits robots noindex from the code, from an override, and from a non-indexing site", () => {
222
+ expect(tags(seoHead(ctx("/x", RECORD), { title: "X", noindex: true }).meta).robots).toBe("noindex, nofollow");
223
+ const seo = record({ pages: { "/x": { noindex: true } } });
224
+ expect(tags(seoHead(ctx("/x", seo), { title: "X" }).meta).robots).toBe("noindex, nofollow");
225
+ expect(tags(seoHead(ctx("/x", record({ indexing: false })), { title: "X" }).meta).robots).toBe("noindex, nofollow");
226
+ });
227
+
228
+ it("emits each jsonLd node as a script:ld+json meta entry", () => {
229
+ const nodes = [{ "@type": "Thing", name: "a" }, { "@type": "Thing", name: "b" }];
230
+ expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes }).meta)).toEqual(nodes);
231
+ expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes[0] }).meta)).toEqual([nodes[0]]);
232
+ });
233
+ });
234
+
235
+ describe("siteSeoHead", () => {
236
+ it("emits the site fallbacks, verification and a canonical for the leaf path", () => {
237
+ const out = siteSeoHead(ctx("/about/", RECORD));
238
+ const t = tags(out.meta);
239
+ expect(t).toMatchObject({
240
+ title: "Clay Studio",
241
+ description: RECORD.defaultDescription,
242
+ "og:site_name": "Clay Studio",
243
+ "og:image": RECORD.defaultImage,
244
+ "twitter:card": "summary_large_image",
245
+ "google-site-verification": "g-token",
246
+ "msvalidate.01": "b-token",
247
+ });
248
+ expect(t.robots).toBeUndefined();
249
+ expect(out.links).toEqual([
250
+ { rel: "canonical", href: "https://claystudio.com/about" },
251
+ md("https://claystudio.com/about"),
252
+ ]);
253
+ });
254
+
255
+ it("uses an override canonical", () => {
256
+ const seo = record({ pages: { "/about": { canonical: "https://claystudio.com/story" } } });
257
+ expect(siteSeoHead(ctx("/about", seo)).links).toEqual([
258
+ { rel: "canonical", href: "https://claystudio.com/story" },
259
+ md("https://claystudio.com/story"),
260
+ ]);
261
+ });
262
+
263
+ it("emits no canonical under /i/, where the hosted page emits its own", () => {
264
+ expect(siteSeoHead(ctx("/i/store/mug", RECORD)).links).toEqual([]);
265
+ expect(siteSeoHead(ctx("/i", RECORD)).links).toEqual([]);
266
+ // A creator page that merely starts with "i" is not hosted.
267
+ expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(2);
268
+ });
269
+
270
+ it("noindexes when the site is not indexing or the path's override says so", () => {
271
+ expect(tags(siteSeoHead(ctx("/", record({ indexing: false }))).meta).robots).toBe("noindex, nofollow");
272
+ const seo = record({ pages: { "/team/$slug": { noindex: true } } });
273
+ expect(tags(siteSeoHead(ctx("/team/ada", seo, "/team/$slug")).meta).robots).toBe("noindex, nofollow");
274
+ });
275
+
276
+ it("emits WebSite and identity JSON-LD on / only", () => {
277
+ const home = jsonLd(siteSeoHead(ctx("/", RECORD)).meta);
278
+ expect(home.map((n) => n["@type"])).toEqual(["WebSite", "LocalBusiness"]);
279
+ expect(home[0]).toMatchObject({
280
+ name: "Clay Studio",
281
+ url: "https://claystudio.com/",
282
+ publisher: { "@id": "https://claystudio.com/#identity" },
283
+ });
284
+ expect(home[1]).toMatchObject({
285
+ "@id": "https://claystudio.com/#identity",
286
+ telephone: "+44 20 0000 0000",
287
+ sameAs: ["https://instagram.com/claystudio"],
288
+ address: { "@type": "PostalAddress", streetAddress: "1 Mare St", addressCountry: "GB" },
289
+ });
290
+ expect(jsonLd(siteSeoHead(ctx("/about", RECORD)).meta)).toEqual([]);
291
+ });
292
+
293
+ it("emits WebSite alone when there is no identity", () => {
294
+ const home = jsonLd(siteSeoHead(ctx("/", record({ identity: null }))).meta);
295
+ expect(home).toHaveLength(1);
296
+ expect(home[0]).not.toHaveProperty("publisher");
297
+ });
298
+
299
+ it("with no record, emits only the fallback it was given and no canonical", () => {
300
+ const out = siteSeoHead(ctx("/about", null), { siteName: "Clay" });
301
+ expect(out.links).toEqual([]);
302
+ expect(tags(out.meta)).toEqual({ title: "Clay", "og:site_name": "Clay", "twitter:card": "summary_large_image" });
303
+ expect(tags(siteSeoHead(ctx("/", undefined)).meta)).toEqual({ "twitter:card": "summary_large_image" });
304
+ });
305
+ });
306
+
307
+ /**
308
+ * A not-found render, shaped as TanStack builds it.
309
+ *
310
+ * A URL no route matches leaves ONLY the root match, at "/", marked
311
+ * `_notFound`. A route whose loader threw `notFound()` keeps its own match with
312
+ * `status: "notFound"`, and the boundary above it carries `_notFound`.
313
+ */
314
+ function globalNotFoundCtx(seo: SeoRecord | null | undefined): SeoHeadContext {
315
+ const root = { pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo }, _notFound: true, status: "success" };
316
+ return { matches: [root], match: root, params: {} };
317
+ }
318
+ function thrownNotFoundCtx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
319
+ const leaf = { pathname, fullPath, routeId: fullPath, loaderData: undefined, status: "notFound", _notFound: true };
320
+ return {
321
+ matches: [{ pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo }, status: "success" }, leaf],
322
+ match: leaf,
323
+ params: {},
324
+ };
325
+ }
326
+
327
+ describe("a not-found render", () => {
328
+ afterEach(() => {
329
+ vi.unstubAllGlobals();
330
+ });
331
+
332
+ it("is recognised from either TanStack marker, and a normal render is not", () => {
333
+ expect(isNotFoundContext(globalNotFoundCtx(RECORD))).toBe(true);
334
+ expect(isNotFoundContext(thrownNotFoundCtx("/blog/gone", RECORD))).toBe(true);
335
+ expect(isNotFoundContext(ctx("/", RECORD))).toBe(false);
336
+ expect(isNotFoundContext(ctx("/about", RECORD))).toBe(false);
337
+ expect(isNotFoundContext(undefined)).toBe(false);
338
+ });
339
+
340
+ it("reads its pathname from the location, not the root match's \"/\"", () => {
341
+ vi.stubGlobal("location", { pathname: "/no-such-page/" });
342
+ expect(pathnameFromContext(globalNotFoundCtx(RECORD))).toBe("/no-such-page");
343
+ // A page that exists keeps its leaf, whatever the location says.
344
+ expect(pathnameFromContext(ctx("/about", RECORD))).toBe("/about");
345
+ });
346
+
347
+ it("on an unmatched URL states no canonical, no Markdown copy and no site JSON-LD, and is noindex", () => {
348
+ const out = siteSeoHead(globalNotFoundCtx(RECORD));
349
+ expect(out.links).toEqual([]);
350
+ expect(jsonLd(out.meta)).toEqual([]);
351
+ const t = tags(out.meta);
352
+ expect(t.robots).toBe("noindex, nofollow");
353
+ // The site-wide fallbacks still hold, so the page is not bare.
354
+ expect(t.title).toBe("Clay Studio");
355
+ expect(t["og:site_name"]).toBe("Clay Studio");
356
+ expect(t["google-site-verification"]).toBe("g-token");
357
+ });
358
+
359
+ it("does the same in the browser, where the location is known", () => {
360
+ vi.stubGlobal("location", { pathname: "/no-such-page" });
361
+ const out = siteSeoHead(globalNotFoundCtx(RECORD));
362
+ expect(out.links).toEqual([]);
363
+ expect(jsonLd(out.meta)).toEqual([]);
364
+ expect(tags(out.meta).robots).toBe("noindex, nofollow");
365
+ });
366
+
367
+ it("ignores an override for \"/\" that would otherwise apply to the root match", () => {
368
+ const seo = record({ pages: { "/": { canonical: "https://claystudio.com/home" } } });
369
+ expect(siteSeoHead(globalNotFoundCtx(seo)).links).toEqual([]);
370
+ });
371
+
372
+ it("when a loader threw notFound(), the root and the page both say noindex and state no URL", () => {
373
+ const root = siteSeoHead(thrownNotFoundCtx("/blog/gone", RECORD, "/blog/$slug"));
374
+ expect(root.links).toEqual([]);
375
+ expect(tags(root.meta).robots).toBe("noindex, nofollow");
376
+
377
+ const page = seoHead(thrownNotFoundCtx("/blog/gone", RECORD, "/blog/$slug"), {
378
+ title: "Gone",
379
+ jsonLd: { "@type": "BlogPosting" },
380
+ });
381
+ const t = tags(page.meta);
382
+ expect(t.robots).toBe("noindex, nofollow");
383
+ expect(t["og:url"]).toBeUndefined();
384
+ expect(jsonLd(page.meta)).toEqual([]);
385
+ });
386
+
387
+ it("leaves the home page exactly as it was", () => {
388
+ const home = siteSeoHead(ctx("/", RECORD));
389
+ expect(home.links[0]).toEqual({ rel: "canonical", href: "https://claystudio.com/" });
390
+ expect(jsonLd(home.meta).map((n) => n["@type"])).toEqual(["WebSite", "LocalBusiness"]);
391
+ expect(tags(home.meta).robots).toBeUndefined();
392
+ });
393
+ });
394
+
395
+ describe("applySeoToHead", () => {
396
+ const productHead = () => ({
397
+ ...buildHeadMeta({ title: "Mug", description: "A mug.", image: "https://cdn.example/mug.jpg", ogType: "product" }),
398
+ scripts: [{ type: "application/ld+json", children: "{}" }],
399
+ });
400
+
401
+ it("returns the head untouched with no record", () => {
402
+ const head = productHead();
403
+ expect(applySeoToHead(head, null, "/i/store/mug", "product")).toBe(head);
404
+ });
405
+
406
+ it("templates the title, keeps the page's own tags and adds the entity canonical", () => {
407
+ const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
408
+ const out = applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug");
409
+ const t = tags(out.meta!);
410
+ expect(t.title).toBe("Mug | Clay Studio");
411
+ expect(t["og:title"]).toBe("Mug");
412
+ expect(t["og:type"]).toBe("product");
413
+ expect(t["og:site_name"]).toBe("Clay Studio");
414
+ expect(t["og:url"]).toBe("https://claystudio.com/i/store/mug");
415
+ expect(out.links).toEqual([
416
+ { rel: "canonical", href: "https://claystudio.com/i/store/mug" },
417
+ md("https://claystudio.com/i/store/mug"),
418
+ ]);
419
+ expect(out.scripts).toEqual([{ type: "application/ld+json", children: "{}" }]);
420
+ // Exactly one of each: the old entries were replaced, not appended to.
421
+ expect(out.meta!.filter((m) => m.name === "description")).toHaveLength(1);
422
+ expect(out.meta!.filter((m) => typeof m.title === "string")).toHaveLength(1);
423
+ });
424
+
425
+ it("canonicalizes an index page to its own website", () => {
426
+ const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
427
+ const out = applySeoToHead(buildHeadMeta({ title: "Store" }), seo, "/i/store", "page");
428
+ expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }, md("https://shop.example/i/store")]);
429
+ });
430
+
431
+ it("applies overrides by pathname and by pattern", () => {
432
+ const seo = record({
433
+ pages: {
434
+ "/i/store/mug": { title: "The best mug" },
435
+ "/i/store/$slug": { description: "Handmade in Hackney.", image: "https://cdn.example/o.jpg" },
436
+ },
437
+ });
438
+ const t = tags(applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug").meta!);
439
+ expect(t.title).toBe("The best mug");
440
+ expect(t.description).toBe("Handmade in Hackney.");
441
+ expect(t["og:image"]).toBe("https://cdn.example/o.jpg");
442
+ expect(t["twitter:image"]).toBe("https://cdn.example/o.jpg");
443
+ });
444
+
445
+ it("does not let buildHeadMeta's title-as-description beat the site default", () => {
446
+ const t = tags(applySeoToHead(buildHeadMeta({ title: "Events" }), RECORD, "/i/events", "page").meta!);
447
+ expect(t.description).toBe(RECORD.defaultDescription);
448
+ });
449
+
450
+ it("keeps a page's noindex and then states no canonical", () => {
451
+ const out = applySeoToHead(buildHeadMeta({ title: "Login", noindex: true }), RECORD, "/i/login", "page");
452
+ expect(tags(out.meta!).robots).toBe("noindex, nofollow");
453
+ expect(out.links).toEqual([]);
454
+ });
455
+ });
456
+
457
+ describe("Markdown alternate (section 5.4)", () => {
458
+ /** The same table as apps/dispatch-worker/src/_tests/markdown.spec.ts: the two rules must agree. */
459
+ it.each([
460
+ ["/", true],
461
+ ["/about", true],
462
+ ["/team/ada/", true],
463
+ ["/i/store", true],
464
+ ["/i/store/mug", true],
465
+ ["/i/events/launch-night", true],
466
+ ["/i/podcasts/show/episode-1", true],
467
+ ["/i/communities/potters", true],
468
+ ["/i/store/mug/extra", false],
469
+ ["/i/events/launch-night/finalise", false],
470
+ ["/i/films/library", false],
471
+ ["/i/films/doc/watch/e1", false],
472
+ ["/i/account", false],
473
+ ["/i/account/orders", false],
474
+ ["/i/checkout", false],
475
+ ["/i/login", false],
476
+ ["/i/downloads/tok", false],
477
+ ["/i/app", false],
478
+ ["/i", false],
479
+ ["/api/things", false],
480
+ ["/__tn/unlock", false],
481
+ ["/.well-known/mcp", false],
482
+ ["/logo.png", false],
483
+ ["/sitemap.xml", false],
484
+ ])("isMarkdownPath(%s) is %s", (path, expected) => {
485
+ expect(isMarkdownPath(path)).toBe(expected);
486
+ });
487
+
488
+ it("is left out when readable copies are off, the page is noindex or the site is not indexing", () => {
489
+ const off = (seo: SeoRecord, path = "/about") =>
490
+ (siteSeoHead(ctx(path, seo)).links as Array<{ rel: string }>).filter((l) => l.rel === "alternate");
491
+ expect(off(record({ markdownEnabled: false }))).toEqual([]);
492
+ expect(off(record({ indexing: false }))).toEqual([]);
493
+ expect(off(record({ pages: { "/about": { noindex: true } } }))).toEqual([]);
494
+ // Absent means on: an API older than the field.
495
+ expect(off(record({ markdownEnabled: undefined }))).toHaveLength(1);
496
+ });
497
+
498
+ it("is left out of a hosted page that is noindex or private", () => {
499
+ const head = buildHeadMeta({ title: "Orders" });
500
+ expect(applySeoToHead(head, RECORD, "/i/account/orders").links).toEqual([
501
+ { rel: "canonical", href: "https://claystudio.com/i/account/orders" },
502
+ ]);
503
+ const off = applySeoToHead(buildHeadMeta({ title: "Mug" }), record({ markdownEnabled: false }), "/i/store/mug", "product");
504
+ expect(off.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/i/store/mug" }]);
505
+ });
506
+
507
+ it("replaces a markdown link the page already had rather than adding a second", () => {
508
+ const head = { ...buildHeadMeta({ title: "Mug" }), links: [{ rel: "alternate", type: "text/markdown", href: "/old" }] };
509
+ const out = applySeoToHead(head, RECORD, "/i/store/mug", "product");
510
+ expect(out.links!.filter((l) => l.rel === "alternate")).toEqual([md("https://claystudio.com/i/store/mug")]);
511
+ });
512
+
513
+ it("points at this site when the canonical is somewhere else, and appends to a canonical with a query", () => {
514
+ expect(markdownAlternateHref(RECORD, "/about", "https://elsewhere.example/about")).toBe(
515
+ "https://claystudio.com/about?md",
516
+ );
517
+ expect(markdownAlternateHref(RECORD, "/about", "https://claystudio.com/about?lang=en")).toBe(
518
+ "https://claystudio.com/about?lang=en&md",
519
+ );
520
+ expect(markdownAlternateHref(RECORD, "/", null)).toBe("https://claystudio.com/?md");
521
+ });
522
+ });
@@ -0,0 +1,104 @@
1
+ import { markdownAlternateLink } from "./markdown";
2
+ import { normalizePathname, resolvePageSeo } from "./resolve";
3
+ import type { SeoPageType, SeoRecord } from "./types";
4
+
5
+ type HeadEntry = Record<string, string | undefined>;
6
+
7
+ /** The `{ meta, links, scripts }` shape a hosted page's `head()` returns. */
8
+ export type HeadTags = {
9
+ meta?: HeadEntry[];
10
+ links?: HeadEntry[];
11
+ scripts?: HeadEntry[];
12
+ };
13
+
14
+ /**
15
+ * Meta this function owns and rewrites. Anything else a page set (`og:type`,
16
+ * `og:video`, a custom tag) passes through untouched.
17
+ */
18
+ const OWNED_META = new Set([
19
+ "description",
20
+ "robots",
21
+ "og:title",
22
+ "og:description",
23
+ "og:image",
24
+ "og:url",
25
+ "og:site_name",
26
+ "twitter:title",
27
+ "twitter:description",
28
+ "twitter:image",
29
+ ]);
30
+
31
+ const metaKey = (m: HeadEntry) => m.name ?? m.property;
32
+
33
+ /**
34
+ * Resolve the SEO record over a hosted `/i/` page's own head.
35
+ *
36
+ * The page's `head()` says what its code defaults are (title, description,
37
+ * image, noindex), exactly as before. This applies, on top of them, the
38
+ * creator's override for the pathname or pattern, the page type's template,
39
+ * the site defaults and the canonical.
40
+ *
41
+ * The canonical is emitted HERE, not by the site's root, because only the page
42
+ * knows it is an entity: a product, event or post canonicalizes to the
43
+ * profile's DEFAULT website (`defaultWebsiteOrigin`), so the same product shown
44
+ * on three websites is one result, not three. An index page (`/i/store`)
45
+ * belongs to the website it is on.
46
+ *
47
+ * With no record, the head is returned unchanged.
48
+ */
49
+ export function applySeoToHead<T extends HeadTags>(
50
+ head: T,
51
+ seo: SeoRecord | null | undefined,
52
+ pathname: string,
53
+ type: SeoPageType = "page",
54
+ routePattern?: string,
55
+ ): T {
56
+ if (!seo) return head;
57
+
58
+ const meta = head.meta ?? [];
59
+ const find = (key: string) => meta.find((m) => metaKey(m) === key)?.content;
60
+ const codeTitle = meta.find((m) => typeof m.title === "string")?.title ?? "";
61
+ const codeNoindex = /noindex/i.test(find("robots") ?? "");
62
+
63
+ const path = normalizePathname(pathname);
64
+ const resolved = resolvePageSeo(seo, {
65
+ pathname: path,
66
+ routePattern,
67
+ type,
68
+ title: codeTitle,
69
+ // `buildHeadMeta` falls back to the title when a page has no description.
70
+ // That fallback is not a description the code chose, so it must not beat
71
+ // the type template or the site default.
72
+ description: find("description") && find("description") !== codeTitle ? find("description") : undefined,
73
+ image: find("og:image"),
74
+ noindex: codeNoindex,
75
+ entity: type !== "page",
76
+ });
77
+
78
+ const kept = meta.filter((m) => typeof m.title !== "string" && !OWNED_META.has(metaKey(m) ?? ""));
79
+ const nextMeta: HeadEntry[] = [
80
+ { title: resolved.title },
81
+ { name: "description", content: resolved.description },
82
+ { property: "og:title", content: resolved.shareTitle },
83
+ { property: "og:description", content: resolved.description },
84
+ { property: "og:site_name", content: seo.siteName },
85
+ ...(resolved.canonical && !resolved.noindex ? [{ property: "og:url", content: resolved.canonical }] : []),
86
+ ...(resolved.image ? [{ property: "og:image", content: resolved.image }] : []),
87
+ ...kept,
88
+ { name: "twitter:title", content: resolved.shareTitle },
89
+ { name: "twitter:description", content: resolved.description },
90
+ ...(resolved.image ? [{ name: "twitter:image", content: resolved.image }] : []),
91
+ ...(resolved.noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
92
+ ];
93
+
94
+ // A noindex page states no canonical: there is no indexed copy to point at.
95
+ const links = (head.links ?? []).filter(
96
+ (l) => l.rel !== "canonical" && !(l.rel === "alternate" && l.type === "text/markdown"),
97
+ );
98
+ if (resolved.canonical && !resolved.noindex) links.push({ rel: "canonical", href: resolved.canonical });
99
+ // The readable copy for agents, on the same terms as the canonical.
100
+ const markdown = markdownAlternateLink(seo, path, { canonical: resolved.canonical, noindex: resolved.noindex });
101
+ if (markdown) links.push({ ...markdown });
102
+
103
+ return { ...head, meta: nextMeta, links };
104
+ }