diff --git a/addons/docs/package.json b/addons/docs/package.json index 0b6c43ebfc5..b990d4bee7c 100644 --- a/addons/docs/package.json +++ b/addons/docs/package.json @@ -70,6 +70,7 @@ "html-tags": "^3.1.0", "js-string-escape": "^1.0.1", "lodash": "^4.17.15", + "prettier": "~2.0.5", "prop-types": "^15.7.2", "react": "^16.8.3", "react-dom": "^16.8.3", @@ -106,7 +107,6 @@ "jest-specific-snapshot": "^4.0.0", "lit-element": "^2.2.1", "lit-html": "^1.0.0", - "prettier": "~2.0.5", "require-from-string": "^2.0.2", "rxjs": "^6.5.4", "styled-components": "^5.0.1", diff --git a/addons/docs/src/frameworks/vue/config.ts b/addons/docs/src/frameworks/vue/config.ts index 4a4fd37a39f..a5e41a72613 100644 --- a/addons/docs/src/frameworks/vue/config.ts +++ b/addons/docs/src/frameworks/vue/config.ts @@ -1,6 +1,7 @@ import { extractArgTypes } from './extractArgTypes'; import { extractComponentDescription } from '../../lib/docgen'; import { prepareForInline } from './prepareForInline'; +import { sourceDecorator } from './sourceDecorator'; export const parameters = { docs: { @@ -10,3 +11,5 @@ export const parameters = { extractComponentDescription, }, }; + +export const decorators = [sourceDecorator]; diff --git a/addons/docs/src/frameworks/vue/sourceDecorator.test.ts b/addons/docs/src/frameworks/vue/sourceDecorator.test.ts new file mode 100644 index 00000000000..9f21c563720 --- /dev/null +++ b/addons/docs/src/frameworks/vue/sourceDecorator.test.ts @@ -0,0 +1,77 @@ +/* eslint no-underscore-dangle: ["error", { "allow": ["_vnode"] }] */ + +import { ComponentOptions } from 'vue'; +import Vue from 'vue/dist/vue'; +import { vnodeToString } from './sourceDecorator'; + +expect.addSnapshotSerializer({ + print: (val: any) => val, + test: (val) => typeof val === 'string', +}); + +const getVNode = (Component: ComponentOptions) => { + const vm = new Vue({ + render(h: (c: any) => unknown) { + return h(Component); + }, + }).$mount(); + + return vm.$children[0]._vnode; +}; + +describe('vnodeToString', () => { + it('basic', () => { + expect( + vnodeToString( + getVNode({ + template: ``, + }) + ) + ).toMatchInlineSnapshot(``); + }); + + it('attributes', () => { + const MyComponent: ComponentOptions = { + props: ['propA', 'propB', 'propC', 'propD'], + template: '
', + }; + + expect( + vnodeToString( + getVNode({ + components: { MyComponent }, + data(): { props: Record } { + return { + props: { + propA: 'propA', + propB: 1, + propC: null, + propD: { + foo: 'bar', + }, + }, + }; + }, + template: ``, + }) + ) + ).toMatchInlineSnapshot( + `` + ); + }); + + it('children', () => { + expect( + vnodeToString( + getVNode({ + template: ` +
+
+ +
+
`, + }) + ) + ).toMatchInlineSnapshot(`
`); + }); +}); diff --git a/addons/docs/src/frameworks/vue/sourceDecorator.ts b/addons/docs/src/frameworks/vue/sourceDecorator.ts new file mode 100644 index 00000000000..749ed00ab93 --- /dev/null +++ b/addons/docs/src/frameworks/vue/sourceDecorator.ts @@ -0,0 +1,198 @@ +/* eslint no-underscore-dangle: ["error", { "allow": ["_vnode"] }] */ + +import { addons, StoryContext } from '@storybook/addons'; +import { logger } from '@storybook/client-logger'; +import prettier from 'prettier/standalone'; +import prettierHtml from 'prettier/parser-html'; +import Vue from 'vue'; + +import { SourceType, SNIPPET_RENDERED } from '../../shared'; + +export const skipSourceRender = (context: StoryContext) => { + const sourceParams = context?.parameters.docs?.source; + const isArgsStory = context?.parameters.__isArgsStory; + + // always render if the user forces it + if (sourceParams?.type === SourceType.DYNAMIC) { + return false; + } + + // 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; +}; + +export const sourceDecorator = (storyFn: any, context: StoryContext) => { + const story = storyFn(); + + // See ../react/jsxDecorator.tsx + if (skipSourceRender(context)) { + return story; + } + + try { + // Creating a Vue instance each time is very costly. But we need to do it + // in order to access VNode, otherwise vm.$vnode will be undefined. + // Also, I couldn't see any notable difference from the implementation with + // per-story-cache. + // But if there is a more performant way, we should replace it with that ASAP. + const vm = new Vue({ + data() { + return { + STORYBOOK_VALUES: context.args, + }; + }, + render(h) { + return h(story); + }, + }).$mount(); + + const channel = addons.getChannel(); + + const storyComponent = getStoryComponent(story.options.STORYBOOK_WRAPS); + + const storyNode = lookupStoryInstance(vm, storyComponent); + + const code = vnodeToString(storyNode._vnode); + + channel.emit( + SNIPPET_RENDERED, + (context || {}).id, + prettier.format(``, { + parser: 'vue', + plugins: [prettierHtml], + // Because the parsed vnode missing spaces right before/after the surround tag, + // we always get weird wrapped code without this option. + htmlWhitespaceSensitivity: 'ignore', + }) + ); + } catch (e) { + logger.warn(`Failed to generate dynamic story source: ${e}`); + } + + return story; +}; + +export function vnodeToString(vnode: Vue.VNode): string { + const attrString = [ + ...(vnode.data?.slot ? ([['slot', vnode.data.slot]] as [string, any][]) : []), + ...(vnode.componentOptions?.propsData ? Object.entries(vnode.componentOptions.propsData) : []), + ...(vnode.data?.attrs ? Object.entries(vnode.data.attrs) : []), + ] + .filter(([name], index, list) => list.findIndex((item) => item[0] === name) === index) + .map(([name, value]) => stringifyAttr(name, value)) + .filter(Boolean) + .join(' '); + + if (!vnode.componentOptions) { + // Non-component elements (div, span, etc...) + if (vnode.tag) { + if (!vnode.children) { + return `<${vnode.tag} ${attrString}/>`; + } + + return `<${vnode.tag} ${attrString}>${vnode.children.map(vnodeToString).join('')}`; + } + + // TextNode + if (vnode.text) { + if (/[<>"&]/.test(vnode.text)) { + return `{{\`${vnode.text.replace(/`/g, '\\`')}\`}}`; + } + + return vnode.text; + } + + // Unknown + return ''; + } + + // Probably users never see the "unknown-component". It seems that vnode.tag + // is always set. + const tag = vnode.componentOptions.tag || vnode.tag || 'unknown-component'; + + if (!vnode.componentOptions.children) { + return `<${tag} ${attrString}/>`; + } + + return `<${tag} ${attrString}>${vnode.componentOptions.children + .map(vnodeToString) + .join('')}`; +} + +function stringifyAttr(attrName: string, value?: any): string | null { + if (typeof value === 'undefined') { + return null; + } + + if (value === true) { + return attrName; + } + + if (typeof value === 'string') { + return `${attrName}=${quote(value)}`; + } + + // TODO: Better serialization (unquoted object key, Symbol/Classes, etc...) + // Seems like Prettier don't format JSON-look object (= when keys are quoted) + return `:${attrName}=${quote(JSON.stringify(value))}`; +} + +function quote(value: string) { + return value.includes(`"`) && !value.includes(`'`) + ? `'${value}'` + : `"${value.replace(/"/g, '"')}"`; +} + +/** + * Skip decorators and grab a story component itself. + * https://github.com/pocka/storybook-addon-vue-info/pull/113 + */ +function getStoryComponent(w: any) { + let matched = w; + + while ( + matched && + matched.options && + matched.options.components && + matched.options.components.story && + matched.options.components.story.options && + matched.options.components.story.options.STORYBOOK_WRAPS + ) { + matched = matched.options.components.story.options.STORYBOOK_WRAPS; + } + return matched; +} + +interface VueInternal { + // We need to access this private property, in order to grab the vnode of the + // component instead of the "vnode of the parent of the component". + // Probably it's safe to rely on this because vm.$vnode is a reference for this. + // https://github.com/vuejs/vue/issues/6070#issuecomment-314389883 + _vnode: Vue.VNode; +} + +/** + * Find the story's instance from VNode tree. + */ +function lookupStoryInstance(instance: Vue, storyComponent: any): (Vue & VueInternal) | null { + if ( + instance.$vnode && + instance.$vnode.componentOptions && + instance.$vnode.componentOptions.Ctor === storyComponent + ) { + return instance as Vue & VueInternal; + } + + for (let i = 0, l = instance.$children.length; i < l; i += 1) { + const found = lookupStoryInstance(instance.$children[i], storyComponent); + + if (found) { + return found; + } + } + + return null; +} diff --git a/addons/docs/src/typings.d.ts b/addons/docs/src/typings.d.ts index b4dfdb4df18..99571482f45 100644 --- a/addons/docs/src/typings.d.ts +++ b/addons/docs/src/typings.d.ts @@ -7,6 +7,7 @@ declare module 'babel-plugin-react-docgen'; declare module 'require-from-string'; declare module 'styled-components'; declare module 'acorn-jsx'; +declare module 'vue/dist/vue'; declare module 'sveltedoc-parser' { export function parse(options: any): Promise;