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

Vue: Extend sourceDecorator to support v-bind and nested keys in slots #28787

Open
wants to merge 9 commits into
base: next
Choose a base branch
from
12 changes: 8 additions & 4 deletions code/renderers/vue3/src/docs/sourceDecorator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,20 @@ test('should generate source code for slots with bindings', () => {
type TestBindings = {
foo: string;
bar?: number;
boo: {
mimeType: string;
};
};

const slots = {
a: ({ foo, bar }: TestBindings) => `Slot with bindings ${foo} and ${bar}`,
b: ({ foo }: TestBindings) => h('a', { href: foo, target: foo }, `Test link: ${foo}`),
a: ({ foo, bar, boo }: TestBindings) => `Slot with bindings ${foo}, ${bar} and ${boo.mimeType}`,
b: ({ foo, boo }: TestBindings) =>
h('a', { href: foo, target: foo, type: boo.mimeType, ...boo }, `Test link: ${foo}`),
};

const expectedCode = `<template #a="{ foo, bar }">Slot with bindings {{ foo }} and {{ bar }}</template>
const expectedCode = `<template #a="{ foo, bar, boo }">Slot with bindings {{ foo }}, {{ bar }} and {{ boo.mimeType }}</template>

<template #b="{ foo }"><a :href="foo" :target="foo">Test link: {{ foo }}</a></template>`;
<template #b="{ foo, boo }"><a :href="foo" :target="foo" :type="boo.mimeType" v-bind="boo">Test link: {{ foo }}</a></template>`;

const actualCode = generateSlotSourceCode(slots, Object.keys(slots), {
imports: {},
Expand Down
84 changes: 69 additions & 15 deletions code/renderers/vue3/src/docs/sourceDecorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ export type SourceCodeGeneratorContext = {
imports: Record<string, Set<string>>;
};

/**
* Used to get the tracking data from the proxy.
* A symbol is unique, so when using it as a key it can't be accidentally accessed.
*/
const TRACKING_SYMBOL = Symbol('DEEP_ACCESS_SYMBOL');

type TrackingProxy = {
[TRACKING_SYMBOL]: true;
toString: () => string;
};

const isProxy = (obj: unknown): obj is TrackingProxy =>
!!(obj && typeof obj === 'object' && TRACKING_SYMBOL in obj);

/**
* Decorator to generate Vue source code for stories.
*/
Expand Down Expand Up @@ -208,6 +222,10 @@ export const generatePropsSourceCode = (
if (slotNames.includes(propName)) return;
if (value == undefined) return; // do not render undefined/null values

if (isProxy(value)) {
value = value.toString();
}

switch (typeof value) {
case 'string':
if (value === '') return; // do not render empty strings
Expand Down Expand Up @@ -249,7 +267,7 @@ export const generatePropsSourceCode = (
case 'object': {
properties.push({
name: propName,
value: formatObject(value),
value: formatObject(value ?? {}),
// to follow Vue best practices, complex values like object and arrays are
// usually placed inside the <script setup> block instead of inlining them in the <template>
templateFn: undefined,
Expand Down Expand Up @@ -397,24 +415,60 @@ const generateSlotChildrenSourceCode = (
(param) => !['{', '}'].includes(param)
);

const parameters = paramNames.reduce<Record<string, string>>((obj, param) => {
obj[param] = `{{ ${param} }}`;
return obj;
}, {});

const returnValue = child(parameters);
let slotSourceCode = generateSlotChildrenSourceCode([returnValue], ctx);

// 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"
// We create proxy to track how and which properties of a parameter are accessed
const parameters: Record<string, string> = {};
// TODO: it should be possible to extend the proxy logic here and maybe get rid of the `generatePropsSourceCode` and `getFunctionParamNames`
const proxied: Record<string, TrackingProxy> = {};
paramNames.forEach((param) => {
slotSourceCode = slotSourceCode.replaceAll(
new RegExp(` (\\S+)="{{ ${param} }}"`, 'g'),
` :$1="${param}"`
parameters[param] = `{{ ${param} }}`;
proxied[param] = new Proxy(
{
// we use the symbol to identify the proxy
[TRACKING_SYMBOL]: true,
} as TrackingProxy,
{
// getter is called when any prop of the parameter is read
get: (t, key) => {
if (key === TRACKING_SYMBOL) {
// allow retrieval of the tracking data
return t[TRACKING_SYMBOL];
}
if ([Symbol.toPrimitive, Symbol.toStringTag, 'toString'].includes(key)) {
// when the parameter is used as a string we return the parameter name
// we use the double brace notation as we don't know if the parameter is used in text or in a binding
return () => `{{ ${param} }}`;
}
if (key === 'v-bind') {
// if this key is returned we just return the parameter name
return `${param}`;
}
// otherwise a specific key of the parameter was accessed
// we use the double brace notation as we don't know if the parameter is used in text or in a binding
return `{{ ${param}.${key.toString()} }}`;
},
// ownKeys is called, among other uses, when an object is destructured
// in this case we assume the parameter is supposed to be bound using "v-bind"
// Therefore we only return one special key "v-bind" and the getter will be called afterwards with it
ownKeys: () => {
return [`v-bind`];
},
/** called when destructured */
getOwnPropertyDescriptor: () => ({
configurable: true,
enumerable: true,
value: param,
writable: true,
}),
}
);
});

return slotSourceCode;
const returnValue = child(proxied);
const slotSourceCode = generateSlotChildrenSourceCode([returnValue], ctx);

// 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"
return slotSourceCode.replaceAll(/ (\S+)="{{ (\S+) }}"/g, ` :$1="$2"`);
}

case 'bigint':
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import Component from './template-slots/component.vue';
import { h } from 'vue';

const meta = {
component: Component,
Expand All @@ -13,6 +14,12 @@ 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}`,
vbind: ({ num, str, obj }) => [
`Named v-bind slot: num=${num}, str=${str}, obj.title=${obj.title}`,
h('br'),
h('button', obj, 'button'),
h('br'),
h('button', { disabled: true, ...obj }, 'merged props'),
],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
<br />
<slot name="named" str="str"></slot>
<br />
<slot name="vbind" v-bind="{ num: 123, str: 'str' }"></slot>
<slot
name="vbind"
v-bind="{ num: 123, str: 'str', obj: { title: 'see me', style: { color: 'blue' } } }"
></slot>
</template>