Skip to content

Commit

Permalink
Merge pull request #26143 from storybookjs/version-non-patch-from-8.0…
Browse files Browse the repository at this point in the history
….0-beta.4

Release: Prerelease 8.0.0-beta.5
  • Loading branch information
kasperpeulen authored Feb 23, 2024
2 parents 5ce7f27 + f235f90 commit a4dccea
Show file tree
Hide file tree
Showing 45 changed files with 4,653 additions and 56 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.prerelease.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## 8.0.0-beta.5

- Addon-controls: Dont show "setup controls" if control is disabled or a function - [#26120](https://github.com/storybookjs/storybook/pull/26120), thanks [@kasperpeulen](https://github.com/kasperpeulen)!
- Addon-interactions: Only mutate arg keys when writable - [#26118](https://github.com/storybookjs/storybook/pull/26118), thanks [@kasperpeulen](https://github.com/kasperpeulen)!
- CLI: Fix logic to add `^` packages in upgrade - [#26049](https://github.com/storybookjs/storybook/pull/26049), thanks [@ndelangen](https://github.com/ndelangen)!
- Core: Fix addon bundling script - [#26145](https://github.com/storybookjs/storybook/pull/26145), thanks [@ndelangen](https://github.com/ndelangen)!
- Vue3: Fix SourceDecorator Exception - [#25773](https://github.com/storybookjs/storybook/pull/25773), thanks [@chakAs3](https://github.com/chakAs3)!
- Vue: Replace vue-docgen-api with Volar vue-component-meta - [#22285](https://github.com/storybookjs/storybook/pull/22285), thanks [@chakAs3](https://github.com/chakAs3)!

## 8.0.0-beta.4

- Addon-actions: Warn when argTypesRegex is used together with the visual test addon - [#26094](https://github.com/storybookjs/storybook/pull/26094), thanks [@kasperpeulen](https://github.com/kasperpeulen)!
Expand Down
42 changes: 42 additions & 0 deletions code/addons/interactions/src/preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest';
import { fn, isMockFunction } from '@storybook/test';
import { action } from '@storybook/addon-actions';

import { traverseArgs } from './preview';

describe('traverseArgs', () => {
const args = {
deep: {
deeper: {
fnKey: fn(),
actionKey: action('name'),
},
},
arg2: Object.freeze({ frozen: true }),
};

expect(args.deep.deeper.fnKey.getMockName()).toEqual('spy');

const traversed = traverseArgs(args) as typeof args;

test('The same structure is maintained', () =>
expect(traversed).toEqual({
deep: {
deeper: {
fnKey: args.deep.deeper.fnKey,
actionKey: args.deep.deeper.actionKey,
},
},
// We don't mutate frozen objects, but we do insert them back in the tree
arg2: args.arg2,
}));

test('The mock name is mutated to be the arg key', () =>
expect(traversed.deep.deeper.fnKey.getMockName()).toEqual('fnKey'));

const actionFn = traversed.deep.deeper.actionKey;

test('Actions are wrapped in a spy', () => expect(isMockFunction(actionFn)).toBeTruthy());
test('The spy of the action is also matching the arg key ', () =>
expect(isMockFunction(actionFn) && actionFn.getMockName()).toEqual('actionKey'));
});
8 changes: 5 additions & 3 deletions code/addons/interactions/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const { step: runStep } = instrument(
{ intercept: true }
);

const traverseArgs = (value: unknown, depth = 0, key?: string): any => {
export const traverseArgs = (value: unknown, depth = 0, key?: string): unknown => {
// Make sure to not get in infinite loops with self referencing args
if (depth > 5) return value;
if (value == null) return value;
Expand Down Expand Up @@ -45,9 +45,11 @@ const traverseArgs = (value: unknown, depth = 0, key?: string): any => {

if (typeof value === 'object' && value.constructor === Object) {
depth++;
// We have to mutate the original object for this to survive HMR.
for (const [k, v] of Object.entries(value)) {
(value as Record<string, unknown>)[k] = traverseArgs(v, depth, k);
if (Object.getOwnPropertyDescriptor(value, k).writable) {
// We have to mutate the original object for this to survive HMR.
(value as Record<string, unknown>)[k] = traverseArgs(v, depth, k);
}
}
return value;
}
Expand Down
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
Loading

0 comments on commit a4dccea

Please sign in to comment.