diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 36d2ad79ad6e..041119a18847 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -26,6 +26,7 @@ import { dedent } from 'ts-dedent'; import { Tag } from '../shared/constants/tags.ts'; import type { PrintResultType } from './PrintResultType.ts'; import { findVarInitialization } from './findVarInitialization.ts'; +import { isCanonicalCsf2BindCall, isCsfFactoryCall } from './story-shape/utils.ts'; // We add this BabelFile as a temporary workaround to deal with a BabelFileClass "ImportEquals should have a literal source" issue in no link mode with tsup interface BabelFile { @@ -118,25 +119,12 @@ export const isModuleMock = (importPath: string) => MODULE_MOCK_REGEX.test(impor const isArgsStory = (init: t.Node, parent: t.Node, csf: CsfFile) => { let storyFn: t.Node = init; // export const Foo = Bar.bind({}) - if (t.isCallExpression(init)) { - const { callee, arguments: bindArguments } = init; - if ( - t.isProgram(parent) && - t.isMemberExpression(callee) && - t.isIdentifier(callee.object) && - t.isIdentifier(callee.property) && - callee.property.name === 'bind' && - (bindArguments.length === 0 || - (bindArguments.length === 1 && - t.isObjectExpression(bindArguments[0]) && - bindArguments[0].properties.length === 0)) - ) { - const boundIdentifier = callee.object.name; - const template = findVarInitialization(boundIdentifier, parent); - if (template) { - csf._templates[boundIdentifier] = template; - storyFn = template; - } + if (t.isProgram(parent) && isCanonicalCsf2BindCall(init)) { + const boundIdentifier = init.callee.object.name; + const template = findVarInitialization(boundIdentifier, parent); + if (template) { + csf._templates[boundIdentifier] = template; + storyFn = template; } } if (t.isArrowFunctionExpression(storyFn)) { @@ -413,21 +401,8 @@ export class CsfFile { getStoryExport(key: string) { let node = this._storyExports[key] as t.Node; node = t.isVariableDeclarator(node) ? (node.init as t.Node) : node; - if (t.isCallExpression(node)) { - const { callee, arguments: bindArguments } = node; - if ( - t.isMemberExpression(callee) && - t.isIdentifier(callee.object) && - t.isIdentifier(callee.property) && - callee.property.name === 'bind' && - (bindArguments.length === 0 || - (bindArguments.length === 1 && - t.isObjectExpression(bindArguments[0]) && - bindArguments[0].properties.length === 0)) - ) { - const { name } = callee.object; - node = this._templates[name]; - } + if (isCanonicalCsf2BindCall(node)) { + node = this._templates[node.callee.object.name]; } return node; } @@ -583,13 +558,7 @@ export class CsfFile { // Check if this is a factory story (meta.story() or meta.extend()) let storyIsFactory = false; - if ( - t.isCallExpression(storyNode) && - t.isMemberExpression(storyNode.callee) && - t.isIdentifier(storyNode.callee.property) && - (storyNode.callee.property.name === 'story' || - storyNode.callee.property.name === 'extend') - ) { + if (storyNode && isCsfFactoryCall(storyNode)) { storyIsFactory = true; storyNode = storyNode.arguments[0]; } diff --git a/code/core/src/csf-tools/story-shape/index.ts b/code/core/src/csf-tools/story-shape/index.ts index 0cfc35b4afcc..80359111a984 100644 --- a/code/core/src/csf-tools/story-shape/index.ts +++ b/code/core/src/csf-tools/story-shape/index.ts @@ -37,6 +37,8 @@ export { } from './resolve-story-args.ts'; export { resolveRenderFunction, type RenderFunctionPath, type RenderResolution } from './render.ts'; export { + isCanonicalCsf2BindCall, + isCsfFactoryCall, keyOf, metaObjectPath, pathForNode, diff --git a/code/core/src/csf-tools/story-shape/normalize-story.test.ts b/code/core/src/csf-tools/story-shape/normalize-story.test.ts index 98a7ea8597ed..291cb7dcc986 100644 --- a/code/core/src/csf-tools/story-shape/normalize-story.test.ts +++ b/code/core/src/csf-tools/story-shape/normalize-story.test.ts @@ -93,6 +93,15 @@ describe('normalizeStoryDeclaration', () => { `); }); + it('rejects zero-argument calls that are not CSF factories', () => { + expect(() => + normalize(dedent` + export default { title: 'Button' }; + export const A = makeStory(); + `) + ).toThrow('Expected story to be csf factory, function or an object expression'); + }); + it('resolves CSF2 Template.bind({}) to a local const arrow function', () => { expect( printedShape(dedent` @@ -123,6 +132,34 @@ describe('normalizeStoryDeclaration', () => { }); }); + it('resolves CSF2 Template.bind() to its local template', () => { + expect( + printedShape(dedent` + export default { title: 'Button' }; + const Template = (args) => args; + export const A = Template.bind(); + `) + ).toMatchInlineSnapshot(` + { + "code": "(args) => args", + "type": "fn", + } + `); + }); + + it.each(["Template.bind({ role: 'button' })", "Template['bind']({})"])( + 'rejects non-canonical CSF2 bind initializer %s', + (initializer) => { + expect(() => + normalize(dedent` + export default { title: 'Button' }; + const Template = (args) => args; + export const A = ${initializer}; + `) + ).toThrow('Expected story to be csf factory, function or an object expression'); + } + ); + it('normalizes plain arrow function story exports to fn', () => { const normalized = normalize(dedent` export default { title: 'Button' }; diff --git a/code/core/src/csf-tools/story-shape/normalize-story.ts b/code/core/src/csf-tools/story-shape/normalize-story.ts index fe08e33ac05a..e65ef512cc3a 100644 --- a/code/core/src/csf-tools/story-shape/normalize-story.ts +++ b/code/core/src/csf-tools/story-shape/normalize-story.ts @@ -1,6 +1,6 @@ -import { type NodePath, types as t } from 'storybook/internal/babel'; +import type { NodePath, types as t } from 'storybook/internal/babel'; -import { resolveIdentifierInit } from './utils.ts'; +import { isCanonicalCsf2BindCall, isCsfFactoryCall, resolveIdentifierInit } from './utils.ts'; export type NormalizedStoryDeclaration = | { type: 'config'; path: NodePath } @@ -64,18 +64,16 @@ function bindInitializer( return null; } + if (!isCanonicalCsf2BindCall(storyPath.node)) { + return null; + } + const callee = storyPath.get('callee'); if (!callee.isMemberExpression()) { return null; } - const obj = callee.get('object'); - const prop = callee.get('property'); - const isBind = - (prop.isIdentifier() && prop.node.name === 'bind') || - (t.isStringLiteral(prop.node) && prop.node.value === 'bind'); - - if (!obj.isIdentifier() || !isBind) { + if (!obj.isIdentifier()) { return null; } @@ -86,7 +84,7 @@ function bindInitializer( function factoryArgumentExpression( storyPath: StoryDeclarationExpression ): StoryDeclarationExpression { - if (!storyPath.isCallExpression()) { + if (!storyPath.isCallExpression() || !isCsfFactoryCall(storyPath.node)) { return storyPath; } @@ -131,7 +129,7 @@ function classifyStoryPath(storyPath: StoryDeclarationExpression): NormalizedSto if ( storyPath.isCallExpression() && - Array.isArray(storyPath.node.arguments) && + isCsfFactoryCall(storyPath.node) && storyPath.node.arguments.length === 0 ) { return { type: 'emptyConfig', path: storyPath }; diff --git a/code/core/src/csf-tools/story-shape/resolve-members.test.ts b/code/core/src/csf-tools/story-shape/resolve-members.test.ts index 570b6bd5c608..76d49426178e 100644 --- a/code/core/src/csf-tools/story-shape/resolve-members.test.ts +++ b/code/core/src/csf-tools/story-shape/resolve-members.test.ts @@ -338,6 +338,181 @@ describe('write order', () => { }); }); +describe('story initializers', () => { + it('reports opaque calls while retaining members assigned after the call', () => { + const code = dedent` + export const Built = makeStory(); + Built.args = { label: 'assigned' }; + `; + + expect(argsOf(code, 'Built')).toMatchInlineSnapshot(` + { + "args": { + "label": "'assigned'", + }, + "unresolved": [ + "makeStory()", + ], + } + `); + }); + + it('reports a factory call whose named config cannot be inspected', () => { + const code = dedent` + const CONFIG = getConfig(); + export const Named = meta.story(CONFIG); + `; + + expect(argsOf(code, 'Named')).toMatchInlineSnapshot(` + { + "args": {}, + "unresolved": [ + "meta.story(CONFIG)", + ], + } + `); + }); + + it('reports every opaque initializer shape while retaining later assignments', () => { + const code = dedent` + export const Optional = makeStory?.(); + Optional.args = { label: 'optional' }; + export const Constructed = new StoryBuilder(); + Constructed.args = { label: 'constructed' }; + export const Conditional = usePrimary ? makePrimary() : makeSecondary(); + Conditional.args = { label: 'conditional' }; + export const Awaited = await makeStory(); + Awaited.args = { label: 'awaited' }; + `; + + expect({ + optional: argsOf(code, 'Optional'), + constructed: argsOf(code, 'Constructed'), + conditional: argsOf(code, 'Conditional'), + awaited: argsOf(code, 'Awaited'), + }).toMatchInlineSnapshot(` + { + "awaited": { + "args": { + "label": "'awaited'", + }, + "unresolved": [ + "await makeStory()", + ], + }, + "conditional": { + "args": { + "label": "'conditional'", + }, + "unresolved": [ + "usePrimary ? makePrimary() : makeSecondary()", + ], + }, + "constructed": { + "args": { + "label": "'constructed'", + }, + "unresolved": [ + "new StoryBuilder()", + ], + }, + "optional": { + "args": { + "label": "'optional'", + }, + "unresolved": [ + "makeStory?.()", + ], + }, + } + `); + }); + + it('keeps canonical bind stories readable without exempting bind calls with config', () => { + const code = dedent` + const Template = (args) => args; + export const EmptyObject = Template.bind({}); + EmptyObject.args = { label: 'empty object' }; + export const NoArgument = Template.bind(); + NoArgument.args = { label: 'no argument' }; + export const Configured = Template.bind({ role: 'button' }); + `; + + expect({ + emptyObject: argsOf(code, 'EmptyObject'), + noArgument: argsOf(code, 'NoArgument'), + configured: argsOf(code, 'Configured'), + }).toMatchInlineSnapshot(` + { + "configured": { + "args": {}, + "unresolved": [ + "Template.bind({ role: 'button' })", + ], + }, + "emptyObject": { + "args": { + "label": "'empty object'", + }, + "unresolved": [], + }, + "noArgument": { + "args": { + "label": "'no argument'", + }, + "unresolved": [], + }, + } + `); + }); + + it('keeps function stories and inline factory configs readable', () => { + const code = dedent` + export const Arrow = (args) => args; + Arrow.args = { label: 'arrow' }; + export function Declared(args) { return args; } + Declared.args = { label: 'declared' }; + export const Inline = meta.story({ args: { label: 'inline' } }); + export const Extended = Inline.extend({ args: { size: 'large' } }); + `; + + expect({ + arrow: argsOf(code, 'Arrow'), + declared: argsOf(code, 'Declared'), + inline: argsOf(code, 'Inline'), + extended: argsOf(code, 'Extended'), + }).toMatchInlineSnapshot(` + { + "arrow": { + "args": { + "label": "'arrow'", + }, + "unresolved": [], + }, + "declared": { + "args": { + "label": "'declared'", + }, + "unresolved": [], + }, + "extended": { + "args": { + "label": "'inline'", + "size": "'large'", + }, + "unresolved": [], + }, + "inline": { + "args": { + "label": "'inline'", + }, + "unresolved": [], + }, + } + `); + }); +}); + describe('CSF factories', () => { it("resolves a spread of a factory story's args", () => { const code = dedent` diff --git a/code/core/src/csf-tools/story-shape/resolve-members.ts b/code/core/src/csf-tools/story-shape/resolve-members.ts index a54f2e8150b6..49f255e90d0a 100644 --- a/code/core/src/csf-tools/story-shape/resolve-members.ts +++ b/code/core/src/csf-tools/story-shape/resolve-members.ts @@ -3,7 +3,7 @@ import { generate, type NodePath, types as t } from 'storybook/internal/babel'; import { importedName, isTypeSpecifier } from './imports.ts'; -import { keyOf, unwrapExpression } from './utils.ts'; +import { isCanonicalCsf2BindCall, isCsfFactoryCall, keyOf, unwrapExpression } from './utils.ts'; /** Members of an object, and what reading it statically could not account for. */ export interface ResolvedMembers { @@ -444,10 +444,14 @@ const declaredMembers = ( } const factory = factoryCall(unwrapped); - if (factory === undefined) { - // A function story carries no config of its own; a CSF2 assignment is still readable. + if (factory === undefined && (t.isFunction(unwrapped) || isCanonicalCsf2BindCall(unwrapped))) { return { members: complete() }; } + if (factory === undefined) { + return { + members: { properties: {}, shadowed: [], unresolved: [sourceOf(unwrapped)] }, + }; + } const config = factory.config ? membersOf(factory.config, ctx, visited) : complete(); if (factory.method === 'story') { @@ -516,13 +520,7 @@ const mergedAnnotations = ( const factoryCall = ( node: t.Node ): { method: 'story' | 'extend'; parent: string; config?: t.ObjectExpression } | undefined => { - if ( - !t.isCallExpression(node) || - !t.isMemberExpression(node.callee) || - node.callee.computed || - !t.isIdentifier(node.callee.property) || - !t.isIdentifier(node.callee.object) - ) { + if (!isCsfFactoryCall(node)) { return undefined; } const method = node.callee.property.name; 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 ca931d98aa18..b01811e70fb1 100644 --- a/code/core/src/csf-tools/story-shape/utils.test.ts +++ b/code/core/src/csf-tools/story-shape/utils.test.ts @@ -4,9 +4,11 @@ import { recast, types as t } from 'storybook/internal/babel'; import { dedent } from 'ts-dedent'; -import { loadCsf } from '../CsfFile.ts'; +import { babelParseFile, loadCsf } from '../CsfFile.ts'; import type { RenderFunctionPath } from './render.ts'; import { + isCanonicalCsf2BindCall, + isCsfFactoryCall, keyOf, metaObjectPath, resolveIdentifierInit, @@ -19,6 +21,23 @@ const parse = (code: string) => { return loadCsf(code, { makeTitle: (title) => title ?? 'title' }).parse(); }; +const storyInitializer = (initializer: string): t.Node => { + let found: t.Expression | null | undefined; + babelParseFile({ code: `const A = ${initializer};` }).path.traverse({ + VariableDeclarator(path) { + if (t.isIdentifier(path.node.id, { name: 'A' })) { + found = path.node.init; + path.stop(); + } + }, + }); + + if (!found) { + throw new Error('Expected declaration to have an initializer'); + } + return found; +}; + // Recast may emit CRLF on Windows; keep assertions LF-stable across OSes. const printed = (node: t.Node) => recast.print(node).code.replace(/\r\n/g, '\n'); @@ -81,6 +100,95 @@ const renderFunctionPath = (code: string): RenderFunctionPath => { return found; }; +describe('isCanonicalCsf2BindCall', () => { + it('accepts only an identifier .bind call with no configuration', () => { + expect( + [ + 'Template.bind()', + 'Template.bind({})', + "Template.bind({ role: 'button' })", + "Template['bind']({})", + 'Template[bind]({})', + 'makeStory({})', + ].map((initializer) => [initializer, isCanonicalCsf2BindCall(storyInitializer(initializer))]) + ).toMatchInlineSnapshot(` + [ + [ + "Template.bind()", + true, + ], + [ + "Template.bind({})", + true, + ], + [ + "Template.bind({ role: 'button' })", + false, + ], + [ + "Template['bind']({})", + false, + ], + [ + "Template[bind]({})", + false, + ], + [ + "makeStory({})", + false, + ], + ] + `); + }); +}); + +describe('isCsfFactoryCall', () => { + it('accepts only static story and extend calls on an identifier receiver', () => { + expect( + [ + 'meta.story({})', + 'Base.extend({})', + "meta['story']({})", + 'meta[story]({})', + 'getMeta().story({})', + 'makeStory({})', + 'Template.bind({})', + ].map((initializer) => [initializer, isCsfFactoryCall(storyInitializer(initializer))]) + ).toMatchInlineSnapshot(` + [ + [ + "meta.story({})", + true, + ], + [ + "Base.extend({})", + true, + ], + [ + "meta['story']({})", + false, + ], + [ + "meta[story]({})", + false, + ], + [ + "getMeta().story({})", + false, + ], + [ + "makeStory({})", + false, + ], + [ + "Template.bind({})", + false, + ], + ] + `); + }); +}); + describe('keyOf', () => { it('returns literal object member keys and skips dynamic keys', () => { const meta = metaObjectPath( diff --git a/code/core/src/csf-tools/story-shape/utils.ts b/code/core/src/csf-tools/story-shape/utils.ts index 05db76c9ca3c..68f2daba629b 100644 --- a/code/core/src/csf-tools/story-shape/utils.ts +++ b/code/core/src/csf-tools/story-shape/utils.ts @@ -3,6 +3,10 @@ import { type NodePath, types as t } from 'storybook/internal/babel'; import type { CsfFile } from '../CsfFile.ts'; import type { RenderFunctionPath } from './render.ts'; +type StaticIdentifierMemberCall = t.CallExpression & { + callee: t.MemberExpression & { object: t.Identifier; property: t.Identifier }; +}; + /** Peels TS assertion/satisfies wrappers and parentheses off an expression node. */ export const unwrapExpression = (node: t.Node): t.Node => t.isTSAsExpression(node) || @@ -13,6 +17,25 @@ export const unwrapExpression = (node: t.Node): t.Node => ? unwrapExpression(node.expression) : node; +export const isCanonicalCsf2BindCall = (node: t.Node): node is StaticIdentifierMemberCall => + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + !node.callee.computed && + t.isIdentifier(node.callee.object) && + t.isIdentifier(node.callee.property, { name: 'bind' }) && + (node.arguments.length === 0 || + (node.arguments.length === 1 && + t.isObjectExpression(node.arguments[0]) && + node.arguments[0].properties.length === 0)); + +export const isCsfFactoryCall = (node: t.Node): node is StaticIdentifierMemberCall => + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + !node.callee.computed && + t.isIdentifier(node.callee.object) && + t.isIdentifier(node.callee.property) && + (node.callee.property.name === 'story' || node.callee.property.name === 'extend'); + /** * Static key of an object member, or `null` when it is computed from something else. * diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-args.ts b/code/frameworks/angular-vite/src/docgen/story-docs-args.ts index cd0bc715e792..21bb0ab88e47 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-args.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-args.ts @@ -97,6 +97,9 @@ const evaluateNode = (node: t.Node, enums: SnippetEnum[]): unknown => { if (t.isIdentifier(unwrapped) && unwrapped.name === 'undefined') { return undefined; } + if (t.isUnaryExpression(unwrapped) && unwrapped.operator === 'void') { + return undefined; + } if ( t.isUnaryExpression(unwrapped) && unwrapped.operator === '-' && diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-build.test.ts b/code/frameworks/angular-vite/src/docgen/story-docs-build.test.ts index 1556b6f9dbd1..b3a3bedeeeee 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-build.test.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-build.test.ts @@ -43,7 +43,7 @@ const givenStoryFile = (source: string) => { const noDocgen = async (): Promise => undefined; const buttonDocgen = - (jsDocTags: AngularDocgenPayload['jsDocTags'] = {}, standalone = true) => + (jsDocTags: AngularDocgenPayload['jsDocTags'] = {}, standalone = true, outputs = ['pressed']) => async (): Promise => ({ id: 'example-button', name: 'ButtonComponent', @@ -54,7 +54,7 @@ const buttonDocgen = selector: 'sb-button', standalone, inputs: ['label'], - outputs: ['pressed'], + outputs, enums: [], }, }); @@ -120,21 +120,21 @@ const STORY_SHAPES_FILE = [ // The idiom every Angular docs example uses: wrapper markup the user wrote, with the bindings // filled in by `argsToTemplate`. `export const ArgsToTemplate = {`, - ` args: { label: 'Save', count: 7 },`, + ` args: { label: 'Save', count: 7, clicked: () => {} },`, ` render: (args) => ({`, ' props: args,', ' template: `
`,', ` }),`, `};`, `export const ArgsToTemplateExclude = {`, - ` args: { label: 'Save', count: 7 },`, + ` args: { label: 'Save', count: 7, clicked: () => {} },`, ` render: (args) => ({`, ' props: args,', " template: ``,", ` }),`, `};`, `export const SlotInterpolation = {`, - ` args: { label: 'Save', footer: 'Bye' },`, + ` args: { label: 'Save', footer: 'Bye', clicked: () => {} },`, ` render: ({ footer, ...args }) => ({`, ' props: args,', ' template: `${footer}`,', @@ -451,7 +451,7 @@ describe('buildStoryDocsPayload', () => { expect(story.warning).toContain('NgModule'); }); - it('leaves a standalone component in `imports` even when moduleMetadata lists modules', async () => { + it('imports a standalone component alongside the modules its moduleMetadata lists', async () => { givenStoryFile(` import { moduleMetadata } from '@storybook/angular-vite'; import { ButtonComponent } from './button.component'; @@ -467,9 +467,23 @@ describe('buildStoryDocsPayload', () => { const payload = await buildStoryDocsPayload({ entry }, { getDocgenPayload: buttonDocgen() }); const story = Object.values(payload!.stories)[0]; - expect(story.snippet).toContain('imports: [ButtonComponent],'); - expect(story.snippet).not.toContain('IconModule'); - expect(story.warning).toBeUndefined(); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + import { IconModule } from './icon.module'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent, IconModule], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": undefined, + } + `); }); it('names the payload after the story file component when core/docgen has no payload', async () => { @@ -671,6 +685,157 @@ describe('buildStoryDocsPayload', () => { ); }); + it('reports an arg the component does not declare instead of binding it', async () => { + const story = await soleStory(` + import { argsToTemplate } from '@storybook/angular-vite'; + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { label: 'Save', tooltip: 'Hi' }, + render: (args) => ({ + props: args, + template: \`\`, + }), + }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent {}", + "warning": "Incomplete snippet: \`tooltip\` could not be bound, since ButtonComponent declares no such input.", + } + `); + }); + + it('reports an arg whose name is not a binding the component accepts', async () => { + const story = await soleStory(` + import { argsToTemplate } from '@storybook/angular-vite'; + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { label: 'Save', 'aria-label': 'Close', 'two words': 'x' }, + render: (args) => ({ + props: args, + template: \`\`, + }), + }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent {}", + "warning": "Incomplete snippet: \`aria-label\`, \`two words\` could not be bound, since ButtonComponent declares no such input.", + } + `); + }); + + it('leaves an undefined-valued arg unbound, as argsToTemplate does at runtime', async () => { + const story = await soleStory(` + import { argsToTemplate } from '@storybook/angular-vite'; + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { label: undefined }, + render: (args) => ({ + props: args, + template: \`\`, + }), + }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent {}", + "warning": undefined, + } + `); + }); + + it('reports a function-valued arg the component declares no output for', async () => { + const story = await soleStory(` + import { argsToTemplate } from '@storybook/angular-vite'; + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { onSelect: () => {} }, + render: (args) => ({ + props: args, + template: \`\`, + }), + }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent {}", + "warning": "Incomplete snippet: \`onSelect\` could not be bound, since ButtonComponent declares no such output.", + } + `); + }); + + it('expands only present args whose values match their binding kind', async () => { + const story = await soleStory( + ` + import { argsToTemplate } from '@storybook/angular-vite'; + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { label: 'Save', pressed: () => {}, changed: 'not a handler' }, + render: (args) => ({ + props: args, + template: \`\`, + }), + }; + `, + buttonDocgen({}, true, ['pressed', 'changed', 'absent']) + ); + + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": "Incomplete snippet: \`changed\` could not be bound, since ButtonComponent declares no such input.", + } + `); + }); + it('honours argsToTemplate exclude options', async () => { expect((await templatesOf(STORY_SHAPES_FILE)).get('Args To Template Exclude')).toBe( `` @@ -678,13 +843,26 @@ describe('buildStoryDocsPayload', () => { }); it('substitutes an interpolated arg used as slot content', async () => { - expect((await templatesOf(STORY_SHAPES_FILE)).get('Slot Interpolation')).toBe( - [ - ``, - ' Bye', - '', - ].join('\n') - ); + const story = (await storiesOf(STORY_SHAPES_FILE)).get('Slot Interpolation'); + expect({ snippet: story?.snippet, warning: story?.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \` + + Bye + \`, + }) + export class DemoComponent { + clicked(event: unknown) {} + }", + "warning": undefined, + } + `); }); it('leaves a story it could read entirely alone', async () => { @@ -749,10 +927,71 @@ describe('buildStoryDocsPayload', () => { expect(snippet).not.toContain(`clicked = 'not a handler';`); }); - it('declares nothing for a story whose markup names no args', async () => { - expect((await storiesOf(STORY_SHAPES_FILE)).get('Own Template')?.snippet).toContain( - 'export class DemoComponent {}' - ); + it('declares statically keyed and void-valued args as host fields', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { 'two words': 'available', label: void 0 }, + render: (args) => ({ + props: args, + template: \`{{ this['two words'] }}\`, + }), + }; + `); + + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`{{ this['two words'] }}\`, + }) + export class DemoComponent { + ['two words'] = 'available'; + label = undefined; + }", + "warning": undefined, + } + `); + }); + + it('declares an arg a @defer condition reads', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { ready: true }, + render: (args) => ({ + props: args, + template: '
@defer (when ready) { }
', + }), + }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \` +
+ @defer (when ready) { + + } +
\`, + }) + export class DemoComponent { + ready = true; + }", + "warning": undefined, + } + `); }); // A name the story file declares is read through to its value: the host component the snippet @@ -969,6 +1208,35 @@ describe('buildStoryDocsPayload', () => { expect(story.snippet).toContain(`[label]="'Save'"`); }); + it('reports an opaque factory story even when its template reads', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + import { makeStory } from './factory'; + export default { + title: 'Example/Button', + component: ButtonComponent, + render: () => ({ template: '' }), + }; + export const Default = makeStory('primary'); + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": "Incomplete snippet: \`makeStory('primary')\` could not be resolved statically.", + } + `); + }); + it('falls back with a warning when argsToTemplate options need the story to run', async () => { const story = await soleStory(` import { argsToTemplate } from '@storybook/angular-vite'; @@ -1389,4 +1657,169 @@ describe('buildStoryDocsPayload', () => { ); }); }); + + describe('args the generated element cannot represent', () => { + it('reports an arg the component declares no input for', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { args: { label: 'Save', tooltip: 'Hi' } }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": "Incomplete snippet: \`tooltip\` could not be bound, since ButtonComponent declares no such input.", + } + `); + }); + + it('names every arg it could not bind', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { args: { tooltip: 'Hi', size: 'large' } }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": "Incomplete snippet: \`tooltip\`, \`size\` could not be bound, since ButtonComponent declares no such input.", + } + `); + }); + + it('leaves an arg the component declares as an output to its handler', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { args: { pressed: () => {} } }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": undefined, + } + `); + }); + + it('reports an arg whose value another module owns', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + import { REMOTE_LABEL } from './labels'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { args: { label: REMOTE_LABEL } }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": "Incomplete snippet: \`REMOTE_LABEL\` could not be resolved statically.", + } + `); + }); + }); + + describe('a source the author wrote', () => { + it('omits generated code when authored source code is explicitly disabled', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { label: 'Save' }, + parameters: { docs: { source: { code: null } } }, + }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": undefined, + "warning": undefined, + } + `); + }); + + it('reports authored code it cannot read instead of replacing it silently', async () => { + const story = await soleStory(` + import { ButtonComponent } from './button.component'; + import { AUTHORED } from './authored'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + args: { label: 'Save' }, + parameters: { docs: { source: { code: AUTHORED } } }, + }; + `); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "import { Component } from '@angular/core'; + import { ButtonComponent } from './button.component'; + + @Component({ + selector: 'app-demo', + imports: [ButtonComponent], + template: \`\`, + }) + export class DemoComponent { + pressed(event: unknown) {} + }", + "warning": "Incomplete snippet: \`AUTHORED\` could not be resolved statically.", + } + `); + }); + + it('uses authored code even when core/docgen has no payload', async () => { + const story = await soleStory( + ` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { + parameters: { docs: { source: { code: '' } } }, + }; + `, + noDocgen + ); + expect({ snippet: story.snippet, warning: story.warning }).toMatchInlineSnapshot(` + { + "snippet": "", + "warning": undefined, + } + `); + }); + }); }); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-build.ts b/code/frameworks/angular-vite/src/docgen/story-docs-build.ts index 49ac7036690e..6eede49f3899 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-build.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-build.ts @@ -12,6 +12,7 @@ import { parseReferenceModule, resolveComponentImport, unresolvedWarning, + unwrapExpression, } from 'storybook/internal/csf-tools'; import type { StoryDoc, StoryDocsPayload, StoryDocsProviderInput } from 'storybook/internal/types'; @@ -24,17 +25,18 @@ import { evaluateArgExpression, evaluateArgLiteral, } from './story-docs-args.ts'; -import type { Bindings, StoryShape, TemplateResult } from './story-docs-markup.ts'; +import type { Bindings, StoryShape } from './story-docs-markup.ts'; import { sourceOf, userTemplate } from './story-docs-markup.ts'; import type { StoryNgModules } from './story-docs-ng-modules.ts'; import { ngModulesFromDecorators, storyNgModules } from './story-docs-ng-modules.ts'; import type { HostComponentSnippet } from './story-docs-snippet.ts'; import { buildHostComponentSnippet } from './story-docs-snippet.ts'; +import { authoredSource } from './story-docs-source.ts'; +import type { StoryTemplateAnalysis } from './story-docs-template-analysis.ts'; import { buildComponentOutletTemplate, buildTemplate, formatTemplateMarkup, - isValidIdentifier, } from '../template-grammar.ts'; export interface BuildStoryDocsContext { @@ -97,10 +99,14 @@ export const buildStoryDocsPayload = async ( importBindings, }; - const stories: Record = {}; - for (const [exportName, story] of Object.entries(csf._stories)) { - stories[story.id] = buildStoryDoc(exportName, story, deps); - } + const stories = Object.fromEntries( + await Promise.all( + Object.entries(csf._stories).map( + async ([exportName, story]) => + [story.id, await buildStoryDoc(exportName, story, deps)] as const + ) + ) + ); const titleName = input.entry.title.split('/').at(-1)!.replace(/\s+/g, ''); return { @@ -150,19 +156,16 @@ interface StoryDocDeps { // One instance per process, so the module-resolution cache is shared; each build opens its own. const openStoryReferences = createStoryReferenceResolver(); -const buildStoryDoc = ( +const buildStoryDoc = async ( exportName: string, story: CsfFile['_stories'][string], deps: StoryDocDeps -): StoryDoc => { - const { csf, snippetMeta } = deps; +): Promise => { + const { csf } = deps; const name = story.name ?? storyNameFromExport(exportName); try { const { description, summary } = extractStoryJSDocInfo(csf._storyStatements[exportName]); - const shape = storyShape(exportName, deps); - const rendered = snippetMeta - ? renderStorySnippet(snippetMeta, shape, shape.members.properties.decorators, deps) - : undefined; + const rendered = await renderedSnippet(storyShape(exportName, deps), deps); return { id: story.id, @@ -182,6 +185,27 @@ const buildStoryDoc = ( } }; +// `parameters.docs.source.code` is the example the author chose to publish, so it replaces what +// this pass would derive rather than competing with it. +const renderedSnippet = async ( + shape: StoryShape, + deps: StoryDocDeps +): Promise => { + const authored = authoredSource(shape, deps.resolveStoryArgs.ctx); + if (authored.kind === 'code') { + return { snippet: authored.code }; + } + if (authored.kind === 'disabled') { + return undefined; + } + const derived = deps.snippetMeta + ? await renderStorySnippet(deps.snippetMeta, shape, shape.members.properties.decorators, deps) + : undefined; + return derived && authored.kind === 'unresolvable' + ? withWarnings(derived, unresolvedWarning([authored.source])) + : derived; +}; + const storyShape = (exportName: string, deps: StoryDocDeps): StoryShape => { const resolved = deps.resolveStoryArgs.resolve(exportName); const enums = deps.snippetMeta?.enums ?? []; @@ -214,21 +238,21 @@ const storyShape = (exportName: string, deps: StoryDocDeps): StoryShape => { * but silently shipping it would leave a consumer no way to know its example is partial, so the * story carries a `warning` naming the source text this pass could not read. */ -const renderStorySnippet = ( +const renderStorySnippet = async ( snippetMeta: AngularComponentSnippetMeta, shape: StoryShape, storyDecorators: t.Node | undefined, deps: StoryDocDeps -): HostComponentSnippet => { +): Promise => { const { componentImport } = deps; // The story file's local name is what the import binds, so an aliased import stays consistent // between the import statement, the `imports` array and the template. const localName = componentNameOf(shape.csf._metaAnnotations.component) ?? snippetMeta.name; - const ngModules = snippetMeta.standalone ? undefined : storyNgModules(storyDecorators, deps); - const bindings = collectBindings(snippetMeta, shape); + const ngModules = storyNgModules(storyDecorators, deps); + const expansion = argsExpansion(snippetMeta, shape); // Hidden args would expand `argsToTemplate` into markup that looks complete, so the markup is // read without bindings then and falls back with a warning instead. - const userMarkup = userTemplate(shape, shape.unresolvedArgs.length === 0 ? bindings : undefined); + const userMarkup = userTemplate(shape, shape.unresolvedArgs.length === 0 ? expansion : undefined); const host = ( template: string, @@ -248,15 +272,33 @@ const renderStorySnippet = ( }); if (userMarkup?.kind === 'literal') { - // The markup is shown exactly as it was written, so the host has to supply what the story - // supplied: handlers for the outputs it binds, and the args it reaches for by name. - const boundOutputs = snippetMeta.outputs.filter((name) => - userMarkup.markup.includes(`(${name})=`) + const { analyzeStoryTemplate } = await import('./story-docs-template-analysis.ts'); + const analysis = analyzeStoryTemplate(userMarkup.markup, userMarkup.expansions); + const referencedNames = new Set(analysis.kind === 'resolved' ? analysis.referencedNames : []); + const boundOutputs = + analysis.kind === 'resolved' + ? snippetMeta.outputs.filter((name) => analysis.boundOutputs.includes(name)) + : []; + const hostArgs = referencedArgFields( + referencedNames, + userMarkup.representedArgs, + shape, + boundOutputs, + snippetMeta.enums ); - const hostArgs = referencedArgFields(userMarkup, shape, boundOutputs, snippetMeta.enums); - return withUnresolved( - host(formatTemplateMarkup(userMarkup.markup), false, boundOutputs, hostArgs.fields), - hostArgs.unresolved + return withWarnings( + host(formatTemplateMarkup(analysis.markup), false, boundOutputs, hostArgs.fields), + unresolvedWarning([ + ...shape.metaMembers.unresolved, + ...shape.members.unresolved, + ...hostArgs.unresolved, + ]), + templateAnalysisWarning(analysis), + unknownTemplateReferencesWarning(referencedNames, shape, boundOutputs), + unboundArgsWarning(localName, snippetMeta, shape, [ + ...userMarkup.representedArgs, + ...referencedNames, + ]) ); } @@ -264,39 +306,40 @@ const renderStorySnippet = ( // The outlet form shows no args at all, so naming the args that could not be read would say // nothing about what is missing from it. return snippetMeta.selector - ? withUnresolved( + ? withWarnings( host( - buildTemplate(snippetMeta.selector, { ...bindings, selfClosing: true }), + buildTemplate(snippetMeta.selector, { + ...componentBindings(snippetMeta, shape), + selfClosing: true, + }), false, snippetMeta.outputs ), - [...markupSources, ...shape.unresolvedArgs] + unresolvedWarning([...markupSources, ...shape.unresolvedArgs]), + unboundArgsWarning(localName, snippetMeta, shape) ) - : withUnresolved( + : withWarnings( host(buildComponentOutletTemplate(localName, { selfClosing: true }), true, []), - markupSources + unresolvedWarning(markupSources) ); }; -/** - * Args the story's own markup binds by name, as host members holding the value the story gave them. - */ const referencedArgFields = ( - markup: Extract, + referencedNames: ReadonlySet, + representedArgs: readonly string[], shape: StoryShape, boundOutputs: readonly string[], enums: AngularComponentSnippetMeta['enums'] ): { fields: { name: string; value: string }[]; unresolved: string[] } => { - // An output already contributes a handler under the same name, and a class cannot hold both. - const taken = new Set([...markup.expandedArgs, ...boundOutputs]); + const taken = new Set([...representedArgs, ...boundOutputs]); const fields: { name: string; value: string }[] = []; const unresolved: string[] = []; for (const [name, node] of Object.entries(shape.args)) { - if (taken.has(name) || !isValidIdentifier(name)) { + if (taken.has(name)) { continue; } - if (!new RegExp(`\\b${name}\\b`).test(markup.markup)) { + if (!referencedNames.has(name)) { continue; } const value = evaluateArgLiteral(node, enums); @@ -310,20 +353,72 @@ const referencedArgFields = ( return { fields, unresolved }; }; -const withUnresolved = ( +const withWarnings = ( rendered: HostComponentSnippet, - unresolved: readonly string[] + ...parts: (string | undefined)[] ): HostComponentSnippet => { - if (unresolved.length === 0) { - return rendered; - } - const warning = [rendered.warning, unresolvedWarning(unresolved)] + const warning = [...new Set([rendered.warning, ...parts])] .filter((part) => part !== undefined) .join('\n'); - return { snippet: rendered.snippet, warning }; + return warning === '' ? rendered : { snippet: rendered.snippet, warning }; +}; + +const templateAnalysisWarning = (analysis: StoryTemplateAnalysis): string | undefined => + analysis.kind === 'resolved' + ? undefined + : `Incomplete snippet: the story template could not be analyzed statically. ${analysis.errors.join('\n')}`; + +const unknownTemplateReferencesWarning = ( + referencedNames: ReadonlySet, + shape: StoryShape, + outputHandlers: readonly string[] +): string | undefined => { + const provided = new Set([...Object.keys(shape.args), ...outputHandlers]); + const names = [...referencedNames].filter((name) => !provided.has(name)); + return names.length === 0 + ? undefined + : `Incomplete snippet: ${names.map((name) => `\`${name}\``).join(', ')} could not be provided, ` + + `since the story declares no such arg.`; }; -const collectBindings = (snippetMeta: AngularComponentSnippetMeta, shape: StoryShape): Bindings => { +const unboundArgsWarning = ( + componentName: string, + snippetMeta: AngularComponentSnippetMeta, + shape: StoryShape, + representedArgs?: readonly string[] +): string | undefined => { + const represented = new Set(representedArgs); + const inputNames = new Set(snippetMeta.inputs); + const outputNames = new Set(snippetMeta.outputs); + const unbound = Object.entries(shape.args) + .filter(([name, node]) => { + if (represented.has(name) || isUndefinedValue(node)) { + return false; + } + if (representedArgs === undefined) { + return !inputNames.has(name) && !outputNames.has(name); + } + return isFunctionValue(node) ? !outputNames.has(name) : !inputNames.has(name); + }) + .map(([name, node]) => ({ name, kind: isFunctionValue(node) ? 'output' : 'input' })); + if (unbound.length === 0) { + return undefined; + } + const kinds = new Set(unbound.map(({ kind }) => kind)); + const reason = + kinds.size === 1 + ? `declares no such ${unbound[0]!.kind}` + : 'declares no compatible input or output'; + return ( + `Incomplete snippet: ${unbound.map(({ name }) => `\`${name}\``).join(', ')} could not be bound, ` + + `since ${componentName} ${reason}.` + ); +}; + +const componentBindings = ( + snippetMeta: AngularComponentSnippetMeta, + shape: StoryShape +): Bindings => { const inputNames = new Set(snippetMeta.inputs); const inputs = Object.entries(shape.args) .filter(([argName]) => inputNames.has(argName)) @@ -333,3 +428,36 @@ const collectBindings = (snippetMeta: AngularComponentSnippetMeta, shape: StoryS })); return { inputs, outputs: snippetMeta.outputs }; }; + +const argsExpansion = (snippetMeta: AngularComponentSnippetMeta, shape: StoryShape): Bindings => { + const inputNames = new Set(snippetMeta.inputs); + const outputNames = new Set(snippetMeta.outputs); + const inputs: Bindings['inputs'] = []; + const outputs: string[] = []; + for (const [name, node] of Object.entries(shape.args)) { + if (isUndefinedValue(node)) { + continue; + } + if (isFunctionValue(node)) { + if (outputNames.has(name)) { + outputs.push(name); + } + } else if (inputNames.has(name)) { + inputs.push({ name, expression: evaluateArgExpression(node, snippetMeta.enums) }); + } + } + return { inputs, outputs }; +}; + +const isFunctionValue = (node: t.Node): boolean => { + const unwrapped = unwrapExpression(node); + return t.isFunction(unwrapped); +}; + +const isUndefinedValue = (node: t.Node): boolean => { + const unwrapped = unwrapExpression(node); + return ( + (t.isIdentifier(unwrapped) && unwrapped.name === 'undefined') || + t.isUnaryExpression(unwrapped, { operator: 'void' }) + ); +}; diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-markup.test.ts b/code/frameworks/angular-vite/src/docgen/story-docs-markup.test.ts new file mode 100644 index 000000000000..d3b062aeeaa5 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-markup.test.ts @@ -0,0 +1,274 @@ +import { createStoryArgsResolver, loadCsf } from 'storybook/internal/csf-tools'; + +import { describe, expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import type { Bindings, StoryShape } from './story-docs-markup.ts'; +import { userTemplate } from './story-docs-markup.ts'; +import { analyzeStoryTemplate } from './story-docs-template-analysis.ts'; + +const shapeOf = (source: string, exportName: string): StoryShape => { + const csf = loadCsf(source, { makeTitle: () => 'Example/Button' }).parse(); + const resolved = createStoryArgsResolver(csf).resolve(exportName); + return { + csf, + exportName, + members: resolved.storyMembers, + metaMembers: resolved.metaMembers, + args: resolved.args, + unresolvedArgs: resolved.unresolved, + }; +}; + +const bindings: Bindings = { + inputs: [{ name: 'label', expression: "'Save'" }], + outputs: ['pressed'], +}; + +const templateOf = ( + source: string, + exportName = 'Default', + templateBindings: Bindings = bindings +) => { + const template = userTemplate(shapeOf(source, exportName), templateBindings); + if (template?.kind !== 'literal') { + return template; + } + const analysis = analyzeStoryTemplate(template.markup, template.expansions); + if (analysis.kind === 'unresolvable') { + throw new Error(analysis.errors.join('\n')); + } + return { + kind: template.kind, + markup: analysis.markup, + representedArgs: template.representedArgs, + }; +}; + +describe('userTemplate', () => { + it('reads a String.raw template as the markup it spells out', () => { + expect( + templateOf(dedent` + export default { title: 'Example/Button' }; + export const Default = { template: String.raw\`Save\` }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "Save", + "representedArgs": [], + } + `); + }); + + it('reads a String.raw template the story reaches through a module-level name', () => { + expect( + templateOf(dedent` + const TEMPLATE = String.raw\`\`; + export default { title: 'Example/Button' }; + export const Default = { template: TEMPLATE }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "", + "representedArgs": [], + } + `); + }); + + it('reads a String.raw template out of a render function', () => { + expect( + templateOf(dedent` + export default { title: 'Example/Button' }; + export const Default = { + render: () => ({ template: String.raw\`\` }), + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "", + "representedArgs": [], + } + `); + }); + + it('keeps a String.raw escape sequence literal instead of cooking it', () => { + expect( + templateOf(dedent` + export default { title: 'Example/Button' }; + export const Default = { template: String.raw\`\` }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "", + "representedArgs": [], + } + `); + }); + + it('substitutes into a String.raw template the same way it does a plain one', () => { + expect( + templateOf(dedent` + import { argsToTemplate } from '@storybook/angular-vite'; + const FOOTER = 'Bye'; + export default { title: 'Example/Button' }; + export const Default = { + args: { label: 'Save' }, + render: (args) => ({ + props: args, + template: String.raw\`\${FOOTER}\`, + }), + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "Bye", + "representedArgs": [ + "label", + "pressed", + ], + } + `); + }); + + it('does not duplicate an included output already bound on the interpolation element', () => { + expect( + templateOf(dedent` + import { argsToTemplate } from '@storybook/angular-vite'; + export default { title: 'Example/Button' }; + export const Default = { + render: (args) => ({ + props: args, + template: \`\`, + }), + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "", + "representedArgs": [ + "pressed", + ], + } + `); + }); + + it('records an interpolated parameter as represented when it substitutes its arg value', () => { + expect( + templateOf(dedent` + export default { title: 'Example/Button' }; + export const Default = { + args: { footer: 'Bye' }, + render: ({ footer }) => ({ template: \`\${footer}\` }), + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "Bye", + "representedArgs": [ + "footer", + ], + } + `); + }); + + it('maps a renamed destructured parameter back to its source arg', () => { + expect( + templateOf(dedent` + export default { title: 'Example/Button' }; + export const Default = { + args: { label: 'Save', text: 'Wrong' }, + render: ({ label: text }) => ({ template: \`\${text}\` }), + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "literal", + "markup": "Save", + "representedArgs": [ + "label", + ], + } + `); + }); + + it('does not guess which arg a computed destructuring key reads', () => { + expect( + templateOf(dedent` + const key = 'label'; + const text = 'module value'; + export default { title: 'Example/Button' }; + export const Default = { + args: { label: 'Save' }, + render: ({ [key]: text }) => ({ template: \`\${text}\` }), + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "unresolvable", + "source": "\`\${text}\`", + } + `); + }); + + it('does not treat the render context parameter as story args', () => { + expect( + templateOf(dedent` + const context = 'module value'; + export default { title: 'Example/Button' }; + export const Default = { + args: { context: 'ARG' }, + render: (args, context) => ({ template: \`\${context}\` }), + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "unresolvable", + "source": "\`\${context}\`", + } + `); + }); + + it('does not replace a local helper that shadows the imported argsToTemplate', () => { + expect( + templateOf(dedent` + import { argsToTemplate } from '@storybook/angular-vite'; + export default { title: 'Example/Button' }; + export const Default = { + args: { label: 'Save' }, + render: (args) => { + const argsToTemplate = () => 'custom'; + return { template: \`\` }; + }, + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "unresolvable", + "source": "\`\`", + } + `); + }); + + it('leaves a template tagged with anything other than String.raw unresolvable', () => { + expect( + templateOf(dedent` + import { html } from 'lit'; + export default { title: 'Example/Button' }; + export const Default = { template: html\`\` }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "unresolvable", + "source": "html\`\`", + } + `); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts b/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts index ef78b8accc5e..1d4407221eeb 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts @@ -3,7 +3,12 @@ // is the shared CSF pass in `story-shape`, spreads and names already followed. import { type NodePath, types as t } from 'storybook/internal/babel'; import type { CsfFile, ResolvedMembers } from 'storybook/internal/csf-tools'; -import { sourceOf, unwrapExpression } from 'storybook/internal/csf-tools'; +import { + isCanonicalCsf2BindCall, + isCsfFactoryCall, + sourceOf, + unwrapExpression, +} from 'storybook/internal/csf-tools'; import { formatPropInTemplate } from '../template-grammar.ts'; @@ -29,6 +34,13 @@ export interface Bindings { outputs: string[]; } +// One `argsToTemplate` site, materialized after Angular identifies its surrounding element. +export interface TemplateExpansion { + marker: string; + inputAttributes: string[]; + outputAttributes: { name: string; markup: string }[]; +} + /** Which arg names a binding list covers, mirroring `argsToTemplate`'s own options. */ interface BindingFilter { include?: readonly string[]; @@ -44,26 +56,35 @@ interface BindingFilter { const bindingAttributes = ( { inputs, outputs }: Bindings, filter: BindingFilter, - expanded: Set -): string[] => { + representedArgs: Set +): Omit => { const allowed = (name: string) => filter.include ? filter.include.includes(name) : !filter.exclude?.includes(name); - const expandedInputs = inputs.filter(({ name }) => allowed(name)); - expandedInputs.forEach(({ name }) => expanded.add(name)); - return [ - ...expandedInputs.map(({ name, expression }) => `[${name}]="${expression}"`), - ...outputs.filter(allowed).map((name) => `(${name})="${formatPropInTemplate(name)}($event)"`), - ]; + const representedInputs = inputs.filter(({ name }) => allowed(name)); + const representedOutputs = outputs.filter(allowed); + representedInputs.forEach(({ name }) => representedArgs.add(name)); + representedOutputs.forEach((name) => representedArgs.add(name)); + return { + inputAttributes: representedInputs.map(({ name, expression }) => `[${name}]="${expression}"`), + outputAttributes: representedOutputs.map((name) => ({ + name, + markup: `(${name})="${formatPropInTemplate(name)}($event)"`, + })), + }; }; /** What a `template` turned out to hold. */ export type TemplateResult = /** - * Read as markup, so the story is shown as written. `expandedArgs` names the args an - * `argsToTemplate` call already wrote into the markup as values, which is what tells a caller - * which of the remaining args the markup can only be referring to by name. + * Read as markup, so the story is shown as written. `representedArgs` names the args whose values + * the markup already contains. */ - | { kind: 'literal'; markup: string; expandedArgs: readonly string[] } + | { + kind: 'literal'; + markup: string; + representedArgs: readonly string[]; + expansions: readonly TemplateExpansion[]; + } /** * A `template` or `render` exists, but its markup needs the story to run. `source` is that * expression as written, so the story can say which one it fell back from; it is absent when a @@ -73,8 +94,10 @@ export type TemplateResult = /** What the function owning a template literal binds, deciding how `${name}` resolves. */ interface FunctionScope { - /** Names bound from the function's parameters; they resolve to story args. */ - paramNames: ReadonlySet; + /** Local parameter names mapped to the story arg each one destructures. */ + argBindings: ReadonlyMap; + /** Every parameter-local name, including bindings whose value cannot be mapped to one arg. */ + parameterNames: ReadonlySet; /** Names its body declares; their value at render time is not statically knowable. */ bodyDeclared: ReadonlySet; /** @@ -85,7 +108,8 @@ interface FunctionScope { } const NO_SCOPE: FunctionScope = { - paramNames: new Set(), + argBindings: new Map(), + parameterNames: new Set(), bodyDeclared: new Set(), argsExpansions: new Map(), }; @@ -183,34 +207,89 @@ const templateFrom = ( return undefined; } if (t.isStringLiteral(node)) { - return { kind: 'literal', markup: node.value, expandedArgs: [] }; + return literalTemplate(node.value, new Set(), []); } - if (t.isTemplateLiteral(node)) { - const expanded = new Set(); - const markup = interpolate(node, shape, bindings, scope, expanded); + const parts = templateParts(node); + if (parts) { + const representedArgs = new Set(); + const expansions: TemplateExpansion[] = []; + const markup = interpolate(parts, shape, bindings, scope, representedArgs, expansions); return markup === undefined ? { kind: 'unresolvable', source: sourceOf(node) } - : { kind: 'literal', markup, expandedArgs: [...expanded] }; + : literalTemplate(markup, representedArgs, expansions); } return { kind: 'unresolvable', source: sourceOf(node) }; }; +const literalTemplate = ( + markup: string, + representedArgs: ReadonlySet, + expansions: readonly TemplateExpansion[] +): Extract => ({ + kind: 'literal', + markup, + representedArgs: [...representedArgs], + expansions, +}); + +interface TemplateParts { + quasis: string[]; + expressions: t.Node[]; +} + +// `String.raw` is the identity tag: it hands back the text between the backticks, so a template +// wearing it is as readable as a plain one. No other tag transforms its input predictably. +export const templateParts = (node: t.Node): TemplateParts | undefined => { + if (t.isTemplateLiteral(node)) { + return { + quasis: node.quasis.map((quasi) => quasi.value.cooked ?? ''), + expressions: node.expressions, + }; + } + if (!t.isTaggedTemplateExpression(node) || !isStringRawTag(node.tag)) { + return undefined; + } + return { + quasis: node.quasi.quasis.map((quasi) => quasi.value.raw), + expressions: node.quasi.expressions, + }; +}; + +const isStringRawTag = (tag: t.Expression): boolean => + t.isMemberExpression(tag) && + !tag.computed && + t.isIdentifier(tag.object, { name: 'String' }) && + t.isIdentifier(tag.property, { name: 'raw' }); + /** Markup a template literal holds once every `${…}` in it has been substituted. */ const interpolate = ( - node: t.TemplateLiteral, + { quasis, expressions }: TemplateParts, shape: StoryShape, bindings: Bindings | undefined, scope: FunctionScope, - expanded: Set + representedArgs: Set, + expansions: TemplateExpansion[] ): string | undefined => { - let markup = node.quasis[0]?.value.cooked ?? ''; + let markup = quasis[0] ?? ''; - for (const [index, expression] of node.expressions.entries()) { - const substituted = substituteExpression(expression, shape, bindings, scope, expanded); + for (const [index, expression] of expressions.entries()) { + let marker = `data-storybook-args-to-template-${index}`; + while (markup.includes(marker) || quasis.some((quasi) => quasi.includes(marker))) { + marker += '-x'; + } + const substituted = substituteExpression( + expression, + shape, + bindings, + scope, + representedArgs, + marker, + expansions + ); if (substituted === undefined) { return undefined; } - markup += substituted + (node.quasis[index + 1]?.value.cooked ?? ''); + markup += substituted + (quasis[index + 1] ?? ''); } return markup; @@ -233,13 +312,11 @@ const substituteExpression = ( shape: StoryShape, bindings: Bindings | undefined, scope: FunctionScope, - expanded: Set + representedArgs: Set, + marker: string, + expansions: TemplateExpansion[] ): string | undefined => { - if ( - t.isCallExpression(expression) && - t.isIdentifier(expression.callee) && - expression.callee.name === 'argsToTemplate' - ) { + if (t.isCallExpression(expression) && isImportedArgsToTemplate(expression, shape)) { // Only the args parameter (whole, or as a rest binding) has a knowable expansion; a derived // object expands to whatever the story computes at runtime. const argument = expression.arguments[0]; @@ -256,14 +333,23 @@ const substituteExpression = ( const allowed = filter.include ? { ...withRest, include: filter.include.filter((name) => !excluded.includes(name)) } : withRest; - return bindingAttributes(bindings, allowed, expanded).join(' '); + expansions.push({ marker, ...bindingAttributes(bindings, allowed, representedArgs) }); + return marker; } if (!t.isIdentifier(expression)) { return undefined; } - if (scope.paramNames.has(expression.name)) { - return shape.unresolvedArgs.length === 0 ? literalText(shape.args[expression.name]) : undefined; + const argName = scope.argBindings.get(expression.name); + if (argName !== undefined) { + const text = shape.unresolvedArgs.length === 0 ? literalText(shape.args[argName]) : undefined; + if (text !== undefined) { + representedArgs.add(argName); + } + return text; + } + if (scope.parameterNames.has(expression.name)) { + return undefined; } // A name the body declares has a render-time value this pass cannot know. if (scope.bodyDeclared.has(expression.name)) { @@ -273,6 +359,29 @@ const substituteExpression = ( return declared === expression ? undefined : literalText(declared); }; +const ARGS_TO_TEMPLATE_MODULES = new Set(['@storybook/angular', '@storybook/angular-vite']); + +const isImportedArgsToTemplate = (call: t.CallExpression, shape: StoryShape): boolean => { + if (!t.isIdentifier(call.callee)) { + return false; + } + const binding = shape.csf._file.path.scope.getBinding(call.callee.name); + if (!binding?.referencePaths.some((path) => path.node === call.callee)) { + return false; + } + const specifier = binding?.path.node; + const declaration = binding?.path.findParent((path) => t.isImportDeclaration(path.node))?.node; + if (!t.isImportSpecifier(specifier) || !t.isImportDeclaration(declaration)) { + return false; + } + const importedName = t.isIdentifier(specifier.imported) + ? specifier.imported.name + : specifier.imported.value; + return ( + importedName === 'argsToTemplate' && ARGS_TO_TEMPLATE_MODULES.has(declaration.source.value) + ); +}; + /** Filter for `argsToTemplate` options, or `undefined` when the options need the story to run. */ const bindingFilterOf = (options: t.Node | undefined): BindingFilter | undefined => { if (options === undefined) { @@ -317,16 +426,15 @@ const literalText = (node: t.Node | undefined): string | undefined => { : undefined; }; -/** How one annotation resolved against its config object. */ type AnnotationResolution = | { kind: 'value'; node: t.Node } | { kind: 'missing' } - /** - * A spread may shadow or supply the property, or it is an accessor; the value is unknowable. - * `node` is the accessor itself; a spread cause carries no node, the config-member scan names it. - */ | { kind: 'unresolvable'; node?: t.Node }; +type PropertyResolution = + | Exclude + | { kind: 'unresolvable'; node: t.Node }; + /** * A named member of a resolved config record. * @@ -346,26 +454,26 @@ export const resolvedMember = (members: ResolvedMembers, key: string): Annotatio }; /** A named property of an object literal, for options a call site writes out inline. */ -export const resolvedProperty = (object: t.ObjectExpression, key: string): AnnotationResolution => { +export const resolvedProperty = (object: t.ObjectExpression, key: string): PropertyResolution => { let found: t.ObjectMethod | t.ObjectProperty | undefined; - let opaqueAfter = false; + let opaqueAfter: t.ObjectExpression['properties'][number] | undefined; object.properties.forEach((property) => { const isMember = t.isObjectProperty(property) || t.isObjectMethod(property); if (isMember && keyNameOf(property) === key) { found = property; - opaqueAfter = false; + opaqueAfter = undefined; return; } if (t.isSpreadElement(property) || (isMember && keyNameOf(property) === undefined)) { - opaqueAfter = true; + opaqueAfter = property; } }); if (!found) { - return opaqueAfter ? { kind: 'unresolvable' } : { kind: 'missing' }; + return opaqueAfter ? { kind: 'unresolvable', node: opaqueAfter } : { kind: 'missing' }; } if (opaqueAfter) { - return { kind: 'unresolvable' }; + return { kind: 'unresolvable', node: opaqueAfter }; } if (t.isObjectMethod(found)) { return found.kind === 'method' && !found.generator @@ -400,7 +508,7 @@ export const storyConfigObject = ( if (unwrapped && t.isObjectExpression(unwrapped)) { return unwrapped; } - if (unwrapped && t.isCallExpression(unwrapped) && isStoryFactoryCall(unwrapped)) { + if (unwrapped && isCsfFactoryCall(unwrapped)) { const argument = unwrapped.arguments[0]; const config = argument && unwrapExpression(argument); if (config && t.isObjectExpression(config)) { @@ -411,11 +519,6 @@ export const storyConfigObject = ( return undefined; }; -export const isStoryFactoryCall = (call: t.CallExpression): boolean => - t.isMemberExpression(call.callee) && - t.isIdentifier(call.callee.property) && - ['story', 'extend'].includes(call.callee.property.name); - export const metaConfigObject = (csf: CsfFile): t.ObjectExpression | undefined => { const node = csf._metaNode; return node && t.isObjectExpression(node) ? node : undefined; @@ -469,8 +572,8 @@ const csf2Shape = (shape: StoryShape): { fn: t.Node; returned: t.ObjectExpressio for (const candidate of candidates) { let fn = candidate ? unwrapExpression(candidate) : undefined; // `Template.bind({})` renders Template; the bound copy shares its body. - if (fn && t.isCallExpression(fn) && isBindCall(fn)) { - fn = declaredValue(shape, unwrapExpression((fn.callee as t.MemberExpression).object)); + if (fn && isCanonicalCsf2BindCall(fn)) { + fn = declaredValue(shape, fn.callee.object); } const returned = returnedObject(fn); if (fn && returned) { @@ -480,9 +583,6 @@ const csf2Shape = (shape: StoryShape): { fn: t.Node; returned: t.ObjectExpressio return undefined; }; -export const isBindCall = (call: t.CallExpression): boolean => - t.isMemberExpression(call.callee) && t.isIdentifier(call.callee.property, { name: 'bind' }); - /** What a render function binds, as far as it can be enumerated statically. */ const functionScope = (fn: t.Node | undefined): FunctionScope => { if ( @@ -494,27 +594,23 @@ const functionScope = (fn: t.Node | undefined): FunctionScope => { return NO_SCOPE; } - const paramNames = new Set(); - const collect = (pattern: t.Node): void => { - if (t.isIdentifier(pattern)) { - paramNames.add(pattern.name); - } else if (t.isObjectPattern(pattern)) { - for (const property of pattern.properties) { - if (t.isRestElement(property)) { - collect(property.argument); - } else { - collect(property.value); - } + const [firstParam] = fn.params; + const argsPattern = t.isAssignmentPattern(firstParam) ? firstParam.left : firstParam; + const parameterNames = new Set(); + fn.params.forEach((param) => collectPatternNames(param, parameterNames)); + const argBindings = new Map(); + if (t.isObjectPattern(argsPattern)) { + for (const property of argsPattern.properties) { + if (!t.isObjectProperty(property) || property.computed) { + continue; + } + const argName = keyNameOf(property); + const local = t.isAssignmentPattern(property.value) ? property.value.left : property.value; + if (argName !== undefined && t.isIdentifier(local)) { + argBindings.set(local.name, argName); } - } else if (t.isArrayPattern(pattern)) { - pattern.elements.forEach((element) => element && collect(element)); - } else if (t.isAssignmentPattern(pattern)) { - collect(pattern.left); - } else if (t.isRestElement(pattern)) { - collect(pattern.argument); } - }; - fn.params.forEach(collect); + } const bodyDeclared = new Set(); if (t.isBlockStatement(fn.body)) { @@ -528,28 +624,30 @@ const functionScope = (fn: t.Node | undefined): FunctionScope => { } const argsExpansions = new Map(); - const [firstParam] = fn.params; - if (t.isIdentifier(firstParam)) { - argsExpansions.set(firstParam.name, []); - } else if (t.isObjectPattern(firstParam)) { + if (t.isIdentifier(argsPattern)) { + argsExpansions.set(argsPattern.name, []); + } else if (t.isObjectPattern(argsPattern)) { const destructured: string[] = []; let rest: string | undefined; - for (const property of firstParam.properties) { + let knownKeys = true; + for (const property of argsPattern.properties) { if (t.isRestElement(property) && t.isIdentifier(property.argument)) { rest = property.argument.name; } else if (t.isObjectProperty(property)) { - const key = keyNameOf(property); + const key = property.computed ? undefined : keyNameOf(property); if (key !== undefined) { destructured.push(key); + } else { + knownKeys = false; } } } - if (rest !== undefined) { + if (rest !== undefined && knownKeys) { argsExpansions.set(rest, destructured); } } - return { paramNames, bodyDeclared, argsExpansions }; + return { argBindings, parameterNames, bodyDeclared, argsExpansions }; }; const collectPatternNames = (pattern: t.Node, into: Set): void => { diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-ng-modules.ts b/code/frameworks/angular-vite/src/docgen/story-docs-ng-modules.ts index 6dc5985f33a4..28ea8b4b16b7 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-ng-modules.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-ng-modules.ts @@ -1,5 +1,3 @@ -// Mirrors the NgModules a story's `moduleMetadata` decorator wires a non-standalone component -// through, so the host snippet can claim them in `imports` instead of the component itself. import { types as t } from 'storybook/internal/babel'; import type { collectImportBindings } from 'storybook/internal/csf-tools'; import { diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts b/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts index 6b22beafb3ff..9fbdfcf35a18 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts @@ -17,7 +17,7 @@ export interface HostComponentSnippetInput { viaComponentOutlet: boolean; /** `false` for a `standalone: false` component, which only its declaring NgModule can provide. */ standalone: boolean; - /** NgModules the story's `moduleMetadata` lists, which stand in for a non-standalone component. */ + /** NgModules the story's `moduleMetadata` adds to its template scope. */ ngModules?: { names: string[]; importStatements: string[] }; /** Output binding names, each of which needs a handler for the template to compile. */ outputs: string[]; @@ -66,9 +66,10 @@ export const buildHostComponentSnippet = ({ }: HostComponentSnippetInput): HostComponentSnippet => { // A `standalone: false` component is only reachable through its declaring NgModule, which static // analysis cannot find reliably. The modules the story's own `moduleMetadata` lists are the next - // best claim; without them the tag path leaves `imports` empty and warns why instead. + // best claim; without them the tag path leaves `imports` empty and warns why instead. A standalone + // component imports itself and still needs those modules, since its template may depend on them. const importable = viaComponentOutlet || standalone; - const moduleNames = importable ? [] : (ngModules?.names ?? []); + const moduleNames = viaComponentOutlet ? [] : (ngModules?.names ?? []); const imports = [ ...(viaComponentOutlet ? ["import { NgComponentOutlet } from '@angular/common';"] : []), "import { Component } from '@angular/core';", @@ -80,12 +81,10 @@ export const buildHostComponentSnippet = ({ // so the directive is what the host declares and the class has to be reachable from the template. const declared = viaComponentOutlet ? 'NgComponentOutlet' - : standalone - ? componentName - : moduleNames.join(', '); + : [...(standalone ? [componentName] : []), ...moduleNames].join(', '); const members = [ ...(viaComponentOutlet ? [` protected readonly ${componentName} = ${componentName};`] : []), - ...fields.map(({ name, value }) => ` ${name} = ${value};`), + ...fields.map(({ name, value }) => ` ${memberName(name)} = ${value};`), ...outputs.map((name) => ` ${memberName(name)}(event: unknown) {}`), ]; const body = members.length > 0 ? `{\n${members.join('\n')}\n}` : '{}'; diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-source.test.ts b/code/frameworks/angular-vite/src/docgen/story-docs-source.test.ts new file mode 100644 index 000000000000..4f957a416349 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-source.test.ts @@ -0,0 +1,203 @@ +import { createStoryArgsResolver, loadCsf } from 'storybook/internal/csf-tools'; + +import { describe, expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { authoredSource } from './story-docs-source.ts'; + +const authoredSourceOf = (source: string, exportName = 'Default') => { + const csf = loadCsf(source, { makeTitle: () => 'Example/Button' }).parse(); + const resolver = createStoryArgsResolver(csf); + const resolved = resolver.resolve(exportName); + return authoredSource( + { members: resolved.storyMembers, metaMembers: resolved.metaMembers }, + resolver.ctx + ); +}; + +describe('authoredSource', () => { + it('follows local names through every level of the source path', () => { + expect( + authoredSourceOf(dedent` + const CODE = ''; + const source = { code: CODE }; + const docs = { source }; + export default { title: 'Example/Button' }; + export const Default = { parameters: { docs } }; + `) + ).toMatchInlineSnapshot(` + { + "code": "", + "kind": "code", + } + `); + }); + + it('reports an opaque spread that can shadow the source code', () => { + expect( + authoredSourceOf(dedent` + declare function overrides(): object; + export default { title: 'Example/Button' }; + export const Default = { + parameters: { + docs: { source: { code: '', ...overrides() } }, + }, + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "unresolvable", + "source": "...overrides()", + } + `); + }); + + it('reports a computed member that can provide the source code', () => { + expect( + authoredSourceOf(dedent` + declare const key: string; + export default { title: 'Example/Button' }; + export const Default = { + parameters: { docs: { source: { [key]: '' } } }, + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "unresolvable", + "source": "[key]: ''", + } + `); + }); + + it('reads source parameters inherited through a CSF factory extension', () => { + expect( + authoredSourceOf( + dedent` + import preview from '../.storybook/preview'; + const meta = preview.meta({ title: 'Example/Button' }); + const Base = meta.story({ + parameters: { + docs: { source: { code: '' } }, + }, + }); + export const Default = Base.extend({ parameters: { layout: 'centered' } }); + ` + ) + ).toMatchInlineSnapshot(` + { + "code": "", + "kind": "code", + } + `); + }); + + it('treats null source code as an explicit disable', () => { + expect( + authoredSourceOf(dedent` + export default { + title: 'Example/Button', + parameters: { docs: { source: { code: '' } } }, + }; + export const Default = { + parameters: { docs: { source: { code: null } } }, + }; + `) + ).toMatchInlineSnapshot(` + { + "kind": "disabled", + } + `); + }); + + it('falls through an explicit undefined story value to the meta source', () => { + expect( + authoredSourceOf(dedent` + export default { + title: 'Example/Button', + parameters: { docs: { source: { code: '' } } }, + }; + export const Default = { + parameters: { docs: { source: { code: undefined } } }, + }; + `) + ).toMatchInlineSnapshot(` + { + "code": "", + "kind": "code", + } + `); + }); + + it('does not inherit meta code through a story value that replaces an intermediate object', () => { + expect([ + authoredSourceOf(dedent` + export default { + title: 'Example/Button', + parameters: { docs: { source: { code: '' } } }, + }; + export const Default = { parameters: { docs: null } }; + `), + authoredSourceOf(dedent` + export default { + title: 'Example/Button', + parameters: { docs: { source: { code: '' } } }, + }; + export const Default = { parameters: { docs: false } }; + `), + authoredSourceOf(dedent` + export default { + title: 'Example/Button', + parameters: { docs: { source: { code: '' } } }, + }; + export const Default = { parameters: { docs: { source: [] } } }; + `), + ]).toMatchInlineSnapshot(` + [ + { + "kind": "missing", + }, + { + "kind": "missing", + }, + { + "kind": "missing", + }, + ] + `); + }); + + it('inherits meta code through an undefined intermediate value', () => { + expect( + authoredSourceOf(dedent` + export default { + title: 'Example/Button', + parameters: { docs: { source: { code: '' } } }, + }; + export const Default = { parameters: { docs: void 0 } }; + `) + ).toMatchInlineSnapshot(` + { + "code": "", + "kind": "code", + } + `); + }); + + it('ignores a non-object story parameter set like the runtime parameter merger', () => { + expect( + authoredSourceOf(dedent` + export default { + title: 'Example/Button', + parameters: { docs: { source: { code: '' } } }, + }; + export const Default = { parameters: null }; + `) + ).toMatchInlineSnapshot(` + { + "code": "", + "kind": "code", + } + `); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-source.ts b/code/frameworks/angular-vite/src/docgen/story-docs-source.ts new file mode 100644 index 000000000000..74de4cb3c832 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-source.ts @@ -0,0 +1,107 @@ +import { types as t } from 'storybook/internal/babel'; +import type { ReferenceContext, ResolvedMembers } from 'storybook/internal/csf-tools'; +import { + resolveArgValue, + resolveObjectMembers, + sourceOf, + unwrapExpression, +} from 'storybook/internal/csf-tools'; + +import type { StoryShape } from './story-docs-markup.ts'; +import { templateParts } from './story-docs-markup.ts'; + +export type AuthoredSource = + | { kind: 'missing' } + | { kind: 'disabled' } + | { kind: 'code'; code: string } + | { kind: 'unresolvable'; source: string }; + +type MemberPathResolution = + | { kind: 'value'; node: t.Node } + | { kind: 'missing' } + | { kind: 'masked' } + | { kind: 'unresolvable'; source: string }; + +export const authoredSource = ( + shape: Pick, + ctx: ReferenceContext +): AuthoredSource => { + for (const members of [shape.members, shape.metaMembers]) { + const code = memberAt(members, ['parameters', 'docs', 'source', 'code'], ctx); + if (code.kind === 'missing') { + continue; + } + if (code.kind === 'masked') { + return { kind: 'missing' }; + } + if (code.kind === 'unresolvable') { + return code; + } + + const resolved = resolveArgValue(code.node, ctx); + const value = unwrapExpression(resolved.node); + if (t.isIdentifier(value, { name: 'undefined' })) { + continue; + } + if (t.isUnaryExpression(value, { operator: 'void' })) { + continue; + } + if (t.isNullLiteral(value)) { + return { kind: 'disabled' }; + } + if (t.isStringLiteral(value)) { + return { kind: 'code', code: value.value }; + } + const parts = templateParts(value); + if (parts && parts.expressions.length === 0) { + return { kind: 'code', code: parts.quasis[0] ?? '' }; + } + return { kind: 'unresolvable', source: sourceOf(code.node) }; + } + return { kind: 'missing' }; +}; + +const memberAt = ( + members: ResolvedMembers, + path: readonly [string, ...string[]], + ctx: ReferenceContext +): MemberPathResolution => { + let result = memberOf(members, path[0]); + + for (const [depth, key] of path.slice(1).entries()) { + if (result.kind !== 'value') { + return result; + } + const original = result.node; + const object = unwrapExpression(resolveArgValue(original, ctx).node); + if (!t.isObjectExpression(object)) { + if ( + t.isIdentifier(object, { name: 'undefined' }) || + t.isUnaryExpression(object, { operator: 'void' }) + ) { + return { kind: 'missing' }; + } + if (isOpaqueValue(object)) { + return { kind: 'unresolvable', source: sourceOf(original) }; + } + return depth === 0 ? { kind: 'missing' } : { kind: 'masked' }; + } + result = memberOf(resolveObjectMembers(object, ctx), key); + } + + return result; +}; + +const memberOf = (members: ResolvedMembers, key: string): MemberPathResolution => { + const node = members.properties[key]; + if (node !== undefined && !members.shadowed.includes(key)) { + return { kind: 'value', node }; + } + if (members.unresolved.length > 0) { + return { kind: 'unresolvable', source: members.unresolved.at(-1)! }; + } + return { kind: 'missing' }; +}; + +const isOpaqueValue = (node: t.Node): boolean => + !t.isLiteral(node) && !t.isArrayExpression(node) && !t.isFunction(node); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-template-analysis.test.ts b/code/frameworks/angular-vite/src/docgen/story-docs-template-analysis.test.ts new file mode 100644 index 000000000000..ecf8a4c35643 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-template-analysis.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { analyzeStoryTemplate } from './story-docs-template-analysis.ts'; + +describe('analyzeStoryTemplate', () => { + it('finds component references through control flow and object literals', () => { + expect( + analyzeStoryTemplate(dedent` +
+ @if ({ value: label }.value) { + {{ message }} + } +
+ `) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [], + "kind": "resolved", + "markup": "
+ @if ({ value: label }.value) { + {{ message }} + } +
", + "referencedNames": [ + "label", + "message", + ], + } + `); + }); + + it('excludes template locals, references, and event-local values', () => { + expect( + analyzeStoryTemplate(dedent` + @let display = label; + + @defer (when ready; on interaction(trigger)) { + {{ detail }} + } + `) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [ + "pressed", + ], + "kind": "resolved", + "markup": "@let display = label; + + @defer (when ready; on interaction(trigger)) { + {{ detail }} + }", + "referencedNames": [ + "label", + "select", + "ready", + "detail", + ], + } + `); + }); + + it('excludes for-loop locals while traversing safe property reads', () => { + expect( + analyzeStoryTemplate(dedent` + @for (item of items; track item.id; let row = $index) { + {{ row }} {{ suffix }} + } + + `) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [], + "kind": "resolved", + "markup": "@for (item of items; track item.id; let row = $index) { + {{ row }} {{ suffix }} + } + ", + "referencedNames": [ + "items", + "suffix", + "account", + ], + } + `); + }); + + it('recognizes explicit this and statically keyed component reads', () => { + expect(analyzeStoryTemplate(`{{ this.label }} {{ this['detail'] }}`)) + .toMatchInlineSnapshot(` + { + "boundOutputs": [], + "kind": "resolved", + "markup": "{{ this.label }} {{ this['detail'] }}", + "referencedNames": [ + "label", + "detail", + ], + } + `); + }); + + it('excludes the Angular $any builtin without hiding an explicit component member', () => { + expect(analyzeStoryTemplate(`{{ $any(label) }} {{ this.$any(detail) }}`)) + .toMatchInlineSnapshot(` + { + "boundOutputs": [], + "kind": "resolved", + "markup": "{{ $any(label) }} {{ this.$any(detail) }}", + "referencedNames": [ + "label", + "$any", + "detail", + ], + } + `); + }); + + it('reports a component-root keyed read whose property cannot be known statically', () => { + expect(analyzeStoryTemplate(`{{ this[key] }}`)).toMatchInlineSnapshot(` + { + "errors": [ + "A component-root keyed read has a dynamic key.", + ], + "kind": "unresolvable", + "markup": "{{ this[key] }}", + } + `); + }); + + it('returns event binding names without treating attributes or literals as references', () => { + expect( + analyzeStoryTemplate(dedent` + + `) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [ + "pressed", + "valueChange", + ], + "kind": "resolved", + "markup": "", + "referencedNames": [ + "onPressed", + "value", + ], + } + `); + }); + + it('materializes each argsToTemplate site against only its own element outputs', () => { + expect( + analyzeStoryTemplate( + '
' + + '', + [ + { + marker: 'data-storybook-args-to-template-0', + inputAttributes: ['[label]="\'Save\'"'], + outputAttributes: [{ name: 'pressed', markup: '(pressed)="pressed($event)"' }], + }, + { + marker: 'data-storybook-args-to-template-1', + inputAttributes: [], + outputAttributes: [{ name: 'pressed', markup: '(pressed)="pressed($event)"' }], + }, + ] + ) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [ + "pressed", + ], + "kind": "resolved", + "markup": "
", + "referencedNames": [ + "outer", + ], + } + `); + }); + + it('uses parsed attributes instead of output-shaped text when materializing an expansion', () => { + expect( + analyzeStoryTemplate( + '', + [ + { + marker: 'data-storybook-args-to-template-0', + inputAttributes: [], + outputAttributes: [{ name: 'pressed', markup: '(pressed)="pressed($event)"' }], + }, + ] + ) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [ + "pressed", + ], + "kind": "resolved", + "markup": "", + "referencedNames": [ + "manual", + ], + } + `); + }); + + it('recognizes a two-way binding as its change output at the expansion site', () => { + expect( + analyzeStoryTemplate('', [ + { + marker: 'data-storybook-args-to-template-0', + inputAttributes: [], + outputAttributes: [ + { name: 'valueChange', markup: '(valueChange)="valueChange($event)"' }, + ], + }, + ]) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [ + "valueChange", + ], + "kind": "resolved", + "markup": "", + "referencedNames": [ + "value", + ], + } + `); + }); + + it('materializes a marker exposed by structural directive desugaring only once', () => { + expect( + analyzeStoryTemplate( + '', + [ + { + marker: 'data-storybook-args-to-template-0', + inputAttributes: ['[label]="\'Save\'"'], + outputAttributes: [{ name: 'pressed', markup: '(pressed)="pressed($event)"' }], + }, + ] + ) + ).toMatchInlineSnapshot(` + { + "boundOutputs": [ + "pressed", + ], + "kind": "resolved", + "markup": "", + "referencedNames": [ + "manual", + "ready", + ], + } + `); + }); + + it('reports an argsToTemplate marker outside an element start tag', () => { + expect( + analyzeStoryTemplate('data-storybook-args-to-template-0', [ + { + marker: 'data-storybook-args-to-template-0', + inputAttributes: ['[label]="\'Save\'"'], + outputAttributes: [], + }, + ]) + ).toMatchInlineSnapshot(` + { + "errors": [ + "An argsToTemplate expression is not inside an element start tag.", + ], + "kind": "unresolvable", + "markup": "[label]="'Save'"", + } + `); + }); + + it('does not rescan materialized arg text while replacing a missing marker', () => { + expect( + analyzeStoryTemplate( + '' + + 'data-storybook-args-to-template-1', + [ + { + marker: 'data-storybook-args-to-template-0', + inputAttributes: ['[label]="\'data-storybook-args-to-template-1\'"'], + outputAttributes: [], + }, + { + marker: 'data-storybook-args-to-template-1', + inputAttributes: ['[count]="1"'], + outputAttributes: [], + }, + ] + ) + ).toMatchInlineSnapshot(` + { + "errors": [ + "An argsToTemplate expression is not inside an element start tag.", + ], + "kind": "unresolvable", + "markup": "[count]="1"", + } + `); + }); + + it('reports parse failures instead of returning partial analysis', () => { + expect(analyzeStoryTemplate('
{{ value + }}
')).toMatchInlineSnapshot(` + { + "errors": [ + "Parser Error: Unexpected end of expression: value + at the end of the expression [ value + ] in storybook-template.html@0:5 ("
[ERROR ->]{{ value + }}
"): storybook-template.html@0:5", + ], + "kind": "unresolvable", + "markup": "
{{ value + }}
", + } + `); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-template-analysis.ts b/code/frameworks/angular-vite/src/docgen/story-docs-template-analysis.ts new file mode 100644 index 000000000000..13de8ccdb09a --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-template-analysis.ts @@ -0,0 +1,275 @@ +import type { + BoundTarget, + Call, + DirectiveMeta, + KeyedRead, + SafeKeyedRead, + SafePropertyRead, + TmplAstBoundEvent, + TmplAstElement, + TmplAstTemplate, +} from '@angular/compiler'; +import { + CombinedRecursiveAstVisitor, + ImplicitReceiver, + LiteralPrimitive, + PropertyRead, + R3TargetBinder, + ThisReceiver, + TmplAstTextAttribute, + parseTemplate, +} from '@angular/compiler'; + +import type { TemplateExpansion } from './story-docs-markup.ts'; + +export type StoryTemplateAnalysis = + | { + kind: 'resolved'; + markup: string; + referencedNames: string[]; + boundOutputs: string[]; + } + | { + kind: 'unresolvable'; + markup: string; + errors: string[]; + }; + +interface Replacement { + marker: string; + start: number; + end: number; + text: string; +} + +class StoryTemplateVisitor extends CombinedRecursiveAstVisitor { + readonly referencedNames = new Set(); + readonly boundOutputs = new Set(); + readonly errors = new Set(); + readonly replacements: Replacement[] = []; + + private readonly target: BoundTarget; + private readonly expansions: ReadonlyMap; + private readonly seenExpansionMarkers = new Set(); + private inEventHandler = false; + private builtinAnyReceiver: PropertyRead | undefined; + + constructor( + target: BoundTarget, + expansions: ReadonlyMap + ) { + super(); + this.target = target; + this.expansions = expansions; + } + + override visitElement(element: TmplAstElement): void { + this.recordExpansions(element.attributes, element.outputs); + super.visitElement(element); + } + + override visitTemplate(template: TmplAstTemplate): void { + this.recordExpansions( + [ + ...template.attributes, + ...template.templateAttrs.filter( + (attribute): attribute is TmplAstTextAttribute => + attribute instanceof TmplAstTextAttribute + ), + ], + template.outputs + ); + super.visitTemplate(template); + } + + override visitPropertyRead(ast: PropertyRead): void { + this.recordComponentReference(ast); + super.visitPropertyRead(ast, undefined); + } + + override visitSafePropertyRead(ast: SafePropertyRead): void { + this.recordComponentReference(ast); + super.visitSafePropertyRead(ast, undefined); + } + + override visitKeyedRead(ast: KeyedRead): void { + this.recordComponentKey(ast); + super.visitKeyedRead(ast, undefined); + } + + override visitSafeKeyedRead(ast: SafeKeyedRead): void { + this.recordComponentKey(ast); + super.visitSafeKeyedRead(ast, undefined); + } + + override visitCall(ast: Call): void { + const previous = this.builtinAnyReceiver; + this.builtinAnyReceiver = isBuiltinAnyCall(ast) ? ast.receiver : undefined; + try { + super.visitCall(ast, undefined); + } finally { + this.builtinAnyReceiver = previous; + } + } + + override visitBoundEvent(event: TmplAstBoundEvent): void { + this.boundOutputs.add(event.name); + const wasInEventHandler = this.inEventHandler; + this.inEventHandler = true; + super.visitBoundEvent(event); + this.inEventHandler = wasInEventHandler; + } + + private recordComponentReference(ast: PropertyRead | SafePropertyRead): void { + if ( + this.isComponentReceiver(ast.receiver) && + this.target.getExpressionTarget(ast) === null && + ast !== this.builtinAnyReceiver && + !(this.inEventHandler && ast.name === '$event') + ) { + this.referencedNames.add(ast.name); + } + } + + private recordExpansions( + attributes: readonly TmplAstTextAttribute[], + outputs: readonly TmplAstBoundEvent[] + ): void { + const occupiedOutputs = new Set(outputs.map((output) => output.name)); + const markers = attributes + .map((attribute) => ({ attribute, expansion: this.expansions.get(attribute.name) })) + .filter( + (entry): entry is { attribute: TmplAstTextAttribute; expansion: TemplateExpansion } => + entry.expansion !== undefined && !this.seenExpansionMarkers.has(entry.attribute.name) + ) + .sort( + (left, right) => + left.attribute.sourceSpan.start.offset - right.attribute.sourceSpan.start.offset + ); + + for (const { attribute, expansion } of markers) { + this.seenExpansionMarkers.add(attribute.name); + const outputAttributes = expansion.outputAttributes.filter( + ({ name }) => !occupiedOutputs.has(name) + ); + outputAttributes.forEach(({ name }) => { + occupiedOutputs.add(name); + this.boundOutputs.add(name); + }); + this.replacements.push({ + marker: attribute.name, + start: attribute.sourceSpan.start.offset, + end: attribute.sourceSpan.end.offset, + text: [...expansion.inputAttributes, ...outputAttributes.map(({ markup }) => markup)].join( + ' ' + ), + }); + } + } + + private recordComponentKey(ast: KeyedRead | SafeKeyedRead): void { + if (!this.isComponentReceiver(ast.receiver) || this.target.getExpressionTarget(ast) !== null) { + return; + } + if (ast.key instanceof LiteralPrimitive && typeof ast.key.value === 'string') { + this.referencedNames.add(ast.key.value); + } else { + this.errors.add('A component-root keyed read has a dynamic key.'); + } + } + + private isComponentReceiver(receiver: unknown): boolean { + return receiver instanceof ImplicitReceiver || receiver instanceof ThisReceiver; + } +} + +const isBuiltinAnyCall = (ast: Call): ast is Call & { receiver: PropertyRead } => + ast.receiver instanceof PropertyRead && + ast.receiver.name === '$any' && + ast.receiver.receiver instanceof ImplicitReceiver && + !(ast.receiver.receiver instanceof ThisReceiver); + +export const analyzeStoryTemplate = ( + markup: string, + expansions: readonly TemplateExpansion[] = [] +): StoryTemplateAnalysis => { + const expansionMap = new Map(expansions.map((expansion) => [expansion.marker, expansion])); + try { + const parsed = parseTemplate(markup, 'storybook-template.html'); + if (parsed.errors) { + return { + kind: 'unresolvable', + markup: materializeWithoutAnalysis(markup, expansions), + errors: parsed.errors.map((error) => error.toString()), + }; + } + + const target = new R3TargetBinder(null).bind({ template: parsed.nodes }); + const visitor = new StoryTemplateVisitor(target, expansionMap); + parsed.nodes.forEach((node) => visitor.visit(node)); + const missingExpansion = visitor.replacements.length !== expansions.length; + if (missingExpansion) { + visitor.errors.add('An argsToTemplate expression is not inside an element start tag.'); + } + const materializedMarkup = missingExpansion + ? materializeWithoutAnalysis(markup, expansions, visitor.replacements) + : applyReplacements(markup, visitor.replacements); + if (visitor.errors.size > 0) { + return { kind: 'unresolvable', markup: materializedMarkup, errors: [...visitor.errors] }; + } + + return { + kind: 'resolved', + markup: materializedMarkup, + referencedNames: [...visitor.referencedNames], + boundOutputs: [...visitor.boundOutputs], + }; + } catch (error) { + return { + kind: 'unresolvable', + markup: materializeWithoutAnalysis(markup, expansions), + errors: [error instanceof Error ? error.message : String(error)], + }; + } +}; + +const applyReplacements = (markup: string, replacements: readonly Replacement[]): string => { + let position = 0; + let materialized = ''; + for (const replacement of [...replacements].sort((left, right) => left.start - right.start)) { + const precedingMarkup = markup.slice(position, replacement.start); + materialized += + (replacement.text === '' ? precedingMarkup.trimEnd() : precedingMarkup) + replacement.text; + position = replacement.end; + } + return materialized + markup.slice(position); +}; + +const materializeWithoutAnalysis = ( + markup: string, + expansions: readonly TemplateExpansion[], + replacements: readonly Replacement[] = [] +): string => { + const replacedMarkers = new Set(replacements.map(({ marker }) => marker)); + const fallbackReplacements = expansions.flatMap((expansion): Replacement[] => { + if (replacedMarkers.has(expansion.marker)) { + return []; + } + const start = markup.indexOf(expansion.marker); + if (start === -1) { + return []; + } + return [ + { + marker: expansion.marker, + start, + end: start + expansion.marker.length, + text: [ + ...expansion.inputAttributes, + ...expansion.outputAttributes.map(({ markup: attribute }) => attribute), + ].join(' '), + }, + ]; + }); + return applyReplacements(markup, [...replacements, ...fallbackReplacements]); +}; diff --git a/code/lib/docgen-harness/src/angular/__storyshapes__/button-shapes/input.stories.ts b/code/lib/docgen-harness/src/angular/__storyshapes__/button-shapes/input.stories.ts index 6916680a0caf..b4dd2b0c4a93 100644 --- a/code/lib/docgen-harness/src/angular/__storyshapes__/button-shapes/input.stories.ts +++ b/code/lib/docgen-harness/src/angular/__storyshapes__/button-shapes/input.stories.ts @@ -5,12 +5,7 @@ import { ShapeButtonComponent } from './shape-button.component.ts'; import { IMPORTED_TEMPLATE } from './templates.ts'; import * as BaseStories from './base-args.stories.ts'; -// This file is only ever parsed, not executed; a declaration keeps the package's client tree out -// of the harness type graph while the generator matches the call by name. -declare function argsToTemplate( - args: unknown, - options?: { include?: readonly string[]; exclude?: readonly string[] } -): string; +import { argsToTemplate } from '@storybook/angular-vite'; type LooseStory = { args?: Record; @@ -49,7 +44,7 @@ export const MethodRender = { }; export const ArgsToTemplateWrapper = { - args: { label: 'Save', count: 7 }, + args: { label: 'Save', count: 7, clicked: () => {} }, render: (args: Record) => ({ props: args, template: `
`, diff --git a/code/lib/docgen-harness/tsconfig.json b/code/lib/docgen-harness/tsconfig.json index b2d4d9bd84af..abb692408bf4 100644 --- a/code/lib/docgen-harness/tsconfig.json +++ b/code/lib/docgen-harness/tsconfig.json @@ -18,6 +18,7 @@ "src/angular/angular-provider-seam.test.ts", "src/angular/angular-render.test.ts", "src/angular/render-helpers.ts", + "src/angular/__storyshapes__/button-shapes/input.stories.ts", "src/angular/story-docs/__testfixtures__/non-standalone-module/input.stories.ts" ], "vueCompilerOptions": {