diff --git a/examples/react-ts/main.ts b/examples/react-ts/main.ts index 677f7382cd01..e3077e25ebb7 100644 --- a/examples/react-ts/main.ts +++ b/examples/react-ts/main.ts @@ -1,7 +1,7 @@ import type { StorybookConfig } from '@storybook/react/types'; const config: StorybookConfig = { - stories: ['./src/*.stories.*'], + stories: [{ directory: './src', titlePrefix: 'Demo' }], logLevel: 'debug', addons: [ '@storybook/addon-essentials', diff --git a/examples/react-ts/src/AccountForm.stories.tsx b/examples/react-ts/src/AccountForm.stories.tsx index 11291fec1601..1ba866a24769 100644 --- a/examples/react-ts/src/AccountForm.stories.tsx +++ b/examples/react-ts/src/AccountForm.stories.tsx @@ -5,7 +5,6 @@ import userEvent from '@testing-library/user-event'; import { AccountForm, AccountFormProps } from './AccountForm'; export default { - title: 'Demo/AccountForm', component: AccountForm, parameters: { layout: 'centered', diff --git a/lib/builder-webpack4/src/index.ts b/lib/builder-webpack4/src/index.ts index c3e6a698c561..7051fbd363bd 100644 --- a/lib/builder-webpack4/src/index.ts +++ b/lib/builder-webpack4/src/index.ts @@ -9,6 +9,7 @@ import { useProgressReporting, checkWebpackVersion, Options, + normalizeStories, } from '@storybook/core-common'; let compilation: ReturnType; @@ -32,7 +33,7 @@ export const getConfig: WebpackBuilder['getConfig'] = async (options) => { const typescriptOptions = await presets.apply('typescript', {}, options); const babelOptions = await presets.apply('babel', {}, { ...options, typescriptOptions }); const entries = await presets.apply('entries', [], options); - const stories = await presets.apply('stories', [], options); + const stories = normalizeStories(await presets.apply('stories', [], options), options.configDir); const frameworkOptions = await presets.apply(`${options.framework}Options`, {}, options); return presets.apply( diff --git a/lib/builder-webpack4/src/preview/entries.ts b/lib/builder-webpack4/src/preview/entries.ts index fa81ba266e16..dd8b64795e2d 100644 --- a/lib/builder-webpack4/src/preview/entries.ts +++ b/lib/builder-webpack4/src/preview/entries.ts @@ -3,7 +3,7 @@ import { logger } from '@storybook/node-logger'; import stable from 'stable'; import dedent from 'ts-dedent'; import glob from 'glob-promise'; -import { loadPreviewOrConfigFile } from '@storybook/core-common'; +import { loadPreviewOrConfigFile, normalizeStories } from '@storybook/core-common'; export const sortEntries = (entries: string[]) => { const isGenerated = /generated-(config|other)-entry/; @@ -41,7 +41,7 @@ export async function createPreviewEntry(options: { configDir: string; presets: const configs = getMainConfigs(options); const other: string[] = await presets.apply('config', [], options); - const stories: string[] = await presets.apply('stories', [], options); + const stories = normalizeStories(await presets.apply('stories', [], options), configDir); if (configs.length > 0) { const noun = configs.length === 1 ? 'file' : 'files'; @@ -58,15 +58,16 @@ export async function createPreviewEntry(options: { configDir: string; presets: if (stories && stories.length) { entries.push(path.resolve(path.join(configDir, `generated-stories-entry.js`))); + const globs = stories.map((s) => s.glob); const files = ( - await Promise.all(stories.map((g) => glob(path.isAbsolute(g) ? g : path.join(configDir, g)))) + await Promise.all(globs.map((g) => glob(path.isAbsolute(g) ? g : path.join(configDir, g)))) ).reduce((a, b) => a.concat(b)); if (files.length === 0) { logger.warn(dedent` We found no files matching any of the following globs: - ${stories.join('\n')} + ${globs.join('\n')} Maybe your glob was invalid? see: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#correct-globs-in-mainjs diff --git a/lib/builder-webpack4/src/preview/iframe-webpack.config.ts b/lib/builder-webpack4/src/preview/iframe-webpack.config.ts index 5748972b6949..f631598bfe23 100644 --- a/lib/builder-webpack4/src/preview/iframe-webpack.config.ts +++ b/lib/builder-webpack4/src/preview/iframe-webpack.config.ts @@ -23,6 +23,7 @@ import { interpolate, nodeModulesPaths, Options, + NormalizedStoriesEntry, } from '@storybook/core-common'; import { createBabelLoader } from './babel-loader-preview'; @@ -109,9 +110,16 @@ export default async ({ }); if (stories) { const storiesFilename = path.resolve(path.join(configDir, `generated-stories-entry.js`)); - virtualModuleMapping[storiesFilename] = interpolate(storyTemplate, { frameworkImportPath }) - // Make sure we also replace quotes for this one - .replace("'{{stories}}'", stories.map(toRequireContextString).join(',')); + // Make sure we also replace quotes for this one + virtualModuleMapping[storiesFilename] = interpolate(storyTemplate, { + frameworkImportPath, + }).replace( + "'{{stories}}'", + stories + .map((s: NormalizedStoriesEntry) => s.glob) + .map(toRequireContextString) + .join(',') + ); } const shouldCheckTs = useBaseTsSupport(framework) && typescriptOptions.check; @@ -155,6 +163,7 @@ export default async ({ LOGLEVEL: logLevel, FRAMEWORK_OPTIONS: frameworkOptions, FEATURES: features, + STORIES: stories, }, headHtmlSnippet, bodyHtmlSnippet, @@ -220,6 +229,7 @@ export default async ({ runtimeChunk: true, sideEffects: true, usedExports: true, + moduleIds: 'named', minimizer: isProd ? [ new TerserWebpackPlugin({ diff --git a/lib/builder-webpack5/src/index.ts b/lib/builder-webpack5/src/index.ts index 9c19be3b15e8..b21b64dd9ff0 100644 --- a/lib/builder-webpack5/src/index.ts +++ b/lib/builder-webpack5/src/index.ts @@ -7,6 +7,8 @@ import { useProgressReporting, checkWebpackVersion, Options, + normalizeStories, + StoriesEntry, } from '@storybook/core-common'; let compilation: ReturnType; @@ -19,7 +21,10 @@ export const getConfig: WebpackBuilder['getConfig'] = async (options) => { const typescriptOptions = await presets.apply('typescript', {}, options); const babelOptions = await presets.apply('babel', {}, { ...options, typescriptOptions }); const entries = await presets.apply('entries', [], options); - const stories = await presets.apply('stories', [], options); + const stories = normalizeStories( + (await presets.apply('stories', [], options)) as StoriesEntry[], + options.configDir + ); const frameworkOptions = await presets.apply(`${options.framework}Options`, {}, options); return presets.apply( diff --git a/lib/builder-webpack5/src/preview/entries.ts b/lib/builder-webpack5/src/preview/entries.ts index fa81ba266e16..f47d810b3f3c 100644 --- a/lib/builder-webpack5/src/preview/entries.ts +++ b/lib/builder-webpack5/src/preview/entries.ts @@ -3,7 +3,7 @@ import { logger } from '@storybook/node-logger'; import stable from 'stable'; import dedent from 'ts-dedent'; import glob from 'glob-promise'; -import { loadPreviewOrConfigFile } from '@storybook/core-common'; +import { loadPreviewOrConfigFile, normalizeStories } from '@storybook/core-common'; export const sortEntries = (entries: string[]) => { const isGenerated = /generated-(config|other)-entry/; @@ -41,7 +41,7 @@ export async function createPreviewEntry(options: { configDir: string; presets: const configs = getMainConfigs(options); const other: string[] = await presets.apply('config', [], options); - const stories: string[] = await presets.apply('stories', [], options); + const stories = normalizeStories(await presets.apply('stories', [], options), options.configDir); if (configs.length > 0) { const noun = configs.length === 1 ? 'file' : 'files'; @@ -58,15 +58,16 @@ export async function createPreviewEntry(options: { configDir: string; presets: if (stories && stories.length) { entries.push(path.resolve(path.join(configDir, `generated-stories-entry.js`))); + const globs = stories.map((s) => s.glob); const files = ( - await Promise.all(stories.map((g) => glob(path.isAbsolute(g) ? g : path.join(configDir, g)))) + await Promise.all(globs.map((g) => glob(path.isAbsolute(g) ? g : path.join(configDir, g)))) ).reduce((a, b) => a.concat(b)); if (files.length === 0) { logger.warn(dedent` We found no files matching any of the following globs: - ${stories.join('\n')} + ${globs.join('\n')} Maybe your glob was invalid? see: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#correct-globs-in-mainjs diff --git a/lib/builder-webpack5/src/preview/iframe-webpack.config.ts b/lib/builder-webpack5/src/preview/iframe-webpack.config.ts index abc20147766d..2288cc4f9a4e 100644 --- a/lib/builder-webpack5/src/preview/iframe-webpack.config.ts +++ b/lib/builder-webpack5/src/preview/iframe-webpack.config.ts @@ -19,6 +19,7 @@ import { interpolate, Options, hasDotenv, + NormalizedStoriesEntry, } from '@storybook/core-common'; import { createBabelLoader } from './babel-loader-preview'; @@ -107,7 +108,13 @@ export default async ({ const storiesFilename = path.resolve(path.join(configDir, `generated-stories-entry.js`)); virtualModuleMapping[storiesFilename] = interpolate(storyTemplate, { frameworkImportPath }) // Make sure we also replace quotes for this one - .replace("'{{stories}}'", stories.map(toRequireContextString).join(',')); + .replace( + "'{{stories}}'", + stories + .map((s: NormalizedStoriesEntry) => s.glob) + .map(toRequireContextString) + .join(',') + ); } const shouldCheckTs = useBaseTsSupport(framework) && typescriptOptions.check; @@ -156,6 +163,7 @@ export default async ({ LOGLEVEL: logLevel, FRAMEWORK_OPTIONS: frameworkOptions, FEATURES: features, + STORIES: stories, }, headHtmlSnippet, bodyHtmlSnippet, @@ -210,6 +218,7 @@ export default async ({ runtimeChunk: true, sideEffects: true, usedExports: true, + moduleIds: 'named', minimizer: isProd ? [ new TerserWebpackPlugin({ diff --git a/lib/core-client/src/preview/autoTitle.test.ts b/lib/core-client/src/preview/autoTitle.test.ts new file mode 100644 index 000000000000..3f64db00d69e --- /dev/null +++ b/lib/core-client/src/preview/autoTitle.test.ts @@ -0,0 +1,65 @@ +import { autoTitleFromEntry as auto } from './autoTitle'; + +expect.addSnapshotSerializer({ + print: (val: any) => val, + test: (val) => true, +}); + +describe('autoTitle', () => { + it('no directory', () => { + expect(auto('/path/to/file', { glob: '' })).toBeFalsy(); + }); + + it('no match', () => { + expect(auto('/path/to/file', { glob: '', specifier: { directory: '/other' } })).toBeFalsy(); + }); + + describe('no trailing slash', () => { + it('match with no titlePrefix', () => { + expect( + auto('/path/to/file', { glob: '', specifier: { directory: '/path' } }) + ).toMatchInlineSnapshot(`to/file`); + }); + + it('match with titlePrefix', () => { + expect( + auto('/path/to/file', { glob: '', specifier: { directory: '/path', titlePrefix: 'atoms' } }) + ).toMatchInlineSnapshot(`atoms/to/file`); + }); + + it('match with extension', () => { + expect( + auto('/path/to/file.stories.tsx', { + glob: '', + specifier: { directory: '/path', titlePrefix: 'atoms' }, + }) + ).toMatchInlineSnapshot(`atoms/to/file`); + }); + }); + + describe('trailing slash', () => { + it('match with no titlePrefix', () => { + expect( + auto('/path/to/file', { glob: '', specifier: { directory: '/path/' } }) + ).toMatchInlineSnapshot(`to/file`); + }); + + it('match with titlePrefix', () => { + expect( + auto('/path/to/file', { + glob: '', + specifier: { directory: '/path/', titlePrefix: 'atoms' }, + }) + ).toMatchInlineSnapshot(`atoms/to/file`); + }); + + it('match with extension', () => { + expect( + auto('/path/to/file.stories.tsx', { + glob: '', + specifier: { directory: '/path/', titlePrefix: 'atoms' }, + }) + ).toMatchInlineSnapshot(`atoms/to/file`); + }); + }); +}); diff --git a/lib/core-client/src/preview/autoTitle.ts b/lib/core-client/src/preview/autoTitle.ts new file mode 100644 index 000000000000..022ec70a6935 --- /dev/null +++ b/lib/core-client/src/preview/autoTitle.ts @@ -0,0 +1,46 @@ +import global from 'global'; +import path from 'path'; +import type { NormalizedStoriesEntry } from '@storybook/core-common'; + +const { FEATURES = {}, STORIES = [] } = global; + +interface Meta { + title?: string; +} + +const autoTitleV2 = (meta: Meta, fileName: string) => { + return meta.title; +}; + +const stripExtension = (titleWithExtension: string) => { + let parts = titleWithExtension.split('/'); + const last = parts[parts.length - 1]; + const dotIndex = last.indexOf('.'); + const stripped = dotIndex > 0 ? last.substr(0, dotIndex) : last; + parts[parts.length - 1] = stripped; + const [first, ...rest] = parts; + if (first === '') { + parts = rest; + } + return parts.join('/'); +}; + +export const autoTitleFromEntry = (fileName: string, entry: NormalizedStoriesEntry) => { + const { directory, titlePrefix = '' } = entry.specifier || {}; + if (fileName.startsWith(directory)) { + const suffix = fileName.replace(directory, ''); + return stripExtension(path.join(titlePrefix, suffix)); + } + return undefined; +}; + +const autoTitleV3 = (meta: Meta, fileName: string) => { + if (meta.title) return meta.title; + for (let i = 0; i < STORIES.length; i += 1) { + const title = autoTitleFromEntry(fileName, STORIES[i]); + if (title) return title; + } + return undefined; +}; + +export const autoTitle = FEATURES.previewCsfV3 ? autoTitleV3 : autoTitleV2; diff --git a/lib/core-client/src/preview/loadCsf.ts b/lib/core-client/src/preview/loadCsf.ts index a63c430c36aa..1b655712d26a 100644 --- a/lib/core-client/src/preview/loadCsf.ts +++ b/lib/core-client/src/preview/loadCsf.ts @@ -6,6 +6,7 @@ import deprecate from 'util-deprecate'; import { Loadable, LoaderFunction, RequireContext } from './types'; import { normalizeStory } from './normalizeStory'; +import { autoTitle } from './autoTitle'; const duplicateKindWarning = deprecate( (kindName: string) => { @@ -80,14 +81,18 @@ const loadStories = ( return; } - if (!fileExports.default.title) { + const { default: defaultExport, __namedExportsOrder, ...namedExports } = fileExports; + let exports = namedExports; + + const fileName = currentExports.get(fileExports); + const title = autoTitle(defaultExport, fileName); + if (!title) { throw new Error( `Unexpected default export without title: ${JSON.stringify(fileExports.default)}` ); } - const { default: meta, __namedExportsOrder, ...namedExports } = fileExports; - let exports = namedExports; + const meta = { ...defaultExport, title }; // prefer a user/loader provided `__namedExportsOrder` array if supplied // we do this as es module exports are always ordered alphabetically @@ -126,7 +131,7 @@ const loadStories = ( framework, component, subcomponents, - fileName: currentExports.get(fileExports), + fileName, ...kindParameters, args: kindArgs, argTypes: kindArgTypes, diff --git a/lib/core-common/src/index.ts b/lib/core-common/src/index.ts index d2251f02d60f..7eb2cdaca711 100644 --- a/lib/core-common/src/index.ts +++ b/lib/core-common/src/index.ts @@ -22,5 +22,6 @@ export * from './utils/interpolate'; export * from './utils/validate-configuration-files'; export * from './utils/to-require-context'; export * from './utils/has-dotenv'; +export * from './utils/normalize-stories'; export * from './types'; diff --git a/lib/core-common/src/types.ts b/lib/core-common/src/types.ts index 4e7286755bf9..b2c150985f88 100644 --- a/lib/core-common/src/types.ts +++ b/lib/core-common/src/types.ts @@ -33,7 +33,7 @@ export interface Presets { ): Promise; apply(extension: 'babel', config: {}, args: any): Promise; apply(extension: 'entries', config: [], args: any): Promise; - apply(extension: 'stories', config: [], args: any): Promise; + apply(extension: 'stories', config: [], args: any): Promise; apply( extension: 'webpack', config: {}, @@ -218,6 +218,19 @@ export interface TypescriptOptions { reactDocgenTypescriptOptions: PluginOptions; } +interface StoriesSpecifier { + directory: string; + files?: string; + titlePrefix?: string; +} + +export type StoriesEntry = string | StoriesSpecifier; + +export interface NormalizedStoriesEntry { + glob: string; + specifier?: StoriesSpecifier; +} + /** * The interface for Storybook configuration in `main.ts` files. */ @@ -257,7 +270,7 @@ export interface StorybookConfig { * * @example `['./src/*.stories.@(j|t)sx?']` */ - stories: string[]; + stories: StoriesEntry[]; /** * Controls how Storybook handles TypeScript files. */ diff --git a/lib/core-common/src/utils/__tests__/normalize-stories.test.ts b/lib/core-common/src/utils/__tests__/normalize-stories.test.ts new file mode 100644 index 000000000000..c347d63847c1 --- /dev/null +++ b/lib/core-common/src/utils/__tests__/normalize-stories.test.ts @@ -0,0 +1,95 @@ +import { normalizeStoriesEntry } from '../normalize-stories'; + +expect.addSnapshotSerializer({ + print: (val: any) => JSON.stringify(val, null, 2), + test: (val) => typeof val !== 'string', +}); + +jest.mock('path', () => ({ + resolve: () => 'dummy', +})); + +jest.mock('fs', () => ({ + lstatSync: () => ({ + isDirectory: () => true, + }), +})); + +describe('normalizeStoriesEntry', () => { + it('glob', () => { + expect(normalizeStoriesEntry('../**/*.stories.mdx', '')).toMatchInlineSnapshot(` + { + "glob": "../**/*.stories.mdx" + } + `); + }); + + it('directory', () => { + expect(normalizeStoriesEntry('..', '')).toMatchInlineSnapshot(` + { + "glob": "../**/*.stories.@(mdx|tsx|ts|jsx|js)", + "specifier": { + "directory": "..", + "titlePrefix": "", + "files": "*.stories.@(mdx|tsx|ts|jsx|js)" + } + } + `); + }); + + it('directory specifier', () => { + expect(normalizeStoriesEntry({ directory: '..' }, '')).toMatchInlineSnapshot(` + { + "glob": "../**/*.stories.@(mdx|tsx|ts|jsx|js)", + "specifier": { + "directory": "..", + "titlePrefix": "", + "files": "*.stories.@(mdx|tsx|ts|jsx|js)" + } + } + `); + }); + + it('directory/files specifier', () => { + expect(normalizeStoriesEntry({ directory: '..', files: '*.stories.mdx' }, '')) + .toMatchInlineSnapshot(` + { + "glob": "../**/*.stories.mdx", + "specifier": { + "directory": "..", + "titlePrefix": "", + "files": "*.stories.mdx" + } + } + `); + }); + + it('directory/titlePrefix specifier', () => { + expect(normalizeStoriesEntry({ directory: '..', titlePrefix: 'atoms' }, '')) + .toMatchInlineSnapshot(` + { + "glob": "../**/*.stories.@(mdx|tsx|ts|jsx|js)", + "specifier": { + "directory": "..", + "titlePrefix": "atoms", + "files": "*.stories.@(mdx|tsx|ts|jsx|js)" + } + } + `); + }); + + it('directory/titlePrefix/files specifier', () => { + expect( + normalizeStoriesEntry({ directory: '..', titlePrefix: 'atoms', files: '*.stories.mdx' }, '') + ).toMatchInlineSnapshot(` + { + "glob": "../**/*.stories.mdx", + "specifier": { + "directory": "..", + "titlePrefix": "atoms", + "files": "*.stories.mdx" + } + } + `); + }); +}); diff --git a/lib/core-common/src/utils/normalize-stories.ts b/lib/core-common/src/utils/normalize-stories.ts new file mode 100644 index 000000000000..4667a12c1cc4 --- /dev/null +++ b/lib/core-common/src/utils/normalize-stories.ts @@ -0,0 +1,44 @@ +import fs from 'fs'; +import { resolve } from 'path'; +import type { StoriesEntry, NormalizedStoriesEntry } from '../types'; + +const DEFAULT_FILES = '*.stories.@(mdx|tsx|ts|jsx|js)'; +const DEFAULT_TITLE_PREFIX = ''; + +const isDirectory = (configDir: string, entry: string) => { + try { + return fs.lstatSync(resolve(configDir, entry)).isDirectory(); + } catch (err) { + return false; + } +}; + +export const normalizeStoriesEntry = ( + entry: StoriesEntry, + configDir: string +): NormalizedStoriesEntry => { + let glob; + let directory; + let files; + let titlePrefix; + if (typeof entry === 'string') { + if (!entry.includes('**') && isDirectory(configDir, entry)) { + directory = entry; + titlePrefix = DEFAULT_TITLE_PREFIX; + files = DEFAULT_FILES; + } else { + glob = entry; + } + } else { + directory = entry.directory; + files = entry.files || DEFAULT_FILES; + titlePrefix = entry.titlePrefix || DEFAULT_TITLE_PREFIX; + } + if (typeof glob !== 'undefined') { + return { glob, specifier: undefined }; + } + return { glob: `${directory}/**/${files}`, specifier: { directory, titlePrefix, files } }; +}; + +export const normalizeStories = (entries: StoriesEntry[], configDir: string) => + entries.map((entry) => normalizeStoriesEntry(entry, configDir)); diff --git a/lib/core-server/src/build-static.ts b/lib/core-server/src/build-static.ts index 38c0b68461de..be02cc0736ef 100644 --- a/lib/core-server/src/build-static.ts +++ b/lib/core-server/src/build-static.ts @@ -14,6 +14,7 @@ import { Builder, StorybookConfig, cache, + normalizeStories, } from '@storybook/core-common'; import { getProdCli } from './cli'; @@ -68,10 +69,10 @@ export async function buildStaticStandalone(options: CLIOptions & LoadOptions & const features = await presets.apply('features'); if (features?.buildStoriesJson) { - const storiesGlobs = (await presets.apply('stories')) as StorybookConfig['stories']; + const stories = normalizeStories(await presets.apply('stories'), options.configDir); await extractStoriesJson( path.join(options.outputDir, 'stories.json'), - storiesGlobs, + stories.map((s) => s.glob), options.configDir ); } diff --git a/lib/core-server/src/utils/stories-json.ts b/lib/core-server/src/utils/stories-json.ts index 16e39f05d9cf..4c122df9f475 100644 --- a/lib/core-server/src/utils/stories-json.ts +++ b/lib/core-server/src/utils/stories-json.ts @@ -2,7 +2,7 @@ import path from 'path'; import fs from 'fs-extra'; import glob from 'globby'; import { logger } from '@storybook/node-logger'; -import { resolvePathInStorybookCache, Options } from '@storybook/core-common'; +import { resolvePathInStorybookCache, Options, normalizeStories } from '@storybook/core-common'; import { readCsf } from '@storybook/csf-tools'; interface ExtractedStory { @@ -64,8 +64,9 @@ const step = 100; // .1s export async function useStoriesJson(router: any, options: Options) { const storiesJson = resolvePathInStorybookCache('stories.json'); await fs.remove(storiesJson); - const storiesGlobs = (await options.presets.apply('stories')) as string[]; - extractStoriesJson(storiesJson, storiesGlobs, options.configDir); + const stories = normalizeStories(await options.presets.apply('stories'), options.configDir); + const globs = stories.map((s) => s.glob); + extractStoriesJson(storiesJson, globs, options.configDir); router.use('/stories.json', async (_req: any, res: any) => { for (let i = 0; i < timeout / step; i += 1) { if (fs.existsSync(storiesJson)) {