Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/chart-docsite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build-storybook": "storybook build -o ./dist/storybook --docs",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Drawer 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Drawer.overlay drawer full - High Contrast.chromium.png 4942 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 726 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 915 Changed
vr-tests-react-components/TagPicker 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.disabled input hover.chromium.png 659 Changed

There were 1 duplicate changes discarded. Check the build logs for more information.

"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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the postbuild hook didn't run for the new deployment

"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",
Expand Down
5 changes: 3 additions & 2 deletions apps/public-docsite-v9/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same as above, but here we're passing the env var cross-env DEPLOY_PATH=/react/ to use diferrent config for storybook-llms config based on deployment

"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",
Expand Down
5 changes: 3 additions & 2 deletions apps/public-docsite-v9/storybook-llms.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add Copy as Markdown button to FluentDocsPage",
"packageName": "@fluentui/react-storybook-addon",
"email": "[email protected]",
"dependentChangeType": "patch"
}
12 changes: 8 additions & 4 deletions packages/react-components/react-storybook-addon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
13 changes: 8 additions & 5 deletions packages/react-components/react-storybook-addon/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CopyAsMarkdownProps> = ({ 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<string | null>(null);

// AbortController to track and cancel fetch requests
const abortControllerRef = React.useRef<AbortController | null>(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(
<Toast>
<ToastTitle media={<Spinner />}>Copying page content...</ToastTitle>
</Toast>,
{
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: (
<Toast>
<ToastTitle>Page content copied to clipboard!</ToastTitle>
</Toast>
),
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: (
<Toast>
<ToastTitle>Failed to copy page content: {errorMessage}</ToastTitle>
</Toast>
),
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 (
<>
<Menu positioning="below-end">
<MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => (
<SplitButton
className={styles.button}
menuButton={triggerProps}
primaryActionButton={{
appearance: 'secondary',
icon: <MarkdownIcon />,
onClick: copyPageContentToClipboard,
'aria-label': 'Copy page content as markdown to clipboard',
}}
aria-label="Copy page as markdown"
>
Copy Page
</SplitButton>
)}
</MenuTrigger>

<MenuPopover>
<MenuList>
<MenuItem icon={<MarkdownIcon />} onClick={openInNewTab}>
View as Markdown
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
<Toaster toasterId={toasterId} />
</>
);
};

/**
* 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<string> {
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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Renderer>;

Expand Down Expand Up @@ -346,6 +347,7 @@ export const FluentDocsPage = (): JSXElement => {
tableOfContents: showTableOfContents,
dirSwitcher: showDirSwitcher,
themePicker: showThemePicker,
copyAsMarkdown: showCopyAsMarkdown,
argTable,
} = docsPageConfig;

Expand All @@ -366,10 +368,11 @@ export const FluentDocsPage = (): JSXElement => {
<Title />
<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 />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -45,6 +46,7 @@ export function getDocsPageConfig(context: DocsContextProps): {
tableOfContents: boolean;
dirSwitcher: boolean;
themePicker: boolean;
copyAsMarkdown: boolean;
argTable: {
slotsApi: boolean;
nativePropsApi: boolean;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type FluentDocsConfig =
tableOfContents?: boolean;
dirSwitcher?: boolean;
themePicker?: boolean;
copyAsMarkdown?: boolean;
argTable?:
| boolean
| {
Expand Down