UNPKG

@kv-systems/ng-packagr

Version:

Compile and package Angular libraries in Angular Package Format (APF)

406 lines (357 loc) 14.7 kB
import ora from 'ora'; import * as path from 'path'; import { AssetPattern } from '../../../ng-package.schema'; import { BuildGraph } from '../../graph/build-graph'; import { Node } from '../../graph/node'; import { transformFromPromise } from '../../graph/transform'; import { colors } from '../../utils/color'; import { copyFile, exists, readFile, rmdir, stat, writeFile } from '../../utils/fs'; import { globFiles } from '../../utils/glob'; import * as log from '../../utils/log'; import { ensureUnixPath } from '../../utils/path'; import { EntryPointNode, PackageNode, fileUrl, isEntryPoint, isEntryPointInProgress, isPackage } from '../nodes'; import { NgPackagrOptions } from '../options.di'; import { NgPackage } from '../package'; import { NgEntryPoint } from './entry-point'; type CompilationMode = 'partial' | 'full' | undefined; export const writePackageTransform = (options: NgPackagrOptions) => transformFromPromise(async graph => { const spinner = ora({ hideCursor: false, discardStdin: false, }); const entryPoint: EntryPointNode = graph.find(isEntryPointInProgress()); const ngEntryPoint: NgEntryPoint = entryPoint.data.entryPoint; const ngPackageNode: PackageNode = graph.find(isPackage); const ngPackage = ngPackageNode.data; const { destinationFiles } = entryPoint.data; if (!ngEntryPoint.isSecondaryEntryPoint) { spinner.start('Copying assets'); try { await copyAssets(graph, entryPoint, ngPackageNode); } catch (error) { spinner.fail(); throw error; } spinner.succeed(); } // 6. WRITE PACKAGE.JSON const relativeUnixFromDestPath = (filePath: string) => ensureUnixPath(path.relative(ngEntryPoint.destinationPath, filePath)); if (!ngEntryPoint.isSecondaryEntryPoint) { try { spinner.start('Writing package manifest'); if (!options.watch) { const primary = ngPackageNode.data.primary; await writeFile( path.join(primary.destinationPath, '.npmignore'), `# Nested package.json's are only needed for development.\n**/package.json`, ); } const { compilationMode } = entryPoint.data.tsConfig.options; await writePackageJson( ngEntryPoint, ngPackage, { module: relativeUnixFromDestPath(destinationFiles.fesm2022), typings: relativeUnixFromDestPath(destinationFiles.declarations), exports: generatePackageExports(ngEntryPoint, graph), // webpack v4+ specific flag to enable advanced optimizations and code splitting sideEffects: ngEntryPoint.packageJson.sideEffects ?? false, }, !!options.watch, compilationMode as CompilationMode, spinner, ); } catch (error) { spinner.fail(); throw error; } spinner.succeed(); } else if (ngEntryPoint.isSecondaryEntryPoint) { if (options.watch) { // Update the watch version of the primary entry point `package.json` file. // this is needed because of Webpack's 5 `cachemanagedpaths` // https://github.com/ng-packagr/ng-packagr/issues/2069 const primary = ngPackageNode.data.primary; const packageJsonPath = path.join(primary.destinationPath, 'package.json'); if (await exists(packageJsonPath)) { const packageJson = JSON.parse(await readFile(packageJsonPath, { encoding: 'utf8' })); packageJson.version = generateWatchVersion(); await writeFile( path.join(primary.destinationPath, 'package.json'), JSON.stringify(packageJson, undefined, 2), ); } } // Write a package.json in each secondary entry-point // This is need for esbuild to secondary entry-points in dist correctly. await writeFile( path.join(ngEntryPoint.destinationPath, 'package.json'), JSON.stringify({ module: relativeUnixFromDestPath(destinationFiles.fesm2022) }, undefined, 2), ); } spinner.succeed(`Built ${ngEntryPoint.moduleId}`); return graph; }); type AssetEntry = Exclude<AssetPattern, string>; async function copyAssets( graph: BuildGraph, entryPointNode: EntryPointNode, ngPackageNode: PackageNode, ): Promise<void> { const ngPackage = ngPackageNode.data; const globsForceIgnored: string[] = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db', `${ngPackage.dest}/**`]; const defaultAssets: AssetEntry[] = [ { glob: 'LICENSE', input: '/', output: '/' }, ...graph.filter(isEntryPoint).map(({ data }) => { const subpath = data.entryPoint.destinationFiles.directory || '/'; return { glob: 'README.md', input: subpath, output: subpath, }; }), ]; const assets: AssetEntry[] = []; for (const assetPath of [...ngPackage.assets, ...defaultAssets]) { let asset: AssetEntry; if (typeof assetPath === 'object') { asset = { ...assetPath }; } else { const [isDir, isFile] = await stat(path.join(ngPackage.src, assetPath)) .then(stats => [stats.isDirectory(), stats.isFile()]) .catch(() => [false, false]); if (isDir) { asset = { glob: '**/*', input: assetPath, output: assetPath }; } else if (isFile) { // filenames are their own glob asset = { glob: path.basename(assetPath), input: path.dirname(assetPath), output: path.dirname(assetPath), }; } else { asset = { glob: assetPath, input: '/', output: '/' }; } } asset.input = path.join(ngPackage.src, asset.input); asset.output = path.join(ngPackage.dest, asset.output); const isAncestorPath = (target: string, datum: string) => path.relative(datum, target).startsWith('..'); if (isAncestorPath(asset.input, ngPackage.src)) { throw new Error('Cannot read assets from a location outside of the project root.'); } if (isAncestorPath(asset.output, ngPackage.dest)) { throw new Error('Cannot write assets to a location outside of the output path.'); } assets.push(asset); } for (const asset of assets) { const filePaths = await globFiles(asset.glob, { cwd: asset.input, ignore: [...(asset.ignore ?? []), ...globsForceIgnored], dot: true, onlyFiles: true, followSymbolicLinks: asset.followSymlinks, }); for (const filePath of filePaths) { const fileSrcFullPath = path.join(asset.input, filePath); const fileDestFullPath = path.join(asset.output, filePath); const nodeUri = fileUrl(ensureUnixPath(fileSrcFullPath)); let node = graph.get(nodeUri); if (!node) { node = new Node(nodeUri); graph.put(node); } entryPointNode.dependsOn(node); await copyFile(fileSrcFullPath, fileDestFullPath); } } } /** * Creates and writes a `package.json` file of the entry point used by the `node_module` * resolution strategies. * * #### Example * * A consumer of the entry point depends on it by `import {..} from '@my/module/id';`. * The module id `@my/module/id` will be resolved to the `package.json` file that is written by * this build step. * The properties `main`, `module`, `typings` (and so on) in the `package.json` point to the * flattened JavaScript bundles, type definitions, (...). * * @param entryPoint An entry point of an Angular package / library * @param additionalProperties Additional properties, e.g. binary artefacts (bundle files), to merge into `package.json` */ async function writePackageJson( entryPoint: NgEntryPoint, pkg: NgPackage, additionalProperties: { [key: string]: string | boolean | string[] | ConditionalExport }, isWatchMode: boolean, compilationMode: CompilationMode, spinner: ora.Ora, ): Promise<void> { log.debug('Writing package.json'); // set additional properties const packageJson = { ...entryPoint.packageJson, ...additionalProperties }; // read tslib version from `@angular/compiler` so that our tslib // version at least matches that of angular if we use require('tslib').version // it will get what installed and not the minimum version nor if it is a `~` or `^` // this is only required for primary if (isWatchMode) { // Needed because of Webpack's 5 `cachemanagedpaths` // https://github.com/angular/angular-cli/issues/20962 packageJson.version = generateWatchVersion(); } if (!packageJson.peerDependencies?.tslib && !packageJson.dependencies?.tslib) { const { peerDependencies: angularPeerDependencies = {}, dependencies: angularDependencies = {}, } = require('@angular/compiler/package.json'); const tsLibVersion = angularPeerDependencies.tslib || angularDependencies.tslib; if (tsLibVersion) { packageJson.dependencies = { ...packageJson.dependencies, tslib: tsLibVersion, }; } } else if (packageJson.peerDependencies?.tslib) { spinner.warn( colors.yellow( `'tslib' is no longer recommended to be used as a 'peerDependencies'. Moving it to 'dependencies'.`, ), ); packageJson.dependencies = { ...(packageJson.dependencies || {}), tslib: packageJson.peerDependencies.tslib, }; delete packageJson.peerDependencies.tslib; } // Verify non-peerDependencies as they can easily lead to duplicate installs or version conflicts // in the node_modules folder of an application const allowedList = pkg.allowedNonPeerDependencies.map(value => new RegExp(value)); try { checkNonPeerDependencies(packageJson, 'dependencies', allowedList, spinner); } catch (e) { await rmdir(entryPoint.destinationPath, { recursive: true }); throw e; } // Removes scripts from package.json after build if (packageJson.scripts) { if (pkg.keepLifecycleScripts !== true) { spinner.info(`Removing scripts section in package.json as it's considered a potential security vulnerability.`); delete packageJson.scripts; } else { spinner.warn( colors.yellow( `You enabled keepLifecycleScripts explicitly. The scripts section in package.json will be published to npm.`, ), ); } } if (compilationMode !== 'partial') { const scripts = packageJson.scripts || (packageJson.scripts = {}); scripts.prepublishOnly = 'node --eval "console.error(\'' + 'ERROR: Trying to publish a package that has been compiled by Ivy in full compilation mode. This is not allowed.\\n' + 'Please delete and rebuild the package with Ivy partial compilation mode, before attempting to publish.\\n' + '\')" ' + '&& exit 1'; } // keep the dist package.json clean // this will not throw if ngPackage field does not exist delete packageJson.ngPackage; const packageJsonPropertiesToDelete = [ 'stylelint', 'prettier', 'browserslist', 'devDependencies', 'jest', 'workspaces', 'husky', ]; for (const prop of packageJsonPropertiesToDelete) { if (prop in packageJson) { delete packageJson[prop]; spinner.info(`Removing ${prop} section in package.json.`); } } packageJson.name = entryPoint.moduleId; await writeFile(path.join(entryPoint.destinationPath, 'package.json'), JSON.stringify(packageJson, undefined, 2)); } function checkNonPeerDependencies( packageJson: Record<string, unknown>, property: string, allowed: RegExp[], spinner: ora.Ora, ) { if (!packageJson[property]) { return; } for (const dep of Object.keys(packageJson[property])) { if (allowed.some(regex => regex.test(dep))) { log.debug(`Dependency ${dep} is allowed in '${property}'`); } else { spinner.warn( colors.yellow( `Distributing npm packages with '${property}' is not recommended. Please consider adding ${dep} ` + `to 'peerDependencies' or remove it from '${property}'.`, ), ); throw new Error(`Dependency ${dep} must be explicitly allowed using the "allowedNonPeerDependencies" option.`); } } } type PackageExports = Record<string, ConditionalExport>; /** * Type describing the conditional exports descriptor for an entry-point. * https://nodejs.org/api/packages.html#packages_conditional_exports */ type ConditionalExport = { types?: string; default?: string; }; /** * Generates the `package.json` package exports following APF v13. * This is supposed to match with: https://github.com/angular/angular/blob/e0667efa6eada64d1fb8b143840689090fc82e52/packages/bazel/src/ng_package/packager.ts#L415. */ function generatePackageExports({ destinationPath, packageJson }: NgEntryPoint, graph: BuildGraph): PackageExports { const exports: PackageExports = packageJson.exports ? JSON.parse(JSON.stringify(packageJson.exports)) : {}; const insertMappingOrError = (subpath: string, mapping: ConditionalExport) => { exports[subpath] ??= {}; const subpathExport = exports[subpath]; // Go through all conditions that should be inserted. If the condition is already // manually set of the subpath export, we throw an error. In general, we allow for // additional conditions to be set. These will always precede the generated ones. for (const conditionName of Object.keys(mapping)) { if (subpathExport[conditionName] !== undefined) { log.warn( `Found a conflicting export condition for "${subpath}". The "${conditionName}" ` + `condition would be overridden by ng-packagr. Please unset it.`, ); } // **Note**: The order of the conditions is preserved even though we are setting // the conditions once at a time (the latest assignment will be at the end). subpathExport[conditionName] = mapping[conditionName]; } }; const relativeUnixFromDestPath = (filePath: string) => './' + ensureUnixPath(path.relative(destinationPath, filePath)); insertMappingOrError('./package.json', { default: './package.json' }); const entryPoints = graph.filter(isEntryPoint); for (const entryPoint of entryPoints) { const { destinationFiles, isSecondaryEntryPoint } = entryPoint.data.entryPoint; const subpath = isSecondaryEntryPoint ? `./${destinationFiles.directory}` : '.'; insertMappingOrError(subpath, { types: relativeUnixFromDestPath(destinationFiles.declarations), default: relativeUnixFromDestPath(destinationFiles.fesm2022), }); } return exports; } /** * Generates a new version for the package `package.json` when runing in watch mode. */ function generateWatchVersion() { return `0.0.0-watch+${Date.now()}`; }