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 4f0460123895..74cfce5f69d6 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 @@ -10,6 +10,7 @@ import { vol } from 'memfs'; import type { AngularClassMeta, AngularComponentMetaResult } from '@storybook/angular-cm'; import type { AngularComponentMetaSource } from './build-docgen.ts'; +import type { BuildStoryDocsContext } from './story-docs-build.ts'; import { buildStoryDocsPayload } from './story-docs-build.ts'; vi.mock('node:fs', { spy: true }); @@ -157,16 +158,20 @@ const STORY_SHAPES_FILE = [ `Csf2AssignedArgs.args = { label: 'assigned', count: 11 };`, ].join('\n'); -/** Story per name, for a file that declares more than one. */ -const storiesOf = (storyFile: string) => { +const payloadOf = (storyFile: string, context: Partial = {}) => { givenStoryFile(storyFile); - const payload = buildStoryDocsPayload( + return buildStoryDocsPayload( { entry }, - { manager: managerReturning(metaFor(componentEntry())) } + { manager: managerReturning(metaFor(componentEntry())), ...context } ); - return new Map(Object.values(payload?.stories ?? {}).map((story) => [story.name, story])); }; +/** Story per name, for a file that declares more than one. */ +const storiesOf = (storyFile: string, context: Partial = {}) => + new Map( + Object.values(payloadOf(storyFile, context)?.stories ?? {}).map((story) => [story.name, story]) + ); + const snippetsOf = (storyFile: string) => new Map([...storiesOf(storyFile)].map(([name, story]) => [name, story.snippet])); @@ -530,4 +535,114 @@ describe('buildStoryDocsPayload', () => { expect(story.snippet).toBeUndefined(); expect(story.error).toBeUndefined(); }); + + it('carries no import block in the default template format', () => { + expect(payloadOf(DEFAULT_STORY_FILE)?.import).toBeUndefined(); + }); +}); + +describe('buildStoryDocsPayload in component format', () => { + const componentFormat = { snippetFormat: 'component' } as const; + + it('wraps the generated bindings in a host component that declares their handlers', () => { + expect(storiesOf(DEFAULT_STORY_FILE, componentFormat).get('Default')?.snippet).toBe( + `@Component({ + selector: 'app-root', + template: \`\`, + imports: [ButtonComponent], +}) +export class App { + clicked(event: unknown) {} +}` + ); + }); + + it('carries the import block once for the whole component', () => { + expect(payloadOf(DEFAULT_STORY_FILE, componentFormat)?.import).toBe( + `import { Component } from '@angular/core';\nimport { ButtonComponent } from './button.component';` + ); + }); + + it('declares no handlers for a story whose own markup binds no outputs', () => { + expect(storiesOf(STORY_SHAPES_FILE, componentFormat).get('Own Template')?.snippet).toBe( + `@Component({ + selector: 'app-root', + template: \`hi\`, + imports: [ButtonComponent], +}) +export class App {}` + ); + }); + + // `argsToTemplate` expands into the same output bindings this generator emits, so the markup a + // story wrote around it does need handlers - and only for the outputs that survived the filter. + it('declares handlers only for the outputs the story kept', () => { + const stories = storiesOf( + [ + `import { argsToTemplate } from '@storybook/angular-vite';`, + `import { ButtonComponent } from './button.component';`, + `export default { title: 'Example/Button', component: ButtonComponent };`, + `export const Kept = {`, + ` args: { label: 'Save' },`, + ' render: (args) => ({ props: args, template: `` }),', + `};`, + `export const Dropped = {`, + ` args: { label: 'Save' },`, + " render: (args) => ({ props: args, template: `` }),", + `};`, + ].join('\n'), + componentFormat + ); + + expect(stories.get('Kept')?.snippet).toContain('clicked(event: unknown) {}'); + expect(stories.get('Dropped')?.snippet).toContain('export class App {}'); + }); + + it('exposes the class the outlet template reads as a value', () => { + givenStoryFile(DEFAULT_STORY_FILE); + const payload = buildStoryDocsPayload( + { entry }, + { + manager: managerReturning(metaFor(componentEntry({ selector: undefined }))), + ...componentFormat, + } + ); + + expect(Object.values(payload!.stories)[0].snippet).toBe( + `@Component({ + selector: 'app-root', + template: \`\`, + imports: [NgComponentOutlet], +}) +export class App { + readonly ButtonComponent = ButtonComponent; +}` + ); + expect(payload?.import).toContain(`import { NgComponentOutlet } from '@angular/common';`); + }); + + it('reports an unresolved arg on the story, not inside the component it emits', () => { + const story = storiesOf(STORY_SHAPES_FILE, componentFormat).get('Spread Args'); + + expect(story?.warning).toBe( + 'Incomplete snippet: `...sharedArgs` could not be resolved statically.' + ); + expect(story?.snippet).toContain('export class App {'); + expect(story?.snippet).not.toContain('sharedArgs'); + }); + + // A payload with nothing to wrap must not advertise an import block for markup it never emits. + it('emits neither a wrapper nor an import block when there is no component to host', () => { + givenStoryFile(` + export default { title: 'Example/Button' }; + /** Documented without a component. */ + export const Default = {}; + `); + const payload = buildStoryDocsPayload({ entry }, { manager: undefined, ...componentFormat }); + const stories = Object.values(payload!.stories); + + expect(payload?.import).toBeUndefined(); + expect(stories[0].snippet).toBeUndefined(); + expect(stories[0].description).toBe('Documented without a component.'); + }); }); 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 960c530c8390..16af02bb649b 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-build.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-build.ts @@ -3,6 +3,7 @@ import { createMetaComponentResolver, getComponentIdFromEntry, getStoryImportPathFromEntry, + type ResolvedMetaComponent, } from 'storybook/internal/common'; import { storyNameFromExport } from 'storybook/internal/csf'; import type { CsfFile } from 'storybook/internal/csf-tools'; @@ -15,6 +16,8 @@ import { resolve } from 'node:path'; import type { EnumType, Property } from '@storybook/angular-compodoc'; import type { AngularComponentMetaResult } from '@storybook/angular-cm'; import type { AngularComponentMetaSource } from './build-docgen.ts'; +import type { AngularHostContext } from './story-docs-host.ts'; +import { angularHostComponent, angularHostImports } from './story-docs-host.ts'; import { type BindingFilter, RawArgExpression, @@ -23,16 +26,48 @@ import { renderComponentSnippet, type SnippetInputBinding, } from './story-docs-snippet.ts'; +import type { SnippetFormat } from '../types.ts'; const resolveMetaComponent = createMetaComponentResolver(); +/** Preserves what the browser generator produces, so `component` stays an opt-in. */ +export const DEFAULT_SNIPPET_FORMAT: SnippetFormat = 'template'; + export interface BuildStoryDocsContext { /** `undefined` when the analyzer could not be created; descriptions still extract without it. */ manager: AngularComponentMetaSource | undefined; /** Same hook the docgen builder exposes, defaulting to the same resolution against cwd. */ resolvePath?: (importPath: string) => string; + /** Shape of the emitted snippet. Defaults to {@link DEFAULT_SNIPPET_FORMAT}. */ + snippetFormat?: SnippetFormat; } +/** + * How the host wrapper names and imports the component. + * + * The name is the class's own, which is already what the outlet form renders as a template + * expression; the import aliases the story file's export name back to it when the two differ. + */ +const hostContext = ( + ref: ResolvedMetaComponent, + snippetContext: SnippetContext +): AngularHostContext => ({ + componentName: snippetContext.componentName, + exportName: ref.exportName, + ...(ref.importId === undefined ? {} : { importId: ref.importId }), + outlet: !snippetContext.selector, +}); + +/** + * Output names the markup binds, which the host has to declare methods for. + * + * Matched against the binding this generator emits rather than assumed to be every output: a story + * that wrote its own markup around `argsToTemplate(args, { exclude })` binds only some of them, and + * one that wrote plain markup binds none. + */ +const boundOutputs = (markup: string, outputs: readonly string[]): string[] => + outputs.filter((name) => markup.includes(bindingAttributes({ inputs: [], outputs: [name] })[0])); + /** * Builds a {@link StoryDocsPayload} for the stories in one CSF story file. * @@ -84,6 +119,13 @@ export const buildStoryDocsPayload = ( } } const snippetContext = meta ? createSnippetContext(meta) : undefined; + // Set only for the `component` format, and only when there is something to wrap: a payload that + // carries no snippets would otherwise advertise an import block for markup it never emits. + const host = + (context.snippetFormat ?? DEFAULT_SNIPPET_FORMAT) === 'component' && snippetContext && component + ? hostContext(component, snippetContext) + : undefined; + const outputs = snippetContext?.outputs ?? []; const displayName = component && (component.exportName === 'default' ? component.localName : component.exportName); @@ -105,11 +147,15 @@ export const buildStoryDocsPayload = ( const rendered = snippetContext ? renderStorySnippet(snippetContext, { csf, exportName, annotations, args, source }) : undefined; + const snippet = + rendered && host + ? angularHostComponent(rendered.snippet, boundOutputs(rendered.snippet, outputs), host) + : rendered?.snippet; stories[story.id] = { id: story.id, name, - ...(rendered === undefined ? {} : { snippet: rendered.snippet }), + ...(snippet === undefined ? {} : { snippet }), ...(rendered?.warning === undefined ? {} : { warning: rendered.warning }), ...(finalDescription ? { description: finalDescription } : {}), ...(summary === undefined ? {} : { summary }), @@ -129,6 +175,7 @@ export const buildStoryDocsPayload = ( // The analyzer knows the class name even when the story file imported it as a default export. name: meta?.entry.name ?? displayName ?? titleName, path: storyImportPath, + ...(host ? { import: angularHostImports(host) } : {}), stories, }; }; diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-host.test.ts b/code/frameworks/angular-vite/src/docgen/story-docs-host.test.ts new file mode 100644 index 000000000000..ceeb139deb4a --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-host.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import type { AngularHostContext } from './story-docs-host.ts'; +import { angularHostComponent, angularHostImports } from './story-docs-host.ts'; + +const context = (overrides: Partial = {}): AngularHostContext => ({ + componentName: 'ButtonComponent', + exportName: 'ButtonComponent', + importId: './button.component', + ...overrides, +}); + +describe('angularHostImports', () => { + it('imports a named export the way the story file did', () => { + expect(angularHostImports(context())).toBe( + `import { Component } from '@angular/core';\nimport { ButtonComponent } from './button.component';` + ); + }); + + it('imports a default export without braces', () => { + expect(angularHostImports(context({ exportName: 'default' }))).toBe( + `import { Component } from '@angular/core';\nimport ButtonComponent from './button.component';` + ); + }); + + // The template names the class itself, so an export under another name has to be aliased back to + // it rather than left bound to a name the template never mentions. + it('aliases an export whose name is not the class name', () => { + expect(angularHostImports(context({ exportName: 'Button' }))).toContain( + `import { Button as ButtonComponent } from './button.component';` + ); + }); + + it('emits no component import when the story file declares the component itself', () => { + expect(angularHostImports(context({ importId: undefined }))).toBe( + `import { Component } from '@angular/core';` + ); + }); + + it('pulls in NgComponentOutlet for a component with no selector', () => { + expect(angularHostImports(context({ outlet: true }))).toContain( + `import { NgComponentOutlet } from '@angular/common';` + ); + }); +}); + +describe('angularHostComponent', () => { + it('declares a no-op method per bound output so the template type-checks', () => { + expect(angularHostComponent('', ['clicked', 'closed'], context())).toBe( + `@Component({ + selector: 'app-root', + template: \`\`, + imports: [ButtonComponent], +}) +export class App { + clicked(event: unknown) {} + closed(event: unknown) {} +}` + ); + }); + + it('leaves the class empty when the template binds nothing', () => { + expect(angularHostComponent('', [], context())).toContain('export class App {}'); + }); + + it('quotes an output name that is not a valid identifier', () => { + expect(angularHostComponent('', ['data-changed'], context())).toContain( + `['data-changed'](event: unknown) {}` + ); + }); + + it('exposes the class the outlet template reads as a value', () => { + const snippet = angularHostComponent( + '', + [], + context({ outlet: true }) + ); + + expect(snippet).toContain('imports: [NgComponentOutlet],'); + expect(snippet).toContain('readonly ButtonComponent = ButtonComponent;'); + }); + + it.each([ + ['a backtick', '', ''], + ['an interpolation', '${x}', '\\${x}'], + ['a backslash', '', ''], + ])('escapes %s so it cannot break out of the host template literal', (_case, markup, escaped) => { + expect(angularHostComponent(markup, [], context())).toContain(`template: \`${escaped}\`,`); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-host.ts b/code/frameworks/angular-vite/src/docgen/story-docs-host.ts new file mode 100644 index 000000000000..771e1858efe5 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-host.ts @@ -0,0 +1,83 @@ +/** + * Host component the `component` snippet format wraps a story's markup in. An Angular template only + * exists inside a component, so the bare markup the `template` format emits has nowhere to be + * pasted; this adds the smallest standalone host that compiles it. + */ + +import { isValidIdentifier } from './story-docs-snippet.ts'; + +/** Selector of the generated host, matching what the Angular CLI scaffolds for a root component. */ +const HOST_SELECTOR = 'app-root'; +const HOST_CLASS = 'App'; +const INDENT = ' '; + +/** What the host wrapper needs, all of it the same for every story under one component. */ +export interface AngularHostContext { + /** Class name the template renders and the host's `imports` lists. */ + componentName: string; + /** Name the declaring module exports the class under: `default`, or a named export. */ + exportName: string; + /** Specifier the component is imported from; absent when the story file declares it inline. */ + importId?: string; + /** The template renders through `*ngComponentOutlet`, because the component has no selector. */ + outlet?: boolean; +} + +/** + * Import that brings the class into scope under `componentName`, which is the name the template and + * the `imports` array use. A default import binds to any local name; a named export is aliased back + * to the class's own name when the two differ. + */ +const componentImport = (context: AngularHostContext, importId: string): string => { + const { componentName, exportName } = context; + if (exportName === 'default') { + return `import ${componentName} from '${importId}';`; + } + const named = exportName === componentName ? componentName : `${exportName} as ${componentName}`; + return `import { ${named} } from '${importId}';`; +}; + +/** Import block for a component's snippets, carried once per payload rather than per story. */ +export const angularHostImports = (context: AngularHostContext): string => + [ + `import { Component } from '@angular/core';`, + ...(context.outlet ? [`import { NgComponentOutlet } from '@angular/common';`] : []), + ...(context.importId ? [componentImport(context, context.importId)] : []), + ].join('\n'); + +/** + * Wraps one story's markup in its host component. + * + * `handlers` are the output names the markup binds. Angular's template type checking resolves them + * against the host class, so a snippet that binds an output without declaring the method would not + * compile - which is the whole point of this format. + */ +export const angularHostComponent = ( + template: string, + handlers: readonly string[], + context: AngularHostContext +): string => { + const members = [ + // `*ngComponentOutlet` reads the class as a template expression, so the host has to expose it. + ...(context.outlet ? [`readonly ${context.componentName} = ${context.componentName};`] : []), + ...handlers.map((name) => `${memberName(name)}(event: unknown) {}`), + ]; + + return [ + '@Component({', + `${INDENT}selector: '${HOST_SELECTOR}',`, + `${INDENT}template: \`${escapeTemplateLiteral(template)}\`,`, + `${INDENT}imports: [${context.outlet ? 'NgComponentOutlet' : context.componentName}],`, + '})', + members.length === 0 + ? `export class ${HOST_CLASS} {}` + : `export class ${HOST_CLASS} {\n${members.map((member) => INDENT + member).join('\n')}\n}`, + ].join('\n'); +}; + +/** Class member name: written plainly when it is an identifier, quoted otherwise. */ +const memberName = (name: string): string => (isValidIdentifier(name) ? name : `['${name}']`); + +/** Escapes what would otherwise close or interpolate the host's template literal. */ +const escapeTemplateLiteral = (markup: string): string => + markup.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-limitations.md b/code/frameworks/angular-vite/src/docgen/story-docs-limitations.md index 95012c99c9b4..9d47996d0e9b 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-limitations.md +++ b/code/frameworks/angular-vite/src/docgen/story-docs-limitations.md @@ -3,6 +3,36 @@ Source material for the user-facing setup and known-limitations page. Behaviour described here applies to `@storybook/angular-vite` with the `experimentalDocgenServer` feature flag on, where the Source block and the Code panel show a snippet generated on the server from the component's declared metadata instead of one generated in the browser from the loaded component class. +## Two snippet formats + +`framework.options.snippetFormat` picks the shape of the emitted snippet. + +`'template'` is the default and emits the bare markup, which is what the browser generator has always produced: + +```html + +``` + +`'component'` wraps that markup in the standalone host component it needs in order to compile, and the payload carries the matching import block: + +```ts +import { Component } from '@angular/core'; +import { ButtonComponent } from './button.component'; + +@Component({ + selector: 'app-root', + template: ``, + imports: [ButtonComponent], +}) +export class App { + clicked(event: unknown) {} +} +``` + +The host declares a no-op method per bound output because Angular's template type checking resolves `(clicked)="clicked($event)"` against the host class, so a snippet without it would not compile. +Only the outputs the markup actually binds get a method, so a story that wrote its own markup around `argsToTemplate(args, { exclude: ['count'] })` gets exactly the handlers that survived the filter. +The host `selector` and class name are fixed, and the component is imported through the specifier the story file itself used. + ## Snippets no longer update as you change Controls This is the one accepted regression of the server-side docs path. diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts b/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts index 9b8f2ad3a573..7cd50fbbd494 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts @@ -4,6 +4,7 @@ import type { StoryDocsProviderPreset } from 'storybook/internal/types'; import { AngularComponentMetaManager } from '@storybook/angular-cm'; import { buildStoryDocsPayload } from './story-docs-build.ts'; +import type { FrameworkOptions } from '../types.ts'; const createManager = async (): Promise => { try { @@ -36,11 +37,17 @@ const createManager = async (): Promise * No `startWatching()`: `extract` stats its cached snapshots per call, which keeps the story-file * driven re-extractions the module-graph subscription issues fresh without main-process watchers. */ -export const experimental_storyDocsProvider: StoryDocsProviderPreset = async (nextStoryDocs) => { +export const experimental_storyDocsProvider: StoryDocsProviderPreset = async ( + nextStoryDocs, + options +) => { // Scoped to the composed chain rather than the module, so the manager has exactly one owner and // one lifetime. Created lazily on the first eligible entry. let managerPromise: Promise | undefined; + const { snippetFormat } = + (await options.presets.apply('frameworkOptions')) ?? {}; + return async (input) => { const storyImportPath = getStoryImportPathFromEntry(input.entry); if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { @@ -48,7 +55,10 @@ export const experimental_storyDocsProvider: StoryDocsProviderPreset = async (ne } const manager = await (managerPromise ??= createManager()); - const ours = buildStoryDocsPayload(input, { manager }); + const ours = buildStoryDocsPayload(input, { + manager, + ...(snippetFormat === undefined ? {} : { snippetFormat }), + }); // The language service holds large type caches; check heap pressure after each extraction. manager?.recycleIfHeapPressured(); 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 65e482b93759..d18b4b4baf28 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-snippet.ts @@ -25,7 +25,7 @@ export interface RenderComponentSnippetInput { outputs: string[]; } -const isValidIdentifier = (name: string): boolean => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); +export const isValidIdentifier = (name: string): boolean => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); const formatPropInTemplate = (propertyName: string) => isValidIdentifier(propertyName) ? propertyName : `this['${propertyName}']`; diff --git a/code/frameworks/angular-vite/src/types.ts b/code/frameworks/angular-vite/src/types.ts index ea270d093f69..fc76fff47acd 100644 --- a/code/frameworks/angular-vite/src/types.ts +++ b/code/frameworks/angular-vite/src/types.ts @@ -7,6 +7,15 @@ import type { BuilderOptions, StorybookConfigVite } from '@storybook/builder-vit type FrameworkName = CompatibleString<'@storybook/angular-vite'>; type BuilderName = CompatibleString<'@storybook/builder-vite'>; +/** + * Shape of the server-side story snippet. + * + * `template` emits the bare markup, which is what the browser generator has always produced. + * `component` wraps it in the standalone host component it needs to compile, so the snippet can be + * pasted into a project or handed to an agent as-is. + */ +export type SnippetFormat = 'template' | 'component'; + export type FrameworkOptions = { builder?: BuilderOptions; jit?: boolean; @@ -15,6 +24,8 @@ export type FrameworkOptions = { tsconfig?: string; compodoc?: boolean; compodocArgs?: string[]; + /** Only read when `features.experimentalDocgenServer` is on. Defaults to `'template'`. */ + snippetFormat?: SnippetFormat; }; type StorybookConfigFramework = {