From f952c136f234d67a7f0df834bdfc996d492f27fc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 14 Aug 2026 10:21:05 +0200 Subject: [PATCH 1/3] Angular: Declare story args the snippet markup binds by name Hand-written story markup runs against the story's `props: args`, but the host component the snippet ships had no such members, so a template like `[label]="label"` resolved to nothing when pasted into an app. The args a template names are now declared on the host, leaving the markup byte-identical to what the author wrote. Args an `argsToTemplate` expansion already inlined are skipped, an arg sharing a name with a bound output leaves the handler in place, and an arg whose value needs the story to run is reported through the existing incomplete-snippet warning instead. --- .../src/docgen/story-docs-args.ts | 29 +++++-- .../src/docgen/story-docs-build.test.ts | 83 +++++++++++++++++++ .../src/docgen/story-docs-build.ts | 66 +++++++++++++-- .../src/docgen/story-docs-markup.ts | 37 ++++++--- .../src/docgen/story-docs-snippet.ts | 4 + .../meta-render/story-docs.payload.snapshot | 4 +- .../story-docs.payload.snapshot | 4 +- 7 files changed, 201 insertions(+), 26 deletions(-) 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 6b9132cf0764..fa08075d611d 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-args.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-args.ts @@ -415,11 +415,11 @@ export const evaluateArgExpression = ( source: string, enums: SnippetEnum[] ): string => { - const unwrapped = unwrapExpression(node); - const value = evaluateNode(unwrapped, enums); - if (value !== EVAL_FAILED) { - return escapeAttributeExpression(printExpressionValue(value, new Set())); + const literal = evaluateArgLiteral(node, enums); + if (literal !== undefined) { + return escapeAttributeExpression(literal); } + const unwrapped = unwrapExpression(node); const text = unwrapped.start != null && unwrapped.end != null ? source.slice(unwrapped.start, unwrapped.end) @@ -427,9 +427,26 @@ export const evaluateArgExpression = ( return escapeAttributeExpression(text ?? 'undefined'); }; -// Angular expression strings support backslash escapes, so quoting stays lossless. +/** + * 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()); +}; + +// Angular expression strings support backslash escapes, so quoting stays lossless. Line terminators +// are escaped rather than emitted raw, which would end the literal in a TypeScript position. 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 152bbcde5d81..6d0f0fdec54a 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 ff43ba3cf241..01d94fea3d1a 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 { @@ -218,7 +221,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, @@ -227,15 +235,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]; @@ -249,6 +262,49 @@ 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. + * + * Markup written by hand runs against the story's `props: args`, so `[label]="label"` reads an arg + * rather than a member of the host the snippet builds. Declaring those args on the host restores + * that scope without touching the markup, which keeps the example both runnable and byte-identical + * to what the author wrote. Args an `argsToTemplate` expansion already inlined are skipped: their + * values are in the markup, and only the attribute name is left to match on. + * + * The name has to occur in the markup for the arg to be declared, so an unrelated arg does not + * become a dead member. A bare word match over-declares in the rare case where an arg is named + * after an attribute the markup sets statically; that costs an unused member, where missing one + * costs a snippet that does not compile. + */ +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 c6d6485f9611..55be9d3a4ee7 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-markup.ts @@ -44,21 +44,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 @@ -179,13 +187,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) }; }; @@ -195,12 +204,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; } @@ -226,7 +236,8 @@ const substituteExpression = ( expression: t.Node, shape: StoryShape, bindings: Bindings | undefined, - scope: FunctionScope + scope: FunctionScope, + expanded: Set ): string | undefined => { if ( t.isCallExpression(expression) && @@ -249,7 +260,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", From e9b2e3c7d680568346d97ddc34fcead4673f2715 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 14 Aug 2026 10:33:00 +0200 Subject: [PATCH 2/3] Apply suggestion from @valentinpalkovic --- .../angular-vite/src/docgen/story-docs-build.ts | 11 ----------- 1 file changed, 11 deletions(-) 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 01d94fea3d1a..f4b908731f18 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-build.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-build.ts @@ -264,17 +264,6 @@ const renderStorySnippet = ( /** * Args the story's own markup binds by name, as host members holding the value the story gave them. - * - * Markup written by hand runs against the story's `props: args`, so `[label]="label"` reads an arg - * rather than a member of the host the snippet builds. Declaring those args on the host restores - * that scope without touching the markup, which keeps the example both runnable and byte-identical - * to what the author wrote. Args an `argsToTemplate` expansion already inlined are skipped: their - * values are in the markup, and only the attribute name is left to match on. - * - * The name has to occur in the markup for the arg to be declared, so an unrelated arg does not - * become a dead member. A bare word match over-declares in the rare case where an arg is named - * after an attribute the markup sets statically; that costs an unused member, where missing one - * costs a snippet that does not compile. */ const referencedArgFields = ( markup: Extract, From 107a93a2947b20a28fc89fc780c52dd414cd9486 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 14 Aug 2026 10:33:44 +0200 Subject: [PATCH 3/3] Apply suggestion from @valentinpalkovic --- code/frameworks/angular-vite/src/docgen/story-docs-args.ts | 2 -- 1 file changed, 2 deletions(-) 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 fa08075d611d..e2f67d3adf3a 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-args.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-args.ts @@ -439,8 +439,6 @@ export const evaluateArgLiteral = (node: t.Node, enums: SnippetEnum[]): string | return value === EVAL_FAILED ? undefined : printExpressionValue(value, new Set()); }; -// Angular expression strings support backslash escapes, so quoting stays lossless. Line terminators -// are escaped rather than emitted raw, which would end the literal in a TypeScript position. const quoteExpressionString = (value: string): string => `'${value .replace(/\\/g, '\\\\')