diff --git a/code/lib/codemod/package.json b/code/lib/codemod/package.json index c805f990a8d..9f8c8af0dbf 100644 --- a/code/lib/codemod/package.json +++ b/code/lib/codemod/package.json @@ -62,7 +62,7 @@ "@types/cross-spawn": "^6.0.2", "cross-spawn": "^7.0.3", "globby": "^11.0.2", - "jscodeshift": "^0.14.0", + "jscodeshift": "^0.15.1", "lodash": "^4.17.21", "prettier": "^2.8.0", "recast": "^0.23.1" diff --git a/code/lib/core-common/src/utils/normalize-stories.ts b/code/lib/core-common/src/utils/normalize-stories.ts index 3beabeae4c8..1a69fb1ce68 100644 --- a/code/lib/core-common/src/utils/normalize-stories.ts +++ b/code/lib/core-common/src/utils/normalize-stories.ts @@ -34,7 +34,7 @@ export const getDirectoryFromWorkingDir = ({ export const normalizeStoriesEntry = ( entry: StoriesEntry, - { configDir, workingDir, default_files_pattern = DEFAULT_FILES_PATTERN }: NormalizeOptions + { configDir, workingDir, defaultFilesPattern = DEFAULT_FILES_PATTERN }: NormalizeOptions ): NormalizedStoriesSpecifier => { let specifierWithoutMatcher: Omit; @@ -53,7 +53,7 @@ export const normalizeStoriesEntry = ( specifierWithoutMatcher = { titlePrefix: DEFAULT_TITLE_PREFIX, directory: entry, - files: default_files_pattern, + files: defaultFilesPattern, }; } else { specifierWithoutMatcher = { @@ -65,7 +65,7 @@ export const normalizeStoriesEntry = ( } else { specifierWithoutMatcher = { titlePrefix: DEFAULT_TITLE_PREFIX, - files: default_files_pattern, + files: defaultFilesPattern, ...entry, }; } @@ -99,7 +99,7 @@ export const normalizeStoriesEntry = ( interface NormalizeOptions { configDir: string; workingDir: string; - default_files_pattern?: string; + defaultFilesPattern?: string; } export const normalizeStories = (entries: StoriesEntry[], options: NormalizeOptions) => { diff --git a/code/lib/core-server/src/presets/common-override-preset.ts b/code/lib/core-server/src/presets/common-override-preset.ts index d92dead5d8f..8cc740aea7d 100644 --- a/code/lib/core-server/src/presets/common-override-preset.ts +++ b/code/lib/core-server/src/presets/common-override-preset.ts @@ -1,14 +1,5 @@ -import type { - Options, - PresetProperty, - StoriesEntry, - StorybookConfig, - TestBuildFlags, -} from '@storybook/types'; -import { normalizeStories, commonGlobOptions } from '@storybook/core-common'; -import { isAbsolute, join, relative } from 'path'; -import slash from 'slash'; -import { glob } from 'glob'; +import type { Options, PresetProperty, StorybookConfig, TestBuildFlags } from '@storybook/types'; +import { removeMDXEntries } from '../utils/remove-mdx-entries'; export const framework: PresetProperty<'framework', StorybookConfig> = async (config) => { // This will get called with the values from the user's main config, but before @@ -25,49 +16,7 @@ export const framework: PresetProperty<'framework', StorybookConfig> = async (co export const stories: PresetProperty<'stories', StorybookConfig> = async (entries, options) => { if (options?.build?.test?.disableMDXEntries) { - const list = normalizeStories(entries, { - configDir: options.configDir, - workingDir: options.configDir, - default_files_pattern: '**/*.@(stories.@(js|jsx|mjs|ts|tsx))', - }); - const result = ( - await Promise.all( - list.map(async ({ directory, files, titlePrefix }) => { - const pattern = join(directory, files); - const absolutePattern = isAbsolute(pattern) ? pattern : join(options.configDir, pattern); - const absoluteDirectory = isAbsolute(directory) - ? directory - : join(options.configDir, directory); - - return { - files: ( - await glob(slash(absolutePattern), { - ...commonGlobOptions(absolutePattern), - follow: true, - }) - ).map((f) => relative(absoluteDirectory, f)), - directory, - titlePrefix, - }; - }) - ) - ).flatMap((expanded, i) => { - const filteredEntries = expanded.files.filter((s) => !s.endsWith('.mdx')); - // only return the filtered entries when there is something to filter - // as webpack is faster with unexpanded globs - let items = []; - if (filteredEntries.length < expanded.files.length) { - items = filteredEntries.map((k) => ({ - ...expanded, - files: `**/${k}`, - })); - } else { - items = [list[i]]; - } - - return items; - }); - return result; + return removeMDXEntries(entries, options); } return entries; }; diff --git a/code/lib/core-server/src/utils/__tests__/remove-mdx-stories.test.ts b/code/lib/core-server/src/utils/__tests__/remove-mdx-stories.test.ts new file mode 100644 index 00000000000..93280240e74 --- /dev/null +++ b/code/lib/core-server/src/utils/__tests__/remove-mdx-stories.test.ts @@ -0,0 +1,248 @@ +import { glob as globlOriginal } from 'glob'; +import { type StoriesEntry } from '@storybook/types'; +import { normalizeStoriesEntry } from '@storybook/core-common'; +import { join } from 'path'; +import slash from 'slash'; +import { removeMDXEntries } from '../remove-mdx-entries'; + +const glob = globlOriginal as jest.MockedFunction; + +const configDir = '/configDir/'; +const workingDir = '/'; + +jest.mock('glob', () => ({ glob: jest.fn() })); + +const createList = (list: { entry: StoriesEntry; result: string[] }[]) => { + return list.reduce>( + (acc, { entry, result }) => { + const { directory, files } = normalizeStoriesEntry(entry, { + configDir, + workingDir, + }); + acc[slash(join('/', directory, files))] = { result, entry }; + return acc; + }, + {} + ); +}; + +const createGlobMock = (input: ReturnType) => { + return async (k: string | string[]) => { + if (Array.isArray(k)) { + throw new Error('do not pass an array to glob during tests'); + } + if (input[slash(k)]) { + return input[slash(k)]?.result; + } + + throw new Error('can not find key in input'); + }; +}; + +test('empty', async () => { + const list = createList([]); + glob.mockImplementation(createGlobMock(list)); + + await expect(() => removeMDXEntries(Object.keys(list), { configDir })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Storybook could not index your stories. + Your main configuration somehow does not contain a 'stories' field, or it resolved to an empty array. + + Please check your main configuration file and make sure it exports a 'stories' field that is not an empty array. + + More info: https://storybook.js.org/docs/react/faq#can-i-have-a-storybook-with-no-local-stories + " + `); +}); + +test('minimal', async () => { + const list = createList([{ entry: '*.js', result: [] }]); + glob.mockImplementation(createGlobMock(list)); + + const result = await removeMDXEntries( + Object.values(list).map((e) => e.entry), + { configDir } + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "directory": ".", + "files": "*.js", + "titlePrefix": "", + }, + ] + `); +}); + +test('multiple', async () => { + const list = createList([ + { entry: '*.ts', result: [] }, + { entry: '*.js', result: [] }, + ]); + glob.mockImplementation(createGlobMock(list)); + + const result = await removeMDXEntries( + Object.values(list).map((e) => e.entry), + { configDir } + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "directory": ".", + "files": "*.ts", + "titlePrefix": "", + }, + Object { + "directory": ".", + "files": "*.js", + "titlePrefix": "", + }, + ] + `); +}); + +test('mdx but not matching any files', async () => { + const list = createList([ + { entry: '*.mdx', result: [] }, + { entry: '*.js', result: [] }, + ]); + glob.mockImplementation(createGlobMock(list)); + + const result = await removeMDXEntries( + Object.values(list).map((e) => e.entry), + { configDir } + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "directory": ".", + "files": "*.mdx", + "titlePrefix": "", + }, + Object { + "directory": ".", + "files": "*.js", + "titlePrefix": "", + }, + ] + `); +}); + +test('removes entries that only yield mdx files', async () => { + const list = createList([ + { entry: '*.mdx', result: ['/configDir/my-file.mdx'] }, + { entry: '*.js', result: [] }, + ]); + glob.mockImplementation(createGlobMock(list)); + + const result = await removeMDXEntries( + Object.values(list).map((e) => e.entry), + { configDir } + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "directory": ".", + "files": "*.js", + "titlePrefix": "", + }, + ] + `); +}); + +test('expands entries that only yield mixed files', async () => { + const list = createList([ + { entry: '*.@(mdx|ts)', result: ['/configDir/my-file.mdx', '/configDir/my-file.ts'] }, + { entry: '*.js', result: [] }, + ]); + glob.mockImplementation(createGlobMock(list)); + + const result = await removeMDXEntries( + Object.values(list).map((e) => e.entry), + { configDir } + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "directory": ".", + "files": "**/my-file.ts", + "titlePrefix": "", + }, + Object { + "directory": ".", + "files": "*.js", + "titlePrefix": "", + }, + ] + `); +}); + +test('passes titlePrefix', async () => { + const list = createList([ + { + entry: { files: '*.@(mdx|ts)', directory: '.', titlePrefix: 'foo' }, + result: ['/configDir/my-file.mdx', '/configDir/my-file.ts'], + }, + ]); + glob.mockImplementation(createGlobMock(list)); + + const result = await removeMDXEntries( + Object.values(list).map((e) => e.entry), + { configDir } + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "directory": ".", + "files": "**/my-file.ts", + "titlePrefix": "foo", + }, + ] + `); +}); + +test('expands to multiple entries', async () => { + const list = createList([ + { + entry: { files: '*.@(mdx|ts)', directory: '.', titlePrefix: 'foo' }, + result: [ + '/configDir/my-file.mdx', + '/configDir/my-file1.ts', + '/configDir/my-file2.ts', + '/configDir/my-file3.ts', + ], + }, + ]); + glob.mockImplementation(createGlobMock(list)); + + const result = await removeMDXEntries( + Object.values(list).map((e) => e.entry), + { configDir } + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "directory": ".", + "files": "**/my-file1.ts", + "titlePrefix": "foo", + }, + Object { + "directory": ".", + "files": "**/my-file2.ts", + "titlePrefix": "foo", + }, + Object { + "directory": ".", + "files": "**/my-file3.ts", + "titlePrefix": "foo", + }, + ] + `); +}); diff --git a/code/lib/core-server/src/utils/remove-mdx-entries.ts b/code/lib/core-server/src/utils/remove-mdx-entries.ts new file mode 100644 index 00000000000..ed93c1bc8d6 --- /dev/null +++ b/code/lib/core-server/src/utils/remove-mdx-entries.ts @@ -0,0 +1,57 @@ +import type { Options, StoriesEntry } from '@storybook/types'; +import { normalizeStories, commonGlobOptions } from '@storybook/core-common'; +import { isAbsolute, join, relative } from 'path'; +import slash from 'slash'; +import { glob } from 'glob'; + +export async function removeMDXEntries( + entries: StoriesEntry[], + options: Pick +): Promise { + const list = normalizeStories(entries, { + configDir: options.configDir, + workingDir: options.configDir, + defaultFilesPattern: '**/*.@(stories.@(js|jsx|mjs|ts|tsx))', + }); + const result = ( + await Promise.all( + list.map(async ({ directory, files, titlePrefix }) => { + const pattern = join(directory, files); + const absolutePattern = isAbsolute(pattern) ? pattern : join(options.configDir, pattern); + const absoluteDirectory = isAbsolute(directory) + ? directory + : join(options.configDir, directory); + + return { + files: ( + await glob(slash(absolutePattern), { + ...commonGlobOptions(absolutePattern), + follow: true, + }) + ).map((f) => relative(absoluteDirectory, f)), + directory, + titlePrefix, + }; + }) + ) + ).flatMap(({ directory, files, titlePrefix }, i) => { + const filteredEntries = files.filter((s) => !s.endsWith('.mdx')); + // only return the filtered entries when there is something to filter + // as webpack is faster with unexpanded globs + let items = []; + if (filteredEntries.length < files.length) { + items = filteredEntries.map((k) => ({ + directory, + titlePrefix, + files: `**/${k}`, + })); + } else { + items = [ + { directory: list[i].directory, titlePrefix: list[i].titlePrefix, files: list[i].files }, + ]; + } + + return items; + }); + return result; +} diff --git a/code/lib/postinstall/package.json b/code/lib/postinstall/package.json index a9779b34bd8..9e2a19920e0 100644 --- a/code/lib/postinstall/package.json +++ b/code/lib/postinstall/package.json @@ -47,7 +47,7 @@ "devDependencies": { "jest": "^29.7.0", "jest-specific-snapshot": "^8.0.0", - "jscodeshift": "^0.14.0", + "jscodeshift": "^0.15.1", "typescript": "~4.9.3" }, "publishConfig": { diff --git a/code/yarn.lock b/code/yarn.lock index 3999f341dbe..90e5ab5564a 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6317,7 +6317,7 @@ __metadata: globby: "npm:^11.0.2" jest: "npm:^29.7.0" jest-specific-snapshot: "npm:^8.0.0" - jscodeshift: "npm:^0.14.0" + jscodeshift: "npm:^0.15.1" lodash: "npm:^4.17.21" mdast-util-mdx-jsx: "npm:^2.1.2" mdast-util-mdxjs-esm: "npm:^1.3.1" @@ -6922,7 +6922,7 @@ __metadata: dependencies: jest: "npm:^29.7.0" jest-specific-snapshot: "npm:^8.0.0" - jscodeshift: "npm:^0.14.0" + jscodeshift: "npm:^0.15.1" typescript: "npm:~4.9.3" languageName: unknown linkType: soft @@ -11154,15 +11154,6 @@ __metadata: languageName: node linkType: hard -"ast-types@npm:0.15.2": - version: 0.15.2 - resolution: "ast-types@npm:0.15.2" - dependencies: - tslib: "npm:^2.0.1" - checksum: 5b26e3656e9e8d1db8c8d14971d0cb88ca0138aacce72171cb4cd4555fc8dc53c07e821c568e57fe147366931708fefd25cb9d7e880d42ce9cb569947844c962 - languageName: node - linkType: hard - "ast-types@npm:^0.16.1": version: 0.16.1 resolution: "ast-types@npm:0.16.1" @@ -20127,37 +20118,6 @@ __metadata: languageName: node linkType: hard -"jscodeshift@npm:^0.14.0": - version: 0.14.0 - resolution: "jscodeshift@npm:0.14.0" - dependencies: - "@babel/core": "npm:^7.13.16" - "@babel/parser": "npm:^7.13.16" - "@babel/plugin-proposal-class-properties": "npm:^7.13.0" - "@babel/plugin-proposal-nullish-coalescing-operator": "npm:^7.13.8" - "@babel/plugin-proposal-optional-chaining": "npm:^7.13.12" - "@babel/plugin-transform-modules-commonjs": "npm:^7.13.8" - "@babel/preset-flow": "npm:^7.13.13" - "@babel/preset-typescript": "npm:^7.13.0" - "@babel/register": "npm:^7.13.16" - babel-core: "npm:^7.0.0-bridge.0" - chalk: "npm:^4.1.2" - flow-parser: "npm:0.*" - graceful-fs: "npm:^4.2.4" - micromatch: "npm:^4.0.4" - neo-async: "npm:^2.5.0" - node-dir: "npm:^0.1.17" - recast: "npm:^0.21.0" - temp: "npm:^0.8.4" - write-file-atomic: "npm:^2.3.0" - peerDependencies: - "@babel/preset-env": ^7.1.6 - bin: - jscodeshift: bin/jscodeshift.js - checksum: dab63bdb4b7e67d79634fcd3f5dc8b227146e9f68aa88700bc49c5a45b6339d05bd934a98aa53d29abd04f81237d010e7e037799471b2aab66ec7b9a7d752786 - languageName: node - linkType: hard - "jscodeshift@npm:^0.15.1": version: 0.15.1 resolution: "jscodeshift@npm:0.15.1" @@ -25954,18 +25914,6 @@ __metadata: languageName: node linkType: hard -"recast@npm:^0.21.0": - version: 0.21.5 - resolution: "recast@npm:0.21.5" - dependencies: - ast-types: "npm:0.15.2" - esprima: "npm:~4.0.0" - source-map: "npm:~0.6.1" - tslib: "npm:^2.0.1" - checksum: a45168c82195f24fa2c70293a624fece0069a2e8e8adb637f9963777735f81cb3bb62e55172db677ec3573b08b2daaf1eddd85b74da6fe0bd37c9b15eeaf94b4 - languageName: node - linkType: hard - "recast@npm:^0.23.1, recast@npm:^0.23.3": version: 0.23.4 resolution: "recast@npm:0.23.4"