Skip to content

Commit 859d956

Browse files
committed
feat(react-storybook-addon): add Copy as Markdown button to FluentDocsPage
1 parent 572f92d commit 859d956

File tree

7 files changed

+238
-5
lines changed

7 files changed

+238
-5
lines changed

apps/chart-docsite/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"build-storybook": "storybook build -o ./dist/storybook --docs",
88
"build-storybook:docsite": "cross-env DEPLOY_PATH=/charts/ storybook build -o ./dist/storybook --docs",
99
"postbuild-storybook": "yarn rewrite-title && yarn generate-llms-docs",
10+
"postbuild-storybook:docsite": "yarn rewrite-title && yarn generate-llms-docs",
1011
"rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI Charts v9' --distPath ./dist/storybook",
1112
"generate-llms-docs": "yarn storybook-llms-extractor --distPath ./dist/storybook --summaryBaseUrl \"https://fluentuipr.z22.web.core.windows.net/pull/34838/chart-docsite/storybook\" --summaryTitle \"Fluent UI Charts v9\" --summaryDescription \"Fluent UI React charts is a set of modern, accessible, interactive, lightweight and highly customizable visualization library representing the Microsoft design system. These charts are used across 100s of projects inside Microsoft across Microsoft 365, Copilot and Azure.\"",
1213
"clean": "just-scripts clean",

apps/public-docsite-v9/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
"scripts": {
77
"build-storybook": "cross-env NODE_OPTIONS=--max_old_space_size=3072 storybook build -o ./dist/storybook --docs",
88
"build-storybook:react": "cross-env NODE_OPTIONS=--max_old_space_size=3072 DEPLOY_PATH=/react/ storybook build -o ./dist/react --docs",
9-
"postbuild-storybook": "yarn rewrite-title && yarn generate-llms-docs",
10-
"rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI React v9' --distPath ./dist/storybook",
9+
"postbuild-storybook": "yarn rewrite-title --distPath ./dist/storybook && yarn generate-llms-docs",
10+
"postbuild-storybook:react": "yarn rewrite-title --distPath ./dist/react && cross-env DEPLOY_PATH=/react/ yarn generate-llms-docs",
11+
"rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI React v9'",
1112
"generate-llms-docs": "yarn storybook-llms-extractor --config storybook-llms.config.js",
1213
"clean": "just-scripts clean",
1314
"code-style": "just-scripts code-style",

apps/public-docsite-v9/storybook-llms.config.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ const storybookConfig = require('./.storybook/main');
99
* @type {import('@fluentui/storybook-llms-extractor').Config}
1010
*/
1111
module.exports = {
12-
distPath: './dist/storybook',
13-
summaryBaseUrl: 'https://react.fluentui.dev',
12+
distPath: process.env.DEPLOY_PATH === '/react/' ? './dist/react' : './dist/storybook',
13+
summaryBaseUrl:
14+
process.env.DEPLOY_PATH === '/react/' ? 'https://storybooks.fluentui.dev/react' : 'https://react.fluentui.dev',
1415
summaryTitle: 'Fluent UI React v9',
1516
summaryDescription:
1617
"Fluent UI React is a library of React components that implement Microsoft's [Fluent Design System](https://fluent2.microsoft.design).",
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import * as React from 'react';
2+
import {
3+
makeStyles,
4+
Menu,
5+
MenuButtonProps,
6+
MenuItem,
7+
MenuList,
8+
MenuPopover,
9+
MenuTrigger,
10+
Spinner,
11+
SplitButton,
12+
Toast,
13+
Toaster,
14+
ToastTitle,
15+
useId,
16+
useToastController,
17+
} from '@fluentui/react-components';
18+
import { bundleIcon, MarkdownFilled, MarkdownRegular } from '@fluentui/react-icons';
19+
20+
const MarkdownIcon = bundleIcon(MarkdownFilled, MarkdownRegular);
21+
22+
const useStyles = makeStyles({
23+
button: {
24+
marginInlineStart: 'auto',
25+
},
26+
});
27+
28+
export interface CopyAsMarkdownProps {
29+
/** The Storybook story ID used to generate the markdown URL */
30+
storyId?: string;
31+
}
32+
33+
/**
34+
* A button that allows users to copy the current page as markdown to their clipboard or view it in a new tab.
35+
* The markdown content is fetched from the Storybook API and cached for subsequent requests.
36+
*/
37+
export const CopyAsMarkdownButton: React.FC<CopyAsMarkdownProps> = ({ storyId = '' }) => {
38+
const styles = useStyles();
39+
const toastId = useId('copy-toast');
40+
const toasterId = useId('toaster');
41+
const { dispatchToast, updateToast } = useToastController(toasterId);
42+
43+
// Cache for the fetched markdown content to avoid redundant network requests
44+
const markdownContentCache = React.useRef<string | null>(null);
45+
46+
// AbortController to track and cancel fetch requests
47+
const abortControllerRef = React.useRef<AbortController | null>(null);
48+
49+
// Full URL to the markdown endpoint for this story
50+
const markdownUrl = React.useMemo(() => {
51+
return convertStoryIdToMarkdownUrl(storyId);
52+
}, [storyId]);
53+
54+
// Cleanup: abort pending requests on unmount
55+
React.useEffect(() => {
56+
return () => {
57+
abortControllerRef.current?.abort();
58+
};
59+
}, []);
60+
61+
/**
62+
* Fetches the markdown content (with caching) and copies it to the clipboard.
63+
* Shows a toast notification with loading, success, or error states.
64+
* Skips the request if one is already in progress.
65+
*/
66+
const copyPageContentToClipboard = React.useCallback(async () => {
67+
// Skip if a request is already in progress (abort controller exists and not aborted)
68+
if (abortControllerRef.current && !abortControllerRef.current.signal.aborted) {
69+
return;
70+
}
71+
72+
// Create new AbortController for this request
73+
const abortController = new AbortController();
74+
abortControllerRef.current = abortController;
75+
76+
// Show loading toast that persists until updated
77+
dispatchToast(
78+
<Toast>
79+
<ToastTitle media={<Spinner />}>Copying page content...</ToastTitle>
80+
</Toast>,
81+
{
82+
toastId,
83+
intent: 'info',
84+
timeout: -1, // Never auto-dismiss
85+
},
86+
);
87+
88+
try {
89+
// Use cached content if available, otherwise fetch from API
90+
if (!markdownContentCache.current) {
91+
markdownContentCache.current = await fetchMarkdownContent(markdownUrl, abortController.signal);
92+
}
93+
94+
// Copy to clipboard
95+
await navigator.clipboard.writeText(markdownContentCache.current);
96+
97+
// Update toast to success
98+
updateToast({
99+
content: (
100+
<Toast>
101+
<ToastTitle>Page content copied to clipboard!</ToastTitle>
102+
</Toast>
103+
),
104+
intent: 'success',
105+
toastId,
106+
timeout: 3000,
107+
});
108+
} catch (error) {
109+
// Don't show error if request was aborted
110+
if (abortController.signal.aborted) {
111+
return;
112+
}
113+
114+
const errorMessage = error instanceof Error ? error.message : String(error);
115+
116+
// Update toast to error
117+
updateToast({
118+
content: (
119+
<Toast>
120+
<ToastTitle>Failed to copy page content: {errorMessage}</ToastTitle>
121+
</Toast>
122+
),
123+
intent: 'error',
124+
toastId,
125+
timeout: 3000,
126+
});
127+
} finally {
128+
// Clear the abort controller ref to allow new requests
129+
abortControllerRef.current = null;
130+
}
131+
}, [dispatchToast, updateToast, toastId, markdownUrl]);
132+
133+
/** Opens the markdown content in a new browser tab */
134+
const openInNewTab = React.useCallback(() => {
135+
window.open(markdownUrl, '_blank');
136+
}, [markdownUrl]);
137+
138+
if (!storyId) {
139+
return null;
140+
}
141+
142+
return (
143+
<>
144+
<Menu positioning="below-end">
145+
<MenuTrigger disableButtonEnhancement>
146+
{(triggerProps: MenuButtonProps) => (
147+
<SplitButton
148+
className={styles.button}
149+
menuButton={triggerProps}
150+
primaryActionButton={{
151+
appearance: 'secondary',
152+
icon: <MarkdownIcon />,
153+
onClick: copyPageContentToClipboard,
154+
'aria-label': 'Copy page content as markdown to clipboard',
155+
}}
156+
aria-label="Copy page as markdown"
157+
>
158+
Copy Page
159+
</SplitButton>
160+
)}
161+
</MenuTrigger>
162+
163+
<MenuPopover>
164+
<MenuList>
165+
<MenuItem icon={<MarkdownIcon />} onClick={openInNewTab}>
166+
View as Markdown
167+
</MenuItem>
168+
</MenuList>
169+
</MenuPopover>
170+
</Menu>
171+
<Toaster toasterId={toasterId} />
172+
</>
173+
);
174+
};
175+
176+
/**
177+
* Regex pattern to remove the story variant suffix from Storybook story IDs.
178+
* @example "button--primary" -> "button"
179+
*/
180+
const STORYBOOK_VARIANT_SUFFIX_PATTERN = /--\w+$/g;
181+
182+
/**
183+
* Gets the base URL for fetching markdown content from the Storybook LLM endpoint.
184+
* Each story's markdown is available at: {BASE_URL}/{storyId}.txt
185+
* @returns The base URL constructed from current location origin and pathname
186+
*/
187+
function getStorybookMarkdownApiBaseUrl(): string {
188+
// Remove the [page].html file from pathname and append /llms/
189+
const basePath = window.location.pathname.replace(/\/[^/]*\.html$/, '');
190+
return `${window.location.origin}${basePath}/llms/`;
191+
}
192+
193+
/**
194+
* Converts a Storybook story ID to a markdown URL.
195+
* @param storyId - The Storybook story ID
196+
* @returns The full URL to the markdown endpoint for the story
197+
* @example "button--primary" -> "https://storybooks.fluentui.dev/llms/button.txt"
198+
*/
199+
function convertStoryIdToMarkdownUrl(storyId: string): string {
200+
return `${getStorybookMarkdownApiBaseUrl()}${storyId.replace(STORYBOOK_VARIANT_SUFFIX_PATTERN, '.txt')}`;
201+
}
202+
203+
/**
204+
* Fetches markdown content from the Storybook API.
205+
* @param url - The URL to fetch markdown content from
206+
* @param signal - Optional AbortSignal to cancel the request
207+
* @returns Promise resolving to the markdown text content
208+
* @throws Error if the fetch request fails or is aborted
209+
*/
210+
async function fetchMarkdownContent(url: string, signal?: AbortSignal): Promise<string> {
211+
const response = await fetch(url, {
212+
headers: {
213+
'Content-Type': 'text/plain',
214+
},
215+
signal,
216+
});
217+
218+
if (!response.ok) {
219+
throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`);
220+
}
221+
222+
return response.text();
223+
}

packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { getDocsPageConfig } from './utils';
2626
import { DirSwitch } from './DirSwitch';
2727
import { ThemePicker } from './ThemePicker';
2828
import { Toc, nameToHash } from './Toc';
29+
import { CopyAsMarkdownButton } from './CopyAsMarkdownButton';
2930

3031
type PrimaryStory = PreparedStory<Renderer>;
3132

@@ -346,6 +347,7 @@ export const FluentDocsPage = (): JSXElement => {
346347
tableOfContents: showTableOfContents,
347348
dirSwitcher: showDirSwitcher,
348349
themePicker: showThemePicker,
350+
copyAsMarkdown: showCopyAsMarkdown,
349351
argTable,
350352
} = docsPageConfig;
351353

@@ -366,10 +368,11 @@ export const FluentDocsPage = (): JSXElement => {
366368
<Title />
367369
<div className={styles.wrapper}>
368370
<div className={styles.container}>
369-
{(showThemePicker || showDirSwitcher) && (
371+
{(showThemePicker || showDirSwitcher || showCopyAsMarkdown) && (
370372
<div className={styles.globalTogglesContainer}>
371373
{showThemePicker && <ThemePicker selectedThemeId={selectedTheme?.id} />}
372374
{showDirSwitcher && <DirSwitch dir={dir} />}
375+
{showCopyAsMarkdown && <CopyAsMarkdownButton storyId={primaryStory.id} />}
373376
</div>
374377
)}
375378
<Subtitle />

packages/react-components/react-storybook-addon/src/docs/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type DocsContextProps } from '@storybook/addon-docs';
33
import { type FluentParameters } from '../hooks';
44

55
const docsDefaults = {
6+
copyAsMarkdown: true,
67
tableOfContents: true,
78
dirSwitcher: true,
89
themePicker: true,
@@ -45,6 +46,7 @@ export function getDocsPageConfig(context: DocsContextProps): {
4546
tableOfContents: boolean;
4647
dirSwitcher: boolean;
4748
themePicker: boolean;
49+
copyAsMarkdown: boolean;
4850
argTable: {
4951
slotsApi: boolean;
5052
nativePropsApi: boolean;
@@ -60,6 +62,7 @@ export function getDocsPageConfig(context: DocsContextProps): {
6062
// If docs is an object, extract the configuration directly
6163
if (typeof docsConfig === 'object' && docsConfig !== null) {
6264
return {
65+
copyAsMarkdown: docsConfig.copyAsMarkdown !== false,
6366
tableOfContents: docsConfig.tableOfContents !== false,
6467
dirSwitcher: docsConfig.dirSwitcher !== false,
6568
themePicker: docsConfig.themePicker !== false,

packages/react-components/react-storybook-addon/src/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type FluentDocsConfig =
4040
tableOfContents?: boolean;
4141
dirSwitcher?: boolean;
4242
themePicker?: boolean;
43+
copyAsMarkdown?: boolean;
4344
argTable?:
4445
| boolean
4546
| {

0 commit comments

Comments
 (0)