@solutiondesk/blog 0.0.0-stage → 1.0.0-69d9f69
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 +21 -0
- package/README.md +196 -2
- package/dist/index.cjs +356 -0
- package/dist/index.d.cts +349 -0
- package/dist/index.d.ts +349 -0
- package/dist/index.js +344 -0
- package/package.json +55 -3
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,3 +1,197 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @solutiondesk/blog
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @solutiondesk/blog
|
|
11
|
+
```
|
|
12
|
+
|
|
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.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createBlogClient } from "@solutiondesk/blog";
|
|
19
|
+
|
|
20
|
+
const blog = createBlogClient({ workspace: "acme", lang: "en" });
|
|
21
|
+
|
|
22
|
+
const { posts, nextCursor } = await blog.listPosts({ limit: 10 });
|
|
23
|
+
const { post } = await blog.getPost("metadata-that-pays-off");
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`workspace` is your workspace's slug, the `acme` in the blog address shown under **Blog → Website** (`https://api.solutiondesk.io/v1/blog/acme`).
|
|
27
|
+
|
|
28
|
+
## What you can read
|
|
29
|
+
|
|
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). |
|
|
39
|
+
|
|
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.
|
|
41
|
+
|
|
42
|
+
## Example: Next.js App Router
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
// app/blog/page.tsx
|
|
46
|
+
import { createBlogClient } from "@solutiondesk/blog";
|
|
47
|
+
|
|
48
|
+
const blog = createBlogClient({ workspace: "acme", lang: "en" });
|
|
49
|
+
|
|
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;
|