diff --git a/apps/public-docsite-v9-headless/package.json b/apps/public-docsite-v9-headless/package.json index 8e5736f87ea10..524d1e28e385b 100644 --- a/apps/public-docsite-v9-headless/package.json +++ b/apps/public-docsite-v9-headless/package.json @@ -16,6 +16,7 @@ "@fluentui/react-headless-components-preview": "*", "@fluentui/react-storybook-addon": "*", "@fluentui/react-storybook-addon-export-to-sandbox": "*", + "@fluentui/react-storybook-addon-playground": "*", "react": "19.2.0", "react-dom": "19.2.0", "tslib": "^2.1.0" diff --git a/apps/public-docsite-v9-headless/project.json b/apps/public-docsite-v9-headless/project.json index d48c59b15dd70..0c887704c318d 100644 --- a/apps/public-docsite-v9-headless/project.json +++ b/apps/public-docsite-v9-headless/project.json @@ -8,7 +8,13 @@ "build-storybook": { "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "react-storybook-addon-playground", + "storybook-llms-extractor" + ], "target": "build" } ] @@ -16,7 +22,13 @@ "build-storybook:docsite": { "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "react-storybook-addon-playground", + "storybook-llms-extractor" + ], "target": "build" } ], diff --git a/change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json b/change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json new file mode 100644 index 0000000000000..7f82747d8508d --- /dev/null +++ b/change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json @@ -0,0 +1,6 @@ +{ + "type": "minor", + "comment": "feat: support per-story full source generation and playground addon buttons", + "packageName": "@fluentui/react-storybook-addon-export-to-sandbox", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/change/@fluentui-react-storybook-addon-playground-751293a3-5a89-42e3-8d5d-f2e4b6b20f94.json b/change/@fluentui-react-storybook-addon-playground-751293a3-5a89-42e3-8d5d-f2e4b6b20f94.json new file mode 100644 index 0000000000000..10cb08f8b63ce --- /dev/null +++ b/change/@fluentui-react-storybook-addon-playground-751293a3-5a89-42e3-8d5d-f2e4b6b20f94.json @@ -0,0 +1,6 @@ +{ + "type": "minor", + "comment": "feat: add initial playground implementation", + "packageName": "@fluentui/react-storybook-addon-playground", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json b/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json index a3ff4ddc7a388..f846cb601258e 100644 --- a/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json +++ b/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json @@ -1,6 +1,6 @@ { - "type": "patch", - "comment": "feat: add @fluentui/react-storybook-addon-playground package scaffold", + "type": "minor", + "comment": "feat: add an in-browser TSX playground for Storybook stories", "packageName": "@fluentui/react-storybook-addon-playground", "email": "dmytrokirpa@microsoft.com" } diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts b/packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts new file mode 100644 index 0000000000000..35306c6fc9aed --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/main.js b/packages/react-components/react-headless-components-preview/stories/.storybook/main.js index 8d95fb49d632f..b2419b0ec48bf 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/main.js +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/main.js @@ -7,29 +7,63 @@ const { processBabelLoaderOptions, } = require('@fluentui/scripts-storybook'); const { registerCssModuleRules } = require('./css-modules-webpack'); +const headlessPackageJson = require('../../library/package.json'); const repoRoot = path.resolve(__dirname, '../../../../..'); const tsConfigPath = path.resolve(repoRoot, 'tsconfig.base.json'); +const HEADLESS_PACKAGE_NAME = '@fluentui/react-headless-components-preview'; /** + * Headless Storybook replaces the root sandbox addon with a CSS-modules-aware one, and registers + * the playground here only (not on the shared v9 Storybook). + * * @param {string | { name?: string }} addon */ -function isNotExportToSandboxAddon(addon) { +function isNotRootSandboxOrPlaygroundAddon(addon) { const name = typeof addon === 'string' ? addon : addon?.name ?? ''; - return !name.includes('react-storybook-addon-export-to-sandbox'); + return ( + !name.includes('react-storybook-addon-export-to-sandbox') && !name.includes('react-storybook-addon-playground') + ); +} + +/** + * Maps every published subpath of the headless package (plus react-icons) into the playground allowlist. + * Skips `.` — the barrel is empty, and Storybook only has a tsconfig path for `package/*` (source), + * not the package root (built `lib/index.js`, which CI `build-storybook` does not produce). + * + * @returns {Record} + */ +function getHeadlessPlaygroundModules() { + /** @type {Record} */ + const modules = { + '@ctrl/tinycolor': '@ctrl/tinycolor', + '@fluentui/react-icons': '@fluentui/react-icons', + }; + + for (const exportPath of Object.keys(headlessPackageJson.exports ?? {})) { + if (exportPath === '.' || exportPath === './package.json') { + continue; + } + + const specifier = `${HEADLESS_PACKAGE_NAME}${exportPath.slice(1)}`; + modules[specifier] = specifier; + } + + return modules; } module.exports = /** @type {Omit} */ ({ ...rootMain, stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], addons: [ - ...rootMain.addons.filter(isNotExportToSandboxAddon), + ...rootMain.addons.filter(isNotRootSandboxOrPlaygroundAddon), loadWorkspaceAddon('@fluentui/react-storybook-addon-export-to-sandbox', { tsConfigPath, /** @type {import('../../../react-storybook-addon-export-to-sandbox/src/index').PresetConfig} */ options: { importMappings: getImportMappingsForExportToSandboxAddon(), babelLoaderOptionsUpdater: processBabelLoaderOptions, + storyGranularity: 'story', cssModules: { tokensFilePath: path.resolve(__dirname, 'tokens.css') }, webpackRule: { test: /\.stories\.tsx$/, @@ -37,6 +71,15 @@ module.exports = /** @type {Omit { const localConfig = /** @type {any} */ ({ ...rootMain.webpackFinal(config, options) }); diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx b/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx new file mode 100644 index 0000000000000..ec08ef5656a26 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; +import { Provider } from '@fluentui/react-headless-components-preview/provider'; +import { definePlaygroundSetup } from '@fluentui/react-storybook-addon-playground/setup'; + +import './tokens.css'; + +polyfillBodyAndObserve(); + +type HeadlessPlaygroundTheme = 'light' | 'dark'; + +const defaultCode = `import * as React from 'react'; +import { Button } from '@fluentui/react-headless-components-preview/button'; + +export default () => { + const [count, setCount] = React.useState(0); + + return ( + + ); +}; +`; + +export default definePlaygroundSetup({ + title: 'Fluent UI Playground', + subtitle: 'Headless', + defaultCode, + themes: [ + { id: 'light', label: 'Light', value: 'light', dark: false }, + { id: 'dark', label: 'Dark', value: 'dark', dark: true }, + ], + render: ({ Component, theme }) => ( + +
+ +
+
+ ), +}); diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js b/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js index a16824d5be686..1ad92661b8e71 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js @@ -1,4 +1,5 @@ import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; +import '../../../react-storybook-addon-playground/src/styles.css'; import * as rootPreview from '../../../../../.storybook/preview'; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json b/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json index 94fa013fc1872..4911d72ee49f5 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json @@ -7,5 +7,5 @@ "moduleResolution": "bundler", "types": ["static-assets", "environment"] }, - "include": ["*.js"] + "include": ["*.js", "*.tsx", "*.d.ts"] } diff --git a/packages/react-components/react-headless-components-preview/stories/project.json b/packages/react-components/react-headless-components-preview/stories/project.json index 0effce2bfe286..fc52b7e599414 100644 --- a/packages/react-components/react-headless-components-preview/stories/project.json +++ b/packages/react-components/react-headless-components-preview/stories/project.json @@ -4,5 +4,23 @@ "projectType": "library", "sourceRoot": "packages/react-components/react-headless-components-preview/stories/src", "tags": ["vNext", "platform:web", "type:stories", "react-headless"], - "implicitDependencies": [] + "implicitDependencies": [], + "targets": { + "storybook": { + "dependsOn": [ + { + "projects": ["react-headless-components-preview", "react-storybook-addon-playground"], + "target": "build" + } + ] + }, + "build-storybook": { + "dependsOn": [ + { + "projects": ["react-headless-components-preview", "react-storybook-addon-playground"], + "target": "build" + } + ] + } + } } diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx index 498fe9f2b21cd..4ea549ae290c0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx @@ -17,6 +17,9 @@ export default { title: 'Concepts/Positioning', component: Positioning, parameters: { + playground: { + disable: true, + }, docs: { description: { component: [descriptionMd, bestPracticesMd].join('\n'), diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx index 0de4df1661294..fbf6214d87d81 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx @@ -25,6 +25,9 @@ export default { component: TagPicker, subcomponents: { TagPickerControl, TagPickerGroup, TagPickerInput, TagPickerButton, TagPickerList, TagPickerOption }, parameters: { + playground: { + disable: true, + }, docs: { description: { component: [descriptionMd, getBrowserSupportNotice('TagPicker')].join('\n'), diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md b/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md index 693ff3c0ae6fc..2b5e829349cec 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/etc/react-storybook-addon-export-to-sandbox.api.md @@ -27,6 +27,8 @@ export interface PresetConfig { // (undocumented) importMappings: BabelPluginOptions['importMappings']; // (undocumented) + storyGranularity?: BabelPluginOptions['storyGranularity']; + // (undocumented) webpackRule?: RuleSetRule; } diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts index 6a039de1fc6c2..5703ee9721123 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts @@ -38,6 +38,7 @@ export interface ParametersExtension { export interface PresetConfig { importMappings: import('@fluentui/babel-preset-storybook-full-source').BabelPluginOptions['importMappings']; + storyGranularity?: import('@fluentui/babel-preset-storybook-full-source').BabelPluginOptions['storyGranularity']; webpackRule?: import('webpack').RuleSetRule; babelLoaderOptionsUpdater?: (value: import('@babel/core').TransformOptions) => typeof value; /** diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts index 1c118531da63d..84066e72ba1ad 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts @@ -26,8 +26,9 @@ export function prepareSandboxContainers(context: StoryContext) { } return Array.from(rootElements).map(rootElement => { + // Exclude buttons added by this addon and by @fluentui/react-storybook-addon-playground const showCodeButton = rootElement.querySelector( - '.docblock-code-toggle:not(.with-code-sandbox-button):not(.with-open-in-new-tab-button)', + '.docblock-code-toggle:not(.with-code-sandbox-button):not(.with-open-in-new-tab-button):not(.with-open-in-playground-button)', ); const container = showCodeButton?.parentElement; diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts index bfc9fc0e4dbf0..b4971bf0fbd22 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.spec.ts @@ -23,7 +23,7 @@ describe(`webpack`, () => { plugins: [ [ expect.stringContaining('babel-preset-storybook-full-source'), - { importMappings: undefined, cssModules: false }, + { importMappings: undefined, storyGranularity: 'file', cssModules: false }, ], ], }, @@ -46,6 +46,7 @@ describe(`webpack`, () => { babelLoaderOptionsUpdater: value => { return Object.assign(value, { presets: ['babel-foo-bar-preset'] }); }, + storyGranularity: 'story', } as PresetConfig, }, ], @@ -65,6 +66,7 @@ describe(`webpack`, () => { expect.stringContaining('babel-preset-storybook-full-source'), { importMappings: { '@proj/foo': { replace: '@proj/moo' } }, + storyGranularity: 'story', cssModules: false, }, ], @@ -101,7 +103,7 @@ describe(`webpack`, () => { plugins: [ [ expect.stringContaining('babel-preset-storybook-full-source'), - { importMappings: undefined, cssModules }, + { importMappings: undefined, storyGranularity: 'file', cssModules }, ], ], }, diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts index 8ebe39c87ae43..1ae2b7a298096 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts @@ -20,6 +20,7 @@ const addonFilePattern = /react-storybook-addon-export-to-sandbox\/[a-z/]+.[jt]s const defaultOptions = { webpackRule: {}, babelLoaderOptionsUpdater: identity, + storyGranularity: 'file' as const, cssModules: false, }; @@ -29,9 +30,9 @@ const PLUGIN_PATH = : '@fluentui/babel-preset-storybook-full-source'; function createBabelLoaderRule(config: Required): import('webpack').RuleSetRule { - const { babelLoaderOptionsUpdater, importMappings, webpackRule, cssModules } = config; + const { babelLoaderOptionsUpdater, importMappings, webpackRule, storyGranularity, cssModules } = config; - const plugin = [require.resolve(PLUGIN_PATH), { importMappings, cssModules }]; + const plugin = [require.resolve(PLUGIN_PATH), { importMappings, storyGranularity, cssModules }]; return { test: /\.stories\.(jsx?$|tsx?$)/, diff --git a/packages/react-components/react-storybook-addon-playground/.swcrc b/packages/react-components/react-storybook-addon-playground/.swcrc index 7f9fbdf94785d..30f79f5975e21 100644 --- a/packages/react-components/react-storybook-addon-playground/.swcrc +++ b/packages/react-components/react-storybook-addon-playground/.swcrc @@ -7,7 +7,8 @@ "/**/*.spec.ts", "/**/*.spec.tsx", "/**/*.test.ts", - "/**/*.test.tsx" + "/**/*.test.tsx", + "/**/src/playground/**" ], "jsc": { "baseUrl": ".", diff --git a/packages/react-components/react-storybook-addon-playground/README.md b/packages/react-components/react-storybook-addon-playground/README.md index 464254a9fae6b..d98c42c279c15 100644 --- a/packages/react-components/react-storybook-addon-playground/README.md +++ b/packages/react-components/react-storybook-addon-playground/README.md @@ -1,8 +1,18 @@ # @fluentui/react-storybook-addon-playground -**React Storybook Addon Playground for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** +**Configurable Storybook TSX playground for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** -This package is the published scaffold for an in-browser TSX playground Storybook addon. The playground implementation will land in an experimental release so that `latest` stays on this no-op 0.0.x line. +This addon serves a prebuilt Monaco playground shell and adds an **Open in Playground** button next to "Show code" in +Storybook Docs mode. The consumer's Storybook Webpack build produces a separate runtime for React, configured packages, +an optional setup module, and Monaco declarations. User code runs inside a sandboxed preview iframe. + +## Features + +- Consumer-controlled import map (`options.modules`) eagerly compiled into the sandbox runtime by Storybook Webpack +- Private registry and workspace packages without a runtime CDN +- Build-time declaration collection for Monaco IntelliSense (from `modules` + optional `typings`) +- Optional TSX `setup` module for branding, themes and provider/render behavior (Fluent defaults when omitted) +- Sandboxed preview (`sandbox="allow-scripts"`), Prettier formatting, error reporting and shareable URL state ## Installation @@ -12,11 +22,87 @@ yarn add @fluentui/react-storybook-addon-playground ## Usage -This initial release is a no-op Storybook addon. Registering it has no effect on Docs or Canvas until the experimental playground ships. - ```js // .storybook/main.js +const path = require('path'); + module.exports = { - addons: ['@fluentui/react-storybook-addon-playground'], + addons: [ + { + name: '@fluentui/react-storybook-addon-playground', + options: { + modules: { + '@fluentui/react-components': '@fluentui/react-components', + '@fluentui/react-components/unstable': '@fluentui/react-components/unstable', + '@fluentui/react-icons': '@fluentui/react-icons', + }, + setup: path.resolve(__dirname, './playground.setup.tsx'), // optional + // typings: ['@fluentui/react-theme'], // optional declaration-only entries + }, + }, + ], }; ``` + +```js +// .storybook/preview.js +import '@fluentui/react-storybook-addon-playground/styles.css'; +``` + +`modules` maps public imports accepted by the editor to package requests resolved by the consumer's Webpack +configuration. React runtime entries are provided automatically. `typings` adds declaration-only entries. + +The optional setup module default-exports a value created with `definePlaygroundSetup`: + +```tsx +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { definePlaygroundSetup } from '@fluentui/react-storybook-addon-playground/setup'; + +export default definePlaygroundSetup({ + title: 'Fluent UI Playground', + defaultCode: `import { Button } from '@fluentui/react-components'; + +export default () => ;`, + themes: [{ id: 'web-light', label: 'Web Light', value: webLightTheme }], + render: ({ Component, theme }) => ( + + + + ), +}); +``` + +When `setup` is omitted, the addon's built-in Fluent UI default setup is used. + +### Story source + +The button reads the story source from `parameters.fullSource`, which is injected at build time by +`@fluentui/babel-preset-storybook-full-source` (registered by `@fluentui/react-storybook-addon-export-to-sandbox`). +Other consumers can populate the same parameter with their own Storybook source transform. The button is not rendered +when `parameters.fullSource` is unavailable. + +### Disabling per story + +```ts +export const MyStory = () => ; +`; + +describe('url', () => { + describe('encodeCode / decodeCode', () => { + it('round trips source code', () => { + expect(decodeCode(encodeCode(sampleCode))).toBe(sampleCode); + }); + + it('produces a URL safe payload', () => { + const encoded = encodeCode(sampleCode); + + expect(encoded).toMatch(/^[A-Za-z0-9+\-$]+$/); + }); + + it('tolerates "+" being decoded as a space by URLSearchParams', () => { + const encoded = encodeCode(sampleCode); + + expect(encoded).toContain('+'); + expect(decodeCode(encoded.replace(/\+/g, ' '))).toBe(sampleCode); + }); + + it('returns null for empty or invalid payloads', () => { + expect(decodeCode('')).toBeNull(); + expect(decodeCode('not-a-valid-payload!!!')).toBeNull(); + }); + + it('round trips empty source through playground state hashes', () => { + const hash = createPlaygroundHash({ code: '' }); + + expect(decodePlaygroundStateFromHash(hash)).toEqual({ code: '', cssModules: [] }); + expect(decodeCodeFromHash(hash)).toBe(''); + }); + }); + + describe('createCodeHash / decodeCodeFromHash', () => { + it('creates a hash with the code param', () => { + const hash = createCodeHash(sampleCode); + + expect(hash.startsWith(`#${CODE_HASH_PARAM}=`)).toBe(true); + expect(decodeCodeFromHash(hash)).toBe(sampleCode); + }); + + it('accepts hash with and without the leading "#"', () => { + const hash = createCodeHash(sampleCode); + + expect(decodeCodeFromHash(hash.slice(1))).toBe(sampleCode); + }); + + it('ignores unrelated hash params', () => { + expect(decodeCodeFromHash('')).toBeNull(); + expect(decodeCodeFromHash('#foo=bar')).toBeNull(); + expect(decodeCodeFromHash(`#foo=bar&${createCodeHash(sampleCode).slice(1)}`)).toBe(sampleCode); + }); + }); + + describe('createPlaygroundUrl', () => { + it('defaults to the playground shell path', () => { + const url = createPlaygroundUrl(sampleCode); + + expect(url.startsWith(`./${PLAYGROUND_PATH}#${CODE_HASH_PARAM}=`)).toBe(true); + }); + + it('supports a custom base url', () => { + const url = createPlaygroundUrl(sampleCode, 'https://example.com/storybook/playground.html'); + const [base, hash] = url.split('#'); + + expect(base).toBe('https://example.com/storybook/playground.html'); + expect(decodeCodeFromHash(`#${hash}`)).toBe(sampleCode); + }); + + it('encodes CSS modules next to the source so the sandbox can apply them', () => { + const cssModules = [{ name: 'button.module.css', source: '.root { color: red; }' }]; + const url = createPlaygroundUrl(sampleCode, undefined, cssModules); + const hash = `#${url.split('#')[1]}`; + + expect(hash).toContain(`${CSS_HASH_PARAM}=`); + expect(decodePlaygroundStateFromHash(hash)).toEqual({ code: sampleCode, cssModules }); + expect(createPlaygroundHash({ code: sampleCode, cssModules }).startsWith('#')).toBe(true); + }); + }); +}); diff --git a/packages/react-components/react-storybook-addon-playground/src/url.ts b/packages/react-components/react-storybook-addon-playground/src/url.ts new file mode 100644 index 0000000000000..a3e8dff18e0df --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/url.ts @@ -0,0 +1,117 @@ +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; + +export const CODE_HASH_PARAM = 'code'; +export const CSS_HASH_PARAM = 'css'; +/** Relative to Storybook's `iframe.html`: shell under `/playground/app`, runtime under `/playground/runtime`. */ +export const PLAYGROUND_PATH = 'playground/app/playground.html'; + +export interface CssModuleSource { + name: string; + source: string; +} + +export interface PlaygroundUrlState { + code: string; + cssModules: CssModuleSource[]; +} + +/** + * Compresses source code into a URL safe string. + */ +export function encodeCode(code: string): string { + return compressToEncodedURIComponent(code); +} + +/** + * Inverse of {@link encodeCode}. Returns `null` when the payload is missing or cannot be decoded. + */ +export function decodeCode(encoded: string): string | null { + if (!encoded) { + return null; + } + + const decoded = decompressFromEncodedURIComponent(encoded); + + return typeof decoded === 'string' ? decoded : null; +} + +function parseCssModulesPayload(encoded: string | null): CssModuleSource[] { + if (!encoded) { + return []; + } + + const json = decodeCode(encoded); + if (!json) { + return []; + } + + try { + const parsed: unknown = JSON.parse(json); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter( + (entry): entry is CssModuleSource => + Boolean(entry) && typeof entry.name === 'string' && typeof entry.source === 'string', + ); + } catch { + return []; + } +} + +/** + * Reads the playground payload from a `location.hash` value (`#code=...&css=...`). + */ +export function decodePlaygroundStateFromHash(hash: string): PlaygroundUrlState | null { + const params = new URLSearchParams(hash.replace(/^#/, '')); + const encoded = params.get(CODE_HASH_PARAM); + const code = encoded === null ? null : encoded === '' ? '' : decodeCode(encoded); + + if (code === null) { + return null; + } + + return { code, cssModules: parseCssModulesPayload(params.get(CSS_HASH_PARAM)) }; +} + +/** + * Reads the code payload from a `location.hash` value (`#code=...`). + */ +export function decodeCodeFromHash(hash: string): string | null { + return decodePlaygroundStateFromHash(hash)?.code ?? null; +} + +/** + * Creates a `location.hash` value for provided playground state. + */ +export function createPlaygroundHash(state: { code: string; cssModules?: readonly CssModuleSource[] }): string { + let hash = `#${CODE_HASH_PARAM}=${encodeCode(state.code)}`; + + if (state.cssModules && state.cssModules.length > 0) { + hash += `&${CSS_HASH_PARAM}=${encodeCode(JSON.stringify(state.cssModules))}`; + } + + return hash; +} + +/** + * Creates a `location.hash` value for provided code. + */ +export function createCodeHash(code: string): string { + return createPlaygroundHash({ code }); +} + +/** + * Creates the playground URL for provided code (and optional CSS modules from the story). + * + * The URL is relative to the Storybook preview (`iframe.html`), which lives next to the playground static dirs in the + * Storybook root, so it also works when Storybook is deployed under a sub path. + */ +export function createPlaygroundUrl( + code: string, + baseUrl = `./${PLAYGROUND_PATH}`, + cssModules?: readonly CssModuleSource[], +): string { + return `${baseUrl}${createPlaygroundHash({ code, cssModules })}`; +} diff --git a/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts b/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts new file mode 100644 index 0000000000000..4e36f010f8327 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts @@ -0,0 +1,76 @@ +import { buildRuntimeEntrySource, filterRuntimeEntryAssets, isPlaygroundAddonFile } from './webpack'; + +describe('isPlaygroundAddonFile', () => { + it.each([ + '/repo/react-storybook-addon-playground/preset.js', + 'C:\\repo\\react-storybook-addon-playground\\preset.js', + ])('matches the addon preset path %s', presetPath => { + expect(isPlaygroundAddonFile(presetPath)).toBe(true); + }); +}); + +describe('filterRuntimeEntryAssets', () => { + it('removes playground-only assets from Storybook HTML and preserves shared chunks', () => { + const entrypoints = new Map([ + [ + 'main', + { + getFiles: () => ['runtime~main.js', 'shared.js', 'main.js', 'shared.css'], + }, + ], + [ + 'playground-runtime', + { + getFiles: () => [ + 'runtime~playground-runtime.js', + 'react-dom-client.js', + 'shared.js', + 'playground-runtime.js', + 'shared.css', + 'playground.css', + ], + }, + ], + ]); + const data = { + assets: { + js: [ + 'runtime~main.js', + 'shared.js', + 'main.js', + 'runtime~playground-runtime.js', + 'react-dom-client.js', + 'playground-runtime.js', + ], + css: ['shared.css', 'playground.css'], + }, + }; + + expect(filterRuntimeEntryAssets(entrypoints, data)).toEqual({ + assets: { + js: ['runtime~main.js', 'shared.js', 'main.js'], + css: ['shared.css'], + }, + }); + }); +}); + +describe('buildRuntimeEntrySource', () => { + it('eagerly imports configured modules instead of using dynamic import()', () => { + const source = buildRuntimeEntrySource({ + modules: { + '@fluentui/react-components': '@fluentui/react-components', + '@fluentui/react-icons': '@fluentui/react-icons', + }, + setup: '/abs/playground.setup.tsx', + }); + + expect(source).toContain('import * as __pg_mod_0 from "@fluentui/react-components";'); + expect(source).toContain('import * as __pg_mod_1 from "@fluentui/react-icons";'); + expect(source).toContain('"@fluentui/react-components": () => Promise.resolve(__pg_mod_0)'); + expect(source).toContain('"@fluentui/react-icons": () => Promise.resolve(__pg_mod_1)'); + expect(source).toContain('const allowedModules = Object.freeze(Object.keys(moduleLoaders));'); + expect(source).toContain('allowedModules,'); + expect(source).not.toMatch(/\bimport\(/); + }); +}); diff --git a/packages/react-components/react-storybook-addon-playground/src/webpack.ts b/packages/react-components/react-storybook-addon-playground/src/webpack.ts new file mode 100644 index 0000000000000..c8cf36e543e54 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/webpack.ts @@ -0,0 +1,312 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { PresetConfig } from './public-types'; + +type WebpackFinalFn = NonNullable; +type WebpackFinalConfig = Parameters[0]; +type WebpackFinalOptions = Parameters[1]; +type CollectTypingsResult = { + files: Record; + sources: string[]; + missing: string[]; +}; +type HtmlAssetsData = { + assets: { + js: string[]; + css: string[]; + }; +}; +type EntrypointFiles = { + getFiles(): string[]; +}; +type HtmlWebpackPluginConstructor = { + getHooks(compilation: import('webpack').Compilation): { + beforeAssetTagGeneration: { + tap(name: string, callback: (data: HtmlAssetsData) => HtmlAssetsData): void; + }; + }; +}; + +export const ENTRY_NAME = 'playground-runtime'; +export const REGISTER_CALLBACK = '__FLUENTUI_PLAYGROUND_REGISTER_V1__'; + +const addonFilePattern = /react-storybook-addon-playground[\\/][a-z\\/]+\.[jt]s$/; +const defaultOptions: PresetConfig = { + modules: {}, +}; + +const { collectTypings } = require('../tools/collect-typings') as { + collectTypings(options: { packageRoot: string; entries: string[]; typescriptVersion: string }): CollectTypingsResult; +}; + +/** + * Storybook preset hook: emits a separate Webpack entry for the playground runtime (configured modules + setup), + * collects Monaco typings for those modules, and writes `playground/runtime/manifest.json`. + */ +export function webpackFinal(config: WebpackFinalConfig, options: WebpackFinalOptions): WebpackFinalConfig { + const addonOptions = getAddonOptions(options); + const runtimeEntry = writeRuntimeEntry(addonOptions, options.configDir ?? process.cwd()); + const typings = collectConfiguredTypings(addonOptions, options); + const originalEntry = config.entry; + + config.entry = async (): Promise => { + const entry = typeof originalEntry === 'function' ? await originalEntry() : originalEntry; + const normalizedEntry: import('webpack').EntryObject = + !entry || typeof entry === 'string' || Array.isArray(entry) ? { main: entry ?? [] } : entry; + + return { + ...normalizedEntry, + [ENTRY_NAME]: runtimeEntry, + }; + }; + + config.plugins = config.plugins ?? []; + config.plugins.push(new ExcludeRuntimeEntryFromHtmlPlugin()); + config.plugins.push(new PlaygroundRuntimeManifestPlugin(addonOptions, typings)); + + return config; +} + +/** + * Storybook injects every Webpack entry into `iframe.html` by default. The playground runtime must only load inside + * the sandboxed preview iframe. + */ +class ExcludeRuntimeEntryFromHtmlPlugin { + public apply(compiler: import('webpack').Compiler): void { + const pluginName = 'ExcludePlaygroundRuntimeFromHtmlPlugin'; + const htmlPlugin = compiler.options.plugins.find( + plugin => plugin && plugin.constructor.name === 'HtmlWebpackPlugin', + ); + const htmlPluginConstructor = htmlPlugin?.constructor as unknown as HtmlWebpackPluginConstructor | undefined; + + if (!htmlPluginConstructor?.getHooks) { + return; + } + + compiler.hooks.compilation.tap(pluginName, compilation => { + htmlPluginConstructor.getHooks(compilation).beforeAssetTagGeneration.tap(pluginName, data => { + return filterRuntimeEntryAssets(compilation.entrypoints, data); + }); + }); + } +} + +export function filterRuntimeEntryAssets( + entrypoints: ReadonlyMap, + data: HtmlAssetsData, +): HtmlAssetsData { + const runtimeFiles = new Set(entrypoints.get(ENTRY_NAME)?.getFiles() ?? []); + const otherEntryFiles = new Set( + Array.from(entrypoints.entries()).flatMap(([name, entrypoint]) => + name === ENTRY_NAME ? [] : entrypoint.getFiles(), + ), + ); + const exclusiveRuntimeFiles = Array.from(runtimeFiles).filter(file => !otherEntryFiles.has(file)); + + data.assets.js = data.assets.js.filter(asset => !matchesAnyAsset(asset, exclusiveRuntimeFiles)); + data.assets.css = data.assets.css.filter(asset => !matchesAnyAsset(asset, exclusiveRuntimeFiles)); + + return data; +} + +function matchesAnyAsset(assetUrl: string, assetFiles: string[]): boolean { + const normalizedUrl = assetUrl.split(/[?#]/, 1)[0].replace(/\\/g, '/'); + + return assetFiles.some(file => { + const normalizedFile = file.replace(/\\/g, '/'); + return normalizedUrl === normalizedFile || normalizedUrl.endsWith(`/${normalizedFile}`); + }); +} + +function getAddonOptions(options: WebpackFinalOptions): PresetConfig { + const presetRegistration = options.presetsList?.find(preset => isPlaygroundAddonFile(preset.name)); + const addonOptions = presetRegistration?.options ?? {}; + + return { ...defaultOptions, ...addonOptions }; +} + +export function isPlaygroundAddonFile(filePath: string): boolean { + return addonFilePattern.test(filePath); +} + +function getDefaultSetupPath(): string { + // Compiled next to this file (`lib-commonjs/webpack.js` → `lib-commonjs/defaultSetup.js`) + return require.resolve('./defaultSetup'); +} + +/** + * Builds the playground runtime entry source. + * + * Configured modules are **static** imports (not `import()`). Dynamic imports become async chunks that Storybook's + * webpack lazy-compilation serves from a separate origin — which fails inside `sandbox="allow-scripts"` (opaque + * origin) iframes. Static imports stay on the entrypoint graph and ship via the manifest `