@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 +1 -1
- package/src/runtime/pages.ts +2 -0
- package/src/seo/_tests/seo.spec.ts +202 -6
- package/src/seo/applySeoToHead.ts +7 -1
- package/src/seo/index.ts +1 -0
- package/src/seo/markdown.ts +76 -0
- package/src/seo/resolve.ts +46 -3
- package/src/seo/seoHead.ts +6 -0
- package/src/seo/siteSeoHead.ts +24 -7
- package/src/seo/types.ts +15 -1
- package/src/server/_tests/buildPwaHead.spec.ts +18 -1
- package/src/server/pwa.ts +16 -1
- package/src/utils/_tests/structuredDataSeo.spec.ts +36 -0
- package/src/utils/structuredData.ts +24 -0
package/package.json
CHANGED
package/src/runtime/pages.ts
CHANGED
|
@@ -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([
|
|
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([
|
|
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(
|
|
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([
|
|
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(
|
|
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
|
@@ -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
|
+
}
|
package/src/seo/resolve.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 =
|
package/src/seo/seoHead.ts
CHANGED
|
@@ -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
|
}
|
package/src/seo/siteSeoHead.ts
CHANGED
|
@@ -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
|
|
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[] =
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
{
|
|
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
|
*
|