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
+
+a content
+
+42
+
+true
+
+d content
+
+e content
+
+f content
+
+child 1
+child 2
+
+child 1
+child 2
+
+child 1
+
nested child 1
+nested child 2
+
+child 1
+child 2
+
+{"foo":"bar"}
+
+{{ BigInt(9007199254740991) }}`;
+
+ 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 = `Slot with bindings {{ foo }} and {{ bar }}
+
+Test link: {{ foo }}`;
+
+ 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}${componentName}>`;
+ }
+ 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}${name}>`;
+ 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}${name}>`;
- }
+/**
+ * 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 \n ${generatedTemplate} \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}`,
},
};