Skip to content

Commit c6b8914

Browse files
authored
Merge pull request #35665 from storybookjs/julien/vue_docgen_rendzerer
Vue: Move vue-component-meta in vue renderer and implement docgen payload builder
2 parents 002c4d9 + 761ccd3 commit c6b8914

41 files changed

Lines changed: 1709 additions & 368 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

code/core/src/common/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import versions from './versions.ts';
22

33
/// <reference types="@types/compression" />
44

5+
export { jsTsSourceExtensions } from '../shared/constants/extensions.ts';
56
export * from './presets.ts';
67
export * from './utils/cache.ts';
78
export * from './utils/cli.ts';
@@ -24,6 +25,7 @@ export * from './utils/load-main-config.ts';
2425
export * from './utils/load-manager-or-addons-file.ts';
2526
export * from './utils/load-preview-or-config-file.ts';
2627
export * from './utils/log-config.ts';
28+
export * from './utils/module-resolver.ts';
2729
export * from './utils/normalize-stories.ts';
2830
export * from './utils/paths.ts';
2931
export * from './utils/read-dependency-manifest.ts';
@@ -40,6 +42,7 @@ export * from './utils/formatter.ts';
4042
export * from './utils/get-story-id.ts';
4143
export * from './utils/component-id.ts';
4244
export * from './utils/select-component-entry.ts';
45+
export * from './utils/lazy-docgen-middleware.ts';
4346
export * from './utils/posix.ts';
4447
export * from './utils/sync-main-preview-addons.ts';
4548
export * from './utils/setup-addon-in-config.ts';

code/core/src/common/utils/interpret-files.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { existsSync } from 'node:fs';
22
import { extname } from 'node:path';
33

4-
import { ResolverFactory } from 'oxc-resolver';
5-
64
import { storybookConfigExtensions } from '../../shared/constants/extensions.ts';
5+
import { createModuleResolver } from './module-resolver.ts';
76

87
const typescriptFallbackExtensions: Record<string, string[]> = {
98
'.js': ['.ts', '.tsx'],
@@ -20,7 +19,7 @@ export function getInterpretedFile(pathToFile: string) {
2019
.find((candidate) => existsSync(candidate));
2120
}
2221

23-
const importResolver = new ResolverFactory({
22+
const importResolver = createModuleResolver({
2423
extensions: [...supportedExtensions],
2524
mainFields: ['module', 'main'],
2625
});
@@ -61,9 +60,5 @@ export function resolveImport(id: string, options: ResolveImportOptions): string
6160
}
6261

6362
function resolveSync(id: string, basedir: string): string {
64-
const result = importResolver.sync(basedir, id);
65-
if (result.path) {
66-
return result.path;
67-
}
68-
throw new Error(result.error ?? `Cannot resolve module '${id}' from '${basedir}'`);
63+
return importResolver.resolveSync(basedir, id);
6964
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type {
2+
DocgenMiddleware,
3+
DocgenPayload,
4+
DocgenProvider,
5+
DocgenProviderInput,
6+
} from '../../shared/open-service/services/docgen/types.ts';
7+
import { STORY_FILE_TEST_REGEXP, getStoryImportPathFromEntry } from './select-component-entry.ts';
8+
9+
export interface LazyDocgenMiddlewareOptions<TManager> {
10+
/**
11+
* Builds the renderer's extraction manager.
12+
* Called once, lazily, on the first eligible request and memoized for the worker's lifetime.
13+
* Return `undefined` to permanently pass through to the rest of the chain.
14+
*/
15+
createManager: () => Promise<TManager | undefined>;
16+
/**
17+
* Extracts one payload
18+
* Returns `undefined` to delegate the request downstream
19+
*/
20+
extract: (manager: TManager, input: DocgenProviderInput) => Promise<DocgenPayload | undefined>;
21+
}
22+
23+
export function createLazyDocgenMiddleware<TManager>({
24+
createManager,
25+
extract,
26+
}: LazyDocgenMiddlewareOptions<TManager>): DocgenMiddleware {
27+
let managerPromise: Promise<TManager | undefined> | undefined;
28+
const getManager = () => (managerPromise ??= createManager());
29+
30+
return (nextDocgen: DocgenProvider): DocgenProvider =>
31+
async (input) => {
32+
const storyImportPath = getStoryImportPathFromEntry(input.entry);
33+
if (!storyImportPath || !STORY_FILE_TEST_REGEXP.test(storyImportPath)) {
34+
return nextDocgen(input);
35+
}
36+
37+
const manager = await getManager();
38+
if (!manager) {
39+
return nextDocgen(input);
40+
}
41+
42+
const ours = await extract(manager, input);
43+
if (!ours) {
44+
return nextDocgen(input);
45+
}
46+
47+
const downstream = await nextDocgen(input);
48+
return { ...downstream, ...ours };
49+
};
50+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ResolverFactory } from 'oxc-resolver';
2+
3+
/**
4+
* Module resolution options, declared locally so consumers don't depend on the underlying resolver
5+
* package.
6+
*/
7+
export interface ModuleResolverOptions {
8+
extensions?: string[];
9+
mainFields?: string[];
10+
conditionNames?: string[];
11+
/** Honor `paths`/`baseUrl` from a tsconfig. `'auto'` walks up from the resolution origin. */
12+
tsconfig?: 'auto' | { configFile: string };
13+
}
14+
15+
export interface ModuleResolver {
16+
/** Resolves `specifier` as if imported from `fromFile`. Throws when unresolvable. */
17+
resolveFileSync(fromFile: string, specifier: string): string;
18+
/** Resolves `specifier` as if imported from a file in `fromDirectory`. Throws when unresolvable. */
19+
resolveSync(fromDirectory: string, specifier: string): string;
20+
}
21+
22+
export function createModuleResolver(options: ModuleResolverOptions = {}): ModuleResolver {
23+
const factory = new ResolverFactory(options);
24+
25+
const unwrap = (
26+
result: { path?: string | null; error?: string | null },
27+
specifier: string,
28+
from: string
29+
) => {
30+
if (result.path) {
31+
return result.path;
32+
}
33+
throw new Error(result.error ?? `Cannot resolve module '${specifier}' from '${from}'`);
34+
};
35+
36+
return {
37+
resolveFileSync: (fromFile, specifier) =>
38+
unwrap(factory.resolveFileSync(fromFile, specifier), specifier, fromFile),
39+
resolveSync: (fromDirectory, specifier) =>
40+
unwrap(factory.sync(fromDirectory, specifier), specifier, fromDirectory),
41+
};
42+
}

code/core/src/types/modules/core-common.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,11 @@ export interface ComponentsManifest {
459459
v: number;
460460
components: Record<string, ComponentManifest>;
461461
meta?: {
462-
docgen: 'react-docgen' | 'react-docgen-typescript' | 'react-component-meta';
462+
docgen:
463+
| 'react-docgen'
464+
| 'react-docgen-typescript'
465+
| 'react-component-meta'
466+
| 'vue-component-meta';
463467
durationMs: number;
464468
};
465469
}

code/frameworks/vue3-vite/package.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,25 @@
5151
"dependencies": {
5252
"@storybook/builder-vite": "workspace:*",
5353
"@storybook/vue3": "workspace:*",
54-
"magic-string": "^1.1.0",
55-
"typescript": "^5.9.3",
56-
"vue-component-meta": "^3.3.9",
57-
"vue-docgen-api": "^4.75.1"
54+
"magic-string": "^1.1.0"
5855
},
5956
"devDependencies": {
6057
"@types/node": "^22.19.1",
6158
"typescript": "^6.0.3",
62-
"vite": "^7.0.4"
59+
"vite": "^7.0.4",
60+
"vue-component-meta": "^3.3.9",
61+
"vue-docgen-api": "^4.75.1"
6362
},
6463
"peerDependencies": {
6564
"storybook": "workspace:^",
65+
"typescript": ">= 4.9.x",
6666
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
6767
},
68+
"peerDependenciesMeta": {
69+
"typescript": {
70+
"optional": true
71+
}
72+
},
6873
"publishConfig": {
6974
"access": "public"
7075
},
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Category, StorybookError } from 'storybook/internal/server-errors';
2+
3+
export class Vue3ViteDocgenManifestError extends StorybookError {
4+
constructor() {
5+
super({
6+
name: 'Vue3ViteDocgenManifestError',
7+
category: Category.FRAMEWORK_VUE3_VITE,
8+
code: 1,
9+
message:
10+
"The Vue docgen manifest currently requires `docgen: 'vue-component-meta'` in `framework.options`.\n" +
11+
'Update the Vue framework configuration or disable `features.componentsManifest`.',
12+
});
13+
}
14+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Options, StorybookConfigRaw } from 'storybook/internal/types';
2+
3+
import type { FrameworkOptions, VueDocgenPlugin } from '../types.ts';
4+
5+
export const VUE_COMPONENT_META = 'vue-component-meta' satisfies VueDocgenPlugin;
6+
7+
export type ResolvedDocgenOptions = false | { plugin: VueDocgenPlugin; tsconfig?: string };
8+
9+
export interface DocgenContext {
10+
docgen: ResolvedDocgenOptions;
11+
features: StorybookConfigRaw['features'];
12+
/**
13+
* Only true if the docgen server is active and the `vue-component-meta` plugin is selected.
14+
*/
15+
docgenServerActive: boolean;
16+
}
17+
18+
export async function resolveDocgenContext(options: Options): Promise<DocgenContext> {
19+
const [frameworkOptions, features] = await Promise.all([
20+
options.presets.apply<FrameworkOptions | null>('frameworkOptions'),
21+
options.presets.apply('features', {}),
22+
]);
23+
const docgen = resolveDocgenOptions(frameworkOptions?.docgen);
24+
25+
return {
26+
docgen,
27+
features,
28+
docgenServerActive:
29+
features?.experimentalDocgenServer === true &&
30+
docgen !== false &&
31+
docgen.plugin === VUE_COMPONENT_META,
32+
};
33+
}
34+
35+
export function resolveDocgenOptions(docgen?: FrameworkOptions['docgen']): ResolvedDocgenOptions {
36+
if (docgen === false) {
37+
return false;
38+
}
39+
40+
if (docgen === undefined || docgen === true) {
41+
return { plugin: 'vue-docgen-api' };
42+
}
43+
44+
if (typeof docgen === 'string') {
45+
return { plugin: docgen };
46+
}
47+
48+
return docgen;
49+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { DocgenProviderDescriptor, Options } from 'storybook/internal/types';
4+
5+
import type { FrameworkOptions } from '../types.ts';
6+
import { experimental_docgenProvider, experimental_manifests } from './preset.ts';
7+
8+
const optionsWith = (docgen?: FrameworkOptions['docgen'], features: Record<string, boolean> = {}) =>
9+
({
10+
presets: {
11+
apply: async (key: string) => {
12+
if (key === 'frameworkOptions') {
13+
return { docgen };
14+
}
15+
return key === 'features' ? features : {};
16+
},
17+
},
18+
}) as unknown as Options;
19+
20+
const existing: DocgenProviderDescriptor[] = [{ moduleSpecifier: '/addon/docgen-worker.js' }];
21+
const docgenServerOn = { experimentalDocgenServer: true };
22+
const componentsManifestOn = { ...docgenServerOn, componentsManifest: true };
23+
24+
describe('experimental_docgenProvider', () => {
25+
it('appends a descriptor pointing at the renderer worker module', async () => {
26+
const descriptors = await experimental_docgenProvider(
27+
existing,
28+
optionsWith('vue-component-meta', docgenServerOn)
29+
);
30+
31+
expect(descriptors).toHaveLength(2);
32+
// Appended, so addon providers stack on top of ours rather than replacing it.
33+
expect(descriptors[0]).toBe(existing[0]);
34+
expect(descriptors[1].moduleSpecifier).toMatch(/docgen-worker\.js$/);
35+
});
36+
37+
// The worker extracts with vue-component-meta only. Registering for vue-docgen-api would
38+
// silently swap the engine the project asked for; `docgen: false` opted out of extraction
39+
// altogether and must stay opted out.
40+
it.each(['vue-docgen-api' as const, undefined, false as const])(
41+
'registers nothing for docgen: %s',
42+
async (docgen) => {
43+
await expect(
44+
experimental_docgenProvider(existing, optionsWith(docgen, docgenServerOn))
45+
).resolves.toEqual(existing);
46+
}
47+
);
48+
});
49+
50+
describe('experimental_manifests', () => {
51+
const manifests = (docgen?: FrameworkOptions['docgen'], features?: Record<string, boolean>) =>
52+
experimental_manifests({}, optionsWith(docgen, features) as never);
53+
54+
// Core asserts `components.meta.docgen` is present whenever the feature is on, so omitting it
55+
// fails a Vue `storybook build` outright rather than degrading the debugger.
56+
it('declares the engine so core can label the components debugger', async () => {
57+
await expect(manifests('vue-component-meta', componentsManifestOn)).resolves.toEqual({
58+
components: { v: 0, components: {}, meta: { docgen: 'vue-component-meta', durationMs: 0 } },
59+
});
60+
});
61+
62+
it.each(['vue-docgen-api' as const, undefined, false as const])(
63+
'rejects the components manifest for docgen: %s',
64+
async (docgen) => {
65+
await expect(manifests(docgen, componentsManifestOn)).rejects.toThrow(
66+
"The Vue docgen manifest currently requires `docgen: 'vue-component-meta'` in `framework.options`."
67+
);
68+
}
69+
);
70+
71+
// The manifest is populated from docgen-service payloads. When another engine (or none) runs,
72+
// there are no payloads, so claiming an engine here would report "0 components" against it.
73+
it.each(['vue-docgen-api' as const, undefined, false as const])(
74+
'contributes nothing for docgen: %s',
75+
async (docgen) => {
76+
await expect(manifests(docgen, docgenServerOn)).resolves.toEqual({});
77+
}
78+
);
79+
80+
// Vue has no legacy component manifest, so with the feature off there is nothing to contribute.
81+
it('contributes nothing when the docgen service is off', async () => {
82+
await expect(manifests('vue-component-meta')).resolves.toEqual({});
83+
});
84+
});

0 commit comments

Comments
 (0)