diff --git a/addons/docs/package.json b/addons/docs/package.json index 238f6682bd70..01434f99be07 100644 --- a/addons/docs/package.json +++ b/addons/docs/package.json @@ -23,6 +23,7 @@ "angular/**/*", "common/**/*", "html/**/*", + "postinstall/**/*", "react/**/*", "vue/**/*", "web-components/**/*", @@ -47,6 +48,7 @@ "@storybook/addons": "5.3.0-alpha.44", "@storybook/api": "5.3.0-alpha.44", "@storybook/components": "5.3.0-alpha.44", + "@storybook/postinstall": "5.3.0-alpha.44", "@storybook/router": "5.3.0-alpha.44", "@storybook/source-loader": "5.3.0-alpha.44", "@storybook/theming": "5.3.0-alpha.44", diff --git a/addons/docs/postinstall/presets.js b/addons/docs/postinstall/presets.js new file mode 100644 index 000000000000..a8feae9101e4 --- /dev/null +++ b/addons/docs/postinstall/presets.js @@ -0,0 +1,39 @@ +import fs from 'fs'; +import { presetsAddPreset, getFrameworks } from '@storybook/postinstall'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { logger } from '@storybook/node-logger'; + +export default function transformer(file, api) { + const packageJson = JSON.parse(fs.readFileSync('./package.json')); + const frameworks = getFrameworks(packageJson); + + let err = null; + let framework = null; + let presetOptions = null; + if (frameworks.length !== 1) { + err = `${frameworks.length === 0 ? 'No' : 'Multiple'} frameworks found: ${frameworks}`; + logger.error(`${err}, please configure '@storybook/addon-docs' manually.`); + return file.source; + } + + // eslint-disable-next-line prefer-destructuring + framework = frameworks[0]; + + const { dependencies, devDependencies } = packageJson; + if ( + framework === 'react' && + ((dependencies && dependencies['react-scripts']) || + (devDependencies && devDependencies['react-scripts'])) + ) { + presetOptions = { + configureJSX: true, + }; + } + + const j = api.jscodeshift; + const root = j(file.source); + + presetsAddPreset(`@storybook/addon-docs/preset`, presetOptions, { root, api }); + + return root.toSource({ quote: 'single' }); +} diff --git a/lib/addons/src/typings.d.ts b/lib/addons/src/typings.d.ts index 2f4eb9cf4fd9..afbc638db2fa 100644 --- a/lib/addons/src/typings.d.ts +++ b/lib/addons/src/typings.d.ts @@ -1 +1,2 @@ declare module 'global'; +declare module '@hypnosphi/jscodeshift/dist/testUtils'; diff --git a/lib/cli/bin/generate.js b/lib/cli/bin/generate.js index 7df5c4e4b605..792f639688a0 100644 --- a/lib/cli/bin/generate.js +++ b/lib/cli/bin/generate.js @@ -33,6 +33,7 @@ if (process.argv[1].includes('getstorybook')) { .command('add ') .description('Add an addon to your Storybook') .option('-N --use-npm', 'Use NPM to build the Storybook server') + .option('-s --skip-postinstall', 'Skip package specific postinstall config modifications') .action((addonName, options) => add(addonName, options)); program diff --git a/lib/cli/lib/add.js b/lib/cli/lib/add.js index ffb11e8b4343..17713c1bcc0c 100644 --- a/lib/cli/lib/add.js +++ b/lib/cli/lib/add.js @@ -90,18 +90,42 @@ export const addStorybookAddonToFile = (addonName, addonsFile, isOfficialAddon) ]; }; -const registerAddon = (addonName, isOfficialAddon) => { - const registerDone = commandLog(`Registering the ${addonName} Storybook addon`); - const addonsFilePath = path.resolve('.storybook/addons.js'); - const addonsFile = fs - .readFileSync(addonsFilePath) - .toString() - .split('\n'); - fs.writeFileSync( - addonsFilePath, - addStorybookAddonToFile(addonName, addonsFile, isOfficialAddon).join('\n') - ); - registerDone(); +const LEGACY_CONFIGS = ['addons', 'config', 'presets']; + +const postinstallAddon = async (addonName, isOfficialAddon) => { + let skipMsg = null; + if (!isOfficialAddon) { + skipMsg = 'unofficial addon'; + } else if (!fs.existsSync('.storybook')) { + skipMsg = 'no .storybook config'; + } else { + skipMsg = 'no codmods found'; + LEGACY_CONFIGS.forEach(config => { + try { + const codemod = require.resolve( + `${getPackageName(addonName, isOfficialAddon)}/postinstall/${config}.js` + ); + commandLog(`Running postinstall script for ${addonName}`)(); + let configFile = path.join('.storybook', `${config}.ts`); + if (!fs.existsSync(configFile)) { + configFile = path.join('.storybook', `${config}.js`); + if (!fs.existsSync(configFile)) { + fs.writeFileSync(configFile, '', 'utf8'); + } + } + spawnSync('npx', ['jscodeshift', '-t', codemod, configFile], { + stdio: 'inherit', + }); + skipMsg = null; + } catch (err) { + // resolve failed, skip + } + }); + } + + if (skipMsg) { + commandLog(`Skipping postinstall for ${addonName}, ${skipMsg}`)(); + } }; export default async function add(addonName, options) { @@ -119,5 +143,7 @@ export default async function add(addonName, options) { } addonCheckDone(); installAddon(addonName, npmOptions, isOfficialAddon); - registerAddon(addonName, isOfficialAddon); + if (!options.skipPostinstall) { + await postinstallAddon(addonName, isOfficialAddon); + } } diff --git a/lib/postinstall/README.md b/lib/postinstall/README.md new file mode 100644 index 000000000000..92be110856e4 --- /dev/null +++ b/lib/postinstall/README.md @@ -0,0 +1,20 @@ +# Storybook Postinstall Utilties + +A minimal utility library for addons to update project configurations after the addon is installed via the [Storybook CLI](https://github.com/storybookjs/storybook/tree/master/lib/cli), e.g. `sb add docs`. + +Each postinstall is written as a [jscodeshift](https://github.com/facebook/jscodeshift) codemod, with the naming convention `addon-name/postinstall/.js` where `file` is one of { `config`, `addons`, `presets` }. + +If these files are present in the addon, the CLI will run them on the existing file in the user's project (or create a new empty file if one doesn't exist). This library exists to make it really easy to make common modifications without having to muck with jscodeshift internals. + +## Adding a preset + +To add a preset to `presets.js`, simply create a file `postinstall/presets.js` in your addon: + +```js +improt { presetsAddPreset } = require('@storybook/postinstall'); +export default function transformer(file, api) { + const root = api.jscodeshift(file.source); + presetsAddPreset(`@storybook/addon-docs/preset`, { some: 'options' }, { root, api }); + return root.toSource(); +}; +``` diff --git a/lib/postinstall/package.json b/lib/postinstall/package.json new file mode 100644 index 000000000000..b7ba91a64c4c --- /dev/null +++ b/lib/postinstall/package.json @@ -0,0 +1,39 @@ +{ + "name": "@storybook/postinstall", + "version": "5.3.0-alpha.44", + "description": "", + "keywords": [ + "storybook" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/master/lib/postinstall", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "lib/postinstall" + }, + "license": "MIT", + "files": [ + "dist/**/*", + "README.md", + "*.js", + "*.d.ts" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "prepare": "node ../../scripts/prepare.js" + }, + "dependencies": { + "core-js": "^3.0.1" + }, + "devDependencies": { + "@hypnosphi/jscodeshift": "^0.6.4", + "jest-specific-snapshot": "^2.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset-options/basic.input.js b/lib/postinstall/src/__testfixtures__/presets-add-preset-options/basic.input.js new file mode 100644 index 000000000000..40a844e905a5 --- /dev/null +++ b/lib/postinstall/src/__testfixtures__/presets-add-preset-options/basic.input.js @@ -0,0 +1 @@ +module.exports = ['foo']; diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset-options/basic.output.snapshot b/lib/postinstall/src/__testfixtures__/presets-add-preset-options/basic.output.snapshot new file mode 100644 index 000000000000..4a3d8bd7e284 --- /dev/null +++ b/lib/postinstall/src/__testfixtures__/presets-add-preset-options/basic.output.snapshot @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`presets-add-preset-options transforms correctly using "basic.input.js" data 1`] = ` +"module.exports = ['foo', { + name: 'test', + options: {\\"a\\":[1,2,3],\\"b\\":{\\"foo\\":\\"bar\\"},\\"c\\":\\"baz\\"} +}];" +`; diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset-options/empty.input.js b/lib/postinstall/src/__testfixtures__/presets-add-preset-options/empty.input.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset-options/empty.output.snapshot b/lib/postinstall/src/__testfixtures__/presets-add-preset-options/empty.output.snapshot new file mode 100644 index 000000000000..a28c9e8e6419 --- /dev/null +++ b/lib/postinstall/src/__testfixtures__/presets-add-preset-options/empty.output.snapshot @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`presets-add-preset-options transforms correctly using "empty.input.js" data 1`] = ` +"module.exports = [{ + name: 'test', + options: {\\"a\\":[1,2,3],\\"b\\":{\\"foo\\":\\"bar\\"},\\"c\\":\\"baz\\"} +}];" +`; diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset/basic.input.js b/lib/postinstall/src/__testfixtures__/presets-add-preset/basic.input.js new file mode 100644 index 000000000000..40a844e905a5 --- /dev/null +++ b/lib/postinstall/src/__testfixtures__/presets-add-preset/basic.input.js @@ -0,0 +1 @@ +module.exports = ['foo']; diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset/basic.output.snapshot b/lib/postinstall/src/__testfixtures__/presets-add-preset/basic.output.snapshot new file mode 100644 index 000000000000..aaf16d58b3d8 --- /dev/null +++ b/lib/postinstall/src/__testfixtures__/presets-add-preset/basic.output.snapshot @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`presets-add-preset transforms correctly using "basic.input.js" data 1`] = `"module.exports = ['foo', 'test'];"`; diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset/empty.input.js b/lib/postinstall/src/__testfixtures__/presets-add-preset/empty.input.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lib/postinstall/src/__testfixtures__/presets-add-preset/empty.output.snapshot b/lib/postinstall/src/__testfixtures__/presets-add-preset/empty.output.snapshot new file mode 100644 index 000000000000..9acaaca175c2 --- /dev/null +++ b/lib/postinstall/src/__testfixtures__/presets-add-preset/empty.output.snapshot @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`presets-add-preset transforms correctly using "empty.input.js" data 1`] = `"module.exports = ['test'];"`; diff --git a/lib/postinstall/src/__testtransforms__/presets-add-preset-options.js b/lib/postinstall/src/__testtransforms__/presets-add-preset-options.js new file mode 100644 index 000000000000..aacd83f2f86e --- /dev/null +++ b/lib/postinstall/src/__testtransforms__/presets-add-preset-options.js @@ -0,0 +1,16 @@ +import { addPreset } from '../presets'; + +export default function transformer(file, api) { + const j = api.jscodeshift; + const root = j(file.source); + + const options = { + a: [1, 2, 3], + b: { foo: 'bar' }, + c: 'baz', + }; + + addPreset('test', options, { root, api }); + + return root.toSource({ quote: 'single' }); +} diff --git a/lib/postinstall/src/__testtransforms__/presets-add-preset.js b/lib/postinstall/src/__testtransforms__/presets-add-preset.js new file mode 100644 index 000000000000..76b5fa60fb21 --- /dev/null +++ b/lib/postinstall/src/__testtransforms__/presets-add-preset.js @@ -0,0 +1,10 @@ +import { addPreset } from '../presets'; + +export default function transformer(file, api) { + const j = api.jscodeshift; + const root = j(file.source); + + addPreset('test', null, { root, api }); + + return root.toSource({ quote: 'single' }); +} diff --git a/lib/postinstall/src/codemods.test.ts b/lib/postinstall/src/codemods.test.ts new file mode 100644 index 000000000000..d29fd9dc0d02 --- /dev/null +++ b/lib/postinstall/src/codemods.test.ts @@ -0,0 +1,32 @@ +import path from 'path'; +import fs from 'fs'; +import 'jest-specific-snapshot'; +// TODO move back to original 'jscodeshift' package as soon as https://github.com/facebook/jscodeshift/pull/297 is released +import { applyTransform } from '@hypnosphi/jscodeshift/dist/testUtils'; + +jest.mock('@storybook/node-logger'); + +const inputRegExp = /\.input\.js$/; + +const fixturesDir = path.resolve(__dirname, './__testfixtures__'); +fs.readdirSync(fixturesDir).forEach(transformName => { + const transformFixturesDir = path.join(fixturesDir, transformName); + // eslint-disable-next-line jest/valid-describe + describe(transformName, () => + fs + .readdirSync(transformFixturesDir) + .filter(fileName => inputRegExp.test(fileName)) + .forEach(fileName => { + const inputPath = path.join(transformFixturesDir, fileName); + it(`transforms correctly using "${fileName}" data`, () => + expect( + applyTransform( + // eslint-disable-next-line global-require,import/no-dynamic-require + require(path.join(__dirname, '__testtransforms__', transformName)), + null, + { path: inputPath, source: fs.readFileSync(inputPath, 'utf8') } + ) + ).toMatchSpecificSnapshot(inputPath.replace(inputRegExp, '.output.snapshot'))); + }) + ); +}); diff --git a/lib/postinstall/src/frameworks.test.ts b/lib/postinstall/src/frameworks.test.ts new file mode 100644 index 000000000000..32192dacfca1 --- /dev/null +++ b/lib/postinstall/src/frameworks.test.ts @@ -0,0 +1,41 @@ +import { getFrameworks } from './frameworks'; + +const REACT = { + '@storybook/react': '5.2.5', +}; + +const VUE = { + '@storybook/vue': '5.2.5', +}; + +const NONE = { + '@storybook/addons': '5.2.5', + lodash: '^4.17.15', +}; + +describe('getFrameworks', () => { + it('single framework', () => { + const frameworks = getFrameworks({ + dependencies: NONE, + devDependencies: REACT, + }); + expect(frameworks).toEqual(['react']); + }); + it('multi-framework', () => { + const frameworks = getFrameworks({ + dependencies: VUE, + devDependencies: REACT, + }); + expect(frameworks.sort()).toEqual(['react', 'vue']); + }); + it('no deps', () => { + const frameworks = getFrameworks({}); + expect(frameworks).toEqual([]); + }); + it('no framework', () => { + const frameworks = getFrameworks({ + dependencies: NONE, + }); + expect(frameworks).toEqual([]); + }); +}); diff --git a/lib/postinstall/src/frameworks.ts b/lib/postinstall/src/frameworks.ts new file mode 100644 index 000000000000..dd0710d612e8 --- /dev/null +++ b/lib/postinstall/src/frameworks.ts @@ -0,0 +1,30 @@ +type Deps = Record; +interface PackageJson { + dependencies?: Deps; + devDependencies?: Deps; +} + +const FRAMEWORKS = [ + 'angular', + 'ember', + 'html', + 'marko', + 'mithril', + 'polymer', + 'preact', + 'rax', + 'react', + 'react-native', + 'riot', + 'svelte', + 'vue', + 'web-components', +]; + +export const getFrameworks = ({ dependencies, devDependencies }: PackageJson): string[] => { + const allDeps: Deps = {}; + Object.assign(allDeps, dependencies || {}); + Object.assign(allDeps, devDependencies || {}); + + return FRAMEWORKS.filter(f => !!allDeps[`@storybook/${f}`]); +}; diff --git a/lib/postinstall/src/index.ts b/lib/postinstall/src/index.ts new file mode 100644 index 000000000000..e9b7c48ca047 --- /dev/null +++ b/lib/postinstall/src/index.ts @@ -0,0 +1,2 @@ +export { addPreset as presetsAddPreset } from './presets'; +export * from './frameworks'; diff --git a/lib/postinstall/src/presets.ts b/lib/postinstall/src/presets.ts new file mode 100644 index 000000000000..fe6c61f75b19 --- /dev/null +++ b/lib/postinstall/src/presets.ts @@ -0,0 +1,50 @@ +interface PostinstallContext { + root: any; + api: any; +} + +export function addPreset(preset: string, presetOptions: any, { api, root }: PostinstallContext) { + const j = api.jscodeshift; + const moduleExports: any[] = []; + root + .find(j.AssignmentExpression) + .filter( + (assignment: any) => + assignment.node.left.type === 'MemberExpression' && + assignment.node.left.object.name === 'module' && + assignment.node.left.property.name === 'exports' + ) + .forEach((exp: any) => moduleExports.push(exp)); + + let exportArray = null; + switch (moduleExports.length) { + case 0: { + exportArray = j.arrayExpression([]); + const exportStatement = j.assignmentStatement( + '=', + j.memberExpression(j.identifier('module'), j.identifier('exports')), + exportArray + ); + root.get().node.program.body.push(exportStatement); + break; + } + case 1: + exportArray = moduleExports[0].node.right; + break; + default: + throw new Error('Multiple module export statements'); + } + + let presetConfig = j.literal(preset); + if (presetOptions) { + const optionsJson = `const x = ${JSON.stringify(presetOptions)}`; + const optionsRoot = j(optionsJson); + const optionsNode = optionsRoot.find(j.VariableDeclarator).get().node.init; + + presetConfig = j.objectExpression([ + j.property('init', j.identifier('name'), j.literal(preset)), + j.property('init', j.identifier('options'), optionsNode), + ]); + } + exportArray.elements.push(presetConfig); +} diff --git a/lib/postinstall/tsconfig.json b/lib/postinstall/tsconfig.json new file mode 100644 index 000000000000..a24ec6393862 --- /dev/null +++ b/lib/postinstall/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["src/**.test.ts"] +}