Skip to content
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
16 changes: 8 additions & 8 deletions code/core/src/csf-tools/story-shape/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
15 changes: 14 additions & 1 deletion code/core/src/csf-tools/story-shape/jsdoc.ts
Original file line number Diff line number Diff line change
@@ -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<t.Node>): Record<string, string[]> {
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;
Expand Down
50 changes: 33 additions & 17 deletions code/core/src/csf-tools/story-shape/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {
keyOf,
metaObjectPath,
resolveIdentifierInit,
returnedExpression,
returnedExpressionPath,
unwrapValue,
unwrapExpression,
} from './utils.ts';

const parse = (code: string) => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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`
Expand Down Expand Up @@ -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"`);
});
});

Expand Down
129 changes: 46 additions & 83 deletions code/core/src/csf-tools/story-shape/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand All @@ -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,
Expand All @@ -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 <expression>`. */
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<t.Expression> | undefined;
export function returnedExpressionPath(
renderFunction: RenderFunctionNode
): t.Expression | undefined;
export function returnedExpressionPath(
renderFunction: RenderFunctionPath | RenderFunctionNode
): NodePath<t.Expression> | t.Expression | undefined {
if (isRenderFunctionPath(renderFunction)) {
const body = renderFunction.get('body');
if (body.isExpression()) {
return body;
}
if (!body.isBlockStatement()) {
return undefined;
}
): NodePath<t.Expression> | 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(
Expand Down Expand Up @@ -203,10 +173,3 @@ export function metaObjectPath(csf: CsfFile): NodePath<t.ObjectExpression> | 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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)]),
};
Loading