@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
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Local SEO: the pages that win "plumber in Ashford" and "buy X near me".
3
+ *
4
+ * ## What this adds over the builders in `@businessdash/sdk/seo`
5
+ *
6
+ * Those are primitives — `service()`, `localBusiness()`, `product()`,
7
+ * `breadcrumb()` — each producing one JSON-LD node. Correct, and not the thing
8
+ * a consumer actually needs, because a local landing page is not one node. It
9
+ * is a `Service` scoped to an area, the `LocalBusiness` that provides it, and
10
+ * a `BreadcrumbList` placing it in the site, plus the meta tags that carry the
11
+ * place name into the title and description.
12
+ *
13
+ * Assembling that by hand for every service × area combination is where local
14
+ * SEO dies: someone ships 400 pages that all claim `areaServed: "Nationwide"`,
15
+ * or 400 identical titles, and the pages compete with each other instead of
16
+ * ranking.
17
+ *
18
+ * So these return a `SeoInput` — meta AND structured data together — that
19
+ * composes with `resolveSeo()` like any other layer.
20
+ *
21
+ * ## The multiplication is the point, and the risk
22
+ *
23
+ * `servicesTimesAreas()` exists because service × area is a Cartesian product
24
+ * and doing it by hand is how mistakes scale. It is also how thin content
25
+ * scales, so it takes a `describe` callback rather than templating a sentence
26
+ * for you: pages that differ only by a swapped town name are what "doorway
27
+ * pages" means, and Google has a specific penalty for them.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import {
33
+ breadcrumb,
34
+ type JsonLdNode,
35
+ type LocalBusinessInput,
36
+ localBusiness,
37
+ product as productNode,
38
+ service as serviceNode,
39
+ } from "../seo.js";
40
+
41
+ import type { SeoInput } from "./index.js";
42
+
43
+ /** A place the org serves. */
44
+ export type ServiceArea = {
45
+ /** URL segment, e.g. `ashford`. */
46
+ slug: string;
47
+ /** Display name, e.g. `Ashford`. */
48
+ name: string;
49
+ /** Optional region for disambiguation — `Kent`, `WA`. */
50
+ region?: string;
51
+ };
52
+
53
+ /** One thing the org does. */
54
+ export type ServiceOffering = {
55
+ slug: string;
56
+ name: string;
57
+ description: string;
58
+ };
59
+
60
+ export type LocalServicePageInput = {
61
+ /** Absolute site origin, no trailing slash. */
62
+ siteUrl: string;
63
+ service: ServiceOffering;
64
+ area: ServiceArea;
65
+ /**
66
+ * Path of THIS page, e.g. `/services/drain-cleaning/ashford`.
67
+ *
68
+ * Required, and used as the canonical. A service-area page without its own
69
+ * canonical is the single most common way these get deduplicated away by
70
+ * search engines — they look near-identical, so one is picked and the rest
71
+ * are dropped.
72
+ */
73
+ path: string;
74
+ /**
75
+ * The page's own copy.
76
+ *
77
+ * A callback rather than a template, deliberately. Generating 400 pages
78
+ * whose only difference is the town name produces doorway pages, which are
79
+ * penalised rather than ranked. Write something true about serving that
80
+ * place, or do not generate the page.
81
+ */
82
+ describe?: (input: { service: ServiceOffering; area: ServiceArea }) => {
83
+ title?: string;
84
+ description?: string;
85
+ };
86
+ /** The org, for the `LocalBusiness` node the `Service` points at. */
87
+ business?: LocalBusinessInput;
88
+ /** Extra crumbs before this page. Home is added automatically. */
89
+ breadcrumbs?: Array<{ name: string; url: string }>;
90
+ ogImage?: string;
91
+ noIndex?: boolean;
92
+ };
93
+
94
+ function areaLabel(area: ServiceArea): string {
95
+ return area.region ? `${area.name}, ${area.region}` : area.name;
96
+ }
97
+
98
+ /**
99
+ * SEO for one service in one place.
100
+ *
101
+ * ```ts
102
+ * const seo = resolveSeo(
103
+ * layoutDefaults,
104
+ * localServicePageSeo({ siteUrl, service, area, path }),
105
+ * )
106
+ * ```
107
+ */
108
+ export function localServicePageSeo(input: LocalServicePageInput): SeoInput {
109
+ const label = areaLabel(input.area);
110
+ const written = input.describe?.({
111
+ service: input.service,
112
+ area: input.area,
113
+ });
114
+
115
+ const title = written?.title ?? `${input.service.name} in ${label}`;
116
+ const description =
117
+ written?.description ??
118
+ `${input.service.description} Serving ${label} and the surrounding area.`;
119
+
120
+ const canonical = `${input.siteUrl.replace(/\/+$/, "")}${input.path}`;
121
+
122
+ const jsonLd: JsonLdNode[] = [
123
+ serviceNode({
124
+ siteUrl: input.siteUrl,
125
+ slug: input.service.slug,
126
+ title: input.service.name,
127
+ description,
128
+ // The whole point of the page: this service, in THIS place. A node
129
+ // that omits it is indistinguishable from the generic service page.
130
+ areaServed: [label],
131
+ }),
132
+ ];
133
+
134
+ if (input.business) jsonLd.push(localBusiness(input.business));
135
+
136
+ jsonLd.push(
137
+ breadcrumb({
138
+ siteUrl: input.siteUrl,
139
+ items: [
140
+ { name: "Home", url: input.siteUrl },
141
+ ...(input.breadcrumbs ?? []),
142
+ { name: `${input.service.name} in ${label}`, url: canonical },
143
+ ],
144
+ }),
145
+ );
146
+
147
+ return {
148
+ title,
149
+ description,
150
+ canonical,
151
+ ...(input.ogImage ? { ogImage: input.ogImage } : {}),
152
+ ...(input.noIndex === undefined ? {} : { noIndex: input.noIndex }),
153
+ jsonLd,
154
+ };
155
+ }
156
+
157
+ export type LocalProductPageInput = {
158
+ siteUrl: string;
159
+ path: string;
160
+ product: {
161
+ name: string;
162
+ description?: string;
163
+ sku?: string;
164
+ brand?: string;
165
+ image?: string | string[];
166
+ /** Integer cents — the money rule everywhere in this codebase. */
167
+ priceCents?: number;
168
+ currency?: string;
169
+ availability?: "InStock" | "OutOfStock" | "PreOrder";
170
+ /**
171
+ * Average rating and how many reviews it came from.
172
+ *
173
+ * Both required together, and omitted entirely when `reviewCount` is 0.
174
+ * An `AggregateRating` with zero reviews is invalid structured data —
175
+ * Google rejects the whole Product node for it, so a product with no
176
+ * reviews yet would lose its price and availability too.
177
+ */
178
+ avgRating?: number;
179
+ reviewCount?: number;
180
+ /**
181
+ * Cheapest and dearest variant, in cents.
182
+ *
183
+ * When a product has variants at different prices, a single `price` is
184
+ * a lie in one direction or the other. `AggregateOffer` states the
185
+ * range, which is what shopping surfaces display.
186
+ */
187
+ priceRangeCents?: { low: number; high: number };
188
+ };
189
+ breadcrumbs?: Array<{ name: string; url: string }>;
190
+ noIndex?: boolean;
191
+ };
192
+
193
+ /**
194
+ * SEO for a product page.
195
+ *
196
+ * Pairs with the AI Distribution product feed: the feed gets the product into
197
+ * the engines' catalogues, this markup is what their crawlers verify when they
198
+ * land on the page. One without the other is half the job — a feed entry with
199
+ * no matching page markup reads as unverified.
200
+ */
201
+ export function productPageSeo(input: LocalProductPageInput): SeoInput {
202
+ const canonical = `${input.siteUrl.replace(/\/+$/, "")}${input.path}`;
203
+ const p = input.product;
204
+
205
+ const jsonLd: JsonLdNode[] = [
206
+ productNode({
207
+ url: canonical,
208
+ name: p.name,
209
+ ...(p.description ? { description: p.description } : {}),
210
+ ...(p.image ? { image: p.image } : {}),
211
+ ...(p.brand ? { brand: p.brand } : {}),
212
+ ...(p.sku ? { sku: p.sku } : {}),
213
+ ...(p.priceCents === undefined
214
+ ? {}
215
+ : {
216
+ offer: {
217
+ priceCents: p.priceCents,
218
+ ...(p.currency ? { currency: p.currency } : {}),
219
+ ...(p.availability ? { availability: p.availability } : {}),
220
+ },
221
+ }),
222
+ }),
223
+ breadcrumb({
224
+ siteUrl: input.siteUrl,
225
+ items: [
226
+ { name: "Home", url: input.siteUrl },
227
+ ...(input.breadcrumbs ?? []),
228
+ { name: p.name, url: canonical },
229
+ ],
230
+ }),
231
+ ];
232
+
233
+ // Ratings and price ranges are patched onto the Product node rather than
234
+ // passed to `product()`, whose input covers a single offer. Both are what
235
+ // turn a plain result into a rich one — stars and a price range are the
236
+ // whole visual difference in a shopping surface.
237
+ const productJson = jsonLd[0] as JsonLdNode;
238
+ if (
239
+ typeof p.avgRating === "number" &&
240
+ typeof p.reviewCount === "number" &&
241
+ p.reviewCount > 0
242
+ ) {
243
+ productJson["aggregateRating"] = {
244
+ "@type": "AggregateRating",
245
+ ratingValue: p.avgRating,
246
+ reviewCount: p.reviewCount,
247
+ };
248
+ }
249
+ if (p.priceRangeCents && p.priceRangeCents.high > p.priceRangeCents.low) {
250
+ productJson["offers"] = {
251
+ "@type": "AggregateOffer",
252
+ priceCurrency: p.currency ?? "USD",
253
+ lowPrice: (p.priceRangeCents.low / 100).toFixed(2),
254
+ highPrice: (p.priceRangeCents.high / 100).toFixed(2),
255
+ ...(p.availability
256
+ ? { availability: `https://schema.org/${p.availability}` }
257
+ : {}),
258
+ };
259
+ }
260
+
261
+ return {
262
+ title: p.name,
263
+ ...(p.description ? { description: p.description } : {}),
264
+ canonical,
265
+ ...(p.image
266
+ ? { ogImage: Array.isArray(p.image) ? p.image[0] : p.image }
267
+ : {}),
268
+ // Products are things people buy, so the card should show the thing.
269
+ ogType: "product",
270
+ ...(input.noIndex === undefined ? {} : { noIndex: input.noIndex }),
271
+ jsonLd,
272
+ };
273
+ }
274
+
275
+ export type ServicesTimesAreasInput = {
276
+ siteUrl: string;
277
+ services: readonly ServiceOffering[];
278
+ areas: readonly ServiceArea[];
279
+ /** Build the path for one combination. */
280
+ path: (service: ServiceOffering, area: ServiceArea) => string;
281
+ describe?: LocalServicePageInput["describe"];
282
+ business?: LocalBusinessInput;
283
+ /**
284
+ * Skip combinations the org does not actually serve.
285
+ *
286
+ * Worth using. A page for a service you do not offer in a place you do not
287
+ * cover is a page that ranks, gets clicked, and produces a call you have to
288
+ * turn down — which costs more than the traffic was worth.
289
+ */
290
+ include?: (service: ServiceOffering, area: ServiceArea) => boolean;
291
+ };
292
+
293
+ /** One generated combination: where it lives, and its SEO. */
294
+ export type LocalPagePlan = {
295
+ path: string;
296
+ service: ServiceOffering;
297
+ area: ServiceArea;
298
+ seo: SeoInput;
299
+ };
300
+
301
+ /**
302
+ * Every service × area combination, as a list of pages to generate.
303
+ *
304
+ * Returns plans rather than rendering anything: which router builds them, and
305
+ * how, is the framework's business. Feed the result to Next's
306
+ * `generateStaticParams`, Astro's `getStaticPaths`, a SvelteKit `entries`
307
+ * export, or a loop in a build script.
308
+ */
309
+ export function servicesTimesAreas(
310
+ input: ServicesTimesAreasInput,
311
+ ): LocalPagePlan[] {
312
+ const plans: LocalPagePlan[] = [];
313
+ for (const service of input.services) {
314
+ for (const area of input.areas) {
315
+ if (input.include && !input.include(service, area)) continue;
316
+ const path = input.path(service, area);
317
+ plans.push({
318
+ path,
319
+ service,
320
+ area,
321
+ seo: localServicePageSeo({
322
+ siteUrl: input.siteUrl,
323
+ service,
324
+ area,
325
+ path,
326
+ ...(input.describe ? { describe: input.describe } : {}),
327
+ ...(input.business ? { business: input.business } : {}),
328
+ }),
329
+ });
330
+ }
331
+ }
332
+ return plans;
333
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `@businessdash/sdk/sitemap` — build a sitemap that matches YOUR site.
3
+ *
4
+ * ## Why the platform cannot do this alone
5
+ *
6
+ * BusinessDash knows an org has 40 blog posts. It does not know whether their
7
+ * site renders them at `/blog/[slug]`, `/updates/[slug]`, `/news/[slug]`, or
8
+ * not at all. Guessing produces a sitemap full of URLs that 404 — which is
9
+ * meaningfully worse than a smaller sitemap, because a crawler that finds
10
+ * broken URLs in your sitemap discounts the whole file.
11
+ *
12
+ * So the split is: the platform emits the URLs whose shape it owns (site-builder
13
+ * pages, legal documents, programmatic pages — it defines those route
14
+ * patterns), and this merges in the rest using paths YOU declare.
15
+ *
16
+ * ## Everything is opt-in, and the default is omission
17
+ *
18
+ * A mobile mechanic has no store. If you do not declare `routes.product`, no
19
+ * product URLs are emitted — and even if you did, the platform would refuse the
20
+ * data because the org has no `ecommerce.storefront` entitlement. Two
21
+ * independent gates, both defaulting to "no".
22
+ *
23
+ * That asymmetry is deliberate. A missing URL costs you the indexing of one
24
+ * page, which Google will usually find by crawling anyway. A sitemap full of
25
+ * 404s costs you trust in every URL in the file. When unsure, this omits.
26
+ *
27
+ * ```ts
28
+ * const xml = await buildSitemap({
29
+ * client,
30
+ * siteId: SITE_ID,
31
+ * baseUrl: 'https://acme.com',
32
+ * staticPaths: ['/', '/about', '/contact'],
33
+ * routes: {
34
+ * blog: '/blog', // → /blog/{slug}
35
+ * product: '/store', // → /store/{id}
36
+ * // no `legal` key → the platform already emits those at their own slugs
37
+ * },
38
+ * })
39
+ * ```
40
+ *
41
+ * @module
42
+ */
43
+
44
+ import { BiabAccessRejectedError } from "../errors.js";
45
+ import { isReservedPagePath } from "../reserved-paths.js";
46
+
47
+ /** Where a kind of platform content lives on YOUR site. */
48
+ export type SitemapRoutes = {
49
+ /**
50
+ * Path prefix for blog posts — `/blog` yields `/blog/{slug}`.
51
+ *
52
+ * Omit if you do not render a blog. Requires the `blog.public`
53
+ * entitlement; without it the platform returns nothing and this skips the
54
+ * section rather than failing.
55
+ */
56
+ blog?: string;
57
+ /**
58
+ * Path prefix for products — `/store` yields `/store/{id}`.
59
+ *
60
+ * Omit if you do not sell anything. Requires `ecommerce.storefront`.
61
+ */
62
+ product?: string;
63
+ /**
64
+ * Prefix for the org's legal pages, if you mounted them somewhere other
65
+ * than the site root.
66
+ *
67
+ * Usually omitted: the platform already emits legal URLs at their own
68
+ * slugs (`/privacy`), which is where the SDK's catch-all serves them.
69
+ * Set it only if your catch-all lives under a prefix.
70
+ */
71
+ legal?: string;
72
+ };
73
+
74
+ export type SitemapEntry = {
75
+ /** Absolute URL. */
76
+ loc: string;
77
+ /** ISO 8601. Omitted when nothing meaningful is known. */
78
+ lastmod?: string;
79
+ };
80
+
81
+ export type BuildSitemapOptions = {
82
+ client: SitemapClientLike;
83
+ siteId: string;
84
+ /** Your canonical origin, e.g. `https://acme.com`. No trailing slash needed. */
85
+ baseUrl: string;
86
+ /**
87
+ * Paths you hand-wrote in your own repo — the homepage, an about page, a
88
+ * landing page. The platform cannot know these exist.
89
+ *
90
+ * Always include `/`. A sitemap without the homepage is a common and
91
+ * entirely self-inflicted omission.
92
+ */
93
+ staticPaths?: string[];
94
+ routes?: SitemapRoutes;
95
+ /** How many blog posts to enumerate. Default 1000. */
96
+ blogLimit?: number;
97
+ /** How many products to enumerate. Default 1000. */
98
+ productLimit?: number;
99
+ /**
100
+ * Called when a section is skipped, with the reason.
101
+ *
102
+ * Worth logging during setup: "skipped products — org has no
103
+ * ecommerce.storefront entitlement" is the difference between a deliberate
104
+ * omission and a silent one you discover months later in Search Console.
105
+ */
106
+ onSkip?: (section: string, reason: string) => void;
107
+ };
108
+
109
+ /** The subset of the SDK client this needs. */
110
+ export type SitemapClientLike = {
111
+ blog: {
112
+ listPosts(input?: { limit?: number }): Promise<{
113
+ posts: Array<{ slug: string; updatedAt?: string | null }>;
114
+ }>;
115
+ };
116
+ storefront: {
117
+ listProducts(input?: { limit?: number }): Promise<{
118
+ products: Array<{ id: string; updatedAt?: string | null }>;
119
+ }>;
120
+ };
121
+ site(siteId: string): {
122
+ sitemapEntries(): Promise<{ entries: SitemapEntry[] }>;
123
+ };
124
+ };
125
+
126
+ function joinUrl(baseUrl: string, path: string): string {
127
+ const base = baseUrl.replace(/\/+$/, "");
128
+ const suffix = path.startsWith("/") ? path : `/${path}`;
129
+ return `${base}${suffix === "/" ? "/" : suffix.replace(/\/+$/, "")}`;
130
+ }
131
+
132
+ function escapeXml(value: string): string {
133
+ return value
134
+ .replace(/&/g, "&amp;")
135
+ .replace(/</g, "&lt;")
136
+ .replace(/>/g, "&gt;")
137
+ .replace(/"/g, "&quot;")
138
+ .replace(/'/g, "&apos;");
139
+ }
140
+
141
+ /**
142
+ * True when a failure means "this org does not have that surface".
143
+ *
144
+ * A missing entitlement is a normal answer for most orgs, not an error — a
145
+ * plumber has no storefront. Anything else (a network fault, a bad key) is also
146
+ * swallowed per-section, because a sitemap that renders four of five sections
147
+ * is far better than one that 500s: a crawler retries a partial sitemap and
148
+ * gives up on an error page.
149
+ */
150
+ function isExpectedAbsence(cause: unknown): boolean {
151
+ return cause instanceof BiabAccessRejectedError;
152
+ }
153
+
154
+ /**
155
+ * Collect every URL for the site, deduplicated and ordered.
156
+ *
157
+ * Sections fail independently — one unreachable surface never takes the
158
+ * sitemap down with it.
159
+ */
160
+ export async function collectSitemapEntries(
161
+ options: BuildSitemapOptions,
162
+ ): Promise<SitemapEntry[]> {
163
+ const { client, baseUrl, routes = {} } = options;
164
+ const seen = new Map<string, SitemapEntry>();
165
+
166
+ function add(path: string, lastmod?: string | null): void {
167
+ // The customer dashboard and the other token-gated surfaces never enter
168
+ // a sitemap — `/portal`, `/quotes/accept`, `/contracts/sign`,
169
+ // `/invoices/pay`. Each is per-customer and behind a token, so a crawler
170
+ // following one lands on a login redirect. That reads as a soft 404 and
171
+ // drags on the trust of every other URL in the file, quite apart from
172
+ // publishing the shape of a private area.
173
+ //
174
+ // Filtered here rather than only on the platform side because a
175
+ // consumer can pass `staticPaths: ['/portal']` by hand, and this is the
176
+ // last place to catch it.
177
+ if (isReservedPagePath(path)) return;
178
+
179
+ const loc = joinUrl(baseUrl, path);
180
+ // First writer wins: a hand-declared static path outranks the same URL
181
+ // discovered from content, because the consumer said it explicitly.
182
+ if (seen.has(loc)) return;
183
+ seen.set(loc, lastmod ? { loc, lastmod } : { loc });
184
+ }
185
+
186
+ // 1. Your own routes, first, so they take precedence on collision.
187
+ for (const path of options.staticPaths ?? []) add(path);
188
+
189
+ // 2. What the platform owns the shape of: site-builder pages, legal
190
+ // documents, programmatic pages. These arrive as full paths.
191
+ try {
192
+ const { entries } = await client.site(options.siteId).sitemapEntries();
193
+ for (const entry of entries) {
194
+ // Already absolute from the platform — re-join so a consumer serving
195
+ // under a different origin than the org's primary domain still gets
196
+ // their own host rather than the platform's idea of it.
197
+ const path = entry.loc.replace(/^https?:\/\/[^/]+/, "");
198
+ add(path || "/", entry.lastmod);
199
+ }
200
+ } catch (cause) {
201
+ options.onSkip?.(
202
+ "platform",
203
+ isExpectedAbsence(cause)
204
+ ? "org is not entitled to programmatic pages"
205
+ : "platform sitemap was unreachable",
206
+ );
207
+ }
208
+
209
+ // 3. Blog — only when you told us where it lives.
210
+ if (routes.blog) {
211
+ try {
212
+ const { posts } = await client.blog.listPosts({
213
+ limit: options.blogLimit ?? 1000,
214
+ });
215
+ for (const post of posts) {
216
+ add(`${routes.blog}/${post.slug}`, post.updatedAt ?? null);
217
+ }
218
+ } catch (cause) {
219
+ options.onSkip?.(
220
+ "blog",
221
+ isExpectedAbsence(cause)
222
+ ? "org has no blog.public entitlement"
223
+ : "blog posts were unreachable",
224
+ );
225
+ }
226
+ } else {
227
+ options.onSkip?.("blog", "no routes.blog declared");
228
+ }
229
+
230
+ // 4. Products — same rule.
231
+ if (routes.product) {
232
+ try {
233
+ const { products } = await client.storefront.listProducts({
234
+ limit: options.productLimit ?? 1000,
235
+ });
236
+ for (const product of products) {
237
+ add(`${routes.product}/${product.id}`, product.updatedAt ?? null);
238
+ }
239
+ } catch (cause) {
240
+ options.onSkip?.(
241
+ "products",
242
+ isExpectedAbsence(cause)
243
+ ? "org has no ecommerce.storefront entitlement"
244
+ : "products were unreachable",
245
+ );
246
+ }
247
+ } else {
248
+ options.onSkip?.("products", "no routes.product declared");
249
+ }
250
+
251
+ return [...seen.values()];
252
+ }
253
+
254
+ /** Serialise entries as a sitemap document. */
255
+ export function renderSitemapXml(entries: SitemapEntry[]): string {
256
+ const body = entries
257
+ .map((entry) => {
258
+ const lastmod = entry.lastmod
259
+ ? `\n <lastmod>${escapeXml(entry.lastmod)}</lastmod>`
260
+ : "";
261
+ return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>${lastmod}\n </url>`;
262
+ })
263
+ .join("\n");
264
+
265
+ return (
266
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
267
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
268
+ (body ? `${body}\n` : "") +
269
+ "</urlset>\n"
270
+ );
271
+ }
272
+
273
+ /**
274
+ * The whole thing: collect, then serialise.
275
+ *
276
+ * Never throws. A sitemap route that 500s is worse than one that returns fewer
277
+ * URLs — crawlers retry a thin sitemap and back off from an error page.
278
+ */
279
+ export async function buildSitemap(
280
+ options: BuildSitemapOptions,
281
+ ): Promise<string> {
282
+ const entries = await collectSitemapEntries(options);
283
+ return renderSitemapXml(entries);
284
+ }
285
+
286
+ /**
287
+ * A sitemap with only the homepage in it.
288
+ *
289
+ * The fallback when BusinessDash is unconfigured or unreachable. Deliberately
290
+ * NOT an empty `<urlset>`: an empty sitemap affirmatively tells a crawler the
291
+ * site has no pages, which is a worse claim than saying nothing. One honest URL
292
+ * is the smallest truthful document.
293
+ */
294
+ export function minimalSitemapXml(baseUrl: string): string {
295
+ return renderSitemapXml([{ loc: joinUrl(baseUrl, "/") }]);
296
+ }