@solutiondesk/blog 0.0.0 → 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 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,271 @@
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`, `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). |
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
+ ## 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