diff --git a/code/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts index 22b5834c9e9a..0190838b8dd9 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, StorybookConfig } 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$/, @@ -141,7 +146,7 @@ export const createStoriesMdxIndexer = (legacyMdx1?: boolean): Indexer => ({ ? await import('@storybook/mdx1-csf') : await import('@storybook/mdx2-csf'); code = await compile(code, {}); - const csf = loadCsf(code, { ...opts, fileName }).parse(); + const csf = loadCsf(code, code, { ...opts, fileName }).parse(); const { indexInputs, stories } = csf; @@ -194,4 +199,4 @@ const docsX = docs as any; ensureReactPeerDeps(); -export { webpackX as webpack, indexersX as experimental_indexers, docsX as docs }; +export { webpackX as webpackFinal, indexersX as experimental_indexers, docsX as docs }; diff --git a/code/builders/builder-vite/src/plugins/csf-plugin.ts b/code/builders/builder-vite/src/plugins/csf-plugin.ts index 9f472f6552f8..0498bfedda2c 100644 --- a/code/builders/builder-vite/src/plugins/csf-plugin.ts +++ b/code/builders/builder-vite/src/plugins/csf-plugin.ts @@ -1,5 +1,5 @@ import type { Plugin } from 'vite'; -import { vite } from '@storybook/csf-plugin'; +import CsfVitePlugin from '@storybook/csf-plugin/vite'; import type { StorybookConfig, Options } from '@storybook/types'; export async function csfPlugin(config: Options): Promise { @@ -10,6 +10,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/codemod/src/transforms/csf-2-to-3.ts b/code/lib/codemod/src/transforms/csf-2-to-3.ts index 945f3542b6e8..8a5623eadd5f 100644 --- a/code/lib/codemod/src/transforms/csf-2-to-3.ts +++ b/code/lib/codemod/src/transforms/csf-2-to-3.ts @@ -88,7 +88,7 @@ const isSimpleCSFStory = (init: t.Expression, annotations: t.ObjectProperty[]) = annotations.length === 0 && t.isArrowFunctionExpression(init) && init.params.length === 0; function removeUnusedTemplates(csf: CsfFile) { - Object.entries(csf._templates).forEach(([template, templateExpression]) => { + Object.entries(csf._originalTemplates).forEach(([template, templateExpression]) => { const references: NodePath[] = []; babel.traverse(csf._ast, { Identifier: (path) => { @@ -113,7 +113,7 @@ export default function transform(info: FileInfo, api: API, options: { parser?: const makeTitle = (userTitle?: string) => { return userTitle || 'FIXME'; }; - const csf = loadCsf(info.source, { makeTitle }); + const csf = loadCsf(info.source, info.source, { makeTitle }); try { csf.parse(); @@ -132,10 +132,12 @@ export default function transform(info: FileInfo, api: API, options: { parser?: const importHelper = new StorybookImportHelper(file, info); const objectExports: Record = {}; - Object.entries(csf._storyExports).forEach(([key, decl]) => { - const annotations = Object.entries(csf._storyAnnotations[key]).map(([annotation, val]) => { - return t.objectProperty(t.identifier(renameAnnotation(annotation)), val as t.Expression); - }); + Object.entries(csf._originalStoryExports).forEach(([key, decl]) => { + const annotations = Object.entries(csf._originalStoryAnnotations[key]).map( + ([annotation, val]) => { + return t.objectProperty(t.identifier(renameAnnotation(annotation)), val as t.Expression); + } + ); if (t.isVariableDeclarator(decl)) { const { init, id } = decl; @@ -162,7 +164,7 @@ export default function transform(info: FileInfo, api: API, options: { parser?: // export const A = Template.bind({}); const renderAnnotation = isReactGlobalRenderFn( csf, - template ? csf._templates[template] : storyFn + template ? csf._originalTemplates[template] : storyFn ) ? [] : [t.objectProperty(t.identifier('render'), storyFn)]; diff --git a/code/lib/codemod/src/transforms/upgrade-deprecated-types.ts b/code/lib/codemod/src/transforms/upgrade-deprecated-types.ts index f2b2a97bcd09..11750454d452 100644 --- a/code/lib/codemod/src/transforms/upgrade-deprecated-types.ts +++ b/code/lib/codemod/src/transforms/upgrade-deprecated-types.ts @@ -23,7 +23,7 @@ function migrateType(oldType: string) { export default function transform(info: FileInfo, api: API, options: { parser?: string }) { // TODO what do I need to with the title? - const csf = loadCsf(info.source, { makeTitle: (title) => title }); + const csf = loadCsf(info.source, info.source, { makeTitle: (title) => title }); const fileNode = csf._ast; // @ts-expect-error File is not yet exposed, see https://github.com/babel/babel/issues/11350#issuecomment-644118606 const file: BabelFile = new babel.File( diff --git a/code/lib/csf-plugin/package.json b/code/lib/csf-plugin/package.json index c5fa16545163..0ed258116bd1 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": "../../../scripts/prepare/bundle.ts" }, "dependencies": { - "@storybook/csf-tools": "workspace:*", - "unplugin": "^1.3.1" + "@storybook/csf-tools": "workspace:*" }, "devDependencies": { - "typescript": "~4.9.3" + "typescript": "~4.9.3", + "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..1785bd0a54fb --- /dev/null +++ b/code/lib/csf-plugin/src/vite.ts @@ -0,0 +1,43 @@ +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(code, originalCode, { + makeTitle: (userTitle) => userTitle || 'default', + }).parse(); + enrichCsf(csf, options); + const result = formatCsf(csf, { sourceMaps: true }); + if (typeof result === 'string') { + return result; + } + return { + code: result.code, + map: result.map, + }; + } catch (err: any) { + if (!err.message?.startsWith('CSF:')) { + logger.warn(err.message); + } + return null; + } + }, + }; +} + +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..ef12a934d631 --- /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(source as string, originalCode, { + makeTitle: (userTitle) => userTitle || 'default', + }).parse(); + enrichCsf(csf, options); + const result = formatCsf(csf, { sourceMaps: true, inputSourceMap: sourceMap }); + + if (typeof result === 'string') { + callback(null, result); + } else { + callback(null, result.code, result.map || undefined); + } + } catch (err: any) { + // Handle errors. + if (!err.message?.startsWith('CSF:')) { + logger.warn(err.message); + } + // Pass the error to the callback function. + callback(err); + } +}; + +export default CsfWebpackLoaderFn as (source: string, sourceMap: any, meta: any) => void; diff --git a/code/lib/csf-tools/src/CsfFile.test.ts b/code/lib/csf-tools/src/CsfFile.test.ts index c57859e3fee5..5289bfac7c7a 100644 --- a/code/lib/csf-tools/src/CsfFile.test.ts +++ b/code/lib/csf-tools/src/CsfFile.test.ts @@ -15,7 +15,7 @@ const makeTitle = (userTitle?: string) => { }; const parse = (code: string, includeParameters?: boolean) => { - const { stories, meta } = loadCsf(code, { makeTitle }).parse(); + const { stories, meta } = loadCsf(code, code, { makeTitle }).parse(); const filtered = includeParameters ? stories : stories.map(({ parameters, ...rest }) => rest); return { meta, stories: filtered }; }; @@ -684,8 +684,8 @@ describe('CsfFile', () => { const input = dedent` export default { title: 'foo/bar', x: 1, y: 2 }; `; - const csf = loadCsf(input, { makeTitle }).parse(); - expect(Object.keys(csf._metaAnnotations)).toEqual(['title', 'x', 'y']); + const csf = loadCsf(input, input, { makeTitle }).parse(); + expect(Object.keys(csf._originalMetaAnnotations)).toEqual(['title', 'x', 'y']); }); it('story annotations', () => { @@ -697,9 +697,9 @@ describe('CsfFile', () => { export const B = () => {}; B.z = 3; `; - const csf = loadCsf(input, { makeTitle }).parse(); - expect(Object.keys(csf._storyAnnotations.A)).toEqual(['x', 'y']); - expect(Object.keys(csf._storyAnnotations.B)).toEqual(['z']); + const csf = loadCsf(input, input, { makeTitle }).parse(); + expect(Object.keys(csf._originalStoryAnnotations.A)).toEqual(['x', 'y']); + expect(Object.keys(csf._originalStoryAnnotations.B)).toEqual(['z']); }); it('v1-style story annotations', () => { @@ -715,9 +715,9 @@ describe('CsfFile', () => { z: 3, } `; - const csf = loadCsf(input, { makeTitle }).parse(); - expect(Object.keys(csf._storyAnnotations.A)).toEqual(['x', 'y']); - expect(Object.keys(csf._storyAnnotations.B)).toEqual(['z']); + const csf = loadCsf(input, input, { makeTitle }).parse(); + expect(Object.keys(csf._originalStoryAnnotations.A)).toEqual(['x', 'y']); + expect(Object.keys(csf._originalStoryAnnotations.B)).toEqual(['z']); }); }); @@ -839,8 +839,8 @@ describe('CsfFile', () => { import { Check } from './Check'; export default { title: 'foo/bar', x: 1, y: 2 }; `; - const csf = loadCsf(input, { makeTitle }).parse(); - expect(csf.imports).toMatchInlineSnapshot(` + const csf = loadCsf(input, input, { makeTitle }).parse(); + expect(csf.originalImports).toMatchInlineSnapshot(` - ./Button - ./Check `); @@ -851,8 +851,8 @@ describe('CsfFile', () => { const Button = await import('./Button'); export default { title: 'foo/bar', x: 1, y: 2 }; `; - const csf = loadCsf(input, { makeTitle }).parse(); - expect(csf.imports).toMatchInlineSnapshot(); + const csf = loadCsf(input, input, { makeTitle }).parse(); + expect(csf.originalImports).toMatchInlineSnapshot(); }); // eslint-disable-next-line jest/no-disabled-tests it.skip('requires', () => { @@ -860,8 +860,8 @@ describe('CsfFile', () => { const Button = require('./Button'); export default { title: 'foo/bar', x: 1, y: 2 }; `; - const csf = loadCsf(input, { makeTitle }).parse(); - expect(csf.imports).toMatchInlineSnapshot(); + const csf = loadCsf(input, input, { makeTitle }).parse(); + expect(csf.originalImports).toMatchInlineSnapshot(); }); }); @@ -1067,8 +1067,7 @@ describe('CsfFile', () => { describe('index inputs', () => { it('generates index inputs', () => { - const { indexInputs } = loadCsf( - dedent` + const code = dedent` export default { id: 'component-id', title: 'custom foo title', @@ -1084,9 +1083,11 @@ describe('CsfFile', () => { play: () => {}, tags: ['story-tag'], }; - `, - { makeTitle, fileName: 'foo/bar.stories.js' } - ).parse(); + `; + const { indexInputs } = loadCsf(code, code, { + makeTitle, + fileName: 'foo/bar.stories.js', + }).parse(); expect(indexInputs).toMatchInlineSnapshot(` - type: story @@ -1115,8 +1116,7 @@ describe('CsfFile', () => { }); it('supports custom parameters.__id', () => { - const { indexInputs } = loadCsf( - dedent` + const code = dedent` export default { id: 'component-id', title: 'custom foo title', @@ -1126,9 +1126,11 @@ describe('CsfFile', () => { export const A = { parameters: { __id: 'custom-story-id' } }; - `, - { makeTitle, fileName: 'foo/bar.stories.js' } - ).parse(); + `; + const { indexInputs } = loadCsf(code, code, { + makeTitle, + fileName: 'foo/bar.stories.js', + }).parse(); expect(indexInputs).toMatchInlineSnapshot(` - type: story @@ -1144,8 +1146,7 @@ describe('CsfFile', () => { }); it('removes duplicate tags', () => { - const { indexInputs } = loadCsf( - dedent` + const code = dedent` export default { title: 'custom foo title', tags: ['component-tag', 'component-tag-dup', 'component-tag-dup', 'inherit-tag-dup'] @@ -1154,9 +1155,11 @@ describe('CsfFile', () => { export const A = { tags: ['story-tag', 'story-tag-dup', 'story-tag-dup', 'inherit-tag-dup'] }; - `, - { makeTitle, fileName: 'foo/bar.stories.js' } - ).parse(); + `; + const { indexInputs } = loadCsf(code, code, { + makeTitle, + fileName: 'foo/bar.stories.js', + }).parse(); expect(indexInputs).toMatchInlineSnapshot(` - type: story @@ -1175,8 +1178,7 @@ describe('CsfFile', () => { }); it('throws if getting indexInputs without filename option', () => { - const csf = loadCsf( - dedent` + const code = dedent` export default { title: 'custom foo title', tags: ['component-tag', 'component-tag-dup', 'component-tag-dup', 'inherit-tag-dup'] @@ -1185,9 +1187,8 @@ describe('CsfFile', () => { export const A = { tags: ['story-tag', 'story-tag-dup', 'story-tag-dup', 'inherit-tag-dup'] }; - `, - { makeTitle } - ).parse(); + `; + const csf = loadCsf(code, code, { makeTitle }).parse(); expect(() => csf.indexInputs).toThrowErrorMatchingInlineSnapshot(` "Cannot automatically create index inputs with CsfFile.indexInputs because the CsfFile instance was created without a the fileName option. diff --git a/code/lib/csf-tools/src/CsfFile.ts b/code/lib/csf-tools/src/CsfFile.ts index 15ab39abdc45..42f81df83184 100644 --- a/code/lib/csf-tools/src/CsfFile.ts +++ b/code/lib/csf-tools/src/CsfFile.ts @@ -20,8 +20,6 @@ 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) => { @@ -73,7 +71,7 @@ const isArgsStory = (init: t.Node, parent: t.Node, csf: CsfFile) => { const template = findVarInitialization(boundIdentifier, parent); if (template) { // eslint-disable-next-line no-param-reassign - csf._templates[boundIdentifier] = template; + csf._originalTemplates[boundIdentifier] = template; storyFn = template; } } @@ -138,6 +136,8 @@ export interface StaticStory extends Pick string; @@ -146,28 +146,29 @@ export class CsfFile { _stories: Record = {}; - _metaAnnotations: Record = {}; + _originalMetaAnnotations: Record = {}; - _storyExports: Record = {}; + _originalStoryExports: Record = {}; - _metaStatement: t.Statement | undefined; + _originalMetaStatement: t.Statement | undefined; _metaNode: t.Expression | undefined; - _storyStatements: Record = {}; + _originalStoryStatements: Record = {}; - _storyAnnotations: Record> = {}; + _originalStoryAnnotations: Record> = {}; - _templates: Record = {}; + _originalTemplates: Record = {}; - _namedExportsOrder?: string[]; + _originalNamedExportsOrder?: string[]; - imports: string[]; + originalImports: string[]; - constructor(ast: t.File, { fileName, makeTitle }: CsfOptions) { + constructor(ast: t.File, astOriginal: t.File, { fileName, makeTitle }: CsfOptions) { this._ast = ast; + this._astOriginal = astOriginal; this._fileName = fileName as string; - this.imports = []; + this.originalImports = []; this._makeTitle = makeTitle; } @@ -193,7 +194,7 @@ export class CsfFile { const meta: StaticMeta = {}; (declaration.properties as t.ObjectProperty[]).forEach((p) => { if (t.isIdentifier(p.key)) { - this._metaAnnotations[p.key.name] = p.value; + this._originalMetaAnnotations[p.key.name] = p.value; if (p.key.name === 'title') { meta.title = this._parseTitle(p.value); @@ -221,7 +222,7 @@ export class CsfFile { } getStoryExport(key: string) { - let node = this._storyExports[key] as t.Node; + let node = this._originalStoryExports[key] as t.Node; node = t.isVariableDeclarator(node) ? (node.init as t.Node) : node; if (t.isCallExpression(node)) { const { callee, arguments: bindArguments } = node; @@ -236,7 +237,7 @@ export class CsfFile { bindArguments[0].properties.length === 0)) ) { const { name } = callee.object; - node = this._templates[name]; + node = this._originalTemplates[name]; } } return node; @@ -245,12 +246,10 @@ export class CsfFile { parse() { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; - traverse.default(this._ast, { + traverse.default(this._astOriginal, { 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; @@ -258,41 +257,13 @@ export class CsfFile { const isVariableDeclarator = (declaration: t.VariableDeclarator) => t.isIdentifier(declaration.id) && declaration.id.name === variableName; - self._metaStatement = self._ast.program.body.find( + self._originalMetaStatement = self._astOriginal.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 - ); + self._originalMetaStatement = node; } }, }, @@ -310,18 +281,14 @@ export class CsfFile { if (t.isIdentifier(decl.id)) { const { name: exportName } = decl.id; if (exportName === '__namedExportsOrder' && t.isVariableDeclarator(decl)) { - self._namedExportsOrder = parseExportsOrder(decl.init as t.Expression); + self._originalNamedExportsOrder = parseExportsOrder(decl.init as t.Expression); return; } - self._storyExports[exportName] = decl; - self._storyStatements[exportName] = node; + self._originalStoryExports[exportName] = decl; + self._originalStoryStatements[exportName] = node; let name = storyNameFromExport(exportName); - if (self._storyAnnotations[exportName]) { - logger.warn( - `Unexpected annotations for "${exportName}" before story declaration` - ); - } else { - self._storyAnnotations[exportName] = {}; + if (!self._originalStoryAnnotations[exportName]) { + self._originalStoryAnnotations[exportName] = {}; } let storyNode; if (t.isVariableDeclarator(decl)) { @@ -347,9 +314,7 @@ export class CsfFile { } 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.` - ); + // do nothing } else if (p.key.name === 'parameters' && t.isObjectExpression(p.value)) { const idProperty = p.value.properties.find( (property) => @@ -362,7 +327,7 @@ export class CsfFile { } } - self._storyAnnotations[exportName][p.key.name] = p.value; + self._originalStoryAnnotations[exportName][p.key.name] = p.value; } }); } else { @@ -401,7 +366,7 @@ export class CsfFile { self._parseMeta(metaNode, parent); } } else { - self._storyAnnotations[exportName] = {}; + self._originalStoryAnnotations[exportName] = {}; self._stories[exportName] = { id: 'FIXME', name: exportName, parameters: {} }; } } @@ -427,15 +392,15 @@ export class CsfFile { // v1-style annotation // A.story = { parameters: ..., decorators: ... } - if (self._storyAnnotations[exportName]) { + if (self._originalStoryAnnotations[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; + self._originalStoryAnnotations[exportName][prop.key.name] = prop.value; } }); } else { - self._storyAnnotations[exportName][annotationKey] = annotationValue; + self._originalStoryAnnotations[exportName][annotationKey] = annotationValue; } } @@ -465,13 +430,66 @@ export class CsfFile { enter({ node }) { const { source } = node; if (t.isStringLiteral(source)) { - self.imports.push(source.value); + self.originalImports.push(source.value); } else { throw new Error('CSF: unexpected import source'); } }, }, }); + traverse.default(this._ast, { + ExportDefaultDeclaration: { + enter({ node, parent }) { + let metaNode: t.ObjectExpression | undefined; + const isVariableReference = t.isIdentifier(node.declaration) && t.isProgram(parent); + let decl; + let metaStatement; + 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; + + metaStatement = self._ast.program.body.find( + (topLevelNode) => + t.isVariableDeclaration(topLevelNode) && + topLevelNode.declarations.find(isVariableDeclarator) + ); + decl = ((metaStatement as t.VariableDeclaration)?.declarations || []).find( + isVariableDeclarator + )?.init; + } else { + 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 (metaStatement && !self._metaNode) { + throw new NoMetaError( + 'default export must be an object', + metaStatement, + self._fileName + ); + } + }, + }, + }); if (!self._meta) { throw new NoMetaError('missing default export', self._ast, self._fileName); @@ -488,7 +506,7 @@ export class CsfFile { // 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) { + if (self._originalMetaAnnotations.play) { self._meta.tags = [...(self._meta.tags || []), 'play-fn']; } self._stories = entries.reduce((acc, [key, story]) => { @@ -508,7 +526,7 @@ export class CsfFile { parameters.docsOnly = true; } acc[key] = { ...story, id, parameters }; - const { tags, play } = self._storyAnnotations[key]; + const { tags, play } = self._originalStoryAnnotations[key]; if (tags) { const node = t.isIdentifier(tags) ? findVarInitialization(tags.name, this._ast.program) @@ -521,19 +539,22 @@ export class CsfFile { return acc; }, {} as Record); - Object.keys(self._storyExports).forEach((key) => { + Object.keys(self._originalStoryExports).forEach((key) => { if (!isExportStory(key, self._meta as StaticMeta)) { - delete self._storyExports[key]; - delete self._storyAnnotations[key]; + delete self._originalStoryExports[key]; + delete self._originalStoryAnnotations[key]; } }); - if (self._namedExportsOrder) { - const unsortedExports = Object.keys(self._storyExports); - self._storyExports = sortExports(self._storyExports, self._namedExportsOrder); - self._stories = sortExports(self._stories, self._namedExportsOrder); + if (self._originalNamedExportsOrder) { + const unsortedExports = Object.keys(self._originalStoryExports); + self._originalStoryExports = sortExports( + self._originalStoryExports, + self._originalNamedExportsOrder + ); + self._stories = sortExports(self._stories, self._originalNamedExportsOrder); - const sortedExports = Object.keys(self._storyExports); + const sortedExports = Object.keys(self._originalStoryExports); if (unsortedExports.length !== sortedExports.length) { throw new Error( `Missing exports after sort: ${unsortedExports.filter( @@ -578,14 +599,16 @@ export class CsfFile { } } -export const loadCsf = (code: string, options: CsfOptions) => { +export const loadCsf = (code: string, originalCode: string, options: CsfOptions) => { const ast = babelParse(code); - return new CsfFile(ast, options); + const astOriginal = babelParse(originalCode); + return new CsfFile(ast, astOriginal, options); }; interface FormatOptions { sourceMaps?: boolean; preserveStyle?: boolean; + inputSourceMap?: any; } export const formatCsf = (csf: CsfFile, options: FormatOptions = { sourceMaps: false }) => { @@ -606,7 +629,7 @@ export const printCsf = (csf: CsfFile, options: Options = {}) => { export const readCsf = async (fileName: string, options: CsfOptions) => { const code = (await fs.readFile(fileName, 'utf-8')).toString(); - return loadCsf(code, { ...options, fileName }); + return loadCsf(code, code, { ...options, fileName }); }; export const writeCsf = async (csf: CsfFile, fileName?: string) => { diff --git a/code/lib/csf-tools/src/enrichCsf.test.ts b/code/lib/csf-tools/src/enrichCsf.test.ts index a8f5f3aaa09c..0a9787c8694a 100644 --- a/code/lib/csf-tools/src/enrichCsf.test.ts +++ b/code/lib/csf-tools/src/enrichCsf.test.ts @@ -11,10 +11,12 @@ 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(); + const csf = loadCsf(code, originalCode, { + makeTitle: (userTitle) => userTitle || 'default', + }).parse(); enrichCsf(csf, options); return formatCsf(csf); }; @@ -23,17 +25,28 @@ describe('enrichCsf', () => { describe('source', () => { it('csf1', () => { expect( - enrich(dedent` + enrich( + dedent` + // compiled code export default { title: 'Button', } - export const Basic = () =>