diff --git a/code/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts index 2ead78d53ac6..12021fbebbc7 100644 --- a/code/addons/docs/src/preset.ts +++ b/code/addons/docs/src/preset.ts @@ -5,7 +5,7 @@ import remarkExternalLinks from 'remark-external-links'; import { dedent } from 'ts-dedent'; import type { DocsOptions, Indexer, Options, PresetProperty } from '@storybook/types'; -import type { CsfPluginOptions } from '@storybook/csf-plugin'; +import type { CsfOptions } from '@storybook/csf-plugin'; import type { JSXOptions, CompileOptions } from '@storybook/mdx2-csf'; import { global } from '@storybook/global'; import { loadCsf } from '@storybook/csf-tools'; @@ -27,7 +27,7 @@ async function webpack( mdxBabelOptions?: any; /** @deprecated */ sourceLoaderOptions: any; - csfPluginOptions: CsfPluginOptions | null; + csfPluginOptions: CsfOptions | null; jsxOptions?: JSXOptions; mdxPluginOptions?: CompileOptions; } /* & Parameters< @@ -92,17 +92,22 @@ async function webpack( const result = { ...webpackConfig, - plugins: [ - ...(webpackConfig.plugins || []), - - ...(csfPluginOptions - ? [(await import('@storybook/csf-plugin')).webpack(csfPluginOptions)] - : []), - ], - module: { ...module, rules: [ + ...(csfPluginOptions + ? [ + { + test: /\.(story|stories)\.[tj]sx?$/, + use: [ + { + loader: require.resolve('@storybook/csf-plugin/webpack'), + options: csfPluginOptions, + }, + ], + }, + ] + : []), ...(module.rules || []), { test: /(stories|story)\.mdx$/, @@ -202,4 +207,9 @@ const optimizeViteDeps = [ 'markdown-to-jsx', ]; -export { webpackX as webpack, indexersX as experimental_indexers, docsX as docs, optimizeViteDeps }; +export { + webpackX as webpackFinal, + indexersX as experimental_indexers, + docsX as docs, + optimizeViteDeps, +}; diff --git a/code/builders/builder-vite/src/plugins/csf-plugin.ts b/code/builders/builder-vite/src/plugins/csf-plugin.ts index 94cea3344c1c..b0e994dd37da 100644 --- a/code/builders/builder-vite/src/plugins/csf-plugin.ts +++ b/code/builders/builder-vite/src/plugins/csf-plugin.ts @@ -1,6 +1,7 @@ import type { Plugin } from 'vite'; -import { vite } from '@storybook/csf-plugin'; import type { Options } from '@storybook/types'; +// @ts-expect-error - The tsconfig.json in code sets moduleResolution: Node. But to respect `exports` fields from package.json's, we would need to set the moduleResolution field to either "Node16" or "nodenext", which introduces another wave of errors +import CsfVitePlugin from '@storybook/csf-plugin/vite'; export async function csfPlugin(config: Options): Promise { const { presets } = config; @@ -10,6 +11,5 @@ export async function csfPlugin(config: Options): Promise { // @ts-expect-error - not sure what type to use here addons.find((a) => [a, a.name].includes('@storybook/addon-docs'))?.options ?? {}; - // TODO: looks like unplugin can return an array of plugins - return vite(docsOptions?.csfPluginOptions) as Plugin; + return CsfVitePlugin(docsOptions?.csfPluginOptions) as Plugin; } diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts index 60d700bad62e..7d266b02f8b5 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts @@ -35,14 +35,14 @@ const getStorySortParameterMock = getStorySortParameter as jest.Mock< const csfIndexer = async (fileName: string, opts: any) => { const code = (await fs.readFile(fileName, 'utf-8')).toString(); - return loadCsf(code, { ...opts, fileName }).parse(); + return loadCsf(code, code, { ...opts, fileName }).parse(); }; const storiesMdxIndexer = async (fileName: string, opts: any) => { let code = (await fs.readFile(fileName, 'utf-8')).toString(); const { compile } = await import('@storybook/mdx2-csf'); code = await compile(code, {}); - return loadCsf(code, { ...opts, fileName }).parse(); + return loadCsf(code, code, { ...opts, fileName }).parse(); }; const options: StoryIndexGeneratorOptions = { @@ -1168,7 +1168,7 @@ describe('StoryIndexGenerator with deprecated indexer API', () => { test: /\.stories\.(m?js|ts)x?$/, createIndex: async (fileName, options) => { const code = (await fs.readFile(fileName, 'utf-8')).toString(); - const csf = loadCsf(code, { ...options, fileName }).parse(); + const csf = loadCsf(code, code, { ...options, fileName }).parse(); // eslint-disable-next-line no-underscore-dangle return Object.entries(csf._stories).map(([exportName, story]) => ({ diff --git a/code/lib/csf-plugin/package.json b/code/lib/csf-plugin/package.json index 8bde5a75683c..0253629ce9ab 100644 --- a/code/lib/csf-plugin/package.json +++ b/code/lib/csf-plugin/package.json @@ -21,12 +21,24 @@ "license": "MIT", "sideEffects": false, "exports": { - ".": { + "./index": { "types": "./dist/index.d.ts", "node": "./dist/index.js", "require": "./dist/index.js", "import": "./dist/index.mjs" }, + "./webpack": { + "types": "./dist/webpack.d.ts", + "node": "./dist/webpack.js", + "require": "./dist/webpack.js", + "import": "./dist/webpack.mjs" + }, + "./vite": { + "types": "./dist/vite.d.ts", + "node": "./dist/vite.js", + "require": "./dist/vite.js", + "import": "./dist/vite.mjs" + }, "./package.json": "./package.json" }, "main": "dist/index.js", @@ -44,18 +56,21 @@ "prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts" }, "dependencies": { - "@storybook/csf-tools": "workspace:*", - "unplugin": "^1.3.1" + "@storybook/csf-tools": "workspace:*" }, "devDependencies": { - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "vite": "^4.5.0", + "webpack": "^5.89.0" }, "publishConfig": { "access": "public" }, "bundler": { "entries": [ - "./src/index.ts" + "./src/index.ts", + "./src/vite.ts", + "./src/webpack.ts" ], "externals": [ "webpack", diff --git a/code/lib/csf-plugin/src/index.ts b/code/lib/csf-plugin/src/index.ts index aed7a531ee32..6bc1a72e5a8d 100644 --- a/code/lib/csf-plugin/src/index.ts +++ b/code/lib/csf-plugin/src/index.ts @@ -1,40 +1,5 @@ -import { createUnplugin } from 'unplugin'; -import fs from 'fs/promises'; -import { loadCsf, enrichCsf, formatCsf } from '@storybook/csf-tools'; import type { EnrichCsfOptions } from '@storybook/csf-tools'; -export type CsfPluginOptions = EnrichCsfOptions; +export default {}; -const STORIES_REGEX = /\.(story|stories)\.[tj]sx?$/; - -const logger = console; - -export const unplugin = createUnplugin((options) => { - return { - name: 'unplugin-csf', - enforce: 'pre', - loadInclude(id) { - return STORIES_REGEX.test(id); - }, - async load(fname) { - const code = await fs.readFile(fname, 'utf-8'); - try { - const csf = loadCsf(code, { makeTitle: (userTitle) => userTitle || 'default' }).parse(); - enrichCsf(csf, options); - return formatCsf(csf, { sourceMaps: true }); - } catch (err: any) { - // This can be called on legacy storiesOf files, so just ignore - // those errors. But warn about other errors. - if (!err.message?.startsWith('CSF:')) { - logger.warn(err.message); - } - return code; - } - }, - }; -}); - -export const { esbuild } = unplugin; -export const { webpack } = unplugin; -export const { rollup } = unplugin; -export const { vite } = unplugin; +export type CsfOptions = EnrichCsfOptions; diff --git a/code/lib/csf-plugin/src/vite.ts b/code/lib/csf-plugin/src/vite.ts new file mode 100644 index 000000000000..b952764ac542 --- /dev/null +++ b/code/lib/csf-plugin/src/vite.ts @@ -0,0 +1,44 @@ +import { loadCsf, enrichCsf, formatCsf } from '@storybook/csf-tools'; +import fs from 'fs/promises'; +import type { Plugin } from 'vite'; +import type { CsfOptions } from '.'; + +const STORIES_REGEX = /\.(story|stories)\.[tj]sx?$/; + +const logger = console; + +function CsfVitePluginFn(options: CsfOptions = {}): Plugin { + return { + name: 'csf-vite-plugin', + + async transform(code: string, id: string) { + if (!STORIES_REGEX.test(id)) { + return null; + } + + try { + const originalCode = await fs.readFile(id, 'utf-8'); + const csf = loadCsf(originalCode, { + makeTitle: (userTitle) => userTitle || 'default', + }).parse(); + enrichCsf(csf, options); + const result = formatCsf(csf, { sourceMaps: true }); + if (typeof result === 'string') { + return result; + } + return { + code: `${code}\n${result.code}`, + }; + } catch (err: any) { + if (!err.message?.startsWith('CSF:')) { + logger.warn(err.message); + } + return { + code, + }; + } + }, + }; +} + +export default CsfVitePluginFn as any; diff --git a/code/lib/csf-plugin/src/webpack.ts b/code/lib/csf-plugin/src/webpack.ts new file mode 100644 index 000000000000..2702ca68fe0b --- /dev/null +++ b/code/lib/csf-plugin/src/webpack.ts @@ -0,0 +1,50 @@ +import type { LoaderContext } from 'webpack'; +import { loadCsf, enrichCsf, formatCsf } from '@storybook/csf-tools'; +import fs from 'fs/promises'; +import type { CsfOptions } from '.'; + +type LoaderFunction = ( + this: LoaderContext, + source: string | Buffer, + sourceMap?: any, + meta?: any +) => void; + +const logger = console; + +const CsfWebpackLoaderFn: LoaderFunction = async function CsfWebpackLoaderFn( + source, + sourceMap, + meta +) { + // Indicate that the loader is asynchronous. + const callback = this.async(); + const filename = this.resourcePath; + + // Access the loader options + const options = this.getOptions() || {}; + + try { + const originalCode = await fs.readFile(filename, 'utf-8'); + const csf = loadCsf(originalCode, { + makeTitle: (userTitle) => userTitle || 'default', + }).parse(); + enrichCsf(csf, options); + const result = formatCsf(csf, { sourceMaps: true }); + + if (typeof result === 'string') { + callback(null, `${source}\n${result}`, sourceMap); + } else { + callback(null, `${source}\n${result.code}`, sourceMap); + } + } catch (err: any) { + // Handle errors. + if (!err.message?.startsWith('CSF:')) { + logger.warn(err.message); + } + + callback(null, source, sourceMap); + } +}; + +export default CsfWebpackLoaderFn as (source: string, sourceMap: any, meta: any) => void; diff --git a/code/lib/csf-tools/src/CsfFileSource.ts b/code/lib/csf-tools/src/CsfFileSource.ts new file mode 100644 index 000000000000..ec4a6a469509 --- /dev/null +++ b/code/lib/csf-tools/src/CsfFileSource.ts @@ -0,0 +1,616 @@ +/* eslint-disable no-underscore-dangle */ +import fs from 'fs-extra'; +import { dedent } from 'ts-dedent'; + +import * as t from '@babel/types'; + +import * as generate from '@babel/generator'; +import * as recast from 'recast'; + +import * as traverse from '@babel/traverse'; +import { toId, isExportStory, storyNameFromExport } from '@storybook/csf'; +import type { + Tag, + StoryAnnotations, + ComponentAnnotations, + IndexedCSFFile, + IndexInput, +} from '@storybook/types'; +import type { Options } from 'recast'; +import { babelParse } from './babelParse'; +import { findVarInitialization } from './findVarInitialization'; + +const logger = console; + +function parseIncludeExclude(prop: t.Node) { + if (t.isArrayExpression(prop)) { + return prop.elements.map((e) => { + if (t.isStringLiteral(e)) return e.value; + throw new Error(`Expected string literal: ${e}`); + }); + } + + if (t.isStringLiteral(prop)) return new RegExp(prop.value); + + if (t.isRegExpLiteral(prop)) return new RegExp(prop.pattern, prop.flags); + + throw new Error(`Unknown include/exclude: ${prop}`); +} + +function parseTags(prop: t.Node) { + if (!t.isArrayExpression(prop)) { + throw new Error('CSF: Expected tags array'); + } + + return prop.elements.map((e) => { + if (t.isStringLiteral(e)) return e.value; + throw new Error(`CSF: Expected tag to be string literal`); + }) as Tag[]; +} + +const formatLocation = (node: t.Node, fileName?: string) => { + const { line, column } = node.loc?.start || {}; + return `${fileName || ''} (line ${line}, col ${column})`.trim(); +}; + +const isArgsStory = (init: t.Node, parent: t.Node, csf: CsfFile) => { + let storyFn: t.Node = init; + // export const Foo = Bar.bind({}) + if (t.isCallExpression(init)) { + const { callee, arguments: bindArguments } = init; + if ( + t.isProgram(parent) && + t.isMemberExpression(callee) && + t.isIdentifier(callee.object) && + t.isIdentifier(callee.property) && + callee.property.name === 'bind' && + (bindArguments.length === 0 || + (bindArguments.length === 1 && + t.isObjectExpression(bindArguments[0]) && + bindArguments[0].properties.length === 0)) + ) { + const boundIdentifier = callee.object.name; + const template = findVarInitialization(boundIdentifier, parent); + if (template) { + // eslint-disable-next-line no-param-reassign + csf._templates[boundIdentifier] = template; + storyFn = template; + } + } + } + if (t.isArrowFunctionExpression(storyFn)) { + return storyFn.params.length > 0; + } + if (t.isFunctionDeclaration(storyFn)) { + return storyFn.params.length > 0; + } + return false; +}; + +const parseExportsOrder = (init: t.Expression) => { + if (t.isArrayExpression(init)) { + return (init.elements as t.Expression[]).map((item) => { + if (t.isStringLiteral(item)) { + return item.value; + } + throw new Error(`Expected string literal named export: ${item}`); + }); + } + throw new Error(`Expected array of string literals: ${init}`); +}; + +const sortExports = (exportByName: Record, order: string[]) => { + return order.reduce((acc, name) => { + const namedExport = exportByName[name]; + if (namedExport) acc[name] = namedExport; + return acc; + }, {} as Record); +}; + +export interface CsfOptions { + fileName?: string; + makeTitle: (userTitle: string) => string; +} + +export class NoMetaError extends Error { + constructor(message: string, ast: t.Node, fileName?: string) { + super(dedent` + CSF: ${message} ${formatLocation(ast, fileName)} + + More info: https://storybook.js.org/docs/react/writing-stories#default-export + `); + this.name = this.constructor.name; + } +} + +export interface StaticMeta + extends Pick< + ComponentAnnotations, + 'id' | 'title' | 'includeStories' | 'excludeStories' | 'tags' + > { + component?: string; +} + +export interface StaticStory extends Pick { + id: string; +} + +export class CsfFileSource { + _ast: t.File; + + _fileName: string; + + _makeTitle: (title: string) => string; + + _meta?: StaticMeta; + + _stories: Record = {}; + + _metaAnnotations: Record = {}; + + _storyExports: Record = {}; + + _metaStatement: t.Statement | undefined; + + _metaNode: t.Expression | undefined; + + _storyStatements: Record = {}; + + _storyAnnotations: Record> = {}; + + _templates: Record = {}; + + _namedExportsOrder?: string[]; + + imports: string[]; + + constructor(ast: t.File, { fileName, makeTitle }: CsfOptions) { + this._ast = ast; + this._fileName = fileName as string; + this.imports = []; + this._makeTitle = makeTitle; + } + + _parseTitle(value: t.Node) { + const node = t.isIdentifier(value) + ? findVarInitialization(value.name, this._ast.program) + : value; + if (t.isStringLiteral(node)) { + return node.value; + } + if (t.isTSSatisfiesExpression(node) && t.isStringLiteral(node.expression)) { + return node.expression.value; + } + + throw new Error(dedent` + CSF: unexpected dynamic title ${formatLocation(node, this._fileName)} + + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#string-literal-titles + `); + } + + _parseMeta(declaration: t.ObjectExpression, program: t.Program) { + const meta: StaticMeta = {}; + (declaration.properties as t.ObjectProperty[]).forEach((p) => { + if (t.isIdentifier(p.key)) { + this._metaAnnotations[p.key.name] = p.value; + + if (p.key.name === 'title') { + meta.title = this._parseTitle(p.value); + } else if (['includeStories', 'excludeStories'].includes(p.key.name)) { + (meta as any)[p.key.name] = parseIncludeExclude(p.value); + } else if (p.key.name === 'component') { + const { code } = recast.print(p.value, {}); + meta.component = code; + } else if (p.key.name === 'tags') { + let node = p.value; + if (t.isIdentifier(node)) { + node = findVarInitialization(node.name, this._ast.program); + } + meta.tags = parseTags(node); + } else if (p.key.name === 'id') { + if (t.isStringLiteral(p.value)) { + meta.id = p.value.value; + } else { + throw new Error(`Unexpected component id: ${p.value}`); + } + } + } + }); + this._meta = meta; + } + + getStoryExport(key: string) { + let node = this._storyExports[key] as t.Node; + node = t.isVariableDeclarator(node) ? (node.init as t.Node) : node; + if (t.isCallExpression(node)) { + const { callee, arguments: bindArguments } = node; + if ( + t.isMemberExpression(callee) && + t.isIdentifier(callee.object) && + t.isIdentifier(callee.property) && + callee.property.name === 'bind' && + (bindArguments.length === 0 || + (bindArguments.length === 1 && + t.isObjectExpression(bindArguments[0]) && + bindArguments[0].properties.length === 0)) + ) { + const { name } = callee.object; + node = this._templates[name]; + } + } + return node; + } + + parse() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + traverse.default(this._ast, { + ExportDefaultDeclaration: { + enter({ node, parent }) { + let metaNode: t.ObjectExpression | undefined; + const isVariableReference = t.isIdentifier(node.declaration) && t.isProgram(parent); + let decl; + if (isVariableReference) { + // const meta = { ... }; + // export default meta; + const variableName = (node.declaration as t.Identifier).name; + const isVariableDeclarator = (declaration: t.VariableDeclarator) => + t.isIdentifier(declaration.id) && declaration.id.name === variableName; + + self._metaStatement = self._ast.program.body.find( + (topLevelNode) => + t.isVariableDeclaration(topLevelNode) && + topLevelNode.declarations.find(isVariableDeclarator) + ); + decl = ((self?._metaStatement as t.VariableDeclaration)?.declarations || []).find( + isVariableDeclarator + )?.init; + } else { + self._metaStatement = node; + decl = node.declaration; + } + + if (t.isObjectExpression(decl)) { + // export default { ... }; + metaNode = decl; + } else if ( + // export default { ... } as Meta<...> + (t.isTSAsExpression(decl) || t.isTSSatisfiesExpression(decl)) && + t.isObjectExpression(decl.expression) + ) { + metaNode = decl.expression; + } + + if (!self._meta && metaNode && t.isProgram(parent)) { + self._metaNode = metaNode; + self._parseMeta(metaNode, parent); + } + + if (self._metaStatement && !self._metaNode) { + throw new NoMetaError( + 'default export must be an object', + self._metaStatement, + self._fileName + ); + } + }, + }, + ExportNamedDeclaration: { + enter({ node, parent }) { + let declarations; + if (t.isVariableDeclaration(node.declaration)) { + declarations = node.declaration.declarations.filter((d) => t.isVariableDeclarator(d)); + } else if (t.isFunctionDeclaration(node.declaration)) { + declarations = [node.declaration]; + } + if (declarations) { + // export const X = ...; + declarations.forEach((decl: t.VariableDeclarator | t.FunctionDeclaration) => { + if (t.isIdentifier(decl.id)) { + const { name: exportName } = decl.id; + if (exportName === '__namedExportsOrder' && t.isVariableDeclarator(decl)) { + self._namedExportsOrder = parseExportsOrder(decl.init as t.Expression); + return; + } + self._storyExports[exportName] = decl; + self._storyStatements[exportName] = node; + let name = storyNameFromExport(exportName); + if (self._storyAnnotations[exportName]) { + logger.warn( + `Unexpected annotations for "${exportName}" before story declaration` + ); + } else { + self._storyAnnotations[exportName] = {}; + } + let storyNode; + if (t.isVariableDeclarator(decl)) { + storyNode = + t.isTSAsExpression(decl.init) || t.isTSSatisfiesExpression(decl.init) + ? decl.init.expression + : decl.init; + } else { + storyNode = decl; + } + const parameters: { [key: string]: any } = {}; + if (t.isObjectExpression(storyNode)) { + parameters.__isArgsStory = true; // assume default render is an args story + // CSF3 object export + (storyNode.properties as t.ObjectProperty[]).forEach((p) => { + if (t.isIdentifier(p.key)) { + if (p.key.name === 'render') { + parameters.__isArgsStory = isArgsStory( + p.value as t.Expression, + parent, + self + ); + } else if (p.key.name === 'name' && t.isStringLiteral(p.value)) { + name = p.value.value; + } else if (p.key.name === 'storyName' && t.isStringLiteral(p.value)) { + logger.warn( + `Unexpected usage of "storyName" in "${exportName}". Please use "name" instead.` + ); + } else if (p.key.name === 'parameters' && t.isObjectExpression(p.value)) { + const idProperty = p.value.properties.find( + (property) => + t.isObjectProperty(property) && + t.isIdentifier(property.key) && + property.key.name === '__id' + ) as t.ObjectProperty | undefined; + if (idProperty) { + parameters.__id = (idProperty.value as t.StringLiteral).value; + } + } + + self._storyAnnotations[exportName][p.key.name] = p.value; + } + }); + } else { + parameters.__isArgsStory = isArgsStory(storyNode as t.Node, parent, self); + } + self._stories[exportName] = { + id: 'FIXME', + name, + parameters, + }; + } + }); + } else if (node.specifiers.length > 0) { + // export { X as Y } + node.specifiers.forEach((specifier) => { + if (t.isExportSpecifier(specifier) && t.isIdentifier(specifier.exported)) { + const { name: exportName } = specifier.exported; + if (exportName === 'default') { + let metaNode: t.ObjectExpression | undefined; + const decl = t.isProgram(parent) + ? findVarInitialization(specifier.local.name, parent) + : specifier.local; + + if (t.isObjectExpression(decl)) { + // export default { ... }; + metaNode = decl; + } else if ( + // export default { ... } as Meta<...> + t.isTSAsExpression(decl) && + t.isObjectExpression(decl.expression) + ) { + metaNode = decl.expression; + } + + if (!self._meta && metaNode && t.isProgram(parent)) { + self._parseMeta(metaNode, parent); + } + } else { + self._storyAnnotations[exportName] = {}; + self._stories[exportName] = { id: 'FIXME', name: exportName, parameters: {} }; + } + } + }); + } + }, + }, + ExpressionStatement: { + enter({ node, parent }) { + const { expression } = node; + // B.storyName = 'some string'; + if ( + t.isProgram(parent) && + t.isAssignmentExpression(expression) && + t.isMemberExpression(expression.left) && + t.isIdentifier(expression.left.object) && + t.isIdentifier(expression.left.property) + ) { + const exportName = expression.left.object.name; + const annotationKey = expression.left.property.name; + const annotationValue = expression.right; + + // v1-style annotation + // A.story = { parameters: ..., decorators: ... } + + if (self._storyAnnotations[exportName]) { + if (annotationKey === 'story' && t.isObjectExpression(annotationValue)) { + (annotationValue.properties as t.ObjectProperty[]).forEach((prop) => { + if (t.isIdentifier(prop.key)) { + self._storyAnnotations[exportName][prop.key.name] = prop.value; + } + }); + } else { + self._storyAnnotations[exportName][annotationKey] = annotationValue; + } + } + + if (annotationKey === 'storyName' && t.isStringLiteral(annotationValue)) { + const storyName = annotationValue.value; + const story = self._stories[exportName]; + if (!story) return; + story.name = storyName; + } + } + }, + }, + CallExpression: { + enter({ node }) { + const { callee } = node; + if (t.isIdentifier(callee) && callee.name === 'storiesOf') { + throw new Error(dedent` + Unexpected \`storiesOf\` usage: ${formatLocation(node, self._fileName)}. + + In SB7, we use the next-generation \`storyStoreV7\` by default, which does not support \`storiesOf\`. + More info, with details about how to opt-out here: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#storystorev7-enabled-by-default + `); + } + }, + }, + ImportDeclaration: { + enter({ node }) { + const { source } = node; + if (t.isStringLiteral(source)) { + self.imports.push(source.value); + } else { + throw new Error('CSF: unexpected import source'); + } + }, + }, + }); + + if (!self._meta) { + throw new NoMetaError('missing default export', self._ast, self._fileName); + } + + if (!self._meta.title && !self._meta.component) { + throw new Error(dedent` + CSF: missing title/component ${formatLocation(self._ast, self._fileName)} + + More info: https://storybook.js.org/docs/react/writing-stories#default-export + `); + } + + // default export can come at any point in the file, so we do this post processing last + const entries = Object.entries(self._stories); + self._meta.title = this._makeTitle(self._meta?.title as string); + if (self._metaAnnotations.play) { + self._meta.tags = [...(self._meta.tags || []), 'play-fn']; + } + self._stories = entries.reduce((acc, [key, story]) => { + if (!isExportStory(key, self._meta as StaticMeta)) { + return acc; + } + const id = + story.parameters?.__id ?? + toId((self._meta?.id || self._meta?.title) as string, storyNameFromExport(key)); + const parameters: Record = { ...story.parameters, __id: id }; + + const { includeStories } = self._meta || {}; + if ( + key === '__page' && + (entries.length === 1 || (Array.isArray(includeStories) && includeStories.length === 1)) + ) { + parameters.docsOnly = true; + } + acc[key] = { ...story, id, parameters }; + const { tags, play } = self._storyAnnotations[key]; + if (tags) { + const node = t.isIdentifier(tags) + ? findVarInitialization(tags.name, this._ast.program) + : tags; + acc[key].tags = parseTags(node); + } + if (play) { + acc[key].tags = [...(acc[key].tags || []), 'play-fn']; + } + return acc; + }, {} as Record); + + Object.keys(self._storyExports).forEach((key) => { + if (!isExportStory(key, self._meta as StaticMeta)) { + delete self._storyExports[key]; + delete self._storyAnnotations[key]; + } + }); + + if (self._namedExportsOrder) { + const unsortedExports = Object.keys(self._storyExports); + self._storyExports = sortExports(self._storyExports, self._namedExportsOrder); + self._stories = sortExports(self._stories, self._namedExportsOrder); + + const sortedExports = Object.keys(self._storyExports); + if (unsortedExports.length !== sortedExports.length) { + throw new Error( + `Missing exports after sort: ${unsortedExports.filter( + (key) => !sortedExports.includes(key) + )}` + ); + } + } + + return self as CsfFile & IndexedCSFFile; + } + + public get meta() { + return this._meta; + } + + public get stories() { + return Object.values(this._stories); + } + + public get indexInputs(): IndexInput[] { + if (!this._fileName) { + throw new Error( + dedent`Cannot automatically create index inputs with CsfFile.indexInputs because the CsfFile instance was created without a the fileName option. + Either add the fileName option when creating the CsfFile instance, or create the index inputs manually.` + ); + } + return Object.entries(this._stories).map(([exportName, story]) => { + // combine meta and story tags, removing any duplicates + const tags = Array.from(new Set([...(this._meta?.tags ?? []), ...(story.tags ?? [])])); + return { + type: 'story', + importPath: this._fileName, + exportName, + name: story.name, + title: this.meta?.title, + metaId: this.meta?.id, + tags, + __id: story.id, + }; + }); + } +} + +export const loadCsf = (code: string, options: CsfOptions) => { + const ast = babelParse(code); + return new CsfFile(ast, options); +}; + +interface FormatOptions { + sourceMaps?: boolean; + preserveStyle?: boolean; +} + +export const formatCsf = (csf: CsfFile, options: FormatOptions = { sourceMaps: false }) => { + const result = generate.default(csf._ast, options); + if (options.sourceMaps) { + return result; + } + const { code } = result; + return code; +}; + +/** + * Use this function, if you want to preserve styles. Uses recast under the hood. + */ +export const printCsf = (csf: CsfFile, options: Options = {}) => { + return recast.print(csf._ast, options); +}; + +export const readCsf = async (fileName: string, options: CsfOptions) => { + const code = (await fs.readFile(fileName, 'utf-8')).toString(); + return loadCsf(code, { ...options, fileName }); +}; + +export const writeCsf = async (csf: CsfFile, fileName?: string) => { + const fname = fileName || csf._fileName; + if (!fname) throw new Error('Please specify a fileName for writeCsf'); + await fs.writeFile(fileName as string, printCsf(csf).code); +}; diff --git a/code/lib/csf-tools/src/enrichCsf.test.ts b/code/lib/csf-tools/src/enrichCsf.test.ts index a8f5f3aaa09c..d793f730cecc 100644 --- a/code/lib/csf-tools/src/enrichCsf.test.ts +++ b/code/lib/csf-tools/src/enrichCsf.test.ts @@ -11,11 +11,16 @@ expect.addSnapshotSerializer({ test: (val) => true, }); -const enrich = (code: string, options?: EnrichCsfOptions) => { +const enrich = (code: string, originalCode: string, options?: EnrichCsfOptions) => { // we don't actually care about the title - const csf = loadCsf(code, { makeTitle: (userTitle) => userTitle || 'default' }).parse(); - enrichCsf(csf, options); + const csf = loadCsf(code, { + makeTitle: (userTitle) => userTitle || 'default', + }).parse(); + const csfSource = loadCsf(originalCode, { + makeTitle: (userTitle) => userTitle || 'default', + }).parse(); + enrichCsf(csf, csfSource, options); return formatCsf(csf); }; @@ -23,17 +28,28 @@ describe('enrichCsf', () => { describe('source', () => { it('csf1', () => { expect( - enrich(dedent` + enrich( + dedent` + // compiled code export default { title: 'Button', } - export const Basic = () =>