Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions code/frameworks/angular-vite/src/docgen/story-docs-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions code/frameworks/angular-vite/src/docgen/story-docs-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const STORY_SHAPES_FILE = [
`const HOISTED_TEMPLATE = '<sb-button hoisted></sb-button>';`,
`const renderFn = () => ({ template: '<sb-button via-fn></sb-button>' });`,
`const sharedArgs = { label: 'shared' };`,
`const LOCAL_LABEL = 'Save';`,
`export default {`,
` title: 'Example/Button',`,
` component: ButtonComponent,`,
Expand Down Expand Up @@ -139,6 +140,34 @@ const STORY_SHAPES_FILE = [
' template: `<sb-button ${argsToTemplate(args)}><span>${footer}</span></sb-button>`,',
` }),`,
`};`,
// 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: '<sb-button [label]="label" [count]="count"></sb-button>',`,
` }),`,
`};`,
// 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: `<sb-button ${argsToTemplate(args, { exclude: [\'label\'] })} [label]="label.toUpperCase()"></sb-button>`,',
` }),`,
`};`,
`export const OutputNamedArg = {`,
` args: { clicked: 'not a handler' },`,
` render: (args) => ({`,
' props: args,',
` template: '<sb-button (clicked)="clicked($event)"></sb-button>',`,
` }),`,
`};`,
`export const IdentifierArgValue = {`,
` args: { label: LOCAL_LABEL },`,
` render: (args) => ({ props: args, template: '<sb-button [label]="label"></sb-button>' }),`,
`};`,
`export const UnreadableInterpolation = {`,
` args: { label: 'Save' },`,
' render: (args) => ({ props: args, template: `<sb-button>${buildSlot(args)}</sb-button>` }),',
Expand Down Expand Up @@ -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: `<sb-button [label]="label" [count]="count"></sb-button>`,',
'})',
'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(`
Expand Down
55 changes: 50 additions & 5 deletions code/frameworks/angular-vite/src/docgen/story-docs-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,6 +38,7 @@ import {
buildComponentOutletTemplate,
buildTemplate,
formatTemplateMarkup,
isValidIdentifier,
} from '../template-grammar.ts';

export interface BuildStoryDocsContext {
Expand Down Expand Up @@ -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,
Expand All @@ -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})=`)
);
Comment thread
valentinpalkovic marked this conversation as resolved.
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];
Expand All @@ -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 = (
Comment thread
valentinpalkovic marked this conversation as resolved.
markup: Extract<TemplateResult, { kind: 'literal' }>,
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 });
Comment thread
valentinpalkovic marked this conversation as resolved.
}

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)]
Expand Down
37 changes: 24 additions & 13 deletions code/frameworks/angular-vite/src/docgen/story-docs-markup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
): 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
Expand Down Expand Up @@ -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<string>();
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) };
};
Expand All @@ -194,12 +203,13 @@ const interpolate = (
node: t.TemplateLiteral,
shape: StoryShape,
bindings: Bindings | undefined,
scope: FunctionScope
scope: FunctionScope,
expanded: Set<string>
): 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;
}
Expand All @@ -225,7 +235,8 @@ const substituteExpression = (
expression: t.Node,
shape: StoryShape,
bindings: Bindings | undefined,
scope: FunctionScope
scope: FunctionScope,
expanded: Set<string>
): string | undefined => {
if (
t.isCallExpression(expression) &&
Expand All @@ -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)) {
Expand Down
4 changes: 4 additions & 0 deletions code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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}` : '{}';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { MetaRenderComponent } from './meta-render.component.ts';
imports: [MetaRenderComponent],
template: `<sb-meta-render [label]="label"></sb-meta-render>`,
})
export class DemoComponent {}",
export class DemoComponent {
label = 'Meta';
}",
},
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { RenderFunctionComponent } from './render-function.component.ts';
imports: [RenderFunctionComponent],
template: `<sb-render-function [label]="label"></sb-render-function>`,
})
export class DemoComponent {}",
export class DemoComponent {
label = 'Render';
}",
},
"storydocs-render-function--no-render": {
"id": "storydocs-render-function--no-render",
Expand Down
Loading