diff --git a/packages/bazel/src/ng_package/ng_package.bzl b/packages/bazel/src/ng_package/ng_package.bzl index 3a2e5a8f4e..51e9e211da 100644 --- a/packages/bazel/src/ng_package/ng_package.bzl +++ b/packages/bazel/src/ng_package/ng_package.bzl @@ -312,6 +312,8 @@ def _ng_package_impl(ctx): args = ctx.actions.args() args.use_param_file("%s", use_always = True) + + # The order of arguments matters here, as they are read in order in packager.ts. args.add(npm_package_directory.path) args.add(ctx.label.package) args.add(primary_entry_point_name(ctx.attr.name, ctx.attr.entry_point)) diff --git a/packages/bazel/src/ng_package/packager.ts b/packages/bazel/src/ng_package/packager.ts index 506b42821c..9215e36ac6 100644 --- a/packages/bazel/src/ng_package/packager.ts +++ b/packages/bazel/src/ng_package/packager.ts @@ -10,17 +10,54 @@ import * as fs from 'fs'; import * as path from 'path'; import * as shx from 'shelljs'; -function filter(ext: string): (path: string) => boolean { - return f => f.endsWith(ext) && !f.endsWith(`.ngfactory${ext}`) && !f.endsWith(`.ngsummary${ext}`); -} - function main(args: string[]): number { + // Exit immediately when encountering an error. shx.set('-e'); - args = fs.readFileSync(args[0], {encoding: 'utf-8'}).split('\n').map(s => s === '\'\'' ? '' : s); - const - [out, srcDir, primaryEntryPoint, secondaryEntryPointsArg, binDir, readmeMd, esm2015Arg, - esm5Arg, bundlesArg, srcsArg, licenseFile] = args; + // This utility expects all of its arguments to be specified in a params file generated by + // bazel (see https://docs.bazel.build/versions/master/skylark/lib/Args.html#use_param_file). + const paramFilePath = args[0]; + + // Paramaters are specified in the file one per line. Empty params are represented as two + // single-quotes, so turn these into real empty strings.. + const params = + fs.readFileSync(paramFilePath, 'utf-8').split('\n').map(s => s === '\'\'' ? '' : s); + + const [ + // Output directory for the npm package. + out, + + // The package segment of the ng_package rule's label (e.g. 'package/common'). + srcDir, + + // Path to the JS file for the primaery entry point (e.g. 'packages/common/index.js') + primaryEntryPoint, + + // List of secondary entry-points (e.g. ['http', 'http/testing']). + secondaryEntryPointsArg, + + // The bazel-bin dir joined with the srcDir (e.g. 'bazel-bin/package.common'). + // This is the intended output location for package artifacts. + binDir, + + // Path to the package's README.md. + readmeMd, + + // List of ES2015 files generated by rollup. + esm2015Arg, + + // List of flattenned, ES5 files generated by rollup. + esm5Arg, + + // List of all UMD bundles generated by rollup. + bundlesArg, + + // List of all files in the ng_package rule's srcs. + srcsArg, + + // Path to the package's LICENSE. + licenseFile] = params; + const esm2015 = esm2015Arg.split(',').filter(s => !!s); const esm5 = esm5Arg.split(',').filter(s => !!s); const bundles = bundlesArg.split(',').filter(s => !!s); @@ -29,67 +66,6 @@ function main(args: string[]): number { shx.mkdir('-p', out); - /** - * Inserts properties into the package.json file(s) in the package so that - * they point to all the right generated artifacts. - * - * @param filePath file being copied - * @param content current file content - */ - function amendPackageJson(filePath: string, content: string) { - const parsedPackage = JSON.parse(content); - let nameParts = parsedPackage['name'].split('/'); - // for scoped packages, we don't care about the scope segment of the path - if (nameParts[0].startsWith('@')) nameParts = nameParts.splice(1); - let rel = Array(nameParts.length - 1).fill('..').join('/'); - if (!rel) { - rel = '.'; - } - const basename = nameParts[nameParts.length - 1]; - const indexName = [...nameParts, `${basename}.js`].splice(1).join('/'); - parsedPackage['main'] = `${rel}/bundles/${nameParts.join('-')}.umd.js`; - parsedPackage['module'] = `${rel}/esm5/${indexName}`; - parsedPackage['es2015'] = `${rel}/esm2015/${indexName}`; - parsedPackage['typings'] = `./${basename}.d.ts`; - return JSON.stringify(parsedPackage, null, 2); - } - - function writeFesm(file: string, baseDir: string) { - const parts = path.basename(file).split('__'); - const entryPointName = parts.join('/').replace(/\..*/, ''); - const filename = parts.splice(-1)[0]; - const dir = path.join(baseDir, ...parts); - shx.mkdir('-p', dir); - shx.cp(file, dir); - shx.mv(path.join(dir, path.basename(file)), path.join(dir, filename)); - } - - function writeFile(file: string, relative: string, baseDir: string) { - const dir = path.join(baseDir, path.dirname(relative)); - shx.mkdir('-p', dir); - shx.cp(file, dir); - } - - // Copy these bundle_index outputs from the ng_module rules in the deps - // Mapping looks like: - // $bin/_core.bundle_index.d.ts - // -> $out/core.d.ts - // $bin/testing/_testing.bundle_index.d.ts - // -> $out/testing/testing.d.ts - // $bin/_core.bundle_index.metadata.json - // -> $out/core.metadata.json - // $bin/testing/_testing.bundle_index.metadata.json - // -> $out/testing/testing.metadata.json - // JS is a little different, as controlled by the `dir` parameter - // $bin/_core.bundle_index.js - // -> $out/esm5/core.js - // $bin/testing/_testing.bundle_index.js - // -> $out/esm5/testing.js - function moveBundleIndex(f: string, dir = '.') { - const relative = path.relative(binDir, f); - return path.join(out, dir, relative.replace(/_(.*)\.bundle_index/, '$1')); - } - if (readmeMd) { shx.cp(readmeMd, path.join(out, 'README.md')); } @@ -109,8 +85,8 @@ function main(args: string[]): number { bundles.forEach(bundle => { shx.cp(bundle, bundlesDir); }); const allsrcs = shx.find('-R', binDir); - allsrcs.filter(filter('.d.ts')).forEach((f: string) => { - const content = fs.readFileSync(f, {encoding: 'utf-8'}) + allsrcs.filter(hasFileExtension('.d.ts')).forEach((f: string) => { + const content = fs.readFileSync(f, 'utf-8') // Strip the named AMD module for compatibility with non-bazel users .replace(/^\/\/\/ \n/, ''); let outputPath: string; @@ -122,51 +98,169 @@ function main(args: string[]): number { shx.mkdir('-p', path.dirname(outputPath)); fs.writeFileSync(outputPath, content); }); - allsrcs.filter(filter('.bundle_index.js')).forEach((f: string) => { - const content = fs.readFileSync(f, {encoding: 'utf-8'}); + allsrcs.filter(hasFileExtension('.bundle_index.js')).forEach((f: string) => { + const content = fs.readFileSync(f, 'utf-8'); fs.writeFileSync(moveBundleIndex(f, 'esm5'), content); fs.writeFileSync(moveBundleIndex(f, 'esm2015'), content); }); + // Root package name (e.g. '@angular/common'), captures as we iterate through sources below. + let rootPackageName = ''; + const packagesWithExistingPackageJson = new Set(); + + // Modify source files as necessary for publishing, including updating the + // version placeholders and the paths in any package.json files. for (const src of srcs) { - let content = fs.readFileSync(src, {encoding: 'utf-8'}); + let content = fs.readFileSync(src, 'utf-8'); if (path.basename(src) === 'package.json') { - content = amendPackageJson(src, content); + const packageJson = JSON.parse(content); + content = amendPackageJson(packageJson); + + const packageName = packageJson['name']; + packagesWithExistingPackageJson.add(packageName); + + // Keep track of the root package name, e.g. "@angular/common". We assume that the + // root name will be shortest because secondary entry-points will append to it + // (e.g. "@angular/common/http"). + if (!rootPackageName || packageName.length < rootPackageName.length) { + rootPackageName = packageJson['name']; + } } const outputPath = path.join(out, path.relative(srcDir, src)); shx.mkdir('-p', path.dirname(outputPath)); fs.writeFileSync(outputPath, content); } - allsrcs.filter(filter('.bundle_index.metadata.json')).forEach((f: string) => { - fs.writeFileSync(moveBundleIndex(f), fs.readFileSync(f, {encoding: 'utf-8'})); + allsrcs.filter(hasFileExtension('.bundle_index.metadata.json')).forEach((f: string) => { + fs.writeFileSync(moveBundleIndex(f), fs.readFileSync(f, 'utf-8')); }); - const licenseBanner = licenseFile ? fs.readFileSync(licenseFile, {encoding: 'utf-8'}) : ''; + const licenseBanner = licenseFile ? fs.readFileSync(licenseFile, 'utf-8') : ''; + // Generate extra files for secondary entry-points. for (const secondaryEntryPoint of secondaryEntryPoints) { - const baseName = secondaryEntryPoint.split('/').pop(); - if (!baseName) throw new Error('secondaryEntryPoint has no slash'); + const entryPointName = secondaryEntryPoint.split('/').pop(); + const entryPointPackageName = `${rootPackageName}/${secondaryEntryPoint}`; + const dirName = path.join(...secondaryEntryPoint.split('/').slice(0, -1)); + const destDir = path.join(out, dirName); - fs.writeFileSync(path.join(out, dirName, `${baseName}.metadata.json`), JSON.stringify({ - '__symbolic': 'module', - 'version': 3, - 'metadata': {}, - 'exports': [{'from': `./${baseName}/${baseName}`}], - 'flatModuleIndexRedirect': true - }) + '\n'); + createMetadataReexportFile(destDir, entryPointName); + createTypingsReexportFile(destDir, entryPointName, licenseBanner); - fs.writeFileSync( - path.join(out, dirName, `${baseName}.d.ts`), - // Format carefully to match existing build.sh output - licenseBanner + ' ' + - ` - export * from './${baseName}/${baseName}' -`); + if (!packagesWithExistingPackageJson.has(entryPointPackageName)) { + createEntryPointPackageJson(path.join(destDir, entryPointName), entryPointPackageName); + } } return 0; + + // Copy these bundle_index outputs from the ng_module rules in the deps + // Mapping looks like: + // $bin/_core.bundle_index.d.ts + // -> $out/core.d.ts + // $bin/testing/_testing.bundle_index.d.ts + // -> $out/testing/testing.d.ts + // $bin/_core.bundle_index.metadata.json + // -> $out/core.metadata.json + // $bin/testing/_testing.bundle_index.metadata.json + // -> $out/testing/testing.metadata.json + // JS is a little different, as controlled by the `dir` parameter + // $bin/_core.bundle_index.js + // -> $out/esm5/core.js + // $bin/testing/_testing.bundle_index.js + // -> $out/esm5/testing.js + function moveBundleIndex(f: string, dir = '.') { + const relative = path.relative(binDir, f); + return path.join(out, dir, relative.replace(/_(.*)\.bundle_index/, '$1')); + } +} + +/** Gets a predicate function to filter non-generated files with a specified extension. */ +function hasFileExtension(ext: string): (path: string) => boolean { + return f => f.endsWith(ext) && !f.endsWith(`.ngfactory${ext}`) && !f.endsWith(`.ngsummary${ext}`); +} + +function writeFile(file: string, relative: string, baseDir: string) { + const dir = path.join(baseDir, path.dirname(relative)); + shx.mkdir('-p', dir); + shx.cp(file, dir); +} + +function writeFesm(file: string, baseDir: string) { + const parts = path.basename(file).split('__'); + const entryPointName = parts.join('/').replace(/\..*/, ''); + const filename = parts.splice(-1)[0]; + const dir = path.join(baseDir, ...parts); + shx.mkdir('-p', dir); + shx.cp(file, dir); + shx.mv(path.join(dir, path.basename(file)), path.join(dir, filename)); +} + +/** + * Inserts or edits properties into the package.json file(s) in the package so that + * they point to all the right generated artifacts. + * + * @param parsedPackage Parsed package.json content + */ +function amendPackageJson(parsedPackage: object) { + const packageName = parsedPackage['name']; + const nameParts = getPackageNameParts(packageName); + const relativePathToPackageRoot = getRelativePathToPackageRoot(packageName); + const basename = nameParts[nameParts.length - 1]; + const indexName = [...nameParts, `${basename}.js`].splice(1).join('/'); + + parsedPackage['main'] = `${relativePathToPackageRoot}/bundles/${nameParts.join('-')}.umd.js`; + parsedPackage['module'] = `${relativePathToPackageRoot}/esm5/${indexName}`; + parsedPackage['es2015'] = `${relativePathToPackageRoot}/esm2015/${indexName}`; + parsedPackage['typings'] = `./${basename}.d.ts`; + return JSON.stringify(parsedPackage, null, 2); +} + +/** Gets a package name split into parts, omitting the scope if present. */ +function getPackageNameParts(fullPackageName: string): string[] { + const parts = fullPackageName.split('/'); + return fullPackageName.startsWith('@') ? parts.splice(1) : parts; +} + +/** Gets the relative path to the package root from a given entry-point import path. */ +function getRelativePathToPackageRoot(entryPointPath: string) { + const parts = getPackageNameParts(entryPointPath); + const relativePath = Array(parts.length - 1).fill('..').join('/'); + return relativePath || '.'; +} + +/** Creates metadata re-export file for a secondary entry-point. */ +function createMetadataReexportFile(destDir: string, entryPointName: string) { + fs.writeFileSync(path.join(destDir, `${entryPointName}.metadata.json`), JSON.stringify({ + '__symbolic': 'module', + 'version': 3, + 'metadata': {}, + 'exports': [{'from': `./${entryPointName}/${entryPointName}`}], + 'flatModuleIndexRedirect': true + }) + '\n'); +} + +/** + * Creates a typings (d.ts) re-export file for a secondary-entry point, + * e.g., `export * from './common/common'` + */ +function createTypingsReexportFile(destDir: string, entryPointName: string, license: string) { + // Format carefully to match existing build.sh output: + // LICENSE SPACE NEWLINE SPACE EXPORT NEWLINE + const content = `${license} \n export * from \'./${entryPointName}/${entryPointName}\n`; + fs.writeFileSync(path.join(destDir, `${entryPointName}.d.ts`), content); +} + +/** + * Creates a package.json for a secondary entry-point. + * @param destDir Directory into which the package.json will be written. + * @param entryPointPackageName The full package name for the entry point, + * e.g. '@angular/common/http'. + */ +function createEntryPointPackageJson(destDir: string, entryPointPackageName: string) { + const content = amendPackageJson({name: entryPointPackageName}); + fs.writeFileSync(path.join(destDir, 'package.json'), content, 'utf-8'); } if (require.main === module) {