diff --git a/src/lib/schematics/install/fonts/head-element.ts b/src/lib/schematics/install/fonts/head-element.ts new file mode 100644 index 00000000000..323d2844cc8 --- /dev/null +++ b/src/lib/schematics/install/fonts/head-element.ts @@ -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 '' 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; +} diff --git a/src/lib/schematics/install/fonts/material-fonts.ts b/src/lib/schematics/install/fonts/material-fonts.ts new file mode 100644 index 00000000000..346e05a00e6 --- /dev/null +++ b/src/lib/schematics/install/fonts/material-fonts.ts @@ -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, ``)); + + return host; + }; +} diff --git a/src/lib/schematics/install/fonts/project-index-html.ts b/src/lib/schematics/install/fonts/project-index-html.ts new file mode 100644 index 00000000000..e7dd5d464d6 --- /dev/null +++ b/src/lib/schematics/install/fonts/project-index-html.ts @@ -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.'); +} diff --git a/src/lib/schematics/install/index.spec.ts b/src/lib/schematics/install/index.spec.ts index f5adc31c01e..393bc0f273d 100644 --- a/src/lib/schematics/install/index.spec.ts +++ b/src/lib/schematics/install/index.spec.ts @@ -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( + ' { 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`)); - 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); }; } diff --git a/src/lib/schematics/install/custom-theme.ts b/src/lib/schematics/install/theming/custom-theme.ts similarity index 100% rename from src/lib/schematics/install/custom-theme.ts rename to src/lib/schematics/install/theming/custom-theme.ts diff --git a/src/lib/schematics/install/theming.ts b/src/lib/schematics/install/theming/theming.ts similarity index 95% rename from src/lib/schematics/install/theming.ts rename to src/lib/schematics/install/theming/theming.ts index 35ce25b07fe..3f10f3e4c0f 100644 --- a/src/lib/schematics/install/theming.ts +++ b/src/lib/schematics/install/theming/theming.ts @@ -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) { diff --git a/src/lib/schematics/utils/ast.ts b/src/lib/schematics/utils/ast.ts index b4520372e5d..2b57f3d5fd3 100644 --- a/src/lib/schematics/utils/ast.ts +++ b/src/lib/schematics/utils/ast.ts @@ -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); diff --git a/src/lib/schematics/utils/html.ts b/src/lib/schematics/utils/html.ts deleted file mode 100644 index ac7fcfb5a97..00000000000 --- a/src/lib/schematics/utils/html.ts +++ /dev/null @@ -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: - * `` - * @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); - } -} diff --git a/src/lib/schematics/utils/package.ts b/src/lib/schematics/utils/package-json.ts similarity index 100% rename from src/lib/schematics/utils/package.ts rename to src/lib/schematics/utils/package-json.ts diff --git a/src/lib/schematics/utils/parse5-element.ts b/src/lib/schematics/utils/parse5-element.ts new file mode 100644 index 00000000000..6b275ff1d48 --- /dev/null +++ b/src/lib/schematics/utils/parse5-element.ts @@ -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; +} diff --git a/src/lib/schematics/utils/project-style-file.ts b/src/lib/schematics/utils/project-style-file.ts new file mode 100644 index 00000000000..3e73d6b263c --- /dev/null +++ b/src/lib/schematics/utils/project-style-file.ts @@ -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.'); +}