@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.
Files changed (239) hide show
  1. package/README.md +991 -295
  2. package/assets/biab-sw.js +92 -0
  3. package/dist/account-core/index.d.ts +126 -0
  4. package/dist/account-core/index.d.ts.map +1 -0
  5. package/dist/account-core/index.js +191 -0
  6. package/dist/account-core/index.js.map +1 -0
  7. package/dist/account.d.ts +11 -0
  8. package/dist/account.d.ts.map +1 -0
  9. package/dist/account.js +11 -0
  10. package/dist/account.js.map +1 -0
  11. package/dist/auth-handler.d.ts +1 -1
  12. package/dist/auth-links.d.ts +33 -0
  13. package/dist/auth-links.d.ts.map +1 -0
  14. package/dist/auth-links.js +64 -0
  15. package/dist/auth-links.js.map +1 -0
  16. package/dist/blog-core/index.d.ts +112 -0
  17. package/dist/blog-core/index.d.ts.map +1 -0
  18. package/dist/blog-core/index.js +330 -0
  19. package/dist/blog-core/index.js.map +1 -0
  20. package/dist/blog.d.ts +11 -0
  21. package/dist/blog.d.ts.map +1 -0
  22. package/dist/blog.js +11 -0
  23. package/dist/blog.js.map +1 -0
  24. package/dist/chat-core/index.d.ts +97 -0
  25. package/dist/chat-core/index.d.ts.map +1 -0
  26. package/dist/chat-core/index.js +237 -0
  27. package/dist/chat-core/index.js.map +1 -0
  28. package/dist/chat.d.ts +11 -0
  29. package/dist/chat.d.ts.map +1 -0
  30. package/dist/chat.js +11 -0
  31. package/dist/chat.js.map +1 -0
  32. package/dist/checkout-core/index.d.ts +65 -0
  33. package/dist/checkout-core/index.d.ts.map +1 -0
  34. package/dist/checkout-core/index.js +159 -0
  35. package/dist/checkout-core/index.js.map +1 -0
  36. package/dist/checkout.d.ts +10 -0
  37. package/dist/checkout.d.ts.map +1 -0
  38. package/dist/checkout.js +10 -0
  39. package/dist/checkout.js.map +1 -0
  40. package/dist/cli.js +133 -25
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +622 -1
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +888 -1
  45. package/dist/client.js.map +1 -1
  46. package/dist/consent-core/index.cjs +107 -0
  47. package/dist/consent-core/index.d.ts +103 -0
  48. package/dist/consent-core/index.d.ts.map +1 -0
  49. package/dist/consent-core/index.js +157 -0
  50. package/dist/consent-core/index.js.map +1 -0
  51. package/dist/contracts.d.ts +3526 -767
  52. package/dist/contracts.d.ts.map +1 -1
  53. package/dist/contracts.js +571 -4
  54. package/dist/contracts.js.map +1 -1
  55. package/dist/data-model-schema.d.ts +1 -1
  56. package/dist/data-model-schema.d.ts.map +1 -1
  57. package/dist/element.d.ts +30 -0
  58. package/dist/element.d.ts.map +1 -1
  59. package/dist/element.js +56 -0
  60. package/dist/element.js.map +1 -1
  61. package/dist/embed-protocol.d.ts +86 -0
  62. package/dist/embed-protocol.d.ts.map +1 -0
  63. package/dist/embed-protocol.js +60 -0
  64. package/dist/embed-protocol.js.map +1 -0
  65. package/dist/framework-support.d.ts +122 -0
  66. package/dist/framework-support.d.ts.map +1 -0
  67. package/dist/framework-support.js +323 -0
  68. package/dist/framework-support.js.map +1 -0
  69. package/dist/index.cjs +1355 -24
  70. package/dist/legal-core/index.cjs +115 -0
  71. package/dist/legal-core/index.d.ts +160 -0
  72. package/dist/legal-core/index.d.ts.map +1 -0
  73. package/dist/legal-core/index.js +220 -0
  74. package/dist/legal-core/index.js.map +1 -0
  75. package/dist/marketing-core/index.d.ts +66 -0
  76. package/dist/marketing-core/index.d.ts.map +1 -0
  77. package/dist/marketing-core/index.js +154 -0
  78. package/dist/marketing-core/index.js.map +1 -0
  79. package/dist/marketing.d.ts +7 -0
  80. package/dist/marketing.d.ts.map +1 -0
  81. package/dist/marketing.js +7 -0
  82. package/dist/marketing.js.map +1 -0
  83. package/dist/mcp-adapters/index.d.ts +135 -0
  84. package/dist/mcp-adapters/index.d.ts.map +1 -0
  85. package/dist/mcp-adapters/index.js +187 -0
  86. package/dist/mcp-adapters/index.js.map +1 -0
  87. package/dist/mcp-adapters.d.ts +7 -0
  88. package/dist/mcp-adapters.d.ts.map +1 -0
  89. package/dist/mcp-adapters.js +7 -0
  90. package/dist/mcp-adapters.js.map +1 -0
  91. package/dist/model3d-core/index.cjs +95 -0
  92. package/dist/model3d-core/index.d.ts +167 -0
  93. package/dist/model3d-core/index.d.ts.map +1 -0
  94. package/dist/model3d-core/index.js +158 -0
  95. package/dist/model3d-core/index.js.map +1 -0
  96. package/dist/notifications-core/index.cjs +315 -0
  97. package/dist/notifications-core/index.d.ts +229 -0
  98. package/dist/notifications-core/index.d.ts.map +1 -0
  99. package/dist/notifications-core/index.js +445 -0
  100. package/dist/notifications-core/index.js.map +1 -0
  101. package/dist/portal-core/index.d.ts +451 -0
  102. package/dist/portal-core/index.d.ts.map +1 -0
  103. package/dist/portal-core/index.js +819 -0
  104. package/dist/portal-core/index.js.map +1 -0
  105. package/dist/portal.d.ts +7 -0
  106. package/dist/portal.d.ts.map +1 -0
  107. package/dist/portal.js +7 -0
  108. package/dist/portal.js.map +1 -0
  109. package/dist/push-core/index.cjs +171 -0
  110. package/dist/push-core/index.d.ts +149 -0
  111. package/dist/push-core/index.d.ts.map +1 -0
  112. package/dist/push-core/index.js +231 -0
  113. package/dist/push-core/index.js.map +1 -0
  114. package/dist/react-marketing.d.ts +1 -17
  115. package/dist/react-marketing.d.ts.map +1 -1
  116. package/dist/react-marketing.js +4 -0
  117. package/dist/react-marketing.js.map +1 -1
  118. package/dist/react.d.ts +294 -101
  119. package/dist/react.d.ts.map +1 -1
  120. package/dist/react.js +302 -246
  121. package/dist/react.js.map +1 -1
  122. package/dist/seed-core/index.d.ts +354 -0
  123. package/dist/seed-core/index.d.ts.map +1 -0
  124. package/dist/seed-core/index.js +420 -0
  125. package/dist/seed-core/index.js.map +1 -0
  126. package/dist/seed.d.ts +7 -0
  127. package/dist/seed.d.ts.map +1 -0
  128. package/dist/seed.js +7 -0
  129. package/dist/seed.js.map +1 -0
  130. package/dist/seo-core/blog.d.ts +94 -0
  131. package/dist/seo-core/blog.d.ts.map +1 -0
  132. package/dist/seo-core/blog.js +149 -0
  133. package/dist/seo-core/blog.js.map +1 -0
  134. package/dist/seo-core/commerce.d.ts +64 -0
  135. package/dist/seo-core/commerce.d.ts.map +1 -0
  136. package/dist/seo-core/commerce.js +87 -0
  137. package/dist/seo-core/commerce.js.map +1 -0
  138. package/dist/seo-core/index.cjs +751 -0
  139. package/dist/seo-core/index.d.ts +226 -0
  140. package/dist/seo-core/index.d.ts.map +1 -0
  141. package/dist/seo-core/index.js +455 -0
  142. package/dist/seo-core/index.js.map +1 -0
  143. package/dist/seo-core/local.d.ts +181 -0
  144. package/dist/seo-core/local.d.ts.map +1 -0
  145. package/dist/seo-core/local.js +193 -0
  146. package/dist/seo-core/local.js.map +1 -0
  147. package/dist/sitemap-core/index.cjs +167 -0
  148. package/dist/sitemap-core/index.d.ts +155 -0
  149. package/dist/sitemap-core/index.d.ts.map +1 -0
  150. package/dist/sitemap-core/index.js +195 -0
  151. package/dist/sitemap-core/index.js.map +1 -0
  152. package/dist/social-core/index.d.ts +74 -0
  153. package/dist/social-core/index.d.ts.map +1 -0
  154. package/dist/social-core/index.js +72 -0
  155. package/dist/social-core/index.js.map +1 -0
  156. package/dist/social.d.ts +7 -0
  157. package/dist/social.d.ts.map +1 -0
  158. package/dist/social.js +7 -0
  159. package/dist/social.js.map +1 -0
  160. package/dist/solid-compat.d.ts +204 -0
  161. package/dist/solid-compat.d.ts.map +1 -0
  162. package/dist/solid-compat.js +221 -0
  163. package/dist/solid-compat.js.map +1 -0
  164. package/dist/store-core/index.d.ts +141 -0
  165. package/dist/store-core/index.d.ts.map +1 -0
  166. package/dist/store-core/index.js +277 -0
  167. package/dist/store-core/index.js.map +1 -0
  168. package/dist/store.d.ts +12 -0
  169. package/dist/store.d.ts.map +1 -0
  170. package/dist/store.js +12 -0
  171. package/dist/store.js.map +1 -0
  172. package/dist/vanilla.d.ts +111 -0
  173. package/dist/vanilla.d.ts.map +1 -1
  174. package/dist/vanilla.js +206 -0
  175. package/dist/vanilla.js.map +1 -1
  176. package/dist/verify-core/index.d.ts +98 -0
  177. package/dist/verify-core/index.d.ts.map +1 -0
  178. package/dist/verify-core/index.js +186 -0
  179. package/dist/verify-core/index.js.map +1 -0
  180. package/dist/verify.d.ts +8 -0
  181. package/dist/verify.d.ts.map +1 -0
  182. package/dist/verify.js +8 -0
  183. package/dist/verify.js.map +1 -0
  184. package/openapi.json +4516 -1311
  185. package/package.json +128 -11
  186. package/src/account-core/index.ts +309 -0
  187. package/src/account.ts +11 -0
  188. package/src/angular/headless.ts +519 -0
  189. package/src/angular/index.ts +3 -0
  190. package/src/auth-links.ts +76 -0
  191. package/src/blog-core/index.ts +423 -0
  192. package/src/blog.ts +11 -0
  193. package/src/chat-core/index.ts +335 -0
  194. package/src/chat.ts +11 -0
  195. package/src/checkout-core/index.ts +225 -0
  196. package/src/checkout.ts +10 -0
  197. package/src/cli.ts +184 -28
  198. package/src/client.ts +1097 -0
  199. package/src/consent-core/index.ts +213 -0
  200. package/src/contracts.ts +768 -11
  201. package/src/data-model-schema.ts +1 -1
  202. package/src/element.ts +102 -0
  203. package/src/embed-protocol.ts +100 -0
  204. package/src/framework-support.ts +440 -0
  205. package/src/legal-core/index.ts +276 -0
  206. package/src/marketing-core/index.ts +209 -0
  207. package/src/marketing.ts +7 -0
  208. package/src/mcp-adapters/index.ts +264 -0
  209. package/src/mcp-adapters.ts +7 -0
  210. package/src/model3d-core/index.ts +248 -0
  211. package/src/notifications-core/index.ts +650 -0
  212. package/src/portal-core/index.ts +1564 -0
  213. package/src/portal.ts +7 -0
  214. package/src/push-core/index.ts +335 -0
  215. package/src/qwik.tsx +602 -2
  216. package/src/react-marketing.tsx +5 -0
  217. package/src/react.tsx +434 -319
  218. package/src/seed-core/index.ts +735 -0
  219. package/src/seed.ts +7 -0
  220. package/src/seo-core/blog.ts +216 -0
  221. package/src/seo-core/commerce.ts +138 -0
  222. package/src/seo-core/index.ts +605 -0
  223. package/src/seo-core/local.ts +333 -0
  224. package/src/sitemap-core/index.ts +296 -0
  225. package/src/social-core/index.ts +103 -0
  226. package/src/social.ts +7 -0
  227. package/src/solid-compat.tsx +347 -0
  228. package/src/solid-surfaces.tsx +1311 -0
  229. package/src/solid.tsx +82 -60
  230. package/src/store-core/index.ts +426 -0
  231. package/src/store.ts +12 -0
  232. package/src/svelte/SocialLinks.svelte +84 -0
  233. package/src/svelte/headless.ts +435 -0
  234. package/src/svelte/index.ts +9 -0
  235. package/src/vanilla.ts +367 -0
  236. package/src/verify-core/index.ts +288 -0
  237. package/src/verify.ts +8 -0
  238. package/src/vue/headless.ts +527 -0
  239. package/src/vue/index.ts +3 -0
package/src/seed.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@businessdash/sdk/seed` — schema push and data seeding from any build step.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export * from "./seed-core/index.js";
@@ -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
+ }