@businessdash/sdk 0.9.60 → 0.9.80
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/README.md +991 -295
- package/assets/biab-sw.js +92 -0
- package/dist/account-core/index.d.ts +126 -0
- package/dist/account-core/index.d.ts.map +1 -0
- package/dist/account-core/index.js +191 -0
- package/dist/account-core/index.js.map +1 -0
- package/dist/account.d.ts +11 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +11 -0
- package/dist/account.js.map +1 -0
- package/dist/auth-handler.d.ts +1 -1
- package/dist/auth-links.d.ts +33 -0
- package/dist/auth-links.d.ts.map +1 -0
- package/dist/auth-links.js +64 -0
- package/dist/auth-links.js.map +1 -0
- package/dist/blog-core/index.d.ts +112 -0
- package/dist/blog-core/index.d.ts.map +1 -0
- package/dist/blog-core/index.js +330 -0
- package/dist/blog-core/index.js.map +1 -0
- package/dist/blog.d.ts +11 -0
- package/dist/blog.d.ts.map +1 -0
- package/dist/blog.js +11 -0
- package/dist/blog.js.map +1 -0
- package/dist/chat-core/index.d.ts +97 -0
- package/dist/chat-core/index.d.ts.map +1 -0
- package/dist/chat-core/index.js +237 -0
- package/dist/chat-core/index.js.map +1 -0
- package/dist/chat.d.ts +11 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +11 -0
- package/dist/chat.js.map +1 -0
- package/dist/checkout-core/index.d.ts +65 -0
- package/dist/checkout-core/index.d.ts.map +1 -0
- package/dist/checkout-core/index.js +159 -0
- package/dist/checkout-core/index.js.map +1 -0
- package/dist/checkout.d.ts +10 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +10 -0
- package/dist/checkout.js.map +1 -0
- package/dist/cli.js +133 -25
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +622 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +888 -1
- package/dist/client.js.map +1 -1
- package/dist/consent-core/index.cjs +107 -0
- package/dist/consent-core/index.d.ts +103 -0
- package/dist/consent-core/index.d.ts.map +1 -0
- package/dist/consent-core/index.js +157 -0
- package/dist/consent-core/index.js.map +1 -0
- package/dist/contracts.d.ts +3526 -767
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +571 -4
- package/dist/contracts.js.map +1 -1
- package/dist/data-model-schema.d.ts +1 -1
- package/dist/data-model-schema.d.ts.map +1 -1
- package/dist/element.d.ts +30 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +56 -0
- package/dist/element.js.map +1 -1
- package/dist/embed-protocol.d.ts +86 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +60 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/framework-support.d.ts +122 -0
- package/dist/framework-support.d.ts.map +1 -0
- package/dist/framework-support.js +323 -0
- package/dist/framework-support.js.map +1 -0
- package/dist/index.cjs +1355 -24
- package/dist/legal-core/index.cjs +115 -0
- package/dist/legal-core/index.d.ts +160 -0
- package/dist/legal-core/index.d.ts.map +1 -0
- package/dist/legal-core/index.js +220 -0
- package/dist/legal-core/index.js.map +1 -0
- package/dist/marketing-core/index.d.ts +66 -0
- package/dist/marketing-core/index.d.ts.map +1 -0
- package/dist/marketing-core/index.js +154 -0
- package/dist/marketing-core/index.js.map +1 -0
- package/dist/marketing.d.ts +7 -0
- package/dist/marketing.d.ts.map +1 -0
- package/dist/marketing.js +7 -0
- package/dist/marketing.js.map +1 -0
- package/dist/mcp-adapters/index.d.ts +135 -0
- package/dist/mcp-adapters/index.d.ts.map +1 -0
- package/dist/mcp-adapters/index.js +187 -0
- package/dist/mcp-adapters/index.js.map +1 -0
- package/dist/mcp-adapters.d.ts +7 -0
- package/dist/mcp-adapters.d.ts.map +1 -0
- package/dist/mcp-adapters.js +7 -0
- package/dist/mcp-adapters.js.map +1 -0
- package/dist/model3d-core/index.cjs +95 -0
- package/dist/model3d-core/index.d.ts +167 -0
- package/dist/model3d-core/index.d.ts.map +1 -0
- package/dist/model3d-core/index.js +158 -0
- package/dist/model3d-core/index.js.map +1 -0
- package/dist/notifications-core/index.cjs +315 -0
- package/dist/notifications-core/index.d.ts +229 -0
- package/dist/notifications-core/index.d.ts.map +1 -0
- package/dist/notifications-core/index.js +445 -0
- package/dist/notifications-core/index.js.map +1 -0
- package/dist/portal-core/index.d.ts +451 -0
- package/dist/portal-core/index.d.ts.map +1 -0
- package/dist/portal-core/index.js +819 -0
- package/dist/portal-core/index.js.map +1 -0
- package/dist/portal.d.ts +7 -0
- package/dist/portal.d.ts.map +1 -0
- package/dist/portal.js +7 -0
- package/dist/portal.js.map +1 -0
- package/dist/push-core/index.cjs +171 -0
- package/dist/push-core/index.d.ts +149 -0
- package/dist/push-core/index.d.ts.map +1 -0
- package/dist/push-core/index.js +231 -0
- package/dist/push-core/index.js.map +1 -0
- package/dist/react-marketing.d.ts +1 -17
- package/dist/react-marketing.d.ts.map +1 -1
- package/dist/react-marketing.js +4 -0
- package/dist/react-marketing.js.map +1 -1
- package/dist/react.d.ts +294 -101
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +302 -246
- package/dist/react.js.map +1 -1
- package/dist/seed-core/index.d.ts +354 -0
- package/dist/seed-core/index.d.ts.map +1 -0
- package/dist/seed-core/index.js +420 -0
- package/dist/seed-core/index.js.map +1 -0
- package/dist/seed.d.ts +7 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +7 -0
- package/dist/seed.js.map +1 -0
- package/dist/seo-core/blog.d.ts +94 -0
- package/dist/seo-core/blog.d.ts.map +1 -0
- package/dist/seo-core/blog.js +149 -0
- package/dist/seo-core/blog.js.map +1 -0
- package/dist/seo-core/commerce.d.ts +64 -0
- package/dist/seo-core/commerce.d.ts.map +1 -0
- package/dist/seo-core/commerce.js +87 -0
- package/dist/seo-core/commerce.js.map +1 -0
- package/dist/seo-core/index.cjs +751 -0
- package/dist/seo-core/index.d.ts +226 -0
- package/dist/seo-core/index.d.ts.map +1 -0
- package/dist/seo-core/index.js +455 -0
- package/dist/seo-core/index.js.map +1 -0
- package/dist/seo-core/local.d.ts +181 -0
- package/dist/seo-core/local.d.ts.map +1 -0
- package/dist/seo-core/local.js +193 -0
- package/dist/seo-core/local.js.map +1 -0
- package/dist/sitemap-core/index.cjs +167 -0
- package/dist/sitemap-core/index.d.ts +155 -0
- package/dist/sitemap-core/index.d.ts.map +1 -0
- package/dist/sitemap-core/index.js +195 -0
- package/dist/sitemap-core/index.js.map +1 -0
- package/dist/social-core/index.d.ts +74 -0
- package/dist/social-core/index.d.ts.map +1 -0
- package/dist/social-core/index.js +72 -0
- package/dist/social-core/index.js.map +1 -0
- package/dist/social.d.ts +7 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +7 -0
- package/dist/social.js.map +1 -0
- package/dist/solid-compat.d.ts +204 -0
- package/dist/solid-compat.d.ts.map +1 -0
- package/dist/solid-compat.js +221 -0
- package/dist/solid-compat.js.map +1 -0
- package/dist/store-core/index.d.ts +141 -0
- package/dist/store-core/index.d.ts.map +1 -0
- package/dist/store-core/index.js +277 -0
- package/dist/store-core/index.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +12 -0
- package/dist/store.js.map +1 -0
- package/dist/vanilla.d.ts +111 -0
- package/dist/vanilla.d.ts.map +1 -1
- package/dist/vanilla.js +206 -0
- package/dist/vanilla.js.map +1 -1
- package/dist/verify-core/index.d.ts +98 -0
- package/dist/verify-core/index.d.ts.map +1 -0
- package/dist/verify-core/index.js +186 -0
- package/dist/verify-core/index.js.map +1 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +8 -0
- package/dist/verify.js.map +1 -0
- package/openapi.json +4516 -1311
- package/package.json +128 -11
- package/src/account-core/index.ts +309 -0
- package/src/account.ts +11 -0
- package/src/angular/headless.ts +519 -0
- package/src/angular/index.ts +3 -0
- package/src/auth-links.ts +76 -0
- package/src/blog-core/index.ts +423 -0
- package/src/blog.ts +11 -0
- package/src/chat-core/index.ts +335 -0
- package/src/chat.ts +11 -0
- package/src/checkout-core/index.ts +225 -0
- package/src/checkout.ts +10 -0
- package/src/cli.ts +184 -28
- package/src/client.ts +1097 -0
- package/src/consent-core/index.ts +213 -0
- package/src/contracts.ts +768 -11
- package/src/data-model-schema.ts +1 -1
- package/src/element.ts +102 -0
- package/src/embed-protocol.ts +100 -0
- package/src/framework-support.ts +440 -0
- package/src/legal-core/index.ts +276 -0
- package/src/marketing-core/index.ts +209 -0
- package/src/marketing.ts +7 -0
- package/src/mcp-adapters/index.ts +264 -0
- package/src/mcp-adapters.ts +7 -0
- package/src/model3d-core/index.ts +248 -0
- package/src/notifications-core/index.ts +650 -0
- package/src/portal-core/index.ts +1564 -0
- package/src/portal.ts +7 -0
- package/src/push-core/index.ts +335 -0
- package/src/qwik.tsx +602 -2
- package/src/react-marketing.tsx +5 -0
- package/src/react.tsx +434 -319
- package/src/seed-core/index.ts +735 -0
- package/src/seed.ts +7 -0
- package/src/seo-core/blog.ts +216 -0
- package/src/seo-core/commerce.ts +138 -0
- package/src/seo-core/index.ts +605 -0
- package/src/seo-core/local.ts +333 -0
- package/src/sitemap-core/index.ts +296 -0
- package/src/social-core/index.ts +103 -0
- package/src/social.ts +7 -0
- package/src/solid-compat.tsx +347 -0
- package/src/solid-surfaces.tsx +1311 -0
- package/src/solid.tsx +82 -60
- package/src/store-core/index.ts +426 -0
- package/src/store.ts +12 -0
- package/src/svelte/SocialLinks.svelte +84 -0
- package/src/svelte/headless.ts +435 -0
- package/src/svelte/index.ts +9 -0
- package/src/vanilla.ts +367 -0
- package/src/verify-core/index.ts +288 -0
- package/src/verify.ts +8 -0
- package/src/vue/headless.ts +527 -0
- package/src/vue/index.ts +3 -0
package/src/seed.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO for blog posts and blog listings.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a post needs more than a title
|
|
5
|
+
*
|
|
6
|
+
* A `BlogPosting` node is what turns a link in search results into a dated,
|
|
7
|
+
* attributed article card. Without it a post competes as a bare URL, and the
|
|
8
|
+
* publish date — the thing readers use to decide whether it is still true —
|
|
9
|
+
* never appears.
|
|
10
|
+
*
|
|
11
|
+
* ## Gated posts stay indexed, and say so
|
|
12
|
+
*
|
|
13
|
+
* A subscriber-only post should still be findable: being discoverable is how
|
|
14
|
+
* anyone subscribes. But serving a full article to a crawler and a paywall to
|
|
15
|
+
* a reader is **cloaking**, which is penalised.
|
|
16
|
+
*
|
|
17
|
+
* The correct answer is schema.org's paywall markup — `isAccessibleForFree:
|
|
18
|
+
* false` plus a `hasPart` naming the gated region. That tells a crawler the
|
|
19
|
+
* content is restricted, so the difference between what it sees and what a
|
|
20
|
+
* reader sees is declared rather than deceptive.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { article, breadcrumb, type JsonLdNode } from "../seo.js";
|
|
26
|
+
|
|
27
|
+
import type { MetaTag, SeoInput } from "./index.js";
|
|
28
|
+
|
|
29
|
+
/** A blog post, as it arrives from the platform. */
|
|
30
|
+
export type BlogPostSeoSource = {
|
|
31
|
+
title: string;
|
|
32
|
+
slug: string;
|
|
33
|
+
excerpt?: string | null;
|
|
34
|
+
imageUrl?: string | null;
|
|
35
|
+
publishedAt?: string | null;
|
|
36
|
+
updatedAt?: string | null;
|
|
37
|
+
authorName?: string | null;
|
|
38
|
+
tags?: string[];
|
|
39
|
+
/**
|
|
40
|
+
* `public` is open. Anything else is gated — the platform writes both
|
|
41
|
+
* `members`/`subscribers` and `followers`/`paid` into this column, so
|
|
42
|
+
* anything that is not `public` is treated as restricted.
|
|
43
|
+
*/
|
|
44
|
+
accessLevel?: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type BlogPostSeoInput = {
|
|
48
|
+
siteUrl: string;
|
|
49
|
+
post: BlogPostSeoSource;
|
|
50
|
+
/** Path of this post, e.g. `/blog/my-post`. Defaults to `/blog/{slug}`. */
|
|
51
|
+
path?: string;
|
|
52
|
+
/** Extra crumbs between Home and the post. */
|
|
53
|
+
breadcrumbs?: Array<{ name: string; url: string }>;
|
|
54
|
+
/**
|
|
55
|
+
* CSS selector wrapping the gated body, for the paywall markup.
|
|
56
|
+
*
|
|
57
|
+
* Defaults to `.biab-post-body`. It only has to match the element your
|
|
58
|
+
* template actually hides — a selector that matches nothing makes the
|
|
59
|
+
* markup a claim you are not backing up.
|
|
60
|
+
*/
|
|
61
|
+
paywallSelector?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function isGated(accessLevel: string | null | undefined): boolean {
|
|
65
|
+
const value = (accessLevel ?? "public").trim();
|
|
66
|
+
// Anything unrecognised counts as gated. Being wrong the other way means
|
|
67
|
+
// declaring open access for content that is actually restricted, which is
|
|
68
|
+
// the cloaking case.
|
|
69
|
+
return value !== "public" && value !== "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* SEO for one blog post.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* const seo = resolveSeo(layoutDefaults, blogPostSeo({ siteUrl, post }))
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function blogPostSeo(input: BlogPostSeoInput): SeoInput {
|
|
80
|
+
const { post } = input;
|
|
81
|
+
const path = input.path ?? `/blog/${post.slug}`;
|
|
82
|
+
const canonical = `${input.siteUrl.replace(/\/+$/, "")}${path}`;
|
|
83
|
+
const gated = isGated(post.accessLevel);
|
|
84
|
+
|
|
85
|
+
const description = post.excerpt?.trim() || undefined;
|
|
86
|
+
|
|
87
|
+
const node = article({
|
|
88
|
+
siteUrl: input.siteUrl,
|
|
89
|
+
headline: post.title,
|
|
90
|
+
description: description ?? post.title,
|
|
91
|
+
slug: post.slug,
|
|
92
|
+
pubDate: post.publishedAt ?? new Date(0).toISOString(),
|
|
93
|
+
...(post.updatedAt ? { updatedDate: post.updatedAt } : {}),
|
|
94
|
+
...(post.authorName ? { authorName: post.authorName } : {}),
|
|
95
|
+
...(post.imageUrl ? { heroImageUrl: post.imageUrl } : {}),
|
|
96
|
+
}) as JsonLdNode;
|
|
97
|
+
|
|
98
|
+
// The post's real URL, which may not be `/blog/{slug}` if the consumer
|
|
99
|
+
// routes differently. `article()` assumes that shape; correct it here
|
|
100
|
+
// rather than shipping a mainEntityOfPage that 404s.
|
|
101
|
+
node["mainEntityOfPage"] = canonical;
|
|
102
|
+
node["url"] = canonical;
|
|
103
|
+
if (post.tags?.length) node["keywords"] = post.tags.join(", ");
|
|
104
|
+
|
|
105
|
+
// Paywall markup — see the note at the top of this module.
|
|
106
|
+
node["isAccessibleForFree"] = !gated;
|
|
107
|
+
if (gated) {
|
|
108
|
+
node["hasPart"] = {
|
|
109
|
+
"@type": "WebPageElement",
|
|
110
|
+
isAccessibleForFree: false,
|
|
111
|
+
cssSelector: input.paywallSelector ?? ".biab-post-body",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const extraMeta: MetaTag[] = [];
|
|
116
|
+
if (post.publishedAt) {
|
|
117
|
+
extraMeta.push({
|
|
118
|
+
property: "article:published_time",
|
|
119
|
+
content: post.publishedAt,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (post.updatedAt) {
|
|
123
|
+
extraMeta.push({
|
|
124
|
+
property: "article:modified_time",
|
|
125
|
+
content: post.updatedAt,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (post.authorName) {
|
|
129
|
+
extraMeta.push({ property: "article:author", content: post.authorName });
|
|
130
|
+
}
|
|
131
|
+
for (const tag of post.tags ?? []) {
|
|
132
|
+
extraMeta.push({ property: "article:tag", content: tag });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
title: post.title,
|
|
137
|
+
...(description ? { description } : {}),
|
|
138
|
+
canonical,
|
|
139
|
+
...(post.imageUrl ? { ogImage: post.imageUrl } : {}),
|
|
140
|
+
// Not "website": social cards for articles show the date and byline.
|
|
141
|
+
ogType: "article",
|
|
142
|
+
...(post.tags?.length ? { keywords: post.tags } : {}),
|
|
143
|
+
extraMeta,
|
|
144
|
+
jsonLd: [
|
|
145
|
+
node,
|
|
146
|
+
breadcrumb({
|
|
147
|
+
siteUrl: input.siteUrl,
|
|
148
|
+
items: [
|
|
149
|
+
{ name: "Home", url: input.siteUrl },
|
|
150
|
+
{ name: "Blog", url: `${input.siteUrl.replace(/\/+$/, "")}/blog` },
|
|
151
|
+
...(input.breadcrumbs ?? []),
|
|
152
|
+
{ name: post.title, url: canonical },
|
|
153
|
+
],
|
|
154
|
+
}),
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type BlogListingSeoInput = {
|
|
160
|
+
siteUrl: string;
|
|
161
|
+
/** Path of the listing, e.g. `/blog` or `/blog/tag/plumbing`. */
|
|
162
|
+
path?: string;
|
|
163
|
+
title?: string;
|
|
164
|
+
description?: string;
|
|
165
|
+
posts: readonly BlogPostSeoSource[];
|
|
166
|
+
/** Path builder, when posts do not live at `/blog/{slug}`. */
|
|
167
|
+
postPath?: (post: BlogPostSeoSource) => string;
|
|
168
|
+
/**
|
|
169
|
+
* Page number, for paginated archives.
|
|
170
|
+
*
|
|
171
|
+
* Pages 2+ are marked `noindex, follow` by convention: the individual posts
|
|
172
|
+
* are what should rank, and thin archive pages competing with them splits
|
|
173
|
+
* the signal. Links are still followed so the posts stay reachable.
|
|
174
|
+
*/
|
|
175
|
+
page?: number;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* SEO for a blog index or archive.
|
|
180
|
+
*
|
|
181
|
+
* Emits a `Blog` node with an `ItemList` of the posts on this page, so a
|
|
182
|
+
* crawler can see the structure without fetching every post.
|
|
183
|
+
*/
|
|
184
|
+
export function blogListingSeo(input: BlogListingSeoInput): SeoInput {
|
|
185
|
+
const base = input.siteUrl.replace(/\/+$/, "");
|
|
186
|
+
const path = input.path ?? "/blog";
|
|
187
|
+
const canonical = `${base}${path}`;
|
|
188
|
+
const postPath =
|
|
189
|
+
input.postPath ?? ((p: BlogPostSeoSource) => `/blog/${p.slug}`);
|
|
190
|
+
const page = input.page ?? 1;
|
|
191
|
+
|
|
192
|
+
const node: JsonLdNode = {
|
|
193
|
+
"@context": "https://schema.org",
|
|
194
|
+
"@type": "Blog",
|
|
195
|
+
url: canonical,
|
|
196
|
+
...(input.title ? { name: input.title } : {}),
|
|
197
|
+
...(input.description ? { description: input.description } : {}),
|
|
198
|
+
blogPost: input.posts.map((post) => ({
|
|
199
|
+
"@type": "BlogPosting",
|
|
200
|
+
headline: post.title,
|
|
201
|
+
url: `${base}${postPath(post)}`,
|
|
202
|
+
...(post.publishedAt ? { datePublished: post.publishedAt } : {}),
|
|
203
|
+
...(post.imageUrl ? { image: post.imageUrl } : {}),
|
|
204
|
+
})),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
...(input.title ? { title: input.title } : {}),
|
|
209
|
+
...(input.description ? { description: input.description } : {}),
|
|
210
|
+
canonical,
|
|
211
|
+
// Thin archive pages competing with the posts themselves splits the
|
|
212
|
+
// signal; the posts are what should rank.
|
|
213
|
+
...(page > 1 ? { noIndex: true } : {}),
|
|
214
|
+
jsonLd: [node],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO for storefront listings — the category and collection pages.
|
|
3
|
+
*
|
|
4
|
+
* A product page has `productPageSeo`. The page ABOVE it needs different
|
|
5
|
+
* markup: `CollectionPage` with an `ItemList`, so a crawler can see what the
|
|
6
|
+
* category contains without fetching every product, and so the listing ranks
|
|
7
|
+
* for the category term instead of competing with its own products.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { breadcrumb, type JsonLdNode } from "../seo.js";
|
|
13
|
+
|
|
14
|
+
import type { SeoInput } from "./index.js";
|
|
15
|
+
|
|
16
|
+
/** A product as it appears in a listing. */
|
|
17
|
+
export type ListedProduct = {
|
|
18
|
+
name: string;
|
|
19
|
+
/** Path or absolute URL of the product page. */
|
|
20
|
+
url: string;
|
|
21
|
+
image?: string | null;
|
|
22
|
+
/** Integer cents. */
|
|
23
|
+
priceCents?: number | null;
|
|
24
|
+
currency?: string | null;
|
|
25
|
+
availability?: "InStock" | "OutOfStock" | "PreOrder";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ProductListingSeoInput = {
|
|
29
|
+
siteUrl: string;
|
|
30
|
+
/** Path of the listing, e.g. `/shop` or `/shop/tools`. */
|
|
31
|
+
path: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
products: readonly ListedProduct[];
|
|
35
|
+
breadcrumbs?: Array<{ name: string; url: string }>;
|
|
36
|
+
/**
|
|
37
|
+
* Page number for paginated listings.
|
|
38
|
+
*
|
|
39
|
+
* Pages 2+ are `noindex, follow`: the products are what should rank, and a
|
|
40
|
+
* thin page-7 of a category competes with them for the same term. Links are
|
|
41
|
+
* still followed so every product stays reachable.
|
|
42
|
+
*/
|
|
43
|
+
page?: number;
|
|
44
|
+
/**
|
|
45
|
+
* True when the listing is filtered or sorted.
|
|
46
|
+
*
|
|
47
|
+
* Faceted URLs are the classic crawl trap — colour × size × sort multiplies
|
|
48
|
+
* into thousands of near-identical pages that eat crawl budget and split
|
|
49
|
+
* ranking. A filtered view should point its canonical at the unfiltered
|
|
50
|
+
* category, which is what this does.
|
|
51
|
+
*/
|
|
52
|
+
filtered?: boolean;
|
|
53
|
+
/** Unfiltered category path, used as the canonical when `filtered`. */
|
|
54
|
+
unfilteredPath?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** SEO for a storefront category or collection page. */
|
|
58
|
+
export function productListingSeo(input: ProductListingSeoInput): SeoInput {
|
|
59
|
+
const base = input.siteUrl.replace(/\/+$/, "");
|
|
60
|
+
const page = input.page ?? 1;
|
|
61
|
+
|
|
62
|
+
// A filtered view canonicalises to the category it filters, so the facets
|
|
63
|
+
// consolidate rather than compete.
|
|
64
|
+
const canonicalPath =
|
|
65
|
+
input.filtered && input.unfilteredPath ? input.unfilteredPath : input.path;
|
|
66
|
+
const canonical = `${base}${canonicalPath}`;
|
|
67
|
+
|
|
68
|
+
const absolute = (url: string) =>
|
|
69
|
+
/^[a-z][a-z0-9+.-]*:|^\/\//i.test(url)
|
|
70
|
+
? url
|
|
71
|
+
: `${base}${url.startsWith("/") ? "" : "/"}${url}`;
|
|
72
|
+
|
|
73
|
+
const node: JsonLdNode = {
|
|
74
|
+
"@context": "https://schema.org",
|
|
75
|
+
"@type": "CollectionPage",
|
|
76
|
+
url: canonical,
|
|
77
|
+
...(input.title ? { name: input.title } : {}),
|
|
78
|
+
...(input.description ? { description: input.description } : {}),
|
|
79
|
+
mainEntity: {
|
|
80
|
+
"@type": "ItemList",
|
|
81
|
+
numberOfItems: input.products.length,
|
|
82
|
+
itemListElement: input.products.map((product, index) => ({
|
|
83
|
+
"@type": "ListItem",
|
|
84
|
+
position: index + 1,
|
|
85
|
+
item: {
|
|
86
|
+
"@type": "Product",
|
|
87
|
+
name: product.name,
|
|
88
|
+
url: absolute(product.url),
|
|
89
|
+
...(product.image ? { image: absolute(product.image) } : {}),
|
|
90
|
+
...(typeof product.priceCents === "number"
|
|
91
|
+
? {
|
|
92
|
+
offers: {
|
|
93
|
+
"@type": "Offer",
|
|
94
|
+
price: (product.priceCents / 100).toFixed(2),
|
|
95
|
+
priceCurrency: product.currency ?? "USD",
|
|
96
|
+
...(product.availability
|
|
97
|
+
? {
|
|
98
|
+
availability: `https://schema.org/${product.availability}`,
|
|
99
|
+
}
|
|
100
|
+
: {}),
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
: {}),
|
|
104
|
+
},
|
|
105
|
+
})),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...(input.title ? { title: input.title } : {}),
|
|
111
|
+
...(input.description ? { description: input.description } : {}),
|
|
112
|
+
canonical,
|
|
113
|
+
// Either reason is enough on its own to keep the page out of the index.
|
|
114
|
+
...(page > 1 || input.filtered ? { noIndex: page > 1 } : {}),
|
|
115
|
+
jsonLd: [
|
|
116
|
+
node,
|
|
117
|
+
breadcrumb({
|
|
118
|
+
siteUrl: input.siteUrl,
|
|
119
|
+
items: [
|
|
120
|
+
{ name: "Home", url: input.siteUrl },
|
|
121
|
+
...(input.breadcrumbs ?? []),
|
|
122
|
+
...(input.title ? [{ name: input.title, url: canonical }] : []),
|
|
123
|
+
],
|
|
124
|
+
}),
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* SEO for cart, checkout, account and order-confirmation pages.
|
|
131
|
+
*
|
|
132
|
+
* These must never be indexed. A cart URL in search results is at best useless
|
|
133
|
+
* and at worst a leak — order confirmations have been indexed with customer
|
|
134
|
+
* details in the query string more than once, by more than one large retailer.
|
|
135
|
+
*/
|
|
136
|
+
export function transactionalPageSeo(title: string): SeoInput {
|
|
137
|
+
return { title, noIndex: true, canonical: null };
|
|
138
|
+
}
|