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",