Skip to content

Commit

Permalink
refactor(schematics): properly add font stylesheets (#12636)
Browse files Browse the repository at this point in the history
* 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)
  • Loading branch information
devversion authored and mmalerba committed Aug 14, 2018
1 parent 4d430bd commit 9169c05
Show file tree
Hide file tree
Showing 12 changed files with 227 additions and 157 deletions.
63 changes: 63 additions & 0 deletions src/lib/schematics/install/fonts/head-element.ts
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions src/lib/schematics/install/fonts/material-fonts.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
21 changes: 21 additions & 0 deletions src/lib/schematics/install/fonts/project-index-html.ts
Original file line number Diff line number Diff line change
@@ -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.');
}
16 changes: 11 additions & 5 deletions src/lib/schematics/install/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();

expect(indexSrc.indexOf('fonts.googleapis.com')).toBeGreaterThan(-1);
const buffer = tree.read(indexPath)!;
const htmlContent = buffer.toString();

// 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"');
});
});
76 changes: 34 additions & 42 deletions src/lib/schematics/install/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 * as parse5 from 'parse5';
import {addModuleImportToRootModule} from '../utils/ast';
import {getProjectFromWorkspace} from '../utils/get-project';
import {addHeadLink} from '../utils/html';
import {addPackageToPackageJson, getPackageVersionFromPackageJson} from '../utils/package';
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';
import * as parse5 from 'parse5';
import {addThemeToAppStyles} from './theming/theming';
import {materialVersion, requiredAngularVersion} from './version-names';

/**
* Scaffolds the basics of a Angular Material application, this includes:
Expand All @@ -43,7 +43,7 @@ export default function(options: Schema): Rule {
addThemeToAppStyles(options),
addAnimationRootConfig(options),
addFontsToIndex(options),
addBodyMarginToStyles(options),
addMaterialAppStyles(options),
]);
}

Expand All @@ -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;
};
}
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.`);
}

/** 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 htmlContent = buffer.toString();
const insertion = '\n' +
`html, body { height: 100%; }\n` +
`body { margin: 0; font-family: 'Roboto', sans-serif; }\n`;

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 (htmlContent.includes(insertion)) {
return;
}

const recorder = host.beginUpdate(styleFilePath);

recorder.insertLeft(htmlContent.length, insertion);
host.commitUpdate(recorder);
};
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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) {
Expand Down
40 changes: 2 additions & 38 deletions src/lib/schematics/utils/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 9169c05

Please sign in to comment.