Skip to content

Commit

Permalink
feat(addon-docs): Dynamic source rendering for Vue
Browse files Browse the repository at this point in the history
#11400

This commit adds dynamic source code rendering feature to Docs addon for
Vue.js. The goals are 1) reflecting Controls' value to code block and
2) showing a code similar to what component consumers would write.

To archive these goals, this feature compiles a component with Vue, then
walks through vdom and stringifys the result. It could be possible to
parse components' template or render function then stringify results,
but it would be costly and hard to maintain (especially for parsing).
We can use vue-template-compiler for the purpose, but it can't handle
render functions so it's not the way to go IMO.

Speaking of the goal 2, someone wants events to be in the output code.
But it's so hard to retrieve component definitions (e.g. `methods`,
`computed`). I think it's okay to skip events until we figure there is a
high demand for that.
  • Loading branch information
pocka committed Oct 18, 2020
1 parent 59459ec commit 5372139
Show file tree
Hide file tree
Showing 5 changed files with 280 additions and 1 deletion.
2 changes: 1 addition & 1 deletion addons/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions addons/docs/src/frameworks/vue/config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -10,3 +11,5 @@ export const parameters = {
extractComponentDescription,
},
};

export const decorators = [sourceDecorator];
77 changes: 77 additions & 0 deletions addons/docs/src/frameworks/vue/sourceDecorator.test.ts
Original file line number Diff line number Diff line change
@@ -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<any, any, any>) => {
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: `<button>Button</button>`,
})
)
).toMatchInlineSnapshot(`<button >Button</button>`);
});

it('attributes', () => {
const MyComponent: ComponentOptions<any, any, any> = {
props: ['propA', 'propB', 'propC', 'propD'],
template: '<div/>',
};

expect(
vnodeToString(
getVNode({
components: { MyComponent },
data(): { props: Record<string, any> } {
return {
props: {
propA: 'propA',
propB: 1,
propC: null,
propD: {
foo: 'bar',
},
},
};
},
template: `<my-component v-bind="props"/>`,
})
)
).toMatchInlineSnapshot(
`<my-component :propD='{"foo":"bar"}' :propC="null" :propB="1" propA="propA"/>`
);
});

it('children', () => {
expect(
vnodeToString(
getVNode({
template: `
<div>
<form>
<button>Button</button>
</form>
</div>`,
})
)
).toMatchInlineSnapshot(`<div ><form ><button >Button</button></form></div>`);
});
});
198 changes: 198 additions & 0 deletions addons/docs/src/frameworks/vue/sourceDecorator.ts
Original file line number Diff line number Diff line change
@@ -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 skipJsxRender = (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 (skipJsxRender(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(`<template>${code}</template>`, {
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('')}</${
vnode.tag
}>`;
}

// 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('')}</${tag}>`;
}

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, '&quot;')}"`;
}

/**
* 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;
}
1 change: 1 addition & 0 deletions addons/docs/src/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
Expand Down

0 comments on commit 5372139

Please sign in to comment.