import { addDependenciesToPackageJson, addProjectConfiguration, convertNxGenerator, ensurePackage, extractLayoutDirectory, formatFiles, generateFiles, GeneratorCallback, getWorkspaceLayout, joinPathFragments, names, offsetFromRoot, ProjectConfiguration, runTasksInSerial, toJS, Tree, updateJson, writeJson, } from '@nx/devkit'; import { getImportPath } from 'nx/src/utils/path'; import { addTsConfigPath, getRelativePathToRootTsConfig, } from '../../utils/typescript/ts-config'; import { join } from 'path'; import { addMinimalPublishScript } from '../../utils/minimal-publish-script'; import { Bundler, LibraryGeneratorSchema } from '../../utils/schema'; import { addSwcConfig } from '../../utils/swc/add-swc-config'; import { addSwcDependencies } from '../../utils/swc/add-swc-dependencies'; import { esbuildVersion, nxVersion, typesNodeVersion, } from '../../utils/versions'; import jsInitGenerator from '../init/init'; import { PackageJson } from 'nx/src/utils/package-json'; export async function libraryGenerator( tree: Tree, schema: LibraryGeneratorSchema ) { const { layoutDirectory, projectDirectory } = extractLayoutDirectory( schema.directory ); schema.directory = projectDirectory; const libsDir = schema.rootProject ? '.' : layoutDirectory ?? getWorkspaceLayout(tree).libsDir; return projectGenerator(tree, schema, libsDir, join(__dirname, './files')); } export async function projectGenerator( tree: Tree, schema: LibraryGeneratorSchema, destinationDir: string, filesDir: string ) { const tasks: GeneratorCallback[] = []; tasks.push( await jsInitGenerator(tree, { ...schema, skipFormat: true, }) ); const options = normalizeOptions(tree, schema, destinationDir); createFiles(tree, options, `${filesDir}/lib`); addProject(tree, options, destinationDir); tasks.push(addProjectDependencies(tree, options)); if (options.bundler === 'vite') { const { viteConfigurationGenerator } = ensurePackage('@nx/vite', nxVersion); const viteTask = await viteConfigurationGenerator(tree, { project: options.name, newProject: true, uiFramework: 'none', includeVitest: options.unitTestRunner === 'vitest', includeLib: true, skipFormat: true, }); tasks.push(viteTask); } if (options.bundler === 'rollup') { ensureBabelRootConfigExists(tree); } if (options.linter !== 'none') { const lintCallback = await addLint(tree, options); tasks.push(lintCallback); } if (options.unitTestRunner === 'jest') { const jestCallback = await addJest(tree, options); tasks.push(jestCallback); if (options.bundler === 'swc' || options.bundler === 'rollup') { replaceJestConfig(tree, options, `${filesDir}/jest-config`); } } else if ( options.unitTestRunner === 'vitest' && options.bundler !== 'vite' // Test would have been set up already ) { const { vitestGenerator } = ensurePackage('@nx/vite', nxVersion); const vitestTask = await vitestGenerator(tree, { project: options.name, uiFramework: 'none', coverageProvider: 'c8', skipFormat: true, }); tasks.push(vitestTask); } if (!schema.skipTsConfig) { addTsConfigPath(tree, options.importPath, [ joinPathFragments( options.projectRoot, './src', 'index.' + (options.js ? 'js' : 'ts') ), ]); } if (!options.skipFormat) { await formatFiles(tree); } return runTasksInSerial(...tasks); } export interface NormalizedSchema extends LibraryGeneratorSchema { name: string; fileName: string; projectRoot: string; projectDirectory: string; parsedTags: string[]; importPath?: string; } function addProject( tree: Tree, options: NormalizedSchema, destinationDir: string ) { const projectConfiguration: ProjectConfiguration = { root: options.projectRoot, sourceRoot: joinPathFragments(options.projectRoot, 'src'), projectType: 'library', targets: {}, tags: options.parsedTags, }; if ( options.bundler && options.bundler !== 'none' && options.config !== 'npm-scripts' ) { const outputPath = destinationDir ? `dist/${destinationDir}/${options.projectDirectory}` : `dist/${options.projectDirectory}`; projectConfiguration.targets.build = { executor: getBuildExecutor(options.bundler), outputs: ['{options.outputPath}'], options: { outputPath, main: `${options.projectRoot}/src/index` + (options.js ? '.js' : '.ts'), tsConfig: `${options.projectRoot}/tsconfig.lib.json`, assets: [], }, }; if (options.bundler === 'esbuild') { projectConfiguration.targets.build.options.generatePackageJson = true; } if (options.bundler === 'rollup') { projectConfiguration.targets.build.options.project = `${options.projectRoot}/package.json`; projectConfiguration.targets.build.options.compiler = 'swc'; } if (options.bundler === 'swc' && options.skipTypeCheck) { projectConfiguration.targets.build.options.skipTypeCheck = true; } if ( !options.minimal && // TODO(jack): assets for rollup have validation that we need to fix (assets must be under /src) options.bundler !== 'rollup' ) { projectConfiguration.targets.build.options.assets ??= []; projectConfiguration.targets.build.options.assets.push( joinPathFragments(options.projectRoot, '*.md') ); } if (options.publishable) { const publishScriptPath = addMinimalPublishScript(tree); projectConfiguration.targets.publish = { executor: 'nx:run-commands', options: { command: `node ${publishScriptPath} ${options.name} {args.ver} {args.tag}`, }, dependsOn: ['build'], }; } } if (options.config === 'workspace' || options.config === 'project') { addProjectConfiguration(tree, options.name, projectConfiguration); } else { addProjectConfiguration( tree, options.name, { root: projectConfiguration.root, tags: projectConfiguration.tags, targets: {}, }, true ); } } export async function addLint( tree: Tree, options: NormalizedSchema ): Promise { const { lintProjectGenerator } = ensurePackage('@nx/linter', nxVersion); return lintProjectGenerator(tree, { project: options.name, linter: options.linter, skipFormat: true, tsConfigPaths: [ joinPathFragments(options.projectRoot, 'tsconfig.lib.json'), ], unitTestRunner: options.unitTestRunner, eslintFilePatterns: [ `${options.projectRoot}/**/*.${options.js ? 'js' : 'ts'}`, ], setParserOptionsProject: options.setParserOptionsProject, rootProject: options.rootProject, }); } function updateTsConfig(tree: Tree, options: NormalizedSchema) { updateJson(tree, join(options.projectRoot, 'tsconfig.json'), (json) => { if (options.strict) { json.compilerOptions = { ...json.compilerOptions, forceConsistentCasingInFileNames: true, strict: true, noImplicitOverride: true, noPropertyAccessFromIndexSignature: true, noImplicitReturns: true, noFallthroughCasesInSwitch: true, }; } return json; }); } function addBabelRc(tree: Tree, options: NormalizedSchema) { const filename = '.babelrc'; const babelrc = { presets: [['@nx/js/babel', { useBuiltIns: 'usage' }]], }; writeJson(tree, join(options.projectRoot, filename), babelrc); } function createFiles(tree: Tree, options: NormalizedSchema, filesDir: string) { const { className, name, propertyName } = names(options.name); generateFiles(tree, filesDir, options.projectRoot, { ...options, dot: '.', className, name, propertyName, js: !!options.js, cliCommand: 'nx', strict: undefined, tmpl: '', offsetFromRoot: offsetFromRoot(options.projectRoot), rootTsConfigPath: getRelativePathToRootTsConfig(tree, options.projectRoot), buildable: options.bundler && options.bundler !== 'none', hasUnitTestRunner: options.unitTestRunner !== 'none', }); if (options.bundler === 'swc' || options.bundler === 'rollup') { addSwcDependencies(tree); addSwcConfig( tree, options.projectRoot, options.bundler === 'swc' ? 'commonjs' : 'es6' ); } else if (options.includeBabelRc) { addBabelRc(tree, options); } if (options.unitTestRunner === 'none') { tree.delete( join(options.projectRoot, 'src/lib', `${options.fileName}.spec.ts`) ); tree.delete( join(options.projectRoot, 'src/app', `${options.fileName}.spec.ts`) ); } if (options.js) { toJS(tree); } const packageJsonPath = joinPathFragments( options.projectRoot, 'package.json' ); if (tree.exists(packageJsonPath)) { updateJson(tree, packageJsonPath, (json) => { json.name = options.importPath; json.version = '0.0.1'; json.type = 'commonjs'; return json; }); } else { writeJson(tree, packageJsonPath, { name: options.importPath, version: '0.0.1', type: 'commonjs', }); } if (options.config === 'npm-scripts') { updateJson(tree, packageJsonPath, (json) => { json.scripts = { build: "echo 'implement build'", test: "echo 'implement test'", }; return json; }); } else if ( (!options.bundler || options.bundler === 'none') && !(options.projectRoot === '.') ) { tree.delete(packageJsonPath); } if (options.minimal && !(options.projectRoot === '.')) { tree.delete(join(options.projectRoot, 'README.md')); } updateTsConfig(tree, options); } async function addJest( tree: Tree, options: NormalizedSchema ): Promise { const { jestProjectGenerator } = ensurePackage('@nx/jest', nxVersion); return await jestProjectGenerator(tree, { ...options, project: options.name, setupFile: 'none', supportTsx: false, skipSerializers: true, testEnvironment: options.testEnvironment, skipFormat: true, compiler: options.bundler === 'swc' || options.bundler === 'tsc' ? options.bundler : options.bundler === 'rollup' ? 'swc' : undefined, }); } function replaceJestConfig( tree: Tree, options: NormalizedSchema, filesDir: string ) { // the existing config has to be deleted otherwise the new config won't overwrite it const existingJestConfig = joinPathFragments( filesDir, `jest.config.${options.js ? 'js' : 'ts'}` ); if (tree.exists(existingJestConfig)) { tree.delete(existingJestConfig); } // replace with JS:SWC specific jest config generateFiles(tree, filesDir, options.projectRoot, { ext: options.js ? 'js' : 'ts', js: !!options.js, project: options.name, offsetFromRoot: offsetFromRoot(options.projectRoot), projectRoot: options.projectRoot, }); } function normalizeOptions( tree: Tree, options: LibraryGeneratorSchema, destinationDir: string ): NormalizedSchema { /** * We are deprecating the compiler and the buildable options. * However, we want to keep the existing behavior for now. * * So, if the user has not provided a bundler, we will use the compiler option, if any. * * If the user has not provided a bundler and no compiler, but has set buildable to true, * we will use tsc, since that is the compiler the old generator used to default to, if buildable was true * and no compiler was provided. * * If the user has not provided a bundler and no compiler, and has not set buildable to true, then * set the bundler to tsc, to preserve old default behaviour (buildable: true by default). * * If it's publishable, we need to build the code before publishing it, so again * we default to `tsc`. In the previous version of this, it would set `buildable` to true * and that would default to `tsc`. * * In the past, the only way to get a non-buildable library was to set buildable to false. * Now, the only way to get a non-buildble library is to set bundler to none. * By default, with nothing provided, libraries are buildable with `@nx/js:tsc`. */ options.bundler = options.bundler ?? options.compiler ?? 'tsc'; // ensure programmatic runs have an expected default if (!options.config) { options.config = 'project'; } if (options.publishable) { if (!options.importPath) { throw new Error( `For publishable libs you have to provide a proper "--importPath" which needs to be a valid npm package name (e.g. my-awesome-lib or @myorg/my-lib)` ); } if (options.bundler === 'none') { options.bundler = 'tsc'; } } // This is to preserve old behaviour, buildable: false if (options.publishable === false && options.buildable === false) { options.bundler = 'none'; } const { Linter } = ensurePackage('@nx/linter', nxVersion); if (options.config === 'npm-scripts') { options.unitTestRunner = 'none'; options.linter = Linter.None; options.bundler = 'none'; } if ( (options.bundler === 'swc' || options.bundler === 'rollup') && options.skipTypeCheck == null ) { options.skipTypeCheck = false; } const name = names(options.name).fileName; const projectDirectory = options.directory ? `${names(options.directory).fileName}/${name}` : options.rootProject ? '.' : name; if (!options.unitTestRunner && options.bundler === 'vite') { options.unitTestRunner = 'vitest'; } else if (!options.unitTestRunner && options.config !== 'npm-scripts') { options.unitTestRunner = 'jest'; } if (!options.linter && options.config !== 'npm-scripts') { options.linter = Linter.EsLint; } const projectName = options.rootProject ? name : projectDirectory.replace(new RegExp('/', 'g'), '-'); const fileName = getCaseAwareFileName({ fileName: options.simpleModuleName ? name : projectName, pascalCaseFiles: options.pascalCaseFiles, }); const { npmScope } = getWorkspaceLayout(tree); const projectRoot = joinPathFragments(destinationDir, projectDirectory); const parsedTags = options.tags ? options.tags.split(',').map((s) => s.trim()) : []; const importPath = options.importPath || getImportPath(npmScope, projectDirectory); options.minimal ??= false; return { ...options, fileName, name: projectName, projectRoot, projectDirectory, parsedTags, importPath, }; } function getCaseAwareFileName(options: { pascalCaseFiles: boolean; fileName: string; }) { const normalized = names(options.fileName); return options.pascalCaseFiles ? normalized.className : normalized.fileName; } function addProjectDependencies( tree: Tree, options: NormalizedSchema ): GeneratorCallback { if (options.bundler == 'esbuild') { return addDependenciesToPackageJson( tree, {}, { '@nx/esbuild': nxVersion, '@types/node': typesNodeVersion, esbuild: esbuildVersion, } ); } if (options.bundler == 'rollup') { return addDependenciesToPackageJson( tree, {}, { '@nx/rollup': nxVersion, '@types/node': typesNodeVersion } ); } // Vite is being installed in the next step if bundler is vite // noop return () => {}; } function getBuildExecutor(bundler: Bundler) { switch (bundler) { case 'esbuild': return `@nx/esbuild:esbuild`; case 'rollup': return `@nx/rollup:rollup`; case 'swc': case 'tsc': return `@nx/js:${bundler}`; case 'vite': return `@nx/vite:build`; case 'none': default: return undefined; } } function ensureBabelRootConfigExists(tree: Tree) { if (tree.exists('babel.config.json')) return; writeJson(tree, 'babel.config.json', { babelrcRoots: ['*'], }); } export default libraryGenerator; export const librarySchematic = convertNxGenerator(libraryGenerator);