diff --git a/heft-plugins/heft-storybook-plugin/.npmignore b/heft-plugins/heft-storybook-plugin/.npmignore index ad6bcd960e8..47e24bddcbb 100644 --- a/heft-plugins/heft-storybook-plugin/.npmignore +++ b/heft-plugins/heft-storybook-plugin/.npmignore @@ -9,6 +9,7 @@ !/lib-*/** !/dist/** !ThirdPartyNotice.txt +!heft-plugin.json # Ignore certain patterns that should not get published. /dist/*.stats.* diff --git a/heft-plugins/heft-storybook-plugin/README.md b/heft-plugins/heft-storybook-plugin/README.md index fd4fed4cf34..4654c7bb305 100644 --- a/heft-plugins/heft-storybook-plugin/README.md +++ b/heft-plugins/heft-storybook-plugin/README.md @@ -8,5 +8,6 @@ UI components. - [CHANGELOG.md]( https://github.com/microsoft/rushstack/blob/main/heft-plugins/heft-storybook-plugin/CHANGELOG.md) - Find out what's new in the latest version +- [@rushstack/heft](https://www.npmjs.com/package/@rushstack/heft) - Heft is a config-driven toolchain that invokes popular tools such as TypeScript, ESLint, Jest, Webpack, and API Extractor. Heft is part of the [Rush Stack](https://rushstack.io/) family of projects. diff --git a/heft-plugins/heft-storybook-plugin/config/api-extractor.json b/heft-plugins/heft-storybook-plugin/config/api-extractor.json deleted file mode 100644 index 74590d3c4f8..00000000000 --- a/heft-plugins/heft-storybook-plugin/config/api-extractor.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - - "mainEntryPointFilePath": "/lib/index.d.ts", - "apiReport": { - "enabled": true, - "reportFolder": "../../../common/reviews/api" - }, - "docModel": { - "enabled": false - }, - "dtsRollup": { - "enabled": true, - "betaTrimmedFilePath": "/dist/.d.ts" - } -} diff --git a/heft-plugins/heft-storybook-plugin/heft-plugin.json b/heft-plugins/heft-storybook-plugin/heft-plugin.json new file mode 100644 index 00000000000..acc02fabb95 --- /dev/null +++ b/heft-plugins/heft-storybook-plugin/heft-plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/heft-plugin.schema.json", + + "taskPlugins": [ + { + "pluginName": "StorybookPlugin", + "entryPoint": "./lib/StorybookPlugin", + "optionsSchema": "./lib/schemas/storybook.schema.json", + + "parameterScope": "storybook", + "parameters": [ + { + "longName": "--storybook", + "description": "(EXPERIMENTAL) Used by the \"@rushstack/heft-storybook-plugin\" package to launch Storybook.", + "parameterKind": "flag" + } + ] + } + ] +} diff --git a/heft-plugins/heft-storybook-plugin/package.json b/heft-plugins/heft-storybook-plugin/package.json index 3e06f204e01..2cb929d12b9 100644 --- a/heft-plugins/heft-storybook-plugin/package.json +++ b/heft-plugins/heft-storybook-plugin/package.json @@ -8,14 +8,12 @@ "directory": "heft-plugins/heft-storybook-plugin" }, "homepage": "https://rushstack.io/pages/heft/overview/", - "main": "lib/index.js", - "types": "dist/heft-storybook-plugin.d.ts", "license": "MIT", "scripts": { "build": "heft build --clean", "start": "heft test --clean --watch", - "_phase:build": "heft build --clean", - "_phase:test": "heft test --no-build" + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" }, "peerDependencies": { "@rushstack/heft": "^0.45.14" @@ -27,6 +25,8 @@ "@rushstack/eslint-config": "workspace:*", "@rushstack/heft": "workspace:*", "@rushstack/heft-node-rig": "workspace:*", + "@rushstack/heft-webpack4-plugin": "workspace:*", + "@rushstack/heft-webpack5-plugin": "workspace:*", "@types/node": "12.20.24" } -} +} \ No newline at end of file diff --git a/heft-plugins/heft-storybook-plugin/src/StorybookPlugin.ts b/heft-plugins/heft-storybook-plugin/src/StorybookPlugin.ts index 0f7ec69359b..48ca34d8ca6 100644 --- a/heft-plugins/heft-storybook-plugin/src/StorybookPlugin.ts +++ b/heft-plugins/heft-storybook-plugin/src/StorybookPlugin.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import * as path from 'path'; import { AlreadyExistsBehavior, FileSystem, @@ -11,18 +10,23 @@ import { } from '@rushstack/node-core-library'; import type { HeftConfiguration, - HeftSession, - IBuildStageContext, - IBundleSubstage, - IHeftPlugin, - IHeftFlagParameter, - IPreCompileSubstage, - ScopedLogger + IHeftTaskSession, + IScopedLogger, + IHeftTaskPlugin, + CommandLineFlagParameter, + IHeftTaskRunHookOptions } from '@rushstack/heft'; -import { StorybookRunner } from './StorybookRunner'; +import type { + PluginName as Webpack4PluginName, + IWebpackPluginAccessor as IWebpack4PluginAccessor +} from '@rushstack/heft-webpack4-plugin'; +import type { + PluginName as Webpack5PluginName, + IWebpackPluginAccessor as IWebpack5PluginAccessor +} from '@rushstack/heft-webpack5-plugin'; const PLUGIN_NAME: string = 'StorybookPlugin'; -const TASK_NAME: string = 'heft-storybook'; +const WEBPACK_PLUGIN_NAME: typeof Webpack4PluginName & typeof Webpack5PluginName = 'WebpackPlugin'; /** * Options for `StorybookPlugin`. @@ -67,135 +71,118 @@ export interface IStorybookPluginOptions { } /** @public */ -export class StorybookPlugin implements IHeftPlugin { - public readonly pluginName: string = PLUGIN_NAME; - - private _logger!: ScopedLogger; - private _storykitPackageName!: string; - private _startupModulePath!: string; - private _resolvedStartupModulePath!: string; +export default class StorybookPlugin implements IHeftTaskPlugin { + private _logger!: IScopedLogger; /** * Generate typings for Sass files before TypeScript compilation. */ public apply( - heftSession: HeftSession, + taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration, options: IStorybookPluginOptions ): void { - this._logger = heftSession.requestScopedLogger(TASK_NAME); - - if (!options.storykitPackageName) { - throw new Error( - `The ${TASK_NAME} task cannot start because the "storykitPackageName"` + - ` plugin option was not specified` - ); - } + this._logger = taskSession.logger; + const storybookParameter: CommandLineFlagParameter = taskSession.getFlagParameter('--storybook'); const parseResult: IParsedPackageNameOrError = PackageName.tryParse(options.storykitPackageName); if (parseResult.error) { throw new Error( - `The ${TASK_NAME} task cannot start because the "storykitPackageName"` + + `The ${taskSession.taskName} task cannot start because the "storykitPackageName"` + ` plugin option is not a valid package name: ` + parseResult.error ); } - this._storykitPackageName = options.storykitPackageName; - if (!options.startupModulePath) { - throw new Error( - `The ${TASK_NAME} task cannot start because the "startupModulePath"` + - ` plugin option was not specified` - ); - } - this._startupModulePath = options.startupModulePath; - - const storybookParameters: IHeftFlagParameter = heftSession.commandLine.registerFlagParameter({ - associatedActionNames: ['start'], - parameterLongName: '--storybook', - description: - '(EXPERIMENTAL) Used by the "@rushstack/heft-storybook-plugin" package to launch Storybook.' - }); - - heftSession.hooks.build.tap(PLUGIN_NAME, (build: IBuildStageContext) => { - if (!storybookParameters.actionAssociated || !storybookParameters.value) { - this._logger.terminal.writeVerboseLine( - 'The command line does not include "--storybook", so bundling will proceed without Storybook' + // Only tap if the --storybook flag is present. + if (storybookParameter.value) { + const configureWebpackTapOptions: { name: string, stage: number } = { + name: PLUGIN_NAME, + stage: Number.MAX_SAFE_INTEGER + }; + const configureWebpackTap: () => Promise = async () => { + // Discard Webpack's configuration to prevent Webpack from running + this._logger.terminal.writeLine( + 'The command line includes "--storybook", redirecting Webpack to Storybook' ); - return; - } - - this._logger.terminal.writeVerboseLine( - 'The command line includes "--storybook", redirecting Webpack to Storybook' + return null; + }; + + taskSession.requestAccessToPluginByName( + '@rushstack/heft-webpack4-plugin', + WEBPACK_PLUGIN_NAME, + (accessor: IWebpack4PluginAccessor) => + accessor.onConfigureWebpackHook.tapPromise(configureWebpackTapOptions, configureWebpackTap) ); - build.hooks.preCompile.tap(PLUGIN_NAME, (preCompile: IPreCompileSubstage) => { - preCompile.hooks.run.tapPromise(PLUGIN_NAME, () => { - return this._onPreCompileAsync(heftConfiguration); - }); - }); - - build.hooks.bundle.tap(PLUGIN_NAME, (bundle: IBundleSubstage) => { - bundle.hooks.configureWebpack.tap( - { name: PLUGIN_NAME, stage: Number.MAX_SAFE_INTEGER }, - (webpackConfiguration: unknown) => { - // Discard Webpack's configuration to prevent Webpack from running - return null; - } - ); + taskSession.requestAccessToPluginByName( + '@rushstack/heft-webpack5-plugin', + WEBPACK_PLUGIN_NAME, + (accessor: IWebpack5PluginAccessor) => + accessor.onConfigureWebpackHook.tapPromise(configureWebpackTapOptions, configureWebpackTap) + ); - bundle.hooks.run.tapPromise(PLUGIN_NAME, async () => { - await this._onBundleRunAsync(heftSession, heftConfiguration); - }); + taskSession.hooks.run.tapPromise(PLUGIN_NAME, async (runOptions: IHeftTaskRunHookOptions) => { + const resolvedStartupModulePath: string = + await this._prepareStorybookAsync(taskSession, heftConfiguration, options); + await this._runStorybookAsync(resolvedStartupModulePath); }); - }); + } } - private async _onPreCompileAsync(heftConfiguration: HeftConfiguration): Promise { - this._logger.terminal.writeVerboseLine(`Probing for "${this._storykitPackageName}"`); + private async _prepareStorybookAsync( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + options: IStorybookPluginOptions + ): Promise { + const { storykitPackageName, startupModulePath } = options; + this._logger.terminal.writeVerboseLine(`Probing for "${storykitPackageName}"`); // Example: "/path/to/my-project/node_modules/my-storykit" let storykitFolder: string; try { storykitFolder = Import.resolvePackage({ - packageName: this._storykitPackageName, + packageName: storykitPackageName, baseFolderPath: heftConfiguration.buildFolder }); } catch (ex) { - throw new Error(`The ${TASK_NAME} task cannot start: ` + (ex as Error).message); + throw new Error(`The ${taskSession.taskName} task cannot start: ` + (ex as Error).message); } - this._logger.terminal.writeVerboseLine(`Found "${this._storykitPackageName}" in ` + storykitFolder); + this._logger.terminal.writeVerboseLine(`Found "${storykitPackageName}" in ` + storykitFolder); // Example: "/path/to/my-project/node_modules/my-storykit/node_modules" - const storykitModuleFolder: string = path.join(storykitFolder, 'node_modules'); - if (!(await FileSystem.existsAsync(storykitModuleFolder))) { + const storykitModuleFolder: string = `${storykitFolder}/node_modules`; + const storykitModuleFolderExists: boolean = await FileSystem.existsAsync(storykitModuleFolder); + if (!storykitModuleFolderExists) { throw new Error( - `The ${TASK_NAME} task cannot start because the storykit module folder does not exist:\n` + + `The ${taskSession.taskName} task cannot start because the storykit module folder does not exist:\n` + storykitModuleFolder + '\nDid you forget to install it?' ); } - this._logger.terminal.writeVerboseLine(`Resolving startupModulePath "${this._startupModulePath}"`); + this._logger.terminal.writeVerboseLine(`Resolving startupModulePath "${startupModulePath}"`); + let resolvedStartupModulePath: string | undefined; try { - this._resolvedStartupModulePath = Import.resolveModule({ - modulePath: this._startupModulePath, + resolvedStartupModulePath = Import.resolveModule({ + modulePath: startupModulePath, baseFolderPath: storykitModuleFolder }); } catch (ex) { - throw new Error(`The ${TASK_NAME} task cannot start: ` + (ex as Error).message); + throw new Error(`The ${taskSession.taskName} task cannot start: ` + (ex as Error).message); } + this._logger.terminal.writeVerboseLine( - `Resolved startupModulePath is "${this._resolvedStartupModulePath}"` + `Resolved startupModulePath is "${resolvedStartupModulePath}"` ); // Example: "/path/to/my-project/.storybook" - const dotStorybookFolder: string = path.join(heftConfiguration.buildFolder, '.storybook'); + const dotStorybookFolder: string = `${heftConfiguration.buildFolder}/.storybook`; await FileSystem.ensureFolderAsync(dotStorybookFolder); // Example: "/path/to/my-project/.storybook/node_modules" - const dotStorybookModuleFolder: string = path.join(dotStorybookFolder, 'node_modules'); + const dotStorybookModuleFolder: string = `${dotStorybookFolder}/node_modules`; // Example: // LINK FROM: "/path/to/my-project/.storybook/node_modules" @@ -208,28 +195,16 @@ export class StorybookPlugin implements IHeftPlugin { linkTargetPath: storykitModuleFolder, alreadyExistsBehavior: AlreadyExistsBehavior.Overwrite }); + + return resolvedStartupModulePath; } - private async _onBundleRunAsync( - heftSession: HeftSession, - heftConfiguration: HeftConfiguration - ): Promise { - this._logger.terminal.writeLine('Starting Storybook runner...'); - - const storybookRunner: StorybookRunner = new StorybookRunner( - heftConfiguration.terminalProvider, - { - buildFolder: heftConfiguration.buildFolder, - resolvedStartupModulePath: this._resolvedStartupModulePath - }, - // TODO: Extract SubprocessRunnerBase into a public API - // eslint-disable-next-line - heftSession as any - ); - if (heftSession.debugMode) { - await storybookRunner.invokeAsync(); - } else { - await storybookRunner.invokeAsSubprocessAsync(); - } + private async _runStorybookAsync(resolvedStartupModulePath: string): Promise { + this._logger.terminal.writeLine('Starting Storybook...'); + this._logger.terminal.writeLine(`Launching "${resolvedStartupModulePath}"`); + + require(resolvedStartupModulePath); + + this._logger.terminal.writeVerboseLine('Completed synchronous portion of launching startupModulePath'); } } diff --git a/heft-plugins/heft-storybook-plugin/src/StorybookRunner.ts b/heft-plugins/heft-storybook-plugin/src/StorybookRunner.ts deleted file mode 100644 index cc60d2ab598..00000000000 --- a/heft-plugins/heft-storybook-plugin/src/StorybookRunner.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -import { ITerminal } from '@rushstack/node-core-library'; - -import { IScopedLogger } from '@rushstack/heft'; -import { - ISubprocessRunnerBaseConfiguration, - SubprocessRunnerBase -} from '@rushstack/heft/lib/utilities/subprocess/SubprocessRunnerBase'; - -export interface IStorybookRunnerConfiguration extends ISubprocessRunnerBaseConfiguration { - resolvedStartupModulePath: string; -} - -// TODO: Why must this be a different name from the logger in StorybookPlugin.ts? -const TASK_NAME: string = 'heft-storybook-runnner'; - -export class StorybookRunner extends SubprocessRunnerBase { - private _logger!: IScopedLogger; - - public get filename(): string { - return __filename; - } - - public async invokeAsync(): Promise { - this._logger = await this.requestScopedLoggerAsync(TASK_NAME); - const terminal: ITerminal = this._logger.terminal; - - terminal.writeLine('Launching ' + this._configuration.resolvedStartupModulePath); - - require(this._configuration.resolvedStartupModulePath); - - terminal.writeVerboseLine('Completed synchronous portion of launching startupModulePath'); - } -} diff --git a/heft-plugins/heft-storybook-plugin/src/index.ts b/heft-plugins/heft-storybook-plugin/src/index.ts deleted file mode 100644 index 284f2af00c6..00000000000 --- a/heft-plugins/heft-storybook-plugin/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -/** - * A Heft plugin for using Storybook during the "test" stage. - * - * @packageDocumentation - */ - -import type { IHeftPlugin } from '@rushstack/heft'; -import { IStorybookPluginOptions, StorybookPlugin } from './StorybookPlugin'; - -export { IStorybookPluginOptions }; - -/** - * @internal - */ -export default new StorybookPlugin() as IHeftPlugin; diff --git a/heft-plugins/heft-storybook-plugin/src/schemas/storybook.schema.json b/heft-plugins/heft-storybook-plugin/src/schemas/storybook.schema.json new file mode 100644 index 00000000000..a8722857efe --- /dev/null +++ b/heft-plugins/heft-storybook-plugin/src/schemas/storybook.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Heft Storybook Plugin Options Configuration", + "description": "This schema describes the \"options\" field that can be specified in heft.json when loading \"@rushstack/heft-storybook-plugin\".", + "type": "object", + + "required": ["storykitPackageName", "startupModulePath"], + "additionalProperties": false, + + "properties": { + "storykitPackageName": { + "title": "Storykit Package Name", + "description": "Specifies an NPM package that will provide the Storybook dependencies for the project (ex. \"my-react-storykit\"). Storybook's conventional approach is for your app project to have direct dependencies on NPM packages such as `@storybook/react` and `@storybook/addon-essentials`. These packages have heavyweight dependencies such as Babel, Webpack, and the associated loaders and plugins needed to build the Storybook app (which is bundled completely independently from Heft). Naively adding these dependencies to your app's package.json muddies the waters of two radically different toolchains, and is likely to lead to dependency conflicts, for example if Heft installs Webpack 5 but `@storybook/react` installs Webpack 4. To solve this problem, `heft-storybook-plugin` introduces the concept of a separate \"storykit package\". All of your Storybook NPM packages are moved to be dependencies of the storykit. Storybook's browser API unfortunately isn't separated into dedicated NPM packages, but instead is exported by the Node.js toolchain packages such as `@storybook/react`. For an even cleaner separation the storykit package can simply reexport such APIs.", + "type": "string" + }, + + "startupModulePath": { + "title": "Startup Module Path", + "description": "The module entry point that Heft should use to launch the Storybook toolchain. Typically it is the path loaded the `start-storybook` shell script (ex. if you are using `@storybook/react`, then the startup path would be \"@storybook/react/bin/index.js\".", + "pattern": "[^\\\\]", + "type": "string" + } + } +}