storybook/scripts/sandbox.ts

297 lines
8.3 KiB
TypeScript
Raw Normal View History

/* eslint-disable no-restricted-syntax, no-await-in-loop */
import path from 'path';
2022-08-02 10:25:16 +10:00
import { remove, pathExists, readJSON, writeJSON, ensureSymlink } from 'fs-extra';
import prompts from 'prompts';
import { getOptionsOrPrompt } from './utils/options';
import { executeCLIStep } from './utils/cli-step';
import { exec } from '../code/lib/cli/src/repro-generators/scripts';
import { getInterpretedFile } from '../code/lib/core-common';
2022-08-01 10:41:52 +10:00
import { ConfigFile, readConfig, writeConfig } from '../code/lib/csf-tools';
import { babelParse } from '../code/lib/csf-tools/src/babelParse';
2022-07-28 20:15:57 +10:00
const frameworks = ['react', 'angular'];
const addons = ['a11y', 'storysource'];
const defaultAddons = [
'actions',
'backgrounds',
'controls',
'docs',
'highlight',
'links',
'interactions',
'measure',
'outline',
'toolbars',
'viewport',
];
2022-08-02 09:15:06 +10:00
const sandboxDir = path.resolve(__dirname, '../sandbox');
2022-07-28 21:21:58 +10:00
const codeDir = path.resolve(__dirname, '../code');
// TODO -- how to encode this information
const renderersMap = { react: 'react', angular: 'angular' };
async function getOptions() {
2022-08-02 09:15:06 +10:00
return getOptionsOrPrompt('yarn sandbox', {
framework: {
description: 'Which framework would you like to use?',
values: frameworks,
required: true as const,
},
addon: {
description: 'Which extra addons (beyond the CLI defaults) would you like installed?',
values: addons,
multiple: true as const,
},
includeStories: {
2022-07-28 20:15:57 +10:00
description: "Include Storybook's own stories?",
promptType: (_, { framework }) => framework === 'react',
},
create: {
2022-08-02 09:15:06 +10:00
description: 'Create the template from scratch (rather than degitting it)?',
},
forceDelete: {
2022-08-02 09:15:06 +10:00
description: 'Always delete an existing sandbox, even if it has the same configuration?',
2022-07-28 20:15:57 +10:00
promptType: false,
},
forceReuse: {
2022-08-02 09:15:06 +10:00
description: 'Always reuse an existing sandbox, even if it has a different configuration?',
2022-07-28 20:15:57 +10:00
promptType: false,
},
2022-07-28 19:46:40 +10:00
link: {
description: 'Link the storybook to the local code?',
inverse: true,
},
start: {
2022-08-02 09:15:06 +10:00
description: 'Start the Storybook?',
inverse: true,
},
build: {
2022-08-02 09:15:06 +10:00
description: 'Build the Storybook?',
},
watch: {
2022-08-02 09:15:06 +10:00
description: 'Start building used packages in watch mode as well as the Storybook?',
},
2022-07-26 16:20:49 +10:00
dryRun: {
2022-07-28 19:46:40 +10:00
description: "Don't execute commands, just list them (dry run)?",
2022-07-26 16:20:49 +10:00
},
});
}
const steps = {
repro: {
command: 'repro',
2022-08-02 09:15:06 +10:00
description: 'Bootstrapping Template',
icon: '👷',
hasArgument: true,
options: {
template: { values: frameworks },
e2e: {},
},
},
add: {
command: 'add',
description: 'Adding addon',
icon: '+',
hasArgument: true,
options: {},
},
2022-07-28 21:21:58 +10:00
link: {
command: 'link',
description: 'Linking packages',
icon: '🔗',
hasArgument: true,
options: { local: {}, start: { inverse: true } },
2022-07-28 21:21:58 +10:00
},
build: {
command: 'build',
2022-08-02 09:15:06 +10:00
description: 'Building Storybook',
icon: '🔨',
options: {},
},
dev: {
command: 'dev',
2022-08-02 09:15:06 +10:00
description: 'Starting Storybook',
2022-07-26 16:20:49 +10:00
icon: '🖥 ',
options: {},
},
};
const logger = console;
const addPackageScripts = async ({
cwd,
scripts,
}: {
cwd: string;
scripts: Record<string, string>;
}) => {
logger.info(`🔢 Adding package resolutions:`);
const packageJsonPath = path.join(cwd, 'package.json');
const packageJson = await readJSON(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
...scripts,
};
await writeJSON(packageJsonPath, packageJson, { spaces: 2 });
};
2022-08-02 08:35:07 +10:00
async function readMainConfig({ cwd }: { cwd: string }) {
const configDir = path.join(cwd, '.storybook');
const mainConfigPath = getInterpretedFile(path.resolve(configDir, 'main'));
return readConfig(mainConfigPath);
}
2022-08-02 08:48:07 +10:00
// NOTE: the test regexp here will apply whether the path is symlink-preserved or otherwise
const loaderPath = require.resolve('../code/node_modules/esbuild-loader');
2022-08-01 22:57:00 +10:00
const webpackFinalCode = `
(config) => ({
...config,
module: {
...config.modules,
rules: [
{
test: [/\\/node_modules\\/@storybook\\/[^/]*\\/template\\/stories\\//],
2022-08-02 08:48:07 +10:00
loader: '${loaderPath}',
2022-08-01 22:57:00 +10:00
options: {
loader: 'tsx',
target: 'es2015',
},
},
...config.module.rules,
],
},
})`;
// paths are of the form 'node_modules/@storybook/react'
2022-08-01 10:41:52 +10:00
async function addStories(
paths: string[],
{ mainConfig, cwd }: { mainConfig: ConfigFile; cwd: string }
) {
2022-08-02 08:35:07 +10:00
const stories = mainConfig.getFieldValue(['stories']) as string[];
2022-08-01 22:57:00 +10:00
const extraStoryDirsAndExistence = await Promise.all(
paths
.map((p) => path.join(p, 'template', 'stories'))
.map(async (p) => [p, await pathExists(path.resolve(codeDir, p))] as const)
);
const extraStories = extraStoryDirsAndExistence
.filter(([, exists]) => exists)
.map(([p]) => path.join('..', p, '*.stories.@(js|jsx|ts|tsx)'));
2022-08-02 08:35:07 +10:00
mainConfig.setFieldValue(['stories'], [...stories, ...extraStories]);
2022-08-01 22:57:00 +10:00
mainConfig.setFieldNode(
['webpackFinal'],
// @ts-ignore (not sure why TS complains here, it does exist)
babelParse(webpackFinalCode).program.body[0].expression
);
}
async function main() {
const optionValues = await getOptions();
2022-07-28 21:21:58 +10:00
const { framework, forceDelete, forceReuse, link, dryRun } = optionValues;
const cwd = path.join(sandboxDir, framework);
const exists = await pathExists(cwd);
2022-07-28 19:46:40 +10:00
let shouldDelete = exists && !forceReuse;
if (exists && !forceDelete && !forceReuse) {
2022-07-28 19:46:40 +10:00
const relativePath = path.relative(process.cwd(), cwd);
({ shouldDelete } = await prompts({
type: 'toggle',
2022-07-28 19:46:40 +10:00
message: `${relativePath} already exists, should delete it and create a new one?`,
name: 'shouldDelete',
initial: false,
active: 'yes',
inactive: 'no',
}));
}
2022-07-28 19:46:40 +10:00
if (exists && shouldDelete && !dryRun) await remove(cwd);
2022-07-28 19:46:40 +10:00
if (!exists || shouldDelete) {
await executeCLIStep(steps.repro, {
argument: cwd,
optionValues: { template: framework },
2022-08-02 09:15:06 +10:00
cwd: sandboxDir,
2022-07-26 16:20:49 +10:00
dryRun,
});
2022-08-01 10:41:52 +10:00
const mainConfig = await readMainConfig({ cwd });
// TODO -- can we get the options type to return something more specific
const renderer = renderersMap[framework as 'react' | 'angular'];
2022-08-01 10:41:52 +10:00
const storiesPath = 'stories'; // This may differ in different projects
// Link in the template/components/index.js from the renderer
const rendererPath = path.join('node_modules', '@storybook', renderer);
await ensureSymlink(
path.join(codeDir, rendererPath, 'template', 'components'),
path.resolve(cwd, storiesPath, 'components')
);
mainConfig.setFieldValue(
['previewEntries'],
[`.${path.sep}${path.join(storiesPath, 'components')}`]
);
2022-08-02 08:35:07 +10:00
const storiesToAdd = [] as string[];
2022-08-01 10:41:52 +10:00
storiesToAdd.push(rendererPath);
// TODO -- sb add <addon> doesn't actually work properly:
// - installs in `deps` not `devDeps`
// - does a `workspace:^` install (what does that mean?)
// - doesn't add to `main.js`
for (const addon of optionValues.addon) {
const addonName = `@storybook/addon-${addon}`;
2022-07-26 16:20:49 +10:00
await executeCLIStep(steps.add, { argument: addonName, cwd, dryRun });
}
for (const addon of [...defaultAddons, ...optionValues.addon]) {
2022-08-01 22:57:00 +10:00
storiesToAdd.push(path.join('node_modules', '@storybook', `addon-${addon}`));
}
2022-08-01 10:41:52 +10:00
await addStories(storiesToAdd, { mainConfig, cwd });
await writeConfig(mainConfig);
2022-07-28 21:21:58 +10:00
if (link) {
await executeCLIStep(steps.link, {
argument: cwd,
cwd: codeDir,
dryRun,
optionValues: { local: true, start: false },
});
await addPackageScripts({
cwd,
scripts: {
storybook:
'NODE_OPTIONS="--preserve-symlinks --preserve-symlinks-main" storybook dev -p 6006',
'build-storybook':
'NODE_OPTIONS="--preserve-symlinks --preserve-symlinks-main" storybook build',
},
2022-07-28 21:21:58 +10:00
});
}
}
const { start } = optionValues;
if (start) {
await exec(
'yarn storybook',
{ cwd },
{
dryRun,
startMessage: `⬆️ Starting Storybook`,
errorMessage: `🚨 Starting Storybook failed`,
}
);
} else {
2022-07-26 16:20:49 +10:00
await executeCLIStep(steps.build, { cwd, dryRun });
// TODO serve
}
// TODO start dev
}
main().catch((err) => console.error(err));