Nicholas Cunningham 7da48d6471
fix(bundling): fix esbuild to work with ts project references (#30230)
<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
If we are using `esbuild` as our bundler and ts project references
(`--workspaces`) local libraries are not building are not resolved in
the build artifacts.

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
When using ts project references with esbuild all types libraries
(buildable / non-buildable) should work out of the box.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
2025-03-05 13:49:00 -07:00

830 lines
22 KiB
TypeScript

import {
ensurePackage,
generateFiles,
joinPathFragments,
logger,
offsetFromRoot,
readCachedProjectGraph,
readJson,
readNxJson,
readProjectConfiguration,
toJS,
Tree,
updateJson,
updateNxJson,
updateProjectConfiguration,
workspaceRoot,
writeJson,
} from '@nx/devkit';
import { Linter } from '@nx/eslint';
import { join, relative } from 'path';
import {
dedupe,
findStorybookAndBuildTargetsAndCompiler,
TsConfig,
} from '../../../utils/utilities';
import { StorybookConfigureSchema } from '../schema';
import { UiFramework } from '../../../utils/models';
import { nxVersion } from '../../../utils/versions';
import { findEslintFile } from '@nx/eslint/src/generators/utils/eslint-file';
import { useFlatConfig } from '@nx/eslint/src/utils/flat-config';
import {
findRuntimeTsConfigName,
getProjectType,
isUsingTsSolutionSetup,
} from '@nx/js/src/utils/typescript/ts-solution-setup';
const DEFAULT_PORT = 4400;
export function addStorybookTarget(
tree: Tree,
projectName: string,
uiFramework: UiFramework,
interactionTests: boolean
) {
const projectConfig = readProjectConfiguration(tree, projectName);
projectConfig.targets['storybook'] = {
executor: '@nx/storybook:storybook',
options: {
port: DEFAULT_PORT,
configDir: `${projectConfig.root}/.storybook`,
},
configurations: {
ci: {
quiet: true,
},
},
};
projectConfig.targets['build-storybook'] = {
executor: '@nx/storybook:build',
outputs: ['{options.outputDir}'],
options: {
outputDir: joinPathFragments('dist/storybook', projectName),
configDir: `${projectConfig.root}/.storybook`,
},
configurations: {
ci: {
quiet: true,
},
},
};
if (interactionTests === true) {
projectConfig.targets['test-storybook'] = {
executor: 'nx:run-commands',
options: {
command: `test-storybook -c ${projectConfig.root}/.storybook --url=http://localhost:${DEFAULT_PORT}`,
},
};
}
updateProjectConfiguration(tree, projectName, projectConfig);
}
export function addAngularStorybookTarget(
tree: Tree,
projectName: string,
interactionTests: boolean
) {
const projectConfig = readProjectConfiguration(tree, projectName);
const { ngBuildTarget } = findStorybookAndBuildTargetsAndCompiler(
projectConfig.targets
);
projectConfig.targets['storybook'] = {
executor: '@storybook/angular:start-storybook',
options: {
port: 4400,
configDir: `${projectConfig.root}/.storybook`,
browserTarget: `${projectName}:${
ngBuildTarget ? 'build' : 'build-storybook'
}`,
compodoc: false,
},
configurations: {
ci: {
quiet: true,
},
},
};
projectConfig.targets['build-storybook'] = {
executor: '@storybook/angular:build-storybook',
outputs: ['{options.outputDir}'],
options: {
outputDir: joinPathFragments('dist/storybook', projectName),
configDir: `${projectConfig.root}/.storybook`,
browserTarget: `${projectName}:${
ngBuildTarget ? 'build' : 'build-storybook'
}`,
compodoc: false,
},
configurations: {
ci: {
quiet: true,
},
},
};
if (interactionTests === true) {
projectConfig.targets['test-storybook'] = {
executor: 'nx:run-commands',
options: {
command: `test-storybook -c ${projectConfig.root}/.storybook --url=http://localhost:${DEFAULT_PORT}`,
},
};
}
updateProjectConfiguration(tree, projectName, projectConfig);
}
export async function addStaticTarget(
tree: Tree,
opts: StorybookConfigureSchema
) {
const { webStaticServeGenerator } = ensurePackage<typeof import('@nx/web')>(
'@nx/web',
nxVersion
);
await webStaticServeGenerator(tree, {
buildTarget: `${opts.project}:build-storybook`,
outputPath: joinPathFragments('dist/storybook', opts.project),
targetName: 'static-storybook',
});
const projectConfig = readProjectConfiguration(tree, opts.project);
projectConfig.targets['static-storybook'].configurations = {
ci: {
buildTarget: `${opts.project}:build-storybook:ci`,
},
};
updateProjectConfiguration(tree, opts.project, projectConfig);
}
export function createStorybookTsconfigFile(
tree: Tree,
projectRoot: string,
uiFramework: UiFramework,
isRootProject: boolean,
mainDir: 'components' | 'src'
) {
const offset = offsetFromRoot(projectRoot);
const useTsSolution = isUsingTsSolutionSetup(tree);
// First let's check if old configuration file exists
// If it exists, let's rename it and move it to the new location
const oldStorybookTsConfigPath = joinPathFragments(
projectRoot,
'.storybook/tsconfig.json'
);
if (tree.exists(oldStorybookTsConfigPath)) {
logger.warn(`.storybook/tsconfig.json already exists for this project`);
logger.warn(
`It will be renamed and moved to tsconfig.storybook.json.
Please make sure all settings look correct after this change.
Also, please make sure to use "nx migrate" to move from one version of Nx to another.
`
);
renameAndMoveOldTsConfig(projectRoot, oldStorybookTsConfigPath, tree);
return;
}
const storybookTsConfigName = 'tsconfig.storybook.json';
const storybookTsConfigPath = joinPathFragments(
projectRoot,
storybookTsConfigName
);
if (tree.exists(storybookTsConfigPath)) {
logger.info(`tsconfig.storybook.json already exists for this project`);
return;
}
const storybookTsConfig: any = {
extends: useTsSolution
? joinPathFragments(offset, 'tsconfig.base.json')
: './tsconfig.json',
compilerOptions: {
emitDecoratorMetadata: useTsSolution ? undefined : true,
outDir: useTsSolution
? 'out-tsc/storybook'
: uiFramework === '@storybook/react-webpack5' ||
uiFramework === '@storybook/react-vite'
? ''
: undefined,
module: useTsSolution ? 'esnext' : undefined,
moduleResolution: useTsSolution ? 'bundler' : undefined,
jsx:
useTsSolution && uiFramework !== '@storybook/angular'
? 'preserve'
: undefined,
},
exclude: [`${mainDir}/**/*.spec.ts`, `${mainDir}/**/*.test.ts`],
include: [
`${mainDir}/**/*.stories.ts`,
`${mainDir}/**/*.stories.js`,
`${mainDir}/**/*.stories.jsx`,
`${mainDir}/**/*.stories.tsx`,
`${mainDir}/**/*.stories.mdx`,
'.storybook/*.js',
'.storybook/*.ts',
],
};
if (useTsSolution) {
const runtimeConfig = findRuntimeTsConfigName(projectRoot, tree);
if (runtimeConfig) {
storybookTsConfig.references ??= [];
storybookTsConfig.references.push({
path: `./${runtimeConfig}`,
});
}
}
if (
uiFramework === '@storybook/react-webpack5' ||
uiFramework === '@storybook/react-vite'
) {
storybookTsConfig.exclude.push(
`${mainDir}/**/*.spec.js`,
`${mainDir}/**/*.test.js`,
`${mainDir}/**/*.spec.tsx`,
`${mainDir}/**/*.test.tsx`,
`${mainDir}/**/*.spec.jsx`,
`${mainDir}/**/*.test.js`
);
storybookTsConfig.files = [
`${
!isRootProject ? offset : ''
}node_modules/@nx/react/typings/styled-jsx.d.ts`,
`${
!isRootProject ? offset : ''
}node_modules/@nx/react/typings/cssmodule.d.ts`,
`${
!isRootProject ? offset : ''
}node_modules/@nx/react/typings/image.d.ts`,
];
}
if (useTsSolution) {
updateJson(
tree,
joinPathFragments(projectRoot, 'tsconfig.json'),
(json) => {
json.references ??= [];
json.references.push({
path: `./${storybookTsConfigName}`,
});
return json;
}
);
}
writeJson(tree, storybookTsConfigPath, storybookTsConfig);
}
export function editTsconfigBaseJson(tree: Tree) {
let tsconfigBasePath = 'tsconfig.base.json';
// standalone workspace maybe
if (!tree.exists(tsconfigBasePath)) tsconfigBasePath = 'tsconfig.json';
if (!tree.exists(tsconfigBasePath)) return;
const tsconfigBaseContent = readJson<TsConfig>(tree, tsconfigBasePath);
if (!tsconfigBaseContent.compilerOptions)
tsconfigBaseContent.compilerOptions = {};
tsconfigBaseContent.compilerOptions.skipLibCheck = true;
writeJson(tree, tsconfigBasePath, tsconfigBaseContent);
}
export function configureTsProjectConfig(
tree: Tree,
schema: StorybookConfigureSchema
) {
const { project: projectName } = schema;
let tsConfigPath: string;
let tsConfigContent: TsConfig;
try {
tsConfigPath = getTsConfigPath(tree, projectName);
tsConfigContent = readJson<TsConfig>(tree, tsConfigPath);
} catch {
/**
* Custom app configurations
* may contain a tsconfig.json
* instead of a tsconfig.app.json.
*/
tsConfigPath = getTsConfigPath(tree, projectName, 'tsconfig.json');
tsConfigContent = readJson<TsConfig>(tree, tsConfigPath);
}
if (
!tsConfigContent?.exclude?.includes('**/*.stories.ts') &&
!tsConfigContent?.exclude?.includes('**/*.stories.js')
) {
tsConfigContent.exclude = [
...(tsConfigContent.exclude || []),
'**/*.stories.ts',
'**/*.stories.js',
...(schema.uiFramework?.startsWith('@storybook/react')
? ['**/*.stories.jsx', '**/*.stories.tsx']
: []),
];
}
writeJson(tree, tsConfigPath, tsConfigContent);
}
export function configureTsSolutionConfig(
tree: Tree,
schema: StorybookConfigureSchema
) {
const { project: projectName } = schema;
const { root } = readProjectConfiguration(tree, projectName);
const tsConfigPath = join(root, 'tsconfig.json');
const tsConfigContent = readJson<TsConfig>(tree, tsConfigPath);
if (schema.uiFramework === '@storybook/angular') {
if (
!tsConfigContent.references
?.map((reference) => reference.path)
?.includes('./.storybook/tsconfig.json')
) {
tsConfigContent.references = [
...(tsConfigContent.references || []),
{
path: './.storybook/tsconfig.json',
},
];
}
} else {
if (
!tsConfigContent.references
?.map((reference) => reference.path)
?.includes('./tsconfig.storybook.json')
) {
tsConfigContent.references = [
...(tsConfigContent.references || []),
{
path: './tsconfig.storybook.json',
},
];
}
}
writeJson(tree, tsConfigPath, tsConfigContent);
}
/**
* When adding storybook we need to inform ESLint
* of the additional tsconfig.json file which will be the only tsconfig
* which includes *.stories files.
*
* This is done within the eslint config file.
*/
export function updateLintConfig(tree: Tree, schema: StorybookConfigureSchema) {
const { project: projectName } = schema;
const { root } = readProjectConfiguration(tree, projectName);
const eslintFile = findEslintFile(tree, root);
if (!eslintFile) {
return;
}
const parserConfigPath = join(
root,
schema.uiFramework === '@storybook/angular'
? '.storybook/tsconfig.json'
: 'tsconfig.storybook.json'
);
if (useFlatConfig(tree)) {
let config = tree.read(eslintFile, 'utf-8');
const projectRegex = RegExp(/project:\s?\[?['"](.*)['"]\]?/g);
let match;
while ((match = projectRegex.exec(config)) !== null) {
const matchSet = new Set(
match[1].split(',').map((p) => p.trim().replace(/['"]/g, ''))
);
matchSet.add(parserConfigPath);
const insert = `project: [${Array.from(matchSet)
.map((p) => `'${p}'`)
.join(', ')}]`;
config =
config.slice(0, match.index) +
insert +
config.slice(match.index + match[0].length);
}
tree.write(eslintFile, config);
} else {
updateJson(tree, join(root, eslintFile), (json) => {
if (typeof json.parserOptions?.project === 'string') {
json.parserOptions.project = [json.parserOptions.project];
}
if (json.parserOptions?.project) {
json.parserOptions.project = dedupe([
...json.parserOptions.project,
parserConfigPath,
]);
}
const overrides = json.overrides || [];
for (const o of overrides) {
if (typeof o.parserOptions?.project === 'string') {
o.parserOptions.project = [o.parserOptions.project];
}
if (o.parserOptions?.project) {
o.parserOptions.project = dedupe([
...o.parserOptions.project,
parserConfigPath,
]);
}
}
const ignorePatterns = json.ignorePatterns || [];
if (!ignorePatterns.includes('storybook-static')) {
ignorePatterns.push('storybook-static');
}
return json;
});
}
}
export function normalizeSchema(
schema: StorybookConfigureSchema
): StorybookConfigureSchema {
const defaults = {
configureCypress: true,
linter: Linter.EsLint,
js: false,
};
return {
...defaults,
...schema,
};
}
export function addStorybookToNamedInputs(tree: Tree) {
const nxJson = readNxJson(tree);
if (nxJson.namedInputs) {
const hasProductionFileset = !!nxJson.namedInputs?.production;
if (hasProductionFileset) {
if (
!nxJson.namedInputs.production.includes(
'!{projectRoot}/**/*.stories.@(js|jsx|ts|tsx|mdx)'
)
) {
nxJson.namedInputs.production.push(
'!{projectRoot}/**/*.stories.@(js|jsx|ts|tsx|mdx)'
);
}
if (
!nxJson.namedInputs.production.includes(
'!{projectRoot}/.storybook/**/*'
)
) {
nxJson.namedInputs.production.push('!{projectRoot}/.storybook/**/*');
}
if (
!nxJson.namedInputs.production.includes(
'!{projectRoot}/tsconfig.storybook.json'
)
) {
nxJson.namedInputs.production.push(
'!{projectRoot}/tsconfig.storybook.json'
);
}
}
updateNxJson(tree, nxJson);
}
}
export function addStorybookToTargetDefaults(tree: Tree, setCache = true) {
const nxJson = readNxJson(tree);
nxJson.targetDefaults ??= {};
nxJson.targetDefaults['build-storybook'] ??= {};
if (setCache) {
nxJson.targetDefaults['build-storybook'].cache ??= true;
}
nxJson.targetDefaults['build-storybook'].inputs ??= [
'default',
nxJson.namedInputs && 'production' in nxJson.namedInputs
? '^production'
: '^default',
];
if (
!nxJson.targetDefaults['build-storybook'].inputs.includes(
'{projectRoot}/.storybook/**/*'
)
) {
nxJson.targetDefaults['build-storybook'].inputs.push(
'{projectRoot}/.storybook/**/*'
);
}
// Delete the !{projectRoot}/.storybook/**/* glob from build-storybook
// because we want to rebuild Storybook if the .storybook folder changes
const index = nxJson.targetDefaults['build-storybook'].inputs.indexOf(
'!{projectRoot}/.storybook/**/*'
);
if (index !== -1) {
nxJson.targetDefaults['build-storybook'].inputs.splice(index, 1);
}
if (
!nxJson.targetDefaults['build-storybook'].inputs.includes(
'{projectRoot}/tsconfig.storybook.json'
)
) {
nxJson.targetDefaults['build-storybook'].inputs.push(
'{projectRoot}/tsconfig.storybook.json'
);
}
updateNxJson(tree, nxJson);
}
export function createProjectStorybookDir(
tree: Tree,
projectName: string,
uiFramework: UiFramework,
js: boolean,
tsConfiguration: boolean,
root: string,
projectType: string,
projectIsRootProjectInStandaloneWorkspace: boolean,
interactionTests: boolean,
mainDir?: string,
isNextJs?: boolean,
usesSwc?: boolean,
usesVite?: boolean,
viteConfigFilePath?: string,
hasPlugin?: boolean,
viteConfigFileName?: string,
usesReactNative?: boolean
) {
let projectDirectory =
getProjectType(tree, root, projectType as 'application' | 'library') ===
'application'
? isNextJs
? 'components'
: 'src/app'
: 'src/lib';
if (uiFramework === '@storybook/vue3-vite') {
projectDirectory = 'src';
}
const storybookConfigExists = projectIsRootProjectInStandaloneWorkspace
? tree.exists('.storybook/main.js') || tree.exists('.storybook/main.ts')
: tree.exists(join(root, '.storybook/main.ts')) ||
tree.exists(join(root, '.storybook/main.js'));
if (storybookConfigExists) {
logger.warn(
`Storybook configuration files already exist for ${projectName}!`
);
return;
}
const templatePath = join(
__dirname,
`../project-files${tsConfiguration ? '-ts' : ''}`
);
generateFiles(tree, templatePath, root, {
tmpl: '',
uiFramework,
offsetFromRoot: offsetFromRoot(root),
projectDirectory,
projectType,
interactionTests,
mainDir,
isNextJs:
isNextJs &&
getProjectType(tree, root, projectType as 'application' | 'library') ===
'application',
usesSwc,
usesVite,
isRootProject: projectIsRootProjectInStandaloneWorkspace,
viteConfigFilePath,
hasPlugin,
viteConfigFileName,
usesReactNative,
});
if (js) {
toJS(tree);
}
if (uiFramework !== '@storybook/angular') {
// This file is only used for Angular
// For non-Angular projects, we generate a file
// called tsconfig.storybook.json at the root of the project
// using the createStorybookTsconfigFile function
// since Storybook is only taking into account .storybook/tsconfig.json
// for Angular projects
tree.delete(join(root, '.storybook/tsconfig.json'));
}
}
export function getTsConfigPath(
tree: Tree,
projectName: string,
path?: string
): string {
const { root, projectType } = readProjectConfiguration(tree, projectName);
return join(
root,
path?.length > 0
? path
: getProjectType(tree, root, projectType) === 'application'
? 'tsconfig.app.json'
: 'tsconfig.lib.json'
);
}
export function addBuildStorybookToCacheableOperations(tree: Tree) {
const nxJson = readNxJson(tree);
if (
nxJson.tasksRunnerOptions?.default?.options?.cacheableOperations &&
!nxJson.tasksRunnerOptions.default.options.cacheableOperations.includes(
'build-storybook'
)
) {
nxJson.tasksRunnerOptions.default.options.cacheableOperations.push(
'build-storybook'
);
updateNxJson(tree, nxJson);
}
}
export function projectIsRootProjectInStandaloneWorkspace(projectRoot: string) {
return relative(workspaceRoot, projectRoot)?.length === 0;
}
export function workspaceHasRootProject(tree: Tree) {
return tree.exists('project.json');
}
export function rootFileIsTs(
tree: Tree,
rootFileName: string,
tsConfiguration: boolean
): boolean {
if (tree.exists(`.storybook/${rootFileName}.ts`) && !tsConfiguration) {
logger.info(
`The root Storybook configuration is in TypeScript,
so Nx will generate TypeScript Storybook configuration files
in this project's .storybook folder as well.`
);
return true;
} else if (tree.exists(`.storybook/${rootFileName}.js`) && tsConfiguration) {
logger.info(
`The root Storybook configuration is in JavaScript,
so Nx will generate JavaScript Storybook configuration files
in this project's .storybook folder as well.`
);
return false;
} else {
return tsConfiguration;
}
}
export function findViteConfig(
tree: Tree,
projectRoot: string
): {
fullConfigPath: string | undefined;
viteConfigFileName: string | undefined;
} {
const allowsExt = ['js', 'mjs', 'ts', 'cjs', 'mts', 'cts'];
for (const ext of allowsExt) {
const viteConfigPath = joinPathFragments(projectRoot, `vite.config.${ext}`);
if (tree.exists(viteConfigPath)) {
return {
fullConfigPath: viteConfigPath,
viteConfigFileName: `vite.config.${ext}`,
};
}
}
}
export function findNextConfig(
tree: Tree,
projectRoot: string
): string | undefined {
const allowsExt = ['js', 'mjs', 'cjs'];
for (const ext of allowsExt) {
const nextConfigPath = joinPathFragments(projectRoot, `next.config.${ext}`);
if (tree.exists(nextConfigPath)) {
return nextConfigPath;
}
}
}
export function isUsingReactNative(projectName: string): boolean {
try {
const projectGraph = readCachedProjectGraph();
return projectGraph?.dependencies?.[projectName]?.some(
(dep) => dep.target === 'npm:react-native'
);
} catch {
return false;
}
}
export function renameAndMoveOldTsConfig(
projectRoot: string,
pathToStorybookConfigFile: string,
tree: Tree
) {
if (pathToStorybookConfigFile && tree.exists(pathToStorybookConfigFile)) {
updateJson(tree, pathToStorybookConfigFile, (json) => {
if (json.extends?.startsWith('../')) {
// drop one level of nesting
json.extends = json.extends.replace('../', './');
}
for (let i = 0; i < json.files?.length; i++) {
// drop one level of nesting
if (json.files[i].startsWith('../../../')) {
json.files[i] = json.files[i].replace('../../../', '../../');
}
}
for (let i = 0; i < json.include?.length; i++) {
if (json.include[i].startsWith('../')) {
json.include[i] = json.include[i].replace('../', '');
}
if (json.include[i] === '*.js') {
json.include[i] = '.storybook/*.js';
}
if (json.include[i] === '*.ts') {
json.include[i] = '.storybook/*.ts';
}
}
for (let i = 0; i < json.exclude?.length; i++) {
if (json.exclude[i].startsWith('../')) {
json.exclude[i] = json.exclude[i].replace('../', 'src/');
}
}
return json;
});
tree.rename(
pathToStorybookConfigFile,
joinPathFragments(projectRoot, `tsconfig.storybook.json`)
);
}
const projectTsConfig = joinPathFragments(projectRoot, 'tsconfig.json');
if (tree.exists(projectTsConfig)) {
updateJson(tree, projectTsConfig, (json) => {
for (let i = 0; i < json.references?.length; i++) {
if (json.references[i].path === './.storybook/tsconfig.json') {
json.references[i].path = './tsconfig.storybook.json';
break;
}
}
return json;
});
}
const eslintFile = findEslintFile(tree, projectRoot);
if (eslintFile) {
const fileName = joinPathFragments(projectRoot, eslintFile);
const config = tree.read(fileName, 'utf-8');
tree.write(
fileName,
config.replace(/\.storybook\/tsconfig\.json/g, 'tsconfig.storybook.json')
);
}
}