@solutiondesk/help 0.0.0 → 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 +233 -28
- package/dist/fumadocs.d.ts +614 -0
- package/dist/fumadocs.js +664 -0
- package/dist/index.cjs +344 -0
- package/dist/index.d.cts +427 -0
- package/dist/index.d.ts +427 -0
- package/dist/index.js +334 -0
- package/package.json +84 -7
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,250 @@
|
|
|
1
1
|
# @solutiondesk/help
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npm install @solutiondesk/help
|
|
11
|
+
```
|
|
8
12
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
```ts
|
|
18
|
+
import { createHelpClient } from "@solutiondesk/help";
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
const help = createHelpClient({ workspace: "acme", site: "docs", lang: "en" });
|
|
19
21
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
| Method | API | Returns |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `site({ lang })` | `GET …/site` | The site (`name`, `languages`, `websiteUrl`) and its navigation tree: pages, folders (with index page, `root` tab, `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`, `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
|
-
|
|
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`.
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
### Moved pages
|
|
38
43
|
|
|
39
|
-
|
|
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
|
-
|
|
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` (always at the top) |
|
|
107
|
+
| folder `guides` | `guides/meta.json` with `title`, `icon`, `root`, `defaultOpen`, `collapsible`, `pages` |
|
|
108
|
+
| folder's own page | `guides/index.mdx` (`pagesIndex: "index"`, so it also works for `root` tabs) |
|
|
109
|
+
| any other page with the slug `index` | `guides/index_.mdx` (`slugs: ["guides", "index"]`), so it never collides with the folder's own page |
|
|
110
|
+
| order of the top level | `meta.json` (`title` = site name) |
|
|
111
|
+
| separator | `"---[Icon]Title---"` (a separator without a title is `"---"`) |
|
|
112
|
+
| link | `"[Icon][Title](url)"`, `"external:[Icon][Title](url)"` |
|
|
113
|
+
|
|
114
|
+
Every page's `data` holds `title`, `description`, `icon`, `path`, `lang`, `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).
|
|
115
|
+
|
|
116
|
+
**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.
|
|
117
|
+
|
|
118
|
+
**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`):
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
loader({
|
|
122
|
+
baseUrl: "/docs",
|
|
123
|
+
i18n: defineI18n({ languages: ["de", "en"], defaultLanguage: "de" }),
|
|
124
|
+
source: await createHelpSource(help, { languages: ["de", "en"] }),
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`helpDynamicSource(help, options)` is the same for `dynamicLoader()` from `fumadocs-core/source/dynamic`.
|
|
129
|
+
|
|
130
|
+
### The page
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
// app/docs/[[...slug]]/page.tsx
|
|
134
|
+
import { getHelpToc, HelpBody } from "@solutiondesk/help/fumadocs";
|
|
135
|
+
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
|
|
136
|
+
import { icons } from "lucide-react";
|
|
137
|
+
import { notFound } from "next/navigation";
|
|
138
|
+
import { getSource } from "@/lib/help";
|
|
139
|
+
|
|
140
|
+
const icon = (name: string) => {
|
|
141
|
+
const Icon = icons[name as keyof typeof icons];
|
|
142
|
+
return Icon ? <Icon /> : null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
|
|
146
|
+
const entry = (await getSource()).getPage((await params).slug);
|
|
147
|
+
if (!entry) notFound();
|
|
148
|
+
const page = await entry.data.load();
|
|
149
|
+
if (!page) notFound(); // taken offline since the navigation was cached
|
|
150
|
+
return (
|
|
151
|
+
<DocsPage toc={getHelpToc(page.headings)}>
|
|
152
|
+
<DocsTitle>{page.title}</DocsTitle>
|
|
153
|
+
<DocsDescription>{page.description}</DocsDescription>
|
|
154
|
+
<DocsBody>
|
|
155
|
+
<HelpBody markdown={page.markdown} headings={page.headings} icon={icon} />
|
|
156
|
+
</DocsBody>
|
|
157
|
+
</DocsPage>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function generateStaticParams() {
|
|
162
|
+
return (await getSource()).generateParams();
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`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` so the anchors match `getHelpToc`. Replace any part with `components`, for example syntax highlighting:
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
|
|
170
|
+
|
|
171
|
+
<HelpBody
|
|
172
|
+
markdown={page.markdown}
|
|
173
|
+
headings={page.headings}
|
|
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
|
+
### Refresh when something changes
|
|
181
|
+
|
|
182
|
+
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:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// app/api/solutiondesk/help/route.ts
|
|
186
|
+
import { createHelpRevalidateHandler } from "@solutiondesk/help/fumadocs";
|
|
187
|
+
import { revalidateTag } from "next/cache";
|
|
188
|
+
import { HELP_TAG } from "@/lib/help";
|
|
189
|
+
|
|
190
|
+
export const POST = createHelpRevalidateHandler({
|
|
191
|
+
secret: process.env.SOLUTIONDESK_HELP_WEBHOOK_SECRET ?? "",
|
|
192
|
+
revalidate: () => revalidateTag(HELP_TAG, "max"),
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
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`.
|
|
197
|
+
|
|
198
|
+
### Preview with Draft Mode
|
|
199
|
+
|
|
200
|
+
Set the site's preview address to `https://help.acme.com/api/preview?token={token}&path={path}`:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// app/api/preview/route.ts
|
|
204
|
+
import { cookies, draftMode } from "next/headers";
|
|
205
|
+
import { redirect } from "next/navigation";
|
|
206
|
+
|
|
207
|
+
export async function GET(request: Request) {
|
|
208
|
+
const url = new URL(request.url);
|
|
209
|
+
const token = url.searchParams.get("token");
|
|
210
|
+
if (!token) return new Response("Missing token", { status: 400 });
|
|
211
|
+
(await draftMode()).enable();
|
|
212
|
+
(await cookies()).set("sd-help-preview", token, { httpOnly: true, sameSite: "lax", maxAge: 3600 });
|
|
213
|
+
redirect(`/docs/${(url.searchParams.get("path") ?? "").replace(/[^a-z0-9/-]/g, "")}`);
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
In the page, read the page from the preview while Draft Mode is on:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
if ((await draftMode()).isEnabled) {
|
|
221
|
+
const token = (await cookies()).get("sd-help-preview")?.value;
|
|
222
|
+
if (token) return (await help.preview(token, {}, { fetchOptions: { cache: "no-store" } })).page;
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
A complete Next.js app with these files is in [`examples/fumadocs-next`](./examples/fumadocs-next).
|
|
227
|
+
|
|
228
|
+
## Any other website
|
|
229
|
+
|
|
230
|
+
Without Fumadocs, use `page.html` and style its stable classes:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
const { page, movedFrom } = await help.page(path);
|
|
234
|
+
if (movedFrom) redirect(`/docs/${page.path}`);
|
|
235
|
+
document.querySelector("article")!.innerHTML = page.html;
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
| Block | HTML |
|
|
239
|
+
| --- | --- |
|
|
240
|
+
| 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">` |
|
|
241
|
+
| steps | `<ol class="sd-steps">` of `<li class="sd-step">` with `<p class="sd-step-title">` and `<div class="sd-step-body">` |
|
|
242
|
+
| 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) |
|
|
243
|
+
| 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">` |
|
|
244
|
+
| titled code | `<figure class="sd-code"><figcaption>client.ts</figcaption><pre><code class="language-ts">` |
|
|
245
|
+
|
|
246
|
+
Build the table of contents from `page.headings` (`#id`), the breadcrumb from `page.breadcrumbs` and the footer links from `page.previous`/`page.next`.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT © wemogy GmbH
|