@solutiondesk/blog 0.0.0 → 1.0.0-e730b93

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.
@@ -0,0 +1,349 @@
1
+ /**
2
+ * The HTTP part shared by the public SolutionDesk SDKs. Runs on any runtime with
3
+ * a WHATWG `fetch` (Node ≥ 18, Deno, Bun, edge runtimes, browsers) — and on
4
+ * purpose names no DOM or Node type, so the published `.d.ts` files need neither
5
+ * `lib: ["dom"]` nor `@types/node`.
6
+ */
7
+ /** The production API. */
8
+ declare const DEFAULT_BASE_URL = "https://api.solutiondesk.io";
9
+ /** The response parts the SDK reads — what every `fetch` implementation returns. */
10
+ interface FetchResponseLike {
11
+ readonly status: number;
12
+ readonly ok: boolean;
13
+ readonly headers: {
14
+ get(name: string): string | null;
15
+ };
16
+ text(): Promise<string>;
17
+ }
18
+ /** What the SDK passes to `fetch` (plus whatever you set in `fetchOptions`). */
19
+ interface FetchInitLike {
20
+ method: string;
21
+ headers: Record<string, string>;
22
+ body?: string;
23
+ }
24
+ /**
25
+ * A `fetch` implementation. The global `fetch` fits, so do `undici`, `node-fetch`
26
+ * and wrappers (e.g. one that adds tracing). Declared bivariantly so a fetch with
27
+ * a richer `init` type is accepted.
28
+ */
29
+ type FetchLike = {
30
+ bivarianceHack(input: string, init?: FetchInitLike): Promise<FetchResponseLike>;
31
+ }["bivarianceHack"];
32
+ /** A response kept for `If-None-Match` revalidation. */
33
+ interface CachedResponse {
34
+ etag: string;
35
+ body: unknown;
36
+ }
37
+ /**
38
+ * Where the client keeps answers for ETag revalidation. Any `Map` fits; bring
39
+ * your own to share it between clients or to bound it differently.
40
+ */
41
+ interface EtagCache {
42
+ get(key: string): CachedResponse | undefined;
43
+ set(key: string, value: CachedResponse): unknown;
44
+ }
45
+ /** Options every client factory takes. */
46
+ interface BaseClientOptions {
47
+ /** The API origin, without `/v1`. Defaults to `https://api.solutiondesk.io`. */
48
+ baseUrl?: string;
49
+ /** A `fetch` implementation; defaults to the global one. */
50
+ fetch?: FetchLike;
51
+ /** Headers sent with every request (e.g. `accept-language` forwarded from your visitor). */
52
+ headers?: Record<string, string>;
53
+ /**
54
+ * Revalidate with `If-None-Match`: the client remembers each answer's `ETag`
55
+ * and, when the API answers `304 Not Modified`, returns the remembered body.
56
+ * `true` keeps up to 500 answers in memory; or pass your own {@link EtagCache}.
57
+ * Off by default (your framework's fetch cache may already do this).
58
+ */
59
+ etagCache?: boolean | EtagCache;
60
+ }
61
+ /** Per-call options. */
62
+ interface RequestOptions {
63
+ /** Cancels the request (an `AbortSignal`). */
64
+ signal?: unknown;
65
+ /** Extra headers for this request. */
66
+ headers?: Record<string, string>;
67
+ /**
68
+ * Extra `fetch` options for this request, passed through unchanged — e.g.
69
+ * Next.js `{ next: { revalidate: 60, tags: ["legal"] } }` or `{ cache: "no-store" }`.
70
+ */
71
+ fetchOptions?: Record<string, unknown>;
72
+ }
73
+ /** Any non-2xx answer of the API (and a failed network request, with `status` 0). */
74
+ declare class SolutionDeskError extends Error {
75
+ /** The HTTP status; `0` when no answer arrived (network error, aborted). */
76
+ readonly status: number;
77
+ /** The parsed JSON body of the error answer (usually `{ error: string }`), else its text. */
78
+ readonly body: unknown;
79
+ /** The requested URL. */
80
+ readonly url: string;
81
+ /** Messages per input field on a `400` (e.g. `{ email: "Enter a valid email address." }`). */
82
+ readonly fields: Record<string, string>;
83
+ constructor(message: string, init: {
84
+ status: number;
85
+ body?: unknown;
86
+ url: string;
87
+ cause?: unknown;
88
+ });
89
+ /** `404`: unknown workspace, module turned off or unknown item — on purpose indistinguishable. */
90
+ get isNotFound(): boolean;
91
+ /** `429`: over the rate limit; try again later. */
92
+ get isRateLimited(): boolean;
93
+ }
94
+ /** A bounded in-memory {@link EtagCache} (oldest entry dropped first). */
95
+ declare function createMemoryEtagCache(maxEntries?: number): EtagCache;
96
+
97
+ /**
98
+ * Public blog and event images come in a fixed set of widths (`<url>?w=<width>`,
99
+ * WebP, never wider than the original); the API refuses any other width.
100
+ */
101
+ declare const IMAGE_WIDTHS: readonly [320, 640, 960, 1280, 1920];
102
+ type ImageWidth = (typeof IMAGE_WIDTHS)[number];
103
+ /** The smallest offered width that is at least `width` (1920 for anything wider). */
104
+ declare function imageWidthFor(width: number): ImageWidth;
105
+ /**
106
+ * The address of an image at (at least) `width` pixels — e.g. for a Next.js
107
+ * custom image loader: `({ src, width }) => imageUrl(src, width)`. Without a
108
+ * width the original address is returned.
109
+ */
110
+ declare function imageUrl(url: string, width?: number): string;
111
+
112
+ /**
113
+ * Webhook signature check with Web Crypto only — the same code runs in Node,
114
+ * Deno, Bun, edge runtimes and browsers. The header SolutionDesk sends is
115
+ * `X-SolutionDesk-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`.
116
+ */
117
+ /** The header that carries the signature. */
118
+ declare const SIGNATURE_HEADER = "x-solutiondesk-signature";
119
+ /** The header that names the event (also in the body as `event`). */
120
+ declare const EVENT_HEADER = "x-solutiondesk-event";
121
+ interface VerifyWebhookOptions {
122
+ /** How old (or how far in the future) the signed time may be. Default 300 (5 minutes). */
123
+ toleranceSeconds?: number;
124
+ /** "Now" in milliseconds (or a `Date`) — for tests. Default `Date.now()`. */
125
+ now?: number | Date;
126
+ /**
127
+ * A Web Crypto implementation. Default `globalThis.crypto`. On Node 18 pass
128
+ * `require("node:crypto").webcrypto` (Node 19+ has it globally).
129
+ */
130
+ crypto?: {
131
+ subtle: object;
132
+ };
133
+ }
134
+ /** The raw request body, exactly as received. */
135
+ type WebhookBody = string | Uint8Array | ArrayBuffer;
136
+ /**
137
+ * Check a SolutionDesk webhook signature. `true` only when the header is well
138
+ * formed, its time lies within the tolerance and the HMAC matches — anything
139
+ * else (missing, tampered, expired, malformed) is `false`, never an exception.
140
+ *
141
+ * @param secret The webhook's signing secret (`whsec_…`), shown once in SolutionDesk.
142
+ * @param rawBody The request body exactly as received — not re-serialized JSON.
143
+ * @param header The `X-SolutionDesk-Signature` header.
144
+ */
145
+ declare function verifyWebhookSignature(secret: string, rawBody: WebhookBody, header: string | null | undefined, options?: VerifyWebhookOptions): Promise<boolean>;
146
+
147
+ /**
148
+ * The answers of the public blog API (`GET /v1/blog/{workspace}/…`). Written by
149
+ * hand so the package has no dependencies; a drift test in the SolutionDesk
150
+ * repository keeps them identical to the API's own contracts.
151
+ */
152
+ /** An author as readers see them. */
153
+ interface PublicBlogAuthor {
154
+ key: string;
155
+ name: string;
156
+ initials: string;
157
+ kind: "person" | "organization";
158
+ /** The role in the requested language (the main role when not translated). */
159
+ role: string | null;
160
+ }
161
+ interface PublicBlogTag {
162
+ key: string;
163
+ /** The label in the requested language. */
164
+ label: string;
165
+ }
166
+ /** The cover image of a post. */
167
+ interface PublicBlogImage {
168
+ url: string;
169
+ /** Every web-ready width (WebP, `<url>?w=<width>`) for `<img srcset>`. */
170
+ srcset: string;
171
+ width: number | null;
172
+ height: number | null;
173
+ alt: string | null;
174
+ /** A short caption or placeholder text. */
175
+ hint: string | null;
176
+ /** Made with AI: show a disclosure. */
177
+ ai: boolean;
178
+ credit: string | null;
179
+ }
180
+ /** The call to action at the end of a post. */
181
+ interface PublicBlogClosing {
182
+ title: string | null;
183
+ lead: string | null;
184
+ label: string | null;
185
+ href: string | null;
186
+ }
187
+ /** A heading of the text, for a table of contents. */
188
+ interface BlogHeading {
189
+ /** 1–6. */
190
+ level: number;
191
+ text: string;
192
+ /** Unique within the post; `#<id>` links to the heading. */
193
+ id: string;
194
+ }
195
+ /** A post in the list: everything but the text. */
196
+ interface PublicBlogPostSummary {
197
+ slug: string;
198
+ title: string;
199
+ description: string;
200
+ /** ISO time readers see as the publication date. */
201
+ publishedAt: string;
202
+ /** "Updated on" as the team set it, or null. */
203
+ contentUpdatedAt: string | null;
204
+ /** The last change of any kind (for caches and sitemaps). */
205
+ updatedAt: string;
206
+ authors: PublicBlogAuthor[];
207
+ tags: PublicBlogTag[];
208
+ cover: PublicBlogImage | null;
209
+ /** The language the texts above are in — may differ from the one asked for. */
210
+ locale: string;
211
+ /** Every language the post is offered in, main language first. */
212
+ languages: string[];
213
+ readingMinutes: number;
214
+ }
215
+ /** One post with its text. */
216
+ interface PublicBlogPost extends PublicBlogPostSummary {
217
+ markdown: string;
218
+ /** The Markdown as safe HTML (headings carry `id`s). */
219
+ html: string;
220
+ /** The outline, for a table of contents. */
221
+ headings: BlogHeading[];
222
+ closing: PublicBlogClosing | null;
223
+ }
224
+ /** `GET …/posts`. */
225
+ interface PublicBlogPostsPayload {
226
+ posts: PublicBlogPostSummary[];
227
+ /** Pass as `cursor` for the next page; null on the last page. */
228
+ nextCursor: string | null;
229
+ }
230
+ /** `GET …/posts/{slug}` and `GET …/preview`. */
231
+ interface PublicBlogPostPayload {
232
+ post: PublicBlogPost;
233
+ }
234
+ /** `GET …/tags`: every tag with how many published posts carry it, most used first. */
235
+ interface PublicBlogTagsPayload {
236
+ tags: (PublicBlogTag & {
237
+ count: number;
238
+ })[];
239
+ }
240
+ /** `GET …/authors`. */
241
+ interface PublicBlogAuthorsPayload {
242
+ authors: PublicBlogAuthor[];
243
+ }
244
+ /** What a blog webhook announces. */
245
+ type BlogWebhookEvent = "post.published" | "post.updated" | "post.unpublished" | "post.deleted" | "ping";
246
+ /** The body of a blog webhook call (verify it first — see `parseBlogWebhook`). */
247
+ interface BlogWebhookBody {
248
+ event: BlogWebhookEvent;
249
+ /** The workspace slug, as in the public API path. */
250
+ workspace: string;
251
+ /** The post's slug; null for `ping`. */
252
+ slug: string | null;
253
+ /** Earlier slugs of the post — their pages are stale too. */
254
+ previousSlugs: string[];
255
+ /** The languages the post is offered in after the change. */
256
+ locales: string[];
257
+ occurredAt: string;
258
+ }
259
+
260
+ interface BlogClientOptions extends BaseClientOptions {
261
+ /** Your workspace's slug — the `acme` in `https://api.solutiondesk.io/v1/blog/acme`. */
262
+ workspace: string;
263
+ /**
264
+ * The language to ask for by default (`de`, `en`, `de-AT` …). Each answer
265
+ * says in `locale` which language it really is in. Override per call.
266
+ */
267
+ lang?: string;
268
+ }
269
+ interface LangParams {
270
+ /** The language to ask for; overrides the client's `lang`. */
271
+ lang?: string;
272
+ }
273
+ interface ListPostsParams extends LangParams {
274
+ /** Only posts with this tag key. An unknown key matches nothing. */
275
+ tag?: string;
276
+ /** Only posts by this author key. An unknown key matches nothing. */
277
+ author?: string;
278
+ /** Page size, 1–50 (default 20). */
279
+ limit?: number;
280
+ /** `nextCursor` of the previous page. */
281
+ cursor?: string | null;
282
+ }
283
+ interface GetPostParams extends LangParams {
284
+ /**
285
+ * What to do when the slug is an earlier address of a post:
286
+ * - `"follow"` (default): return the post; `movedFrom` holds the slug you asked for.
287
+ * - `"manual"`: return `{ redirectTo }` with the current slug instead.
288
+ */
289
+ redirect?: "follow" | "manual";
290
+ }
291
+ /** A post fetched with `redirect: "follow"`. */
292
+ interface BlogPostResult extends PublicBlogPostPayload {
293
+ /**
294
+ * The slug you asked for when the post has moved to `post.slug` since
295
+ * (redirect your page there, e.g. with a 301); null when it hasn't.
296
+ */
297
+ movedFrom: string | null;
298
+ }
299
+ /** The post lives at another slug now (with `redirect: "manual"`). */
300
+ interface BlogPostMoved {
301
+ redirectTo: string;
302
+ }
303
+ interface BlogClient {
304
+ /** The workspace this client reads. */
305
+ readonly workspace: string;
306
+ /** Published posts, newest first, one page (`GET /v1/blog/{workspace}/posts`). */
307
+ listPosts(params?: ListPostsParams, options?: RequestOptions): Promise<PublicBlogPostsPayload>;
308
+ /** Every published post, page by page — e.g. for a sitemap or a static build. */
309
+ allPosts(params?: Omit<ListPostsParams, "cursor">, options?: RequestOptions): AsyncGenerator<PublicBlogPostSummary, void, undefined>;
310
+ /**
311
+ * One published post with its text (`GET /v1/blog/{workspace}/posts/{slug}`).
312
+ * Throws a `SolutionDeskError` with status 404 for an unknown or unpublished post.
313
+ */
314
+ getPost(slug: string, params?: GetPostParams & {
315
+ redirect?: "follow";
316
+ }, options?: RequestOptions): Promise<BlogPostResult>;
317
+ getPost(slug: string, params: GetPostParams & {
318
+ redirect: "manual";
319
+ }, options?: RequestOptions): Promise<PublicBlogPostPayload | BlogPostMoved>;
320
+ /** Every tag with how many published posts carry it, most used first. */
321
+ listTags(params?: LangParams, options?: RequestOptions): Promise<PublicBlogTagsPayload>;
322
+ /** Every author with their role in the asked-for language. */
323
+ listAuthors(params?: LangParams, options?: RequestOptions): Promise<PublicBlogAuthorsPayload>;
324
+ /**
325
+ * The current state of a post (also a draft) for your preview route — the
326
+ * `token` from your preview address (valid for one hour). The API sends it with
327
+ * `Cache-Control: no-store`; in Next.js also pass `fetchOptions: { cache: "no-store" }`.
328
+ */
329
+ getPreview(token: string, params?: LangParams, options?: RequestOptions): Promise<PublicBlogPostPayload>;
330
+ /** The address of a blog image at (at least) `width` pixels: 320, 640, 960, 1280 or 1920. */
331
+ imageUrl(url: string, width?: number): string;
332
+ }
333
+ /**
334
+ * A client for the public blog API — no key, no login; only published posts.
335
+ * Safe in the browser and on the server.
336
+ *
337
+ * @example
338
+ * const blog = createBlogClient({ workspace: "acme", lang: "en" });
339
+ * const { posts, nextCursor } = await blog.listPosts({ limit: 10 });
340
+ */
341
+ declare function createBlogClient(options: BlogClientOptions): BlogClient;
342
+ /**
343
+ * Verify a blog webhook call and return its body — or `null` when the
344
+ * `X-SolutionDesk-Signature` header doesn't match (wrong secret, changed body,
345
+ * older than 5 minutes, malformed). Pass the raw body exactly as received.
346
+ */
347
+ declare function parseBlogWebhook(secret: string, rawBody: WebhookBody, signatureHeader: string | null | undefined, options?: VerifyWebhookOptions): Promise<BlogWebhookBody | null>;
348
+
349
+ export { type BaseClientOptions, type BlogClient, type BlogClientOptions, type BlogHeading, type BlogPostMoved, type BlogPostResult, type BlogWebhookBody, type BlogWebhookEvent, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPostParams, IMAGE_WIDTHS, type ImageWidth, type LangParams, type ListPostsParams, type PublicBlogAuthor, type PublicBlogAuthorsPayload, type PublicBlogClosing, type PublicBlogImage, type PublicBlogPost, type PublicBlogPostPayload, type PublicBlogPostSummary, type PublicBlogPostsPayload, type PublicBlogTag, type PublicBlogTagsPayload, type RequestOptions, SIGNATURE_HEADER, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createBlogClient, createMemoryEtagCache, imageUrl, imageWidthFor, parseBlogWebhook, verifyWebhookSignature };
package/dist/index.js ADDED
@@ -0,0 +1,344 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ // ../sdk-core/src/http.ts
6
+ var DEFAULT_BASE_URL = "https://api.solutiondesk.io";
7
+ var SolutionDeskError = class extends Error {
8
+ constructor(message, init) {
9
+ super(message);
10
+ /** The HTTP status; `0` when no answer arrived (network error, aborted). */
11
+ __publicField(this, "status");
12
+ /** The parsed JSON body of the error answer (usually `{ error: string }`), else its text. */
13
+ __publicField(this, "body");
14
+ /** The requested URL. */
15
+ __publicField(this, "url");
16
+ /** Messages per input field on a `400` (e.g. `{ email: "Enter a valid email address." }`). */
17
+ __publicField(this, "fields");
18
+ this.name = "SolutionDeskError";
19
+ this.status = init.status;
20
+ this.body = init.body ?? null;
21
+ this.url = init.url;
22
+ this.fields = fieldsOf(init.body);
23
+ if (init.cause !== void 0) {
24
+ Object.defineProperty(this, "cause", { value: init.cause, enumerable: false });
25
+ }
26
+ }
27
+ /** `404`: unknown workspace, module turned off or unknown item — on purpose indistinguishable. */
28
+ get isNotFound() {
29
+ return this.status === 404;
30
+ }
31
+ /** `429`: over the rate limit; try again later. */
32
+ get isRateLimited() {
33
+ return this.status === 429;
34
+ }
35
+ };
36
+ function fieldsOf(body) {
37
+ if (!body || typeof body !== "object") return {};
38
+ const fields = body.fields;
39
+ if (!fields || typeof fields !== "object") return {};
40
+ const out = {};
41
+ for (const [key, value] of Object.entries(fields)) {
42
+ if (typeof value === "string") out[key] = value;
43
+ }
44
+ return out;
45
+ }
46
+ function messageOf(body, status) {
47
+ if (body && typeof body === "object") {
48
+ const { error, message, statusCode } = body;
49
+ if (typeof statusCode === "number" && typeof message === "string" && message) return message;
50
+ if (typeof error === "string" && error) return error;
51
+ if (typeof message === "string" && message) return message;
52
+ if (Array.isArray(message) && typeof message[0] === "string") return message[0];
53
+ }
54
+ if (typeof body === "string" && body.trim() && body.length <= 300) return body.trim();
55
+ return `SolutionDesk answered ${status}.`;
56
+ }
57
+ function createMemoryEtagCache(maxEntries = 500) {
58
+ const entries = /* @__PURE__ */ new Map();
59
+ return {
60
+ get: (key) => entries.get(key),
61
+ set(key, value) {
62
+ entries.delete(key);
63
+ entries.set(key, value);
64
+ if (entries.size > maxEntries) {
65
+ const oldest = entries.keys().next().value;
66
+ if (oldest !== void 0) entries.delete(oldest);
67
+ }
68
+ }
69
+ };
70
+ }
71
+ function queryString(query) {
72
+ if (!query) return "";
73
+ const parts = [];
74
+ for (const [key, value] of Object.entries(query)) {
75
+ if (value === void 0 || value === null || value === "") continue;
76
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
77
+ }
78
+ return parts.length ? `?${parts.join("&")}` : "";
79
+ }
80
+ var seg = (value) => encodeURIComponent(String(value));
81
+ function parseBody(text) {
82
+ if (!text) return null;
83
+ try {
84
+ return JSON.parse(text);
85
+ } catch {
86
+ return text;
87
+ }
88
+ }
89
+ function createHttp(options = {}) {
90
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "").replace(/\/v1$/, "");
91
+ const fetchImpl = options.fetch ?? globalThis.fetch;
92
+ const cache = options.etagCache === true ? createMemoryEtagCache() : options.etagCache ? options.etagCache : void 0;
93
+ const url = (path, query) => `${baseUrl}/v1${path}${queryString(query)}`;
94
+ async function requestWithMeta(req) {
95
+ if (!fetchImpl) {
96
+ throw new Error("No global fetch available: pass `fetch` to the client.");
97
+ }
98
+ const target = url(req.path, req.query);
99
+ const headers = {
100
+ accept: req.accept ?? "application/json",
101
+ ...options.headers,
102
+ ...req.options?.headers
103
+ };
104
+ if (req.body !== void 0) headers["content-type"] = "application/json";
105
+ if (req.apiKey) headers.authorization = `Bearer ${req.apiKey}`;
106
+ const cacheKey = req.method === "GET" && !req.apiKey ? target : void 0;
107
+ const cached = cacheKey && cache ? cache.get(cacheKey) : void 0;
108
+ if (cached && !Object.keys(headers).some((h) => h.toLowerCase() === "if-none-match")) {
109
+ headers["if-none-match"] = cached.etag;
110
+ }
111
+ const init = {
112
+ ...req.options?.fetchOptions,
113
+ method: req.method,
114
+ headers
115
+ };
116
+ if (req.body !== void 0) init.body = JSON.stringify(req.body);
117
+ if (req.options?.signal !== void 0) init.signal = req.options.signal;
118
+ let response;
119
+ try {
120
+ response = await fetchImpl(target, init);
121
+ } catch (cause) {
122
+ const reason = cause instanceof Error ? cause.message : String(cause);
123
+ throw new SolutionDeskError(`The request to SolutionDesk failed: ${reason}`, {
124
+ status: 0,
125
+ url: target,
126
+ cause
127
+ });
128
+ }
129
+ const finalUrl = response.url;
130
+ const meta = {
131
+ status: response.status,
132
+ finalUrl: typeof finalUrl === "string" && finalUrl ? finalUrl : null
133
+ };
134
+ if (response.status === 304 && cached) {
135
+ return { data: cached.body, ...meta };
136
+ }
137
+ const text = await response.text();
138
+ const body = response.ok && req.responseType === "text" ? text : parseBody(text);
139
+ if (!response.ok) {
140
+ throw new SolutionDeskError(messageOf(body, response.status), {
141
+ status: response.status,
142
+ body,
143
+ url: target
144
+ });
145
+ }
146
+ const etag = response.headers.get("etag");
147
+ if (cacheKey && cache && etag) cache.set(cacheKey, { etag, body });
148
+ return { data: body, ...meta };
149
+ }
150
+ return {
151
+ baseUrl,
152
+ url,
153
+ requestWithMeta,
154
+ request: async (req) => (await requestWithMeta(req)).data
155
+ };
156
+ }
157
+ function required(value, name) {
158
+ if (typeof value !== "string" || !value.trim()) {
159
+ throw new TypeError(`SolutionDesk: \`${name}\` is required.`);
160
+ }
161
+ return value;
162
+ }
163
+
164
+ // ../sdk-core/src/images.ts
165
+ var IMAGE_WIDTHS = [320, 640, 960, 1280, 1920];
166
+ function imageWidthFor(width) {
167
+ return IMAGE_WIDTHS.find((w) => w >= width) ?? 1920;
168
+ }
169
+ function imageUrl(url, width) {
170
+ if (width === void 0) return url;
171
+ const base = url.replace(/([?&])w=\d+(&|$)/, (_m, sep, end) => end ? sep : "");
172
+ const join = base.includes("?") ? /[?&]$/.test(base) ? "" : "&" : "?";
173
+ return `${base}${join}w=${imageWidthFor(width)}`;
174
+ }
175
+
176
+ // ../sdk-core/src/webhook.ts
177
+ var SIGNATURE_HEADER = "x-solutiondesk-signature";
178
+ var EVENT_HEADER = "x-solutiondesk-event";
179
+ function encoder() {
180
+ const Encoder = globalThis.TextEncoder;
181
+ if (!Encoder) throw new Error("TextEncoder is not available in this runtime.");
182
+ return new Encoder();
183
+ }
184
+ function bytesOf(body) {
185
+ if (typeof body === "string") return encoder().encode(body);
186
+ return body instanceof Uint8Array ? body : new Uint8Array(body);
187
+ }
188
+ function webhookBodyText(body) {
189
+ if (typeof body === "string") return body;
190
+ const Decoder = globalThis.TextDecoder;
191
+ if (!Decoder) throw new Error("TextDecoder is not available in this runtime.");
192
+ return new Decoder().decode(bytesOf(body));
193
+ }
194
+ function hex(buffer) {
195
+ let out = "";
196
+ for (const byte of new Uint8Array(buffer)) out += byte.toString(16).padStart(2, "0");
197
+ return out;
198
+ }
199
+ function constantTimeEqual(a, b) {
200
+ if (a.length !== b.length) return false;
201
+ let diff = 0;
202
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
203
+ return diff === 0;
204
+ }
205
+ function parseHeader(header) {
206
+ let t = Number.NaN;
207
+ const signatures = [];
208
+ for (const part of header.split(",")) {
209
+ const index = part.indexOf("=");
210
+ if (index < 0) continue;
211
+ const key = part.slice(0, index).trim();
212
+ const value = part.slice(index + 1).trim();
213
+ if (key === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
214
+ else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase());
215
+ }
216
+ return Number.isFinite(t) && signatures.length > 0 ? { t, signatures } : null;
217
+ }
218
+ async function verifyWebhookSignature(secret, rawBody, header, options = {}) {
219
+ if (!secret || !header || header.length > 1e3) return false;
220
+ const parsed = parseHeader(header);
221
+ if (!parsed) return false;
222
+ const now = options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now();
223
+ const tolerance = options.toleranceSeconds ?? 300;
224
+ if (Math.abs(now / 1e3 - parsed.t) > tolerance) return false;
225
+ const subtle = options.crypto?.subtle ?? globalThis.crypto?.subtle;
226
+ if (!subtle) {
227
+ throw new Error(
228
+ "Web Crypto is not available: pass `crypto` (on Node 18: require('node:crypto').webcrypto)."
229
+ );
230
+ }
231
+ const text = encoder();
232
+ const key = await subtle.importKey(
233
+ "raw",
234
+ text.encode(secret),
235
+ { name: "HMAC", hash: "SHA-256" },
236
+ false,
237
+ ["sign"]
238
+ );
239
+ const prefix = text.encode(`${parsed.t}.`);
240
+ const body = bytesOf(rawBody);
241
+ const signed = new Uint8Array(prefix.length + body.length);
242
+ signed.set(prefix, 0);
243
+ signed.set(body, prefix.length);
244
+ const expected = hex(await subtle.sign("HMAC", key, signed));
245
+ let ok = false;
246
+ for (const signature of parsed.signatures) ok = constantTimeEqual(expected, signature) || ok;
247
+ return ok;
248
+ }
249
+ async function parseWebhook(secret, rawBody, header, options = {}) {
250
+ if (!await verifyWebhookSignature(secret, rawBody, header, options)) return null;
251
+ try {
252
+ return JSON.parse(webhookBodyText(rawBody));
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ // src/client.ts
259
+ function createBlogClient(options) {
260
+ const workspace = required(options.workspace, "workspace");
261
+ const http = createHttp(options);
262
+ const base = `/blog/${seg(workspace)}`;
263
+ const langOf = (params) => params?.lang ?? options.lang;
264
+ const listPosts = (params = {}, requestOptions) => http.request({
265
+ method: "GET",
266
+ path: `${base}/posts`,
267
+ query: {
268
+ lang: langOf(params),
269
+ tag: params.tag,
270
+ author: params.author,
271
+ limit: params.limit,
272
+ cursor: params.cursor
273
+ },
274
+ options: requestOptions
275
+ });
276
+ const fetchPost = (slug, lang, requestOptions) => http.request({
277
+ method: "GET",
278
+ path: `${base}/posts/${seg(slug)}`,
279
+ query: { lang },
280
+ options: requestOptions
281
+ });
282
+ async function getPost(slug, params = {}, requestOptions) {
283
+ required(slug, "slug");
284
+ const lang = langOf(params);
285
+ const asked = slug.toLowerCase();
286
+ if (params.redirect === "manual") {
287
+ try {
288
+ return await fetchPost(slug, lang, {
289
+ ...requestOptions,
290
+ fetchOptions: { ...requestOptions?.fetchOptions, redirect: "manual" }
291
+ });
292
+ } catch (error) {
293
+ if (!(error instanceof SolutionDeskError)) throw error;
294
+ const redirectTo = error.body?.redirectTo;
295
+ if (error.status === 301 && typeof redirectTo === "string") return { redirectTo };
296
+ if (error.status !== 0) throw error;
297
+ }
298
+ const payload2 = await fetchPost(slug, lang, requestOptions);
299
+ return payload2.post.slug !== asked ? { redirectTo: payload2.post.slug } : payload2;
300
+ }
301
+ const payload = await fetchPost(slug, lang, requestOptions);
302
+ return { ...payload, movedFrom: payload.post.slug !== asked ? slug : null };
303
+ }
304
+ return {
305
+ workspace,
306
+ listPosts,
307
+ async *allPosts(params = {}, requestOptions) {
308
+ let cursor = null;
309
+ do {
310
+ const page = await listPosts(
311
+ { ...params, limit: params.limit ?? 50, cursor },
312
+ requestOptions
313
+ );
314
+ yield* page.posts;
315
+ cursor = page.nextCursor;
316
+ } while (cursor);
317
+ },
318
+ getPost,
319
+ listTags: async (params, requestOptions) => http.request({
320
+ method: "GET",
321
+ path: `${base}/tags`,
322
+ query: { lang: langOf(params) },
323
+ options: requestOptions
324
+ }),
325
+ listAuthors: async (params, requestOptions) => http.request({
326
+ method: "GET",
327
+ path: `${base}/authors`,
328
+ query: { lang: langOf(params) },
329
+ options: requestOptions
330
+ }),
331
+ getPreview: async (token, params, requestOptions) => http.request({
332
+ method: "GET",
333
+ path: `${base}/preview`,
334
+ query: { token: required(token, "token"), lang: langOf(params) },
335
+ options: requestOptions
336
+ }),
337
+ imageUrl
338
+ };
339
+ }
340
+ function parseBlogWebhook(secret, rawBody, signatureHeader, options) {
341
+ return parseWebhook(secret, rawBody, signatureHeader, options);
342
+ }
343
+
344
+ export { DEFAULT_BASE_URL, EVENT_HEADER, IMAGE_WIDTHS, SIGNATURE_HEADER, SolutionDeskError, createBlogClient, createMemoryEtagCache, imageUrl, imageWidthFor, parseBlogWebhook, verifyWebhookSignature };