perf(core): add option to remove blank text nodes from compiled templates
This commit is contained in:
parent
088532bf2e
commit
d2c0d986d4
|
@ -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 {
|
||||
|
|
|
@ -347,6 +347,7 @@ function getAotCompilerOptions(options: CompilerOptions): AotCompilerOptions {
|
|||
i18nFormat: options.i18nInFormat || options.i18nOutFormat, translations, missingTranslation,
|
||||
enableLegacyTemplate: options.enableLegacyTemplate,
|
||||
enableSummariesForJit: true,
|
||||
preserveWhitespaces: options.preserveWhitespaces,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -15,4 +15,5 @@ export interface AotCompilerOptions {
|
|||
missingTranslation?: MissingTranslationStrategy;
|
||||
enableLegacyTemplate?: boolean;
|
||||
enableSummariesForJit?: boolean;
|
||||
preserveWhitespaces?: boolean;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -262,6 +262,7 @@ export class JitCompiler implements Compiler {
|
|||
const externalStylesheetsByModuleUrl = new Map<string, CompiledStylesheet>();
|
||||
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);
|
||||
|
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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: <any>'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(
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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('<br> <br>\t<br>\n<br>')).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('<div><span> </span></div>')).toEqual([
|
||||
[html.Element, 'div', 0],
|
||||
[html.Element, 'span', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove whitespaces from the beginning and end of a template', () => {
|
||||
expect(parseAndRemoveWS(` <br>\t`)).toEqual([
|
||||
[html.Element, 'br', 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert &ngsp; to a space and preserve it', () => {
|
||||
expect(parseAndRemoveWS('<div><span>foo</span>&ngsp;<span>bar</span></div>')).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 <pre> elements', () => {
|
||||
expect(parseAndRemoveWS(`<pre><strong>foo</strong>\n<strong>bar</strong></pre>`)).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 <textarea>', () => {
|
||||
expect(parseAndRemoveWS(`<textarea>foo\n\n bar</textarea>`)).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(`<div ${PRESERVE_WS_ATTR_NAME}><img> <img></div>`)).toEqual([
|
||||
[html.Element, 'div', 0],
|
||||
[html.Element, 'img', 1],
|
||||
[html.Text, ' ', 1],
|
||||
[html.Element, 'img', 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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(' <br> <br>\t<br>\n<br> ', []))).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(' <br> <br>\t<br>\n<br> ', [], [], [], 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 = '<ng-container [ngPlural]="count">' +
|
||||
'<ng-template ngPluralCase="=0">small</ng-template>' +
|
||||
'<ng-template ngPluralCase="many">big</ng-template>' +
|
||||
'</ng-container>';
|
||||
const humanizedExpandedForm = humanizeTplAst(parse(expandedForm, []));
|
||||
|
||||
// ICU expansions are converted to `<ng-container>` 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 `<template>` support', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureCompiler({
|
||||
|
|
|
@ -85,7 +85,8 @@ export class MockDirectiveResolver extends DirectiveResolver {
|
|||
styles: view.styles,
|
||||
styleUrls: view.styleUrls,
|
||||
encapsulation: view.encapsulation,
|
||||
interpolation: view.interpolation
|
||||
interpolation: view.interpolation,
|
||||
preserveWhitespaces: view.preserveWhitespaces,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@ export type CompilerOptions = {
|
|||
// Whether to support the `<template>` tag and the `template` attribute to define angular
|
||||
// templates. They have been deprecated in 4.x, `<ng-template>` should be used instead.
|
||||
enableLegacyTemplate?: boolean,
|
||||
preserveWhitespaces?: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -675,6 +675,16 @@ export interface Component extends Directive {
|
|||
* {@link ComponentFactoryResolver}.
|
||||
*/
|
||||
entryComponents?: Array<Type<any>|any[]>;
|
||||
|
||||
/**
|
||||
* If preserveWhitespaces is set to `false` potentially superfluous blank characters (space, tab,
|
||||
* new line) will be removed from compiled templates. This can greatly reduce generated code size
|
||||
* as well as speed up components' creation. The whitespace removal algorithm will drop all
|
||||
* the blank text nodes and collapse series of whitespaces to just one space.
|
||||
* Those transformations can potentially influence layout of the generated markup so
|
||||
* the `preserveWhitespaces` should be used with care.
|
||||
*/
|
||||
preserveWhitespaces?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1758,6 +1758,51 @@ function declareTests({useJit}: {useJit: boolean}) {
|
|||
});
|
||||
});
|
||||
|
||||
describe('whitespaces in templates', () => {
|
||||
it('should not remove whitespaces by default', async(() => {
|
||||
@Component({
|
||||
selector: 'comp',
|
||||
template: '<span>foo</span> <span>bar</span>',
|
||||
})
|
||||
class MyCmp {
|
||||
}
|
||||
|
||||
const f = TestBed.configureTestingModule({declarations: [MyCmp]}).createComponent(MyCmp);
|
||||
f.detectChanges();
|
||||
|
||||
expect(f.nativeElement.childNodes.length).toBe(3);
|
||||
}));
|
||||
|
||||
it('should not remove whitespaces when explicitly requested not to do so', async(() => {
|
||||
@Component({
|
||||
selector: 'comp',
|
||||
template: '<span>foo</span> <span>bar</span>',
|
||||
preserveWhitespaces: true,
|
||||
})
|
||||
class MyCmp {
|
||||
}
|
||||
|
||||
const f = TestBed.configureTestingModule({declarations: [MyCmp]}).createComponent(MyCmp);
|
||||
f.detectChanges();
|
||||
|
||||
expect(f.nativeElement.childNodes.length).toBe(3);
|
||||
}));
|
||||
|
||||
it('should remove whitespaces when explicitly requested to do so', async(() => {
|
||||
@Component({
|
||||
selector: 'comp',
|
||||
template: '<span>foo</span> <span>bar</span>',
|
||||
preserveWhitespaces: false,
|
||||
})
|
||||
class MyCmp {
|
||||
}
|
||||
|
||||
const f = TestBed.configureTestingModule({declarations: [MyCmp]}).createComponent(MyCmp);
|
||||
f.detectChanges();
|
||||
|
||||
expect(f.nativeElement.childNodes.length).toBe(2);
|
||||
}));
|
||||
});
|
||||
|
||||
if (getDOM().supportsDOMEvents()) {
|
||||
describe('svg', () => {
|
||||
|
|
|
@ -201,6 +201,7 @@ export declare type CompilerOptions = {
|
|||
providers?: StaticProvider[];
|
||||
missingTranslation?: MissingTranslationStrategy;
|
||||
enableLegacyTemplate?: boolean;
|
||||
preserveWhitespaces?: boolean;
|
||||
};
|
||||
|
||||
/** @stable */
|
||||
|
|
Loading…
Reference in New Issue