refactor(schematics): properly add font stylesheets (#12636)

* refactor(schematics): properly add font stylesheets

* No longer just inserts the Material fonts (icon font and Roboto) at the beginning of the `<head>` element. The link elements should be appended and properly indented.
* Moves utils that are specific to the `install` / `ng-add` schematic into the given schematic folder instead of having them in the global schematic utility folder.
* Fixes that multiple runs of `ng add @angular/material` result in duplicate styles in the project `styles.ext` file.

* Improved head element find logic (BFS)
This commit is contained in:
Paul Gschwendtner
2018-08-15 00:53:22 +02:00
committed by mmalerba
parent 4d430bd146
commit 9169c0593b
12 changed files with 229 additions and 159 deletions
@@ -0,0 +1,63 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {SchematicsException, Tree} from '@angular-devkit/schematics';
import {WorkspaceProject} from '@schematics/angular/utility/config';
import {DefaultTreeDocument, DefaultTreeElement, parse as parseHtml} from 'parse5';
import {getChildElementIndentation} from '../../utils/parse5-element';
import {getIndexHtmlPath} from './project-index-html';
/** Appends the given element HTML fragment to the index.html head tag. */
export function appendElementToHead(host: Tree, project: WorkspaceProject, elementHtml: string) {
const indexPath = getIndexHtmlPath(project);
const indexHtmlBuffer = host.read(indexPath);
if (!indexHtmlBuffer) {
throw new SchematicsException(`Could not find file for path: ${indexPath}`);
}
const htmlContent = indexHtmlBuffer.toString();
if (htmlContent.includes(elementHtml)) {
return;
}
const headTag = getHeadTagElement(htmlContent);
if (!headTag) {
throw `Could not find '<head>' element in HTML file: ${indexPath}`;
}
const endTagOffset = headTag.sourceCodeLocation.endTag.startOffset;
const indentationOffset = getChildElementIndentation(headTag);
const insertion = `${' '.repeat(indentationOffset)}${elementHtml}`;
const recordedChange = host
.beginUpdate(indexPath)
.insertRight(endTagOffset, `${insertion}\n`);
host.commitUpdate(recordedChange);
}
/** Parses the given HTML file and returns the head element if available. */
export function getHeadTagElement(src: string): DefaultTreeElement | null {
const document = parseHtml(src, {sourceCodeLocationInfo: true}) as DefaultTreeDocument;
const nodeQueue = [...document.childNodes];
while (nodeQueue.length) {
const node = nodeQueue.shift() as DefaultTreeElement;
if (node.nodeName.toLowerCase() === 'head') {
return node;
} else if (node.childNodes) {
nodeQueue.push(...node.childNodes);
}
}
return null;
}
@@ -0,0 +1,30 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Tree} from '@angular-devkit/schematics';
import {getWorkspace} from '@schematics/angular/utility/config';
import {getProjectFromWorkspace} from '../../utils/get-project';
import {Schema} from '../schema';
import {appendElementToHead} from './head-element';
/** Adds the Material Design fonts to the index HTML file. */
export function addFontsToIndex(options: Schema): (host: Tree) => Tree {
return (host: Tree) => {
const workspace = getWorkspace(host);
const project = getProjectFromWorkspace(workspace, options.project);
const fonts = [
'https://fonts.googleapis.com/css?family=Roboto:300,400,500',
'https://fonts.googleapis.com/icon?family=Material+Icons',
];
fonts.forEach(f => appendElementToHead(host, project, `<link href="${f}" rel="stylesheet">`));
return host;
};
}
@@ -0,0 +1,21 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {SchematicsException} from '@angular-devkit/schematics';
import {WorkspaceProject} from '@schematics/angular/utility/config';
/** Looks for the index HTML file in the given project and returns its path. */
export function getIndexHtmlPath(project: WorkspaceProject): string {
const buildTarget = project.architect.build.options;
if (buildTarget.index && buildTarget.index.endsWith('index.html')) {
return buildTarget.index;
}
throw new SchematicsException('No index.html file was found.');
}
+10 -4
View File
@@ -1,10 +1,10 @@
import {Tree} from '@angular-devkit/schematics';
import {SchematicTestRunner} from '@angular-devkit/schematics/testing';
import {getIndexHtmlPath} from './fonts/project-index-html';
import {getProjectFromWorkspace} from '../utils/get-project';
import {getFileContent} from '@schematics/angular/utility/test';
import {collectionPath, createTestApp} from '../test-setup/test-app';
import {getWorkspace} from '@schematics/angular/utility/config';
import {getIndexHtmlPath} from '../utils/ast';
import {normalize} from '@angular-devkit/core';
describe('material-install-schematic', () => {
@@ -65,9 +65,15 @@ describe('material-install-schematic', () => {
const project = getProjectFromWorkspace(workspace);
const indexPath = getIndexHtmlPath(project);
const buffer: any = tree.read(indexPath);
const indexSrc = buffer.toString();
const buffer = tree.read(indexPath)!;
const htmlContent = buffer.toString();
expect(indexSrc.indexOf('fonts.googleapis.com')).toBeGreaterThan(-1);
// Ensure that the indentation has been determined properly. We want to make sure that
// the created links properly align with the existing HTML. Default CLI projects use an
// indentation of two columns.
expect(htmlContent).toContain(
' <link href="https://fonts.googleapis.com/icon?family=Material+Icons"');
expect(htmlContent).toContain(
' <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"');
});
});
+37 -45
View File
@@ -15,16 +15,16 @@ import {
Tree,
} from '@angular-devkit/schematics';
import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks';
import {InsertChange} from '@schematics/angular/utility/change';
import {getWorkspace} from '@schematics/angular/utility/config';
import {materialVersion, requiredAngularVersion} from './version-names';
import {addModuleImportToRootModule, getStylesPath} from '../utils/ast';
import {getProjectFromWorkspace} from '../utils/get-project';
import {addHeadLink} from '../utils/html';
import {addPackageToPackageJson, getPackageVersionFromPackageJson} from '../utils/package';
import {Schema} from './schema';
import {addThemeToAppStyles} from './theming';
import * as parse5 from 'parse5';
import {addModuleImportToRootModule} from '../utils/ast';
import {getProjectFromWorkspace} from '../utils/get-project';
import {addPackageToPackageJson, getPackageVersionFromPackageJson} from '../utils/package-json';
import {getProjectStyleFile} from '../utils/project-style-file';
import {addFontsToIndex} from './fonts/material-fonts';
import {Schema} from './schema';
import {addThemeToAppStyles} from './theming/theming';
import {materialVersion, requiredAngularVersion} from './version-names';
/**
* Scaffolds the basics of a Angular Material application, this includes:
@@ -43,7 +43,7 @@ export default function(options: Schema): Rule {
addThemeToAppStyles(options),
addAnimationRootConfig(options),
addFontsToIndex(options),
addBodyMarginToStyles(options),
addMaterialAppStyles(options),
]);
}
@@ -66,56 +66,48 @@ function addMaterialToPackageJson() {
};
}
/** Add browser animation module to app.module */
/** Add browser animation module to the app module file. */
function addAnimationRootConfig(options: Schema) {
return (host: Tree) => {
const workspace = getWorkspace(host);
const project = getProjectFromWorkspace(workspace, options.project);
addModuleImportToRootModule(
host,
'BrowserAnimationsModule',
'@angular/platform-browser/animations',
project);
addModuleImportToRootModule(host, 'BrowserAnimationsModule',
'@angular/platform-browser/animations', project);
return host;
};
}
/** Adds fonts to the index.ext file */
function addFontsToIndex(options: Schema) {
/**
* Adds custom Material styles to the project style file. The custom CSS sets up the Roboto font
* and reset the default browser body margin.
*/
function addMaterialAppStyles(options: Schema) {
return (host: Tree) => {
const workspace = getWorkspace(host);
const project = getProjectFromWorkspace(workspace, options.project);
const styleFilePath = getProjectStyleFile(project);
const buffer = host.read(styleFilePath);
const fonts = [
'https://fonts.googleapis.com/css?family=Roboto:300,400,500',
'https://fonts.googleapis.com/icon?family=Material+Icons',
];
fonts.forEach(f => addHeadLink(host, project, `\n<link href="${f}" rel="stylesheet">`));
return host;
};
}
/** Add 0 margin to body in styles.ext */
function addBodyMarginToStyles(options: Schema) {
return (host: Tree) => {
const workspace = getWorkspace(host);
const project = getProjectFromWorkspace(workspace, options.project);
const stylesPath = getStylesPath(project);
const buffer = host.read(stylesPath);
if (buffer) {
const src = buffer.toString();
const insertion = new InsertChange(stylesPath, src.length,
`\nhtml, body { height: 100%; }\nbody { margin: 0; font-family: 'Roboto', sans-serif; }\n`);
const recorder = host.beginUpdate(stylesPath);
recorder.insertLeft(insertion.pos, insertion.toAdd);
host.commitUpdate(recorder);
} else {
console.warn(`Skipped body reset; could not find file: ${stylesPath}`);
if (!buffer) {
return console.warn(`Could not find styles file: "${styleFilePath}". Skipping styles ` +
`generation. Please consider manually adding the "Roboto" font and resetting the ` +
`body margin.`);
}
const htmlContent = buffer.toString();
const insertion = '\n' +
`html, body { height: 100%; }\n` +
`body { margin: 0; font-family: 'Roboto', sans-serif; }\n`;
if (htmlContent.includes(insertion)) {
return;
}
const recorder = host.beginUpdate(styleFilePath);
recorder.insertLeft(htmlContent.length, insertion);
host.commitUpdate(recorder);
};
}
@@ -9,10 +9,10 @@
import {SchematicsException, Tree} from '@angular-devkit/schematics';
import {InsertChange} from '@schematics/angular/utility/change';
import {getWorkspace, WorkspaceProject, WorkspaceSchema} from '@schematics/angular/utility/config';
import {getStylesPath} from '../utils/ast';
import {getProjectFromWorkspace} from '../utils/get-project';
import {getProjectFromWorkspace} from '../../utils/get-project';
import {getProjectStyleFile} from '../../utils/project-style-file';
import {Schema} from '../schema';
import {createCustomTheme} from './custom-theme';
import {Schema} from './schema';
/** Add pre-built styles to the main project style file. */
@@ -38,7 +38,7 @@ export function addThemeToAppStyles(options: Schema): (host: Tree) => Tree {
/** Insert a custom theme to styles.scss file. */
function insertCustomTheme(project: WorkspaceProject, projectName: string, host: Tree) {
const stylesPath = getStylesPath(project);
const stylesPath = getProjectStyleFile(project);
const buffer = host.read(stylesPath);
if (buffer) {
+2 -38
View File
@@ -6,13 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/
import {normalize} from '@angular-devkit/core';
import {SchematicsException, Tree} from '@angular-devkit/schematics';
import {addImportToModule} from '@schematics/angular/utility/ast-utils';
import {InsertChange} from '@schematics/angular/utility/change';
import {WorkspaceProject, getWorkspace} from '@schematics/angular/utility/config';
import {getAppModulePath} from '@schematics/angular/utility/ng-ast-utils';
import {getWorkspace, WorkspaceProject} from '@schematics/angular/utility/config';
import {findModuleFromOptions as internalFindModule} from '@schematics/angular/utility/find-module';
import {getAppModulePath} from '@schematics/angular/utility/ng-ast-utils';
import * as ts from 'typescript';
@@ -61,41 +60,6 @@ export function addModuleImportToModule(
host.commitUpdate(recorder);
}
/** Gets the app index.html file */
export function getIndexHtmlPath(project: WorkspaceProject): string {
const buildTarget = project.architect.build.options;
if (buildTarget.index && buildTarget.index.endsWith('index.html')) {
return buildTarget.index;
}
throw new SchematicsException('No index.html file was found.');
}
/** Get the root stylesheet file. */
export function getStylesPath(project: WorkspaceProject): string {
const buildTarget = project.architect['build'];
if (buildTarget.options && buildTarget.options.styles && buildTarget.options.styles.length) {
const styles = buildTarget.options.styles.map(s => typeof s === 'string' ? s : s.input);
// First, see if any of the assets is called "styles.(le|sc|c)ss", which is the default
// "main" style sheet.
const defaultMainStylePath = styles.find(a => /styles\.(c|le|sc)ss/.test(a));
if (defaultMainStylePath) {
return normalize(defaultMainStylePath);
}
// If there was no obvious default file, use the first style asset.
const fallbackStylePath = styles.find(a => /\.(c|le|sc)ss/.test(a));
if (fallbackStylePath) {
return normalize(fallbackStylePath);
}
}
throw new SchematicsException('No style files could be found into which a theme could be added');
}
/** Wraps the internal find module from options with undefined path handling */
export function findModuleFromOptions(host: Tree, options: any) {
const workspace = getWorkspace(host);
-68
View File
@@ -1,68 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Tree, SchematicsException} from '@angular-devkit/schematics';
import {parse as parseHtml, DefaultTreeDocument, DefaultTreeElement} from 'parse5';
import {getIndexHtmlPath} from './ast';
import {InsertChange} from '@schematics/angular/utility/change';
import {WorkspaceProject} from '@schematics/angular/utility/config';
/**
* Parses the index.html file to get the HEAD tag position.
* @param src the src path of the html file to parse
*/
export function getHeadTag(src: string) {
const document = parseHtml(src, {sourceCodeLocationInfo: true}) as DefaultTreeDocument;
let head: DefaultTreeElement;
const visitNodes = nodes => {
nodes.forEach(node => {
if (node.tagName === 'head') {
head = node;
} else {
if (node.childNodes) {
visitNodes(node.childNodes);
}
}
});
};
visitNodes(document.childNodes);
if (!head) {
throw new SchematicsException('Head element could not be found!');
}
return {
position: head.sourceCodeLocation.startTag.endOffset
};
}
/**
* Adds a link to the index.html head tag Example:
* `<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">`
* @param host The tree we are updating
* @param project The project we're targeting.
* @param link html element string we are inserting.
*/
export function addHeadLink(host: Tree, project: WorkspaceProject, link: string) {
const indexPath = getIndexHtmlPath(project);
const buffer = host.read(indexPath);
if (!buffer) {
throw new SchematicsException(`Could not find file for path: ${indexPath}`);
}
const src = buffer.toString();
if (src.indexOf(link) === -1) {
const node = getHeadTag(src);
const insertion = new InsertChange(indexPath, node.position, link);
const recorder = host.beginUpdate(indexPath);
recorder.insertLeft(insertion.pos, insertion.toAdd);
host.commitUpdate(recorder);
}
}
@@ -0,0 +1,27 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {DefaultTreeElement} from 'parse5';
/** Determines the indentation of child elements for the given Parse5 element. */
export function getChildElementIndentation(element: DefaultTreeElement) {
const childElement = element.childNodes
.find(node => node['tagName']) as DefaultTreeElement | null;
const startColumns = childElement ?
// In case there are child elements inside of the element, we assume that their
// indentation is also applicable for other child elements.
childElement.sourceCodeLocation.startCol :
// In case there is no child element, we just assume that child elements should be indented
// by two spaces.
element.sourceCodeLocation.startCol + 2;
// Since Parse5 does not set the `startCol` properties as zero-based, we need to subtract
// one column in order to have a proper zero-based offset for the indentation.
return startColumns - 1;
}
@@ -0,0 +1,35 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {normalize} from '@angular-devkit/core';
import {SchematicsException} from '@angular-devkit/schematics';
import {WorkspaceProject} from '@schematics/angular/utility/config';
/** Looks for the primary style file for a given project and returns its path. */
export function getProjectStyleFile(project: WorkspaceProject): string {
const buildTarget = project.architect['build'];
if (buildTarget.options && buildTarget.options.styles && buildTarget.options.styles.length) {
const styles = buildTarget.options.styles.map(s => typeof s === 'string' ? s : s.input);
// First, see if any of the assets is called "styles.(le|sc|c)ss", which is the default
// "main" style sheet.
const defaultMainStylePath = styles.find(a => /styles\.(c|le|sc)ss/.test(a));
if (defaultMainStylePath) {
return normalize(defaultMainStylePath);
}
// If there was no obvious default file, use the first style asset.
const fallbackStylePath = styles.find(a => /\.(c|le|sc)ss/.test(a));
if (fallbackStylePath) {
return normalize(fallbackStylePath);
}
}
throw new SchematicsException('No style files could be found into which a theme could be added.');
}