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 =
+ 'hello'
+ 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 =
'hello'
@@ -52,6 +76,43 @@ describe('transform', () => {
"
`)
})
+ it('extracts title from defineMeta', async () => {
+ const code =
+ 'hello'
+ 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 = 'hello'
await expect(() =>
@@ -84,6 +145,42 @@ describe('transform', () => {
"
`)
})
+ it('extracts component from defineMeta', async () => {
+ const code =
+ 'hello'
+ 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 =
'hello'