build: build material-examples with bazel (#13932)

This includes a `genrule` to create the generated `ExampleModule`
This commit is contained in:
Paul Gschwendtner
2018-11-02 22:38:34 +01:00
committed by Jeremy Elbourn
parent 150c964c32
commit c64503e0bf
10 changed files with 334 additions and 240 deletions
+5
View File
@@ -24,5 +24,10 @@
"lib": ["es2015", "dom"],
"skipLibCheck": true,
"types": ["tslib"]
},
"bazelOptions": {
// Note: We can remove this once we fully switched away from Gulp. Currently we still set
// some options here just in favor of the standard tsconfig's which extending this one.
"suppressTsconfigOverrideWarnings": true
}
}
+51
View File
@@ -0,0 +1,51 @@
package(default_visibility=["//visibility:public"])
load("@angular//:index.bzl", "ng_package")
load("//:packages.bzl", "CDK_TARGETS", "MATERIAL_TARGETS", "ROLLUP_GLOBALS")
load("//tools:defaults.bzl", "ng_module")
ng_module(
name = "examples",
srcs = glob(["**/*.ts"]) + [":example-module"],
module_name = "@angular/material-examples",
assets = glob(["**/*.html", "**/*.css"]),
deps = [
"@angular//packages/common",
"@angular//packages/core",
"@angular//packages/forms",
"@npm//moment",
"//src/material-moment-adapter",
] + CDK_TARGETS + MATERIAL_TARGETS,
# Specify the tsconfig that is also used by Gulp. We need to explicitly use this tsconfig
# because in order to import Moment with TypeScript, some specific options need to be set.
tsconfig = ":tsconfig-build.json",
)
ng_package(
name = "npm_package",
srcs = ["package.json"],
entry_point = "src/material-examples/public_api.js",
globals = ROLLUP_GLOBALS,
deps = [":examples"],
# TODO(devversion): re-enable once we have set up the proper compiler for the ng_package
tags = ["manual"],
)
genrule(
name = "example-module",
srcs = glob(["**/*.ts"]),
outs = ["example-module.ts"],
cmd = """
# As a workaround for https://github.com/bazelbuild/rules_nodejs/issues/404, we pass the
# data to the Bazel entry-point through environment variables.
export _SOURCE_FILES="$(SRCS)"
export _OUTPUT_FILE="$@"
export _BASE_DIR="$$(dirname $(location //src/material-examples:index.ts))"
# Run the bazel entry-point for generating the example module.
./$(location //tools/example-module:bazel-bin)
""",
tools = ["//tools/example-module:bazel-bin"],
output_to_bindir = True,
)
+1 -1
View File
@@ -18,7 +18,7 @@
// Unset options inherited from tsconfig-build
"annotateForClosureCompiler": false,
"flatModuleOutFile": null,
"flatModuleId": null,
"flatModuleId": null
},
"include": [
"**/*.spec.ts",
+25
View File
@@ -0,0 +1,25 @@
package(default_visibility=["//visibility:public"])
load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary")
load("//tools:defaults.bzl", "ts_library")
ts_library(
name = "example-module-lib",
srcs = glob(["**/*.ts"]),
deps = [
"@npm//@types/node",
"@npm//typescript",
],
tsconfig = ":tsconfig.json",
)
nodejs_binary(
name = "bazel-bin",
entry_point = "angular_material/tools/example-module/bazel-bin.js",
data = [
"@npm//typescript",
"@npm//source-map-support",
":example-module-lib",
":example-module.template",
],
)
+16
View File
@@ -0,0 +1,16 @@
import {generateExampleModule} from './generate-example-module';
/**
* Entry point for the Bazel NodeJS target. Usually this would be a more generic CLI, but due to
* Bazel not being able to handle a lot of files on Windows (with emulated Bash), we need to
* read the arguments through environment variables which are handled better.
*
* - https://github.com/bazelbuild/rules_nodejs/issues/404
* - https://github.com/bazelbuild/bazel/issues/3636
*/
if (require.main === module) {
const {_SOURCE_FILES, _OUTPUT_FILE, _BASE_DIR} = process.env;
generateExampleModule(_SOURCE_FILES.split(' '), _OUTPUT_FILE, _BASE_DIR);
}
@@ -0,0 +1,36 @@
/* tslint:disable */
/**
******************************************************************************
* DO NOT MANUALLY EDIT THIS FILE. THIS FILE IS AUTOMATICALLY GENERATED.
******************************************************************************
*/
import {NgModule} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {CommonModule} from '@angular/common';
import {ExampleMaterialModule} from './material-module';
${exampleImports}
export interface LiveExample {
title: string;
component: any;
additionalFiles?: string[];
selectorName?: string;
}
export const EXAMPLE_COMPONENTS: {[key: string]: LiveExample} = ${exampleComponents};
export const EXAMPLE_LIST = ${exampleList}
@NgModule({
declarations: EXAMPLE_LIST,
entryComponents: EXAMPLE_LIST,
imports: [
ExampleMaterialModule,
FormsModule,
ReactiveFormsModule,
CommonModule
]
})
export class ExampleModule { }
@@ -0,0 +1,110 @@
import * as fs from 'fs';
import * as path from 'path';
import {parseExampleFile} from './parse-example-file';
interface ExampleMetadata {
component: string;
sourcePath: string;
id: string;
title: string;
additionalComponents: string[];
additionalFiles: string[];
selectorName: string[];
}
/** Build ES module import statements for the given example metadata. */
function buildImportsTemplate(data: ExampleMetadata): string {
const components = data.additionalComponents.concat(data.component);
const relativeSrcPath = data.sourcePath.replace(/\\/g, '/').replace('.ts', '');
return `import {${components.join(',')}} from './${relativeSrcPath}';`;
}
/** Inlines the example module template with the specified parsed data. */
function inlineExampleModuleTemplate(parsedData: ExampleMetadata[]): string {
const exampleImports = parsedData.map(m => buildImportsTemplate(m)).join('\n');
const exampleList = parsedData.reduce((result, data) => {
return result.concat(data.component).concat(data.additionalComponents);
}, [] as string[]).join(',');
const exampleComponents = parsedData.reduce((result, data) => {
result[data.id] = {
title: data.title,
component: data.component,
additionalFiles: data.additionalFiles,
selectorName: data.selectorName.join(', '),
};
return result;
}, {} as any);
return fs.readFileSync(require.resolve('./example-module.template'), 'utf8')
.replace('${exampleImports}', exampleImports)
.replace('${exampleComponents}', JSON.stringify(exampleComponents))
.replace('${exampleList}', `[${exampleList}]`);
}
/** Converts a given camel-cased string to a dash-cased string. */
function convertToDashCase(name: string): string {
name = name.replace(/[A-Z]/g, ' $&');
name = name.toLowerCase().trim();
return name.split(' ').join('-');
}
/** Collects the metadata of the given source files by parsing the given TypeScript files. */
function collectExampleMetadata(sourceFiles: string[], baseFile: string): ExampleMetadata[] {
const exampleMetadata: ExampleMetadata[] = [];
for (const sourceFile of sourceFiles) {
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const {primaryComponent, secondaryComponents} = parseExampleFile(sourceFile, sourceContent);
if (primaryComponent) {
// Generate a unique id for the component by converting the class name to dash-case.
const exampleId = convertToDashCase(primaryComponent.component.replace('Example', ''));
const example: ExampleMetadata = {
sourcePath: path.relative(baseFile, sourceFile),
id: exampleId,
component: primaryComponent.component,
title: primaryComponent.title.trim(),
additionalComponents: [],
additionalFiles: [],
selectorName: []
};
if (secondaryComponents.length) {
example.selectorName.push(example.component);
for (const meta of secondaryComponents) {
example.additionalComponents.push(meta.component);
if (meta.templateUrl) {
example.additionalFiles.push(meta.templateUrl);
}
if (meta.styleUrls) {
example.additionalFiles.push(...meta.styleUrls);
}
example.selectorName.push(meta.component);
}
}
exampleMetadata.push(example);
}
}
return exampleMetadata;
}
/**
* Generates the example module from the given source files and writes it to a specified output
* file.
*/
export function generateExampleModule(sourceFiles: string[], outputFile: string,
baseDir: string = path.dirname(outputFile)) {
const results = collectExampleMetadata(sourceFiles, baseDir);
const generatedModuleFile = inlineExampleModuleTemplate(results);
fs.writeFileSync(outputFile, generatedModuleFile);
}
@@ -0,0 +1,75 @@
import * as ts from 'typescript';
interface ParsedMetadata {
primary: boolean;
component: string;
title: string;
templateUrl: string;
styleUrls: string[];
}
interface ParsedMetadataResults {
primaryComponent: ParsedMetadata;
secondaryComponents: ParsedMetadata[];
}
/** Parse the AST of the given source file and collect Angular component metadata. */
export function parseExampleFile(fileName: string, content: string): ParsedMetadataResults {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, false);
const metas: any[] = [];
const visitNode = (node: any): void => {
if (node.kind === ts.SyntaxKind.ClassDeclaration) {
const meta: any = {
component: node.name.text
};
if (node.jsDoc && node.jsDoc.length) {
for (const doc of node.jsDoc) {
if (doc.tags && doc.tags.length) {
for (const tag of doc.tags) {
const tagValue = tag.comment;
const tagName = tag.tagName.text;
if (tagName === 'title') {
meta.title = tagValue;
meta.primary = true;
}
}
}
}
}
if (node.decorators && node.decorators.length) {
for (const decorator of node.decorators) {
if (decorator.expression.expression.text === 'Component') {
for (const arg of decorator.expression.arguments) {
for (const prop of arg.properties) {
const propName = prop.name.text;
// Since additional files can be also stylesheets, we need to properly parse
// the styleUrls metadata property.
if (propName === 'styleUrls' && ts.isArrayLiteralExpression(prop.initializer)) {
meta[propName] = prop.initializer.elements
.map((literal: ts.StringLiteral) => literal.text);
} else {
meta[propName] = prop.initializer.text;
}
}
}
metas.push(meta);
}
}
}
}
ts.forEachChild(node, visitNode);
};
visitNode(sourceFile);
return {
primaryComponent: metas.find(m => m.primary),
secondaryComponents: metas.filter(m => !m.primary)
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["es2015"],
"module": "commonjs",
"target": "es5",
"sourceMap": true,
"types": ["node"]
},
"bazelOptions": {
"suppressTsconfigOverrideWarnings": true
}
}
+3 -239
View File
@@ -1,256 +1,20 @@
import {task} from 'gulp';
import {sync as glob} from 'glob';
import {task} from 'gulp';
import {buildConfig} from 'material2-build-tools';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import {generateExampleModule} from '../../example-module/generate-example-module';
const {packagesDir} = buildConfig;
interface ExampleMetadata {
component: string;
sourcePath: string;
id: string;
title: string;
additionalComponents: string[];
additionalFiles: string[];
selectorName: string[];
}
interface ParsedMetadata {
primary: boolean;
component: string;
title: string;
templateUrl: string;
styleUrls: string[];
}
interface ParsedMetadataResults {
primaryComponent: ParsedMetadata;
secondaryComponents: ParsedMetadata[];
}
/** Path to find the examples */
const examplesPath = path.join(packagesDir, 'material-examples');
/** Output path of the module that is being created */
const outputModuleFilename = path.join(examplesPath, 'example-module.ts');
/** Build ES module import statements for the examples. */
function buildImportsTemplate(metadata: ExampleMetadata): string {
const components = metadata.additionalComponents.concat(metadata.component);
// Create a relative path to the source file of the current example.
// The relative path will be used inside of a TypeScript import statement.
const relativeSrcPath = path
.relative(examplesPath, metadata.sourcePath)
.replace(/\\/g, '/')
.replace('.ts', '');
return `import {${components.join(',')}} from './${relativeSrcPath}';
`;
}
/**
* Builds the examples metadata including title, component, etc.
*/
function buildExamplesTemplate(metadata: ExampleMetadata): string {
const fields = [
`title: '${metadata.title.trim()}'`,
`component: ${metadata.component}`,
];
// if no additional files or selectors were provided,
// return null since we don't care about if these were not found
if (metadata.additionalFiles.length) {
fields.push(`additionalFiles: ${JSON.stringify(metadata.additionalFiles)}`);
}
if (metadata.selectorName.length) {
fields.push(`selectorName: '${metadata.selectorName.join(', ')}'`);
}
const data = '\n' + fields.map(field => ' ' + field).join(',\n');
return `'${metadata.id}': {${data}
},
`;
}
/**
* Build the list of components template
*/
function buildListTemplate(metadata: ExampleMetadata): string {
const components = metadata.additionalComponents.concat(metadata.component);
return `${components.join(',')},
`;
}
/**
* Builds the template for the examples module
*/
function generateExampleNgModule(extractedMetadata: ExampleMetadata[]): string {
return `
/* tslint:disable */
/** DO NOT MANUALLY EDIT THIS FILE, IT IS GENERATED VIA GULP 'build-examples-module' */
import {NgModule} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {CommonModule} from '@angular/common';
import {ExampleMaterialModule} from './material-module';
${extractedMetadata.map(r => buildImportsTemplate(r)).join('').trim()}
export interface LiveExample {
title: string;
component: any;
additionalFiles?: string[];
selectorName?: string;
}
export const EXAMPLE_COMPONENTS: {[key: string]: LiveExample} = {
${extractedMetadata.map(r => buildExamplesTemplate(r)).join('').trim()}
};
export const EXAMPLE_LIST = [
${extractedMetadata.map(r => buildListTemplate(r)).join('').trim()}
];
@NgModule({
declarations: EXAMPLE_LIST,
entryComponents: EXAMPLE_LIST,
imports: [
ExampleMaterialModule,
FormsModule,
ReactiveFormsModule,
CommonModule
]
})
export class ExampleModule { }
`;
}
/**
* Given a string that is a camel or pascal case,
* this function will convert to dash case.
*/
function convertToDashCase(name: string): string {
name = name.replace(/[A-Z]/g, ' $&');
name = name.toLowerCase().trim();
return name.split(' ').join('-');
}
/**
* Parse the AST of a file and get metadata about it
*/
function parseExampleMetadata(fileName: string, sourceContent: string): ParsedMetadataResults {
const sourceFile = ts.createSourceFile(
fileName, sourceContent, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
const metas: any[] = [];
const visit = (node: any): void => {
if (node.kind === ts.SyntaxKind.ClassDeclaration) {
const meta: any = {
component: node.name.text
};
if (node.jsDoc && node.jsDoc.length) {
for (const doc of node.jsDoc) {
if (doc.tags && doc.tags.length) {
for (const tag of doc.tags) {
const tagValue = tag.comment;
const tagName = tag.tagName.text;
if (tagName === 'title') {
meta.title = tagValue;
meta.primary = true;
}
}
}
}
}
if (node.decorators && node.decorators.length) {
for (const decorator of node.decorators) {
if (decorator.expression.expression.text === 'Component') {
for (const arg of decorator.expression.arguments) {
for (const prop of arg.properties) {
const propName = prop.name.text;
// Since additional files can be also stylesheets, we need to properly parse
// the styleUrls metadata property.
if (propName === 'styleUrls' && ts.isArrayLiteralExpression(prop.initializer)) {
meta[propName] = prop.initializer.elements
.map((literal: ts.StringLiteral) => literal.text);
} else {
meta[propName] = prop.initializer.text;
}
}
}
metas.push(meta);
}
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return {
primaryComponent: metas.find(m => m.primary),
secondaryComponents: metas.filter(m => !m.primary)
};
}
/**
* Creates the examples module and metadata
*/
task('build-examples-module', () => {
const results: ExampleMetadata[] = [];
const matchedFiles = glob(path.join(examplesPath, '**/*.ts'));
for (const sourcePath of matchedFiles) {
const sourceContent = fs.readFileSync(sourcePath, 'utf-8');
const {primaryComponent, secondaryComponents} =
parseExampleMetadata(sourcePath, sourceContent);
if (primaryComponent) {
// Generate a unique id for the component by converting the class name to dash-case.
const id = convertToDashCase(primaryComponent.component.replace('Example', ''));
const example: ExampleMetadata = {
sourcePath,
id,
component: primaryComponent.component,
title: primaryComponent.title,
additionalComponents: [],
additionalFiles: [],
selectorName: []
};
if (secondaryComponents.length) {
example.selectorName.push(example.component);
for (const meta of secondaryComponents) {
example.additionalComponents.push(meta.component);
if (meta.templateUrl) {
example.additionalFiles.push(meta.templateUrl);
}
if (meta.styleUrls) {
example.additionalFiles.push(...meta.styleUrls);
}
example.selectorName.push(meta.component);
}
}
results.push(example);
}
}
const generatedModuleFile = generateExampleNgModule(results);
fs.writeFileSync(outputModuleFilename, generatedModuleFile);
generateExampleModule(glob(path.join(examplesPath, '**/*.ts')), outputModuleFilename);
});