@businessdash/sdk 0.9.60 → 0.9.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +991 -295
- package/assets/biab-sw.js +92 -0
- package/dist/account-core/index.d.ts +126 -0
- package/dist/account-core/index.d.ts.map +1 -0
- package/dist/account-core/index.js +191 -0
- package/dist/account-core/index.js.map +1 -0
- package/dist/account.d.ts +11 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +11 -0
- package/dist/account.js.map +1 -0
- package/dist/auth-handler.d.ts +1 -1
- package/dist/auth-links.d.ts +33 -0
- package/dist/auth-links.d.ts.map +1 -0
- package/dist/auth-links.js +64 -0
- package/dist/auth-links.js.map +1 -0
- package/dist/blog-core/index.d.ts +112 -0
- package/dist/blog-core/index.d.ts.map +1 -0
- package/dist/blog-core/index.js +330 -0
- package/dist/blog-core/index.js.map +1 -0
- package/dist/blog.d.ts +11 -0
- package/dist/blog.d.ts.map +1 -0
- package/dist/blog.js +11 -0
- package/dist/blog.js.map +1 -0
- package/dist/chat-core/index.d.ts +97 -0
- package/dist/chat-core/index.d.ts.map +1 -0
- package/dist/chat-core/index.js +237 -0
- package/dist/chat-core/index.js.map +1 -0
- package/dist/chat.d.ts +11 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +11 -0
- package/dist/chat.js.map +1 -0
- package/dist/checkout-core/index.d.ts +65 -0
- package/dist/checkout-core/index.d.ts.map +1 -0
- package/dist/checkout-core/index.js +159 -0
- package/dist/checkout-core/index.js.map +1 -0
- package/dist/checkout.d.ts +10 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +10 -0
- package/dist/checkout.js.map +1 -0
- package/dist/cli.js +133 -25
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +622 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +888 -1
- package/dist/client.js.map +1 -1
- package/dist/consent-core/index.cjs +107 -0
- package/dist/consent-core/index.d.ts +103 -0
- package/dist/consent-core/index.d.ts.map +1 -0
- package/dist/consent-core/index.js +157 -0
- package/dist/consent-core/index.js.map +1 -0
- package/dist/contracts.d.ts +3526 -767
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +571 -4
- package/dist/contracts.js.map +1 -1
- package/dist/data-model-schema.d.ts +1 -1
- package/dist/data-model-schema.d.ts.map +1 -1
- package/dist/element.d.ts +30 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +56 -0
- package/dist/element.js.map +1 -1
- package/dist/embed-protocol.d.ts +86 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +60 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/framework-support.d.ts +122 -0
- package/dist/framework-support.d.ts.map +1 -0
- package/dist/framework-support.js +323 -0
- package/dist/framework-support.js.map +1 -0
- package/dist/index.cjs +1355 -24
- package/dist/legal-core/index.cjs +115 -0
- package/dist/legal-core/index.d.ts +160 -0
- package/dist/legal-core/index.d.ts.map +1 -0
- package/dist/legal-core/index.js +220 -0
- package/dist/legal-core/index.js.map +1 -0
- package/dist/marketing-core/index.d.ts +66 -0
- package/dist/marketing-core/index.d.ts.map +1 -0
- package/dist/marketing-core/index.js +154 -0
- package/dist/marketing-core/index.js.map +1 -0
- package/dist/marketing.d.ts +7 -0
- package/dist/marketing.d.ts.map +1 -0
- package/dist/marketing.js +7 -0
- package/dist/marketing.js.map +1 -0
- package/dist/mcp-adapters/index.d.ts +135 -0
- package/dist/mcp-adapters/index.d.ts.map +1 -0
- package/dist/mcp-adapters/index.js +187 -0
- package/dist/mcp-adapters/index.js.map +1 -0
- package/dist/mcp-adapters.d.ts +7 -0
- package/dist/mcp-adapters.d.ts.map +1 -0
- package/dist/mcp-adapters.js +7 -0
- package/dist/mcp-adapters.js.map +1 -0
- package/dist/model3d-core/index.cjs +95 -0
- package/dist/model3d-core/index.d.ts +167 -0
- package/dist/model3d-core/index.d.ts.map +1 -0
- package/dist/model3d-core/index.js +158 -0
- package/dist/model3d-core/index.js.map +1 -0
- package/dist/notifications-core/index.cjs +315 -0
- package/dist/notifications-core/index.d.ts +229 -0
- package/dist/notifications-core/index.d.ts.map +1 -0
- package/dist/notifications-core/index.js +445 -0
- package/dist/notifications-core/index.js.map +1 -0
- package/dist/portal-core/index.d.ts +451 -0
- package/dist/portal-core/index.d.ts.map +1 -0
- package/dist/portal-core/index.js +819 -0
- package/dist/portal-core/index.js.map +1 -0
- package/dist/portal.d.ts +7 -0
- package/dist/portal.d.ts.map +1 -0
- package/dist/portal.js +7 -0
- package/dist/portal.js.map +1 -0
- package/dist/push-core/index.cjs +171 -0
- package/dist/push-core/index.d.ts +149 -0
- package/dist/push-core/index.d.ts.map +1 -0
- package/dist/push-core/index.js +231 -0
- package/dist/push-core/index.js.map +1 -0
- package/dist/react-marketing.d.ts +1 -17
- package/dist/react-marketing.d.ts.map +1 -1
- package/dist/react-marketing.js +4 -0
- package/dist/react-marketing.js.map +1 -1
- package/dist/react.d.ts +294 -101
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +302 -246
- package/dist/react.js.map +1 -1
- package/dist/seed-core/index.d.ts +354 -0
- package/dist/seed-core/index.d.ts.map +1 -0
- package/dist/seed-core/index.js +420 -0
- package/dist/seed-core/index.js.map +1 -0
- package/dist/seed.d.ts +7 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +7 -0
- package/dist/seed.js.map +1 -0
- package/dist/seo-core/blog.d.ts +94 -0
- package/dist/seo-core/blog.d.ts.map +1 -0
- package/dist/seo-core/blog.js +149 -0
- package/dist/seo-core/blog.js.map +1 -0
- package/dist/seo-core/commerce.d.ts +64 -0
- package/dist/seo-core/commerce.d.ts.map +1 -0
- package/dist/seo-core/commerce.js +87 -0
- package/dist/seo-core/commerce.js.map +1 -0
- package/dist/seo-core/index.cjs +751 -0
- package/dist/seo-core/index.d.ts +226 -0
- package/dist/seo-core/index.d.ts.map +1 -0
- package/dist/seo-core/index.js +455 -0
- package/dist/seo-core/index.js.map +1 -0
- package/dist/seo-core/local.d.ts +181 -0
- package/dist/seo-core/local.d.ts.map +1 -0
- package/dist/seo-core/local.js +193 -0
- package/dist/seo-core/local.js.map +1 -0
- package/dist/sitemap-core/index.cjs +167 -0
- package/dist/sitemap-core/index.d.ts +155 -0
- package/dist/sitemap-core/index.d.ts.map +1 -0
- package/dist/sitemap-core/index.js +195 -0
- package/dist/sitemap-core/index.js.map +1 -0
- package/dist/social-core/index.d.ts +74 -0
- package/dist/social-core/index.d.ts.map +1 -0
- package/dist/social-core/index.js +72 -0
- package/dist/social-core/index.js.map +1 -0
- package/dist/social.d.ts +7 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +7 -0
- package/dist/social.js.map +1 -0
- package/dist/solid-compat.d.ts +204 -0
- package/dist/solid-compat.d.ts.map +1 -0
- package/dist/solid-compat.js +221 -0
- package/dist/solid-compat.js.map +1 -0
- package/dist/store-core/index.d.ts +141 -0
- package/dist/store-core/index.d.ts.map +1 -0
- package/dist/store-core/index.js +277 -0
- package/dist/store-core/index.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +12 -0
- package/dist/store.js.map +1 -0
- package/dist/vanilla.d.ts +111 -0
- package/dist/vanilla.d.ts.map +1 -1
- package/dist/vanilla.js +206 -0
- package/dist/vanilla.js.map +1 -1
- package/dist/verify-core/index.d.ts +98 -0
- package/dist/verify-core/index.d.ts.map +1 -0
- package/dist/verify-core/index.js +186 -0
- package/dist/verify-core/index.js.map +1 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +8 -0
- package/dist/verify.js.map +1 -0
- package/openapi.json +4516 -1311
- package/package.json +128 -11
- package/src/account-core/index.ts +309 -0
- package/src/account.ts +11 -0
- package/src/angular/headless.ts +519 -0
- package/src/angular/index.ts +3 -0
- package/src/auth-links.ts +76 -0
- package/src/blog-core/index.ts +423 -0
- package/src/blog.ts +11 -0
- package/src/chat-core/index.ts +335 -0
- package/src/chat.ts +11 -0
- package/src/checkout-core/index.ts +225 -0
- package/src/checkout.ts +10 -0
- package/src/cli.ts +184 -28
- package/src/client.ts +1097 -0
- package/src/consent-core/index.ts +213 -0
- package/src/contracts.ts +768 -11
- package/src/data-model-schema.ts +1 -1
- package/src/element.ts +102 -0
- package/src/embed-protocol.ts +100 -0
- package/src/framework-support.ts +440 -0
- package/src/legal-core/index.ts +276 -0
- package/src/marketing-core/index.ts +209 -0
- package/src/marketing.ts +7 -0
- package/src/mcp-adapters/index.ts +264 -0
- package/src/mcp-adapters.ts +7 -0
- package/src/model3d-core/index.ts +248 -0
- package/src/notifications-core/index.ts +650 -0
- package/src/portal-core/index.ts +1564 -0
- package/src/portal.ts +7 -0
- package/src/push-core/index.ts +335 -0
- package/src/qwik.tsx +602 -2
- package/src/react-marketing.tsx +5 -0
- package/src/react.tsx +434 -319
- package/src/seed-core/index.ts +735 -0
- package/src/seed.ts +7 -0
- package/src/seo-core/blog.ts +216 -0
- package/src/seo-core/commerce.ts +138 -0
- package/src/seo-core/index.ts +605 -0
- package/src/seo-core/local.ts +333 -0
- package/src/sitemap-core/index.ts +296 -0
- package/src/social-core/index.ts +103 -0
- package/src/social.ts +7 -0
- package/src/solid-compat.tsx +347 -0
- package/src/solid-surfaces.tsx +1311 -0
- package/src/solid.tsx +82 -60
- package/src/store-core/index.ts +426 -0
- package/src/store.ts +12 -0
- package/src/svelte/SocialLinks.svelte +84 -0
- package/src/svelte/headless.ts +435 -0
- package/src/svelte/index.ts +9 -0
- package/src/vanilla.ts +367 -0
- package/src/verify-core/index.ts +288 -0
- package/src/verify.ts +8 -0
- package/src/vue/headless.ts +527 -0
- package/src/vue/index.ts +3 -0
|
@@ -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, "&")
|
|
135
|
+
.replace(/</g, "<")
|
|
136
|
+
.replace(/>/g, ">")
|
|
137
|
+
.replace(/"/g, """)
|
|
138
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|