Skip to content

Commit 9169c05

Browse files
devversionmmalerba
authored andcommitted
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)
1 parent 4d430bd commit 9169c05

File tree

12 files changed

+227
-157
lines changed

12 files changed

+227
-157
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {SchematicsException, Tree} from '@angular-devkit/schematics';
10+
import {WorkspaceProject} from '@schematics/angular/utility/config';
11+
import {DefaultTreeDocument, DefaultTreeElement, parse as parseHtml} from 'parse5';
12+
import {getChildElementIndentation} from '../../utils/parse5-element';
13+
import {getIndexHtmlPath} from './project-index-html';
14+
15+
/** Appends the given element HTML fragment to the index.html head tag. */
16+
export function appendElementToHead(host: Tree, project: WorkspaceProject, elementHtml: string) {
17+
const indexPath = getIndexHtmlPath(project);
18+
const indexHtmlBuffer = host.read(indexPath);
19+
20+
if (!indexHtmlBuffer) {
21+
throw new SchematicsException(`Could not find file for path: ${indexPath}`);
22+
}
23+
24+
const htmlContent = indexHtmlBuffer.toString();
25+
26+
if (htmlContent.includes(elementHtml)) {
27+
return;
28+
}
29+
30+
const headTag = getHeadTagElement(htmlContent);
31+
32+
if (!headTag) {
33+
throw `Could not find '<head>' element in HTML file: ${indexPath}`;
34+
}
35+
36+
const endTagOffset = headTag.sourceCodeLocation.endTag.startOffset;
37+
const indentationOffset = getChildElementIndentation(headTag);
38+
const insertion = `${' '.repeat(indentationOffset)}${elementHtml}`;
39+
40+
const recordedChange = host
41+
.beginUpdate(indexPath)
42+
.insertRight(endTagOffset, `${insertion}\n`);
43+
44+
host.commitUpdate(recordedChange);
45+
}
46+
47+
/** Parses the given HTML file and returns the head element if available. */
48+
export function getHeadTagElement(src: string): DefaultTreeElement | null {
49+
const document = parseHtml(src, {sourceCodeLocationInfo: true}) as DefaultTreeDocument;
50+
const nodeQueue = [...document.childNodes];
51+
52+
while (nodeQueue.length) {
53+
const node = nodeQueue.shift() as DefaultTreeElement;
54+
55+
if (node.nodeName.toLowerCase() === 'head') {
56+
return node;
57+
} else if (node.childNodes) {
58+
nodeQueue.push(...node.childNodes);
59+
}
60+
}
61+
62+
return null;
63+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Tree} from '@angular-devkit/schematics';
10+
import {getWorkspace} from '@schematics/angular/utility/config';
11+
import {getProjectFromWorkspace} from '../../utils/get-project';
12+
import {Schema} from '../schema';
13+
import {appendElementToHead} from './head-element';
14+
15+
/** Adds the Material Design fonts to the index HTML file. */
16+
export function addFontsToIndex(options: Schema): (host: Tree) => Tree {
17+
return (host: Tree) => {
18+
const workspace = getWorkspace(host);
19+
const project = getProjectFromWorkspace(workspace, options.project);
20+
21+
const fonts = [
22+
'https://fonts.googleapis.com/css?family=Roboto:300,400,500',
23+
'https://fonts.googleapis.com/icon?family=Material+Icons',
24+
];
25+
26+
fonts.forEach(f => appendElementToHead(host, project, `<link href="${f}" rel="stylesheet">`));
27+
28+
return host;
29+
};
30+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {SchematicsException} from '@angular-devkit/schematics';
10+
import {WorkspaceProject} from '@schematics/angular/utility/config';
11+
12+
/** Looks for the index HTML file in the given project and returns its path. */
13+
export function getIndexHtmlPath(project: WorkspaceProject): string {
14+
const buildTarget = project.architect.build.options;
15+
16+
if (buildTarget.index && buildTarget.index.endsWith('index.html')) {
17+
return buildTarget.index;
18+
}
19+
20+
throw new SchematicsException('No index.html file was found.');
21+
}

src/lib/schematics/install/index.spec.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import {Tree} from '@angular-devkit/schematics';
22
import {SchematicTestRunner} from '@angular-devkit/schematics/testing';
3+
import {getIndexHtmlPath} from './fonts/project-index-html';
34
import {getProjectFromWorkspace} from '../utils/get-project';
45
import {getFileContent} from '@schematics/angular/utility/test';
56
import {collectionPath, createTestApp} from '../test-setup/test-app';
67
import {getWorkspace} from '@schematics/angular/utility/config';
7-
import {getIndexHtmlPath} from '../utils/ast';
88
import {normalize} from '@angular-devkit/core';
99

1010
describe('material-install-schematic', () => {
@@ -65,9 +65,15 @@ describe('material-install-schematic', () => {
6565
const project = getProjectFromWorkspace(workspace);
6666

6767
const indexPath = getIndexHtmlPath(project);
68-
const buffer: any = tree.read(indexPath);
69-
const indexSrc = buffer.toString();
70-
71-
expect(indexSrc.indexOf('fonts.googleapis.com')).toBeGreaterThan(-1);
68+
const buffer = tree.read(indexPath)!;
69+
const htmlContent = buffer.toString();
70+
71+
// Ensure that the indentation has been determined properly. We want to make sure that
72+
// the created links properly align with the existing HTML. Default CLI projects use an
73+
// indentation of two columns.
74+
expect(htmlContent).toContain(
75+
' <link href="https://fonts.googleapis.com/icon?family=Material+Icons"');
76+
expect(htmlContent).toContain(
77+
' <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"');
7278
});
7379
});

src/lib/schematics/install/index.ts

Lines changed: 34 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ import {
1515
Tree,
1616
} from '@angular-devkit/schematics';
1717
import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks';
18-
import {InsertChange} from '@schematics/angular/utility/change';
1918
import {getWorkspace} from '@schematics/angular/utility/config';
20-
import {materialVersion, requiredAngularVersion} from './version-names';
21-
import {addModuleImportToRootModule, getStylesPath} from '../utils/ast';
19+
import * as parse5 from 'parse5';
20+
import {addModuleImportToRootModule} from '../utils/ast';
2221
import {getProjectFromWorkspace} from '../utils/get-project';
23-
import {addHeadLink} from '../utils/html';
24-
import {addPackageToPackageJson, getPackageVersionFromPackageJson} from '../utils/package';
22+
import {addPackageToPackageJson, getPackageVersionFromPackageJson} from '../utils/package-json';
23+
import {getProjectStyleFile} from '../utils/project-style-file';
24+
import {addFontsToIndex} from './fonts/material-fonts';
2525
import {Schema} from './schema';
26-
import {addThemeToAppStyles} from './theming';
27-
import * as parse5 from 'parse5';
26+
import {addThemeToAppStyles} from './theming/theming';
27+
import {materialVersion, requiredAngularVersion} from './version-names';
2828

2929
/**
3030
* Scaffolds the basics of a Angular Material application, this includes:
@@ -43,7 +43,7 @@ export default function(options: Schema): Rule {
4343
addThemeToAppStyles(options),
4444
addAnimationRootConfig(options),
4545
addFontsToIndex(options),
46-
addBodyMarginToStyles(options),
46+
addMaterialAppStyles(options),
4747
]);
4848
}
4949

@@ -66,56 +66,48 @@ function addMaterialToPackageJson() {
6666
};
6767
}
6868

69-
/** Add browser animation module to app.module */
69+
/** Add browser animation module to the app module file. */
7070
function addAnimationRootConfig(options: Schema) {
7171
return (host: Tree) => {
7272
const workspace = getWorkspace(host);
7373
const project = getProjectFromWorkspace(workspace, options.project);
7474

75-
addModuleImportToRootModule(
76-
host,
77-
'BrowserAnimationsModule',
78-
'@angular/platform-browser/animations',
79-
project);
75+
addModuleImportToRootModule(host, 'BrowserAnimationsModule',
76+
'@angular/platform-browser/animations', project);
8077

8178
return host;
8279
};
8380
}
8481

85-
/** Adds fonts to the index.ext file */
86-
function addFontsToIndex(options: Schema) {
82+
/**
83+
* Adds custom Material styles to the project style file. The custom CSS sets up the Roboto font
84+
* and reset the default browser body margin.
85+
*/
86+
function addMaterialAppStyles(options: Schema) {
8787
return (host: Tree) => {
8888
const workspace = getWorkspace(host);
8989
const project = getProjectFromWorkspace(workspace, options.project);
90+
const styleFilePath = getProjectStyleFile(project);
91+
const buffer = host.read(styleFilePath);
9092

91-
const fonts = [
92-
'https://fonts.googleapis.com/css?family=Roboto:300,400,500',
93-
'https://fonts.googleapis.com/icon?family=Material+Icons',
94-
];
95-
96-
fonts.forEach(f => addHeadLink(host, project, `\n<link href="${f}" rel="stylesheet">`));
97-
return host;
98-
};
99-
}
93+
if (!buffer) {
94+
return console.warn(`Could not find styles file: "${styleFilePath}". Skipping styles ` +
95+
`generation. Please consider manually adding the "Roboto" font and resetting the ` +
96+
`body margin.`);
97+
}
10098

101-
/** Add 0 margin to body in styles.ext */
102-
function addBodyMarginToStyles(options: Schema) {
103-
return (host: Tree) => {
104-
const workspace = getWorkspace(host);
105-
const project = getProjectFromWorkspace(workspace, options.project);
99+
const htmlContent = buffer.toString();
100+
const insertion = '\n' +
101+
`html, body { height: 100%; }\n` +
102+
`body { margin: 0; font-family: 'Roboto', sans-serif; }\n`;
106103

107-
const stylesPath = getStylesPath(project);
108-
109-
const buffer = host.read(stylesPath);
110-
if (buffer) {
111-
const src = buffer.toString();
112-
const insertion = new InsertChange(stylesPath, src.length,
113-
`\nhtml, body { height: 100%; }\nbody { margin: 0; font-family: 'Roboto', sans-serif; }\n`);
114-
const recorder = host.beginUpdate(stylesPath);
115-
recorder.insertLeft(insertion.pos, insertion.toAdd);
116-
host.commitUpdate(recorder);
117-
} else {
118-
console.warn(`Skipped body reset; could not find file: ${stylesPath}`);
104+
if (htmlContent.includes(insertion)) {
105+
return;
119106
}
107+
108+
const recorder = host.beginUpdate(styleFilePath);
109+
110+
recorder.insertLeft(htmlContent.length, insertion);
111+
host.commitUpdate(recorder);
120112
};
121113
}

src/lib/schematics/install/theming.ts renamed to src/lib/schematics/install/theming/theming.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import {SchematicsException, Tree} from '@angular-devkit/schematics';
1010
import {InsertChange} from '@schematics/angular/utility/change';
1111
import {getWorkspace, WorkspaceProject, WorkspaceSchema} from '@schematics/angular/utility/config';
12-
import {getStylesPath} from '../utils/ast';
13-
import {getProjectFromWorkspace} from '../utils/get-project';
12+
import {getProjectFromWorkspace} from '../../utils/get-project';
13+
import {getProjectStyleFile} from '../../utils/project-style-file';
14+
import {Schema} from '../schema';
1415
import {createCustomTheme} from './custom-theme';
15-
import {Schema} from './schema';
1616

1717

1818
/** Add pre-built styles to the main project style file. */
@@ -38,7 +38,7 @@ export function addThemeToAppStyles(options: Schema): (host: Tree) => Tree {
3838

3939
/** Insert a custom theme to styles.scss file. */
4040
function insertCustomTheme(project: WorkspaceProject, projectName: string, host: Tree) {
41-
const stylesPath = getStylesPath(project);
41+
const stylesPath = getProjectStyleFile(project);
4242
const buffer = host.read(stylesPath);
4343

4444
if (buffer) {

src/lib/schematics/utils/ast.ts

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {normalize} from '@angular-devkit/core';
109
import {SchematicsException, Tree} from '@angular-devkit/schematics';
1110
import {addImportToModule} from '@schematics/angular/utility/ast-utils';
1211
import {InsertChange} from '@schematics/angular/utility/change';
13-
import {WorkspaceProject, getWorkspace} from '@schematics/angular/utility/config';
14-
import {getAppModulePath} from '@schematics/angular/utility/ng-ast-utils';
12+
import {getWorkspace, WorkspaceProject} from '@schematics/angular/utility/config';
1513
import {findModuleFromOptions as internalFindModule} from '@schematics/angular/utility/find-module';
14+
import {getAppModulePath} from '@schematics/angular/utility/ng-ast-utils';
1615
import * as ts from 'typescript';
1716

1817

@@ -61,41 +60,6 @@ export function addModuleImportToModule(
6160
host.commitUpdate(recorder);
6261
}
6362

64-
/** Gets the app index.html file */
65-
export function getIndexHtmlPath(project: WorkspaceProject): string {
66-
const buildTarget = project.architect.build.options;
67-
68-
if (buildTarget.index && buildTarget.index.endsWith('index.html')) {
69-
return buildTarget.index;
70-
}
71-
72-
throw new SchematicsException('No index.html file was found.');
73-
}
74-
75-
/** Get the root stylesheet file. */
76-
export function getStylesPath(project: WorkspaceProject): string {
77-
const buildTarget = project.architect['build'];
78-
79-
if (buildTarget.options && buildTarget.options.styles && buildTarget.options.styles.length) {
80-
const styles = buildTarget.options.styles.map(s => typeof s === 'string' ? s : s.input);
81-
82-
// First, see if any of the assets is called "styles.(le|sc|c)ss", which is the default
83-
// "main" style sheet.
84-
const defaultMainStylePath = styles.find(a => /styles\.(c|le|sc)ss/.test(a));
85-
if (defaultMainStylePath) {
86-
return normalize(defaultMainStylePath);
87-
}
88-
89-
// If there was no obvious default file, use the first style asset.
90-
const fallbackStylePath = styles.find(a => /\.(c|le|sc)ss/.test(a));
91-
if (fallbackStylePath) {
92-
return normalize(fallbackStylePath);
93-
}
94-
}
95-
96-
throw new SchematicsException('No style files could be found into which a theme could be added');
97-
}
98-
9963
/** Wraps the internal find module from options with undefined path handling */
10064
export function findModuleFromOptions(host: Tree, options: any) {
10165
const workspace = getWorkspace(host);

0 commit comments

Comments
 (0)