Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addon-docs: Dynamic source rendering for Vue #12812

Merged
merged 2 commits into from
Oct 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"/>`
shilman marked this conversation as resolved.
Show resolved Hide resolved
);
});

it('children', () => {
expect(
vnodeToString(
getVNode({
template: `
<div>
<form>
<button>Button</button>
</form>
</div>`,
})
)
).toMatchInlineSnapshot(`<div ><form ><button >Button</button></form></div>`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway to get rid of the extra spaces? E.g. <div > => <div>? Or is that standard in Vue?

Copy link
Contributor Author

@pocka pocka Oct 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or is that standard in Vue?

Nope, AFAIK.

It's ugly but will be erased by Prettier before emitting. Should we remove it before formatting?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha didn't realize this was the pre-prettier output. If prettier fixes, that's fine by me.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how I can write a story to achieve this effect!

});
});
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 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) => {
shilman marked this conversation as resolved.
Show resolved Hide resolved
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(`<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',
shilman marked this conversation as resolved.
Show resolved Hide resolved
})
);
} 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;
shilman marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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