@solutiondesk/help 1.0.0-69d9f69 → 1.0.0-a1101b8

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/README.md CHANGED
@@ -29,9 +29,9 @@ const { page } = await help.page("guides/login"); // one page with its text
29
29
 
30
30
  | Method | API | Returns |
31
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. |
32
+ | `site({ lang })` | `GET …/site` | The site (`name`, `languages`, `websiteUrl`, its own building `blocks`) and its navigation tree: pages, folders (with index page, `root` tab, `group`, `defaultOpen`, `collapsible`), separators and links. Empty folders are left out. |
33
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 `""`. |
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
35
  | `pageMarkdown(path, { lang })` | `GET …/page.md?path=` | One page as plain Markdown. |
36
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
37
  | `preview(token, { lang })` | `GET …/preview?token=` | A page in any state (drafts too) for your preview route. |
@@ -103,15 +103,16 @@ The navigation becomes the files Fumadocs knows from a content folder:
103
103
  | SolutionDesk | Fumadocs |
104
104
  | --- | --- |
105
105
  | page `guides/login` | `guides/login.mdx` (`slugs: ["guides", "login"]`) |
106
- | home page | `index.mdx` (always at the top) |
106
+ | home page | `index.mdx` at the top, or `(core)/index.mdx` when it is a top-level group's own page |
107
107
  | folder `guides` | `guides/meta.json` with `title`, `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` |
108
109
  | folder's own page | `guides/index.mdx` (`pagesIndex: "index"`, so it also works for `root` tabs) |
109
110
  | any other page with the slug `index` | `guides/index_.mdx` (`slugs: ["guides", "index"]`), so it never collides with the folder's own page |
110
111
  | order of the top level | `meta.json` (`title` = site name) |
111
112
  | separator | `"---[Icon]Title---"` (a separator without a title is `"---"`) |
112
113
  | link | `"[Icon][Title](url)"`, `"external:[Icon][Title](url)"` |
113
114
 
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
+ 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).
115
116
 
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.
117
118
 
@@ -152,7 +153,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
152
153
  <DocsTitle>{page.title}</DocsTitle>
153
154
  <DocsDescription>{page.description}</DocsDescription>
154
155
  <DocsBody>
155
- <HelpBody markdown={page.markdown} headings={page.headings} icon={icon} />
156
+ <HelpBody page={page} blocks={entry.data.blocks} icon={icon} />
156
157
  </DocsBody>
157
158
  </DocsPage>
158
159
  );
@@ -163,20 +164,67 @@ export async function generateStaticParams() {
163
164
  }
164
165
  ```
165
166
 
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
+ `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:
167
168
 
168
169
  ```tsx
169
170
  import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
170
171
 
171
172
  <HelpBody
172
- markdown={page.markdown}
173
- headings={page.headings}
173
+ page={page}
174
174
  components={{ Code: ({ code, lang, title }) => <DynamicCodeBlock lang={lang ?? "text"} code={code} codeblock={{ title }} /> }}
175
175
  />;
176
176
  ```
177
177
 
178
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
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
+
180
228
  ### Refresh when something changes
181
229
 
182
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:
@@ -242,6 +290,7 @@ document.querySelector("article")!.innerHTML = page.html;
242
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) |
243
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">` |
244
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 |
245
294
 
246
295
  Build the table of contents from `page.headings` (`#id`), the breadcrumb from `page.breadcrumbs` and the footer links from `page.previous`/`page.next`.
247
296
 
@@ -1,4 +1,4 @@
1
- import { ComponentType, ReactNode } from 'react';
1
+ import { JSXElementConstructor, ReactNode, ComponentType } from 'react';
2
2
  import { MetaData, PageData, StaticSource, VirtualFile, DynamicSource } from 'fumadocs-core/source';
3
3
  import { TOCItemType } from 'fumadocs-core/toc';
4
4
 
@@ -12,7 +12,8 @@ import { TOCItemType } from 'fumadocs-core/toc';
12
12
  * preview token names. Absent values are `null`, never a missing key.
13
13
  *
14
14
  * Paths: a page's path is its folder slugs plus its slug, without leading or
15
- * trailing slash (`guides/login`); the site's home page has the path `""`.
15
+ * trailing slash (`guides/login`) — a `group` folder adds no slug; the site's
16
+ * home page has the path `""`.
16
17
  */
17
18
  /** The doc site itself. */
18
19
  interface HelpPublicSite {
@@ -25,6 +26,40 @@ interface HelpPublicSite {
25
26
  languages: string[];
26
27
  /** The docs website's base address (`https://help.acme.com/docs`), if set. */
27
28
  websiteUrl: string | null;
29
+ /** The site's own building blocks pages may use (empty when it defines none). */
30
+ blocks: HelpPublicBlockDefinition[];
31
+ }
32
+ /** What a field of a site's own block holds (see {@link HelpPublicBlockField}). */
33
+ type HelpPublicBlockFieldType = "text" | "longText" | "icon" | "url" | "choice" | "image" | "video" | "boolean";
34
+ /**
35
+ * One field of a site's own block: an attribute of its directive
36
+ * (`key="value"`). `boolean` fields are on when written bare (`key`) or as
37
+ * `key="true"`; every other value is a string. `options` lists a `choice`
38
+ * field's values (null for other types).
39
+ */
40
+ interface HelpPublicBlockField {
41
+ key: string;
42
+ label: string;
43
+ type: HelpPublicBlockFieldType;
44
+ required: boolean;
45
+ options: string[] | null;
46
+ }
47
+ /**
48
+ * A building block the site defines itself — WHAT it is; how it looks is the
49
+ * website's own component. Written in Markdown as `:name[label]{…}` in running
50
+ * text (`inline`), `::name[label]{…}` alone on a line (`block`) or
51
+ * `:::name{…}` … `:::` around Markdown content (`container`). `text` is the
52
+ * template of its text version (`{key}` = a field's value, a `boolean` field's
53
+ * label when on; `{{`/`}}` = a brace) — what to show without a component.
54
+ */
55
+ interface HelpPublicBlockDefinition {
56
+ /** The directive name (`app-button`). */
57
+ name: string;
58
+ /** Display name. */
59
+ label: string;
60
+ kind: "inline" | "block" | "container";
61
+ fields: HelpPublicBlockField[];
62
+ text: string;
28
63
  }
29
64
  /** A page in the navigation. */
30
65
  interface HelpPublicTreePage {
@@ -43,12 +78,17 @@ interface HelpPublicTreeFolder {
43
78
  /** Stable node id. */
44
79
  id: string;
45
80
  slug: string;
46
- /** The folder's path (its index page, if any, lives here). */
81
+ /** The folder's path (its index page, if any, lives here; a group's is its parent's). */
47
82
  path: string;
48
83
  title: string;
49
84
  icon: string | null;
50
85
  /** A tab (top level only; Fumadocs `root`). */
51
86
  root: boolean;
87
+ /**
88
+ * Adds no segment to its pages' paths (Fumadocs `(name)` folder): `path` is
89
+ * the parent's, a top-level group's index page is the home (`""`).
90
+ */
91
+ group: boolean;
52
92
  defaultOpen: boolean;
53
93
  collapsible: boolean;
54
94
  /** The folder's own page, shown at the folder's path; null without one. */
@@ -129,12 +169,17 @@ interface HelpPublicPageLink {
129
169
  }
130
170
  /** One page with its text. */
131
171
  interface HelpPublicPage extends HelpPublicPageSummary {
132
- /** The page's Markdown with directives (`:::callout`, `::::steps`, …). */
172
+ /**
173
+ * The page's Markdown with directives (`:::callout`, `::::steps`, …, and the
174
+ * site's own blocks as written — see `site.blocks`).
175
+ */
133
176
  markdown: string;
134
177
  /** Safe server-rendered HTML of `markdown` (stable `sd-*` classes for the directives). */
135
178
  html: string;
136
179
  headings: HelpPublicHeading[];
137
180
  blocks: HelpPublicBlock[];
181
+ /** The site's own building blocks the page uses, by name (in the site's order). */
182
+ customBlocks: string[];
138
183
  breadcrumbs: HelpPublicBreadcrumb[];
139
184
  previous: HelpPublicPageLink | null;
140
185
  next: HelpPublicPageLink | null;
@@ -209,8 +254,8 @@ interface HelpWebhookBody {
209
254
  * Parsing the help center's Markdown the way the API renders it
210
255
  * (`packages/db/src/help-markdown.ts`): GFM plus container directives in the
211
256
  * generic syntax of `remark-directive`. Only the directive names below mean
212
- * something; every other directive — and every leaf (`::x`) or text (`:x`)
213
- * directive — is printed back as the text it was written as.
257
+ * something, plus the site's own blocks (`blocks.ts`); every other directive
258
+ * is printed back as the text it was written as.
214
259
  */
215
260
  /** The directive names the help center knows. */
216
261
  declare const HELP_BLOCKS: readonly ["callout", "steps", "step", "tabs", "tab", "cards", "card"];
@@ -230,6 +275,38 @@ type HelpCalloutType = (typeof HELP_CALLOUT_TYPES)[number];
230
275
  */
231
276
  declare function safeHelpHref(raw: string | null | undefined): string | null;
232
277
 
278
+ /**
279
+ * A doc site's own building blocks (`site.blocks`) in the page Markdown — the
280
+ * same rules as the API (`packages/db/src/help-blocks.ts`, `help-markdown.ts`,
281
+ * contract in `docs/help/06-eigene-bausteine.md`), copied so the package has no
282
+ * dependency on the API's code; `test/blocks.test.tsx` holds both in step.
283
+ *
284
+ * The Markdown parser (micromark's directive extension) finds the directives;
285
+ * a use only counts when its name is defined, it is written in its kind's form
286
+ * (`:name` inline, `::name` alone on a line, `:::name` around content) and its
287
+ * source re-reads under the API's stricter syntax (label and attributes on one
288
+ * line, the basic character references) — otherwise it stays literal text.
289
+ */
290
+ /** A field's checked value: `boolean` fields are true/false, every other type a string. */
291
+ type HelpBlockValue = string | boolean;
292
+ /** A block's checked field values, in the definition's field order. */
293
+ type HelpBlockValues = Record<string, HelpBlockValue>;
294
+ /**
295
+ * A block's attributes as checked field values, by the API's rules: only
296
+ * defined fields; an absent `boolean` is `false`; an empty value counts as
297
+ * absent; invalid values (a `javascript:` link, a malformed icon, a choice
298
+ * not offered) and attributes without a field are left out.
299
+ */
300
+ declare function helpBlockValues(block: Pick<HelpPublicBlockDefinition, "fields">, attributes: Readonly<Record<string, string>>): HelpBlockValues;
301
+ /**
302
+ * A block's text version: its template with every `{key}` replaced by the
303
+ * field's value (a `boolean` by its label when on, else nothing; a missing
304
+ * value by nothing), `{{`/`}}` as braces, then `label` after a space;
305
+ * whitespace collapsed. What `HelpBody` shows without a component, and what
306
+ * search and the outline read.
307
+ */
308
+ declare function helpBlockText(block: Pick<HelpPublicBlockDefinition, "fields" | "text">, values: Readonly<HelpBlockValues>, label?: string): string;
309
+
233
310
  interface HelpCalloutProps {
234
311
  /** `info`, `warn`, `error`, `success` or `idea` (an unknown type arrives as `info`). */
235
312
  type: HelpCalloutType;
@@ -288,6 +365,40 @@ interface HelpImageProps {
288
365
  alt: string;
289
366
  title?: string;
290
367
  }
368
+ /** A field value of a site's own block, as its component receives it. */
369
+ type HelpBlockFieldValue = string | boolean | undefined;
370
+ /**
371
+ * The props of a component for one of the site's own blocks
372
+ * (`docs/help/06`). `Fields` names the block's fields: a `boolean` field is
373
+ * always there (`true`/`false`); any other field is a string, or absent when
374
+ * the page leaves it out or its value isn't valid (a `javascript:` link, an
375
+ * icon name that isn't one, a choice not offered) — even a required one, so
376
+ * every field is optional here.
377
+ *
378
+ * @example
379
+ * type AppButtonProps = HelpBlockProps<{ icon: string; label: string }>;
380
+ * function AppButton({ icon, label, children, lang }: AppButtonProps) { … }
381
+ */
382
+ type HelpBlockProps<Fields extends {
383
+ [K in keyof Fields]: HelpBlockFieldValue;
384
+ } = Record<never, never>> = {
385
+ [K in Exclude<keyof Fields, "block" | "lang" | "children">]?: Fields[K];
386
+ } & {
387
+ /** The block's name (`app-button`). */
388
+ block: string;
389
+ /** The page's language (`page.locale`, or `HelpBody`'s `lang`). */
390
+ lang: string;
391
+ /**
392
+ * `inline`/`block`: the `[label]` (rendered Markdown), if written;
393
+ * `container`: the rendered content — built-in and own blocks included.
394
+ */
395
+ children?: ReactNode;
396
+ };
397
+ /**
398
+ * A component for a site's own block. Typed loosely so any component fits;
399
+ * type yours with {@link HelpBlockProps}.
400
+ */
401
+ type HelpBlockComponent = JSXElementConstructor<never>;
291
402
  /** Everything `HelpBody` renders through a component — replace any of them. */
292
403
  interface HelpBodyComponents {
293
404
  Callout: ComponentType<HelpCalloutProps>;
@@ -305,17 +416,47 @@ interface HelpBodyComponents {
305
416
  }
306
417
  /** The Fumadocs UI defaults. */
307
418
  declare const helpBodyComponents: HelpBodyComponents;
419
+ /**
420
+ * `HelpBody`'s components: any of the built-in ones by their (capitalised)
421
+ * name, and a component per site block by the block's name (`app-button`).
422
+ */
423
+ interface HelpBodyComponentMap extends Partial<HelpBodyComponents> {
424
+ [block: string]: HelpBlockComponent | undefined;
425
+ }
308
426
  interface HelpBodyProps {
309
- /** The page's Markdown with directives (`page.markdown` from the API). */
310
- markdown: string;
427
+ /**
428
+ * The page from the API (`page.data.load()`, `help.page()`, `help.preview()`):
429
+ * its `markdown`, `headings` and `locale` (passed to block components as
430
+ * `lang`). Or pass `markdown` (and `headings`, `lang`) yourself.
431
+ */
432
+ page?: {
433
+ markdown: string;
434
+ headings?: readonly HelpPublicHeading[];
435
+ locale?: string;
436
+ };
437
+ /** The page's Markdown with directives (`page.markdown` from the API); wins over `page`. */
438
+ markdown?: string;
311
439
  /**
312
440
  * The page's `headings` from the API: their ids become the headings' anchors,
313
441
  * so they always match `getHelpToc(page.headings)`. Without them the ids are
314
442
  * computed the API's way.
315
443
  */
316
444
  headings?: readonly HelpPublicHeading[];
317
- /** Replace any of the default Fumadocs UI components. */
318
- components?: Partial<HelpBodyComponents>;
445
+ /** The page's language for block components (default `page.locale`, else `""`). */
446
+ lang?: string;
447
+ /**
448
+ * The site's own blocks (`page.data.blocks` of a help source, or
449
+ * `site.blocks` from `help.site()`). Without them every such directive stays
450
+ * the text it was written as.
451
+ */
452
+ blocks?: readonly HelpPublicBlockDefinition[];
453
+ /**
454
+ * Replace any of the default Fumadocs UI components (`Callout`, `Code`, …),
455
+ * and render the site's own blocks with your components, keyed by the
456
+ * block's name: `{ "app-button": AppButton }`. A defined block without a
457
+ * component shows its text version.
458
+ */
459
+ components?: HelpBodyComponentMap;
319
460
  /**
320
461
  * Turn a card's Lucide icon name (`Rocket`) into an element, e.g. with
321
462
  * `lucide-react`: `(name) => { const Icon = icons[name as keyof typeof icons]; return Icon ? <Icon /> : null; }`.
@@ -326,15 +467,20 @@ interface HelpBodyProps {
326
467
  /**
327
468
  * A help page's Markdown with its blocks (callout, steps, tabs, cards, titled
328
469
  * code fences) as Fumadocs UI components — a React Server Component, also fine
329
- * on the client. The API's safety rules apply: raw HTML is shown as text,
330
- * links only to http(s), mailto, `/paths` and `#anchors`, images only from
331
- * http(s) or `/paths`; unknown directives stay the text they were written as.
470
+ * on the client. The site's own blocks (`blocks`) render with your
471
+ * `components`, or as their text version. The API's safety rules apply: raw
472
+ * HTML is shown as text, links only to http(s), mailto, `/paths` and
473
+ * `#anchors`, images only from http(s) or `/paths`; unknown directives stay
474
+ * the text they were written as.
332
475
  *
333
476
  * @example
334
- * const page = await source.getPage(slugs)?.data.load();
335
- * <DocsBody><HelpBody markdown={page.markdown} headings={page.headings} /></DocsBody>
477
+ * const entry = source.getPage(slugs);
478
+ * const page = await entry.data.load();
479
+ * <DocsBody>
480
+ * <HelpBody page={page} blocks={entry.data.blocks} components={{ "app-button": AppButton }} />
481
+ * </DocsBody>
336
482
  */
337
- declare function HelpBody({ markdown, headings, components, icon }: HelpBodyProps): ReactNode;
483
+ declare function HelpBody(props: HelpBodyProps): ReactNode;
338
484
 
339
485
  /** Per-call options. */
340
486
  interface RequestOptions {
@@ -477,9 +623,11 @@ interface HelpStructuredData {
477
623
  /**
478
624
  * A page's search data for Fumadocs (`createFromSource`): every heading with
479
625
  * its anchor, and every paragraph, quote and table cell under the heading it
480
- * belongs to — code is left out, as in Fumadocs' own `remark-structure`.
626
+ * belongs to — code is left out, as in Fumadocs' own `remark-structure`. The
627
+ * site's own `blocks` count as their text version (like the API's search): a
628
+ * container's text version is an entry of its own, followed by its content.
481
629
  */
482
- declare function helpStructuredData(markdown: string, headings?: readonly HelpPublicHeading[]): HelpStructuredData;
630
+ declare function helpStructuredData(markdown: string, headings?: readonly HelpPublicHeading[], blocks?: readonly HelpPublicBlockDefinition[]): HelpStructuredData;
483
631
 
484
632
  /** What every page of a help source carries in `page.data`. */
485
633
  interface HelpPageData extends PageData {
@@ -491,6 +639,11 @@ interface HelpPageData extends PageData {
491
639
  path: string;
492
640
  /** The language this file was built for (the `lang` asked for; the API falls back per page). */
493
641
  lang: string;
642
+ /**
643
+ * The site's own building blocks (`site.blocks`) — pass them to `HelpBody`
644
+ * (`blocks={page.data.blocks}`) so pages render them with your components.
645
+ */
646
+ blocks: HelpPublicBlockDefinition[];
494
647
  /**
495
648
  * The page with its text (`markdown`, `html`, `headings`, `breadcrumbs`,
496
649
  * `previous`/`next`). Fetched when called — or right away with
@@ -570,8 +723,10 @@ interface HelpFilesOptions {
570
723
  /**
571
724
  * The Fumadocs virtual files for one language of the site: a `.mdx` page file
572
725
  * per page (a folder's own page as `<folder>/index.mdx`, the home page as
573
- * `index.mdx` at the top, any other page named `index` as `index_.mdx`) and a
574
- * `meta.json` per folder plus one at the top that fixes the order. Every file
726
+ * `index.mdx` at the top or in a top-level group, any other page named `index`
727
+ * as `index_.mdx`) and a `meta.json` per folder plus one at the top that fixes
728
+ * the order. A group folder is a `(slug)/` directory, so Fumadocs gives its
729
+ * pages the same addresses as the tree (no segment for the group). Every file
575
730
  * path is unique. Exported for custom setups; `createHelpSource` uses it.
576
731
  */
577
732
  declare function helpTreeToFiles(payload: HelpPublicSitePayload, options: HelpFilesOptions): HelpVirtualFile[];
@@ -611,4 +766,4 @@ declare function getHelpToc(headings: readonly HelpPublicHeading[] | {
611
766
  headings: readonly HelpPublicHeading[];
612
767
  }, options?: HelpTocOptions): TOCItemType[];
613
768
 
614
- export { HELP_BLOCKS, HELP_CALLOUT_TYPES, type HelpBlockName, HelpBody, type HelpBodyComponents, type HelpBodyProps, type HelpCalloutProps, type HelpCalloutType, type HelpCardProps, type HelpCardsProps, type HelpCodeProps, type HelpFilesOptions, type HelpHeadingProps, type HelpImageProps, type HelpLinkProps, type HelpMetaData, type HelpPageData, type HelpRevalidateOptions, type HelpSource, type HelpSourceConfig, type HelpSourceOptions, type HelpStepProps, type HelpStepsProps, type HelpStructuredData, type HelpTabProps, type HelpTabsProps, type HelpTocOptions, type HelpVirtualFile, type HelpWebhookRequest, createHelpRevalidateHandler, createHelpSource, getHelpToc, helpBodyComponents, helpDynamicSource, helpMetaEntry, helpStructuredData, helpTreePaths, helpTreeToFiles, safeHelpHref };
769
+ export { HELP_BLOCKS, HELP_CALLOUT_TYPES, type HelpBlockComponent, type HelpBlockFieldValue, type HelpBlockName, type HelpBlockProps, type HelpBlockValue, type HelpBlockValues, HelpBody, type HelpBodyComponentMap, type HelpBodyComponents, type HelpBodyProps, type HelpCalloutProps, type HelpCalloutType, type HelpCardProps, type HelpCardsProps, type HelpCodeProps, type HelpFilesOptions, type HelpHeadingProps, type HelpImageProps, type HelpLinkProps, type HelpMetaData, type HelpPageData, type HelpRevalidateOptions, type HelpSource, type HelpSourceConfig, type HelpSourceOptions, type HelpStepProps, type HelpStepsProps, type HelpStructuredData, type HelpTabProps, type HelpTabsProps, type HelpTocOptions, type HelpVirtualFile, type HelpWebhookRequest, createHelpRevalidateHandler, createHelpSource, getHelpToc, helpBlockText, helpBlockValues, helpBodyComponents, helpDynamicSource, helpMetaEntry, helpStructuredData, helpTreePaths, helpTreeToFiles, safeHelpHref };
package/dist/fumadocs.js CHANGED
@@ -1,26 +1,57 @@
1
+ import { directiveFromMarkdown } from 'mdast-util-directive';
2
+ import { fromMarkdown } from 'mdast-util-from-markdown';
3
+ import { gfmFromMarkdown } from 'mdast-util-gfm';
4
+ import { directive } from 'micromark-extension-directive';
5
+ import { gfm } from 'micromark-extension-gfm';
1
6
  import { Callout } from 'fumadocs-ui/components/callout';
2
7
  import { Card, Cards } from 'fumadocs-ui/components/card';
3
8
  import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
4
9
  import { Heading } from 'fumadocs-ui/components/heading';
5
10
  import { Step, Steps } from 'fumadocs-ui/components/steps';
6
11
  import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
7
- import { Fragment as Fragment$1 } from 'react';
8
- import { directiveFromMarkdown } from 'mdast-util-directive';
9
- import { fromMarkdown } from 'mdast-util-from-markdown';
10
- import { gfmFromMarkdown } from 'mdast-util-gfm';
11
- import { directive } from 'micromark-extension-directive';
12
- import { gfm } from 'micromark-extension-gfm';
12
+ import { Fragment as Fragment$1, createElement } from 'react';
13
13
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
14
14
 
15
- // src/fumadocs/body.tsx
15
+ // src/fumadocs/markdown.ts
16
16
  var HELP_BLOCKS = ["callout", "steps", "step", "tabs", "tab", "cards", "card"];
17
17
  var HELP_CALLOUT_TYPES = ["info", "warn", "error", "success", "idea"];
18
18
  var isHelpBlock = (name) => HELP_BLOCKS.includes(name);
19
+ var CODE_FENCE = /^[ \t]*(`{3,}|~{3,})(.*)$/;
20
+ var CLOSING_FENCE = /^([ \t]*):(:{2,}[ \t]*)$/;
21
+ var MASK = "\uE000";
22
+ function maskCodeFences(markdown) {
23
+ if (markdown.includes(MASK)) return markdown;
24
+ let open = null;
25
+ return markdown.split("\n").map((line) => {
26
+ const fence = CODE_FENCE.exec(line);
27
+ if (open) {
28
+ if (fence?.[1]?.[0] === open.char && fence[1].length >= open.length && !fence[2]?.trim()) {
29
+ open = null;
30
+ return line;
31
+ }
32
+ return line.replace(CLOSING_FENCE, `$1${MASK}$2`);
33
+ }
34
+ if (fence?.[1] && !(fence[1][0] === "`" && fence[2]?.includes("`"))) {
35
+ open = { char: fence[1][0] ?? "`", length: fence[1].length };
36
+ }
37
+ return line;
38
+ }).join("\n");
39
+ }
40
+ function unmask(node) {
41
+ if ("value" in node && typeof node.value === "string" && node.value.includes(MASK)) {
42
+ node.value = node.value.replaceAll(MASK, ":");
43
+ }
44
+ if ("children" in node) for (const child of node.children) unmask(child);
45
+ }
19
46
  function parseHelpMarkdown(markdown) {
20
- return fromMarkdown(markdown ?? "", {
47
+ const source = markdown ?? "";
48
+ const masked = maskCodeFences(source);
49
+ const root = fromMarkdown(masked, {
21
50
  extensions: [gfm(), directive()],
22
51
  mdastExtensions: [gfmFromMarkdown(), directiveFromMarkdown()]
23
52
  });
53
+ if (masked !== source) unmask(root);
54
+ return root;
24
55
  }
25
56
  var CONTROL = /[\u0000-\u001f\u007f]/;
26
57
  var PROTOCOL_RELATIVE = /^[\\/]{2}/;
@@ -40,10 +71,14 @@ function safeHelpImageSrc(raw) {
40
71
  if (/^\/[^\s]*$/.test(src)) return src;
41
72
  return null;
42
73
  }
43
- function plainText(node) {
74
+ function plainText(node, override) {
75
+ const own = override?.(node);
76
+ if (own !== void 0) return own;
44
77
  if ("value" in node && typeof node.value === "string") return node.value;
45
78
  if (node.type === "image") return node.alt ?? "";
46
- if ("children" in node) return node.children.map(plainText).join("");
79
+ if ("children" in node) {
80
+ return node.children.map((child) => plainText(child, override)).join("");
81
+ }
47
82
  return "";
48
83
  }
49
84
  function sourceOf(node, markdown) {
@@ -104,7 +139,7 @@ function collectHeadings(root) {
104
139
  walk(root);
105
140
  return out;
106
141
  }
107
- function headingIds(root, known) {
142
+ function headingIds(root, known, override) {
108
143
  const headings = collectHeadings(root);
109
144
  const ids = /* @__PURE__ */ new Map();
110
145
  if (known && known.length === headings.length) {
@@ -115,7 +150,7 @@ function headingIds(root, known) {
115
150
  }
116
151
  const used = /* @__PURE__ */ new Map();
117
152
  for (const heading of headings) {
118
- const text = plainText(heading).replace(/\s+/g, " ").trim();
153
+ const text = plainText(heading, override).replace(/\s+/g, " ").trim();
119
154
  const base = headingSlug(text) || "section";
120
155
  const seen = used.get(base) ?? 0;
121
156
  used.set(base, seen + 1);
@@ -123,6 +158,220 @@ function headingIds(root, known) {
123
158
  }
124
159
  return ids;
125
160
  }
161
+
162
+ // src/fumadocs/blocks.ts
163
+ var TEXT_VALUE_MAX = 300;
164
+ var LONG_VALUE_MAX = 2e3;
165
+ var ICON = /^[A-Za-z][A-Za-z0-9]{0,47}$/;
166
+ var NAMED_ENTITIES = {
167
+ amp: "&",
168
+ lt: "<",
169
+ gt: ">",
170
+ quot: '"',
171
+ apos: "'",
172
+ nbsp: "\xA0"
173
+ };
174
+ function decodeEntities(value) {
175
+ return value.replace(/&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z]+);/gi, (match, ref) => {
176
+ if (ref[0] === "#") {
177
+ const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : Number(ref.slice(1));
178
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
179
+ }
180
+ return NAMED_ENTITIES[ref.toLowerCase()] ?? match;
181
+ });
182
+ }
183
+ var ATTRIBUTE = /^(?:#([^\s"'<>=`}#.]+)|\.([^\s"'<>=`}#.]+)|([A-Za-z_:][A-Za-z0-9_.:-]*)(?:[ \t]*=[ \t]*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s"'<>=`}]+)))?)/;
184
+ function parseAttributes(src) {
185
+ const attributes = {};
186
+ let pos = 1;
187
+ for (; ; ) {
188
+ while (src[pos] === " " || src[pos] === " ") pos++;
189
+ if (src[pos] === "}") return { attributes, length: pos + 1 };
190
+ const match = ATTRIBUTE.exec(src.slice(pos));
191
+ if (!match) return null;
192
+ const [whole, id, cls, key, dq, sq, bare] = match;
193
+ if (id !== void 0) attributes.id = decodeEntities(id);
194
+ else if (cls !== void 0)
195
+ attributes.class = attributes.class ? `${attributes.class} ${cls}` : cls;
196
+ else if (key !== void 0) attributes[key] = decodeEntities(dq ?? sq ?? bare ?? "");
197
+ pos += whole.length;
198
+ if (src[pos] !== " " && src[pos] !== " " && src[pos] !== "}") return null;
199
+ }
200
+ }
201
+ function parseLabel(src) {
202
+ let depth = 0;
203
+ for (let pos = 0; pos < src.length; pos++) {
204
+ const char = src[pos];
205
+ if (char === "\n") return null;
206
+ if (char === "\\") {
207
+ pos++;
208
+ continue;
209
+ }
210
+ if (char === "[") depth++;
211
+ else if (char === "]") {
212
+ depth--;
213
+ if (depth === 0) return { label: src.slice(1, pos), length: pos + 1 };
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+ function parseDirectiveTail(src) {
219
+ let rest = src;
220
+ let label;
221
+ let attributes = {};
222
+ if (rest.startsWith("[")) {
223
+ const parsed = parseLabel(rest);
224
+ if (!parsed) return null;
225
+ label = parsed.label;
226
+ rest = rest.slice(parsed.length);
227
+ }
228
+ if (rest.startsWith("{")) {
229
+ const parsed = parseAttributes(rest);
230
+ if (!parsed) return null;
231
+ attributes = parsed.attributes;
232
+ rest = rest.slice(parsed.length);
233
+ }
234
+ return { ...label !== void 0 ? { label } : {}, attributes, rest };
235
+ }
236
+ function plainLabel(label) {
237
+ if (label === void 0) return void 0;
238
+ const text = label.replace(/\\(.)/g, "$1").replace(/[*_`~]/g, "").trim();
239
+ return text || void 0;
240
+ }
241
+ var isVideoAddress = (value) => safeHelpHref(value) === value && (/^https:\/\//i.test(value) || value.startsWith("/"));
242
+ function fieldValue(field, raw) {
243
+ if (field.type === "boolean") {
244
+ const flag = raw.trim().toLowerCase();
245
+ if (flag === "" || flag === "true") return true;
246
+ if (flag === "false") return false;
247
+ return void 0;
248
+ }
249
+ const value = raw.trim();
250
+ switch (field.type) {
251
+ case "text":
252
+ return !/[\r\n]/.test(value) && value.length <= TEXT_VALUE_MAX ? value : void 0;
253
+ case "longText":
254
+ return value.length <= LONG_VALUE_MAX ? value : void 0;
255
+ case "icon":
256
+ return ICON.test(value) ? value : void 0;
257
+ case "url":
258
+ return value.length <= LONG_VALUE_MAX && safeHelpHref(value) === value ? value : void 0;
259
+ case "choice":
260
+ return field.options?.includes(value) ? value : void 0;
261
+ case "image":
262
+ return value.length <= LONG_VALUE_MAX && safeHelpImageSrc(value) === value ? value : void 0;
263
+ case "video":
264
+ return value.length <= LONG_VALUE_MAX && isVideoAddress(value) ? value : void 0;
265
+ default:
266
+ return void 0;
267
+ }
268
+ }
269
+ function helpBlockValues(block, attributes) {
270
+ const values = {};
271
+ for (const field of block.fields) {
272
+ const raw = Object.hasOwn(attributes, field.key) ? attributes[field.key] : void 0;
273
+ if (field.type === "boolean") {
274
+ if (raw === void 0) {
275
+ values[field.key] = false;
276
+ continue;
277
+ }
278
+ } else if (raw === void 0 || raw.trim() === "") {
279
+ continue;
280
+ }
281
+ const value = fieldValue(field, raw);
282
+ if (value !== void 0) values[field.key] = value;
283
+ }
284
+ return values;
285
+ }
286
+ function isTemplate(template) {
287
+ for (let i = 0; i < template.length; i++) {
288
+ const char = template[i];
289
+ if ((char === "{" || char === "}") && template[i + 1] === char) {
290
+ i++;
291
+ continue;
292
+ }
293
+ if (char === "}") return false;
294
+ if (char !== "{") continue;
295
+ const end = template.indexOf("}", i + 1);
296
+ if (end === -1) return false;
297
+ const key = template.slice(i + 1, end).trim();
298
+ if (!key || key.includes("{")) return false;
299
+ i = end;
300
+ }
301
+ return true;
302
+ }
303
+ function helpBlockText(block, values, label) {
304
+ const template = block.text ?? "";
305
+ const fields = new Map(block.fields.map((f) => [f.key, f]));
306
+ let text = "";
307
+ if (isTemplate(template)) {
308
+ for (let i = 0; i < template.length; i++) {
309
+ const char = template[i] ?? "";
310
+ if ((char === "{" || char === "}") && template[i + 1] === char) {
311
+ text += char;
312
+ i++;
313
+ continue;
314
+ }
315
+ if (char !== "{") {
316
+ text += char;
317
+ continue;
318
+ }
319
+ const end = template.indexOf("}", i + 1);
320
+ const key = template.slice(i + 1, end).trim();
321
+ const value = values[key];
322
+ if (value === true) text += fields.get(key)?.label ?? "";
323
+ else if (typeof value === "string") text += value;
324
+ i = end;
325
+ }
326
+ }
327
+ return `${text} ${label ?? ""}`.replace(/\s+/g, " ").trim();
328
+ }
329
+ var FORMS = {
330
+ textDirective: "inline",
331
+ leafDirective: "block",
332
+ containerDirective: "container"
333
+ };
334
+ function helpBlockMatcher(blocks, markdown) {
335
+ const defined = new Map((blocks ?? []).map((block) => [block.name, block]));
336
+ const cache = /* @__PURE__ */ new Map();
337
+ const find = (node) => {
338
+ if (defined.size === 0) return null;
339
+ if (node.type !== "textDirective" && node.type !== "leafDirective" && node.type !== "containerDirective") {
340
+ return null;
341
+ }
342
+ const block = defined.get(node.name);
343
+ if (!block || block.kind !== FORMS[node.type]) return null;
344
+ const start = node.position?.start.offset;
345
+ const end = node.position?.end.offset;
346
+ if (start === void 0 || end === void 0) return null;
347
+ const lineEnd = markdown.indexOf("\n", start);
348
+ const line = markdown.slice(start, lineEnd === -1 ? void 0 : lineEnd);
349
+ const head = /^(:+)([A-Za-z][A-Za-z0-9_-]*)/.exec(line);
350
+ const colons = head?.[1]?.length ?? 0;
351
+ const form = colons === 1 ? "textDirective" : colons === 2 ? "leafDirective" : "containerDirective";
352
+ if (!head || form !== node.type || head[2] !== node.name) return null;
353
+ const tail = parseDirectiveTail(line.slice(head[0].length));
354
+ if (!tail) return null;
355
+ if (node.type === "textDirective") {
356
+ if (line.length - tail.rest.length !== end - start) return null;
357
+ } else if (tail.rest.trim() !== "") return null;
358
+ const values = helpBlockValues(block, tail.attributes);
359
+ const label = node.type === "containerDirective" ? void 0 : plainLabel(tail.label);
360
+ return { block, node, values, text: helpBlockText(block, values, label) };
361
+ };
362
+ const use = (node) => {
363
+ if (!cache.has(node)) cache.set(node, find(node));
364
+ return cache.get(node) ?? null;
365
+ };
366
+ const text = (node) => {
367
+ const found = use(node);
368
+ if (!found) return void 0;
369
+ if (found.node.type !== "containerDirective") return found.text;
370
+ const inner = contentOf(found.node).map((child) => plainText(child, text)).join(" ");
371
+ return `${found.text} ${inner}`.trim();
372
+ };
373
+ return { use, text };
374
+ }
126
375
  var PLAIN_CODE_COLORS = {
127
376
  "--shiki-light": "var(--color-fd-foreground)",
128
377
  "--shiki-dark": "var(--color-fd-foreground)"
@@ -152,6 +401,15 @@ var helpBodyComponents = {
152
401
  Link: ({ href, title, children }) => /* @__PURE__ */ jsx("a", { href, title, children }),
153
402
  Image: ({ src, alt, title }) => /* @__PURE__ */ jsx("img", { src, alt, title, loading: "lazy" })
154
403
  };
404
+ var BUILT_IN_KEYS = Object.keys(helpBodyComponents);
405
+ function pickBuiltIns(components) {
406
+ const out = {};
407
+ for (const key of BUILT_IN_KEYS) {
408
+ const component = components[key];
409
+ if (component) out[key] = component;
410
+ }
411
+ return out;
412
+ }
155
413
  var normalizeId = (id) => id.trim().replace(/\s+/g, " ").toLowerCase();
156
414
  function collectDefinitions(root) {
157
415
  const out = /* @__PURE__ */ new Map();
@@ -254,8 +512,30 @@ function renderTabs(children, ctx) {
254
512
  );
255
513
  }) });
256
514
  }
515
+ function renderCustomBlock(use, ctx) {
516
+ const { block, node, values, text } = use;
517
+ const isContainer = node.type === "containerDirective";
518
+ const children = node.type === "containerDirective" ? renderAll(contentOf(node), { ...ctx, tight: false }) : node.children.length ? renderAll(node.children, { ...ctx, inline: true }) : void 0;
519
+ const Component = Object.hasOwn(ctx.custom, block.name) ? ctx.custom[block.name] : void 0;
520
+ if (Component) {
521
+ return createElement(Component, { ...values, block: block.name, lang: ctx.lang }, children);
522
+ }
523
+ const attributes = {
524
+ className: "sd-block",
525
+ "data-block": block.name,
526
+ "data-props": JSON.stringify(values)
527
+ };
528
+ if (block.kind === "inline") return /* @__PURE__ */ jsx("span", { ...attributes, children: text });
529
+ if (!isContainer) return /* @__PURE__ */ jsx("div", { ...attributes, children: text });
530
+ return /* @__PURE__ */ jsxs("div", { ...attributes, children: [
531
+ text ? /* @__PURE__ */ jsx("p", { className: "sd-block-text", children: text }) : null,
532
+ /* @__PURE__ */ jsx("div", { className: "sd-block-body", children })
533
+ ] });
534
+ }
257
535
  function renderDirective(node, ctx) {
258
536
  const { c } = ctx;
537
+ const custom = ctx.blocks.use(node);
538
+ if (custom) return renderCustomBlock(custom, ctx);
259
539
  if (!isHelpBlock(node.name)) {
260
540
  const source = sourceOf(node, ctx.markdown);
261
541
  const lines = source.split("\n");
@@ -358,24 +638,34 @@ function renderNode(node, ctx) {
358
638
  return null;
359
639
  case "containerDirective":
360
640
  return renderDirective(node, { ...ctx, tight: false });
361
- // Not ours: printed back as written.
362
641
  case "leafDirective":
642
+ case "textDirective": {
643
+ const custom = ctx.blocks.use(node);
644
+ if (custom) return renderCustomBlock(custom, ctx);
645
+ return node.type === "leafDirective" ? literalBlock(sourceOf(node, ctx.markdown)) : sourceOf(node, ctx.markdown);
646
+ }
647
+ // Not ours: printed back as written.
363
648
  case "footnoteDefinition":
364
649
  return literalBlock(sourceOf(node, ctx.markdown));
365
- case "textDirective":
366
650
  case "footnoteReference":
367
651
  return sourceOf(node, ctx.markdown);
368
652
  default:
369
653
  return "children" in node ? renderAll(node.children, ctx) : "value" in node ? String(node.value) : plainText(node);
370
654
  }
371
655
  }
372
- function HelpBody({ markdown, headings, components, icon }) {
373
- const source = markdown ?? "";
656
+ function HelpBody(props) {
657
+ const { page, components = {}, icon } = props;
658
+ const source = props.markdown ?? page?.markdown ?? "";
659
+ const headings = props.headings ?? page?.headings;
374
660
  const root = parseHelpMarkdown(source);
661
+ const blocks = helpBlockMatcher(props.blocks, source);
375
662
  const ctx = {
376
663
  markdown: source,
377
- ids: headingIds(root, headings),
378
- c: { ...helpBodyComponents, ...components },
664
+ blocks,
665
+ custom: components,
666
+ lang: props.lang ?? page?.locale ?? "",
667
+ ids: headingIds(root, headings, blocks.text),
668
+ c: { ...helpBodyComponents, ...pickBuiltIns(components) },
379
669
  icon,
380
670
  definitions: collectDefinitions(root),
381
671
  linkless: false,
@@ -484,12 +774,17 @@ function parseBody(raw) {
484
774
  }
485
775
 
486
776
  // src/fumadocs/structured.ts
487
- function helpStructuredData(markdown, headings) {
488
- const root = parseHelpMarkdown(markdown);
489
- const ids = headingIds(root, headings);
777
+ function helpStructuredData(markdown, headings, blocks) {
778
+ const source = markdown ?? "";
779
+ const root = parseHelpMarkdown(source);
780
+ const matcher = helpBlockMatcher(blocks, source);
781
+ const ids = headingIds(root, headings, matcher.text);
490
782
  const out = { headings: [], contents: [] };
491
783
  let current;
492
- const text = (node) => plainText(node).replace(/\s+/g, " ").trim();
784
+ const text = (node) => plainText(node, matcher.text).replace(/\s+/g, " ").trim();
785
+ const add = (content) => {
786
+ if (content) out.contents.push({ heading: current, content });
787
+ };
493
788
  const walk = (node) => {
494
789
  if (node.type === "heading") {
495
790
  current = ids.get(node);
@@ -497,9 +792,15 @@ function helpStructuredData(markdown, headings) {
497
792
  if (current && content) out.headings.push({ id: current, content });
498
793
  return;
499
794
  }
795
+ const use = matcher.use(node);
796
+ if (use) {
797
+ add(use.text);
798
+ if (use.node.type === "containerDirective")
799
+ for (const child of contentOf(use.node)) walk(child);
800
+ return;
801
+ }
500
802
  if (node.type === "paragraph" || node.type === "tableCell") {
501
- const content = text(node);
502
- if (content) out.contents.push({ heading: current, content });
803
+ add(text(node));
503
804
  return;
504
805
  }
505
806
  if (node.type === "code" || node.type === "html" || node.type === "definition") return;
@@ -519,12 +820,13 @@ var pageFileName = (page) => {
519
820
  const name = lastSegment(page.path);
520
821
  return name === "index" ? "index_" : name;
521
822
  };
823
+ var folderDirName = (folder) => folder.group ? `(${folder.slug})` : folder.slug;
522
824
  function helpMetaEntry(node) {
523
825
  switch (node.type) {
524
826
  case "page":
525
827
  return pageFileName(node);
526
828
  case "folder":
527
- return node.slug;
829
+ return folderDirName(node);
528
830
  case "separator": {
529
831
  const title = node.title?.replace(/---/g, "\u2013").trim();
530
832
  if (!title) return "---";
@@ -539,6 +841,7 @@ function helpTreeToFiles(payload, options) {
539
841
  const ext = (name, extension) => `${name}${options.suffix ? `.${options.suffix}` : ""}.${extension}`;
540
842
  const join = (dir, name) => dir ? `${dir}/${name}` : name;
541
843
  const seen = /* @__PURE__ */ new Set();
844
+ const blocks = payload.site.blocks ?? [];
542
845
  const addPage = (page, file) => {
543
846
  if (seen.has(file)) return;
544
847
  seen.add(file);
@@ -551,10 +854,11 @@ function helpTreeToFiles(payload, options) {
551
854
  ...page.icon ? { icon: page.icon } : {},
552
855
  path: page.path,
553
856
  lang: options.lang,
857
+ blocks,
554
858
  load,
555
859
  structuredData: async () => {
556
860
  const full = await load();
557
- return full ? helpStructuredData(full.markdown, full.headings) : { headings: [], contents: [] };
861
+ return full ? helpStructuredData(full.markdown, full.headings, blocks) : { headings: [], contents: [] };
558
862
  },
559
863
  ...prefetched ? { page: prefetched } : {}
560
864
  };
@@ -577,9 +881,9 @@ function helpTreeToFiles(payload, options) {
577
881
  const addChildren = (nodes, dir) => {
578
882
  for (const node of nodes) {
579
883
  if (node.type === "page") {
580
- addPage(node, node.path ? join(dir, ext(pageFileName(node), "mdx")) : ext("index", "mdx"));
884
+ addPage(node, join(dir, ext(pageFileName(node), "mdx")));
581
885
  } else if (node.type === "folder") {
582
- addFolder(node, join(dir, node.slug));
886
+ addFolder(node, join(dir, folderDirName(node)));
583
887
  }
584
888
  }
585
889
  };
@@ -661,4 +965,4 @@ function getHelpToc(headings, options = {}) {
661
965
  return list.filter((heading) => heading.level >= min && heading.level <= max && heading.id).map((heading) => ({ title: heading.text, url: `#${heading.id}`, depth: heading.level }));
662
966
  }
663
967
 
664
- export { HELP_BLOCKS, HELP_CALLOUT_TYPES, HelpBody, createHelpRevalidateHandler, createHelpSource, getHelpToc, helpBodyComponents, helpDynamicSource, helpMetaEntry, helpStructuredData, helpTreePaths, helpTreeToFiles, safeHelpHref };
968
+ export { HELP_BLOCKS, HELP_CALLOUT_TYPES, HelpBody, createHelpRevalidateHandler, createHelpSource, getHelpToc, helpBlockText, helpBlockValues, helpBodyComponents, helpDynamicSource, helpMetaEntry, helpStructuredData, helpTreePaths, helpTreeToFiles, safeHelpHref };
package/dist/index.d.cts CHANGED
@@ -139,7 +139,8 @@ declare function verifyWebhookSignature(secret: string, rawBody: WebhookBody, he
139
139
  * preview token names. Absent values are `null`, never a missing key.
140
140
  *
141
141
  * Paths: a page's path is its folder slugs plus its slug, without leading or
142
- * trailing slash (`guides/login`); the site's home page has the path `""`.
142
+ * trailing slash (`guides/login`) — a `group` folder adds no slug; the site's
143
+ * home page has the path `""`.
143
144
  */
144
145
  /** The doc site itself. */
145
146
  interface HelpPublicSite {
@@ -152,6 +153,40 @@ interface HelpPublicSite {
152
153
  languages: string[];
153
154
  /** The docs website's base address (`https://help.acme.com/docs`), if set. */
154
155
  websiteUrl: string | null;
156
+ /** The site's own building blocks pages may use (empty when it defines none). */
157
+ blocks: HelpPublicBlockDefinition[];
158
+ }
159
+ /** What a field of a site's own block holds (see {@link HelpPublicBlockField}). */
160
+ type HelpPublicBlockFieldType = "text" | "longText" | "icon" | "url" | "choice" | "image" | "video" | "boolean";
161
+ /**
162
+ * One field of a site's own block: an attribute of its directive
163
+ * (`key="value"`). `boolean` fields are on when written bare (`key`) or as
164
+ * `key="true"`; every other value is a string. `options` lists a `choice`
165
+ * field's values (null for other types).
166
+ */
167
+ interface HelpPublicBlockField {
168
+ key: string;
169
+ label: string;
170
+ type: HelpPublicBlockFieldType;
171
+ required: boolean;
172
+ options: string[] | null;
173
+ }
174
+ /**
175
+ * A building block the site defines itself — WHAT it is; how it looks is the
176
+ * website's own component. Written in Markdown as `:name[label]{…}` in running
177
+ * text (`inline`), `::name[label]{…}` alone on a line (`block`) or
178
+ * `:::name{…}` … `:::` around Markdown content (`container`). `text` is the
179
+ * template of its text version (`{key}` = a field's value, a `boolean` field's
180
+ * label when on; `{{`/`}}` = a brace) — what to show without a component.
181
+ */
182
+ interface HelpPublicBlockDefinition {
183
+ /** The directive name (`app-button`). */
184
+ name: string;
185
+ /** Display name. */
186
+ label: string;
187
+ kind: "inline" | "block" | "container";
188
+ fields: HelpPublicBlockField[];
189
+ text: string;
155
190
  }
156
191
  /** A page in the navigation. */
157
192
  interface HelpPublicTreePage {
@@ -170,12 +205,17 @@ interface HelpPublicTreeFolder {
170
205
  /** Stable node id. */
171
206
  id: string;
172
207
  slug: string;
173
- /** The folder's path (its index page, if any, lives here). */
208
+ /** The folder's path (its index page, if any, lives here; a group's is its parent's). */
174
209
  path: string;
175
210
  title: string;
176
211
  icon: string | null;
177
212
  /** A tab (top level only; Fumadocs `root`). */
178
213
  root: boolean;
214
+ /**
215
+ * Adds no segment to its pages' paths (Fumadocs `(name)` folder): `path` is
216
+ * the parent's, a top-level group's index page is the home (`""`).
217
+ */
218
+ group: boolean;
179
219
  defaultOpen: boolean;
180
220
  collapsible: boolean;
181
221
  /** The folder's own page, shown at the folder's path; null without one. */
@@ -256,12 +296,17 @@ interface HelpPublicPageLink {
256
296
  }
257
297
  /** One page with its text. */
258
298
  interface HelpPublicPage extends HelpPublicPageSummary {
259
- /** The page's Markdown with directives (`:::callout`, `::::steps`, …). */
299
+ /**
300
+ * The page's Markdown with directives (`:::callout`, `::::steps`, …, and the
301
+ * site's own blocks as written — see `site.blocks`).
302
+ */
260
303
  markdown: string;
261
304
  /** Safe server-rendered HTML of `markdown` (stable `sd-*` classes for the directives). */
262
305
  html: string;
263
306
  headings: HelpPublicHeading[];
264
307
  blocks: HelpPublicBlock[];
308
+ /** The site's own building blocks the page uses, by name (in the site's order). */
309
+ customBlocks: string[];
265
310
  breadcrumbs: HelpPublicBreadcrumb[];
266
311
  previous: HelpPublicPageLink | null;
267
312
  next: HelpPublicPageLink | null;
@@ -424,4 +469,4 @@ declare function createHelpClient(options: HelpClientOptions): HelpClient;
424
469
  */
425
470
  declare function verifyHelpWebhook(secret: string, rawBody: WebhookBody, signatureHeader: string | null | undefined, options?: VerifyWebhookOptions): Promise<HelpWebhookBody | null>;
426
471
 
427
- export { type BaseClientOptions, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPageParams, type HelpClient, type HelpClientOptions, type HelpPageResult, type HelpPublicBlock, type HelpPublicBreadcrumb, type HelpPublicHeading, type HelpPublicPage, type HelpPublicPageLink, type HelpPublicPagePayload, type HelpPublicPageSummary, type HelpPublicPagesPayload, type HelpPublicPreviewPage, type HelpPublicPreviewPayload, type HelpPublicRedirect, type HelpPublicSearchHit, type HelpPublicSearchPayload, type HelpPublicSite, type HelpPublicSitePayload, type HelpPublicTreeFolder, type HelpPublicTreeLink, type HelpPublicTreeNode, type HelpPublicTreePage, type HelpPublicTreeSeparator, type HelpWebhookBody, type HelpWebhookEvent, type LangParams, type RequestOptions, SIGNATURE_HEADER, type SearchParams, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createHelpClient, createMemoryEtagCache, normalizeHelpPath, verifyHelpWebhook, verifyWebhookSignature };
472
+ export { type BaseClientOptions, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPageParams, type HelpClient, type HelpClientOptions, type HelpPageResult, type HelpPublicBlock, type HelpPublicBlockDefinition, type HelpPublicBlockField, type HelpPublicBlockFieldType, type HelpPublicBreadcrumb, type HelpPublicHeading, type HelpPublicPage, type HelpPublicPageLink, type HelpPublicPagePayload, type HelpPublicPageSummary, type HelpPublicPagesPayload, type HelpPublicPreviewPage, type HelpPublicPreviewPayload, type HelpPublicRedirect, type HelpPublicSearchHit, type HelpPublicSearchPayload, type HelpPublicSite, type HelpPublicSitePayload, type HelpPublicTreeFolder, type HelpPublicTreeLink, type HelpPublicTreeNode, type HelpPublicTreePage, type HelpPublicTreeSeparator, type HelpWebhookBody, type HelpWebhookEvent, type LangParams, type RequestOptions, SIGNATURE_HEADER, type SearchParams, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createHelpClient, createMemoryEtagCache, normalizeHelpPath, verifyHelpWebhook, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -139,7 +139,8 @@ declare function verifyWebhookSignature(secret: string, rawBody: WebhookBody, he
139
139
  * preview token names. Absent values are `null`, never a missing key.
140
140
  *
141
141
  * Paths: a page's path is its folder slugs plus its slug, without leading or
142
- * trailing slash (`guides/login`); the site's home page has the path `""`.
142
+ * trailing slash (`guides/login`) — a `group` folder adds no slug; the site's
143
+ * home page has the path `""`.
143
144
  */
144
145
  /** The doc site itself. */
145
146
  interface HelpPublicSite {
@@ -152,6 +153,40 @@ interface HelpPublicSite {
152
153
  languages: string[];
153
154
  /** The docs website's base address (`https://help.acme.com/docs`), if set. */
154
155
  websiteUrl: string | null;
156
+ /** The site's own building blocks pages may use (empty when it defines none). */
157
+ blocks: HelpPublicBlockDefinition[];
158
+ }
159
+ /** What a field of a site's own block holds (see {@link HelpPublicBlockField}). */
160
+ type HelpPublicBlockFieldType = "text" | "longText" | "icon" | "url" | "choice" | "image" | "video" | "boolean";
161
+ /**
162
+ * One field of a site's own block: an attribute of its directive
163
+ * (`key="value"`). `boolean` fields are on when written bare (`key`) or as
164
+ * `key="true"`; every other value is a string. `options` lists a `choice`
165
+ * field's values (null for other types).
166
+ */
167
+ interface HelpPublicBlockField {
168
+ key: string;
169
+ label: string;
170
+ type: HelpPublicBlockFieldType;
171
+ required: boolean;
172
+ options: string[] | null;
173
+ }
174
+ /**
175
+ * A building block the site defines itself — WHAT it is; how it looks is the
176
+ * website's own component. Written in Markdown as `:name[label]{…}` in running
177
+ * text (`inline`), `::name[label]{…}` alone on a line (`block`) or
178
+ * `:::name{…}` … `:::` around Markdown content (`container`). `text` is the
179
+ * template of its text version (`{key}` = a field's value, a `boolean` field's
180
+ * label when on; `{{`/`}}` = a brace) — what to show without a component.
181
+ */
182
+ interface HelpPublicBlockDefinition {
183
+ /** The directive name (`app-button`). */
184
+ name: string;
185
+ /** Display name. */
186
+ label: string;
187
+ kind: "inline" | "block" | "container";
188
+ fields: HelpPublicBlockField[];
189
+ text: string;
155
190
  }
156
191
  /** A page in the navigation. */
157
192
  interface HelpPublicTreePage {
@@ -170,12 +205,17 @@ interface HelpPublicTreeFolder {
170
205
  /** Stable node id. */
171
206
  id: string;
172
207
  slug: string;
173
- /** The folder's path (its index page, if any, lives here). */
208
+ /** The folder's path (its index page, if any, lives here; a group's is its parent's). */
174
209
  path: string;
175
210
  title: string;
176
211
  icon: string | null;
177
212
  /** A tab (top level only; Fumadocs `root`). */
178
213
  root: boolean;
214
+ /**
215
+ * Adds no segment to its pages' paths (Fumadocs `(name)` folder): `path` is
216
+ * the parent's, a top-level group's index page is the home (`""`).
217
+ */
218
+ group: boolean;
179
219
  defaultOpen: boolean;
180
220
  collapsible: boolean;
181
221
  /** The folder's own page, shown at the folder's path; null without one. */
@@ -256,12 +296,17 @@ interface HelpPublicPageLink {
256
296
  }
257
297
  /** One page with its text. */
258
298
  interface HelpPublicPage extends HelpPublicPageSummary {
259
- /** The page's Markdown with directives (`:::callout`, `::::steps`, …). */
299
+ /**
300
+ * The page's Markdown with directives (`:::callout`, `::::steps`, …, and the
301
+ * site's own blocks as written — see `site.blocks`).
302
+ */
260
303
  markdown: string;
261
304
  /** Safe server-rendered HTML of `markdown` (stable `sd-*` classes for the directives). */
262
305
  html: string;
263
306
  headings: HelpPublicHeading[];
264
307
  blocks: HelpPublicBlock[];
308
+ /** The site's own building blocks the page uses, by name (in the site's order). */
309
+ customBlocks: string[];
265
310
  breadcrumbs: HelpPublicBreadcrumb[];
266
311
  previous: HelpPublicPageLink | null;
267
312
  next: HelpPublicPageLink | null;
@@ -424,4 +469,4 @@ declare function createHelpClient(options: HelpClientOptions): HelpClient;
424
469
  */
425
470
  declare function verifyHelpWebhook(secret: string, rawBody: WebhookBody, signatureHeader: string | null | undefined, options?: VerifyWebhookOptions): Promise<HelpWebhookBody | null>;
426
471
 
427
- export { type BaseClientOptions, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPageParams, type HelpClient, type HelpClientOptions, type HelpPageResult, type HelpPublicBlock, type HelpPublicBreadcrumb, type HelpPublicHeading, type HelpPublicPage, type HelpPublicPageLink, type HelpPublicPagePayload, type HelpPublicPageSummary, type HelpPublicPagesPayload, type HelpPublicPreviewPage, type HelpPublicPreviewPayload, type HelpPublicRedirect, type HelpPublicSearchHit, type HelpPublicSearchPayload, type HelpPublicSite, type HelpPublicSitePayload, type HelpPublicTreeFolder, type HelpPublicTreeLink, type HelpPublicTreeNode, type HelpPublicTreePage, type HelpPublicTreeSeparator, type HelpWebhookBody, type HelpWebhookEvent, type LangParams, type RequestOptions, SIGNATURE_HEADER, type SearchParams, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createHelpClient, createMemoryEtagCache, normalizeHelpPath, verifyHelpWebhook, verifyWebhookSignature };
472
+ export { type BaseClientOptions, type CachedResponse, DEFAULT_BASE_URL, EVENT_HEADER, type EtagCache, type FetchInitLike, type FetchLike, type FetchResponseLike, type GetPageParams, type HelpClient, type HelpClientOptions, type HelpPageResult, type HelpPublicBlock, type HelpPublicBlockDefinition, type HelpPublicBlockField, type HelpPublicBlockFieldType, type HelpPublicBreadcrumb, type HelpPublicHeading, type HelpPublicPage, type HelpPublicPageLink, type HelpPublicPagePayload, type HelpPublicPageSummary, type HelpPublicPagesPayload, type HelpPublicPreviewPage, type HelpPublicPreviewPayload, type HelpPublicRedirect, type HelpPublicSearchHit, type HelpPublicSearchPayload, type HelpPublicSite, type HelpPublicSitePayload, type HelpPublicTreeFolder, type HelpPublicTreeLink, type HelpPublicTreeNode, type HelpPublicTreePage, type HelpPublicTreeSeparator, type HelpWebhookBody, type HelpWebhookEvent, type LangParams, type RequestOptions, SIGNATURE_HEADER, type SearchParams, SolutionDeskError, type VerifyWebhookOptions, type WebhookBody, createHelpClient, createMemoryEtagCache, normalizeHelpPath, verifyHelpWebhook, verifyWebhookSignature };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solutiondesk/help",
3
- "version": "1.0.0-69d9f69",
3
+ "version": "1.0.0-a1101b8",
4
4
  "description": "Typed, zero-dependency client for the SolutionDesk public help center API, plus a Fumadocs adapter: navigation, pages, search, previews, llms.txt and webhook verification.",
5
5
  "keywords": [
6
6
  "solutiondesk",