From cf7655a2ffd6ac76a368dd539a91dd06cf66d58c Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Sun, 21 Jul 2024 17:55:24 +0200 Subject: [PATCH] Merge pull request #27194 from storybookjs/larsrickert/improve-vue-source-code Vue: Improve generated code snippets (cherry picked from commit 6356195f5c5072c91180d1207b03e3670aafa098) --- .../vue3/src/docs/sourceDecorator.test.ts | 522 ++++++------ .../vue3/src/docs/sourceDecorator.ts | 777 ++++++++++++------ code/renderers/vue3/src/entry-preview-docs.ts | 11 +- .../SourceCode.stories.ts | 29 + .../SourceCode.vue | 27 + .../component-meta/DefineSlots.stories.ts | 6 +- .../component-meta/TemplateSlots.stories.ts | 6 +- 7 files changed, 813 insertions(+), 565 deletions(-) create mode 100644 code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.stories.ts create mode 100644 code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.vue diff --git a/code/renderers/vue3/src/docs/sourceDecorator.test.ts b/code/renderers/vue3/src/docs/sourceDecorator.test.ts index d695d7979c37..4f92bcd9cb27 100644 --- a/code/renderers/vue3/src/docs/sourceDecorator.test.ts +++ b/code/renderers/vue3/src/docs/sourceDecorator.test.ts @@ -1,304 +1,250 @@ -import { describe, expect, it } from 'vitest'; - +import { expect, test } from 'vitest'; +import { h } from 'vue'; +import type { SourceCodeGeneratorContext } from './sourceDecorator'; import { - mapAttributesAndDirectives, - generateAttributesSource, - attributeSource, - htmlEventAttributeToVueEventAttribute as htmlEventToVueEvent, + generatePropsSourceCode, + generateSlotSourceCode, + generateSourceCode, + getFunctionParamNames, + parseDocgenInfo, } from './sourceDecorator'; -expect.addSnapshotSerializer({ - print: (val: any) => val, - test: (val: unknown) => typeof val === 'string', -}); +test('should generate source code for props', () => { + const ctx: SourceCodeGeneratorContext = { + scriptVariables: {}, + imports: {}, + }; -describe('Vue3: sourceDecorator->mapAttributesAndDirective()', () => { - it('camelCase boolean Arg', () => { - expect(mapAttributesAndDirectives({ camelCaseBooleanArg: true })).toMatchInlineSnapshot(` - [ - { - arg: { - content: camel-case-boolean-arg, - loc: { - source: camel-case-boolean-arg, - }, - }, - exp: { - isStatic: false, - loc: { - source: true, - }, - }, - loc: { - source: :camel-case-boolean-arg="true", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - ] - `); - }); - it('camelCase string Arg', () => { - expect(mapAttributesAndDirectives({ camelCaseStringArg: 'foo' })).toMatchInlineSnapshot(` - [ - { - arg: { - content: camel-case-string-arg, - loc: { - source: camel-case-string-arg, - }, - }, - exp: { - isStatic: false, - loc: { - source: foo, - }, - }, - loc: { - source: camel-case-string-arg="foo", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - ] - `); - }); - it('boolean arg', () => { - expect(mapAttributesAndDirectives({ booleanarg: true })).toMatchInlineSnapshot(` - [ - { - arg: { - content: booleanarg, - loc: { - source: booleanarg, - }, - }, - exp: { - isStatic: false, - loc: { - source: true, - }, - }, - loc: { - source: :booleanarg="true", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - ] - `); - }); - it('string arg', () => { - expect(mapAttributesAndDirectives({ stringarg: 'bar' })).toMatchInlineSnapshot(` - [ - { - arg: { - content: stringarg, - loc: { - source: stringarg, - }, - }, - exp: { - isStatic: false, - loc: { - source: bar, - }, - }, - loc: { - source: stringarg="bar", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - ] - `); - }); - it('number arg', () => { - expect(mapAttributesAndDirectives({ numberarg: 2023 })).toMatchInlineSnapshot(` - [ - { - arg: { - content: numberarg, - loc: { - source: numberarg, - }, - }, - exp: { - isStatic: false, - loc: { - source: 2023, - }, - }, - loc: { - source: :numberarg="2023", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - ] - `); - }); - it('camelCase boolean, string, and number Args', () => { - expect( - mapAttributesAndDirectives({ - camelCaseBooleanArg: true, - camelCaseStringArg: 'foo', - cameCaseNumberArg: 2023, - }) - ).toMatchInlineSnapshot(` - [ - { - arg: { - content: camel-case-boolean-arg, - loc: { - source: camel-case-boolean-arg, - }, - }, - exp: { - isStatic: false, - loc: { - source: true, - }, - }, - loc: { - source: :camel-case-boolean-arg="true", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - { - arg: { - content: camel-case-string-arg, - loc: { - source: camel-case-string-arg, - }, - }, - exp: { - isStatic: false, - loc: { - source: foo, - }, - }, - loc: { - source: camel-case-string-arg="foo", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - { - arg: { - content: came-case-number-arg, - loc: { - source: came-case-number-arg, - }, - }, - exp: { - isStatic: false, - loc: { - source: 2023, - }, - }, - loc: { - source: :came-case-number-arg="2023", - }, - modifiers: [ - , - ], - name: bind, - type: 6, - }, - ] - `); + const code = generatePropsSourceCode( + { + a: 'foo', + b: '"I am double quoted"', + c: 42, + d: true, + e: false, + f: [1, 2, 3], + g: { + g1: 'foo', + g2: 42, + }, + h: undefined, + i: null, + j: '', + k: BigInt(9007199254740991), + l: Symbol(), + m: Symbol('foo'), + modelValue: 'test-v-model', + otherModelValue: 42, + default: 'default slot', + testSlot: 'test slot', + }, + ['default', 'testSlot'], + ['update:modelValue', 'update:otherModelValue'], + ctx + ); + + expect(code).toBe( + `a="foo" b='"I am double quoted"' :c="42" d :e="false" :f="f" :g="g" :k="BigInt(9007199254740991)" :l="Symbol()" :m="Symbol('foo')" v-model="modelValue" v-model:otherModelValue="otherModelValue"` + ); + + expect(ctx.scriptVariables).toStrictEqual({ + f: `[1,2,3]`, + g: `{"g1":"foo","g2":42}`, + modelValue: 'ref("test-v-model")', + otherModelValue: 'ref(42)', }); + + expect(Array.from(ctx.imports.vue.values())).toStrictEqual(['ref']); }); -describe('Vue3: sourceDecorator->generateAttributesSource()', () => { - it('camelCase boolean Arg', () => { - expect( - generateAttributesSource( - mapAttributesAndDirectives({ camelCaseBooleanArg: true }), - { camelCaseBooleanArg: true }, - [{ camelCaseBooleanArg: { type: 'boolean' } }] as any - ) - ).toMatchInlineSnapshot(`:camel-case-boolean-arg="true"`); - }); - it('camelCase string Arg', () => { - expect( - generateAttributesSource( - mapAttributesAndDirectives({ camelCaseStringArg: 'foo' }), - { camelCaseStringArg: 'foo' }, - [{ camelCaseStringArg: { type: 'string' } }] as any - ) - ).toMatchInlineSnapshot(`camel-case-string-arg="foo"`); +test('should generate source code for slots', () => { + // slot code generator should support primitive values (string, number etc.) + // but also VNodes (e.g. created using h()) so custom Vue components can also be used + // inside slots with proper generated code + + const slots = { + default: 'default content', + a: 'a content', + b: 42, + c: true, + // single VNode without props + d: h('div', 'd content'), + // VNode with props and single child + e: h('div', { style: 'color:red' }, 'e content'), + // VNode with props and single child returned as getter + f: h('div', { style: 'color:red' }, () => 'f content'), + // VNode with multiple children + g: h('div', { style: 'color:red' }, [ + 'child 1', + h('span', { style: 'color:green' }, 'child 2'), + ]), + // VNode multiple children but returned as getter + h: h('div', { style: 'color:red' }, () => [ + 'child 1', + h('span', { style: 'color:green' }, 'child 2'), + ]), + // VNode with multiple and nested children + i: h('div', { style: 'color:red' }, [ + 'child 1', + h('span', { style: 'color:green' }, ['nested child 1', h('p', 'nested child 2')]), + ]), + j: ['child 1', 'child 2'], + k: null, + l: { foo: 'bar' }, + m: BigInt(9007199254740991), + }; + + const expectedCode = `default content + + + + + + + + + + + + + + + + + + + + + + + +`; + + let actualCode = generateSlotSourceCode(slots, Object.keys(slots), { + scriptVariables: {}, + imports: {}, }); + expect(actualCode).toBe(expectedCode); + + // should generate the same code if getters/functions are used to return the slot content + const slotsWithGetters = Object.entries(slots).reduce< + Record (typeof slots)[keyof typeof slots]> + >((obj, [slotName, value]) => { + obj[slotName] = () => value; + return obj; + }, {}); - it('camelCase boolean, string, and number Args', () => { - expect( - generateAttributesSource( - mapAttributesAndDirectives({ - camelCaseBooleanArg: true, - camelCaseStringArg: 'foo', - cameCaseNumberArg: 2023, - }), - { - camelCaseBooleanArg: true, - camelCaseStringArg: 'foo', - cameCaseNumberArg: 2023, - }, - [] as any - ) - ).toMatchInlineSnapshot( - `:camel-case-boolean-arg="true" camel-case-string-arg="foo" :came-case-number-arg="2023"` - ); + actualCode = generateSlotSourceCode(slotsWithGetters, Object.keys(slotsWithGetters), { + scriptVariables: {}, + imports: {}, }); + expect(actualCode).toBe(expectedCode); }); -describe('Vue3: sourceDecorator->attributeSoure()', () => { - it('camelCase boolean Arg', () => { - expect(attributeSource('stringArg', 'foo')).toMatchInlineSnapshot(`stringArg="foo"`); - }); +test('should generate source code for slots with bindings', () => { + type TestBindings = { + foo: string; + bar?: number; + }; - it('html event attribute should convert to vue event directive', () => { - expect(attributeSource('onClick', () => {})).toMatchInlineSnapshot(`v-on:click='()=>({})'`); - expect(attributeSource('onclick', () => {})).toMatchInlineSnapshot(`v-on:click='()=>({})'`); - }); - it('normal html attribute should not convert to vue event directive', () => { - expect(attributeSource('on-click', () => {})).toMatchInlineSnapshot(`on-click='()=>({})'`); - }); - it('The value undefined or empty string must not be returned.', () => { - expect(attributeSource('icon', undefined)).toMatchInlineSnapshot(`icon=""`); - expect(attributeSource('icon', '')).toMatchInlineSnapshot(`icon=""`); + const slots = { + a: ({ foo, bar }: TestBindings) => `Slot with bindings ${foo} and ${bar}`, + b: ({ foo }: TestBindings) => h('a', { href: foo, target: foo }, `Test link: ${foo}`), + }; + + const expectedCode = ` + +`; + + const actualCode = generateSlotSourceCode(slots, Object.keys(slots), { + imports: {}, + scriptVariables: {}, }); - it('htmlEventAttributeToVueEventAttribute onEv => v-on:', () => { - const htmlEventAttributeToVueEventAttribute = (attribute: string) => { - return htmlEventToVueEvent(attribute); - }; - expect(/^on[A-Za-z]/.test('onClick')).toBeTruthy(); - expect(htmlEventAttributeToVueEventAttribute('onclick')).toMatchInlineSnapshot(`v-on:click`); - expect(htmlEventAttributeToVueEventAttribute('onClick')).toMatchInlineSnapshot(`v-on:click`); - expect(htmlEventAttributeToVueEventAttribute('onChange')).toMatchInlineSnapshot(`v-on:change`); - expect(htmlEventAttributeToVueEventAttribute('onFocus')).toMatchInlineSnapshot(`v-on:focus`); - expect(htmlEventAttributeToVueEventAttribute('on-focus')).toMatchInlineSnapshot(`on-focus`); + expect(actualCode).toBe(expectedCode); +}); + +test('should generate source code with + +`); +}); + +test.each([ + { __docgenInfo: 'invalid-value', slotNames: [] }, + { __docgenInfo: {}, slotNames: [] }, + { __docgenInfo: { slots: 'invalid-value' }, slotNames: [] }, + { __docgenInfo: { slots: ['invalid-value'] }, slotNames: [] }, + { + __docgenInfo: { slots: [{ name: 'slot-1' }, { name: 'slot-2' }, { notName: 'slot-3' }] }, + slotNames: ['slot-1', 'slot-2'], + }, +])('should parse slots names from __docgenInfo', ({ __docgenInfo, slotNames }) => { + const docgenInfo = parseDocgenInfo({ __docgenInfo }); + expect(docgenInfo.slotNames).toStrictEqual(slotNames); +}); + +test.each([ + { __docgenInfo: 'invalid-value', eventNames: [] }, + { __docgenInfo: {}, eventNames: [] }, + { __docgenInfo: { events: 'invalid-value' }, eventNames: [] }, + { __docgenInfo: { events: ['invalid-value'] }, eventNames: [] }, + { + __docgenInfo: { events: [{ name: 'event-1' }, { name: 'event-2' }, { notName: 'event-3' }] }, + eventNames: ['event-1', 'event-2'], + }, +])('should parse event names from __docgenInfo', ({ __docgenInfo, eventNames }) => { + const docgenInfo = parseDocgenInfo({ __docgenInfo }); + expect(docgenInfo.eventNames).toStrictEqual(eventNames); +}); + +test.each<{ fn: (...args: any[]) => unknown; expectedNames: string[] }>([ + { fn: () => ({}), expectedNames: [] }, + { fn: (a) => ({}), expectedNames: ['a'] }, + { fn: (a, b) => ({}), expectedNames: ['a', 'b'] }, + { fn: (a, b, { c }) => ({}), expectedNames: ['a', 'b', '{', 'c', '}'] }, + { fn: ({ a, b }) => ({}), expectedNames: ['{', 'a', 'b', '}'] }, + { + fn: { + // simulate minified function after running "storybook build" + toString: () => '({a:foo,b:bar})=>({})', + } as (...args: any[]) => unknown, + expectedNames: ['{', 'a', 'b', '}'], + }, +])('should extract function parameter names', ({ fn, expectedNames }) => { + const paramNames = getFunctionParamNames(fn); + expect(paramNames).toStrictEqual(expectedNames); }); diff --git a/code/renderers/vue3/src/docs/sourceDecorator.ts b/code/renderers/vue3/src/docs/sourceDecorator.ts index 28277a23b95b..764aacf91fe7 100644 --- a/code/renderers/vue3/src/docs/sourceDecorator.ts +++ b/code/renderers/vue3/src/docs/sourceDecorator.ts @@ -1,46 +1,119 @@ /* eslint-disable no-underscore-dangle */ +import { SNIPPET_RENDERED, SourceType } from 'storybook/internal/docs-tools'; import { addons } from 'storybook/internal/preview-api'; -import type { ArgTypes, Args, StoryContext } from 'storybook/internal/types'; - -import { SourceType, SNIPPET_RENDERED } from 'storybook/internal/docs-tools'; - -import type { - ElementNode, - AttributeNode, - DirectiveNode, - TextNode, - InterpolationNode, - TemplateChildNode, -} from '@vue/compiler-core'; -import { baseParse } from '@vue/compiler-core'; -import type { ConcreteComponent, FunctionalComponent, VNode } from 'vue'; -import { h, isVNode, watch } from 'vue'; -import kebabCase from 'lodash/kebabCase'; -import { - attributeSource, - htmlEventAttributeToVueEventAttribute, - omitEvent, - evalExp, - replaceValueWithRef, - generateExpression, -} from './utils'; -import type { VueRenderer } from '../types'; +import type { VNode } from 'vue'; +import { isVNode, watch } from 'vue'; +import type { Args, Decorator, StoryContext } from '../public-types'; /** - * Check if the sourcecode should be generated. - * - * @param context StoryContext + * Context that is passed down to nested components/slots when generating the source code for a single story. */ -const skipSourceRender = (context: StoryContext) => { - const sourceParams = context?.parameters.docs?.source; - const isArgsStory = context?.parameters.__isArgsStory; - const isDocsViewMode = context?.viewMode === 'docs'; +export type SourceCodeGeneratorContext = { + /** + * Properties/variables that should be placed inside a ` + +${template}`; +}; + +/** + * Checks if the source code generation should be skipped for the given Story context. + * Will be true if one of the following is true: + * - view mode is not "docs" + * - story is no arg story + * - story has set custom source code via parameters.docs.source.code + * - story has set source type to "code" via parameters.docs.source.type + */ +export const shouldSkipSourceCodeGeneration = (context: StoryContext): boolean => { + const sourceParams = context?.parameters.docs?.source; if (sourceParams?.type === SourceType.DYNAMIC) { + // always render if the user forces it return false; } + const isArgsStory = context?.parameters.__isArgsStory; + const isDocsViewMode = context?.viewMode === 'docs'; + // never render if the user is forcing the block to render code, or // if the user provides code, or if it's not an args story. return ( @@ -49,271 +122,441 @@ const skipSourceRender = (context: StoryContext) => { }; /** - * - * @param _args - * @param argTypes - * @param byRef - */ -export function generateAttributesSource( - tempArgs: (AttributeNode | DirectiveNode)[], - args: Args, - argTypes: ArgTypes, - byRef?: boolean -): string { - return Object.keys(tempArgs) - .map((key: any) => { - const source = tempArgs[key].loc.source.replace(/\$props/g, 'args'); - const argKey = (tempArgs[key] as DirectiveNode).arg?.loc.source; - return byRef && argKey - ? replaceValueWithRef(source, args, argKey) - : evalExp(source, omitEvent(args)); - }) - .join(' '); -} -/** - * map attributes and directives - * @param props + * Parses the __docgenInfo of the given component. + * Requires Storybook docs addon to be enabled. + * Default slot will always be sorted first, remaining slots are sorted alphabetically. */ -function mapAttributesAndDirectives(props: Args) { - const tranformKey = (key: string) => (key.startsWith('on') ? key : kebabCase(key)); - return Object.keys(props).map( - (key) => - ({ - name: 'bind', - type: ['v-', '@', 'v-on'].includes(key) ? 7 : 6, // 6 is attribute, 7 is directive - arg: { content: tranformKey(key), loc: { source: tranformKey(key) } }, // attribute name or directive name (v-bind, v-on, v-model) - loc: { source: attributeSource(tranformKey(key), props[key]) }, // attribute value or directive value - exp: { isStatic: false, loc: { source: props[key] } }, // directive expression - modifiers: [''], - }) as unknown as AttributeNode - ); -} +export const parseDocgenInfo = ( + component?: StoryContext['component'] & { __docgenInfo?: unknown } +) => { + // type check __docgenInfo to prevent errors + if ( + !component || + !('__docgenInfo' in component) || + !component.__docgenInfo || + typeof component.__docgenInfo !== 'object' + ) { + return { + displayName: component?.__name, + eventNames: [], + slotNames: [], + }; + } + + const docgenInfo = component.__docgenInfo as Record; + + const displayName = + 'displayName' in docgenInfo && typeof docgenInfo.displayName === 'string' + ? docgenInfo.displayName + : undefined; + + const parseNames = (key: 'slots' | 'events') => { + if (!(key in docgenInfo) || !Array.isArray(docgenInfo[key])) return []; + + const values = docgenInfo[key] as unknown[]; + + return values + .map((i) => (i && typeof i === 'object' && 'name' in i ? i.name : undefined)) + .filter((i): i is string => typeof i === 'string'); + }; + + return { + displayName: displayName || component.__name, + slotNames: parseNames('slots').sort((a, b) => { + if (a === 'default') return -1; + if (b === 'default') return 1; + return a.localeCompare(b); + }), + eventNames: parseNames('events'), + }; +}; + /** - * map slots - * @param slotsArgs + * Generates the source code for the given Vue component properties. + * Props with complex values (objects and arrays) and v-models will be added to the ctx.scriptVariables because they should be + * generated in a ``; -} /** - * get template components one or more - * @param renderFn + * Generates the source code for the given slot children (the code inside ). */ -function getTemplateComponents( - renderFn: any, - context?: StoryContext -): (TemplateChildNode | VNode)[] { - try { - const originalStoryFn = renderFn; - - const storyFn = originalStoryFn ? originalStoryFn(context?.args, context) : context?.component; - const story = typeof storyFn === 'function' ? storyFn() : storyFn; - - const { template } = story; - - if (!template) return [h(story, context?.args)]; - return getComponents(template); - } catch (e) { - return []; - } -} +const generateSlotChildrenSourceCode = ( + children: unknown[], + ctx: SourceCodeGeneratorContext +): string => { + const slotChildrenSourceCodes: string[] = []; + + /** + * Recursively generates the source code for a single slot child and all its children. + * @returns Source code for child and all nested children or empty string if child is of a non-supported type. + */ + const generateSingleChildSourceCode = (child: unknown): string => { + if (isVNode(child)) { + return generateVNodeSourceCode(child, ctx); + } + + switch (typeof child) { + case 'string': + case 'number': + case 'boolean': + return child.toString(); + + case 'object': + if (child === null) return ''; + if (Array.isArray(child)) { + // if child also has children, we generate them recursively + return child + .map(generateSingleChildSourceCode) + .filter((code) => code !== '') + .join('\n'); + } + return JSON.stringify(child); + + case 'function': { + const paramNames = getFunctionParamNames(child).filter( + (param) => !['{', '}'].includes(param) + ); + + const parameters = paramNames.reduce>((obj, param) => { + obj[param] = `{{ ${param} }}`; + return obj; + }, {}); + + const returnValue = child(parameters); + let slotSourceCode = generateSlotChildrenSourceCode([returnValue], ctx); -function getComponents(template: string): (TemplateChildNode | VNode)[] { - const ast = baseParse(template, { - isNativeTag: () => true, - decodeEntities: (rawtext, asAttr) => rawtext, + // if slot bindings are used for properties of other components, our {{ paramName }} is incorrect because + // it would generate e.g. my-prop="{{ paramName }}", therefore, we replace it here to e.g. :my-prop="paramName" + paramNames.forEach((param) => { + slotSourceCode = slotSourceCode.replaceAll( + new RegExp(` (\\S+)="{{ ${param} }}"`, 'g'), + ` :$1="${param}"` + ); + }); + + return slotSourceCode; + } + + case 'bigint': + return `{{ BigInt(${child.toString()}) }}`; + + // the only missing case here is "symbol" + // because rendering a symbol as slot / HTML does not make sense and is not supported by Vue + default: + return ''; + } + }; + + children.forEach((child) => { + const sourceCode = generateSingleChildSourceCode(child); + if (sourceCode !== '') slotChildrenSourceCodes.push(sourceCode); }); - const components = ast?.children; - if (!components) return []; - return components; -} + + return slotChildrenSourceCodes.join('\n'); +}; /** - * Generate a vue3 template. + * Generates source code for the given VNode and all its children (e.g. created using `h(MyComponent)` or `h("div")`). + */ +const generateVNodeSourceCode = (vnode: VNode, ctx: SourceCodeGeneratorContext): string => { + const componentName = getVNodeName(vnode); + let childrenCode = ''; + + if (typeof vnode.children === 'string') { + childrenCode = vnode.children; + } else if (Array.isArray(vnode.children)) { + childrenCode = generateSlotChildrenSourceCode(vnode.children, ctx); + } else if (vnode.children) { + // children are an object, just like if regular Story args where used + // so we can generate the source code with the regular "generateSlotSourceCode()". + childrenCode = generateSlotSourceCode( + vnode.children, + // $stable is a default property in vnode.children so we need to filter it out + // to not generate source code for it + Object.keys(vnode.children).filter((i) => i !== '$stable'), + ctx + ); + } + + const props = vnode.props ? generatePropsSourceCode(vnode.props, [], [], ctx) : ''; + + // prefer self closing tag if no children exist + if (childrenCode) { + return `<${componentName}${props ? ` ${props}` : ''}>${childrenCode}`; + } + return `<${componentName}${props ? ` ${props}` : ''} />`; +}; + +/** + * Gets the name for the given VNode. + * Will return "component" if name could not be extracted. * - * @param component Component - * @param args Args - * @param argTypes ArgTypes - * @param slotProp Prop used to simulate a slot + * @example "div" for `h("div")` or "MyComponent" for `h(MyComponent)` */ +const getVNodeName = (vnode: VNode) => { + // this is e.g. the case when rendering native HTML elements like, h("div") + if (typeof vnode.type === 'string') return vnode.type; -export function generateTemplateSource( - componentOrNodes: (ConcreteComponent | TemplateChildNode)[] | TemplateChildNode | VNode, - { args, argTypes }: { args: Args; argTypes: ArgTypes }, - byRef = false -) { - const isElementNode = (node: any) => node && node.type === 1; - const isInterpolationNode = (node: any) => node && node.type === 5; - const isTextNode = (node: any) => node && node.type === 2; - - const generateComponentSource = ( - componentOrNode: ConcreteComponent | TemplateChildNode | VNode - ) => { - if (isElementNode(componentOrNode)) { - const { tag: name, props: attributes, children } = componentOrNode as ElementNode; - const childSources: string = - typeof children === 'string' - ? children - : children.map((child: TemplateChildNode) => generateComponentSource(child)).join(''); - const props = generateAttributesSource(attributes, args, argTypes, byRef); - - return childSources === '' - ? `<${name} ${props} />` - : `<${name} ${props}>${childSources}`; + if (typeof vnode.type === 'object') { + // this is the case when using custom Vue components like h(MyComponent) + if ('name' in vnode.type && vnode.type.name) { + // prefer custom component name set by the developer + return vnode.type.name; + } else if ('__name' in vnode.type && vnode.type.__name) { + // otherwise use name inferred by Vue from the file name + return vnode.type.__name; } + } - if (isTextNode(componentOrNode)) { - const { content } = componentOrNode as TextNode; - return content; - } - if (isInterpolationNode(componentOrNode)) { - const { content } = componentOrNode as InterpolationNode; - const expValue = evalExp(content.loc.source, args); - if (expValue === content.loc.source) return `{{${expValue}}}`; - return eval(expValue); - } - if (isVNode(componentOrNode)) { - const vnode = componentOrNode as VNode; - const { props, type, children } = vnode; - const slotsProps = typeof children === 'string' ? undefined : (children as Args); - const componentSlots = (type as any)?.__docgenInfo?.slots; - - const attrsProps = slotsProps - ? Object.fromEntries( - Object.entries(props ?? {}) - .filter(([key, value]) => !slotsProps[key] && !['class', 'style'].includes(key)) - .map(([key, value]) => [key, value]) - ) - : props; - const attributes = mapAttributesAndDirectives(attrsProps ?? {}); - const slotArgs = Object.fromEntries( - Object.entries(props ?? {}).filter(([key, value]) => slotsProps?.[key]) - ); + return 'component'; +}; - const childSources: string = children - ? typeof children === 'string' - ? children - : mapSlots(slotArgs as Args, generateComponentSource, componentSlots ?? []) - .map((child) => child.content) - .join('') - : ''; - const name = - typeof type === 'string' - ? type - : (type as FunctionalComponent).name || - (type as ConcreteComponent).__name || - (type as any).__docgenInfo?.displayName; - const propsSource = generateAttributesSource(attributes, args, argTypes, byRef); - return childSources.trim() === '' - ? `<${name} ${propsSource}/>` - : `<${name} ${propsSource}>${childSources}`; - } +/** + * Gets a list of parameters for the given function since func.arguments can not be used since + * it throws a TypeError. + * + * If the arguments are destructured (e.g. "func({ foo, bar })"), the returned array will also + * include "{" and "}". + * + * @see Based on https://stackoverflow.com/a/9924463 + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export const getFunctionParamNames = (func: Function): string[] => { + const STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm; + const ARGUMENT_NAMES = /([^\s,]+)/g; - return null; - }; + const fnStr = func.toString().replace(STRIP_COMMENTS, ''); + const result = fnStr.slice(fnStr.indexOf('(') + 1, fnStr.indexOf(')')).match(ARGUMENT_NAMES); + if (!result) return []; - const componentsOrNodes = Array.isArray(componentOrNodes) ? componentOrNodes : [componentOrNodes]; - const source = componentsOrNodes - .map((componentOrNode) => generateComponentSource(componentOrNode)) - .join(' '); - return source || null; -} + // when running "storybook build", the function will be minified, so result for e.g. + // `({ foo, bar }) => { // function body }` will be `["{foo:e", "bar:a}"]` + // therefore we need to remove the :e and :a mappings and extract the "{" and "}"" from the destructured object + // so the final result becomes `["{", "foo", "bar", "}"]` + return result.flatMap((param) => { + if (['{', '}'].includes(param)) return param; + const nonMinifiedName = param.split(':')[0].trim(); + if (nonMinifiedName.startsWith('{')) { + return ['{', nonMinifiedName.substring(1)]; + } + if (param.endsWith('}') && !nonMinifiedName.endsWith('}')) { + return [nonMinifiedName, '}']; + } + return nonMinifiedName; + }); +}; /** - * source decorator. - * @param storyFn Fn - * @param context StoryContext + * Converts the given slot bindings/parameters to a string. + * + * @example + * If no params: '#slotName' + * If params: '#slotName="{ foo, bar }"' */ -export const sourceDecorator = (storyFn: any, context: StoryContext) => { - const skip = skipSourceRender(context); - const story = storyFn(); +const slotBindingsToString = ( + slotName: string, + params: string[] +): `#${string}` | `#${string}="${string}"` => { + if (!params.length) return `#${slotName}`; + if (params.length === 1) return `#${slotName}="${params[0]}"`; - watch( - () => context.args, - () => { - if (!skip) { - generateSource(context); - } - }, - { immediate: true, deep: true } - ); - return story; + // parameters might be destructured so remove duplicated brackets here + return `#${slotName}="{ ${params.filter((i) => !['{', '}'].includes(i)).join(', ')} }"`; }; -export function generateSource(context: StoryContext) { - const channel = addons.getChannel(); - const { args = {}, argTypes = {}, id } = context || {}; - const storyComponents = getTemplateComponents(context?.originalStoryFn, context); +/** + * Formats the given object as string. + * Will format in single line if it only contains non-object values. + * Otherwise will format multiline. + */ +export const formatObject = (obj: object): string => { + const isPrimitive = Object.values(obj).every( + (value) => value == null || typeof value !== 'object' + ); - const withScript = context?.parameters?.docs?.source?.withScriptSetup || false; - const generatedScript = withScript ? generateScriptSetup(args, argTypes, storyComponents) : ''; - const generatedTemplate = generateTemplateSource(storyComponents, context, withScript); + // if object/array only contains non-object values, we format all values in one line + if (isPrimitive) return JSON.stringify(obj); - if (generatedTemplate) { - const source = `${generatedScript}\n `; - channel.emit(SNIPPET_RENDERED, { id, args, source, format: 'vue' }); - return source; - } - return null; -} -// export local function for testing purpose -export { - generateScriptSetup, - getTemplateComponents as getComponentsFromRenderFn, - getComponents as getComponentsFromTemplate, - mapAttributesAndDirectives, - attributeSource, - htmlEventAttributeToVueEventAttribute, + // otherwise, we use a "pretty" formatting with newlines and spaces + return JSON.stringify(obj, null, 2); }; diff --git a/code/renderers/vue3/src/entry-preview-docs.ts b/code/renderers/vue3/src/entry-preview-docs.ts index 66e848f2bc12..16c9338a164b 100644 --- a/code/renderers/vue3/src/entry-preview-docs.ts +++ b/code/renderers/vue3/src/entry-preview-docs.ts @@ -1,6 +1,9 @@ -import type { ArgTypesEnhancer, DecoratorFunction } from 'storybook/internal/types'; -import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; -import { extractComponentDescription, enhanceArgTypes } from 'storybook/internal/docs-tools'; +import { + enhanceArgTypes, + extractComponentDescription, + type ArgTypesExtractor, +} from 'storybook/internal/docs-tools'; +import type { ArgTypesEnhancer } from 'storybook/internal/types'; import { extractArgTypes } from './docs/extractArgTypes'; import { sourceDecorator } from './docs/sourceDecorator'; import type { VueRenderer } from './types'; @@ -21,6 +24,6 @@ export const parameters: { }, }; -export const decorators: DecoratorFunction[] = [sourceDecorator]; +export const decorators = [sourceDecorator]; export const argTypesEnhancers: ArgTypesEnhancer[] = [enhanceArgTypes]; diff --git a/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.stories.ts b/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.stories.ts new file mode 100644 index 000000000000..c9500c509a48 --- /dev/null +++ b/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.stories.ts @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/vue3'; +import { h } from 'vue'; +import SourceCode from './SourceCode.vue'; + +const meta: Meta = { + component: SourceCode, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default = { + args: { + foo: 'Example string', + bar: 42, + array: ['A', 'B', 'C'], + object: { + a: 'Test A', + b: 42, + }, + modelValue: 'Model value', + default: 'Default slot content', + namedSlot: ({ foo }) => [ + 'Plain text', + h('div', { style: 'color:red' }, ['Div child', h('span', foo)]), + ], + }, +} satisfies Story; diff --git a/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.vue b/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.vue new file mode 100644 index 000000000000..6fae0e0ec5d8 --- /dev/null +++ b/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceCode.vue @@ -0,0 +1,27 @@ + + + diff --git a/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/DefineSlots.stories.ts b/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/DefineSlots.stories.ts index 1a06ce6bb504..da11664027c9 100644 --- a/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/DefineSlots.stories.ts +++ b/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/DefineSlots.stories.ts @@ -11,8 +11,8 @@ export default meta; export const Default: Story = { args: { - default: ({ num }) => `Default slot { num=${num} }`, - named: ({ str }) => `Named slot { str=${str} }`, - vbind: ({ num, str }) => `Named v-bind slot { num=${num}, str=${str} }`, + default: ({ num }) => `Default slot: num=${num}`, + named: ({ str }) => `Named slot: str=${str}`, + vbind: ({ num, str }) => `Named v-bind slot: num=${num}, str=${str}`, }, }; diff --git a/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/TemplateSlots.stories.ts b/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/TemplateSlots.stories.ts index 817bdcba42df..7ae73f819caf 100644 --- a/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/TemplateSlots.stories.ts +++ b/code/renderers/vue3/template/stories_vue3-vite-default-ts/component-meta/TemplateSlots.stories.ts @@ -11,8 +11,8 @@ export default meta; export const Default: Story = { args: { - default: ({ num }) => `Default slot { num=${num} }`, - named: ({ str }) => `Named slot { str=${str} }`, - vbind: ({ num, str }) => `Named v-bind slot { num=${num}, str=${str} }`, + default: ({ num }) => `Default slot: num=${num}`, + named: ({ str }) => `Named slot: str=${str}`, + vbind: ({ num, str }) => `Named v-bind slot: num=${num}, str=${str}`, }, };