Skip to content

Commit

Permalink
Merge pull request #22285 from storybookjs/chaks/vue-component-meta
Browse files Browse the repository at this point in the history
Vue: Replace vue-docgen-api with Volar vue-component-meta
  • Loading branch information
kasperpeulen authored Feb 23, 2024
2 parents 639e83a + f910dcc commit 2a8d0b9
Show file tree
Hide file tree
Showing 30 changed files with 4,497 additions and 35 deletions.
6 changes: 5 additions & 1 deletion code/frameworks/vue3-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,14 @@
"@storybook/builder-vite": "workspace:*",
"@storybook/core-server": "workspace:*",
"@storybook/vue3": "workspace:*",
"find-package-json": "^1.2.0",
"magic-string": "^0.30.0",
"vue-docgen-api": "^4.40.0"
"typescript": "^5.0.0",
"vue-component-meta": "^1.8.27",
"vue-docgen-api": "^4.75.1"
},
"devDependencies": {
"@types/find-package-json": "^1.2.6",
"@types/node": "^18.0.0",
"typescript": "^5.3.2",
"vite": "^4.0.0"
Expand Down
253 changes: 253 additions & 0 deletions code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import findPackageJson from 'find-package-json';
import fs from 'fs/promises';
import MagicString from 'magic-string';
import path from 'path';
import type { PluginOption } from 'vite';
import {
TypeMeta,
createComponentMetaChecker,
createComponentMetaCheckerByJsonConfig,
type ComponentMeta,
type MetaCheckerOptions,
} from 'vue-component-meta';
import { parseMulti } from 'vue-docgen-api';

type MetaSource = {
exportName: string;
displayName: string;
sourceFiles: string;
} & ComponentMeta &
MetaCheckerOptions['schema'];

export async function vueComponentMeta(): Promise<PluginOption> {
const { createFilter } = await import('vite');

// exclude stories, virtual modules and storybook internals
const exclude =
/\.stories\.(ts|tsx|js|jsx)$|^\/virtual:|^\/sb-preview\/|\.storybook\/.*\.(ts|js)$/;
const include = /\.(vue|ts|js|tsx|jsx)$/;
const filter = createFilter(include, exclude);

const checker = await createChecker();

return {
name: 'storybook:vue-component-meta-plugin',
async transform(src, id) {
if (!filter(id)) return undefined;

try {
const exportNames = checker.getExportNames(id);
let componentsMeta = exportNames.map((name) => checker.getComponentMeta(id, name));
componentsMeta = await applyTempFixForEventDescriptions(id, componentsMeta);

const metaSources: MetaSource[] = [];

componentsMeta.forEach((meta, index) => {
// filter out empty meta
const isEmpty =
!meta.props.length && !meta.events.length && !meta.slots.length && !meta.exposed.length;
if (isEmpty || meta.type === TypeMeta.Unknown) return;

const exportName = exportNames[index];

const exposed =
// the meta also includes duplicated entries in the "exposed" array with "on"
// prefix (e.g. onClick instead of click), so we need to filter them out here
meta.exposed
.filter((expose) => {
let nameWithoutOnPrefix = expose.name;

if (nameWithoutOnPrefix.startsWith('on')) {
nameWithoutOnPrefix = lowercaseFirstLetter(expose.name.replace('on', ''));
}

const hasEvent = meta.events.find((event) => event.name === nameWithoutOnPrefix);
return !hasEvent;
})
// remove unwanted duplicated "$slots" expose
.filter((expose) => {
if (expose.name === '$slots') {
const slotNames = meta.slots.map((slot) => slot.name);
return !slotNames.every((slotName) => expose.type.includes(slotName));
}
return true;
});

metaSources.push({
exportName,
displayName: exportName === 'default' ? getFilenameWithoutExtension(id) : exportName,
...meta,
exposed,
sourceFiles: id,
});
});

// if there is no component meta, return undefined
if (metaSources.length === 0) return undefined;

const s = new MagicString(src);

metaSources.forEach((meta) => {
const isDefaultExport = meta.exportName === 'default';
const name = isDefaultExport ? '_sfc_main' : meta.exportName;

// we can only add the "__docgenInfo" to variables that are actually defined in the current file
// so e.g. re-exports like "export { default as MyComponent } from './MyComponent.vue'" must be ignored
// to prevent runtime errors
if (new RegExp(`export {.*${name}.*}`).test(src)) {
return;
}

if (!id.endsWith('.vue') && isDefaultExport) {
// we can not add the __docgenInfo if the component is default exported directly
// so we need to safe it to a variable instead and export default it instead
s.replace('export default ', 'const _sfc_main = ');
s.append('\nexport default _sfc_main;');
}

s.append(`\n;${name}.__docgenInfo = ${JSON.stringify(meta)}`);
});

return {
code: s.toString(),
map: s.generateMap({ hires: true, source: id }),
};
} catch (e) {
return undefined;
}
},
};
}

/**
* Creates the vue-component-meta checker to use for extracting component meta/docs.
*/
async function createChecker() {
const checkerOptions: MetaCheckerOptions = {
forceUseTs: true,
noDeclarations: true,
printer: { newLine: 1 },
};

const projectRoot = getProjectRoot();
const projectTsConfigPath = path.join(projectRoot, 'tsconfig.json');

const defaultChecker = createComponentMetaCheckerByJsonConfig(
projectRoot,
{ include: ['**/*'] },
checkerOptions
);

// prefer the tsconfig.json file of the project to support alias resolution etc.
if (await fileExists(projectTsConfigPath)) {
// tsconfig that uses references is currently not supported by vue-component-meta
// see: https://github.com/vuejs/language-tools/issues/3896
// so we return the no-tsconfig defaultChecker if tsconfig references are found
// remove this workaround once the above issue is fixed
const references = await getTsConfigReferences(projectTsConfigPath);
if (references.length > 0) {
// TODO: paths/aliases are not resolvable, find workaround for this
return defaultChecker;
}
return createComponentMetaChecker(projectTsConfigPath, checkerOptions);
}

return defaultChecker;
}

/**
* Gets the absolute path to the project root.
*/
function getProjectRoot() {
const projectRoot = findPackageJson().next().value?.path ?? '';

const currentFileDir = path.dirname(__filename);
const relativePathToProjectRoot = path.relative(currentFileDir, projectRoot);

return path.resolve(currentFileDir, relativePathToProjectRoot);
}

/**
* Gets the filename without file extension.
*/
function getFilenameWithoutExtension(filename: string) {
return path.parse(filename).name;
}

/**
* Lowercases the first letter.
*/
function lowercaseFirstLetter(string: string) {
return string.charAt(0).toLowerCase() + string.slice(1);
}

/**
* Checks whether the given file path exists.
*/
async function fileExists(fullPath: string) {
try {
await fs.stat(fullPath);
return true;
} catch {
return false;
}
}

/**
* Applies a temporary workaround/fix for missing event descriptions because
* Volar is currently not able to extract them.
* Will modify the events of the passed meta.
* Performance note: Based on some quick tests, calling "parseMulti" only takes a few milliseconds (8-20ms)
* so it should not decrease performance that much. Especially because it is only execute if the component actually
* has events.
*
* Check status of this Volar issue: https://github.com/vuejs/language-tools/issues/3893
* and update/remove this workaround once Volar supports it:
* - delete this function
* - uninstall vue-docgen-api dependency
*/
async function applyTempFixForEventDescriptions(filename: string, componentMeta: ComponentMeta[]) {
// do not apply temp fix if no events exist for performance reasons
const hasEvents = componentMeta.some((meta) => meta.events.length);
if (!hasEvents) return componentMeta;

try {
const parsedComponentDocs = await parseMulti(filename);

// add event descriptions to the existing Volar meta if available
componentMeta.map((meta, index) => {
const eventsWithDescription = parsedComponentDocs[index].events;
if (!meta.events.length || !eventsWithDescription?.length) return meta;

meta.events = meta.events.map((event) => {
const description = eventsWithDescription.find((i) => i.name === event.name)?.description;
if (description) {
(event as typeof event & { description: string }).description = description;
}
return event;
});

return meta;
});
} catch {
// noop
}

return componentMeta;
}

/**
* Gets a list of tsconfig references for the given tsconfig path.
* This is only needed for the temporary workaround/fix for:
* https://github.com/vuejs/language-tools/issues/3896
*/
async function getTsConfigReferences(tsConfigPath: string) {
try {
const content = JSON.parse(await fs.readFile(tsConfigPath, 'utf-8'));
if (!('references' in content) || !Array.isArray(content.references)) return [];
return content.references as unknown[];
} catch {
// invalid project tsconfig
return [];
}
}
14 changes: 7 additions & 7 deletions code/frameworks/vue3-vite/src/plugins/vue-docgen.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { parse } from 'vue-docgen-api';
import type { PluginOption } from 'vite';
import MagicString from 'magic-string';
import type { PluginOption } from 'vite';
import { parse } from 'vue-docgen-api';

export async function vueDocgen(): Promise<PluginOption> {
const include = /\.(vue)$/;
const { createFilter } = await import('vite');

const include = /\.(vue)$/;
const filter = createFilter(include);

return {
name: 'storybook:vue-docgen-plugin',

async transform(src: string, id: string) {
async transform(src, id) {
if (!filter(id)) return undefined;

const metaData = await parse(id);
const metaSource = JSON.stringify(metaData);

const s = new MagicString(src);
s.append(`;_sfc_main.__docgenInfo = ${metaSource}`);
s.append(`;_sfc_main.__docgenInfo = ${JSON.stringify(metaData)}`);

return {
code: s.toString(),
Expand Down
21 changes: 16 additions & 5 deletions code/frameworks/vue3-vite/src/preset.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { PresetProperty } from '@storybook/types';
import type { PluginOption } from 'vite';
import { dirname, join } from 'path';
import type { StorybookConfig } from './types';
import type { PluginOption } from 'vite';
import { vueComponentMeta } from './plugins/vue-component-meta';
import { vueDocgen } from './plugins/vue-docgen';
import type { FrameworkOptions, StorybookConfig } from './types';

const getAbsolutePath = <I extends string>(input: I): I =>
dirname(require.resolve(join(input, 'package.json'))) as any;
Expand All @@ -12,11 +13,21 @@ export const core: PresetProperty<'core'> = {
renderer: getAbsolutePath('@storybook/vue3'),
};

export const viteFinal: StorybookConfig['viteFinal'] = async (config, { presets }) => {
export const viteFinal: StorybookConfig['viteFinal'] = async (config, options) => {
const plugins: PluginOption[] = [];

// Add docgen plugin
plugins.push(await vueDocgen());
const framework = await options.presets.apply('framework');
const frameworkOptions: FrameworkOptions =
typeof framework === 'string' ? {} : framework.options ?? {};

const docgenPlugin = frameworkOptions.docgen ?? 'vue-docgen-api';

// add docgen plugin depending on framework option
if (docgenPlugin === 'vue-component-meta') {
plugins.push(await vueComponentMeta());
} else {
plugins.push(await vueDocgen());
}

const { mergeConfig } = await import('vite');
return mergeConfig(config, {
Expand Down
11 changes: 10 additions & 1 deletion code/frameworks/vue3-vite/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import type { BuilderOptions, StorybookConfigVite } from '@storybook/builder-vite';
import type { StorybookConfig as StorybookConfigBase } from '@storybook/types';
import type { StorybookConfigVite, BuilderOptions } from '@storybook/builder-vite';

type FrameworkName = '@storybook/vue3-vite';
type BuilderName = '@storybook/builder-vite';

export type FrameworkOptions = {
builder?: BuilderOptions;
/**
* Plugin to use for generation docs for component props, events, slots and exposes.
* Since Storybook 8, the official vue plugin "vue-component-meta" (Volar) can be used which supports
* more complex types, better type docs, support for js(x)/ts(x) components and more.
*
* "vue-component-meta" will become the new default in the future and "vue-docgen-api" will be removed.
* @default "vue-docgen-api"
*/
docgen?: 'vue-docgen-api' | 'vue-component-meta';
};

type StorybookConfigFramework = {
Expand Down
Loading

0 comments on commit 2a8d0b9

Please sign in to comment.