@solutiondesk/help 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,299 @@
1
1
  # @solutiondesk/help
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Typed client for the public [SolutionDesk](https://solutiondesk.io) help center API, plus a ready-made adapter for [Fumadocs](https://fumadocs.dev). Your team writes the documentation in SolutionDesk; your docs website reads it 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
+ - The client has no dependencies: ESM and CommonJS, full TypeScript types, Node.js 18+, Deno, Bun, edge runtimes and browsers.
6
+ - No key and no login: the API only ever returns pages that are published, public and placed in the navigation.
7
+ - `@solutiondesk/help/fumadocs` turns a help site into a Fumadocs source and renders its pages with Fumadocs UI.
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/help
11
+ ```
8
12
 
9
- ## Purpose
13
+ The guide [Help center on your website](https://help.solutiondesk.io/docs/team/help-center-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/help`
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 { createHelpClient } from "@solutiondesk/help";
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 help = createHelpClient({ workspace: "acme", site: "docs", lang: "en" });
19
21
 
20
- ## Setup Instructions
22
+ const { site, tree } = await help.site(); // the navigation
23
+ const { page } = await help.page("guides/login"); // one page with its text
24
+ ```
21
25
 
22
- To properly configure OIDC trusted publishing for this package:
26
+ `workspace` is your workspace's slug and `site` the doc site's slug, the `acme` and `docs` in `https://api.solutiondesk.io/v1/help/acme/docs`.
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
+ | `site({ lang })` | `GET …/site` | The site (`name`, `languages`, `websiteUrl`, its own building `blocks`) and its navigation tree: pages, folders (with index page, `listIndex`, `root` tab, `description`, `group`, `defaultOpen`, `collapsible`), separators and links. Empty folders are left out. |
33
+ | `pages({ lang })` | `GET …/pages` | Every visible page without its text, in reading order (for builds and sitemaps). |
34
+ | `page(path, { lang, redirect })` | `GET …/page?path=` | One page: `markdown`, `html`, `headings`, `blocks`, `customBlocks`, `breadcrumbs`, `previous`/`next`. The home page has the path `""`. |
35
+ | `pageMarkdown(path, { lang })` | `GET …/page.md?path=` | One page as plain Markdown. |
36
+ | `search(query, { lang, limit })` | `GET …/search?q=` | Up to 20 hits: title hits before heading hits before text hits, with an `excerpt` and the best `heading`. |
37
+ | `preview(token, { lang })` | `GET …/preview?token=` | A page in any state (drafts too) for your preview route. |
38
+ | `llmsTxt({ lang })` / `llmsFullTxt({ lang })` | `GET …/llms.txt`, `…/llms-full.txt` | The site for language models: the index, or every page as Markdown. |
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: raw HTML in the Markdown is shown as text, links only go to web addresses, `mailto:`, paths and anchors, images only come from your help center. Headings carry the `id`s listed in `headings`, made the way GitHub and Fumadocs make them (umlauts kept, `-1` for a repeat), so a site moved from Fumadocs keeps its `#links`; a step's title is a heading too.
36
41
 
37
- ## More Information
42
+ ### Moved pages
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
+ When the team renames or moves a page, its old path answers with a `301` to the new one.
42
45
 
43
- ---
46
+ - Default (`redirect: "follow"`): you get the page; `movedFrom` holds the path you asked for (else `null`). Redirect your visitor to `page.path`.
47
+ - `redirect: "manual"`: you get `{ redirectTo: "new/path" }` instead.
44
48
 
45
- **Maintained for OIDC setup purposes only**
49
+ ### Languages
50
+
51
+ Pass `lang` to the client (default for every call) or per call. A page that doesn't exist in that language comes in the site's main language. Every answer names the language it is really in (`locale`) and the site's languages (`languages`, main language first).
52
+
53
+ ### Errors, caching, options
54
+
55
+ Every failed request rejects with a `SolutionDeskError` (`status`, `message`, `body`, `url`, `isNotFound`, `isRateLimited`); `status` is `0` when no answer arrived. A missing page, a hidden page, a turned-off module and an unknown site all answer `404`, on purpose alike.
56
+
57
+ Answers carry `Cache-Control: public, max-age=60, must-revalidate` and an `ETag`. `etagCache: true` makes the client revalidate with `If-None-Match`. Per call, `fetchOptions` goes to `fetch` unchanged, e.g. Next.js `{ next: { tags: ["solutiondesk-help"], revalidate: 3600 } }` or `{ cache: "no-store" }` for previews.
58
+
59
+ ```ts
60
+ createHelpClient({
61
+ workspace: "acme", // required
62
+ site: "docs", // required
63
+ lang: "en", // default language
64
+ baseUrl: "https://api.solutiondesk.io", // the API origin
65
+ fetch: customFetch,
66
+ headers: { "x-request-id": "…" },
67
+ etagCache: true,
68
+ });
69
+ ```
70
+
71
+ ## Fumadocs
72
+
73
+ `@solutiondesk/help/fumadocs` needs `fumadocs-core`, `fumadocs-ui` (16.x) and `react` 19, which a Fumadocs site has anyway. It is ESM only and tested against Fumadocs 16.14.
74
+
75
+ ### The source
76
+
77
+ ```ts
78
+ // lib/help.ts
79
+ import { createHelpClient } from "@solutiondesk/help";
80
+ import { createHelpSource } from "@solutiondesk/help/fumadocs";
81
+ import { loader } from "fumadocs-core/source";
82
+ import { lucideIconsPlugin } from "fumadocs-core/source/plugins/lucide-icons";
83
+ import { cache } from "react";
84
+
85
+ export const HELP_TAG = "solutiondesk-help";
86
+ export const help = createHelpClient({ workspace: "acme", site: "docs" });
87
+
88
+ // Built per request from the cached navigation: after the webhook revalidates
89
+ // the tag, the next request sees the new tree.
90
+ export const getSource = cache(async () =>
91
+ loader({
92
+ baseUrl: "/docs",
93
+ source: await createHelpSource(help, {
94
+ requestOptions: { fetchOptions: { next: { tags: [HELP_TAG], revalidate: 3600 } } },
95
+ }),
96
+ plugins: [lucideIconsPlugin()], // the icons are Lucide names
97
+ }),
98
+ );
99
+ ```
100
+
101
+ The navigation becomes the files Fumadocs knows from a content folder:
102
+
103
+ | SolutionDesk | Fumadocs |
104
+ | --- | --- |
105
+ | page `guides/login` | `guides/login.mdx` (`slugs: ["guides", "login"]`) |
106
+ | home page | `index.mdx` at the top, or `(core)/index.mdx` when it is a top-level group's own page |
107
+ | folder `guides` | `guides/meta.json` with `title`, `description` (shown under a tab's name), `pagesIndex` — or, with `listIndex`, `"index"` first in `pages` (after a leading separator), so the page is an entry and the name only opens the folder —, `icon`, `root`, `defaultOpen`, `collapsible`, `pages` |
108
+ | group folder `core` (adds no address part) | `(core)/` — a Fumadocs folder group, so `(core)/sign-up/index.mdx` is still `sign-up` |
109
+ | folder's own page | `guides/index.mdx` (`pagesIndex: "index"`, so it also works for `root` tabs) |
110
+ | any other page with the slug `index` | `guides/index_.mdx` (`slugs: ["guides", "index"]`), so it never collides with the folder's own page |
111
+ | order of the top level | `meta.json` (`title` = site name) |
112
+ | separator | `"---[Icon]Title---"` (a separator without a title is `"---"`) |
113
+ | link | `"[Icon][Title](url)"`, `"external:[Icon][Title](url)"` |
114
+
115
+ Every page's `data` holds `title`, `description`, `icon`, `path`, `lang`, `blocks` (the site's own building blocks, for `HelpBody`), `load()` (the page with its text, or `null` when the page was taken offline after the source was built — answer with a 404) and `structuredData()` (for Fumadocs search; empty for such a page, so it never breaks the search index).
116
+
117
+ **Lazy or eager.** The page list has no texts, so by default (`pages: "lazy"`) building the source costs one request per language, and `page.data.load()` fetches a page's text when it is rendered. Cache that with your framework (the tags above). With `pages: "eager"` every page is fetched while building (`concurrency`, default 6) and kept in `page.data.page`: slower to build, nothing fetched later, best for a fully static export.
118
+
119
+ **Several languages.** Pass the languages of your Fumadocs i18n config. The source then holds one set of files per language with Fumadocs' default `dot` naming (`guides/login.en.mdx`, `guides/meta.en.json`):
120
+
121
+ ```ts
122
+ loader({
123
+ baseUrl: "/docs",
124
+ i18n: defineI18n({ languages: ["de", "en"], defaultLanguage: "de" }),
125
+ source: await createHelpSource(help, { languages: ["de", "en"] }),
126
+ });
127
+ ```
128
+
129
+ `helpDynamicSource(help, options)` is the same for `dynamicLoader()` from `fumadocs-core/source/dynamic`.
130
+
131
+ ### The page
132
+
133
+ ```tsx
134
+ // app/docs/[[...slug]]/page.tsx
135
+ import { getHelpToc, HelpBody } from "@solutiondesk/help/fumadocs";
136
+ import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
137
+ import { icons } from "lucide-react";
138
+ import { notFound } from "next/navigation";
139
+ import { getSource } from "@/lib/help";
140
+
141
+ const icon = (name: string) => {
142
+ const Icon = icons[name as keyof typeof icons];
143
+ return Icon ? <Icon /> : null;
144
+ };
145
+
146
+ export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
147
+ const entry = (await getSource()).getPage((await params).slug);
148
+ if (!entry) notFound();
149
+ const page = await entry.data.load();
150
+ if (!page) notFound(); // taken offline since the navigation was cached
151
+ return (
152
+ <DocsPage toc={getHelpToc(page.headings)}>
153
+ <DocsTitle>{page.title}</DocsTitle>
154
+ <DocsDescription>{page.description}</DocsDescription>
155
+ <DocsBody>
156
+ <HelpBody page={page} blocks={entry.data.blocks} icon={icon} />
157
+ </DocsBody>
158
+ </DocsPage>
159
+ );
160
+ }
161
+
162
+ export async function generateStaticParams() {
163
+ return (await getSource()).generateParams();
164
+ }
165
+ ```
166
+
167
+ `page` gives `HelpBody` the page's `markdown`, `headings` and `locale`; you can also pass `markdown`, `headings` and `lang` yourself. `HelpBody` renders the Markdown with Fumadocs UI: `:::callout` → `Callout`, `::::steps`/`:::step` → `Steps`/`Step`, `::::tabs`/`:::tab` → `Tabs`/`Tab`, `::::cards`/`:::card` → `Cards`/`Card`, a code fence with `title="…"` → `CodeBlock`. Pass `headings` (or `page`) so the anchors match `getHelpToc`. Replace any part with `components`, for example syntax highlighting:
168
+
169
+ ```tsx
170
+ import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
171
+
172
+ <HelpBody
173
+ page={page}
174
+ components={{ Code: ({ code, lang, title }) => <DynamicCodeBlock lang={lang ?? "text"} code={code} codeblock={{ title }} /> }}
175
+ />;
176
+ ```
177
+
178
+ The same safety rules as the API's `html` apply: raw HTML is shown as text, links only go to `http(s)`, `mailto:`, `/paths` and `#anchors`, images only come from `http(s)` or `/paths` — never to another host through `//`, `/\`, `\\` or control characters hidden in the address. A directive SolutionDesk doesn't know stays the text it was written as.
179
+
180
+ ### Your own building blocks
181
+
182
+ In SolutionDesk, a doc site can define its own building blocks: what a block is called, whether it sits in a sentence, on its own line or around content, and which fields it has. How it looks is up to your website. Map each block's name to one of your components:
183
+
184
+ ```tsx
185
+ import { type HelpBlockProps, HelpBody } from "@solutiondesk/help/fumadocs";
186
+ import { icons } from "lucide-react";
187
+
188
+ // :app-button{icon="Palette" label="Your team"} — in a sentence
189
+ function AppButton({ icon, label }: HelpBlockProps<{ icon: string; label: string }>) {
190
+ const Icon = icon ? icons[icon as keyof typeof icons] : undefined;
191
+ return <kbd className="app-button">{Icon ? <Icon /> : null}{label}</kbd>;
192
+ }
193
+
194
+ // ::paid-addon{name="Events"} — on its own line
195
+ function PaidAddon({ name, lang }: HelpBlockProps<{ name: string }>) {
196
+ return <aside className="paid-addon">{lang === "de" ? "Kostenpflichtig" : "Paid add-on"}: {name}</aside>;
197
+ }
198
+
199
+ // :::download-buttons{title="UXP installer" versionsHref="#versions"} … ::: — around content
200
+ function DownloadButtons({ title, versionsHref, children }: HelpBlockProps<{ title: string; versionsHref: string }>) {
201
+ return (
202
+ <section className="downloads">
203
+ <h4>{title}</h4>
204
+ {children}
205
+ {versionsHref ? <a href={versionsHref}>Older versions</a> : null}
206
+ </section>
207
+ );
208
+ }
209
+
210
+ <HelpBody
211
+ page={page}
212
+ blocks={entry.data.blocks} // or `site.blocks` from `help.site()`
213
+ components={{ "app-button": AppButton, "paid-addon": PaidAddon, "download-buttons": DownloadButtons }}
214
+ />;
215
+ ```
216
+
217
+ A component gets:
218
+
219
+ - the block's **fields**, 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 such as `javascript:`, a picture or video address that isn't allowed, an icon name that isn't one, an option that isn't offered) is left out, so every field may be missing — `HelpBlockProps<Fields>` types them as optional.
220
+ - `lang`: the page's language (`page.locale`, or the `lang` you pass) — no need to store it in the block.
221
+ - `block`: the block's name.
222
+ - `children`: for a block in a sentence or on its own line, its text in `[brackets]` (`:app-button[Share]{icon="Share2"}`); for a block around content, that content, rendered like the rest of the page (built-in and own blocks inside included).
223
+
224
+ `block`, `lang` and `children` always mean the above; SolutionDesk doesn't allow fields with these names. A block the site defines but you have no component for shows its **text version** (set in SolutionDesk, e.g. `Paid add-on: Events`) in a neutral `<span class="sd-block" data-block="…" data-props="…">` (in a sentence) or `<div class="sd-block">` (on its own line; around content with `<p class="sd-block-text">` and `<div class="sd-block-body">`) — the same as the API's `html`. A name the site doesn't define, or a block written in the wrong form, stays the text it was written as. Without `blocks`, every one of them stays text. Search data (`structuredData()`) and heading anchors use the text version as well, like the API's search.
225
+
226
+ `helpBlockValues(definition, attributes)` and `helpBlockText(definition, values, label?)` expose the same rules if you render blocks yourself.
227
+
228
+ ### Refresh when something changes
229
+
230
+ In SolutionDesk set the site's webhook to a route on your website and copy the signing secret. The handler checks the `X-SolutionDesk-Signature` header and answers `401` when it doesn't match (or is older than 5 minutes) and `400` for a signed body that isn't a help webhook:
231
+
232
+ ```ts
233
+ // app/api/solutiondesk/help/route.ts
234
+ import { createHelpRevalidateHandler } from "@solutiondesk/help/fumadocs";
235
+ import { revalidateTag } from "next/cache";
236
+ import { HELP_TAG } from "@/lib/help";
237
+
238
+ export const POST = createHelpRevalidateHandler({
239
+ secret: process.env.SOLUTIONDESK_HELP_WEBHOOK_SECRET ?? "",
240
+ revalidate: () => revalidateTag(HELP_TAG, "max"),
241
+ });
242
+ ```
243
+
244
+ The handler works with any framework that has `Request` and `Response`. Events: `page.published`, `page.updated`, `page.unpublished`, `page.deleted`, `navigation.updated` and `ping` (the test call, which doesn't call `revalidate`). Without the handler, check calls yourself with `verifyHelpWebhook(secret, rawBody, header)`, which returns the body or `null`.
245
+
246
+ ### Preview with Draft Mode
247
+
248
+ Set the site's preview address to `https://help.acme.com/api/preview?token={token}&path={path}`:
249
+
250
+ ```ts
251
+ // app/api/preview/route.ts
252
+ import { cookies, draftMode } from "next/headers";
253
+ import { redirect } from "next/navigation";
254
+
255
+ export async function GET(request: Request) {
256
+ const url = new URL(request.url);
257
+ const token = url.searchParams.get("token");
258
+ if (!token) return new Response("Missing token", { status: 400 });
259
+ (await draftMode()).enable();
260
+ (await cookies()).set("sd-help-preview", token, { httpOnly: true, sameSite: "lax", maxAge: 3600 });
261
+ redirect(`/docs/${(url.searchParams.get("path") ?? "").replace(/[^a-z0-9/-]/g, "")}`);
262
+ }
263
+ ```
264
+
265
+ In the page, read the page from the preview while Draft Mode is on:
266
+
267
+ ```ts
268
+ if ((await draftMode()).isEnabled) {
269
+ const token = (await cookies()).get("sd-help-preview")?.value;
270
+ if (token) return (await help.preview(token, {}, { fetchOptions: { cache: "no-store" } })).page;
271
+ }
272
+ ```
273
+
274
+ A complete Next.js app with these files is in [`examples/fumadocs-next`](./examples/fumadocs-next).
275
+
276
+ ## Any other website
277
+
278
+ Without Fumadocs, use `page.html` and style its stable classes:
279
+
280
+ ```ts
281
+ const { page, movedFrom } = await help.page(path);
282
+ if (movedFrom) redirect(`/docs/${page.path}`);
283
+ document.querySelector("article")!.innerHTML = page.html;
284
+ ```
285
+
286
+ | Block | HTML |
287
+ | --- | --- |
288
+ | callout | `<div class="sd-callout" data-type="info\|warn\|error\|success\|idea" role="note">` with `<p class="sd-callout-title">` and `<div class="sd-callout-body">` |
289
+ | steps | `<ol class="sd-steps">` of `<li class="sd-step">` with `<h3 class="sd-step-title" id="…">` and `<div class="sd-step-body">` |
290
+ | tabs | `<div class="sd-tabs">` of `<section class="sd-tab" data-title="…">` (build the tab buttons from `data-title`; `page.blocks` says when a page has tabs) |
291
+ | cards | `<div class="sd-cards">` of `<a class="sd-card" href="…" data-icon="Rocket">` (or `<div class="sd-card">`) with `<p class="sd-card-title">` and `<div class="sd-card-body">` |
292
+ | titled code | `<figure class="sd-code"><figcaption>client.ts</figcaption><pre><code class="language-ts">` |
293
+ | the site's own block | `<span class="sd-block" data-block="app-button" data-props="{…}">` in a sentence, `<div class="sd-block">` on its own line or around content (`<p class="sd-block-text">`, `<div class="sd-block-body">`); the text is the block's text version, `data-props` its checked values as JSON — hydrate your own component from it |
294
+
295
+ Build the table of contents from `page.headings` (`#id`), the breadcrumb from `page.breadcrumbs` and the footer links from `page.previous`/`page.next`.
296
+
297
+ ## License
298
+
299
+ MIT © wemogy GmbH