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 0128bb8fd6cd..664caa970b56 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-args.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-args.ts @@ -411,11 +411,20 @@ const EVAL_FAILED = Symbol('story-docs-eval-failed'); // expression is escaped for the attribute position it lands in: the double-quote delimiter and // text Angular's lexer would decode as a character reference survive the round-trip unchanged. export const evaluateArgExpression = (node: t.Node, enums: SnippetEnum[]): string => { - const unwrapped = unwrapExpression(node); - const value = evaluateNode(unwrapped, enums); - return escapeAttributeExpression( - value === EVAL_FAILED ? printArgSource(unwrapped) : printExpressionValue(value, new Set()) - ); + const literal = evaluateArgLiteral(node, enums); + return escapeAttributeExpression(literal ?? printArgSource(unwrapExpression(node))); +}; + +/** + * The arg's value as a standalone expression, or `undefined` when it needs the story to run. + * + * Unlike {@link evaluateArgExpression} this never falls back to source text, so a caller that has + * to produce code rather than an attribute can tell a real value from a name only the story file + * knows. The two positions share a printer, so a value reads the same wherever it lands. + */ +export const evaluateArgLiteral = (node: t.Node, enums: SnippetEnum[]): string | undefined => { + const value = evaluateNode(unwrapExpression(node), enums); + return value === EVAL_FAILED ? undefined : printExpressionValue(value, new Set()); }; // recast reprints a node it parsed straight from the file's own text, comments and indentation @@ -425,7 +434,11 @@ const printArgSource = (node: t.Node): string => babelPrint(t.cloneNode(node, tr // Angular expression strings support backslash escapes, so quoting stays lossless. const quoteExpressionString = (value: string): string => - `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + `'${value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r')}'`; // Renders an evaluated arg as a template expression, in the same shape the runtime generator // prints, but losslessly for strings carrying quotes. 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 5185647abd4e..ac3456af0131 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 @@ -89,6 +89,7 @@ const STORY_SHAPES_FILE = [ `const HOISTED_TEMPLATE = '';`, `const renderFn = () => ({ template: '' });`, `const sharedArgs = { label: 'shared' };`, + `const LOCAL_LABEL = 'Save';`, `export default {`, ` title: 'Example/Button',`, ` component: ButtonComponent,`, @@ -139,6 +140,34 @@ const STORY_SHAPES_FILE = [ ' template: `${footer}`,', ` }),`, `};`, + // Markup written without `argsToTemplate` binds the args by name, which only resolves because + // the story hands them to the template through `props: args`. + `export const HandWrittenBindings = {`, + ` args: { label: 'Save', count: 3 },`, + ` render: (args) => ({`, + ' props: args,', + ` template: '',`, + ` }),`, + `};`, + // The half-and-half shape `exclude` exists for: most bindings expanded, one written by hand. + `export const PartlyHandWritten = {`, + ` args: { label: 'Save', count: 7 },`, + ` render: (args) => ({`, + ' props: args,', + ' template: ``,', + ` }),`, + `};`, + `export const OutputNamedArg = {`, + ` args: { clicked: 'not a handler' },`, + ` render: (args) => ({`, + ' props: args,', + ` template: '',`, + ` }),`, + `};`, + `export const IdentifierArgValue = {`, + ` args: { label: LOCAL_LABEL },`, + ` render: (args) => ({ props: args, template: '' }),`, + `};`, `export const UnreadableInterpolation = {`, ` args: { label: 'Save' },`, ' render: (args) => ({ props: args, template: `${buildSlot(args)}` }),', @@ -672,6 +701,60 @@ describe('buildStoryDocsPayload', () => { }); }); + // Hand-written markup runs against the story's `props: args`, which the host component the + // snippet ships does not have. The args it names have to come with it or the example is dead. + describe('args the markup binds by name', () => { + it('declares them on the host, leaving the markup as written', async () => { + expect((await storiesOf(STORY_SHAPES_FILE)).get('Hand Written Bindings')?.snippet).toBe( + [ + `import { Component } from '@angular/core';`, + `import { ButtonComponent } from './button.component';`, + '', + '@Component({', + ` selector: 'app-demo',`, + ' imports: [ButtonComponent],', + ' template: ``,', + '})', + 'export class DemoComponent {', + ` label = 'Save';`, + ' count = 3;', + '}', + ].join('\n') + ); + }); + + // An expanded binding carries its value in the markup, so only the attribute name is left to + // match on; declaring it would add a member nothing reads. + it('skips the args argsToTemplate already expanded', async () => { + const snippet = (await storiesOf(STORY_SHAPES_FILE)).get('Partly Hand Written')?.snippet; + expect(snippet).toContain(`[count]="7"`); + expect(snippet).toContain(` label = 'Save';`); + expect(snippet).not.toContain('count = 7;'); + }); + + it('leaves an output binding to its handler rather than declaring both', async () => { + const snippet = (await storiesOf(STORY_SHAPES_FILE)).get('Output Named Arg')?.snippet; + expect(snippet).toContain(' clicked(event: unknown) {}'); + 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 {}' + ); + }); + + // A value only the story file can resolve would not compile on the host either, so it is + // reported the same way any other unreadable source text is. + it('reports an arg whose value needs the story to run instead of declaring it', async () => { + const story = (await storiesOf(STORY_SHAPES_FILE)).get('Identifier Arg Value'); + expect(story?.warning).toBe( + 'Incomplete snippet: `LOCAL_LABEL` could not be resolved statically.' + ); + expect(story?.snippet).not.toContain('label = LOCAL_LABEL;'); + }); + }); + describe('story shapes that cannot be read statically', () => { it('reads the template a render method shorthand returns', async () => { const story = await soleStory(` 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 3fac640c300d..6ce2929ce66f 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-build.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-build.ts @@ -20,10 +20,12 @@ import { createSpreadArgsResolver, deepAssignmentSources, evaluateArgExpression, + evaluateArgLiteral, } from './story-docs-args.ts'; -import type { Bindings, StoryShape } from './story-docs-markup.ts'; +import type { Bindings, StoryShape, TemplateResult } from './story-docs-markup.ts'; import { metaConfigObject, + sourceOf, storyConfigObject, unresolvableConfigMembers, userTemplate, @@ -36,6 +38,7 @@ import { buildComponentOutletTemplate, buildTemplate, formatTemplateMarkup, + isValidIdentifier, } from '../template-grammar.ts'; export interface BuildStoryDocsContext { @@ -214,7 +217,12 @@ const renderStorySnippet = ( // read without bindings then and falls back with a warning instead. const userMarkup = userTemplate(shape, shape.unresolvedArgs.length === 0 ? bindings : undefined); - const host = (template: string, viaComponentOutlet: boolean, outputs: string[]) => + const host = ( + template: string, + viaComponentOutlet: boolean, + outputs: string[], + fields?: { name: string; value: string }[] + ) => buildHostComponentSnippet({ template, componentName: localName, @@ -223,15 +231,20 @@ const renderStorySnippet = ( standalone: snippetMeta.standalone, ngModules, outputs, + fields, }); if (userMarkup?.kind === 'literal') { - // The story is shown exactly as it was written, so nothing about it is missing; the host only - // needs handlers for the outputs the markup actually binds. + // 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})=`) ); - return host(formatTemplateMarkup(userMarkup.markup), false, boundOutputs); + const hostArgs = referencedArgFields(userMarkup, shape, boundOutputs, snippetMeta.enums); + return withUnresolved( + host(formatTemplateMarkup(userMarkup.markup), false, boundOutputs, hostArgs.fields), + hostArgs.unresolved + ); } const markupSources = userMarkup?.source === undefined ? [] : [userMarkup.source]; @@ -245,6 +258,38 @@ const renderStorySnippet = ( : withUnresolved(host(buildComponentOutletTemplate(localName), true, []), markupSources); }; +/** + * Args the story's own markup binds by name, as host members holding the value the story gave them. + */ +const referencedArgFields = ( + markup: Extract, + 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 fields: { name: string; value: string }[] = []; + const unresolved: string[] = []; + + for (const [name, node] of Object.entries(shape.args)) { + if (taken.has(name) || !isValidIdentifier(name)) { + continue; + } + if (!new RegExp(`\\b${name}\\b`).test(markup.markup)) { + continue; + } + const value = evaluateArgLiteral(node, enums); + if (value === undefined) { + unresolved.push(sourceOf(node)); + continue; + } + fields.push({ name, value }); + } + + return { fields, unresolved }; +}; + /** Says which source text a static pass could not read, so a reader can see what is missing. */ const unresolvedWarning = (unresolved: readonly string[]): string => `Incomplete snippet: ${[...new Set(unresolved)] 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 49c49191022a..6212b895b102 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts @@ -43,21 +43,29 @@ interface BindingFilter { * This is what `argsToTemplate(args)` expands to at runtime, except that values are inlined rather * than referenced by name, so the result stands alone without the story's `props: args`. */ -const bindingAttributes = ({ inputs, outputs }: Bindings, filter: BindingFilter): string[] => { +const bindingAttributes = ( + { inputs, outputs }: Bindings, + filter: BindingFilter, + expanded: Set +): string[] => { 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 [ - ...inputs - .filter(({ name }) => allowed(name)) - .map(({ name, expression }) => `[${name}]="${expression}"`), + ...expandedInputs.map(({ name, expression }) => `[${name}]="${expression}"`), ...outputs.filter(allowed).map((name) => `(${name})="${formatPropInTemplate(name)}($event)"`), ]; }; /** What a `template` turned out to hold. */ export type TemplateResult = - /** Read as markup, so the story is shown as written. */ - | { kind: 'literal'; markup: string } + /** + * 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. + */ + | { kind: 'literal'; markup: string; expandedArgs: readonly string[] } /** * 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 @@ -178,13 +186,14 @@ const templateFrom = ( return undefined; } if (t.isStringLiteral(node)) { - return { kind: 'literal', markup: node.value }; + return { kind: 'literal', markup: node.value, expandedArgs: [] }; } if (t.isTemplateLiteral(node)) { - const markup = interpolate(node, shape, bindings, scope); + const expanded = new Set(); + const markup = interpolate(node, shape, bindings, scope, expanded); return markup === undefined ? { kind: 'unresolvable', source: sourceOf(node) } - : { kind: 'literal', markup }; + : { kind: 'literal', markup, expandedArgs: [...expanded] }; } return { kind: 'unresolvable', source: sourceOf(node) }; }; @@ -194,12 +203,13 @@ const interpolate = ( node: t.TemplateLiteral, shape: StoryShape, bindings: Bindings | undefined, - scope: FunctionScope + scope: FunctionScope, + expanded: Set ): string | undefined => { let markup = node.quasis[0]?.value.cooked ?? ''; for (const [index, expression] of node.expressions.entries()) { - const substituted = substituteExpression(expression, shape, bindings, scope); + const substituted = substituteExpression(expression, shape, bindings, scope, expanded); if (substituted === undefined) { return undefined; } @@ -225,7 +235,8 @@ const substituteExpression = ( expression: t.Node, shape: StoryShape, bindings: Bindings | undefined, - scope: FunctionScope + scope: FunctionScope, + expanded: Set ): string | undefined => { if ( t.isCallExpression(expression) && @@ -248,7 +259,7 @@ const substituteExpression = ( const allowed = filter.include ? { ...withRest, include: filter.include.filter((name) => !excluded.includes(name)) } : withRest; - return bindingAttributes(bindings, allowed).join(' '); + return bindingAttributes(bindings, allowed, expanded).join(' '); } if (!t.isIdentifier(expression)) { 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 71852901a3cd..6b22beafb3ff 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts @@ -21,6 +21,8 @@ export interface HostComponentSnippetInput { ngModules?: { names: string[]; importStatements: string[] }; /** Output binding names, each of which needs a handler for the template to compile. */ outputs: string[]; + /** Args the template refers to by name, which the story supplied through `props: args`. */ + fields?: { name: string; value: string }[]; } export interface HostComponentSnippet { @@ -60,6 +62,7 @@ export const buildHostComponentSnippet = ({ standalone, ngModules, outputs, + fields = [], }: 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 @@ -82,6 +85,7 @@ export const buildHostComponentSnippet = ({ : moduleNames.join(', '); const members = [ ...(viaComponentOutlet ? [` protected readonly ${componentName} = ${componentName};`] : []), + ...fields.map(({ name, value }) => ` ${name} = ${value};`), ...outputs.map((name) => ` ${memberName(name)}(event: unknown) {}`), ]; const body = members.length > 0 ? `{\n${members.join('\n')}\n}` : '{}'; diff --git a/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/meta-render/story-docs.payload.snapshot b/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/meta-render/story-docs.payload.snapshot index 5faac9d3b15a..9d14bf01591c 100644 --- a/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/meta-render/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/meta-render/story-docs.payload.snapshot @@ -15,7 +15,9 @@ import { MetaRenderComponent } from './meta-render.component.ts'; imports: [MetaRenderComponent], template: ``, }) -export class DemoComponent {}", +export class DemoComponent { + label = 'Meta'; +}", }, }, } \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot b/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot index c007464be61b..58f2527fa9dc 100644 --- a/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/angular/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot @@ -15,7 +15,9 @@ import { RenderFunctionComponent } from './render-function.component.ts'; imports: [RenderFunctionComponent], template: ``, }) -export class DemoComponent {}", +export class DemoComponent { + label = 'Render'; +}", }, "storydocs-render-function--no-render": { "id": "storydocs-render-function--no-render",