From 639bd08e7d9a451686f68637ee961ba7ceebe1f4 Mon Sep 17 00:00:00 2001 From: Chakir QATAB Date: Thu, 8 Jun 2023 13:14:30 +0400 Subject: [PATCH] Merge pull request #22518 from storybookjs/chaks/source-decorator-feat-fix Vue3: fix source decorator to generate proper story code (cherry picked from commit 6f0c2fc2912084577996348633e7242c7f93d12f) --- .../vue3/src/docs/sourceDecorator.test.ts | 355 +++++++++++--- .../vue3/src/docs/sourceDecorator.ts | 449 +++++++++--------- code/renderers/vue3/src/docs/utils.ts | 91 ++++ .../SourceDecorator.stories.ts | 46 ++ 4 files changed, 644 insertions(+), 297 deletions(-) create mode 100644 code/renderers/vue3/src/docs/utils.ts create mode 100644 code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceDecorator.stories.ts diff --git a/code/renderers/vue3/src/docs/sourceDecorator.test.ts b/code/renderers/vue3/src/docs/sourceDecorator.test.ts index d3189e9eb767..40d5f4a8da64 100644 --- a/code/renderers/vue3/src/docs/sourceDecorator.test.ts +++ b/code/renderers/vue3/src/docs/sourceDecorator.test.ts @@ -1,95 +1,302 @@ import { describe, expect, test } from '@jest/globals'; import type { Args } from '@storybook/types'; -import { generateSource } from './sourceDecorator'; + +import type { ArgsType } from 'jest-mock'; +import { + mapAttributesAndDirectives, + generateAttributesSource, + attributeSource, + htmlEventAttributeToVueEventAttribute as htmlEventToVueEvent, +} from './sourceDecorator'; expect.addSnapshotSerializer({ print: (val: any) => val, test: (val: unknown) => typeof val === 'string', }); -function generateArgTypes(args: Args, slotProps: string[] | undefined) { - return Object.keys(args).reduce((acc, prop) => { - acc[prop] = { table: { category: slotProps?.includes(prop) ? 'slots' : 'props' } }; - return acc; - }, {} as Record); -} -function generateForArgs(args: Args, slotProps: string[] | undefined = undefined) { - return generateSource({ name: 'Component' }, args, generateArgTypes(args, slotProps), true); -} -function generateMultiComponentForArgs(args: Args, slotProps: string[] | undefined = undefined) { - return generateSource( - [{ name: 'Component' }, { name: 'Component' }], - args, - generateArgTypes(args, slotProps), - true - ); -} -describe('generateSource Vue3', () => { - test('boolean true', () => { - expect(generateForArgs({ booleanProp: true })).toMatchInlineSnapshot( - `` - ); +describe('Vue3: sourceDecorator->mapAttributesAndDirective()', () => { + test('camelCase boolean Arg', () => { + expect(mapAttributesAndDirectives({ camelCaseBooleanArg: true })).toMatchInlineSnapshot(` + Array [ + Object { + arg: Object { + content: camel-case-boolean-arg, + loc: Object { + source: camel-case-boolean-arg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: true, + }, + }, + loc: Object { + source: :camel-case-boolean-arg="true", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + ] + `); }); - test('boolean false', () => { - expect(generateForArgs({ booleanProp: false })).toMatchInlineSnapshot( - `` - ); + test('camelCase string Arg', () => { + expect(mapAttributesAndDirectives({ camelCaseStringArg: 'foo' })).toMatchInlineSnapshot(` + Array [ + Object { + arg: Object { + content: camel-case-string-arg, + loc: Object { + source: camel-case-string-arg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: foo, + }, + }, + loc: Object { + source: camel-case-string-arg="foo", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + ] + `); }); - test('null property', () => { - expect(generateForArgs({ nullProp: null })).toMatchInlineSnapshot( - `` - ); + test('boolean arg', () => { + expect(mapAttributesAndDirectives({ booleanarg: true })).toMatchInlineSnapshot(` + Array [ + Object { + arg: Object { + content: booleanarg, + loc: Object { + source: booleanarg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: true, + }, + }, + loc: Object { + source: :booleanarg="true", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + ] + `); }); - test('string property', () => { - expect(generateForArgs({ stringProp: 'mystr' })).toMatchInlineSnapshot( - `` - ); + test('string arg', () => { + expect(mapAttributesAndDirectives({ stringarg: 'bar' })).toMatchInlineSnapshot(` + Array [ + Object { + arg: Object { + content: stringarg, + loc: Object { + source: stringarg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: bar, + }, + }, + loc: Object { + source: stringarg="bar", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + ] + `); }); - test('number property', () => { - expect(generateForArgs({ numberProp: 42 })).toMatchInlineSnapshot( - `` - ); + test('number arg', () => { + expect(mapAttributesAndDirectives({ numberarg: 2023 })).toMatchInlineSnapshot(` + Array [ + Object { + arg: Object { + content: numberarg, + loc: Object { + source: numberarg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: 2023, + }, + }, + loc: Object { + source: :numberarg="2023", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + ] + `); }); - test('object property', () => { - expect(generateForArgs({ objProp: { x: true } })).toMatchInlineSnapshot( - `` - ); + test('camelCase boolean, string, and number Args', () => { + expect( + mapAttributesAndDirectives({ + camelCaseBooleanArg: true, + camelCaseStringArg: 'foo', + cameCaseNumberArg: 2023, + }) + ).toMatchInlineSnapshot(` + Array [ + Object { + arg: Object { + content: camel-case-boolean-arg, + loc: Object { + source: camel-case-boolean-arg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: true, + }, + }, + loc: Object { + source: :camel-case-boolean-arg="true", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + Object { + arg: Object { + content: camel-case-string-arg, + loc: Object { + source: camel-case-string-arg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: foo, + }, + }, + loc: Object { + source: camel-case-string-arg="foo", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + Object { + arg: Object { + content: came-case-number-arg, + loc: Object { + source: came-case-number-arg, + }, + }, + exp: Object { + isStatic: false, + loc: Object { + source: 2023, + }, + }, + loc: Object { + source: :came-case-number-arg="2023", + }, + modifiers: Array [ + , + ], + name: bind, + type: 6, + }, + ] + `); }); - test('multiple properties', () => { - expect(generateForArgs({ a: 1, b: 2 })).toMatchInlineSnapshot(``); +}); + +describe('Vue3: sourceDecorator->generateAttributesSource()', () => { + test('camelCase boolean Arg', () => { + expect( + generateAttributesSource( + mapAttributesAndDirectives({ camelCaseBooleanArg: true }), + { camelCaseBooleanArg: true }, + [{ camelCaseBooleanArg: { type: 'boolean' } }] as ArgsType + ) + ).toMatchInlineSnapshot(`:camel-case-boolean-arg="true"`); }); - test('1 slot property', () => { - expect(generateForArgs({ content: 'xyz', myProp: 'abc' }, ['content'])).toMatchInlineSnapshot(` - - {{ content }} - - `); + test('camelCase string Arg', () => { + expect( + generateAttributesSource( + mapAttributesAndDirectives({ camelCaseStringArg: 'foo' }), + { camelCaseStringArg: 'foo' }, + [{ camelCaseStringArg: { type: 'string' } }] as ArgsType + ) + ).toMatchInlineSnapshot(`camel-case-string-arg="foo"`); }); - test('multiple slot property with second slot value not set', () => { - expect(generateForArgs({ content: 'xyz', myProp: 'abc' }, ['content', 'footer'])) - .toMatchInlineSnapshot(` - - {{ content }} - - `); + + test('camelCase boolean, string, and number Args', () => { + expect( + generateAttributesSource( + mapAttributesAndDirectives({ + camelCaseBooleanArg: true, + camelCaseStringArg: 'foo', + cameCaseNumberArg: 2023, + }), + { + camelCaseBooleanArg: true, + camelCaseStringArg: 'foo', + cameCaseNumberArg: 2023, + }, + [] as ArgsType + ) + ).toMatchInlineSnapshot( + `:camel-case-boolean-arg="true" camel-case-string-arg="foo" :came-case-number-arg="2023"` + ); }); - test('multiple slot property with second slot value is set', () => { - expect(generateForArgs({ content: 'xyz', footer: 'foo', myProp: 'abc' }, ['content', 'footer'])) - .toMatchInlineSnapshot(` - - - - - `); +}); + +describe('Vue3: sourceDecorator->attributeSoure()', () => { + test('camelCase boolean Arg', () => { + expect(attributeSource('stringArg', 'foo')).toMatchInlineSnapshot(`stringArg="foo"`); }); - // test mutil components - test('multi component with boolean true', () => { - expect(generateMultiComponentForArgs({ booleanProp: true })).toMatchInlineSnapshot(` - - - `); + + test('html event attribute should convert to vue event directive', () => { + expect(attributeSource('onClick', () => {})).toMatchInlineSnapshot(`v-on:click='()=>({})'`); + expect(attributeSource('onclick', () => {})).toMatchInlineSnapshot(`v-on:click='()=>({})'`); + }); + test('normal html attribute should not convert to vue event directive', () => { + expect(attributeSource('on-click', () => {})).toMatchInlineSnapshot(`on-click='()=>({})'`); }); - test('component is not set', () => { - expect(generateSource(null, {}, {})).toBeNull(); + test('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`); }); }); diff --git a/code/renderers/vue3/src/docs/sourceDecorator.ts b/code/renderers/vue3/src/docs/sourceDecorator.ts index 59ac746ae027..007980f1ff14 100644 --- a/code/renderers/vue3/src/docs/sourceDecorator.ts +++ b/code/renderers/vue3/src/docs/sourceDecorator.ts @@ -1,23 +1,32 @@ +/* eslint-disable no-eval */ +/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable no-underscore-dangle */ -import { addons, useEffect } from '@storybook/preview-api'; +import { addons } from '@storybook/preview-api'; import type { ArgTypes, Args, StoryContext, Renderer } from '@storybook/types'; import { SourceType, SNIPPET_RENDERED } from '@storybook/docs-tools'; -// eslint-disable-next-line import/no-extraneous-dependencies -import parserHTML from 'prettier/parser-html'; +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'; +import { + attributeSource, + htmlEventAttributeToVueEventAttribute, + omitEvent, + evalExp, + replaceValueWithRef, + generateExpression, +} from './utils'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { isArray } from '@vue/shared'; -import { toRaw } from 'vue'; - -type ArgEntries = [string, any][]; -type Attribute = { - name: string; - value: string; - sourceSpan?: any; - valueSpan?: any; -} & Record; /** * Check if the sourcecode should be generated. * @@ -26,6 +35,7 @@ type Attribute = { const skipSourceRender = (context: StoryContext) => { const sourceParams = context?.parameters.docs?.source; const isArgsStory = context?.parameters.__isArgsStory; + const isDocsViewMode = context?.viewMode === 'docs'; // always render if the user forces it if (sourceParams?.type === SourceType.DYNAMIC) { @@ -34,92 +44,94 @@ const skipSourceRender = (context: StoryContext) => { // 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 !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE; + return ( + !isDocsViewMode || !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE + ); }; -/** - * Extract a component name. - * - * @param component Component - */ -function getComponentNameAndChildren(component: any): { - name: string | null; - children: any; - attributes: any; -} { - return { - name: component?.name || component?.__name || component?.__docgenInfo?.__name || null, - children: component?.children || null, - attributes: component?.attributes || component?.attrs || null, - }; -} /** * * @param _args * @param argTypes * @param byRef */ -function generateAttributesSource(_args: Args, argTypes: ArgTypes, byRef?: boolean): string { - // create a copy of the args object to avoid modifying the original - const args = { ...toRaw(_args) }; - // filter out keys that are children or slots, and convert event keys to the proper format - const argsKeys = Object.keys(args) - .filter( - (key: any) => - ['children', 'slots'].indexOf(argTypes[key]?.table?.category) === -1 || !argTypes[key] // remove slots and children - ) - .map((key) => { - const akey = - argTypes[key]?.table?.category !== 'events' // is event - ? key - .replace(/([A-Z])/g, '-$1') - .replace(/^on-/, 'v-on:') - .replace(/^:/, '') - .toLowerCase() - : `v-on:${key}`; - args[akey] = args[key]; - return akey; +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)); }) - .filter((key, index, self) => self.indexOf(key) === index); // remove duplicated keys - - const camelCase = (str: string) => str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); - const source = argsKeys - .map((key) => - generateAttributeSource( - byRef && !key.includes(':') ? `:${key}` : key, - byRef && !key.includes(':') ? camelCase(key) : args[key], - argTypes[key] - ) - ) .join(' '); - - return source; } - -function generateAttributeSource( - key: string, - value: Args[keyof Args], - argType: ArgTypes[keyof ArgTypes] -): string { - if (!value) { - return ''; - } - - if (value === true) { - return key; - } - - if (key.startsWith('v-on:')) { - return `${key}='() => {}'`; - } - - if (typeof value === 'string') { - return `${key}='${value}'`; - } - - return `:${key}='${JSON.stringify(value)}'`; +/** + * map attributes and directives + * @param props + */ +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) + ); } +/** + * map slots + * @param slotsArgs + */ +function mapSlots( + slotsArgs: Args, + generateComponentSource: any, + slots: { name: string; scoped?: boolean; bindings?: { name: string }[] }[] +): TextNode[] { + return Object.keys(slotsArgs).map((key) => { + const slot = slotsArgs[key]; + let slotContent = ''; + + const scropedArgs = slots + .find((s) => s.name === key && s.scoped) + ?.bindings?.map((b) => b.name) + .join(','); + + if (typeof slot === 'function') { + slotContent = generateExpression(slot); + } + if (isVNode(slot)) { + slotContent = generateComponentSource(slot); + } + if (typeof slot === 'object' && !isVNode(slot)) { + slotContent = JSON.stringify(slot); + } + const bindingsString = scropedArgs ? `="{${scropedArgs}}"` : ''; + slotContent = slot ? `` : ``; + + return { + type: 2, + content: slotContent, + loc: { + source: slotContent, + start: { offset: 0, line: 1, column: 0 }, + end: { offset: 0, line: 1, column: 0 }, + }, + }; + }); + // TODO: handle other cases (array, object, html,etc) +} /** * * @param args generate script setup from args @@ -137,35 +149,34 @@ function generateScriptSetup(args: Args, argTypes: ArgTypes, components: any[]): return ``; } /** - * get component templates one or more + * get template components one or more * @param renderFn */ -function getTemplates(renderFn: any): [] { +function getTemplateComponents( + renderFn: any, + context?: StoryContext +): (TemplateChildNode | VNode)[] { try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const ast = parserHTML.parsers.vue.parse(renderFn.toString()); - let components = ast.children?.filter( - ({ name: _name = '', type: _type = '' }) => - _name && !['template', 'script', 'style', 'slot'].includes(_name) && _type === 'element' - ); - if (!isArray(components)) { - return []; - } - components = components.map( - ({ attrs: attributes = [], name: Name = '', children: Children = [] }) => { - return { - name: Name, - attrs: attributes, - children: Children, - }; - } - ); - return components; + 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) { - // console.error(e); + console.log('error', e); + return []; } - return []; +} + +function getComponents(template: string): (TemplateChildNode | VNode)[] { + const ast = baseParse(template); + const components = ast?.children; + if (!components) return []; + return components; } /** @@ -176,141 +187,133 @@ function getTemplates(renderFn: any): [] { * @param argTypes ArgTypes * @param slotProp Prop used to simulate a slot */ -export function generateSource( - compOrComps: any, - args: Args, - argTypes: ArgTypes, - byRef?: boolean | undefined -): string | null { - if (!compOrComps) return null; - const generateComponentSource = (component: any): string | null => { - const { name, children, attributes } = getComponentNameAndChildren(component); - if (!name) { - return ''; +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}`; } - const argsIn = attributes ? getArgsInAttrs(args, attributes) : args; // keep only args that are in attributes - const props = generateAttributesSource(argsIn, argTypes, byRef); - const slotArgs = Object.entries(argsIn).filter( - ([arg]) => argTypes[arg]?.table?.category === 'slots' - ); - const slotProps = Object.entries(argTypes).filter( - ([arg]) => argTypes[arg]?.table?.category === 'slots' - ); - if (slotArgs && slotArgs.length > 0) { - const namedSlotContents = createNamedSlots(slotArgs, slotProps, byRef); - return `<${name} ${props}>\n${namedSlotContents}\n`; + if (isTextNode(componentOrNode)) { + const { content } = componentOrNode as TextNode; + return content; } - - if (children && children.length > 0) { - const childrenSource = children.map((child: any) => { - return generateSource( - typeof child.value === 'string' ? getTemplates(child.value) : child.value, - args, - argTypes, - byRef - ); - }); - - if (childrenSource.join('').trim() === '') return `<${name} ${props}/>`; - - const isNativeTag = - name.includes('template') || - name.match(/^[a-z]/) || - (name === 'Fragment' && !name.includes('-')); - - return `<${name} ${isNativeTag ? '' : props}>\n${childrenSource}\n`; + 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]) + ); + // eslint-disable-next-line no-nested-ternary + const childSources: string = children + ? typeof children === 'string' + ? children + : mapSlots(slotArgs as Args, generateComponentSource, componentSlots ?? []) + .map((child) => child.content) + .join('') + : ''; + console.log(' vnode ', vnode, ' childSources ', childSources, ' attributes ', attributes); + 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}`; } - return `<${name} ${props}/>`; + return null; }; - // get one component or multiple - const components = isArray(compOrComps) ? compOrComps : [compOrComps]; - - const source = Object.keys(components) - .map((key: any) => `${generateComponentSource(components[key])}`) - .join(`\n`); - return source; -} -/** - * create Named Slots content in source - * @param slotProps - * @param slotArgs - */ - -function createNamedSlots(slotArgs: ArgEntries, slotProps: ArgEntries, byRef?: boolean) { - if (!slotArgs) return ''; - const many = slotProps.length > 1; - return slotArgs - .map(([key, value]) => { - const content = !byRef ? JSON.stringify(value) : `{{ ${key} }}`; - return many ? ` ` : ` ${content}`; - }) - .join('\n'); -} - -function getArgsInAttrs(args: Args, attributes: Attribute[]) { - return Object.keys(args).reduce((acc, prop) => { - if (attributes?.find((attr: any) => attr.name === 'v-bind')) { - acc[prop] = args[prop]; - } - const attribute = attributes?.find( - (attr: any) => attr.name === prop || attr.name === `:${prop}` - ); - if (attribute) { - acc[prop] = attribute.name === `:${prop}` ? args[prop] : attribute.value; - } - if (Object.keys(acc).length === 0) { - attributes?.forEach((attr: any) => { - acc[attr.name] = JSON.parse(JSON.stringify(attr.value)); - }); - } - return acc; - }, {} as Record); + const componentsOrNodes = Array.isArray(componentOrNodes) ? componentOrNodes : [componentOrNodes]; + const source = componentsOrNodes + .map((componentOrNode) => generateComponentSource(componentOrNode)) + .join(' '); + return source || null; } -/** - * format prettier for vue - * @param source - */ - /** * source decorator. * @param storyFn Fn * @param context StoryContext */ export const sourceDecorator = (storyFn: any, context: StoryContext) => { - const channel = addons.getChannel(); const skip = skipSourceRender(context); const story = storyFn(); - let source: string; - - useEffect(() => { - if (!skip && source) { - const { id, unmappedArgs } = context; - channel.emit(SNIPPET_RENDERED, { id, args: unmappedArgs, source, format: 'vue' }); - } - }); - - if (skip) { - return story; - } - - const { args = {}, component: ctxtComponent, argTypes = {} } = context || {}; - const components = getTemplates(context?.originalStoryFn); + watch( + () => context.args, + () => { + if (!skip) { + generateSource(context); + } + }, + { immediate: true, deep: true } + ); + return story; +}; - const storyComponent = components.length ? components : ctxtComponent; +export function generateSource(context: StoryContext) { + const channel = addons.getChannel(); + const { args = {}, argTypes = {}, id } = context || {}; + const storyComponents = getTemplateComponents(context?.originalStoryFn, context); const withScript = context?.parameters?.docs?.source?.withScriptSetup || false; - const generatedScript = withScript ? generateScriptSetup(args, argTypes, components) : ''; - const generatedTemplate = generateSource(storyComponent, args, argTypes, withScript); + const generatedScript = withScript ? generateScriptSetup(args, argTypes, storyComponents) : ''; + const generatedTemplate = generateTemplateSource(storyComponents, context, withScript); if (generatedTemplate) { - source = `${generatedScript}\n `; + const source = `${generatedScript}\n `; + channel.emit(SNIPPET_RENDERED, { id, args, source, format: 'vue' }); + return source; } - - return story; + return null; +} +// export local function for testing purpose +export { + generateScriptSetup, + getTemplateComponents as getComponentsFromRenderFn, + getComponents as getComponentsFromTemplate, + mapAttributesAndDirectives, + attributeSource, + htmlEventAttributeToVueEventAttribute, }; diff --git a/code/renderers/vue3/src/docs/utils.ts b/code/renderers/vue3/src/docs/utils.ts new file mode 100644 index 000000000000..707feb46e58f --- /dev/null +++ b/code/renderers/vue3/src/docs/utils.ts @@ -0,0 +1,91 @@ +import type { Args } from '@storybook/types'; +import type { FunctionalComponent } from 'vue'; + +/** + * omit event args + * @param args + */ +const omitEvent = (args: Args): Args => + args + ? Object.fromEntries(Object.entries(args).filter(([key, value]) => !key.startsWith('on'))) + : {}; + +const displayObject = (obj: any): string | boolean | number => { + if (obj && typeof obj === 'object') { + return `{${Object.keys(obj) + .map((key) => `${key}:${displayObject(obj[key])}`) + .join(',')}}`; + } + if (typeof obj === 'string') return `'${obj}'`; + return obj?.toString(); +}; +const htmlEventAttributeToVueEventAttribute = (key: string) => { + return /^on[A-Za-z]/.test(key) ? key.replace(/^on/, 'v-on:').toLowerCase() : key; +}; + +const directiveSource = (key: string, value: unknown) => + key.includes('on') + ? `${htmlEventAttributeToVueEventAttribute(key)}='()=>({})'` + : `${key}="${value}"`; + +const attributeSource = (key: string, value: unknown, dynamic?: boolean) => + // convert html event key to vue event key + ['boolean', 'number', 'object'].includes(typeof value) || // dynamic value + (dynamic && ['style', 'class'].includes(key)) // dynamic style or class + ? `:${key}="${displayObject(value)}"` + : directiveSource(key, value); + +const evalExp = (argExpValue: any, args: Args): any => { + let evalVal = argExpValue; + if (evalVal && /v-bind="(\w+)"/.test(evalVal)) + return evalVal.replace(/"(\w+)"/g, `"${displayObject(args)}"`); + + Object.keys(args).forEach((akey) => { + const regexMatch = new RegExp(`(\\w+)\\.${akey}`, 'g'); + const regexTarget = new RegExp(`(\\w+)\\.${akey}`, 'g'); + if (regexMatch.test(evalVal)) { + evalVal = evalVal.replace(regexTarget, displayObject(args[akey])); + } + }); + + return evalVal; +}; + +const replaceValueWithRef = (source: string, args: Args, ref: string) => { + const value = ref ? args[ref] : 'args'; + + const bindValue = () => { + const argsRef = Object.fromEntries(Object.entries(args).map(([key]) => [key, key])); + return (displayObject(argsRef) as string).replace(/'/g, ''); + }; + + const regexMatch = new RegExp(`="${value}"`, 'g'); + return source.replace(regexMatch, `="${ref ?? bindValue()}"`); +}; + +/** + * + * replace function curly brackets and return with empty string ex: () => { return `${text} , ${year}` } => `${text} , ${year}` + * + * @param slot + * @returns + * */ + +function generateExpression(slot: FunctionalComponent): string { + let body = slot.toString().split('=>')[1].trim().replace('return', '').trim(); + if (body.startsWith('{') && body.endsWith('}')) { + body = body.substring(1, body.length - 1).trim(); + } + return `{{${body}}}`; +} + +export { + omitEvent, + displayObject, + htmlEventAttributeToVueEventAttribute, + directiveSource, + attributeSource, + evalExp, + replaceValueWithRef, + generateExpression, +}; diff --git a/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceDecorator.stories.ts b/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceDecorator.stories.ts new file mode 100644 index 000000000000..4a5a8613069e --- /dev/null +++ b/code/renderers/vue3/template/stories_vue3-vite-default-ts/SourceDecorator.stories.ts @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/vue3'; + +import GlobalUsage from './GlobalUsage.vue'; +import GlobalSetup from './GlobalSetup.vue'; + +const meta: Meta = { + component: GlobalUsage, + argTypes: {}, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const MultiComponents: Story = { + args: { + label: 'Button', + size: 'large', + backgroundColor: '#aa00ff', + btn1Args: { label: 'Button 10', size: 'small', backgroundColor: '#aa00ff' }, + }, + render(args: any) { + return { + components: { GlobalUsage, GlobalSetup }, + setup() { + return { args }; + }, + template: `
+ +
+ +   +
+

Complex Story Custom template



+ + +
Multiple + Components
+
+ + +
+
`, + }; + }, +};