import { dedent } from 'ts-dedent'; import { logger } from '@storybook/node-logger'; import type { BuilderOptions, CLIOptions, CoreCommon_ResolvedAddonPreset, CoreCommon_ResolvedAddonVirtual, LoadedPreset, LoadOptions, PresetConfig, Presets, } from '@storybook/types'; import { loadCustomPresets } from './utils/load-custom-presets'; import { safeResolve, safeResolveFrom } from './utils/safeResolve'; import { interopRequireDefault } from './utils/interpret-require'; const isObject = (val: unknown): val is Record => val != null && typeof val === 'object' && Array.isArray(val) === false; const isFunction = (val: unknown): val is Function => typeof val === 'function'; export function filterPresetsConfig(presetsConfig: PresetConfig[]): PresetConfig[] { return presetsConfig.filter((preset) => { const presetName = typeof preset === 'string' ? preset : preset.name; return !/@storybook[\\\\/]preset-typescript/.test(presetName); }); } function resolvePresetFunction( input: T[] | Function, presetOptions: any, storybookOptions: InterPresetOptions ): T[] { if (isFunction(input)) { return [...input({ ...storybookOptions, ...presetOptions })]; } if (Array.isArray(input)) { return [...input]; } return []; } /** * Parse an addon into either a managerEntries or a preset. Throw on invalid input. * * Valid inputs: * - '@storybook/addon-actions/manager' * => { type: 'virtual', item } * * - '@storybook/addon-docs/preset' * => { type: 'presets', item } * * - '@storybook/addon-docs' * => { type: 'presets', item: '@storybook/addon-docs/preset' } * * - { name: '@storybook/addon-docs(/preset)?', options: { ... } } * => { type: 'presets', item: { name: '@storybook/addon-docs/preset', options } } */ export const resolveAddonName = ( configDir: string, name: string, options: any ): CoreCommon_ResolvedAddonPreset | CoreCommon_ResolvedAddonVirtual | undefined => { const resolve = name.startsWith('/') ? safeResolve : safeResolveFrom.bind(null, configDir); const resolved = resolve(name); if (resolved) { if (name.match(/\/(manager|register(-panel)?)(\.(js|ts|tsx|jsx))?$/)) { return { type: 'virtual', name, managerEntries: [resolved], }; } if (name.match(/\/(preset)(\.(js|ts|tsx|jsx))?$/)) { return { type: 'presets', name: resolved, }; } } const checkExists = (exportName: string) => { if (resolve(`${name}${exportName}`)) return `${name}${exportName}`; return undefined; }; const path = name; // We don't want to resolve an import path (e.g. '@addons/foo/preview') to the file on disk, // because you are not allowed to import arbitrary files in packages in Vite. // Instead we check if the export exists and "absolutize" it. const managerFile = checkExists(`/manager`); const registerFile = checkExists(`/register`) || checkExists(`/register-panel`); const previewFile = checkExists(`/preview`); // Presets are imported by node, so therefore fine to be a path on disk (at this stage anyway) const presetFile = resolve(`${path}/preset`); if (!(managerFile || previewFile) && presetFile) { return { type: 'presets', name: presetFile, }; } if (managerFile || registerFile || previewFile || presetFile) { const managerEntries = []; if (managerFile) { managerEntries.push(managerFile); } // register file is the old way of registering addons if (!managerFile && registerFile && !presetFile) { managerEntries.push(registerFile); } return { type: 'virtual', name: path, ...(managerEntries.length ? { managerEntries } : {}), ...(previewFile ? { previewAnnotations: [previewFile] } : {}), ...(presetFile ? { presets: [{ name: presetFile, options }] } : {}), }; } if (resolved) { return { type: 'presets', name: resolved, }; } return undefined; }; const map = ({ configDir }: InterPresetOptions) => (item: any) => { const options = isObject(item) ? item.options || undefined : undefined; const name = isObject(item) ? item.name : item; try { const resolved = resolveAddonName(configDir, name, options); return { ...(options ? { options } : {}), ...resolved, }; } catch (err) { logger.error( `Addon value should end in /manager or /preview or /register OR it should be a valid preset https://storybook.js.org/docs/react/addons/writing-presets/\n${item}` ); } return undefined; }; async function getContent(input: any) { if (input.type === 'virtual') { const { type, name, ...rest } = input; return rest; } const name = input.name ? input.name : input; return interopRequireDefault(name); } export async function loadPreset( input: PresetConfig, level: number, storybookOptions: InterPresetOptions ): Promise { try { // @ts-expect-error (Converted from ts-ignore) const name: string = input.name ? input.name : input; // @ts-expect-error (Converted from ts-ignore) const presetOptions = input.options ? input.options : {}; let contents = await getContent(input); if (typeof contents === 'function') { // allow the export of a preset to be a function, that gets storybookOptions contents = contents(storybookOptions, presetOptions); } if (Array.isArray(contents)) { const subPresets = contents; return await loadPresets(subPresets, level + 1, storybookOptions); } if (isObject(contents)) { const { addons: addonsInput, presets: presetsInput, ...rest } = contents; const subPresets = resolvePresetFunction(presetsInput, presetOptions, storybookOptions); const subAddons = resolvePresetFunction(addonsInput, presetOptions, storybookOptions); return [ ...(await loadPresets([...subPresets], level + 1, storybookOptions)), ...(await loadPresets( [...subAddons.map(map(storybookOptions))].filter(Boolean) as PresetConfig[], level + 1, storybookOptions )), { name, preset: rest, options: presetOptions, }, ]; } throw new Error(dedent` ${input} is not a valid preset `); } catch (e: any) { const warning = level > 0 ? ` Failed to load preset: ${JSON.stringify(input)} on level ${level}` : ` Failed to load preset: ${JSON.stringify(input)}`; logger.warn(warning); logger.error(e); return []; } } async function loadPresets( presets: PresetConfig[], level: number, storybookOptions: InterPresetOptions ): Promise { if (!presets || !Array.isArray(presets) || !presets.length) { return []; } if (!level) { logger.info('=> Loading presets'); } return ( await Promise.all(presets.map(async (preset) => loadPreset(preset, level, storybookOptions))) ).reduce((acc, loaded) => { return acc.concat(loaded); }, []); } function applyPresets( presets: LoadedPreset[], extension: string, config: any, args: any, storybookOptions: InterPresetOptions ): Promise { const presetResult = new Promise((res) => res(config)); if (!presets.length) { return presetResult; } return presets.reduce((accumulationPromise: Promise, { preset, options }) => { const change = preset[extension]; if (!change) { return accumulationPromise; } if (typeof change === 'function') { const extensionFn = change; const context = { preset, combinedOptions: { ...storybookOptions, ...args, ...options, presetsList: presets, presets: { apply: async (ext: string, c: any, a = {}) => applyPresets(presets, ext, c, a, storybookOptions), }, }, }; return accumulationPromise.then((newConfig) => extensionFn.call(context.preset, newConfig, context.combinedOptions) ); } return accumulationPromise.then((newConfig) => { if (Array.isArray(newConfig) && Array.isArray(change)) { return [...newConfig, ...change]; } if (isObject(newConfig) && isObject(change)) { return { ...newConfig, ...change }; } return change; }); }, presetResult); } type InterPresetOptions = Omit; export async function getPresets( presets: PresetConfig[], storybookOptions: InterPresetOptions ): Promise { const loadedPresets: LoadedPreset[] = await loadPresets(presets, 0, storybookOptions); return { apply: async (extension: string, config: any, args = {}) => applyPresets(loadedPresets, extension, config, args, storybookOptions), }; } export async function loadAllPresets( options: CLIOptions & LoadOptions & BuilderOptions & { corePresets: PresetConfig[]; overridePresets: PresetConfig[]; } ) { const { corePresets = [], overridePresets = [], ...restOptions } = options; const presetsConfig: PresetConfig[] = [ ...corePresets, ...loadCustomPresets(options), ...overridePresets, ]; // Remove `@storybook/preset-typescript` and add a warning if in use. const filteredPresetConfig = filterPresetsConfig(presetsConfig); if (filteredPresetConfig.length < presetsConfig.length) { logger.warn( 'Storybook now supports TypeScript natively. You can safely remove `@storybook/preset-typescript`.' ); } return getPresets(filteredPresetConfig, restOptions); }