@solutiondesk/blog 0.0.0 → 1.0.0-239896d

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wemogy GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,45 +1,197 @@
1
1
  # @solutiondesk/blog
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Typed client for the public [SolutionDesk](https://solutiondesk.io) blog API. Your team writes the articles in SolutionDesk; your website reads them with this package.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ - No dependencies, ESM and CommonJS, full TypeScript types.
6
+ - Runs wherever `fetch` does: Node.js 18+, Deno, Bun, edge runtimes and browsers.
7
+ - No key and no login: the API only ever returns published articles.
6
8
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
9
+ ```bash
10
+ npm install @solutiondesk/blog
11
+ ```
8
12
 
9
- ## Purpose
13
+ The guide [Blog on your website](https://help.solutiondesk.io/docs/team/blog-website) explains the API behind this package and where to set it up in SolutionDesk.
10
14
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@solutiondesk/blog`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
15
+ ## Quick start
15
16
 
16
- ## What is OIDC Trusted Publishing?
17
+ ```ts
18
+ import { createBlogClient } from "@solutiondesk/blog";
17
19
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
20
+ const blog = createBlogClient({ workspace: "acme", lang: "en" });
19
21
 
20
- ## Setup Instructions
22
+ const { posts, nextCursor } = await blog.listPosts({ limit: 10 });
23
+ const { post } = await blog.getPost("metadata-that-pays-off");
24
+ ```
21
25
 
22
- To properly configure OIDC trusted publishing for this package:
26
+ `workspace` is your workspace's slug, the `acme` in the blog address shown under **Blog → Website** (`https://api.solutiondesk.io/v1/blog/acme`).
23
27
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
28
+ ## What you can read
28
29
 
29
- ## DO NOT USE THIS PACKAGE
30
+ | Method | API | Returns |
31
+ | --- | --- | --- |
32
+ | `listPosts({ lang, tag, author, limit, cursor })` | `GET /v1/blog/{workspace}/posts` | One page, newest first, without the text; `nextCursor` for the next page (`limit` 1–50, default 20). |
33
+ | `allPosts({ lang, tag, author })` | the same, page by page | An async iterator over every published post, e.g. for a sitemap. |
34
+ | `getPost(slug, { lang, redirect })` | `GET /v1/blog/{workspace}/posts/{slug}` | One post with `markdown`, `html`, `headings` and `readingMinutes`. |
35
+ | `listTags({ lang })` | `GET /v1/blog/{workspace}/tags` | Every tag with its label and how many published posts carry it. |
36
+ | `listAuthors({ lang })` | `GET /v1/blog/{workspace}/authors` | Every author with their role. |
37
+ | `getPreview(token, { lang })` | `GET /v1/blog/{workspace}/preview?token=` | The current state of a post, drafts too, for your preview route. |
38
+ | `imageUrl(url, width)` | | The image at the next offered width up (320, 640, 960, 1280, 1920). |
30
39
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
40
+ `html` is safe to insert as it is: no scripts, links only to web addresses and paths on your site, images only from your blog. Headings carry `id`s for a table of contents. A tag or author key that doesn't exist matches nothing, never everything.
36
41
 
37
- ## More Information
42
+ ## Example: Next.js App Router
38
43
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
44
+ ```tsx
45
+ // app/blog/page.tsx
46
+ import { createBlogClient } from "@solutiondesk/blog";
42
47
 
43
- ---
48
+ const blog = createBlogClient({ workspace: "acme", lang: "en" });
44
49
 
45
- **Maintained for OIDC setup purposes only**
50
+ export default async function BlogPage() {
51
+ const { posts } = await blog.listPosts({ limit: 20 }, { fetchOptions: { next: { tags: ["blog"] } } });
52
+ return (
53
+ <ul>
54
+ {posts.map((post) => (
55
+ <li key={post.slug} lang={post.locale}>
56
+ <a href={`/blog/${post.slug}`}>{post.title}</a> · {post.readingMinutes} min
57
+ </li>
58
+ ))}
59
+ </ul>
60
+ );
61
+ }
62
+ ```
63
+
64
+ ```tsx
65
+ // app/blog/[slug]/page.tsx
66
+ import { createBlogClient, SolutionDeskError } from "@solutiondesk/blog";
67
+ import { notFound, permanentRedirect } from "next/navigation";
68
+
69
+ const blog = createBlogClient({ workspace: "acme", lang: "en" });
70
+
71
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
72
+ const { slug } = await params;
73
+ try {
74
+ const { post, movedFrom } = await blog.getPost(slug, {}, { fetchOptions: { next: { tags: ["blog"] } } });
75
+ if (movedFrom) permanentRedirect(`/blog/${post.slug}`);
76
+ return <article lang={post.locale} dangerouslySetInnerHTML={{ __html: post.html }} />;
77
+ } catch (error) {
78
+ if (error instanceof SolutionDeskError && error.isNotFound) notFound();
79
+ throw error;
80
+ }
81
+ }
82
+ ```
83
+
84
+ Anything that runs on a server works the same way: Astro, Remix, SvelteKit, Nuxt or a plain Node script that renders your site at build time.
85
+
86
+ ## Moved articles
87
+
88
+ When the team changes an article's address, the old slug answers with a `301` to the new one.
89
+
90
+ - Default (`redirect: "follow"`): the client follows it and returns the post. `movedFrom` holds the slug you asked for (else `null`) — redirect your page to `post.slug`, as in the example above.
91
+ - `redirect: "manual"`: you get `{ redirectTo: "new-slug" }` instead of the post.
92
+
93
+ ```ts
94
+ const result = await blog.getPost("old-slug", { redirect: "manual" });
95
+ if ("redirectTo" in result) return Response.redirect(`https://www.acme.com/blog/${result.redirectTo}`, 301);
96
+ ```
97
+
98
+ ## Languages
99
+
100
+ Pass `lang` to the client (default for every call) or per call. When an article doesn't exist in that language you get its main language. Every post says which language its text is really in (`locale`) and every language it exists in (`languages`, main language first) — use `locale` for `lang`, `hreflang` and structured data.
101
+
102
+ ```ts
103
+ const { post } = await blog.getPost("hello", { lang: "de" });
104
+ post.locale; // "de", or the main language when there's no German version
105
+ post.languages; // ["en", "de"]
106
+ ```
107
+
108
+ ## Images
109
+
110
+ Image addresses are public and cached for a year. Add a width with `imageUrl(url, width)` to get a WebP at most that wide; the cover has a ready `srcset` and its `width`/`height`. Images inside `html` already come with a `srcset`.
111
+
112
+ ```ts
113
+ // image-loader.ts — Next.js: images.loader "custom", images.loaderFile "./image-loader.ts"
114
+ import { imageUrl } from "@solutiondesk/blog";
115
+
116
+ export default function solutionDeskLoader({ src, width }: { src: string; width: number }) {
117
+ return imageUrl(src, width);
118
+ }
119
+ ```
120
+
121
+ ## Caching and ETags
122
+
123
+ Answers are sent with `Cache-Control: public, max-age=60, stale-while-revalidate=600` and an `ETag`. With `etagCache: true` the client remembers each answer's ETag and sends `If-None-Match`; an unchanged answer is a body-less `304` and you get the remembered data back.
124
+
125
+ ```ts
126
+ const blog = createBlogClient({ workspace: "acme", etagCache: true }); // or etagCache: new Map()
127
+ ```
128
+
129
+ Per call, `fetchOptions` is handed to `fetch` unchanged — use it for your framework's caching (Next.js `{ next: { revalidate: 300, tags: ["blog"] } }`) or `{ cache: "no-store" }`. Previews are never cached by the API; in Next.js pass `fetchOptions: { cache: "no-store" }` to `getPreview`.
130
+
131
+ ## Webhooks: refresh when something changes
132
+
133
+ Under **Blog → Website** set **Tell your website about changes** to a URL on your site and copy the signing secret (shown once). SolutionDesk then `POST`s whenever readers would see something different. Check the `X-SolutionDesk-Signature` header before acting on it:
134
+
135
+ ```ts
136
+ // app/api/solutiondesk/blog/route.ts (Next.js)
137
+ import { parseBlogWebhook } from "@solutiondesk/blog";
138
+ import { revalidatePath, revalidateTag } from "next/cache";
139
+
140
+ export async function POST(request: Request) {
141
+ const body = await parseBlogWebhook(
142
+ process.env.SOLUTIONDESK_BLOG_WEBHOOK_SECRET!,
143
+ await request.text(), // the raw body, exactly as received
144
+ request.headers.get("x-solutiondesk-signature"),
145
+ );
146
+ if (!body) return new Response("Invalid signature", { status: 401 });
147
+ if (body.event !== "ping") {
148
+ revalidateTag("blog");
149
+ for (const slug of [body.slug, ...body.previousSlugs]) if (slug) revalidatePath(`/blog/${slug}`);
150
+ }
151
+ return new Response(null, { status: 204 });
152
+ }
153
+ ```
154
+
155
+ `event` is `post.published`, `post.updated`, `post.unpublished`, `post.deleted` or `ping` (**Send a test**). `parseBlogWebhook` returns `null` for a wrong secret, a changed body, a malformed header or a signature older than five minutes. If you only need the check, use `verifyWebhookSignature(secret, rawBody, header)`, which resolves to `true` or `false`. It uses Web Crypto. On Node 18 pass `{ crypto: require("node:crypto").webcrypto }` as the fourth argument; Node 19 and later need nothing.
156
+
157
+ Answer with a `2xx` status; anything else is retried a few times.
158
+
159
+ ## Errors
160
+
161
+ Every failed request rejects with a `SolutionDeskError`:
162
+
163
+ ```ts
164
+ import { SolutionDeskError } from "@solutiondesk/blog";
165
+
166
+ try {
167
+ await blog.getPost("nope");
168
+ } catch (error) {
169
+ if (error instanceof SolutionDeskError) {
170
+ error.status; // 404 — unknown workspace, blog turned off or unknown post look the same on purpose
171
+ error.message; // the API's message, e.g. "This blog or article does not exist."
172
+ error.body; // the parsed error body
173
+ error.url; // the requested URL
174
+ }
175
+ }
176
+ ```
177
+
178
+ `status` is `0` when no answer arrived (network error, aborted request). `isNotFound` and `isRateLimited` (`429`) are shortcuts. Each address may be asked 600 times a minute per IP address, the preview 60 times.
179
+
180
+ ## Options
181
+
182
+ ```ts
183
+ createBlogClient({
184
+ workspace: "acme", // required
185
+ lang: "en", // default language
186
+ baseUrl: "https://api.solutiondesk.io", // the API origin
187
+ fetch: customFetch, // e.g. a fetch with tracing
188
+ headers: { "x-request-id": "…" }, // sent with every request
189
+ etagCache: true, // or your own Map
190
+ });
191
+ ```
192
+
193
+ Every method also takes request options as its last argument: `{ signal, headers, fetchOptions }`.
194
+
195
+ ## License
196
+
197
+ MIT © wemogy GmbH
package/dist/index.cjs ADDED
@@ -0,0 +1,356 @@
1
+ 'use strict';
2
+
3
+ var __defProp = Object.defineProperty;
4
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
5
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
6
+
7
+ // ../sdk-core/src/http.ts
8
+ var DEFAULT_BASE_URL = "https://api.solutiondesk.io";
9
+ var SolutionDeskError = class extends Error {
10
+ constructor(message, init) {
11
+ super(message);
12
+ /** The HTTP status; `0` when no answer arrived (network error, aborted). */
13
+ __publicField(this, "status");
14
+ /** The parsed JSON body of the error answer (usually `{ error: string }`), else its text. */
15
+ __publicField(this, "body");
16
+ /** The requested URL. */
17
+ __publicField(this, "url");
18
+ /** Messages per input field on a `400` (e.g. `{ email: "Enter a valid email address." }`). */
19
+ __publicField(this, "fields");
20
+ this.name = "SolutionDeskError";
21
+ this.status = init.status;
22
+ this.body = init.body ?? null;
23
+ this.url = init.url;
24
+ this.fields = fieldsOf(init.body);
25
+ if (init.cause !== void 0) {
26
+ Object.defineProperty(this, "cause", { value: init.cause, enumerable: false });
27
+ }
28
+ }
29
+ /** `404`: unknown workspace, module turned off or unknown item — on purpose indistinguishable. */
30
+ get isNotFound() {
31
+ return this.status === 404;
32
+ }
33
+ /** `429`: over the rate limit; try again later. */
34
+ get isRateLimited() {
35
+ return this.status === 429;
36
+ }
37
+ };
38
+ function fieldsOf(body) {
39
+ if (!body || typeof body !== "object") return {};
40
+ const fields = body.fields;
41
+ if (!fields || typeof fields !== "object") return {};
42
+ const out = {};
43
+ for (const [key, value] of Object.entries(fields)) {
44
+ if (typeof value === "string") out[key] = value;
45
+ }
46
+ return out;
47
+ }
48
+ function messageOf(body, status) {
49
+ if (body && typeof body === "object") {
50
+ const { error, message, statusCode } = body;
51
+ if (typeof statusCode === "number" && typeof message === "string" && message) return message;
52
+ if (typeof error === "string" && error) return error;
53
+ if (typeof message === "string" && message) return message;
54
+ if (Array.isArray(message) && typeof message[0] === "string") return message[0];
55
+ }
56
+ if (typeof body === "string" && body.trim() && body.length <= 300) return body.trim();
57
+ return `SolutionDesk answered ${status}.`;
58
+ }
59
+ function createMemoryEtagCache(maxEntries = 500) {
60
+ const entries = /* @__PURE__ */ new Map();
61
+ return {
62
+ get: (key) => entries.get(key),
63
+ set(key, value) {
64
+ entries.delete(key);
65
+ entries.set(key, value);
66
+ if (entries.size > maxEntries) {
67
+ const oldest = entries.keys().next().value;
68
+ if (oldest !== void 0) entries.delete(oldest);
69
+ }
70
+ }
71
+ };
72
+ }
73
+ function queryString(query) {
74
+ if (!query) return "";
75
+ const parts = [];
76
+ for (const [key, value] of Object.entries(query)) {
77
+ if (value === void 0 || value === null || value === "") continue;
78
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
79
+ }
80
+ return parts.length ? `?${parts.join("&")}` : "";
81
+ }
82
+ var seg = (value) => encodeURIComponent(String(value));
83
+ function parseBody(text) {
84
+ if (!text) return null;
85
+ try {
86
+ return JSON.parse(text);
87
+ } catch {
88
+ return text;
89
+ }
90
+ }
91
+ function createHttp(options = {}) {
92
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "").replace(/\/v1$/, "");
93
+ const fetchImpl = options.fetch ?? globalThis.fetch;
94
+ const cache = options.etagCache === true ? createMemoryEtagCache() : options.etagCache ? options.etagCache : void 0;
95
+ const url = (path, query) => `${baseUrl}/v1${path}${queryString(query)}`;
96
+ async function requestWithMeta(req) {
97
+ if (!fetchImpl) {
98
+ throw new Error("No global fetch available: pass `fetch` to the client.");
99
+ }
100
+ const target = url(req.path, req.query);
101
+ const headers = {
102
+ accept: req.accept ?? "application/json",
103
+ ...options.headers,
104
+ ...req.options?.headers
105
+ };
106
+ if (req.body !== void 0) headers["content-type"] = "application/json";
107
+ if (req.apiKey) headers.authorization = `Bearer ${req.apiKey}`;
108
+ const cacheKey = req.method === "GET" && !req.apiKey ? target : void 0;
109
+ const cached = cacheKey && cache ? cache.get(cacheKey) : void 0;
110
+ if (cached && !Object.keys(headers).some((h) => h.toLowerCase() === "if-none-match")) {
111
+ headers["if-none-match"] = cached.etag;
112
+ }
113
+ const init = {
114
+ ...req.options?.fetchOptions,
115
+ method: req.method,
116
+ headers
117
+ };
118
+ if (req.body !== void 0) init.body = JSON.stringify(req.body);
119
+ if (req.options?.signal !== void 0) init.signal = req.options.signal;
120
+ let response;
121
+ try {
122
+ response = await fetchImpl(target, init);
123
+ } catch (cause) {
124
+ const reason = cause instanceof Error ? cause.message : String(cause);
125
+ throw new SolutionDeskError(`The request to SolutionDesk failed: ${reason}`, {
126
+ status: 0,
127
+ url: target,
128
+ cause
129
+ });
130
+ }
131
+ const finalUrl = response.url;
132
+ const meta = {
133
+ status: response.status,
134
+ finalUrl: typeof finalUrl === "string" && finalUrl ? finalUrl : null
135
+ };
136
+ if (response.status === 304 && cached) {
137
+ return { data: cached.body, ...meta };
138
+ }
139
+ const text = await response.text();
140
+ const body = response.ok && req.responseType === "text" ? text : parseBody(text);
141
+ if (!response.ok) {
142
+ throw new SolutionDeskError(messageOf(body, response.status), {
143
+ status: response.status,
144
+ body,
145
+ url: target
146
+ });
147
+ }
148
+ const etag = response.headers.get("etag");
149
+ if (cacheKey && cache && etag) cache.set(cacheKey, { etag, body });
150
+ return { data: body, ...meta };
151
+ }
152
+ return {
153
+ baseUrl,
154
+ url,
155
+ requestWithMeta,
156
+ request: async (req) => (await requestWithMeta(req)).data
157
+ };
158
+ }
159
+ function required(value, name) {
160
+ if (typeof value !== "string" || !value.trim()) {
161
+ throw new TypeError(`SolutionDesk: \`${name}\` is required.`);
162
+ }
163
+ return value;
164
+ }
165
+
166
+ // ../sdk-core/src/images.ts
167
+ var IMAGE_WIDTHS = [320, 640, 960, 1280, 1920];
168
+ function imageWidthFor(width) {
169
+ return IMAGE_WIDTHS.find((w) => w >= width) ?? 1920;
170
+ }
171
+ function imageUrl(url, width) {
172
+ if (width === void 0) return url;
173
+ const base = url.replace(/([?&])w=\d+(&|$)/, (_m, sep, end) => end ? sep : "");
174
+ const join = base.includes("?") ? /[?&]$/.test(base) ? "" : "&" : "?";
175
+ return `${base}${join}w=${imageWidthFor(width)}`;
176
+ }
177
+
178
+ // ../sdk-core/src/webhook.ts
179
+ var SIGNATURE_HEADER = "x-solutiondesk-signature";
180
+ var EVENT_HEADER = "x-solutiondesk-event";
181
+ function encoder() {
182
+ const Encoder = globalThis.TextEncoder;
183
+ if (!Encoder) throw new Error("TextEncoder is not available in this runtime.");
184
+ return new Encoder();
185
+ }
186
+ function bytesOf(body) {
187
+ if (typeof body === "string") return encoder().encode(body);
188
+ return body instanceof Uint8Array ? body : new Uint8Array(body);
189
+ }
190
+ function webhookBodyText(body) {
191
+ if (typeof body === "string") return body;
192
+ const Decoder = globalThis.TextDecoder;
193
+ if (!Decoder) throw new Error("TextDecoder is not available in this runtime.");
194
+ return new Decoder().decode(bytesOf(body));
195
+ }
196
+ function hex(buffer) {
197
+ let out = "";
198
+ for (const byte of new Uint8Array(buffer)) out += byte.toString(16).padStart(2, "0");
199
+ return out;
200
+ }
201
+ function constantTimeEqual(a, b) {
202
+ if (a.length !== b.length) return false;
203
+ let diff = 0;
204
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
205
+ return diff === 0;
206
+ }
207
+ function parseHeader(header) {
208
+ let t = Number.NaN;
209
+ const signatures = [];
210
+ for (const part of header.split(",")) {
211
+ const index = part.indexOf("=");
212
+ if (index < 0) continue;
213
+ const key = part.slice(0, index).trim();
214
+ const value = part.slice(index + 1).trim();
215
+ if (key === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
216
+ else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase());
217
+ }
218
+ return Number.isFinite(t) && signatures.length > 0 ? { t, signatures } : null;
219
+ }
220
+ async function verifyWebhookSignature(secret, rawBody, header, options = {}) {
221
+ if (!secret || !header || header.length > 1e3) return false;
222
+ const parsed = parseHeader(header);
223
+ if (!parsed) return false;
224
+ const now = options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now();
225
+ const tolerance = options.toleranceSeconds ?? 300;
226
+ if (Math.abs(now / 1e3 - parsed.t) > tolerance) return false;
227
+ const subtle = options.crypto?.subtle ?? globalThis.crypto?.subtle;
228
+ if (!subtle) {
229
+ throw new Error(
230
+ "Web Crypto is not available: pass `crypto` (on Node 18: require('node:crypto').webcrypto)."
231
+ );
232
+ }
233
+ const text = encoder();
234
+ const key = await subtle.importKey(
235
+ "raw",
236
+ text.encode(secret),
237
+ { name: "HMAC", hash: "SHA-256" },
238
+ false,
239
+ ["sign"]
240
+ );
241
+ const prefix = text.encode(`${parsed.t}.`);
242
+ const body = bytesOf(rawBody);
243
+ const signed = new Uint8Array(prefix.length + body.length);
244
+ signed.set(prefix, 0);
245
+ signed.set(body, prefix.length);
246
+ const expected = hex(await subtle.sign("HMAC", key, signed));
247
+ let ok = false;
248
+ for (const signature of parsed.signatures) ok = constantTimeEqual(expected, signature) || ok;
249
+ return ok;
250
+ }
251
+ async function parseWebhook(secret, rawBody, header, options = {}) {
252
+ if (!await verifyWebhookSignature(secret, rawBody, header, options)) return null;
253
+ try {
254
+ return JSON.parse(webhookBodyText(rawBody));
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ // src/client.ts
261
+ function createBlogClient(options) {
262
+ const workspace = required(options.workspace, "workspace");
263
+ const http = createHttp(options);
264
+ const base = `/blog/${seg(workspace)}`;
265
+ const langOf = (params) => params?.lang ?? options.lang;
266
+ const listPosts = (params = {}, requestOptions) => http.request({
267
+ method: "GET",
268
+ path: `${base}/posts`,
269
+ query: {
270
+ lang: langOf(params),
271
+ tag: params.tag,
272
+ author: params.author,
273
+ limit: params.limit,
274
+ cursor: params.cursor
275
+ },
276
+ options: requestOptions
277
+ });
278
+ const fetchPost = (slug, lang, requestOptions) => http.request({
279
+ method: "GET",
280
+ path: `${base}/posts/${seg(slug)}`,
281
+ query: { lang },
282
+ options: requestOptions
283
+ });
284
+ async function getPost(slug, params = {}, requestOptions) {
285
+ required(slug, "slug");
286
+ const lang = langOf(params);
287
+ const asked = slug.toLowerCase();
288
+ if (params.redirect === "manual") {
289
+ try {
290
+ return await fetchPost(slug, lang, {
291
+ ...requestOptions,
292
+ fetchOptions: { ...requestOptions?.fetchOptions, redirect: "manual" }
293
+ });
294
+ } catch (error) {
295
+ if (!(error instanceof SolutionDeskError)) throw error;
296
+ const redirectTo = error.body?.redirectTo;
297
+ if (error.status === 301 && typeof redirectTo === "string") return { redirectTo };
298
+ if (error.status !== 0) throw error;
299
+ }
300
+ const payload2 = await fetchPost(slug, lang, requestOptions);
301
+ return payload2.post.slug !== asked ? { redirectTo: payload2.post.slug } : payload2;
302
+ }
303
+ const payload = await fetchPost(slug, lang, requestOptions);
304
+ return { ...payload, movedFrom: payload.post.slug !== asked ? slug : null };
305
+ }
306
+ return {
307
+ workspace,
308
+ listPosts,
309
+ async *allPosts(params = {}, requestOptions) {
310
+ let cursor = null;
311
+ do {
312
+ const page = await listPosts(
313
+ { ...params, limit: params.limit ?? 50, cursor },
314
+ requestOptions
315
+ );
316
+ yield* page.posts;
317
+ cursor = page.nextCursor;
318
+ } while (cursor);
319
+ },
320
+ getPost,
321
+ listTags: async (params, requestOptions) => http.request({
322
+ method: "GET",
323
+ path: `${base}/tags`,
324
+ query: { lang: langOf(params) },
325
+ options: requestOptions
326
+ }),
327
+ listAuthors: async (params, requestOptions) => http.request({
328
+ method: "GET",
329
+ path: `${base}/authors`,
330
+ query: { lang: langOf(params) },
331
+ options: requestOptions
332
+ }),
333
+ getPreview: async (token, params, requestOptions) => http.request({
334
+ method: "GET",
335
+ path: `${base}/preview`,
336
+ query: { token: required(token, "token"), lang: langOf(params) },
337
+ options: requestOptions
338
+ }),
339
+ imageUrl
340
+ };
341
+ }
342
+ function parseBlogWebhook(secret, rawBody, signatureHeader, options) {
343
+ return parseWebhook(secret, rawBody, signatureHeader, options);
344
+ }
345
+
346
+ exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
347
+ exports.EVENT_HEADER = EVENT_HEADER;
348
+ exports.IMAGE_WIDTHS = IMAGE_WIDTHS;
349
+ exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
350
+ exports.SolutionDeskError = SolutionDeskError;
351
+ exports.createBlogClient = createBlogClient;
352
+ exports.createMemoryEtagCache = createMemoryEtagCache;
353
+ exports.imageUrl = imageUrl;
354
+ exports.imageWidthFor = imageWidthFor;
355
+ exports.parseBlogWebhook = parseBlogWebhook;
356
+ exports.verifyWebhookSignature = verifyWebhookSignature;