@tribe-nest/forge 3.63.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.63.0",
3
+ "version": "3.64.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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",
@@ -1,9 +1,13 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  applySeoToHead,
4
4
  applyTitleTemplate,
5
5
  findOverride,
6
+ isMarkdownPath,
7
+ isNotFoundContext,
8
+ markdownAlternateHref,
6
9
  normalizePathname,
10
+ pathnameFromContext,
7
11
  resolvePageSeo,
8
12
  seoHead,
9
13
  siteSeoHead,
@@ -38,6 +42,9 @@ const RECORD: SeoRecord = {
38
42
 
39
43
  const record = (patch: Partial<SeoRecord> = {}): SeoRecord => ({ ...RECORD, ...patch });
40
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
+
41
48
  /** A head() context shaped like TanStack's: root match first, leaf last. */
42
49
  function ctx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
43
50
  const leaf = { pathname, fullPath, routeId: fullPath, loaderData: {} };
@@ -86,6 +93,31 @@ describe("applyTitleTemplate", () => {
86
93
  });
87
94
  });
88
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
+
89
121
  describe("findOverride", () => {
90
122
  it("prefers the exact pathname and fills gaps from the route pattern", () => {
91
123
  const seo = record({
@@ -214,19 +246,25 @@ describe("siteSeoHead", () => {
214
246
  "msvalidate.01": "b-token",
215
247
  });
216
248
  expect(t.robots).toBeUndefined();
217
- expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/about" }]);
249
+ expect(out.links).toEqual([
250
+ { rel: "canonical", href: "https://claystudio.com/about" },
251
+ md("https://claystudio.com/about"),
252
+ ]);
218
253
  });
219
254
 
220
255
  it("uses an override canonical", () => {
221
256
  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" }]);
257
+ expect(siteSeoHead(ctx("/about", seo)).links).toEqual([
258
+ { rel: "canonical", href: "https://claystudio.com/story" },
259
+ md("https://claystudio.com/story"),
260
+ ]);
223
261
  });
224
262
 
225
263
  it("emits no canonical under /i/, where the hosted page emits its own", () => {
226
264
  expect(siteSeoHead(ctx("/i/store/mug", RECORD)).links).toEqual([]);
227
265
  expect(siteSeoHead(ctx("/i", RECORD)).links).toEqual([]);
228
266
  // A creator page that merely starts with "i" is not hosted.
229
- expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(1);
267
+ expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(2);
230
268
  });
231
269
 
232
270
  it("noindexes when the site is not indexing or the path's override says so", () => {
@@ -266,6 +304,94 @@ describe("siteSeoHead", () => {
266
304
  });
267
305
  });
268
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
+
269
395
  describe("applySeoToHead", () => {
270
396
  const productHead = () => ({
271
397
  ...buildHeadMeta({ title: "Mug", description: "A mug.", image: "https://cdn.example/mug.jpg", ogType: "product" }),
@@ -286,7 +412,10 @@ describe("applySeoToHead", () => {
286
412
  expect(t["og:type"]).toBe("product");
287
413
  expect(t["og:site_name"]).toBe("Clay Studio");
288
414
  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" }]);
415
+ expect(out.links).toEqual([
416
+ { rel: "canonical", href: "https://claystudio.com/i/store/mug" },
417
+ md("https://claystudio.com/i/store/mug"),
418
+ ]);
290
419
  expect(out.scripts).toEqual([{ type: "application/ld+json", children: "{}" }]);
291
420
  // Exactly one of each: the old entries were replaced, not appended to.
292
421
  expect(out.meta!.filter((m) => m.name === "description")).toHaveLength(1);
@@ -296,7 +425,7 @@ describe("applySeoToHead", () => {
296
425
  it("canonicalizes an index page to its own website", () => {
297
426
  const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
298
427
  const out = applySeoToHead(buildHeadMeta({ title: "Store" }), seo, "/i/store", "page");
299
- expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }]);
428
+ expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }, md("https://shop.example/i/store")]);
300
429
  });
301
430
 
302
431
  it("applies overrides by pathname and by pattern", () => {
@@ -324,3 +453,70 @@ describe("applySeoToHead", () => {
324
453
  expect(out.links).toEqual([]);
325
454
  });
326
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
+ });
@@ -1,3 +1,4 @@
1
+ import { markdownAlternateLink } from "./markdown";
1
2
  import { normalizePathname, resolvePageSeo } from "./resolve";
2
3
  import type { SeoPageType, SeoRecord } from "./types";
3
4
 
@@ -91,8 +92,13 @@ export function applySeoToHead<T extends HeadTags>(
91
92
  ];
92
93
 
93
94
  // 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
+ const links = (head.links ?? []).filter(
96
+ (l) => l.rel !== "canonical" && !(l.rel === "alternate" && l.type === "text/markdown"),
97
+ );
95
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 });
96
102
 
97
103
  return { ...head, meta: nextMeta, links };
98
104
  }
package/src/seo/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from "./resolve";
7
7
  export * from "./seoHead";
8
8
  export * from "./siteSeoHead";
9
9
  export * from "./applySeoToHead";
10
+ export * from "./markdown";
@@ -0,0 +1,76 @@
1
+ import { normalizePathname } from "./resolve";
2
+ import type { SeoLinkEntry, SeoRecord } from "./types";
3
+
4
+ /**
5
+ * Readable copies for agents (docs/proposals/seo-initiative.md, section 5.4).
6
+ *
7
+ * The edge answers `Accept: text/markdown` or `?md` on a page with Markdown.
8
+ * These helpers decide which pages have such a copy, so a page only ever
9
+ * advertises one the edge will actually serve.
10
+ *
11
+ * KEEP IN STEP with `isMarkdownPath` in apps/dispatch-worker/src/markdown.ts.
12
+ * The dispatch Worker does not depend on Forge, so the rule is written twice;
13
+ * both specs run the same table of paths.
14
+ */
15
+
16
+ /** Hosted families with a public page per item. Everything else under /i/ is private or transactional. */
17
+ export const MARKDOWN_HOSTED_FAMILIES = [
18
+ "store",
19
+ "events",
20
+ "courses",
21
+ "coaching",
22
+ "blog",
23
+ "blog-category",
24
+ "podcasts",
25
+ "films",
26
+ "series",
27
+ "communities",
28
+ ] as const;
29
+
30
+ /** Segments that mark a step in a flow, not a page about something. */
31
+ const FLOW_SEGMENTS = new Set(["finalise", "library", "claim", "watch"]);
32
+
33
+ /** Whether the edge serves a Markdown copy of this path. */
34
+ export function isMarkdownPath(pathname: string): boolean {
35
+ const path = normalizePathname(pathname);
36
+ if (path.startsWith("/api/") || path.startsWith("/__tn/") || path.startsWith("/.well-known/")) return false;
37
+ const segments = path.split("/").filter(Boolean);
38
+ // A file, not a page: `/logo.png`, `/sw.js`, `/robots.txt`.
39
+ const last = segments[segments.length - 1] ?? "";
40
+ if (last.includes(".")) return false;
41
+ if (segments[0] !== "i") return true;
42
+
43
+ const [, family, ...rest] = segments;
44
+ if (!family || !(MARKDOWN_HOSTED_FAMILIES as readonly string[]).includes(family)) return false;
45
+ if (rest.some((s) => FLOW_SEGMENTS.has(s))) return false;
46
+ // The index and one item. A podcast episode is the one page a level deeper.
47
+ return rest.length <= (family === "podcasts" ? 2 : 1);
48
+ }
49
+
50
+ /**
51
+ * Where the Markdown copy of a page lives: the canonical with `md` added, when
52
+ * the canonical is one of this profile's websites (they all serve Markdown).
53
+ * A canonical pointing anywhere else may not, so the page's own URL is used.
54
+ */
55
+ export function markdownAlternateHref(seo: SeoRecord, pathname: string, canonical?: string | null): string {
56
+ const own = [seo.origin, seo.defaultWebsiteOrigin].filter(Boolean).map((o) => o.replace(/\/+$/, ""));
57
+ const onOurs = !!canonical && own.some((o) => canonical === o || canonical.startsWith(`${o}/`));
58
+ const path = normalizePathname(pathname);
59
+ const base = onOurs ? canonical! : `${seo.origin.replace(/\/+$/, "")}${path === "/" ? "/" : path}`;
60
+ return `${base}${base.includes("?") ? "&" : "?"}md`;
61
+ }
62
+
63
+ /**
64
+ * The `<link rel="alternate" type="text/markdown">` for an indexable page, or
65
+ * null when there is no copy to point at (readable copies off, a noindex page,
66
+ * a path the edge does not convert).
67
+ */
68
+ export function markdownAlternateLink(
69
+ seo: SeoRecord,
70
+ pathname: string,
71
+ options: { canonical?: string | null; noindex?: boolean },
72
+ ): SeoLinkEntry | null {
73
+ if (seo.markdownEnabled === false || options.noindex || seo.indexing === false) return null;
74
+ if (!isMarkdownPath(pathname)) return null;
75
+ return { rel: "alternate", type: "text/markdown", href: markdownAlternateHref(seo, pathname, options.canonical) };
76
+ }
@@ -40,8 +40,36 @@ export function seoRecordFromContext(ctx: SeoHeadContext | undefined | null): Se
40
40
  return seo && typeof seo === "object" && typeof seo.siteName === "string" ? seo : null;
41
41
  }
42
42
 
43
- /** The leaf pathname: the last match is the page being rendered. */
43
+ /**
44
+ * Whether this render is a not-found page: a URL no route matches, or a route
45
+ * whose loader threw `notFound()`. TanStack marks the boundary match with
46
+ * `_notFound` and the throwing match with `status: "notFound"`.
47
+ */
48
+ export function isNotFoundContext(ctx: SeoHeadContext | undefined | null): boolean {
49
+ const all = [...(ctx?.matches ?? []), ctx?.match];
50
+ return all.some((m) => !!m && (m._notFound === true || m.status === "notFound"));
51
+ }
52
+
53
+ /** The browser's own pathname, when there is a browser. Undefined on the server. */
54
+ function locationPathname(): string | undefined {
55
+ const loc = (globalThis as { location?: { pathname?: unknown } }).location;
56
+ return typeof loc?.pathname === "string" && loc.pathname ? loc.pathname : undefined;
57
+ }
58
+
59
+ /**
60
+ * The pathname of the page being rendered: the leaf match's.
61
+ *
62
+ * A not-found render is the exception. A URL no route matches has only the
63
+ * root match left, whose pathname is "/", so the leaf would claim to be the
64
+ * home page. There the location is used when there is one (in the browser),
65
+ * and the leaf only when nothing better is known. Callers must not state a
66
+ * canonical for a not-found render whatever this returns (see siteSeoHead).
67
+ */
44
68
  export function pathnameFromContext(ctx: SeoHeadContext | undefined | null): string {
69
+ if (isNotFoundContext(ctx)) {
70
+ const fromLocation = locationPathname();
71
+ if (fromLocation) return normalizePathname(fromLocation);
72
+ }
45
73
  const matches = ctx?.matches ?? [];
46
74
  for (let i = matches.length - 1; i >= 0; i--) {
47
75
  const pathname = matches[i]?.pathname;
@@ -97,9 +125,20 @@ export function fillTemplate(template: string, values: { title: string; site: st
97
125
  .trim();
98
126
  }
99
127
 
128
+ /**
129
+ * Whether a stored title template can be used: it must say where the page's
130
+ * title goes. The API refuses one without `{title}`, but a record saved before
131
+ * that check (or by an older API) may still carry "%s | Clay Studio", which
132
+ * would otherwise become every page's literal title.
133
+ */
134
+ export function isUsableTitleTemplate(template: string | null | undefined): template is string {
135
+ return typeof template === "string" && template.includes("{title}");
136
+ }
137
+
100
138
  /**
101
139
  * The document title for a code title.
102
140
  *
141
+ * A template without `{title}` is ignored in favour of the default.
103
142
  * The template is skipped when the title already names the site, so a code
104
143
  * title of "Clay Studio | Pottery in Hackney" does not become
105
144
  * "Clay Studio | Pottery in Hackney | Clay Studio".
@@ -110,7 +149,8 @@ export function applyTitleTemplate(title: string, siteName: string, template?: s
110
149
  if (!site) return base;
111
150
  if (!base) return site;
112
151
  if (base.toLowerCase().includes(site.toLowerCase())) return base;
113
- const filled = fillTemplate(template?.trim() || DEFAULT_TITLE_TEMPLATE, { title: base, site });
152
+ const usable = isUsableTitleTemplate(template?.trim()) ? template!.trim() : DEFAULT_TITLE_TEMPLATE;
153
+ const filled = fillTemplate(usable, { title: base, site });
114
154
  return filled || base;
115
155
  }
116
156
 
@@ -183,7 +223,10 @@ export function resolvePageSeo(seo: SeoRecord | null | undefined, input: Resolve
183
223
  shareTitle = title;
184
224
  } else {
185
225
  shareTitle = codeTitle || site;
186
- title = applyTitleTemplate(shareTitle, site, typeTemplate?.title || seo.titleTemplate);
226
+ // The page type's template, else the site's: whichever first says where the
227
+ // title goes. Neither usable means the default.
228
+ const template = [typeTemplate?.title, seo.titleTemplate].find(isUsableTitleTemplate);
229
+ title = applyTitleTemplate(shareTitle, site, template);
187
230
  }
188
231
 
189
232
  const description =
@@ -1,4 +1,5 @@
1
1
  import {
2
+ isNotFoundContext,
2
3
  pathnameFromContext,
3
4
  resolvePageSeo,
4
5
  routePatternFromContext,
@@ -57,5 +58,10 @@ export function seoHead(ctx: SeoHeadContext | undefined | null, input: SeoHeadIn
57
58
  image: input.image,
58
59
  noindex: input.noindex,
59
60
  });
61
+ // A page whose loader threw notFound() renders the not-found page under its
62
+ // own URL. That is not a page to index or to share as itself.
63
+ if (isNotFoundContext(ctx)) {
64
+ return { meta: pageMeta({ ...resolved, noindex: true, canonical: null }) };
65
+ }
60
66
  return { meta: [...pageMeta(resolved), ...jsonLdMeta(input.jsonLd)] };
61
67
  }
@@ -3,10 +3,12 @@ import {
3
3
  canonicalFor,
4
4
  findOverride,
5
5
  isHostedPath,
6
+ isNotFoundContext,
6
7
  leafRoutePatternFromContext,
7
8
  pathnameFromContext,
8
9
  seoRecordFromContext,
9
10
  } from "./resolve";
11
+ import { markdownAlternateLink } from "./markdown";
10
12
  import { jsonLdMeta } from "./seoHead";
11
13
  import type { SeoHeadContext, SeoLinkEntry, SeoMetaEntry } from "./types";
12
14
 
@@ -30,10 +32,16 @@ export type SiteSeoFallback = {
30
32
  * than replaced. It is skipped under `/i/`, where the hosted page emits its
31
33
  * own (an entity's canonical is on the default website, which only that page
32
34
  * knows to say);
33
- * - the WebSite and identity JSON-LD, on "/" only.
35
+ * - the WebSite and identity JSON-LD, on "/" only;
36
+ * - the `alternate` link to the page's Markdown copy, beside the canonical.
34
37
  *
35
38
  * `robots` is set here when the whole site is noindex or this path's override
36
39
  * says so, so it holds even on a page whose code never calls `seoHead`.
40
+ *
41
+ * A not-found render states none of the three: no canonical, no Markdown copy
42
+ * and no site JSON-LD, and it is `noindex`. On a URL no route matches, the only
43
+ * match left is the root's, at "/", so without this every 404 would claim to
44
+ * be the home page.
37
45
  */
38
46
  export function siteSeoHead(
39
47
  ctx: SeoHeadContext | undefined | null,
@@ -55,10 +63,13 @@ export function siteSeoHead(
55
63
  };
56
64
  }
57
65
 
58
- const override = findOverride(seo, pathname, leafRoutePatternFromContext(ctx));
66
+ const notFound = isNotFoundContext(ctx);
67
+ // A missing page has no override: one keyed to "/" or to the route pattern
68
+ // belongs to a page that exists.
69
+ const override = notFound ? null : findOverride(seo, pathname, leafRoutePatternFromContext(ctx));
59
70
  const description = seo.defaultDescription || fallback.description || undefined;
60
71
  const image = seo.defaultImage || fallback.image || undefined;
61
- const noindex = seo.indexing === false || override?.noindex === true;
72
+ const noindex = notFound || seo.indexing === false || override?.noindex === true;
62
73
 
63
74
  const meta: SeoMetaEntry[] = [
64
75
  { title: seo.siteName },
@@ -71,7 +82,7 @@ export function siteSeoHead(
71
82
  ...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
72
83
  ];
73
84
 
74
- if (pathname === "/") {
85
+ if (pathname === "/" && !notFound) {
75
86
  const origin = seo.origin.replace(/\/+$/, "");
76
87
  const identity = buildIdentitySchema(seo.identity, { origin });
77
88
  meta.push(
@@ -89,9 +100,15 @@ export function siteSeoHead(
89
100
  );
90
101
  }
91
102
 
92
- const links: SeoLinkEntry[] = isHostedPath(pathname)
93
- ? []
94
- : [{ rel: "canonical", href: canonicalFor(seo, pathname, override) }];
103
+ const links: SeoLinkEntry[] = [];
104
+ if (!notFound && !isHostedPath(pathname)) {
105
+ const canonical = canonicalFor(seo, pathname, override);
106
+ links.push({ rel: "canonical", href: canonical });
107
+ // The page's readable copy for agents (section 5.4). A hosted page states
108
+ // its own, beside its own canonical (see applySeoToHead).
109
+ const markdown = markdownAlternateLink(seo, pathname, { canonical, noindex });
110
+ if (markdown) links.push(markdown);
111
+ }
95
112
 
96
113
  return { meta, links };
97
114
  }
package/src/seo/types.ts CHANGED
@@ -53,6 +53,12 @@ export type SeoRecord = {
53
53
  verification: { google?: string | null; bing?: string | null };
54
54
  /** false = the whole site is noindex. */
55
55
  indexing: boolean;
56
+ /**
57
+ * false = readable copies are off: the edge serves no Markdown and pages do
58
+ * not advertise one. Optional because an API older than Forge 3.64 omits it,
59
+ * which means on.
60
+ */
61
+ markdownEnabled?: boolean;
56
62
  /**
57
63
  * Key = exact pathname ("/about", "/i/store/mug") OR route pattern
58
64
  * ("/team/$slug"). Entity overrides arrive already expanded to their /i/
@@ -84,6 +90,14 @@ export type SeoMatchLike = {
84
90
  routeId?: string;
85
91
  fullPath?: string;
86
92
  loaderData?: unknown;
93
+ /** "notFound" on the match whose loader threw `notFound()`. */
94
+ status?: string;
95
+ /**
96
+ * TanStack sets this on the match that renders the not-found boundary. On a
97
+ * URL no route matches, that is the ROOT match, whose pathname is "/": read
98
+ * without this flag, every unknown URL looks like the home page.
99
+ */
100
+ _notFound?: boolean;
87
101
  };
88
102
 
89
103
  export type SeoHeadContext = {
@@ -96,4 +110,4 @@ export type SeoHeadContext = {
96
110
  /** One `head()` meta entry. A JSON-LD node rides as `{ "script:ld+json": obj }`. */
97
111
  export type SeoMetaEntry = Record<string, unknown>;
98
112
 
99
- export type SeoLinkEntry = { rel: string; href: string };
113
+ export type SeoLinkEntry = { rel: string; href: string; type?: string };
@@ -7,7 +7,7 @@
7
7
  // - apple-touch-icon derive (icon192 preferred, icon512 fallback, absent → omit)
8
8
  // - theme-color derivation + default fallback (bare + localized + no doc)
9
9
  // - manifestHref override / default
10
- // - apple-mobile-web-app-title fallback chain (shortName → name → siteName → "App")
10
+ // - apple-mobile-web-app-title fallback chain (shortName → name → SEO siteName → siteName → "App")
11
11
  // - null/undefined siteConfig degradation (never throws)
12
12
  // - the known client bug this fixes: apple-touch-icon is always emitted when an icon exists
13
13
  //
@@ -165,6 +165,23 @@ describe("buildPwaHead — apple-mobile-web-app-title fallback chain", () => {
165
165
  const head = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: null }) });
166
166
  expect(metaFor(head.metas, "apple-mobile-web-app-title")?.content).toBe("App");
167
167
  });
168
+
169
+ it("prefers the SEO record's site name over the site config's (the profile's)", () => {
170
+ const head = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: "Ada Potter" }), seo: { siteName: "Clay Studio" } });
171
+ expect(metaFor(head.metas, "apple-mobile-web-app-title")?.content).toBe("Clay Studio");
172
+ });
173
+
174
+ it("keeps an explicit PWA name ahead of the SEO site name", () => {
175
+ const head = buildPwaHead({ siteConfig: siteConfig({ pwa: fullPwa({ shortName: "Nv" }) }), seo: { siteName: "Clay Studio" } });
176
+ expect(metaFor(head.metas, "apple-mobile-web-app-title")?.content).toBe("Nv");
177
+ });
178
+
179
+ it("ignores a blank SEO site name, and no record at all", () => {
180
+ const blank = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: "Ada Potter" }), seo: { siteName: " " } });
181
+ expect(metaFor(blank.metas, "apple-mobile-web-app-title")?.content).toBe("Ada Potter");
182
+ const none = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: "Ada Potter" }), seo: null });
183
+ expect(metaFor(none.metas, "apple-mobile-web-app-title")?.content).toBe("Ada Potter");
184
+ });
168
185
  });
169
186
 
170
187
  describe("buildPwaHead — degradation (never throws)", () => {
package/src/server/pwa.ts CHANGED
@@ -178,6 +178,13 @@ export interface BuildPwaHeadOptions {
178
178
  contentDoc?: ContentDocument | null;
179
179
  /** Where the manifest route lives (default `/manifest.webmanifest`). */
180
180
  manifestHref?: string;
181
+ /**
182
+ * The website's SEO record, as the root loader returned it. Its `siteName` is
183
+ * what the creator calls the site in search, so the Home Screen label uses it
184
+ * ahead of the site config's name (the profile's). A PWA name the creator set
185
+ * explicitly still wins: that is the app's own label.
186
+ */
187
+ seo?: { siteName?: string | null } | null;
181
188
  }
182
189
 
183
190
  /** A TanStack `head()`-shaped link entry. */
@@ -219,7 +226,15 @@ export function buildPwaHead(opts: BuildPwaHeadOptions): PwaHead {
219
226
  { name: "mobile-web-app-capable", content: "yes" },
220
227
  { name: "apple-mobile-web-app-capable", content: "yes" },
221
228
  { name: "apple-mobile-web-app-status-bar-style", content: "default" },
222
- { name: "apple-mobile-web-app-title", content: siteConfig?.pwa?.shortName || siteConfig?.pwa?.name || siteConfig?.siteName || "App" },
229
+ {
230
+ name: "apple-mobile-web-app-title",
231
+ content:
232
+ siteConfig?.pwa?.shortName ||
233
+ siteConfig?.pwa?.name ||
234
+ opts.seo?.siteName?.trim() ||
235
+ siteConfig?.siteName ||
236
+ "App",
237
+ },
223
238
  ];
224
239
  return { links, metas };
225
240
  }
@@ -2,11 +2,17 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  buildBlogPostingSchema,
4
4
  buildBreadcrumbSchema,
5
+ buildCourseSchema,
5
6
  buildEventSchema,
7
+ buildFilmSchema,
6
8
  buildIdentitySchema,
7
9
  buildPodcastEpisodeSchema,
8
10
  buildPodcastSeriesSchema,
11
+ buildProductSchema,
12
+ buildServiceSchema,
9
13
  buildWebSiteSchema,
14
+ seoBrandName,
15
+ withSeoBrand,
10
16
  type EventSchemaSource,
11
17
  } from "../structuredData";
12
18
 
@@ -245,3 +251,33 @@ describe("site schemas", () => {
245
251
  expect(buildBreadcrumbSchema([{ name: "Clay", url: "https://clay.com/" }])).toBeNull();
246
252
  });
247
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
+ });
@@ -299,6 +299,30 @@ export function seoContextFromSiteConfig(
299
299
  return { currency: config.currency || undefined, brand: config.siteName || undefined };
300
300
  }
301
301
 
302
+ /**
303
+ * The name structured data gives the business behind a page: the SEO
304
+ * identity's name when the creator set one, else the SEO site name, else
305
+ * `fallback` (the site config's name, which is the profile's).
306
+ *
307
+ * One rule for every node that names the business (Product `brand`, Course,
308
+ * Service and Film `provider`, Event `organizer`), so a product page and an
309
+ * event page never disagree about who sells them.
310
+ */
311
+ export function seoBrandName(
312
+ seo?: { identity?: { name?: string | null } | null; siteName?: string | null } | null,
313
+ fallback?: string | null,
314
+ ): string | undefined {
315
+ return seo?.identity?.name?.trim() || seo?.siteName?.trim() || fallback?.trim() || undefined;
316
+ }
317
+
318
+ /** A route's `SiteSeoContext` with its `brand` named by the SEO record (see `seoBrandName`). */
319
+ export function withSeoBrand(
320
+ context: SiteSeoContext | null | undefined,
321
+ seo?: { identity?: { name?: string | null } | null; siteName?: string | null } | null,
322
+ ): SiteSeoContext {
323
+ return { ...context, brand: seoBrandName(seo, context?.brand) };
324
+ }
325
+
302
326
  /**
303
327
  * The offer for a product, derived from its variants.
304
328
  *