diff --git a/README.md b/README.md index d551fd50..e1362f9d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,30 @@ The way to write stories as idiomatic Vue templates is heavily inspired by the g ``` +## Metadata + +The metadata for your stories is defined in the `` component. Alternatively, you can use the `defineMeta` function in the ` + + ``` + ## Adding documentation You can add documentation for your components directly in your story SFC using the custom `docs` block. diff --git a/examples/vite/src/writing-stories/1.default-export-defineMeta.stories.vue b/examples/vite/src/writing-stories/1.default-export-defineMeta.stories.vue new file mode 100644 index 00000000..7253234f --- /dev/null +++ b/examples/vite/src/writing-stories/1.default-export-defineMeta.stories.vue @@ -0,0 +1,14 @@ + + diff --git a/examples/vite/src/writing-stories/1.default-export.stories.vue b/examples/vite/src/writing-stories/1.default-export.stories.vue index b20f423e..c11e5ae9 100644 --- a/examples/vite/src/writing-stories/1.default-export.stories.vue +++ b/examples/vite/src/writing-stories/1.default-export.stories.vue @@ -11,5 +11,6 @@ import Button from '../components/Button.vue' title="docs/1. Default export/native" :component="Button" > + diff --git a/src/core/parser.ts b/src/core/parser.ts index ed74a596..d1e5077a 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -1,12 +1,38 @@ import type { ElementNode, NodeTypes as _NodeTypes } from '@vue/compiler-core' -import type { SFCDescriptor } from 'vue/compiler-sfc' +import { TS_NODE_TYPES } from '@vue/compiler-dom' +import type { SFCDescriptor, SFCScriptBlock } from 'vue/compiler-sfc' import { + MagicString, compileScript, compileTemplate, parse as parseSFC, } from 'vue/compiler-sfc' +import { CallExpression, Node } from '@babel/types' + import { sanitize } from '@storybook/csf' +// Taken from https://github.com/vuejs/core/blob/2857a59e61c9955e15553180e7480aa40714151c/packages/compiler-sfc/src/script/utils.ts#L35-L42 +export function unwrapTSNode(node: Node): Node { + if (TS_NODE_TYPES.includes(node.type)) { + return unwrapTSNode((node as any).expression) + } else { + return node + } +} +export function isCallOf( + node: Node | null | undefined, + test: string | ((id: string) => boolean) | null | undefined, +): node is CallExpression { + return !!( + node && + test && + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + (typeof test === 'string' + ? node.callee.name === test + : test(node.callee.name)) + ) +} // import { NodeTypes } from '@vue/compiler-core' // Doesn't work, for some reason, maybe https://github.com/vuejs/core/issues/1228 @@ -33,7 +59,18 @@ export function parse(code: string) { if (descriptor.template === null) throw new Error('No template found in SFC') const resolvedScript = resolveScript(descriptor) - const { meta, stories } = parseTemplate(descriptor.template.content) + const parsedTemplate = parseTemplate(descriptor.template.content) + let { meta } = parsedTemplate + const { stories } = parsedTemplate + if (resolvedScript) { + const { meta: scriptMeta } = parseScript(resolvedScript) + if (meta && scriptMeta) { + throw new Error('Cannot define meta by both and defineMeta') + } + if (scriptMeta) { + meta = scriptMeta + } + } const docsBlock = descriptor.customBlocks?.find( (block) => block.type === 'docs', ) @@ -47,7 +84,7 @@ export function parse(code: string) { } function parseTemplate(content: string): { - meta: ParsedMeta + meta?: ParsedMeta stories: ParsedStory[] } { const template = compileTemplate({ @@ -61,22 +98,31 @@ function parseTemplate(content: string): { const roots = template.ast?.children.filter((node) => node.type === ELEMENT) ?? [] - if (roots.length !== 1) { - throw new Error('Expected exactly one element as root.') + if (roots.length === 0) { + throw new Error( + 'No root element found in template, must be or ', + ) } const root = roots[0] - if (root.type !== ELEMENT || root.tag !== 'Stories') - throw new Error('Expected root to be a element.') - const meta = { - title: extractTitle(root), - component: extractComponent(root), - tags: [], + let meta + let storyNodes = roots + if (root.type === ELEMENT && root.tag === 'Stories') { + meta = { + title: extractTitle(root), + component: extractComponent(root), + tags: [], + } + storyNodes = root.children ?? [] } const stories: ParsedStory[] = [] - for (const story of root.children ?? []) { - if (story.type !== ELEMENT || story.tag !== 'Story') continue + for (const story of storyNodes ?? []) { + if (story.type !== ELEMENT || story.tag !== 'Story') { + throw new Error( + 'Only elements are allowed as children of or as root element', + ) + } const title = extractTitle(story) if (!title) throw new Error('Story is missing a title') @@ -142,3 +188,77 @@ function extractProp(node: ElementNode, name: string) { ) } } + +interface ScriptCompileContext { + hasDefineMetaCall: boolean + meta?: ParsedMeta +} +function parseScript(resolvedScript: SFCScriptBlock): { meta?: ParsedMeta } { + if (!resolvedScript.scriptSetupAst) { + return { meta: undefined } + } + const ctx: ScriptCompileContext = { + hasDefineMetaCall: false, + } + const content = new MagicString(resolvedScript.content) + for (const node of resolvedScript.scriptSetupAst) { + if (node.type === 'ExpressionStatement') { + const expr = unwrapTSNode(node.expression) + // process `defineMeta` calls + if (processDefineMeta(ctx, expr)) { + // The ast is sadly out of sync with the content, so we have to find the meta call in the content + const startOffset = content.original.indexOf('defineMeta') + content.remove(startOffset, node.end! - node.start! + startOffset) + } + } + } + resolvedScript.content = content.toString() + return ctx.meta ? { meta: ctx.meta } : {} +} + +// Similar to https://github.com/vuejs/core/blob/2857a59e61c9955e15553180e7480aa40714151c/packages/compiler-sfc/src/script/defineEmits.ts +function processDefineMeta(ctx: ScriptCompileContext, node: Node) { + const defineMetaName = 'defineMeta' + if (!isCallOf(node, defineMetaName)) { + return false + } + if (ctx.hasDefineMetaCall) { + throw new Error(`duplicate ${defineMetaName}() call at ${node.start}`) + } + ctx.hasDefineMetaCall = true + const metaDecl = unwrapTSNode(node.arguments[0]) + const meta: ParsedMeta = { + tags: [], + } + if (metaDecl.type === 'ObjectExpression') { + for (const prop of metaDecl.properties) { + if (prop.type === 'ObjectProperty') { + const key = unwrapTSNode(prop.key) + const valueNode = unwrapTSNode(prop.value) + if (key.type === 'Identifier') { + const value = + valueNode.type === 'StringLiteral' + ? valueNode.value + : valueNode.type === 'Identifier' + ? valueNode.name + : undefined + if (!value) { + throw new Error( + `defineMeta() ${key.name} must be a string literal or identifier`, + ) + } + if (key.name === 'title') { + meta.title = value + } else if (key.name === 'component') { + meta.component = value + } else if (key.name === 'tags') { + meta.tags = value.split(',').map((tag) => tag.trim()) + } + } + } + } + } + ctx.meta = meta + + return true +} diff --git a/src/core/transform.ts b/src/core/transform.ts index 8da7e0ad..b0d1d891 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -86,8 +86,8 @@ async function transformTemplate( meta, stories, docs, - }: { meta: ParsedMeta; stories: ParsedStory[]; docs?: string }, - resolvedScript?: SFCScriptBlock, + }: { meta?: ParsedMeta; stories: ParsedStory[]; docs?: string }, + resolvedScript?: SFCScriptBlock ) { let result = generateDefaultImport(meta, docs) for (const story of stories) { @@ -101,13 +101,10 @@ async function transformTemplate( return result } -function generateDefaultImport( - { title, component }: ParsedMeta, - docs?: string, -) { +function generateDefaultImport(meta?: ParsedMeta, docs?: string) { return `export default { - ${title ? `title: '${title}',` : ''} - ${component ? `component: ${component},` : ''} + ${meta?.title ? `title: '${meta.title}',` : ''} + ${meta?.component ? `component: ${meta.component},` : ''} //decorators: [ ... ], parameters: { ${docs ? `docs: { page: MDXContent },` : ''} @@ -118,7 +115,7 @@ function generateDefaultImport( function generateStoryImport( { id, title, play, template }: ParsedStory, - resolvedScript?: SFCScriptBlock, + resolvedScript?: SFCScriptBlock ) { const { code } = compileTemplate({ source: template.trim(), @@ -137,7 +134,7 @@ function generateStoryImport( const renderFunction = code.replace( 'export function render', - `function render${id}`, + `function render${id}` ) // Each named export is a story, has to return a Vue ComponentOptionsBase diff --git a/test/index.test.ts b/test/index.test.ts index d9f92a21..205a54b7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -26,6 +26,30 @@ describe('transform', () => { " `) }) + it('handles a simple story without Stories container', async () => { + const code = + '' + const result = await transform(code) + expect(result).toMatchInlineSnapshot(` + "const _sfc_main = {}; + export default { + //decorators: [ ... ], + parameters: {}, + }; + + function renderPrimary(_ctx, _cache) { + return \\"hello\\"; + } + export const Primary = () => + Object.assign({ render: renderPrimary }, _sfc_main); + Primary.storyName = \\"Primary\\"; + + Primary.parameters = { + docs: { source: { code: \`hello\` } }, + }; + " + `) + }) it('extracts title from Stories', async () => { const code = '' @@ -52,6 +76,43 @@ describe('transform', () => { " `) }) + it('extracts title from defineMeta', async () => { + const code = + '' + const result = await transform(code) + expect(result).toMatchInlineSnapshot(` + "const _sfc_main = { + setup(__props, { expose: __expose }) { + __expose(); + + const __returned__ = {}; + Object.defineProperty(__returned__, \\"__isScriptSetup\\", { + enumerable: false, + value: true, + }); + return __returned__; + }, + }; + export default { + title: \\"test\\", + + //decorators: [ ... ], + parameters: {}, + }; + + function renderPrimary(_ctx, _cache, $props, $setup, $data, $options) { + return \\"hello\\"; + } + export const Primary = () => + Object.assign({ render: renderPrimary }, _sfc_main); + Primary.storyName = \\"Primary\\"; + + Primary.parameters = { + docs: { source: { code: \`hello\` } }, + }; + " + `) + }) it('throws error if story does not have a title', async () => { const code = '' await expect(() => @@ -84,6 +145,42 @@ describe('transform', () => { " `) }) + it('extracts component from defineMeta', async () => { + const code = + '' + const result = await transform(code) + expect(result).toMatchInlineSnapshot(` + "const _sfc_main = { + setup(__props, { expose: __expose }) { + __expose(); + const MyComponent = {}; + const __returned__ = { MyComponent }; + Object.defineProperty(__returned__, \\"__isScriptSetup\\", { + enumerable: false, + value: true, + }); + return __returned__; + }, + }; + export default { + component: MyComponent, + //decorators: [ ... ], + parameters: {}, + }; + + function renderPrimary(_ctx, _cache, $props, $setup, $data, $options) { + return \\"hello\\"; + } + export const Primary = () => + Object.assign({ render: renderPrimary }, _sfc_main); + Primary.storyName = \\"Primary\\"; + + Primary.parameters = { + docs: { source: { code: \`hello\` } }, + }; + " + `) + }) it('handles title with spaces', async () => { const code = ''