diff --git a/apps/chart-docsite/package.json b/apps/chart-docsite/package.json index 076cc729405ce..2ba4226170ee5 100644 --- a/apps/chart-docsite/package.json +++ b/apps/chart-docsite/package.json @@ -7,6 +7,7 @@ "build-storybook": "storybook build -o ./dist/storybook --docs", "build-storybook:docsite": "cross-env DEPLOY_PATH=/charts/ storybook build -o ./dist/storybook --docs", "postbuild-storybook": "yarn rewrite-title && yarn generate-llms-docs", + "postbuild-storybook:docsite": "yarn rewrite-title && yarn generate-llms-docs", "rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI Charts v9' --distPath ./dist/storybook", "generate-llms-docs": "yarn storybook-llms-extractor --distPath ./dist/storybook --summaryBaseUrl \"https://storybooks.fluentui.dev/charts/\" --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.\"", "clean": "just-scripts clean", diff --git a/apps/public-docsite-v9/package.json b/apps/public-docsite-v9/package.json index 5ad585c2e8fc4..948cf15225a1a 100644 --- a/apps/public-docsite-v9/package.json +++ b/apps/public-docsite-v9/package.json @@ -6,8 +6,9 @@ "scripts": { "build-storybook": "cross-env NODE_OPTIONS=--max_old_space_size=3072 storybook build -o ./dist/storybook --docs", "build-storybook:react": "cross-env NODE_OPTIONS=--max_old_space_size=3072 DEPLOY_PATH=/react/ storybook build -o ./dist/react --docs", - "postbuild-storybook": "yarn rewrite-title && yarn generate-llms-docs", - "rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI React v9' --distPath ./dist/storybook", + "postbuild-storybook": "yarn rewrite-title --distPath ./dist/storybook && yarn generate-llms-docs", + "postbuild-storybook:react": "yarn rewrite-title --distPath ./dist/react && cross-env DEPLOY_PATH=/react/ yarn generate-llms-docs", + "rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI React v9'", "generate-llms-docs": "yarn storybook-llms-extractor --config storybook-llms.config.js", "clean": "just-scripts clean", "code-style": "just-scripts code-style", diff --git a/apps/public-docsite-v9/storybook-llms.config.js b/apps/public-docsite-v9/storybook-llms.config.js index 72cd825ac65cf..1e41483e1c765 100644 --- a/apps/public-docsite-v9/storybook-llms.config.js +++ b/apps/public-docsite-v9/storybook-llms.config.js @@ -9,8 +9,9 @@ const storybookConfig = require('./.storybook/main'); * @type {import('@fluentui/storybook-llms-extractor').Config} */ module.exports = { - distPath: './dist/storybook', - summaryBaseUrl: 'https://react.fluentui.dev', + distPath: process.env.DEPLOY_PATH === '/react/' ? './dist/react' : './dist/storybook', + summaryBaseUrl: + process.env.DEPLOY_PATH === '/react/' ? 'https://storybooks.fluentui.dev/react' : 'https://react.fluentui.dev', summaryTitle: 'Fluent UI React v9', summaryDescription: "Fluent UI React is a library of React components that implement Microsoft's [Fluent Design System](https://fluent2.microsoft.design).", diff --git a/change/@fluentui-react-storybook-addon-b114671d-8487-459e-ac61-bd859e51634b.json b/change/@fluentui-react-storybook-addon-b114671d-8487-459e-ac61-bd859e51634b.json new file mode 100644 index 0000000000000..5b721e6604b67 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-b114671d-8487-459e-ac61-bd859e51634b.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add Copy as Markdown button to FluentDocsPage", + "packageName": "@fluentui/react-storybook-addon", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-storybook-addon/package.json b/packages/react-components/react-storybook-addon/package.json index 1ca8cdac296f8..cf73fa249b131 100644 --- a/packages/react-components/react-storybook-addon/package.json +++ b/packages/react-components/react-storybook-addon/package.json @@ -18,14 +18,18 @@ "dependencies": { "@fluentui/react-aria": "^9.17.0", "@fluentui/react-button": "^9.6.6", - "@fluentui/react-menu": "^9.19.6", + "@fluentui/react-icons": "^2.0.245", "@fluentui/react-label": "^9.3.5", - "@fluentui/react-switch": "^9.4.5", - "@fluentui/react-text": "^9.6.5", "@fluentui/react-link": "^9.6.5", + "@fluentui/react-menu": "^9.19.6", "@fluentui/react-provider": "^9.22.5", - "@fluentui/react-utilities": "^9.24.1", + "@fluentui/react-shared-contexts": "^9.25.1", + "@fluentui/react-spinner": "^9.7.5", + "@fluentui/react-switch": "^9.4.5", + "@fluentui/react-text": "^9.6.5", "@fluentui/react-theme": "^9.2.0", + "@fluentui/react-toast": "^9.7.1", + "@fluentui/react-utilities": "^9.24.1", "@griffel/react": "^1.5.22", "@swc/helpers": "^0.5.1" }, diff --git a/packages/react-components/react-storybook-addon/project.json b/packages/react-components/react-storybook-addon/project.json index cc7512bd07530..62a91f586e5fc 100644 --- a/packages/react-components/react-storybook-addon/project.json +++ b/packages/react-components/react-storybook-addon/project.json @@ -11,15 +11,18 @@ { "projects": [ "react-aria", - "react-provider", "react-button", - "react-menu", "react-label", + "react-link", + "react-menu", + "react-provider", + "react-shared-contexts", + "react-spinner", "react-switch", "react-text", - "react-link", - "react-utilities", - "react-theme" + "react-theme", + "react-toast", + "react-utilities" ], "target": "build" } diff --git a/packages/react-components/react-storybook-addon/src/docs/CopyAsMarkdownButton.tsx b/packages/react-components/react-storybook-addon/src/docs/CopyAsMarkdownButton.tsx new file mode 100644 index 0000000000000..a133ba58c4edf --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/docs/CopyAsMarkdownButton.tsx @@ -0,0 +1,228 @@ +import * as React from 'react'; +import { SplitButton, type MenuButtonProps } from '@fluentui/react-button'; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-menu'; +import { Spinner } from '@fluentui/react-spinner'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-toast'; +import { useId } from '@fluentui/react-utilities'; +import { makeStyles } from '@griffel/react'; +import { bundleIcon, MarkdownFilled, MarkdownRegular } from '@fluentui/react-icons'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; + +const MarkdownIcon = bundleIcon(MarkdownFilled, MarkdownRegular); + +const useStyles = makeStyles({ + button: { + marginInlineStart: 'auto', + }, +}); + +export interface CopyAsMarkdownProps { + /** The Storybook story ID used to generate the markdown URL */ + storyId?: string; +} + +/** + * A button that allows users to copy the current page as markdown to their clipboard or view it in a new tab. + * The markdown content is fetched from the Storybook API and cached for subsequent requests. + */ +export const CopyAsMarkdownButton: React.FC = ({ storyId = '' }) => { + const { targetDocument } = useFluent(); + const targetWindow = targetDocument?.defaultView; + const styles = useStyles(); + const toastId = useId('copy-toast'); + const toasterId = useId('toaster'); + const { dispatchToast, updateToast } = useToastController(toasterId); + + // Cache for the fetched markdown content to avoid redundant network requests + const markdownContentCache = React.useRef(null); + + // AbortController to track and cancel fetch requests + const abortControllerRef = React.useRef(null); + + // Full URL to the markdown endpoint for this story + const markdownUrl = React.useMemo(() => { + return targetWindow ? convertStoryIdToMarkdownUrl(targetWindow, storyId) : ''; + }, [storyId, targetWindow]); + + // Cleanup: abort pending requests on unmount + React.useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + /** + * Fetches the markdown content (with caching) and copies it to the clipboard. + * Shows a toast notification with loading, success, or error states. + * Skips the request if one is already in progress. + */ + const copyPageContentToClipboard = React.useCallback(async () => { + // Skip if a request is already in progress (abort controller exists and not aborted) + if (abortControllerRef.current && !abortControllerRef.current.signal.aborted) { + return; + } + + // Ensure we have a window context to use for clipboard and fetch + if (!targetWindow) { + return; + } + + // Create new AbortController for this request + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + // Show loading toast that persists until updated + dispatchToast( + + }>Copying page content... + , + { + toastId, + intent: 'info', + timeout: -1, // Never auto-dismiss + }, + ); + + try { + // Use cached content if available, otherwise fetch from API + if (!markdownContentCache.current) { + markdownContentCache.current = await fetchMarkdownContent(targetWindow, markdownUrl, abortController.signal); + } + + // Copy to clipboard + await targetWindow?.navigator.clipboard.writeText(markdownContentCache.current); + + // Update toast to success + updateToast({ + content: ( + + Page content copied to clipboard! + + ), + intent: 'success', + toastId, + timeout: 3000, + }); + } catch (error) { + // Don't show error if request was aborted + if (abortController.signal.aborted) { + return; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + + // Update toast to error + updateToast({ + content: ( + + Failed to copy page content: {errorMessage} + + ), + intent: 'error', + toastId, + timeout: 3000, + }); + } finally { + // Clear the abort controller ref to allow new requests + abortControllerRef.current = null; + } + }, [dispatchToast, updateToast, toastId, markdownUrl, targetWindow]); + + /** Opens the markdown content in a new browser tab */ + const openInNewTab = React.useCallback(() => { + targetWindow?.open(markdownUrl, '_blank'); + }, [markdownUrl, targetWindow]); + + if (!storyId) { + return null; + } + + return ( + <> + + + {(triggerProps: MenuButtonProps) => ( + , + onClick: copyPageContentToClipboard, + 'aria-label': 'Copy page content as markdown to clipboard', + }} + aria-label="Copy page as markdown" + > + Copy Page + + )} + + + + + } onClick={openInNewTab}> + View as Markdown + + + + + + + ); +}; + +/** + * Regex pattern to remove the story variant suffix from Storybook story IDs. + * @example "button--primary" -> "button" + */ +const STORYBOOK_VARIANT_SUFFIX_PATTERN = /--\w+$/g; + +/** + * Gets the base URL for fetching markdown content from the Storybook LLM endpoint. + * Each story's markdown is available at: {BASE_URL}/{storyId}.txt + * @param targetWindow - The window object to use for location access + * @returns The base URL constructed from current location origin and pathname + */ +function getStorybookMarkdownApiBaseUrl(targetWindow: Window): string { + // Remove the [page].html file from pathname and append /llms/ + const basePath = targetWindow.location.pathname.replace(/\/[^/]*\.html$/, ''); + return `${targetWindow.location.origin}${basePath}/llms/`; +} + +/** + * Converts a Storybook story ID to a markdown URL. + * @param targetWindow - The window object to use for location access + * @param storyId - The Storybook story ID + * @returns The full URL to the markdown endpoint for the story + * @example "button--primary" -> "https://storybooks.fluentui.dev/llms/button.txt" + */ +function convertStoryIdToMarkdownUrl(targetWindow: Window, storyId: string): string { + return `${getStorybookMarkdownApiBaseUrl(targetWindow)}${storyId.replace(STORYBOOK_VARIANT_SUFFIX_PATTERN, '.txt')}`; +} + +/** + * Fetches markdown content from the Storybook API. + * @param targetWindow - The window object to use for fetch access + * @param url - The URL to fetch markdown content from + * @param signal - Optional AbortSignal to cancel the request + * @returns Promise resolving to the markdown text content + * @throws Error if the fetch request fails or is aborted + */ +async function fetchMarkdownContent( + targetWindow: Window, + url: string, + signal: AbortSignal | undefined, +): Promise { + const response = await targetWindow.fetch(url, { + headers: { + 'Content-Type': 'text/plain', + }, + signal, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`); + } + + return response.text(); +} diff --git a/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx b/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx index 633c784868bf9..ab305842d9544 100644 --- a/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx +++ b/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx @@ -26,6 +26,7 @@ import { getDocsPageConfig } from './utils'; import { DirSwitch } from './DirSwitch'; import { ThemePicker } from './ThemePicker'; import { Toc, nameToHash } from './Toc'; +import { CopyAsMarkdownButton } from './CopyAsMarkdownButton'; type PrimaryStory = PreparedStory; @@ -346,6 +347,7 @@ export const FluentDocsPage = (): JSXElement => { tableOfContents: showTableOfContents, dirSwitcher: showDirSwitcher, themePicker: showThemePicker, + copyAsMarkdown: showCopyAsMarkdown, argTable, } = docsPageConfig; @@ -366,10 +368,11 @@ export const FluentDocsPage = (): JSXElement => { <div className={styles.wrapper}> <div className={styles.container}> - {(showThemePicker || showDirSwitcher) && ( + {(showThemePicker || showDirSwitcher || showCopyAsMarkdown) && ( <div className={styles.globalTogglesContainer}> {showThemePicker && <ThemePicker selectedThemeId={selectedTheme?.id} />} {showDirSwitcher && <DirSwitch dir={dir} />} + {showCopyAsMarkdown && <CopyAsMarkdownButton storyId={primaryStory.id} />} </div> )} <Subtitle /> diff --git a/packages/react-components/react-storybook-addon/src/docs/utils.ts b/packages/react-components/react-storybook-addon/src/docs/utils.ts index c864704506ad9..a255f41eb3459 100644 --- a/packages/react-components/react-storybook-addon/src/docs/utils.ts +++ b/packages/react-components/react-storybook-addon/src/docs/utils.ts @@ -3,6 +3,7 @@ import { type DocsContextProps } from '@storybook/addon-docs'; import { type FluentParameters } from '../hooks'; const docsDefaults = { + copyAsMarkdown: true, tableOfContents: true, dirSwitcher: true, themePicker: true, @@ -45,6 +46,7 @@ export function getDocsPageConfig(context: DocsContextProps): { tableOfContents: boolean; dirSwitcher: boolean; themePicker: boolean; + copyAsMarkdown: boolean; argTable: { slotsApi: boolean; nativePropsApi: boolean; @@ -60,6 +62,7 @@ export function getDocsPageConfig(context: DocsContextProps): { // If docs is an object, extract the configuration directly if (typeof docsConfig === 'object' && docsConfig !== null) { return { + copyAsMarkdown: docsConfig.copyAsMarkdown !== false, tableOfContents: docsConfig.tableOfContents !== false, dirSwitcher: docsConfig.dirSwitcher !== false, themePicker: docsConfig.themePicker !== false, diff --git a/packages/react-components/react-storybook-addon/src/hooks.ts b/packages/react-components/react-storybook-addon/src/hooks.ts index 797b84d0e2c3e..5b5458278f75d 100644 --- a/packages/react-components/react-storybook-addon/src/hooks.ts +++ b/packages/react-components/react-storybook-addon/src/hooks.ts @@ -40,6 +40,7 @@ type FluentDocsConfig = tableOfContents?: boolean; dirSwitcher?: boolean; themePicker?: boolean; + copyAsMarkdown?: boolean; argTable?: | boolean | {