mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-08 11:11:53 +08:00
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
/* eslint-disable @typescript-eslint/ban-ts-comment,no-param-reassign,@typescript-eslint/no-shadow */
|
|
import type { FileInfo } from 'jscodeshift';
|
|
import { babelParse, babelParseExpression } from '@storybook/csf-tools';
|
|
import { remark } from 'remark';
|
|
import remarkMdx from 'remark-mdx';
|
|
import { SKIP, visit } from 'unist-util-visit';
|
|
import { is } from 'unist-util-is';
|
|
import type {
|
|
MdxJsxAttribute,
|
|
MdxJsxExpressionAttribute,
|
|
MdxJsxFlowElement,
|
|
MdxJsxTextElement,
|
|
} from 'mdast-util-mdx-jsx';
|
|
import type { MdxjsEsm } from 'mdast-util-mdxjs-esm';
|
|
import * as t from '@babel/types';
|
|
import type { BabelFile } from '@babel/core';
|
|
import * as babel from '@babel/core';
|
|
import * as recast from 'recast';
|
|
import * as path from 'node:path';
|
|
import prettier from 'prettier';
|
|
import * as fs from 'node:fs';
|
|
import camelCase from 'lodash/camelCase';
|
|
import type { MdxFlowExpression } from 'mdast-util-mdx-expression';
|
|
|
|
const mdxProcessor = remark().use(remarkMdx) as ReturnType<typeof remark>;
|
|
|
|
export default async function jscodeshift(info: FileInfo) {
|
|
const parsed = path.parse(info.path);
|
|
|
|
let baseName = path.join(
|
|
parsed.dir,
|
|
parsed.name.replace('.mdx', '').replace('.stories', '').replace('.story', '')
|
|
);
|
|
|
|
// make sure the new csf file we are going to create exists
|
|
while (fs.existsSync(`${baseName}.stories.js`)) {
|
|
baseName += '_';
|
|
}
|
|
|
|
const result = await transform(info, path.basename(baseName));
|
|
|
|
const [mdx, csf] = result;
|
|
|
|
if (csf != null) {
|
|
fs.writeFileSync(`${baseName}.stories.js`, csf);
|
|
}
|
|
|
|
return mdx;
|
|
}
|
|
|
|
export async function transform(info: FileInfo, baseName: string): Promise<[string, string]> {
|
|
const root = mdxProcessor.parse(info.source);
|
|
const storyNamespaceName = nameToValidExport(`${baseName}Stories`);
|
|
|
|
const metaAttributes: Array<MdxJsxAttribute | MdxJsxExpressionAttribute> = [];
|
|
const storiesMap = new Map<
|
|
string,
|
|
| {
|
|
type: 'value';
|
|
attributes: Array<MdxJsxAttribute | MdxJsxExpressionAttribute>;
|
|
children: (MdxJsxFlowElement | MdxJsxTextElement)['children'];
|
|
}
|
|
| {
|
|
type: 'reference';
|
|
}
|
|
| {
|
|
type: 'id';
|
|
}
|
|
>();
|
|
|
|
// rewrite addon docs import
|
|
// @ts-ignore
|
|
visit(root, ['mdxjsEsm'], (node: MdxjsEsm) => {
|
|
node.value = node.value
|
|
.replaceAll('@storybook/addon-docs/blocks', '@storybook/blocks')
|
|
.replaceAll('@storybook/addon-docs', '@storybook/blocks');
|
|
});
|
|
|
|
const file = getEsmAst(root);
|
|
|
|
visit(root, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node, index, parent) => {
|
|
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
if (is(node, { name: 'Meta' })) {
|
|
metaAttributes.push(...node.attributes);
|
|
node.attributes = [
|
|
{
|
|
type: 'mdxJsxAttribute',
|
|
name: 'of',
|
|
value: {
|
|
type: 'mdxJsxAttributeValueExpression',
|
|
value: storyNamespaceName,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
if (is(node, { name: 'Story' })) {
|
|
const nameAttribute = node.attributes.find(
|
|
(it) => it.type === 'mdxJsxAttribute' && it.name === 'name'
|
|
);
|
|
const idAttribute = node.attributes.find(
|
|
(it) => it.type === 'mdxJsxAttribute' && it.name === 'id'
|
|
);
|
|
const storyAttribute = node.attributes.find(
|
|
(it) => it.type === 'mdxJsxAttribute' && it.name === 'story'
|
|
);
|
|
if (typeof nameAttribute?.value === 'string') {
|
|
let name = nameToValidExport(nameAttribute.value);
|
|
while (variableNameExists(name)) name += '_';
|
|
|
|
storiesMap.set(name, {
|
|
type: 'value',
|
|
attributes: node.attributes,
|
|
children: node.children,
|
|
});
|
|
node.attributes = [
|
|
{
|
|
type: 'mdxJsxAttribute',
|
|
name: 'of',
|
|
value: {
|
|
type: 'mdxJsxAttributeValueExpression',
|
|
value: `${storyNamespaceName}.${name}`,
|
|
},
|
|
},
|
|
];
|
|
node.children = [];
|
|
} else if (idAttribute?.value) {
|
|
// e.g. <Story id="button--primary" />
|
|
// should be migrated manually as it is very hard to find out where the definition of such a string id is located
|
|
const nodeString = mdxProcessor.stringify({ type: 'root', children: [node] }).trim();
|
|
const newNode: MdxFlowExpression = {
|
|
type: 'mdxFlowExpression',
|
|
value: `/* ${nodeString} is deprecated, please migrate it to <Story of={referenceToStory} /> see: https://storybook.js.org/migration-guides/7.0 */`,
|
|
};
|
|
storiesMap.set(idAttribute.value as string, { type: 'id' });
|
|
parent?.children.splice(index as number, 0, newNode);
|
|
// current index is the new comment, and index + 1 is current node
|
|
// SKIP traversing current node, and continue with the node after that
|
|
return [SKIP, (index as number) + 2];
|
|
} else if (
|
|
storyAttribute?.type === 'mdxJsxAttribute' &&
|
|
typeof storyAttribute.value === 'object' &&
|
|
storyAttribute.value?.type === 'mdxJsxAttributeValueExpression'
|
|
) {
|
|
// e.g. <Story story={Primary} />
|
|
|
|
const name = storyAttribute.value?.value;
|
|
node.attributes = [
|
|
{
|
|
type: 'mdxJsxAttribute',
|
|
name: 'of',
|
|
value: {
|
|
type: 'mdxJsxAttributeValueExpression',
|
|
value: `${storyNamespaceName}.${name}`,
|
|
},
|
|
},
|
|
];
|
|
node.children = [];
|
|
|
|
storiesMap.set(name ?? '', { type: 'reference' });
|
|
} else {
|
|
parent?.children.splice(index as number, 1);
|
|
// Do not traverse `node`, continue at the node *now* at `index`.
|
|
return [SKIP, index];
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const metaProperties = metaAttributes.flatMap((attribute) => {
|
|
if (attribute.type === 'mdxJsxAttribute') {
|
|
if (typeof attribute.value === 'string') {
|
|
return [t.objectProperty(t.identifier(attribute.name), t.stringLiteral(attribute.value))];
|
|
}
|
|
return [
|
|
t.objectProperty(
|
|
t.identifier(attribute.name),
|
|
babelParseExpression(attribute.value?.value ?? '') as any as t.Expression
|
|
),
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
file.path.traverse({
|
|
// remove mdx imports from csf
|
|
ImportDeclaration(path) {
|
|
if (path.node.source.value === '@storybook/blocks') {
|
|
path.remove();
|
|
}
|
|
},
|
|
// remove exports from csf file
|
|
ExportNamedDeclaration(path) {
|
|
// @ts-ignore
|
|
path.replaceWith(path.node.declaration);
|
|
},
|
|
});
|
|
|
|
if (storiesMap.size === 0 && metaAttributes.length === 0) {
|
|
// A CSF file must have at least one story, so skip migrating if this is the case.
|
|
return [mdxProcessor.stringify(root), ''];
|
|
}
|
|
|
|
addStoriesImport(root, baseName, storyNamespaceName);
|
|
|
|
const newStatements: t.Statement[] = [
|
|
t.exportDefaultDeclaration(t.objectExpression(metaProperties)),
|
|
];
|
|
|
|
function mapChildrenToRender(children: (MdxJsxFlowElement | MdxJsxTextElement)['children']) {
|
|
const child = children[0];
|
|
|
|
if (!child) return undefined;
|
|
|
|
if (child.type === 'text') {
|
|
return t.arrowFunctionExpression([], t.stringLiteral(child.value));
|
|
}
|
|
if (child.type === 'mdxFlowExpression' || child.type === 'mdxTextExpression') {
|
|
const expression = babelParseExpression(child.value) as any as t.Expression;
|
|
|
|
// Recreating those lines: https://github.com/storybookjs/mdx1-csf/blob/f408fc97e9a63097ca1ee577df9315a3cccca975/src/sb-mdx-plugin.ts#L185-L198
|
|
const BIND_REGEX = /\.bind\(.*\)/;
|
|
if (BIND_REGEX.test(child.value)) {
|
|
return expression;
|
|
}
|
|
if (t.isIdentifier(expression)) {
|
|
return expression;
|
|
}
|
|
if (t.isArrowFunctionExpression(expression)) {
|
|
return expression;
|
|
}
|
|
return t.arrowFunctionExpression([], expression);
|
|
}
|
|
|
|
const expression = babelParseExpression(
|
|
mdxProcessor.stringify({ type: 'root', children: [child] })
|
|
) as any as t.Expression;
|
|
return t.arrowFunctionExpression([], expression);
|
|
}
|
|
|
|
function variableNameExists(name: string) {
|
|
let found = false;
|
|
file.path.traverse({
|
|
VariableDeclarator: (path) => {
|
|
const lVal = path.node.id;
|
|
if (t.isIdentifier(lVal) && lVal.name === name) found = true;
|
|
},
|
|
});
|
|
return found;
|
|
}
|
|
|
|
newStatements.push(
|
|
...[...storiesMap].flatMap(([key, value]) => {
|
|
if (value.type === 'id') return [];
|
|
if (value.type === 'reference') {
|
|
return [
|
|
t.exportNamedDeclaration(null, [t.exportSpecifier(t.identifier(key), t.identifier(key))]),
|
|
];
|
|
}
|
|
const renderProperty = mapChildrenToRender(value.children);
|
|
const newObject = t.objectExpression([
|
|
...(renderProperty ? [t.objectProperty(t.identifier('render'), renderProperty)] : []),
|
|
...value.attributes.flatMap((attribute) => {
|
|
if (attribute.type === 'mdxJsxAttribute') {
|
|
if (typeof attribute.value === 'string') {
|
|
return [
|
|
t.objectProperty(t.identifier(attribute.name), t.stringLiteral(attribute.value)),
|
|
];
|
|
}
|
|
return [
|
|
t.objectProperty(
|
|
t.identifier(attribute.name),
|
|
babelParseExpression(attribute.value?.value ?? '') as any as t.Expression
|
|
),
|
|
];
|
|
}
|
|
return [];
|
|
}),
|
|
]);
|
|
|
|
return [
|
|
t.exportNamedDeclaration(
|
|
t.variableDeclaration('const', [t.variableDeclarator(t.identifier(key), newObject)])
|
|
),
|
|
];
|
|
})
|
|
);
|
|
|
|
file.path.node.body = [...file.path.node.body, ...newStatements];
|
|
|
|
const newMdx = mdxProcessor.stringify(root);
|
|
let output = recast.print(file.path.node).code;
|
|
|
|
const prettierConfig = (await prettier.resolveConfig(`${info.path}.jsx`)) || {
|
|
printWidth: 100,
|
|
tabWidth: 2,
|
|
bracketSpacing: true,
|
|
trailingComma: 'es5',
|
|
singleQuote: true,
|
|
};
|
|
|
|
output = await prettier.format(output.trim(), {
|
|
...prettierConfig,
|
|
filepath: `${info.path}.jsx`,
|
|
});
|
|
|
|
return [newMdx, output];
|
|
}
|
|
|
|
function getEsmAst(root: ReturnType<typeof mdxProcessor.parse>) {
|
|
const esm: string[] = [];
|
|
visit(root, 'mdxjsEsm', (node) => {
|
|
esm.push(node.value);
|
|
});
|
|
const esmSource = `${esm.join('\n\n')}`;
|
|
|
|
// @ts-expect-error (File is not yet exposed, see https://github.com/babel/babel/issues/11350#issuecomment-644118606)
|
|
const file: BabelFile = new babel.File(
|
|
{ filename: 'info.path' },
|
|
{ code: esmSource, ast: babelParse(esmSource) }
|
|
);
|
|
return file;
|
|
}
|
|
|
|
function addStoriesImport(
|
|
root: ReturnType<typeof mdxProcessor.parse>,
|
|
baseName: string,
|
|
storyNamespaceName: string
|
|
): void {
|
|
let found = false;
|
|
visit(root, 'mdxjsEsm', (node) => {
|
|
if (!found) {
|
|
node.value += `\nimport * as ${storyNamespaceName} from './${baseName}.stories';`;
|
|
found = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
export function nameToValidExport(name: string) {
|
|
const [first, ...rest] = Array.from(camelCase(name));
|
|
|
|
return `${first.match(/[a-zA-Z_$]/) ? first.toUpperCase() : `$${first}`}${rest.join('')}`;
|
|
}
|