@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.
@@ -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
+ }
@@ -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)", () => {