@solutiondesk/blog 0.0.0-stage → 1.0.0-1cb60bf
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 +270 -2
- package/dist/index.cjs +450 -0
- package/dist/index.d.cts +499 -0
- package/dist/index.d.ts +499 -0
- package/dist/index.js +434 -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,271 @@
|
|
|
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`, `readingMinutes`, `summary`, `faq`, `seo` and the blog's own building blocks it uses (`customBlocks`, `blocks`). |
|
|
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
|
+
## Search engines, link previews and answer engines
|
|
87
|
+
|
|
88
|
+
Every post carries `seo`: what belongs in the page's `<head>`, with the fallbacks already applied — the search title or else the title, the search description or else the description, the link preview image or else the cover.
|
|
89
|
+
|
|
90
|
+
| Field | Use it for |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `seo.title`, `seo.description` | `<title>` and `<meta name="description">` |
|
|
93
|
+
| `seo.canonicalUrl` | `<link rel="canonical">` — the original address the team set, else the post's address on your site |
|
|
94
|
+
| `seo.alternates` | `<link rel="alternate" hreflang>` for every language (when the post address has `{lang}`) |
|
|
95
|
+
| `seo.noindex` | the team asked search engines not to list the post (`robots` is in `meta` too) |
|
|
96
|
+
| `seo.meta` | every meta tag: description, robots, Open Graph (`og:*`, `article:*`) and Twitter cards |
|
|
97
|
+
| `seo.jsonLd` | schema.org structured data: `BlogPosting`, and `FAQPage` when the post has a FAQ |
|
|
98
|
+
|
|
99
|
+
Canonical links, alternates, `og:url` and the address in the structured data need the post address from **Blog → Website** (e.g. `https://www.example.com/{lang}/blog/{slug}`); every post then also has its `url`. The website's name there becomes `og:site_name` and the publisher.
|
|
100
|
+
|
|
101
|
+
`renderBlogHead(post)` renders all of it as escaped HTML. With a framework's own head API, map the fields instead:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
// app/blog/[slug]/page.tsx (Next.js)
|
|
105
|
+
import { renderBlogJsonLd } from "@solutiondesk/blog";
|
|
106
|
+
|
|
107
|
+
export async function generateMetadata({ params }) {
|
|
108
|
+
const { post } = await blog.getPost((await params).slug);
|
|
109
|
+
return {
|
|
110
|
+
title: post.seo.title,
|
|
111
|
+
description: post.seo.description,
|
|
112
|
+
alternates: {
|
|
113
|
+
canonical: post.seo.canonicalUrl,
|
|
114
|
+
languages: Object.fromEntries(post.seo.alternates.map((a) => [a.locale, a.url])),
|
|
115
|
+
},
|
|
116
|
+
robots: post.seo.noindex ? { index: false, follow: true } : undefined,
|
|
117
|
+
openGraph: { images: post.seo.image ? [post.seo.image.url] : [] },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// in the page component:
|
|
122
|
+
{post.seo.jsonLd.map((data, i) => (
|
|
123
|
+
<script key={i} type="application/ld+json" dangerouslySetInnerHTML={{ __html: renderBlogJsonLd(data) }} />
|
|
124
|
+
))}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For answer engines (ChatGPT, Perplexity, Google's AI answers) a post can have a `summary` (the key points in a few sentences) and a `faq` (questions and answers). Show them on the page — answer engines quote what readers can see — the FAQ is in `seo.jsonLd` already.
|
|
128
|
+
|
|
129
|
+
`GET /v1/blog/{workspace}/llms.txt?lang=` lists every published post that search engines may list, as an [llms.txt](https://llmstxt.org) file. Serve it at `/llms.txt` on your site, e.g. with a rewrite to that address.
|
|
130
|
+
|
|
131
|
+
## Your own building blocks
|
|
132
|
+
|
|
133
|
+
In SolutionDesk, the blog can define its own building blocks under **Blog → Website**: what a block is called, whether it sits in a sentence, on its own line or around content, and which fields it has — a newsletter box, a product card, an app button. Writers add them to posts from **Block**. How a block looks is up to your website.
|
|
134
|
+
|
|
135
|
+
In `html`, every such block is a neutral element with its **text version** (set in SolutionDesk, e.g. `Subscribe to Weekly tips`), so a page without your markup still reads well:
|
|
136
|
+
|
|
137
|
+
| Kind | HTML |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| in a sentence | `<span class="sd-block" data-block="app-button" data-props="{…}">…</span>` |
|
|
140
|
+
| on its own line | `<div class="sd-block" data-block="newsletter" data-props="{…}">…</div>` |
|
|
141
|
+
| around content | `<div class="sd-block" …><p class="sd-block-text">…</p><div class="sd-block-body">…</div></div>` |
|
|
142
|
+
|
|
143
|
+
`data-props` holds the block's field values as JSON, already checked the way SolutionDesk checks them: text fields as strings, on/off fields as `true`/`false`; a value that isn't valid (an unsafe address, a picture that isn't from your blog, an option that isn't offered) is left out. `post.customBlocks` names the blocks a post uses, `post.blocks` describes them (name, kind, fields, text version template).
|
|
144
|
+
|
|
145
|
+
`renderBlogBlocks` swaps them for your own markup, by block name:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { escapeBlogHtml, renderBlogBlocks } from "@solutiondesk/blog";
|
|
149
|
+
|
|
150
|
+
const html = renderBlogBlocks(post.html, {
|
|
151
|
+
newsletter: ({ props }) =>
|
|
152
|
+
`<newsletter-signup list="${escapeBlogHtml(String(props.list ?? ""))}"></newsletter-signup>`,
|
|
153
|
+
aside: ({ props, childrenHtml }) =>
|
|
154
|
+
`<aside class="note"><h4>${escapeBlogHtml(String(props.title ?? ""))}</h4>${childrenHtml}</aside>`,
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Your renderer gets `block` (the name), `props`, `inline`, `textHtml` (the text version) and, for a block around content, `childrenHtml` (that content, with blocks inside already rendered). Return `undefined` to keep the neutral element. Values in `props` are raw text — escape them with `escapeBlogHtml` before putting them into HTML. A block without a renderer keeps its text version. With React, you can also hydrate your own components from `data-block` and `data-props` with an HTML parser of your choice.
|
|
159
|
+
|
|
160
|
+
## Moved articles
|
|
161
|
+
|
|
162
|
+
When the team changes an article's address, the old slug answers with a `301` to the new one.
|
|
163
|
+
|
|
164
|
+
- 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.
|
|
165
|
+
- `redirect: "manual"`: you get `{ redirectTo: "new-slug" }` instead of the post.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const result = await blog.getPost("old-slug", { redirect: "manual" });
|
|
169
|
+
if ("redirectTo" in result) return Response.redirect(`https://www.acme.com/blog/${result.redirectTo}`, 301);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Languages
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const { post } = await blog.getPost("hello", { lang: "de" });
|
|
178
|
+
post.locale; // "de", or the main language when there's no German version
|
|
179
|
+
post.languages; // ["en", "de"]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Images
|
|
183
|
+
|
|
184
|
+
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`.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
// image-loader.ts — Next.js: images.loader "custom", images.loaderFile "./image-loader.ts"
|
|
188
|
+
import { imageUrl } from "@solutiondesk/blog";
|
|
189
|
+
|
|
190
|
+
export default function solutionDeskLoader({ src, width }: { src: string; width: number }) {
|
|
191
|
+
return imageUrl(src, width);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Caching and ETags
|
|
196
|
+
|
|
197
|
+
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.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const blog = createBlogClient({ workspace: "acme", etagCache: true }); // or etagCache: new Map()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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`.
|
|
204
|
+
|
|
205
|
+
## Webhooks: refresh when something changes
|
|
206
|
+
|
|
207
|
+
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:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// app/api/solutiondesk/blog/route.ts (Next.js)
|
|
211
|
+
import { parseBlogWebhook } from "@solutiondesk/blog";
|
|
212
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
213
|
+
|
|
214
|
+
export async function POST(request: Request) {
|
|
215
|
+
const body = await parseBlogWebhook(
|
|
216
|
+
process.env.SOLUTIONDESK_BLOG_WEBHOOK_SECRET!,
|
|
217
|
+
await request.text(), // the raw body, exactly as received
|
|
218
|
+
request.headers.get("x-solutiondesk-signature"),
|
|
219
|
+
);
|
|
220
|
+
if (!body) return new Response("Invalid signature", { status: 401 });
|
|
221
|
+
if (body.event !== "ping") {
|
|
222
|
+
revalidateTag("blog");
|
|
223
|
+
for (const slug of [body.slug, ...body.previousSlugs]) if (slug) revalidatePath(`/blog/${slug}`);
|
|
224
|
+
}
|
|
225
|
+
return new Response(null, { status: 204 });
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`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.
|
|
230
|
+
|
|
231
|
+
Answer with a `2xx` status; anything else is retried a few times.
|
|
232
|
+
|
|
233
|
+
## Errors
|
|
234
|
+
|
|
235
|
+
Every failed request rejects with a `SolutionDeskError`:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { SolutionDeskError } from "@solutiondesk/blog";
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await blog.getPost("nope");
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error instanceof SolutionDeskError) {
|
|
244
|
+
error.status; // 404 — unknown workspace, blog turned off or unknown post look the same on purpose
|
|
245
|
+
error.message; // the API's message, e.g. "This blog or article does not exist."
|
|
246
|
+
error.body; // the parsed error body
|
|
247
|
+
error.url; // the requested URL
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`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.
|
|
253
|
+
|
|
254
|
+
## Options
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
createBlogClient({
|
|
258
|
+
workspace: "acme", // required
|
|
259
|
+
lang: "en", // default language
|
|
260
|
+
baseUrl: "https://api.solutiondesk.io", // the API origin
|
|
261
|
+
fetch: customFetch, // e.g. a fetch with tracing
|
|
262
|
+
headers: { "x-request-id": "…" }, // sent with every request
|
|
263
|
+
etagCache: true, // or your own Map
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Every method also takes request options as its last argument: `{ signal, headers, fetchOptions }`.
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT © wemogy GmbH
|