diff --git a/packages/compiler-cli/src/transformers/api.ts b/packages/compiler-cli/src/transformers/api.ts index 2c463c1437..1d3b5b6d34 100644 --- a/packages/compiler-cli/src/transformers/api.ts +++ b/packages/compiler-cli/src/transformers/api.ts @@ -111,6 +111,10 @@ export interface CompilerOptions extends ts.CompilerOptions { i18nInFile?: string; // How to handle missing messages i18nInMissingTranslations?: 'error'|'warning'|'ignore'; + + // Whether to remove blank text nodes from compiled templates. It is `true` by default + // in Angular 5 and will be re-visited in Angular 6. + preserveWhitespaces?: boolean; } export interface ModuleFilenameResolver { diff --git a/packages/compiler-cli/src/transformers/program.ts b/packages/compiler-cli/src/transformers/program.ts index 9e1cc3a84c..5c079304ce 100644 --- a/packages/compiler-cli/src/transformers/program.ts +++ b/packages/compiler-cli/src/transformers/program.ts @@ -347,6 +347,7 @@ function getAotCompilerOptions(options: CompilerOptions): AotCompilerOptions { i18nFormat: options.i18nInFormat || options.i18nOutFormat, translations, missingTranslation, enableLegacyTemplate: options.enableLegacyTemplate, enableSummariesForJit: true, + preserveWhitespaces: options.preserveWhitespaces, }; } @@ -480,4 +481,4 @@ function createProgramWithStubsHost( fileExists = (fileName: string) => this.generatedFiles.has(fileName) || originalHost.fileExists(fileName); }; -} \ No newline at end of file +} diff --git a/packages/compiler/src/aot/compiler.ts b/packages/compiler/src/aot/compiler.ts index 03b1e34aac..9a4b870f90 100644 --- a/packages/compiler/src/aot/compiler.ts +++ b/packages/compiler/src/aot/compiler.ts @@ -322,9 +322,10 @@ export class AotCompiler { const pipes = ngModule.transitiveModule.pipes.map( pipe => this._metadataResolver.getPipeSummary(pipe.reference)); + const preserveWhitespaces = compMeta !.template !.preserveWhitespaces; const {template: parsedTemplate, pipes: usedPipes} = this._templateParser.parse( compMeta, compMeta.template !.template !, directives, pipes, ngModule.schemas, - templateSourceUrl(ngModule.type, compMeta, compMeta.template !)); + templateSourceUrl(ngModule.type, compMeta, compMeta.template !), preserveWhitespaces); const stylesExpr = componentStyles ? o.variable(componentStyles.stylesVar) : o.literalArr([]); const viewResult = this._viewCompiler.compileComponent( outputCtx, compMeta, parsedTemplate, stylesExpr, usedPipes); diff --git a/packages/compiler/src/aot/compiler_factory.ts b/packages/compiler/src/aot/compiler_factory.ts index 7d911e4c81..cbdbc806ec 100644 --- a/packages/compiler/src/aot/compiler_factory.ts +++ b/packages/compiler/src/aot/compiler_factory.ts @@ -54,6 +54,7 @@ export function createAotCompiler(compilerHost: AotCompilerHost, options: AotCom useJit: false, enableLegacyTemplate: options.enableLegacyTemplate !== false, missingTranslation: options.missingTranslation, + preserveWhitespaces: options.preserveWhitespaces, }); const normalizer = new DirectiveNormalizer( {get: (url: string) => compilerHost.loadResource(url)}, urlResolver, htmlParser, config); diff --git a/packages/compiler/src/aot/compiler_options.ts b/packages/compiler/src/aot/compiler_options.ts index ed260b3a6a..063c4d137b 100644 --- a/packages/compiler/src/aot/compiler_options.ts +++ b/packages/compiler/src/aot/compiler_options.ts @@ -15,4 +15,5 @@ export interface AotCompilerOptions { missingTranslation?: MissingTranslationStrategy; enableLegacyTemplate?: boolean; enableSummariesForJit?: boolean; + preserveWhitespaces?: boolean; } diff --git a/packages/compiler/src/compile_metadata.ts b/packages/compiler/src/compile_metadata.ts index 8d2b5c4f1e..9c62149604 100644 --- a/packages/compiler/src/compile_metadata.ts +++ b/packages/compiler/src/compile_metadata.ts @@ -252,8 +252,9 @@ export class CompileTemplateMetadata { animations: any[]; ngContentSelectors: string[]; interpolation: [string, string]|null; + preserveWhitespaces: boolean; constructor({encapsulation, template, templateUrl, styles, styleUrls, externalStylesheets, - animations, ngContentSelectors, interpolation, isInline}: { + animations, ngContentSelectors, interpolation, isInline, preserveWhitespaces}: { encapsulation: ViewEncapsulation | null, template: string|null, templateUrl: string|null, @@ -263,7 +264,8 @@ export class CompileTemplateMetadata { ngContentSelectors: string[], animations: any[], interpolation: [string, string]|null, - isInline: boolean + isInline: boolean, + preserveWhitespaces: boolean }) { this.encapsulation = encapsulation; this.template = template; @@ -278,6 +280,7 @@ export class CompileTemplateMetadata { } this.interpolation = interpolation; this.isInline = isInline; + this.preserveWhitespaces = preserveWhitespaces; } toSummary(): CompileTemplateSummary { @@ -516,7 +519,8 @@ export function createHostComponentMeta( animations: [], isInline: true, externalStylesheets: [], - interpolation: null + interpolation: null, + preserveWhitespaces: false, }), exportAs: null, changeDetection: ChangeDetectionStrategy.Default, diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 8a88d720b9..f3f9fa87d8 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -24,7 +24,7 @@ export {VERSION} from './version'; export * from './template_parser/template_ast'; export {TEMPLATE_TRANSFORMS} from './template_parser/template_parser'; -export {CompilerConfig} from './config'; +export {CompilerConfig, preserveWhitespacesDefault} from './config'; export * from './compile_metadata'; export * from './aot/compiler_factory'; export * from './aot/compiler'; diff --git a/packages/compiler/src/config.ts b/packages/compiler/src/config.ts index 75be193731..8ab10926e5 100644 --- a/packages/compiler/src/config.ts +++ b/packages/compiler/src/config.ts @@ -6,11 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {InjectionToken, MissingTranslationStrategy, ViewEncapsulation, isDevMode} from '@angular/core'; - -import {CompileIdentifierMetadata} from './compile_metadata'; -import {Identifiers} from './identifiers'; - +import {MissingTranslationStrategy, ViewEncapsulation} from '@angular/core'; +import {noUndefined} from './util'; export class CompilerConfig { public defaultEncapsulation: ViewEncapsulation|null; @@ -19,18 +16,26 @@ export class CompilerConfig { public enableLegacyTemplate: boolean; public useJit: boolean; public missingTranslation: MissingTranslationStrategy|null; + public preserveWhitespaces: boolean; constructor( {defaultEncapsulation = ViewEncapsulation.Emulated, useJit = true, missingTranslation, - enableLegacyTemplate}: { + enableLegacyTemplate, preserveWhitespaces}: { defaultEncapsulation?: ViewEncapsulation, useJit?: boolean, missingTranslation?: MissingTranslationStrategy, enableLegacyTemplate?: boolean, + preserveWhitespaces?: boolean } = {}) { this.defaultEncapsulation = defaultEncapsulation; this.useJit = !!useJit; this.missingTranslation = missingTranslation || null; this.enableLegacyTemplate = enableLegacyTemplate !== false; + this.preserveWhitespaces = preserveWhitespacesDefault(noUndefined(preserveWhitespaces)); } } + +export function preserveWhitespacesDefault( + preserveWhitespacesOption: boolean | null, defaultSetting = true): boolean { + return preserveWhitespacesOption === null ? defaultSetting : preserveWhitespacesOption; +} diff --git a/packages/compiler/src/directive_normalizer.ts b/packages/compiler/src/directive_normalizer.ts index e9484a34ae..299263feeb 100644 --- a/packages/compiler/src/directive_normalizer.ts +++ b/packages/compiler/src/directive_normalizer.ts @@ -9,7 +9,7 @@ import {ViewEncapsulation, ɵstringify as stringify} from '@angular/core'; import {CompileAnimationEntryMetadata, CompileDirectiveMetadata, CompileStylesheetMetadata, CompileTemplateMetadata, templateSourceUrl} from './compile_metadata'; -import {CompilerConfig} from './config'; +import {CompilerConfig, preserveWhitespacesDefault} from './config'; import {CompilerInjectable} from './injectable'; import * as html from './ml_parser/ast'; import {HtmlParser} from './ml_parser/html_parser'; @@ -31,6 +31,7 @@ export interface PrenormalizedTemplateMetadata { interpolation: [string, string]|null; encapsulation: ViewEncapsulation|null; animations: CompileAnimationEntryMetadata[]; + preserveWhitespaces: boolean|null; } @CompilerInjectable() @@ -82,6 +83,13 @@ export class DirectiveNormalizer { throw syntaxError( `No template specified for component ${stringify(prenormData.componentType)}`); } + + if (isDefined(prenormData.preserveWhitespaces) && + typeof prenormData.preserveWhitespaces !== 'boolean') { + throw syntaxError( + `The preserveWhitespaces option for component ${stringify(prenormData.componentType)} must be a boolean`); + } + return SyncAsync.then( this.normalizeTemplateOnly(prenormData), (result: CompileTemplateMetadata) => this.normalizeExternalStylesheets(result)); @@ -149,7 +157,9 @@ export class DirectiveNormalizer { ngContentSelectors: visitor.ngContentSelectors, animations: prenormData.animations, interpolation: prenormData.interpolation, isInline, - externalStylesheets: [] + externalStylesheets: [], + preserveWhitespaces: preserveWhitespacesDefault( + prenormData.preserveWhitespaces, this._config.preserveWhitespaces), }); } @@ -168,6 +178,7 @@ export class DirectiveNormalizer { animations: templateMeta.animations, interpolation: templateMeta.interpolation, isInline: templateMeta.isInline, + preserveWhitespaces: templateMeta.preserveWhitespaces, })); } diff --git a/packages/compiler/src/directive_resolver.ts b/packages/compiler/src/directive_resolver.ts index 2b1790305f..599dc26678 100644 --- a/packages/compiler/src/directive_resolver.ts +++ b/packages/compiler/src/directive_resolver.ts @@ -152,7 +152,8 @@ export class DirectiveResolver { styleUrls: directive.styleUrls, encapsulation: directive.encapsulation, animations: directive.animations, - interpolation: directive.interpolation + interpolation: directive.interpolation, + preserveWhitespaces: directive.preserveWhitespaces, }); } else { return new Directive({ diff --git a/packages/compiler/src/jit/compiler.ts b/packages/compiler/src/jit/compiler.ts index 7cf3a447b3..4a3fbf9430 100644 --- a/packages/compiler/src/jit/compiler.ts +++ b/packages/compiler/src/jit/compiler.ts @@ -262,6 +262,7 @@ export class JitCompiler implements Compiler { const externalStylesheetsByModuleUrl = new Map(); const outputContext = createOutputContext(); const componentStylesheet = this._styleCompiler.compileComponent(outputContext, compMeta); + const preserveWhitespaces = compMeta !.template !.preserveWhitespaces; compMeta.template !.externalStylesheets.forEach((stylesheetMeta) => { const compiledStylesheet = this._styleCompiler.compileStyles(createOutputContext(), compMeta, stylesheetMeta); @@ -274,7 +275,8 @@ export class JitCompiler implements Compiler { pipe => this._metadataResolver.getPipeSummary(pipe.reference)); const {template: parsedTemplate, pipes: usedPipes} = this._templateParser.parse( compMeta, compMeta.template !.template !, directives, pipes, template.ngModule.schemas, - templateSourceUrl(template.ngModule.type, template.compMeta, template.compMeta.template !)); + templateSourceUrl(template.ngModule.type, template.compMeta, template.compMeta.template !), + preserveWhitespaces); const compileResult = this._viewCompiler.compileComponent( outputContext, compMeta, parsedTemplate, ir.variable(componentStylesheet.stylesVar), usedPipes); diff --git a/packages/compiler/src/jit/compiler_factory.ts b/packages/compiler/src/jit/compiler_factory.ts index 1e51157f0d..b57b2bf0c8 100644 --- a/packages/compiler/src/jit/compiler_factory.ts +++ b/packages/compiler/src/jit/compiler_factory.ts @@ -123,6 +123,7 @@ export class JitCompilerFactory implements CompilerFactory { defaultEncapsulation: ViewEncapsulation.Emulated, missingTranslation: MissingTranslationStrategy.Warning, enableLegacyTemplate: true, + preserveWhitespaces: true, }; this._defaultOptions = [compilerOptions, ...defaultOptions]; @@ -142,6 +143,7 @@ export class JitCompilerFactory implements CompilerFactory { defaultEncapsulation: opts.defaultEncapsulation, missingTranslation: opts.missingTranslation, enableLegacyTemplate: opts.enableLegacyTemplate, + preserveWhitespaces: opts.preserveWhitespaces, }); }, deps: [] @@ -169,6 +171,7 @@ function _mergeOptions(optionsArr: CompilerOptions[]): CompilerOptions { providers: _mergeArrays(optionsArr.map(options => options.providers !)), missingTranslation: _lastDefined(optionsArr.map(options => options.missingTranslation)), enableLegacyTemplate: _lastDefined(optionsArr.map(options => options.enableLegacyTemplate)), + preserveWhitespaces: _lastDefined(optionsArr.map(options => options.preserveWhitespaces)), }; } diff --git a/packages/compiler/src/metadata_resolver.ts b/packages/compiler/src/metadata_resolver.ts index 5efad1d1d5..5a1a9e3f25 100644 --- a/packages/compiler/src/metadata_resolver.ts +++ b/packages/compiler/src/metadata_resolver.ts @@ -219,7 +219,8 @@ export class CompileMetadataResolver { styles: template.styles, styleUrls: template.styleUrls, animations: template.animations, - interpolation: template.interpolation + interpolation: template.interpolation, + preserveWhitespaces: template.preserveWhitespaces }); if (isPromise(templateMeta) && isSync) { this._reportError(componentStillLoadingError(directiveType), directiveType); @@ -267,7 +268,8 @@ export class CompileMetadataResolver { interpolation: noUndefined(dirMeta.interpolation), isInline: !!dirMeta.template, externalStylesheets: [], - ngContentSelectors: [] + ngContentSelectors: [], + preserveWhitespaces: noUndefined(dirMeta.preserveWhitespaces), }); } diff --git a/packages/compiler/src/ml_parser/html_whitespaces.ts b/packages/compiler/src/ml_parser/html_whitespaces.ts new file mode 100644 index 0000000000..f2cd01f7e3 --- /dev/null +++ b/packages/compiler/src/ml_parser/html_whitespaces.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google Inc. 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 * as html from './ast'; +import {ParseTreeResult} from './parser'; +import {NGSP_UNICODE} from './tags'; + +export const PRESERVE_WS_ATTR_NAME = 'ngPreserveWhitespaces'; + +const SKIP_WS_TRIM_TAGS = new Set(['pre', 'template', 'textarea', 'script', 'style']); + +function hasPreserveWhitespacesAttr(attrs: html.Attribute[]): boolean { + return attrs.some((attr: html.Attribute) => attr.name === PRESERVE_WS_ATTR_NAME); +} + +/** + * This visitor can walk HTML parse tree and remove / trim text nodes using the following rules: + * - consider spaces, tabs and new lines as whitespace characters; + * - drop text nodes consisting of whitespace characters only; + * - for all other text nodes replace consecutive whitespace characters with one space; + * - convert &ngsp; pseudo-entity to a single space; + * + * The idea of using &ngsp; as a placeholder for non-removable space was originally introduced in + * Angular Dart, see: + * https://github.com/dart-lang/angular/blob/0bb611387d29d65b5af7f9d2515ab571fd3fbee4/_tests/test/compiler/preserve_whitespace_test.dart#L25-L32 + * In Angular Dart &ngsp; is converted to the 0xE500 PUA (Private Use Areas) unicode character + * and later on replaced by a space. We are re-implementing the same idea here. + * + * Removal and trimming of whitespaces have positive performance impact (less code to generate + * while compiling templates, faster view creation). At the same time it can be "destructive" + * in some cases (whitespaces can influence layout). Becouse of the potential of breaking layout + * this visitor is not activated by default in Angular 5 and people need to explicitly opt-in for + * whitespace removal. The default option for whitespace removal will be revisited in Angular 6 + * and might be changed to "on" by default. + */ +class WhitespaceVisitor implements html.Visitor { + visitElement(element: html.Element, context: any): any { + if (SKIP_WS_TRIM_TAGS.has(element.name) || hasPreserveWhitespacesAttr(element.attrs)) { + // don't descent into elements where we need to preserve whitespaces + // but still visit all attributes to eliminate one used as a market to preserve WS + return new html.Element( + element.name, html.visitAll(this, element.attrs), element.children, element.sourceSpan, + element.startSourceSpan, element.endSourceSpan); + } + + return new html.Element( + element.name, element.attrs, html.visitAll(this, element.children), element.sourceSpan, + element.startSourceSpan, element.endSourceSpan); + } + + visitAttribute(attribute: html.Attribute, context: any): any { + return attribute.name !== PRESERVE_WS_ATTR_NAME ? attribute : null; + } + + visitText(text: html.Text, context: any): any { + const isBlank = text.value.trim().length === 0; + + if (!isBlank) { + // lexer is replacing the &ngsp; pseudo-entity with NGSP_UNICODE + return new html.Text( + text.value.replace(NGSP_UNICODE, ' ').replace(/\s\s+/g, ' '), text.sourceSpan); + } + + return null; + } + + visitComment(comment: html.Comment, context: any): any { return comment; } + + visitExpansion(expansion: html.Expansion, context: any): any { return expansion; } + + visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any { return expansionCase; } +} + +export function removeWhitespaces(htmlAstWithErrors: ParseTreeResult): ParseTreeResult { + return new ParseTreeResult( + html.visitAll(new WhitespaceVisitor(), htmlAstWithErrors.rootNodes), + htmlAstWithErrors.errors); +} diff --git a/packages/compiler/src/ml_parser/tags.ts b/packages/compiler/src/ml_parser/tags.ts index 5f06c3fd25..8fb6580d7c 100644 --- a/packages/compiler/src/ml_parser/tags.ts +++ b/packages/compiler/src/ml_parser/tags.ts @@ -71,6 +71,7 @@ export function mergeNsAndName(prefix: string, localName: string): string { // This list is not exhaustive to keep the compiler footprint low. // The `{` / `ƫ` syntax should be used when the named character reference does not // exist. + export const NAMED_ENTITIES: {[k: string]: string} = { 'Aacute': '\u00C1', 'aacute': '\u00E1', @@ -325,3 +326,9 @@ export const NAMED_ENTITIES: {[k: string]: string} = { 'zwj': '\u200D', 'zwnj': '\u200C', }; + +// The &ngsp; pseudo-entity is denoting a space. see: +// https://github.com/dart-lang/angular/blob/0bb611387d29d65b5af7f9d2515ab571fd3fbee4/_tests/test/compiler/preserve_whitespace_test.dart +export const NGSP_UNICODE = '\uE500'; + +NAMED_ENTITIES['ngsp'] = NGSP_UNICODE; diff --git a/packages/compiler/src/template_parser/template_parser.ts b/packages/compiler/src/template_parser/template_parser.ts index 9d342c2dda..ae5dab1455 100644 --- a/packages/compiler/src/template_parser/template_parser.ts +++ b/packages/compiler/src/template_parser/template_parser.ts @@ -18,6 +18,7 @@ import {Identifiers, createTokenForExternalReference, createTokenForReference} f import {CompilerInjectable} from '../injectable'; import * as html from '../ml_parser/ast'; import {ParseTreeResult} from '../ml_parser/html_parser'; +import {removeWhitespaces} from '../ml_parser/html_whitespaces'; import {expandNodes} from '../ml_parser/icu_ast_expander'; import {InterpolationConfig} from '../ml_parser/interpolation_config'; import {isNgTemplate, splitNsName} from '../ml_parser/tags'; @@ -113,9 +114,10 @@ export class TemplateParser { parse( component: CompileDirectiveMetadata, template: string, directives: CompileDirectiveSummary[], - pipes: CompilePipeSummary[], schemas: SchemaMetadata[], - templateUrl: string): {template: TemplateAst[], pipes: CompilePipeSummary[]} { - const result = this.tryParse(component, template, directives, pipes, schemas, templateUrl); + pipes: CompilePipeSummary[], schemas: SchemaMetadata[], templateUrl: string, + preserveWhitespaces: boolean): {template: TemplateAst[], pipes: CompilePipeSummary[]} { + const result = this.tryParse( + component, template, directives, pipes, schemas, templateUrl, preserveWhitespaces); const warnings = result.errors !.filter(error => error.level === ParseErrorLevel.WARNING) .filter(warnOnlyOnce( @@ -137,12 +139,17 @@ export class TemplateParser { tryParse( component: CompileDirectiveMetadata, template: string, directives: CompileDirectiveSummary[], - pipes: CompilePipeSummary[], schemas: SchemaMetadata[], - templateUrl: string): TemplateParseResult { + pipes: CompilePipeSummary[], schemas: SchemaMetadata[], templateUrl: string, + preserveWhitespaces: boolean): TemplateParseResult { + let htmlParseResult = this._htmlParser !.parse( + template, templateUrl, true, this.getInterpolationConfig(component)); + + if (!preserveWhitespaces) { + htmlParseResult = removeWhitespaces(htmlParseResult); + } + return this.tryParseHtml( - this.expandHtml(this._htmlParser !.parse( - template, templateUrl, true, this.getInterpolationConfig(component))), - component, directives, pipes, schemas); + this.expandHtml(htmlParseResult), component, directives, pipes, schemas); } tryParseHtml( diff --git a/packages/compiler/test/directive_normalizer_spec.ts b/packages/compiler/test/directive_normalizer_spec.ts index bd68115eb1..a2a1c06cc4 100644 --- a/packages/compiler/test/directive_normalizer_spec.ts +++ b/packages/compiler/test/directive_normalizer_spec.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ import {CompileAnimationEntryMetadata} from '@angular/compiler'; -import {CompileDirectiveMetadata, CompileStylesheetMetadata, CompileTemplateMetadata, CompileTypeMetadata} from '@angular/compiler/src/compile_metadata'; -import {CompilerConfig} from '@angular/compiler/src/config'; +import {CompileStylesheetMetadata, CompileTemplateMetadata} from '@angular/compiler/src/compile_metadata'; +import {CompilerConfig, preserveWhitespacesDefault} from '@angular/compiler/src/config'; import {DirectiveNormalizer} from '@angular/compiler/src/directive_normalizer'; import {ResourceLoader} from '@angular/compiler/src/resource_loader'; import {MockResourceLoader} from '@angular/compiler/testing/src/resource_loader_mock'; @@ -31,6 +31,7 @@ function normalizeTemplate(normalizer: DirectiveNormalizer, o: { interpolation?: [string, string] | null; encapsulation?: ViewEncapsulation | null; animations?: CompileAnimationEntryMetadata[]; + preserveWhitespaces?: boolean | null; }) { return normalizer.normalizeTemplate({ ngModuleType: noUndefined(o.ngModuleType), @@ -42,7 +43,8 @@ function normalizeTemplate(normalizer: DirectiveNormalizer, o: { styleUrls: noUndefined(o.styleUrls), interpolation: noUndefined(o.interpolation), encapsulation: noUndefined(o.encapsulation), - animations: noUndefined(o.animations) + animations: noUndefined(o.animations), + preserveWhitespaces: noUndefined(o.preserveWhitespaces), }); } @@ -54,6 +56,7 @@ function normalizeTemplateOnly(normalizer: DirectiveNormalizer, o: { interpolation?: [string, string] | null; encapsulation?: ViewEncapsulation | null; animations?: CompileAnimationEntryMetadata[]; + preserveWhitespaces?: boolean | null; }) { return normalizer.normalizeTemplateOnly({ ngModuleType: noUndefined(o.ngModuleType), @@ -65,13 +68,14 @@ function normalizeTemplateOnly(normalizer: DirectiveNormalizer, o: { styleUrls: noUndefined(o.styleUrls), interpolation: noUndefined(o.interpolation), encapsulation: noUndefined(o.encapsulation), - animations: noUndefined(o.animations) + animations: noUndefined(o.animations), + preserveWhitespaces: noUndefined(o.preserveWhitespaces), }); } function compileTemplateMetadata({encapsulation, template, templateUrl, styles, styleUrls, externalStylesheets, animations, ngContentSelectors, - interpolation, isInline}: { + interpolation, isInline, preserveWhitespaces}: { encapsulation?: ViewEncapsulation | null, template?: string | null, templateUrl?: string | null, @@ -81,7 +85,8 @@ function compileTemplateMetadata({encapsulation, template, templateUrl, styles, ngContentSelectors?: string[], animations?: any[], interpolation?: [string, string] | null, - isInline?: boolean + isInline?: boolean, + preserveWhitespaces?: boolean | null }): CompileTemplateMetadata { return new CompileTemplateMetadata({ encapsulation: encapsulation || null, @@ -94,6 +99,7 @@ function compileTemplateMetadata({encapsulation, template, templateUrl, styles, animations: animations || [], interpolation: interpolation || null, isInline: !!isInline, + preserveWhitespaces: preserveWhitespacesDefault(noUndefined(preserveWhitespaces)), }); } @@ -106,6 +112,7 @@ function normalizeLoadedTemplate( interpolation?: [string, string] | null; encapsulation?: ViewEncapsulation | null; animations?: CompileAnimationEntryMetadata[]; + preserveWhitespaces?: boolean; }, template: string, templateAbsUrl: string) { return normalizer.normalizeLoadedTemplate( @@ -120,6 +127,7 @@ function normalizeLoadedTemplate( interpolation: o.interpolation || null, encapsulation: o.encapsulation || null, animations: o.animations || [], + preserveWhitespaces: noUndefined(o.preserveWhitespaces), }, template, templateAbsUrl); } @@ -169,6 +177,18 @@ export function main() { })) .toThrowError(`'SomeComp' component cannot define both template and templateUrl`); })); + it('should throw if preserveWhitespaces is not a boolean', + inject([DirectiveNormalizer], (normalizer: DirectiveNormalizer) => { + expect(() => normalizeTemplate(normalizer, { + ngModuleType: null, + componentType: SomeComp, + moduleUrl: SOME_MODULE_URL, + template: '', + preserveWhitespaces: 'WRONG', + })) + .toThrowError( + 'The preserveWhitespaces option for component SomeComp must be a boolean'); + })); }); describe('normalizeTemplateOnly sync', () => { @@ -431,6 +451,28 @@ export function main() { expect(template.encapsulation).toBe(viewEncapsulation); })); + it('should use preserveWhitespaces setting from compiler config if none provided', + inject( + [DirectiveNormalizer, CompilerConfig], + (normalizer: DirectiveNormalizer, config: CompilerConfig) => { + const template = normalizeLoadedTemplate(normalizer, {}, '', ''); + expect(template.preserveWhitespaces).toBe(config.preserveWhitespaces); + })); + + it('should store the preserveWhitespaces=false in the result', + inject([DirectiveNormalizer], (normalizer: DirectiveNormalizer) => { + const template = + normalizeLoadedTemplate(normalizer, {preserveWhitespaces: false}, '', ''); + expect(template.preserveWhitespaces).toBe(false); + })); + + it('should store the preserveWhitespaces=true in the result', + inject([DirectiveNormalizer], (normalizer: DirectiveNormalizer) => { + const template = + normalizeLoadedTemplate(normalizer, {preserveWhitespaces: true}, '', ''); + expect(template.preserveWhitespaces).toBe(true); + })); + it('should keep the template as html', inject([DirectiveNormalizer], (normalizer: DirectiveNormalizer) => { const template = normalizeLoadedTemplate( diff --git a/packages/compiler/test/directive_resolver_spec.ts b/packages/compiler/test/directive_resolver_spec.ts index 7322277350..b98eb0eab0 100644 --- a/packages/compiler/test/directive_resolver_spec.ts +++ b/packages/compiler/test/directive_resolver_spec.ts @@ -79,7 +79,12 @@ class SomeDirectiveWithViewChild { c: any; } -@Component({selector: 'sample', template: 'some template', styles: ['some styles']}) +@Component({ + selector: 'sample', + template: 'some template', + styles: ['some styles'], + preserveWhitespaces: true +}) class ComponentWithTemplate { } @@ -439,6 +444,7 @@ export function main() { const compMetadata: Component = resolver.resolve(ComponentWithTemplate); expect(compMetadata.template).toEqual('some template'); expect(compMetadata.styles).toEqual(['some styles']); + expect(compMetadata.preserveWhitespaces).toBe(true); }); }); }); diff --git a/packages/compiler/test/integration_spec.ts b/packages/compiler/test/integration_spec.ts index c8c4e15e4a..321b37c0d9 100644 --- a/packages/compiler/test/integration_spec.ts +++ b/packages/compiler/test/integration_spec.ts @@ -9,7 +9,6 @@ import {Component, Directive, Input} from '@angular/core'; import {ComponentFixture, TestBed, async} from '@angular/core/testing'; import {By} from '@angular/platform-browser/src/dom/debug/by'; -import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; import {expect} from '@angular/platform-browser/testing/src/matchers'; diff --git a/packages/compiler/test/ml_parser/ast_serializer_spec.ts b/packages/compiler/test/ml_parser/ast_serializer_spec.ts index 9452c3c9fb..5e33a302d5 100644 --- a/packages/compiler/test/ml_parser/ast_serializer_spec.ts +++ b/packages/compiler/test/ml_parser/ast_serializer_spec.ts @@ -97,4 +97,4 @@ const serializerVisitor = new _SerializerVisitor(); export function serializeNodes(nodes: html.Node[]): string[] { return nodes.map(node => node.visit(serializerVisitor, null)); -} \ No newline at end of file +} diff --git a/packages/compiler/test/ml_parser/html_whitespaces_spec.ts b/packages/compiler/test/ml_parser/html_whitespaces_spec.ts new file mode 100644 index 0000000000..7f9f6fa898 --- /dev/null +++ b/packages/compiler/test/ml_parser/html_whitespaces_spec.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright Google Inc. 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 * as html from '../../src/ml_parser/ast'; +import {HtmlParser} from '../../src/ml_parser/html_parser'; +import {PRESERVE_WS_ATTR_NAME, removeWhitespaces} from '../../src/ml_parser/html_whitespaces'; + +import {humanizeDom} from './ast_spec_utils'; + +export function main() { + describe('removeWhitespaces', () => { + + function parseAndRemoveWS(template: string): any[] { + return humanizeDom(removeWhitespaces(new HtmlParser().parse(template, 'TestComp'))); + } + + it('should remove blank text nodes', () => { + expect(parseAndRemoveWS(' ')).toEqual([]); + expect(parseAndRemoveWS('\n')).toEqual([]); + expect(parseAndRemoveWS('\t')).toEqual([]); + expect(parseAndRemoveWS(' \t \n ')).toEqual([]); + }); + + it('should remove whitespaces (space, tab, new line) between elements', () => { + expect(parseAndRemoveWS('

\t
\n
')).toEqual([ + [html.Element, 'br', 0], + [html.Element, 'br', 0], + [html.Element, 'br', 0], + [html.Element, 'br', 0], + ]); + }); + + it('should remove whitespaces from child text nodes', () => { + expect(parseAndRemoveWS('
')).toEqual([ + [html.Element, 'div', 0], + [html.Element, 'span', 1], + ]); + }); + + it('should remove whitespaces from the beginning and end of a template', () => { + expect(parseAndRemoveWS(`
\t`)).toEqual([ + [html.Element, 'br', 0], + ]); + }); + + it('should convert &ngsp; to a space and preserve it', () => { + expect(parseAndRemoveWS('
foo&ngsp;bar
')).toEqual([ + [html.Element, 'div', 0], + [html.Element, 'span', 1], + [html.Text, 'foo', 2], + [html.Text, ' ', 1], + [html.Element, 'span', 1], + [html.Text, 'bar', 2], + ]); + }); + + it('should replace multiple whitespaces with one space', () => { + expect(parseAndRemoveWS('\n\n\nfoo\t\t\t')).toEqual([[html.Text, ' foo ', 0]]); + expect(parseAndRemoveWS(' \n foo \t ')).toEqual([[html.Text, ' foo ', 0]]); + }); + + it('should not replace single tab and newline with spaces', () => { + expect(parseAndRemoveWS('\nfoo')).toEqual([[html.Text, '\nfoo', 0]]); + expect(parseAndRemoveWS('\tfoo')).toEqual([[html.Text, '\tfoo', 0]]); + }); + + it('should preserve single whitespaces between interpolations', () => { + expect(parseAndRemoveWS(`{{fooExp}} {{barExp}}`)).toEqual([ + [html.Text, '{{fooExp}} {{barExp}}', 0], + ]); + expect(parseAndRemoveWS(`{{fooExp}}\t{{barExp}}`)).toEqual([ + [html.Text, '{{fooExp}}\t{{barExp}}', 0], + ]); + expect(parseAndRemoveWS(`{{fooExp}}\n{{barExp}}`)).toEqual([ + [html.Text, '{{fooExp}}\n{{barExp}}', 0], + ]); + }); + + it('should preserve whitespaces around interpolations', () => { + expect(parseAndRemoveWS(` {{exp}} `)).toEqual([ + [html.Text, ' {{exp}} ', 0], + ]); + }); + + it('should preserve whitespaces inside
 elements', () => {
+      expect(parseAndRemoveWS(`
foo\nbar
`)).toEqual([ + [html.Element, 'pre', 0], + [html.Element, 'strong', 1], + [html.Text, 'foo', 2], + [html.Text, '\n', 1], + [html.Element, 'strong', 1], + [html.Text, 'bar', 2], + ]); + }); + + it('should skip whitespace trimming in `)).toEqual([ + [html.Element, 'textarea', 0], + [html.Text, 'foo\n\n bar', 1], + ]); + }); + + it(`should preserve whitespaces inside elements annotated with ${PRESERVE_WS_ATTR_NAME}`, + () => { + expect(parseAndRemoveWS(`
`)).toEqual([ + [html.Element, 'div', 0], + [html.Element, 'img', 1], + [html.Text, ' ', 1], + [html.Element, 'img', 1], + ]); + }); + }); +} diff --git a/packages/compiler/test/template_parser/template_parser_spec.ts b/packages/compiler/test/template_parser/template_parser_spec.ts index 8204b2f67c..7ffb4dfe58 100644 --- a/packages/compiler/test/template_parser/template_parser_spec.ts +++ b/packages/compiler/test/template_parser/template_parser_spec.ts @@ -5,7 +5,7 @@ * 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 {CompileQueryMetadata, CompilerConfig, JitReflector, ProxyClass, StaticSymbol} from '@angular/compiler'; +import {CompileQueryMetadata, CompilerConfig, JitReflector, ProxyClass, StaticSymbol, preserveWhitespacesDefault} from '@angular/compiler'; import {CompileAnimationEntryMetadata, CompileDiDependencyMetadata, CompileDirectiveMetadata, CompileDirectiveSummary, CompilePipeMetadata, CompilePipeSummary, CompileProviderMetadata, CompileTemplateMetadata, CompileTokenMetadata, CompileTypeMetadata, tokenReference} from '@angular/compiler/src/compile_metadata'; import {DomElementSchemaRegistry} from '@angular/compiler/src/schema/dom_element_schema_registry'; import {ElementSchemaRegistry} from '@angular/compiler/src/schema/element_schema_registry'; @@ -84,7 +84,7 @@ function compileDirectiveMetadataCreate( function compileTemplateMetadata({encapsulation, template, templateUrl, styles, styleUrls, externalStylesheets, animations, ngContentSelectors, - interpolation, isInline}: { + interpolation, isInline, preserveWhitespaces}: { encapsulation?: ViewEncapsulation | null, template?: string | null, templateUrl?: string | null, @@ -94,7 +94,8 @@ function compileTemplateMetadata({encapsulation, template, templateUrl, styles, ngContentSelectors?: string[], animations?: any[], interpolation?: [string, string] | null, - isInline?: boolean + isInline?: boolean, + preserveWhitespaces?: boolean | null, }): CompileTemplateMetadata { return new CompileTemplateMetadata({ encapsulation: noUndefined(encapsulation), @@ -106,7 +107,8 @@ function compileTemplateMetadata({encapsulation, template, templateUrl, styles, animations: animations || [], ngContentSelectors: ngContentSelectors || [], interpolation: noUndefined(interpolation), - isInline: !!isInline + isInline: !!isInline, + preserveWhitespaces: preserveWhitespacesDefault(noUndefined(preserveWhitespaces)), }); } @@ -116,7 +118,7 @@ export function main() { let ngIf: CompileDirectiveSummary; let parse: ( template: string, directives: CompileDirectiveSummary[], pipes?: CompilePipeSummary[], - schemas?: SchemaMetadata[]) => TemplateAst[]; + schemas?: SchemaMetadata[], preserveWhitespaces?: boolean) => TemplateAst[]; let console: ArrayConsole; function commonBeforeEach() { @@ -148,12 +150,15 @@ export function main() { parse = (template: string, directives: CompileDirectiveSummary[], - pipes: CompilePipeSummary[] | null = null, - schemas: SchemaMetadata[] = []): TemplateAst[] => { + pipes: CompilePipeSummary[] | null = null, schemas: SchemaMetadata[] = [], + preserveWhitespaces = true): TemplateAst[] => { if (pipes === null) { pipes = []; } - return parser.parse(component, template, directives, pipes, schemas, 'TestComp') + return parser + .parse( + component, template, directives, pipes, schemas, 'TestComp', + preserveWhitespaces) .template; }; })); @@ -398,7 +403,8 @@ export function main() { externalStylesheets: [], styleUrls: [], styles: [], - encapsulation: null + encapsulation: null, + preserveWhitespaces: preserveWhitespacesDefault(null), }), isHost: false, exportAs: null, @@ -417,7 +423,7 @@ export function main() { }); expect(humanizeTplAst( - parser.parse(component, '{%a%}', [], [], [], 'TestComp').template, + parser.parse(component, '{%a%}', [], [], [], 'TestComp', true).template, {start: '{%', end: '%}'})) .toEqual([[BoundTextAst, '{% a %}']]); })); @@ -2052,6 +2058,48 @@ The pipe 'test' could not be found ("{{[ERROR ->]a | test}}"): TestComp@0:2`); }); }); + describe('whitespaces removal', () => { + + it('should not remove whitespaces by default', () => { + expect(humanizeTplAst(parse('

\t
\n
', []))).toEqual([ + [TextAst, ' '], + [ElementAst, 'br'], + [TextAst, ' '], + [ElementAst, 'br'], + [TextAst, '\t'], + [ElementAst, 'br'], + [TextAst, '\n'], + [ElementAst, 'br'], + [TextAst, ' '], + ]); + }); + + it('should remove whitespaces when explicitly requested', () => { + expect(humanizeTplAst(parse('

\t
\n
', [], [], [], false))).toEqual([ + [ElementAst, 'br'], + [ElementAst, 'br'], + [ElementAst, 'br'], + [ElementAst, 'br'], + ]); + }); + + it('should remove whitespace between ICU expansions when not preserving whitespaces', () => { + const shortForm = '{ count, plural, =0 {small} many {big} }'; + const expandedForm = '' + + 'small' + + 'big' + + ''; + const humanizedExpandedForm = humanizeTplAst(parse(expandedForm, [])); + + // ICU expansions are converted to `` tags and all blank text nodes are reomved + // so any whitespace between ICU exansions are removed as well + expect(humanizeTplAst(parse(`${shortForm} ${shortForm}`, [], [], [], false))).toEqual([ + ...humanizedExpandedForm, ...humanizedExpandedForm + ]); + }); + + }); + describe('Template Parser - opt-out `