storybook-addon-md 0.3.0 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # storybook-addon-md
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - df081eb: Respect `docs.defaultName`, use leading H1s as standalone titles, and add `tagFields` and the `storybook-addon-md/node` parsing API. Markdown pages retain props, examples, and original manifest source.
8
+
9
+ Migration: update attached links from `--markdown` to `--docs` (or the configured name), enable Autodocs in preview-level tags, and render the supplied `heading` in custom layouts. MCP component IDs are unchanged.
10
+
3
11
  ## 0.3.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -61,7 +61,19 @@ Use buttons to trigger actions.
61
61
  - Confirm a choice.
62
62
  ```
63
63
 
64
- The component gets a **Markdown** Docs entry with the content, status and tag chips, examples, and props. Existing stories and Autodocs remain available.
64
+ The component gets one **Docs** entry with Markdown, status and tag chips, examples, and automatic props. The name follows Storybook's `docs.defaultName`, falling back to `Docs`.
65
+
66
+ Enable Autodocs at project level in `.storybook/preview.ts`:
67
+
68
+ ```ts
69
+ export default {
70
+ tags: ['autodocs'],
71
+ };
72
+ ```
73
+
74
+ Storybook 10.6.0 replaces project-level Autodocs with the attached Markdown page. Components without Markdown keep ordinary Autodocs. Use `tags: ['!autodocs']` on components that should not have automatic docs. Component-only or story-only `autodocs` tags conflict with attached MDX in this Storybook version; move the enabling tag to the preview. Native props and examples on Markdown pages do not require an `autodocs` tag.
75
+
76
+ Authored MDX keeps its title and explicit `name`. Additional attached MDX pages can use a distinct name such as `<Meta of={ButtonStories} name="Design notes" />`. An authored page using the default docs name for the same component conflicts with the addon page: keep the authored page and exclude that component's Markdown from `patterns` (or remove its association). The addon does not overwrite authored pages.
65
77
 
66
78
  The sibling convention supports `.stories.tsx`, `.stories.ts`, `.stories.jsx`, and `.stories.js`. To associate a different file, or share a document across components, set `stories` relative to the Markdown file:
67
79
 
@@ -89,6 +101,8 @@ Write ordinary Markdown here.
89
101
 
90
102
  Without a title, `docs/Introduction.md` appears at `Documentation/docs/Introduction`.
91
103
 
104
+ A leading Markdown H1 supplies the visible title, including inline formatting. Otherwise the final segment of `title` is shown. Both `# Heading` and Setext H1 syntax work; a later H1 is ordinary content. Sidebar placement and IDs always use the configured or inferred sidebar title. The original file and manifest content remain unchanged.
105
+
92
106
  ### Frontmatter
93
107
 
94
108
  YAML frontmatter is optional. Use lowercase field names.
@@ -113,12 +127,59 @@ Invalid frontmatter, missing or ambiguous story references, and missing local as
113
127
  | `generatedDir` | `storybook-markdown-generated` | Disposable output folder under the working directory. |
114
128
  | `stylesheet` | None | Custom stylesheet path. |
115
129
  | `manifests` | `false` | Include original Markdown in Storybook documentation manifests. |
130
+ | `tagFields` | `[]` | Additional frontmatter fields displayed as tags. |
116
131
  | `presentation` | None | Module exporting `Layout` and/or `MarkdownRenderer`. |
117
132
 
118
133
  Globs and customization paths start from your project folder (the parent of `.storybook` by default). Keep the config and local files inside that folder. Restart Storybook after changing options.
119
134
 
120
135
  `generatedDir` must be a visible folder name using letters, digits, hyphens, or underscores. Hidden folders, nested paths, `node_modules`, and `storybook-static` are unsupported. Ignore the folder in Git; the addon manages its contents.
121
136
 
137
+ ### Docs names and metadata tags
138
+
139
+ ```ts
140
+ const config: StorybookConfig = {
141
+ framework: '@storybook/react-vite',
142
+ docs: { defaultName: 'Reference' },
143
+ stories: ['../src/**/*.stories.@(ts|tsx|js|jsx)', '../docs/**/*.mdx'],
144
+ addons: [
145
+ '@storybook/addon-docs',
146
+ {
147
+ name: 'storybook-addon-md',
148
+ options: {
149
+ patterns: ['src/**/*.md', 'docs/**/*.md'],
150
+ tagFields: ['category', 'subcategory'],
151
+ },
152
+ },
153
+ ],
154
+ };
155
+ ```
156
+
157
+ `tagFields` adds string values and string array items from those fields to the existing tags. Missing fields, blank strings, and non-string values are ignored. Labels are deduplicated by exact value across documents, tags, configured fields, and status; status takes precedence and retains its original `data-status`. Metadata is not modified. Consumers can remove adapters that only copied these fields into `tags`.
158
+
159
+ ### Node parsing and CI checks
160
+
161
+ Use the Node-only `storybook-addon-md/node` export. It shares discovery's parser and story resolution without loading Storybook or browser code:
162
+
163
+ ```ts
164
+ import { readMarkdown, parseMarkdown, resolveStoryAssociations } from 'storybook-addon-md/node';
165
+
166
+ const root = process.cwd();
167
+ const document = await readMarkdown('src/Button.metadata.md', root);
168
+
169
+ if (!document.body.includes('## When to use')) {
170
+ throw new Error(`${document.file}: missing When to use section`);
171
+ }
172
+
173
+ const parsed = parseMarkdown('---\ntags: [Actions]\n---\n## Overview', 'virtual.md');
174
+ const stories = await resolveStoryAssociations(document.file, document.metadata, root);
175
+ ```
176
+
177
+ `readMarkdown(file, root)` accepts a root-relative or absolute `.md` path and returns `{ file, original, body, metadata, stories }`. `file` and resolved `stories` are absolute paths. `original` is the complete unchanged source; `body` excludes frontmatter and normalizes BOM/CRLF just as discovery does.
178
+
179
+ `parseMarkdown(text, source)` returns `{ body, metadata }` and validates YAML, lowercase keys, title, tags, status, and story-reference value shapes. `source` labels errors. `resolveStoryAssociations(file, metadata, root)` takes parsed metadata and absolute paths, checks relative references, the four supported story extensions, file existence and root boundaries, and missing or ambiguous `.metadata.md` siblings. Explicit references take precedence. These functions throw source-specific errors; consumers can add their own template checks using `body` and metadata.
180
+
181
+ This is not a separate validation framework. These functions do not check local assets, duplicate sidebar titles, or whether stories match the consuming Storybook's globs; discovery/build still performs the relevant integration checks. Filesystem dependencies are confined to Node entry points, never the runtime export.
182
+
122
183
  ## Documentation manifests and MCP
123
184
 
124
185
  Manifest support is opt-in. Set `manifests: true` in this addon's options and enable Storybook's `features.componentsManifest`:
@@ -142,6 +203,8 @@ const config: StorybookConfig = {
142
203
  };
143
204
  ```
144
205
 
206
+ Use MCP's `docs-list` to find IDs, then `docs-show` with a component ID (for attached guidance) or standalone documentation ID.
207
+
145
208
  For MCP access, install `@storybook/addon-mcp@10.6.0` and connect your MCP client to `http://localhost:6006/mcp`. Its Get Documentation tool is named `docs-show` in 10.6.0. Omit that addon if you only need JSON manifests. It is not a dependency of `storybook-addon-md`.
146
209
 
147
210
  With Storybook and React Vite **10.6.0**, standalone Markdown appears in `/manifests/docs.json`, and attached Markdown appears in the component's `docs` in `/manifests/components.json`. Both development and static builds include the complete original source, including frontmatter. Development updates use the existing file watcher. Shared documents appear under each associated component; multiple documents on one page are joined in discovery order with two newlines. String `description` values supply optional summaries.
@@ -172,7 +235,7 @@ See [Styling](STYLING.md) for all variables, status colors, theme switching, and
172
235
  - Links to `.md` files open the original source, not a rendered Docs page. Use a Storybook URL such as `/?path=/docs/guides-introduction--docs` for page navigation.
173
236
  - Braces and JSX-like text are treated as content. Raw HTML renders as text by default.
174
237
  - Set Storybook’s `parameters.options.storySort` for explicit sidebar ordering. See the [example preview](https://github.com/ruijdacd/storybook-addon-md/blob/main/example/.storybook/preview.ts).
175
- - The attached Docs entry name **Markdown** is reserved. Multiple development Storybooks sharing one config directory are unsupported.
238
+ - Multiple development Storybooks sharing one config directory are unsupported.
176
239
 
177
240
  ## Examples and contributing
178
241
 
@@ -182,7 +245,7 @@ Install dependencies with **Nub 0.7.5** and **Node 24.11+**:
182
245
  nub install
183
246
  ```
184
247
 
185
- Choose either example. They share stories, Markdown, and styling, with separate Storybook configurations:
248
+ Choose either example. They share stories, Markdown, and styling, with separate Storybook configurations. The MCP example sets `docs.defaultName: 'Reference'` to exercise custom docs names:
186
249
 
187
250
  | Example | Configuration | Run | Build |
188
251
  | ----------- | ------------------------------------- | ------------------------------ | ----------------------------- |
package/STYLING.md CHANGED
@@ -178,9 +178,9 @@ export function MarkdownRenderer(document: MarkdownDocument) {
178
178
  }
179
179
  ```
180
180
 
181
- `MarkdownRenderer` receives `{ markdown, metadata, source }`: processed Markdown with resolved asset URLs, preserved frontmatter, and the source path relative to the project folder.
181
+ `MarkdownRenderer` receives `{ markdown, metadata, source, heading? }`: processed Markdown with resolved asset URLs, preserved frontmatter, and the source path relative to the project folder.
182
182
 
183
- `Layout` receives `{ documents, title, attached, children, examples }`. Render `children` and `examples` to keep documentation and native example/props blocks. `examples` is `null` for standalone pages. `title` contains the standalone sidebar title and is empty for attached pages; `DefaultLayout` uses Storybook’s `Title` block for those.
183
+ `Layout` receives `{ documents, title, attached, children, examples, heading, tagFields }`. A standalone leading H1 is extracted in the default pipeline and supplied as the rendered `heading` node; render it instead of your fallback title. The remaining Markdown is supplied through `children`, while source files and manifests stay intact. `tagFields` lists configured metadata fields for tag display. Render `children` and `examples` to keep documentation and native example/props blocks. `examples` is `null` for standalone pages. `title` contains the standalone sidebar title and is empty for attached pages; `DefaultLayout` uses Storybook’s `Title` block for those.
184
184
 
185
185
  Customization paths are relative to the project folder and must stay inside it. Missing files produce source-specific errors. Styling and presentation are independent options.
186
186
 
@@ -0,0 +1,44 @@
1
+ import { t as MarkdownOptions } from "./index-BzM0tjFM.js";
2
+ //#region src/content.d.ts
3
+ interface ContentOptions extends MarkdownOptions {
4
+ root: string;
5
+ output: string;
6
+ docsName?: string;
7
+ }
8
+ interface Frontmatter extends Record<string, unknown> {
9
+ title?: string;
10
+ stories?: string | string[];
11
+ tags?: string[];
12
+ status?: string;
13
+ }
14
+ type DiscoveredDocument = Awaited<ReturnType<typeof discover>>[number];
15
+ declare function parseMarkdown(text: string, source: string): {
16
+ body: string;
17
+ metadata: Frontmatter;
18
+ };
19
+ declare function resolveStoryAssociations(file: string, metadata: Frontmatter, root: string): Promise<string[]>;
20
+ declare function discover({ root, patterns, output }: ContentOptions): Promise<{
21
+ markdown: string;
22
+ assets: {
23
+ file: string;
24
+ suffix: string;
25
+ token: string;
26
+ }[];
27
+ heading: string | undefined;
28
+ file: string;
29
+ original: string;
30
+ body: string;
31
+ source: string;
32
+ title: string;
33
+ metadata: Frontmatter;
34
+ stories: string[];
35
+ }[]>;
36
+ declare function readMarkdown(file: string, root: string): Promise<{
37
+ file: string;
38
+ original: string;
39
+ body: string;
40
+ metadata: Frontmatter;
41
+ stories: string[];
42
+ }>;
43
+ //#endregion
44
+ export { readMarkdown as a, parseMarkdown as i, DiscoveredDocument as n, resolveStoryAssociations as o, Frontmatter as r, ContentOptions as t };
@@ -0,0 +1,174 @@
1
+ import path from "node:path";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import { glob } from "tinyglobby";
4
+ import { isMap, parseDocument } from "yaml";
5
+ import { unified } from "unified";
6
+ import remarkParse from "remark-parse";
7
+ import remarkGfm from "remark-gfm";
8
+ import remarkStringify from "remark-stringify";
9
+ import { visit } from "unist-util-visit";
10
+ //#region src/content.ts
11
+ const markdown = unified().use(remarkParse).use(remarkGfm).use(remarkStringify);
12
+ const storyExtensions = [
13
+ "tsx",
14
+ "ts",
15
+ "jsx",
16
+ "js"
17
+ ];
18
+ const slash = (value) => value.split(path.sep).join("/");
19
+ const fail = (source, message) => /* @__PURE__ */ new Error(`[storybook-addon-md] ${source}: ${message}`);
20
+ async function localFile(file, root, source, kind) {
21
+ try {
22
+ const actual = await realpath(file);
23
+ const relative = path.relative(await realpath(root), actual);
24
+ if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) throw fail(source, `${kind} must be inside root: ${file}`);
25
+ if (!(await stat(actual)).isFile()) throw new Error("not a file");
26
+ return file;
27
+ } catch (error) {
28
+ if (error instanceof Error && error.message.startsWith("[storybook-addon-md]")) throw error;
29
+ throw Object.assign(fail(source, `missing ${kind}: ${file}`), { dependency: file });
30
+ }
31
+ }
32
+ function parseMarkdown(text, source) {
33
+ const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
34
+ if (!normalized.startsWith("---\n")) return {
35
+ body: normalized,
36
+ metadata: {}
37
+ };
38
+ const end = /^---\s*$/gm;
39
+ end.lastIndex = 4;
40
+ const closing = end.exec(normalized);
41
+ if (!closing) throw fail(source, "frontmatter must end with ---");
42
+ const yaml = parseDocument(normalized.slice(4, closing.index), { uniqueKeys: true });
43
+ if (yaml.errors.length) throw fail(source, `invalid frontmatter: ${yaml.errors[0].message}`);
44
+ if (yaml.warnings.length) throw fail(source, `invalid frontmatter: ${yaml.warnings[0].message}`);
45
+ if (yaml.contents && !isMap(yaml.contents)) throw fail(source, "frontmatter must be a mapping");
46
+ let metadata;
47
+ try {
48
+ metadata = yaml.toJS({ maxAliasCount: 50 }) ?? {};
49
+ JSON.stringify(metadata, (_, value) => {
50
+ if (typeof value === "number" && !Number.isFinite(value)) throw new Error("metadata numbers must be finite");
51
+ return value;
52
+ });
53
+ } catch (error) {
54
+ throw fail(source, `invalid frontmatter: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ if (typeof metadata !== "object" || Array.isArray(metadata)) throw fail(source, "frontmatter must be a mapping");
57
+ for (const key of Object.keys(metadata)) if (!/^[a-z][a-z0-9_-]*$/.test(key)) throw fail(source, `frontmatter keys must be lowercase: ${key}`);
58
+ if ("title" in metadata && (typeof metadata.title !== "string" || !metadata.title.trim())) throw fail(source, "title must be a non-empty string");
59
+ if ("status" in metadata && (typeof metadata.status !== "string" || !metadata.status.trim())) throw fail(source, "status must be a non-empty string");
60
+ if ("tags" in metadata && (!Array.isArray(metadata.tags) || metadata.tags.some((tag) => typeof tag !== "string" || !tag.trim()))) throw fail(source, "tags must be an array of non-empty strings");
61
+ if ("stories" in metadata) {
62
+ const references = Array.isArray(metadata.stories) ? metadata.stories : [metadata.stories];
63
+ if (!references.length || references.some((ref) => typeof ref !== "string" || !ref.trim())) throw fail(source, "stories must be a relative path or a non-empty array of relative paths");
64
+ }
65
+ return {
66
+ body: normalized.slice(closing.index + closing[0].length).replace(/^\n/, ""),
67
+ metadata
68
+ };
69
+ }
70
+ async function resolveStoryAssociations(file, metadata, root) {
71
+ if ("stories" in metadata) {
72
+ const references = Array.isArray(metadata.stories) ? metadata.stories : [metadata.stories];
73
+ return Promise.all([...new Set(references)].map(async (ref) => {
74
+ if (path.isAbsolute(ref) || !/\.stories\.[jt]sx?$/.test(ref)) throw fail(file, `stories must reference a relative .stories.js, .jsx, .ts, or .tsx file: ${ref}`);
75
+ return localFile(path.resolve(path.dirname(file), ref), root, file, "story reference");
76
+ }));
77
+ }
78
+ if (!file.endsWith(".metadata.md")) return [];
79
+ const base = file.slice(0, -12);
80
+ const matches = [];
81
+ for (const extension of storyExtensions) {
82
+ const candidate = `${base}.stories.${extension}`;
83
+ if (await stat(candidate).then((value) => value.isFile(), () => false)) matches.push(candidate);
84
+ }
85
+ if (matches.length !== 1) throw Object.assign(fail(file, matches.length ? "ambiguous sibling stories; set stories explicitly" : "missing sibling story for .metadata.md; set stories explicitly or rename the Markdown file"), { dependencies: storyExtensions.map((extension) => `${base}.stories.${extension}`) });
86
+ return [await localFile(matches[0], root, file, "story reference")];
87
+ }
88
+ async function resolveAssets(body, file, root, standalone = false) {
89
+ const tree = markdown.parse(body);
90
+ const nodes = [];
91
+ visit(tree, (node) => {
92
+ if (node.type === "image" || node.type === "link" || node.type === "definition") nodes.push(node);
93
+ });
94
+ const assets = [];
95
+ for (const node of nodes) {
96
+ const url = node.url;
97
+ if (!url || /^(?:[a-z][a-z\d+.-]*:|\/|#|\?)/i.test(url)) continue;
98
+ const [, pathname, suffix = ""] = /^([^?#]*)(.*)$/.exec(url);
99
+ let decoded;
100
+ try {
101
+ decoded = decodeURIComponent(pathname);
102
+ } catch {
103
+ throw fail(file, `invalid local URL: ${url}`);
104
+ }
105
+ const asset = await localFile(path.resolve(path.dirname(file), decoded), root, file, "local asset");
106
+ const token = `SBMDASSET${assets.length}END`;
107
+ if (body.includes(token)) throw fail(file, `reserved asset token in content: ${token}`);
108
+ assets.push({
109
+ file: asset,
110
+ suffix,
111
+ token
112
+ });
113
+ node.url = token;
114
+ }
115
+ const first = tree.children[0];
116
+ const heading = standalone && first?.type === "heading" && first.depth === 1 ? markdown.stringify({
117
+ ...tree,
118
+ children: [first, ...tree.children.filter((node) => node.type === "definition")]
119
+ }) : void 0;
120
+ if (heading) tree.children.shift();
121
+ return {
122
+ markdown: markdown.stringify(tree),
123
+ assets,
124
+ heading
125
+ };
126
+ }
127
+ async function discover({ root, patterns, output }) {
128
+ if (!Array.isArray(patterns) || !patterns.length || patterns.some((item) => typeof item !== "string" || !item || path.isAbsolute(item) || item.split("/").includes(".."))) throw fail(root, "patterns must be a non-empty array of globs relative to root");
129
+ const files = await glob(patterns, {
130
+ cwd: root,
131
+ absolute: true,
132
+ onlyFiles: true,
133
+ expandDirectories: false,
134
+ followSymbolicLinks: false,
135
+ ignore: [
136
+ "**/node_modules/**",
137
+ "**/.git/**",
138
+ "**/storybook-static/**",
139
+ ...path.relative(root, output).startsWith("..") ? [] : [`${slash(path.relative(root, output))}/**`]
140
+ ]
141
+ });
142
+ return Promise.all(files.sort().filter((file) => file.endsWith(".md")).map(async (file) => {
143
+ const { original, body, metadata, stories } = await readMarkdown(file, root);
144
+ const content = await resolveAssets(body, file, root, !stories.length);
145
+ return {
146
+ file,
147
+ original,
148
+ body,
149
+ source: slash(path.relative(root, file)),
150
+ title: metadata.title ?? `Documentation/${slash(path.relative(root, file)).replace(/\.md$/, "")}`,
151
+ metadata,
152
+ stories,
153
+ ...content
154
+ };
155
+ }));
156
+ }
157
+ async function readMarkdown(file, root) {
158
+ root = path.resolve(root);
159
+ file = path.resolve(root, file);
160
+ if (!file.endsWith(".md")) throw fail(file, "Markdown file must have a .md extension");
161
+ await localFile(file, root, file, "Markdown file");
162
+ const original = await readFile(file, "utf8");
163
+ const { body, metadata } = parseMarkdown(original, file);
164
+ const stories = await resolveStoryAssociations(file, metadata, root);
165
+ return {
166
+ file,
167
+ original,
168
+ body,
169
+ metadata,
170
+ stories
171
+ };
172
+ }
173
+ //#endregion
174
+ export { readMarkdown as a, parseMarkdown as i, fail as n, resolveStoryAssociations as o, localFile as r, slash as s, discover as t };
@@ -0,0 +1,12 @@
1
+ //#region src/index.d.ts
2
+ interface MarkdownOptions {
3
+ tagFields?: string[];
4
+ manifests?: boolean;
5
+ generatedDir?: string;
6
+ patterns: string[];
7
+ stylesheet?: string;
8
+ root?: string;
9
+ presentation?: string;
10
+ }
11
+ //#endregion
12
+ export { MarkdownOptions as t };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,2 @@
1
- //#region src/index.d.ts
2
- export interface MarkdownOptions {
3
- manifests?: boolean;
4
- generatedDir?: string;
5
- patterns: string[];
6
- stylesheet?: string;
7
- root?: string;
8
- presentation?: string;
9
- }
10
- //#endregion
1
+ import { t as MarkdownOptions } from "./index-BzM0tjFM.js";
2
+ export { MarkdownOptions };
package/dist/node.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { a as readMarkdown, i as parseMarkdown, o as resolveStoryAssociations, r as Frontmatter } from "./content-CKQsa_HK.js";
2
+ export { type Frontmatter, parseMarkdown, readMarkdown, resolveStoryAssociations };
package/dist/node.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as readMarkdown, i as parseMarkdown, o as resolveStoryAssociations } from "./content-CTcZAfoF.js";
2
+ export { parseMarkdown, readMarkdown, resolveStoryAssociations };
package/dist/preset.d.ts CHANGED
@@ -1,36 +1,15 @@
1
- import { MarkdownOptions } from "./index.js";
1
+ import { t as MarkdownOptions } from "./index-BzM0tjFM.js";
2
+ import { n as DiscoveredDocument, t as ContentOptions } from "./content-CKQsa_HK.js";
2
3
  import { StorybookConfigRaw } from "storybook/internal/types";
3
4
  import { UserConfig } from "vite";
4
- //#region src/content.d.ts
5
- interface ContentOptions extends MarkdownOptions {
6
- root: string;
7
- output: string;
8
- }
9
- interface Frontmatter extends Record<string, unknown> {
10
- title?: string;
11
- stories?: string | string[];
12
- tags?: string[];
13
- status?: string;
14
- }
15
- type DiscoveredDocument = Awaited<ReturnType<typeof discover>>[number];
16
- declare function discover({ root, patterns, output }: ContentOptions): Promise<{
17
- markdown: string;
18
- assets: {
19
- file: string;
20
- suffix: string;
21
- token: string;
22
- }[];
23
- file: string;
24
- original: string;
25
- source: string;
26
- title: string;
27
- metadata: Frontmatter;
28
- stories: string[];
29
- }[]>;
30
- //#endregion
31
5
  //#region src/preset.d.ts
32
6
  type PresetOptions = MarkdownOptions & {
33
7
  configDir: string;
8
+ presets?: {
9
+ apply: (name: 'docs') => Promise<{
10
+ defaultName?: string;
11
+ }>;
12
+ };
34
13
  };
35
14
  type StoryEntry = string | {
36
15
  directory: string;
package/dist/preset.js CHANGED
@@ -1,158 +1,9 @@
1
+ import { n as fail, r as localFile, s as slash, t as discover } from "./content-CTcZAfoF.js";
1
2
  import path from "node:path";
2
3
  import { createHash } from "node:crypto";
3
4
  import { watch } from "chokidar";
4
5
  import picomatch from "picomatch";
5
- import { mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
6
- import { glob } from "tinyglobby";
7
- import { isMap, parseDocument } from "yaml";
8
- import { unified } from "unified";
9
- import remarkParse from "remark-parse";
10
- import remarkGfm from "remark-gfm";
11
- import remarkStringify from "remark-stringify";
12
- import { visit } from "unist-util-visit";
13
- //#region src/content.ts
14
- const markdown = unified().use(remarkParse).use(remarkGfm).use(remarkStringify);
15
- const storyExtensions = [
16
- "tsx",
17
- "ts",
18
- "jsx",
19
- "js"
20
- ];
21
- const slash = (value) => value.split(path.sep).join("/");
22
- const fail = (source, message) => /* @__PURE__ */ new Error(`[storybook-addon-md] ${source}: ${message}`);
23
- async function localFile(file, root, source, kind) {
24
- try {
25
- const actual = await realpath(file);
26
- const relative = path.relative(await realpath(root), actual);
27
- if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) throw fail(source, `${kind} must be inside root: ${file}`);
28
- if (!(await stat(actual)).isFile()) throw new Error("not a file");
29
- return file;
30
- } catch (error) {
31
- if (error instanceof Error && error.message.startsWith("[storybook-addon-md]")) throw error;
32
- throw Object.assign(fail(source, `missing ${kind}: ${file}`), { dependency: file });
33
- }
34
- }
35
- function parseMarkdown(text, source) {
36
- const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
37
- if (!normalized.startsWith("---\n")) return {
38
- body: normalized,
39
- metadata: {}
40
- };
41
- const end = /^---\s*$/gm;
42
- end.lastIndex = 4;
43
- const closing = end.exec(normalized);
44
- if (!closing) throw fail(source, "frontmatter must end with ---");
45
- const yaml = parseDocument(normalized.slice(4, closing.index), { uniqueKeys: true });
46
- if (yaml.errors.length) throw fail(source, `invalid frontmatter: ${yaml.errors[0].message}`);
47
- if (yaml.warnings.length) throw fail(source, `invalid frontmatter: ${yaml.warnings[0].message}`);
48
- if (yaml.contents && !isMap(yaml.contents)) throw fail(source, "frontmatter must be a mapping");
49
- let metadata;
50
- try {
51
- metadata = yaml.toJS({ maxAliasCount: 50 }) ?? {};
52
- JSON.stringify(metadata, (_, value) => {
53
- if (typeof value === "number" && !Number.isFinite(value)) throw new Error("metadata numbers must be finite");
54
- return value;
55
- });
56
- } catch (error) {
57
- throw fail(source, `invalid frontmatter: ${error instanceof Error ? error.message : String(error)}`);
58
- }
59
- if (typeof metadata !== "object" || Array.isArray(metadata)) throw fail(source, "frontmatter must be a mapping");
60
- for (const key of Object.keys(metadata)) if (!/^[a-z][a-z0-9_-]*$/.test(key)) throw fail(source, `frontmatter keys must be lowercase: ${key}`);
61
- if ("title" in metadata && (typeof metadata.title !== "string" || !metadata.title.trim())) throw fail(source, "title must be a non-empty string");
62
- if ("status" in metadata && (typeof metadata.status !== "string" || !metadata.status.trim())) throw fail(source, "status must be a non-empty string");
63
- if ("tags" in metadata && (!Array.isArray(metadata.tags) || metadata.tags.some((tag) => typeof tag !== "string" || !tag.trim()))) throw fail(source, "tags must be an array of non-empty strings");
64
- if ("stories" in metadata) {
65
- const references = Array.isArray(metadata.stories) ? metadata.stories : [metadata.stories];
66
- if (!references.length || references.some((ref) => typeof ref !== "string" || !ref.trim())) throw fail(source, "stories must be a relative path or a non-empty array of relative paths");
67
- }
68
- return {
69
- body: normalized.slice(closing.index + closing[0].length).replace(/^\n/, ""),
70
- metadata
71
- };
72
- }
73
- async function associations(file, metadata, root) {
74
- if ("stories" in metadata) {
75
- const references = Array.isArray(metadata.stories) ? metadata.stories : [metadata.stories];
76
- return Promise.all([...new Set(references)].map(async (ref) => {
77
- if (path.isAbsolute(ref) || !/\.stories\.[jt]sx?$/.test(ref)) throw fail(file, `stories must reference a relative .stories.js, .jsx, .ts, or .tsx file: ${ref}`);
78
- return localFile(path.resolve(path.dirname(file), ref), root, file, "story reference");
79
- }));
80
- }
81
- if (!file.endsWith(".metadata.md")) return [];
82
- const base = file.slice(0, -12);
83
- const matches = [];
84
- for (const extension of storyExtensions) {
85
- const candidate = `${base}.stories.${extension}`;
86
- if (await stat(candidate).then((value) => value.isFile(), () => false)) matches.push(candidate);
87
- }
88
- if (matches.length !== 1) throw Object.assign(fail(file, matches.length ? "ambiguous sibling stories; set stories explicitly" : "missing sibling story for .metadata.md; set stories explicitly or rename the Markdown file"), { dependencies: storyExtensions.map((extension) => `${base}.stories.${extension}`) });
89
- return [await localFile(matches[0], root, file, "story reference")];
90
- }
91
- async function resolveAssets(body, file, root) {
92
- const tree = markdown.parse(body);
93
- const nodes = [];
94
- visit(tree, (node) => {
95
- if (node.type === "image" || node.type === "link" || node.type === "definition") nodes.push(node);
96
- });
97
- const assets = [];
98
- for (const node of nodes) {
99
- const url = node.url;
100
- if (!url || /^(?:[a-z][a-z\d+.-]*:|\/|#|\?)/i.test(url)) continue;
101
- const [, pathname, suffix = ""] = /^([^?#]*)(.*)$/.exec(url);
102
- let decoded;
103
- try {
104
- decoded = decodeURIComponent(pathname);
105
- } catch {
106
- throw fail(file, `invalid local URL: ${url}`);
107
- }
108
- const asset = await localFile(path.resolve(path.dirname(file), decoded), root, file, "local asset");
109
- const token = `SBMDASSET${assets.length}END`;
110
- if (body.includes(token)) throw fail(file, `reserved asset token in content: ${token}`);
111
- assets.push({
112
- file: asset,
113
- suffix,
114
- token
115
- });
116
- node.url = token;
117
- }
118
- return {
119
- markdown: markdown.stringify(tree),
120
- assets
121
- };
122
- }
123
- async function discover({ root, patterns, output }) {
124
- if (!Array.isArray(patterns) || !patterns.length || patterns.some((item) => typeof item !== "string" || !item || path.isAbsolute(item) || item.split("/").includes(".."))) throw fail(root, "patterns must be a non-empty array of globs relative to root");
125
- const files = await glob(patterns, {
126
- cwd: root,
127
- absolute: true,
128
- onlyFiles: true,
129
- expandDirectories: false,
130
- followSymbolicLinks: false,
131
- ignore: [
132
- "**/node_modules/**",
133
- "**/.git/**",
134
- "**/storybook-static/**",
135
- `${slash(path.relative(root, output))}/**`
136
- ]
137
- });
138
- return Promise.all(files.sort().filter((file) => file.endsWith(".md")).map(async (file) => {
139
- await localFile(file, root, file, "Markdown file");
140
- const original = await readFile(file, "utf8");
141
- const { body, metadata } = parseMarkdown(original, file);
142
- const stories = await associations(file, metadata, root);
143
- const content = await resolveAssets(body, file, root);
144
- return {
145
- file,
146
- original,
147
- source: slash(path.relative(root, file)),
148
- title: metadata.title ?? `Documentation/${slash(path.relative(root, file)).replace(/\.md$/, "")}`,
149
- metadata,
150
- stories,
151
- ...content
152
- };
153
- }));
154
- }
155
- //#endregion
6
+ import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
156
7
  //#region src/generator.ts
157
8
  const attribute = (value) => value.replace(/[&"<>\r\n]/g, (character) => `&#${character.charCodeAt(0)};`);
158
9
  const id = (value) => createHash("sha256").update(value).digest("hex").slice(0, 16);
@@ -171,8 +22,8 @@ async function writeChanged(file, content) {
171
22
  }
172
23
  function documentModule(document, output) {
173
24
  const imports = document.assets.map((asset, index) => `import asset${index} from ${JSON.stringify(`${specifier(output, assetPath(asset.file, output))}?url&no-inline`)};`);
174
- const expression = document.assets.reduce((value, asset, index) => `${value}.split(${JSON.stringify(asset.token)}).join(asset${index}.replace('?no-inline', '') + ${JSON.stringify(asset.suffix)})`, JSON.stringify(document.markdown));
175
- return `${imports.join("\n")}\nexport default { source: ${JSON.stringify(document.source)}, metadata: JSON.parse(${JSON.stringify(JSON.stringify(document.metadata))}), markdown: ${expression} };\n`;
25
+ const expression = (markdown) => document.assets.reduce((value, asset, index) => `${value}.split(${JSON.stringify(asset.token)}).join(asset${index}.replace('?no-inline', '') + ${JSON.stringify(asset.suffix)})`, JSON.stringify(markdown));
26
+ return `${imports.join("\n")}\nexport default { source: ${JSON.stringify(document.source)}, metadata: JSON.parse(${JSON.stringify(JSON.stringify(document.metadata))}), markdown: ${expression(document.markdown)}, heading: ${document.heading ? expression(document.heading) : "undefined"} };\n`;
176
27
  }
177
28
  async function generate(options) {
178
29
  const { output, root, presentation, stylesheet } = options;
@@ -219,8 +70,8 @@ async function generate(options) {
219
70
  if (group.story) imports.push(`import * as ComponentStories from ${JSON.stringify(specifier(output, group.story))};`);
220
71
  if (presentation) imports.push(`import * as presentation from ${JSON.stringify(specifier(output, presentation))};`);
221
72
  if (stylesheet) imports.push(`import ${JSON.stringify(specifier(output, stylesheet))};`);
222
- const meta = group.story ? "<Meta of={ComponentStories} name=\"Markdown\" />" : `<Meta title="${attribute(group.title)}" />`;
223
- files.set(pageFile(key), `${imports.join("\n")}\n\n${meta}\n\n<Documentation documents={[${group.documents.map((_, index) => `document${index}`).join(", ")}]} attached={${Boolean(group.story)}} title={${JSON.stringify(group.title ?? "")}}${presentation ? " presentation={presentation}" : ""} />\n`);
73
+ const meta = group.story ? `<Meta of={ComponentStories} name="${attribute(options.docsName ?? "Docs")}" />` : `<Meta title="${attribute(group.title)}" />`;
74
+ files.set(pageFile(key), `${imports.join("\n")}\n\n${meta}\n\n<Documentation documents={[${group.documents.map((_, index) => `document${index}`).join(", ")}]} attached={${Boolean(group.story)}} title={${JSON.stringify(group.title ?? "")}} tagFields={${JSON.stringify(options.tagFields ?? [])}}${presentation ? " presentation={presentation}" : ""} />\n`);
224
75
  }
225
76
  files.set("status.js", "export {};\n");
226
77
  await mkdir(output, { recursive: true });
@@ -281,12 +132,14 @@ function settings(options) {
281
132
  const configDir = path.resolve(options.configDir);
282
133
  const root = path.resolve(configDir, options.root ?? "..");
283
134
  const generatedDir = options.generatedDir ?? "storybook-markdown-generated";
135
+ if (options.tagFields !== void 0 && (!Array.isArray(options.tagFields) || options.tagFields.some((field) => typeof field !== "string" || !/^[a-z][a-z0-9_-]*$/.test(field)))) throw fail(configDir, "tagFields must be an array of lowercase frontmatter field names");
284
136
  if ("exclude" in options) throw fail(configDir, "exclude has been removed; use negative globs in patterns, such as !docs/private/**");
285
137
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(generatedDir) || ["node_modules", "storybook-static"].includes(generatedDir)) throw fail(configDir, "generatedDir must be a visible folder name containing only letters, digits, hyphens or underscores");
286
138
  return {
287
139
  root,
288
140
  output: path.join(process.cwd(), generatedDir, createHash("sha256").update(configDir).digest("hex").slice(0, 12)),
289
141
  patterns: options.patterns,
142
+ tagFields: options.tagFields,
290
143
  stylesheet: options.stylesheet ? path.resolve(root, options.stylesheet) : void 0,
291
144
  presentation: options.presentation ? path.resolve(root, options.presentation) : void 0
292
145
  };
@@ -294,7 +147,11 @@ function settings(options) {
294
147
  async function stories(existing = [], options) {
295
148
  const key = path.resolve(options.configDir);
296
149
  if (!sessions.has(key)) {
297
- const config = settings(options);
150
+ const docs = await options.presets?.apply("docs");
151
+ const config = {
152
+ ...settings(options),
153
+ docsName: docs?.defaultName ?? "Docs"
154
+ };
298
155
  sessions.set(key, {
299
156
  config,
300
157
  ready: generate(config)
package/dist/runtime.d.ts CHANGED
@@ -3,6 +3,7 @@ import { ComponentType, ReactNode } from "react";
3
3
  interface MarkdownDocument {
4
4
  source: string;
5
5
  markdown: string;
6
+ heading?: string;
6
7
  metadata: Record<string, unknown>;
7
8
  }
8
9
  interface LayoutProps {
@@ -11,6 +12,8 @@ interface LayoutProps {
11
12
  attached: boolean;
12
13
  children: ReactNode;
13
14
  examples: ReactNode;
15
+ heading?: ReactNode;
16
+ tagFields?: string[];
14
17
  }
15
18
  interface Presentation {
16
19
  Layout?: ComponentType<LayoutProps>;
@@ -19,12 +22,13 @@ interface Presentation {
19
22
  //#endregion
20
23
  //#region src/runtime.d.ts
21
24
  export declare function DefaultMarkdownRenderer({ markdown }: MarkdownDocument): import("react").JSX.Element;
22
- export declare function DefaultLayout({ documents, title, attached, children, examples }: LayoutProps): import("react").JSX.Element;
23
- export declare function Documentation({ documents, title, attached, presentation }: {
25
+ export declare function DefaultLayout({ documents, title, attached, children, examples, heading, tagFields }: LayoutProps): import("react").JSX.Element;
26
+ export declare function Documentation({ documents, title, attached, presentation, tagFields }: {
24
27
  documents: MarkdownDocument[];
25
28
  title: string;
26
29
  attached?: boolean;
27
30
  presentation?: Presentation;
31
+ tagFields?: string[];
28
32
  }): import("react").JSX.Element;
29
33
  //#endregion
30
34
  export type { LayoutProps, MarkdownDocument, Presentation };
package/dist/runtime.js CHANGED
@@ -15,18 +15,21 @@ function DefaultMarkdownRenderer({ markdown }) {
15
15
  children: markdown
16
16
  });
17
17
  }
18
- function DefaultLayout({ documents, title, attached, children, examples }) {
19
- const tags = [...new Set(documents.flatMap(({ metadata }) => Array.isArray(metadata.tags) ? metadata.tags : []))];
18
+ function DefaultLayout({ documents, title, attached, children, examples, heading, tagFields = [] }) {
19
+ const tags = [...new Set(documents.flatMap(({ metadata }) => ["tags", ...tagFields].flatMap((field) => {
20
+ const value = metadata[field];
21
+ return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string" && Boolean(item.trim()));
22
+ })))];
20
23
  const statuses = [...new Set(documents.flatMap(({ metadata }) => typeof metadata.status === "string" ? [metadata.status] : []))];
21
24
  return /* @__PURE__ */ jsxs(Fragment, { children: [
22
25
  /* @__PURE__ */ jsx("div", {
23
26
  className: "storybook-addon-md-title",
24
- children: attached ? /* @__PURE__ */ jsx(Title, {}) : /* @__PURE__ */ jsx("h1", { children: title.split("/").at(-1) })
27
+ children: attached ? /* @__PURE__ */ jsx(Title, {}) : heading ?? /* @__PURE__ */ jsx("h1", { children: title.split("/").at(-1) })
25
28
  }),
26
29
  (tags.length > 0 || statuses.length > 0) && /* @__PURE__ */ jsxs("ul", {
27
30
  className: "storybook-addon-md-tags",
28
31
  "aria-label": "Documentation tags",
29
- children: [tags.map((tag) => /* @__PURE__ */ jsx("li", {
32
+ children: [tags.filter((tag) => !statuses.includes(tag)).map((tag) => /* @__PURE__ */ jsx("li", {
30
33
  className: "storybook-addon-md-tag",
31
34
  children: tag
32
35
  }, `tag:${tag}`)), statuses.map((status) => /* @__PURE__ */ jsx("li", {
@@ -39,7 +42,7 @@ function DefaultLayout({ documents, title, attached, children, examples }) {
39
42
  examples
40
43
  ] });
41
44
  }
42
- function Documentation({ documents, title, attached = false, presentation = {} }) {
45
+ function Documentation({ documents, title, attached = false, presentation = {}, tagFields = [] }) {
43
46
  const theme = useTheme();
44
47
  const defaults = {
45
48
  "--sbmd-native-color": theme.color.defaultText,
@@ -51,6 +54,11 @@ function Documentation({ documents, title, attached = false, presentation = {} }
51
54
  };
52
55
  const Layout = presentation.Layout ?? DefaultLayout;
53
56
  const Renderer = presentation.MarkdownRenderer ?? DefaultMarkdownRenderer;
57
+ const first = documents[0];
58
+ const heading = !attached && first?.heading ? /* @__PURE__ */ jsx(Renderer, {
59
+ ...first,
60
+ markdown: first.heading
61
+ }) : void 0;
54
62
  return /* @__PURE__ */ jsx("div", {
55
63
  className: "storybook-addon-md-page",
56
64
  style: defaults,
@@ -63,6 +71,8 @@ function Documentation({ documents, title, attached = false, presentation = {} }
63
71
  /* @__PURE__ */ jsx(Controls, {}),
64
72
  /* @__PURE__ */ jsx(Stories, { includePrimary: false })
65
73
  ] }) : null,
74
+ heading,
75
+ tagFields,
66
76
  children: documents.map((document) => /* @__PURE__ */ jsx("div", {
67
77
  className: "storybook-addon-md",
68
78
  children: /* @__PURE__ */ jsx(Renderer, { ...document })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storybook-addon-md",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Discover ordinary Markdown and render it in Storybook Docs.",
5
5
  "keywords": [
6
6
  "docs",
@@ -36,6 +36,10 @@
36
36
  "types": "./dist/runtime.d.ts",
37
37
  "default": "./dist/runtime.js"
38
38
  },
39
+ "./node": {
40
+ "types": "./dist/node.d.ts",
41
+ "node": "./dist/node.js"
42
+ },
39
43
  "./package.json": "./package.json",
40
44
  "./styles.css": "./dist/styles.css"
41
45
  },