diff --git a/code/core/src/csf-tools/story-shape/args.test.ts b/code/core/src/csf-tools/story-shape/args.test.ts index b005938a1ad2..cf279aab3949 100644 --- a/code/core/src/csf-tools/story-shape/args.test.ts +++ b/code/core/src/csf-tools/story-shape/args.test.ts @@ -11,6 +11,7 @@ import { argsRecordFromObjectPath, mergeArgsRecords, metaArgsRecord, + storyAssignedArgsPath, } from './args.ts'; import { normalizeStoryDeclaration } from './normalize-story.ts'; import { keyOf, metaObjectPath } from './utils.ts'; @@ -179,3 +180,52 @@ describe('mergeArgsRecords', () => { `); }); }); + +describe('storyAssignedArgsPath', () => { + const assignedArgs = (code: string) => { + const source = `export default { title: 'T' };\n${dedent(code)}`; + const csf = loadCsf(source, { makeTitle: (title) => title ?? 'title' }).parse(); + return argsRecordFromObjectPath(storyAssignedArgsPath(csf._file.path, 'A')); + }; + + it('reads the CSF2 assignment form', () => { + expect( + Object.keys( + assignedArgs(` + export const A = () => 1; + A.args = { label: 'Save' }; + `) + ) + ).toEqual(['label']); + }); + + it('reads the computed spelling too', () => { + expect( + Object.keys( + assignedArgs(` + export const A = () => 1; + A['args'] = { label: 'Save' }; + `) + ) + ).toEqual(['label']); + }); + + it('ignores an assignment to a different story', () => { + expect( + assignedArgs(` + export const A = () => 1; + export const B = () => 1; + B.args = { label: 'Save' }; + `) + ).toEqual({}); + }); + + it('ignores a non-args property', () => { + expect( + assignedArgs(` + export const A = () => 1; + A.parameters = { docs: {} }; + `) + ).toEqual({}); + }); +}); diff --git a/code/core/src/csf-tools/story-shape/args.ts b/code/core/src/csf-tools/story-shape/args.ts index ee58bb03e04e..7a325259666f 100644 --- a/code/core/src/csf-tools/story-shape/args.ts +++ b/code/core/src/csf-tools/story-shape/args.ts @@ -40,6 +40,42 @@ export const metaArgsRecord = (meta?: t.ObjectExpression | null): Record, + storyName: string +): NodePath | null => { + let found: NodePath | null = null; + + program.traverse({ + AssignmentExpression(assignment) { + const left = assignment.get('left'); + const right = assignment.get('right'); + if (!left.isMemberExpression() || !right.isObjectExpression()) { + return; + } + + const object = left.get('object'); + const property = left.get('property'); + const isStory = object.isIdentifier() && object.node.name === storyName; + const isArgs = + (property.isIdentifier() && property.node.name === 'args' && !left.node.computed) || + (t.isStringLiteral(property.node) && left.node.computed && property.node.value === 'args'); + + if (isStory && isArgs) { + found = right; + } + }, + }); + + return found; +}; + /** CSF arg precedence: story args override meta args per key. */ export const mergeArgsRecords = ( metaArgs: Record, diff --git a/code/core/src/csf-tools/story-shape/index.ts b/code/core/src/csf-tools/story-shape/index.ts index a243c49c6177..ab33fe79854f 100644 --- a/code/core/src/csf-tools/story-shape/index.ts +++ b/code/core/src/csf-tools/story-shape/index.ts @@ -1,4 +1,9 @@ -export { argsRecordFromObjectPath, mergeArgsRecords, metaArgsRecord } from './args.ts'; +export { + argsRecordFromObjectPath, + mergeArgsRecords, + metaArgsRecord, + storyAssignedArgsPath, +} from './args.ts'; export { type ImportBinding, collectImportBindings, @@ -7,4 +12,11 @@ export { } from './imports.ts'; export { extractStoryJSDocInfo } from './jsdoc.ts'; export { type NormalizedStoryDeclaration, normalizeStoryDeclaration } from './normalize-story.ts'; -export { keyOf, metaObjectPath, resolveIdentifierInit } from './utils.ts'; +export { type RenderFunctionPath, type RenderResolution, resolveRenderFunction } from './render.ts'; +export { + keyOf, + metaObjectPath, + propertyValue, + resolveIdentifierInit, + returnedObjectExpression, +} from './utils.ts'; diff --git a/code/core/src/csf-tools/story-shape/render.test.ts b/code/core/src/csf-tools/story-shape/render.test.ts new file mode 100644 index 000000000000..f950a9e8c9db --- /dev/null +++ b/code/core/src/csf-tools/story-shape/render.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import type { types as t } from 'storybook/internal/babel'; +import { type NodePath, recast } from 'storybook/internal/babel'; + +import { dedent } from 'ts-dedent'; + +import { loadCsf } from '../CsfFile.ts'; +import { resolveRenderFunction } from './render.ts'; +import { normalizeStoryDeclaration } from './normalize-story.ts'; + +/** Resolves `render` on story `A`, the way a snippet generator would. */ +const resolveStoryRender = (code: string) => { + const source = `export default { title: 'T' };\n${dedent(code)}`; + const csf = loadCsf(source, { makeTitle: (title) => title ?? 'title' }).parse(); + const declaration = csf._storyDeclarationPath['A']; + const normalized = normalizeStoryDeclaration(declaration); + const properties: NodePath[] = + normalized.type === 'config' + ? normalized.path.get('properties').filter((p) => p.isObjectProperty()) + : []; + + return resolveRenderFunction(properties, declaration); +}; + +const printedBody = (resolution: ReturnType) => + resolution.kind === 'resolved' ? recast.print(resolution.path.node).code : undefined; + +describe('resolveRenderFunction', () => { + it('reports a story with no render property as missing', () => { + expect(resolveStoryRender(`export const A = { args: {} };`)).toEqual({ kind: 'missing' }); + }); + + it('resolves an inline arrow function', () => { + expect(printedBody(resolveStoryRender(`export const A = { render: () => 1 };`))).toBe( + '() => 1' + ); + }); + + it('follows an identifier to a local arrow function', () => { + expect( + printedBody( + resolveStoryRender(` + const Template = () => 1; + export const A = { render: Template }; + `) + ) + ).toBe('() => 1'); + }); + + it('follows an identifier to a local function declaration', () => { + expect( + printedBody( + resolveStoryRender(` + function Template() { return 1; } + export const A = { render: Template }; + `) + ) + ).toBe('function Template() { return 1; }'); + }); + + // The distinction that matters: an unreadable render is not the same as no render, because a + // caller may only fall back to the meta's render in the second case. + it('reports an identifier it cannot follow as unresolved rather than missing', () => { + expect(resolveStoryRender(`export const A = { render: ImportedTemplate };`)).toEqual({ + kind: 'unresolved', + }); + }); + + it('reports an identifier bound to a non-function as unresolved', () => { + expect( + resolveStoryRender(` + const Template = 'not a function'; + export const A = { render: Template }; + `) + ).toEqual({ kind: 'unresolved' }); + }); + + it('throws when render is present but is not a function at all', () => { + expect(() => resolveStoryRender(`export const A = { render: { nested: true } };`)).toThrow( + /Expected render to be an arrow function or function expression/ + ); + }); +}); diff --git a/code/core/src/csf-tools/story-shape/render.ts b/code/core/src/csf-tools/story-shape/render.ts new file mode 100644 index 000000000000..79da70df4dcb --- /dev/null +++ b/code/core/src/csf-tools/story-shape/render.ts @@ -0,0 +1,60 @@ +import type { types as t } from 'storybook/internal/babel'; +import { type NodePath } from 'storybook/internal/babel'; + +import { keyOf, resolveIdentifierInit } from './utils.ts'; + +/** A function a story or meta supplies through `render`. */ +export type RenderFunctionPath = NodePath< + t.ArrowFunctionExpression | t.FunctionExpression | t.FunctionDeclaration +>; + +/** + * Outcome of looking for a `render` function. + * + * `missing` and `unresolved` have to stay distinct. A story whose `render` exists but cannot be + * read must not fall back to the meta's `render`: the story's intent was to override it, and + * quietly rendering the meta's version instead produces a snippet for code the story never runs. + */ +export type RenderResolution = + | { kind: 'missing' } + | { kind: 'resolved'; path: RenderFunctionPath } + | { kind: 'unresolved' }; + +const isRenderFunction = (path: NodePath): path is RenderFunctionPath => + path.isArrowFunctionExpression() || path.isFunctionExpression() || path.isFunctionDeclaration(); + +/** + * Resolves the `render` property of a story or meta config, following a local identifier + * (`render: Template`) to the function it names. + * + * `storyDeclaration` anchors the identifier lookup to the module the story lives in, so a helper + * declared beside the story resolves while an imported one reports `unresolved`. + * + * Throws when `render` is present but is neither a function nor an identifier, because that is a + * story-file mistake rather than something a static pass merely could not follow. + */ +export function resolveRenderFunction( + properties: NodePath[], + storyDeclaration: NodePath +): RenderResolution { + const renderPath = properties.find((property) => keyOf(property.node) === 'render')?.get('value'); + + if (!renderPath) { + return { kind: 'missing' }; + } + + if (renderPath.isIdentifier()) { + const resolved = resolveIdentifierInit(storyDeclaration, renderPath); + return resolved && isRenderFunction(resolved) + ? { kind: 'resolved', path: resolved } + : { kind: 'unresolved' }; + } + + if (!isRenderFunction(renderPath)) { + throw renderPath.buildCodeFrameError( + 'Expected render to be an arrow function or function expression' + ); + } + + return { kind: 'resolved', path: renderPath }; +} diff --git a/code/core/src/csf-tools/story-shape/utils.ts b/code/core/src/csf-tools/story-shape/utils.ts index 8015f61a6b2a..74e13fe073fa 100644 --- a/code/core/src/csf-tools/story-shape/utils.ts +++ b/code/core/src/csf-tools/story-shape/utils.ts @@ -12,6 +12,43 @@ export const keyOf = (p: t.ObjectProperty): string | null => ? p.key.value : null; +/** Value of an object expression's own property, when it has one. */ +export const propertyValue = ( + object: t.ObjectExpression | undefined | null, + name: string +): t.Node | undefined => + object?.properties.find( + (candidate): candidate is t.ObjectProperty => + t.isObjectProperty(candidate) && keyOf(candidate) === name + )?.value; + +/** + * Object literal a function returns, when it returns one directly. + * + * Covers the concise body (`() => ({ … })`) and a block body whose `return` carries an object + * literal, which is the shape template-based renderers use for `render`. + */ +export const returnedObjectExpression = ( + fn: t.Node | undefined +): t.ObjectExpression | undefined => { + if ( + !t.isArrowFunctionExpression(fn) && + !t.isFunctionExpression(fn) && + !t.isFunctionDeclaration(fn) + ) { + return undefined; + } + if (t.isObjectExpression(fn.body)) { + return fn.body; + } + const returned = t.isBlockStatement(fn.body) + ? fn.body.body.find((statement): statement is t.ReturnStatement => + t.isReturnStatement(statement) + )?.argument + : undefined; + return t.isObjectExpression(returned) ? returned : undefined; +}; + /** Resolve a local story helper used by `Template.bind({})` or `render: Template`. */ export function resolveIdentifierInit( storyPath: NodePath, diff --git a/code/frameworks/angular-vite/src/client/docs/config.test.ts b/code/frameworks/angular-vite/src/client/docs/config.test.ts new file mode 100644 index 000000000000..c0ca07df5ed2 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/config.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The flag is read once at module evaluation, so each case needs a fresh module registry. +const loadDecorators = async () => { + vi.resetModules(); + return (await import('./config.ts')).decorators; +}; + +describe('angular docs decorators', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('registers the source decorator by default', async () => { + await expect(loadDecorators()).resolves.toHaveLength(1); + }); + + it('drops the source decorator when the server-side docs path is on', async () => { + vi.stubGlobal('FEATURES', { experimentalDocgenServer: true }); + + await expect(loadDecorators()).resolves.toEqual([]); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/docs/config.ts b/code/frameworks/angular-vite/src/client/docs/config.ts index e9e4c8e0578a..8f9a59362e53 100644 --- a/code/frameworks/angular-vite/src/client/docs/config.ts +++ b/code/frameworks/angular-vite/src/client/docs/config.ts @@ -12,4 +12,8 @@ export const parameters: Parameters = { }, }; -export const decorators: DecoratorFunction[] = [sourceDecorator]; +// Two generators would otherwise call `emitTransformCode` for the same story with nothing +// coordinating them: `story-docs` covers snippets when the server-side docs path is on. +const useStaticServiceSnippets = globalThis.FEATURES?.experimentalDocgenServer; + +export const decorators: DecoratorFunction[] = useStaticServiceSnippets ? [] : [sourceDecorator]; diff --git a/code/frameworks/angular-vite/src/docgen/__testfixtures__/documentation.json b/code/frameworks/angular-vite/src/docgen/__testfixtures__/documentation.json new file mode 100644 index 000000000000..cb00d27ab3b1 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/__testfixtures__/documentation.json @@ -0,0 +1,20 @@ +{ + "components": [ + { + "name": "ButtonComponent", + "type": "component", + "file": "button.component.ts", + "selector": "sb-button", + "propertiesClass": [], + "methodsClass": [], + "inputsClass": [ + { "name": "label", "type": "string", "optional": false, "defaultValue": "'Badge'" }, + { "name": "count", "type": "number", "optional": true } + ], + "outputsClass": [{ "name": "clicked", "type": "EventEmitter", "optional": false }] + } + ], + "directives": [], + "classes": [], + "miscellaneous": {} +} diff --git a/code/frameworks/angular-vite/src/docgen/__testfixtures__/no-component.stories.ts b/code/frameworks/angular-vite/src/docgen/__testfixtures__/no-component.stories.ts new file mode 100644 index 000000000000..97dc364f8299 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/__testfixtures__/no-component.stories.ts @@ -0,0 +1,4 @@ +// Fixture: a story file with no `meta.component`, which the story-docs provider passes through. +export default { title: 'NoComponent' }; + +export const Only = { args: { label: 'x' } }; diff --git a/code/frameworks/angular-vite/src/docgen/__testfixtures__/story-docs.stories.ts b/code/frameworks/angular-vite/src/docgen/__testfixtures__/story-docs.stories.ts new file mode 100644 index 000000000000..efae59079169 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/__testfixtures__/story-docs.stories.ts @@ -0,0 +1,20 @@ +// Fixture: the story shapes the Angular story-docs provider has to tell apart. +// Excluded from the package tsconfig, so the loose typing here is deliberate. +import { ButtonComponent } from './button.component'; + +const notAStoryConfig = (...parts: unknown[]) => ({ parts }); + +export default { + title: 'StoryDocs', + component: ButtonComponent, + args: { label: 'meta' }, +}; + +/** Renders the button with a label and a count. */ +export const Basic = { + args: { label: 'Save', count: 3, clicked: () => {}, notAnInput: 'dropped' }, +}; + +export const InheritsMetaArgs = { args: { count: 2 } }; + +export const Unclassifiable = notAStoryConfig(1, 2); diff --git a/code/frameworks/angular-vite/src/docgen/build-docgen.ts b/code/frameworks/angular-vite/src/docgen/build-docgen.ts index a7ff09543b05..4cbc8757601d 100644 --- a/code/frameworks/angular-vite/src/docgen/build-docgen.ts +++ b/code/frameworks/angular-vite/src/docgen/build-docgen.ts @@ -12,6 +12,7 @@ import { join, resolve } from 'node:path'; import type { CompodocEntry, + Directive, CompodocJson, CompodocParsingLogger, JsDocTag, @@ -91,11 +92,11 @@ export const findCompodocEntry = ( json: CompodocJson, component: Pick, workspaceRoot: string -): CompodocEntry | undefined => { +): Directive | undefined => { const { exportName, path } = component; const entries = [...(json.components ?? []), ...(json.directives ?? [])].filter( Boolean - ) as WithFile[]; + ) as WithFile[]; if (path) { const wanted = comparablePath(path); @@ -128,11 +129,11 @@ export const findCompodocEntry = ( }; /** Whether every entry describes the same class, so picking the first is not a guess. */ -const namesAgree = (entries: WithFile[]): boolean => +const namesAgree = (entries: WithFile[]): boolean => entries.length > 0 && entries.every((entry) => entry.name === entries[0].name); /** Whether same-named entries are all the same physical file, rather than genuine namesakes. */ -const sameComponent = (entries: WithFile[], workspaceRoot: string): boolean => { +const sameComponent = (entries: WithFile[], workspaceRoot: string): boolean => { if (entries.length === 0) { return false; } diff --git a/code/frameworks/angular-vite/src/docgen/build-story-docs.test.ts b/code/frameworks/angular-vite/src/docgen/build-story-docs.test.ts new file mode 100644 index 000000000000..a17812ae6df7 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/build-story-docs.test.ts @@ -0,0 +1,160 @@ +import type { IndexEntry, StoryDocsPayload } from 'storybook/internal/types'; + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { CompodocJson } from '@storybook/angular-compodoc'; +import type { BuildStoryDocsContext } from './build-story-docs.ts'; +import { buildStoryDocsPayload } from './build-story-docs.ts'; +import type { CompodocComponentResolverOptions } from './compodoc-component-resolver.ts'; +import { createCompodocComponentResolver } from './compodoc-component-resolver.ts'; + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '__testfixtures__'); + +/** The same shape production reads out of the resolved output directory. */ +const documentationJson = () => + JSON.parse(readFileSync(join(FIXTURES, 'documentation.json'), 'utf8')) as CompodocJson; + +const entry = (importPath: string, title = 'StoryDocs'): IndexEntry => ({ + id: 'storydocs--basic', + name: 'Basic', + title, + type: 'story', + subtype: 'story', + importPath, +}); + +const compodocResolver = (overrides: Partial = {}) => + createCompodocComponentResolver({ + workspaceRoot: FIXTURES, + readMetadata: documentationJson, + logger: { warn: vi.fn(), debug: vi.fn() }, + ...overrides, + }); + +const build = ( + importPath: string, + overrides: Partial = {}, + title?: string +): StoryDocsPayload | undefined => + buildStoryDocsPayload( + { entry: entry(importPath, title) }, + { + storyRoot: FIXTURES, + resolveComponent: compodocResolver(), + logger: { debug: vi.fn() }, + ...overrides, + } + ); + +/** Snippet for one story export, keyed the way the payload is: by story id. */ +const snippetOf = (payload: StoryDocsPayload | undefined, storyName: string) => + Object.values(payload?.stories ?? {}).find((story) => story.name === storyName)?.snippet; + +const storyOf = (payload: StoryDocsPayload | undefined, storyName: string) => + Object.values(payload?.stories ?? {}).find((story) => story.name === storyName); + +describe('buildStoryDocsPayload', () => { + it('builds a payload for a real Angular story file', () => { + const payload = build('./story-docs.stories.ts'); + + expect(payload).toMatchObject({ + id: 'storydocs', + name: 'ButtonComponent', + path: './story-docs.stories.ts', + }); + expect(snippetOf(payload, 'Basic')).toBe( + `` + ); + }); + + it('merges meta args under story args', () => { + // `label` comes from the meta, `count` from the story. + expect(snippetOf(build('./story-docs.stories.ts'), 'Inherits Meta Args')).toBe( + `` + ); + }); + + it('carries the story JSDoc description through', () => { + expect(storyOf(build('./story-docs.stories.ts'), 'Basic')?.description).toBe( + 'Renders the button with a label and a count.' + ); + }); + + it('isolates a story it cannot read and still ships the rest of the file', () => { + const payload = build('./story-docs.stories.ts'); + + expect(storyOf(payload, 'Unclassifiable')).toMatchObject({ + error: { message: expect.stringContaining('Could not evaluate story expression') }, + }); + expect(storyOf(payload, 'Unclassifiable')?.snippet).toBeUndefined(); + expect(snippetOf(payload, 'Basic')).toBeDefined(); + }); + + it.each([ + ['a story file that does not exist', './missing.stories.ts', {}], + ['a story file with no meta.component', './no-component.stories.ts', {}], + [ + 'a component the docgen engine has nothing for', + './story-docs.stories.ts', + { resolveComponent: (): undefined => undefined }, + ], + [ + 'a component Compodoc never documented', + './story-docs.stories.ts', + { + resolveComponent: compodocResolver({ + readMetadata: (): CompodocJson => ({ components: [] }), + }), + }, + ], + [ + 'unreadable Compodoc metadata', + './story-docs.stories.ts', + { + resolveComponent: compodocResolver({ + readMetadata: () => { + throw new Error('ENOENT'); + }, + }), + }, + ], + ])('falls through for %s', (_case, importPath, overrides) => { + expect(build(importPath, overrides as Partial)).toBeUndefined(); + }); + + it('falls through when the entry has no story import path', () => { + expect( + buildStoryDocsPayload( + { + entry: { id: 'x', name: 'x', title: 'x', type: 'docs', storiesImports: [] } as IndexEntry, + }, + { + storyRoot: FIXTURES, + resolveComponent: compodocResolver(), + logger: { debug: vi.fn() }, + } + ) + ).toBeUndefined(); + }); + + // Compodoc is one source of a component's selector and binding names; an in-process Angular + // component meta service is meant to be another. Snippet generation must not care which. + it('builds from a resolver that has never heard of Compodoc', () => { + const payload = build('./story-docs.stories.ts', { + resolveComponent: () => ({ + name: 'ButtonComponent', + selector: 'my-button', + inputs: ['label'], + outputs: ['pressed'], + }), + }); + + expect(snippetOf(payload, 'Basic')).toBe( + `` + ); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/build-story-docs.ts b/code/frameworks/angular-vite/src/docgen/build-story-docs.ts new file mode 100644 index 000000000000..b63fb8d08b4a --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/build-story-docs.ts @@ -0,0 +1,126 @@ +import type { types as t } from 'storybook/internal/babel'; +import type { ResolvedMetaComponent } from 'storybook/internal/common'; +import { getComponentIdFromEntry, getStoryImportPathFromEntry } from 'storybook/internal/common'; +import { storyNameFromExport } from 'storybook/internal/csf'; +import type { CsfFile } from 'storybook/internal/csf-tools'; +import { + extractStoryJSDocInfo, + loadCsf, + mergeArgsRecords, + metaArgsRecord, + normalizeStoryDeclaration, +} from 'storybook/internal/csf-tools'; +import type { + StoryDocsById, + StoryDocsPayload, + StoryDocsProviderInput, +} from 'storybook/internal/types'; + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { resolveMetaComponent } from './resolve-component.ts'; +import type { AngularComponentTemplate } from './template-snippet.ts'; +import { generateAngularSnippet } from './template-snippet.ts'; + +export type AngularComponentResolver = ( + component: ResolvedMetaComponent +) => AngularComponentTemplate | undefined; + +/** The slice of the logger this module uses. */ +export interface StoryDocsLogger { + debug: (message: string) => void; +} + +export interface BuildStoryDocsContext { + /** + * Directory a story index `importPath` resolves against. This is the index generator's own + * working directory, which is the Storybook process cwd. + */ + storyRoot: string; + resolveComponent: AngularComponentResolver; + logger: StoryDocsLogger; +} + +/** + * Static Angular template snippets for one CSF story file. + * + * `undefined` means the file is not ours to handle - unparseable, no `meta.component`, or a + * component the docgen engine has nothing for. A story whose snippet fails instead carries an + * `error` while the rest of the file still ships; the two levels are deliberately distinct. + */ +export const buildStoryDocsPayload = ( + input: StoryDocsProviderInput, + context: BuildStoryDocsContext +): StoryDocsPayload | undefined => { + const storyImportPath = getStoryImportPathFromEntry(input.entry); + if (!storyImportPath) { + return undefined; + } + + const storyPath = resolve(context.storyRoot, storyImportPath); + let csf: CsfFile; + try { + csf = loadCsf(readFileSync(storyPath, 'utf8'), { makeTitle: () => input.entry.title }).parse(); + } catch (error) { + context.logger.debug( + `Could not parse ${storyPath}: ${error instanceof Error ? error.message : String(error)}.` + ); + return undefined; + } + + const ref = resolveMetaComponent(csf, storyPath); + if ('reason' in ref) { + context.logger.debug(`No Angular component resolved from ${storyPath}: ${ref.reason}.`); + return undefined; + } + + const component = context.resolveComponent(ref.component); + if (!component) { + return undefined; + } + + const metaArgs = metaArgsRecord(csf._metaNode); + + const stories: StoryDocsById = {}; + for (const [exportName, story] of Object.entries(csf._stories)) { + const name = story.name ?? storyNameFromExport(exportName); + try { + const { description, summary } = extractStoryJSDocInfo(csf._storyStatements[exportName]); + stories[story.id] = { + id: story.id, + name, + description, + summary, + snippet: generateAngularSnippet({ + component, + // Not meta-specific: the helper reads the `args` property of any CSF config object. + args: mergeArgsRecords(metaArgs, metaArgsRecord(storyConfig(csf, exportName))), + }), + }; + } catch (error) { + stories[story.id] = { + id: story.id, + name, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : { name: 'Error', message: String(error) }, + }; + } + } + + return { + id: getComponentIdFromEntry(input.entry), + name: component.name, + path: storyImportPath, + stories, + }; +}; + +/** The config object a story declares, when it declares one. */ +const storyConfig = (csf: CsfFile, exportName: string): t.ObjectExpression | undefined => { + const declaration = csf._storyDeclarationPath[exportName]; + const normalized = declaration ? normalizeStoryDeclaration(declaration) : undefined; + return normalized?.type === 'config' ? normalized.path.node : undefined; +}; diff --git a/code/frameworks/angular-vite/src/docgen/compodoc-component-resolver.ts b/code/frameworks/angular-vite/src/docgen/compodoc-component-resolver.ts new file mode 100644 index 000000000000..7c3f4013c2ea --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/compodoc-component-resolver.ts @@ -0,0 +1,63 @@ +import type { ResolvedMetaComponent } from 'storybook/internal/common'; + +import type { CompodocJson, CompodocParsingLogger } from '@storybook/angular-compodoc'; +import { extractArgTypesFromData, htmlToText } from '@storybook/angular-compodoc'; +import { findCompodocEntry } from './build-docgen.ts'; +import type { AngularComponentResolver } from './build-story-docs.ts'; + +export interface CompodocComponentResolverOptions { + /** Directory Compodoc's relative `file` paths resolve against. */ + workspaceRoot: string; + /** + * Compodoc's metadata. Called per resolve rather than captured once, so a Compodoc run that + * finishes mid-session is picked up; where it comes from is the caller's business. + */ + readMetadata: () => CompodocJson; + logger: CompodocParsingLogger; +} + +/** + * `extractArgTypesFromData` already resolves a `model()` into an input plus a `${name}Change` + * output, so the binding names it reports are the ones a template may bind. + */ +export const createCompodocComponentResolver = + (options: CompodocComponentResolverOptions): AngularComponentResolver => + (component: ResolvedMetaComponent) => { + let compodocJson: CompodocJson; + try { + compodocJson = options.readMetadata(); + } catch (error) { + options.logger.debug( + `Could not read Compodoc metadata: ${error instanceof Error ? error.message : String(error)}.` + ); + return undefined; + } + + const entry = findCompodocEntry(compodocJson, component, options.workspaceRoot); + if (!entry) { + options.logger.debug( + `Compodoc has no entry for "${component.exportName}" (${component.path ?? 'unresolved'}).` + ); + return undefined; + } + + const argTypes = extractArgTypesFromData(entry, { + compodocJson, + // Snippets bind inputs and outputs, which this flag never filters. + filterNonInputControls: false, + logger: options.logger, + unwrapHtml: htmlToText, + }); + + const named = (category: string) => + Object.entries(argTypes ?? {}) + .filter(([, argType]) => argType?.table?.category === category) + .map(([name]) => name); + + return { + name: entry.name ?? component.exportName, + selector: entry.selector, + inputs: named('inputs'), + outputs: named('outputs'), + }; + }; diff --git a/code/frameworks/angular-vite/src/docgen/docgen-worker.ts b/code/frameworks/angular-vite/src/docgen/docgen-worker.ts index 6c1ab46a94a8..97d4db7d3780 100644 --- a/code/frameworks/angular-vite/src/docgen/docgen-worker.ts +++ b/code/frameworks/angular-vite/src/docgen/docgen-worker.ts @@ -5,39 +5,13 @@ * middleware it folds into the provider chain. Everything here runs inside that worker thread. */ import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from 'storybook/internal/common'; -import { logger } from 'storybook/internal/node-logger'; import type { DocgenMiddleware, DocgenProvider } from 'storybook/internal/types'; -import { readFileSync, statSync } from 'node:fs'; - -import type { CompodocJson, CompodocParsingLogger } from '@storybook/angular-compodoc'; import { ensureCompodocDocumentation } from '../compodoc/ensure-documentation.ts'; import type { AngularDocgenOptions } from './build-docgen.ts'; import { buildDocgenPayload } from './build-docgen.ts'; - -/** Worker-side logger, prefixed so a line from a worker thread is attributable. */ -const workerLogger: CompodocParsingLogger = { - warn: (message) => logger.warn(`[storybook-angular-vite] ${message}`), - debug: (message) => logger.debug(`[storybook-angular-vite] ${message}`), -}; - -/** - * Reads `documentation.json`, memoized on the file's mtime and size: the burst of one request per - * component would otherwise reparse a real app's multi-megabyte file, while keying on the file's - * identity still picks up a Compodoc run the user starts mid-session. - */ -const createDocumentationJsonReader = () => { - let cached: { key: string; json: CompodocJson } | undefined; - - return (path: string): CompodocJson => { - const stats = statSync(path); - const key = `${path}:${stats.mtimeMs}:${stats.size}`; - if (cached?.key !== key) { - cached = { key, json: JSON.parse(readFileSync(path, 'utf8')) as CompodocJson }; - } - return cached.json; - }; -}; +import { createDocumentationJsonReader } from './documentation-json.ts'; +import { compodocLogger } from './logger.ts'; /** * Builds the Angular docgen middleware, running Compodoc first if `documentation.json` is missing or @@ -67,7 +41,7 @@ export const createDocgenProvider = async ( const ours = buildDocgenPayload(input, { options, readDocumentationJson, - logger: workerLogger, + logger: compodocLogger, }); if (!ours) { diff --git a/code/frameworks/angular-vite/src/docgen/documentation-json.ts b/code/frameworks/angular-vite/src/docgen/documentation-json.ts new file mode 100644 index 000000000000..3d6fdef5e453 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/documentation-json.ts @@ -0,0 +1,21 @@ +import { readFileSync, statSync } from 'node:fs'; + +import type { CompodocJson } from '@storybook/angular-compodoc'; + +/** + * Reads `documentation.json`, memoized on mtime and size: a burst of one request per component + * would otherwise reparse a multi-megabyte file, while keying on the file's identity still picks up + * a Compodoc run started mid-session. + */ +export const createDocumentationJsonReader = () => { + let cached: { key: string; json: CompodocJson } | undefined; + + return (path: string): CompodocJson => { + const stats = statSync(path); + const key = `${path}:${stats.mtimeMs}:${stats.size}`; + if (cached?.key !== key) { + cached = { key, json: JSON.parse(readFileSync(path, 'utf8')) as CompodocJson }; + } + return cached.json; + }; +}; diff --git a/code/frameworks/angular-vite/src/docgen/logger.ts b/code/frameworks/angular-vite/src/docgen/logger.ts new file mode 100644 index 000000000000..a0e15e450471 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/logger.ts @@ -0,0 +1,9 @@ +import { logger } from 'storybook/internal/node-logger'; + +import type { CompodocParsingLogger } from '@storybook/angular-compodoc'; + +/** Prefixed so a line is attributable to this framework, wherever it was emitted from. */ +export const compodocLogger: CompodocParsingLogger = { + warn: (message) => logger.warn(`[storybook-angular-vite] ${message}`), + debug: (message) => logger.debug(`[storybook-angular-vite] ${message}`), +}; diff --git a/code/frameworks/angular-vite/src/docgen/resolve-component.ts b/code/frameworks/angular-vite/src/docgen/resolve-component.ts index e14405850608..c133aa71164b 100644 --- a/code/frameworks/angular-vite/src/docgen/resolve-component.ts +++ b/code/frameworks/angular-vite/src/docgen/resolve-component.ts @@ -5,8 +5,9 @@ import { loadCsf } from 'storybook/internal/csf-tools'; import { readFileSync } from 'node:fs'; // Angular has no single-file-component format, so the JS/TS extensions the resolver already tries -// are enough. -const resolveMetaComponent = createMetaComponentResolver(); +// are enough. Exported so a caller that already parsed the story file resolves against that parse +// instead of reading and parsing it a second time. +export const resolveMetaComponent = createMetaComponentResolver(); /** * Story file → the component it documents. diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-limitations.md b/code/frameworks/angular-vite/src/docgen/story-docs-limitations.md new file mode 100644 index 000000000000..561d1b81eb66 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-limitations.md @@ -0,0 +1,45 @@ +# Angular story-docs snippets: v1 known limitations + +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. + +## Snippets no longer update as you change Controls + +This is the one accepted regression of the server-side docs path. +The snippet is generated once, from the args written in the story file, so changing a Control updates the story but not the code shown next to it. + +## Property and event bindings only + +The generator emits `[input]="value"` and `(output)="handler($event)"` and nothing else. +Out of scope for this version: + +- structural directives (`*ngIf`, `*ngFor`, `*ngSwitch`) +- two-way banana-in-a-box syntax: an Angular `model()` is shown as a separate `[value]` input and `(valueChange)` output, which is what `[(value)]` desugars to +- content projection, so a component with `` is shown as an empty element + +## Args the component does not accept are not shown + +Which args become bindings is decided by the component's metadata: an arg is an event binding only when it matches a declared output, and a property binding only when it matches a declared input. +Args that match neither are omitted, exactly as the browser generator omits them today. +A consequence worth knowing: a function passed to an `@Input()` stays a property binding rather than being reclassified as an event. + +## Functions and `undefined` are printed literally + +An arg whose value is a function is printed as its source text, and an explicit `undefined` is printed as `undefined`. +Angular template expressions cannot contain function literals, so such a snippet reads correctly but would not compile as written. + +## Values are read from the source, not evaluated + +The snippet is built by reading the story file rather than by running it, so an arg whose value is a constant, an enum member, or a call expression is shown as that expression rather than as the value it evaluates to. +For example `args: { kind: ButtonKind.Secondary }` is shown as `[kind]="ButtonKind.Secondary"`, where the browser generator showed `[kind]="'secondary'"`. + +Args assigned in the older CSF2 style (`MyStory.args = { ... }`) are not read either. +Declare them on the story object or on the meta instead. + +## The component's metadata has to be available + +The snippet needs the component's selector and its input and output names. +Under `features.experimentalDocgenServer` those come from the in-process analyzer in `@storybook/angular-cm`, which reads the component's TypeScript source directly. +When it cannot resolve the component, no snippet is generated and Storybook falls back to showing the story's own source. + +The generator only consumes a selector plus binding names, so swapping the metadata source changes the resolver, not the snippets. diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-preset.test.ts b/code/frameworks/angular-vite/src/docgen/story-docs-preset.test.ts new file mode 100644 index 000000000000..996872bea2df --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-preset.test.ts @@ -0,0 +1,108 @@ +import type { + IndexEntry, + Options, + StoryDocsPayload, + StoryDocsProvider, +} from 'storybook/internal/types'; + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { experimental_storyDocsProvider } from './story-docs-preset.ts'; + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '__testfixtures__'); + +// `storyRoot` and `workspaceRoot` differ here on purpose. +beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue(FIXTURES); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Minimal `Options` stand-in: no dev server, no Vite, no builder context beyond the root. */ +const options = (frameworkOptions: Record = {}): Options => + ({ + presets: { + apply: async (key: string, fallback?: unknown): Promise => + key === 'framework' + ? { name: '@storybook/angular-vite', options: frameworkOptions } + : fallback, + }, + angularBuilderContext: { workspaceRoot: FIXTURES }, + }) as unknown as Options; + +const storyEntry: IndexEntry = { + id: 'storydocs--basic', + name: 'Basic', + title: 'StoryDocs', + type: 'story', + subtype: 'story', + importPath: './story-docs.stories.ts', +}; + +const noDownstream: StoryDocsProvider = async () => undefined; + +describe('experimental_storyDocsProvider', () => { + // NFR3: cold-callable as a plain Node function. Everything it needs comes from the preset + // options and the filesystem, and the assertion is the real snippet rather than "it returned". + it('generates snippets from a bare preset call, with no dev server running', async () => { + const provider = await experimental_storyDocsProvider(noDownstream, options()); + + const payload = await provider({ entry: storyEntry }); + + expect(Object.values(payload!.stories).map((story) => story.snippet)).toContain( + `` + ); + }); + + it('falls through to the next provider for an entry that is not a story file', async () => { + const downstream: StoryDocsPayload = { + id: 'other', + name: 'Other', + path: './other.ts', + stories: {}, + }; + const provider = await experimental_storyDocsProvider(async () => downstream, options()); + + expect(await provider({ entry: { ...storyEntry, importPath: './readme.mdx' } })).toBe( + downstream + ); + }); + + it('keeps downstream keys our payload does not set', async () => { + const provider = await experimental_storyDocsProvider( + async () => ({ + id: 'downstream', + name: 'Downstream', + path: './x', + stories: {}, + import: 'IMPORT', + }), + options() + ); + + const payload = await provider({ entry: storyEntry }); + + expect(payload).toMatchObject({ id: 'storydocs', name: 'ButtonComponent', import: 'IMPORT' }); + }); + + it('falls through when the story path resolves against cwd but Compodoc lives elsewhere', async () => { + vi.mocked(process.cwd).mockReturnValue(join(FIXTURES, 'aliased')); + const provider = await experimental_storyDocsProvider(noDownstream, options()); + + expect(await provider({ entry: storyEntry })).toBeUndefined(); + }); + + it('does not register at all when the user opted out of Compodoc', async () => { + const provider = await experimental_storyDocsProvider( + noDownstream, + options({ compodoc: false }) + ); + + expect(provider).toBe(noDownstream); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts b/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts new file mode 100644 index 000000000000..42f45b183d3d --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts @@ -0,0 +1,50 @@ +import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from 'storybook/internal/common'; +import type { StoryDocsProviderPreset } from 'storybook/internal/types'; + +import { join } from 'node:path'; + +import { DOCUMENTATION_JSON, resolveCompodocConfig } from '../compodoc-config.ts'; +import { buildStoryDocsPayload } from './build-story-docs.ts'; +import { createCompodocComponentResolver } from './compodoc-component-resolver.ts'; +import { createDocumentationJsonReader } from './documentation-json.ts'; +import { compodocLogger } from './logger.ts'; + +export const experimental_storyDocsProvider: StoryDocsProviderPreset = async ( + nextStoryDocs, + options +) => { + const compodoc = await resolveCompodocConfig(options); + + // Opting out of Compodoc removes the only source of a component's selector and binding names, so + // there is nothing left for this provider to generate from. + if (!compodoc.enabled) { + return nextStoryDocs; + } + + const readDocumentationJson = createDocumentationJsonReader(); + const documentationJson = join(compodoc.outputDir, DOCUMENTATION_JSON); + + const context = { + storyRoot: process.cwd(), + resolveComponent: createCompodocComponentResolver({ + workspaceRoot: compodoc.workspaceRoot, + readMetadata: () => readDocumentationJson(documentationJson), + logger: compodocLogger, + }), + logger: compodocLogger, + }; + + return async (input) => { + const storyImportPath = getStoryImportPathFromEntry(input.entry); + if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { + return nextStoryDocs(input); + } + + const ours = buildStoryDocsPayload(input, context); + if (!ours) { + return nextStoryDocs(input); + } + + return { ...(await nextStoryDocs(input)), ...ours }; + }; +}; diff --git a/code/frameworks/angular-vite/src/docgen/template-snippet.test.ts b/code/frameworks/angular-vite/src/docgen/template-snippet.test.ts new file mode 100644 index 000000000000..307371fc0230 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/template-snippet.test.ts @@ -0,0 +1,161 @@ +import type { types as t } from 'storybook/internal/babel'; +import { babelParse } from 'storybook/internal/babel'; + +import { describe, expect, it } from 'vitest'; + +import type { AngularComponentTemplate } from './template-snippet.ts'; +import { generateAngularSnippet, parseSelector, templateExpression } from './template-snippet.ts'; + +/** Parses one expression the way an arg value arrives from a story file. */ +const expression = (source: string): t.Node => { + const program = babelParse(`const value = ${source};`); + const declaration = program.program.body[0] as t.VariableDeclaration; + return declaration.declarations[0].init as t.Node; +}; + +const component = ( + overrides: Partial = {} +): AngularComponentTemplate => ({ + name: 'ButtonComponent', + selector: 'sb-button', + inputs: ['label', 'count'], + outputs: ['clicked'], + ...overrides, +}); + +const snippet = (args: Record, overrides?: Partial) => + generateAngularSnippet({ + component: component(overrides), + args: Object.fromEntries(Object.entries(args).map(([key, src]) => [key, expression(src)])), + }); + +describe('selector to host element', () => { + it.each([ + ['sb-button', ''], + // Compound selector: the directive belongs on its host element, not on an invented one. + ['button[sb-harness-action], a[sb-harness-action]', ''], + ['[myDirective]', '
'], + ['.my-class', '
'], + ['sb-button.a.b', ''], + ['sb-button#main', ''], + // Void elements cannot carry a closing tag. + ['input[myDir]', ''], + // A comma inside an attribute value does not start a second selector. + ['sb-button[data-tags="a,b"]', ''], + // A pseudo-selector narrows when a directive applies; it is not part of the host element. + ['sb-button:not([disabled])', ''], + ['[myDir]:not(.excluded)', '
'], + // A comma inside a pseudo's parentheses separates its arguments, not two selectors, so + // everything written after it still belongs to the host element. + ['sb-button:not(.a, .b).cls', ''], + ['sb-button[type=submit]', ''], + ])('%s renders as %s', (selector, expected) => { + expect(snippet({}, { selector, inputs: [], outputs: [] })).toBe(expected); + }); + + it('reports the host element parts separately', () => { + expect(parseSelector('a.link.external#home[href="/a,b"]')).toEqual({ + tag: 'a', + id: 'home', + classes: ['link', 'external'], + attributes: ['href="/a,b"'], + }); + }); +}); + +describe('templateExpression', () => { + it.each([ + [`'Save'`, `'Save'`], + ['3', '3'], + ['true', 'true'], + ['null', 'null'], + ['undefined', 'undefined'], + [`['a', 'b']`, `['a', 'b']`], + [ + `{ id: 7, tags: ['a', 'b'], nested: { deep: true } }`, + `{ id: 7, tags: ['a', 'b'], nested: { deep: true } }`, + ], + // Functions print literally: Angular template expressions cannot hold one, and the browser + // generator does the same, so the baselines encode it. + ['() => {}', '() => {}'], + ['(value) => value.toFixed(2)', 'value => value.toFixed(2)'], + // Comments and line breaks would leave the attribute value spanning lines. + ['{\n id: 1, // note\n other: 2\n}', '{ id: 1, other: 2 }'], + // The value lives inside a double-quoted HTML attribute, so quotes and ampersands must survive + // as entities or the snippet stops being well-formed markup. + [`"it's fine"`, `"it's fine"`], + [`'say "hi"'`, `'say "hi"'`], + [`'a & b'`, `'a & b'`], + [`'"'`, `'&quot;'`], + ])('%s serializes to %s', (source, expected) => { + expect(templateExpression(expression(source))).toBe(expected); + }); +}); + +describe('generateAngularSnippet', () => { + it('emits a property binding per declared arg the component accepts', () => { + expect(snippet({ label: `'Save'`, count: '3' })).toBe( + `` + ); + }); + + it('drops args the component declares as neither an input nor an output', () => { + expect(snippet({ label: `'Save'`, notAnInput: `'x'` })).toBe( + `` + ); + }); + + it('emits an event binding for every output, declared as an arg or not', () => { + expect(snippet({})).toBe(``); + }); + + it('keeps a function passed to an input as a property binding', () => { + expect(snippet({ label: '() => {}' }, { inputs: ['label'], outputs: [] })).toBe( + `` + ); + }); + + it('emits a model() as separate input and output bindings rather than banana-in-a-box', () => { + expect( + snippet( + { value: `'hello'`, checked: 'true' }, + { inputs: ['value', 'checked'], outputs: ['valueChange', 'checkedChange'] } + ) + ).toBe( + `` + ); + }); + + it('falls back to ngComponentOutlet when the component has no selector', () => { + expect(snippet({ label: `'Save'` }, { selector: undefined })).toBe( + `` + ); + }); + + it('references an output whose name is not an identifier through bracket notation', () => { + expect(snippet({}, { inputs: [], outputs: ['data-changed'] })).toBe( + `` + ); + }); + + it('keeps a multi-line arg value on one line', () => { + expect(snippet({ label: '`line1\nline2`' })).toBe( + '' + ); + }); + + it('does not name a directive selector attribute twice when it is also a bound input', () => { + expect( + snippet( + { appHighlight: `'yellow'` }, + { selector: '[appHighlight]', inputs: ['appHighlight'], outputs: [] } + ) + ).toBe(`
`); + }); + + it('ignores an input named after an inherited Object member rather than throwing', () => { + expect(snippet({ label: `'Save'` }, { inputs: ['label', 'toString'], outputs: [] })).toBe( + `` + ); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/template-snippet.ts b/code/frameworks/angular-vite/src/docgen/template-snippet.ts new file mode 100644 index 000000000000..eff599af5763 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/template-snippet.ts @@ -0,0 +1,166 @@ +/** + * Static Angular template snippets, built from a component's resolved selector and binding names + * plus a story's declared args. Which docgen engine reported those names is not its concern. + */ +import type { types as t } from 'storybook/internal/babel'; +import { generate } from 'storybook/internal/babel'; + +/** The parts of an Angular component a template snippet is built from. */ +export interface AngularComponentTemplate { + /** Class name. Used for the `*ngComponentOutlet` fallback when the component has no selector. */ + name: string; + selector?: string; + /** Template names of the component's inputs, i.e. what `[binding]` may name. */ + inputs: readonly string[]; + /** Template names of the component's outputs, i.e. what `(binding)` may name. */ + outputs: readonly string[]; +} + +export interface AngularSnippetInput { + component: AngularComponentTemplate; + /** Declared args (meta merged with story), as the value AST nodes they were written as. */ + args: Record; +} + +/** Elements HTML forbids a closing tag on. */ +const VOID_ELEMENTS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'command', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]); + +/** The host element an Angular selector puts a component or directive on. */ +export interface HostElement { + tag: string; + id?: string; + classes: string[]; + /** Attributes the selector pins on the host, already in `name` or `name="value"` form. */ + attributes: string[]; +} + +/** A name that can be written as-is rather than through bracket notation. */ +export const IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +/** + * Property name as it can be referenced from a template: dot notation when the name is a valid + * identifier, bracket notation otherwise. + */ +export const formatPropInTemplate = (propertyName: string): string => + IDENTIFIER.test(propertyName) ? propertyName : `this['${propertyName}']`; + +/** One selector atom: an attribute, a pseudo, an element/class/id name, or any single character. */ +const SELECTOR_TOKEN = + /\[(?:[^\]'"]|'[^']*'|"[^"]*")*\]|:[\w-]+(?:\([^()]*\))?|[.#]?[a-zA-Z][\w-]*|[\s\S]/g; + +/** `attr`, `attr=value` or `attr="value"` from a selector, normalised to HTML attribute syntax. */ +const toHostAttribute = (body: string): string => { + const equals = body.indexOf('='); + if (equals === -1) { + return body; + } + const name = body.slice(0, equals).trim(); + const rawValue = body.slice(equals + 1).trim(); + const value = + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) + ? rawValue.slice(1, -1) + : rawValue; + return `${name}="${escapeAttributeValue(value)}"`; +}; + +/** + * Angular selector → the host element a story snippet renders it on. + * + * Tokenized the way Angular's own `CssSelector.parse` does, so quotes, brackets and pseudo + * parentheses stop being scanner state: each token consumes its own delimiters. A selector with no + * element part (`[myDirective]`, `.my-class`) describes a directive, which has no host element of + * its own, so `div` stands in as a neutral, copy-pasteable host. + */ +export const parseSelector = (selector: string): HostElement => { + const attributes: string[] = []; + const classes: string[] = []; + let tag: string | undefined; + let id: string | undefined; + + for (const [token] of selector.matchAll(SELECTOR_TOKEN)) { + if (token === ',') { + // Angular matches on any one of a comma-separated list, so the first is as good as any. + break; + } + if (token.startsWith('[')) { + const body = token.slice(1, -1).trim(); + if (body) { + attributes.push(toHostAttribute(body)); + } + } else if (token.startsWith('#')) { + id = token.slice(1); + } else if (token.startsWith('.')) { + classes.push(token.slice(1)); + } else if (tag === undefined && /^[a-zA-Z]/.test(token)) { + // A `:pseudo` token never matches here, so it stays out of the tag name. + tag = token; + } + } + + return { tag: tag ?? 'div', id, classes, attributes }; +}; + +// The value lands inside a double-quoted HTML attribute, so these two have to survive as entities +// or the snippet stops being well-formed markup. +const escapeAttributeValue = (value: string): string => + value.replace(/&/g, '&').replace(/"/g, '"'); + +/** An arg's value AST node as a single-line Angular template expression. */ +export const templateExpression = (node: t.Node): string => + escapeAttributeValue( + // `concise` leaves newlines inside a template literal, which would break the attribute value. + generate(node, { concise: true, comments: false }).code.replace(/\s*\n\s*/g, ' ') + ); + +/** Builds the static Angular template snippet for one story. */ +export const generateAngularSnippet = ({ component, args }: AngularSnippetInput): string => { + if (!component.selector) { + return ``; + } + + const host = parseSelector(component.selector); + + // `hasOwn` rather than `in`: an input named after an `Object.prototype` member would otherwise + // read the inherited function and hand a non-node to the generator. + const boundInputs = component.inputs.filter((name) => Object.hasOwn(args, name)); + const inputBindings = boundInputs.map((name) => `[${name}]="${templateExpression(args[name])}"`); + + // Storybook's actions enhancer injects an arg for each output at runtime, so every output gets a + // handler whether or not the story declared one. + const outputBindings = component.outputs.map( + (name) => `(${name})="${formatPropInTemplate(name)}($event)"` + ); + + // An attribute directive's own selector attribute is often an input too (`[appHighlight]` with + // an `@Input() appHighlight`). Emitting both would name it twice on the same element. + const boundNames = new Set(boundInputs); + const hostAttributes = [ + ...(host.id ? [`id="${host.id}"`] : []), + ...(host.classes.length > 0 ? [`class="${host.classes.join(' ')}"`] : []), + ...host.attributes.filter((attribute) => !boundNames.has(attribute.split('=')[0])), + ]; + + const attributes = [...hostAttributes, ...inputBindings, ...outputBindings]; + const attributeText = attributes.length > 0 ? ` ${attributes.join(' ')}` : ''; + return VOID_ELEMENTS.has(host.tag) + ? `<${host.tag}${attributeText} />` + : `<${host.tag}${attributeText}>`; +}; diff --git a/code/frameworks/angular-vite/src/preset.ts b/code/frameworks/angular-vite/src/preset.ts index bfaafe9e76cb..4cb470cb6f56 100644 --- a/code/frameworks/angular-vite/src/preset.ts +++ b/code/frameworks/angular-vite/src/preset.ts @@ -18,6 +18,7 @@ import type { StandaloneOptions } from './builders/utils/standalone-options.ts'; import type { UserConfig, Plugin } from 'vite'; export { experimental_docgenProvider, experimental_manifests } from './docgen/preset.ts'; +export { experimental_storyDocsProvider } from './docgen/story-docs-preset.ts'; export const addons: PresetProperty<'addons'> = []; diff --git a/code/lib/angular-compodoc/src/compodoc-types.ts b/code/lib/angular-compodoc/src/compodoc-types.ts index e0344770f6d3..80514d5a79b9 100644 --- a/code/lib/angular-compodoc/src/compodoc-types.ts +++ b/code/lib/angular-compodoc/src/compodoc-types.ts @@ -106,6 +106,8 @@ export interface Directive { methodsClass: Method[]; description?: Html; rawdescription?: string; + /** CSS selector the directive or component matches on. */ + selector?: string; } export type Component = Directive; diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/complex-selector/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/complex-selector/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..b639fe67a7a2 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/complex-selector/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/cross-file-inheritance/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/cross-file-inheritance/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..3867a6ccc031 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/cross-file-inheritance/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-generic/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-generic/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..8c39855b1c23 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-generic/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-getter-setter/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-getter-setter/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..38d3346ed3be --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-getter-setter/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-EventHandlerArg.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-EventHandlerArg.snapshot new file mode 100644 index 000000000000..94edb35f6402 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-EventHandlerArg.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-ExplicitUndefinedArg.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-ExplicitUndefinedArg.snapshot new file mode 100644 index 000000000000..38fba3400081 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-ExplicitUndefinedArg.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-ObjectAndArrayArgs.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-ObjectAndArrayArgs.snapshot new file mode 100644 index 000000000000..5bf738492f85 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-ObjectAndArrayArgs.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..265961272be0 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-io-basics/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-union-enum/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-union-enum/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..b4e75c5b69a8 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/decorator-union-enum/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/expression-defaults/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/expression-defaults/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..31f7a4bc122d --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/expression-defaults/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/jsdoc-tags/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/jsdoc-tags/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..2df02dd74948 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/jsdoc-tags/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/properties-methods-noise/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/properties-methods-noise/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..b2088b2a0f75 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/properties-methods-noise/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/signal-io/osa-snippet-EventHandlerArg.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/signal-io/osa-snippet-EventHandlerArg.snapshot new file mode 100644 index 000000000000..7bbd9cae8f01 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/signal-io/osa-snippet-EventHandlerArg.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/signal-io/osa-snippet-PropsAsWritten.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/signal-io/osa-snippet-PropsAsWritten.snapshot new file mode 100644 index 000000000000..81edcfc62793 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/signal-io/osa-snippet-PropsAsWritten.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/__testfixtures__/signal-model/osa-snippet-TwoWayBinding.snapshot b/code/lib/docgen-harness/src/angular/__testfixtures__/signal-model/osa-snippet-TwoWayBinding.snapshot new file mode 100644 index 000000000000..395a9677165b --- /dev/null +++ b/code/lib/docgen-harness/src/angular/__testfixtures__/signal-model/osa-snippet-TwoWayBinding.snapshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/lib/docgen-harness/src/angular/angular-osa-snippets.test.ts b/code/lib/docgen-harness/src/angular/angular-osa-snippets.test.ts new file mode 100644 index 000000000000..97282c65d306 --- /dev/null +++ b/code/lib/docgen-harness/src/angular/angular-osa-snippets.test.ts @@ -0,0 +1,100 @@ +import type { IndexEntry } from 'storybook/internal/types'; + +import { loadCsf } from 'storybook/internal/csf-tools'; + +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { buildStoryDocsPayload } from '../../../../frameworks/angular-vite/src/docgen/build-story-docs.ts'; +import { createCompodocComponentResolver } from '../../../../frameworks/angular-vite/src/docgen/compodoc-component-resolver.ts'; +import { expectCurrentOrBetter } from '../compare/expect-current-or-better.ts'; +import { BASELINE_PATH } from './baseline-path.ts'; + +// A second recorder alongside the legacy one, following the vue3 `cm-` precedent: the committed +// `snippet-*.snapshot` files stay the oracle this path is measured against instead of being +// overwritten by it, so `BASELINE_PATH` is deliberately untouched. +if (BASELINE_PATH !== 'legacy') { + throw new Error( + 'angular-osa-snippets.test.ts compares the OSA snippets against the legacy baselines; update the recorder or baseline-path.ts' + ); +} + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '__testfixtures__'); + +const fixtureCases = readdirSync(fixturesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + +const silentLogger = { warn: () => {}, debug: () => {} }; + +describe('angular OSA snippet baselines', () => { + it.each(fixtureCases)('%s', async (fixtureCase) => { + const testDir = join(fixturesDir, fixtureCase); + const title = `AngularFixtures/${fixtureCase}`; + const storySource = readFileSync(join(testDir, 'input.stories.ts'), 'utf8'); + const compodocJson = JSON.parse(readFileSync(join(testDir, 'compodoc-input.json'), 'utf8')); + + // The fixtures ship Compodoc's capture as `compodoc-input.json`; production reads the same + // shape from `documentation.json` in the resolved output directory. + const payload = buildStoryDocsPayload( + { + entry: { + type: 'story', + subtype: 'story', + id: `${fixtureCase}--recorder`, + name: 'recorder', + title, + importPath: './input.stories.ts', + } as IndexEntry, + }, + { + storyRoot: testDir, + resolveComponent: createCompodocComponentResolver({ + workspaceRoot: testDir, + readMetadata: () => compodocJson, + logger: silentLogger, + }), + logger: silentLogger, + } + ); + + expect(payload).toBeDefined(); + + const csf = loadCsf(storySource, { makeTitle: () => title }).parse(); + const storyExports = Object.keys(csf._stories); + expect(storyExports.length).toBeGreaterThan(0); + + for (const exportName of storyExports) { + const doc = payload!.stories[csf._stories[exportName].id]; + expect(doc?.error).toBeUndefined(); + + const snippet = doc!.snippet!; + await expect(snippet).toMatchFileSnapshot( + join(testDir, `osa-snippet-${exportName}.snapshot`) + ); + + // The legacy recorder synthesizes an action arg for every output before generating, which is + // what the runtime actions enhancer does, so the committed baselines carry an event binding + // per output. Losing one here is a regression, not a formatting difference. + expectCurrentOrBetter({ + kind: 'snippet', + framework: 'angular', + baseline: readFileSync(join(testDir, `snippet-${exportName}.snapshot`), 'utf8'), + candidate: snippet, + }); + } + + // toMatchFileSnapshot files sit outside vitest's obsolete-snapshot detection, so a renamed or + // removed story export would silently leave its old snapshot on disk. + const snippetFilesOnDisk = readdirSync(testDir) + .filter((file) => file.startsWith('osa-snippet-') && file.endsWith('.snapshot')) + .sort(); + expect(snippetFilesOnDisk).toEqual( + storyExports.map((exportName) => `osa-snippet-${exportName}.snapshot`).sort() + ); + }); +}); diff --git a/code/lib/docgen-harness/src/angular/angular-provider-seam.test.ts b/code/lib/docgen-harness/src/angular/angular-provider-seam.test.ts index 398d05f2ff7d..caec8abd48fb 100644 --- a/code/lib/docgen-harness/src/angular/angular-provider-seam.test.ts +++ b/code/lib/docgen-harness/src/angular/angular-provider-seam.test.ts @@ -5,7 +5,10 @@ import { isAbsolute } from 'node:path'; import { expect, test } from 'vitest'; -import { experimental_docgenProvider } from '../../../../frameworks/angular-vite/src/preset.ts'; +import { + experimental_docgenProvider, + experimental_storyDocsProvider, +} from '../../../../frameworks/angular-vite/src/preset.ts'; // Requiring a real on-disk worker module is what keeps this honest: a stub export (empty array or a // dangling descriptor) must not satisfy it. @@ -26,3 +29,9 @@ test('angular-vite registers a docgen provider pointing at a worker module that ) ).toHaveLength(1); }); + +// Story-docs providers are in-process middleware, so there is no module on disk to require: the +// preset key being an exported function is the whole registration. +test('angular-vite registers a story-docs provider', () => { + expect(typeof experimental_storyDocsProvider).toBe('function'); +}); diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 095fab6dc885..400cb183f4e2 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -7,7 +7,8 @@ import { mergeArgsRecords, metaArgsRecord, normalizeStoryDeclaration, - resolveIdentifierInit, + resolveRenderFunction, + storyAssignedArgsPath, } from 'storybook/internal/csf-tools'; import { invariant } from './utils.ts'; @@ -44,50 +45,8 @@ export function getCodeSnippet( const metaPath = metaObjectPath(csf); const metaProps = metaPath?.get('properties').filter((p) => p.isObjectProperty()) ?? []; - // Tri-state render resolution: distinguishes "no render property" from - // "render exists but couldn't be resolved" so that an unresolvable story-level - // render (e.g. `render: ImportedTemplate`) doesn't incorrectly fall back to meta's render. - type RenderResolution = - | { kind: 'missing' } - | { - kind: 'resolved'; - path: NodePath; - } - | { kind: 'unresolved' }; - - const getRenderPath = (object: NodePath[]): RenderResolution => { - const renderPath = object.find((p) => keyOf(p.node) === 'render')?.get('value'); - - if (!renderPath) { - return { kind: 'missing' }; - } - - // If render is an identifier (e.g. `render: Template`), try to resolve it - if (renderPath.isIdentifier()) { - const resolved = resolveIdentifierInit(storyDeclaration, renderPath); - if ( - resolved && - (resolved.isArrowFunctionExpression() || - resolved.isFunctionExpression() || - resolved.isFunctionDeclaration()) - ) { - return { kind: 'resolved', path: resolved }; - } - // Render property exists but couldn't be resolved — don't fall back to meta's render - return { kind: 'unresolved' }; - } - - if (!(renderPath.isArrowFunctionExpression() || renderPath.isFunctionExpression())) { - throw renderPath.buildCodeFrameError( - 'Expected render to be an arrow function or function expression' - ); - } - - return { kind: 'resolved', path: renderPath }; - }; - - const metaRender = getRenderPath(metaProps); - const storyRender = getRenderPath(storyProps); + const metaRender = resolveRenderFunction(metaProps, storyDeclaration); + const storyRender = resolveRenderFunction(storyProps, storyDeclaration); // Story render takes precedence. Only fall back to meta render when the story // has no render property at all — NOT when it has one that couldn't be resolved. @@ -107,8 +66,8 @@ export function getCodeSnippet( .map((p) => p.get('value')) .find((v) => v.isObjectExpression()); const storyArgs = argsRecordFromObjectPath(storyArgsPath); - const storyAssignedArgsPath = storyArgsAssignmentPath(csf._file.path, storyName); - const storyAssignedArgs = argsRecordFromObjectPath(storyAssignedArgsPath); + const assignedArgsPath = storyAssignedArgsPath(csf._file.path, storyName); + const storyAssignedArgs = argsRecordFromObjectPath(assignedArgsPath); const merged: Record = { ...mergeArgsRecords(metaArgs, storyArgs), ...storyAssignedArgs, @@ -218,32 +177,6 @@ function buildInvalidSpread(entries: ReadonlyArray<[string, t.Node]>): t.JSXSpre const isValidJsxAttrName = (n: string) => /^[A-Za-z_][A-Za-z0-9_:-]*$/.test(n); -/** Find `StoryName.args = { ... }` assignment and return the right-hand ObjectExpression if present. */ -function storyArgsAssignmentPath( - program: NodePath, - storyName: string -): NodePath | null { - let found: NodePath | null = null; - program.traverse({ - AssignmentExpression(p) { - const left = p.get('left'); - const right = p.get('right'); - if (left.isMemberExpression()) { - const obj = left.get('object'); - const prop = left.get('property'); - const isStoryIdent = obj.isIdentifier() && obj.node.name === storyName; - const isArgsProp = - (prop.isIdentifier() && prop.node.name === 'args' && !left.node.computed) || - (t.isStringLiteral(prop.node) && left.node.computed && prop.node.value === 'args'); - if (isStoryIdent && isArgsProp && right.isObjectExpression()) { - found = right as NodePath; - } - } - }, - }); - return found; -} - const toAttr = (key: string, value: t.Node) => { if (t.isBooleanLiteral(value)) { return value.value