diff --git a/code/frameworks/angular-vite/src/docgen/build-docgen.integration.test.ts b/code/frameworks/angular-vite/src/docgen/build-docgen.integration.test.ts index 62b0f70aa0c1..b0f135668e4f 100644 --- a/code/frameworks/angular-vite/src/docgen/build-docgen.integration.test.ts +++ b/code/frameworks/angular-vite/src/docgen/build-docgen.integration.test.ts @@ -43,7 +43,7 @@ it('builds a real payload through the TypeScript-backed analyzer', async () => { name: 'label', table: { category: 'inputs' }, }); - expect(payload?.angularComponentMeta?.name).toBe('ButtonComponent'); + expect(payload?.angularComponentMeta?.entry.name).toBe('ButtonComponent'); } finally { manager.dispose(); } diff --git a/code/frameworks/angular-vite/src/docgen/build-docgen.test.ts b/code/frameworks/angular-vite/src/docgen/build-docgen.test.ts index a462aa01453b..fd28928b146c 100644 --- a/code/frameworks/angular-vite/src/docgen/build-docgen.test.ts +++ b/code/frameworks/angular-vite/src/docgen/build-docgen.test.ts @@ -102,7 +102,7 @@ describe('buildDocgenPayload', () => { name: 'label', table: { category: 'inputs', defaultValue: { summary: 'Click me' } }, }); - expect(payload?.angularComponentMeta).toBe(classMeta); + expect(payload?.angularComponentMeta?.entry).toBe(classMeta); expect(payload?.compodoc).toBeUndefined(); expect(payload?.subcomponents).toBeUndefined(); expect(payload?.error).toBeUndefined(); diff --git a/code/frameworks/angular-vite/src/docgen/build-docgen.ts b/code/frameworks/angular-vite/src/docgen/build-docgen.ts index 92d18ad47de1..5d2f98e00ef0 100644 --- a/code/frameworks/angular-vite/src/docgen/build-docgen.ts +++ b/code/frameworks/angular-vite/src/docgen/build-docgen.ts @@ -8,6 +8,7 @@ import type { import { resolve } from 'node:path'; +import type { EnumType } from '@storybook/angular-compodoc'; import type { AngularClassMeta, AngularComponentMetaResult, @@ -21,9 +22,13 @@ export interface AngularDocgenOptions { angularFilterNonInputControls?: boolean; } +export interface AngularComponentSnippetMeta { + entry: AngularClassMeta; + enums: EnumType[]; +} + export type AngularDocgenPayload = DocgenPayload & { - // The analyzer's record for the class, not filtered by `angularFilterNonInputControls`. - angularComponentMeta?: AngularClassMeta; + angularComponentMeta?: AngularComponentSnippetMeta; }; // Structural on purpose: tests hand in a stub instead of a real TypeScript-backed analyzer. @@ -150,6 +155,6 @@ export const buildDocgenPayload = ( summary: jsDocTags.summary?.[0], jsDocTags, argTypes, - angularComponentMeta: meta.entry, + angularComponentMeta: { entry: meta.entry, enums: meta.json.miscellaneous?.enumerations ?? [] }, }; }; 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 270eba579aed..d40e4c21cafb 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 @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { vol } from 'memfs'; -import type { AngularComponentMetaSource } from './build-docgen.ts'; +import type { AngularDocgenPayload } from './build-docgen.ts'; import { buildStoryDocsPayload } from './story-docs-build.ts'; vi.mock('node:fs', { spy: true }); @@ -42,7 +42,7 @@ const givenStoryFile = (source: string) => { }; describe('buildStoryDocsPayload', () => { - it('returns undefined for entries without a story file or with an unparsable one', () => { + it('returns undefined for entries without a story file or with an unparsable one', async () => { const docsEntry: IndexEntry = { id: 'docs--page', name: 'Page', @@ -52,20 +52,22 @@ describe('buildStoryDocsPayload', () => { storiesImports: [], tags: [], }; - expect(buildStoryDocsPayload({ entry: docsEntry }, { manager: undefined })).toBeUndefined(); + expect( + await buildStoryDocsPayload({ entry: docsEntry }, { getDocgenPayload: undefined }) + ).toBeUndefined(); givenStoryFile('export default { title: "Broken" '); - expect(buildStoryDocsPayload({ entry }, { manager: undefined })).toBeUndefined(); + expect(await buildStoryDocsPayload({ entry }, { getDocgenPayload: undefined })).toBeUndefined(); }); - it('still emits description-only stories when the analyzer is unavailable', () => { + it('still emits description-only stories when core/docgen is unavailable', async () => { givenStoryFile(` export default { title: 'Example/Button' }; /** Documented without a component. */ export const Default = {}; `); - const payload = buildStoryDocsPayload({ entry }, { manager: undefined }); + const payload = await buildStoryDocsPayload({ entry }, { getDocgenPayload: undefined }); expect(payload?.name).toBe('Button'); expect(Object.values(payload!.stories)[0]).toEqual({ @@ -75,19 +77,47 @@ describe('buildStoryDocsPayload', () => { }); }); - it('drops the snippet without erroring when the analyzer throws', () => { + it('builds a snippet from the raw analyzer fields core/docgen carries alongside argTypes', async () => { givenStoryFile(` import { ButtonComponent } from './button.component'; export default { title: 'Example/Button', component: ButtonComponent }; export const Default = { args: { label: 'Save' } }; `); - const manager: AngularComponentMetaSource = { - extractComponentMeta: () => { - throw new Error('ts blew up'); - }, + const getDocgenPayload = async (): Promise => + ({ + id: 'example-button', + name: 'ButtonComponent', + path: STORY_PATH, + jsDocTags: {}, + angularComponentMeta: { + entry: { + name: 'ButtonComponent', + selector: 'sb-button', + inputsClass: [{ name: 'label' }], + outputsClass: [], + }, + enums: [], + }, + }) as AngularDocgenPayload; + + const payload = await buildStoryDocsPayload({ entry }, { getDocgenPayload }); + + const story = Object.values(payload!.stories)[0]; + expect(story.snippet).toContain('sb-button'); + expect(story.snippet).toContain(`[label]="'Save'"`); + }); + + it('drops the snippet without erroring when querying core/docgen throws', async () => { + givenStoryFile(` + import { ButtonComponent } from './button.component'; + export default { title: 'Example/Button', component: ButtonComponent }; + export const Default = { args: { label: 'Save' } }; + `); + const getDocgenPayload = async (): Promise => { + throw new Error('core/docgen query failed'); }; - const payload = buildStoryDocsPayload({ entry }, { manager }); + const payload = await buildStoryDocsPayload({ entry }, { getDocgenPayload }); const story = Object.values(payload!.stories)[0]; expect(story.snippet).toBeUndefined(); 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 572bae7e1849..b0563985f68c 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-build.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-build.ts @@ -12,8 +12,8 @@ import type { StoryDoc, StoryDocsPayload, StoryDocsProviderInput } from 'storybo import { resolve } from 'node:path'; import type { EnumType, Property } from '@storybook/angular-compodoc'; -import type { AngularClassMeta, AngularComponentMetaResult } from '@storybook/angular-cm'; -import type { AngularComponentMetaSource } from './build-docgen.ts'; +import type { AngularClassMeta } from '@storybook/angular-cm'; +import type { AngularComponentSnippetMeta, AngularDocgenPayload } from './build-docgen.ts'; import { parseStoryFile, resolveComponentOf } from './resolve-component.ts'; import { buildComponentOutletTemplate } from '../template-grammar.ts'; import { @@ -23,17 +23,18 @@ import { } from './story-docs-snippet.ts'; export interface BuildStoryDocsContext { - // `undefined` when the analyzer could not be created; descriptions still extract without it. - manager: AngularComponentMetaSource | undefined; + getDocgenPayload: + | ((componentId: string) => Promise) + | undefined; resolvePath?: (importPath: string) => string; } // Stories that declare their own `render` get no snippet: their template is a runtime value static // analysis cannot see, and a component-derived one would misrepresent it. -export const buildStoryDocsPayload = ( +export const buildStoryDocsPayload = async ( input: StoryDocsProviderInput, context: BuildStoryDocsContext -): StoryDocsPayload | undefined => { +): Promise => { const storyImportPath = getStoryImportPathFromEntry(input.entry); if (!storyImportPath) { return undefined; @@ -51,18 +52,16 @@ export const buildStoryDocsPayload = ( const resolution = resolveComponentOf(csf, storyPath); const component = 'reason' in resolution ? undefined : resolution.component; - let meta: AngularComponentMetaResult | undefined; - if (component?.path && context.manager) { + let snippetMeta: AngularComponentSnippetMeta | undefined; + if (component?.path && context.getDocgenPayload) { try { - meta = context.manager.extractComponentMeta(component.path, { - exportName: component.exportName, - localName: component.localName, - }); + const docgenPayload = await context.getDocgenPayload(getComponentIdFromEntry(input.entry)); + snippetMeta = docgenPayload?.angularComponentMeta; } catch { - meta = undefined; + snippetMeta = undefined; } } - const snippetContext = meta ? createSnippetContext(meta) : undefined; + const snippetContext = snippetMeta ? createSnippetContext(snippetMeta) : undefined; const displayName = component && (component.exportName === 'default' ? component.localName : component.exportName); @@ -103,7 +102,7 @@ export const buildStoryDocsPayload = ( return { id: getComponentIdFromEntry(input.entry), // The analyzer knows the class name even when the story file imported it as a default export. - name: meta?.entry.name ?? displayName ?? titleName, + name: snippetMeta?.entry.name ?? displayName ?? titleName, path: storyImportPath, stories, }; @@ -124,10 +123,10 @@ const inputsOf = (entry: AngularClassMeta): Property[] => const outputsOf = (entry: AngularClassMeta): Property[] => 'outputsClass' in entry ? (entry.outputsClass ?? []) : []; -const createSnippetContext = (meta: AngularComponentMetaResult): SnippetContext => { - const inputNames = new Set(inputsOf(meta.entry).map((input) => input.name)); +const createSnippetContext = ({ entry, enums }: AngularComponentSnippetMeta): SnippetContext => { + const inputNames = new Set(inputsOf(entry).map((input) => input.name)); const outputs: string[] = []; - for (const output of outputsOf(meta.entry)) { + for (const output of outputsOf(entry)) { // model() lands under the same bare name in both arrays; its output binds as `${name}Change`. const bindingName = inputNames.has(output.name) ? `${output.name}Change` : output.name; if (!outputs.includes(bindingName)) { @@ -135,11 +134,11 @@ const createSnippetContext = (meta: AngularComponentMetaResult): SnippetContext } } return { - selector: meta.entry.selector, - componentName: meta.entry.name, + selector: entry.selector, + componentName: entry.name, inputNames, outputs, - enums: meta.json.miscellaneous?.enumerations ?? [], + enums, }; }; 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 96ab0fa66ea9..34a0f6b5f6f6 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-preset.ts @@ -1,39 +1,41 @@ import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from 'storybook/internal/common'; +import { getService } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import type { StoryDocsProviderPreset } from 'storybook/internal/types'; -import { AngularComponentMetaManager } from '@storybook/angular-cm'; +import type { AngularDocgenPayload } from './build-docgen.ts'; import { buildStoryDocsPayload } from './story-docs-build.ts'; -const createManager = async (): Promise => { +// `core/docgen` is only registered when `experimentalDocgenServer` set up its worker (see +// `common-preset.ts`); both services are gated by the same feature, but registration order isn't +// a type-level guarantee, so this stays defensive rather than asserting the service exists. +let warnedMissingDocgenService = false; + +const getDocgenPayload = async (componentId: string): Promise => { try { - const typescript = await import('typescript'); - return new AngularComponentMetaManager(typescript.default ?? typescript); + const docgenService = getService('core/docgen', { internal: true }); + return await docgenService.queries.docgen.loaded({ id: componentId }); } catch (error) { - logger.warn( - `Angular story snippets are unavailable: the component meta analyzer could not be created. ${ - error instanceof Error ? error.message : String(error) - }` - ); + if (!warnedMissingDocgenService) { + warnedMissingDocgenService = true; + logger.warn( + `Angular story snippets are unavailable: querying core/docgen failed. ${ + error instanceof Error ? error.message : String(error) + }` + ); + } return undefined; } }; export const experimental_storyDocsProvider: StoryDocsProviderPreset = async (nextStoryDocs) => { - // Scoped to the composed chain rather than the module, so the manager has one owner and one - // lifetime. - let managerPromise: Promise | undefined; - return async (input) => { const storyImportPath = getStoryImportPathFromEntry(input.entry); if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { return nextStoryDocs(input); } - const manager = await (managerPromise ??= createManager()); - const ours = buildStoryDocsPayload(input, { manager }); - // The language service holds large type caches; check heap pressure after each extraction. - manager?.recycleIfHeapPressured(); + const ours = await buildStoryDocsPayload(input, { getDocgenPayload }); if (!ours) { return nextStoryDocs(input); diff --git a/code/lib/docgen-harness/src/angular/angular-story-docs-snippets.test.ts b/code/lib/docgen-harness/src/angular/angular-story-docs-snippets.test.ts index bcb22fa27c36..dde34768f534 100644 --- a/code/lib/docgen-harness/src/angular/angular-story-docs-snippets.test.ts +++ b/code/lib/docgen-harness/src/angular/angular-story-docs-snippets.test.ts @@ -12,7 +12,12 @@ import { loadCsf } from 'storybook/internal/csf-tools'; import type { IndexEntry } from 'storybook/internal/types'; import { AngularComponentMetaManager } from '@storybook/angular-cm'; +import type { AngularDocgenPayload } from '../../../../frameworks/angular-vite/src/docgen/build-docgen.ts'; import { buildStoryDocsPayload } from '../../../../frameworks/angular-vite/src/docgen/story-docs-build.ts'; +import { + parseStoryFile, + resolveComponentOf, +} from '../../../../frameworks/angular-vite/src/docgen/resolve-component.ts'; import { expectNoStaleSnippets, fixtureCases, @@ -24,6 +29,41 @@ import { // component file resolves to its own per-fixture project. const manager = new AngularComponentMetaManager(ts); +// In production `getDocgenPayload` queries the already-registered `core/docgen` service, which is +// keyed by component id and resolves the story's component internally. There is no service here, +// so this stands in for it: resolve the same component `buildStoryDocsPayload` would (the fixture +// carries exactly one), then extract straight off the manager, matching what `buildDocgenPayload` +// stores on `AngularDocgenPayload` in production. +const buildGetDocgenPayload = + (storyPath: string, title: string) => async (): Promise => { + const parsed = parseStoryFile(storyPath, title); + if (!parsed) { + return undefined; + } + const resolution = resolveComponentOf(parsed.csf, storyPath); + const component = 'reason' in resolution ? undefined : resolution.component; + if (!component?.path) { + return undefined; + } + const meta = manager.extractComponentMeta(component.path, { + exportName: component.exportName, + localName: component.localName, + }); + if (!meta) { + return undefined; + } + return { + id: 'fixture', + name: meta.entry.name, + path: storyPath, + jsDocTags: {}, + angularComponentMeta: { + entry: meta.entry, + enums: meta.json.miscellaneous?.enumerations ?? [], + }, + }; + }; + afterAll(() => { manager.dispose(); }); @@ -47,7 +87,10 @@ describe('angular story-docs server snippets', () => { subtype: 'story', importPath: storyPath, }; - const payload = buildStoryDocsPayload({ entry }, { manager, resolvePath: (path) => path }); + const payload = await buildStoryDocsPayload( + { entry }, + { getDocgenPayload: buildGetDocgenPayload(storyPath, title), resolvePath: (path) => path } + ); expect(payload).toBeDefined(); for (const [exportName, story] of storyExports) { diff --git a/code/lib/docgen-harness/src/angular/story-docs/build-story-docs.test.ts b/code/lib/docgen-harness/src/angular/story-docs/build-story-docs.test.ts index cc29f60aada8..08cd6643a54b 100644 --- a/code/lib/docgen-harness/src/angular/story-docs/build-story-docs.test.ts +++ b/code/lib/docgen-harness/src/angular/story-docs/build-story-docs.test.ts @@ -8,6 +8,11 @@ import ts from 'typescript'; import type { IndexEntry } from 'storybook/internal/types'; import { AngularComponentMetaManager } from '@storybook/angular-cm'; +import type { AngularDocgenPayload } from '../../../../../frameworks/angular-vite/src/docgen/build-docgen.ts'; +import { + parseStoryFile, + resolveComponentOf, +} from '../../../../../frameworks/angular-vite/src/docgen/resolve-component.ts'; import { buildStoryDocsPayload } from '../../../../../frameworks/angular-vite/src/docgen/story-docs-build.ts'; import { listFixtureCases } from '../snippet-recorder.ts'; @@ -16,6 +21,38 @@ const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), '__testfixtur // One manager for the whole suite; the fixtures share a single tsconfig.json at the tree root. const manager = new AngularComponentMetaManager(ts); +// Stands in for the `core/docgen` service query `getDocgenPayload` hits in production: resolve the +// same component `buildStoryDocsPayload` would, then extract straight off the manager. +const buildGetDocgenPayload = + (storyPath: string, title: string) => async (): Promise => { + const parsed = parseStoryFile(storyPath, title); + if (!parsed) { + return undefined; + } + const resolution = resolveComponentOf(parsed.csf, storyPath); + const component = 'reason' in resolution ? undefined : resolution.component; + if (!component?.path) { + return undefined; + } + const meta = manager.extractComponentMeta(component.path, { + exportName: component.exportName, + localName: component.localName, + }); + if (!meta) { + return undefined; + } + return { + id: 'fixture', + name: meta.entry.name, + path: storyPath, + jsDocTags: {}, + angularComponentMeta: { + entry: meta.entry, + enums: meta.json.miscellaneous?.enumerations ?? [], + }, + }; + }; + afterAll(() => { manager.dispose(); }); @@ -32,9 +69,11 @@ const makeEntry = (storyPath: string, title: string): IndexEntry => ({ describe('angular story-docs payload baselines', () => { it.each(listFixtureCases(FIXTURES_DIR))('%s', async (fixtureCase) => { const testDir = join(FIXTURES_DIR, fixtureCase); - const payload = buildStoryDocsPayload( - { entry: makeEntry(join(testDir, 'input.stories.ts'), `StoryDocs/${fixtureCase}`) }, - { manager, resolvePath: (path) => path } + const storyPath = join(testDir, 'input.stories.ts'); + const title = `StoryDocs/${fixtureCase}`; + const payload = await buildStoryDocsPayload( + { entry: makeEntry(storyPath, title) }, + { getDocgenPayload: buildGetDocgenPayload(storyPath, title), resolvePath: (path) => path } ); await expect(payload && { ...payload, path: '__PATH__' }).toMatchFileSnapshot(