Skip to content

feat: allow to specify metadata using a defineMeta function #82

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Stories>` component. Alternatively, you can use the `defineMeta` function in the `<script setup>` block of your story.
In this case, you don't need to wrap your stories in a `<Stories>` component.

```vue
<script setup lang="ts">
import Button from './Button.vue'

defineMeta({
title: 'Button',
component: Button,
})
</script>
<template>
<Story title="Primary">
<Button
background="#ff0"
label="Button"
/>
</Story>
</template>
```

## Adding documentation

You can add documentation for your components directly in your story SFC using the custom `docs` block.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script setup lang="ts">
import Button from '../components/Button.vue'

defineMeta({
component: Button,
// 👇 The title is optional.
// See https://storybook.js.org/docs/vue/configure/overview#configure-story-loading
// to learn how to generate automatic titles
title: 'docs/1. Default export/native-defineMeta',
})
</script>
<template>
<!--Define your stories here-->
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ import Button from '../components/Button.vue'
title="docs/1. Default export/native"
:component="Button"
>
<!--Define your stories here-->
</Stories>
</template>
146 changes: 133 additions & 13 deletions src/core/parser.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <Stories> and defineMeta')
}
if (scriptMeta) {
meta = scriptMeta
}
}
const docsBlock = descriptor.customBlocks?.find(
(block) => block.type === 'docs',
)
Expand All @@ -47,7 +84,7 @@ export function parse(code: string) {
}

function parseTemplate(content: string): {
meta: ParsedMeta
meta?: ParsedMeta
stories: ParsedStory[]
} {
const template = compileTemplate({
Expand All @@ -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 <Stories> element as root.')
if (roots.length === 0) {
throw new Error(
'No root element found in template, must be <Stories> or <Story>',
)
}

const root = roots[0]
if (root.type !== ELEMENT || root.tag !== 'Stories')
throw new Error('Expected root to be a <Stories> 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 <Story> elements are allowed as children of <Stories> or as root element',
)
}

const title = extractTitle(story)
if (!title) throw new Error('Story is missing a title')
Expand Down Expand Up @@ -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
}
17 changes: 7 additions & 10 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 },` : ''}
Expand All @@ -118,7 +115,7 @@ function generateDefaultImport(

function generateStoryImport(
{ id, title, play, template }: ParsedStory,
resolvedScript?: SFCScriptBlock,
resolvedScript?: SFCScriptBlock
) {
const { code } = compileTemplate({
source: template.trim(),
Expand All @@ -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
Expand Down
Loading