-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(addon-docs): Dynamic source rendering for Vue
#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
Showing
5 changed files
with
280 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>`); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, '"')}"`; | ||
} | ||
|
||
/** | ||
* 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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters