diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index e2d4085b3831..dcce47e5631f 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -42,6 +42,7 @@ export * from './utils/formatter.ts'; export * from './utils/get-story-id.ts'; export * from './utils/component-id.ts'; export * from './utils/select-component-entry.ts'; +export * from './utils/lazy-docgen-middleware.ts'; export * from './utils/posix.ts'; export * from './utils/sync-main-preview-addons.ts'; export * from './utils/setup-addon-in-config.ts'; diff --git a/code/core/src/common/utils/lazy-docgen-middleware.ts b/code/core/src/common/utils/lazy-docgen-middleware.ts new file mode 100644 index 000000000000..5736bd64c13c --- /dev/null +++ b/code/core/src/common/utils/lazy-docgen-middleware.ts @@ -0,0 +1,50 @@ +import type { + DocgenMiddleware, + DocgenPayload, + DocgenProvider, + DocgenProviderInput, +} from '../../shared/open-service/services/docgen/types.ts'; +import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from './select-component-entry.ts'; + +export interface LazyDocgenMiddlewareOptions { + /** + * Builds the renderer's extraction manager. + * Called once, lazily, on the first eligible request and memoized for the worker's lifetime. + * Return `undefined` to permanently pass through to the rest of the chain. + */ + createManager: () => Promise; + /** + * Extracts one payload + * Returns `undefined` to delegate the request downstream + */ + extract: (manager: TManager, input: DocgenProviderInput) => Promise; +} + +export function createLazyDocgenMiddleware({ + createManager, + extract, +}: LazyDocgenMiddlewareOptions): DocgenMiddleware { + let managerPromise: Promise | undefined; + const getManager = () => (managerPromise ??= createManager()); + + return (nextDocgen: DocgenProvider): DocgenProvider => + async (input) => { + const storyImportPath = getStoryImportPathFromEntry(input.entry); + if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { + return nextDocgen(input); + } + + const manager = await getManager(); + if (!manager) { + return nextDocgen(input); + } + + const ours = await extract(manager, input); + if (!ours) { + return nextDocgen(input); + } + + const downstream = await nextDocgen(input); + return { ...downstream, ...ours }; + }; +} diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index a3116e4162f8..e504261ca108 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -459,7 +459,11 @@ export interface ComponentsManifest { v: number; components: Record; meta?: { - docgen: 'react-docgen' | 'react-docgen-typescript' | 'react-component-meta'; + docgen: + | 'react-docgen' + | 'react-docgen-typescript' + | 'react-component-meta' + | 'vue-component-meta'; durationMs: number; }; } diff --git a/code/frameworks/vue3-vite/package.json b/code/frameworks/vue3-vite/package.json index 8962d47628fe..74d6ab676855 100644 --- a/code/frameworks/vue3-vite/package.json +++ b/code/frameworks/vue3-vite/package.json @@ -52,7 +52,6 @@ "@storybook/builder-vite": "workspace:*", "@storybook/vue3": "workspace:*", "magic-string": "^1.1.0", - "typescript": "^5.9.3", "vue-component-meta": "^3.3.9", "vue-docgen-api": "^4.75.1" }, diff --git a/code/frameworks/vue3-vite/src/docgen/errors.ts b/code/frameworks/vue3-vite/src/docgen/errors.ts new file mode 100644 index 000000000000..4330e727946e --- /dev/null +++ b/code/frameworks/vue3-vite/src/docgen/errors.ts @@ -0,0 +1,14 @@ +import { Category, StorybookError } from 'storybook/internal/server-errors'; + +export class Vue3ViteDocgenManifestError extends StorybookError { + constructor() { + super({ + name: 'Vue3ViteDocgenManifestError', + category: Category.FRAMEWORK_VUE3_VITE, + code: 1, + message: + "The Vue docgen manifest currently requires `docgen: 'vue-component-meta'` in `framework.options`.\n" + + 'Update the Vue framework configuration or disable `features.componentsManifest`.', + }); + } +} diff --git a/code/frameworks/vue3-vite/src/docgen/options.ts b/code/frameworks/vue3-vite/src/docgen/options.ts index 427b6aef55a1..22df6b0b7960 100644 --- a/code/frameworks/vue3-vite/src/docgen/options.ts +++ b/code/frameworks/vue3-vite/src/docgen/options.ts @@ -1,7 +1,37 @@ +import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; + import type { FrameworkOptions, VueDocgenPlugin } from '../types.ts'; +export const VUE_COMPONENT_META = 'vue-component-meta' satisfies VueDocgenPlugin; + export type ResolvedDocgenOptions = false | { plugin: VueDocgenPlugin; tsconfig?: string }; +export interface DocgenContext { + docgen: ResolvedDocgenOptions; + features: StorybookConfigRaw['features']; + /** + * Only true if the docgen server is active and the `vue-component-meta` plugin is selected. + */ + docgenServerActive: boolean; +} + +export async function resolveDocgenContext(options: Options): Promise { + const [frameworkOptions, features] = await Promise.all([ + options.presets.apply('frameworkOptions'), + options.presets.apply('features', {}), + ]); + const docgen = resolveDocgenOptions(frameworkOptions?.docgen); + + return { + docgen, + features, + docgenServerActive: + features?.experimentalDocgenServer === true && + docgen !== false && + docgen.plugin === VUE_COMPONENT_META, + }; +} + export function resolveDocgenOptions(docgen?: FrameworkOptions['docgen']): ResolvedDocgenOptions { if (docgen === false) { return false; diff --git a/code/frameworks/vue3-vite/src/docgen/preset.test.ts b/code/frameworks/vue3-vite/src/docgen/preset.test.ts new file mode 100644 index 000000000000..3b278afe7767 --- /dev/null +++ b/code/frameworks/vue3-vite/src/docgen/preset.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import type { DocgenProviderDescriptor, Options } from 'storybook/internal/types'; + +import type { FrameworkOptions } from '../types.ts'; +import { experimental_docgenProvider, experimental_manifests } from './preset.ts'; + +const optionsWith = (docgen?: FrameworkOptions['docgen'], features: Record = {}) => + ({ + presets: { + apply: async (key: string) => { + if (key === 'frameworkOptions') { + return { docgen }; + } + return key === 'features' ? features : {}; + }, + }, + }) as unknown as Options; + +const existing: DocgenProviderDescriptor[] = [{ moduleSpecifier: '/addon/docgen-worker.js' }]; +const docgenServerOn = { experimentalDocgenServer: true }; +const componentsManifestOn = { ...docgenServerOn, componentsManifest: true }; + +describe('experimental_docgenProvider', () => { + it('appends a descriptor pointing at the renderer worker module', async () => { + const descriptors = await experimental_docgenProvider( + existing, + optionsWith('vue-component-meta', docgenServerOn) + ); + + expect(descriptors).toHaveLength(2); + // Appended, so addon providers stack on top of ours rather than replacing it. + expect(descriptors[0]).toBe(existing[0]); + expect(descriptors[1].moduleSpecifier).toMatch(/docgen-worker\.js$/); + }); + + // The worker extracts with vue-component-meta only. Registering for vue-docgen-api would + // silently swap the engine the project asked for; `docgen: false` opted out of extraction + // altogether and must stay opted out. + it.each(['vue-docgen-api' as const, undefined, false as const])( + 'registers nothing for docgen: %s', + async (docgen) => { + await expect( + experimental_docgenProvider(existing, optionsWith(docgen, docgenServerOn)) + ).resolves.toEqual(existing); + } + ); +}); + +describe('experimental_manifests', () => { + const manifests = (docgen?: FrameworkOptions['docgen'], features?: Record) => + experimental_manifests({}, optionsWith(docgen, features) as never); + + // Core asserts `components.meta.docgen` is present whenever the feature is on, so omitting it + // fails a Vue `storybook build` outright rather than degrading the debugger. + it('declares the engine so core can label the components debugger', async () => { + await expect(manifests('vue-component-meta', componentsManifestOn)).resolves.toEqual({ + components: { v: 0, components: {}, meta: { docgen: 'vue-component-meta', durationMs: 0 } }, + }); + }); + + it.each(['vue-docgen-api' as const, undefined, false as const])( + 'rejects the components manifest for docgen: %s', + async (docgen) => { + await expect(manifests(docgen, componentsManifestOn)).rejects.toThrow( + "The Vue docgen manifest currently requires `docgen: 'vue-component-meta'` in `framework.options`." + ); + } + ); + + // The manifest is populated from docgen-service payloads. When another engine (or none) runs, + // there are no payloads, so claiming an engine here would report "0 components" against it. + it.each(['vue-docgen-api' as const, undefined, false as const])( + 'contributes nothing for docgen: %s', + async (docgen) => { + await expect(manifests(docgen, docgenServerOn)).resolves.toEqual({}); + } + ); + + // Vue has no legacy component manifest, so with the feature off there is nothing to contribute. + it('contributes nothing when the docgen service is off', async () => { + await expect(manifests('vue-component-meta')).resolves.toEqual({}); + }); +}); diff --git a/code/frameworks/vue3-vite/src/docgen/preset.ts b/code/frameworks/vue3-vite/src/docgen/preset.ts new file mode 100644 index 000000000000..b0f45bf09567 --- /dev/null +++ b/code/frameworks/vue3-vite/src/docgen/preset.ts @@ -0,0 +1,64 @@ +import { fileURLToPath } from 'node:url'; + +import type { + DocgenProviderDescriptor, + IndexEntry, + Options, + PresetPropertyFn, + StorybookConfigRaw, +} from 'storybook/internal/types'; + +import { Vue3ViteDocgenManifestError } from './errors.ts'; +import { VUE_COMPONENT_META, resolveDocgenContext } from './options.ts'; + +/** + * Vue docgen provider. + * + * Contributes a {@link DocgenProviderDescriptor} pointing at `@storybook/vue3/internal/docgen-worker` + */ +export const experimental_docgenProvider = async ( + existing: DocgenProviderDescriptor[] = [], + options: Options +): Promise => { + const { docgenServerActive } = await resolveDocgenContext(options); + + if (!docgenServerActive) { + return existing; + } + + return [ + ...existing, + { + moduleSpecifier: fileURLToPath(import.meta.resolve('@storybook/vue3/internal/docgen-worker')), + }, + ]; +}; + +export const experimental_manifests: PresetPropertyFn< + 'experimental_manifests', + StorybookConfigRaw, + { manifestEntries: IndexEntry[]; watch: boolean } +> = async (existingManifests = {}, options) => { + const { features, docgenServerActive } = await resolveDocgenContext(options); + + if ( + features?.experimentalDocgenServer === true && + features.componentsManifest === true && + !docgenServerActive + ) { + throw new Vue3ViteDocgenManifestError(); + } + + if (!docgenServerActive) { + return existingManifests; + } + + return { + ...existingManifests, + components: { + v: 0, + components: {}, + meta: { docgen: VUE_COMPONENT_META, durationMs: 0 }, + }, + }; +}; diff --git a/code/frameworks/vue3-vite/src/preset.test.ts b/code/frameworks/vue3-vite/src/preset.test.ts new file mode 100644 index 000000000000..a9fcf9b9f2db --- /dev/null +++ b/code/frameworks/vue3-vite/src/preset.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Options } from 'storybook/internal/types'; + +import { vueComponentMeta } from './plugins/vue-component-meta.ts'; +import { vueDocgen } from './plugins/vue-docgen.ts'; +import { templateCompilation } from './plugins/vue-template.ts'; +import type { FrameworkOptions } from './types.ts'; + +// The real plugin factories build a vue-component-meta checker / vue-docgen-api parser, which is +// far too heavy for a preset test. Identify them by name instead. +vi.mock('./plugins/vue-template.ts', { spy: true }); +vi.mock('./plugins/vue-component-meta.ts', { spy: true }); +vi.mock('./plugins/vue-docgen.ts', { spy: true }); + +beforeEach(() => { + vi.mocked(templateCompilation).mockResolvedValue({ name: 'template' }); + vi.mocked(vueComponentMeta).mockResolvedValue({ name: 'vue-component-meta' }); + vi.mocked(vueDocgen).mockResolvedValue({ name: 'vue-docgen-api' }); +}); + +const optionsWith = (docgen: FrameworkOptions['docgen'], features: Record = {}) => + ({ + presets: { + apply: async (key: string) => { + if (key === 'frameworkOptions') { + return { docgen }; + } + return key === 'features' ? features : {}; + }, + }, + }) as unknown as Options; + +const pluginNames = async ( + docgen: FrameworkOptions['docgen'], + features?: Record +) => { + const { viteFinal } = await import('./preset.ts'); + const config = await viteFinal!({}, optionsWith(docgen, features)); + return (config.plugins ?? []).map((plugin) => (plugin as { name: string }).name); +}; + +describe('viteFinal', () => { + it.each([ + ['vue-component-meta' as const, 'vue-component-meta'], + [undefined, 'vue-docgen-api'], + ])('adds the %s docgen plugin when the docgen service is off', async (docgen, expected) => { + expect(await pluginNames(docgen)).toEqual(['template', expected]); + }); + + // The service extracts the same metadata, so leaving the plugin on would compile every component + // twice and put a `__docgenInfo` in the preview bundle that nothing reads. + it('omits the vue-component-meta plugin when the docgen service is on', async () => { + expect(await pluginNames('vue-component-meta', { experimentalDocgenServer: true })).toEqual([ + 'template', + ]); + }); + + // vue-docgen-api has no worker-side extractor, so the feature flag must not strip its plugin — + // that would leave these projects with no docgen at all. + it.each(['vue-docgen-api' as const, undefined])( + 'keeps the docgen plugin for docgen: %s even when the docgen service is on', + async (docgen) => { + expect(await pluginNames(docgen, { experimentalDocgenServer: true })).toEqual([ + 'template', + 'vue-docgen-api', + ]); + } + ); + + it('keeps template compilation when docgen is disabled', async () => { + expect(await pluginNames(false)).toEqual(['template']); + }); +}); diff --git a/code/frameworks/vue3-vite/src/preset.ts b/code/frameworks/vue3-vite/src/preset.ts index bfd1b1ac4011..f265fe00bc4b 100644 --- a/code/frameworks/vue3-vite/src/preset.ts +++ b/code/frameworks/vue3-vite/src/preset.ts @@ -2,11 +2,13 @@ import type { PresetProperty } from 'storybook/internal/types'; import type { Plugin } from 'vite'; -import { resolveDocgenOptions } from './docgen/options.ts'; +import { VUE_COMPONENT_META, resolveDocgenContext } from './docgen/options.ts'; import { type VueDocgenEngine, vueComponentMeta } from './plugins/vue-component-meta.ts'; import { vueDocgen } from './plugins/vue-docgen.ts'; import { templateCompilation } from './plugins/vue-template.ts'; -import type { FrameworkOptions, StorybookConfig } from './types.ts'; +import type { StorybookConfig } from './types.ts'; + +export { experimental_docgenProvider, experimental_manifests } from './docgen/preset.ts'; export const core: PresetProperty<'core'> = { builder: import.meta.resolve('@storybook/builder-vite'), @@ -16,15 +18,11 @@ export const core: PresetProperty<'core'> = { export const viteFinal: StorybookConfig['viteFinal'] = async (config, options) => { const plugins: Plugin[] = [await templateCompilation()]; - const framework = await options.presets.apply('framework'); - const frameworkOptions: FrameworkOptions = - typeof framework === 'string' ? {} : (framework.options ?? {}); - - const docgen = resolveDocgenOptions(frameworkOptions.docgen); + const { docgen, docgenServerActive } = await resolveDocgenContext(options); // add docgen plugin depending on framework option - if (docgen !== false) { - if (docgen.plugin === 'vue-component-meta') { + if (docgen !== false && !docgenServerActive) { + if (docgen.plugin === VUE_COMPONENT_META) { const engine: VueDocgenEngine = await options.presets.apply('experimental_vueDocgenEngine'); plugins.push(await vueComponentMeta(engine, docgen.tsconfig)); } else { diff --git a/code/renderers/react/src/docgen/docgen-worker.ts b/code/renderers/react/src/docgen/docgen-worker.ts index 2e793f2af380..7bcafd5f9032 100644 --- a/code/renderers/react/src/docgen/docgen-worker.ts +++ b/code/renderers/react/src/docgen/docgen-worker.ts @@ -13,8 +13,8 @@ * docgen-relevant TS option (`reactDocgenTypescriptOptions`, including `propFilter`) applies solely * to the `react-docgen-typescript` engine used by the legacy manifest path — not here. */ -import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from 'storybook/internal/common'; -import type { DocgenMiddleware, DocgenProvider } from 'storybook/internal/types'; +import { createLazyDocgenMiddleware } from 'storybook/internal/common'; +import type { DocgenMiddleware } from 'storybook/internal/types'; import { ComponentMetaManager } from '../componentManifest/componentMeta/ComponentMetaManager.ts'; import { buildDocgenPayload } from './buildDocgen.ts'; @@ -25,41 +25,15 @@ import { buildDocgenPayload } from './buildDocgen.ts'; * created lazily on the first eligible request and memoized; when TypeScript is unavailable the * middleware passes through to the rest of the chain. */ -export const createDocgenProvider = (): DocgenMiddleware => { - let managerPromise: Promise | undefined; - - const getManager = (): Promise => { - if (!managerPromise) { - managerPromise = (async () => { - try { - const ts = await import('typescript'); - return new ComponentMetaManager(ts); - } catch { - return undefined; - } - })(); - } - return managerPromise; - }; - - return (nextDocgen: DocgenProvider): DocgenProvider => - async (input) => { - const storyImportPath = getStoryImportPathFromEntry(input.entry); - if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) { - return nextDocgen(input); - } - - const componentMetaManager = await getManager(); - if (!componentMetaManager) { - return nextDocgen(input); +export const createDocgenProvider = (): DocgenMiddleware => + createLazyDocgenMiddleware({ + createManager: async () => { + try { + const ts = await import('typescript'); + return new ComponentMetaManager(ts); + } catch { + return undefined; } - - const ours = await buildDocgenPayload(input, { componentMetaManager }); - if (!ours) { - return nextDocgen(input); - } - - const downstream = await nextDocgen(input); - return { ...downstream, ...ours }; - }; -}; + }, + extract: (componentMetaManager, input) => buildDocgenPayload(input, { componentMetaManager }), + }); diff --git a/code/renderers/vue3/build-config.ts b/code/renderers/vue3/build-config.ts index ffb5cc351c5b..87e3b70d2a2c 100644 --- a/code/renderers/vue3/build-config.ts +++ b/code/renderers/vue3/build-config.ts @@ -27,6 +27,11 @@ const config: BuildEntries = { exportEntries: ['./preset'], entryPoint: './src/preset.ts', }, + { + exportEntries: ['./internal/docgen-worker'], + entryPoint: './src/docgen/docgen-worker.ts', + dts: false, + }, ], }, }; diff --git a/code/renderers/vue3/package.json b/code/renderers/vue3/package.json index 633874193643..b8d9a81da8dd 100644 --- a/code/renderers/vue3/package.json +++ b/code/renderers/vue3/package.json @@ -37,6 +37,7 @@ "code": "./src/playwright.ts", "default": "./dist/playwright.js" }, + "./internal/docgen-worker": "./dist/docgen/docgen-worker.js", "./package.json": "./package.json", "./preset": { "types": "./dist/preset.d.ts", @@ -54,6 +55,7 @@ ], "dependencies": { "@storybook/global": "^5.0.0", + "@vue/language-core": "^3.2.7", "type-fest": "^5.6.0", "vue-component-meta": "^3.2.7", "vue-component-type-helpers": "^3.2.9", @@ -68,8 +70,14 @@ }, "peerDependencies": { "storybook": "workspace:^", + "typescript": ">= 4.9.x", "vue": "^3.0.0" }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "publishConfig": { "access": "public" }, diff --git a/code/renderers/vue3/src/docgen/__testfixtures__/references/src/RefButton.stories.ts b/code/renderers/vue3/src/docgen/__testfixtures__/references/src/RefButton.stories.ts new file mode 100644 index 000000000000..38420f660bff --- /dev/null +++ b/code/renderers/vue3/src/docgen/__testfixtures__/references/src/RefButton.stories.ts @@ -0,0 +1,9 @@ +// Fixture read from disk by the project-manager tests, never imported. +import RefButton from './RefButton.vue'; + +export default { + title: 'Example/RefButton', + component: RefButton, +}; + +export const Default = { args: { label: 'Hello' } }; diff --git a/code/renderers/vue3/src/docgen/__testfixtures__/references/src/RefButton.vue b/code/renderers/vue3/src/docgen/__testfixtures__/references/src/RefButton.vue new file mode 100644 index 000000000000..d45f5c6d4ed8 --- /dev/null +++ b/code/renderers/vue3/src/docgen/__testfixtures__/references/src/RefButton.vue @@ -0,0 +1,11 @@ + + + diff --git a/code/renderers/vue3/src/docgen/__testfixtures__/references/tsconfig.app.json b/code/renderers/vue3/src/docgen/__testfixtures__/references/tsconfig.app.json new file mode 100644 index 000000000000..860e5704af8a --- /dev/null +++ b/code/renderers/vue3/src/docgen/__testfixtures__/references/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/code/renderers/vue3/src/docgen/__testfixtures__/references/tsconfig.json b/code/renderers/vue3/src/docgen/__testfixtures__/references/tsconfig.json new file mode 100644 index 000000000000..9a591f0cd376 --- /dev/null +++ b/code/renderers/vue3/src/docgen/__testfixtures__/references/tsconfig.json @@ -0,0 +1,5 @@ +{ + "//": "Stock create-vue shape: a root tsconfig that is nothing but references. Read only by the project-manager tests; excluded from the package program.", + "files": [], + "references": [{ "path": "./tsconfig.app.json" }] +} diff --git a/code/renderers/vue3/src/docgen/build-docgen.ts b/code/renderers/vue3/src/docgen/build-docgen.ts index 116cf1348886..9bbc8ed3dd10 100644 --- a/code/renderers/vue3/src/docgen/build-docgen.ts +++ b/code/renderers/vue3/src/docgen/build-docgen.ts @@ -20,6 +20,7 @@ type VueDocgenPayload = DocgenPayload & { vueComponentMeta?: MetaSource }; export interface BuildDocgenContext { getChecker: (componentFilePath: string) => ComponentMetaChecker; + resolvePath?: (importPath: string) => string; } const UNRESOLVED_COMPONENT_ERRORS: Record< @@ -56,7 +57,9 @@ export async function buildDocgenPayload( } // Import paths in the index are relative to the project root the server runs from. - const storyPath = join(process.cwd(), storyFilePath); + const resolvePath = + context.resolvePath ?? ((importPath: string) => join(process.cwd(), importPath)); + const storyPath = resolvePath(storyFilePath); let storyFile: string; try { diff --git a/code/renderers/vue3/src/docgen/component-meta.ts b/code/renderers/vue3/src/docgen/component-meta.ts index 603150b8d8bf..bf19748530b9 100644 --- a/code/renderers/vue3/src/docgen/component-meta.ts +++ b/code/renderers/vue3/src/docgen/component-meta.ts @@ -41,6 +41,14 @@ function toSerializableMeta(obj: T): Serializable { return JSON.parse(JSON.stringify(obj)) as Serializable; } +/** Checker options shared by every path so legacy and server extraction produce identical meta. */ +export const CHECKER_OPTIONS: MetaCheckerOptions = { + forceUseTs: true, + noDeclarations: true, + printer: { newLine: 1 }, + schema: true, +}; + /** * Creates the `vue-component-meta` checker to use for extracting component meta/docs. Considers the * given tsconfig file (will use a fallback checker if it does not exist or is not supported). @@ -48,18 +56,11 @@ function toSerializableMeta(obj: T): Serializable { export async function createVueComponentMetaChecker( tsconfigPath = 'tsconfig.json' ): Promise { - const checkerOptions: MetaCheckerOptions = { - forceUseTs: true, - noDeclarations: true, - printer: { newLine: 1 }, - schema: true, - }; - const projectRoot = getProjectRoot(); const projectTsConfigPath = join(projectRoot, tsconfigPath); - const defaultChecker = createCheckerByJson(projectRoot, { include: ['**/*'] }, checkerOptions); + const defaultChecker = createCheckerByJson(projectRoot, { include: ['**/*'] }, CHECKER_OPTIONS); // prefer the tsconfig.json file of the project to support alias resolution etc. if (await fileExists(projectTsConfigPath)) { @@ -71,7 +72,7 @@ export async function createVueComponentMetaChecker( if (references.length > 0) { return defaultChecker; } - return createChecker(projectTsConfigPath, checkerOptions); + return createChecker(projectTsConfigPath, CHECKER_OPTIONS); } return defaultChecker; diff --git a/code/renderers/vue3/src/docgen/docgen-worker.ts b/code/renderers/vue3/src/docgen/docgen-worker.ts new file mode 100644 index 000000000000..6a662efb1e14 --- /dev/null +++ b/code/renderers/vue3/src/docgen/docgen-worker.ts @@ -0,0 +1,52 @@ +/** + * Worker-target docgen module for the Vue 3 renderer. + * + * Core's docgen worker imports this module and calls {@link createDocgenProvider} once to build the + * middleware it folds into the provider chain. Everything here runs inside that worker thread, so + * the synchronous `vue-component-meta` (Volar) type checking stays off the main event loop and + * cannot starve the dev server. + * + * The framework contributes the descriptor that points here; nothing in this module is bundler + * specific, so it sits with the rest of the Vue docgen code in the renderer. + */ +import { createLazyDocgenMiddleware } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; +import type { DocgenMiddleware } from 'storybook/internal/types'; + +import { buildDocgenPayload } from './build-docgen.ts'; +import { VueComponentMetaManager } from './vue-project-manager.ts'; + +/** + * Builds the Vue docgen middleware. Owns one {@link VueComponentMetaManager} for the worker's + * lifetime: one `vue-component-meta` checker per matched tsconfig, kept warm across components and + * kept fresh by the manager's file watching plus a targeted mtime check at extraction time. The + * manager is created lazily on the first eligible request and memoized; when it cannot be created + * the middleware passes through to the rest of the chain. + */ +export const createDocgenProvider = (): DocgenMiddleware => + createLazyDocgenMiddleware({ + createManager: async () => { + try { + const typescript = await import('typescript'); + const manager = new VueComponentMetaManager(typescript.default ?? typescript); + manager.startWatching(); + return manager; + } catch (error) { + logger.warn( + `Vue docgen is unavailable: the vue-component-meta project manager could not be created. ${ + error instanceof Error ? error.message : String(error) + }` + ); + return undefined; + } + }, + extract: async (manager, input) => { + const ours = await buildDocgenPayload(input, { + getChecker: (componentPath) => manager.getCheckerForFile(componentPath), + }); + // Volar programs hold large type caches; check heap pressure after each extraction since Vue + // has no batch surface to hang this on. + manager.recycleIfHeapPressured(); + return ours; + }, + }); diff --git a/code/renderers/vue3/src/docgen/vue-project-manager.test.ts b/code/renderers/vue3/src/docgen/vue-project-manager.test.ts new file mode 100644 index 000000000000..bc99f430cf56 --- /dev/null +++ b/code/renderers/vue3/src/docgen/vue-project-manager.test.ts @@ -0,0 +1,141 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, describe, expect, it, vi } from 'vitest'; + +import type { IndexEntry } from 'storybook/internal/types'; + +import ts from 'typescript'; +import type { ComponentMetaChecker } from 'vue-component-meta'; + +import { buildDocgenPayload } from './build-docgen.ts'; +import { VueComponentMetaManager, VueComponentMetaProject } from './vue-project-manager.ts'; + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '__testfixtures__'); +const referencesDir = join(fixturesDir, 'references'); + +// One manager across the suite — checker construction is the expensive part, and sharing it is +// exactly the production shape (one manager per worker lifetime). +const manager = new VueComponentMetaManager(ts); +afterAll(() => manager.dispose()); + +describe('VueComponentMetaManager', () => { + // The stock `create-vue` scaffold: the root tsconfig is nothing but `references`. The previous + // single-checker path bailed to a whole-project `include: ['**/*']` fallback here (upstream: + // vuejs/language-tools#3896); the manager walks the reference chain instead and lands on the + // sub-config whose `include` actually covers the component. + it('resolves a component through a references-only root tsconfig to its sub-project', () => { + const componentPath = join(referencesDir, 'src/RefButton.vue').replace(/\\/g, '/'); + const project = manager.getProjectForFile(componentPath); + + expect(project.configFileName).toBe( + join(referencesDir, 'tsconfig.app.json').replace(/\\/g, '/') + ); + // Pin the *direct-include* mechanism: the SFC must appear in the parsed fileNames, so matching + // is by config contents — not by the slower indirect fallback that probes each candidate + // project's built program. + expect(project.getCommandLine().fileNames).toContain(componentPath); + }); + + it('extracts docgen end to end through the manager-resolved checker', async () => { + const entry = { + type: 'story', + subtype: 'story', + id: 'example-refbutton--default', + name: 'Default', + title: 'Example/RefButton', + importPath: './src/RefButton.stories.ts', + } as unknown as IndexEntry; + + const payload = await buildDocgenPayload( + { entry }, + { + getChecker: (componentPath) => manager.getCheckerForFile(componentPath), + resolvePath: (importPath) => join(referencesDir, importPath), + } + ); + + expect(payload?.error).toBeUndefined(); + expect(payload?.argTypes?.label).toMatchObject({ + description: 'Text shown inside the button.', + type: { name: 'string', required: true }, + }); + // The union survives as a real enum — proof the checker compiled the SFC under a scoped + // config rather than reporting nothing. + expect(payload?.argTypes?.tone?.type).toEqual({ + name: 'enum', + value: ['calm', 'loud'], + required: false, + }); + // And the payload still satisfies the worker transport. + expect(() => structuredClone(payload)).not.toThrow(); + }); + + // `__testfixtures__/tsconfig.json` has no `include`, so it covers everything beneath it — but + // only if the parse lists `.vue` files in `fileNames`. A plain TS parse never would (that is why + // the factory re-enumerates with the Vue extensions as `extraFileExtensions`), and the SFC would + // only match through the indirect built-program fallback. + it('matches .vue files to a tsconfig via the Vue-aware command-line parse', () => { + const componentPath = join(fixturesDir, 'Button.vue').replace(/\\/g, '/'); + const project = manager.getProjectForFile(componentPath); + + expect(project.configFileName).toBe(join(fixturesDir, 'tsconfig.json').replace(/\\/g, '/')); + expect(project.getCommandLine().fileNames).toContain(componentPath); + }); + + it('serves files no tsconfig covers from the inferred project', async () => { + const { mkdtemp, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const dir = await mkdtemp(join(tmpdir(), 'sb-vue-inferred-')); + await writeFile( + join(dir, 'Loose.vue'), + '' + ); + + const project = manager.getProjectForFile(join(dir, 'Loose.vue')); + + expect(project.configFileName).toBeUndefined(); + }); + + it('defers and batches created files until the project is next queried', () => { + const buttonPath = join(fixturesDir, 'Button.vue').replace(/\\/g, '/'); + const refButtonPath = join(referencesDir, 'src/RefButton.vue').replace(/\\/g, '/'); + const excludedPath = join(fixturesDir, 'excluded.vue').replace(/\\/g, '/'); + const checker = { + updateFile: vi.fn(), + clearCache: vi.fn(), + } as unknown as ComponentMetaChecker; + const refreshedCommandLine = { + options: {}, + fileNames: [buttonPath, refButtonPath], + errors: [], + } satisfies ts.ParsedCommandLine; + const getCommandLine = vi.fn(() => refreshedCommandLine); + const project = new VueComponentMetaProject( + checker, + { options: {}, fileNames: [], errors: [] }, + join(fixturesDir, 'tsconfig.json'), + getCommandLine + ); + + project.onFilesChanged([ + { filePath: buttonPath, type: 'created' }, + { filePath: refButtonPath, type: 'created' }, + { filePath: excludedPath, type: 'created' }, + ]); + + expect(getCommandLine).not.toHaveBeenCalled(); + expect(checker.updateFile).not.toHaveBeenCalled(); + + expect(project.getCommandLine()).toBe(refreshedCommandLine); + expect(getCommandLine).toHaveBeenCalledOnce(); + expect(checker.updateFile).toHaveBeenCalledTimes(2); + expect(checker.updateFile).toHaveBeenCalledWith(buttonPath, expect.any(String)); + expect(checker.updateFile).toHaveBeenCalledWith(refButtonPath, expect.any(String)); + expect(checker.updateFile).not.toHaveBeenCalledWith(excludedPath, expect.any(String)); + + project.getCommandLine(); + expect(getCommandLine).toHaveBeenCalledOnce(); + expect(checker.updateFile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/code/renderers/vue3/src/docgen/vue-project-manager.ts b/code/renderers/vue3/src/docgen/vue-project-manager.ts new file mode 100644 index 000000000000..98b79a321ddf --- /dev/null +++ b/code/renderers/vue3/src/docgen/vue-project-manager.ts @@ -0,0 +1,302 @@ +/** + * Vue specialization of core's generic `ComponentMetaManager`. + * + * The manager contributes what `vue-component-meta` lacks: tsconfig discovery per file, + * project-reference resolution, multi-project lifecycle, file watching, and heap-pressure + * recycling. Each matched tsconfig becomes its own checker, so a `create-vue` root config that only + * holds `references` resolves to the sub-config that actually includes the component — instead of + * the whole-project `include: ['**\/*']` fallback checker that `createVueComponentMetaChecker` in + * `./component-meta.ts` still uses for the legacy Vite path. + * + * The checker keeps its own snapshot cache internally (unlike React's project, which shares the + * manager-side mtime cache), so this adapter's job on file events is to push content into the + * checker via `updateFile`/`deleteFile` rather than to invalidate a shared map. + * + * @see https://github.com/vuejs/language-tools/issues/3896 — upstream issue: vue-component-meta + * does not resolve tsconfig project references. + */ +import { readFileSync, statSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { + ComponentMetaManager, + type ComponentMetaProjectBase, + type ComponentMetaProjectFactory, + type FileChange, +} from 'storybook/internal/component-meta'; + +import { createParsedCommandLine, getAllExtensions } from '@vue/language-core'; +import type ts from 'typescript'; +import { type ComponentMetaChecker, createChecker, createCheckerByJson } from 'vue-component-meta'; + +import { CHECKER_OPTIONS } from './component-meta.ts'; + +/** + * Raw-JSON compiler options for files no discovered tsconfig covers (`createCheckerByJson` parses + * JSON, so these are tsconfig spellings, not `ts.CompilerOptions` enums). Mirrors the intent of the + * React inferred project's defaults, with `jsx: preserve` in place of `react-jsx`. + */ +const INFERRED_COMPILER_OPTIONS = { + strict: true, + allowJs: true, + skipLibCheck: true, + target: 'esnext', + module: 'esnext', + moduleResolution: 'bundler', + jsx: 'preserve', +}; + +const normalize = (fileName: string) => fileName.replace(/\\/g, '/'); + +/** One `vue-component-meta` checker per matched tsconfig. */ +export class VueComponentMetaProject implements ComponentMetaProjectBase { + /** + * Modification time of the text the checker last read, per absolute path — only for files this + * project has been asked to keep fresh. The checker's snapshot cache never re-reads on its own, + * so without this an extraction racing an editor save would serve stale docgen forever. + */ + private readonly mtimes = new Map(); + private readonly pendingCreatedFiles = new Set(); + + constructor( + public readonly checker: ComponentMetaChecker, + private commandLine: ts.ParsedCommandLine, + public readonly configFileName: string | undefined, + private getCommandLineFn?: () => ts.ParsedCommandLine + ) {} + + getCommandLine(): ts.ParsedCommandLine { + this.replayPendingCreatedFiles(); + return this.commandLine; + } + + private replayPendingCreatedFiles(): void { + if (this.pendingCreatedFiles.size === 0) { + return; + } + + const pendingCreatedFiles = [...this.pendingCreatedFiles]; + this.pendingCreatedFiles.clear(); + + const commandLine = this.getCommandLineFn?.(); + if (!commandLine) { + return; + } + + this.commandLine = commandLine; + const rootFiles = new Set(commandLine.fileNames); + for (const fileName of pendingCreatedFiles) { + if (!rootFiles.has(fileName)) { + continue; + } + const text = tryReadFile(fileName); + if (text !== undefined) { + this.checker.updateFile(fileName, text); + } + } + } + + hasSourceFile(fileName: string): boolean { + return !!this.checker.getProgram()?.getSourceFile(normalize(fileName)); + } + + /** Push files the program does not know yet into the checker (inferred-project inclusion). */ + ensureFiles(fileNames: string[]): void { + for (const fileName of fileNames) { + const normalized = normalize(fileName); + if (this.hasSourceFile(normalized)) { + continue; + } + const text = tryReadFile(normalized); + if (text !== undefined) { + this.checker.updateFile(normalized, text); + } + } + } + + /** + * Re-read the given files when their mtime moved since the checker last saw them. The targeted + * counterpart of the manager's watch layer, covering the race where an extraction lands before + * the debounced fs.watch event (same role as the React project's `ensureFresh`). + */ + ensureFresh(fileNames: string[]): void { + for (const fileName of fileNames) { + const normalized = normalize(fileName); + const mtime = statMtime(normalized); + if (mtime === undefined) { + continue; + } + + const previous = this.mtimes.get(normalized); + if (previous === undefined) { + // First sighting: the checker reads the file itself on first access, so the snapshot is + // current as of now. Record the mtime so the next edit registers as a change. + this.mtimes.set(normalized, mtime); + continue; + } + if (previous === mtime) { + continue; + } + + const text = tryReadFile(normalized); + if (text !== undefined) { + this.checker.updateFile(normalized, text); + this.mtimes.set(normalized, statMtime(normalized) ?? mtime); + } + } + } + + onFilesChanged(changes: FileChange[]): void { + for (const { filePath, type } of changes) { + const fileName = normalize(filePath); + + if (type === 'deleted') { + this.pendingCreatedFiles.delete(fileName); + if (this.hasSourceFile(fileName)) { + this.checker.deleteFile(fileName); + } + this.mtimes.delete(fileName); + continue; + } + + if (type === 'changed') { + // Only refresh files this program actually holds — bumping the checker's projectVersion + // for unrelated files would rebuild the program for nothing. + if (!this.hasSourceFile(fileName)) { + continue; + } + const text = tryReadFile(fileName); + if (text !== undefined) { + this.checker.updateFile(fileName, text); + this.mtimes.set(fileName, statMtime(fileName) ?? Date.now()); + } + continue; + } + + if (this.getCommandLineFn) { + this.pendingCreatedFiles.add(fileName); + } + } + } + + getSourceFilePaths(): string[] { + const program = this.checker.getProgram(); + if (!program) { + return []; + } + return program + .getSourceFiles() + .map((sourceFile) => normalize(sourceFile.fileName)) + .filter((fileName) => !fileName.includes('node_modules')); + } + + dispose(): void { + this.checker.clearCache(); + this.mtimes.clear(); + this.pendingCreatedFiles.clear(); + } +} + +function createVueProjectFactory( + typescript: typeof ts +): ComponentMetaProjectFactory { + return { + /** + * Mirrors vue-component-meta's own config loading + * + * Matching the checker's recipe exactly means the manager matches files against the same set + * the checker's program will contain — a plain TS parse would never list `.vue` files and every + * SFC would fall through direct-include matching. + * + * @see https://github.com/vuejs/language-tools/blob/v3.3.2/packages/component-meta/index.ts — + * the two-step parse this reproduces. + * @see https://github.com/vuejs/language-tools/blob/v3.3.8/packages/language-core/lib/compilerOptions.ts + * — `createParsedCommandLine` and its `readDirectory` stub. + */ + parseCommandLine: (tsconfig) => { + const commandLine = createParsedCommandLine(typescript, typescript.sys, tsconfig); + const { fileNames } = typescript.parseJsonSourceFileConfigFileContent( + typescript.readJsonConfigFile(tsconfig, typescript.sys.readFile), + typescript.sys, + dirname(tsconfig), + {}, + tsconfig, + undefined, + getAllExtensions(commandLine.vueOptions).map((extension) => ({ + extension: extension.slice(1), + isMixedContent: true, + scriptKind: typescript.ScriptKind.Deferred, + })) + ); + return { + ...commandLine, + // Same workaround as React's `parseTsconfigCommandLine`: neutralize outDir, then normalize + // separators for the manager's path comparisons. + // fix https://github.com/johnsoncodehk/volar/issues/1786 + // https://github.com/microsoft/TypeScript/issues/30457 + options: { ...commandLine.options, outDir: undefined }, + fileNames: fileNames.map(normalize), + }; + }, + createConfiguredProject: (commandLine, tsconfig, getCommandLine) => + new VueComponentMetaProject( + createChecker(tsconfig, CHECKER_OPTIONS), + commandLine, + tsconfig, + getCommandLine + ), + createInferredProject: () => + new VueComponentMetaProject( + createCheckerByJson( + getProjectRoot(), + { compilerOptions: INFERRED_COMPILER_OPTIONS, include: [] }, + CHECKER_OPTIONS + ), + { options: {}, fileNames: [], errors: [] }, + undefined + ), + }; +} + +export class VueComponentMetaManager extends ComponentMetaManager< + VueComponentMetaProject, + ts.ParsedCommandLine +> { + constructor(typescript: typeof ts) { + super(typescript, createVueProjectFactory(typescript)); + } + + /** + * Checker for the tsconfig project that covers `componentPath`, with that file guaranteed + * present and fresh. Extraction only ever feeds the checker the component file — the story file + * is parsed separately with babel and never enters the TS program. + */ + getCheckerForFile(componentPath: string): ComponentMetaChecker { + const project = this.getProjectForFile(componentPath); + project.ensureFiles([componentPath]); + project.ensureFresh([componentPath]); + return project.checker; + } + + /** Post-extraction heap check; Vue has no batch surface, so callers invoke this per payload. */ + recycleIfHeapPressured(): void { + this.recycleProjectsIfHeapPressured(); + } +} + +function tryReadFile(fileName: string): string | undefined { + try { + return readFileSync(fileName, 'utf8'); + } catch { + return undefined; + } +} + +function statMtime(fileName: string): number | undefined { + try { + return statSync(fileName).mtimeMs; + } catch { + return undefined; + } +} diff --git a/yarn.lock b/yarn.lock index f6ee37617759..3b3f68d59b7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13031,6 +13031,7 @@ __metadata: "@storybook/global": "npm:^5.0.0" "@testing-library/vue": "npm:^8.0.0" "@vitejs/plugin-vue": "npm:^4.6.2" + "@vue/language-core": "npm:^3.2.7" type-fest: "npm:^5.6.0" typescript: "npm:^6.0.3" vue: "npm:^3.2.47" @@ -13040,7 +13041,11 @@ __metadata: vue-tsc: "npm:latest" peerDependencies: storybook: "workspace:^" + typescript: ">= 4.9.x" vue: ^3.0.0 + peerDependenciesMeta: + typescript: + optional: true languageName: unknown linkType: soft @@ -16553,7 +16558,7 @@ __metadata: languageName: node linkType: hard -"@vue/language-core@npm:3.3.9": +"@vue/language-core@npm:3.3.9, @vue/language-core@npm:^3.2.7": version: 3.3.9 resolution: "@vue/language-core@npm:3.3.9" dependencies: