ng-matero
Version:
Angular Material admin template
448 lines (382 loc) • 15.6 kB
text/typescript
import {
NormalizedRoot,
Path,
template as interpolateTemplate,
normalize,
strings,
} from '@angular-devkit/core';
import { ProjectDefinition } from '@angular-devkit/core/src/workspace';
import {
Rule,
SchematicsException,
Tree,
apply,
applyTemplates,
branchAndMerge,
chain,
filter,
mergeWith,
move,
noop,
url,
} from '@angular-devkit/schematics';
import { FileSystemSchematicContext } from '@angular-devkit/schematics/tools';
import {
getDefaultComponentOptions,
getProjectFromWorkspace,
getProjectMainFile,
isStandaloneApp,
} from '@angular/cdk/schematics';
import { Schema, Style } from '@schematics/angular/component/schema';
import * as ts from '@schematics/angular/third_party/github.com/Microsoft/TypeScript/lib/typescript';
import {
addExportToModule,
findNode,
insertAfterLastOccurrence,
insertImport,
} from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';
import {
MODULE_EXT,
ROUTING_MODULE_EXT,
buildRelativePath,
findModule,
} from '@schematics/angular/utility/find-module';
import { parseName } from '@schematics/angular/utility/parse-name';
import { validateHtmlSelector } from '@schematics/angular/utility/validation';
import { getWorkspace } from '@schematics/angular/utility/workspace';
import { ProjectType } from '@schematics/angular/utility/workspace-models';
import { readFileSync, statSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { addRouteDeclarationToModule } from './ast-utils';
export interface ComponentOptions extends Schema {
pageName: string;
moduleRoot?: string;
entryComponent?: boolean;
moduleExt?: string;
routingModuleExt?: string;
}
/**
* Build a default project path for generating.
* @param project The project to build the path for.
*/
function buildDefaultPath(project: ProjectDefinition): string {
const root = project.sourceRoot ? `/${project.sourceRoot}/` : `/${project.root}/src/`;
const projectDirName = project.extensions.projectType === ProjectType.Application ? 'app' : 'lib';
return `${root}${projectDirName}`;
}
/**
* List of style extensions which are CSS compatible. All supported CLI style extensions can be
* found here: angular/angular-cli/master/packages/schematics/angular/ng-new/schema.json#L118-L122
*/
const supportedCssExtensions = ['css', 'scss', 'less'];
function buildRelativeComponentPath(options: ComponentOptions, modulePath: string): string {
const componentPath =
`/${options.path}/` +
(options.flat ? '' : strings.dasherize(options.name) + '/') +
strings.dasherize(options.name) +
'.component';
return buildRelativePath(modulePath, componentPath);
}
function readIntoSourceFile(host: Tree, modulePath: string) {
const text = host.read(modulePath);
if (text === null) {
throw new SchematicsException(`File ${modulePath} does not exist.`);
}
return ts.createSourceFile(modulePath, text.toString('utf-8'), ts.ScriptTarget.Latest, true);
}
/**
* Add a new component to a declaration array (e.g. `COMPONENTS`)
*/
function addComponent(host: Tree, modulePath: string, fileName: string, symbolName: string) {
const source = readIntoSourceFile(host, modulePath);
const node = findNode(source, ts.SyntaxKind.Identifier, symbolName);
if (!node) {
throw new Error(`Couldn't find a ${symbolName} declaration in '${modulePath}'.`);
}
const nodeArr = (node.parent as any).initializer as ts.ArrayLiteralExpression;
// Whether the declaration added needs a comma...
const occurencesCount = nodeArr.elements.length;
const text = node.getFullText(source);
let componentName: string = fileName;
if (occurencesCount > 0) {
const identation = text.match(/\r?\n(\r?)\s*/) || [];
componentName = `,${identation[0] || ' '}${fileName}`;
}
const addDeclaration = insertAfterLastOccurrence(
nodeArr.elements as unknown as ts.Node[],
componentName,
modulePath,
nodeArr.elements.pos,
ts.SyntaxKind.Identifier
) as InsertChange;
const record = host.beginUpdate(modulePath);
record.insertLeft(addDeclaration.pos, addDeclaration.toAdd);
host.commitUpdate(record);
}
/**
* Add a import declaration (i.e. insert `import ... from ...`)
*/
function addImportDeclaration(host: Tree, modulePath: string, fileName: string, filePath: string) {
const source = readIntoSourceFile(host, modulePath);
const changes = insertImport(source as any, modulePath, fileName, filePath);
const declarationRecorder = host.beginUpdate(modulePath);
if (changes instanceof InsertChange) {
declarationRecorder.insertLeft(changes.pos, changes.toAdd);
}
host.commitUpdate(declarationRecorder);
}
/**
* Add export declaration
*/
function addExportToNgModule(host: Tree, modulePath: string, fileName: string, filePath: string) {
// Need to refresh the AST because we overwrote the file in the host.
const source = readIntoSourceFile(host, modulePath);
const exportRecorder = host.beginUpdate(modulePath);
const exportChanges = addExportToModule(source, modulePath, fileName, filePath);
for (const change of exportChanges) {
if (change instanceof InsertChange) {
exportRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(exportRecorder);
}
/**
* build selector with page name
*/
function buildSelector(options: ComponentOptions, projectPrefix?: string) {
let selector = options.pageName;
if (options.prefix) {
selector = `${options.prefix}-${selector}`;
} else if (options.prefix === undefined && projectPrefix) {
selector = `${projectPrefix}-${selector}`;
}
return selector;
}
/**
* Build page name with module and name
*/
function buildPageName(options: ComponentOptions) {
const tempNameArr = [];
if (options.module) {
tempNameArr.push(options.module);
}
tempNameArr.push(...options.name.split('/'));
return tempNameArr.join('-');
}
/**
* Add declarations to module
*/
function addDeclarationToNgModule(options: ComponentOptions): Rule {
return (host: Tree) => {
if (options.skipImport || !options.module) {
return host;
}
const modulePath = options.module;
const relativePath = buildRelativeComponentPath(options, modulePath);
const classifiedName = strings.classify(`${options.pageName}Component`);
addImportDeclaration(host, modulePath, classifiedName, relativePath);
if (!options.entryComponent) {
addComponent(host, modulePath, classifiedName, 'COMPONENTS');
} else {
addComponent(host, modulePath, classifiedName, 'COMPONENTS_DYNAMIC');
}
if (options.export) {
addExportToNgModule(host, modulePath, classifiedName, relativePath);
}
return host;
};
}
/**
* Add a new component route declaration
*/
function addRouteDeclarationToNgModule(options: ComponentOptions, routingModulePath: string): Rule {
return (host: Tree) => {
if (!options.module) {
throw new Error('Module option required.');
}
let filePath: string;
if (routingModulePath) {
filePath = routingModulePath;
} else {
filePath = options.module;
}
const text = host.read(filePath);
if (!text) {
throw new Error(`Couldn't find the module nor its routing module.`);
}
const relativePath = buildRelativeComponentPath(options, routingModulePath);
const classifiedName = strings.classify(`${options.pageName}Component`);
if (!options.entryComponent) {
addImportDeclaration(host, routingModulePath, classifiedName, relativePath);
// Update component routes array
const source = readIntoSourceFile(host, routingModulePath);
const componentRoute = `{ path: '${options.name}', component: ${classifiedName} }`;
const addDeclaration = addRouteDeclarationToModule(
source,
filePath,
componentRoute,
options.standalone,
true
) as InsertChange;
const recorder = host.beginUpdate(filePath);
recorder.insertLeft(addDeclaration.pos, addDeclaration.toAdd);
host.commitUpdate(recorder);
}
return host;
};
}
/**
* Indents the text content with the amount of specified spaces. The spaces will be added after
* every line-break. This utility function can be used inside of EJS templates to properly
* include the additional files.
*/
function indentTextContent(text: string, numSpaces: number): string {
// In the Material project there should be only LF line-endings, but the schematic files
// are not being linted and therefore there can be also CRLF or just CR line-endings.
return text.replace(/(\r\n|\r|\n)/g, `$1${' '.repeat(numSpaces)}`);
}
/**
* Rule that copies and interpolates the files that belong to this schematic context. Additionally
* a list of file paths can be passed to this rule in order to expose them inside the EJS
* template context.
*
* This allows inlining the external template or stylesheet files in EJS without having
* to manually duplicate the file content.
*/
export function buildComponent(
options: ComponentOptions,
additionalFiles: Record<string, string> = {}
): Rule {
return async (host: Tree, context: FileSystemSchematicContext) => {
const workspace = await getWorkspace(host);
const project = getProjectFromWorkspace(workspace, options.project);
const mainFilePath = getProjectMainFile(project);
const defaultComponentOptions = getDefaultComponentOptions(project) as any;
options.standalone = isStandaloneApp(host, mainFilePath);
// TODO(devversion): Remove if we drop support for older CLI versions.
// This handles an unreported breaking change from the @angular-devkit/schematics. Previously
// the description path resolved to the factory file, but starting from 6.2.0, it resolves
// to the factory directory.
const schematicPath = statSync(context.schematic.description.path).isDirectory()
? context.schematic.description.path
: dirname(context.schematic.description.path);
const schematicFilesUrl = './files';
const schematicFilesPath = resolve(schematicPath, schematicFilesUrl);
// Add the default component option values to the options if an option is not explicitly
// specified but a default component option is available.
Object.keys(options)
.filter(
optionName => (options as any)[optionName] == null && defaultComponentOptions[optionName]
)
.forEach(optionName => ((options as any)[optionName] = defaultComponentOptions[optionName]));
if (options.path === undefined) {
options.path = buildDefaultPath(project);
// Fix default path (i.e. `src/app/routes/{{modulePath}}`)
options.path += `/${options.moduleRoot}/${options.module}`;
}
options.pageName = buildPageName(options) || '';
if (options.standalone) {
options.moduleExt = '.routes.ts';
}
options.module = findModuleFromOptions(host, options) || '';
// Route module path
const routingModulePath = options.standalone
? options.module
: options.module.replace('.module', '-routing.module');
const parsedPath = parseName(options.path, options.name);
options.name = parsedPath.name;
options.path = parsedPath.path;
options.selector = options.selector || buildSelector(options, project.prefix);
validateHtmlSelector(options.selector);
// In case the specified style extension is not part of the supported CSS supersets,
// we generate the stylesheets with the "css" extension. This ensures that we don't
// accidentally generate invalid stylesheets (e.g. drag-drop-comp.styl) which will
// break the Angular CLI project. See: https://github.com/angular/components/issues/15164
if (!supportedCssExtensions.includes(options.style!)) {
options.style = Style.Css;
}
// Object that will be used as context for the EJS templates.
const baseTemplateContext = {
...strings,
'if-flat': (s: string) => (options.flat ? '' : s),
...options,
};
// Key-value object that includes the specified additional files with their loaded content.
// The resolved contents can be used inside EJS templates.
const resolvedFiles: any = {};
for (const key in additionalFiles) {
if (additionalFiles[key]) {
const fileContent = readFileSync(join(schematicFilesPath, additionalFiles[key]), 'utf-8');
// Interpolate the additional files with the base EJS template context.
resolvedFiles[key] = interpolateTemplate(fileContent)(baseTemplateContext);
}
}
const templateSource = apply(url(schematicFilesUrl), [
options.skipTests ? filter(path => !path.endsWith('.spec.ts.template')) : noop(),
options.inlineStyle ? filter(path => !path.endsWith('.__style__.template')) : noop(),
options.inlineTemplate ? filter(path => !path.endsWith('.html.template')) : noop(),
// Treat the template options as any, because the type definition for the template options
// is made unnecessarily explicit. Every type of object can be used in the EJS template.
applyTemplates({ indentTextContent, resolvedFiles, ...baseTemplateContext }),
// TODO(devversion): figure out why we cannot just remove the first parameter
// See for example: angular-cli#schematics/angular/component/index.ts#L160
move(null as any, parsedPath.path),
]);
return () =>
chain([
branchAndMerge(
chain([
options.standalone ? noop() : addDeclarationToNgModule(options),
addRouteDeclarationToNgModule(options, routingModulePath),
mergeWith(templateSource),
])
),
])(host, context);
};
}
/**
* Rewrite [findModuleFromOptions](https://github.com/angular/angular-cli/blob/main/packages/schematics/angular/utility/find-module.ts#L29) for standalone module
*
* Find the module referred by a set of options passed to the schematics.
*
* - module: `*.module.ts`, `*-routing.module.ts`
* - standalone: `*.routes.ts`
*/
export function findModuleFromOptions(host: Tree, options: ComponentOptions): Path | undefined {
if (options.skipImport) {
return undefined;
}
const moduleExt = options.moduleExt || MODULE_EXT;
const routingModuleExt = options.routingModuleExt || ROUTING_MODULE_EXT;
if (!options.module) {
const pathToCheck = (options.path || '') + '/' + options.name;
return normalize(findModule(host, pathToCheck, moduleExt, routingModuleExt));
} else {
const modulePath = normalize(`/${options.path}/${options.module}`);
const componentPath = normalize(`/${options.path}/${options.name}`);
const moduleBaseName = normalize(modulePath).split('/').pop();
const candidateSet = new Set<Path>([normalize(options.path || '/')]);
for (let dir = modulePath; dir != NormalizedRoot; dir = dirname(dir) as any) {
candidateSet.add(dir);
}
for (let dir = componentPath; dir != NormalizedRoot; dir = dirname(dir) as any) {
candidateSet.add(dir);
}
const candidatesDirs = [...candidateSet].sort((a, b) => b.length - a.length);
for (const c of candidatesDirs) {
const candidateFiles = ['', `${moduleBaseName}.ts`, `${moduleBaseName}${moduleExt}`].map(x =>
join(c, x)
);
for (const sc of candidateFiles) {
if (host.exists(sc)) {
return normalize(sc);
}
}
}
throw new Error(
`Specified module '${options.module}' does not exist.\n` +
`Looked in the following directories:\n ${candidatesDirs.join('\n ')}`
);
}
}