diff --git a/code/core/src/csf-tools/story-shape/index.ts b/code/core/src/csf-tools/story-shape/index.ts index 4705a3d307c6..66eb4fbe574a 100644 --- a/code/core/src/csf-tools/story-shape/index.ts +++ b/code/core/src/csf-tools/story-shape/index.ts @@ -7,27 +7,27 @@ export { storyAssignedArgsPath, } from './args.ts'; export { - type ComponentImportRef, - type ImportRef, buildImportStatements, resolveComponentImport, + type ComponentImportRef, + type ImportRef, } from './import-statements.ts'; export { - type ImportBinding, collectImportBindings, importedName, isTypeSpecifier, + type ImportBinding, } from './imports.ts'; -export { extractStoryJSDocInfo } from './jsdoc.ts'; -export { type NormalizedStoryDeclaration, normalizeStoryDeclaration } from './normalize-story.ts'; -export { type RenderFunctionPath, type RenderResolution, resolveRenderFunction } from './render.ts'; +export { extractStoryJSDocInfo, jsDocTagsForPath } from './jsdoc.ts'; +export { normalizeStoryDeclaration, type NormalizedStoryDeclaration } from './normalize-story.ts'; +export { resolveRenderFunction, type RenderFunctionPath, type RenderResolution } from './render.ts'; export { keyOf, metaObjectPath, propertyValue, resolveIdentifierInit, + resolveReturnedObjectExpression, + returnedExpression, returnedExpressionPath, - returnedObjectExpression, unwrapExpression, - unwrapValue, } from './utils.ts'; diff --git a/code/core/src/csf-tools/story-shape/jsdoc.ts b/code/core/src/csf-tools/story-shape/jsdoc.ts index 2bfe96ad23ee..4166fb47cc01 100644 --- a/code/core/src/csf-tools/story-shape/jsdoc.ts +++ b/code/core/src/csf-tools/story-shape/jsdoc.ts @@ -1,8 +1,21 @@ -import type { types as t } from 'storybook/internal/babel'; +import type { NodePath, types as t } from 'storybook/internal/babel'; import { extractDescription } from '../enrichCsf.ts'; import { extractJSDocInfo } from '../jsdoc.ts'; +/** + * JSDoc tags on the docblock of the statement a path belongs to. + * + * The docblock sits on the enclosing statement rather than the expression itself, so a `meta` + * object literal has to look upwards to find the comment an author wrote above `const meta`. + */ +export function jsDocTagsForPath(path?: NodePath): Record { + const statement = path?.getStatementParent(); + const jsdocComment = statement ? extractDescription(statement.node) : ''; + + return jsdocComment ? (extractJSDocInfo(jsdocComment).tags ?? {}) : {}; +} + /** Story description and summary from its JSDoc; `@describe`/`@desc` tags override the body. */ export function extractStoryJSDocInfo(storyStatement?: t.Node): { description?: string; diff --git a/code/core/src/csf-tools/story-shape/utils.test.ts b/code/core/src/csf-tools/story-shape/utils.test.ts index 8b84a35c147b..ca931d98aa18 100644 --- a/code/core/src/csf-tools/story-shape/utils.test.ts +++ b/code/core/src/csf-tools/story-shape/utils.test.ts @@ -10,8 +10,9 @@ import { keyOf, metaObjectPath, resolveIdentifierInit, + returnedExpression, returnedExpressionPath, - unwrapValue, + unwrapExpression, } from './utils.ts'; const parse = (code: string) => { @@ -118,16 +119,17 @@ describe('keyOf', () => { }); }); -describe('unwrapValue', () => { +describe('unwrapExpression', () => { const typeAnnotation = t.tsTypeReference(t.identifier('Story')); - it('unwraps TypeScript expression wrappers', () => { + it('unwraps TypeScript expression wrappers and parentheses', () => { const value = t.objectExpression([]); - expect(unwrapValue(t.tsAsExpression(value, typeAnnotation))).toBe(value); - expect(unwrapValue(t.tsSatisfiesExpression(value, typeAnnotation))).toBe(value); - expect(unwrapValue(t.tsNonNullExpression(value))).toBe(value); - expect(unwrapValue(t.tsTypeAssertion(typeAnnotation, value))).toBe(value); + expect(unwrapExpression(t.tsAsExpression(value, typeAnnotation))).toBe(value); + expect(unwrapExpression(t.tsSatisfiesExpression(value, typeAnnotation))).toBe(value); + expect(unwrapExpression(t.tsNonNullExpression(value))).toBe(value); + expect(unwrapExpression(t.tsTypeAssertion(typeAnnotation, value))).toBe(value); + expect(unwrapExpression(t.parenthesizedExpression(value))).toBe(value); }); it('unwraps nested TypeScript expression wrappers', () => { @@ -137,16 +139,17 @@ describe('unwrapValue', () => { typeAnnotation ); - expect(unwrapValue(wrapped)).toBe(value); + expect(unwrapExpression(wrapped)).toBe(value); }); - it('returns undefined for nullable values', () => { - expect(unwrapValue(null)).toBeUndefined(); - expect(unwrapValue(undefined)).toBeUndefined(); + it('returns other nodes untouched', () => { + const value = t.stringLiteral('Save'); + + expect(unwrapExpression(value)).toBe(value); }); }); -describe('returnedExpressionPath', () => { +describe('returnedExpression', () => { const cases = [ { code: dedent` @@ -193,16 +196,29 @@ describe('returnedExpressionPath', () => { }, ]; - it.each(cases)('resolves $name in path mode', ({ code, expected }) => { + it.each(cases)('resolves $name', ({ code, expected }) => { + const returned = returnedExpression(renderFunctionPath(code).node); + + expect(returned ? printed(returned) : undefined).toBe(expected); + }); + + it.each(cases)('resolves $name as a path', ({ code, expected }) => { const returned = returnedExpressionPath(renderFunctionPath(code)); expect(returned ? printed(returned.node) : undefined).toBe(expected); }); - it.each(cases)('resolves $name in node mode', ({ code, expected }) => { - const returned = returnedExpressionPath(renderFunctionPath(code).node); - - expect(returned ? printed(returned) : undefined).toBe(expected); + it('resolves an object method body, which `setup()` uses', () => { + const [method] = t.objectExpression([ + t.objectMethod( + 'method', + t.identifier('setup'), + [], + t.blockStatement([t.returnStatement(t.stringLiteral('Save'))]) + ), + ]).properties; + + expect(printed(returnedExpression(method)!)).toBe(`"Save"`); }); }); diff --git a/code/core/src/csf-tools/story-shape/utils.ts b/code/core/src/csf-tools/story-shape/utils.ts index ffabc7be6472..ac1ac34f754f 100644 --- a/code/core/src/csf-tools/story-shape/utils.ts +++ b/code/core/src/csf-tools/story-shape/utils.ts @@ -3,8 +3,6 @@ import { type NodePath, types as t } from 'storybook/internal/babel'; import type { CsfFile } from '../CsfFile.ts'; import type { RenderFunctionPath } from './render.ts'; -type RenderFunctionNode = RenderFunctionPath['node']; - /** Peels TS assertion/satisfies wrappers and parentheses off an expression node. */ export const unwrapExpression = (node: t.Node): t.Node => t.isTSAsExpression(node) || @@ -25,23 +23,6 @@ export const keyOf = (p: t.ObjectMethod | t.ObjectProperty): string | null => ? p.key.value : null; -/** Peel TypeScript-only expression wrappers before reading runtime values. */ -export function unwrapValue(node: t.Node): t.Node; -export function unwrapValue(node: t.Node | undefined | null): t.Node | undefined; -export function unwrapValue(node: t.Node | undefined | null): t.Node | undefined { - if ( - node && - (node.type === 'TSAsExpression' || - node.type === 'TSSatisfiesExpression' || - node.type === 'TSNonNullExpression' || - node.type === 'TSTypeAssertion') - ) { - return unwrapValue(node.expression); - } - - return node ?? undefined; -} - /** Value of an object expression's own property, when it has one. */ export const propertyValue = ( object: t.ObjectExpression | undefined | null, @@ -52,79 +33,68 @@ export const propertyValue = ( t.isObjectProperty(candidate) && keyOf(candidate) === name )?.value; +/** Expression a block body consists of, when it consists of exactly `return `. */ +const soleReturnedExpression = (body: t.BlockStatement): t.Expression | undefined => { + const [statement, ...rest] = body.body; + return rest.length === 0 && t.isReturnStatement(statement) && t.isExpression(statement.argument) + ? statement.argument + : undefined; +}; + /** - * Object literal a function returns, when it returns one directly. + * Expression a function returns directly, covering the concise body (`() => …`) and a block body + * that is only a `return`. * - * Covers the concise body (`() => ({ … })`) and a block body whose `return` carries an object - * literal, which is the shape template-based renderers use for `render`. + * A block body must hold nothing but that `return`, since any extra statement could change what the + * expression evaluates to and a static reader cannot follow it. */ -export const returnedObjectExpression = ( - fn: t.Node | undefined -): t.ObjectExpression | undefined => { - if ( - !t.isArrowFunctionExpression(fn) && - !t.isFunctionExpression(fn) && - !t.isFunctionDeclaration(fn) && - !t.isObjectMethod(fn) - ) { +export const returnedExpression = (fn: t.Node | undefined): t.Expression | undefined => { + if (!t.isFunction(fn)) { return undefined; } - if (t.isObjectExpression(fn.body)) { - return fn.body; - } - const returned = t.isBlockStatement(fn.body) - ? fn.body.body.find((statement): statement is t.ReturnStatement => - t.isReturnStatement(statement) - )?.argument - : undefined; - return t.isObjectExpression(returned) ? returned : undefined; + + return t.isExpression(fn.body) ? fn.body : soleReturnedExpression(fn.body); }; -/** Expression returned directly by render-function shapes that static snippets can follow. */ -export function returnedExpressionPath( +/** {@link returnedExpression} as a path, for callers that resolve identifiers against scope. */ +export const returnedExpressionPath = ( renderFunction: RenderFunctionPath -): NodePath | undefined; -export function returnedExpressionPath( - renderFunction: RenderFunctionNode -): t.Expression | undefined; -export function returnedExpressionPath( - renderFunction: RenderFunctionPath | RenderFunctionNode -): NodePath | t.Expression | undefined { - if (isRenderFunctionPath(renderFunction)) { - const body = renderFunction.get('body'); - if (body.isExpression()) { - return body; - } - if (!body.isBlockStatement()) { - return undefined; - } +): NodePath | undefined => { + if (!returnedExpression(renderFunction.node)) { + return undefined; + } - const statements = body.get('body'); - if (statements.length !== 1) { - return undefined; - } + const body = renderFunction.get('body'); + if (body.isExpression()) { + return body; + } - const [statement] = statements; - if (!statement.isReturnStatement()) { - return undefined; - } + const [statement] = body.isBlockStatement() ? body.get('body') : []; + const argument = statement?.isReturnStatement() ? statement.get('argument') : undefined; + return argument?.isExpression() ? argument : undefined; +}; - const argument = statement.get('argument'); - return argument && !Array.isArray(argument) && argument.isExpression() ? argument : undefined; - } +/** + * Object literal a render function resolves to, following a local identifier when it returns one. + * + * @example `() => ({ template })` and `() => config` with `const config = { template }` both → + * that object literal + */ +export const resolveReturnedObjectExpression = ( + renderFunction: RenderFunctionPath +): t.ObjectExpression | undefined => { + const returned = returnedExpressionPath(renderFunction); - if (t.isExpression(renderFunction.body)) { - return renderFunction.body; + if (returned?.isObjectExpression()) { + return returned.node; } - if (renderFunction.body.body.length !== 1) { + if (!returned?.isIdentifier()) { return undefined; } - const [statement] = renderFunction.body.body; - return t.isReturnStatement(statement) && statement.argument && t.isExpression(statement.argument) - ? statement.argument - : undefined; -} + const resolved = resolveIdentifierInit(renderFunction, returned); + return resolved?.isObjectExpression() ? resolved.node : undefined; +}; /** Resolve a local story helper used by `Template.bind({})` or `render: Template`. */ export function resolveIdentifierInit( @@ -203,10 +173,3 @@ export function metaObjectPath(csf: CsfFile): NodePath | und return metaPath?.isObjectExpression() ? metaPath : undefined; } - -function isRenderFunctionPath( - value: RenderFunctionPath | RenderFunctionNode -): value is RenderFunctionPath { - // TS 6 cannot narrow the mixed path/node overload with an inline property check. - return 'node' in value && typeof (value as RenderFunctionPath).get === 'function'; -} diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/input.stories.ts b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/input.stories.ts index 7b99e5c27e22..82f59d98498b 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/input.stories.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/input.stories.ts @@ -19,3 +19,47 @@ export const Primary: Story = { }, render: (args) => h(RenderFunctionButton, args), }; + +export const Nested: Story = { + args: { + label: 'Confirm', + }, + render: (args) => + h('section', { class: 'toolbar' }, [ + h('h2', 'Actions'), + h(RenderFunctionButton, args), + h(RenderFunctionButton, { label: 'Cancel' }), + ]), +}; + +export const SpreadOverride: Story = { + args: { + label: 'Original', + }, + render: (args) => h(RenderFunctionButton, { ...args, label: 'Overridden' }), +}; + +export const ArgsMember: Story = { + args: { + label: 'From args', + }, + render: (args) => h('div', [h(RenderFunctionButton, { label: args.label })]), +}; + +export const SlotChildren: Story = { + args: { + label: 'Confirm', + }, + render: (args) => + h(RenderFunctionButton, args, { + default: () => h('span', 'Extra'), + footer: () => h('strong', 'Note'), + }), +}; + +export const ArgsOnWrapperAndComponent: Story = { + args: { + label: 'Confirm', + }, + render: (args) => h('section', { class: 'toolbar', ...args }, [h(RenderFunctionButton, args)]), +}; diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot index 262c2a973dfc..a2558368c27e 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot @@ -4,12 +4,70 @@ "name": "RenderFunctionButton", "path": "__PATH__", "stories": { + "forms-render-function--args-member": { + "description": undefined, + "id": "forms-render-function--args-member", + "name": "Args Member", + "snippet": "", + "summary": undefined, + }, + "forms-render-function--args-on-wrapper-and-component": { + "description": undefined, + "id": "forms-render-function--args-on-wrapper-and-component", + "name": "Args On Wrapper And Component", + "snippet": "", + "summary": undefined, + }, + "forms-render-function--nested": { + "description": undefined, + "id": "forms-render-function--nested", + "name": "Nested", + "snippet": "", + "summary": undefined, + }, "forms-render-function--primary": { "description": undefined, "id": "forms-render-function--primary", "name": "Primary", "snippet": "", + "summary": undefined, + }, + "forms-render-function--slot-children": { + "description": undefined, + "id": "forms-render-function--slot-children", + "name": "Slot Children", + "snippet": "", + "summary": undefined, + }, + "forms-render-function--spread-override": { + "description": undefined, + "id": "forms-render-function--spread-override", + "name": "Spread Override", + "snippet": "", "summary": undefined, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts index 431f2df9de25..cea0e4f9ccc5 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts @@ -88,17 +88,13 @@ function docgenForFixture( 'update:modelValue': argType('update:modelValue', 'events'), }, }; - const jsDocTagsByFixture: Record = { - 'import-override': { - import: ["import { ImportOverride } from 'my-design-system';"], - }, - }; - + // Deliberately empty: vue-component-meta does not surface component-level tags, so the + // `import-override` fixture has to earn its snapshot from the `@import` tag on its CSF meta. return { id, name: componentNameFromFixture(fixtureCase), path, - jsDocTags: jsDocTagsByFixture[fixtureCase] ?? {}, + jsDocTags: {}, argTypes: argTypesByFixture[fixtureCase] ?? {}, }; } diff --git a/code/renderers/vue3/src/story-docs/ast-utils.ts b/code/renderers/vue3/src/story-docs/ast-utils.ts deleted file mode 100644 index ddad86c09684..000000000000 --- a/code/renderers/vue3/src/story-docs/ast-utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { buildImportStatements, type ImportBinding } from 'storybook/internal/csf-tools'; - -/** - * Import statement binding a component tag, or `undefined` when no statement can name it. - * - * A namespace binding has no importable member to alias, so it yields no statement rather than an - * import the snippet could not compile against. - */ -export function importStatementForBinding( - localName: string, - binding: ImportBinding | undefined -): string | undefined { - if (!binding || binding.importName === '*') { - return undefined; - } - - return buildImportStatements({ - refs: [ - { - importId: binding.importId, - importName: binding.importName, - localImportName: localName, - }, - ], - }).join('\n'); -} - -export function slotSortKey(name: string): string { - return name === 'default' ? '' : name; -} - -export function wrapSlotContent(name: string, content: string): string { - return name === 'default' ? content : ``; -} - -export function isVueExpressionAttribute(name: string): boolean { - return name.startsWith(':') || name.startsWith('@') || name.startsWith('v-'); -} - -export function indent(source: string): string { - return source - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); -} diff --git a/code/renderers/vue3/src/story-docs/build-story-docs.ts b/code/renderers/vue3/src/story-docs/build-story-docs.ts index f37188ca2798..8506afb46de0 100644 --- a/code/renderers/vue3/src/story-docs/build-story-docs.ts +++ b/code/renderers/vue3/src/story-docs/build-story-docs.ts @@ -14,6 +14,7 @@ import { buildImportStatements, collectImportBindings, extractStoryJSDocInfo, + jsDocTagsForPath, keyOf, loadCsf, mergeArgsRecords, @@ -21,10 +22,9 @@ import { normalizeStoryDeclaration, propertyValue, resolveComponentImport, - resolveIdentifierInit, resolveRenderFunction, + resolveReturnedObjectExpression, returnedExpressionPath, - returnedObjectExpression, storyAssignedArgsPath, type ImportBinding, type RenderFunctionPath, @@ -40,7 +40,7 @@ import { type VueDocgenArgInfo, } from './classify-args.ts'; import { renderSfcSnippet } from './render-sfc.ts'; -import { createHSlotRenderer, transformH } from './transform-h.ts'; +import { transformH } from './transform-h.ts'; import { readTemplateRenderConfig, transformTemplate, @@ -127,7 +127,12 @@ export async function buildStoryDocsPayload( } const componentName = resolveMetaComponentIdentifier(metaPath); const importBindings = collectImportBindings(csf._file.path); - const importStatement = createImportStatement(componentName, importBindings, docgenPayload); + const importStatement = createImportStatement( + componentName, + importBindings, + metaPath, + docgenPayload + ); const docgenArgInfo = docgenPayload && !docgenPayload.error ? vueDocgenArgInfo(docgenPayload) : undefined; const snippet = componentName && docgenArgInfo ? { componentName, docgenArgInfo } : undefined; @@ -165,14 +170,18 @@ function resolveMetaComponentIdentifier( /** * Reconstructs the component's import statement from the story file's import bindings, redirected - * to the source an `@import` docgen tag declares when the component has one. + * to the source an `@import` tag declares when the story or the component carries one. + * + * The CSF `meta` docblock is the supported place to write it, because component-level tags do not + * survive every docgen backend. A component tag still wins over nothing when one does come through. * - * @example `@import import { Button } from 'my-ds'` on `MyButton` → + * @example `@import import { Button } from 'my-ds'` above `const meta` for `MyButton` → * `import { Button as MyButton } from 'my-ds';` */ function createImportStatement( componentName: string | undefined, importBindings: Map, + metaPath: NodePath | undefined, docgenPayload: DocgenPayload | undefined ): string | undefined { if (!componentName) { @@ -182,7 +191,8 @@ function createImportStatement( // The override supplies the source and specifier kind, but the local name has to stay the one the // snippet renders, so the statement and the snippet keep referring to the same identifier. const ref = resolveComponentImport(componentName, importBindings); - const importOverride = docgenPayload?.jsDocTags.import?.[0]; + const importOverride = + jsDocTagsForPath(metaPath).import?.[0] ?? docgenPayload?.jsDocTags.import?.[0]; return buildImportStatements({ refs: [{ ...ref, importOverride }] }).join('\n') || undefined; } @@ -327,7 +337,13 @@ function enrichStoryDoc( return plain; } - const rendered = renderStaticStorySnippet(renderer, classified.args, componentName, options); + const rendered = renderStaticStorySnippet( + renderer, + classified.args, + componentName, + docgenArgInfo, + options + ); if (!rendered) { return plain; } @@ -346,7 +362,7 @@ function staticRendererForRenderFunction( renderFunction: RenderFunctionPath, options: StoryDocsContext ): StaticStoryRenderer | undefined { - const renderObject = returnedRenderObjectExpression(renderFunction); + const renderObject = resolveReturnedObjectExpression(renderFunction); const templateConfig = renderObject ? readTemplateRenderConfig(renderObject, options.importBindings) : undefined; @@ -408,56 +424,29 @@ function renderStaticStorySnippet( renderer: StaticStoryRenderer, args: ClassifiedArg[], componentName: string, + docgenArgInfo: VueDocgenArgInfo, options: StoryDocsContext ): StorySnippetResult | undefined { - // Slot imports collect per attempt so a story that ultimately bails contributes none. - const slotImports = new Set(); - const renderFunctionSlot = createHSlotRenderer(options.importBindings, slotImports); - - const rendered = ((): StorySnippetResult | undefined => { - if (renderer.kind === 'sfc') { - const snippet = renderSfcSnippet({ - args, - componentName, - renderFunctionSlot, - }); - return snippet ? { imports: [], snippet } : undefined; - } - - if (renderer.kind === 'template') { - return transformTemplate({ - args, - componentImports: renderer.componentImports, - template: renderer.template, - }); - } + if (renderer.kind === 'sfc') { + return renderSfcSnippet({ args, componentName, importBindings: options.importBindings }); + } - return transformH({ + if (renderer.kind === 'template') { + return transformTemplate({ args, - argsParam: renderer.argsParam, - importBindings: options.importBindings, - node: renderer.expression, + componentImports: renderer.componentImports, + template: renderer.template, }); - })(); - - return rendered ? { ...rendered, imports: [...rendered.imports, ...slotImports] } : undefined; -} - -function returnedRenderObjectExpression( - renderFunction: RenderFunctionPath -): t.ObjectExpression | undefined { - const renderObject = returnedObjectExpression(renderFunction.node); - if (renderObject) { - return renderObject; - } - - const returned = returnedExpressionPath(renderFunction); - if (!returned?.isIdentifier()) { - return undefined; } - const resolved = resolveIdentifierInit(renderFunction, returned); - return resolved?.isObjectExpression() ? resolved.node : undefined; + return transformH({ + args, + argsParam: renderer.argsParam, + componentName, + docgen: docgenArgInfo, + importBindings: options.importBindings, + node: renderer.expression, + }); } function argsParameterName(renderFunction: RenderFunctionPath['node']): string | undefined { diff --git a/code/renderers/vue3/src/story-docs/classify-args.ts b/code/renderers/vue3/src/story-docs/classify-args.ts index e808b887119e..09017223382a 100644 --- a/code/renderers/vue3/src/story-docs/classify-args.ts +++ b/code/renderers/vue3/src/story-docs/classify-args.ts @@ -1,11 +1,11 @@ import { type types as t } from 'storybook/internal/babel'; +import { returnedExpression, unwrapExpression } from 'storybook/internal/csf-tools'; import { classifyValue, isFunctionExpression, isSelfContainedFunction, printValue, - singleReturnedExpression, type ValuePlan, } from './classify-value.ts'; @@ -54,6 +54,17 @@ export interface ClassifiedSlotArg { /** Split by role so `arg.role` checks narrow the plans a renderer has to handle. */ export type ClassifiedArg = ClassifiedPropLikeArg | ClassifiedSlotArg; +/** + * Outcome of classifying one arg, before any story-level decision is taken. + * + * `omit` and `unrepresentable` stay distinct because the story-level aggregation treats them + * differently: the first is silent, the second is named in a warning. + */ +export type ArgClassification = + | { kind: 'classified'; arg: ClassifiedArg } + | { kind: 'omit' } + | { kind: 'unrepresentable' }; + export interface ClassifyArgsResult { /** Args that can be rendered into a static Vue snippet. */ args: ClassifiedArg[]; @@ -89,67 +100,12 @@ export function classifyArgs( const omitted: string[] = []; for (const [name, value] of Object.entries(args)) { - const isSlot = docgen.slots.has(name); - - if (isSlot && isFunctionExpression(value)) { - const returned = singleReturnedExpression(value); - if (returned) { - const plan = classifyValue(returned); - - if (plan.kind === 'inline' || plan.kind === 'hoist') { - classified.push({ name, value: returned, role: 'slot', plan }); - continue; - } - - if (plan.kind === 'omit') { - continue; - } - } - - // No static template can reproduce this content, but an `h()` tree renderer may still - // realize the function itself, so forward it instead of deferring outright. - classified.push({ name, value, role: 'slot', plan: { kind: 'function-slot' } }); - continue; - } + const result = classifyArg(name, value, docgen); - if (isFunctionExpression(value)) { - const eventName = declaredEventName(name, docgen.events); - // Function args matching no declared event or prop drop silently, like the runtime does. - if (eventName === undefined && !docgen.props.has(name)) { - continue; - } - if (isSelfContainedFunction(value)) { - classified.push( - eventName === undefined - ? { name, value, role: 'prop', plan: { kind: 'hoist' } } - : { name, value, role: 'event', eventName, plan: { kind: 'hoist' } } - ); - } else { - omitted.push(`${name}: ${printValue(value)}`); - } - continue; - } - - const plan = classifyValue(value); - - if (plan.kind === 'omit') { - continue; - } - - if (plan.kind === 'unrepresentable') { + if (result.kind === 'classified') { + classified.push(result.arg); + } else if (result.kind === 'unrepresentable') { omitted.push(`${name}: ${printValue(value)}`); - continue; - } - - if (isSlot) { - classified.push({ name, value, role: 'slot', plan }); - } else { - classified.push({ - name, - value, - role: docgen.events.has(`update:${name}`) ? 'model' : 'prop', - plan, - }); } } @@ -167,6 +123,82 @@ export function classifyArgs( }; } +/** + * Classifies one arg by Vue docgen precedence: slot, event, v-model, then prop. + * + * Shared with the `h()` renderer, so a prop written directly in a render tree lands in the same + * role, with the same plan, as the identical value routed through the story's `args`. + */ +export function classifyArg( + name: string, + value: t.Node, + docgen: VueDocgenArgInfo +): ArgClassification { + const isSlot = docgen.slots.has(name); + + if (isSlot && isFunctionExpression(value)) { + const returned = returnedExpression(unwrapExpression(value)); + if (returned) { + const plan = classifyValue(returned); + + if (plan.kind === 'inline' || plan.kind === 'hoist') { + return { + kind: 'classified', + arg: { name, value: returned, role: 'slot', plan }, + }; + } + + if (plan.kind === 'omit') { + return { kind: 'omit' }; + } + } + + // No static template can reproduce this content, but an `h()` tree renderer may still realize + // the function itself, so forward it instead of deferring outright. + return { + kind: 'classified', + arg: { name, value, role: 'slot', plan: { kind: 'function-slot' } }, + }; + } + + const eventName = declaredEventName(name, docgen.events); + if (eventName !== undefined && isFunctionExpression(value)) { + return isSelfContainedFunction(value) + ? { + kind: 'classified', + arg: { name, value, role: 'event', eventName, plan: { kind: 'hoist' } }, + } + : { kind: 'unrepresentable' }; + } + + if (isFunctionExpression(value)) { + if (!docgen.props.has(name)) { + return { kind: 'omit' }; + } + return isSelfContainedFunction(value) + ? { kind: 'classified', arg: { name, value, role: 'prop', plan: { kind: 'hoist' } } } + : { kind: 'unrepresentable' }; + } + + const plan = classifyValue(value); + + if (plan.kind === 'omit' || plan.kind === 'unrepresentable') { + return { kind: plan.kind }; + } + + return { + kind: 'classified', + arg: isSlot + ? { name, value, role: 'slot', plan } + : { + name, + value, + role: docgen.events.has(`update:${name}`) ? 'model' : 'prop', + plan, + }, + }; +} + /** * Matches Storybook handler args to declared Vue events. * diff --git a/code/renderers/vue3/src/story-docs/classify-value.ts b/code/renderers/vue3/src/story-docs/classify-value.ts index 51ee31f8d64e..e5fc7d04dbed 100644 --- a/code/renderers/vue3/src/story-docs/classify-value.ts +++ b/code/renderers/vue3/src/story-docs/classify-value.ts @@ -1,5 +1,5 @@ import { recast, type types as t } from 'storybook/internal/babel'; -import { unwrapValue } from 'storybook/internal/csf-tools'; +import { unwrapExpression } from 'storybook/internal/csf-tools'; /** * How one arg value reaches the generated SFC. @@ -64,7 +64,7 @@ const NO_LOCALS: ReadonlySet = new Set(); /** Classifies one CSF arg value into the single plan both the classifier and the renderer act on. */ export function classifyValue(node: t.Node): ValuePlan { - const value = unwrapValue(node); + const value = unwrapExpression(node); // An empty string renders nothing, which is also what the runtime source decorator does with it. if (isFunctionExpression(value) || isUndefinedIdentifier(value) || isEmptyString(value)) { @@ -85,45 +85,13 @@ export function printValue(node: t.Node): string { export function isFunctionExpression( node: T ): node is T & (t.ArrowFunctionExpression | t.FunctionExpression) { - const unwrapped = unwrapValue(node); + const unwrapped = unwrapExpression(node); return unwrapped.type === 'ArrowFunctionExpression' || unwrapped.type === 'FunctionExpression'; } -/** - * Expression a function returns when its body is exactly one static return path. - * - * Deliberately stricter than csf-tools' `returnedObjectExpression`: a block body must contain - * exactly the return statement, since any extra statement could affect what the story renders. - * - * @example `() => 'hi'` → `'hi'`; `setup() { return { args }; }` → `{ args }` - */ -export function singleReturnedExpression(node: t.Node): t.Node | undefined { - const fn = unwrapValue(node); - if ( - fn.type !== 'ArrowFunctionExpression' && - fn.type !== 'FunctionExpression' && - fn.type !== 'ObjectMethod' - ) { - return undefined; - } - - if (fn.body.type !== 'BlockStatement') { - return fn.body; - } - - if (fn.body.body.length !== 1) { - return undefined; - } - - const [statement] = fn.body.body; - return statement.type === 'ReturnStatement' && statement.argument - ? statement.argument - : undefined; -} - /** `args: { a: undefined }` unsets an inherited meta arg, so it renders nothing. */ export function isUndefinedIdentifier(node: t.Node): boolean { - const unwrapped = unwrapValue(node); + const unwrapped = unwrapExpression(node); return unwrapped.type === 'Identifier' && unwrapped.name === UNDEFINED_IDENTIFIER; } @@ -134,7 +102,7 @@ export function isUndefinedIdentifier(node: t.Node): boolean { * @example `(value) => value.toUpperCase()` → true; `(value) => formatHelper(value)` → false */ export function isSelfContainedFunction(node: t.Node): boolean { - const fn = unwrapValue(node); + const fn = unwrapExpression(node); if (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression') { return false; } @@ -199,13 +167,13 @@ function collectPatternNames(pattern: t.Node, into: Set): boolean { } function isEmptyString(node: t.Node): boolean { - const value = unwrapValue(node); + const value = unwrapExpression(node); return value.type === 'StringLiteral' && value.value === ''; } /** Values whose printed form is self-contained, so a template expression can carry them directly. */ function isInlineLiteral(node: t.Node): boolean { - const value = unwrapValue(node); + const value = unwrapExpression(node); switch (value.type) { case 'StringLiteral': @@ -230,7 +198,7 @@ function isInlineLiteral(node: t.Node): boolean { * @example `new Date('2020-01-01')` → true (`Date` is global); `Sizes.LARGE` → false (`Sizes` is not) */ function isResolvable(node: t.Node, locals: ReadonlySet = NO_LOCALS): boolean { - const value = unwrapValue(node); + const value = unwrapExpression(node); const resolves = (child: t.Node): boolean => isResolvable(child, locals); switch (value.type) { diff --git a/code/renderers/vue3/src/story-docs/render-primitives.ts b/code/renderers/vue3/src/story-docs/render-primitives.ts new file mode 100644 index 000000000000..d34730e59f5b --- /dev/null +++ b/code/renderers/vue3/src/story-docs/render-primitives.ts @@ -0,0 +1,385 @@ +import { types as t } from 'storybook/internal/babel'; +import { + buildImportStatements, + unwrapExpression, + type ImportBinding, +} from 'storybook/internal/csf-tools'; + +import type { + ClassifiedArg, + ClassifiedPropLikeArg, + ClassifiedSlotArg, + RenderableValuePlan, +} from './classify-args.ts'; +import { printValue } from './classify-value.ts'; + +export interface RenderSfcMarkupInput { + /** Rendered Vue template markup without the wrapping `