diff --git a/.circleci/config.yml b/.circleci/config.yml
index ac31411a5af7..2b478fea10ef 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -846,7 +846,7 @@ workflows:
requires:
- create-sandboxes
- test-runner-production:
- parallelism: 9
+ parallelism: 8
requires:
- create-sandboxes
- vitest-integration:
@@ -918,7 +918,7 @@ workflows:
requires:
- create-sandboxes
- test-runner-production:
- parallelism: 15
+ parallelism: 14
requires:
- create-sandboxes
- vitest-integration:
@@ -991,7 +991,7 @@ workflows:
requires:
- create-sandboxes
- test-runner-production:
- parallelism: 32
+ parallelism: 30
requires:
- create-sandboxes
- vitest-integration:
diff --git a/.gitignore b/.gitignore
index fe790007a21e..0a9020bcff75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ test-results
.verdaccio-cache
.next
/.npmrc
+tsconfig.vitest-temp.json
# Yarn stuff
/**/.yarn/*
diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md
index 76b335ba7b7f..f800b80bca2f 100644
--- a/CHANGELOG.prerelease.md
+++ b/CHANGELOG.prerelease.md
@@ -1,3 +1,10 @@
+## 8.6.0-alpha.5
+
+- Core: Add `UniversalStore` API to sync state/events between multiple environments - [#30445](https://github.com/storybookjs/storybook/pull/30445), thanks @JReinhold!
+- Core: Fix statically serving single files and multiple dirs on the same endpoint - [#30467](https://github.com/storybookjs/storybook/pull/30467), thanks @JReinhold!
+- Core: Move CSF to monorepo - [#30488](https://github.com/storybookjs/storybook/pull/30488), thanks @kasperpeulen!
+- React: Update react-docgen-typescript to fix CI hanging issues - [#30422](https://github.com/storybookjs/storybook/pull/30422), thanks @yannbf!
+
## 8.6.0-alpha.4
- Addon A11y: Make Vitest Axe optional - [#30442](https://github.com/storybookjs/storybook/pull/30442), thanks @valentinpalkovic!
diff --git a/MIGRATION.md b/MIGRATION.md
index e5db7ad88c7f..05a071bcec53 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -2,6 +2,9 @@
- [From version 8.5.x to 8.6.x](#from-version-85x-to-86x)
- [Angular: Support experimental zoneless support](#angular-support-experimental-zoneless-support)
+- [From version 8.4.x to 8.5.x](#from-version-84x-to-85x)
+ - [React Vite: react-docgen-typescript is updated](#react-vite-react-docgen-typescript-is-updated)
+ - [Introducing features.developmentModeForBuild](#introducing-featuresdevelopmentmodeforbuild)
- [Added source code panel to docs](#added-source-code-panel-to-docs)
- [Addon-a11y: Component test integration](#addon-a11y-component-test-integration)
- [Addon-a11y: Changing the default element selector](#addon-a11y-changing-the-default-element-selector)
@@ -460,6 +463,26 @@ Storybook now supports [Angular's experimental zoneless mode](https://angular.de
## From version 8.4.x to 8.5.x
+### React Vite: react-docgen-typescript is updated
+
+Storybook now uses [react-docgen-typescript](https://github.com/joshwooding/vite-plugin-react-docgen-typescript) v0.5.0 which updates its internal logic on how it parses files, available under an experimental feature flag `EXPERIMENTAL_useWatchProgram`, which is disabled by default.
+
+Previously, once you made changes to a component's props, the controls and args table would not update unless you restarted Storybook. With the `EXPERIMENTAL_useWatchProgram` flag, you do not need to restart Storybook anymore, however you do need to refresh the browser page. Keep in mind that this flag is experimental and also does not support the `references` field in tsconfig.json files. Depending on how big your codebase is, it might have performance issues.
+
+```ts
+// .storybook/main.ts
+const config = {
+ // ...
+ typescript: {
+ reactDocgen: "react-docgen-typescript",
+ reactDocgenTypescriptOptions: {
+ EXPERIMENTAL_useWatchProgram: true,
+ },
+ },
+};
+export default config;
+```
+
### Introducing features.developmentModeForBuild
As part of our ongoing efforts to improve the testability and debuggability of Storybook, we are introducing a new feature flag: `developmentModeForBuild`. This feature flag allows you to set `process.env.NODE_ENV` to `development` in built Storybooks, enabling development-related optimizations that are typically disabled in production builds.
@@ -473,7 +496,7 @@ export default {
developmentModeForBuild: true,
},
};
-````
+```
### Added source code panel to docs
diff --git a/code/.eslintrc.js b/code/.eslintrc.js
index 4268ec993dd6..e43666e36c2b 100644
--- a/code/.eslintrc.js
+++ b/code/.eslintrc.js
@@ -93,6 +93,7 @@ module.exports = {
'**/__tests__/**',
'**/__testfixtures__/**',
'**/*.test.*',
+ '**/*.test-d.*',
'**/*.stories.*',
'**/*.mockdata.*',
'**/template/**/*',
diff --git a/code/addons/a11y/src/preview.test.tsx b/code/addons/a11y/src/preview.test.tsx
index 0ee4ebba829e..3b50fc8a98d8 100644
--- a/code/addons/a11y/src/preview.test.tsx
+++ b/code/addons/a11y/src/preview.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import type { StoryContext } from '@storybook/csf';
+import type { StoryContext } from 'storybook/internal/csf';
import { run } from './a11yRunner';
import { A11Y_TEST_TAG } from './constants';
diff --git a/code/addons/controls/src/manager.tsx b/code/addons/controls/src/manager.tsx
index 5863159f79db..758a8d9f0f78 100644
--- a/code/addons/controls/src/manager.tsx
+++ b/code/addons/controls/src/manager.tsx
@@ -7,6 +7,7 @@ import type {
SaveStoryResponsePayload,
} from 'storybook/internal/core-events';
import { SAVE_STORY_REQUEST, SAVE_STORY_RESPONSE } from 'storybook/internal/core-events';
+import type { Args } from 'storybook/internal/csf';
import {
addons,
experimental_requestResponse,
@@ -15,8 +16,6 @@ import {
} from 'storybook/internal/manager-api';
import { color } from 'storybook/internal/theming';
-import type { Args } from '@storybook/csf';
-
import { dequal as deepEqual } from 'dequal';
import { ControlsPanel } from './ControlsPanel';
diff --git a/code/addons/docs/package.json b/code/addons/docs/package.json
index 373f59f8c5d4..2fcff94f734c 100644
--- a/code/addons/docs/package.json
+++ b/code/addons/docs/package.json
@@ -106,14 +106,14 @@
"@storybook/blocks": "workspace:*",
"@storybook/csf-plugin": "workspace:*",
"@storybook/react-dom-shim": "workspace:*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
- "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
},
"devDependencies": {
"@mdx-js/mdx": "^3.0.0",
"@rollup/pluginutils": "^5.0.2",
- "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rehype-external-links": "^3.0.0",
diff --git a/code/addons/links/package.json b/code/addons/links/package.json
index c53c8fe614d5..5555a0e50a7a 100644
--- a/code/addons/links/package.json
+++ b/code/addons/links/package.json
@@ -65,7 +65,6 @@
"prep": "jiti ../../../scripts/prepare/addon-bundle.ts"
},
"dependencies": {
- "@storybook/csf": "0.1.12",
"@storybook/global": "^5.0.0",
"ts-dedent": "^2.0.0"
},
diff --git a/code/addons/links/src/utils.ts b/code/addons/links/src/utils.ts
index 86aa75c4fc4a..7f3c01b82b0f 100644
--- a/code/addons/links/src/utils.ts
+++ b/code/addons/links/src/utils.ts
@@ -1,8 +1,8 @@
import { SELECT_STORY, STORY_CHANGED } from 'storybook/internal/core-events';
+import { toId } from 'storybook/internal/csf';
import { addons, makeDecorator } from 'storybook/internal/preview-api';
import type { ComponentTitle, StoryId, StoryKind, StoryName } from 'storybook/internal/types';
-import { toId } from '@storybook/csf';
import { global } from '@storybook/global';
import { PARAM_KEY } from './constants';
diff --git a/code/addons/test/package.json b/code/addons/test/package.json
index b290ebc3cdeb..c45adcdad811 100644
--- a/code/addons/test/package.json
+++ b/code/addons/test/package.json
@@ -81,7 +81,6 @@
"prep": "jiti ../../../scripts/prepare/addon-bundle.ts"
},
"dependencies": {
- "@storybook/csf": "0.1.12",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.12",
"@storybook/instrumenter": "workspace:*",
diff --git a/code/addons/test/src/components/TestProviderRender.tsx b/code/addons/test/src/components/TestProviderRender.tsx
index 55168b4b7aea..ecab017d5d30 100644
--- a/code/addons/test/src/components/TestProviderRender.tsx
+++ b/code/addons/test/src/components/TestProviderRender.tsx
@@ -20,11 +20,11 @@ import {
type TestProviderConfig,
type TestProviderState,
} from 'storybook/internal/core-events';
+import type { Tag } from 'storybook/internal/csf';
import { addons, useStorybookState } from 'storybook/internal/manager-api';
import type { API } from 'storybook/internal/manager-api';
import { styled, useTheme } from 'storybook/internal/theming';
-import type { Tag } from '@storybook/csf';
import {
AccessibilityIcon,
EditIcon,
diff --git a/code/addons/test/src/vitest-plugin/viewports.ts b/code/addons/test/src/vitest-plugin/viewports.ts
index 905ee44fc937..bdc2ef581346 100644
--- a/code/addons/test/src/vitest-plugin/viewports.ts
+++ b/code/addons/test/src/vitest-plugin/viewports.ts
@@ -1,8 +1,7 @@
/* eslint-disable no-underscore-dangle */
+import type { Globals, Parameters } from 'storybook/internal/csf';
import { UnsupportedViewportDimensionError } from 'storybook/internal/preview-errors';
-import type { Globals, Parameters } from '@storybook/csf';
-
import { MINIMAL_VIEWPORTS } from '../../../viewport/src/defaults';
import type { ViewportMap, ViewportStyles } from '../../../viewport/src/types';
diff --git a/code/core/package.json b/code/core/package.json
index 165e070d3072..89c00e406343 100644
--- a/code/core/package.json
+++ b/code/core/package.json
@@ -97,6 +97,11 @@
"import": "./dist/csf-tools/index.js",
"require": "./dist/csf-tools/index.cjs"
},
+ "./csf": {
+ "types": "./dist/csf/index.d.ts",
+ "import": "./dist/csf/index.js",
+ "require": "./dist/csf/index.cjs"
+ },
"./common": {
"types": "./dist/common/index.d.ts",
"import": "./dist/common/index.js",
@@ -219,6 +224,9 @@
"csf-tools": [
"./dist/csf-tools/index.d.ts"
],
+ "csf": [
+ "./dist/csf/index.d.ts"
+ ],
"common": [
"./dist/common/index.d.ts"
],
@@ -274,7 +282,6 @@
"prep": "jiti ./scripts/prep.ts"
},
"dependencies": {
- "@storybook/csf": "0.1.12",
"@storybook/theming": "workspace:*",
"better-opn": "^3.0.2",
"browser-assert": "^1.2.1",
@@ -303,6 +310,7 @@
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
"@fal-works/esbuild-plugin-global-externals": "^2.1.2",
"@ndelangen/get-tarball": "^3.0.7",
+ "@ngard/tiny-isequal": "^1.1.0",
"@polka/compression": "^1.0.0-next.28",
"@popperjs/core": "^2.6.0",
"@radix-ui/react-dialog": "^1.1.2",
diff --git a/code/core/scripts/entries.ts b/code/core/scripts/entries.ts
index a8588f13fd86..2b90e2c4b8a4 100644
--- a/code/core/scripts/entries.ts
+++ b/code/core/scripts/entries.ts
@@ -25,6 +25,7 @@ export const getEntries = (cwd: string) => {
define('src/channels/index.ts', ['browser', 'node'], true),
define('src/types/index.ts', ['browser', 'node'], true, ['react']),
define('src/csf-tools/index.ts', ['node'], true),
+ define('src/csf/index.ts', ['browser', 'node'], true),
define('src/common/index.ts', ['node'], true),
define('src/builder-manager/index.ts', ['node'], true),
define('src/telemetry/index.ts', ['node'], true),
diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts
index a31de6c556f6..c9dce040e55a 100644
--- a/code/core/src/channels/index.ts
+++ b/code/core/src/channels/index.ts
@@ -1,6 +1,7 @@
///
import { global } from '@storybook/global';
+import { UniversalStore } from '../shared/universal-store';
import { Channel } from './main';
import { PostMessageTransport } from './postmessage';
import type { ChannelTransport, Config } from './types';
@@ -39,7 +40,14 @@ export function createBrowserChannel({ page, extraTransports = [] }: Options): C
transports.push(new WebsocketTransport({ url: channelUrl, onError: () => {}, page }));
}
- return new Channel({ transports });
+ const channel = new Channel({ transports });
+ // eslint-disable-next-line no-underscore-dangle
+ UniversalStore.__prepare(
+ channel,
+ page === 'manager' ? UniversalStore.Environment.MANAGER : UniversalStore.Environment.PREVIEW
+ );
+
+ return channel;
}
export type { Listener, ChannelEvent, ChannelTransport, ChannelHandler } from './types';
diff --git a/code/core/src/common/utils/get-story-id.ts b/code/core/src/common/utils/get-story-id.ts
index 2275cdfcf8ce..5376583034aa 100644
--- a/code/core/src/common/utils/get-story-id.ts
+++ b/code/core/src/common/utils/get-story-id.ts
@@ -1,8 +1,8 @@
import { relative } from 'node:path';
import { normalizeStories, normalizeStoryPath } from '@storybook/core/common';
+import { sanitize, storyNameFromExport, toId } from '@storybook/core/csf';
import type { Options, StoriesEntry } from '@storybook/core/types';
-import { sanitize, storyNameFromExport, toId } from '@storybook/csf';
import { userOrAutoTitleFromSpecifier } from '@storybook/core/preview-api';
diff --git a/code/core/src/components/components/addon-panel/addon-panel.tsx b/code/core/src/components/components/addon-panel/addon-panel.tsx
index a34ac48e9efb..8d56028a3293 100644
--- a/code/core/src/components/components/addon-panel/addon-panel.tsx
+++ b/code/core/src/components/components/addon-panel/addon-panel.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from 'react';
+import type { ReactElement } from 'react';
import React, { useEffect, useRef } from 'react';
const usePrevious = (value: any) => {
@@ -20,7 +20,7 @@ const useUpdate = (update: boolean, value: any) => {
export interface AddonPanelProps {
active: boolean;
- children: ReactNode;
+ children: ReactElement;
}
export const AddonPanel = ({ active, children }: AddonPanelProps) => {
diff --git a/code/core/src/components/components/tabs/tabs.hooks.tsx b/code/core/src/components/components/tabs/tabs.hooks.tsx
index be3a8f2060a7..6f106461a265 100644
--- a/code/core/src/components/components/tabs/tabs.hooks.tsx
+++ b/code/core/src/components/components/tabs/tabs.hooks.tsx
@@ -1,7 +1,7 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
+import { sanitize } from '@storybook/core/csf';
import { styled } from '@storybook/core/theming';
-import { sanitize } from '@storybook/csf';
import useResizeObserver from 'use-resize-observer';
diff --git a/code/core/src/components/components/tabs/tabs.tsx b/code/core/src/components/components/tabs/tabs.tsx
index 3c8c46c45c1b..4236cb68142f 100644
--- a/code/core/src/components/components/tabs/tabs.tsx
+++ b/code/core/src/components/components/tabs/tabs.tsx
@@ -1,9 +1,9 @@
import type { FC, PropsWithChildren, ReactElement, ReactNode, SyntheticEvent } from 'react';
import React, { Component, memo, useMemo } from 'react';
+import { sanitize } from '@storybook/core/csf';
import { styled } from '@storybook/core/theming';
import type { Addon_RenderOptions } from '@storybook/core/types';
-import { sanitize } from '@storybook/csf';
import { FlexBar } from '../bar/bar';
import { TabButton } from '../bar/button';
diff --git a/code/core/src/core-events/data/argtypes-info.ts b/code/core/src/core-events/data/argtypes-info.ts
index c993c684768c..147c946df8cd 100644
--- a/code/core/src/core-events/data/argtypes-info.ts
+++ b/code/core/src/core-events/data/argtypes-info.ts
@@ -1,4 +1,4 @@
-import type { ArgTypes } from '@storybook/csf';
+import type { ArgTypes } from '@storybook/core/csf';
export interface ArgTypesRequestPayload {
storyId: string;
diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts
index 5a74e0056c17..01725df3cf73 100644
--- a/code/core/src/core-server/index.ts
+++ b/code/core/src/core-server/index.ts
@@ -10,3 +10,5 @@ export { mapStaticDir } from './utils/server-statics';
export { StoryIndexGenerator } from './utils/StoryIndexGenerator';
export { loadStorybook as experimental_loadStorybook } from './load';
+
+export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store';
diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
index f199c05cfe7f..495cc2010e6a 100644
--- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
+++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
@@ -4,8 +4,8 @@ import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { normalizeStoriesEntry } from '@storybook/core/common';
+import { toId } from '@storybook/core/csf';
import type { NormalizedStoriesSpecifier, StoryIndexEntry } from '@storybook/core/types';
-import { toId } from '@storybook/csf';
import { getStorySortParameter, readCsf } from '@storybook/core/csf-tools';
import { logger, once } from '@storybook/core/node-logger';
@@ -14,8 +14,8 @@ import { csfIndexer } from '../presets/common-preset';
import type { StoryIndexGeneratorOptions } from './StoryIndexGenerator';
import { StoryIndexGenerator } from './StoryIndexGenerator';
-vi.mock('@storybook/csf', async (importOriginal) => {
- const csf = await importOriginal();
+vi.mock('@storybook/core/csf', async (importOriginal) => {
+ const csf = await importOriginal();
return {
...csf,
toId: vi.fn(csf.toId),
diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts
index 164613d7e4c2..ce669635457f 100644
--- a/code/core/src/core-server/utils/StoryIndexGenerator.ts
+++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path';
import { commonGlobOptions, normalizeStoryPath } from '@storybook/core/common';
+import { combineTags, storyNameFromExport, toId } from '@storybook/core/csf';
import type {
DocsIndexEntry,
DocsOptions,
@@ -17,7 +18,6 @@ import type {
StorybookConfigRaw,
Tag,
} from '@storybook/core/types';
-import { combineTags, storyNameFromExport, toId } from '@storybook/csf';
import { getStorySortParameter, loadConfig } from '@storybook/core/csf-tools';
import { logger, once } from '@storybook/core/node-logger';
diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts
index 3c8370e8955d..47f7a06e14e6 100644
--- a/code/core/src/core-server/utils/get-server-channel.ts
+++ b/code/core/src/core-server/utils/get-server-channel.ts
@@ -4,6 +4,8 @@ import { Channel, HEARTBEAT_INTERVAL } from '@storybook/core/channels';
import { isJSON, parse, stringify } from 'telejson';
import WebSocket, { WebSocketServer } from 'ws';
+import { UniversalStore } from '../../shared/universal-store';
+
type Server = NonNullable[0]>['server']>;
/**
@@ -72,7 +74,12 @@ export class ServerChannelTransport {
export function getServerChannel(server: Server) {
const transports = [new ServerChannelTransport(server)];
- return new Channel({ transports, async: true });
+ const channel = new Channel({ transports, async: true });
+
+ // eslint-disable-next-line no-underscore-dangle
+ UniversalStore.__prepare(channel, UniversalStore.Environment.SERVER);
+
+ return channel;
}
// for backwards compatibility
diff --git a/code/core/src/core-server/utils/save-story/save-story.ts b/code/core/src/core-server/utils/save-story/save-story.ts
index 9131107fdc0c..fd4bb5a82979 100644
--- a/code/core/src/core-server/utils/save-story/save-story.ts
+++ b/code/core/src/core-server/utils/save-story/save-story.ts
@@ -4,9 +4,9 @@ import { basename, join } from 'node:path';
import type { Channel } from '@storybook/core/channels';
import { formatFileContent } from '@storybook/core/common';
+import { storyNameFromExport, toId } from '@storybook/core/csf';
import { isExampleStoryId, telemetry } from '@storybook/core/telemetry';
import type { CoreConfig, Options } from '@storybook/core/types';
-import { storyNameFromExport, toId } from '@storybook/csf';
import type {
RequestData,
diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts
index 470d14ceb153..9245d9532890 100644
--- a/code/core/src/core-server/utils/server-statics.ts
+++ b/code/core/src/core-server/utils/server-statics.ts
@@ -1,4 +1,4 @@
-import { existsSync } from 'node:fs';
+import { existsSync, statSync } from 'node:fs';
import { basename, isAbsolute, posix, resolve, sep, win32 } from 'node:path';
import { getDirectoryFromWorkingDir } from '@storybook/core/common';
@@ -26,9 +26,25 @@ export async function useStatics(app: Polka.Polka, options: Options): Promise {
+ // Rewrite the URL to match the file's name, ensuring that we only ever serve the file
+ // even when sirv is passed the full directory
+ req.url = `/${staticPathFile}`;
+ sirvWorkaround(staticPathDir, {
+ dev: true,
+ etag: true,
+ extensions: [],
+ })(req, res, next);
+ });
+ return;
+ }
app.use(
targetEndpoint,
- sirv(staticPath, {
+ sirvWorkaround(staticPath, {
dev: true,
etag: true,
extensions: [],
@@ -43,7 +59,7 @@ export async function useStatics(app: Polka.Polka, options: Options): Promise
+ (req, res, next) => {
+ // polka+sirv will modify the request URL, so we need to restore it after sirv is done
+ // req._parsedUrl is an internal construct used by both polka and sirv
+ // eslint-disable-next-line no-underscore-dangle
+ const originalParsedUrl = (req as any)._parsedUrl;
+
+ const maybeNext = next
+ ? () => {
+ // eslint-disable-next-line no-underscore-dangle
+ (req as any)._parsedUrl = originalParsedUrl;
+ next();
+ }
+ : undefined;
+
+ sirv(...sirvArgs)(req, res, maybeNext);
+ };
+
export const parseStaticDir = (arg: string) => {
// Split on last index of ':', for Windows compatibility (e.g. 'C:\some\dir:\foo')
const lastColonIndex = arg.lastIndexOf(':');
diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts
index b953395c9abc..3465ef090f71 100644
--- a/code/core/src/csf-tools/CsfFile.ts
+++ b/code/core/src/csf-tools/CsfFile.ts
@@ -11,6 +11,7 @@ import {
types as t,
traverse,
} from '@storybook/core/babel';
+import { isExportStory, storyNameFromExport, toId } from '@storybook/core/csf';
import type {
ComponentAnnotations,
IndexInput,
@@ -19,7 +20,6 @@ import type {
StoryAnnotations,
Tag,
} from '@storybook/core/types';
-import { isExportStory, storyNameFromExport, toId } from '@storybook/csf';
import { dedent } from 'ts-dedent';
diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts
index c318faa1d29e..99ccf402bdb2 100644
--- a/code/core/src/csf-tools/vitest-plugin/transformer.ts
+++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts
@@ -3,8 +3,8 @@
/* eslint-disable no-underscore-dangle */
import { types as t } from '@storybook/core/babel';
import { getStoryTitle } from '@storybook/core/common';
+import { combineTags } from '@storybook/core/csf';
import type { StoriesEntry, Tag } from '@storybook/core/types';
-import { combineTags } from '@storybook/csf';
import { dedent } from 'ts-dedent';
diff --git a/code/core/src/csf/SBType.ts b/code/core/src/csf/SBType.ts
new file mode 100644
index 000000000000..193d50fd7a69
--- /dev/null
+++ b/code/core/src/csf/SBType.ts
@@ -0,0 +1,42 @@
+interface SBBaseType {
+ required?: boolean;
+ raw?: string;
+}
+
+export type SBScalarType = SBBaseType & {
+ name: 'boolean' | 'string' | 'number' | 'function' | 'symbol';
+};
+
+export type SBArrayType = SBBaseType & {
+ name: 'array';
+ value: SBType;
+};
+export type SBObjectType = SBBaseType & {
+ name: 'object';
+ value: Record;
+};
+export type SBEnumType = SBBaseType & {
+ name: 'enum';
+ value: (string | number)[];
+};
+export type SBIntersectionType = SBBaseType & {
+ name: 'intersection';
+ value: SBType[];
+};
+export type SBUnionType = SBBaseType & {
+ name: 'union';
+ value: SBType[];
+};
+export type SBOtherType = SBBaseType & {
+ name: 'other';
+ value: string;
+};
+
+export type SBType =
+ | SBScalarType
+ | SBEnumType
+ | SBArrayType
+ | SBObjectType
+ | SBIntersectionType
+ | SBUnionType
+ | SBOtherType;
diff --git a/code/core/src/csf/includeConditionalArg.test.ts b/code/core/src/csf/includeConditionalArg.test.ts
new file mode 100644
index 000000000000..56dd2b3110fa
--- /dev/null
+++ b/code/core/src/csf/includeConditionalArg.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from 'vitest';
+
+import { includeConditionalArg, testValue } from './includeConditionalArg';
+import type { Conditional } from './story';
+
+describe('testValue', () => {
+ describe('truthy', () => {
+ it.each([
+ ['implicit true', {}, true, true],
+ ['implicit truthy', {}, 1, true],
+ ['implicit falsey', {}, 0, false],
+ ['truthy true', { truthy: true }, true, true],
+ ['truthy truthy', { truthy: true }, 1, true],
+ ['truthy falsey', { truthy: true }, 0, false],
+ ['falsey true', { truthy: false }, true, false],
+ ['falsey truthy', { truthy: false }, 1, false],
+ ['falsey falsey', { truthy: false }, 0, true],
+ ])('%s', (_name, cond, value, expected) => {
+ expect(testValue(cond, value)).toBe(expected);
+ });
+ });
+
+ describe('exists', () => {
+ it.each([
+ ['exist', { exists: true }, 1, true],
+ ['exist false', { exists: true }, undefined, false],
+ ['nexist', { exists: false }, undefined, true],
+ ['nexist false', { exists: false }, 1, false],
+ ])('%s', (_name, cond, value, expected) => {
+ expect(testValue(cond, value)).toBe(expected);
+ });
+ });
+ describe('eq', () => {
+ it.each([
+ ['true', { eq: 1 }, 1, true],
+ ['false', { eq: 1 }, 2, false],
+ ['undefined', { eq: undefined }, undefined, false],
+ ['undefined false', { eq: 1 }, undefined, false],
+ ['object true', { eq: { x: 1 } }, { x: 1 }, true],
+ ['object false', { eq: { x: 1 } }, { x: 2 }, false],
+ ])('%s', (_name, cond, value, expected) => {
+ expect(testValue(cond, value)).toBe(expected);
+ });
+ });
+ describe('neq', () => {
+ it.each([
+ ['true', { neq: 1 }, 2, true],
+ ['false', { neq: 1 }, 1, false],
+ ['undefined true', { neq: 1 }, undefined, true],
+ ['undefined false', { neq: undefined }, undefined, false],
+ ['object true', { neq: { x: 1 } }, { x: 2 }, true],
+ ['object false', { neq: { x: 1 } }, { x: 1 }, false],
+ ])('%s', (_name, cond, value, expected) => {
+ expect(testValue(cond, value)).toBe(expected);
+ });
+ });
+});
+
+describe('includeConditionalArg', () => {
+ describe('errors', () => {
+ it('should throw if neither arg nor global is specified', () => {
+ expect(() =>
+ includeConditionalArg({ if: {} as Conditional }, {}, {})
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid conditional value {}]`);
+ });
+ it('should throw if arg and global are both specified', () => {
+ expect(() =>
+ includeConditionalArg({ if: { arg: 'a', global: 'b' } }, {}, {})
+ ).toThrowErrorMatchingInlineSnapshot(
+ `[Error: Invalid conditional value {"arg":"a","global":"b"}]`
+ );
+ });
+ it('should throw if multiple exists / eq / neq are specified', () => {
+ expect(() =>
+ includeConditionalArg({ if: { arg: 'a', exists: true, eq: 1 } }, {}, {})
+ ).toThrowErrorMatchingInlineSnapshot(
+ `[Error: Invalid conditional test {"exists":true,"eq":1}]`
+ );
+
+ expect(() =>
+ includeConditionalArg({ if: { arg: 'a', exists: false, neq: 0 } }, {}, {})
+ ).toThrowErrorMatchingInlineSnapshot(
+ `[Error: Invalid conditional test {"exists":false,"neq":0}]`
+ );
+
+ expect(() =>
+ includeConditionalArg({ if: { arg: 'a', eq: 1, neq: 0 } }, {}, {})
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid conditional test {"eq":1,"neq":0}]`);
+ });
+ });
+
+ describe('args', () => {
+ describe('implicit', () => {
+ it.each([
+ ['implicit true', { if: { arg: 'a' } }, { a: 1 }, {}, true],
+ ['truthy true', { if: { arg: 'a', truthy: true } }, { a: 0 }, {}, false],
+ ['truthy false', { if: { arg: 'a', truthy: false } }, {}, {}, true],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ describe('exists', () => {
+ it.each([
+ ['exist', { if: { arg: 'a', exists: true } }, { a: 1 }, {}, true],
+ ['exist false', { if: { arg: 'a', exists: true } }, {}, {}, false],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ describe('eq', () => {
+ it.each([
+ ['scalar true', { if: { arg: 'a', eq: 1 } }, { a: 1 }, {}, true],
+ ['scalar false', { if: { arg: 'a', eq: 1 } }, { a: 2 }, { a: 1 }, false],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ describe('neq', () => {
+ it.each([
+ ['scalar true', { if: { arg: 'a', neq: 1 } }, { a: 2 }, {}, true],
+ ['scalar false', { if: { arg: 'a', neq: 1 } }, { a: 1 }, { a: 2 }, false],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ });
+ describe('globals', () => {
+ describe('truthy', () => {
+ it.each([
+ ['implicit true', { if: { global: 'a' } }, {}, { a: 1 }, true],
+ ['implicit undefined', { if: { global: 'a' } }, {}, {}, false],
+ ['truthy true', { if: { global: 'a', truthy: true } }, {}, { a: 0 }, false],
+ ['truthy false', { if: { global: 'a', truthy: false } }, {}, { a: 0 }, true],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ describe('exists', () => {
+ it.each([
+ ['implicit exist true', { if: { global: 'a', exists: true } }, {}, { a: 1 }, true],
+ ['implicit exist false', { if: { global: 'a', exists: true } }, { a: 1 }, {}, false],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ describe('eq', () => {
+ it.each([
+ ['scalar true', { if: { global: 'a', eq: 1 } }, {}, { a: 1 }, true],
+ ['scalar false', { if: { global: 'a', eq: 1 } }, { a: 2 }, { a: 2 }, false],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ describe('neq', () => {
+ it.each([
+ ['scalar true', { if: { global: 'a', neq: 1 } }, {}, { a: 2 }, true],
+ ['scalar false', { if: { global: 'a', neq: 1 } }, { a: 2 }, { a: 1 }, false],
+ ])('%s', (_name, argType, args, globals, expected) => {
+ expect(includeConditionalArg(argType, args, globals)).toBe(expected);
+ });
+ });
+ });
+});
diff --git a/code/core/src/csf/includeConditionalArg.ts b/code/core/src/csf/includeConditionalArg.ts
new file mode 100644
index 000000000000..0c48c4db7fea
--- /dev/null
+++ b/code/core/src/csf/includeConditionalArg.ts
@@ -0,0 +1,44 @@
+/* @ts-expect-error (has no typings) */
+import { isEqual } from '@ngard/tiny-isequal';
+
+import type { Args, Conditional, Globals, InputType } from './story';
+
+const count = (vals: any[]) => vals.map((v) => typeof v !== 'undefined').filter(Boolean).length;
+
+export const testValue = (cond: Omit, value: any) => {
+ const { exists, eq, neq, truthy } = cond as any;
+ if (count([exists, eq, neq, truthy]) > 1) {
+ throw new Error(`Invalid conditional test ${JSON.stringify({ exists, eq, neq })}`);
+ }
+ if (typeof eq !== 'undefined') {
+ return isEqual(value, eq);
+ }
+ if (typeof neq !== 'undefined') {
+ return !isEqual(value, neq);
+ }
+ if (typeof exists !== 'undefined') {
+ const valueExists = typeof value !== 'undefined';
+ return exists ? valueExists : !valueExists;
+ }
+ const shouldBeTruthy = typeof truthy === 'undefined' ? true : truthy;
+ return shouldBeTruthy ? !!value : !value;
+};
+
+/**
+ * Helper function to include/exclude an arg based on the value of other other args aka "conditional
+ * args"
+ */
+export const includeConditionalArg = (argType: InputType, args: Args, globals: Globals) => {
+ if (!argType.if) {
+ return true;
+ }
+
+ const { arg, global } = argType.if as any;
+ if (count([arg, global]) !== 1) {
+ throw new Error(`Invalid conditional value ${JSON.stringify({ arg, global })}`);
+ }
+
+ const value = arg ? args[arg] : globals[global];
+
+ return testValue(argType.if!, value);
+};
diff --git a/code/core/src/csf/index.test.ts b/code/core/src/csf/index.test.ts
new file mode 100644
index 000000000000..3a652b8f2e47
--- /dev/null
+++ b/code/core/src/csf/index.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest';
+
+import { combineTags, isExportStory, storyNameFromExport, toId } from './index';
+
+describe('toId', () => {
+ const testCases: [string, string, string | undefined, string][] = [
+ // name, kind, story, output
+ ['handles simple cases', 'kind', 'story', 'kind--story'],
+ ['handles kind without story', 'kind', undefined, 'kind'],
+ ['handles basic substitution', 'a b$c?d😀e', '1-2:3', 'a-b-c-d😀e--1-2-3'],
+ ['handles runs of non-url chars', 'a?&*b', 'story', 'a-b--story'],
+ ['removes non-url chars from start and end', '?ab-', 'story', 'ab--story'],
+ ['downcases', 'KIND', 'STORY', 'kind--story'],
+ ['non-latin', 'Кнопки', 'нормальный', 'кнопки--нормальный'],
+ ['korean', 'kind', '바보 (babo)', 'kind--바보-babo'],
+ ['all punctuation', 'kind', 'unicorns,’–—―′¿`"<>()!.!!!{}[]%^&$*#&', 'kind--unicorns'],
+ ];
+
+ testCases.forEach(([name, kind, story, output]) => {
+ it(name, () => {
+ expect(toId(kind, story)).toBe(output);
+ });
+ });
+
+ it('does not allow kind with *no* url chars', () => {
+ expect(() => toId('?', 'asdf')).toThrow(
+ `Invalid kind '?', must include alphanumeric characters`
+ );
+ });
+
+ it('does not allow empty kind', () => {
+ expect(() => toId('', 'asdf')).toThrow(`Invalid kind '', must include alphanumeric characters`);
+ });
+
+ it('does not allow story with *no* url chars', () => {
+ expect(() => toId('kind', '?')).toThrow(
+ `Invalid name '?', must include alphanumeric characters`
+ );
+ });
+
+ it('allows empty story', () => {
+ expect(() => toId('kind', '')).not.toThrow();
+ });
+});
+
+describe('storyNameFromExport', () => {
+ it('should format CSF exports with sensible defaults', () => {
+ const testCases = {
+ name: 'Name',
+ someName: 'Some Name',
+ someNAME: 'Some NAME',
+ some_custom_NAME: 'Some Custom NAME',
+ someName1234: 'Some Name 1234',
+ someName1_2_3_4: 'Some Name 1 2 3 4',
+ };
+ Object.entries(testCases).forEach(([key, val]) => expect(storyNameFromExport(key)).toBe(val));
+ });
+});
+
+describe('isExportStory', () => {
+ it('should exclude __esModule', () => {
+ expect(isExportStory('__esModule', {})).toBeFalsy();
+ });
+
+ it('should include all stories when there are no filters', () => {
+ expect(isExportStory('a', {})).toBeTruthy();
+ });
+
+ it('should filter stories by arrays', () => {
+ expect(isExportStory('a', { includeStories: ['a'] })).toBeTruthy();
+ expect(isExportStory('a', { includeStories: [] })).toBeFalsy();
+ expect(isExportStory('a', { includeStories: ['b'] })).toBeFalsy();
+
+ expect(isExportStory('a', { excludeStories: ['a'] })).toBeFalsy();
+ expect(isExportStory('a', { excludeStories: [] })).toBeTruthy();
+ expect(isExportStory('a', { excludeStories: ['b'] })).toBeTruthy();
+
+ expect(isExportStory('a', { includeStories: ['a'], excludeStories: ['a'] })).toBeFalsy();
+ expect(isExportStory('a', { includeStories: [], excludeStories: [] })).toBeFalsy();
+ expect(isExportStory('a', { includeStories: ['a'], excludeStories: ['b'] })).toBeTruthy();
+ });
+
+ it('should filter stories by regex', () => {
+ expect(isExportStory('a', { includeStories: /a/ })).toBeTruthy();
+ expect(isExportStory('a', { includeStories: /.*/ })).toBeTruthy();
+ expect(isExportStory('a', { includeStories: /b/ })).toBeFalsy();
+
+ expect(isExportStory('a', { excludeStories: /a/ })).toBeFalsy();
+ expect(isExportStory('a', { excludeStories: /.*/ })).toBeFalsy();
+ expect(isExportStory('a', { excludeStories: /b/ })).toBeTruthy();
+
+ expect(isExportStory('a', { includeStories: /a/, excludeStories: ['a'] })).toBeFalsy();
+ expect(isExportStory('a', { includeStories: /.*/, excludeStories: /.*/ })).toBeFalsy();
+ expect(isExportStory('a', { includeStories: /a/, excludeStories: /b/ })).toBeTruthy();
+ });
+});
+
+describe('combineTags', () => {
+ it.each([
+ [[], []],
+ [
+ ['a', 'b'],
+ ['a', 'b'],
+ ],
+ [
+ ['a', 'b', 'b'],
+ ['a', 'b'],
+ ],
+ [['a', 'b', '!b'], ['a']],
+ [['b', '!b', 'b'], ['b']],
+ ])('combineTags(%o) -> %o', (tags, expected) => {
+ expect(combineTags(...tags)).toEqual(expected);
+ });
+});
diff --git a/code/core/src/csf/index.ts b/code/core/src/csf/index.ts
new file mode 100644
index 000000000000..4c8bc2b44a01
--- /dev/null
+++ b/code/core/src/csf/index.ts
@@ -0,0 +1,90 @@
+import { toStartCaseStr } from './toStartCaseStr';
+
+/**
+ * Remove punctuation and illegal characters from a story ID.
+ *
+ * See https://gist.github.com/davidjrice/9d2af51100e41c6c4b4a
+ */
+export const sanitize = (string: string) => {
+ return string
+ .toLowerCase()
+
+ .replace(/[ ’–—―′¿'`~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/gi, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-+/, '')
+ .replace(/-+$/, '');
+};
+
+const sanitizeSafe = (string: string, part: string) => {
+ const sanitized = sanitize(string);
+ if (sanitized === '') {
+ throw new Error(`Invalid ${part} '${string}', must include alphanumeric characters`);
+ }
+ return sanitized;
+};
+
+/** Generate a storybook ID from a component/kind and story name. */
+export const toId = (kind: string, name?: string) =>
+ `${sanitizeSafe(kind, 'kind')}${name ? `--${sanitizeSafe(name, 'name')}` : ''}`;
+
+/** Transform a CSF named export into a readable story name */
+export const storyNameFromExport = (key: string) => toStartCaseStr(key);
+
+type StoryDescriptor = string[] | RegExp;
+export interface IncludeExcludeOptions {
+ includeStories?: StoryDescriptor;
+ excludeStories?: StoryDescriptor;
+}
+
+function matches(storyKey: string, arrayOrRegex: StoryDescriptor) {
+ if (Array.isArray(arrayOrRegex)) {
+ return arrayOrRegex.includes(storyKey);
+ }
+ return storyKey.match(arrayOrRegex);
+}
+
+/** Does a named export match CSF inclusion/exclusion options? */
+export function isExportStory(
+ key: string,
+ { includeStories, excludeStories }: IncludeExcludeOptions
+) {
+ return (
+ // https://babeljs.io/docs/en/babel-plugin-transform-modules-commonjs
+ key !== '__esModule' &&
+ (!includeStories || matches(key, includeStories)) &&
+ (!excludeStories || !matches(key, excludeStories))
+ );
+}
+
+export interface SeparatorOptions {
+ rootSeparator: string | RegExp;
+ groupSeparator: string | RegExp;
+}
+
+/** Parse out the component/kind name from a path, using the given separator config. */
+export const parseKind = (kind: string, { rootSeparator, groupSeparator }: SeparatorOptions) => {
+ const [root, remainder] = kind.split(rootSeparator, 2);
+ const groups = (remainder || kind).split(groupSeparator).filter((i) => !!i);
+
+ // when there's no remainder, it means the root wasn't found/split
+ return {
+ root: remainder ? root : null,
+ groups,
+ };
+};
+
+/** Combine a set of project / meta / story tags, removing duplicates and handling negations. */
+export const combineTags = (...tags: string[]): string[] => {
+ const result = tags.reduce((acc, tag) => {
+ if (tag.startsWith('!')) {
+ acc.delete(tag.slice(1));
+ } else {
+ acc.add(tag);
+ }
+ return acc;
+ }, new Set());
+ return Array.from(result);
+};
+
+export { includeConditionalArg } from './includeConditionalArg';
+export * from './story';
diff --git a/code/core/src/csf/story.test.ts b/code/core/src/csf/story.test.ts
new file mode 100644
index 000000000000..a12792efba4f
--- /dev/null
+++ b/code/core/src/csf/story.test.ts
@@ -0,0 +1,206 @@
+/* global HTMLElement */
+import { test } from 'vitest';
+
+import { expectTypeOf } from 'expect-type';
+
+import type {
+ Args,
+ ArgsFromMeta,
+ ArgsStoryFn,
+ ComponentAnnotations,
+ DecoratorFunction,
+ LoaderFunction,
+ ProjectAnnotations,
+ Renderer,
+ StoryAnnotationsOrFn,
+ StrictArgs,
+} from './story';
+
+// NOTE Example of internal type definition for @storybook/ (where X is a renderer)
+interface XRenderer extends Renderer {
+ component: (args: this['T']) => string;
+ storyResult: string;
+ canvasElement: HTMLElement;
+}
+
+type XMeta = ComponentAnnotations;
+type XStory = StoryAnnotationsOrFn;
+
+// NOTE Examples of using types from @storybook/ in real project
+
+type ButtonArgs = {
+ x: string;
+ y: string;
+};
+
+const Button = (props: ButtonArgs) => 'Button';
+
+let a = 1;
+async function doSomething() {
+ a = 2;
+}
+
+async function validateSomething() {}
+
+async function cleanup() {
+ a = 1;
+}
+
+// NOTE Various kind usages
+const simple: XMeta = {
+ title: 'simple',
+ component: Button,
+ tags: ['foo', 'bar'],
+ decorators: [(storyFn, context) => `withDecorator(${storyFn(context)})`],
+ parameters: { a: () => null, b: NaN, c: Symbol('symbol') },
+ loaders: [() => Promise.resolve({ d: '3' })],
+ async beforeEach() {
+ await doSomething();
+ return cleanup;
+ },
+ async experimental_afterEach() {
+ await validateSomething();
+ },
+ args: { x: '1' },
+ argTypes: { x: { type: { name: 'string' } } },
+};
+
+const strict: XMeta = {
+ title: 'simple',
+ component: Button,
+ tags: ['foo', 'bar'],
+ decorators: [(storyFn, context) => `withDecorator(${storyFn(context)})`],
+ parameters: { a: () => null, b: NaN, c: Symbol('symbol') },
+ loaders: [() => Promise.resolve({ d: '3' })],
+ args: { x: '1' },
+ async beforeEach() {
+ await doSomething();
+ return cleanup;
+ },
+ async experimental_afterEach() {
+ await validateSomething();
+ },
+ argTypes: { x: { type: { name: 'string' } } },
+};
+
+const options = ['foo', 'bar'] as const;
+const simpleWithReadonlyOptions: XMeta = {
+ title: 'simple',
+ component: Button,
+ tags: ['foo', 'bar'],
+ decorators: [(storyFn, context) => `withDecorator(${storyFn(context)})`],
+ parameters: { a: () => null, b: NaN, c: Symbol('symbol') },
+ loaders: [() => Promise.resolve({ d: '3' })],
+ args: { x: '1' },
+ argTypes: {
+ x: {
+ control: {
+ type: 'select',
+ },
+ options,
+ },
+ },
+};
+
+// NOTE Various story usages
+const Simple: XStory = () => 'Simple';
+
+const CSF1Story: XStory = () => 'Named Story';
+CSF1Story.story = {
+ name: 'Another name for story',
+ tags: ['foo', 'bar'],
+ decorators: [(storyFn) => `Wrapped(${storyFn()}`],
+ parameters: { a: [1, '2', {}], b: undefined, c: Button },
+ loaders: [() => Promise.resolve({ d: '3' })],
+ async beforeEach() {
+ await doSomething();
+ return cleanup;
+ },
+ args: { a: 1 },
+};
+
+const CSF2Story: XStory = () => 'Named Story';
+CSF2Story.storyName = 'Another name for story';
+CSF2Story.tags = ['foo', 'bar'];
+CSF2Story.decorators = [(storyFn) => `Wrapped(${storyFn()}`];
+CSF2Story.parameters = { a: [1, '2', {}], b: undefined, c: Button };
+CSF2Story.loaders = [() => Promise.resolve({ d: '3' })];
+CSF2Story.args = { a: 1 };
+
+const CSF3Story: XStory = {
+ render: (args) => 'Named Story',
+ name: 'Another name for story',
+ tags: ['foo', 'bar'],
+ decorators: [(storyFn) => `Wrapped(${storyFn()}`],
+ parameters: { a: [1, '2', {}], b: undefined, c: Button },
+ loaders: [() => Promise.resolve({ d: '3' })],
+ args: { a: 1 },
+};
+
+const CSF3StoryStrict: XStory = {
+ render: (args) => 'Named Story',
+ name: 'Another name for story',
+ tags: ['foo', 'bar'],
+ decorators: [(storyFn) => `Wrapped(${storyFn()}`],
+ parameters: { a: [1, '2', {}], b: undefined, c: Button },
+ loaders: [() => Promise.resolve({ d: '3' })],
+ args: { x: '1' },
+ play: async ({ step, canvasElement }) => {
+ await step('a step', async ({ step: substep }) => {
+ await substep('a substep', () => {});
+ });
+ },
+};
+
+const project: ProjectAnnotations = {
+ async runStep(label, play, context) {
+ return play(context);
+ },
+};
+
+test('ArgsFromMeta will infer correct args from render/loader/decorators', () => {
+ const decorator1: DecoratorFunction = (Story, { args }) =>
+ `${args.decoratorArg}`;
+
+ const decorator2: DecoratorFunction = (Story, { args }) =>
+ `${args.decoratorArg2}`;
+
+ const decorator3: DecoratorFunction = (Story, { args }) => ``;
+
+ const decorator4: DecoratorFunction = (Story, { args }) => ``;
+
+ const loader: LoaderFunction = async ({ args }) => ({
+ loader: `${args.loaderArg}`,
+ });
+
+ const loader2: LoaderFunction = async ({ args }) => ({
+ loader2: `${args.loaderArg2}`,
+ });
+
+ const renderer: ArgsStoryFn = (args) => `${args.theme}`;
+
+ const meta = {
+ component: Button,
+ args: { disabled: false },
+ render: renderer,
+ decorators: [decorator1, decorator2, decorator3, decorator4],
+ loaders: [loader, loader2],
+ };
+ expectTypeOf>().toEqualTypeOf<{
+ theme: string;
+ decoratorArg: string;
+ decoratorArg2: string;
+ loaderArg: number;
+ loaderArg2: number;
+ }>();
+});
+
+test('You can assign a component to Meta, even when you pass a top type', () => {
+ expectTypeOf({ component: Button }).toMatchTypeOf();
+ expectTypeOf({ component: Button }).toMatchTypeOf>>();
+ expectTypeOf({ component: Button }).toMatchTypeOf>>();
+ expectTypeOf({ component: Button }).toMatchTypeOf>();
+ expectTypeOf({ component: Button }).toMatchTypeOf>();
+ expectTypeOf({ component: Button }).not.toMatchTypeOf>();
+ expectTypeOf({ component: Button }).not.toMatchTypeOf>();
+});
diff --git a/code/core/src/csf/story.ts b/code/core/src/csf/story.ts
new file mode 100644
index 000000000000..9254eaf3c2f8
--- /dev/null
+++ b/code/core/src/csf/story.ts
@@ -0,0 +1,565 @@
+import type { RemoveIndexSignature, Simplify, UnionToIntersection } from 'type-fest';
+
+import type { SBScalarType, SBType } from './SBType';
+
+export * from './SBType';
+export type StoryId = string;
+export type ComponentId = string;
+export type ComponentTitle = string;
+export type StoryName = string;
+
+/** @deprecated */
+export type StoryKind = ComponentTitle;
+
+export type Tag = string;
+
+export interface StoryIdentifier {
+ componentId: ComponentId;
+ title: ComponentTitle;
+ /** @deprecated */
+ kind: ComponentTitle;
+
+ id: StoryId;
+ name: StoryName;
+ /** @deprecated */
+ story: StoryName;
+
+ tags: Tag[];
+}
+
+export interface Parameters {
+ [name: string]: any;
+}
+
+export interface StrictParameters {
+ [name: string]: unknown;
+}
+
+type ControlType =
+ | 'object'
+ | 'boolean'
+ | 'check'
+ | 'inline-check'
+ | 'radio'
+ | 'inline-radio'
+ | 'select'
+ | 'multi-select'
+ | 'number'
+ | 'range'
+ | 'file'
+ | 'color'
+ | 'date'
+ | 'text';
+
+type ConditionalTest = { truthy?: boolean } | { exists: boolean } | { eq: any } | { neq: any };
+type ConditionalValue = { arg: string } | { global: string };
+export type Conditional = ConditionalValue & ConditionalTest;
+
+interface ControlBase {
+ [key: string]: any;
+ /** @see https://storybook.js.org/docs/api/arg-types#controltype */
+ type?: ControlType;
+ disable?: boolean;
+}
+
+interface Report {
+ type: string;
+ version?: number;
+ result: unknown;
+ status: 'failed' | 'passed' | 'warning';
+}
+
+interface ReportingAPI {
+ reports: Report[];
+ addReport: (report: Report) => void;
+}
+
+type Control =
+ | ControlType
+ | false
+ | (ControlBase &
+ (
+ | ControlBase
+ | {
+ type: 'color';
+ /** @see https://storybook.js.org/docs/api/arg-types#controlpresetcolors */
+ presetColors?: string[];
+ }
+ | {
+ type: 'file';
+ /** @see https://storybook.js.org/docs/api/arg-types#controlaccept */
+ accept?: string;
+ }
+ | {
+ type: 'inline-check' | 'radio' | 'inline-radio' | 'select' | 'multi-select';
+ /** @see https://storybook.js.org/docs/api/arg-types#controllabels */
+ labels?: { [options: string]: string };
+ }
+ | {
+ type: 'number' | 'range';
+ /** @see https://storybook.js.org/docs/api/arg-types#controlmax */
+ max?: number;
+ /** @see https://storybook.js.org/docs/api/arg-types#controlmin */
+ min?: number;
+ /** @see https://storybook.js.org/docs/api/arg-types#controlstep */
+ step?: number;
+ }
+ ));
+
+export interface InputType {
+ /** @see https://storybook.js.org/docs/api/arg-types#control */
+ control?: Control;
+ /** @see https://storybook.js.org/docs/api/arg-types#description */
+ description?: string;
+ /** @see https://storybook.js.org/docs/api/arg-types#if */
+ if?: Conditional;
+ /** @see https://storybook.js.org/docs/api/arg-types#mapping */
+ mapping?: { [key: string]: any };
+ /** @see https://storybook.js.org/docs/api/arg-types#name */
+ name?: string;
+ /** @see https://storybook.js.org/docs/api/arg-types#options */
+ options?: readonly any[];
+ /** @see https://storybook.js.org/docs/api/arg-types#table */
+ table?: {
+ [key: string]: unknown;
+ /** @see https://storybook.js.org/docs/api/arg-types#tablecategory */
+ category?: string;
+ /** @see https://storybook.js.org/docs/api/arg-types#tabledefaultvalue */
+ defaultValue?: { summary?: string; detail?: string };
+ /** @see https://storybook.js.org/docs/api/arg-types#tabledisable */
+ disable?: boolean;
+ /** @see https://storybook.js.org/docs/api/arg-types#tablesubcategory */
+ subcategory?: string;
+ /** @see https://storybook.js.org/docs/api/arg-types#tabletype */
+ type?: { summary?: string; detail?: string };
+ };
+ /** @see https://storybook.js.org/docs/api/arg-types#type */
+ type?: SBType | SBScalarType['name'];
+ /**
+ * @deprecated Use `table.defaultValue.summary` instead.
+ * @see https://storybook.js.org/docs/api/arg-types#defaultvalue
+ */
+ defaultValue?: any;
+ [key: string]: any;
+}
+
+export interface StrictInputType extends InputType {
+ name: string;
+ type?: SBType;
+}
+
+export interface Args {
+ [name: string]: any;
+}
+
+export interface StrictArgs {
+ [name: string]: unknown;
+}
+
+/** @see https://storybook.js.org/docs/api/arg-types#argtypes */
+export type ArgTypes = { [name in keyof TArgs]: InputType };
+export type StrictArgTypes = { [name in keyof TArgs]: StrictInputType };
+
+export interface Globals {
+ [name: string]: any;
+}
+export interface GlobalTypes {
+ [name: string]: InputType;
+}
+export interface StrictGlobalTypes {
+ [name: string]: StrictInputType;
+}
+
+export interface Renderer {
+ /** What is the type of the `component` annotation in this renderer? */
+ component: any;
+
+ /** What does the story function return in this renderer? */
+ storyResult: any;
+
+ /** What type of element does this renderer render to? */
+ canvasElement: any;
+
+ mount(): Promise