@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,226 @@
1
+ /**
2
+ * `@businessdash/sdk/seo-core` — turn an org's SEO into whatever your
3
+ * framework puts in `<head>`.
4
+ *
5
+ * ## The gap this fills
6
+ *
7
+ * The platform already ships per-page SEO: every marketing page bundle carries
8
+ * a `seo` object with title, description, canonical, `noIndex`, Open Graph,
9
+ * Twitter card, keywords, JSON-LD and hreflang. Until now the SDK re-exported
10
+ * that as a TYPE and stopped there, so every consumer hand-mapped eleven fields
11
+ * into their framework's own metadata shape — and hand-mapped the fallbacks,
12
+ * the absolute-URL rules and the robots string with them.
13
+ *
14
+ * Eleven fields is enough that everyone does four of them and forgets the rest.
15
+ * The ones people forget are `noIndex` and `canonical`, which are exactly the
16
+ * two where being wrong is expensive.
17
+ *
18
+ * ## Page and layout, not just site
19
+ *
20
+ * SEO composes. A layout sets the defaults every page inherits (site name,
21
+ * default OG image, the base URL); a page overrides the parts it knows better
22
+ * (its own title, its own canonical). `mergeSeo` applies that precedence in one
23
+ * place so it is the same on every framework, and `resolveSeo` accepts the
24
+ * layers directly.
25
+ *
26
+ * Precedence, lowest to highest: **layout defaults → the page bundle from the
27
+ * platform → explicit overrides in code.** Code wins because a developer
28
+ * writing `resolveSeo({ overrides })` has context the CMS does not — a search
29
+ * page that must never be indexed, a canonical that points at the parent.
30
+ *
31
+ * ## No framework imports
32
+ *
33
+ * Every adapter here returns a plain object or array shaped the way a framework
34
+ * expects. Nothing imports Next, Nuxt, Qwik or anything else, so this module
35
+ * works in all of them and adds a dependency to none of them.
36
+ *
37
+ * @module
38
+ */
39
+ /** The platform's per-page SEO, as it arrives on a marketing page bundle. */
40
+ export type PlatformSeo = {
41
+ seoTitle: string | null;
42
+ seoDescription: string | null;
43
+ canonicalUrl: string | null;
44
+ noIndex: boolean;
45
+ ogTitle: string | null;
46
+ ogDescription: string | null;
47
+ ogImageUrl: string | null;
48
+ twitterCard: "summary" | "summary_large_image" | "app" | "player";
49
+ keywords: string[];
50
+ jsonldNodes: unknown[];
51
+ hreflang: Record<string, string>;
52
+ };
53
+ /**
54
+ * SEO written in code — for a layout, a page, or an override on top of the
55
+ * platform's.
56
+ *
57
+ * Every field optional: this is a PATCH, not a replacement. Sending a partial
58
+ * object must not blank the fields it omits, or a page that sets only its title
59
+ * would silently drop the org's OG image.
60
+ */
61
+ export type SeoInput = {
62
+ title?: string | null;
63
+ description?: string | null;
64
+ canonical?: string | null;
65
+ noIndex?: boolean;
66
+ ogTitle?: string | null;
67
+ ogDescription?: string | null;
68
+ ogImage?: string | null;
69
+ ogType?: string;
70
+ twitterCard?: PlatformSeo["twitterCard"];
71
+ twitterSite?: string;
72
+ keywords?: string[];
73
+ jsonLd?: unknown[];
74
+ hreflang?: Record<string, string>;
75
+ /**
76
+ * Appended to the page title, e.g. `" | Acme Plumbing"`.
77
+ *
78
+ * A layout concern almost always — which is why it is here rather than
79
+ * expected to be baked into every page's title by hand.
80
+ */
81
+ titleSuffix?: string;
82
+ /** Absolute origin used to resolve relative canonicals and image URLs. */
83
+ baseUrl?: string;
84
+ /** Site name for `og:site_name`. */
85
+ siteName?: string;
86
+ /** Locale for `og:locale`, e.g. `en_US`. */
87
+ locale?: string;
88
+ /**
89
+ * Tags this module has no opinion about — `article:published_time`,
90
+ * `product:availability`, a verification token.
91
+ *
92
+ * An escape hatch on purpose: enumerating every vertical's meta vocabulary
93
+ * here would be a losing race, and a caller who needs one should not have
94
+ * to fork the resolver to add it. Accumulates across layers rather than
95
+ * replacing, so a layout's verification tag survives a page adding its own.
96
+ */
97
+ extraMeta?: MetaTag[];
98
+ };
99
+ /** One `<meta>` tag. */
100
+ export type MetaTag = {
101
+ /** `name`, or `property` for Open Graph. */
102
+ name?: string;
103
+ property?: string;
104
+ content: string;
105
+ };
106
+ /** One `<link>` tag. */
107
+ export type LinkTag = {
108
+ rel: string;
109
+ href: string;
110
+ hreflang?: string;
111
+ };
112
+ /** Everything a `<head>` needs, framework-neutral. */
113
+ export type ResolvedSeo = {
114
+ title: string | null;
115
+ description: string | null;
116
+ canonical: string | null;
117
+ /** `noindex, nofollow` when hidden; `index, follow` otherwise. */
118
+ robots: string;
119
+ noIndex: boolean;
120
+ meta: MetaTag[];
121
+ links: LinkTag[];
122
+ jsonLd: unknown[];
123
+ };
124
+ /**
125
+ * Absolute-ise a URL against the base.
126
+ *
127
+ * A relative canonical is worse than no canonical: crawlers resolve it against
128
+ * whatever URL they happened to fetch, so a `/pricing` canonical on a
129
+ * paginated or parameterised URL points somewhere nobody intended. Same for
130
+ * `og:image`, which social scrapers will not resolve at all.
131
+ */
132
+ export declare function absoluteUrl(url: string | null | undefined, baseUrl: string | null | undefined): string | null;
133
+ /** Convert the platform's shape into the same patch shape as code input. */
134
+ export declare function platformSeoToInput(seo: PlatformSeo | null | undefined): SeoInput;
135
+ /**
136
+ * Layer SEO patches, later winning over earlier.
137
+ *
138
+ * `undefined` means "not specified" and inherits. `null` means "explicitly
139
+ * cleared" and wins — that distinction is the whole reason this is not
140
+ * `Object.assign`: a page saying `canonical: null` is making a decision, and
141
+ * treating it the same as an omitted field would ignore it.
142
+ *
143
+ * Arrays REPLACE rather than concatenate, except `jsonLd`, which accumulates:
144
+ * a layout's Organization node and a page's Product node both belong in the
145
+ * document, and dropping one because the other exists is how rich results go
146
+ * missing.
147
+ */
148
+ export declare function mergeSeo(...layers: (SeoInput | undefined | null)[]): SeoInput;
149
+ /**
150
+ * Resolve layered SEO into the tags a `<head>` needs.
151
+ *
152
+ * Pass layers lowest-precedence first — typically the layout's defaults, then
153
+ * `platformSeoToInput(bundle.seo)`, then any page-level overrides.
154
+ */
155
+ export declare function resolveSeo(...layers: (SeoInput | undefined | null)[]): ResolvedSeo;
156
+ /**
157
+ * Next.js App Router — return from `generateMetadata`.
158
+ *
159
+ * ```ts
160
+ * export async function generateMetadata() {
161
+ * const bundle = await client.marketing.pageBundle("home");
162
+ * return toNextMetadata(resolveSeo(layoutDefaults, platformSeoToInput(bundle.seo)));
163
+ * }
164
+ * ```
165
+ *
166
+ * JSON-LD is NOT included: Next has no metadata slot for it. Render it with
167
+ * `jsonLdScriptProps` in the page body instead — the one thing this adapter
168
+ * cannot do for you.
169
+ */
170
+ export declare function toNextMetadata(seo: ResolvedSeo): Record<string, unknown>;
171
+ /** One entry of a Remix / React Router 7 `meta` export. */
172
+ export type RemixMetaDescriptor = Record<string, unknown>;
173
+ /**
174
+ * Remix / React Router 7 — return from the route's `meta` export.
175
+ *
176
+ * JSON-LD rides along as a `script` descriptor, which Remix renders natively —
177
+ * so unlike Next, structured data needs nothing extra.
178
+ */
179
+ export declare function toRemixMeta(seo: ResolvedSeo): RemixMetaDescriptor[];
180
+ /**
181
+ * Nuxt — pass straight to `useHead()`.
182
+ *
183
+ * `script` carries the JSON-LD; Nuxt serialises the object for you.
184
+ */
185
+ export declare function toNuxtHead(seo: ResolvedSeo): Record<string, unknown>;
186
+ /** Qwik City — return from the route's `head` / `DocumentHead`. */
187
+ export declare function toQwikDocumentHead(seo: ResolvedSeo): Record<string, unknown>;
188
+ /** TanStack Start — return from a route's `head()`. */
189
+ export declare function toTanStackHead(seo: ResolvedSeo): Record<string, unknown>;
190
+ /** One head tag, for templating frameworks that build `<head>` by hand. */
191
+ export type HeadTag = {
192
+ tag: "title" | "meta" | "link" | "script";
193
+ attrs: Record<string, string>;
194
+ /** Only set for `script` — the JSON-LD payload. */
195
+ children?: string;
196
+ };
197
+ /**
198
+ * A flat tag list for Astro, SvelteKit, Angular, HTMX or anything else that
199
+ * writes `<head>` itself.
200
+ *
201
+ * Deliberately the lowest common denominator: a framework that has no
202
+ * metadata API can still loop this into markup, and one that does can map it.
203
+ */
204
+ export declare function toHeadTags(seo: ResolvedSeo): HeadTag[];
205
+ /**
206
+ * Render the tag list as an HTML string.
207
+ *
208
+ * For server-rendered templates that concatenate `<head>` — Laravel, Phoenix,
209
+ * plain Node, HTMX. Values are escaped: SEO text is org-authored, and an
210
+ * unescaped `"` in a meta description would otherwise break out of the
211
+ * attribute and mangle the document.
212
+ */
213
+ export declare function renderHeadTags(seo: ResolvedSeo): string;
214
+ /**
215
+ * Apply the resolved SEO to the live document.
216
+ *
217
+ * For vanilla JS and any client-side router that changes pages without a
218
+ * reload. Existing tags this module owns are REPLACED rather than appended —
219
+ * otherwise every navigation adds another `og:title` and crawlers pick one at
220
+ * random.
221
+ */
222
+ export declare function applyToDocument(seo: ResolvedSeo, doc?: Document | undefined): void;
223
+ export { type BlogListingSeoInput, type BlogPostSeoInput, type BlogPostSeoSource, blogListingSeo, blogPostSeo, } from "./blog.js";
224
+ export { type ListedProduct, type ProductListingSeoInput, productListingSeo, transactionalPageSeo, } from "./commerce.js";
225
+ export { type LocalPagePlan, type LocalProductPageInput, type LocalServicePageInput, localServicePageSeo, productPageSeo, type ServiceArea, type ServiceOffering, type ServicesTimesAreasInput, servicesTimesAreas, } from "./local.js";
226
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/seo-core/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,455 @@
1
+ /**
2
+ * `@businessdash/sdk/seo-core` — turn an org's SEO into whatever your
3
+ * framework puts in `<head>`.
4
+ *
5
+ * ## The gap this fills
6
+ *
7
+ * The platform already ships per-page SEO: every marketing page bundle carries
8
+ * a `seo` object with title, description, canonical, `noIndex`, Open Graph,
9
+ * Twitter card, keywords, JSON-LD and hreflang. Until now the SDK re-exported
10
+ * that as a TYPE and stopped there, so every consumer hand-mapped eleven fields
11
+ * into their framework's own metadata shape — and hand-mapped the fallbacks,
12
+ * the absolute-URL rules and the robots string with them.
13
+ *
14
+ * Eleven fields is enough that everyone does four of them and forgets the rest.
15
+ * The ones people forget are `noIndex` and `canonical`, which are exactly the
16
+ * two where being wrong is expensive.
17
+ *
18
+ * ## Page and layout, not just site
19
+ *
20
+ * SEO composes. A layout sets the defaults every page inherits (site name,
21
+ * default OG image, the base URL); a page overrides the parts it knows better
22
+ * (its own title, its own canonical). `mergeSeo` applies that precedence in one
23
+ * place so it is the same on every framework, and `resolveSeo` accepts the
24
+ * layers directly.
25
+ *
26
+ * Precedence, lowest to highest: **layout defaults → the page bundle from the
27
+ * platform → explicit overrides in code.** Code wins because a developer
28
+ * writing `resolveSeo({ overrides })` has context the CMS does not — a search
29
+ * page that must never be indexed, a canonical that points at the parent.
30
+ *
31
+ * ## No framework imports
32
+ *
33
+ * Every adapter here returns a plain object or array shaped the way a framework
34
+ * expects. Nothing imports Next, Nuxt, Qwik or anything else, so this module
35
+ * works in all of them and adds a dependency to none of them.
36
+ *
37
+ * @module
38
+ */
39
+ function isBlank(value) {
40
+ return value === null || value === undefined || value.trim() === "";
41
+ }
42
+ /**
43
+ * Absolute-ise a URL against the base.
44
+ *
45
+ * A relative canonical is worse than no canonical: crawlers resolve it against
46
+ * whatever URL they happened to fetch, so a `/pricing` canonical on a
47
+ * paginated or parameterised URL points somewhere nobody intended. Same for
48
+ * `og:image`, which social scrapers will not resolve at all.
49
+ */
50
+ export function absoluteUrl(url, baseUrl) {
51
+ if (isBlank(url))
52
+ return null;
53
+ const value = url.trim();
54
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) {
55
+ return value;
56
+ }
57
+ if (isBlank(baseUrl)) {
58
+ // Returning the relative value would look like it worked. Dropping it
59
+ // is the honest failure: a missing canonical is recoverable, a wrong
60
+ // one silently consolidates ranking onto the wrong URL.
61
+ return null;
62
+ }
63
+ const base = baseUrl.replace(/\/+$/, "");
64
+ return `${base}${value.startsWith("/") ? "" : "/"}${value}`;
65
+ }
66
+ /** Convert the platform's shape into the same patch shape as code input. */
67
+ export function platformSeoToInput(seo) {
68
+ if (!seo)
69
+ return {};
70
+ return {
71
+ title: seo.seoTitle,
72
+ description: seo.seoDescription,
73
+ canonical: seo.canonicalUrl,
74
+ noIndex: seo.noIndex,
75
+ ogTitle: seo.ogTitle,
76
+ ogDescription: seo.ogDescription,
77
+ ogImage: seo.ogImageUrl,
78
+ twitterCard: seo.twitterCard,
79
+ keywords: seo.keywords,
80
+ jsonLd: seo.jsonldNodes,
81
+ hreflang: seo.hreflang,
82
+ };
83
+ }
84
+ /**
85
+ * Layer SEO patches, later winning over earlier.
86
+ *
87
+ * `undefined` means "not specified" and inherits. `null` means "explicitly
88
+ * cleared" and wins — that distinction is the whole reason this is not
89
+ * `Object.assign`: a page saying `canonical: null` is making a decision, and
90
+ * treating it the same as an omitted field would ignore it.
91
+ *
92
+ * Arrays REPLACE rather than concatenate, except `jsonLd`, which accumulates:
93
+ * a layout's Organization node and a page's Product node both belong in the
94
+ * document, and dropping one because the other exists is how rich results go
95
+ * missing.
96
+ */
97
+ export function mergeSeo(...layers) {
98
+ const out = {};
99
+ const jsonLd = [];
100
+ const extraMeta = [];
101
+ for (const layer of layers) {
102
+ if (!layer)
103
+ continue;
104
+ for (const [key, value] of Object.entries(layer)) {
105
+ if (value === undefined)
106
+ continue;
107
+ if (key === "jsonLd") {
108
+ if (Array.isArray(value))
109
+ jsonLd.push(...value);
110
+ continue;
111
+ }
112
+ if (key === "extraMeta") {
113
+ if (Array.isArray(value))
114
+ extraMeta.push(...value);
115
+ continue;
116
+ }
117
+ if (key === "hreflang") {
118
+ out.hreflang = {
119
+ ...(out.hreflang ?? {}),
120
+ ...value,
121
+ };
122
+ continue;
123
+ }
124
+ out[key] = value;
125
+ }
126
+ }
127
+ if (jsonLd.length > 0)
128
+ out.jsonLd = jsonLd;
129
+ if (extraMeta.length > 0)
130
+ out.extraMeta = extraMeta;
131
+ return out;
132
+ }
133
+ /**
134
+ * Resolve layered SEO into the tags a `<head>` needs.
135
+ *
136
+ * Pass layers lowest-precedence first — typically the layout's defaults, then
137
+ * `platformSeoToInput(bundle.seo)`, then any page-level overrides.
138
+ */
139
+ export function resolveSeo(...layers) {
140
+ const seo = mergeSeo(...layers);
141
+ const baseTitle = isBlank(seo.title) ? null : seo.title.trim();
142
+ const title = baseTitle && seo.titleSuffix ? `${baseTitle}${seo.titleSuffix}` : baseTitle;
143
+ const description = isBlank(seo.description)
144
+ ? null
145
+ : seo.description.trim();
146
+ const canonical = absoluteUrl(seo.canonical, seo.baseUrl);
147
+ const ogImage = absoluteUrl(seo.ogImage, seo.baseUrl);
148
+ const noIndex = seo.noIndex === true;
149
+ const meta = [];
150
+ const push = (tag) => {
151
+ if (!isBlank(tag.content))
152
+ meta.push(tag);
153
+ };
154
+ if (description)
155
+ push({ name: "description", content: description });
156
+ if (seo.keywords?.length) {
157
+ push({ name: "keywords", content: seo.keywords.join(", ") });
158
+ }
159
+ // Always emitted, both ways. An absent robots tag means "index" by default,
160
+ // so relying on absence to express "index" is fine — but relying on it to
161
+ // express noindex is catastrophic, and emitting only one of the two makes
162
+ // the code look asymmetric enough that someone eventually "tidies" it.
163
+ const robots = noIndex ? "noindex, nofollow" : "index, follow";
164
+ push({ name: "robots", content: robots });
165
+ // Open Graph falls back to the page's own title/description. Sites almost
166
+ // never set these separately, and a missing og:title is what makes a shared
167
+ // link render as a bare URL.
168
+ const ogTitle = isBlank(seo.ogTitle) ? title : seo.ogTitle;
169
+ const ogDescription = isBlank(seo.ogDescription)
170
+ ? description
171
+ : seo.ogDescription;
172
+ if (ogTitle)
173
+ push({ property: "og:title", content: ogTitle });
174
+ if (ogDescription)
175
+ push({ property: "og:description", content: ogDescription });
176
+ if (ogImage)
177
+ push({ property: "og:image", content: ogImage });
178
+ if (canonical)
179
+ push({ property: "og:url", content: canonical });
180
+ push({ property: "og:type", content: seo.ogType ?? "website" });
181
+ if (seo.siteName)
182
+ push({ property: "og:site_name", content: seo.siteName });
183
+ if (seo.locale)
184
+ push({ property: "og:locale", content: seo.locale });
185
+ push({
186
+ name: "twitter:card",
187
+ content: seo.twitterCard ?? "summary_large_image",
188
+ });
189
+ if (seo.twitterSite)
190
+ push({ name: "twitter:site", content: seo.twitterSite });
191
+ if (ogTitle)
192
+ push({ name: "twitter:title", content: ogTitle });
193
+ if (ogDescription)
194
+ push({ name: "twitter:description", content: ogDescription });
195
+ if (ogImage)
196
+ push({ name: "twitter:image", content: ogImage });
197
+ for (const tag of seo.extraMeta ?? [])
198
+ push(tag);
199
+ const links = [];
200
+ if (canonical)
201
+ links.push({ rel: "canonical", href: canonical });
202
+ for (const [lang, href] of Object.entries(seo.hreflang ?? {})) {
203
+ const resolved = absoluteUrl(href, seo.baseUrl);
204
+ if (resolved)
205
+ links.push({ rel: "alternate", href: resolved, hreflang: lang });
206
+ }
207
+ return {
208
+ title,
209
+ description,
210
+ canonical,
211
+ robots,
212
+ noIndex,
213
+ meta,
214
+ links,
215
+ jsonLd: seo.jsonLd ?? [],
216
+ };
217
+ }
218
+ // ─────────────────────────────────────────────────────────────────────────────
219
+ // Framework adapters
220
+ //
221
+ // Each returns the plain shape its framework expects. None imports a framework,
222
+ // so this module stays dependency-free and usable from all of them.
223
+ // ─────────────────────────────────────────────────────────────────────────────
224
+ /**
225
+ * Next.js App Router — return from `generateMetadata`.
226
+ *
227
+ * ```ts
228
+ * export async function generateMetadata() {
229
+ * const bundle = await client.marketing.pageBundle("home");
230
+ * return toNextMetadata(resolveSeo(layoutDefaults, platformSeoToInput(bundle.seo)));
231
+ * }
232
+ * ```
233
+ *
234
+ * JSON-LD is NOT included: Next has no metadata slot for it. Render it with
235
+ * `jsonLdScriptProps` in the page body instead — the one thing this adapter
236
+ * cannot do for you.
237
+ */
238
+ export function toNextMetadata(seo) {
239
+ const openGraph = {};
240
+ const twitter = {};
241
+ const other = {};
242
+ for (const tag of seo.meta) {
243
+ if (tag.property?.startsWith("og:")) {
244
+ openGraph[tag.property.slice(3)] = tag.content;
245
+ }
246
+ else if (tag.name?.startsWith("twitter:")) {
247
+ twitter[tag.name.slice(8)] = tag.content;
248
+ }
249
+ else if (tag.name &&
250
+ tag.name !== "description" &&
251
+ tag.name !== "robots") {
252
+ other[tag.name] = tag.content;
253
+ }
254
+ }
255
+ const metadata = {
256
+ ...(seo.title ? { title: seo.title } : {}),
257
+ ...(seo.description ? { description: seo.description } : {}),
258
+ // Next wants a structured robots object, not the string.
259
+ robots: { index: !seo.noIndex, follow: !seo.noIndex },
260
+ ...(seo.canonical ? { alternates: { canonical: seo.canonical } } : {}),
261
+ ...(Object.keys(openGraph).length ? { openGraph } : {}),
262
+ ...(Object.keys(twitter).length ? { twitter } : {}),
263
+ ...(Object.keys(other).length ? { other } : {}),
264
+ };
265
+ const alternates = metadata.alternates;
266
+ const languages = {};
267
+ for (const link of seo.links) {
268
+ if (link.rel === "alternate" && link.hreflang) {
269
+ languages[link.hreflang] = link.href;
270
+ }
271
+ }
272
+ if (Object.keys(languages).length > 0) {
273
+ metadata.alternates = { ...(alternates ?? {}), languages };
274
+ }
275
+ return metadata;
276
+ }
277
+ /**
278
+ * Remix / React Router 7 — return from the route's `meta` export.
279
+ *
280
+ * JSON-LD rides along as a `script` descriptor, which Remix renders natively —
281
+ * so unlike Next, structured data needs nothing extra.
282
+ */
283
+ export function toRemixMeta(seo) {
284
+ const out = [];
285
+ if (seo.title)
286
+ out.push({ title: seo.title });
287
+ for (const tag of seo.meta) {
288
+ out.push(tag.property
289
+ ? { property: tag.property, content: tag.content }
290
+ : { name: tag.name, content: tag.content });
291
+ }
292
+ for (const link of seo.links) {
293
+ out.push({ tagName: "link", ...link });
294
+ }
295
+ for (const node of seo.jsonLd) {
296
+ out.push({ "script:ld+json": node });
297
+ }
298
+ return out;
299
+ }
300
+ /**
301
+ * Nuxt — pass straight to `useHead()`.
302
+ *
303
+ * `script` carries the JSON-LD; Nuxt serialises the object for you.
304
+ */
305
+ export function toNuxtHead(seo) {
306
+ return {
307
+ ...(seo.title ? { title: seo.title } : {}),
308
+ meta: seo.meta.map((tag) => tag.property
309
+ ? { property: tag.property, content: tag.content }
310
+ : { name: tag.name, content: tag.content }),
311
+ link: seo.links.map((link) => ({ ...link })),
312
+ script: seo.jsonLd.map((node) => ({
313
+ type: "application/ld+json",
314
+ innerHTML: JSON.stringify(node),
315
+ })),
316
+ };
317
+ }
318
+ /** Qwik City — return from the route's `head` / `DocumentHead`. */
319
+ export function toQwikDocumentHead(seo) {
320
+ return {
321
+ ...(seo.title ? { title: seo.title } : {}),
322
+ meta: seo.meta.map((tag) => tag.property
323
+ ? { property: tag.property, content: tag.content }
324
+ : { name: tag.name ?? "", content: tag.content }),
325
+ links: seo.links.map((link) => ({ ...link })),
326
+ // Qwik has no JSON-LD slot on DocumentHead; carry it in `frontmatter`
327
+ // so the route can render it into the body.
328
+ frontmatter: { jsonLd: seo.jsonLd },
329
+ };
330
+ }
331
+ /** TanStack Start — return from a route's `head()`. */
332
+ export function toTanStackHead(seo) {
333
+ const meta = [];
334
+ if (seo.title)
335
+ meta.push({ title: seo.title });
336
+ for (const tag of seo.meta) {
337
+ meta.push(tag.property
338
+ ? { property: tag.property, content: tag.content }
339
+ : { name: tag.name, content: tag.content });
340
+ }
341
+ return {
342
+ meta,
343
+ links: seo.links.map((link) => ({ ...link })),
344
+ scripts: seo.jsonLd.map((node) => ({
345
+ type: "application/ld+json",
346
+ children: JSON.stringify(node),
347
+ })),
348
+ };
349
+ }
350
+ /**
351
+ * A flat tag list for Astro, SvelteKit, Angular, HTMX or anything else that
352
+ * writes `<head>` itself.
353
+ *
354
+ * Deliberately the lowest common denominator: a framework that has no
355
+ * metadata API can still loop this into markup, and one that does can map it.
356
+ */
357
+ export function toHeadTags(seo) {
358
+ const tags = [];
359
+ if (seo.title)
360
+ tags.push({ tag: "title", attrs: {}, children: seo.title });
361
+ for (const tag of seo.meta) {
362
+ tags.push({
363
+ tag: "meta",
364
+ attrs: tag.property
365
+ ? { property: tag.property, content: tag.content }
366
+ : { name: tag.name ?? "", content: tag.content },
367
+ });
368
+ }
369
+ for (const link of seo.links) {
370
+ const attrs = { rel: link.rel, href: link.href };
371
+ if (link.hreflang)
372
+ attrs.hreflang = link.hreflang;
373
+ tags.push({ tag: "link", attrs });
374
+ }
375
+ for (const node of seo.jsonLd) {
376
+ tags.push({
377
+ tag: "script",
378
+ attrs: { type: "application/ld+json" },
379
+ children: JSON.stringify(node),
380
+ });
381
+ }
382
+ return tags;
383
+ }
384
+ /**
385
+ * Render the tag list as an HTML string.
386
+ *
387
+ * For server-rendered templates that concatenate `<head>` — Laravel, Phoenix,
388
+ * plain Node, HTMX. Values are escaped: SEO text is org-authored, and an
389
+ * unescaped `"` in a meta description would otherwise break out of the
390
+ * attribute and mangle the document.
391
+ */
392
+ export function renderHeadTags(seo) {
393
+ const escapeAttr = (value) => value
394
+ .replace(/&/g, "&amp;")
395
+ .replace(/"/g, "&quot;")
396
+ .replace(/</g, "&lt;")
397
+ .replace(/>/g, "&gt;");
398
+ return toHeadTags(seo)
399
+ .map((tag) => {
400
+ const attrs = Object.entries(tag.attrs)
401
+ .map(([key, value]) => `${key}="${escapeAttr(value)}"`)
402
+ .join(" ");
403
+ if (tag.tag === "title") {
404
+ return `<title>${escapeAttr(tag.children ?? "")}</title>`;
405
+ }
406
+ if (tag.tag === "script") {
407
+ // JSON-LD goes in a script body, where `<` is what actually
408
+ // breaks out. Escaping the whole payload would corrupt the JSON,
409
+ // so only the sequence that can close the tag is neutralised.
410
+ const payload = (tag.children ?? "").replace(/</g, "\\u003c");
411
+ return `<script ${attrs}>${payload}</script>`;
412
+ }
413
+ return `<${tag.tag} ${attrs}>`;
414
+ })
415
+ .join("\n");
416
+ }
417
+ /**
418
+ * Apply the resolved SEO to the live document.
419
+ *
420
+ * For vanilla JS and any client-side router that changes pages without a
421
+ * reload. Existing tags this module owns are REPLACED rather than appended —
422
+ * otherwise every navigation adds another `og:title` and crawlers pick one at
423
+ * random.
424
+ */
425
+ export function applyToDocument(seo, doc = typeof document === "undefined"
426
+ ? undefined
427
+ : document) {
428
+ if (!doc)
429
+ return;
430
+ const OWNED = "data-biab-seo";
431
+ for (const existing of Array.from(doc.head.querySelectorAll(`[${OWNED}]`))) {
432
+ existing.remove();
433
+ }
434
+ if (seo.title !== null)
435
+ doc.title = seo.title;
436
+ for (const tag of toHeadTags(seo)) {
437
+ if (tag.tag === "title")
438
+ continue;
439
+ const element = doc.createElement(tag.tag);
440
+ for (const [key, value] of Object.entries(tag.attrs)) {
441
+ element.setAttribute(key, value);
442
+ }
443
+ if (tag.children)
444
+ element.textContent = tag.children;
445
+ element.setAttribute(OWNED, "");
446
+ doc.head.appendChild(element);
447
+ }
448
+ }
449
+ // Blog and storefront listings.
450
+ export { blogListingSeo, blogPostSeo, } from "./blog.js";
451
+ export { productListingSeo, transactionalPageSeo, } from "./commerce.js";
452
+ // Local SEO — service areas x services, and products. Re-exported so one
453
+ // import covers a whole local landing page.
454
+ export { localServicePageSeo, productPageSeo, servicesTimesAreas, } from "./local.js";
455
+ //# sourceMappingURL=index.js.map