@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,605 @@
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
+
40
+ /** The platform's per-page SEO, as it arrives on a marketing page bundle. */
41
+ export type PlatformSeo = {
42
+ seoTitle: string | null;
43
+ seoDescription: string | null;
44
+ canonicalUrl: string | null;
45
+ noIndex: boolean;
46
+ ogTitle: string | null;
47
+ ogDescription: string | null;
48
+ ogImageUrl: string | null;
49
+ twitterCard: "summary" | "summary_large_image" | "app" | "player";
50
+ keywords: string[];
51
+ jsonldNodes: unknown[];
52
+ hreflang: Record<string, string>;
53
+ };
54
+
55
+ /**
56
+ * SEO written in code — for a layout, a page, or an override on top of the
57
+ * platform's.
58
+ *
59
+ * Every field optional: this is a PATCH, not a replacement. Sending a partial
60
+ * object must not blank the fields it omits, or a page that sets only its title
61
+ * would silently drop the org's OG image.
62
+ */
63
+ export type SeoInput = {
64
+ title?: string | null;
65
+ description?: string | null;
66
+ canonical?: string | null;
67
+ noIndex?: boolean;
68
+ ogTitle?: string | null;
69
+ ogDescription?: string | null;
70
+ ogImage?: string | null;
71
+ ogType?: string;
72
+ twitterCard?: PlatformSeo["twitterCard"];
73
+ twitterSite?: string;
74
+ keywords?: string[];
75
+ jsonLd?: unknown[];
76
+ hreflang?: Record<string, string>;
77
+ /**
78
+ * Appended to the page title, e.g. `" | Acme Plumbing"`.
79
+ *
80
+ * A layout concern almost always — which is why it is here rather than
81
+ * expected to be baked into every page's title by hand.
82
+ */
83
+ titleSuffix?: string;
84
+ /** Absolute origin used to resolve relative canonicals and image URLs. */
85
+ baseUrl?: string;
86
+ /** Site name for `og:site_name`. */
87
+ siteName?: string;
88
+ /** Locale for `og:locale`, e.g. `en_US`. */
89
+ locale?: string;
90
+ /**
91
+ * Tags this module has no opinion about — `article:published_time`,
92
+ * `product:availability`, a verification token.
93
+ *
94
+ * An escape hatch on purpose: enumerating every vertical's meta vocabulary
95
+ * here would be a losing race, and a caller who needs one should not have
96
+ * to fork the resolver to add it. Accumulates across layers rather than
97
+ * replacing, so a layout's verification tag survives a page adding its own.
98
+ */
99
+ extraMeta?: MetaTag[];
100
+ };
101
+
102
+ /** One `<meta>` tag. */
103
+ export type MetaTag = {
104
+ /** `name`, or `property` for Open Graph. */
105
+ name?: string;
106
+ property?: string;
107
+ content: string;
108
+ };
109
+
110
+ /** One `<link>` tag. */
111
+ export type LinkTag = {
112
+ rel: string;
113
+ href: string;
114
+ hreflang?: string;
115
+ };
116
+
117
+ /** Everything a `<head>` needs, framework-neutral. */
118
+ export type ResolvedSeo = {
119
+ title: string | null;
120
+ description: string | null;
121
+ canonical: string | null;
122
+ /** `noindex, nofollow` when hidden; `index, follow` otherwise. */
123
+ robots: string;
124
+ noIndex: boolean;
125
+ meta: MetaTag[];
126
+ links: LinkTag[];
127
+ jsonLd: unknown[];
128
+ };
129
+
130
+ function isBlank(value: string | null | undefined): boolean {
131
+ return value === null || value === undefined || value.trim() === "";
132
+ }
133
+
134
+ /**
135
+ * Absolute-ise a URL against the base.
136
+ *
137
+ * A relative canonical is worse than no canonical: crawlers resolve it against
138
+ * whatever URL they happened to fetch, so a `/pricing` canonical on a
139
+ * paginated or parameterised URL points somewhere nobody intended. Same for
140
+ * `og:image`, which social scrapers will not resolve at all.
141
+ */
142
+ export function absoluteUrl(
143
+ url: string | null | undefined,
144
+ baseUrl: string | null | undefined,
145
+ ): string | null {
146
+ if (isBlank(url)) return null;
147
+ const value = (url as string).trim();
148
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) {
149
+ return value;
150
+ }
151
+ if (isBlank(baseUrl)) {
152
+ // Returning the relative value would look like it worked. Dropping it
153
+ // is the honest failure: a missing canonical is recoverable, a wrong
154
+ // one silently consolidates ranking onto the wrong URL.
155
+ return null;
156
+ }
157
+ const base = (baseUrl as string).replace(/\/+$/, "");
158
+ return `${base}${value.startsWith("/") ? "" : "/"}${value}`;
159
+ }
160
+
161
+ /** Convert the platform's shape into the same patch shape as code input. */
162
+ export function platformSeoToInput(
163
+ seo: PlatformSeo | null | undefined,
164
+ ): SeoInput {
165
+ if (!seo) return {};
166
+ return {
167
+ title: seo.seoTitle,
168
+ description: seo.seoDescription,
169
+ canonical: seo.canonicalUrl,
170
+ noIndex: seo.noIndex,
171
+ ogTitle: seo.ogTitle,
172
+ ogDescription: seo.ogDescription,
173
+ ogImage: seo.ogImageUrl,
174
+ twitterCard: seo.twitterCard,
175
+ keywords: seo.keywords,
176
+ jsonLd: seo.jsonldNodes,
177
+ hreflang: seo.hreflang,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Layer SEO patches, later winning over earlier.
183
+ *
184
+ * `undefined` means "not specified" and inherits. `null` means "explicitly
185
+ * cleared" and wins — that distinction is the whole reason this is not
186
+ * `Object.assign`: a page saying `canonical: null` is making a decision, and
187
+ * treating it the same as an omitted field would ignore it.
188
+ *
189
+ * Arrays REPLACE rather than concatenate, except `jsonLd`, which accumulates:
190
+ * a layout's Organization node and a page's Product node both belong in the
191
+ * document, and dropping one because the other exists is how rich results go
192
+ * missing.
193
+ */
194
+ export function mergeSeo(...layers: (SeoInput | undefined | null)[]): SeoInput {
195
+ const out: SeoInput = {};
196
+ const jsonLd: unknown[] = [];
197
+ const extraMeta: MetaTag[] = [];
198
+
199
+ for (const layer of layers) {
200
+ if (!layer) continue;
201
+ for (const [key, value] of Object.entries(layer)) {
202
+ if (value === undefined) continue;
203
+ if (key === "jsonLd") {
204
+ if (Array.isArray(value)) jsonLd.push(...value);
205
+ continue;
206
+ }
207
+ if (key === "extraMeta") {
208
+ if (Array.isArray(value)) extraMeta.push(...(value as MetaTag[]));
209
+ continue;
210
+ }
211
+ if (key === "hreflang") {
212
+ out.hreflang = {
213
+ ...(out.hreflang ?? {}),
214
+ ...(value as Record<string, string>),
215
+ };
216
+ continue;
217
+ }
218
+ (out as Record<string, unknown>)[key] = value;
219
+ }
220
+ }
221
+
222
+ if (jsonLd.length > 0) out.jsonLd = jsonLd;
223
+ if (extraMeta.length > 0) out.extraMeta = extraMeta;
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * Resolve layered SEO into the tags a `<head>` needs.
229
+ *
230
+ * Pass layers lowest-precedence first — typically the layout's defaults, then
231
+ * `platformSeoToInput(bundle.seo)`, then any page-level overrides.
232
+ */
233
+ export function resolveSeo(
234
+ ...layers: (SeoInput | undefined | null)[]
235
+ ): ResolvedSeo {
236
+ const seo = mergeSeo(...layers);
237
+
238
+ const baseTitle = isBlank(seo.title) ? null : (seo.title as string).trim();
239
+ const title =
240
+ baseTitle && seo.titleSuffix ? `${baseTitle}${seo.titleSuffix}` : baseTitle;
241
+ const description = isBlank(seo.description)
242
+ ? null
243
+ : (seo.description as string).trim();
244
+
245
+ const canonical = absoluteUrl(seo.canonical, seo.baseUrl);
246
+ const ogImage = absoluteUrl(seo.ogImage, seo.baseUrl);
247
+ const noIndex = seo.noIndex === true;
248
+
249
+ const meta: MetaTag[] = [];
250
+ const push = (tag: MetaTag) => {
251
+ if (!isBlank(tag.content)) meta.push(tag);
252
+ };
253
+
254
+ if (description) push({ name: "description", content: description });
255
+ if (seo.keywords?.length) {
256
+ push({ name: "keywords", content: seo.keywords.join(", ") });
257
+ }
258
+
259
+ // Always emitted, both ways. An absent robots tag means "index" by default,
260
+ // so relying on absence to express "index" is fine — but relying on it to
261
+ // express noindex is catastrophic, and emitting only one of the two makes
262
+ // the code look asymmetric enough that someone eventually "tidies" it.
263
+ const robots = noIndex ? "noindex, nofollow" : "index, follow";
264
+ push({ name: "robots", content: robots });
265
+
266
+ // Open Graph falls back to the page's own title/description. Sites almost
267
+ // never set these separately, and a missing og:title is what makes a shared
268
+ // link render as a bare URL.
269
+ const ogTitle = isBlank(seo.ogTitle) ? title : (seo.ogTitle as string);
270
+ const ogDescription = isBlank(seo.ogDescription)
271
+ ? description
272
+ : (seo.ogDescription as string);
273
+
274
+ if (ogTitle) push({ property: "og:title", content: ogTitle });
275
+ if (ogDescription)
276
+ push({ property: "og:description", content: ogDescription });
277
+ if (ogImage) push({ property: "og:image", content: ogImage });
278
+ if (canonical) push({ property: "og:url", content: canonical });
279
+ push({ property: "og:type", content: seo.ogType ?? "website" });
280
+ if (seo.siteName) push({ property: "og:site_name", content: seo.siteName });
281
+ if (seo.locale) push({ property: "og:locale", content: seo.locale });
282
+
283
+ push({
284
+ name: "twitter:card",
285
+ content: seo.twitterCard ?? "summary_large_image",
286
+ });
287
+ if (seo.twitterSite) push({ name: "twitter:site", content: seo.twitterSite });
288
+ if (ogTitle) push({ name: "twitter:title", content: ogTitle });
289
+ if (ogDescription)
290
+ push({ name: "twitter:description", content: ogDescription });
291
+ if (ogImage) push({ name: "twitter:image", content: ogImage });
292
+
293
+ for (const tag of seo.extraMeta ?? []) push(tag);
294
+
295
+ const links: LinkTag[] = [];
296
+ if (canonical) links.push({ rel: "canonical", href: canonical });
297
+ for (const [lang, href] of Object.entries(seo.hreflang ?? {})) {
298
+ const resolved = absoluteUrl(href, seo.baseUrl);
299
+ if (resolved)
300
+ links.push({ rel: "alternate", href: resolved, hreflang: lang });
301
+ }
302
+
303
+ return {
304
+ title,
305
+ description,
306
+ canonical,
307
+ robots,
308
+ noIndex,
309
+ meta,
310
+ links,
311
+ jsonLd: seo.jsonLd ?? [],
312
+ };
313
+ }
314
+
315
+ // ─────────────────────────────────────────────────────────────────────────────
316
+ // Framework adapters
317
+ //
318
+ // Each returns the plain shape its framework expects. None imports a framework,
319
+ // so this module stays dependency-free and usable from all of them.
320
+ // ─────────────────────────────────────────────────────────────────────────────
321
+
322
+ /**
323
+ * Next.js App Router — return from `generateMetadata`.
324
+ *
325
+ * ```ts
326
+ * export async function generateMetadata() {
327
+ * const bundle = await client.marketing.pageBundle("home");
328
+ * return toNextMetadata(resolveSeo(layoutDefaults, platformSeoToInput(bundle.seo)));
329
+ * }
330
+ * ```
331
+ *
332
+ * JSON-LD is NOT included: Next has no metadata slot for it. Render it with
333
+ * `jsonLdScriptProps` in the page body instead — the one thing this adapter
334
+ * cannot do for you.
335
+ */
336
+ export function toNextMetadata(seo: ResolvedSeo): Record<string, unknown> {
337
+ const openGraph: Record<string, unknown> = {};
338
+ const twitter: Record<string, unknown> = {};
339
+ const other: Record<string, string> = {};
340
+
341
+ for (const tag of seo.meta) {
342
+ if (tag.property?.startsWith("og:")) {
343
+ openGraph[tag.property.slice(3)] = tag.content;
344
+ } else if (tag.name?.startsWith("twitter:")) {
345
+ twitter[tag.name.slice(8)] = tag.content;
346
+ } else if (
347
+ tag.name &&
348
+ tag.name !== "description" &&
349
+ tag.name !== "robots"
350
+ ) {
351
+ other[tag.name] = tag.content;
352
+ }
353
+ }
354
+
355
+ const metadata: Record<string, unknown> = {
356
+ ...(seo.title ? { title: seo.title } : {}),
357
+ ...(seo.description ? { description: seo.description } : {}),
358
+ // Next wants a structured robots object, not the string.
359
+ robots: { index: !seo.noIndex, follow: !seo.noIndex },
360
+ ...(seo.canonical ? { alternates: { canonical: seo.canonical } } : {}),
361
+ ...(Object.keys(openGraph).length ? { openGraph } : {}),
362
+ ...(Object.keys(twitter).length ? { twitter } : {}),
363
+ ...(Object.keys(other).length ? { other } : {}),
364
+ };
365
+
366
+ const alternates = metadata.alternates as
367
+ | { canonical?: string; languages?: Record<string, string> }
368
+ | undefined;
369
+ const languages: Record<string, string> = {};
370
+ for (const link of seo.links) {
371
+ if (link.rel === "alternate" && link.hreflang) {
372
+ languages[link.hreflang] = link.href;
373
+ }
374
+ }
375
+ if (Object.keys(languages).length > 0) {
376
+ metadata.alternates = { ...(alternates ?? {}), languages };
377
+ }
378
+
379
+ return metadata;
380
+ }
381
+
382
+ /** One entry of a Remix / React Router 7 `meta` export. */
383
+ export type RemixMetaDescriptor = Record<string, unknown>;
384
+
385
+ /**
386
+ * Remix / React Router 7 — return from the route's `meta` export.
387
+ *
388
+ * JSON-LD rides along as a `script` descriptor, which Remix renders natively —
389
+ * so unlike Next, structured data needs nothing extra.
390
+ */
391
+ export function toRemixMeta(seo: ResolvedSeo): RemixMetaDescriptor[] {
392
+ const out: RemixMetaDescriptor[] = [];
393
+ if (seo.title) out.push({ title: seo.title });
394
+ for (const tag of seo.meta) {
395
+ out.push(
396
+ tag.property
397
+ ? { property: tag.property, content: tag.content }
398
+ : { name: tag.name, content: tag.content },
399
+ );
400
+ }
401
+ for (const link of seo.links) {
402
+ out.push({ tagName: "link", ...link });
403
+ }
404
+ for (const node of seo.jsonLd) {
405
+ out.push({ "script:ld+json": node });
406
+ }
407
+ return out;
408
+ }
409
+
410
+ /**
411
+ * Nuxt — pass straight to `useHead()`.
412
+ *
413
+ * `script` carries the JSON-LD; Nuxt serialises the object for you.
414
+ */
415
+ export function toNuxtHead(seo: ResolvedSeo): Record<string, unknown> {
416
+ return {
417
+ ...(seo.title ? { title: seo.title } : {}),
418
+ meta: seo.meta.map((tag) =>
419
+ tag.property
420
+ ? { property: tag.property, content: tag.content }
421
+ : { name: tag.name, content: tag.content },
422
+ ),
423
+ link: seo.links.map((link) => ({ ...link })),
424
+ script: seo.jsonLd.map((node) => ({
425
+ type: "application/ld+json",
426
+ innerHTML: JSON.stringify(node),
427
+ })),
428
+ };
429
+ }
430
+
431
+ /** Qwik City — return from the route's `head` / `DocumentHead`. */
432
+ export function toQwikDocumentHead(seo: ResolvedSeo): Record<string, unknown> {
433
+ return {
434
+ ...(seo.title ? { title: seo.title } : {}),
435
+ meta: seo.meta.map((tag) =>
436
+ tag.property
437
+ ? { property: tag.property, content: tag.content }
438
+ : { name: tag.name ?? "", content: tag.content },
439
+ ),
440
+ links: seo.links.map((link) => ({ ...link })),
441
+ // Qwik has no JSON-LD slot on DocumentHead; carry it in `frontmatter`
442
+ // so the route can render it into the body.
443
+ frontmatter: { jsonLd: seo.jsonLd },
444
+ };
445
+ }
446
+
447
+ /** TanStack Start — return from a route's `head()`. */
448
+ export function toTanStackHead(seo: ResolvedSeo): Record<string, unknown> {
449
+ const meta: Record<string, unknown>[] = [];
450
+ if (seo.title) meta.push({ title: seo.title });
451
+ for (const tag of seo.meta) {
452
+ meta.push(
453
+ tag.property
454
+ ? { property: tag.property, content: tag.content }
455
+ : { name: tag.name, content: tag.content },
456
+ );
457
+ }
458
+ return {
459
+ meta,
460
+ links: seo.links.map((link) => ({ ...link })),
461
+ scripts: seo.jsonLd.map((node) => ({
462
+ type: "application/ld+json",
463
+ children: JSON.stringify(node),
464
+ })),
465
+ };
466
+ }
467
+
468
+ /** One head tag, for templating frameworks that build `<head>` by hand. */
469
+ export type HeadTag = {
470
+ tag: "title" | "meta" | "link" | "script";
471
+ attrs: Record<string, string>;
472
+ /** Only set for `script` — the JSON-LD payload. */
473
+ children?: string;
474
+ };
475
+
476
+ /**
477
+ * A flat tag list for Astro, SvelteKit, Angular, HTMX or anything else that
478
+ * writes `<head>` itself.
479
+ *
480
+ * Deliberately the lowest common denominator: a framework that has no
481
+ * metadata API can still loop this into markup, and one that does can map it.
482
+ */
483
+ export function toHeadTags(seo: ResolvedSeo): HeadTag[] {
484
+ const tags: HeadTag[] = [];
485
+ if (seo.title) tags.push({ tag: "title", attrs: {}, children: seo.title });
486
+ for (const tag of seo.meta) {
487
+ tags.push({
488
+ tag: "meta",
489
+ attrs: tag.property
490
+ ? { property: tag.property, content: tag.content }
491
+ : { name: tag.name ?? "", content: tag.content },
492
+ });
493
+ }
494
+ for (const link of seo.links) {
495
+ const attrs: Record<string, string> = { rel: link.rel, href: link.href };
496
+ if (link.hreflang) attrs.hreflang = link.hreflang;
497
+ tags.push({ tag: "link", attrs });
498
+ }
499
+ for (const node of seo.jsonLd) {
500
+ tags.push({
501
+ tag: "script",
502
+ attrs: { type: "application/ld+json" },
503
+ children: JSON.stringify(node),
504
+ });
505
+ }
506
+ return tags;
507
+ }
508
+
509
+ /**
510
+ * Render the tag list as an HTML string.
511
+ *
512
+ * For server-rendered templates that concatenate `<head>` — Laravel, Phoenix,
513
+ * plain Node, HTMX. Values are escaped: SEO text is org-authored, and an
514
+ * unescaped `"` in a meta description would otherwise break out of the
515
+ * attribute and mangle the document.
516
+ */
517
+ export function renderHeadTags(seo: ResolvedSeo): string {
518
+ const escapeAttr = (value: string) =>
519
+ value
520
+ .replace(/&/g, "&amp;")
521
+ .replace(/"/g, "&quot;")
522
+ .replace(/</g, "&lt;")
523
+ .replace(/>/g, "&gt;");
524
+
525
+ return toHeadTags(seo)
526
+ .map((tag) => {
527
+ const attrs = Object.entries(tag.attrs)
528
+ .map(([key, value]) => `${key}="${escapeAttr(value)}"`)
529
+ .join(" ");
530
+ if (tag.tag === "title") {
531
+ return `<title>${escapeAttr(tag.children ?? "")}</title>`;
532
+ }
533
+ if (tag.tag === "script") {
534
+ // JSON-LD goes in a script body, where `<` is what actually
535
+ // breaks out. Escaping the whole payload would corrupt the JSON,
536
+ // so only the sequence that can close the tag is neutralised.
537
+ const payload = (tag.children ?? "").replace(/</g, "\\u003c");
538
+ return `<script ${attrs}>${payload}</script>`;
539
+ }
540
+ return `<${tag.tag} ${attrs}>`;
541
+ })
542
+ .join("\n");
543
+ }
544
+
545
+ /**
546
+ * Apply the resolved SEO to the live document.
547
+ *
548
+ * For vanilla JS and any client-side router that changes pages without a
549
+ * reload. Existing tags this module owns are REPLACED rather than appended —
550
+ * otherwise every navigation adds another `og:title` and crawlers pick one at
551
+ * random.
552
+ */
553
+ export function applyToDocument(
554
+ seo: ResolvedSeo,
555
+ doc: Document | undefined = typeof document === "undefined"
556
+ ? undefined
557
+ : document,
558
+ ): void {
559
+ if (!doc) return;
560
+ const OWNED = "data-biab-seo";
561
+
562
+ for (const existing of Array.from(doc.head.querySelectorAll(`[${OWNED}]`))) {
563
+ existing.remove();
564
+ }
565
+ if (seo.title !== null) doc.title = seo.title;
566
+
567
+ for (const tag of toHeadTags(seo)) {
568
+ if (tag.tag === "title") continue;
569
+ const element = doc.createElement(tag.tag);
570
+ for (const [key, value] of Object.entries(tag.attrs)) {
571
+ element.setAttribute(key, value);
572
+ }
573
+ if (tag.children) element.textContent = tag.children;
574
+ element.setAttribute(OWNED, "");
575
+ doc.head.appendChild(element);
576
+ }
577
+ }
578
+
579
+ // Blog and storefront listings.
580
+ export {
581
+ type BlogListingSeoInput,
582
+ type BlogPostSeoInput,
583
+ type BlogPostSeoSource,
584
+ blogListingSeo,
585
+ blogPostSeo,
586
+ } from "./blog.js";
587
+ export {
588
+ type ListedProduct,
589
+ type ProductListingSeoInput,
590
+ productListingSeo,
591
+ transactionalPageSeo,
592
+ } from "./commerce.js";
593
+ // Local SEO — service areas x services, and products. Re-exported so one
594
+ // import covers a whole local landing page.
595
+ export {
596
+ type LocalPagePlan,
597
+ type LocalProductPageInput,
598
+ type LocalServicePageInput,
599
+ localServicePageSeo,
600
+ productPageSeo,
601
+ type ServiceArea,
602
+ type ServiceOffering,
603
+ type ServicesTimesAreasInput,
604
+ servicesTimesAreas,
605
+ } from "./local.js";