@tribe-nest/forge 3.61.0 → 3.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/index.ts +3 -0
- package/src/runtime/pages.ts +2 -0
- package/src/seo/_tests/seo.spec.ts +522 -0
- package/src/seo/applySeoToHead.ts +104 -0
- package/src/seo/index.ts +10 -0
- package/src/seo/markdown.ts +76 -0
- package/src/seo/resolve.ts +254 -0
- package/src/seo/seoHead.ts +67 -0
- package/src/seo/siteSeoHead.ts +114 -0
- package/src/seo/types.ts +113 -0
- package/src/server/_tests/buildPwaHead.spec.ts +18 -1
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/server/pwa.ts +16 -1
- package/src/ui/analytics/ForgeAnalytics.tsx +70 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +283 -0
- package/src/utils/landing.ts +17 -4
- package/src/utils/structuredData.ts +363 -1
package/src/seo/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// SEO for code sites and hosted pages: code declares the defaults, the
|
|
2
|
+
// creator's approved edits live in the SEO record, and these functions resolve
|
|
3
|
+
// one over the other. Pure, React-free, so it is safe on the server entry and in
|
|
4
|
+
// a route's head().
|
|
5
|
+
export * from "./types";
|
|
6
|
+
export * from "./resolve";
|
|
7
|
+
export * from "./seoHead";
|
|
8
|
+
export * from "./siteSeoHead";
|
|
9
|
+
export * from "./applySeoToHead";
|
|
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
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { SeoHeadContext, SeoPageOverride, SeoPageType, SeoRecord } from "./types";
|
|
2
|
+
|
|
3
|
+
// Pure resolution: one place decides what a page's SEO is, so the server render,
|
|
4
|
+
// the hydrated page and an in-site navigation all compute the same tags from
|
|
5
|
+
// the same serialized record.
|
|
6
|
+
//
|
|
7
|
+
// The order, field by field:
|
|
8
|
+
// 1. the creator's approved override for this exact pathname,
|
|
9
|
+
// 2. then the override for the route pattern ("/team/$slug"),
|
|
10
|
+
// 3. then what the code passed in,
|
|
11
|
+
// 4. then the page type's template,
|
|
12
|
+
// 5. then the site defaults.
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_TITLE_TEMPLATE = "{title} | {site}";
|
|
15
|
+
|
|
16
|
+
/** "/about/" and "/about" are one page. The root stays "/". */
|
|
17
|
+
export function normalizePathname(pathname: string | undefined | null): string {
|
|
18
|
+
if (!pathname) return "/";
|
|
19
|
+
const bare = pathname.split(/[?#]/)[0] || "/";
|
|
20
|
+
const withSlash = bare.startsWith("/") ? bare : `/${bare}`;
|
|
21
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") || "/" : withSlash;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Hosted pages render centrally and emit their own canonical. */
|
|
25
|
+
export function isHostedPath(pathname: string): boolean {
|
|
26
|
+
const p = normalizePathname(pathname);
|
|
27
|
+
return p === "/i" || p.startsWith("/i/");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The record a site's root loader returned, read from the root match.
|
|
32
|
+
*
|
|
33
|
+
* `undefined` and `null` both mean "no record": an old backend, a failed fetch,
|
|
34
|
+
* or a site whose root loader predates the field. Every caller degrades to the
|
|
35
|
+
* code's own values in that case.
|
|
36
|
+
*/
|
|
37
|
+
export function seoRecordFromContext(ctx: SeoHeadContext | undefined | null): SeoRecord | null {
|
|
38
|
+
const root = ctx?.matches?.[0]?.loaderData as { seo?: SeoRecord | null } | null | undefined;
|
|
39
|
+
const seo = root?.seo;
|
|
40
|
+
return seo && typeof seo === "object" && typeof seo.siteName === "string" ? seo : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
+
*/
|
|
68
|
+
export function pathnameFromContext(ctx: SeoHeadContext | undefined | null): string {
|
|
69
|
+
if (isNotFoundContext(ctx)) {
|
|
70
|
+
const fromLocation = locationPathname();
|
|
71
|
+
if (fromLocation) return normalizePathname(fromLocation);
|
|
72
|
+
}
|
|
73
|
+
const matches = ctx?.matches ?? [];
|
|
74
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
75
|
+
const pathname = matches[i]?.pathname;
|
|
76
|
+
if (pathname) return normalizePathname(pathname);
|
|
77
|
+
}
|
|
78
|
+
return normalizePathname(ctx?.match?.pathname);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The route pattern of the match whose head() is running ("/team/$slug"). */
|
|
82
|
+
export function routePatternFromContext(ctx: SeoHeadContext | undefined | null): string | undefined {
|
|
83
|
+
const match = ctx?.match;
|
|
84
|
+
const pattern = match?.fullPath ?? match?.routeId;
|
|
85
|
+
return pattern ? normalizePathname(pattern) : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The route pattern of the leaf match, for the root, which renders above it. */
|
|
89
|
+
export function leafRoutePatternFromContext(ctx: SeoHeadContext | undefined | null): string | undefined {
|
|
90
|
+
const matches = ctx?.matches ?? [];
|
|
91
|
+
const leaf = matches[matches.length - 1];
|
|
92
|
+
const pattern = leaf?.fullPath ?? leaf?.routeId;
|
|
93
|
+
return pattern ? normalizePathname(pattern) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const present = <T>(value: T | null | undefined): value is T =>
|
|
97
|
+
value !== null && value !== undefined && !(typeof value === "string" && value.trim() === "");
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The override for one page, merged field by field: the exact pathname wins,
|
|
101
|
+
* the route pattern fills what it leaves unset.
|
|
102
|
+
*/
|
|
103
|
+
export function findOverride(
|
|
104
|
+
seo: SeoRecord | null | undefined,
|
|
105
|
+
pathname: string,
|
|
106
|
+
routePattern?: string,
|
|
107
|
+
): SeoPageOverride | null {
|
|
108
|
+
if (!seo?.pages) return null;
|
|
109
|
+
const exact = seo.pages[normalizePathname(pathname)];
|
|
110
|
+
const pattern = routePattern ? seo.pages[normalizePathname(routePattern)] : undefined;
|
|
111
|
+
if (!exact && !pattern) return null;
|
|
112
|
+
const merged: SeoPageOverride = {};
|
|
113
|
+
for (const key of ["title", "description", "image", "canonical", "noindex"] as const) {
|
|
114
|
+
const value = present(exact?.[key]) ? exact?.[key] : pattern?.[key];
|
|
115
|
+
if (present(value)) (merged as Record<string, unknown>)[key] = value;
|
|
116
|
+
}
|
|
117
|
+
return merged;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Fill `{title}` and `{site}` (or `{site.name}`) in a template. */
|
|
121
|
+
export function fillTemplate(template: string, values: { title: string; site: string }): string {
|
|
122
|
+
return template
|
|
123
|
+
.replace(/\{title\}/g, values.title)
|
|
124
|
+
.replace(/\{site(?:\.name)?\}/g, values.site)
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
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
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The document title for a code title.
|
|
140
|
+
*
|
|
141
|
+
* A template without `{title}` is ignored in favour of the default.
|
|
142
|
+
* The template is skipped when the title already names the site, so a code
|
|
143
|
+
* title of "Clay Studio | Pottery in Hackney" does not become
|
|
144
|
+
* "Clay Studio | Pottery in Hackney | Clay Studio".
|
|
145
|
+
*/
|
|
146
|
+
export function applyTitleTemplate(title: string, siteName: string, template?: string | null): string {
|
|
147
|
+
const site = siteName.trim();
|
|
148
|
+
const base = title.trim();
|
|
149
|
+
if (!site) return base;
|
|
150
|
+
if (!base) return site;
|
|
151
|
+
if (base.toLowerCase().includes(site.toLowerCase())) return base;
|
|
152
|
+
const usable = isUsableTitleTemplate(template?.trim()) ? template!.trim() : DEFAULT_TITLE_TEMPLATE;
|
|
153
|
+
const filled = fillTemplate(usable, { title: base, site });
|
|
154
|
+
return filled || base;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The canonical URL for a page: the override's, else origin + pathname. */
|
|
158
|
+
export function canonicalFor(
|
|
159
|
+
seo: SeoRecord,
|
|
160
|
+
pathname: string,
|
|
161
|
+
override?: SeoPageOverride | null,
|
|
162
|
+
options?: { entity?: boolean },
|
|
163
|
+
): string {
|
|
164
|
+
if (present(override?.canonical)) return override!.canonical!;
|
|
165
|
+
const origin = (options?.entity ? seo.defaultWebsiteOrigin || seo.origin : seo.origin).replace(/\/+$/, "");
|
|
166
|
+
const path = normalizePathname(pathname);
|
|
167
|
+
return path === "/" ? `${origin}/` : `${origin}${path}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type ResolvedPageSeo = {
|
|
171
|
+
/** The document title, template applied. */
|
|
172
|
+
title: string;
|
|
173
|
+
/** The title for share cards, without the site suffix. */
|
|
174
|
+
shareTitle: string;
|
|
175
|
+
description: string;
|
|
176
|
+
image?: string;
|
|
177
|
+
noindex: boolean;
|
|
178
|
+
/** Null when there is no record, so no canonical can be stated. */
|
|
179
|
+
canonical: string | null;
|
|
180
|
+
/** True when a creator override supplied at least one field. */
|
|
181
|
+
overridden: boolean;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export type ResolveInput = {
|
|
185
|
+
pathname: string;
|
|
186
|
+
routePattern?: string;
|
|
187
|
+
type?: SeoPageType;
|
|
188
|
+
title?: string;
|
|
189
|
+
description?: string;
|
|
190
|
+
image?: string;
|
|
191
|
+
noindex?: boolean;
|
|
192
|
+
/** Entity pages canonicalize to the profile's default website. */
|
|
193
|
+
entity?: boolean;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export function resolvePageSeo(seo: SeoRecord | null | undefined, input: ResolveInput): ResolvedPageSeo {
|
|
197
|
+
const codeTitle = input.title?.trim() ?? "";
|
|
198
|
+
|
|
199
|
+
if (!seo) {
|
|
200
|
+
// No record: today's behaviour, exactly. The code's values stand as given.
|
|
201
|
+
const title = codeTitle;
|
|
202
|
+
return {
|
|
203
|
+
title,
|
|
204
|
+
shareTitle: title,
|
|
205
|
+
description: input.description || title,
|
|
206
|
+
image: input.image || undefined,
|
|
207
|
+
noindex: !!input.noindex,
|
|
208
|
+
canonical: null,
|
|
209
|
+
overridden: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const override = findOverride(seo, input.pathname, input.routePattern);
|
|
214
|
+
const typeTemplate = input.type ? seo.typeTemplates?.[input.type] : undefined;
|
|
215
|
+
const site = seo.siteName ?? "";
|
|
216
|
+
|
|
217
|
+
let title: string;
|
|
218
|
+
let shareTitle: string;
|
|
219
|
+
if (present(override?.title)) {
|
|
220
|
+
// An approved title is what the creator signed off on, so it is used
|
|
221
|
+
// verbatim: no template, no suffix.
|
|
222
|
+
title = override!.title!.trim();
|
|
223
|
+
shareTitle = title;
|
|
224
|
+
} else {
|
|
225
|
+
shareTitle = codeTitle || site;
|
|
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);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const description =
|
|
233
|
+
(present(override?.description) ? override!.description! : undefined) ||
|
|
234
|
+
input.description ||
|
|
235
|
+
(typeTemplate?.description ? fillTemplate(typeTemplate.description, { title: shareTitle, site }) : undefined) ||
|
|
236
|
+
seo.defaultDescription ||
|
|
237
|
+
shareTitle;
|
|
238
|
+
|
|
239
|
+
const image = (present(override?.image) ? override!.image! : undefined) || input.image || seo.defaultImage || undefined;
|
|
240
|
+
|
|
241
|
+
const noindex =
|
|
242
|
+
seo.indexing === false ||
|
|
243
|
+
(typeof override?.noindex === "boolean" ? override.noindex : !!input.noindex);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
title,
|
|
247
|
+
shareTitle,
|
|
248
|
+
description,
|
|
249
|
+
image,
|
|
250
|
+
noindex,
|
|
251
|
+
canonical: canonicalFor(seo, input.pathname, override, { entity: input.entity }),
|
|
252
|
+
overridden: !!override && Object.keys(override).length > 0,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isNotFoundContext,
|
|
3
|
+
pathnameFromContext,
|
|
4
|
+
resolvePageSeo,
|
|
5
|
+
routePatternFromContext,
|
|
6
|
+
seoRecordFromContext,
|
|
7
|
+
type ResolvedPageSeo,
|
|
8
|
+
} from "./resolve";
|
|
9
|
+
import type { SeoHeadContext, SeoHeadInput, SeoMetaEntry } from "./types";
|
|
10
|
+
|
|
11
|
+
/** JSON-LD nodes as head() meta entries. TanStack renders each as a script tag. */
|
|
12
|
+
export function jsonLdMeta(jsonLd?: object | object[] | null): SeoMetaEntry[] {
|
|
13
|
+
const nodes = Array.isArray(jsonLd) ? jsonLd : jsonLd ? [jsonLd] : [];
|
|
14
|
+
return nodes.filter((n) => !!n && typeof n === "object").map((n) => ({ "script:ld+json": n }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The per-page share and robots tags for one resolved page. */
|
|
18
|
+
export function pageMeta(resolved: ResolvedPageSeo, ogType = "website"): SeoMetaEntry[] {
|
|
19
|
+
const { title, shareTitle, description, image, noindex, canonical } = resolved;
|
|
20
|
+
return [
|
|
21
|
+
{ title },
|
|
22
|
+
{ name: "description", content: description },
|
|
23
|
+
{ property: "og:title", content: shareTitle },
|
|
24
|
+
{ property: "og:description", content: description },
|
|
25
|
+
{ property: "og:type", content: ogType },
|
|
26
|
+
...(canonical ? [{ property: "og:url", content: canonical }] : []),
|
|
27
|
+
...(image ? [{ property: "og:image", content: image }] : []),
|
|
28
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
29
|
+
{ name: "twitter:title", content: shareTitle },
|
|
30
|
+
{ name: "twitter:description", content: description },
|
|
31
|
+
...(image ? [{ name: "twitter:image", content: image }] : []),
|
|
32
|
+
...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A route's SEO, declared in code with the creator's edits resolved over it.
|
|
38
|
+
*
|
|
39
|
+
* head: (ctx) => seoHead(ctx, { title: "About Clay Studio", description: "..." })
|
|
40
|
+
*
|
|
41
|
+
* Reads the site's SEO record from the root match (the root loader fetched it
|
|
42
|
+
* with `fetchSiteBootstrap`) and returns `{ meta }` ONLY. Never links: TanStack
|
|
43
|
+
* concatenates links across every match, so a canonical here would sit beside
|
|
44
|
+
* the root's and the page would carry two. The root's `siteSeoHead` is the one
|
|
45
|
+
* place a canonical comes from.
|
|
46
|
+
*
|
|
47
|
+
* With no record (an older backend, or a failed fetch) it emits what
|
|
48
|
+
* `buildHeadMeta` always did, minus the links.
|
|
49
|
+
*/
|
|
50
|
+
export function seoHead(ctx: SeoHeadContext | undefined | null, input: SeoHeadInput): { meta: SeoMetaEntry[] } {
|
|
51
|
+
const seo = seoRecordFromContext(ctx);
|
|
52
|
+
const resolved = resolvePageSeo(seo, {
|
|
53
|
+
pathname: pathnameFromContext(ctx),
|
|
54
|
+
routePattern: routePatternFromContext(ctx),
|
|
55
|
+
type: input.type ?? "page",
|
|
56
|
+
title: input.title,
|
|
57
|
+
description: input.description,
|
|
58
|
+
image: input.image,
|
|
59
|
+
noindex: input.noindex,
|
|
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
|
+
}
|
|
66
|
+
return { meta: [...pageMeta(resolved), ...jsonLdMeta(input.jsonLd)] };
|
|
67
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { buildIdentitySchema, buildWebSiteSchema, identitySchemaId } from "../utils/structuredData";
|
|
2
|
+
import {
|
|
3
|
+
canonicalFor,
|
|
4
|
+
findOverride,
|
|
5
|
+
isHostedPath,
|
|
6
|
+
isNotFoundContext,
|
|
7
|
+
leafRoutePatternFromContext,
|
|
8
|
+
pathnameFromContext,
|
|
9
|
+
seoRecordFromContext,
|
|
10
|
+
} from "./resolve";
|
|
11
|
+
import { markdownAlternateLink } from "./markdown";
|
|
12
|
+
import { jsonLdMeta } from "./seoHead";
|
|
13
|
+
import type { SeoHeadContext, SeoLinkEntry, SeoMetaEntry } from "./types";
|
|
14
|
+
|
|
15
|
+
export type SiteSeoFallback = {
|
|
16
|
+
/** Used when there is no SEO record, e.g. the site config's name. */
|
|
17
|
+
siteName?: string | null;
|
|
18
|
+
description?: string | null;
|
|
19
|
+
image?: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Site-wide head tags, for the ROOT route's `head()`.
|
|
24
|
+
*
|
|
25
|
+
* head: (ctx) => siteSeoHead(ctx)
|
|
26
|
+
*
|
|
27
|
+
* Every tag here is a fallback a page can replace (TanStack keeps the deepest
|
|
28
|
+
* title and the deepest meta of each name), except two things only the root
|
|
29
|
+
* may emit:
|
|
30
|
+
*
|
|
31
|
+
* - the canonical link, because links are concatenated across matches rather
|
|
32
|
+
* than replaced. It is skipped under `/i/`, where the hosted page emits its
|
|
33
|
+
* own (an entity's canonical is on the default website, which only that page
|
|
34
|
+
* knows to say);
|
|
35
|
+
* - the WebSite and identity JSON-LD, on "/" only;
|
|
36
|
+
* - the `alternate` link to the page's Markdown copy, beside the canonical.
|
|
37
|
+
*
|
|
38
|
+
* `robots` is set here when the whole site is noindex or this path's override
|
|
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.
|
|
45
|
+
*/
|
|
46
|
+
export function siteSeoHead(
|
|
47
|
+
ctx: SeoHeadContext | undefined | null,
|
|
48
|
+
fallback: SiteSeoFallback = {},
|
|
49
|
+
): { meta: SeoMetaEntry[]; links: SeoLinkEntry[] } {
|
|
50
|
+
const seo = seoRecordFromContext(ctx);
|
|
51
|
+
const pathname = pathnameFromContext(ctx);
|
|
52
|
+
|
|
53
|
+
if (!seo) {
|
|
54
|
+
const name = fallback.siteName?.trim();
|
|
55
|
+
return {
|
|
56
|
+
meta: [
|
|
57
|
+
...(name ? [{ title: name }, { property: "og:site_name", content: name }] : []),
|
|
58
|
+
...(fallback.description ? [{ name: "description", content: fallback.description }] : []),
|
|
59
|
+
...(fallback.image ? [{ property: "og:image", content: fallback.image }] : []),
|
|
60
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
61
|
+
],
|
|
62
|
+
links: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
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));
|
|
70
|
+
const description = seo.defaultDescription || fallback.description || undefined;
|
|
71
|
+
const image = seo.defaultImage || fallback.image || undefined;
|
|
72
|
+
const noindex = notFound || seo.indexing === false || override?.noindex === true;
|
|
73
|
+
|
|
74
|
+
const meta: SeoMetaEntry[] = [
|
|
75
|
+
{ title: seo.siteName },
|
|
76
|
+
...(description ? [{ name: "description", content: description }] : []),
|
|
77
|
+
{ property: "og:site_name", content: seo.siteName },
|
|
78
|
+
...(image ? [{ property: "og:image", content: image }] : []),
|
|
79
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
80
|
+
...(seo.verification?.google ? [{ name: "google-site-verification", content: seo.verification.google }] : []),
|
|
81
|
+
...(seo.verification?.bing ? [{ name: "msvalidate.01", content: seo.verification.bing }] : []),
|
|
82
|
+
...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
if (pathname === "/" && !notFound) {
|
|
86
|
+
const origin = seo.origin.replace(/\/+$/, "");
|
|
87
|
+
const identity = buildIdentitySchema(seo.identity, { origin });
|
|
88
|
+
meta.push(
|
|
89
|
+
...jsonLdMeta(
|
|
90
|
+
[
|
|
91
|
+
buildWebSiteSchema({
|
|
92
|
+
name: seo.siteName,
|
|
93
|
+
url: `${origin}/`,
|
|
94
|
+
description: seo.defaultDescription,
|
|
95
|
+
publisherId: identity ? identitySchemaId(origin) : null,
|
|
96
|
+
}),
|
|
97
|
+
identity,
|
|
98
|
+
].filter((n): n is Record<string, unknown> => !!n),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
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
|
+
}
|
|
112
|
+
|
|
113
|
+
return { meta, links };
|
|
114
|
+
}
|
package/src/seo/types.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The SEO record, as `GET /public/websites/seo` returns it.
|
|
2
|
+
//
|
|
3
|
+
// The backend owns the data (settings, templates, the creator's approved page
|
|
4
|
+
// overrides); code owns the defaults. These types are the seam between the two,
|
|
5
|
+
// and the backend returns exactly this shape. Change one side, change both.
|
|
6
|
+
|
|
7
|
+
export type SeoPageType =
|
|
8
|
+
| "page"
|
|
9
|
+
| "product"
|
|
10
|
+
| "event"
|
|
11
|
+
| "course"
|
|
12
|
+
| "coaching"
|
|
13
|
+
| "blog_post"
|
|
14
|
+
| "blog_category"
|
|
15
|
+
| "podcast"
|
|
16
|
+
| "podcast_episode"
|
|
17
|
+
| "community"
|
|
18
|
+
| "series"
|
|
19
|
+
| "film";
|
|
20
|
+
|
|
21
|
+
export type SeoPageOverride = {
|
|
22
|
+
title?: string | null;
|
|
23
|
+
description?: string | null;
|
|
24
|
+
image?: string | null;
|
|
25
|
+
/** Absolute URL. */
|
|
26
|
+
canonical?: string | null;
|
|
27
|
+
noindex?: boolean | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SeoIdentity = {
|
|
31
|
+
kind: "person" | "organization" | "local_business";
|
|
32
|
+
name: string;
|
|
33
|
+
logo?: string | null;
|
|
34
|
+
sameAs: string[];
|
|
35
|
+
address?: { street?: string; locality?: string; region?: string; postalCode?: string; country?: string } | null;
|
|
36
|
+
phone?: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SeoRecord = {
|
|
40
|
+
websiteId: string;
|
|
41
|
+
/** "https://claystudio.com": the primary verified custom domain, else https://{subdomain}.{ROOT_DOMAIN}. */
|
|
42
|
+
origin: string;
|
|
43
|
+
isDefaultWebsite: boolean;
|
|
44
|
+
/** Origin of the profile's default website. Entity canonicals point here. */
|
|
45
|
+
defaultWebsiteOrigin: string;
|
|
46
|
+
siteName: string;
|
|
47
|
+
defaultDescription: string | null;
|
|
48
|
+
defaultImage: string | null;
|
|
49
|
+
/** Default "{title} | {site}". */
|
|
50
|
+
titleTemplate: string;
|
|
51
|
+
typeTemplates: Partial<Record<SeoPageType, { title?: string; description?: string }>>;
|
|
52
|
+
identity: SeoIdentity | null;
|
|
53
|
+
verification: { google?: string | null; bing?: string | null };
|
|
54
|
+
/** false = the whole site is noindex. */
|
|
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;
|
|
62
|
+
/**
|
|
63
|
+
* Key = exact pathname ("/about", "/i/store/mug") OR route pattern
|
|
64
|
+
* ("/team/$slug"). Entity overrides arrive already expanded to their /i/
|
|
65
|
+
* pathname.
|
|
66
|
+
*/
|
|
67
|
+
pages: Record<string, SeoPageOverride>;
|
|
68
|
+
/** Max updatedAt of the inputs, ISO. */
|
|
69
|
+
version: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** What a route's code says its SEO is, before any creator edit. */
|
|
73
|
+
export type SeoHeadInput = {
|
|
74
|
+
title: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
image?: string;
|
|
77
|
+
noindex?: boolean;
|
|
78
|
+
type?: SeoPageType;
|
|
79
|
+
jsonLd?: object | object[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The part of TanStack's `head()` context Forge reads.
|
|
84
|
+
*
|
|
85
|
+
* Structural rather than imported, so Forge does not pin a router version and a
|
|
86
|
+
* site can pass `ctx` straight through.
|
|
87
|
+
*/
|
|
88
|
+
export type SeoMatchLike = {
|
|
89
|
+
pathname?: string;
|
|
90
|
+
routeId?: string;
|
|
91
|
+
fullPath?: string;
|
|
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;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type SeoHeadContext = {
|
|
104
|
+
matches?: ReadonlyArray<SeoMatchLike | undefined>;
|
|
105
|
+
match?: SeoMatchLike;
|
|
106
|
+
params?: Record<string, string>;
|
|
107
|
+
loaderData?: unknown;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** One `head()` meta entry. A JSON-LD node rides as `{ "script:ld+json": obj }`. */
|
|
111
|
+
export type SeoMetaEntry = Record<string, unknown>;
|
|
112
|
+
|
|
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)", () => {
|