diff --git a/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts b/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts index 064eb21110..425e263584 100644 --- a/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts +++ b/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts @@ -7,7 +7,6 @@ */ import './init'; -let serializer = require('@angular/compiler/src/i18n/xmb_serializer.js'); import * as fs from 'fs'; import * as path from 'path'; @@ -16,13 +15,14 @@ describe('template i18n extraction output', () => { const outDir = ''; it('should extract i18n messages', () => { + const EXPECTED = ` + + translate me +`; + const xmbOutput = path.join(outDir, 'messages.xmb'); expect(fs.existsSync(xmbOutput)).toBeTruthy(); const xmb = fs.readFileSync(xmbOutput, {encoding: 'utf-8'}); - const res = serializer.deserializeXmb(xmb); - const keys = Object.keys(res.messages); - expect(keys.length).toEqual(1); - expect(res.errors.length).toEqual(0); - expect(res.messages[keys[0]][0].value).toEqual('translate me'); + expect(xmb).toEqual(EXPECTED); }); }); diff --git a/modules/@angular/compiler-cli/src/codegen.ts b/modules/@angular/compiler-cli/src/codegen.ts index f1e4bce27e..be4b1fda0d 100644 --- a/modules/@angular/compiler-cli/src/codegen.ts +++ b/modules/@angular/compiler-cli/src/codegen.ts @@ -84,14 +84,14 @@ export class CodeGenerator { } codegen(): Promise { - let filePaths = + const filePaths = this.program.getSourceFiles().map(sf => sf.fileName).filter(f => !GENERATED_FILES.test(f)); - let fileMetas = filePaths.map((filePath) => this.readFileMetadata(filePath)); - let ngModules = fileMetas.reduce((ngModules, fileMeta) => { + const fileMetas = filePaths.map((filePath) => this.readFileMetadata(filePath)); + const ngModules = fileMetas.reduce((ngModules, fileMeta) => { ngModules.push(...fileMeta.ngModules); return ngModules; }, []); - let analyzedNgModules = this.compiler.analyzeModules(ngModules); + const analyzedNgModules = this.compiler.analyzeModules(ngModules); return Promise .all(fileMetas.map( (fileMeta) => this.compiler diff --git a/modules/@angular/compiler-cli/src/compiler_private.ts b/modules/@angular/compiler-cli/src/compiler_private.ts index cfc92777cf..ea7e235238 100644 --- a/modules/@angular/compiler-cli/src/compiler_private.ts +++ b/modules/@angular/compiler-cli/src/compiler_private.ts @@ -20,25 +20,12 @@ export var CompileMetadataResolver: typeof _c.CompileMetadataResolver = _c.Compi export type HtmlParser = _c.HtmlParser; export var HtmlParser: typeof _c.HtmlParser = _c.HtmlParser; -export type I18nHtmlParser = _c.I18nHtmlParser; -export var I18nHtmlParser: typeof _c.I18nHtmlParser = _c.I18nHtmlParser; - -export type MessageExtractor = _c.MessageExtractor; -export var MessageExtractor: typeof _c.MessageExtractor = _c.MessageExtractor; - -export type ExtractionResult = _c.ExtractionResult; -export var ExtractionResult: typeof _c.ExtractionResult = _c.ExtractionResult; - -export type Message = _c.Message; -export var Message: typeof _c.Message = _c.Message; - -export var removeDuplicates: typeof _c.removeDuplicates = _c.removeDuplicates; -export var serializeXmb: typeof _c.serializeXmb = _c.serializeXmb; -export var deserializeXmb: typeof _c.deserializeXmb = _c.deserializeXmb; - export type ParseError = _c.ParseError; export var ParseError: typeof _c.ParseError = _c.ParseError; +export type InterpolationConfig = _c.InterpolationConfig; +export var InterpolationConfig: typeof _c.InterpolationConfig = _c.InterpolationConfig; + export type DirectiveNormalizer = _c.DirectiveNormalizer; export var DirectiveNormalizer: typeof _c.DirectiveNormalizer = _c.DirectiveNormalizer; diff --git a/modules/@angular/compiler-cli/src/extract_i18n.ts b/modules/@angular/compiler-cli/src/extract_i18n.ts index 0b3d9fa54b..24d2e1cdd8 100644 --- a/modules/@angular/compiler-cli/src/extract_i18n.ts +++ b/modules/@angular/compiler-cli/src/extract_i18n.ts @@ -10,121 +10,124 @@ /** * Extract i18n messages from source code + * + * TODO(vicb): factorize code with the CodeGenerator */ - // Must be imported first, because angular2 decorators throws on load. import 'reflect-metadata'; +import * as compiler from '@angular/compiler'; +import {ComponentMetadata, NgModuleMetadata, ViewEncapsulation} from '@angular/core'; +import * as path from 'path'; import * as ts from 'typescript'; import * as tsc from '@angular/tsc-wrapped'; -import * as path from 'path'; -import * as compiler from '@angular/compiler'; -import {ViewEncapsulation} from '@angular/core'; - -import {StaticReflector} from './static_reflector'; -import {CompileMetadataResolver, HtmlParser, DirectiveNormalizer, Lexer, Parser, DomElementSchemaRegistry, TypeScriptEmitter, MessageExtractor, removeDuplicates, ExtractionResult, Message, ParseError, serializeXmb,} from './compiler_private'; +import {CompileMetadataResolver, DirectiveNormalizer, DomElementSchemaRegistry, HtmlParser, Lexer, NgModuleCompiler, Parser, StyleCompiler, TemplateParser, TypeScriptEmitter, ViewCompiler, ParseError} from './compiler_private'; import {Console} from './core_private'; - -import {ReflectorHost} from './reflector_host'; +import {ReflectorHost, ReflectorHostContext} from './reflector_host'; import {StaticAndDynamicReflectionCapabilities} from './static_reflection_capabilities'; +import {StaticReflector, StaticSymbol} from './static_reflector'; function extract( ngOptions: tsc.AngularCompilerOptions, program: ts.Program, host: ts.CompilerHost) { - return Extractor.create(ngOptions, program, host).extract(); + const extractor = Extractor.create(ngOptions, program, host); + const bundlePromise: Promise = extractor.extract(); + + return (bundlePromise).then(messageBundle => { + const serializer = new compiler.i18n.Xmb(); + const dstPath = path.join(ngOptions.genDir, 'messages.xmb'); + host.writeFile(dstPath, messageBundle.write(serializer), false); + }); } -const _dirPaths = new Map(); +const GENERATED_FILES = /\.ngfactory\.ts$|\.css\.ts$|\.css\.shim\.ts$/; -const _GENERATED_FILES = /\.ngfactory\.ts$|\.css\.ts$|\.css\.shim\.ts$/; - -class Extractor { +export class Extractor { constructor( - private _options: tsc.AngularCompilerOptions, private _program: ts.Program, - public host: ts.CompilerHost, private staticReflector: StaticReflector, - private _resolver: CompileMetadataResolver, private _normalizer: DirectiveNormalizer, - private _reflectorHost: ReflectorHost, private _extractor: MessageExtractor) {} + private program: ts.Program, public host: ts.CompilerHost, + private staticReflector: StaticReflector, private messageBundle: compiler.i18n.MessageBundle, + private reflectorHost: ReflectorHost, private metadataResolver: CompileMetadataResolver, + private directiveNormalizer: DirectiveNormalizer, + private compiler: compiler.OfflineCompiler) {} - private _extractCmpMessages(components: compiler.CompileDirectiveMetadata[]): ExtractionResult { - if (!components || !components.length) { - return null; - } - - let messages: Message[] = []; - let errors: ParseError[] = []; - components.forEach(metadata => { - let url = _dirPaths.get(metadata); - let result = this._extractor.extract(metadata.template.template, url); - errors = errors.concat(result.errors); - messages = messages.concat(result.messages); - }); - - // Extraction Result might contain duplicate messages at this point - return new ExtractionResult(messages, errors); - } - - private _readComponents(absSourcePath: string): Promise[] { - const result: Promise[] = []; - const metadata = this.staticReflector.getModuleMetadata(absSourcePath); - if (!metadata) { + private readFileMetadata(absSourcePath: string): FileMetadata { + const moduleMetadata = this.staticReflector.getModuleMetadata(absSourcePath); + const result: FileMetadata = {components: [], ngModules: [], fileUrl: absSourcePath}; + if (!moduleMetadata) { console.log(`WARNING: no metadata found for ${absSourcePath}`); return result; } - - const symbols = Object.keys(metadata['metadata']); + const metadata = moduleMetadata['metadata']; + const symbols = metadata && Object.keys(metadata); if (!symbols || !symbols.length) { return result; } for (const symbol of symbols) { - const staticType = this._reflectorHost.findDeclaration(absSourcePath, symbol, absSourcePath); - let directive: compiler.CompileDirectiveMetadata; - directive = this._resolver.getDirectiveMetadata(staticType, false); - - if (directive && directive.isComponent) { - let promise = this._normalizer.normalizeDirective(directive).asyncResult; - promise.then(md => _dirPaths.set(md, absSourcePath)); - result.push(promise); + if (metadata[symbol] && metadata[symbol].__symbolic == 'error') { + // Ignore symbols that are only included to record error information. + continue; } + const staticType = this.reflectorHost.findDeclaration(absSourcePath, symbol, absSourcePath); + const annotations = this.staticReflector.annotations(staticType); + annotations.forEach((annotation) => { + if (annotation instanceof NgModuleMetadata) { + result.ngModules.push(staticType); + } else if (annotation instanceof ComponentMetadata) { + result.components.push(staticType); + } + }); } return result; } - extract(): Promise { - _dirPaths.clear(); + extract(): Promise { + const filePaths = + this.program.getSourceFiles().map(sf => sf.fileName).filter(f => !GENERATED_FILES.test(f)); + const fileMetas = filePaths.map((filePath) => this.readFileMetadata(filePath)); + const ngModules = fileMetas.reduce((ngModules, fileMeta) => { + ngModules.push(...fileMeta.ngModules); + return ngModules; + }, []); + const analyzedNgModules = this.compiler.analyzeModules(ngModules); + const errors: ParseError[] = []; - const promises = this._program.getSourceFiles() - .map(sf => sf.fileName) - .filter(f => !_GENERATED_FILES.test(f)) - .map( - (absSourcePath: string): Promise => - Promise.all(this._readComponents(absSourcePath)) - .then(metadatas => this._extractCmpMessages(metadatas)) - .catch(e => console.error(e.stack))); + let bundlePromise = + Promise + .all(fileMetas.map((fileMeta) => { + const url = fileMeta.fileUrl; + return Promise.all(fileMeta.components.map(compType => { + const compMeta = this.metadataResolver.getDirectiveMetadata(compType); + const ngModule = analyzedNgModules.ngModuleByComponent.get(compType); + if (!ngModule) { + throw new Error( + `Cannot determine the module for component ${compMeta.type.name}!`); + } + return Promise + .all([compMeta, ...ngModule.transitiveModule.directives].map( + dirMeta => + this.directiveNormalizer.normalizeDirective(dirMeta).asyncResult)) + .then((normalizedCompWithDirectives) => { + const compMeta = normalizedCompWithDirectives[0]; + const html = compMeta.template.template; + const interpolationConfig = + compiler.InterpolationConfig.fromArray(compMeta.template.interpolation); + errors.push( + ...this.messageBundle.updateFromTemplate(html, url, interpolationConfig)); + }); + })); + })) + .then(_ => this.messageBundle) + .catch((e) => { console.error(e.stack); }); - let messages: Message[] = []; - let errors: ParseError[] = []; + if (errors.length) { + throw new Error(errors.map(e => e.toString()).join('\n')); + } - return Promise.all(promises).then(extractionResults => { - extractionResults.filter(result => !!result).forEach(result => { - messages = messages.concat(result.messages); - errors = errors.concat(result.errors); - }); - - if (errors.length) { - throw new Error(errors.map(e => e.toString()).join('\n')); - } - - messages = removeDuplicates(messages); - - let genPath = path.join(this._options.genDir, 'messages.xmb'); - let msgBundle = serializeXmb(messages); - - this.host.writeFile(genPath, msgBundle, false); - }); + return bundlePromise; } static create( - options: tsc.AngularCompilerOptions, program: ts.Program, - compilerHost: ts.CompilerHost): Extractor { + options: tsc.AngularCompilerOptions, program: ts.Program, compilerHost: ts.CompilerHost, + reflectorHostContext?: ReflectorHostContext): Extractor { const xhr: compiler.XHR = { get: (s: string) => { if (!compilerHost.fileExists(s)) { @@ -134,35 +137,49 @@ class Extractor { return Promise.resolve(compilerHost.readFile(s)); } }; + const urlResolver: compiler.UrlResolver = compiler.createOfflineCompileUrlResolver(); - const reflectorHost = new ReflectorHost(program, compilerHost, options); + const reflectorHost = new ReflectorHost(program, compilerHost, options, reflectorHostContext); const staticReflector = new StaticReflector(reflectorHost); StaticAndDynamicReflectionCapabilities.install(staticReflector); const htmlParser = new HtmlParser(); + const config = new compiler.CompilerConfig({ - genDebugInfo: true, + genDebugInfo: options.debug === true, defaultEncapsulation: ViewEncapsulation.Emulated, logBindingUpdate: false, useJit: false }); + const normalizer = new DirectiveNormalizer(xhr, urlResolver, htmlParser, config); const expressionParser = new Parser(new Lexer()); const elementSchemaRegistry = new DomElementSchemaRegistry(); const console = new Console(); + const tmplParser = + new TemplateParser(expressionParser, elementSchemaRegistry, htmlParser, console, []); const resolver = new CompileMetadataResolver( new compiler.NgModuleResolver(staticReflector), new compiler.DirectiveResolver(staticReflector), new compiler.PipeResolver(staticReflector), config, console, elementSchemaRegistry, staticReflector); + const offlineCompiler = new compiler.OfflineCompiler( + resolver, normalizer, tmplParser, new StyleCompiler(urlResolver), new ViewCompiler(config), + new NgModuleCompiler(), new TypeScriptEmitter(reflectorHost)); - // TODO(vicb): handle implicit - const extractor = new MessageExtractor(htmlParser, expressionParser, [], {}); + // TODO(vicb): implicit tags & attributes + let messageBundle = new compiler.i18n.MessageBundle(htmlParser, [], {}); return new Extractor( - options, program, compilerHost, staticReflector, resolver, normalizer, reflectorHost, - extractor); + program, compilerHost, staticReflector, messageBundle, reflectorHost, resolver, normalizer, + offlineCompiler); } } +interface FileMetadata { + fileUrl: string; + components: StaticSymbol[]; + ngModules: StaticSymbol[]; +} + // Entry point if (require.main === module) { const args = require('minimist')(process.argv.slice(2)); @@ -170,7 +187,7 @@ if (require.main === module) { .then(exitCode => process.exit(exitCode)) .catch(e => { console.error(e.stack); - console.error('Compilation failed'); + console.error('Extraction failed'); process.exit(1); }); -} +} \ No newline at end of file diff --git a/modules/@angular/compiler/compiler.ts b/modules/@angular/compiler/compiler.ts deleted file mode 100644 index 51bf2aafe4..0000000000 --- a/modules/@angular/compiler/compiler.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @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 - */ - -/** - * @module - * @description - * Starting point to import all compiler APIs. - */ -export {COMPILER_PROVIDERS, CompileDiDependencyMetadata, CompileDirectiveMetadata, CompileFactoryMetadata, CompileIdentifierMetadata, CompileMetadataWithIdentifier, CompilePipeMetadata, CompileProviderMetadata, CompileQueryMetadata, CompileTemplateMetadata, CompileTokenMetadata, CompileTypeMetadata, CompilerConfig, DEFAULT_PACKAGE_URL_PROVIDER, DirectiveResolver, NgModuleResolver, OfflineCompiler, PipeResolver, RenderTypes, RuntimeCompiler, SourceModule, TEMPLATE_TRANSFORMS, UrlResolver, XHR, analyzeAppProvidersForDeprecatedConfiguration, createOfflineCompileUrlResolver, platformCoreDynamic} from './src/compiler'; -export {ElementSchemaRegistry} from './src/schema/element_schema_registry'; - -export * from './src/template_parser/template_ast'; -export * from './private_export'; diff --git a/modules/@angular/compiler/index.ts b/modules/@angular/compiler/index.ts index bd601ebe89..32399b263f 100644 --- a/modules/@angular/compiler/index.ts +++ b/modules/@angular/compiler/index.ts @@ -6,4 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ -export * from './compiler'; +/** + * @module + * @description + * Starting point to import all compiler APIs. + */ +import * as i18n from './src/i18n/index'; + +export {COMPILER_PROVIDERS, CompileDiDependencyMetadata, CompileDirectiveMetadata, CompileFactoryMetadata, CompileIdentifierMetadata, CompileMetadataWithIdentifier, CompilePipeMetadata, CompileProviderMetadata, CompileQueryMetadata, CompileTemplateMetadata, CompileTokenMetadata, CompileTypeMetadata, CompilerConfig, DEFAULT_PACKAGE_URL_PROVIDER, DirectiveResolver, NgModuleResolver, OfflineCompiler, PipeResolver, RenderTypes, RuntimeCompiler, SourceModule, TEMPLATE_TRANSFORMS, UrlResolver, ViewResolver, XHR, analyzeAppProvidersForDeprecatedConfiguration, createOfflineCompileUrlResolver, platformCoreDynamic} from './src/compiler'; +export {InterpolationConfig} from './src/html_parser/interpolation_config'; +export {ElementSchemaRegistry} from './src/schema/element_schema_registry'; +export {i18n}; + +export * from './src/template_parser/template_ast'; +export * from './private_export'; diff --git a/modules/@angular/compiler/private_export.ts b/modules/@angular/compiler/private_export.ts index 0cfe8a429a..5b0d880f9c 100644 --- a/modules/@angular/compiler/private_export.ts +++ b/modules/@angular/compiler/private_export.ts @@ -10,10 +10,7 @@ import * as directive_normalizer from './src/directive_normalizer'; import * as lexer from './src/expression_parser/lexer'; import * as parser from './src/expression_parser/parser'; import * as html_parser from './src/html_parser/html_parser'; -import * as i18n_html_parser from './src/i18n/i18n_html_parser'; -import * as i18n_message from './src/i18n/message'; -import * as i18n_extractor from './src/i18n/message_extractor'; -import * as xmb_serializer from './src/i18n/xmb_serializer'; +import * as interpolation_config from './src/html_parser/interpolation_config'; import * as metadata_resolver from './src/metadata_resolver'; import * as ng_module_compiler from './src/ng_module_compiler'; import * as path_util from './src/output/path_util'; @@ -44,22 +41,8 @@ export var CompileMetadataResolver = metadata_resolver.CompileMetadataResolver; export type HtmlParser = html_parser.HtmlParser; export var HtmlParser = html_parser.HtmlParser; -export type I18nHtmlParser = i18n_html_parser.I18nHtmlParser; -export var I18nHtmlParser = i18n_html_parser.I18nHtmlParser; - -export type ExtractionResult = i18n_extractor.ExtractionResult; -export var ExtractionResult = i18n_extractor.ExtractionResult; - -export type Message = i18n_message.Message; -export var Message = i18n_message.Message; - -export type MessageExtractor = i18n_extractor.MessageExtractor; -export var MessageExtractor = i18n_extractor.MessageExtractor; - -export var removeDuplicates = i18n_extractor.removeDuplicates; - -export var serializeXmb = xmb_serializer.serializeXmb; -export var deserializeXmb = xmb_serializer.deserializeXmb; +export type InterpolationConfig = interpolation_config.InterpolationConfig; +export var InterpolationConfig = interpolation_config.InterpolationConfig; export type DirectiveNormalizer = directive_normalizer.DirectiveNormalizer; export var DirectiveNormalizer = directive_normalizer.DirectiveNormalizer; diff --git a/modules/@angular/compiler/src/animation/animation_compiler.ts b/modules/@angular/compiler/src/animation/animation_compiler.ts index 144129914e..08025815d1 100644 --- a/modules/@angular/compiler/src/animation/animation_compiler.ts +++ b/modules/@angular/compiler/src/animation/animation_compiler.ts @@ -10,9 +10,9 @@ import {AUTO_STYLE} from '@angular/core'; import {ANY_STATE, DEFAULT_STATE, EMPTY_STATE} from '../../core_private'; import {CompileDirectiveMetadata} from '../compile_metadata'; -import {ListWrapper, Map, StringMapWrapper} from '../facade/collection'; +import {StringMapWrapper} from '../facade/collection'; import {BaseException} from '../facade/exceptions'; -import {isArray, isBlank, isPresent} from '../facade/lang'; +import {isBlank, isPresent} from '../facade/lang'; import {Identifiers} from '../identifiers'; import * as o from '../output/output_ast'; import * as t from '../template_parser/template_ast'; diff --git a/modules/@angular/compiler/src/compile_metadata.ts b/modules/@angular/compiler/src/compile_metadata.ts index 8c8197665f..f33071d78d 100644 --- a/modules/@angular/compiler/src/compile_metadata.ts +++ b/modules/@angular/compiler/src/compile_metadata.ts @@ -8,10 +8,10 @@ import {ChangeDetectionStrategy, SchemaMetadata, ViewEncapsulation} from '@angular/core'; -import {CHANGE_DETECTION_STRATEGY_VALUES, LIFECYCLE_HOOKS_VALUES, LifecycleHooks, VIEW_ENCAPSULATION_VALUES, reflector} from '../core_private'; -import {ListWrapper, StringMapWrapper} from '../src/facade/collection'; -import {BaseException, unimplemented} from '../src/facade/exceptions'; -import {NumberWrapper, RegExpWrapper, Type, isArray, isBlank, isBoolean, isNumber, isPresent, isString, isStringMap, normalizeBlank, normalizeBool, serializeEnum} from '../src/facade/lang'; +import {LifecycleHooks, reflector} from '../core_private'; +import {ListWrapper, StringMapWrapper} from './facade/collection'; +import {BaseException, unimplemented} from './facade/exceptions'; +import {RegExpWrapper, Type, isBlank, isPresent, isStringMap, normalizeBlank, normalizeBool} from './facade/lang'; import {CssSelector} from './selector'; import {getUrlScheme} from './url_resolver'; diff --git a/modules/@angular/compiler/src/compiler.ts b/modules/@angular/compiler/src/compiler.ts index c37018d74d..7b5c6c78f0 100644 --- a/modules/@angular/compiler/src/compiler.ts +++ b/modules/@angular/compiler/src/compiler.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Compiler, CompilerFactory, CompilerOptions, Component, ComponentResolver, Inject, Injectable, NgModule, PLATFORM_DIRECTIVES, PLATFORM_INITIALIZER, PLATFORM_PIPES, PlatformRef, ReflectiveInjector, Type, ViewEncapsulation, createPlatformFactory, disposePlatform, isDevMode, platformCore} from '@angular/core'; +import {Compiler, CompilerFactory, CompilerOptions, Component, Inject, Injectable, PLATFORM_DIRECTIVES, PLATFORM_INITIALIZER, PLATFORM_PIPES, PlatformRef, ReflectiveInjector, Type, ViewEncapsulation, createPlatformFactory, isDevMode, platformCore} from '@angular/core'; export * from './template_parser/template_ast'; export {TEMPLATE_TRANSFORMS} from './template_parser/template_parser'; diff --git a/modules/@angular/compiler/src/directive_lifecycle_reflector.ts b/modules/@angular/compiler/src/directive_lifecycle_reflector.ts index 520fb0bc36..783c32b5ba 100644 --- a/modules/@angular/compiler/src/directive_lifecycle_reflector.ts +++ b/modules/@angular/compiler/src/directive_lifecycle_reflector.ts @@ -9,8 +9,8 @@ import {OnInit, OnDestroy, DoCheck, OnChanges, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked,} from '@angular/core'; import {reflector, LifecycleHooks} from '../core_private'; -import {Type} from '../src/facade/lang'; -import {MapWrapper} from '../src/facade/collection'; +import {Type} from './facade/lang'; +import {MapWrapper} from './facade/collection'; const LIFECYCLE_INTERFACES: Map = MapWrapper.createFromPairs([ [LifecycleHooks.OnInit, OnInit], diff --git a/modules/@angular/compiler/src/directive_normalizer.ts b/modules/@angular/compiler/src/directive_normalizer.ts index 6f4840632d..1a1f767a33 100644 --- a/modules/@angular/compiler/src/directive_normalizer.ts +++ b/modules/@angular/compiler/src/directive_normalizer.ts @@ -7,12 +7,13 @@ */ import {Injectable, ViewEncapsulation} from '@angular/core'; -import {MapWrapper} from '../src/facade/collection'; -import {BaseException} from '../src/facade/exceptions'; -import {isBlank, isPresent} from '../src/facade/lang'; + import {CompileDirectiveMetadata, CompileStylesheetMetadata, CompileTemplateMetadata, CompileTypeMetadata} from './compile_metadata'; import {CompilerConfig} from './config'; -import {HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from './html_parser/html_ast'; +import {MapWrapper} from './facade/collection'; +import {BaseException} from './facade/exceptions'; +import {isBlank, isPresent} from './facade/lang'; +import * as html from './html_parser/ast'; import {HtmlParser} from './html_parser/html_parser'; import {InterpolationConfig} from './html_parser/interpolation_config'; import {extractStyleUrls, isStyleUrlResolvable} from './style_url_resolver'; @@ -111,7 +112,7 @@ export class DirectiveNormalizer { })); const visitor = new TemplatePreparseVisitor(); - htmlVisitAll(visitor, rootNodesAndErrors.rootNodes); + html.visitAll(visitor, rootNodesAndErrors.rootNodes); const templateStyles = this.normalizeStylesheet(new CompileStylesheetMetadata( {styles: visitor.styles, styleUrls: visitor.styleUrls, moduleUrl: templateAbsUrl})); @@ -187,13 +188,13 @@ export class DirectiveNormalizer { } } -class TemplatePreparseVisitor implements HtmlAstVisitor { +class TemplatePreparseVisitor implements html.Visitor { ngContentSelectors: string[] = []; styles: string[] = []; styleUrls: string[] = []; ngNonBindableStackCount: number = 0; - visitElement(ast: HtmlElementAst, context: any): any { + visitElement(ast: html.Element, context: any): any { var preparsedElement = preparseElement(ast); switch (preparsedElement.type) { case PreparsedElementType.NG_CONTENT: @@ -204,7 +205,7 @@ class TemplatePreparseVisitor implements HtmlAstVisitor { case PreparsedElementType.STYLE: var textContent = ''; ast.children.forEach(child => { - if (child instanceof HtmlTextAst) { + if (child instanceof html.Text) { textContent += child.value; } }); @@ -221,18 +222,18 @@ class TemplatePreparseVisitor implements HtmlAstVisitor { if (preparsedElement.nonBindable) { this.ngNonBindableStackCount++; } - htmlVisitAll(this, ast.children); + html.visitAll(this, ast.children); if (preparsedElement.nonBindable) { this.ngNonBindableStackCount--; } return null; } - visitComment(ast: HtmlCommentAst, context: any): any { return null; } - visitAttr(ast: HtmlAttrAst, context: any): any { return null; } - visitText(ast: HtmlTextAst, context: any): any { return null; } - visitExpansion(ast: HtmlExpansionAst, context: any): any { return null; } + visitComment(ast: html.Comment, context: any): any { return null; } + visitAttribute(ast: html.Attribute, context: any): any { return null; } + visitText(ast: html.Text, context: any): any { return null; } + visitExpansion(ast: html.Expansion, context: any): any { return null; } - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; } + visitExpansionCase(ast: html.ExpansionCase, context: any): any { return null; } } function _cloneDirectiveWithTemplate( diff --git a/modules/@angular/compiler/src/html_parser/ast.ts b/modules/@angular/compiler/src/html_parser/ast.ts new file mode 100644 index 0000000000..e99274e988 --- /dev/null +++ b/modules/@angular/compiler/src/html_parser/ast.ts @@ -0,0 +1,72 @@ +/** + * @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 {ParseSourceSpan} from '../parse_util'; + +export interface Node { + sourceSpan: ParseSourceSpan; + visit(visitor: Visitor, context: any): any; +} + +export class Text implements Node { + constructor(public value: string, public sourceSpan: ParseSourceSpan) {} + visit(visitor: Visitor, context: any): any { return visitor.visitText(this, context); } +} + +export class Expansion implements Node { + constructor( + public switchValue: string, public type: string, public cases: ExpansionCase[], + public sourceSpan: ParseSourceSpan, public switchValueSourceSpan: ParseSourceSpan) {} + visit(visitor: Visitor, context: any): any { return visitor.visitExpansion(this, context); } +} + +export class ExpansionCase implements Node { + constructor( + public value: string, public expression: Node[], public sourceSpan: ParseSourceSpan, + public valueSourceSpan: ParseSourceSpan, public expSourceSpan: ParseSourceSpan) {} + + visit(visitor: Visitor, context: any): any { return visitor.visitExpansionCase(this, context); } +} + +export class Attribute implements Node { + constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {} + visit(visitor: Visitor, context: any): any { return visitor.visitAttribute(this, context); } +} + +export class Element implements Node { + constructor( + public name: string, public attrs: Attribute[], public children: Node[], + public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan, + public endSourceSpan: ParseSourceSpan) {} + visit(visitor: Visitor, context: any): any { return visitor.visitElement(this, context); } +} + +export class Comment implements Node { + constructor(public value: string, public sourceSpan: ParseSourceSpan) {} + visit(visitor: Visitor, context: any): any { return visitor.visitComment(this, context); } +} + +export interface Visitor { + visitElement(element: Element, context: any): any; + visitAttribute(attribute: Attribute, context: any): any; + visitText(text: Text, context: any): any; + visitComment(comment: Comment, context: any): any; + visitExpansion(expansion: Expansion, context: any): any; + visitExpansionCase(expansionCase: ExpansionCase, context: any): any; +} + +export function visitAll(visitor: Visitor, nodes: Node[], context: any = null): any[] { + let result: any[] = []; + nodes.forEach(ast => { + const astResult = ast.visit(visitor, context); + if (astResult) { + result.push(astResult); + } + }); + return result; +} diff --git a/modules/@angular/compiler/src/html_parser/html_ast.ts b/modules/@angular/compiler/src/html_parser/html_ast.ts deleted file mode 100644 index a9f0d02c8f..0000000000 --- a/modules/@angular/compiler/src/html_parser/html_ast.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @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 {isPresent} from '../facade/lang'; - -import {ParseSourceSpan} from '../parse_util'; - -export interface HtmlAst { - sourceSpan: ParseSourceSpan; - visit(visitor: HtmlAstVisitor, context: any): any; -} - -export class HtmlTextAst implements HtmlAst { - constructor(public value: string, public sourceSpan: ParseSourceSpan) {} - visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitText(this, context); } -} - -export class HtmlExpansionAst implements HtmlAst { - constructor( - public switchValue: string, public type: string, public cases: HtmlExpansionCaseAst[], - public sourceSpan: ParseSourceSpan, public switchValueSourceSpan: ParseSourceSpan) {} - visit(visitor: HtmlAstVisitor, context: any): any { - return visitor.visitExpansion(this, context); - } -} - -export class HtmlExpansionCaseAst implements HtmlAst { - constructor( - public value: string, public expression: HtmlAst[], public sourceSpan: ParseSourceSpan, - public valueSourceSpan: ParseSourceSpan, public expSourceSpan: ParseSourceSpan) {} - - visit(visitor: HtmlAstVisitor, context: any): any { - return visitor.visitExpansionCase(this, context); - } -} - -export class HtmlAttrAst implements HtmlAst { - constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {} - visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitAttr(this, context); } -} - -export class HtmlElementAst implements HtmlAst { - constructor( - public name: string, public attrs: HtmlAttrAst[], public children: HtmlAst[], - public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan, - public endSourceSpan: ParseSourceSpan) {} - visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitElement(this, context); } -} - -export class HtmlCommentAst implements HtmlAst { - constructor(public value: string, public sourceSpan: ParseSourceSpan) {} - visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitComment(this, context); } -} - -export interface HtmlAstVisitor { - visitElement(ast: HtmlElementAst, context: any): any; - visitAttr(ast: HtmlAttrAst, context: any): any; - visitText(ast: HtmlTextAst, context: any): any; - visitComment(ast: HtmlCommentAst, context: any): any; - visitExpansion(ast: HtmlExpansionAst, context: any): any; - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any; -} - -export function htmlVisitAll(visitor: HtmlAstVisitor, asts: HtmlAst[], context: any = null): any[] { - var result: any[] = []; - asts.forEach(ast => { - var astResult = ast.visit(visitor, context); - if (isPresent(astResult)) { - result.push(astResult); - } - }); - return result; -} diff --git a/modules/@angular/compiler/src/html_parser/html_parser.ts b/modules/@angular/compiler/src/html_parser/html_parser.ts index 157ec68ece..c6fa6599e8 100644 --- a/modules/@angular/compiler/src/html_parser/html_parser.ts +++ b/modules/@angular/compiler/src/html_parser/html_parser.ts @@ -6,403 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import {Injectable} from '../../../core/index'; -import {isPresent, isBlank,} from '../facade/lang'; -import {ListWrapper} from '../facade/collection'; -import {HtmlAst, HtmlAttrAst, HtmlTextAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst} from './html_ast'; -import {HtmlToken, HtmlTokenType, tokenizeHtml} from './html_lexer'; -import {ParseError, ParseSourceSpan} from '../parse_util'; -import {getHtmlTagDefinition, getNsPrefix, mergeNsAndName} from './html_tags'; +import {Injectable} from '@angular/core'; + +import {getHtmlTagDefinition} from './html_tags'; import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from './interpolation_config'; +import {ParseTreeResult, Parser} from './parser'; -export class HtmlTreeError extends ParseError { - static create(elementName: string, span: ParseSourceSpan, msg: string): HtmlTreeError { - return new HtmlTreeError(elementName, span, msg); - } - - constructor(public elementName: string, span: ParseSourceSpan, msg: string) { super(span, msg); } -} - -export class HtmlParseTreeResult { - constructor(public rootNodes: HtmlAst[], public errors: ParseError[]) {} -} +export {ParseTreeResult, TreeError} from './parser'; @Injectable() -export class HtmlParser { +export class HtmlParser extends Parser { + constructor() { super(getHtmlTagDefinition); } + parse( - sourceContent: string, sourceUrl: string, parseExpansionForms: boolean = false, - interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): - HtmlParseTreeResult { - const tokensAndErrors = - tokenizeHtml(sourceContent, sourceUrl, parseExpansionForms, interpolationConfig); - const treeAndErrors = new TreeBuilder(tokensAndErrors.tokens).build(); - return new HtmlParseTreeResult( - treeAndErrors.rootNodes, - (tokensAndErrors.errors).concat(treeAndErrors.errors)); + source: string, url: string, parseExpansionForms: boolean = false, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ParseTreeResult { + return super.parse(source, url, parseExpansionForms, interpolationConfig); } } - -class TreeBuilder { - private index: number = -1; - private peek: HtmlToken; - - private rootNodes: HtmlAst[] = []; - private errors: HtmlTreeError[] = []; - - private elementStack: HtmlElementAst[] = []; - - constructor(private tokens: HtmlToken[]) { this._advance(); } - - build(): HtmlParseTreeResult { - while (this.peek.type !== HtmlTokenType.EOF) { - if (this.peek.type === HtmlTokenType.TAG_OPEN_START) { - this._consumeStartTag(this._advance()); - } else if (this.peek.type === HtmlTokenType.TAG_CLOSE) { - this._consumeEndTag(this._advance()); - } else if (this.peek.type === HtmlTokenType.CDATA_START) { - this._closeVoidElement(); - this._consumeCdata(this._advance()); - } else if (this.peek.type === HtmlTokenType.COMMENT_START) { - this._closeVoidElement(); - this._consumeComment(this._advance()); - } else if ( - this.peek.type === HtmlTokenType.TEXT || this.peek.type === HtmlTokenType.RAW_TEXT || - this.peek.type === HtmlTokenType.ESCAPABLE_RAW_TEXT) { - this._closeVoidElement(); - this._consumeText(this._advance()); - } else if (this.peek.type === HtmlTokenType.EXPANSION_FORM_START) { - this._consumeExpansion(this._advance()); - } else { - // Skip all other tokens... - this._advance(); - } - } - return new HtmlParseTreeResult(this.rootNodes, this.errors); - } - - private _advance(): HtmlToken { - const prev = this.peek; - if (this.index < this.tokens.length - 1) { - // Note: there is always an EOF token at the end - this.index++; - } - this.peek = this.tokens[this.index]; - return prev; - } - - private _advanceIf(type: HtmlTokenType): HtmlToken { - if (this.peek.type === type) { - return this._advance(); - } - return null; - } - - private _consumeCdata(startToken: HtmlToken) { - this._consumeText(this._advance()); - this._advanceIf(HtmlTokenType.CDATA_END); - } - - private _consumeComment(token: HtmlToken) { - const text = this._advanceIf(HtmlTokenType.RAW_TEXT); - this._advanceIf(HtmlTokenType.COMMENT_END); - const value = isPresent(text) ? text.parts[0].trim() : null; - this._addToParent(new HtmlCommentAst(value, token.sourceSpan)); - } - - private _consumeExpansion(token: HtmlToken) { - const switchValue = this._advance(); - - const type = this._advance(); - const cases: HtmlExpansionCaseAst[] = []; - - // read = - while (this.peek.type === HtmlTokenType.EXPANSION_CASE_VALUE) { - let expCase = this._parseExpansionCase(); - if (isBlank(expCase)) return; // error - cases.push(expCase); - } - - // read the final } - if (this.peek.type !== HtmlTokenType.EXPANSION_FORM_END) { - this.errors.push( - HtmlTreeError.create(null, this.peek.sourceSpan, `Invalid ICU message. Missing '}'.`)); - return; - } - this._advance(); - - const mainSourceSpan = new ParseSourceSpan(token.sourceSpan.start, this.peek.sourceSpan.end); - this._addToParent(new HtmlExpansionAst( - switchValue.parts[0], type.parts[0], cases, mainSourceSpan, switchValue.sourceSpan)); - } - - private _parseExpansionCase(): HtmlExpansionCaseAst { - const value = this._advance(); - - // read { - if (this.peek.type !== HtmlTokenType.EXPANSION_CASE_EXP_START) { - this.errors.push( - HtmlTreeError.create(null, this.peek.sourceSpan, `Invalid ICU message. Missing '{'.`)); - return null; - } - - // read until } - const start = this._advance(); - - const exp = this._collectExpansionExpTokens(start); - if (isBlank(exp)) return null; - - const end = this._advance(); - exp.push(new HtmlToken(HtmlTokenType.EOF, [], end.sourceSpan)); - - // parse everything in between { and } - const parsedExp = new TreeBuilder(exp).build(); - if (parsedExp.errors.length > 0) { - this.errors = this.errors.concat(parsedExp.errors); - return null; - } - - const sourceSpan = new ParseSourceSpan(value.sourceSpan.start, end.sourceSpan.end); - const expSourceSpan = new ParseSourceSpan(start.sourceSpan.start, end.sourceSpan.end); - return new HtmlExpansionCaseAst( - value.parts[0], parsedExp.rootNodes, sourceSpan, value.sourceSpan, expSourceSpan); - } - - private _collectExpansionExpTokens(start: HtmlToken): HtmlToken[] { - const exp: HtmlToken[] = []; - const expansionFormStack = [HtmlTokenType.EXPANSION_CASE_EXP_START]; - - while (true) { - if (this.peek.type === HtmlTokenType.EXPANSION_FORM_START || - this.peek.type === HtmlTokenType.EXPANSION_CASE_EXP_START) { - expansionFormStack.push(this.peek.type); - } - - if (this.peek.type === HtmlTokenType.EXPANSION_CASE_EXP_END) { - if (lastOnStack(expansionFormStack, HtmlTokenType.EXPANSION_CASE_EXP_START)) { - expansionFormStack.pop(); - if (expansionFormStack.length == 0) return exp; - - } else { - this.errors.push( - HtmlTreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`)); - return null; - } - } - - if (this.peek.type === HtmlTokenType.EXPANSION_FORM_END) { - if (lastOnStack(expansionFormStack, HtmlTokenType.EXPANSION_FORM_START)) { - expansionFormStack.pop(); - } else { - this.errors.push( - HtmlTreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`)); - return null; - } - } - - if (this.peek.type === HtmlTokenType.EOF) { - this.errors.push( - HtmlTreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`)); - return null; - } - - exp.push(this._advance()); - } - } - - private _consumeText(token: HtmlToken) { - let text = token.parts[0]; - if (text.length > 0 && text[0] == '\n') { - const parent = this._getParentElement(); - if (isPresent(parent) && parent.children.length == 0 && - getHtmlTagDefinition(parent.name).ignoreFirstLf) { - text = text.substring(1); - } - } - - if (text.length > 0) { - this._addToParent(new HtmlTextAst(text, token.sourceSpan)); - } - } - - private _closeVoidElement(): void { - if (this.elementStack.length > 0) { - const el = ListWrapper.last(this.elementStack); - - if (getHtmlTagDefinition(el.name).isVoid) { - this.elementStack.pop(); - } - } - } - - private _consumeStartTag(startTagToken: HtmlToken) { - const prefix = startTagToken.parts[0]; - const name = startTagToken.parts[1]; - const attrs: HtmlAttrAst[] = []; - while (this.peek.type === HtmlTokenType.ATTR_NAME) { - attrs.push(this._consumeAttr(this._advance())); - } - const fullName = getElementFullName(prefix, name, this._getParentElement()); - let selfClosing = false; - // Note: There could have been a tokenizer error - // so that we don't get a token for the end tag... - if (this.peek.type === HtmlTokenType.TAG_OPEN_END_VOID) { - this._advance(); - selfClosing = true; - if (getNsPrefix(fullName) == null && !getHtmlTagDefinition(fullName).isVoid) { - this.errors.push(HtmlTreeError.create( - fullName, startTagToken.sourceSpan, - `Only void and foreign elements can be self closed "${startTagToken.parts[1]}"`)); - } - } else if (this.peek.type === HtmlTokenType.TAG_OPEN_END) { - this._advance(); - selfClosing = false; - } - const end = this.peek.sourceSpan.start; - const span = new ParseSourceSpan(startTagToken.sourceSpan.start, end); - const el = new HtmlElementAst(fullName, attrs, [], span, span, null); - this._pushElement(el); - if (selfClosing) { - this._popElement(fullName); - el.endSourceSpan = span; - } - } - - private _pushElement(el: HtmlElementAst) { - if (this.elementStack.length > 0) { - const parentEl = ListWrapper.last(this.elementStack); - if (getHtmlTagDefinition(parentEl.name).isClosedByChild(el.name)) { - this.elementStack.pop(); - } - } - - const tagDef = getHtmlTagDefinition(el.name); - const {parent, container} = this._getParentElementSkippingContainers(); - - if (isPresent(parent) && tagDef.requireExtraParent(parent.name)) { - const newParent = new HtmlElementAst( - tagDef.parentToAdd, [], [], el.sourceSpan, el.startSourceSpan, el.endSourceSpan); - this._insertBeforeContainer(parent, container, newParent); - } - - this._addToParent(el); - this.elementStack.push(el); - } - - private _consumeEndTag(endTagToken: HtmlToken) { - const fullName = - getElementFullName(endTagToken.parts[0], endTagToken.parts[1], this._getParentElement()); - - if (this._getParentElement()) { - this._getParentElement().endSourceSpan = endTagToken.sourceSpan; - } - - if (getHtmlTagDefinition(fullName).isVoid) { - this.errors.push(HtmlTreeError.create( - fullName, endTagToken.sourceSpan, - `Void elements do not have end tags "${endTagToken.parts[1]}"`)); - } else if (!this._popElement(fullName)) { - this.errors.push(HtmlTreeError.create( - fullName, endTagToken.sourceSpan, `Unexpected closing tag "${endTagToken.parts[1]}"`)); - } - } - - private _popElement(fullName: string): boolean { - for (let stackIndex = this.elementStack.length - 1; stackIndex >= 0; stackIndex--) { - const el = this.elementStack[stackIndex]; - if (el.name == fullName) { - ListWrapper.splice(this.elementStack, stackIndex, this.elementStack.length - stackIndex); - return true; - } - - if (!getHtmlTagDefinition(el.name).closedByParent) { - return false; - } - } - return false; - } - - private _consumeAttr(attrName: HtmlToken): HtmlAttrAst { - const fullName = mergeNsAndName(attrName.parts[0], attrName.parts[1]); - let end = attrName.sourceSpan.end; - let value = ''; - if (this.peek.type === HtmlTokenType.ATTR_VALUE) { - const valueToken = this._advance(); - value = valueToken.parts[0]; - end = valueToken.sourceSpan.end; - } - return new HtmlAttrAst(fullName, value, new ParseSourceSpan(attrName.sourceSpan.start, end)); - } - - private _getParentElement(): HtmlElementAst { - return this.elementStack.length > 0 ? ListWrapper.last(this.elementStack) : null; - } - - /** - * Returns the parent in the DOM and the container. - * - * `` elements are skipped as they are not rendered as DOM element. - */ - private _getParentElementSkippingContainers(): - {parent: HtmlElementAst, container: HtmlElementAst} { - let container: HtmlElementAst = null; - - for (let i = this.elementStack.length - 1; i >= 0; i--) { - if (this.elementStack[i].name !== 'ng-container') { - return {parent: this.elementStack[i], container}; - } - container = this.elementStack[i]; - } - - return {parent: ListWrapper.last(this.elementStack), container}; - } - - private _addToParent(node: HtmlAst) { - const parent = this._getParentElement(); - if (isPresent(parent)) { - parent.children.push(node); - } else { - this.rootNodes.push(node); - } - } - - /** - * Insert a node between the parent and the container. - * When no container is given, the node is appended as a child of the parent. - * Also updates the element stack accordingly. - * - * @internal - */ - private _insertBeforeContainer( - parent: HtmlElementAst, container: HtmlElementAst, node: HtmlElementAst) { - if (!container) { - this._addToParent(node); - this.elementStack.push(node); - } else { - if (parent) { - // replace the container with the new node in the children - const index = parent.children.indexOf(container); - parent.children[index] = node; - } else { - this.rootNodes.push(node); - } - node.children.push(container); - this.elementStack.splice(this.elementStack.indexOf(container), 0, node); - } - } -} - -function getElementFullName( - prefix: string, localName: string, parentElement: HtmlElementAst): string { - if (isBlank(prefix)) { - prefix = getHtmlTagDefinition(localName).implicitNamespacePrefix; - if (isBlank(prefix) && isPresent(parentElement)) { - prefix = getNsPrefix(parentElement.name); - } - } - - return mergeNsAndName(prefix, localName); -} - -function lastOnStack(stack: any[], element: any): boolean { - return stack.length > 0 && stack[stack.length - 1] === element; -} diff --git a/modules/@angular/compiler/src/html_parser/html_tags.ts b/modules/@angular/compiler/src/html_parser/html_tags.ts index 5c94804c9e..664a44d80b 100644 --- a/modules/@angular/compiler/src/html_parser/html_tags.ts +++ b/modules/@angular/compiler/src/html_parser/html_tags.ts @@ -6,299 +6,37 @@ * found in the LICENSE file at https://angular.io/license */ -import {normalizeBool, RegExpWrapper,} from '../facade/lang'; +import {TagContentType, TagDefinition} from './tags'; -// see http://www.w3.org/TR/html51/syntax.html#named-character-references -// see https://html.spec.whatwg.org/multipage/entities.json -// 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 = /*@ts2dart_const*/ { - 'Aacute': '\u00C1', - 'aacute': '\u00E1', - 'Acirc': '\u00C2', - 'acirc': '\u00E2', - 'acute': '\u00B4', - 'AElig': '\u00C6', - 'aelig': '\u00E6', - 'Agrave': '\u00C0', - 'agrave': '\u00E0', - 'alefsym': '\u2135', - 'Alpha': '\u0391', - 'alpha': '\u03B1', - 'amp': '&', - 'and': '\u2227', - 'ang': '\u2220', - 'apos': '\u0027', - 'Aring': '\u00C5', - 'aring': '\u00E5', - 'asymp': '\u2248', - 'Atilde': '\u00C3', - 'atilde': '\u00E3', - 'Auml': '\u00C4', - 'auml': '\u00E4', - 'bdquo': '\u201E', - 'Beta': '\u0392', - 'beta': '\u03B2', - 'brvbar': '\u00A6', - 'bull': '\u2022', - 'cap': '\u2229', - 'Ccedil': '\u00C7', - 'ccedil': '\u00E7', - 'cedil': '\u00B8', - 'cent': '\u00A2', - 'Chi': '\u03A7', - 'chi': '\u03C7', - 'circ': '\u02C6', - 'clubs': '\u2663', - 'cong': '\u2245', - 'copy': '\u00A9', - 'crarr': '\u21B5', - 'cup': '\u222A', - 'curren': '\u00A4', - 'dagger': '\u2020', - 'Dagger': '\u2021', - 'darr': '\u2193', - 'dArr': '\u21D3', - 'deg': '\u00B0', - 'Delta': '\u0394', - 'delta': '\u03B4', - 'diams': '\u2666', - 'divide': '\u00F7', - 'Eacute': '\u00C9', - 'eacute': '\u00E9', - 'Ecirc': '\u00CA', - 'ecirc': '\u00EA', - 'Egrave': '\u00C8', - 'egrave': '\u00E8', - 'empty': '\u2205', - 'emsp': '\u2003', - 'ensp': '\u2002', - 'Epsilon': '\u0395', - 'epsilon': '\u03B5', - 'equiv': '\u2261', - 'Eta': '\u0397', - 'eta': '\u03B7', - 'ETH': '\u00D0', - 'eth': '\u00F0', - 'Euml': '\u00CB', - 'euml': '\u00EB', - 'euro': '\u20AC', - 'exist': '\u2203', - 'fnof': '\u0192', - 'forall': '\u2200', - 'frac12': '\u00BD', - 'frac14': '\u00BC', - 'frac34': '\u00BE', - 'frasl': '\u2044', - 'Gamma': '\u0393', - 'gamma': '\u03B3', - 'ge': '\u2265', - 'gt': '>', - 'harr': '\u2194', - 'hArr': '\u21D4', - 'hearts': '\u2665', - 'hellip': '\u2026', - 'Iacute': '\u00CD', - 'iacute': '\u00ED', - 'Icirc': '\u00CE', - 'icirc': '\u00EE', - 'iexcl': '\u00A1', - 'Igrave': '\u00CC', - 'igrave': '\u00EC', - 'image': '\u2111', - 'infin': '\u221E', - 'int': '\u222B', - 'Iota': '\u0399', - 'iota': '\u03B9', - 'iquest': '\u00BF', - 'isin': '\u2208', - 'Iuml': '\u00CF', - 'iuml': '\u00EF', - 'Kappa': '\u039A', - 'kappa': '\u03BA', - 'Lambda': '\u039B', - 'lambda': '\u03BB', - 'lang': '\u27E8', - 'laquo': '\u00AB', - 'larr': '\u2190', - 'lArr': '\u21D0', - 'lceil': '\u2308', - 'ldquo': '\u201C', - 'le': '\u2264', - 'lfloor': '\u230A', - 'lowast': '\u2217', - 'loz': '\u25CA', - 'lrm': '\u200E', - 'lsaquo': '\u2039', - 'lsquo': '\u2018', - 'lt': '<', - 'macr': '\u00AF', - 'mdash': '\u2014', - 'micro': '\u00B5', - 'middot': '\u00B7', - 'minus': '\u2212', - 'Mu': '\u039C', - 'mu': '\u03BC', - 'nabla': '\u2207', - 'nbsp': '\u00A0', - 'ndash': '\u2013', - 'ne': '\u2260', - 'ni': '\u220B', - 'not': '\u00AC', - 'notin': '\u2209', - 'nsub': '\u2284', - 'Ntilde': '\u00D1', - 'ntilde': '\u00F1', - 'Nu': '\u039D', - 'nu': '\u03BD', - 'Oacute': '\u00D3', - 'oacute': '\u00F3', - 'Ocirc': '\u00D4', - 'ocirc': '\u00F4', - 'OElig': '\u0152', - 'oelig': '\u0153', - 'Ograve': '\u00D2', - 'ograve': '\u00F2', - 'oline': '\u203E', - 'Omega': '\u03A9', - 'omega': '\u03C9', - 'Omicron': '\u039F', - 'omicron': '\u03BF', - 'oplus': '\u2295', - 'or': '\u2228', - 'ordf': '\u00AA', - 'ordm': '\u00BA', - 'Oslash': '\u00D8', - 'oslash': '\u00F8', - 'Otilde': '\u00D5', - 'otilde': '\u00F5', - 'otimes': '\u2297', - 'Ouml': '\u00D6', - 'ouml': '\u00F6', - 'para': '\u00B6', - 'permil': '\u2030', - 'perp': '\u22A5', - 'Phi': '\u03A6', - 'phi': '\u03C6', - 'Pi': '\u03A0', - 'pi': '\u03C0', - 'piv': '\u03D6', - 'plusmn': '\u00B1', - 'pound': '\u00A3', - 'prime': '\u2032', - 'Prime': '\u2033', - 'prod': '\u220F', - 'prop': '\u221D', - 'Psi': '\u03A8', - 'psi': '\u03C8', - 'quot': '\u0022', - 'radic': '\u221A', - 'rang': '\u27E9', - 'raquo': '\u00BB', - 'rarr': '\u2192', - 'rArr': '\u21D2', - 'rceil': '\u2309', - 'rdquo': '\u201D', - 'real': '\u211C', - 'reg': '\u00AE', - 'rfloor': '\u230B', - 'Rho': '\u03A1', - 'rho': '\u03C1', - 'rlm': '\u200F', - 'rsaquo': '\u203A', - 'rsquo': '\u2019', - 'sbquo': '\u201A', - 'Scaron': '\u0160', - 'scaron': '\u0161', - 'sdot': '\u22C5', - 'sect': '\u00A7', - 'shy': '\u00AD', - 'Sigma': '\u03A3', - 'sigma': '\u03C3', - 'sigmaf': '\u03C2', - 'sim': '\u223C', - 'spades': '\u2660', - 'sub': '\u2282', - 'sube': '\u2286', - 'sum': '\u2211', - 'sup': '\u2283', - 'sup1': '\u00B9', - 'sup2': '\u00B2', - 'sup3': '\u00B3', - 'supe': '\u2287', - 'szlig': '\u00DF', - 'Tau': '\u03A4', - 'tau': '\u03C4', - 'there4': '\u2234', - 'Theta': '\u0398', - 'theta': '\u03B8', - 'thetasym': '\u03D1', - 'thinsp': '\u2009', - 'THORN': '\u00DE', - 'thorn': '\u00FE', - 'tilde': '\u02DC', - 'times': '\u00D7', - 'trade': '\u2122', - 'Uacute': '\u00DA', - 'uacute': '\u00FA', - 'uarr': '\u2191', - 'uArr': '\u21D1', - 'Ucirc': '\u00DB', - 'ucirc': '\u00FB', - 'Ugrave': '\u00D9', - 'ugrave': '\u00F9', - 'uml': '\u00A8', - 'upsih': '\u03D2', - 'Upsilon': '\u03A5', - 'upsilon': '\u03C5', - 'Uuml': '\u00DC', - 'uuml': '\u00FC', - 'weierp': '\u2118', - 'Xi': '\u039E', - 'xi': '\u03BE', - 'Yacute': '\u00DD', - 'yacute': '\u00FD', - 'yen': '\u00A5', - 'yuml': '\u00FF', - 'Yuml': '\u0178', - 'Zeta': '\u0396', - 'zeta': '\u03B6', - 'zwj': '\u200D', - 'zwnj': '\u200C', -}; - -export enum HtmlTagContentType { - RAW_TEXT, - ESCAPABLE_RAW_TEXT, - PARSABLE_DATA -} - -export class HtmlTagDefinition { +export class HtmlTagDefinition implements TagDefinition { private closedByChildren: {[key: string]: boolean} = {}; - public closedByParent: boolean = false; - public requiredParents: {[key: string]: boolean}; - public parentToAdd: string; - public implicitNamespacePrefix: string; - public contentType: HtmlTagContentType; - public isVoid: boolean; - public ignoreFirstLf: boolean; + + closedByParent: boolean = false; + requiredParents: {[key: string]: boolean}; + parentToAdd: string; + implicitNamespacePrefix: string; + contentType: TagContentType; + isVoid: boolean; + ignoreFirstLf: boolean; + canSelfClose: boolean = false; constructor( - {closedByChildren, requiredParents, implicitNamespacePrefix, contentType, closedByParent, - isVoid, ignoreFirstLf}: { + {closedByChildren, requiredParents, implicitNamespacePrefix, + contentType = TagContentType.PARSABLE_DATA, closedByParent = false, isVoid = false, + ignoreFirstLf = false}: { closedByChildren?: string[], closedByParent?: boolean, requiredParents?: string[], implicitNamespacePrefix?: string, - contentType?: HtmlTagContentType, + contentType?: TagContentType, isVoid?: boolean, ignoreFirstLf?: boolean } = {}) { if (closedByChildren && closedByChildren.length > 0) { closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true); } - this.isVoid = normalizeBool(isVoid); - this.closedByParent = normalizeBool(closedByParent) || this.isVoid; + this.isVoid = isVoid; + this.closedByParent = closedByParent || isVoid; if (requiredParents && requiredParents.length > 0) { this.requiredParents = {}; // The first parent is the list is automatically when none of the listed parents are present @@ -306,8 +44,8 @@ export class HtmlTagDefinition { requiredParents.forEach(tagName => this.requiredParents[tagName] = true); } this.implicitNamespacePrefix = implicitNamespacePrefix; - this.contentType = contentType || HtmlTagContentType.PARSABLE_DATA; - this.ignoreFirstLf = normalizeBool(ignoreFirstLf); + this.contentType = contentType; + this.ignoreFirstLf = ignoreFirstLf; } requireExtraParent(currentParent: string): boolean { @@ -324,13 +62,13 @@ export class HtmlTagDefinition { } isClosedByChild(name: string): boolean { - return this.isVoid || normalizeBool(this.closedByChildren[name.toLowerCase()]); + return this.isVoid || name.toLowerCase() in this.closedByChildren; } } // see http://www.w3.org/TR/html51/syntax.html#optional-tags // This implementation does not fully conform to the HTML5 spec. -var TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = { +const TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = { 'base': new HtmlTagDefinition({isVoid: true}), 'meta': new HtmlTagDefinition({isVoid: true}), 'area': new HtmlTagDefinition({isVoid: true}), @@ -376,11 +114,11 @@ var TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = { 'option': new HtmlTagDefinition({closedByChildren: ['option', 'optgroup'], closedByParent: true}), 'pre': new HtmlTagDefinition({ignoreFirstLf: true}), 'listing': new HtmlTagDefinition({ignoreFirstLf: true}), - 'style': new HtmlTagDefinition({contentType: HtmlTagContentType.RAW_TEXT}), - 'script': new HtmlTagDefinition({contentType: HtmlTagContentType.RAW_TEXT}), - 'title': new HtmlTagDefinition({contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT}), - 'textarea': new HtmlTagDefinition( - {contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT, ignoreFirstLf: true}), + 'style': new HtmlTagDefinition({contentType: TagContentType.RAW_TEXT}), + 'script': new HtmlTagDefinition({contentType: TagContentType.RAW_TEXT}), + 'title': new HtmlTagDefinition({contentType: TagContentType.ESCAPABLE_RAW_TEXT}), + 'textarea': + new HtmlTagDefinition({contentType: TagContentType.ESCAPABLE_RAW_TEXT, ignoreFirstLf: true}), }; const _DEFAULT_TAG_DEFINITION = new HtmlTagDefinition(); @@ -388,21 +126,3 @@ const _DEFAULT_TAG_DEFINITION = new HtmlTagDefinition(); export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition { return TAG_DEFINITIONS[tagName.toLowerCase()] || _DEFAULT_TAG_DEFINITION; } - -const _NS_PREFIX_RE = /^:([^:]+):(.+)/g; - -export function splitNsName(elementName: string): [string, string] { - if (elementName[0] != ':') { - return [null, elementName]; - } - const match = RegExpWrapper.firstMatch(_NS_PREFIX_RE, elementName); - return [match[1], match[2]]; -} - -export function getNsPrefix(elementName: string): string { - return splitNsName(elementName)[0]; -} - -export function mergeNsAndName(prefix: string, localName: string): string { - return prefix ? `:${prefix}:${localName}` : localName; -} diff --git a/modules/@angular/compiler/src/html_parser/expander.ts b/modules/@angular/compiler/src/html_parser/icu_ast_expander.ts similarity index 54% rename from modules/@angular/compiler/src/html_parser/expander.ts rename to modules/@angular/compiler/src/html_parser/icu_ast_expander.ts index d480f63c92..88745078fa 100644 --- a/modules/@angular/compiler/src/html_parser/expander.ts +++ b/modules/@angular/compiler/src/html_parser/icu_ast_expander.ts @@ -8,8 +8,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util'; -import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from './html_ast'; - +import * as html from './ast'; // http://cldr.unicode.org/index/cldr-spec/plural-rules const PLURAL_CASES: string[] = ['zero', 'one', 'two', 'few', 'many', 'other']; @@ -37,13 +36,13 @@ const PLURAL_CASES: string[] = ['zero', 'one', 'two', 'few', 'many', 'other']; * * ``` */ -export function expandNodes(nodes: HtmlAst[]): ExpansionResult { +export function expandNodes(nodes: html.Node[]): ExpansionResult { const expander = new _Expander(); - return new ExpansionResult(htmlVisitAll(expander, nodes), expander.isExpanded, expander.errors); + return new ExpansionResult(html.visitAll(expander, nodes), expander.isExpanded, expander.errors); } export class ExpansionResult { - constructor(public nodes: HtmlAst[], public expanded: boolean, public errors: ParseError[]) {} + constructor(public nodes: html.Node[], public expanded: boolean, public errors: ParseError[]) {} } export class ExpansionError extends ParseError { @@ -55,34 +54,34 @@ export class ExpansionError extends ParseError { * * @internal */ -class _Expander implements HtmlAstVisitor { +class _Expander implements html.Visitor { isExpanded: boolean = false; errors: ParseError[] = []; - visitElement(ast: HtmlElementAst, context: any): any { - return new HtmlElementAst( - ast.name, ast.attrs, htmlVisitAll(this, ast.children), ast.sourceSpan, ast.startSourceSpan, - ast.endSourceSpan); + visitElement(element: html.Element, context: any): any { + return new html.Element( + element.name, element.attrs, html.visitAll(this, element.children), element.sourceSpan, + element.startSourceSpan, element.endSourceSpan); } - visitAttr(ast: HtmlAttrAst, context: any): any { return ast; } + visitAttribute(attribute: html.Attribute, context: any): any { return attribute; } - visitText(ast: HtmlTextAst, context: any): any { return ast; } + visitText(text: html.Text, context: any): any { return text; } - visitComment(ast: HtmlCommentAst, context: any): any { return ast; } + visitComment(comment: html.Comment, context: any): any { return comment; } - visitExpansion(ast: HtmlExpansionAst, context: any): any { + visitExpansion(icu: html.Expansion, context: any): any { this.isExpanded = true; - return ast.type == 'plural' ? _expandPluralForm(ast, this.errors) : - _expandDefaultForm(ast, this.errors); + return icu.type == 'plural' ? _expandPluralForm(icu, this.errors) : + _expandDefaultForm(icu, this.errors); } - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { + visitExpansionCase(icuCase: html.ExpansionCase, context: any): any { throw new Error('Should not be reached'); } } -function _expandPluralForm(ast: HtmlExpansionAst, errors: ParseError[]): HtmlElementAst { +function _expandPluralForm(ast: html.Expansion, errors: ParseError[]): html.Element { const children = ast.cases.map(c => { if (PLURAL_CASES.indexOf(c.value) == -1 && !c.value.match(/^=\d+$/)) { errors.push(new ExpansionError( @@ -93,25 +92,25 @@ function _expandPluralForm(ast: HtmlExpansionAst, errors: ParseError[]): HtmlEle const expansionResult = expandNodes(c.expression); errors.push(...expansionResult.errors); - return new HtmlElementAst( - `template`, [new HtmlAttrAst('ngPluralCase', `${c.value}`, c.valueSourceSpan)], + return new html.Element( + `template`, [new html.Attribute('ngPluralCase', `${c.value}`, c.valueSourceSpan)], expansionResult.nodes, c.sourceSpan, c.sourceSpan, c.sourceSpan); }); - const switchAttr = new HtmlAttrAst('[ngPlural]', ast.switchValue, ast.switchValueSourceSpan); - return new HtmlElementAst( + const switchAttr = new html.Attribute('[ngPlural]', ast.switchValue, ast.switchValueSourceSpan); + return new html.Element( 'ng-container', [switchAttr], children, ast.sourceSpan, ast.sourceSpan, ast.sourceSpan); } -function _expandDefaultForm(ast: HtmlExpansionAst, errors: ParseError[]): HtmlElementAst { +function _expandDefaultForm(ast: html.Expansion, errors: ParseError[]): html.Element { let children = ast.cases.map(c => { const expansionResult = expandNodes(c.expression); errors.push(...expansionResult.errors); - return new HtmlElementAst( - `template`, [new HtmlAttrAst('ngSwitchCase', `${c.value}`, c.valueSourceSpan)], + return new html.Element( + `template`, [new html.Attribute('ngSwitchCase', `${c.value}`, c.valueSourceSpan)], expansionResult.nodes, c.sourceSpan, c.sourceSpan, c.sourceSpan); }); - const switchAttr = new HtmlAttrAst('[ngSwitch]', ast.switchValue, ast.switchValueSourceSpan); - return new HtmlElementAst( + const switchAttr = new html.Attribute('[ngSwitch]', ast.switchValue, ast.switchValueSourceSpan); + return new html.Element( 'ng-container', [switchAttr], children, ast.sourceSpan, ast.sourceSpan, ast.sourceSpan); } diff --git a/modules/@angular/compiler/src/html_parser/html_lexer.ts b/modules/@angular/compiler/src/html_parser/lexer.ts similarity index 81% rename from modules/@angular/compiler/src/html_parser/html_lexer.ts rename to modules/@angular/compiler/src/html_parser/lexer.ts index c11e0a7e05..338c712e3b 100644 --- a/modules/@angular/compiler/src/html_parser/html_lexer.ts +++ b/modules/@angular/compiler/src/html_parser/lexer.ts @@ -7,13 +7,12 @@ */ import * as chars from '../chars'; -import {isBlank, isPresent} from '../facade/lang'; import {ParseError, ParseLocation, ParseSourceFile, ParseSourceSpan} from '../parse_util'; -import {HtmlTagContentType, NAMED_ENTITIES, getHtmlTagDefinition} from './html_tags'; import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from './interpolation_config'; +import {NAMED_ENTITIES, TagContentType, TagDefinition} from './tags'; -export enum HtmlTokenType { +export enum TokenType { TAG_OPEN_START, TAG_OPEN_END, TAG_OPEN_END_VOID, @@ -36,26 +35,26 @@ export enum HtmlTokenType { EOF } -export class HtmlToken { - constructor( - public type: HtmlTokenType, public parts: string[], public sourceSpan: ParseSourceSpan) {} +export class Token { + constructor(public type: TokenType, public parts: string[], public sourceSpan: ParseSourceSpan) {} } -export class HtmlTokenError extends ParseError { - constructor(errorMsg: string, public tokenType: HtmlTokenType, span: ParseSourceSpan) { +export class TokenError extends ParseError { + constructor(errorMsg: string, public tokenType: TokenType, span: ParseSourceSpan) { super(span, errorMsg); } } -export class HtmlTokenizeResult { - constructor(public tokens: HtmlToken[], public errors: HtmlTokenError[]) {} +export class TokenizeResult { + constructor(public tokens: Token[], public errors: TokenError[]) {} } -export function tokenizeHtml( - sourceContent: string, sourceUrl: string, tokenizeExpansionForms: boolean = false, - interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): HtmlTokenizeResult { - return new _HtmlTokenizer( - new ParseSourceFile(sourceContent, sourceUrl), tokenizeExpansionForms, +export function tokenize( + source: string, url: string, getTagDefinition: (tagName: string) => TagDefinition, + tokenizeExpansionForms: boolean = false, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): TokenizeResult { + return new _Tokenizer( + new ParseSourceFile(source, url), getTagDefinition, tokenizeExpansionForms, interpolationConfig) .tokenize(); } @@ -72,11 +71,11 @@ function _unknownEntityErrorMsg(entitySrc: string): string { } class _ControlFlowError { - constructor(public error: HtmlTokenError) {} + constructor(public error: TokenError) {} } // See http://www.w3.org/TR/html51/syntax.html#writing -class _HtmlTokenizer { +class _Tokenizer { private _input: string; private _length: number; // Note: this is always lowercase! @@ -86,20 +85,22 @@ class _HtmlTokenizer { private _line: number = 0; private _column: number = -1; private _currentTokenStart: ParseLocation; - private _currentTokenType: HtmlTokenType; - private _expansionCaseStack: HtmlTokenType[] = []; + private _currentTokenType: TokenType; + private _expansionCaseStack: TokenType[] = []; private _inInterpolation: boolean = false; - tokens: HtmlToken[] = []; - errors: HtmlTokenError[] = []; + tokens: Token[] = []; + errors: TokenError[] = []; /** * @param _file The html source + * @param _getTagDefinition * @param _tokenizeIcu Whether to tokenize ICU messages (considered as text nodes when false) * @param _interpolationConfig */ constructor( - private _file: ParseSourceFile, private _tokenizeIcu: boolean, + private _file: ParseSourceFile, private _getTagDefinition: (tagName: string) => TagDefinition, + private _tokenizeIcu: boolean, private _interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG) { this._input = _file.content; this._length = _file.content.length; @@ -114,7 +115,7 @@ class _HtmlTokenizer { return content.replace(_CR_OR_CRLF_REGEXP, '\n'); } - tokenize(): HtmlTokenizeResult { + tokenize(): TokenizeResult { while (this._peek !== chars.$EOF) { const start = this._getLocation(); try { @@ -143,9 +144,9 @@ class _HtmlTokenizer { } } } - this._beginToken(HtmlTokenType.EOF); + this._beginToken(TokenType.EOF); this._endToken([]); - return new HtmlTokenizeResult(mergeTextTokens(this.tokens), this.errors); + return new TokenizeResult(mergeTextTokens(this.tokens), this.errors); } /** @@ -188,14 +189,14 @@ class _HtmlTokenizer { return new ParseSourceSpan(start, end); } - private _beginToken(type: HtmlTokenType, start: ParseLocation = this._getLocation()) { + private _beginToken(type: TokenType, start: ParseLocation = this._getLocation()) { this._currentTokenStart = start; this._currentTokenType = type; } - private _endToken(parts: string[], end: ParseLocation = this._getLocation()): HtmlToken { - const token = new HtmlToken( - this._currentTokenType, parts, new ParseSourceSpan(this._currentTokenStart, end)); + private _endToken(parts: string[], end: ParseLocation = this._getLocation()): Token { + const token = + new Token(this._currentTokenType, parts, new ParseSourceSpan(this._currentTokenStart, end)); this.tokens.push(token); this._currentTokenStart = null; this._currentTokenType = null; @@ -204,9 +205,9 @@ class _HtmlTokenizer { private _createError(msg: string, span: ParseSourceSpan): _ControlFlowError { if (this._isInExpansionForm()) { - msg += ' (Do you have an unescaped "{" in your template?).'; + msg += ` (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)`; } - const error = new HtmlTokenError(msg, this._currentTokenType, span); + const error = new TokenError(msg, this._currentTokenType, span); this._currentTokenStart = null; this._currentTokenType = null; return new _ControlFlowError(error); @@ -343,9 +344,9 @@ class _HtmlTokenizer { return '&'; } this._advance(); - let name = this._input.substring(start.offset + 1, this._index - 1); - let char = (NAMED_ENTITIES as any)[name]; - if (isBlank(char)) { + const name = this._input.substring(start.offset + 1, this._index - 1); + const char = NAMED_ENTITIES[name]; + if (!char) { throw this._createError(_unknownEntityErrorMsg(name), this._getSpan(start)); } return char; @@ -353,11 +354,10 @@ class _HtmlTokenizer { } private _consumeRawText( - decodeEntities: boolean, firstCharOfEnd: number, attemptEndRest: () => boolean): HtmlToken { + decodeEntities: boolean, firstCharOfEnd: number, attemptEndRest: () => boolean): Token { let tagCloseStart: ParseLocation; const textStart = this._getLocation(); - this._beginToken( - decodeEntities ? HtmlTokenType.ESCAPABLE_RAW_TEXT : HtmlTokenType.RAW_TEXT, textStart); + this._beginToken(decodeEntities ? TokenType.ESCAPABLE_RAW_TEXT : TokenType.RAW_TEXT, textStart); const parts: string[] = []; while (true) { tagCloseStart = this._getLocation(); @@ -376,25 +376,25 @@ class _HtmlTokenizer { } private _consumeComment(start: ParseLocation) { - this._beginToken(HtmlTokenType.COMMENT_START, start); + this._beginToken(TokenType.COMMENT_START, start); this._requireCharCode(chars.$MINUS); this._endToken([]); const textToken = this._consumeRawText(false, chars.$MINUS, () => this._attemptStr('->')); - this._beginToken(HtmlTokenType.COMMENT_END, textToken.sourceSpan.end); + this._beginToken(TokenType.COMMENT_END, textToken.sourceSpan.end); this._endToken([]); } private _consumeCdata(start: ParseLocation) { - this._beginToken(HtmlTokenType.CDATA_START, start); + this._beginToken(TokenType.CDATA_START, start); this._requireStr('CDATA['); this._endToken([]); const textToken = this._consumeRawText(false, chars.$RBRACKET, () => this._attemptStr(']>')); - this._beginToken(HtmlTokenType.CDATA_END, textToken.sourceSpan.end); + this._beginToken(TokenType.CDATA_END, textToken.sourceSpan.end); this._endToken([]); } private _consumeDocType(start: ParseLocation) { - this._beginToken(HtmlTokenType.DOC_TYPE, start); + this._beginToken(TokenType.DOC_TYPE, start); this._attemptUntilChar(chars.$GT); this._advance(); this._endToken([this._input.substring(start.offset + 2, this._index - 1)]); @@ -421,6 +421,7 @@ class _HtmlTokenizer { private _consumeTagOpen(start: ParseLocation) { let savedPos = this._savePosition(); + let tagName: string; let lowercaseTagName: string; try { if (!chars.isAsciiLetter(this._peek)) { @@ -428,7 +429,8 @@ class _HtmlTokenizer { } const nameStart = this._index; this._consumeTagOpenStart(start); - lowercaseTagName = this._input.substring(nameStart, this._index).toLowerCase(); + tagName = this._input.substring(nameStart, this._index); + lowercaseTagName = tagName.toLowerCase(); this._attemptCharCodeUntilFn(isNotWhitespace); while (this._peek !== chars.$SLASH && this._peek !== chars.$GT) { this._consumeAttributeName(); @@ -445,7 +447,7 @@ class _HtmlTokenizer { // When the start tag is invalid, assume we want a "<" this._restorePosition(savedPos); // Back to back text tokens are merged at the end - this._beginToken(HtmlTokenType.TEXT, start); + this._beginToken(TokenType.TEXT, start); this._endToken(['<']); return; } @@ -453,10 +455,11 @@ class _HtmlTokenizer { throw e; } - const contentTokenType = getHtmlTagDefinition(lowercaseTagName).contentType; - if (contentTokenType === HtmlTagContentType.RAW_TEXT) { + const contentTokenType = this._getTagDefinition(tagName).contentType; + + if (contentTokenType === TagContentType.RAW_TEXT) { this._consumeRawTextWithTagClose(lowercaseTagName, false); - } else if (contentTokenType === HtmlTagContentType.ESCAPABLE_RAW_TEXT) { + } else if (contentTokenType === TagContentType.ESCAPABLE_RAW_TEXT) { this._consumeRawTextWithTagClose(lowercaseTagName, true); } } @@ -469,24 +472,24 @@ class _HtmlTokenizer { this._attemptCharCodeUntilFn(isNotWhitespace); return this._attemptCharCode(chars.$GT); }); - this._beginToken(HtmlTokenType.TAG_CLOSE, textToken.sourceSpan.end); + this._beginToken(TokenType.TAG_CLOSE, textToken.sourceSpan.end); this._endToken([null, lowercaseTagName]); } private _consumeTagOpenStart(start: ParseLocation) { - this._beginToken(HtmlTokenType.TAG_OPEN_START, start); + this._beginToken(TokenType.TAG_OPEN_START, start); const parts = this._consumePrefixAndName(); this._endToken(parts); } private _consumeAttributeName() { - this._beginToken(HtmlTokenType.ATTR_NAME); + this._beginToken(TokenType.ATTR_NAME); const prefixAndName = this._consumePrefixAndName(); this._endToken(prefixAndName); } private _consumeAttributeValue() { - this._beginToken(HtmlTokenType.ATTR_VALUE); + this._beginToken(TokenType.ATTR_VALUE); var value: string; if (this._peek === chars.$SQ || this._peek === chars.$DQ) { var quoteChar = this._peek; @@ -506,15 +509,15 @@ class _HtmlTokenizer { } private _consumeTagOpenEnd() { - const tokenType = this._attemptCharCode(chars.$SLASH) ? HtmlTokenType.TAG_OPEN_END_VOID : - HtmlTokenType.TAG_OPEN_END; + const tokenType = + this._attemptCharCode(chars.$SLASH) ? TokenType.TAG_OPEN_END_VOID : TokenType.TAG_OPEN_END; this._beginToken(tokenType); this._requireCharCode(chars.$GT); this._endToken([]); } private _consumeTagClose(start: ParseLocation) { - this._beginToken(HtmlTokenType.TAG_CLOSE, start); + this._beginToken(TokenType.TAG_CLOSE, start); this._attemptCharCodeUntilFn(isNotWhitespace); let prefixAndName = this._consumePrefixAndName(); this._attemptCharCodeUntilFn(isNotWhitespace); @@ -523,19 +526,19 @@ class _HtmlTokenizer { } private _consumeExpansionFormStart() { - this._beginToken(HtmlTokenType.EXPANSION_FORM_START, this._getLocation()); + this._beginToken(TokenType.EXPANSION_FORM_START, this._getLocation()); this._requireCharCode(chars.$LBRACE); this._endToken([]); - this._expansionCaseStack.push(HtmlTokenType.EXPANSION_FORM_START); + this._expansionCaseStack.push(TokenType.EXPANSION_FORM_START); - this._beginToken(HtmlTokenType.RAW_TEXT, this._getLocation()); + this._beginToken(TokenType.RAW_TEXT, this._getLocation()); const condition = this._readUntil(chars.$COMMA); this._endToken([condition], this._getLocation()); this._requireCharCode(chars.$COMMA); this._attemptCharCodeUntilFn(isNotWhitespace); - this._beginToken(HtmlTokenType.RAW_TEXT, this._getLocation()); + this._beginToken(TokenType.RAW_TEXT, this._getLocation()); let type = this._readUntil(chars.$COMMA); this._endToken([type], this._getLocation()); this._requireCharCode(chars.$COMMA); @@ -543,21 +546,21 @@ class _HtmlTokenizer { } private _consumeExpansionCaseStart() { - this._beginToken(HtmlTokenType.EXPANSION_CASE_VALUE, this._getLocation()); + this._beginToken(TokenType.EXPANSION_CASE_VALUE, this._getLocation()); const value = this._readUntil(chars.$LBRACE).trim(); this._endToken([value], this._getLocation()); this._attemptCharCodeUntilFn(isNotWhitespace); - this._beginToken(HtmlTokenType.EXPANSION_CASE_EXP_START, this._getLocation()); + this._beginToken(TokenType.EXPANSION_CASE_EXP_START, this._getLocation()); this._requireCharCode(chars.$LBRACE); this._endToken([], this._getLocation()); this._attemptCharCodeUntilFn(isNotWhitespace); - this._expansionCaseStack.push(HtmlTokenType.EXPANSION_CASE_EXP_START); + this._expansionCaseStack.push(TokenType.EXPANSION_CASE_EXP_START); } private _consumeExpansionCaseEnd() { - this._beginToken(HtmlTokenType.EXPANSION_CASE_EXP_END, this._getLocation()); + this._beginToken(TokenType.EXPANSION_CASE_EXP_END, this._getLocation()); this._requireCharCode(chars.$RBRACE); this._endToken([], this._getLocation()); this._attemptCharCodeUntilFn(isNotWhitespace); @@ -566,7 +569,7 @@ class _HtmlTokenizer { } private _consumeExpansionFormEnd() { - this._beginToken(HtmlTokenType.EXPANSION_FORM_END, this._getLocation()); + this._beginToken(TokenType.EXPANSION_FORM_END, this._getLocation()); this._requireCharCode(chars.$RBRACE); this._endToken([]); @@ -575,14 +578,16 @@ class _HtmlTokenizer { private _consumeText() { const start = this._getLocation(); - this._beginToken(HtmlTokenType.TEXT, start); + this._beginToken(TokenType.TEXT, start); const parts: string[] = []; do { - if (this._attemptStr(this._interpolationConfig.start)) { + if (this._interpolationConfig && this._attemptStr(this._interpolationConfig.start)) { parts.push(this._interpolationConfig.start); this._inInterpolation = true; - } else if (this._attemptStr(this._interpolationConfig.end) && this._inInterpolation) { + } else if ( + this._interpolationConfig && this._attemptStr(this._interpolationConfig.end) && + this._inInterpolation) { parts.push(this._interpolationConfig.end); this._inInterpolation = false; } else { @@ -638,13 +643,13 @@ class _HtmlTokenizer { private _isInExpansionCase(): boolean { return this._expansionCaseStack.length > 0 && this._expansionCaseStack[this._expansionCaseStack.length - 1] === - HtmlTokenType.EXPANSION_CASE_EXP_START; + TokenType.EXPANSION_CASE_EXP_START; } private _isInExpansionForm(): boolean { return this._expansionCaseStack.length > 0 && this._expansionCaseStack[this._expansionCaseStack.length - 1] === - HtmlTokenType.EXPANSION_FORM_START; + TokenType.EXPANSION_FORM_START; } } @@ -672,8 +677,10 @@ function isNamedEntityEnd(code: number): boolean { function isExpansionFormStart( input: string, offset: number, interpolationConfig: InterpolationConfig): boolean { - return input.charCodeAt(offset) == chars.$LBRACE && - input.indexOf(interpolationConfig.start, offset) != offset; + const isInterpolationStart = + interpolationConfig ? input.indexOf(interpolationConfig.start, offset) == offset : false; + + return input.charCodeAt(offset) == chars.$LBRACE && !isInterpolationStart; } function isExpansionCaseStart(peek: number): boolean { @@ -688,13 +695,12 @@ function toUpperCaseCharCode(code: number): number { return code >= chars.$a && code <= chars.$z ? code - chars.$a + chars.$A : code; } -function mergeTextTokens(srcTokens: HtmlToken[]): HtmlToken[] { - let dstTokens: HtmlToken[] = []; - let lastDstToken: HtmlToken; +function mergeTextTokens(srcTokens: Token[]): Token[] { + let dstTokens: Token[] = []; + let lastDstToken: Token; for (let i = 0; i < srcTokens.length; i++) { let token = srcTokens[i]; - if (isPresent(lastDstToken) && lastDstToken.type == HtmlTokenType.TEXT && - token.type == HtmlTokenType.TEXT) { + if (lastDstToken && lastDstToken.type == TokenType.TEXT && token.type == TokenType.TEXT) { lastDstToken.parts[0] += token.parts[0]; lastDstToken.sourceSpan.end = token.sourceSpan.end; } else { diff --git a/modules/@angular/compiler/src/html_parser/parser.ts b/modules/@angular/compiler/src/html_parser/parser.ts new file mode 100644 index 0000000000..0e9ffe599a --- /dev/null +++ b/modules/@angular/compiler/src/html_parser/parser.ts @@ -0,0 +1,412 @@ +/** + * @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 {isPresent, isBlank,} from '../facade/lang'; +import {ListWrapper} from '../facade/collection'; +import * as html from './ast'; +import * as lex from './lexer'; +import {ParseSourceSpan, ParseError} from '../parse_util'; +import {TagDefinition, getNsPrefix, mergeNsAndName} from './tags'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from './interpolation_config'; + +export class TreeError extends ParseError { + static create(elementName: string, span: ParseSourceSpan, msg: string): TreeError { + return new TreeError(elementName, span, msg); + } + + constructor(public elementName: string, span: ParseSourceSpan, msg: string) { super(span, msg); } +} + +export class ParseTreeResult { + constructor(public rootNodes: html.Node[], public errors: ParseError[]) {} +} + +export class Parser { + constructor(private _getTagDefinition: (tagName: string) => TagDefinition) {} + + parse( + source: string, url: string, parseExpansionForms: boolean = false, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ParseTreeResult { + const tokensAndErrors = + lex.tokenize(source, url, this._getTagDefinition, parseExpansionForms, interpolationConfig); + + const treeAndErrors = new _TreeBuilder(tokensAndErrors.tokens, this._getTagDefinition).build(); + + return new ParseTreeResult( + treeAndErrors.rootNodes, + (tokensAndErrors.errors).concat(treeAndErrors.errors)); + } +} + +class _TreeBuilder { + private _index: number = -1; + private _peek: lex.Token; + + private _rootNodes: html.Node[] = []; + private _errors: TreeError[] = []; + + private _elementStack: html.Element[] = []; + + constructor( + private tokens: lex.Token[], private getTagDefinition: (tagName: string) => TagDefinition) { + this._advance(); + } + + build(): ParseTreeResult { + while (this._peek.type !== lex.TokenType.EOF) { + if (this._peek.type === lex.TokenType.TAG_OPEN_START) { + this._consumeStartTag(this._advance()); + } else if (this._peek.type === lex.TokenType.TAG_CLOSE) { + this._consumeEndTag(this._advance()); + } else if (this._peek.type === lex.TokenType.CDATA_START) { + this._closeVoidElement(); + this._consumeCdata(this._advance()); + } else if (this._peek.type === lex.TokenType.COMMENT_START) { + this._closeVoidElement(); + this._consumeComment(this._advance()); + } else if ( + this._peek.type === lex.TokenType.TEXT || this._peek.type === lex.TokenType.RAW_TEXT || + this._peek.type === lex.TokenType.ESCAPABLE_RAW_TEXT) { + this._closeVoidElement(); + this._consumeText(this._advance()); + } else if (this._peek.type === lex.TokenType.EXPANSION_FORM_START) { + this._consumeExpansion(this._advance()); + } else { + // Skip all other tokens... + this._advance(); + } + } + return new ParseTreeResult(this._rootNodes, this._errors); + } + + private _advance(): lex.Token { + const prev = this._peek; + if (this._index < this.tokens.length - 1) { + // Note: there is always an EOF token at the end + this._index++; + } + this._peek = this.tokens[this._index]; + return prev; + } + + private _advanceIf(type: lex.TokenType): lex.Token { + if (this._peek.type === type) { + return this._advance(); + } + return null; + } + + private _consumeCdata(startToken: lex.Token) { + this._consumeText(this._advance()); + this._advanceIf(lex.TokenType.CDATA_END); + } + + private _consumeComment(token: lex.Token) { + const text = this._advanceIf(lex.TokenType.RAW_TEXT); + this._advanceIf(lex.TokenType.COMMENT_END); + const value = isPresent(text) ? text.parts[0].trim() : null; + this._addToParent(new html.Comment(value, token.sourceSpan)); + } + + private _consumeExpansion(token: lex.Token) { + const switchValue = this._advance(); + + const type = this._advance(); + const cases: html.ExpansionCase[] = []; + + // read = + while (this._peek.type === lex.TokenType.EXPANSION_CASE_VALUE) { + let expCase = this._parseExpansionCase(); + if (isBlank(expCase)) return; // error + cases.push(expCase); + } + + // read the final } + if (this._peek.type !== lex.TokenType.EXPANSION_FORM_END) { + this._errors.push( + TreeError.create(null, this._peek.sourceSpan, `Invalid ICU message. Missing '}'.`)); + return; + } + const sourceSpan = new ParseSourceSpan(token.sourceSpan.start, this._peek.sourceSpan.end); + this._addToParent(new html.Expansion( + switchValue.parts[0], type.parts[0], cases, sourceSpan, switchValue.sourceSpan)); + + this._advance(); + } + + private _parseExpansionCase(): html.ExpansionCase { + const value = this._advance(); + + // read { + if (this._peek.type !== lex.TokenType.EXPANSION_CASE_EXP_START) { + this._errors.push( + TreeError.create(null, this._peek.sourceSpan, `Invalid ICU message. Missing '{'.`)); + return null; + } + + // read until } + const start = this._advance(); + + const exp = this._collectExpansionExpTokens(start); + if (isBlank(exp)) return null; + + const end = this._advance(); + exp.push(new lex.Token(lex.TokenType.EOF, [], end.sourceSpan)); + + // parse everything in between { and } + const parsedExp = new _TreeBuilder(exp, this.getTagDefinition).build(); + if (parsedExp.errors.length > 0) { + this._errors = this._errors.concat(parsedExp.errors); + return null; + } + + const sourceSpan = new ParseSourceSpan(value.sourceSpan.start, end.sourceSpan.end); + const expSourceSpan = new ParseSourceSpan(start.sourceSpan.start, end.sourceSpan.end); + return new html.ExpansionCase( + value.parts[0], parsedExp.rootNodes, sourceSpan, value.sourceSpan, expSourceSpan); + } + + private _collectExpansionExpTokens(start: lex.Token): lex.Token[] { + const exp: lex.Token[] = []; + const expansionFormStack = [lex.TokenType.EXPANSION_CASE_EXP_START]; + + while (true) { + if (this._peek.type === lex.TokenType.EXPANSION_FORM_START || + this._peek.type === lex.TokenType.EXPANSION_CASE_EXP_START) { + expansionFormStack.push(this._peek.type); + } + + if (this._peek.type === lex.TokenType.EXPANSION_CASE_EXP_END) { + if (lastOnStack(expansionFormStack, lex.TokenType.EXPANSION_CASE_EXP_START)) { + expansionFormStack.pop(); + if (expansionFormStack.length == 0) return exp; + + } else { + this._errors.push( + TreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`)); + return null; + } + } + + if (this._peek.type === lex.TokenType.EXPANSION_FORM_END) { + if (lastOnStack(expansionFormStack, lex.TokenType.EXPANSION_FORM_START)) { + expansionFormStack.pop(); + } else { + this._errors.push( + TreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`)); + return null; + } + } + + if (this._peek.type === lex.TokenType.EOF) { + this._errors.push( + TreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`)); + return null; + } + + exp.push(this._advance()); + } + } + + private _consumeText(token: lex.Token) { + let text = token.parts[0]; + if (text.length > 0 && text[0] == '\n') { + const parent = this._getParentElement(); + if (isPresent(parent) && parent.children.length == 0 && + this.getTagDefinition(parent.name).ignoreFirstLf) { + text = text.substring(1); + } + } + + if (text.length > 0) { + this._addToParent(new html.Text(text, token.sourceSpan)); + } + } + + private _closeVoidElement(): void { + if (this._elementStack.length > 0) { + const el = ListWrapper.last(this._elementStack); + + if (this.getTagDefinition(el.name).isVoid) { + this._elementStack.pop(); + } + } + } + + private _consumeStartTag(startTagToken: lex.Token) { + const prefix = startTagToken.parts[0]; + const name = startTagToken.parts[1]; + const attrs: html.Attribute[] = []; + while (this._peek.type === lex.TokenType.ATTR_NAME) { + attrs.push(this._consumeAttr(this._advance())); + } + const fullName = this._getElementFullName(prefix, name, this._getParentElement()); + let selfClosing = false; + // Note: There could have been a tokenizer error + // so that we don't get a token for the end tag... + if (this._peek.type === lex.TokenType.TAG_OPEN_END_VOID) { + this._advance(); + selfClosing = true; + const tagDef = this.getTagDefinition(fullName); + if (!(tagDef.canSelfClose || getNsPrefix(fullName) !== null || tagDef.isVoid)) { + this._errors.push(TreeError.create( + fullName, startTagToken.sourceSpan, + `Only void and foreign elements can be self closed "${startTagToken.parts[1]}"`)); + } + } else if (this._peek.type === lex.TokenType.TAG_OPEN_END) { + this._advance(); + selfClosing = false; + } + const end = this._peek.sourceSpan.start; + const span = new ParseSourceSpan(startTagToken.sourceSpan.start, end); + const el = new html.Element(fullName, attrs, [], span, span, null); + this._pushElement(el); + if (selfClosing) { + this._popElement(fullName); + el.endSourceSpan = span; + } + } + + private _pushElement(el: html.Element) { + if (this._elementStack.length > 0) { + const parentEl = ListWrapper.last(this._elementStack); + if (this.getTagDefinition(parentEl.name).isClosedByChild(el.name)) { + this._elementStack.pop(); + } + } + + const tagDef = this.getTagDefinition(el.name); + const {parent, container} = this._getParentElementSkippingContainers(); + + if (isPresent(parent) && tagDef.requireExtraParent(parent.name)) { + const newParent = new html.Element( + tagDef.parentToAdd, [], [], el.sourceSpan, el.startSourceSpan, el.endSourceSpan); + this._insertBeforeContainer(parent, container, newParent); + } + + this._addToParent(el); + this._elementStack.push(el); + } + + private _consumeEndTag(endTagToken: lex.Token) { + const fullName = this._getElementFullName( + endTagToken.parts[0], endTagToken.parts[1], this._getParentElement()); + + if (this._getParentElement()) { + this._getParentElement().endSourceSpan = endTagToken.sourceSpan; + } + + if (this.getTagDefinition(fullName).isVoid) { + this._errors.push(TreeError.create( + fullName, endTagToken.sourceSpan, + `Void elements do not have end tags "${endTagToken.parts[1]}"`)); + } else if (!this._popElement(fullName)) { + this._errors.push(TreeError.create( + fullName, endTagToken.sourceSpan, `Unexpected closing tag "${endTagToken.parts[1]}"`)); + } + } + + private _popElement(fullName: string): boolean { + for (let stackIndex = this._elementStack.length - 1; stackIndex >= 0; stackIndex--) { + const el = this._elementStack[stackIndex]; + if (el.name == fullName) { + ListWrapper.splice(this._elementStack, stackIndex, this._elementStack.length - stackIndex); + return true; + } + + if (!this.getTagDefinition(el.name).closedByParent) { + return false; + } + } + return false; + } + + private _consumeAttr(attrName: lex.Token): html.Attribute { + const fullName = mergeNsAndName(attrName.parts[0], attrName.parts[1]); + let end = attrName.sourceSpan.end; + let value = ''; + if (this._peek.type === lex.TokenType.ATTR_VALUE) { + const valueToken = this._advance(); + value = valueToken.parts[0]; + end = valueToken.sourceSpan.end; + } + return new html.Attribute(fullName, value, new ParseSourceSpan(attrName.sourceSpan.start, end)); + } + + private _getParentElement(): html.Element { + return this._elementStack.length > 0 ? ListWrapper.last(this._elementStack) : null; + } + + /** + * Returns the parent in the DOM and the container. + * + * `` elements are skipped as they are not rendered as DOM element. + */ + private _getParentElementSkippingContainers(): {parent: html.Element, container: html.Element} { + let container: html.Element = null; + + for (let i = this._elementStack.length - 1; i >= 0; i--) { + if (this._elementStack[i].name !== 'ng-container') { + return {parent: this._elementStack[i], container}; + } + container = this._elementStack[i]; + } + + return {parent: ListWrapper.last(this._elementStack), container}; + } + + private _addToParent(node: html.Node) { + const parent = this._getParentElement(); + if (isPresent(parent)) { + parent.children.push(node); + } else { + this._rootNodes.push(node); + } + } + + /** + * Insert a node between the parent and the container. + * When no container is given, the node is appended as a child of the parent. + * Also updates the element stack accordingly. + * + * @internal + */ + private _insertBeforeContainer( + parent: html.Element, container: html.Element, node: html.Element) { + if (!container) { + this._addToParent(node); + this._elementStack.push(node); + } else { + if (parent) { + // replace the container with the new node in the children + const index = parent.children.indexOf(container); + parent.children[index] = node; + } else { + this._rootNodes.push(node); + } + node.children.push(container); + this._elementStack.splice(this._elementStack.indexOf(container), 0, node); + } + } + + private _getElementFullName(prefix: string, localName: string, parentElement: html.Element): + string { + if (isBlank(prefix)) { + prefix = this.getTagDefinition(localName).implicitNamespacePrefix; + if (isBlank(prefix) && isPresent(parentElement)) { + prefix = getNsPrefix(parentElement.name); + } + } + + return mergeNsAndName(prefix, localName); + } +} + +function lastOnStack(stack: any[], element: any): boolean { + return stack.length > 0 && stack[stack.length - 1] === element; +} diff --git a/modules/@angular/compiler/src/html_parser/tags.ts b/modules/@angular/compiler/src/html_parser/tags.ts new file mode 100644 index 0000000000..074974155c --- /dev/null +++ b/modules/@angular/compiler/src/html_parser/tags.ts @@ -0,0 +1,310 @@ +/** + * @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 + */ + +export enum TagContentType { + RAW_TEXT, + ESCAPABLE_RAW_TEXT, + PARSABLE_DATA +} + +// TODO(vicb): read-only when TS supports it +export interface TagDefinition { + closedByParent: boolean; + requiredParents: {[key: string]: boolean}; + parentToAdd: string; + implicitNamespacePrefix: string; + contentType: TagContentType; + isVoid: boolean; + ignoreFirstLf: boolean; + canSelfClose: boolean; + + requireExtraParent(currentParent: string): boolean; + + isClosedByChild(name: string): boolean; +} + +export function splitNsName(elementName: string): [string, string] { + if (elementName[0] != ':') { + return [null, elementName]; + } + + const parts = elementName.substring(1).split(':', 2); + + if (parts.length != 2) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } + + return parts as[string, string]; +} + +export function getNsPrefix(fullName: string): string { + return fullName === null ? null : splitNsName(fullName)[0]; +} + +export function mergeNsAndName(prefix: string, localName: string): string { + return prefix ? `:${prefix}:${localName}` : localName; +} + +// see http://www.w3.org/TR/html51/syntax.html#named-character-references +// see https://html.spec.whatwg.org/multipage/entities.json +// 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', + 'Acirc': '\u00C2', + 'acirc': '\u00E2', + 'acute': '\u00B4', + 'AElig': '\u00C6', + 'aelig': '\u00E6', + 'Agrave': '\u00C0', + 'agrave': '\u00E0', + 'alefsym': '\u2135', + 'Alpha': '\u0391', + 'alpha': '\u03B1', + 'amp': '&', + 'and': '\u2227', + 'ang': '\u2220', + 'apos': '\u0027', + 'Aring': '\u00C5', + 'aring': '\u00E5', + 'asymp': '\u2248', + 'Atilde': '\u00C3', + 'atilde': '\u00E3', + 'Auml': '\u00C4', + 'auml': '\u00E4', + 'bdquo': '\u201E', + 'Beta': '\u0392', + 'beta': '\u03B2', + 'brvbar': '\u00A6', + 'bull': '\u2022', + 'cap': '\u2229', + 'Ccedil': '\u00C7', + 'ccedil': '\u00E7', + 'cedil': '\u00B8', + 'cent': '\u00A2', + 'Chi': '\u03A7', + 'chi': '\u03C7', + 'circ': '\u02C6', + 'clubs': '\u2663', + 'cong': '\u2245', + 'copy': '\u00A9', + 'crarr': '\u21B5', + 'cup': '\u222A', + 'curren': '\u00A4', + 'dagger': '\u2020', + 'Dagger': '\u2021', + 'darr': '\u2193', + 'dArr': '\u21D3', + 'deg': '\u00B0', + 'Delta': '\u0394', + 'delta': '\u03B4', + 'diams': '\u2666', + 'divide': '\u00F7', + 'Eacute': '\u00C9', + 'eacute': '\u00E9', + 'Ecirc': '\u00CA', + 'ecirc': '\u00EA', + 'Egrave': '\u00C8', + 'egrave': '\u00E8', + 'empty': '\u2205', + 'emsp': '\u2003', + 'ensp': '\u2002', + 'Epsilon': '\u0395', + 'epsilon': '\u03B5', + 'equiv': '\u2261', + 'Eta': '\u0397', + 'eta': '\u03B7', + 'ETH': '\u00D0', + 'eth': '\u00F0', + 'Euml': '\u00CB', + 'euml': '\u00EB', + 'euro': '\u20AC', + 'exist': '\u2203', + 'fnof': '\u0192', + 'forall': '\u2200', + 'frac12': '\u00BD', + 'frac14': '\u00BC', + 'frac34': '\u00BE', + 'frasl': '\u2044', + 'Gamma': '\u0393', + 'gamma': '\u03B3', + 'ge': '\u2265', + 'gt': '>', + 'harr': '\u2194', + 'hArr': '\u21D4', + 'hearts': '\u2665', + 'hellip': '\u2026', + 'Iacute': '\u00CD', + 'iacute': '\u00ED', + 'Icirc': '\u00CE', + 'icirc': '\u00EE', + 'iexcl': '\u00A1', + 'Igrave': '\u00CC', + 'igrave': '\u00EC', + 'image': '\u2111', + 'infin': '\u221E', + 'int': '\u222B', + 'Iota': '\u0399', + 'iota': '\u03B9', + 'iquest': '\u00BF', + 'isin': '\u2208', + 'Iuml': '\u00CF', + 'iuml': '\u00EF', + 'Kappa': '\u039A', + 'kappa': '\u03BA', + 'Lambda': '\u039B', + 'lambda': '\u03BB', + 'lang': '\u27E8', + 'laquo': '\u00AB', + 'larr': '\u2190', + 'lArr': '\u21D0', + 'lceil': '\u2308', + 'ldquo': '\u201C', + 'le': '\u2264', + 'lfloor': '\u230A', + 'lowast': '\u2217', + 'loz': '\u25CA', + 'lrm': '\u200E', + 'lsaquo': '\u2039', + 'lsquo': '\u2018', + 'lt': '<', + 'macr': '\u00AF', + 'mdash': '\u2014', + 'micro': '\u00B5', + 'middot': '\u00B7', + 'minus': '\u2212', + 'Mu': '\u039C', + 'mu': '\u03BC', + 'nabla': '\u2207', + 'nbsp': '\u00A0', + 'ndash': '\u2013', + 'ne': '\u2260', + 'ni': '\u220B', + 'not': '\u00AC', + 'notin': '\u2209', + 'nsub': '\u2284', + 'Ntilde': '\u00D1', + 'ntilde': '\u00F1', + 'Nu': '\u039D', + 'nu': '\u03BD', + 'Oacute': '\u00D3', + 'oacute': '\u00F3', + 'Ocirc': '\u00D4', + 'ocirc': '\u00F4', + 'OElig': '\u0152', + 'oelig': '\u0153', + 'Ograve': '\u00D2', + 'ograve': '\u00F2', + 'oline': '\u203E', + 'Omega': '\u03A9', + 'omega': '\u03C9', + 'Omicron': '\u039F', + 'omicron': '\u03BF', + 'oplus': '\u2295', + 'or': '\u2228', + 'ordf': '\u00AA', + 'ordm': '\u00BA', + 'Oslash': '\u00D8', + 'oslash': '\u00F8', + 'Otilde': '\u00D5', + 'otilde': '\u00F5', + 'otimes': '\u2297', + 'Ouml': '\u00D6', + 'ouml': '\u00F6', + 'para': '\u00B6', + 'permil': '\u2030', + 'perp': '\u22A5', + 'Phi': '\u03A6', + 'phi': '\u03C6', + 'Pi': '\u03A0', + 'pi': '\u03C0', + 'piv': '\u03D6', + 'plusmn': '\u00B1', + 'pound': '\u00A3', + 'prime': '\u2032', + 'Prime': '\u2033', + 'prod': '\u220F', + 'prop': '\u221D', + 'Psi': '\u03A8', + 'psi': '\u03C8', + 'quot': '\u0022', + 'radic': '\u221A', + 'rang': '\u27E9', + 'raquo': '\u00BB', + 'rarr': '\u2192', + 'rArr': '\u21D2', + 'rceil': '\u2309', + 'rdquo': '\u201D', + 'real': '\u211C', + 'reg': '\u00AE', + 'rfloor': '\u230B', + 'Rho': '\u03A1', + 'rho': '\u03C1', + 'rlm': '\u200F', + 'rsaquo': '\u203A', + 'rsquo': '\u2019', + 'sbquo': '\u201A', + 'Scaron': '\u0160', + 'scaron': '\u0161', + 'sdot': '\u22C5', + 'sect': '\u00A7', + 'shy': '\u00AD', + 'Sigma': '\u03A3', + 'sigma': '\u03C3', + 'sigmaf': '\u03C2', + 'sim': '\u223C', + 'spades': '\u2660', + 'sub': '\u2282', + 'sube': '\u2286', + 'sum': '\u2211', + 'sup': '\u2283', + 'sup1': '\u00B9', + 'sup2': '\u00B2', + 'sup3': '\u00B3', + 'supe': '\u2287', + 'szlig': '\u00DF', + 'Tau': '\u03A4', + 'tau': '\u03C4', + 'there4': '\u2234', + 'Theta': '\u0398', + 'theta': '\u03B8', + 'thetasym': '\u03D1', + 'thinsp': '\u2009', + 'THORN': '\u00DE', + 'thorn': '\u00FE', + 'tilde': '\u02DC', + 'times': '\u00D7', + 'trade': '\u2122', + 'Uacute': '\u00DA', + 'uacute': '\u00FA', + 'uarr': '\u2191', + 'uArr': '\u21D1', + 'Ucirc': '\u00DB', + 'ucirc': '\u00FB', + 'Ugrave': '\u00D9', + 'ugrave': '\u00F9', + 'uml': '\u00A8', + 'upsih': '\u03D2', + 'Upsilon': '\u03A5', + 'upsilon': '\u03C5', + 'Uuml': '\u00DC', + 'uuml': '\u00FC', + 'weierp': '\u2118', + 'Xi': '\u039E', + 'xi': '\u03BE', + 'Yacute': '\u00DD', + 'yacute': '\u00FD', + 'yen': '\u00A5', + 'yuml': '\u00FF', + 'Yuml': '\u0178', + 'Zeta': '\u0396', + 'zeta': '\u03B6', + 'zwj': '\u200D', + 'zwnj': '\u200C', +}; \ No newline at end of file diff --git a/modules/@angular/compiler/src/html_parser/xml_parser.ts b/modules/@angular/compiler/src/html_parser/xml_parser.ts new file mode 100644 index 0000000000..4bdae60688 --- /dev/null +++ b/modules/@angular/compiler/src/html_parser/xml_parser.ts @@ -0,0 +1,20 @@ +/** + * @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 {ParseTreeResult, Parser} from './parser'; +import {getXmlTagDefinition} from './xml_tags'; + +export {ParseTreeResult, TreeError} from './parser'; + +export class XmlParser extends Parser { + constructor() { super(getXmlTagDefinition); } + + parse(source: string, url: string, parseExpansionForms: boolean = false): ParseTreeResult { + return super.parse(source, url, parseExpansionForms, null); + } +} diff --git a/modules/@angular/compiler/src/html_parser/xml_tags.ts b/modules/@angular/compiler/src/html_parser/xml_tags.ts new file mode 100644 index 0000000000..6acc2f5363 --- /dev/null +++ b/modules/@angular/compiler/src/html_parser/xml_tags.ts @@ -0,0 +1,30 @@ +/** + * @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 {TagContentType, TagDefinition} from './tags'; + +export class XmlTagDefinition implements TagDefinition { + closedByParent: boolean = false; + requiredParents: {[key: string]: boolean}; + parentToAdd: string; + implicitNamespacePrefix: string; + contentType: TagContentType = TagContentType.PARSABLE_DATA; + isVoid: boolean = false; + ignoreFirstLf: boolean = false; + canSelfClose: boolean = true; + + requireExtraParent(currentParent: string): boolean { return false; } + + isClosedByChild(name: string): boolean { return false; } +} + +const _TAG_DEFINITION = new XmlTagDefinition(); + +export function getXmlTagDefinition(tagName: string): XmlTagDefinition { + return _TAG_DEFINITION; +} diff --git a/modules/@angular/compiler/src/i18n/extractor.ts b/modules/@angular/compiler/src/i18n/extractor.ts index 9b0daef93b..ccc9219e09 100644 --- a/modules/@angular/compiler/src/i18n/extractor.ts +++ b/modules/@angular/compiler/src/i18n/extractor.ts @@ -6,29 +6,28 @@ * found in the LICENSE file at https://angular.io/license */ -import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst} from '../html_parser/html_ast'; +import * as html from '../html_parser/ast'; import {I18nError, I18N_ATTR_PREFIX, getI18nAttr, meaning, description, isOpeningComment, isClosingComment,} from './shared'; -import {htmlVisitAll} from '../html_parser/html_ast'; export function extractAstMessages( - sourceAst: HtmlAst[], implicitTags: string[], + sourceAst: html.Node[], implicitTags: string[], implicitAttrs: {[k: string]: string[]}): ExtractionResult { const visitor = new _ExtractVisitor(implicitTags, implicitAttrs); return visitor.extract(sourceAst); } export class ExtractionResult { - constructor(public messages: AstMessage[], public errors: I18nError[]) {} + constructor(public messages: Message[], public errors: I18nError[]) {} } -class _ExtractVisitor implements HtmlAstVisitor { +class _ExtractVisitor implements html.Visitor { // ... private _inI18nNode = false; private _depth: number = 0; // ... private _blockMeaningAndDesc: string; - private _blockChildren: HtmlAst[]; + private _blockChildren: html.Node[]; private _blockStartDepth: number; private _inI18nBlock: boolean; @@ -40,8 +39,8 @@ class _ExtractVisitor implements HtmlAstVisitor { constructor(private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} - extract(source: HtmlAst[]): ExtractionResult { - const messages: AstMessage[] = []; + extract(nodes: html.Node[]): ExtractionResult { + const messages: Message[] = []; this._inI18nBlock = false; this._inI18nNode = false; this._depth = 0; @@ -49,20 +48,20 @@ class _ExtractVisitor implements HtmlAstVisitor { this._sectionStartIndex = void 0; this._errors = []; - source.forEach(node => node.visit(this, messages)); + nodes.forEach(node => node.visit(this, messages)); if (this._inI18nBlock) { - this._reportError(source[source.length - 1], 'Unclosed block'); + this._reportError(nodes[nodes.length - 1], 'Unclosed block'); } return new ExtractionResult(messages, this._errors); } - visitExpansionCase(part: HtmlExpansionCaseAst, messages: AstMessage[]): any { - htmlVisitAll(this, part.expression, messages); + visitExpansionCase(icuCase: html.ExpansionCase, messages: Message[]): any { + html.visitAll(this, icuCase.expression, messages); } - visitExpansion(icu: HtmlExpansionAst, messages: AstMessage[]): any { + visitExpansion(icu: html.Expansion, messages: Message[]): any { this._mayBeAddBlockChildren(icu); const wasInIcu = this._inIcu; @@ -74,12 +73,12 @@ class _ExtractVisitor implements HtmlAstVisitor { this._inIcu = true; } - htmlVisitAll(this, icu.cases, messages); + html.visitAll(this, icu.cases, messages); this._inIcu = wasInIcu; } - visitComment(comment: HtmlCommentAst, messages: AstMessage[]): any { + visitComment(comment: html.Comment, messages: Message[]): any { const isOpening = isOpeningComment(comment); if (isOpening && (this._inI18nBlock || this._inI18nNode)) { @@ -118,9 +117,9 @@ class _ExtractVisitor implements HtmlAstVisitor { } } - visitText(text: HtmlTextAst, messages: AstMessage[]): any { this._mayBeAddBlockChildren(text); } + visitText(text: html.Text, messages: Message[]): any { this._mayBeAddBlockChildren(text); } - visitElement(el: HtmlElementAst, messages: AstMessage[]): any { + visitElement(el: html.Element, messages: Message[]): any { this._mayBeAddBlockChildren(el); this._depth++; const wasInI18nNode = this._inI18nNode; @@ -152,19 +151,21 @@ class _ExtractVisitor implements HtmlAstVisitor { if (useSection) { this._startSection(messages); - htmlVisitAll(this, el.children, messages); + html.visitAll(this, el.children, messages); this._endSection(messages, el.children); } else { - htmlVisitAll(this, el.children, messages); + html.visitAll(this, el.children, messages); } this._depth--; this._inI18nNode = wasInI18nNode; } - visitAttr(ast: HtmlAttrAst, messages: AstMessage[]): any { throw new Error('unreachable code'); } + visitAttribute(attribute: html.Attribute, messages: Message[]): any { + throw new Error('unreachable code'); + } - private _extractFromAttributes(el: HtmlElementAst, messages: AstMessage[]): void { + private _extractFromAttributes(el: html.Element, messages: Message[]): void { const explicitAttrNameToValue: Map = new Map(); const implicitAttrNames: string[] = this._implicitAttrs[el.name] || []; @@ -182,13 +183,13 @@ class _ExtractVisitor implements HtmlAstVisitor { }); } - private _addMessage(messages: AstMessage[], ast: HtmlAst[], meaningAndDesc?: string): void { + private _addMessage(messages: Message[], ast: html.Node[], meaningAndDesc?: string): void { if (ast.length == 0 || - ast.length == 1 && ast[0] instanceof HtmlAttrAst && !(ast[0]).value) { + ast.length == 1 && ast[0] instanceof html.Attribute && !(ast[0]).value) { // Do not create empty messages return; } - messages.push(new AstMessage(ast, meaning(meaningAndDesc), description(meaningAndDesc))); + messages.push(new Message(ast, meaning(meaningAndDesc), description(meaningAndDesc))); } /** @@ -197,16 +198,16 @@ class _ExtractVisitor implements HtmlAstVisitor { * - we are not inside a ICU message (those are handled separately), * - the node is a "direct child" of the block */ - private _mayBeAddBlockChildren(ast: HtmlAst): void { + private _mayBeAddBlockChildren(node: html.Node): void { if (this._inI18nBlock && !this._inIcu && this._depth == this._blockStartDepth) { - this._blockChildren.push(ast); + this._blockChildren.push(node); } } /** * Marks the start of a section, see `_endSection` */ - private _startSection(messages: AstMessage[]): void { + private _startSection(messages: Message[]): void { if (this._sectionStartIndex !== void 0) { throw new Error('Unexpected section start'); } @@ -231,20 +232,20 @@ class _ExtractVisitor implements HtmlAstVisitor { * Note that we should still keep messages extracted from attributes inside the section (ie in the * ICU message here) */ - private _endSection(messages: AstMessage[], directChildren: HtmlAst[]): void { + private _endSection(messages: Message[], directChildren: html.Node[]): void { if (this._sectionStartIndex === void 0) { throw new Error('Unexpected section end'); } const startIndex = this._sectionStartIndex; const significantChildren: number = directChildren.reduce( - (count: number, node: HtmlAst): number => count + (node instanceof HtmlCommentAst ? 0 : 1), + (count: number, node: html.Node): number => count + (node instanceof html.Comment ? 0 : 1), 0); if (significantChildren == 1) { for (let i = startIndex; i < messages.length; i++) { let ast = messages[i].nodes; - if (!(ast.length == 1 && ast[0] instanceof HtmlAttrAst)) { + if (!(ast.length == 1 && ast[0] instanceof html.Attribute)) { messages.splice(i, 1); break; } @@ -254,11 +255,14 @@ class _ExtractVisitor implements HtmlAstVisitor { this._sectionStartIndex = void 0; } - private _reportError(astNode: HtmlAst, msg: string): void { - this._errors.push(new I18nError(astNode.sourceSpan, msg)); + private _reportError(node: html.Node, msg: string): void { + this._errors.push(new I18nError(node.sourceSpan, msg)); } } -export class AstMessage { - constructor(public nodes: HtmlAst[], public meaning: string, public description: string) {} +/** + * A Message contain a fragment (= a subtree) of the source html AST. + */ +export class Message { + constructor(public nodes: html.Node[], public meaning: string, public description: string) {} } diff --git a/modules/@angular/compiler/src/i18n/i18n_ast.ts b/modules/@angular/compiler/src/i18n/i18n_ast.ts index 19dcd6736f..d5c3d3021b 100644 --- a/modules/@angular/compiler/src/i18n/i18n_ast.ts +++ b/modules/@angular/compiler/src/i18n/i18n_ast.ts @@ -9,7 +9,9 @@ import {ParseSourceSpan} from '../parse_util'; export class Message { - constructor(public nodes: Node[], public meaning: string, public description: string) {} + constructor( + public nodes: Node[], public placeholders: {[name: string]: string}, public meaning: string, + public description: string) {} } export interface Node { visit(visitor: Visitor, context?: any): any; } diff --git a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts deleted file mode 100644 index 3f6983298a..0000000000 --- a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts +++ /dev/null @@ -1,340 +0,0 @@ -/** - * @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 {Parser as ExpressionParser} from '../expression_parser/parser'; -import {ListWrapper, StringMapWrapper} from '../facade/collection'; -import {BaseException} from '../facade/exceptions'; -import {NumberWrapper, RegExpWrapper, isPresent} from '../facade/lang'; -import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from '../html_parser/html_ast'; -import {HtmlParseTreeResult, HtmlParser} from '../html_parser/html_parser'; -import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../html_parser/interpolation_config'; -import {ParseError, ParseSourceSpan} from '../parse_util'; -import {Message, id} from './message'; -import {I18N_ATTR, I18N_ATTR_PREFIX, I18nError, Part, dedupePhName, extractPhNameFromInterpolation, messageFromAttribute, messageFromI18nAttribute, partition} from './shared'; - -const _PLACEHOLDER_ELEMENT = 'ph'; -const _NAME_ATTR = 'name'; -const _PLACEHOLDER_EXPANDED_REGEXP = /<\/ph>/gi; - -/** - * Creates an i18n-ed version of the parsed template. - * - * Algorithm: - * - * See `message_extractor.ts` for details on the partitioning algorithm. - * - * This is how the merging works: - * - * 1. Use the stringify function to get the message id. Look up the message in the map. - * 2. Get the translated message. At this point we have two trees: the original tree - * and the translated tree, where all the elements are replaced with placeholders. - * 3. Use the original tree to create a mapping Index:number -> HtmlAst. - * 4. Walk the translated tree. - * 5. If we encounter a placeholder element, get its name property. - * 6. Get the type and the index of the node using the name property. - * 7. If the type is 'e', which means element, then: - * - translate the attributes of the original element - * - recurse to merge the children - * - create a new element using the original element name, original position, - * and translated children and attributes - * 8. If the type if 't', which means text, then: - * - get the list of expressions from the original node. - * - get the string version of the interpolation subtree - * - find all the placeholders in the translated message, and replace them with the - * corresponding original expressions - */ -export class I18nHtmlParser implements HtmlParser { - private _errors: ParseError[]; - private _interpolationConfig: InterpolationConfig; - - constructor( - private _htmlParser: HtmlParser, public _expressionParser: ExpressionParser, - private _messagesContent: string, private _messages: {[key: string]: HtmlAst[]}, - private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} - - parse( - sourceContent: string, sourceUrl: string, parseExpansionForms: boolean = false, - interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): - HtmlParseTreeResult { - this._errors = []; - this._interpolationConfig = interpolationConfig; - - let res = this._htmlParser.parse(sourceContent, sourceUrl, true, interpolationConfig); - - if (res.errors.length > 0) { - return res; - } - - const nodes = this._recurse(res.rootNodes); - - return this._errors.length > 0 ? new HtmlParseTreeResult([], this._errors) : - new HtmlParseTreeResult(nodes, []); - } - - // Merge the translation recursively - private _processI18nPart(part: Part): HtmlAst[] { - try { - return part.hasI18n ? this._mergeI18Part(part) : this._recurseIntoI18nPart(part); - } catch (e) { - if (e instanceof I18nError) { - this._errors.push(e); - return []; - } else { - throw e; - } - } - } - - private _recurseIntoI18nPart(p: Part): HtmlAst[] { - // we found an element without an i18n attribute - // we need to recurse in case its children may have i18n set - // we also need to translate its attributes - if (isPresent(p.rootElement)) { - const root = p.rootElement; - const children = this._recurse(p.children); - const attrs = this._i18nAttributes(root); - return [new HtmlElementAst( - root.name, attrs, children, root.sourceSpan, root.startSourceSpan, root.endSourceSpan)]; - } - - if (isPresent(p.rootTextNode)) { - // a text node without i18n or interpolation, nothing to do - return [p.rootTextNode]; - } - - return this._recurse(p.children); - } - - private _recurse(nodes: HtmlAst[]): HtmlAst[] { - let parts = partition(nodes, this._errors, this._implicitTags); - return ListWrapper.flatten(parts.map(p => this._processI18nPart(p))); - } - - // Look for the translated message and merge it back to the tree - private _mergeI18Part(part: Part): HtmlAst[] { - let messages = part.createMessages(this._expressionParser, this._interpolationConfig); - // TODO - dirty smoke fix - let message = messages[0]; - - let messageId = id(message); - - if (!StringMapWrapper.contains(this._messages, messageId)) { - throw new I18nError( - part.sourceSpan, - `Cannot find message for id '${messageId}', content '${message.content}'.`); - } - - const translation = this._messages[messageId]; - return this._mergeTrees(part, translation); - } - - - private _mergeTrees(part: Part, translation: HtmlAst[]): HtmlAst[] { - if (isPresent(part.rootTextNode)) { - // this should never happen with a part. Parts that have root text node should not be merged. - throw new BaseException('should not be reached'); - } - - const visitor = new _NodeMappingVisitor(); - htmlVisitAll(visitor, part.children); - - // merge the translated tree with the original tree. - // we do it by preserving the source code position of the original tree - const translatedAst = this._expandPlaceholders(translation, visitor.mapping); - - // if the root element is present, we need to create a new root element with its attributes - // translated - if (part.rootElement) { - const root = part.rootElement; - const attrs = this._i18nAttributes(root); - return [new HtmlElementAst( - root.name, attrs, translatedAst, root.sourceSpan, root.startSourceSpan, - root.endSourceSpan)]; - } - - return translatedAst; - } - - /** - * The translation AST is composed on text nodes and placeholder elements - */ - private _expandPlaceholders(translation: HtmlAst[], mapping: HtmlAst[]): HtmlAst[] { - return translation.map(node => { - if (node instanceof HtmlElementAst) { - // This node is a placeholder, replace with the original content - return this._expandPlaceholdersInNode(node, mapping); - } - - if (node instanceof HtmlTextAst) { - return node; - } - - throw new BaseException('should not be reached'); - }); - } - - private _expandPlaceholdersInNode(node: HtmlElementAst, mapping: HtmlAst[]): HtmlAst { - let name = this._getName(node); - let index = NumberWrapper.parseInt(name.substring(1), 10); - let originalNode = mapping[index]; - - if (originalNode instanceof HtmlTextAst) { - return this._mergeTextInterpolation(node, originalNode); - } - - if (originalNode instanceof HtmlElementAst) { - return this._mergeElement(node, originalNode, mapping); - } - - throw new BaseException('should not be reached'); - } - - // Extract the value of a name attribute - private _getName(node: HtmlElementAst): string { - if (node.name != _PLACEHOLDER_ELEMENT) { - throw new I18nError( - node.sourceSpan, - `Unexpected tag "${node.name}". Only "${_PLACEHOLDER_ELEMENT}" tags are allowed.`); - } - - const nameAttr = node.attrs.find(a => a.name == _NAME_ATTR); - - if (nameAttr) { - return nameAttr.value; - } - - throw new I18nError(node.sourceSpan, `Missing "${_NAME_ATTR}" attribute.`); - } - - private _mergeTextInterpolation(node: HtmlElementAst, originalNode: HtmlTextAst): HtmlTextAst { - const split = this._expressionParser.splitInterpolation( - originalNode.value, originalNode.sourceSpan.toString(), this._interpolationConfig); - - const exps = split ? split.expressions : []; - - const messageSubstring = this._messagesContent.substring( - node.startSourceSpan.end.offset, node.endSourceSpan.start.offset); - - let translated = this._replacePlaceholdersWithInterpolations( - messageSubstring, exps, originalNode.sourceSpan); - - return new HtmlTextAst(translated, originalNode.sourceSpan); - } - - private _mergeElement(node: HtmlElementAst, originalNode: HtmlElementAst, mapping: HtmlAst[]): - HtmlElementAst { - const children = this._expandPlaceholders(node.children, mapping); - - return new HtmlElementAst( - originalNode.name, this._i18nAttributes(originalNode), children, originalNode.sourceSpan, - originalNode.startSourceSpan, originalNode.endSourceSpan); - } - - private _i18nAttributes(el: HtmlElementAst): HtmlAttrAst[] { - let res: HtmlAttrAst[] = []; - let implicitAttrs: string[] = - isPresent(this._implicitAttrs[el.name]) ? this._implicitAttrs[el.name] : []; - - el.attrs.forEach(attr => { - if (attr.name.startsWith(I18N_ATTR_PREFIX) || attr.name == I18N_ATTR) return; - - let message: Message; - - let i18nAttr = el.attrs.find(a => a.name == `${I18N_ATTR_PREFIX}${attr.name}`); - - if (!i18nAttr) { - if (implicitAttrs.indexOf(attr.name) == -1) { - res.push(attr); - return; - } - message = messageFromAttribute(this._expressionParser, this._interpolationConfig, attr); - } else { - message = messageFromI18nAttribute( - this._expressionParser, this._interpolationConfig, el, i18nAttr); - } - - let messageId = id(message); - - if (StringMapWrapper.contains(this._messages, messageId)) { - const updatedMessage = this._replaceInterpolationInAttr(attr, this._messages[messageId]); - res.push(new HtmlAttrAst(attr.name, updatedMessage, attr.sourceSpan)); - - } else { - throw new I18nError( - attr.sourceSpan, - `Cannot find message for id '${messageId}', content '${message.content}'.`); - } - }); - - return res; - } - - private _replaceInterpolationInAttr(attr: HtmlAttrAst, msg: HtmlAst[]): string { - const split = this._expressionParser.splitInterpolation( - attr.value, attr.sourceSpan.toString(), this._interpolationConfig); - const exps = isPresent(split) ? split.expressions : []; - - const first = msg[0]; - const last = msg[msg.length - 1]; - - const start = first.sourceSpan.start.offset; - const end = - last instanceof HtmlElementAst ? last.endSourceSpan.end.offset : last.sourceSpan.end.offset; - const messageSubstring = this._messagesContent.substring(start, end); - - return this._replacePlaceholdersWithInterpolations(messageSubstring, exps, attr.sourceSpan); - }; - - private _replacePlaceholdersWithInterpolations( - message: string, exps: string[], sourceSpan: ParseSourceSpan): string { - const expMap = this._buildExprMap(exps); - - return message.replace( - _PLACEHOLDER_EXPANDED_REGEXP, - (_: string, name: string) => this._convertIntoExpression(name, expMap, sourceSpan)); - } - - private _buildExprMap(exps: string[]): Map { - const expMap = new Map(); - const usedNames = new Map(); - - for (let i = 0; i < exps.length; i++) { - const phName = extractPhNameFromInterpolation(exps[i], i); - expMap.set(dedupePhName(usedNames, phName), exps[i]); - } - - return expMap; - } - - private _convertIntoExpression( - name: string, expMap: Map, sourceSpan: ParseSourceSpan) { - if (expMap.has(name)) { - return `${this._interpolationConfig.start}${expMap.get(name)}${this._interpolationConfig.end}`; - } - - throw new I18nError(sourceSpan, `Invalid interpolation name '${name}'`); - } -} - -// Creates a list of elements and text nodes in the AST -// The indexes match the placeholders indexes -class _NodeMappingVisitor implements HtmlAstVisitor { - mapping: HtmlAst[] = []; - - visitElement(ast: HtmlElementAst, context: any): any { - this.mapping.push(ast); - htmlVisitAll(this, ast.children); - } - - visitText(ast: HtmlTextAst, context: any): any { this.mapping.push(ast); } - - visitAttr(ast: HtmlAttrAst, context: any): any {} - visitExpansion(ast: HtmlExpansionAst, context: any): any {} - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any {} - visitComment(ast: HtmlCommentAst, context: any): any {} -} diff --git a/modules/@angular/compiler/src/i18n/i18n_parser.ts b/modules/@angular/compiler/src/i18n/i18n_parser.ts index 29311cab6c..acadda0ba9 100644 --- a/modules/@angular/compiler/src/i18n/i18n_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_parser.ts @@ -8,44 +8,58 @@ import {Lexer as ExpressionLexer} from '../expression_parser/lexer'; import {Parser as ExpressionParser} from '../expression_parser/parser'; -import * as hAst from '../html_parser/html_ast'; +import * as html from '../html_parser/ast'; import {getHtmlTagDefinition} from '../html_parser/html_tags'; import {InterpolationConfig} from '../html_parser/interpolation_config'; import {ParseSourceSpan} from '../parse_util'; import {extractAstMessages} from './extractor'; -import * as i18nAst from './i18n_ast'; -import {PlaceholderRegistry} from './serializers/util'; +import * as i18n from './i18n_ast'; +import {PlaceholderRegistry} from './serializers/placeholder'; import {extractPlaceholderName} from './shared'; +/** + * Extract all the i18n messages from a component template. + */ export function extractI18nMessages( - sourceAst: hAst.HtmlAst[], interpolationConfig: InterpolationConfig, implicitTags: string[], - implicitAttrs: {[k: string]: string[]}): i18nAst.Message[] { + sourceAst: html.Node[], interpolationConfig: InterpolationConfig, implicitTags: string[], + implicitAttrs: {[k: string]: string[]}): i18n.Message[] { const extractionResult = extractAstMessages(sourceAst, implicitTags, implicitAttrs); if (extractionResult.errors.length) { return []; } - const visitor = - new _I18nVisitor(new ExpressionParser(new ExpressionLexer()), interpolationConfig); + const expParser = new ExpressionParser(new ExpressionLexer()); + const visitor = new _I18nVisitor(expParser, interpolationConfig); - return extractionResult.messages.map((msg): i18nAst.Message => { - return new i18nAst.Message(visitor.convertToI18nAst(msg.nodes), msg.meaning, msg.description); - }); + return extractionResult.messages.map( + (msg) => visitor.toI18nMessage(msg.nodes, msg.meaning, msg.description)); } -class _I18nVisitor implements hAst.HtmlAstVisitor { +class _I18nVisitor implements html.Visitor { private _isIcu: boolean; private _icuDepth: number; private _placeholderRegistry: PlaceholderRegistry; + private _placeholderToContent: {[name: string]: string}; constructor( private _expressionParser: ExpressionParser, private _interpolationConfig: InterpolationConfig) {} - visitElement(el: hAst.HtmlElementAst, context: any): i18nAst.Node { - const children = hAst.htmlVisitAll(this, el.children); + public toI18nMessage(nodes: html.Node[], meaning: string, description: string): i18n.Message { + this._isIcu = nodes.length == 1 && nodes[0] instanceof html.Expansion; + this._icuDepth = 0; + this._placeholderRegistry = new PlaceholderRegistry(); + this._placeholderToContent = {}; + + const i18nodes: i18n.Node[] = html.visitAll(this, nodes, {}); + + return new i18n.Message(i18nodes, this._placeholderToContent, meaning, description); + } + + visitElement(el: html.Element, context: any): i18n.Node { + const children = html.visitAll(this, el.children); const attrs: {[k: string]: string} = {}; el.attrs.forEach(attr => { // Do not visit the attributes, translatable ones are top-level ASTs @@ -55,66 +69,67 @@ class _I18nVisitor implements hAst.HtmlAstVisitor { const isVoid: boolean = getHtmlTagDefinition(el.name).isVoid; const startPhName = this._placeholderRegistry.getStartTagPlaceholderName(el.name, attrs, isVoid); - const closePhName = isVoid ? '' : this._placeholderRegistry.getCloseTagPlaceholderName(el.name); + this._placeholderToContent[startPhName] = el.sourceSpan.toString(); - return new i18nAst.TagPlaceholder( + let closePhName = ''; + + if (!isVoid) { + closePhName = this._placeholderRegistry.getCloseTagPlaceholderName(el.name); + this._placeholderToContent[closePhName] = ``; + } + + return new i18n.TagPlaceholder( el.name, attrs, startPhName, closePhName, children, isVoid, el.sourceSpan); } - visitAttr(attr: hAst.HtmlAttrAst, context: any): i18nAst.Node { - return this._visitTextWithInterpolation(attr.value, attr.sourceSpan); + visitAttribute(attribute: html.Attribute, context: any): i18n.Node { + return this._visitTextWithInterpolation(attribute.value, attribute.sourceSpan); } - visitText(text: hAst.HtmlTextAst, context: any): i18nAst.Node { + visitText(text: html.Text, context: any): i18n.Node { return this._visitTextWithInterpolation(text.value, text.sourceSpan); } - visitComment(comment: hAst.HtmlCommentAst, context: any): i18nAst.Node { return null; } + visitComment(comment: html.Comment, context: any): i18n.Node { return null; } - visitExpansion(icu: hAst.HtmlExpansionAst, context: any): i18nAst.Node { + visitExpansion(icu: html.Expansion, context: any): i18n.Node { this._icuDepth++; - const i18nIcuCases: {[k: string]: i18nAst.Node} = {}; - const i18nIcu = new i18nAst.Icu(icu.switchValue, icu.type, i18nIcuCases, icu.sourceSpan); + const i18nIcuCases: {[k: string]: i18n.Node} = {}; + const i18nIcu = new i18n.Icu(icu.switchValue, icu.type, i18nIcuCases, icu.sourceSpan); icu.cases.forEach((caze): void => { - i18nIcuCases[caze.value] = new i18nAst.Container( - caze.expression.map((hAst) => hAst.visit(this, {})), caze.expSourceSpan); + i18nIcuCases[caze.value] = new i18n.Container( + caze.expression.map((node) => node.visit(this, {})), caze.expSourceSpan); }); this._icuDepth--; if (this._isIcu || this._icuDepth > 0) { - // If the message (vs a part of the message) is an ICU message return its + // If the message (vs a part of the message) is an ICU message returns it return i18nIcu; } // else returns a placeholder const phName = this._placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString()); - return new i18nAst.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan); + this._placeholderToContent[phName] = icu.sourceSpan.toString(); + return new i18n.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan); } - visitExpansionCase(icuCase: hAst.HtmlExpansionCaseAst, context: any): i18nAst.Node { + visitExpansionCase(icuCase: html.ExpansionCase, context: any): i18n.Node { throw new Error('Unreachable code'); } - public convertToI18nAst(htmlAsts: hAst.HtmlAst[]): i18nAst.Node[] { - this._isIcu = htmlAsts.length == 1 && htmlAsts[0] instanceof hAst.HtmlExpansionAst; - this._icuDepth = 0; - this._placeholderRegistry = new PlaceholderRegistry(); - - return hAst.htmlVisitAll(this, htmlAsts, {}); - } - - private _visitTextWithInterpolation(text: string, sourceSpan: ParseSourceSpan): i18nAst.Node { + private _visitTextWithInterpolation(text: string, sourceSpan: ParseSourceSpan): i18n.Node { const splitInterpolation = this._expressionParser.splitInterpolation( text, sourceSpan.start.toString(), this._interpolationConfig); if (!splitInterpolation) { // No expression, return a single text - return new i18nAst.Text(text, sourceSpan); + return new i18n.Text(text, sourceSpan); } // Return a group of text + expressions - const nodes: i18nAst.Node[] = []; - const container = new i18nAst.Container(nodes, sourceSpan); + const nodes: i18n.Node[] = []; + const container = new i18n.Container(nodes, sourceSpan); + const {start: sDelimiter, end: eDelimiter} = this._interpolationConfig; for (let i = 0; i < splitInterpolation.strings.length - 1; i++) { const expression = splitInterpolation.expressions[i]; @@ -122,16 +137,18 @@ class _I18nVisitor implements hAst.HtmlAstVisitor { const phName = this._placeholderRegistry.getPlaceholderName(baseName, expression); if (splitInterpolation.strings[i].length) { - nodes.push(new i18nAst.Text(splitInterpolation.strings[i], sourceSpan)); + // No need to add empty strings + nodes.push(new i18n.Text(splitInterpolation.strings[i], sourceSpan)); } - nodes.push(new i18nAst.Placeholder(expression, phName, sourceSpan)); + nodes.push(new i18n.Placeholder(expression, phName, sourceSpan)); + this._placeholderToContent[phName] = sDelimiter + expression + eDelimiter; } // The last index contains no expression const lastStringIdx = splitInterpolation.strings.length - 1; if (splitInterpolation.strings[lastStringIdx].length) { - nodes.push(new i18nAst.Text(splitInterpolation.strings[lastStringIdx], sourceSpan)); + nodes.push(new i18n.Text(splitInterpolation.strings[lastStringIdx], sourceSpan)); } return container; } diff --git a/modules/@angular/compiler/src/i18n/index.ts b/modules/@angular/compiler/src/i18n/index.ts new file mode 100644 index 0000000000..e810fa5679 --- /dev/null +++ b/modules/@angular/compiler/src/i18n/index.ts @@ -0,0 +1,12 @@ +/** + * @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 + */ + +export {MessageBundle} from './message_bundle'; +export {Serializer} from './serializers/serializer'; +export {Xmb} from './serializers/xmb'; +export {Xtb} from './serializers/xtb'; \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/message.ts b/modules/@angular/compiler/src/i18n/message.ts deleted file mode 100644 index 61c8619b80..0000000000 --- a/modules/@angular/compiler/src/i18n/message.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @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 {escape, isPresent} from '../facade/lang'; - - -/** - * A message extracted from a template. - * - * The identity of a message is comprised of `content` and `meaning`. - * - * `description` is additional information provided to the translator. - */ -export class Message { - constructor(public content: string, public meaning: string, public description: string = null) {} -} - -/** - * Computes the id of a message - */ -export function id(m: Message): string { - let meaning = isPresent(m.meaning) ? m.meaning : ''; - let content = isPresent(m.content) ? m.content : ''; - return escape(`$ng|${meaning}|${content}`); -} diff --git a/modules/@angular/compiler/src/i18n/catalog.ts b/modules/@angular/compiler/src/i18n/message_bundle.ts similarity index 70% rename from modules/@angular/compiler/src/i18n/catalog.ts rename to modules/@angular/compiler/src/i18n/message_bundle.ts index 47bfb4b69f..eed82dde17 100644 --- a/modules/@angular/compiler/src/i18n/catalog.ts +++ b/modules/@angular/compiler/src/i18n/message_bundle.ts @@ -8,24 +8,29 @@ import {HtmlParser} from '../html_parser/html_parser'; import {InterpolationConfig} from '../html_parser/interpolation_config'; +import {ParseError} from '../parse_util'; -import * as i18nAst from './i18n_ast'; +import * as i18n from './i18n_ast'; import {extractI18nMessages} from './i18n_parser'; import {Serializer} from './serializers/serializer'; -export class Catalog { - private _messageMap: {[k: string]: i18nAst.Message} = {}; + +/** + * A container for message extracted from the templates. + */ +export class MessageBundle { + private _messageMap: {[id: string]: i18n.Message} = {}; constructor( private _htmlParser: HtmlParser, private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} - public updateFromTemplate(html: string, url: string, interpolationConfig: InterpolationConfig): - void { + updateFromTemplate(html: string, url: string, interpolationConfig: InterpolationConfig): + ParseError[] { const htmlParserResult = this._htmlParser.parse(html, url, true, interpolationConfig); if (htmlParserResult.errors.length) { - throw new Error(); + return htmlParserResult.errors; } const messages = extractI18nMessages( @@ -37,18 +42,9 @@ export class Catalog { }); } - public load(content: string, serializer: Serializer): void { - const nodeMap = serializer.load(content); - this._messageMap = {}; - - Object.getOwnPropertyNames(nodeMap).forEach( - (id) => { this._messageMap[id] = new i18nAst.Message(nodeMap[id], '', ''); }); - } - - public write(serializer: Serializer): string { return serializer.write(this._messageMap); } + write(serializer: Serializer): string { return serializer.write(this._messageMap); } } - /** * String hash function similar to java.lang.String.hashCode(). * The hash code for a string is computed as @@ -78,35 +74,35 @@ export function strHash(str: string): string { * * @internal */ -class _SerializerVisitor implements i18nAst.Visitor { - visitText(text: i18nAst.Text, context: any): any { return text.value; } +class _SerializerVisitor implements i18n.Visitor { + visitText(text: i18n.Text, context: any): any { return text.value; } - visitContainer(container: i18nAst.Container, context: any): any { + visitContainer(container: i18n.Container, context: any): any { return `[${container.children.map(child => child.visit(this)).join(', ')}]`; } - visitIcu(icu: i18nAst.Icu, context: any): any { + visitIcu(icu: i18n.Icu, context: any): any { let strCases = Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`); return `{${icu.expression}, ${icu.type}, ${strCases.join(', ')}}`; } - visitTagPlaceholder(ph: i18nAst.TagPlaceholder, context: any): any { + visitTagPlaceholder(ph: i18n.TagPlaceholder, context: any): any { return ph.isVoid ? `` : `${ph.children.map(child => child.visit(this)).join(', ')}`; } - visitPlaceholder(ph: i18nAst.Placeholder, context: any): any { + visitPlaceholder(ph: i18n.Placeholder, context: any): any { return `${ph.value}`; } - visitIcuPlaceholder(ph: i18nAst.IcuPlaceholder, context?: any): any { + visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any { return `${ph.value.visit(this)}`; } } const serializerVisitor = new _SerializerVisitor(); -export function serializeAst(ast: i18nAst.Node[]): string[] { +export function serializeAst(ast: i18n.Node[]): string[] { return ast.map(a => a.visit(serializerVisitor, null)); } diff --git a/modules/@angular/compiler/src/i18n/message_extractor.ts b/modules/@angular/compiler/src/i18n/message_extractor.ts deleted file mode 100644 index ecdc1eddd2..0000000000 --- a/modules/@angular/compiler/src/i18n/message_extractor.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * @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 {Parser as ExpressionParser} from '../expression_parser/parser'; -import {StringMapWrapper} from '../facade/collection'; -import {isPresent} from '../facade/lang'; -import {HtmlAst, HtmlElementAst} from '../html_parser/html_ast'; -import {HtmlParser} from '../html_parser/html_parser'; -import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../html_parser/interpolation_config'; -import {ParseError} from '../parse_util'; - -import {Message, id} from './message'; -import {I18N_ATTR_PREFIX, I18nError, Part, messageFromAttribute, messageFromI18nAttribute, partition} from './shared'; - - - -/** - * All messages extracted from a template. - */ -export class ExtractionResult { - constructor(public messages: Message[], public errors: ParseError[]) {} -} - -/** - * Removes duplicate messages. - */ -export function removeDuplicates(messages: Message[]): Message[] { - let uniq: {[key: string]: Message} = {}; - messages.forEach(m => { - if (!StringMapWrapper.contains(uniq, id(m))) { - uniq[id(m)] = m; - } - }); - return StringMapWrapper.values(uniq); -} - -/** - * Extracts all messages from a template. - * - * Algorithm: - * - * To understand the algorithm, you need to know how partitioning works. - * Partitioning is required as we can use two i18n comments to group node siblings together. - * That is why we cannot just use nodes. - * - * Partitioning transforms an array of HtmlAst into an array of Part. - * A part can optionally contain a root element or a root text node. And it can also contain - * children. - * A part can contain i18n property, in which case it needs to be extracted. - * - * Example: - * - * The following array of nodes will be split into four parts: - * - * ``` - * A - * B - * - * C - * D - * - * E - * ``` - * - * Part 1 containing the a tag. It should not be translated. - * Part 2 containing the b tag. It should be translated. - * Part 3 containing the c tag and the D text node. It should be translated. - * Part 4 containing the E text node. It should not be translated.. - * - * It is also important to understand how we stringify nodes to create a message. - * - * We walk the tree and replace every element node with a placeholder. We also replace - * all expressions in interpolation with placeholders. We also insert a placeholder element - * to wrap a text node containing interpolation. - * - * Example: - * - * The following tree: - * - * ``` - * A{{I}}B - * ``` - * - * will be stringified into: - * ``` - * AB - * ``` - * - * This is what the algorithm does: - * - * 1. Use the provided html parser to get the html AST of the template. - * 2. Partition the root nodes, and process each part separately. - * 3. If a part does not have the i18n attribute, recurse to process children and attributes. - * 4. If a part has the i18n attribute, stringify the nodes to create a Message. - */ -export class MessageExtractor { - private _messages: Message[]; - private _errors: ParseError[]; - - constructor( - private _htmlParser: HtmlParser, private _expressionParser: ExpressionParser, - private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} - - extract( - template: string, sourceUrl: string, - interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ExtractionResult { - this._messages = []; - this._errors = []; - - const res = this._htmlParser.parse(template, sourceUrl, true, interpolationConfig); - - if (res.errors.length == 0) { - this._recurse(res.rootNodes, interpolationConfig); - } - - return new ExtractionResult(this._messages, this._errors.concat(res.errors)); - } - - private _extractMessagesFromPart(part: Part, interpolationConfig: InterpolationConfig): void { - if (part.hasI18n) { - this._messages.push(...part.createMessages(this._expressionParser, interpolationConfig)); - this._recurseToExtractMessagesFromAttributes(part.children, interpolationConfig); - } else { - this._recurse(part.children, interpolationConfig); - } - - if (isPresent(part.rootElement)) { - this._extractMessagesFromAttributes(part.rootElement, interpolationConfig); - } - } - - private _recurse(nodes: HtmlAst[], interpolationConfig: InterpolationConfig): void { - if (isPresent(nodes)) { - let parts = partition(nodes, this._errors, this._implicitTags); - parts.forEach(part => this._extractMessagesFromPart(part, interpolationConfig)); - } - } - - private _recurseToExtractMessagesFromAttributes( - nodes: HtmlAst[], interpolationConfig: InterpolationConfig): void { - nodes.forEach(n => { - if (n instanceof HtmlElementAst) { - this._extractMessagesFromAttributes(n, interpolationConfig); - this._recurseToExtractMessagesFromAttributes(n.children, interpolationConfig); - } - }); - } - - private _extractMessagesFromAttributes( - p: HtmlElementAst, interpolationConfig: InterpolationConfig): void { - let transAttrs: string[] = - isPresent(this._implicitAttrs[p.name]) ? this._implicitAttrs[p.name] : []; - let explicitAttrs: string[] = []; - - // `i18n-` prefixed attributes should be translated - p.attrs.filter(attr => attr.name.startsWith(I18N_ATTR_PREFIX)).forEach(attr => { - try { - explicitAttrs.push(attr.name.substring(I18N_ATTR_PREFIX.length)); - this._messages.push( - messageFromI18nAttribute(this._expressionParser, interpolationConfig, p, attr)); - } catch (e) { - if (e instanceof I18nError) { - this._errors.push(e); - } else { - throw e; - } - } - }); - - // implicit attributes should also be translated - p.attrs.filter(attr => !attr.name.startsWith(I18N_ATTR_PREFIX)) - .filter(attr => explicitAttrs.indexOf(attr.name) == -1) - .filter(attr => transAttrs.indexOf(attr.name) > -1) - .forEach( - attr => this._messages.push( - messageFromAttribute(this._expressionParser, interpolationConfig, attr))); - } -} diff --git a/modules/@angular/compiler/src/i18n/serializers/util.ts b/modules/@angular/compiler/src/i18n/serializers/placeholder.ts similarity index 89% rename from modules/@angular/compiler/src/i18n/serializers/util.ts rename to modules/@angular/compiler/src/i18n/serializers/placeholder.ts index fd403b990d..c772a519d3 100644 --- a/modules/@angular/compiler/src/i18n/serializers/util.ts +++ b/modules/@angular/compiler/src/i18n/serializers/placeholder.ts @@ -45,7 +45,9 @@ const TAG_TO_PLACEHOLDER_NAMES: {[k: string]: string} = { * @internal */ export class PlaceholderRegistry { + // Count the occurrence of the base name top generate a unique name private _placeHolderNameCounts: {[k: string]: number} = {}; + // Maps signature to placeholder names private _signatureToName: {[k: string]: string} = {}; getStartTagPlaceholderName(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string { @@ -91,18 +93,17 @@ export class PlaceholderRegistry { return uniqueName; } + // Generate a hash for a tag - does not take attribute order into account private _hashTag(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string { - const start = `<${tag.toUpperCase()}`; + const start = `<${tag}`; const strAttrs = Object.getOwnPropertyNames(attrs).sort().map((name) => ` ${name}=${attrs[name]}`).join(''); - const end = isVoid ? '/>' : `>`; + const end = isVoid ? '/>' : `>`; return start + strAttrs + end; } - private _hashClosingTag(tag: string): string { - return this._hashTag(`/${tag.toUpperCase()}`, {}, false); - } + private _hashClosingTag(tag: string): string { return this._hashTag(`/${tag}`, {}, false); } private _generateUniqueName(base: string): string { let name = base; diff --git a/modules/@angular/compiler/src/i18n/serializers/serializer.ts b/modules/@angular/compiler/src/i18n/serializers/serializer.ts index 690bbcbb86..e5f4effd79 100644 --- a/modules/@angular/compiler/src/i18n/serializers/serializer.ts +++ b/modules/@angular/compiler/src/i18n/serializers/serializer.ts @@ -6,10 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import * as i18nAst from '../i18n_ast'; +import * as html from '../../html_parser/ast'; +import * as i18n from '../i18n_ast'; export interface Serializer { - write(messageMap: {[k: string]: i18nAst.Message}): string; + write(messageMap: {[id: string]: i18n.Message}): string; - load(content: string): {[k: string]: i18nAst.Node[]}; + load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}): + {[id: string]: html.Node[]}; } \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/serializers/xmb.ts b/modules/@angular/compiler/src/i18n/serializers/xmb.ts index d3156801b9..edefda47d0 100644 --- a/modules/@angular/compiler/src/i18n/serializers/xmb.ts +++ b/modules/@angular/compiler/src/i18n/serializers/xmb.ts @@ -7,7 +7,8 @@ */ import {ListWrapper} from '../../facade/collection'; -import * as i18nAst from '../i18n_ast'; +import * as html from '../../html_parser/ast'; +import * as i18n from '../i18n_ast'; import {Serializer} from './serializer'; import * as xml from './xml_helper'; @@ -17,9 +18,9 @@ const _MESSAGE_TAG = 'msg'; const _PLACEHOLDER_TAG = 'ph'; const _EXEMPLE_TAG = 'ex'; -export class XmbSerializer implements Serializer { +export class Xmb implements Serializer { // TODO(vicb): DOCTYPE - write(messageMap: {[k: string]: i18nAst.Message}): string { + write(messageMap: {[k: string]: i18n.Message}): string { const visitor = new _Visitor(); const declaration = new xml.Declaration({version: '1.0', encoding: 'UTF-8'}); let rootNode = new xml.Tag(_MESSAGES_TAG); @@ -49,19 +50,22 @@ export class XmbSerializer implements Serializer { ]); } - load(content: string): {[k: string]: i18nAst.Node[]} { throw new Error('Unsupported'); } + load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}): + {[id: string]: html.Node[]} { + throw new Error('Unsupported'); + } } -class _Visitor implements i18nAst.Visitor { - visitText(text: i18nAst.Text, context?: any): xml.Node[] { return [new xml.Text(text.value)]; } +class _Visitor implements i18n.Visitor { + visitText(text: i18n.Text, context?: any): xml.Node[] { return [new xml.Text(text.value)]; } - visitContainer(container: i18nAst.Container, context?: any): xml.Node[] { + visitContainer(container: i18n.Container, context?: any): xml.Node[] { const nodes: xml.Node[] = []; - container.children.forEach((node: i18nAst.Node) => nodes.push(...node.visit(this))); + container.children.forEach((node: i18n.Node) => nodes.push(...node.visit(this))); return nodes; } - visitIcu(icu: i18nAst.Icu, context?: any): xml.Node[] { + visitIcu(icu: i18n.Icu, context?: any): xml.Node[] { const nodes = [new xml.Text(`{${icu.expression}, ${icu.type}, `)]; Object.getOwnPropertyNames(icu.cases).forEach((c: string) => { @@ -73,7 +77,7 @@ class _Visitor implements i18nAst.Visitor { return nodes; } - visitTagPlaceholder(ph: i18nAst.TagPlaceholder, context?: any): xml.Node[] { + visitTagPlaceholder(ph: i18n.TagPlaceholder, context?: any): xml.Node[] { const startEx = new xml.Tag(_EXEMPLE_TAG, {}, [new xml.Text(`<${ph.tag}>`)]); const startTagPh = new xml.Tag(_PLACEHOLDER_TAG, {name: ph.startName}, [startEx]); if (ph.isVoid) { @@ -87,15 +91,15 @@ class _Visitor implements i18nAst.Visitor { return [startTagPh, ...this.serialize(ph.children), closeTagPh]; } - visitPlaceholder(ph: i18nAst.Placeholder, context?: any): xml.Node[] { + visitPlaceholder(ph: i18n.Placeholder, context?: any): xml.Node[] { return [new xml.Tag(_PLACEHOLDER_TAG, {name: ph.name})]; } - visitIcuPlaceholder(ph: i18nAst.IcuPlaceholder, context?: any): xml.Node[] { + visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): xml.Node[] { return [new xml.Tag(_PLACEHOLDER_TAG, {name: ph.name})]; } - serialize(nodes: i18nAst.Node[]): xml.Node[] { + serialize(nodes: i18n.Node[]): xml.Node[] { return ListWrapper.flatten(nodes.map(node => node.visit(this))); } } diff --git a/modules/@angular/compiler/src/i18n/serializers/xtb.ts b/modules/@angular/compiler/src/i18n/serializers/xtb.ts new file mode 100644 index 0000000000..ede1e0ae69 --- /dev/null +++ b/modules/@angular/compiler/src/i18n/serializers/xtb.ts @@ -0,0 +1,149 @@ +/** + * @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 xml from '../../html_parser/ast'; +import {HtmlParser} from '../../html_parser/html_parser'; +import {InterpolationConfig} from '../../html_parser/interpolation_config'; +import {XmlParser} from '../../html_parser/xml_parser'; +import {ParseError} from '../../parse_util'; +import * as i18n from '../i18n_ast'; +import {I18nError} from '../shared'; + +import {Serializer} from './serializer'; + +const _TRANSLATIONS_TAG = 'translationbundle'; +const _TRANSLATION_TAG = 'translation'; +const _PLACEHOLDER_TAG = 'ph'; + +export class Xtb implements Serializer { + constructor(private _htmlParser: HtmlParser, private _interpolationConfig: InterpolationConfig) {} + + write(messageMap: {[id: string]: i18n.Message}): string { throw new Error('Unsupported'); } + + load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}): + {[id: string]: xml.Node[]} { + // Parse the xtb file into xml nodes + const result = new XmlParser().parse(content, url); + + if (result.errors.length) { + throw new Error(`xtb parse errors:\n${result.errors.join('\n')}`); + } + + // Replace the placeholders, messages are now string + const {messages, errors} = new _Serializer().parse(result.rootNodes, placeholders); + + if (errors.length) { + throw new Error(`xtb parse errors:\n${errors.join('\n')}`); + } + + // Convert the string messages to html ast + // TODO(vicb): map error message back to the original message in xtb + let messageMap: {[id: string]: xml.Node[]} = {}; + let parseErrors: ParseError[] = []; + + Object.getOwnPropertyNames(messages).forEach((id) => { + const res = this._htmlParser.parse(messages[id], url, true, this._interpolationConfig); + parseErrors.push(...res.errors); + messageMap[id] = res.rootNodes; + }); + + if (parseErrors.length) { + throw new Error(`xtb parse errors:\n${parseErrors.join('\n')}`); + } + + return messageMap; + } +} + +class _Serializer implements xml.Visitor { + private _messages: {[id: string]: string}; + private _bundleDepth: number; + private _translationDepth: number; + private _errors: I18nError[]; + private _placeholders: {[id: string]: {[name: string]: string}}; + private _currentPlaceholders: {[name: string]: string}; + + parse(nodes: xml.Node[], _placeholders: {[id: string]: {[name: string]: string}}): + {messages: {[k: string]: string}, errors: I18nError[]} { + this._messages = {}; + this._bundleDepth = 0; + this._translationDepth = 0; + this._errors = []; + this._placeholders = _placeholders; + + xml.visitAll(this, nodes, null); + + return {messages: this._messages, errors: this._errors}; + } + + visitElement(element: xml.Element, context: any): any { + switch (element.name) { + case _TRANSLATIONS_TAG: + this._bundleDepth++; + if (this._bundleDepth > 1) { + this._addError(element, `<${_TRANSLATIONS_TAG}> elements can not be nested`); + } + xml.visitAll(this, element.children, null); + this._bundleDepth--; + break; + + case _TRANSLATION_TAG: + this._translationDepth++; + if (this._translationDepth > 1) { + this._addError(element, `<${_TRANSLATION_TAG}> elements can not be nested`); + } + const idAttr = element.attrs.find((attr) => attr.name === 'id'); + if (!idAttr) { + this._addError(element, `<${_TRANSLATION_TAG}> misses the "id" attribute`); + } else { + this._currentPlaceholders = this._placeholders[idAttr.value] || {}; + this._messages[idAttr.value] = xml.visitAll(this, element.children).join(''); + } + this._translationDepth--; + break; + + case _PLACEHOLDER_TAG: + const nameAttr = element.attrs.find((attr) => attr.name === 'name'); + if (!nameAttr) { + this._addError(element, `<${_PLACEHOLDER_TAG}> misses the "name" attribute`); + } else { + if (this._currentPlaceholders.hasOwnProperty(nameAttr.value)) { + return this._currentPlaceholders[nameAttr.value]; + } + this._addError( + element, `The placeholder "${nameAttr.value}" does not exists in the source message`); + } + break; + + default: + this._addError(element, 'Unexpected tag'); + } + } + + visitAttribute(attribute: xml.Attribute, context: any): any { + throw new Error('unreachable code'); + } + + visitText(text: xml.Text, context: any): any { return text.value; } + + visitComment(comment: xml.Comment, context: any): any { return ''; } + + visitExpansion(expansion: xml.Expansion, context: any): any { + const strCases = expansion.cases.map(c => c.visit(this, null)); + + return `{${expansion.switchValue}, ${expansion.type}, strCases.join(' ')}`; + } + + visitExpansionCase(expansionCase: xml.ExpansionCase, context: any): any { + return `${expansionCase.value} {${xml.visitAll(this, expansionCase.expression, null)}}`; + } + + private _addError(node: xml.Node, message: string): void { + this._errors.push(new I18nError(node.sourceSpan, message)); + } +} diff --git a/modules/@angular/compiler/src/i18n/shared.ts b/modules/@angular/compiler/src/i18n/shared.ts index 6b60422edc..dd43e59730 100644 --- a/modules/@angular/compiler/src/i18n/shared.ts +++ b/modules/@angular/compiler/src/i18n/shared.ts @@ -6,14 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {normalizeBlank} from '../../../router-deprecated/src/facade/lang'; -import {Parser as ExpressionParser} from '../expression_parser/parser'; -import {StringWrapper, isBlank, isPresent} from '../facade/lang'; -import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from '../html_parser/html_ast'; -import {InterpolationConfig} from '../html_parser/interpolation_config'; +import {StringWrapper, isBlank, isPresent, normalizeBlank} from '../facade/lang'; +import * as html from '../html_parser/ast'; import {ParseError, ParseSourceSpan} from '../parse_util'; -import {Message} from './message'; export const I18N_ATTR = 'i18n'; export const I18N_ATTR_PREFIX = 'i18n-'; @@ -26,74 +22,15 @@ export class I18nError extends ParseError { constructor(span: ParseSourceSpan, msg: string) { super(span, msg); } } -export function partition(nodes: HtmlAst[], errors: ParseError[], implicitTags: string[]): Part[] { - let parts: Part[] = []; - - for (let i = 0; i < nodes.length; ++i) { - let node = nodes[i]; - let msgNodes: HtmlAst[] = []; - // Nodes between `` and `` - if (isOpeningComment(node)) { - let i18n = (node).value.replace(/^i18n:?/, '').trim(); - - while (++i < nodes.length && !isClosingComment(nodes[i])) { - msgNodes.push(nodes[i]); - } - - if (i === nodes.length) { - errors.push(new I18nError(node.sourceSpan, 'Missing closing \'i18n\' comment.')); - break; - } - - parts.push(new Part(null, null, msgNodes, i18n, true)); - } else if (node instanceof HtmlElementAst) { - // Node with an `i18n` attribute - let i18n = getI18nAttr(node); - let hasI18n: boolean = isPresent(i18n) || implicitTags.indexOf(node.name) > -1; - parts.push(new Part(node, null, node.children, isPresent(i18n) ? i18n.value : null, hasI18n)); - } else if (node instanceof HtmlTextAst) { - parts.push(new Part(null, node, null, null, false)); - } - } - - return parts; +export function isOpeningComment(n: html.Node): boolean { + return n instanceof html.Comment && isPresent(n.value) && n.value.startsWith('i18n'); } -export class Part { - constructor( - public rootElement: HtmlElementAst, public rootTextNode: HtmlTextAst, - public children: HtmlAst[], public i18n: string, public hasI18n: boolean) {} - - get sourceSpan(): ParseSourceSpan { - if (isPresent(this.rootElement)) { - return this.rootElement.sourceSpan; - } - if (isPresent(this.rootTextNode)) { - return this.rootTextNode.sourceSpan; - } - - return new ParseSourceSpan( - this.children[0].sourceSpan.start, this.children[this.children.length - 1].sourceSpan.end); - } - - createMessages(parser: ExpressionParser, interpolationConfig: InterpolationConfig): Message[] { - let {message, icuMessages} = stringifyNodes(this.children, parser, interpolationConfig); - return [ - new Message(message, meaning(this.i18n), description(this.i18n)), - ...icuMessages.map(icu => new Message(icu, null)) - ]; - } +export function isClosingComment(n: html.Node): boolean { + return n instanceof html.Comment && isPresent(n.value) && n.value === '/i18n'; } -export function isOpeningComment(n: HtmlAst): boolean { - return n instanceof HtmlCommentAst && isPresent(n.value) && n.value.startsWith('i18n'); -} - -export function isClosingComment(n: HtmlAst): boolean { - return n instanceof HtmlCommentAst && isPresent(n.value) && n.value === '/i18n'; -} - -export function getI18nAttr(p: HtmlElementAst): HtmlAttrAst { +export function getI18nAttr(p: html.Element): html.Attribute { return normalizeBlank(p.attrs.find(attr => attr.name === I18N_ATTR)); } @@ -108,148 +45,6 @@ export function description(i18n: string): string { return parts.length > 1 ? parts[1] : ''; } -/** - * Extract a translation string given an `i18n-` prefixed attribute. - * - * @internal - */ -export function messageFromI18nAttribute( - parser: ExpressionParser, interpolationConfig: InterpolationConfig, p: HtmlElementAst, - i18nAttr: HtmlAttrAst): Message { - const expectedName = i18nAttr.name.substring(5); - const attr = p.attrs.find(a => a.name == expectedName); - - if (attr) { - return messageFromAttribute( - parser, interpolationConfig, attr, meaning(i18nAttr.value), description(i18nAttr.value)); - } - - throw new I18nError(p.sourceSpan, `Missing attribute '${expectedName}'.`); -} - -export function messageFromAttribute( - parser: ExpressionParser, interpolationConfig: InterpolationConfig, attr: HtmlAttrAst, - meaning: string = null, description: string = null): Message { - const value = removeInterpolation(attr.value, attr.sourceSpan, parser, interpolationConfig); - return new Message(value, meaning, description); -} - -/** - * Replace interpolation in the `value` string with placeholders - */ -export function removeInterpolation( - value: string, source: ParseSourceSpan, expressionParser: ExpressionParser, - interpolationConfig: InterpolationConfig): string { - try { - const parsed = - expressionParser.splitInterpolation(value, source.toString(), interpolationConfig); - const usedNames = new Map(); - if (isPresent(parsed)) { - let res = ''; - for (let i = 0; i < parsed.strings.length; ++i) { - res += parsed.strings[i]; - if (i != parsed.strings.length - 1) { - let customPhName = extractPhNameFromInterpolation(parsed.expressions[i], i); - customPhName = dedupePhName(usedNames, customPhName); - res += ``; - } - } - return res; - } - - return value; - } catch (e) { - return value; - } -} - -/** - * Extract the placeholder name from the interpolation. - * - * Use a custom name when specified (ie: `{{ //i18n(ph="FIRST")}}`) otherwise generate a - * unique name. - */ -export function extractPhNameFromInterpolation(input: string, index: number): string { - let customPhMatch = StringWrapper.split(input, _CUSTOM_PH_EXP); - return customPhMatch.length > 1 ? customPhMatch[1] : `INTERPOLATION_${index}`; -} - export function extractPlaceholderName(input: string): string { return StringWrapper.split(input, _CUSTOM_PH_EXP)[1]; -} - - -/** - * Return a unique placeholder name based on the given name - */ -export function dedupePhName(usedNames: Map, name: string): string { - const duplicateNameCount = usedNames.get(name); - - if (duplicateNameCount) { - usedNames.set(name, duplicateNameCount + 1); - return `${name}_${duplicateNameCount}`; - } - - usedNames.set(name, 1); - return name; -} - -/** - * Convert a list of nodes to a string message. - * - */ -export function stringifyNodes( - nodes: HtmlAst[], expressionParser: ExpressionParser, - interpolationConfig: InterpolationConfig): {message: string, icuMessages: string[]} { - const visitor = new _StringifyVisitor(expressionParser, interpolationConfig); - const icuMessages: string[] = []; - const message = htmlVisitAll(visitor, nodes, icuMessages).join(''); - return {message, icuMessages}; -} - -class _StringifyVisitor implements HtmlAstVisitor { - private _index: number = 0; - private _nestedExpansion = 0; - - constructor( - private _expressionParser: ExpressionParser, - private _interpolationConfig: InterpolationConfig) {} - - visitElement(ast: HtmlElementAst, context: any): any { - const index = this._index++; - const children = this._join(htmlVisitAll(this, ast.children), ''); - return `${children}`; - } - - visitAttr(ast: HtmlAttrAst, context: any): any { return null; } - - visitText(ast: HtmlTextAst, context: any): any { - const index = this._index++; - const noInterpolation = removeInterpolation( - ast.value, ast.sourceSpan, this._expressionParser, this._interpolationConfig); - if (noInterpolation != ast.value) { - return `${noInterpolation}`; - } - return ast.value; - } - - visitComment(ast: HtmlCommentAst, context: any): any { return ''; } - - visitExpansion(ast: HtmlExpansionAst, context: any): any { - const index = this._index++; - this._nestedExpansion++; - const content = `{${ast.switchValue}, ${ast.type}${htmlVisitAll(this, ast.cases).join('')}}`; - this._nestedExpansion--; - - return this._nestedExpansion == 0 ? `${content}` : content; - } - - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { - const expStr = htmlVisitAll(this, ast.expression).join(''); - return ` ${ast.value} {${expStr}}`; - } - - private _join(strs: string[], str: string): string { - return strs.filter(s => s.length > 0).join(str); - } } \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/translation_bundle.ts b/modules/@angular/compiler/src/i18n/translation_bundle.ts new file mode 100644 index 0000000000..d8e4e10cc7 --- /dev/null +++ b/modules/@angular/compiler/src/i18n/translation_bundle.ts @@ -0,0 +1,24 @@ +/** + * @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 '../html_parser/ast'; +import {Serializer} from './serializers/serializer'; + + +/** + * A container for translated messages + */ +export class TranslationBundle { + constructor(private _messageMap: {[id: string]: html.Node[]} = {}) {} + + static load( + content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}, + serializer: Serializer): TranslationBundle { + return new TranslationBundle(serializer.load(content, 'url', placeholders)); + } +} \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/xmb_serializer.ts b/modules/@angular/compiler/src/i18n/xmb_serializer.ts deleted file mode 100644 index c80ba202f9..0000000000 --- a/modules/@angular/compiler/src/i18n/xmb_serializer.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * @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 {RegExpWrapper, isPresent} from '../facade/lang'; -import {HtmlAst, HtmlElementAst} from '../html_parser/html_ast'; -import {HtmlParser} from '../html_parser/html_parser'; -import {ParseError, ParseSourceSpan} from '../parse_util'; - -import {Message, id} from './message'; - -let _PLACEHOLDER_REGEXP = RegExpWrapper.create(`\\`); -const _ID_ATTR = 'id'; -const _MSG_ELEMENT = 'msg'; -const _BUNDLE_ELEMENT = 'message-bundle'; - -export function serializeXmb(messages: Message[]): string { - let ms = messages.map((m) => _serializeMessage(m)).join(''); - return `${ms}`; -} - -export class XmbDeserializationResult { - constructor( - public content: string, public messages: {[key: string]: HtmlAst[]}, - public errors: ParseError[]) {} -} - -export class XmbDeserializationError extends ParseError { - constructor(span: ParseSourceSpan, msg: string) { super(span, msg); } -} - -export function deserializeXmb(content: string, url: string): XmbDeserializationResult { - const normalizedContent = _expandPlaceholder(content.trim()); - const parsed = new HtmlParser().parse(normalizedContent, url); - - if (parsed.errors.length > 0) { - return new XmbDeserializationResult(null, {}, parsed.errors); - } - - if (_checkRootElement(parsed.rootNodes)) { - return new XmbDeserializationResult( - null, {}, [new XmbDeserializationError(null, `Missing element "${_BUNDLE_ELEMENT}"`)]); - } - - const bundleEl = parsed.rootNodes[0]; // test this - const errors: ParseError[] = []; - const messages: {[key: string]: HtmlAst[]} = {}; - - _createMessages(bundleEl.children, messages, errors); - - return (errors.length == 0) ? - new XmbDeserializationResult(normalizedContent, messages, []) : - new XmbDeserializationResult(null, <{[key: string]: HtmlAst[]}>{}, errors); -} - -function _checkRootElement(nodes: HtmlAst[]): boolean { - return nodes.length < 1 || !(nodes[0] instanceof HtmlElementAst) || - (nodes[0]).name != _BUNDLE_ELEMENT; -} - -function _createMessages( - nodes: HtmlAst[], messages: {[key: string]: HtmlAst[]}, errors: ParseError[]): void { - nodes.forEach((node) => { - if (node instanceof HtmlElementAst) { - let msg = node; - - if (msg.name != _MSG_ELEMENT) { - errors.push( - new XmbDeserializationError(node.sourceSpan, `Unexpected element "${msg.name}"`)); - return; - } - - let idAttr = msg.attrs.find(a => a.name == _ID_ATTR); - - if (idAttr) { - messages[idAttr.value] = msg.children; - } else { - errors.push( - new XmbDeserializationError(node.sourceSpan, `"${_ID_ATTR}" attribute is missing`)); - } - } - }); -} - -function _serializeMessage(m: Message): string { - const desc = isPresent(m.description) ? ` desc='${_escapeXml(m.description)}'` : ''; - const meaning = isPresent(m.meaning) ? ` meaning='${_escapeXml(m.meaning)}'` : ''; - return `${m.content}`; -} - -function _expandPlaceholder(input: string): string { - return RegExpWrapper.replaceAll(_PLACEHOLDER_REGEXP, input, (match: string[]) => { - let nameWithQuotes = match[2]; - return ``; - }); -} - -const _XML_ESCAPED_CHARS: [RegExp, string][] = [ - [/&/g, '&'], - [/"/g, '"'], - [/'/g, '''], - [//g, '>'], -]; - -function _escapeXml(value: string): string { - return _XML_ESCAPED_CHARS.reduce((value, escape) => value.replace(escape[0], escape[1]), value); -} diff --git a/modules/@angular/compiler/src/metadata_resolver.ts b/modules/@angular/compiler/src/metadata_resolver.ts index 67b58a3e1b..93839ac455 100644 --- a/modules/@angular/compiler/src/metadata_resolver.ts +++ b/modules/@angular/compiler/src/metadata_resolver.ts @@ -9,15 +9,15 @@ import {AnimationAnimateMetadata, AnimationEntryMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationStateDeclarationMetadata, AnimationStateMetadata, AnimationStateTransitionMetadata, AnimationStyleMetadata, AnimationWithStepsMetadata, AttributeMetadata, ChangeDetectionStrategy, ComponentMetadata, HostMetadata, Inject, InjectMetadata, Injectable, ModuleWithProviders, NgModule, NgModuleMetadata, Optional, OptionalMetadata, Provider, QueryMetadata, SchemaMetadata, SelfMetadata, SkipSelfMetadata, ViewMetadata, ViewQueryMetadata, resolveForwardRef} from '@angular/core'; import {Console, LIFECYCLE_HOOKS_VALUES, ReflectorReader, createProvider, isProviderLiteral, reflector} from '../core_private'; -import {MapWrapper, StringMapWrapper} from '../src/facade/collection'; -import {BaseException} from '../src/facade/exceptions'; -import {Type, isArray, isBlank, isPresent, isString, stringify} from '../src/facade/lang'; +import {StringMapWrapper} from '../src/facade/collection'; import {assertArrayOfStrings, assertInterpolationSymbols} from './assertions'; import * as cpl from './compile_metadata'; import {CompilerConfig} from './config'; import {hasLifecycleHook} from './directive_lifecycle_reflector'; import {DirectiveResolver} from './directive_resolver'; +import {BaseException} from './facade/exceptions'; +import {Type, isArray, isBlank, isPresent, isString, stringify} from './facade/lang'; import {Identifiers, identifierToken} from './identifiers'; import {NgModuleResolver} from './ng_module_resolver'; import {PipeResolver} from './pipe_resolver'; diff --git a/modules/@angular/compiler/src/ng_module_resolver.ts b/modules/@angular/compiler/src/ng_module_resolver.ts index d9f6851186..805bc0c29f 100644 --- a/modules/@angular/compiler/src/ng_module_resolver.ts +++ b/modules/@angular/compiler/src/ng_module_resolver.ts @@ -10,7 +10,7 @@ import {Injectable, NgModuleMetadata} from '@angular/core'; import {ReflectorReader, reflector} from '../core_private'; import {BaseException} from '../src/facade/exceptions'; -import {Type, isBlank, isPresent, stringify} from '../src/facade/lang'; +import {Type, isPresent, stringify} from './facade/lang'; function _isNgModuleMetadata(obj: any): obj is NgModuleMetadata { return obj instanceof NgModuleMetadata; diff --git a/modules/@angular/compiler/src/offline_compiler.ts b/modules/@angular/compiler/src/offline_compiler.ts index 4a059c8da7..c9ddbdb5e9 100644 --- a/modules/@angular/compiler/src/offline_compiler.ts +++ b/modules/@angular/compiler/src/offline_compiler.ts @@ -70,7 +70,7 @@ export class OfflineCompiler { return Promise .all(components.map((compType) => { const compMeta = this._metadataResolver.getDirectiveMetadata(compType); - let ngModule = ngModulesSummary.ngModuleByComponent.get(compType); + const ngModule = ngModulesSummary.ngModuleByComponent.get(compType); if (!ngModule) { throw new BaseException( `Cannot determine the module for component ${compMeta.type.name}!`); diff --git a/modules/@angular/compiler/src/pipe_resolver.ts b/modules/@angular/compiler/src/pipe_resolver.ts index d6c955e7db..92fb8ee453 100644 --- a/modules/@angular/compiler/src/pipe_resolver.ts +++ b/modules/@angular/compiler/src/pipe_resolver.ts @@ -9,8 +9,8 @@ import {Injectable, PipeMetadata, resolveForwardRef} from '@angular/core'; import {ReflectorReader, reflector} from '../core_private'; -import {BaseException} from '../src/facade/exceptions'; -import {Type, isPresent, stringify} from '../src/facade/lang'; +import {BaseException} from './facade/exceptions'; +import {Type, isPresent, stringify} from './facade/lang'; function _isPipeMetadata(type: any): boolean { return type instanceof PipeMetadata; diff --git a/modules/@angular/compiler/src/provider_analyzer.ts b/modules/@angular/compiler/src/provider_analyzer.ts index ab4cc33ae1..21fd66e1f3 100644 --- a/modules/@angular/compiler/src/provider_analyzer.ts +++ b/modules/@angular/compiler/src/provider_analyzer.ts @@ -6,11 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {ListWrapper} from '../src/facade/collection'; -import {BaseException} from '../src/facade/exceptions'; -import {isArray, isBlank, isPresent, normalizeBlank} from '../src/facade/lang'; - import {CompileDiDependencyMetadata, CompileDirectiveMetadata, CompileIdentifierMap, CompileNgModuleMetadata, CompileProviderMetadata, CompileQueryMetadata, CompileTokenMetadata, CompileTypeMetadata} from './compile_metadata'; +import {ListWrapper} from './facade/collection'; +import {BaseException} from './facade/exceptions'; +import {isArray, isBlank, isPresent, normalizeBlank} from './facade/lang'; import {Identifiers, identifierToken} from './identifiers'; import {ParseError, ParseSourceSpan} from './parse_util'; import {AttrAst, DirectiveAst, ProviderAst, ProviderAstType, ReferenceAst, VariableAst} from './template_parser/template_ast'; diff --git a/modules/@angular/compiler/src/runtime_compiler.ts b/modules/@angular/compiler/src/runtime_compiler.ts index 75d2f28619..95fa89038a 100644 --- a/modules/@angular/compiler/src/runtime_compiler.ts +++ b/modules/@angular/compiler/src/runtime_compiler.ts @@ -9,22 +9,24 @@ import {Compiler, ComponentFactory, ComponentResolver, ComponentStillLoadingError, Injectable, Injector, ModuleWithComponentFactories, NgModule, NgModuleFactory, NgModuleMetadata, OptionalMetadata, Provider, SchemaMetadata, SkipSelfMetadata} from '@angular/core'; import {Console} from '../core_private'; -import {BaseException} from '../src/facade/exceptions'; -import {ConcreteType, IS_DART, Type, isBlank, isString, stringify} from '../src/facade/lang'; -import {PromiseWrapper} from '../src/facade/async'; -import {createHostComponentMeta, CompileDirectiveMetadata, CompilePipeMetadata, CompileIdentifierMetadata, CompileNgModuleMetadata} from './compile_metadata'; -import {StyleCompiler, CompiledStylesheet} from './style_compiler'; -import {ViewCompiler, ViewFactoryDependency, ComponentFactoryDependency} from './view_compiler/view_compiler'; -import {NgModuleCompiler} from './ng_module_compiler'; -import {TemplateParser} from './template_parser/template_parser'; -import {DirectiveNormalizer} from './directive_normalizer'; -import {CompileMetadataResolver} from './metadata_resolver'; +import {CompileDirectiveMetadata, CompileIdentifierMetadata, CompileNgModuleMetadata, CompilePipeMetadata, createHostComponentMeta} from './compile_metadata'; import {CompilerConfig} from './config'; +import {DirectiveNormalizer} from './directive_normalizer'; +import {PromiseWrapper} from './facade/async'; +import {BaseException} from './facade/exceptions'; +import {ConcreteType, IS_DART, Type, isBlank, isString, stringify} from './facade/lang'; +import {CompileMetadataResolver} from './metadata_resolver'; +import {NgModuleCompiler} from './ng_module_compiler'; import * as ir from './output/output_ast'; -import {jitStatements} from './output/output_jit'; import {interpretStatements} from './output/output_interpreter'; +import {jitStatements} from './output/output_jit'; +import {CompiledStylesheet, StyleCompiler} from './style_compiler'; +import {TemplateParser} from './template_parser/template_parser'; import {SyncAsyncResult} from './util'; +import {ComponentFactoryDependency, ViewCompiler, ViewFactoryDependency} from './view_compiler/view_compiler'; + + /** * An internal module of the Angular compiler that begins with component types, diff --git a/modules/@angular/compiler/src/selector.ts b/modules/@angular/compiler/src/selector.ts index e2c9562b3f..22faf58ea0 100644 --- a/modules/@angular/compiler/src/selector.ts +++ b/modules/@angular/compiler/src/selector.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {ListWrapper} from '../src/facade/collection'; -import {BaseException} from '../src/facade/exceptions'; -import {RegExpMatcherWrapper, RegExpWrapper, StringWrapper, isBlank, isPresent} from '../src/facade/lang'; +import {ListWrapper} from './facade/collection'; +import {BaseException} from './facade/exceptions'; +import {RegExpMatcherWrapper, RegExpWrapper, StringWrapper, isBlank, isPresent} from './facade/lang'; const _EMPTY_ATTR_VALUE = /*@ts2dart_const*/ ''; diff --git a/modules/@angular/compiler/src/shadow_css.ts b/modules/@angular/compiler/src/shadow_css.ts index bfb940557e..bc3494994f 100644 --- a/modules/@angular/compiler/src/shadow_css.ts +++ b/modules/@angular/compiler/src/shadow_css.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {ListWrapper} from '../src/facade/collection'; -import {RegExpMatcherWrapper, RegExpWrapper, StringWrapper, isBlank, isPresent} from '../src/facade/lang'; +import {ListWrapper} from './facade/collection'; +import {RegExpMatcherWrapper, RegExpWrapper, StringWrapper, isBlank, isPresent} from './facade/lang'; /** * This file is a port of shadowCSS from webcomponents.js to TypeScript. diff --git a/modules/@angular/compiler/src/style_url_resolver.ts b/modules/@angular/compiler/src/style_url_resolver.ts index cbc5634563..d7f66b052f 100644 --- a/modules/@angular/compiler/src/style_url_resolver.ts +++ b/modules/@angular/compiler/src/style_url_resolver.ts @@ -9,7 +9,7 @@ // Some of the code comes from WebComponents.JS // https://github.com/webcomponents/webcomponentsjs/blob/master/src/HTMLImports/path.js -import {RegExpWrapper, StringWrapper, isBlank, isPresent} from '../src/facade/lang'; +import {RegExpWrapper, StringWrapper, isBlank, isPresent} from './facade/lang'; import {UrlResolver} from './url_resolver'; diff --git a/modules/@angular/compiler/src/template_parser/template_ast.ts b/modules/@angular/compiler/src/template_parser/template_ast.ts index fc7a813a6a..378bc8ee3e 100644 --- a/modules/@angular/compiler/src/template_parser/template_ast.ts +++ b/modules/@angular/compiler/src/template_parser/template_ast.ts @@ -11,7 +11,7 @@ import {isPresent} from '../facade/lang'; import {CompileDirectiveMetadata, CompileTokenMetadata, CompileProviderMetadata,} from '../compile_metadata'; import {ParseSourceSpan} from '../parse_util'; -import {SecurityContext} from '../../../core/index'; +import {SecurityContext} from '@angular/core'; /** * An Abstract Syntax Tree node representing part of a parsed Angular template. diff --git a/modules/@angular/compiler/src/template_parser/template_parser.ts b/modules/@angular/compiler/src/template_parser/template_parser.ts index 0d2fe57f69..31223f9767 100644 --- a/modules/@angular/compiler/src/template_parser/template_parser.ts +++ b/modules/@angular/compiler/src/template_parser/template_parser.ts @@ -6,30 +6,28 @@ * found in the LICENSE file at https://angular.io/license */ -import {Inject, Injectable, OpaqueToken, Optional, SecurityContext, SchemaMetadata} from '../../../core/index'; +import {Inject, Injectable, OpaqueToken, Optional, SchemaMetadata, SecurityContext} from '@angular/core'; import {Console, MAX_INTERPOLATION_VALUES} from '../../core_private'; + import {ListWrapper, StringMapWrapper, SetWrapper,} from '../facade/collection'; -import {RegExpWrapper, isPresent, StringWrapper, isBlank} from '../facade/lang'; +import {RegExpWrapper, isPresent, isBlank} from '../facade/lang'; import {BaseException} from '../facade/exceptions'; import {AST, Interpolation, ASTWithSource, TemplateBinding, RecursiveAstVisitor, BindingPipe, ParserError} from '../expression_parser/ast'; import {Parser} from '../expression_parser/parser'; -import { - CompileDirectiveMetadata, CompilePipeMetadata, CompileTokenMetadata, - removeIdentifierDuplicates, -} from '../compile_metadata'; -import {HtmlParser, HtmlParseTreeResult} from '../html_parser/html_parser'; -import {splitNsName, mergeNsAndName} from '../html_parser/html_tags'; +import {CompileDirectiveMetadata, CompilePipeMetadata, CompileTokenMetadata, removeIdentifierDuplicates,} from '../compile_metadata'; +import {HtmlParser, ParseTreeResult} from '../html_parser/html_parser'; +import {splitNsName, mergeNsAndName} from '../html_parser/tags'; import {ParseSourceSpan, ParseError, ParseErrorLevel} from '../parse_util'; import {InterpolationConfig} from '../html_parser/interpolation_config'; -import {ElementAst, BoundElementPropertyAst, BoundEventAst, ReferenceAst, TemplateAst, TemplateAstVisitor, templateVisitAll, TextAst, BoundTextAst, EmbeddedTemplateAst, AttrAst, NgContentAst, PropertyBindingType, DirectiveAst, BoundDirectivePropertyAst, ProviderAst, ProviderAstType, VariableAst} from './template_ast'; +import {ElementAst, BoundElementPropertyAst, BoundEventAst, ReferenceAst, TemplateAst, TemplateAstVisitor, templateVisitAll, TextAst, BoundTextAst, EmbeddedTemplateAst, AttrAst, NgContentAst, PropertyBindingType, DirectiveAst, BoundDirectivePropertyAst, VariableAst} from './template_ast'; import {CssSelector, SelectorMatcher} from '../selector'; import {ElementSchemaRegistry} from '../schema/element_schema_registry'; import {preparseElement, PreparsedElementType} from './template_preparser'; import {isStyleUrlResolvable} from '../style_url_resolver'; -import {HtmlAstVisitor, HtmlElementAst, HtmlAttrAst, HtmlTextAst, HtmlCommentAst, HtmlExpansionAst, HtmlExpansionCaseAst, htmlVisitAll} from '../html_parser/html_ast'; +import * as html from '../html_parser/ast'; import {splitAtColon} from '../util'; import {identifierToken, Identifiers} from '../identifiers'; -import {expandNodes} from '../html_parser/expander'; +import {expandNodes} from '../html_parser/icu_ast_expander'; import {ProviderElementContext, ProviderViewContext} from '../provider_analyzer'; // Group 1 = "bind-" @@ -118,7 +116,7 @@ export class TemplateParser { // Transform ICU messages to angular directives const expandedHtmlAst = expandNodes(htmlAstWithErrors.rootNodes); errors.push(...expandedHtmlAst.errors); - htmlAstWithErrors = new HtmlParseTreeResult(expandedHtmlAst.nodes, errors); + htmlAstWithErrors = new ParseTreeResult(expandedHtmlAst.nodes, errors); } if (htmlAstWithErrors.rootNodes.length > 0) { @@ -130,7 +128,7 @@ export class TemplateParser { providerViewContext, uniqDirectives, uniqPipes, schemas, this._exprParser, this._schemaRegistry); - result = htmlVisitAll(parseVisitor, htmlAstWithErrors.rootNodes, EMPTY_ELEMENT_CONTEXT); + result = html.visitAll(parseVisitor, htmlAstWithErrors.rootNodes, EMPTY_ELEMENT_CONTEXT); errors.push(...parseVisitor.errors, ...providerViewContext.errors); } else { result = []; @@ -170,7 +168,7 @@ export class TemplateParser { } } -class TemplateParseVisitor implements HtmlAstVisitor { +class TemplateParseVisitor implements html.Visitor { selectorMatcher: SelectorMatcher; errors: TemplateParseError[] = []; directivesIndex = new Map(); @@ -291,27 +289,27 @@ class TemplateParseVisitor implements HtmlAstVisitor { } } - visitExpansion(ast: HtmlExpansionAst, context: any): any { return null; } + visitExpansion(expansion: html.Expansion, context: any): any { return null; } - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; } + visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any { return null; } - visitText(ast: HtmlTextAst, parent: ElementContext): any { + visitText(text: html.Text, parent: ElementContext): any { const ngContentIndex = parent.findNgContentIndex(TEXT_CSS_SELECTOR); - const expr = this._parseInterpolation(ast.value, ast.sourceSpan); + const expr = this._parseInterpolation(text.value, text.sourceSpan); if (isPresent(expr)) { - return new BoundTextAst(expr, ngContentIndex, ast.sourceSpan); + return new BoundTextAst(expr, ngContentIndex, text.sourceSpan); } else { - return new TextAst(ast.value, ngContentIndex, ast.sourceSpan); + return new TextAst(text.value, ngContentIndex, text.sourceSpan); } } - visitAttr(ast: HtmlAttrAst, contex: any): any { - return new AttrAst(ast.name, ast.value, ast.sourceSpan); + visitAttribute(attribute: html.Attribute, contex: any): any { + return new AttrAst(attribute.name, attribute.value, attribute.sourceSpan); } - visitComment(ast: HtmlCommentAst, context: any): any { return null; } + visitComment(comment: html.Comment, context: any): any { return null; } - visitElement(element: HtmlElementAst, parent: ElementContext): any { + visitElement(element: html.Element, parent: ElementContext): any { const nodeName = element.name; const preparsedElement = preparseElement(element); if (preparsedElement.type === PreparsedElementType.SCRIPT || @@ -359,7 +357,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { if (!hasBinding && !hasTemplateBinding) { // don't include the bindings as attributes as well in the AST - attrs.push(this.visitAttr(attr, null)); + attrs.push(this.visitAttribute(attr, null)); matchableAttrs.push([attr.name, attr.value]); } if (hasTemplateBinding) { @@ -380,7 +378,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { const providerContext = new ProviderElementContext( this.providerViewContext, parent.providerContext, isViewRoot, directiveAsts, attrs, references, element.sourceSpan); - const children = htmlVisitAll( + const children = html.visitAll( preparsedElement.nonBindable ? NON_BINDABLE_VISITOR : this, element.children, ElementContext.create( isTemplateElement, directiveAsts, @@ -448,7 +446,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { } private _parseInlineTemplateBinding( - attr: HtmlAttrAst, targetMatchableAttrs: string[][], + attr: html.Attribute, targetMatchableAttrs: string[][], targetProps: BoundElementOrDirectiveProperty[], targetVars: VariableAst[]): boolean { let templateBindingsSource: string = null; if (this._normalizeAttributeName(attr.name) == TEMPLATE_ATTR) { @@ -477,7 +475,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { } private _parseAttr( - isTemplateElement: boolean, attr: HtmlAttrAst, targetMatchableAttrs: string[][], + isTemplateElement: boolean, attr: html.Attribute, targetMatchableAttrs: string[][], targetProps: BoundElementOrDirectiveProperty[], targetAnimationProps: BoundElementPropertyAst[], targetEvents: BoundEventAst[], targetRefs: ElementOrDirectiveRef[], targetVars: VariableAst[]): boolean { @@ -910,8 +908,8 @@ class TemplateParseVisitor implements HtmlAstVisitor { } } -class NonBindableVisitor implements HtmlAstVisitor { - visitElement(ast: HtmlElementAst, parent: ElementContext): ElementAst { +class NonBindableVisitor implements html.Visitor { + visitElement(ast: html.Element, parent: ElementContext): ElementAst { const preparsedElement = preparseElement(ast); if (preparsedElement.type === PreparsedElementType.SCRIPT || preparsedElement.type === PreparsedElementType.STYLE || @@ -925,21 +923,25 @@ class NonBindableVisitor implements HtmlAstVisitor { const attrNameAndValues = ast.attrs.map(attrAst => [attrAst.name, attrAst.value]); const selector = createElementCssSelector(ast.name, attrNameAndValues); const ngContentIndex = parent.findNgContentIndex(selector); - const children = htmlVisitAll(this, ast.children, EMPTY_ELEMENT_CONTEXT); + const children = html.visitAll(this, ast.children, EMPTY_ELEMENT_CONTEXT); return new ElementAst( - ast.name, htmlVisitAll(this, ast.attrs), [], [], [], [], [], false, children, + ast.name, html.visitAll(this, ast.attrs), [], [], [], [], [], false, children, ngContentIndex, ast.sourceSpan); } - visitComment(ast: HtmlCommentAst, context: any): any { return null; } - visitAttr(ast: HtmlAttrAst, context: any): AttrAst { - return new AttrAst(ast.name, ast.value, ast.sourceSpan); + visitComment(comment: html.Comment, context: any): any { return null; } + + visitAttribute(attribute: html.Attribute, context: any): AttrAst { + return new AttrAst(attribute.name, attribute.value, attribute.sourceSpan); } - visitText(ast: HtmlTextAst, parent: ElementContext): TextAst { + + visitText(text: html.Text, parent: ElementContext): TextAst { const ngContentIndex = parent.findNgContentIndex(TEXT_CSS_SELECTOR); - return new TextAst(ast.value, ngContentIndex, ast.sourceSpan); + return new TextAst(text.value, ngContentIndex, text.sourceSpan); } - visitExpansion(ast: HtmlExpansionAst, context: any): any { return ast; } - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return ast; } + + visitExpansion(expansion: html.Expansion, context: any): any { return expansion; } + + visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any { return expansionCase; } } class BoundElementOrDirectiveProperty { @@ -953,7 +955,7 @@ class ElementOrDirectiveRef { } export function splitClasses(classAttrValue: string): string[] { - return StringWrapper.split(classAttrValue.trim(), /\s+/g); + return classAttrValue.trim().split(/\s+/g); } class ElementContext { @@ -967,7 +969,7 @@ class ElementContext { const ngContentSelectors = component.directive.template.ngContentSelectors; for (let i = 0; i < ngContentSelectors.length; i++) { const selector = ngContentSelectors[i]; - if (StringWrapper.equals(selector, '*')) { + if (selector === '*') { wildcardNgContentIndex = i; } else { matcher.addSelectables(CssSelector.parse(ngContentSelectors[i]), i); diff --git a/modules/@angular/compiler/src/template_parser/template_preparser.ts b/modules/@angular/compiler/src/template_parser/template_preparser.ts index e516999817..4bab363944 100644 --- a/modules/@angular/compiler/src/template_parser/template_preparser.ts +++ b/modules/@angular/compiler/src/template_parser/template_preparser.ts @@ -6,10 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {isBlank} from '../facade/lang'; - -import {HtmlElementAst} from '../html_parser/html_ast'; -import {splitNsName} from '../html_parser/html_tags'; +import * as html from '../html_parser/ast'; +import {splitNsName} from '../html_parser/tags'; const NG_CONTENT_SELECT_ATTR = 'select'; const NG_CONTENT_ELEMENT = 'ng-content'; @@ -22,7 +20,7 @@ const SCRIPT_ELEMENT = 'script'; const NG_NON_BINDABLE_ATTR = 'ngNonBindable'; const NG_PROJECT_AS = 'ngProjectAs'; -export function preparseElement(ast: HtmlElementAst): PreparsedElement { +export function preparseElement(ast: html.Element): PreparsedElement { var selectAttr: string = null; var hrefAttr: string = null; var relAttr: string = null; @@ -75,7 +73,7 @@ export class PreparsedElement { function normalizeNgContentSelect(selectAttr: string): string { - if (isBlank(selectAttr) || selectAttr.length === 0) { + if (selectAttr === null || selectAttr.length === 0) { return '*'; } return selectAttr; diff --git a/modules/@angular/compiler/src/url_resolver.ts b/modules/@angular/compiler/src/url_resolver.ts index 0f74bb53a3..9ac0bb8995 100644 --- a/modules/@angular/compiler/src/url_resolver.ts +++ b/modules/@angular/compiler/src/url_resolver.ts @@ -8,7 +8,7 @@ import {Inject, Injectable, PACKAGE_ROOT_URL} from '@angular/core'; -import {StringWrapper, isPresent, isBlank, RegExpWrapper,} from '../src/facade/lang'; +import {StringWrapper, isPresent, isBlank, RegExpWrapper,} from './facade/lang'; const _ASSET_SCHEME = 'asset:'; diff --git a/modules/@angular/compiler/test/html_parser/html_ast_serializer_spec.ts b/modules/@angular/compiler/test/html_parser/ast_serializer_spec.ts similarity index 56% rename from modules/@angular/compiler/test/html_parser/html_ast_serializer_spec.ts rename to modules/@angular/compiler/test/html_parser/ast_serializer_spec.ts index 6e17d3054d..27bd358dd9 100644 --- a/modules/@angular/compiler/test/html_parser/html_ast_serializer_spec.ts +++ b/modules/@angular/compiler/test/html_parser/ast_serializer_spec.ts @@ -1,9 +1,17 @@ +/** + * @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 {beforeEach, ddescribe, describe, expect, it} from '../../../core/testing/testing_internal'; -import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from '../../src/html_parser/html_ast'; +import * as html from '../../src/html_parser/ast'; import {HtmlParser} from '../../src/html_parser/html_parser'; export function main() { - describe('HtmlAst serilaizer', () => { + describe('Node serilaizer', () => { var parser: HtmlParser; beforeEach(() => { parser = new HtmlParser(); }); @@ -52,35 +60,37 @@ export function main() { }); } -class _SerializerVisitor implements HtmlAstVisitor { - visitElement(ast: HtmlElementAst, context: any): any { - return `<${ast.name}${this._visitAll(ast.attrs, ' ')}>${this._visitAll(ast.children)}`; +class _SerializerVisitor implements html.Visitor { + visitElement(element: html.Element, context: any): any { + return `<${element.name}${this._visitAll(element.attrs, ' ')}>${this._visitAll(element.children)}`; } - visitAttr(ast: HtmlAttrAst, context: any): any { return `${ast.name}="${ast.value}"`; } - - visitText(ast: HtmlTextAst, context: any): any { return ast.value; } - - visitComment(ast: HtmlCommentAst, context: any): any { return ``; } - - visitExpansion(ast: HtmlExpansionAst, context: any): any { - return `{${ast.switchValue}, ${ast.type},${this._visitAll(ast.cases)}}`; + visitAttribute(attribute: html.Attribute, context: any): any { + return `${attribute.name}="${attribute.value}"`; } - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { - return ` ${ast.value} {${this._visitAll(ast.expression)}}`; + visitText(text: html.Text, context: any): any { return text.value; } + + visitComment(comment: html.Comment, context: any): any { return ``; } + + visitExpansion(expansion: html.Expansion, context: any): any { + return `{${expansion.switchValue}, ${expansion.type},${this._visitAll(expansion.cases)}}`; } - private _visitAll(ast: HtmlAst[], join: string = ''): string { - if (ast.length == 0) { + visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any { + return ` ${expansionCase.value} {${this._visitAll(expansionCase.expression)}}`; + } + + private _visitAll(nodes: html.Node[], join: string = ''): string { + if (nodes.length == 0) { return ''; } - return join + ast.map(a => a.visit(this, null)).join(join); + return join + nodes.map(a => a.visit(this, null)).join(join); } } const serializerVisitor = new _SerializerVisitor(); -export function serializeAst(ast: HtmlAst[]): string[] { - return ast.map(a => a.visit(serializerVisitor, null)); +export function serializeAst(nodes: html.Node[]): string[] { + return nodes.map(node => node.visit(serializerVisitor, null)); } diff --git a/modules/@angular/compiler/test/html_parser/ast_spec_utils.ts b/modules/@angular/compiler/test/html_parser/ast_spec_utils.ts new file mode 100644 index 0000000000..5ba5d0f8b9 --- /dev/null +++ b/modules/@angular/compiler/test/html_parser/ast_spec_utils.ts @@ -0,0 +1,85 @@ +/** + * @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 {BaseException} from '../../src/facade/exceptions'; +import * as html from '../../src/html_parser/ast'; +import {ParseTreeResult} from '../../src/html_parser/html_parser'; +import {ParseLocation} from '../../src/parse_util'; + +export function humanizeDom(parseResult: ParseTreeResult, addSourceSpan: boolean = false): any[] { + if (parseResult.errors.length > 0) { + var errorString = parseResult.errors.join('\n'); + throw new BaseException(`Unexpected parse errors:\n${errorString}`); + } + + return humanizeNodes(parseResult.rootNodes, addSourceSpan); +} + +export function humanizeDomSourceSpans(parseResult: ParseTreeResult): any[] { + return humanizeDom(parseResult, true); +} + +export function humanizeNodes(nodes: html.Node[], addSourceSpan: boolean = false): any[] { + var humanizer = new _Humanizer(addSourceSpan); + html.visitAll(humanizer, nodes); + return humanizer.result; +} + +export function humanizeLineColumn(location: ParseLocation): string { + return `${location.line}:${location.col}`; +} + +class _Humanizer implements html.Visitor { + result: any[] = []; + elDepth: number = 0; + + constructor(private includeSourceSpan: boolean){}; + + visitElement(element: html.Element, context: any): any { + var res = this._appendContext(element, [html.Element, element.name, this.elDepth++]); + this.result.push(res); + html.visitAll(this, element.attrs); + html.visitAll(this, element.children); + this.elDepth--; + } + + visitAttribute(attribute: html.Attribute, context: any): any { + var res = this._appendContext(attribute, [html.Attribute, attribute.name, attribute.value]); + this.result.push(res); + } + + visitText(text: html.Text, context: any): any { + var res = this._appendContext(text, [html.Text, text.value, this.elDepth]); + this.result.push(res); + } + + visitComment(comment: html.Comment, context: any): any { + var res = this._appendContext(comment, [html.Comment, comment.value, this.elDepth]); + this.result.push(res); + } + + visitExpansion(expansion: html.Expansion, context: any): any { + var res = this._appendContext( + expansion, [html.Expansion, expansion.switchValue, expansion.type, this.elDepth++]); + this.result.push(res); + html.visitAll(this, expansion.cases); + this.elDepth--; + } + + visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any { + var res = + this._appendContext(expansionCase, [html.ExpansionCase, expansionCase.value, this.elDepth]); + this.result.push(res); + } + + private _appendContext(ast: html.Node, input: any[]): any[] { + if (!this.includeSourceSpan) return input; + input.push(ast.sourceSpan.toString()); + return input; + } +} diff --git a/modules/@angular/compiler/test/html_parser/html_ast_spec_utils.ts b/modules/@angular/compiler/test/html_parser/html_ast_spec_utils.ts deleted file mode 100644 index 8f9691476e..0000000000 --- a/modules/@angular/compiler/test/html_parser/html_ast_spec_utils.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @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 {BaseException} from '../../src/facade/exceptions'; -import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from '../../src/html_parser/html_ast'; -import {HtmlParseTreeResult} from '../../src/html_parser/html_parser'; -import {ParseLocation} from '../../src/parse_util'; - -export function humanizeDom( - parseResult: HtmlParseTreeResult, addSourceSpan: boolean = false): any[] { - if (parseResult.errors.length > 0) { - var errorString = parseResult.errors.join('\n'); - throw new BaseException(`Unexpected parse errors:\n${errorString}`); - } - - return humanizeNodes(parseResult.rootNodes, addSourceSpan); -} - -export function humanizeDomSourceSpans(parseResult: HtmlParseTreeResult): any[] { - return humanizeDom(parseResult, true); -} - -export function humanizeNodes(nodes: HtmlAst[], addSourceSpan: boolean = false): any[] { - var humanizer = new _Humanizer(addSourceSpan); - htmlVisitAll(humanizer, nodes); - return humanizer.result; -} - -export function humanizeLineColumn(location: ParseLocation): string { - return `${location.line}:${location.col}`; -} - -class _Humanizer implements HtmlAstVisitor { - result: any[] = []; - elDepth: number = 0; - - constructor(private includeSourceSpan: boolean){}; - - visitElement(ast: HtmlElementAst, context: any): any { - var res = this._appendContext(ast, [HtmlElementAst, ast.name, this.elDepth++]); - this.result.push(res); - htmlVisitAll(this, ast.attrs); - htmlVisitAll(this, ast.children); - this.elDepth--; - } - - visitAttr(ast: HtmlAttrAst, context: any): any { - var res = this._appendContext(ast, [HtmlAttrAst, ast.name, ast.value]); - this.result.push(res); - } - - visitText(ast: HtmlTextAst, context: any): any { - var res = this._appendContext(ast, [HtmlTextAst, ast.value, this.elDepth]); - this.result.push(res); - } - - visitComment(ast: HtmlCommentAst, context: any): any { - var res = this._appendContext(ast, [HtmlCommentAst, ast.value, this.elDepth]); - this.result.push(res); - } - - visitExpansion(ast: HtmlExpansionAst, context: any): any { - var res = - this._appendContext(ast, [HtmlExpansionAst, ast.switchValue, ast.type, this.elDepth++]); - this.result.push(res); - htmlVisitAll(this, ast.cases); - this.elDepth--; - } - - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { - var res = this._appendContext(ast, [HtmlExpansionCaseAst, ast.value, this.elDepth]); - this.result.push(res); - } - - private _appendContext(ast: HtmlAst, input: any[]): any[] { - if (!this.includeSourceSpan) return input; - input.push(ast.sourceSpan.toString()); - return input; - } -} diff --git a/modules/@angular/compiler/test/html_parser/html_lexer_spec.ts b/modules/@angular/compiler/test/html_parser/html_lexer_spec.ts deleted file mode 100644 index dd1b9b82d0..0000000000 --- a/modules/@angular/compiler/test/html_parser/html_lexer_spec.ts +++ /dev/null @@ -1,801 +0,0 @@ -/** - * @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 {afterEach, beforeEach, ddescribe, describe, expect, iit, it, xit} from '../../../core/testing/testing_internal'; -import {HtmlToken, HtmlTokenError, HtmlTokenType, tokenizeHtml} from '../../src/html_parser/html_lexer'; -import {InterpolationConfig} from '../../src/html_parser/interpolation_config'; -import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '../../src/parse_util'; - -export function main() { - describe('HtmlLexer', () => { - describe('line/column numbers', () => { - it('should work without newlines', () => { - expect(tokenizeAndHumanizeLineColumn('a')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, '0:0'], - [HtmlTokenType.TAG_OPEN_END, '0:2'], - [HtmlTokenType.TEXT, '0:3'], - [HtmlTokenType.TAG_CLOSE, '0:4'], - [HtmlTokenType.EOF, '0:8'], - ]); - }); - - it('should work with one newline', () => { - expect(tokenizeAndHumanizeLineColumn('\na')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, '0:0'], - [HtmlTokenType.TAG_OPEN_END, '0:2'], - [HtmlTokenType.TEXT, '0:3'], - [HtmlTokenType.TAG_CLOSE, '1:1'], - [HtmlTokenType.EOF, '1:5'], - ]); - }); - - it('should work with multiple newlines', () => { - expect(tokenizeAndHumanizeLineColumn('\na')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, '0:0'], - [HtmlTokenType.TAG_OPEN_END, '1:0'], - [HtmlTokenType.TEXT, '1:1'], - [HtmlTokenType.TAG_CLOSE, '2:1'], - [HtmlTokenType.EOF, '2:5'], - ]); - }); - - it('should work with CR and LF', () => { - expect(tokenizeAndHumanizeLineColumn('\r\na\r')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, '0:0'], - [HtmlTokenType.TAG_OPEN_END, '1:0'], - [HtmlTokenType.TEXT, '1:1'], - [HtmlTokenType.TAG_CLOSE, '2:1'], - [HtmlTokenType.EOF, '2:5'], - ]); - }); - }); - - describe('comments', () => { - it('should parse comments', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.COMMENT_START], - [HtmlTokenType.RAW_TEXT, 't\ne\ns\nt'], - [HtmlTokenType.COMMENT_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ - [HtmlTokenType.COMMENT_START, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - it('should report { - expect(tokenizeAndHumanizeErrors(' { - expect(tokenizeAndHumanizeErrors('')).toEqual([ - [HtmlTokenType.COMMENT_START, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - it('should accept comments finishing by too many dashes (odd number)', () => { - expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ - [HtmlTokenType.COMMENT_START, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - }); - - describe('doctype', () => { - it('should parse doctypes', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.DOC_TYPE, 'doctype html'], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ - [HtmlTokenType.DOC_TYPE, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - it('should report missing end doctype', () => { - expect(tokenizeAndHumanizeErrors(' { - it('should parse CDATA', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.CDATA_START], - [HtmlTokenType.RAW_TEXT, 't\ne\ns\nt'], - [HtmlTokenType.CDATA_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ - [HtmlTokenType.CDATA_START, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - it('should report { - expect(tokenizeAndHumanizeErrors(' { - expect(tokenizeAndHumanizeErrors(' { - it('should parse open tags without prefix', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'test'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse namespace prefix', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, 'ns1', 'test'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse void tags', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'test'], - [HtmlTokenType.TAG_OPEN_END_VOID], - [HtmlTokenType.EOF], - ]); - }); - - it('should allow whitespace after the tag name', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'test'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - }); - - describe('attributes', () => { - it('should parse attributes without prefix', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes with interpolation', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, '{{v}}'], - [HtmlTokenType.ATTR_NAME, null, 'b'], - [HtmlTokenType.ATTR_VALUE, 's{{m}}e'], - [HtmlTokenType.ATTR_NAME, null, 'c'], - [HtmlTokenType.ATTR_VALUE, 's{{m//c}}e'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes with prefix', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, 'ns1', 'a'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes whose prefix is not valid', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, '(ns1:a)'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes with single quote value', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 'b'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes with double quote value', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 'b'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes with unquoted value', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 'b'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should allow whitespace', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 'b'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes with entities in values', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 'AA'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should not decode entities without trailing ";"', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, '&'], - [HtmlTokenType.ATTR_NAME, null, 'b'], - [HtmlTokenType.ATTR_VALUE, 'c&&d'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse attributes with "&" in values', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 'b && c &'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse values with CR and LF', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 't'], - [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 't\ne\ns\nt'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - }); - - describe('closing tags', () => { - it('should parse closing tags without prefix', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_CLOSE, null, 'test'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse closing tags with prefix', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_CLOSE, 'ns1', 'test'], - [HtmlTokenType.EOF], - ]); - }); - - it('should allow whitespace', () => { - expect(tokenizeAndHumanizeParts('')).toEqual([ - [HtmlTokenType.TAG_CLOSE, null, 'test'], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ - [HtmlTokenType.TAG_CLOSE, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - it('should report missing name after { - expect(tokenizeAndHumanizeErrors('', () => { - expect(tokenizeAndHumanizeErrors(' { - it('should parse named entities', () => { - expect(tokenizeAndHumanizeParts('a&b')).toEqual([ - [HtmlTokenType.TEXT, 'a&b'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse hexadecimal entities', () => { - expect(tokenizeAndHumanizeParts('AA')).toEqual([ - [HtmlTokenType.TEXT, 'AA'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse decimal entities', () => { - expect(tokenizeAndHumanizeParts('A')).toEqual([ - [HtmlTokenType.TEXT, 'A'], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('a&b')).toEqual([ - [HtmlTokenType.TEXT, 'a&b'], - [HtmlTokenType.EOF, ''], - ]); - }); - - it('should report malformed/unknown entities', () => { - expect(tokenizeAndHumanizeErrors('&tbo;')).toEqual([[ - HtmlTokenType.TEXT, - 'Unknown entity "tbo" - use the "&#;" or "&#x;" syntax', '0:0' - ]]); - expect(tokenizeAndHumanizeErrors('&#asdf;')).toEqual([ - [HtmlTokenType.TEXT, 'Unexpected character "s"', '0:3'] - ]); - expect(tokenizeAndHumanizeErrors(' sdf;')).toEqual([ - [HtmlTokenType.TEXT, 'Unexpected character "s"', '0:4'] - ]); - - expect(tokenizeAndHumanizeErrors('઼')).toEqual([ - [HtmlTokenType.TEXT, 'Unexpected character "EOF"', '0:6'] - ]); - }); - }); - - describe('regular text', () => { - it('should parse text', () => { - expect(tokenizeAndHumanizeParts('a')).toEqual([ - [HtmlTokenType.TEXT, 'a'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse interpolation', () => { - expect(tokenizeAndHumanizeParts('{{ a }}b{{ c // comment }}')).toEqual([ - [HtmlTokenType.TEXT, '{{ a }}b{{ c // comment }}'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse interpolation with custom markers', () => { - expect(tokenizeAndHumanizeParts('{% a %}', null, {start: '{%', end: '%}'})).toEqual([ - [HtmlTokenType.TEXT, '{% a %}'], - [HtmlTokenType.EOF], - ]); - }); - - it('should handle CR & LF', () => { - expect(tokenizeAndHumanizeParts('t\ne\rs\r\nt')).toEqual([ - [HtmlTokenType.TEXT, 't\ne\ns\nt'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse entities', () => { - expect(tokenizeAndHumanizeParts('a&b')).toEqual([ - [HtmlTokenType.TEXT, 'a&b'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse text starting with "&"', () => { - expect(tokenizeAndHumanizeParts('a && b &')).toEqual([ - [HtmlTokenType.TEXT, 'a && b &'], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans('a')).toEqual([ - [HtmlTokenType.TEXT, 'a'], - [HtmlTokenType.EOF, ''], - ]); - }); - - it('should allow "<" in text nodes', () => { - expect(tokenizeAndHumanizeParts('{{ a < b ? c : d }}')).toEqual([ - [HtmlTokenType.TEXT, '{{ a < b ? c : d }}'], - [HtmlTokenType.EOF], - ]); - - expect(tokenizeAndHumanizeSourceSpans('

a')).toEqual([ - [HtmlTokenType.TAG_OPEN_START, ''], - [HtmlTokenType.TEXT, 'a'], - [HtmlTokenType.EOF, ''], - ]); - - expect(tokenizeAndHumanizeParts('< a>')).toEqual([ - [HtmlTokenType.TEXT, '< a>'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse valid start tag in interpolation', () => { - expect(tokenizeAndHumanizeParts('{{ a d }}')).toEqual([ - [HtmlTokenType.TEXT, '{{ a '], - [HtmlTokenType.TAG_OPEN_START, null, 'b'], - [HtmlTokenType.ATTR_NAME, null, '&&'], - [HtmlTokenType.ATTR_NAME, null, 'c'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.TEXT, ' d }}'], - [HtmlTokenType.EOF], - ]); - }); - - it('should be able to escape {', () => { - expect(tokenizeAndHumanizeParts('{{ "{" }}')).toEqual([ - [HtmlTokenType.TEXT, '{{ "{" }}'], - [HtmlTokenType.EOF], - ]); - }); - - it('should be able to escape {{', () => { - expect(tokenizeAndHumanizeParts('{{ "{{" }}')).toEqual([ - [HtmlTokenType.TEXT, '{{ "{{" }}'], - [HtmlTokenType.EOF], - ]); - }); - - - }); - - describe('raw text', () => { - it('should parse text', () => { - expect(tokenizeAndHumanizeParts(``)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'script'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.RAW_TEXT, 't\ne\ns\nt'], - [HtmlTokenType.TAG_CLOSE, null, 'script'], - [HtmlTokenType.EOF], - ]); - }); - - it('should not detect entities', () => { - expect(tokenizeAndHumanizeParts(``)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'script'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.RAW_TEXT, '&'], - [HtmlTokenType.TAG_CLOSE, null, 'script'], - [HtmlTokenType.EOF], - ]); - }); - - it('should ignore other opening tags', () => { - expect(tokenizeAndHumanizeParts(``)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'script'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.RAW_TEXT, 'a

'], - [HtmlTokenType.TAG_CLOSE, null, 'script'], - [HtmlTokenType.EOF], - ]); - }); - - it('should ignore other closing tags', () => { - expect(tokenizeAndHumanizeParts(``)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'script'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.RAW_TEXT, 'a'], - [HtmlTokenType.TAG_CLOSE, null, 'script'], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans(``)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, ''], - [HtmlTokenType.RAW_TEXT, 'a'], - [HtmlTokenType.TAG_CLOSE, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - }); - - describe('escapable raw text', () => { - it('should parse text', () => { - expect(tokenizeAndHumanizeParts(`t\ne\rs\r\nt`)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'title'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.ESCAPABLE_RAW_TEXT, 't\ne\ns\nt'], - [HtmlTokenType.TAG_CLOSE, null, 'title'], - [HtmlTokenType.EOF], - ]); - }); - - it('should detect entities', () => { - expect(tokenizeAndHumanizeParts(`&`)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'title'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.ESCAPABLE_RAW_TEXT, '&'], - [HtmlTokenType.TAG_CLOSE, null, 'title'], - [HtmlTokenType.EOF], - ]); - }); - - it('should ignore other opening tags', () => { - expect(tokenizeAndHumanizeParts(`a<div>`)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'title'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.ESCAPABLE_RAW_TEXT, 'a
'], - [HtmlTokenType.TAG_CLOSE, null, 'title'], - [HtmlTokenType.EOF], - ]); - }); - - it('should ignore other closing tags', () => { - expect(tokenizeAndHumanizeParts(`a</test>`)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, null, 'title'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.ESCAPABLE_RAW_TEXT, 'a'], - [HtmlTokenType.TAG_CLOSE, null, 'title'], - [HtmlTokenType.EOF], - ]); - }); - - it('should store the locations', () => { - expect(tokenizeAndHumanizeSourceSpans(`a`)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, ''], - [HtmlTokenType.ESCAPABLE_RAW_TEXT, 'a'], - [HtmlTokenType.TAG_CLOSE, ''], - [HtmlTokenType.EOF, ''], - ]); - }); - - }); - - describe('expansion forms', () => { - it('should parse an expansion form', () => { - expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four} =5 {five} foo {bar} }', true)) - .toEqual([ - [HtmlTokenType.EXPANSION_FORM_START], - [HtmlTokenType.RAW_TEXT, 'one.two'], - [HtmlTokenType.RAW_TEXT, 'three'], - [HtmlTokenType.EXPANSION_CASE_VALUE, '=4'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.TEXT, 'four'], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_CASE_VALUE, '=5'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.TEXT, 'five'], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_CASE_VALUE, 'foo'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.TEXT, 'bar'], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_FORM_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse an expansion form with text elements surrounding it', () => { - expect(tokenizeAndHumanizeParts('before{one.two, three, =4 {four}}after', true)).toEqual([ - [HtmlTokenType.TEXT, 'before'], - [HtmlTokenType.EXPANSION_FORM_START], - [HtmlTokenType.RAW_TEXT, 'one.two'], - [HtmlTokenType.RAW_TEXT, 'three'], - [HtmlTokenType.EXPANSION_CASE_VALUE, '=4'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.TEXT, 'four'], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_FORM_END], - [HtmlTokenType.TEXT, 'after'], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse an expansion forms with elements in it', () => { - expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four a}}', true)).toEqual([ - [HtmlTokenType.EXPANSION_FORM_START], - [HtmlTokenType.RAW_TEXT, 'one.two'], - [HtmlTokenType.RAW_TEXT, 'three'], - [HtmlTokenType.EXPANSION_CASE_VALUE, '=4'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.TEXT, 'four '], - [HtmlTokenType.TAG_OPEN_START, null, 'b'], - [HtmlTokenType.TAG_OPEN_END], - [HtmlTokenType.TEXT, 'a'], - [HtmlTokenType.TAG_CLOSE, null, 'b'], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_FORM_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse an expansion forms containing an interpolation', () => { - expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four {{a}}}}', true)).toEqual([ - [HtmlTokenType.EXPANSION_FORM_START], - [HtmlTokenType.RAW_TEXT, 'one.two'], - [HtmlTokenType.RAW_TEXT, 'three'], - [HtmlTokenType.EXPANSION_CASE_VALUE, '=4'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.TEXT, 'four {{a}}'], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_FORM_END], - [HtmlTokenType.EOF], - ]); - }); - - it('should parse nested expansion forms', () => { - expect(tokenizeAndHumanizeParts(`{one.two, three, =4 { {xx, yy, =x {one}} }}`, true)) - .toEqual([ - [HtmlTokenType.EXPANSION_FORM_START], - [HtmlTokenType.RAW_TEXT, 'one.two'], - [HtmlTokenType.RAW_TEXT, 'three'], - [HtmlTokenType.EXPANSION_CASE_VALUE, '=4'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.EXPANSION_FORM_START], - [HtmlTokenType.RAW_TEXT, 'xx'], - [HtmlTokenType.RAW_TEXT, 'yy'], - [HtmlTokenType.EXPANSION_CASE_VALUE, '=x'], - [HtmlTokenType.EXPANSION_CASE_EXP_START], - [HtmlTokenType.TEXT, 'one'], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_FORM_END], - [HtmlTokenType.TEXT, ' '], - [HtmlTokenType.EXPANSION_CASE_EXP_END], - [HtmlTokenType.EXPANSION_FORM_END], - [HtmlTokenType.EOF], - ]); - }); - }); - - describe('errors', () => { - it('should parse nested expansion forms', () => { - expect(tokenizeAndHumanizeErrors(`

before { after

`, true)).toEqual([[ - HtmlTokenType.RAW_TEXT, - 'Unexpected character "EOF" (Do you have an unescaped "{" in your template?).', - '0:21', - ]]); - }); - - it('should include 2 lines of context in message', () => { - let src = '111\n222\n333\nE\n444\n555\n666\n'; - let file = new ParseSourceFile(src, 'file://'); - let location = new ParseLocation(file, 12, 123, 456); - let span = new ParseSourceSpan(location, location); - let error = new HtmlTokenError('**ERROR**', null, span); - expect(error.toString()) - .toEqual(`**ERROR** ("\n222\n333\n[ERROR ->]E\n444\n555\n"): file://@123:456`); - }); - }); - - describe('unicode characters', () => { - it('should support unicode characters', () => { - expect(tokenizeAndHumanizeSourceSpans(`

İ

`)).toEqual([ - [HtmlTokenType.TAG_OPEN_START, ''], - [HtmlTokenType.TEXT, 'İ'], - [HtmlTokenType.TAG_CLOSE, '

'], - [HtmlTokenType.EOF, ''], - ]); - }); - }); - - }); -} - -function tokenizeWithoutErrors( - input: string, tokenizeExpansionForms: boolean = false, - interpolationConfig?: InterpolationConfig): HtmlToken[] { - var tokenizeResult = tokenizeHtml(input, 'someUrl', tokenizeExpansionForms, interpolationConfig); - - if (tokenizeResult.errors.length > 0) { - const errorString = tokenizeResult.errors.join('\n'); - throw new Error(`Unexpected parse errors:\n${errorString}`); - } - - return tokenizeResult.tokens; -} - -function tokenizeAndHumanizeParts( - input: string, tokenizeExpansionForms: boolean = false, - interpolationConfig?: InterpolationConfig): any[] { - return tokenizeWithoutErrors(input, tokenizeExpansionForms, interpolationConfig) - .map(token => [token.type].concat(token.parts)); -} - -function tokenizeAndHumanizeSourceSpans(input: string): any[] { - return tokenizeWithoutErrors(input).map(token => [token.type, token.sourceSpan.toString()]); -} - -function humanizeLineColumn(location: ParseLocation): string { - return `${location.line}:${location.col}`; -} - -function tokenizeAndHumanizeLineColumn(input: string): any[] { - return tokenizeWithoutErrors(input).map( - token => [token.type, humanizeLineColumn(token.sourceSpan.start)]); -} - -function tokenizeAndHumanizeErrors(input: string, tokenizeExpansionForms: boolean = false): any[] { - return tokenizeHtml(input, 'someUrl', tokenizeExpansionForms) - .errors.map(e => [e.tokenType, e.msg, humanizeLineColumn(e.span.start)]); -} diff --git a/modules/@angular/compiler/test/html_parser/html_parser_spec.ts b/modules/@angular/compiler/test/html_parser/html_parser_spec.ts index e6665d5e20..41a49e200a 100644 --- a/modules/@angular/compiler/test/html_parser/html_parser_spec.ts +++ b/modules/@angular/compiler/test/html_parser/html_parser_spec.ts @@ -7,12 +7,12 @@ */ import {afterEach, beforeEach, ddescribe, describe, expect, iit, it, xit} from '../../../core/testing/testing_internal'; -import {HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst} from '../../src/html_parser/html_ast'; -import {HtmlTokenType} from '../../src/html_parser/html_lexer'; -import {HtmlParseTreeResult, HtmlParser, HtmlTreeError} from '../../src/html_parser/html_parser'; +import * as html from '../../src/html_parser/ast'; +import {HtmlParser, ParseTreeResult, TreeError} from '../../src/html_parser/html_parser'; +import {TokenType} from '../../src/html_parser/lexer'; import {ParseError} from '../../src/parse_util'; -import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn} from './html_ast_spec_utils'; +import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn} from './ast_spec_utils'; export function main() { describe('HtmlParser', () => { @@ -23,24 +23,24 @@ export function main() { describe('parse', () => { describe('text nodes', () => { it('should parse root level text nodes', () => { - expect(humanizeDom(parser.parse('a', 'TestComp'))).toEqual([[HtmlTextAst, 'a', 0]]); + expect(humanizeDom(parser.parse('a', 'TestComp'))).toEqual([[html.Text, 'a', 0]]); }); it('should parse text nodes inside regular elements', () => { expect(humanizeDom(parser.parse('
a
', 'TestComp'))).toEqual([ - [HtmlElementAst, 'div', 0], [HtmlTextAst, 'a', 1] + [html.Element, 'div', 0], [html.Text, 'a', 1] ]); }); it('should parse text nodes inside template elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, 'template', 0], [HtmlTextAst, 'a', 1] + [html.Element, 'template', 0], [html.Text, 'a', 1] ]); }); it('should parse CDATA', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlTextAst, 'text', 0] + [html.Text, 'text', 0] ]); }); }); @@ -48,27 +48,27 @@ export function main() { describe('elements', () => { it('should parse root level elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ - [HtmlElementAst, 'div', 0] + [html.Element, 'div', 0] ]); }); it('should parse elements inside of regular elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ - [HtmlElementAst, 'div', 0], [HtmlElementAst, 'span', 1] + [html.Element, 'div', 0], [html.Element, 'span', 1] ]); }); it('should parse elements inside of template elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))) - .toEqual([[HtmlElementAst, 'template', 0], [HtmlElementAst, 'span', 1]]); + .toEqual([[html.Element, 'template', 0], [html.Element, 'span', 1]]); }); it('should support void elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'link', 0], - [HtmlAttrAst, 'rel', 'author license'], - [HtmlAttrAst, 'href', '/about'], + [html.Element, 'link', 0], + [html.Attribute, 'rel', 'author license'], + [html.Attribute, 'href', '/about'], ]); }); @@ -87,30 +87,30 @@ export function main() { it('should close void elements on text nodes', () => { expect(humanizeDom(parser.parse('

before
after

', 'TestComp'))).toEqual([ - [HtmlElementAst, 'p', 0], - [HtmlTextAst, 'before', 1], - [HtmlElementAst, 'br', 1], - [HtmlTextAst, 'after', 1], + [html.Element, 'p', 0], + [html.Text, 'before', 1], + [html.Element, 'br', 1], + [html.Text, 'after', 1], ]); }); it('should support optional end tags', () => { expect(humanizeDom(parser.parse('

1

2

', 'TestComp'))).toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlElementAst, 'p', 1], - [HtmlTextAst, '1', 2], - [HtmlElementAst, 'p', 1], - [HtmlTextAst, '2', 2], + [html.Element, 'div', 0], + [html.Element, 'p', 1], + [html.Text, '1', 2], + [html.Element, 'p', 1], + [html.Text, '2', 2], ]); }); it('should support nested elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'ul', 0], - [HtmlElementAst, 'li', 1], - [HtmlElementAst, 'ul', 2], - [HtmlElementAst, 'li', 3], + [html.Element, 'ul', 0], + [html.Element, 'li', 1], + [html.Element, 'ul', 2], + [html.Element, 'li', 3], ]); }); @@ -120,19 +120,19 @@ export function main() { '
', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'table', 0], - [HtmlElementAst, 'thead', 1], - [HtmlElementAst, 'tr', 2], - [HtmlAttrAst, 'head', ''], - [HtmlElementAst, 'tbody', 1], - [HtmlElementAst, 'tr', 2], - [HtmlAttrAst, 'noparent', ''], - [HtmlElementAst, 'tbody', 1], - [HtmlElementAst, 'tr', 2], - [HtmlAttrAst, 'body', ''], - [HtmlElementAst, 'tfoot', 1], - [HtmlElementAst, 'tr', 2], - [HtmlAttrAst, 'foot', ''], + [html.Element, 'table', 0], + [html.Element, 'thead', 1], + [html.Element, 'tr', 2], + [html.Attribute, 'head', ''], + [html.Element, 'tbody', 1], + [html.Element, 'tr', 2], + [html.Attribute, 'noparent', ''], + [html.Element, 'tbody', 1], + [html.Element, 'tr', 2], + [html.Attribute, 'body', ''], + [html.Element, 'tfoot', 1], + [html.Element, 'tr', 2], + [html.Attribute, 'foot', ''], ]); }); @@ -140,10 +140,10 @@ export function main() { expect(humanizeDom(parser.parse( '
', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'table', 0], - [HtmlElementAst, 'tbody', 1], - [HtmlElementAst, 'ng-container', 2], - [HtmlElementAst, 'tr', 3], + [html.Element, 'table', 0], + [html.Element, 'tbody', 1], + [html.Element, 'ng-container', 2], + [html.Element, 'tr', 3], ]); }); @@ -152,42 +152,43 @@ export function main() { '
', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'table', 0], - [HtmlElementAst, 'thead', 1], - [HtmlElementAst, 'ng-container', 2], - [HtmlElementAst, 'tr', 3], + [html.Element, 'table', 0], + [html.Element, 'thead', 1], + [html.Element, 'ng-container', 2], + [html.Element, 'tr', 3], ]); }); it('should not add the requiredParent when the parent is a template', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, 'template', 0], - [HtmlElementAst, 'tr', 1], + [html.Element, 'template', 0], + [html.Element, 'tr', 1], ]); }); // https://github.com/angular/angular/issues/5967 it('should not add the requiredParent to a template root element', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, 'tr', 0], + [html.Element, 'tr', 0], ]); }); it('should support explicit mamespace', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, ':myns:div', 0] + [html.Element, ':myns:div', 0] ]); }); it('should support implicit mamespace', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, ':svg:svg', 0] + [html.Element, ':svg:svg', 0] ]); }); it('should propagate the namespace', () => { expect(humanizeDom(parser.parse('

', 'TestComp'))).toEqual([ - [HtmlElementAst, ':myns:div', 0], [HtmlElementAst, ':myns:p', 1] + [html.Element, ':myns:div', 0], + [html.Element, ':myns:p', 1], ]); }); @@ -202,13 +203,13 @@ export function main() { it('should support self closing void elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, 'input', 0] + [html.Element, 'input', 0] ]); }); it('should support self closing foreign elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, ':math:math', 0] + [html.Element, ':math:math', 0] ]); }); @@ -217,13 +218,13 @@ export function main() { '

\n

\n\n
\n\n', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'p', 0], - [HtmlTextAst, '\n', 1], - [HtmlElementAst, 'textarea', 0], - [HtmlElementAst, 'pre', 0], - [HtmlTextAst, '\n', 1], - [HtmlElementAst, 'listing', 0], - [HtmlTextAst, '\n', 1], + [html.Element, 'p', 0], + [html.Text, '\n', 1], + [html.Element, 'textarea', 0], + [html.Element, 'pre', 0], + [html.Text, '\n', 1], + [html.Element, 'listing', 0], + [html.Text, '\n', 1], ]); }); @@ -232,33 +233,37 @@ export function main() { describe('attributes', () => { it('should parse attributes on regular elements case sensitive', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlAttrAst, 'kEy', 'v'], - [HtmlAttrAst, 'key2', 'v2'], + [html.Element, 'div', 0], + [html.Attribute, 'kEy', 'v'], + [html.Attribute, 'key2', 'v2'], ]); }); it('should parse attributes without values', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ - [HtmlElementAst, 'div', 0], [HtmlAttrAst, 'k', ''] + [html.Element, 'div', 0], + [html.Attribute, 'k', ''], ]); }); it('should parse attributes on svg elements case sensitive', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, ':svg:svg', 0], [HtmlAttrAst, 'viewBox', '0'] + [html.Element, ':svg:svg', 0], + [html.Attribute, 'viewBox', '0'], ]); }); it('should parse attributes on template elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, 'template', 0], [HtmlAttrAst, 'k', 'v'] + [html.Element, 'template', 0], + [html.Attribute, 'k', 'v'], ]); }); it('should support namespace', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ - [HtmlElementAst, ':svg:use', 0], [HtmlAttrAst, ':xlink:href', 'Port'] + [html.Element, ':svg:use', 0], + [html.Attribute, ':xlink:href', 'Port'], ]); }); }); @@ -266,7 +271,8 @@ export function main() { describe('comments', () => { it('should preserve comments', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ - [HtmlCommentAst, 'comment', 0], [HtmlElementAst, 'div', 0] + [html.Comment, 'comment', 0], + [html.Element, 'div', 0], ]); }); }); @@ -278,54 +284,54 @@ export function main() { 'TestComp', true); expect(humanizeDom(parsed)).toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlTextAst, 'before', 1], - [HtmlExpansionAst, 'messages.length', 'plural', 1], - [HtmlExpansionCaseAst, '=0', 2], - [HtmlExpansionCaseAst, '=1', 2], - [HtmlTextAst, 'after', 1], + [html.Element, 'div', 0], + [html.Text, 'before', 1], + [html.Expansion, 'messages.length', 'plural', 1], + [html.ExpansionCase, '=0', 2], + [html.ExpansionCase, '=1', 2], + [html.Text, 'after', 1], ]); let cases = (parsed.rootNodes[0]).children[1].cases; - expect(humanizeDom(new HtmlParseTreeResult(cases[0].expression, []))).toEqual([ - [HtmlTextAst, 'You have ', 0], - [HtmlElementAst, 'b', 0], - [HtmlTextAst, 'no', 1], - [HtmlTextAst, ' messages', 0], + expect(humanizeDom(new ParseTreeResult(cases[0].expression, []))).toEqual([ + [html.Text, 'You have ', 0], + [html.Element, 'b', 0], + [html.Text, 'no', 1], + [html.Text, ' messages', 0], ]); - expect(humanizeDom(new HtmlParseTreeResult(cases[1].expression, [ - ]))).toEqual([[HtmlTextAst, 'One {{message}}', 0]]); + expect(humanizeDom(new ParseTreeResult(cases[1].expression, [ + ]))).toEqual([[html.Text, 'One {{message}}', 0]]); }); it('should parse out nested expansion forms', () => { let parsed = parser.parse( `{messages.length, plural, =0 { {p.gender, gender, =m {m}} }}`, 'TestComp', true); expect(humanizeDom(parsed)).toEqual([ - [HtmlExpansionAst, 'messages.length', 'plural', 0], - [HtmlExpansionCaseAst, '=0', 1], + [html.Expansion, 'messages.length', 'plural', 0], + [html.ExpansionCase, '=0', 1], ]); let firstCase = (parsed.rootNodes[0]).cases[0]; - expect(humanizeDom(new HtmlParseTreeResult(firstCase.expression, []))).toEqual([ - [HtmlExpansionAst, 'p.gender', 'gender', 0], - [HtmlExpansionCaseAst, '=m', 1], - [HtmlTextAst, ' ', 0], + expect(humanizeDom(new ParseTreeResult(firstCase.expression, []))).toEqual([ + [html.Expansion, 'p.gender', 'gender', 0], + [html.ExpansionCase, '=m', 1], + [html.Text, ' ', 0], ]); }); it('should error when expansion form is not closed', () => { let p = parser.parse(`{messages.length, plural, =0 {one}`, 'TestComp', true); expect(humanizeErrors(p.errors)).toEqual([ - [null, 'Invalid expansion form. Missing \'}\'.', '0:34'] + [null, 'Invalid ICU message. Missing \'}\'.', '0:34'] ]); }); it('should error when expansion case is not closed', () => { let p = parser.parse(`{messages.length, plural, =0 {one`, 'TestComp', true); expect(humanizeErrors(p.errors)).toEqual([ - [null, 'Invalid expansion form. Missing \'}\'.', '0:29'] + [null, 'Invalid ICU message. Missing \'}\'.', '0:29'] ]); }); @@ -342,17 +348,17 @@ export function main() { expect(humanizeDomSourceSpans(parser.parse( '
\na\n
', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'div', 0, '
'], - [HtmlAttrAst, '[prop]', 'v1', '[prop]="v1"'], - [HtmlAttrAst, '(e)', 'do()', '(e)="do()"'], - [HtmlAttrAst, 'attr', 'v2', 'attr="v2"'], - [HtmlAttrAst, 'noValue', '', 'noValue'], - [HtmlTextAst, '\na\n', 1, '\na\n'], + [html.Element, 'div', 0, '
'], + [html.Attribute, '[prop]', 'v1', '[prop]="v1"'], + [html.Attribute, '(e)', 'do()', '(e)="do()"'], + [html.Attribute, 'attr', 'v2', 'attr="v2"'], + [html.Attribute, 'noValue', '', 'noValue'], + [html.Text, '\na\n', 1, '\na\n'], ]); }); it('should set the start and end source spans', () => { - let node = parser.parse('
a
', 'TestComp').rootNodes[0]; + let node = parser.parse('
a
', 'TestComp').rootNodes[0]; expect(node.startSourceSpan.start.offset).toEqual(0); expect(node.startSourceSpan.end.offset).toEqual(5); @@ -360,6 +366,16 @@ export function main() { expect(node.endSourceSpan.start.offset).toEqual(6); expect(node.endSourceSpan.end.offset).toEqual(12); }); + + it('should support expansion form', () => { + expect(humanizeDomSourceSpans( + parser.parse('
{count, plural, =0 {msg}}
', 'TestComp', true))) + .toEqual([ + [html.Element, 'div', 0, '
'], + [html.Expansion, 'count', 'plural', 1, '{count, plural, =0 {msg}}'], + [html.ExpansionCase, '=0', 2, '=0 {msg}'], + ]); + }); }); describe('errors', () => { @@ -403,7 +419,7 @@ export function main() { let errors = parser.parse('

', 'TestComp').errors; expect(errors.length).toEqual(2); expect(humanizeErrors(errors)).toEqual([ - [HtmlTokenType.COMMENT_START, 'Unexpected character "e"', '0:3'], + [TokenType.COMMENT_START, 'Unexpected character "e"', '0:3'], ['p', 'Unexpected closing tag "p"', '0:14'] ]); }); @@ -414,7 +430,7 @@ export function main() { export function humanizeErrors(errors: ParseError[]): any[] { return errors.map(e => { - if (e instanceof HtmlTreeError) { + if (e instanceof TreeError) { // Parser errors return [e.elementName, e.msg, humanizeLineColumn(e.span.start)]; } diff --git a/modules/@angular/compiler/test/html_parser/expander_spec.ts b/modules/@angular/compiler/test/html_parser/icu_ast_expander_spec.ts similarity index 68% rename from modules/@angular/compiler/test/html_parser/expander_spec.ts rename to modules/@angular/compiler/test/html_parser/icu_ast_expander_spec.ts index 4e1f4376a5..7e4b3ed7b0 100644 --- a/modules/@angular/compiler/test/html_parser/expander_spec.ts +++ b/modules/@angular/compiler/test/html_parser/icu_ast_expander_spec.ts @@ -7,12 +7,12 @@ */ import {ddescribe, describe, expect, iit, it} from '../../../core/testing/testing_internal'; -import {ExpansionResult, expandNodes} from '../../src/html_parser/expander'; -import {HtmlAttrAst, HtmlElementAst, HtmlTextAst} from '../../src/html_parser/html_ast'; +import * as html from '../../src/html_parser/ast'; import {HtmlParser} from '../../src/html_parser/html_parser'; +import {ExpansionResult, expandNodes} from '../../src/html_parser/icu_ast_expander'; import {ParseError} from '../../src/parse_util'; -import {humanizeNodes} from './html_ast_spec_utils'; +import {humanizeNodes} from './ast_spec_utils'; export function main() { describe('Expander', () => { @@ -26,13 +26,13 @@ export function main() { const res = expand(`{messages.length, plural,=0 {zerobold}}`); expect(humanizeNodes(res.nodes)).toEqual([ - [HtmlElementAst, 'ng-container', 0], - [HtmlAttrAst, '[ngPlural]', 'messages.length'], - [HtmlElementAst, 'template', 1], - [HtmlAttrAst, 'ngPluralCase', '=0'], - [HtmlTextAst, 'zero', 2], - [HtmlElementAst, 'b', 2], - [HtmlTextAst, 'bold', 3], + [html.Element, 'ng-container', 0], + [html.Attribute, '[ngPlural]', 'messages.length'], + [html.Element, 'template', 1], + [html.Attribute, 'ngPluralCase', '=0'], + [html.Text, 'zero', 2], + [html.Element, 'b', 2], + [html.Text, 'bold', 3], ]); }); @@ -40,23 +40,23 @@ export function main() { const res = expand(`{messages.length, plural, =0 { {p.gender, gender, =m {m}} }}`); expect(humanizeNodes(res.nodes)).toEqual([ - [HtmlElementAst, 'ng-container', 0], - [HtmlAttrAst, '[ngPlural]', 'messages.length'], - [HtmlElementAst, 'template', 1], - [HtmlAttrAst, 'ngPluralCase', '=0'], - [HtmlElementAst, 'ng-container', 2], - [HtmlAttrAst, '[ngSwitch]', 'p.gender'], - [HtmlElementAst, 'template', 3], - [HtmlAttrAst, 'ngSwitchCase', '=m'], - [HtmlTextAst, 'm', 4], - [HtmlTextAst, ' ', 2], + [html.Element, 'ng-container', 0], + [html.Attribute, '[ngPlural]', 'messages.length'], + [html.Element, 'template', 1], + [html.Attribute, 'ngPluralCase', '=0'], + [html.Element, 'ng-container', 2], + [html.Attribute, '[ngSwitch]', 'p.gender'], + [html.Element, 'template', 3], + [html.Attribute, 'ngSwitchCase', '=m'], + [html.Text, 'm', 4], + [html.Text, ' ', 2], ]); }); it('should correctly set source code positions', () => { const nodes = expand(`{messages.length, plural,=0 {bold}}`).nodes; - const container: HtmlElementAst = nodes[0]; + const container: html.Element = nodes[0]; expect(container.sourceSpan.start.col).toEqual(0); expect(container.sourceSpan.end.col).toEqual(42); expect(container.startSourceSpan.start.col).toEqual(0); @@ -68,7 +68,7 @@ export function main() { expect(switchExp.sourceSpan.start.col).toEqual(1); expect(switchExp.sourceSpan.end.col).toEqual(16); - const template: HtmlElementAst = container.children[0]; + const template: html.Element = container.children[0]; expect(template.sourceSpan.start.col).toEqual(25); expect(template.sourceSpan.end.col).toEqual(41); @@ -76,7 +76,7 @@ export function main() { expect(switchCheck.sourceSpan.start.col).toEqual(25); expect(switchCheck.sourceSpan.end.col).toEqual(28); - const b: HtmlElementAst = template.children[0]; + const b: html.Element = template.children[0]; expect(b.sourceSpan.start.col).toEqual(29); expect(b.endSourceSpan.end.col).toEqual(40); }); @@ -85,11 +85,11 @@ export function main() { const res = expand(`{person.gender, gender,=male {m}}`); expect(humanizeNodes(res.nodes)).toEqual([ - [HtmlElementAst, 'ng-container', 0], - [HtmlAttrAst, '[ngSwitch]', 'person.gender'], - [HtmlElementAst, 'template', 1], - [HtmlAttrAst, 'ngSwitchCase', '=male'], - [HtmlTextAst, 'm', 2], + [html.Element, 'ng-container', 0], + [html.Attribute, '[ngSwitch]', 'person.gender'], + [html.Element, 'template', 1], + [html.Attribute, 'ngSwitchCase', '=male'], + [html.Text, 'm', 2], ]); }); diff --git a/modules/@angular/compiler/test/html_parser/lexer_spec.ts b/modules/@angular/compiler/test/html_parser/lexer_spec.ts new file mode 100644 index 0000000000..a7fcc91abe --- /dev/null +++ b/modules/@angular/compiler/test/html_parser/lexer_spec.ts @@ -0,0 +1,803 @@ +/** + * @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 {afterEach, beforeEach, ddescribe, describe, expect, iit, it, xit} from '../../../core/testing/testing_internal'; +import {getHtmlTagDefinition} from '../../src/html_parser/html_tags'; +import {InterpolationConfig} from '../../src/html_parser/interpolation_config'; +import * as lex from '../../src/html_parser/lexer'; +import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '../../src/parse_util'; + +export function main() { + describe('HtmlLexer', () => { + describe('line/column numbers', () => { + it('should work without newlines', () => { + expect(tokenizeAndHumanizeLineColumn('a')).toEqual([ + [lex.TokenType.TAG_OPEN_START, '0:0'], + [lex.TokenType.TAG_OPEN_END, '0:2'], + [lex.TokenType.TEXT, '0:3'], + [lex.TokenType.TAG_CLOSE, '0:4'], + [lex.TokenType.EOF, '0:8'], + ]); + }); + + it('should work with one newline', () => { + expect(tokenizeAndHumanizeLineColumn('\na')).toEqual([ + [lex.TokenType.TAG_OPEN_START, '0:0'], + [lex.TokenType.TAG_OPEN_END, '0:2'], + [lex.TokenType.TEXT, '0:3'], + [lex.TokenType.TAG_CLOSE, '1:1'], + [lex.TokenType.EOF, '1:5'], + ]); + }); + + it('should work with multiple newlines', () => { + expect(tokenizeAndHumanizeLineColumn('\na')).toEqual([ + [lex.TokenType.TAG_OPEN_START, '0:0'], + [lex.TokenType.TAG_OPEN_END, '1:0'], + [lex.TokenType.TEXT, '1:1'], + [lex.TokenType.TAG_CLOSE, '2:1'], + [lex.TokenType.EOF, '2:5'], + ]); + }); + + it('should work with CR and LF', () => { + expect(tokenizeAndHumanizeLineColumn('\r\na\r')).toEqual([ + [lex.TokenType.TAG_OPEN_START, '0:0'], + [lex.TokenType.TAG_OPEN_END, '1:0'], + [lex.TokenType.TEXT, '1:1'], + [lex.TokenType.TAG_CLOSE, '2:1'], + [lex.TokenType.EOF, '2:5'], + ]); + }); + }); + + describe('comments', () => { + it('should parse comments', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.COMMENT_START], + [lex.TokenType.RAW_TEXT, 't\ne\ns\nt'], + [lex.TokenType.COMMENT_END], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ + [lex.TokenType.COMMENT_START, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + it('should report { + expect(tokenizeAndHumanizeErrors(' { + expect(tokenizeAndHumanizeErrors('')).toEqual([ + [lex.TokenType.COMMENT_START, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + it('should accept comments finishing by too many dashes (odd number)', () => { + expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ + [lex.TokenType.COMMENT_START, ''], + [lex.TokenType.EOF, ''], + ]); + }); + }); + + describe('doctype', () => { + it('should parse doctypes', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.DOC_TYPE, 'doctype html'], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ + [lex.TokenType.DOC_TYPE, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + it('should report missing end doctype', () => { + expect(tokenizeAndHumanizeErrors(' { + it('should parse CDATA', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.CDATA_START], + [lex.TokenType.RAW_TEXT, 't\ne\ns\nt'], + [lex.TokenType.CDATA_END], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ + [lex.TokenType.CDATA_START, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + it('should report { + expect(tokenizeAndHumanizeErrors(' { + expect(tokenizeAndHumanizeErrors(' { + it('should parse open tags without prefix', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'test'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse namespace prefix', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, 'ns1', 'test'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse void tags', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'test'], + [lex.TokenType.TAG_OPEN_END_VOID], + [lex.TokenType.EOF], + ]); + }); + + it('should allow whitespace after the tag name', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'test'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + }); + + describe('attributes', () => { + it('should parse attributes without prefix', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes with interpolation', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, '{{v}}'], + [lex.TokenType.ATTR_NAME, null, 'b'], + [lex.TokenType.ATTR_VALUE, 's{{m}}e'], + [lex.TokenType.ATTR_NAME, null, 'c'], + [lex.TokenType.ATTR_VALUE, 's{{m//c}}e'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes with prefix', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, 'ns1', 'a'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes whose prefix is not valid', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, '(ns1:a)'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes with single quote value', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, 'b'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes with double quote value', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, 'b'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes with unquoted value', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, 'b'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should allow whitespace', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, 'b'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes with entities in values', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, 'AA'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should not decode entities without trailing ";"', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, '&'], + [lex.TokenType.ATTR_NAME, null, 'b'], + [lex.TokenType.ATTR_VALUE, 'c&&d'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse attributes with "&" in values', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, 'b && c &'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse values with CR and LF', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 't'], + [lex.TokenType.ATTR_NAME, null, 'a'], + [lex.TokenType.ATTR_VALUE, 't\ne\ns\nt'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ + [lex.TokenType.TAG_OPEN_START, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + }); + + describe('closing tags', () => { + it('should parse closing tags without prefix', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_CLOSE, null, 'test'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse closing tags with prefix', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_CLOSE, 'ns1', 'test'], + [lex.TokenType.EOF], + ]); + }); + + it('should allow whitespace', () => { + expect(tokenizeAndHumanizeParts('')).toEqual([ + [lex.TokenType.TAG_CLOSE, null, 'test'], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ + [lex.TokenType.TAG_CLOSE, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + it('should report missing name after { + expect(tokenizeAndHumanizeErrors('', () => { + expect(tokenizeAndHumanizeErrors(' { + it('should parse named entities', () => { + expect(tokenizeAndHumanizeParts('a&b')).toEqual([ + [lex.TokenType.TEXT, 'a&b'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse hexadecimal entities', () => { + expect(tokenizeAndHumanizeParts('AA')).toEqual([ + [lex.TokenType.TEXT, 'AA'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse decimal entities', () => { + expect(tokenizeAndHumanizeParts('A')).toEqual([ + [lex.TokenType.TEXT, 'A'], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('a&b')).toEqual([ + [lex.TokenType.TEXT, 'a&b'], + [lex.TokenType.EOF, ''], + ]); + }); + + it('should report malformed/unknown entities', () => { + expect(tokenizeAndHumanizeErrors('&tbo;')).toEqual([[ + lex.TokenType.TEXT, + 'Unknown entity "tbo" - use the "&#;" or "&#x;" syntax', '0:0' + ]]); + expect(tokenizeAndHumanizeErrors('&#asdf;')).toEqual([ + [lex.TokenType.TEXT, 'Unexpected character "s"', '0:3'] + ]); + expect(tokenizeAndHumanizeErrors(' sdf;')).toEqual([ + [lex.TokenType.TEXT, 'Unexpected character "s"', '0:4'] + ]); + + expect(tokenizeAndHumanizeErrors('઼')).toEqual([ + [lex.TokenType.TEXT, 'Unexpected character "EOF"', '0:6'] + ]); + }); + }); + + describe('regular text', () => { + it('should parse text', () => { + expect(tokenizeAndHumanizeParts('a')).toEqual([ + [lex.TokenType.TEXT, 'a'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse interpolation', () => { + expect(tokenizeAndHumanizeParts('{{ a }}b{{ c // comment }}')).toEqual([ + [lex.TokenType.TEXT, '{{ a }}b{{ c // comment }}'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse interpolation with custom markers', () => { + expect(tokenizeAndHumanizeParts('{% a %}', null, {start: '{%', end: '%}'})).toEqual([ + [lex.TokenType.TEXT, '{% a %}'], + [lex.TokenType.EOF], + ]); + }); + + it('should handle CR & LF', () => { + expect(tokenizeAndHumanizeParts('t\ne\rs\r\nt')).toEqual([ + [lex.TokenType.TEXT, 't\ne\ns\nt'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse entities', () => { + expect(tokenizeAndHumanizeParts('a&b')).toEqual([ + [lex.TokenType.TEXT, 'a&b'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse text starting with "&"', () => { + expect(tokenizeAndHumanizeParts('a && b &')).toEqual([ + [lex.TokenType.TEXT, 'a && b &'], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans('a')).toEqual([ + [lex.TokenType.TEXT, 'a'], + [lex.TokenType.EOF, ''], + ]); + }); + + it('should allow "<" in text nodes', () => { + expect(tokenizeAndHumanizeParts('{{ a < b ? c : d }}')).toEqual([ + [lex.TokenType.TEXT, '{{ a < b ? c : d }}'], + [lex.TokenType.EOF], + ]); + + expect(tokenizeAndHumanizeSourceSpans('

a')).toEqual([ + [lex.TokenType.TAG_OPEN_START, ''], + [lex.TokenType.TEXT, 'a'], + [lex.TokenType.EOF, ''], + ]); + + expect(tokenizeAndHumanizeParts('< a>')).toEqual([ + [lex.TokenType.TEXT, '< a>'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse valid start tag in interpolation', () => { + expect(tokenizeAndHumanizeParts('{{ a d }}')).toEqual([ + [lex.TokenType.TEXT, '{{ a '], + [lex.TokenType.TAG_OPEN_START, null, 'b'], + [lex.TokenType.ATTR_NAME, null, '&&'], + [lex.TokenType.ATTR_NAME, null, 'c'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.TEXT, ' d }}'], + [lex.TokenType.EOF], + ]); + }); + + it('should be able to escape {', () => { + expect(tokenizeAndHumanizeParts('{{ "{" }}')).toEqual([ + [lex.TokenType.TEXT, '{{ "{" }}'], + [lex.TokenType.EOF], + ]); + }); + + it('should be able to escape {{', () => { + expect(tokenizeAndHumanizeParts('{{ "{{" }}')).toEqual([ + [lex.TokenType.TEXT, '{{ "{{" }}'], + [lex.TokenType.EOF], + ]); + }); + + + }); + + describe('raw text', () => { + it('should parse text', () => { + expect(tokenizeAndHumanizeParts(``)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'script'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.RAW_TEXT, 't\ne\ns\nt'], + [lex.TokenType.TAG_CLOSE, null, 'script'], + [lex.TokenType.EOF], + ]); + }); + + it('should not detect entities', () => { + expect(tokenizeAndHumanizeParts(``)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'script'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.RAW_TEXT, '&'], + [lex.TokenType.TAG_CLOSE, null, 'script'], + [lex.TokenType.EOF], + ]); + }); + + it('should ignore other opening tags', () => { + expect(tokenizeAndHumanizeParts(``)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'script'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.RAW_TEXT, 'a

'], + [lex.TokenType.TAG_CLOSE, null, 'script'], + [lex.TokenType.EOF], + ]); + }); + + it('should ignore other closing tags', () => { + expect(tokenizeAndHumanizeParts(``)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'script'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.RAW_TEXT, 'a'], + [lex.TokenType.TAG_CLOSE, null, 'script'], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans(``)).toEqual([ + [lex.TokenType.TAG_OPEN_START, ''], + [lex.TokenType.RAW_TEXT, 'a'], + [lex.TokenType.TAG_CLOSE, ''], + [lex.TokenType.EOF, ''], + ]); + }); + }); + + describe('escapable raw text', () => { + it('should parse text', () => { + expect(tokenizeAndHumanizeParts(`t\ne\rs\r\nt`)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'title'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.ESCAPABLE_RAW_TEXT, 't\ne\ns\nt'], + [lex.TokenType.TAG_CLOSE, null, 'title'], + [lex.TokenType.EOF], + ]); + }); + + it('should detect entities', () => { + expect(tokenizeAndHumanizeParts(`&`)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'title'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.ESCAPABLE_RAW_TEXT, '&'], + [lex.TokenType.TAG_CLOSE, null, 'title'], + [lex.TokenType.EOF], + ]); + }); + + it('should ignore other opening tags', () => { + expect(tokenizeAndHumanizeParts(`a<div>`)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'title'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.ESCAPABLE_RAW_TEXT, 'a
'], + [lex.TokenType.TAG_CLOSE, null, 'title'], + [lex.TokenType.EOF], + ]); + }); + + it('should ignore other closing tags', () => { + expect(tokenizeAndHumanizeParts(`a</test>`)).toEqual([ + [lex.TokenType.TAG_OPEN_START, null, 'title'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.ESCAPABLE_RAW_TEXT, 'a'], + [lex.TokenType.TAG_CLOSE, null, 'title'], + [lex.TokenType.EOF], + ]); + }); + + it('should store the locations', () => { + expect(tokenizeAndHumanizeSourceSpans(`a`)).toEqual([ + [lex.TokenType.TAG_OPEN_START, ''], + [lex.TokenType.ESCAPABLE_RAW_TEXT, 'a'], + [lex.TokenType.TAG_CLOSE, ''], + [lex.TokenType.EOF, ''], + ]); + }); + + }); + + describe('expansion forms', () => { + it('should parse an expansion form', () => { + expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four} =5 {five} foo {bar} }', true)) + .toEqual([ + [lex.TokenType.EXPANSION_FORM_START], + [lex.TokenType.RAW_TEXT, 'one.two'], + [lex.TokenType.RAW_TEXT, 'three'], + [lex.TokenType.EXPANSION_CASE_VALUE, '=4'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.TEXT, 'four'], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_CASE_VALUE, '=5'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.TEXT, 'five'], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_CASE_VALUE, 'foo'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.TEXT, 'bar'], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_FORM_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse an expansion form with text elements surrounding it', () => { + expect(tokenizeAndHumanizeParts('before{one.two, three, =4 {four}}after', true)).toEqual([ + [lex.TokenType.TEXT, 'before'], + [lex.TokenType.EXPANSION_FORM_START], + [lex.TokenType.RAW_TEXT, 'one.two'], + [lex.TokenType.RAW_TEXT, 'three'], + [lex.TokenType.EXPANSION_CASE_VALUE, '=4'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.TEXT, 'four'], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_FORM_END], + [lex.TokenType.TEXT, 'after'], + [lex.TokenType.EOF], + ]); + }); + + it('should parse an expansion forms with elements in it', () => { + expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four a}}', true)).toEqual([ + [lex.TokenType.EXPANSION_FORM_START], + [lex.TokenType.RAW_TEXT, 'one.two'], + [lex.TokenType.RAW_TEXT, 'three'], + [lex.TokenType.EXPANSION_CASE_VALUE, '=4'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.TEXT, 'four '], + [lex.TokenType.TAG_OPEN_START, null, 'b'], + [lex.TokenType.TAG_OPEN_END], + [lex.TokenType.TEXT, 'a'], + [lex.TokenType.TAG_CLOSE, null, 'b'], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_FORM_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse an expansion forms containing an interpolation', () => { + expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four {{a}}}}', true)).toEqual([ + [lex.TokenType.EXPANSION_FORM_START], + [lex.TokenType.RAW_TEXT, 'one.two'], + [lex.TokenType.RAW_TEXT, 'three'], + [lex.TokenType.EXPANSION_CASE_VALUE, '=4'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.TEXT, 'four {{a}}'], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_FORM_END], + [lex.TokenType.EOF], + ]); + }); + + it('should parse nested expansion forms', () => { + expect(tokenizeAndHumanizeParts(`{one.two, three, =4 { {xx, yy, =x {one}} }}`, true)) + .toEqual([ + [lex.TokenType.EXPANSION_FORM_START], + [lex.TokenType.RAW_TEXT, 'one.two'], + [lex.TokenType.RAW_TEXT, 'three'], + [lex.TokenType.EXPANSION_CASE_VALUE, '=4'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.EXPANSION_FORM_START], + [lex.TokenType.RAW_TEXT, 'xx'], + [lex.TokenType.RAW_TEXT, 'yy'], + [lex.TokenType.EXPANSION_CASE_VALUE, '=x'], + [lex.TokenType.EXPANSION_CASE_EXP_START], + [lex.TokenType.TEXT, 'one'], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_FORM_END], + [lex.TokenType.TEXT, ' '], + [lex.TokenType.EXPANSION_CASE_EXP_END], + [lex.TokenType.EXPANSION_FORM_END], + [lex.TokenType.EOF], + ]); + }); + }); + + describe('errors', () => { + it('should report unescaped "{" on error', () => { + expect(tokenizeAndHumanizeErrors(`

before { after

`, true)).toEqual([[ + lex.TokenType.RAW_TEXT, + `Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)`, + '0:21', + ]]); + }); + + it('should include 2 lines of context in message', () => { + let src = '111\n222\n333\nE\n444\n555\n666\n'; + let file = new ParseSourceFile(src, 'file://'); + let location = new ParseLocation(file, 12, 123, 456); + let span = new ParseSourceSpan(location, location); + let error = new lex.TokenError('**ERROR**', null, span); + expect(error.toString()) + .toEqual(`**ERROR** ("\n222\n333\n[ERROR ->]E\n444\n555\n"): file://@123:456`); + }); + }); + + describe('unicode characters', () => { + it('should support unicode characters', () => { + expect(tokenizeAndHumanizeSourceSpans(`

İ

`)).toEqual([ + [lex.TokenType.TAG_OPEN_START, ''], + [lex.TokenType.TEXT, 'İ'], + [lex.TokenType.TAG_CLOSE, '

'], + [lex.TokenType.EOF, ''], + ]); + }); + }); + + }); +} + +function tokenizeWithoutErrors( + input: string, tokenizeExpansionForms: boolean = false, + interpolationConfig?: InterpolationConfig): lex.Token[] { + var tokenizeResult = lex.tokenize( + input, 'someUrl', getHtmlTagDefinition, tokenizeExpansionForms, interpolationConfig); + + if (tokenizeResult.errors.length > 0) { + const errorString = tokenizeResult.errors.join('\n'); + throw new Error(`Unexpected parse errors:\n${errorString}`); + } + + return tokenizeResult.tokens; +} + +function tokenizeAndHumanizeParts( + input: string, tokenizeExpansionForms: boolean = false, + interpolationConfig?: InterpolationConfig): any[] { + return tokenizeWithoutErrors(input, tokenizeExpansionForms, interpolationConfig) + .map(token => [token.type].concat(token.parts)); +} + +function tokenizeAndHumanizeSourceSpans(input: string): any[] { + return tokenizeWithoutErrors(input).map(token => [token.type, token.sourceSpan.toString()]); +} + +function humanizeLineColumn(location: ParseLocation): string { + return `${location.line}:${location.col}`; +} + +function tokenizeAndHumanizeLineColumn(input: string): any[] { + return tokenizeWithoutErrors(input).map( + token => [token.type, humanizeLineColumn(token.sourceSpan.start)]); +} + +function tokenizeAndHumanizeErrors(input: string, tokenizeExpansionForms: boolean = false): any[] { + return lex.tokenize(input, 'someUrl', getHtmlTagDefinition, tokenizeExpansionForms) + .errors.map(e => [e.tokenType, e.msg, humanizeLineColumn(e.span.start)]); +} diff --git a/modules/@angular/compiler/test/i18n/extractor_spec.ts b/modules/@angular/compiler/test/i18n/extractor_spec.ts index 624c2c0dd4..e4a310a00c 100644 --- a/modules/@angular/compiler/test/i18n/extractor_spec.ts +++ b/modules/@angular/compiler/test/i18n/extractor_spec.ts @@ -10,261 +10,248 @@ import {ExtractionResult, extractAstMessages} from '@angular/compiler/src/i18n/e import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; import {HtmlParser} from '../../src/html_parser/html_parser'; -import {serializeAst} from '../html_parser/html_ast_serializer_spec' +import {serializeAst} from '../html_parser/ast_serializer_spec'; export function main() { - ddescribe( - 'MessageExtractor', - () => { - describe('elements', () => { - it('should extract from elements', () => { - expect(extract('
textnested
')).toEqual([ - [['text', 'nested'], 'm', 'd'], - ]); - }); + describe('MessageExtractor', () => { + describe('elements', () => { + it('should extract from elements', () => { + expect(extract('
textnested
')).toEqual([ + [['text', 'nested'], 'm', 'd'], + ]); + }); - it('should not create a message for empty elements', - () => { expect(extract('
')).toEqual([]); }); - }); + it('should not create a message for empty elements', + () => { expect(extract('
')).toEqual([]); }); + }); - describe('blocks', () => { - it('should extract from blocks', () => { - expect(extract(`message1 + describe('blocks', () => { + it('should extract from blocks', () => { + expect(extract(`message1 message2 message3`)) - .toEqual([ - [['message1'], 'meaning1', 'desc1'], - [['message2'], 'meaning2', ''], - [['message3'], '', ''], - ]); - }); - - it('should extract siblings', () => { - expect( - extract( - `text

htmlnested

{count, plural, =0 {html}}{{interp}}`)) - .toEqual([ - [['{count, plural, =0 {html}}'], '', ''], - [ - [ - 'text', '

htmlnested

', '{count, plural, =0 {html}}', - '{{interp}}' - ], - '', '' - ], - ]); - }); - - it('should ignore other comments', () => { - expect(extract(`message1`)) - .toEqual([ - [['message1'], 'meaning1', 'desc1'], - ]); - }); - - it('should not create a message for empty blocks', - () => { expect(extract(``)).toEqual([]); }); - }); - - describe('ICU messages', () => { - it('should extract ICU messages from translatable elements', () => { - // single message when ICU is the only children - expect(extract('
{count, plural, =0 {text}}
')).toEqual([ - [['{count, plural, =0 {text}}'], 'm', 'd'], + .toEqual([ + [['message1'], 'meaning1', 'desc1'], + [['message2'], 'meaning2', ''], + [['message3'], '', ''], ]); + }); - // one message for the element content and one message for the ICU - expect(extract('
before{count, plural, =0 {text}}after
')).toEqual([ - [['before', '{count, plural, =0 {text}}', 'after'], 'm', 'd'], + it('should extract siblings', () => { + expect( + extract( + `text

htmlnested

{count, plural, =0 {html}}{{interp}}`)) + .toEqual([ + [['{count, plural, =0 {html}}'], '', ''], + [ + [ + 'text', '

htmlnested

', '{count, plural, =0 {html}}', + '{{interp}}' + ], + '', '' + ], + ]); + }); + + it('should ignore other comments', () => { + expect(extract(`message1`)) + .toEqual([ + [['message1'], 'meaning1', 'desc1'], + ]); + }); + + it('should not create a message for empty blocks', + () => { expect(extract(``)).toEqual([]); }); + }); + + describe('ICU messages', () => { + it('should extract ICU messages from translatable elements', () => { + // single message when ICU is the only children + expect(extract('
{count, plural, =0 {text}}
')).toEqual([ + [['{count, plural, =0 {text}}'], 'm', 'd'], + ]); + + // one message for the element content and one message for the ICU + expect(extract('
before{count, plural, =0 {text}}after
')).toEqual([ + [['before', '{count, plural, =0 {text}}', 'after'], 'm', 'd'], + [['{count, plural, =0 {text}}'], '', ''], + ]); + }); + + it('should extract ICU messages from translatable block', () => { + // single message when ICU is the only children + expect(extract('{count, plural, =0 {text}}')).toEqual([ + [['{count, plural, =0 {text}}'], 'm', 'd'], + ]); + + // one message for the block content and one message for the ICU + expect(extract('before{count, plural, =0 {text}}after')) + .toEqual([ [['{count, plural, =0 {text}}'], '', ''], + [['before', '{count, plural, =0 {text}}', 'after'], 'm', 'd'], ]); - }); + }); - it('should extract ICU messages from translatable block', () => { - // single message when ICU is the only children - expect(extract('{count, plural, =0 {text}}')).toEqual([ - [['{count, plural, =0 {text}}'], 'm', 'd'], + it('should not extract ICU messages outside of i18n sections', + () => { expect(extract('{count, plural, =0 {text}}')).toEqual([]); }); + + it('should not extract nested ICU messages', () => { + expect(extract('
{count, plural, =0 { {sex, gender, =m {m}} }}
')) + .toEqual([ + [['{count, plural, =0 {{sex, gender, =m {m}} }}'], 'm', 'd'], ]); + }); + }); - // one message for the block content and one message for the ICU - expect(extract('before{count, plural, =0 {text}}after')) - .toEqual([ - [['{count, plural, =0 {text}}'], '', ''], - [['before', '{count, plural, =0 {text}}', 'after'], 'm', 'd'], - ]); - }); + describe('attributes', () => { + it('should extract from attributes outside of translatable section', () => { + expect(extract('
')).toEqual([ + [['title="msg"'], 'm', 'd'], + ]); + }); - it('should not extract ICU messages outside of i18n sections', - () => { expect(extract('{count, plural, =0 {text}}')).toEqual([]); }); + it('should extract from attributes in translatable element', () => { + expect(extract('

')).toEqual([ + [['

'], '', ''], + [['title="msg"'], 'm', 'd'], + ]); + }); - it('should not extract nested ICU messages', () => { - expect(extract('
{count, plural, =0 { {sex, gender, =m {m}} }}
')) - .toEqual([ - [['{count, plural, =0 {{sex, gender, =m {m}} }}'], 'm', 'd'], - ]); - }); - }); - - describe('attributes', () => { - it('should extract from attributes outside of translatable section', () => { - expect(extract('
')).toEqual([ + it('should extract from attributes in translatable block', () => { + expect(extract('

')) + .toEqual([ [['title="msg"'], 'm', 'd'], - ]); - }); - - it('should extract from attributes in translatable element', () => { - expect(extract('

')).toEqual([ [['

'], '', ''], + ]); + }); + + it('should extract from attributes in translatable ICU', () => { + expect( + extract( + '{count, plural, =0 {

}}')) + .toEqual([ + [['title="msg"'], 'm', 'd'], + [['{count, plural, =0 {

}}'], '', ''], + ]); + }); + + it('should extract from attributes in non translatable ICU', () => { + expect(extract('{count, plural, =0 {

}}')) + .toEqual([ [['title="msg"'], 'm', 'd'], ]); - }); + }); - it('should extract from attributes in translatable block', () => { - expect( - extract('

')) - .toEqual([ - [['title="msg"'], 'm', 'd'], - [['

'], '', ''], - ]); - }); + it('should not create a message for empty attributes', + () => { expect(extract('
')).toEqual([]); }); + }); - it('should extract from attributes in translatable ICU', () => { - expect( - extract( - '{count, plural, =0 {

}}')) - .toEqual([ - [['title="msg"'], 'm', 'd'], - [['{count, plural, =0 {

}}'], '', ''], - ]); - }); + describe('implicit elements', () => { + it('should extract from implicit elements', () => { + expect(extract('bolditalic', ['b'])).toEqual([ + [['bold'], '', ''], + ]); + }); + }); - it('should extract from attributes in non translatable ICU', () => { - expect(extract('{count, plural, =0 {

}}')) - .toEqual([ - [['title="msg"'], 'm', 'd'], - ]); - }); - - it('should not create a message for empty attributes', - () => { expect(extract('
')).toEqual([]); }); - }); - - describe('implicit elements', () => { - it('should extract from implicit elements', () => { - expect(extract('bolditalic', ['b'])).toEqual([ - [['bold'], '', ''], + describe('implicit attributes', () => { + it('should extract implicit attributes', () => { + expect(extract('bolditalic', [], {'b': ['title']})) + .toEqual([ + [['title="bb"'], '', ''], ]); - }); + }); + }); + + describe('errors', () => { + describe('elements', () => { + it('should report nested translatable elements', () => { + expect(extractErrors(`

`)).toEqual([ + ['Could not mark an element as translatable inside a translatable section', ''], + ]); }); - describe('implicit attributes', () => { - it('should extract implicit attributes', () => { - expect(extract('bolditalic', [], {'b': ['title']})) - .toEqual([ - [['title="bb"'], '', ''], - ]); - }); + it('should report translatable elements in implicit elements', () => { + expect(extractErrors(`

`, ['p'])).toEqual([ + ['Could not mark an element as translatable inside a translatable section', ''], + ]); }); - describe('errors', () => { - describe('elements', () => { - it('should report nested translatable elements', () => { - expect(extractErrors(`

`)).toEqual([ - [ - 'Could not mark an element as translatable inside a translatable section', - '' - ], - ]); - }); - - it('should report translatable elements in implicit elements', () => { - expect(extractErrors(`

`, ['p'])).toEqual([ - [ - 'Could not mark an element as translatable inside a translatable section', - '' - ], - ]); - }); - - it('should report translatable elements in translatable blocks', () => { - expect(extractErrors(``)).toEqual([ - [ - 'Could not mark an element as translatable inside a translatable section', - '' - ], - ]); - }); - }); - - describe('blocks', () => { - it('should report nested blocks', () => { - expect(extractErrors(``)) - .toEqual([ - ['Could not start a block inside a translatable section', '`)).toEqual([ - ['Unclosed block', '

`)).toEqual([ - ['Could not start a block inside a translatable section', '

`, ['p'])).toEqual([ - ['Could not start a block inside a translatable section', '

`)).toEqual([ - ['I18N blocks should not cross element boundaries', '

`)).toEqual([ - ['I18N blocks should not cross element boundaries', '`, ['b'])).toEqual([ - ['Could not mark an element as translatable inside a translatable section', ''], - ]); - }); - }); + it('should report translatable elements in translatable blocks', () => { + expect(extractErrors(``)).toEqual([ + ['Could not mark an element as translatable inside a translatable section', ''], + ]); }); }); + + describe('blocks', () => { + it('should report nested blocks', () => { + expect(extractErrors(``)).toEqual([ + ['Could not start a block inside a translatable section', '`)).toEqual([ + ['Unclosed block', '

`)).toEqual([ + ['Could not start a block inside a translatable section', '

`, ['p'])).toEqual([ + ['Could not start a block inside a translatable section', '

`)).toEqual([ + ['I18N blocks should not cross element boundaries', '

`)).toEqual([ + ['I18N blocks should not cross element boundaries', '`, ['b'])).toEqual([ + ['Could not mark an element as translatable inside a translatable section', ''], + ]); + }); + }); + }); + }); } function getExtractionResult( - html: string, implicitTags: string[], implicitAttrs: - {[k: string]: string[]}): ExtractionResult { + html: string, implicitTags: string[], + implicitAttrs: {[k: string]: string[]}): ExtractionResult { const htmlParser = new HtmlParser(); const parseResult = htmlParser.parse(html, 'extractor spec', true); if (parseResult.errors.length > 1) { @@ -275,8 +262,8 @@ function getExtractionResult( } function extract( - html: string, implicitTags: string[] = [], implicitAttrs: - {[k: string]: string[]} = {}): [string[], string, string][] { + html: string, implicitTags: string[] = [], + implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] { const messages = getExtractionResult(html, implicitTags, implicitAttrs).messages; // clang-format off @@ -287,8 +274,7 @@ function extract( } function extractErrors( - html: string, implicitTags: string[] = [], implicitAttrs: - {[k: string]: string[]} = {}): any[] { + html: string, implicitTags: string[] = [], implicitAttrs: {[k: string]: string[]} = {}): any[] { const errors = getExtractionResult(html, implicitTags, implicitAttrs).errors; return errors.map((e): [string, string] => [e.msg, e.span.toString()]); diff --git a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts deleted file mode 100644 index 8646bcbb87..0000000000 --- a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * @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 {Lexer as ExpressionLexer} from '@angular/compiler/src/expression_parser/lexer'; -import {Parser as ExpressionParser} from '@angular/compiler/src/expression_parser/parser'; -import {I18nHtmlParser} from '@angular/compiler/src/i18n/i18n_html_parser'; -import {Message, id} from '@angular/compiler/src/i18n/message'; -import {deserializeXmb} from '@angular/compiler/src/i18n/xmb_serializer'; -import {ParseError} from '@angular/compiler/src/parse_util'; -import {ddescribe, describe, expect, iit, it} from '@angular/core/testing/testing_internal'; - -import {StringMapWrapper} from '../../src/facade/collection'; -import {HtmlAttrAst, HtmlElementAst, HtmlTextAst} from '../../src/html_parser/html_ast'; -import {HtmlParseTreeResult, HtmlParser} from '../../src/html_parser/html_parser'; -import {InterpolationConfig} from '../../src/html_parser/interpolation_config'; -import {humanizeDom} from '../html_parser/html_ast_spec_utils'; - -export function main() { - describe('I18nHtmlParser', () => { - function parse( - template: string, messages: {[key: string]: string}, implicitTags: string[] = [], - implicitAttrs: {[k: string]: string[]} = {}, - interpolation?: InterpolationConfig): HtmlParseTreeResult { - let htmlParser = new HtmlParser(); - - let msgs = ''; - StringMapWrapper.forEach( - messages, (v: string, k: string) => msgs += `${v}`); - let res = deserializeXmb(`${msgs}`, 'someUrl'); - - const expParser = new ExpressionParser(new ExpressionLexer()); - - return new I18nHtmlParser( - htmlParser, expParser, res.content, res.messages, implicitTags, implicitAttrs) - .parse(template, 'someurl', true, interpolation); - } - - it('should delegate to the provided parser when no i18n', () => { - expect(humanizeDom(parse('
a
', {}))).toEqual([ - [HtmlElementAst, 'div', 0], [HtmlTextAst, 'a', 1] - ]); - }); - - describe('interpolation', () => { - it('should handle interpolation', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message( - ' and ', null, null))] = - ' or '; - - expect(humanizeDom(parse('
', translations))) - .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', '{{b}} or {{a}}']]); - }); - - it('should handle interpolation with config', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message( - ' and ', null, null))] = - ' or '; - - expect(humanizeDom(parse( - '
', translations, [], {}, - InterpolationConfig.fromArray(['{%', '%}'])))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlAttrAst, 'value', '{%b%} or {%a%}'], - ]); - }); - - it('should handle interpolation with custom placeholder names', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message(' and ', null, null))] = - ' or '; - - expect( - humanizeDom(parse( - `
`, - translations))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlAttrAst, 'value', '{{b //i18n(ph="SECOND")}} or {{a //i18n(ph="FIRST")}}'] - ]); - }); - - it('should handle interpolation with duplicate placeholder names', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message(' and ', null, null))] = - ' or '; - - expect( - humanizeDom(parse( - `
`, - translations))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlAttrAst, 'value', '{{b //i18n(ph="FIRST")}} or {{a //i18n(ph="FIRST")}}'] - ]); - }); - - it('should support interpolation', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message( - 'ab', - null, null))] = - 'BA'; - expect(humanizeDom(parse('
ab{{i}}
', translations))).toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlElementAst, 'b', 1], - [HtmlTextAst, '{{i}}B', 2], - [HtmlElementAst, 'a', 1], - [HtmlTextAst, 'A', 2], - ]); - }); - }); - - describe('html', () => { - it('should handle nested html', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('ab', null, null))] = - 'BA'; - - expect(humanizeDom(parse('
ab
', translations))).toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlElementAst, 'b', 1], - [HtmlTextAst, 'B', 2], - [HtmlElementAst, 'a', 1], - [HtmlTextAst, 'A', 2], - ]); - }); - - it('should i18n attributes of placeholder elements', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('a', null, null))] = 'A'; - translations[id(new Message('b', null, null))] = 'B'; - - expect(humanizeDom(parse('', translations))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlElementAst, 'a', 1], - [HtmlAttrAst, 'value', 'B'], - [HtmlTextAst, 'A', 2], - ]); - }); - - it('should preserve non-i18n attributes', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('message', null, null))] = 'another message'; - - expect(humanizeDom(parse('
message
', translations))).toEqual([ - [HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', 'b'], - [HtmlTextAst, 'another message', 1] - ]); - }); - - it('should replace attributes', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('some message', 'meaning', null))] = 'another message'; - - expect( - humanizeDom(parse( - '
', translations))) - .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', 'another message']]); - }); - - it('should replace elements with the i18n attr', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('message', 'meaning', null))] = 'another message'; - - expect(humanizeDom(parse('
message
', translations))) - .toEqual([[HtmlElementAst, 'div', 0], [HtmlTextAst, 'another message', 1]]); - }); - }); - - it('should extract from partitions', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('message1', 'meaning1', null))] = 'another message1'; - translations[id(new Message('message2', 'meaning2', null))] = 'another message2'; - - let res = parse( - `message1message2`, - translations); - - expect(humanizeDom(res)).toEqual([ - [HtmlTextAst, 'another message1', 0], - [HtmlTextAst, 'another message2', 0], - ]); - }); - - it('should preserve original positions', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('ab', null, null))] = - 'BA'; - - let res = - (parse('
ab
', translations).rootNodes[0]).children; - - expect(res[0].sourceSpan.start.offset).toEqual(18); - expect(res[1].sourceSpan.start.offset).toEqual(10); - }); - - describe('errors', () => { - it('should error when giving an invalid template', () => { - expect(humanizeErrors(parse('a
', {}).errors)).toEqual([ - 'Unexpected closing tag "b"' - ]); - }); - - it('should error when no matching message (attr)', () => { - let mid = id(new Message('some message', null, null)); - expect(humanizeErrors(parse('
', {}).errors)) - .toEqual([`Cannot find message for id '${mid}', content 'some message'.`]); - }); - - it('should error when no matching message (text)', () => { - let mid = id(new Message('some message', null, null)); - expect(humanizeErrors(parse('
some message
', {}).errors)).toEqual([ - `Cannot find message for id '${mid}', content 'some message'.` - ]); - }); - - it('should error when a non-placeholder element appears in translation', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('some message', null, null))] = '
a'; - - expect(humanizeErrors(parse('
some message
', translations).errors)).toEqual([ - `Unexpected tag "a". Only "ph" tags are allowed.` - ]); - }); - - it('should error when a placeholder element does not have the name attribute', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('some message', null, null))] = 'a'; - - expect(humanizeErrors(parse('
some message
', translations).errors)).toEqual([ - `Missing "name" attribute.` - ]); - }); - - it('should error when the translation refers to an invalid expression', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('hi ', null, null))] = - 'hi '; - - expect( - humanizeErrors(parse('
', translations).errors)) - .toEqual(['Invalid interpolation name \'INTERPOLATION_99\'']); - }); - }); - - describe('implicit translation', () => { - it('should support attributes', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('some message', null, null))] = 'another message'; - - expect(humanizeDom(parse('', translations, [], { - 'i18n-el': ['value'] - }))).toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlAttrAst, 'value', 'another message']]); - }); - - it('should support attributes with meaning and description', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('some message', 'meaning', 'description'))] = 'another message'; - - expect(humanizeDom(parse( - '', - translations, [], {'i18n-el': ['value']}))) - .toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlAttrAst, 'value', 'another message']]); - }); - - it('should support elements', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('message', null, null))] = 'another message'; - - expect(humanizeDom(parse('message', translations, ['i18n-el']))) - .toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlTextAst, 'another message', 1]]); - }); - - it('should support elements with meaning and description', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('message', 'meaning', 'description'))] = 'another message'; - - expect(humanizeDom(parse( - 'message', translations, - ['i18n-el']))) - .toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlTextAst, 'another message', 1]]); - }); - }); - }); -} - -function humanizeErrors(errors: ParseError[]): string[] { - return errors.map(error => error.msg); -} diff --git a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts index 13c658a5bf..18171c7bd9 100644 --- a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts +++ b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts @@ -6,25 +6,26 @@ * found in the LICENSE file at https://angular.io/license */ -import {serializeAst} from '@angular/compiler/src/i18n/catalog'; +import {Message} from '@angular/compiler/src/i18n/i18n_ast'; import {extractI18nMessages} from '@angular/compiler/src/i18n/i18n_parser'; import {ddescribe, describe, expect, it} from '@angular/core/testing/testing_internal'; import {HtmlParser} from '../../src/html_parser/html_parser'; import {DEFAULT_INTERPOLATION_CONFIG} from '../../src/html_parser/interpolation_config'; +import {serializeAst} from '../../src/i18n/message_bundle'; export function main() { - ddescribe('I18nParser', () => { + describe('I18nParser', () => { describe('elements', () => { it('should extract from elements', () => { - expect(extract('
text
')).toEqual([ + expect(_humanizeMessages('
text
')).toEqual([ [['text'], 'm', 'd'], ]); }); it('should extract from nested elements', () => { - expect(extract('
textnested
')).toEqual([ + expect(_humanizeMessages('
textnested
')).toEqual([ [ [ 'text', @@ -36,13 +37,13 @@ export function main() { }); it('should not create a message for empty elements', - () => { expect(extract('
')).toEqual([]); }); + () => { expect(_humanizeMessages('
')).toEqual([]); }); it('should not create a message for plain elements', - () => { expect(extract('
')).toEqual([]); }); + () => { expect(_humanizeMessages('
')).toEqual([]); }); it('should suppoprt void elements', () => { - expect(extract('


')).toEqual([ + expect(_humanizeMessages('


')).toEqual([ [ [ '' @@ -55,25 +56,27 @@ export function main() { describe('attributes', () => { it('should extract from attributes outside of translatable section', () => { - expect(extract('
')).toEqual([ + expect(_humanizeMessages('
')).toEqual([ [['msg'], 'm', 'd'], ]); }); it('should extract from attributes in translatable element', () => { - expect(extract('

')).toEqual([ - [ - [ - '' - ], - '', '' - ], - [['msg'], 'm', 'd'], - ]); + expect(_humanizeMessages('

')) + .toEqual([ + [ + [ + '' + ], + '', '' + ], + [['msg'], 'm', 'd'], + ]); }); it('should extract from attributes in translatable block', () => { - expect(extract('

')) + expect(_humanizeMessages( + '

')) .toEqual([ [['msg'], 'm', 'd'], [ @@ -87,7 +90,7 @@ export function main() { it('should extract from attributes in translatable ICU', () => { expect( - extract( + _humanizeMessages( '{count, plural, =0 {

}}')) .toEqual([ [['msg'], 'm', 'd'], @@ -101,33 +104,35 @@ export function main() { }); it('should extract from attributes in non translatable ICU', () => { - expect(extract('{count, plural, =0 {

}}')) + expect( + _humanizeMessages('{count, plural, =0 {

}}')) .toEqual([ [['msg'], 'm', 'd'], ]); }); it('should not create a message for empty attributes', - () => { expect(extract('
')).toEqual([]); }); + () => { expect(_humanizeMessages('
')).toEqual([]); }); }); describe('interpolation', () => { it('should replace interpolation with placeholder', () => { - expect(extract('
before{{ exp }}after
')).toEqual([ + expect(_humanizeMessages('
before{{ exp }}after
')).toEqual([ [['[before, exp , after]'], 'm', 'd'], ]); }); it('should support named interpolation', () => { - expect(extract('
before{{ exp //i18n(ph="teSt") }}after
')).toEqual([ - [['[before, exp //i18n(ph="teSt") , after]'], 'm', 'd'], - ]); - }) + expect(_humanizeMessages('
before{{ exp //i18n(ph="teSt") }}after
')) + .toEqual([ + [['[before, exp //i18n(ph="teSt") , after]'], 'm', 'd'], + ]); + }); }); describe('blocks', () => { it('should extract from blocks', () => { - expect(extract(`message1 + expect(_humanizeMessages(`message1 message2 message3`)) .toEqual([ @@ -138,7 +143,7 @@ export function main() { }); it('should extract all siblings', () => { - expect(extract(`text

htmlnested

`)).toEqual([ + expect(_humanizeMessages(`text

htmlnested

`)).toEqual([ [ [ 'text', @@ -152,33 +157,36 @@ export function main() { describe('ICU messages', () => { it('should extract as ICU when single child of an element', () => { - expect(extract('
{count, plural, =0 {zero}}
')).toEqual([ + expect(_humanizeMessages('
{count, plural, =0 {zero}}
')).toEqual([ [['{count, plural, =0 {[zero]}}'], 'm', 'd'], ]); }); it('should extract as ICU + ph when not single child of an element', () => { - expect(extract('
b{count, plural, =0 {zero}}a
')).toEqual([ + expect(_humanizeMessages('
b{count, plural, =0 {zero}}a
')).toEqual([ [['b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'], [['{count, plural, =0 {[zero]}}'], '', ''], ]); }); it('should extract as ICU when single child of a block', () => { - expect(extract('{count, plural, =0 {zero}}')).toEqual([ - [['{count, plural, =0 {[zero]}}'], 'm', 'd'], - ]); + expect(_humanizeMessages('{count, plural, =0 {zero}}')) + .toEqual([ + [['{count, plural, =0 {[zero]}}'], 'm', 'd'], + ]); }); it('should extract as ICU + ph when not single child of a block', () => { - expect(extract('b{count, plural, =0 {zero}}a')).toEqual([ - [['{count, plural, =0 {[zero]}}'], '', ''], - [['b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'], - ]); + expect(_humanizeMessages('b{count, plural, =0 {zero}}a')) + .toEqual([ + [['{count, plural, =0 {[zero]}}'], '', ''], + [['b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'], + ]); }); it('should not extract nested ICU messages', () => { - expect(extract('
b{count, plural, =0 {{sex, gender, =m {m}}}}a
')) + expect(_humanizeMessages( + '
b{count, plural, =0 {{sex, gender, =m {m}}}}a
')) .toEqual([ [ [ @@ -194,7 +202,7 @@ export function main() { describe('implicit elements', () => { it('should extract from implicit elements', () => { - expect(extract('bolditalic', ['b'])).toEqual([ + expect(_humanizeMessages('bolditalic', ['b'])).toEqual([ [['bold'], '', ''], ]); }); @@ -202,7 +210,8 @@ export function main() { describe('implicit attributes', () => { it('should extract implicit attributes', () => { - expect(extract('bolditalic', [], {'b': ['title']})) + expect(_humanizeMessages( + 'bolditalic', [], {'b': ['title']})) .toEqual([ [['bb'], '', ''], ]); @@ -211,7 +220,8 @@ export function main() { describe('placeholders', () => { it('should reuse the same placeholder name for tags', () => { - expect(extract('

one

two

three

')).toEqual([ + const html = '

one

two

three

'; + expect(_humanizeMessages(html)).toEqual([ [ [ 'one', @@ -221,10 +231,16 @@ export function main() { 'm', 'd' ], ]); + + expect(_humanizePlaceholders(html)).toEqual([ + 'START_PARAGRAPH=

, CLOSE_PARAGRAPH=

, START_PARAGRAPH_1=

', + ]); + }); it('should reuse the same placeholder name for interpolations', () => { - expect(extract('

{{ a }}{{ a }}{{ b }}
')).toEqual([ + const html = '
{{ a }}{{ a }}{{ b }}
'; + expect(_humanizeMessages(html)).toEqual([ [ [ '[ a , a , b ]' @@ -232,46 +248,70 @@ export function main() { 'm', 'd' ], ]); + + expect(_humanizePlaceholders(html)).toEqual([ + 'INTERPOLATION={{ a }}, INTERPOLATION_1={{ b }}', + ]); }); it('should reuse the same placeholder name for icu messages', () => { - expect( - extract( - '
{count, plural, =0 {0}}{count, plural, =0 {0}}{count, plural, =1 {1}}
')) - .toEqual([ - [ - [ - '{count, plural, =0 {[0]}}', - '{count, plural, =0 {[0]}}', - '{count, plural, =1 {[1]}}', - ], - 'm', 'd' - ], - [['{count, plural, =0 {[0]}}'], '', ''], - [['{count, plural, =0 {[0]}}'], '', ''], - [['{count, plural, =1 {[1]}}'], '', ''], - ]); - }); + const html = + '
{count, plural, =0 {0}}{count, plural, =0 {0}}{count, plural, =1 {1}}
'; + expect(_humanizeMessages(html)).toEqual([ + [ + [ + '{count, plural, =0 {[0]}}', + '{count, plural, =0 {[0]}}', + '{count, plural, =1 {[1]}}', + ], + 'm', 'd' + ], + [['{count, plural, =0 {[0]}}'], '', ''], + [['{count, plural, =0 {[0]}}'], '', ''], + [['{count, plural, =1 {[1]}}'], '', ''], + ]); + + expect(_humanizePlaceholders(html)).toEqual([ + 'ICU={count, plural, =0 {0}}, ICU_1={count, plural, =1 {1}}', + '', + '', + '', + ]); + }); }); }); } -function extract( +function _humanizeMessages( html: string, implicitTags: string[] = [], implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] { + // clang-format off + // https://github.com/angular/clang-format/issues/35 + return _extractMessages(html, implicitTags, implicitAttrs).map( + message => [serializeAst(message.nodes), message.meaning, message.description, ]) as [string[], string, string][]; + // clang-format on +} + +function _humanizePlaceholders( + html: string, implicitTags: string[] = [], + implicitAttrs: {[k: string]: string[]} = {}): string[] { + // clang-format off + // https://github.com/angular/clang-format/issues/35 + return _extractMessages(html, implicitTags, implicitAttrs).map( + msg => Object.getOwnPropertyNames(msg.placeholders).map((name) => `${name}=${msg.placeholders[name]}`).join(', ')); + // clang-format on +} + +function _extractMessages( + html: string, implicitTags: string[] = [], + implicitAttrs: {[k: string]: string[]} = {}): Message[] { const htmlParser = new HtmlParser(); const parseResult = htmlParser.parse(html, 'extractor spec', true); if (parseResult.errors.length > 1) { throw Error(`unexpected parse errors: ${parseResult.errors.join('\n')}`); } - const messages = extractI18nMessages( + return extractI18nMessages( parseResult.rootNodes, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs); - - // clang-format off - // https://github.com/angular/clang-format/issues/35 - return messages.map( - message => [serializeAst(message.nodes), message.meaning, message.description, ]) as [string[], string, string][]; - // clang-format on } diff --git a/modules/@angular/compiler/test/i18n/catalog_spec.ts b/modules/@angular/compiler/test/i18n/message_bundle_spec.ts similarity index 72% rename from modules/@angular/compiler/test/i18n/catalog_spec.ts rename to modules/@angular/compiler/test/i18n/message_bundle_spec.ts index 4af9e7c390..d66bc1f4fe 100644 --- a/modules/@angular/compiler/test/i18n/catalog_spec.ts +++ b/modules/@angular/compiler/test/i18n/message_bundle_spec.ts @@ -6,49 +6,40 @@ * found in the LICENSE file at https://angular.io/license */ -import {Catalog, strHash} from '@angular/compiler/src/i18n/catalog'; +import * as i18n from '@angular/compiler/src/i18n/i18n_ast'; +import {Serializer} from '@angular/compiler/src/i18n/serializers/serializer'; import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; import {HtmlParser} from '../../src/html_parser/html_parser'; import {DEFAULT_INTERPOLATION_CONFIG} from '../../src/html_parser/interpolation_config'; - -import Serializable = webdriver.Serializable; -import {Serializer} from '@angular/compiler/src/i18n/serializers/serializer'; -import {serializeAst} from '@angular/compiler/src/i18n/catalog'; -import * as i18nAst from '@angular/compiler/src/i18n/i18n_ast'; +import {MessageBundle, serializeAst, strHash} from '../../src/i18n/message_bundle'; export function main(): void { - ddescribe('Catalog', () => { + describe('MessageBundle', () => { + describe('Messages', () => { + let messages: MessageBundle; - describe('write', () => { - let catalog: Catalog; - - beforeEach(() => { catalog = new Catalog(new HtmlParser, [], {}); }); + beforeEach(() => { messages = new MessageBundle(new HtmlParser, [], {}); }); it('should extract the message to the catalog', () => { - catalog.updateFromTemplate( + messages.updateFromTemplate( '

Translate Me

', 'url', DEFAULT_INTERPOLATION_CONFIG); - expect(humanizeCatalog(catalog)).toEqual([ + expect(humanizeMessages(messages)).toEqual([ 'a486901=Translate Me', ]); }); it('should extract the same message with different meaning in different entries', () => { - catalog.updateFromTemplate( + messages.updateFromTemplate( '

Translate Me

Translate Me

', 'url', DEFAULT_INTERPOLATION_CONFIG); - expect(humanizeCatalog(catalog)).toEqual([ + expect(humanizeMessages(messages)).toEqual([ 'a486901=Translate Me', '8475f2cc=Translate Me', ]); }); }); - describe( - 'load', () => { - // TODO - }); - describe('strHash', () => { it('should return a hash value', () => { // https://github.com/google/closure-library/blob/1fb19a857b96b74e6523f3e9d33080baf25be046/closure/goog/string/string_test.js#L1115 @@ -66,16 +57,16 @@ export function main(): void { } class _TestSerializer implements Serializer { - write(messageMap: {[k: string]: i18nAst.Message}): string { + write(messageMap: {[id: string]: i18n.Message}): string { return Object.keys(messageMap) .map(id => `${id}=${serializeAst(messageMap[id].nodes)}`) .join('//'); } - load(content: string): {[k: string]: i18nAst.Node[]} { return null; } + load(content: string, url: string, placeholders: {}): {} { return null; } } -function humanizeCatalog(catalog: Catalog): string[] { +function humanizeMessages(catalog: MessageBundle): string[] { return catalog.write(new _TestSerializer()).split('//'); } diff --git a/modules/@angular/compiler/test/i18n/message_extractor_spec.ts b/modules/@angular/compiler/test/i18n/message_extractor_spec.ts deleted file mode 100644 index ade2e93c4b..0000000000 --- a/modules/@angular/compiler/test/i18n/message_extractor_spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * @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 {Lexer as ExpressionLexer} from '@angular/compiler/src/expression_parser/lexer'; -import {Parser as ExpressionParser} from '@angular/compiler/src/expression_parser/parser'; -import {Message} from '@angular/compiler/src/i18n/message'; -import {MessageExtractor, removeDuplicates} from '@angular/compiler/src/i18n/message_extractor'; -import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; - -import {HtmlParser} from '../../src/html_parser/html_parser'; - - -export function main() { - describe('MessageExtractor', () => { - let extractor: MessageExtractor; - - beforeEach(() => { - const expParser = new ExpressionParser(new ExpressionLexer()); - const htmlParser = new HtmlParser(); - extractor = new MessageExtractor(htmlParser, expParser, ['i18n-tag'], {'i18n-el': ['trans']}); - }); - - it('should extract from partitions', () => { - let res = extractor.extract( - ` - message1 - message2 - message3`, - 'someUrl'); - - expect(res.messages).toEqual([ - new Message('message1', 'meaning1', 'desc1'), - new Message('message2', 'meaning2'), - new Message('message3', null), - ]); - }); - - it('should ignore other comments', () => { - let res = extractor.extract( - ` - message1`, - 'someUrl'); - - expect(res.messages).toEqual([new Message('message1', 'meaning1', 'desc1')]); - }); - - describe('ICU messages', () => { - it('should replace icu messages with placeholders', () => { - let res = extractor.extract('
{count, plural, =0 {text} }
', 'someurl'); - expect(res.messages).toEqual([new Message( - '{count, plural =0 {text}}', null, null)]); - }); - - it('should replace HTML with placeholders in ICU cases', () => { - let res = - extractor.extract('
{count, plural, =0 {

html

} }
', 'someurl'); - expect(res.messages).toEqual([new Message( - '{count, plural =0 {html}}', null, null)]); - }); - - it('should replace interpolation with placeholders in ICU cases', () => { - let res = - extractor.extract('
{count, plural, =0 {{{interpolation}}}}
', 'someurl'); - expect(res.messages).toEqual([new Message( - '{count, plural =0 {}}', - null, null)]); - }); - - it('should not replace nested interpolation with placeholders in ICU cases', () => { - let res = extractor.extract( - '
{count, plural, =0 {{sex, gender, =m {{{he}}} =f {she}}}}
', - 'someurl'); - expect(res.messages).toEqual([new Message( - '{count, plural =0 {{sex, gender =m {} =f {she}}}}', - null, null)]); - }); - }); - - describe('interpolation', () => { - it('should replace interpolation with placeholders (text nodes)', () => { - let res = extractor.extract('
Hi {{one}} and {{two}}
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', - null, null)]); - }); - - it('should replace interpolation with placeholders (attributes)', () => { - let res = - extractor.extract('
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); - }); - - it('should replace interpolation with named placeholders if provided (text nodes)', () => { - let res = extractor.extract( - ` -
Hi {{one //i18n(ph="FIRST")}} and {{two //i18n(ph="SECOND")}}
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); - }); - - it('should replace interpolation with named placeholders if provided (attributes)', () => { - let res = extractor.extract( - ` -
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); - }); - }); - - describe('placehoders', () => { - it('should match named placeholders with extra spacing', () => { - let res = extractor.extract( - ` -
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); - }); - - it('should suffix duplicate placeholder names with numbers', () => { - let res = extractor.extract( - ` -
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and and ', null, - null)]); - }); - }); - - describe('html', () => { - it('should extract from elements with the i18n attr', () => { - let res = extractor.extract('
message
', 'someurl'); - expect(res.messages).toEqual([new Message('message', 'meaning', 'desc')]); - }); - - it('should extract from elements with the i18n attr without a desc', () => { - let res = extractor.extract('
message
', 'someurl'); - expect(res.messages).toEqual([new Message('message', 'meaning', null)]); - }); - - it('should extract from elements with the i18n attr without a meaning', () => { - let res = extractor.extract('
message
', 'someurl'); - expect(res.messages).toEqual([new Message('message', null, null)]); - }); - - it('should extract from attributes', () => { - let res = extractor.extract( - ` -
-
- `, - 'someurl'); - - expect(res.messages).toEqual([ - new Message('message1', 'meaning1', 'desc1'), new Message('message2', 'meaning2', 'desc2') - ]); - }); - - it('should handle html content', () => { - let res = extractor.extract( - '
zero
one
two
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'zeroonetwo', null, null)]); - }); - - it('should handle html content with interpolation', () => { - let res = - extractor.extract('
zero{{a}}
{{b}}
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'zero', - null, null)]); - }); - - it('should extract from nested elements', () => { - let res = extractor.extract( - '
message2
', - 'someurl'); - expect(res.messages).toEqual([ - new Message('message2', 'meaning2', 'desc2'), new Message('message1', 'meaning1', 'desc1') - ]); - }); - - it('should extract messages from attributes in i18n blocks', () => { - let res = extractor.extract( - '
message
', 'someurl'); - expect(res.messages).toEqual([ - new Message('message', null, null), - new Message('value', 'meaning', 'desc') - ]); - }); - }); - - it('should remove duplicate messages', () => { - let res = extractor.extract( - ` - message - message`, - 'someUrl'); - - expect(removeDuplicates(res.messages)).toEqual([ - new Message('message', 'meaning', 'desc1'), - ]); - }); - - describe('implicit translation', () => { - it('should extract from elements', () => { - let res = extractor.extract('message', 'someurl'); - expect(res.messages).toEqual([new Message('message', null, null)]); - }); - - it('should extract meaning and description from elements when present', () => { - let res = extractor.extract( - 'message', 'someurl'); - expect(res.messages).toEqual([new Message('message', 'meaning', 'description')]); - }); - - it('should extract from attributes', () => { - let res = extractor.extract(``, 'someurl'); - expect(res.messages).toEqual([new Message('message', null, null)]); - }); - - it('should extract meaning and description from attributes when present', () => { - let res = extractor.extract( - ``, 'someurl'); - expect(res.messages).toEqual([new Message('message', 'meaning', 'desc')]); - }); - }); - - describe('errors', () => { - it('should error on i18n attributes without matching "real" attributes', () => { - let res = extractor.extract( - ` -
-
`, - 'someurl'); - - expect(res.errors.length).toEqual(1); - expect(res.errors[0].msg).toEqual('Missing attribute \'title2\'.'); - }); - - it('should error when i18n comments are unbalanced', () => { - const res = extractor.extract('message1', 'someUrl'); - expect(res.errors.length).toEqual(1); - expect(res.errors[0].msg).toEqual('Missing closing \'i18n\' comment.'); - }); - - it('should error when i18n comments are unbalanced', () => { - const res = extractor.extract('', 'someUrl'); - expect(res.errors.length).toEqual(1); - expect(res.errors[0].msg).toEqual('Missing closing \'i18n\' comment.'); - }); - - it('should return parse errors when the template is invalid', () => { - let res = extractor.extract(' { - describe('id', () => { - it('should return a different id for messages with and without the meaning', () => { - let m1 = new Message('content', 'meaning', null); - let m2 = new Message('content', null, null); - expect(id(m1)).toEqual(id(m1)); - expect(id(m1)).not.toEqual(id(m2)); - }); - }); - }); -} diff --git a/modules/@angular/compiler/test/i18n/serializers/util_spec.ts b/modules/@angular/compiler/test/i18n/serializers/placeholder_spec.ts similarity index 96% rename from modules/@angular/compiler/test/i18n/serializers/util_spec.ts rename to modules/@angular/compiler/test/i18n/serializers/placeholder_spec.ts index b539671c40..3d24f7a3d7 100644 --- a/modules/@angular/compiler/test/i18n/serializers/util_spec.ts +++ b/modules/@angular/compiler/test/i18n/serializers/placeholder_spec.ts @@ -6,14 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ - - import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; -import {PlaceholderRegistry} from '../../../src/i18n/serializers/util'; +import {PlaceholderRegistry} from '../../../src/i18n/serializers/placeholder'; export function main(): void { - ddescribe('PlaceholderRegistry', () => { + describe('PlaceholderRegistry', () => { let reg: PlaceholderRegistry; beforeEach(() => { reg = new PlaceholderRegistry(); }); @@ -34,11 +32,11 @@ export function main(): void { expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH'); }); - it('should be case insensitive for tag name', () => { + it('should be case sensitive for tag name', () => { expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH'); - expect(reg.getStartTagPlaceholderName('P', {}, false)).toEqual('START_PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('P', {}, false)).toEqual('START_PARAGRAPH_1'); expect(reg.getCloseTagPlaceholderName('p')).toEqual('CLOSE_PARAGRAPH'); - expect(reg.getCloseTagPlaceholderName('P')).toEqual('CLOSE_PARAGRAPH'); + expect(reg.getCloseTagPlaceholderName('P')).toEqual('CLOSE_PARAGRAPH_1'); }); it('should generate the same name for the same tag with the same attributes', () => { diff --git a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts index d9108e7892..fbfb4a36a5 100644 --- a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts +++ b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts @@ -6,16 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {Catalog} from '@angular/compiler/src/i18n/catalog'; -import {XmbSerializer} from '@angular/compiler/src/i18n/serializers/xmb'; +import {Xmb} from '@angular/compiler/src/i18n/serializers/xmb'; import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; import {HtmlParser} from '../../../src/html_parser/html_parser'; import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/html_parser/interpolation_config'; - +import {MessageBundle} from '../../../src/i18n/message_bundle'; export function main(): void { - ddescribe('XMB serializer', () => { + describe('XMB serializer', () => { const HTML = `

not translatable

translatable element with placeholders {{ interpolation}}

@@ -35,35 +34,18 @@ export function main(): void { it('should throw when trying to load an xmb file', () => { expect(() => { - const serializer = new XmbSerializer(); - serializer.load(XMB); + const serializer = new Xmb(); + serializer.load(XMB, 'url', {}); }).toThrow(); }); }); } function toXmb(html: string): string { - let catalog = new Catalog(new HtmlParser, [], {}); - const serializer = new XmbSerializer(); + let catalog = new MessageBundle(new HtmlParser, [], {}); + const serializer = new Xmb(); catalog.updateFromTemplate(html, '', DEFAULT_INTERPOLATION_CONFIG); return catalog.write(serializer); -} - -// translatable -// element <b>with placeholders</b> { count, plural, =0 {<p>test</p>}}foo{ count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }} -// translatable -// element <b>with placeholders</b> { count, plural, =0 {<p>test</p>}}{ count, -// plural, =0 {{ sex, gender, other {<p>deeply -// nested</p>}} }}foo \ No newline at end of file +} \ No newline at end of file diff --git a/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts index 1edc8d3f44..49b4e14917 100644 --- a/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts +++ b/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts @@ -11,7 +11,7 @@ import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit import * as xml from '../../../src/i18n/serializers/xml_helper'; export function main(): void { - ddescribe('XML helper', () => { + describe('XML helper', () => { it('should serialize XML declaration', () => { expect(xml.serialize([new xml.Declaration({version: '1.0'})])) .toEqual(''); diff --git a/modules/@angular/compiler/test/i18n/serializers/xtb_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xtb_spec.ts new file mode 100644 index 0000000000..434d48692b --- /dev/null +++ b/modules/@angular/compiler/test/i18n/serializers/xtb_spec.ts @@ -0,0 +1,171 @@ +/** + * @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 {Xtb} from '@angular/compiler/src/i18n/serializers/xtb'; +import {escapeRegExp} from '@angular/core/src/facade/lang'; +import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; + +import {HtmlParser} from '../../../src/html_parser/html_parser'; +import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/html_parser/interpolation_config'; +import {serializeAst} from '../../html_parser/ast_serializer_spec'; + +export function main(): void { + describe('XTB serializer', () => { + let serializer: Xtb; + + function loadAsText(content: string, placeholders: {[id: string]: {[name: string]: string}}): + {[id: string]: string} { + const asAst = serializer.load(content, 'url', placeholders); + let asText: {[id: string]: string} = {}; + Object.getOwnPropertyNames(asAst).forEach( + id => { asText[id] = serializeAst(asAst[id]).join(''); }); + + return asText; + } + + beforeEach(() => { serializer = new Xtb(new HtmlParser(), DEFAULT_INTERPOLATION_CONFIG); }); + + + describe('load', () => { + it('should load XTB files without placeholders', () => { + const XTB = ` + + + bar +`; + + expect(loadAsText(XTB, {})).toEqual({foo: 'bar'}); + }); + + it('should load XTB files with placeholders', () => { + const XTB = ` + + + bar +`; + + expect(loadAsText(XTB, {foo: {PLACEHOLDER: '!'}})).toEqual({foo: 'bar!!'}); + }); + + it('should load complex XTB files', () => { + const XTB = ` + + + translatable element <b>with placeholders</b> + { count, plural, =0 {<p>test</p>}} + foo + { count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }} +`; + + const PLACEHOLDERS = { + a: { + START_BOLD_TEXT: '', + CLOSE_BOLD_TEXT: '', + INTERPOLATION: '{{ a + b }}', + }, + b: { + START_PARAGRAPH: '

', + CLOSE_PARAGRAPH: '

', + }, + d: { + START_PARAGRAPH: '

', + CLOSE_PARAGRAPH: '

', + }, + }; + + expect(loadAsText(XTB, PLACEHOLDERS)).toEqual({ + a: 'translatable element with placeholders {{ a + b }}', + b: '{ count, plural, =0 {

test

}}', + c: 'foo', + d: '{ count, plural, =0 {{ sex, gender, other {

deeply nested

}} }}', + }); + }); + }); + + describe('errors', () => { + it('should throw on nested ', () => { + const XTB = + ''; + + expect(() => { + serializer.load(XTB, 'url', {}); + }).toThrowError(/ elements can not be nested/); + }); + + it('should throw on nested ', () => { + const XTB = ` + + + + + +`; + + expect(() => { + serializer.load(XTB, 'url', {}); + }).toThrowError(/ elements can not be nested/); + }); + + it('should throw when a has no id attribute', () => { + const XTB = ` + + +`; + + expect(() => { + serializer.load(XTB, 'url', {}); + }).toThrowError(/ misses the "id" attribute/); + }); + + it('should throw when a placeholder has no name attribute', () => { + const XTB = ` + + +`; + + expect(() => { + serializer.load(XTB, 'url', {}); + }).toThrowError(/ misses the "name" attribute/); + }); + + it('should throw when a placeholder is not present in the source message', () => { + const XTB = ` + + +`; + + expect(() => { + serializer.load(XTB, 'url', {}); + }).toThrowError(/The placeholder "UNKNOWN" does not exists in the source message/); + }); + }); + + it('should throw when the translation results in invalid html', () => { + const XTB = ` + + foobar +`; + + expect(() => { + serializer.load(XTB, 'url', {fail: {CLOSE_P: '

'}}); + }).toThrowError(/xtb parse errors:\nUnexpected closing tag "p"/); + + }); + + it('should throw on unknown tags', () => { + const XTB = ``; + + expect(() => { + serializer.load(XTB, 'url', {}); + }).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]")`))); + }); + + it('should throw when trying to save an xmb file', + () => { expect(() => { serializer.write({}); }).toThrow(); }); + }); +} \ No newline at end of file diff --git a/modules/@angular/compiler/test/i18n/xmb_serializer_spec.ts b/modules/@angular/compiler/test/i18n/xmb_serializer_spec.ts deleted file mode 100644 index 29d91e57c8..0000000000 --- a/modules/@angular/compiler/test/i18n/xmb_serializer_spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @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 {Message, id} from '@angular/compiler/src/i18n/message'; -import {deserializeXmb, serializeXmb} from '@angular/compiler/src/i18n/xmb_serializer'; -import {ParseError, ParseSourceSpan} from '@angular/compiler/src/parse_util'; -import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; - -import {HtmlAst} from '../../src/html_parser/html_ast'; - -export function main() { - describe('Xmb', () => { - describe('Xmb Serialization', () => { - it('should return an empty message bundle for an empty list of messages', - () => { expect(serializeXmb([])).toEqual(''); }); - - it('should serialize messages without desc nor meaning', () => { - let m = new Message('content', null, null); - let expected = `content`; - expect(serializeXmb([m])).toEqual(expected); - }); - - it('should serialize messages with desc and meaning', () => { - let m = new Message('content', 'meaning', 'description'); - let expected = - `content`; - expect(serializeXmb([m])).toEqual(expected); - }); - - it('should escape the desc and meaning', () => { - let m = new Message('content', '"\'&<>"\'&<>', '"\'&<>"\'&<>'); - let expected = - `content`; - expect(serializeXmb([m])).toEqual(expected); - }); - }); - - describe('Xmb Deserialization', () => { - it('should parse an empty bundle', () => { - let mb = ''; - expect(deserializeXmb(mb, 'url').messages).toEqual({}); - }); - - it('should parse an non-empty bundle', () => { - let mb = ` - - content1 - content2 - - `; - - let parsed = deserializeXmb(mb, 'url').messages; - expect(_serialize(parsed['id1'])).toEqual('content1'); - expect(_serialize(parsed['id2'])).toEqual('content2'); - }); - - it('should error when cannot parse the content', () => { - let mb = ` - - content - - `; - - let res = deserializeXmb(mb, 'url'); - expect(_serializeErrors(res.errors)).toEqual(['Unexpected closing tag "message-bundle"']); - }); - - it('should error when cannot find the id attribute', () => { - let mb = ` - - content - - `; - - let res = deserializeXmb(mb, 'url'); - expect(_serializeErrors(res.errors)).toEqual(['"id" attribute is missing']); - }); - - it('should error on empty content', () => { - let mb = ``; - let res = deserializeXmb(mb, 'url'); - expect(_serializeErrors(res.errors)).toEqual(['Missing element "message-bundle"']); - }); - - it('should error on an invalid element', () => { - let mb = ` - - content - - `; - - let res = deserializeXmb(mb, 'url'); - expect(_serializeErrors(res.errors)).toEqual(['Unexpected element "invalid"']); - }); - - it('should expand \'ph\' elements', () => { - let mb = ` - - a - - `; - - let res = deserializeXmb(mb, 'url').messages['id1']; - expect((res[1]).name).toEqual('ph'); - }); - }); - }); -} - -function _serialize(nodes: HtmlAst[]): string { - return (nodes[0]).value; -} - -function _serializeErrors(errors: ParseError[]): string[] { - return errors.map(e => e.msg); -} diff --git a/modules/@angular/compiler/test/schema/dom_element_schema_registry_spec.ts b/modules/@angular/compiler/test/schema/dom_element_schema_registry_spec.ts index 138a51759f..d741cf709b 100644 --- a/modules/@angular/compiler/test/schema/dom_element_schema_registry_spec.ts +++ b/modules/@angular/compiler/test/schema/dom_element_schema_registry_spec.ts @@ -11,7 +11,7 @@ import {CUSTOM_ELEMENTS_SCHEMA, SecurityContext} from '@angular/core'; import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; import {browserDetection} from '@angular/platform-browser/testing/browser_util'; -import {HtmlElementAst} from '../../src/html_parser/html_ast'; +import {Element} from '../../src/html_parser/ast'; import {HtmlParser} from '../../src/html_parser/html_parser'; import {extractSchema} from './schema_extractor'; @@ -78,7 +78,7 @@ export function main() { it('should detect properties on namespaced elements', () => { const htmlAst = new HtmlParser().parse('', 'TestComp'); - const nodeName = (htmlAst.rootNodes[0]).name; + const nodeName = (htmlAst.rootNodes[0]).name; expect(registry.hasProperty(nodeName, 'type', [])).toBeTruthy(); }); diff --git a/modules/@angular/compiler/test/template_parser/template_parser_spec.ts b/modules/@angular/compiler/test/template_parser/template_parser_spec.ts index 44a79eb881..ef64c0a79e 100644 --- a/modules/@angular/compiler/test/template_parser/template_parser_spec.ts +++ b/modules/@angular/compiler/test/template_parser/template_parser_spec.ts @@ -16,7 +16,6 @@ import {SchemaMetadata, SecurityContext} from '@angular/core'; import {Console} from '@angular/core/src/console'; import {TestBed} from '@angular/core/testing'; import {afterEach, beforeEach, beforeEachProviders, ddescribe, describe, expect, iit, inject, it, xit} from '@angular/core/testing/testing_internal'; - import {Identifiers, identifierToken} from '../../src/identifiers'; import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../../src/html_parser/interpolation_config'; diff --git a/modules/@angular/compiler/test/template_parser/template_preparser_spec.ts b/modules/@angular/compiler/test/template_parser/template_preparser_spec.ts index dd76f5a0da..3c49989c1a 100644 --- a/modules/@angular/compiler/test/template_parser/template_preparser_spec.ts +++ b/modules/@angular/compiler/test/template_parser/template_preparser_spec.ts @@ -7,7 +7,7 @@ */ import {afterEach, beforeEach, beforeEachProviders, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '../../../core/testing/testing_internal'; -import {HtmlElementAst} from '../../src/html_parser/html_ast'; +import {Element} from '../../src/html_parser/ast'; import {HtmlParser} from '../../src/html_parser/html_parser'; import {PreparsedElement, PreparsedElementType, preparseElement} from '../../src/template_parser/template_preparser'; @@ -17,7 +17,7 @@ export function main() { beforeEach(inject([HtmlParser], (_htmlParser: HtmlParser) => { htmlParser = _htmlParser; })); function preparse(html: string): PreparsedElement { - return preparseElement(htmlParser.parse(html, 'TestComp').rootNodes[0] as HtmlElementAst); + return preparseElement(htmlParser.parse(html, 'TestComp').rootNodes[0] as Element); } it('should detect script elements', inject([HtmlParser], (htmlParser: HtmlParser) => {