mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
mmalerba
parent
4d430bd146
commit
9169c0593b
@@ -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.');
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
+4
-4
@@ -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) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
Reference in New Issue
Block a user