Skip to content

Commit

Permalink
build(bazel): make ng_package auto generate package.json for secondar…
Browse files Browse the repository at this point in the history
…y entry-points
  • Loading branch information
jelbourn committed Mar 15, 2018
1 parent a011654 commit 7efd1fb
Show file tree
Hide file tree
Showing 2 changed files with 191 additions and 95 deletions.
2 changes: 2 additions & 0 deletions packages/bazel/src/ng_package/ng_package.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
284 changes: 189 additions & 95 deletions packages/bazel/src/ng_package/packager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'));
}
Expand All @@ -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(/^\/\/\/ <amd-module name=.*\/>\n/, '');
let outputPath: string;
Expand All @@ -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<string>();

// 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);

createMetadataReexportFile(destDir, entryPointName);
createTypingsReexportFile(destDir, entryPointName, licenseBanner);

fs.writeFileSync(path.join(out, dirName, `${baseName}.metadata.json`), JSON.stringify({
'__symbolic': 'module',
'version': 3,
'metadata': {},
'exports': [{'from': `./${baseName}/${baseName}`}],
'flatModuleIndexRedirect': true
}) + '\n');

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 {string} destDir Directory into which the package.json will be written.
* @param {string} 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) {
Expand Down

0 comments on commit 7efd1fb

Please sign in to comment.