Compare commits

..

17 Commits

Author SHA1 Message Date
81db6a3171 release: cut the v10.0.12 release 2020-08-24 15:15:10 -07:00
8224e65e70 docs(localize): fix angular.json syntax (#38553)
In chapter internationalization (i18n) at section "Deploy multiple locales" the syntax for angular.json is wrong.
This commit fixes the angular.json, when specifying the translation file and the baseHref for a locale.

PR Close #38553
2020-08-24 09:25:38 -07:00
9e50cef507 test(language-service): [Ivy] return cursor position in overwritten template (#38552)
In many testing scenarios, there is a common pattern:

1. Overwrite template (inline or external)
2. Find cursor position
3. Call one of language service APIs
4. Inspect spans in result

In order to faciliate this pattern, this commit refactors
`MockHost.overwrite()` and `MockHost.overwriteInlineTemplate()` to
allow a faux cursor symbol `¦` to be injected into the template, and
the methods will automatically remove it before updating the script snapshot.
Both methods will return the cursor position and the new text without
the cursor symbol.

This makes testing very convenient. Here's a typical example:

```ts
const {position, text} = mockHost.overwrite('template.html', `{{ ti¦tle }}`);
const quickInfo = ngLS.getQuickInfoAtPosition('template.html', position);
const {start, length} = quickInfo!.textSpan;
expect(text.substring(start, start + length)).toBe('title');
```

PR Close #38552
2020-08-24 09:25:07 -07:00
66d8c223a9 feat(language-service): introduce hybrid visitor to locate AST node (#38540)
This commit introduces two visitors, one for Template AST and the other
for Expression AST to allow us to easily find the node that most closely
corresponds to a given cursor position.

This is crucial because many language service APIs take in a `position`
parameter, and the information returned depends on how well we can find
a good candidate node.

In View Engine implementation of language service, the search for the node
and the processing of information to return the result are strongly coupled.
This makes the code hard to understand and hard to debug because the stack
trace is often littered with layers of visitor calls.

With this new feature, we could test the "searching" part separately and
colocate all the logic (aka hacks) that's required to retrieve an accurate
span for a given node.

Right now, only the most "narrow" node is returned by the main exported
function `findNodeAtPosition`. If needed, we could expose the entire AST
path, or expose other methods to provide more context for a node.

Note that due to limitations in the template AST interface, there are
a few known cases where microsyntax spans are not recorded properly.
This will be dealt with in a follow-up PR.

PR Close #38540
2020-08-24 09:24:21 -07:00
814b43647d fix(compiler-cli): adding references to const enums in runtime code (#38542)
We had a couple of places where we were assuming that if a particular
symbol has a value, then it will exist at runtime. This is true in most cases,
but it breaks down for `const` enums.

Fixes #38513.

PR Close #38542
2020-08-21 12:23:24 -07:00
6b98567b6b fix(dev-infra): ignore comments when validating commit messages (#38438)
When creating a commit with the git cli, git pre-populates the editor
used to enter the commit message with some comments (i.e. lines starting
with `#`). These comments contain helpful instructions or information
regarding the changes that are part of the commit. As happens with all
commit message comments, they are removed by git and do not end up in
the final commit message.

However, the file that is passed to the `commit-msg` to be validated
still contains these comments. This may affect the outcome of the commit
message validation. In such cases, the author will not realize that the
commit message is not in the desired format until the linting checks
fail on CI (which validates the final commit messages and is not
affected by this issue), usually several minutes later.

Possible ways in which the commit message validation outcome can be
affected:
- The minimum body length check may pass incorrectly, even if there is
  no actual body, because the comments are counted as part of the body.
- The maximum line length check may fail incorrectly due to a very long
  line in the comments.

This commit fixes the problem by removing comment lines before
validating a commit message.

Fixes #37865

PR Close #38438
2020-08-21 12:17:17 -07:00
7f2a3e4b6b docs: apply code styling in template reference variables guide (#38522)
PR Close #38522
2020-08-20 13:01:37 -07:00
1775f351d6 fix(docs-infra): fix vertical alignment of external link icons (#38410)
At some places external link icons appear as a subscript. For example
8366effeec/aio/content/guide/roadmap.md\#L37
this commit places external link icons in the middle to improve there
positioning in a line.

PR Close #38410
2020-08-20 09:40:05 -07:00
1df52d9acd docs: udpate the details (#37967)
updating my twitter handle and bio as it is changed from
Angular and Web Tech to Angular also the
twitter handle is changed to SantoshYadavDev

PR Close #37967
2020-08-20 09:39:05 -07:00
77e8db4484 docs(core): Fix typo in JSDoc for AbstractType<T> (#38541)
PR Close #38541
2020-08-20 09:30:24 -07:00
c90262e619 Revert "Revert "fix(core): remove closing body tag from inert DOM builder (#38454)""
This reverts commit 87bbf69ce8.
2020-08-20 08:56:01 -07:00
87bbf69ce8 Revert "fix(core): remove closing body tag from inert DOM builder (#38454)"
This reverts commit 552853648c.
2020-08-19 21:02:55 -07:00
552853648c fix(core): remove closing body tag from inert DOM builder (#38454)
Fix a bug in the HTML sanitizer where an unclosed iframe tag would
result in an escaped closing body tag as the output:

_sanitizeHtml(document, '<iframe>') => '&lt;/body&gt;'

This closing body tag comes from the DOMParserHelper where the HTML to be
sanitized is wrapped with surrounding body tags. When an opening iframe
tag is parsed by DOMParser, which DOMParserHelper uses, everything up
until its matching closing tag is consumed as a text node. In the above
example this includes the appended closing body tag.

By removing the explicit closing body tag from the DOMParserHelper and
relying on the body tag being closed implicitly at the end, the above
example is sanitized as expected:

_sanitizeHtml(document, '<iframe>') => ''

PR Close #38454
2020-08-19 14:18:47 -07:00
07b99f5975 fix(localize): parse all parts of a translation with nested HTML (#38452)
Previously nested container placeholders (i.e. HTML elements) were
not being fully parsed from translation files. This resulted in bad
translation of messages that contain these placeholders.

Note that this causes the canonical message ID to change for
such messages. Currently all messages generated from
templates use "legacy" message ids that are not affected by
this change, so this fix should not be seen as a breaking change.

Fixes #38422

PR Close #38452
2020-08-19 14:16:48 -07:00
57d1a483fc fix(localize): include the last placeholder in parsed translation text (#38452)
When creating a `ParsedTranslation` from a set of message parts and
placeholder names a textual representation of the message is computed.
Previously the last placeholder and text segment were missing from this
computed message string.

PR Close #38452
2020-08-19 14:16:45 -07:00
d662a6449e refactor(compiler-cli): add getTemplateOfComponent to TemplateTypeChecker (#38355)
This commit adds a `getTemplateOfComponent` method to the
`TemplateTypeChecker` API, which retrieves the actual nodes parsed and used
by the compiler for template type-checking. This is advantageous for the
language service, which may need to query other APIs in
`TemplateTypeChecker` that require the same nodes used to bind the template
while generating the TCB.

Fixes #38352

PR Close #38355
2020-08-19 14:07:07 -07:00
e57a2b3c47 docs: Typos fixes in the binding syntax guide (#38519)
PR Close #38519
2020-08-19 14:05:53 -07:00
33 changed files with 1330 additions and 94 deletions

View File

@ -1,3 +1,21 @@
<a name="10.0.12"></a>
## 10.0.12 (2020-08-24)
### Bug Fixes
* **compiler-cli:** adding references to const enums in runtime code ([#38542](https://github.com/angular/angular/issues/38542)) ([814b436](https://github.com/angular/angular/commit/814b436)), closes [#38513](https://github.com/angular/angular/issues/38513)
* **core:** remove closing body tag from inert DOM builder ([#38454](https://github.com/angular/angular/issues/38454)) ([5528536](https://github.com/angular/angular/commit/5528536))
* **localize:** include the last placeholder in parsed translation text ([#38452](https://github.com/angular/angular/issues/38452)) ([57d1a48](https://github.com/angular/angular/commit/57d1a48))
* **localize:** parse all parts of a translation with nested HTML ([#38452](https://github.com/angular/angular/issues/38452)) ([07b99f5](https://github.com/angular/angular/commit/07b99f5)), closes [#38422](https://github.com/angular/angular/issues/38422)
### Features
* **language-service:** introduce hybrid visitor to locate AST node ([#38540](https://github.com/angular/angular/issues/38540)) ([66d8c22](https://github.com/angular/angular/commit/66d8c22))
<a name="10.0.11"></a>
## 10.0.11 (2020-08-19)

View File

@ -154,7 +154,7 @@ Attributes can be changed by `setAttribute()`, which re-initializes correspondin
</div>
For more information, see the [MDN Interfaces documentation](https://developer.mozilla.org/en-US/docs/Web/API#Interfaces) which has API docs for all the standard DOM elements and their properties.
Comparing the [`<td>` attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td) attributes to the [`<td>` properties](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableCellElement) provides a helpful example for differentiation.
Comparing the [`<td>` attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td) to the [`<td>` properties](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableCellElement) provides a helpful example for differentiation.
In particular, you can navigate from the attributes page to the properties via "DOM interface" link, and navigate the inheritance hierarchy up to `HTMLTableCellElement`.
@ -195,7 +195,7 @@ To control the state of the button, set the `disabled` *property*,
<div class="alert is-helpful">
Though you could technically set the `[attr.disabled]` attribute binding, the values are different in that the property binding requires to a boolean value, while its corresponding attribute binding relies on whether the value is `null` or not. Consider the following:
Though you could technically set the `[attr.disabled]` attribute binding, the values are different in that the property binding requires to be a boolean value, while its corresponding attribute binding relies on whether the value is `null` or not. Consider the following:
```html
<input [disabled]="condition ? true : false">

View File

@ -766,8 +766,10 @@ The HTML `base` tag with the `href` attribute specifies the base URI, or URL, fo
"i18n": {
"sourceLocale": "en-US",
"locales": {
"fr": "src/locale/messages.fr.xlf"
"baseHref": ""
"fr": {
"translation": "src/locale/messages.fr.xlf",
"baseHref": ""
}
}
},
"architect": {

View File

@ -37,9 +37,9 @@ by HTML.
<code-example path="template-reference-variables/src/app/app.component.html" region="ngForm" header="src/app/hero-form.component.html"></code-example>
The reference value of itemForm, without the ngForm attribute value, would be
The reference value of `itemForm`, without the `ngForm` attribute value, would be
the [HTMLFormElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement).
There is, however, a difference between a Component and a Directive in that a `Component`
There is, however, a difference between a `Component` and a `Directive` in that a `Component`
will be referenced without specifying the attribute value, and a `Directive` will not
change the implicit reference (that is, the element).

View File

@ -713,9 +713,9 @@
"santosh": {
"name": "Santosh Yadav",
"picture": "santoshyadav.jpg",
"twitter": "Santosh19742211",
"twitter": "SantoshYadavDev",
"website": "https://www.santoshyadav.dev",
"bio": "Santosh is a GDE for Angular and Web Technologies and loves to contribute to Open Source. He is the creator of ng deploy for netlify and core team member for NestJS Addons. He writes for AngularInDepth, mentors for DotNetTricks, organizes Pune Tech Meetup, and conducts free workshops on Angular.",
"bio": "Santosh is a GDE for Angular and loves to contribute to Open Source. He is the creator of ng deploy for netlify and core team member for NestJS Addons. He writes for AngularInDepth, mentors for DotNetTricks, organizes Pune Tech Meetup, and conducts free workshops on Angular.",
"groups": ["GDE"]
},
"josephperrott": {

View File

@ -214,7 +214,7 @@ code {
margin-left: 2px;
position: relative;
@include line-height(24);
vertical-align: bottom;
vertical-align: middle;
}
}

View File

@ -82,4 +82,24 @@ describe('commit message parsing:', () => {
const message2 = buildCommitMessage({prefix: 'squash! '});
expect(parseCommitMessage(message2).isSquash).toBe(true);
});
it('ignores comment lines', () => {
const message = buildCommitMessage({
prefix: '# This is a comment line before the header.\n' +
'## This is another comment line before the headers.\n',
body: '# This is a comment line befor the body.\n' +
'This is line 1 of the actual body.\n' +
'## This is another comment line inside the body.\n' +
'This is line 2 of the actual body (and it also contains a # but it not a comment).\n' +
'### This is yet another comment line after the body.\n',
});
const parsedMessage = parseCommitMessage(message);
expect(parsedMessage.header)
.toBe(`${commitValues.type}(${commitValues.scope}): ${commitValues.summary}`);
expect(parsedMessage.body)
.toBe(
'This is line 1 of the actual body.\n' +
'This is line 2 of the actual body (and it also contains a # but it not a comment).\n');
});
});

View File

@ -36,6 +36,10 @@ const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/;
/** Parse a full commit message into its composite parts. */
export function parseCommitMessage(commitMsg: string): ParsedCommitMessage {
// Ignore comments (i.e. lines starting with `#`). Comments are automatically removed by git and
// should not be considered part of the final commit message.
commitMsg = commitMsg.split('\n').filter(line => !line.startsWith('#')).join('\n');
let header = '';
let body = '';
let bodyWithoutLinking = '';

View File

@ -1,6 +1,6 @@
{
"name": "angular-srcs",
"version": "10.0.11",
"version": "10.0.12",
"private": true,
"description": "Angular - a web framework for modern web apps",
"homepage": "https://github.com/angular/angular",

View File

@ -35,8 +35,9 @@ export function typeToValue(
const {local, decl} = symbols;
// It's only valid to convert a type reference to a value reference if the type actually
// has a value declaration associated with it.
if (decl.valueDeclaration === undefined) {
// has a value declaration associated with it. Note that const enums are an exception,
// because while they do have a value declaration, they don't exist at runtime.
if (decl.valueDeclaration === undefined || decl.flags & ts.SymbolFlags.ConstEnum) {
return noValueDeclaration(typeNode, decl.declarations[0]);
}

View File

@ -28,6 +28,14 @@ export interface TemplateTypeChecker {
*/
resetOverrides(): void;
/**
* Retrieve the template in use for the given component.
*
* If the template has been overridden via `overrideComponentTemplate`, this will retrieve the
* overridden template nodes.
*/
getTemplate(component: ts.ClassDeclaration): TmplAstNode[]|null;
/**
* Provide a new template string that will be used in place of the user-defined template when
* checking or operating on the given component.

View File

@ -48,6 +48,29 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
}
getTemplate(component: ts.ClassDeclaration): TmplAstNode[]|null {
this.ensureShimForComponent(component);
const sf = component.getSourceFile();
const sfPath = absoluteFromSourceFile(sf);
const shimPath = this.typeCheckingStrategy.shimPathForComponent(component);
const fileRecord = this.getFileData(sfPath);
if (!fileRecord.shimData.has(shimPath)) {
return [];
}
const templateId = fileRecord.sourceManager.getTemplateId(component);
const shimRecord = fileRecord.shimData.get(shimPath)!;
if (!shimRecord.templates.has(templateId)) {
return null;
}
return shimRecord.templates.get(templateId)!.template;
}
overrideComponentTemplate(component: ts.ClassDeclaration, template: string):
{nodes: TmplAstNode[], errors?: ParseError[]} {
const {nodes, errors} = parseTemplate(template, 'override.html', {

View File

@ -6,14 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ParseSourceFile, R3TargetBinder, SchemaMetadata, TmplAstNode} from '@angular/compiler';
import {BoundTarget, ParseSourceFile, R3TargetBinder, SchemaMetadata, TmplAstNode} from '@angular/compiler';
import * as ts from 'typescript';
import {absoluteFromSourceFile, AbsoluteFsPath} from '../../file_system';
import {NoopImportRewriter, Reference, ReferenceEmitter} from '../../imports';
import {ClassDeclaration, ReflectionHost} from '../../reflection';
import {ImportManager} from '../../translator';
import {ComponentToShimMappingStrategy, TemplateSourceMapping, TypeCheckableDirectiveMeta, TypeCheckBlockMetadata, TypeCheckContext, TypeCheckingConfig, TypeCtorMetadata} from '../api';
import {ComponentToShimMappingStrategy, TemplateId, TemplateSourceMapping, TypeCheckableDirectiveMeta, TypeCheckBlockMetadata, TypeCheckContext, TypeCheckingConfig, TypeCtorMetadata} from '../api';
import {TemplateDiagnostic} from './diagnostics';
import {DomSchemaChecker, RegistryDomSchemaChecker} from './dom';
@ -41,6 +41,28 @@ export interface ShimTypeCheckingData {
* Whether any inline operations for the input file were required to generate this shim.
*/
hasInlines: boolean;
/**
* Map of `TemplateId` to information collected about the template during the template
* type-checking process.
*/
templates: Map<TemplateId, TemplateData>;
}
/**
* Data tracked for each template processed by the template type-checking system.
*/
export interface TemplateData {
/**
* Template nodes for which the TCB was generated.
*/
template: TmplAstNode[];
/**
* `BoundTarget` which was used to generate the TCB, and contains bindings for the associated
* template nodes.
*/
boundTarget: BoundTarget<TypeCheckableDirectiveMeta>;
}
/**
@ -79,6 +101,12 @@ export interface PendingShimData {
* Shim file in the process of being generated.
*/
file: TypeCheckFile;
/**
* Map of `TemplateId` to information collected about the template as it's ingested.
*/
templates: Map<TemplateId, TemplateData>;
}
/**
@ -195,6 +223,7 @@ export class TypeCheckContextImpl implements TypeCheckContext {
const fileData = this.dataForFile(ref.node.getSourceFile());
const shimData = this.pendingShimForComponent(ref.node);
const boundTarget = binder.bind({template});
// Get all of the directives used in the template and record type constructors for all of them.
for (const dir of boundTarget.getUsedDirectives()) {
const dirRef = dir.ref as Reference<ClassDeclaration<ts.ClassDeclaration>>;
@ -221,6 +250,11 @@ export class TypeCheckContextImpl implements TypeCheckContext {
});
}
}
const templateId = fileData.sourceManager.getTemplateId(ref.node);
shimData.templates.set(templateId, {
template,
boundTarget,
});
const tcbRequiresInline = requiresInlineTypeCheckBlock(ref.node);
@ -231,7 +265,6 @@ export class TypeCheckContextImpl implements TypeCheckContext {
// and inlining would be required.
// Record diagnostics to indicate the issues with this template.
const templateId = fileData.sourceManager.getTemplateId(ref.node);
if (tcbRequiresInline) {
shimData.oobRecorder.requiresInlineTcb(templateId, ref.node);
}
@ -348,6 +381,7 @@ export class TypeCheckContextImpl implements TypeCheckContext {
],
hasInlines: pendingFileData.hasInlines,
path: pendingShimData.file.fileName,
templates: pendingShimData.templates,
});
updates.set(pendingShimData.file.fileName, pendingShimData.file.render());
}
@ -380,6 +414,7 @@ export class TypeCheckContextImpl implements TypeCheckContext {
oobRecorder: new OutOfBandDiagnosticRecorderImpl(fileData.sourceManager),
file: new TypeCheckFile(
shimPath, this.config, this.refEmitter, this.reflector, this.compilerHost),
templates: new Map<TemplateId, TemplateData>(),
});
}
return fileData.shimData.get(shimPath)!;

View File

@ -349,5 +349,40 @@ runInEachFileSystem(os => {
expect(diags2[0].messageText).toContain('invalid-element-b');
expect(diags2[0].messageText).not.toContain('invalid-element-a');
});
describe('getTemplateOfComponent()', () => {
it('should provide access to a component\'s real template', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([{
fileName,
templates: {
'Cmp': '<div>Template</div>',
},
}]);
const cmp = getClass(getSourceFileOrError(program, fileName), 'Cmp');
const nodes = templateTypeChecker.getTemplate(cmp)!;
expect(nodes).not.toBeNull();
expect(nodes[0].sourceSpan.start.file.content).toBe('<div>Template</div>');
});
it('should provide access to an overridden template', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([{
fileName,
templates: {
'Cmp': '<div>Template</div>',
},
}]);
const cmp = getClass(getSourceFileOrError(program, fileName), 'Cmp');
templateTypeChecker.overrideComponentTemplate(cmp, '<div>Overridden</div>');
templateTypeChecker.getDiagnosticsForComponent(cmp);
const nodes = templateTypeChecker.getTemplate(cmp)!;
expect(nodes).not.toBeNull();
expect(nodes[0].sourceSpan.start.file.content).toBe('<div>Overridden</div>');
});
});
});
});

View File

@ -298,15 +298,20 @@ function typeReferenceToExpression(
}
/**
* Returns true if the given symbol refers to a value (as distinct from a type).
* Checks whether a given symbol refers to a value that exists at runtime (as distinct from a type).
*
* Expands aliases, which is important for the case where
* import * as x from 'some-module';
* and x is now a value (the module object).
*/
function symbolIsValue(tc: ts.TypeChecker, sym: ts.Symbol): boolean {
if (sym.flags & ts.SymbolFlags.Alias) sym = tc.getAliasedSymbol(sym);
return (sym.flags & ts.SymbolFlags.Value) !== 0;
function symbolIsRuntimeValue(typeChecker: ts.TypeChecker, symbol: ts.Symbol): boolean {
if (symbol.flags & ts.SymbolFlags.Alias) {
symbol = typeChecker.getAliasedSymbol(symbol);
}
// Note that const enums are a special case, because
// while they have a value, they don't exist at runtime.
return (symbol.flags & ts.SymbolFlags.Value & ts.SymbolFlags.ConstEnumExcludes) !== 0;
}
/** ParameterDecorationInfo describes the information for a single constructor parameter. */
@ -351,7 +356,7 @@ export function getDownlevelDecoratorsTransform(
const symbol = typeChecker.getSymbolAtLocation(name);
// Check if the entity name references a symbol that is an actual value. If it is not, it
// cannot be referenced by an expression, so return undefined.
if (!symbol || !symbolIsValue(typeChecker, symbol) || !symbol.declarations ||
if (!symbol || !symbolIsRuntimeValue(typeChecker, symbol) || !symbol.declarations ||
symbol.declarations.length === 0) {
return undefined;
}

View File

@ -4362,6 +4362,53 @@ runInEachFileSystem(os => {
expect(jsContents).toMatch(setClassMetadataRegExp('type: undefined'));
});
it('should use `undefined` in setClassMetadata for const enums', () => {
env.write(`keycodes.ts`, `
export const enum KeyCodes {A, B};
`);
env.write(`test.ts`, `
import {Component, Inject} from '@angular/core';
import {KeyCodes} from './keycodes';
@Component({
selector: 'some-comp',
template: '...',
})
export class SomeComp {
constructor(@Inject('arg-token') arg: KeyCodes) {}
}
`);
env.driveMain();
const jsContents = trim(env.getContents('test.js'));
expect(jsContents).not.toContain(`import { KeyCodes } from './keycodes';`);
// Note: `type: undefined` below, since KeyCodes can't be represented as a value
expect(jsContents).toMatch(setClassMetadataRegExp('type: undefined'));
});
it('should preserve the types of non-const enums in setClassMetadata', () => {
env.write(`keycodes.ts`, `
export enum KeyCodes {A, B};
`);
env.write(`test.ts`, `
import {Component, Inject} from '@angular/core';
import {KeyCodes} from './keycodes';
@Component({
selector: 'some-comp',
template: '...',
})
export class SomeComp {
constructor(@Inject('arg-token') arg: KeyCodes) {}
}
`);
env.driveMain();
const jsContents = trim(env.getContents('test.js'));
expect(jsContents).toContain(`import { KeyCodes } from './keycodes';`);
expect(jsContents).toMatch(setClassMetadataRegExp('type: i1.KeyCodes'));
});
it('should use `undefined` in setClassMetadata if types originate from type-only imports',
() => {
env.write(`types.ts`, `

View File

@ -192,7 +192,7 @@ describe('downlevel decorator transform', () => {
it('should downlevel Angular-decorated class member', () => {
const {output} = transform(`
import {Input} from '@angular/core';
export class MyDir {
@Input() disabled: boolean = false;
}
@ -231,7 +231,7 @@ describe('downlevel decorator transform', () => {
const {output} = transform(`
import {Input} from '@angular/core';
import {MyOtherClass} from './other-file';
export class MyDir {
@Input() trigger: HTMLElement;
@Input() fromOtherFile: MyOtherClass;
@ -255,7 +255,7 @@ describe('downlevel decorator transform', () => {
`
import {Directive} from '@angular/core';
import {MyOtherClass} from './other-file';
@Directive()
export class MyDir {
constructor(other: MyOtherClass) {}
@ -281,7 +281,7 @@ describe('downlevel decorator transform', () => {
`
import {Directive} from '@angular/core';
import {MyOtherClass} from './other-file';
@Directive()
export class MyDir {
constructor(other: MyOtherClass) {}
@ -307,7 +307,7 @@ describe('downlevel decorator transform', () => {
const {output} = transform(`
import {Directive} from '@angular/core';
import * as externalFile from './other-file';
@Directive()
export class MyDir {
constructor(other: externalFile.MyOtherClass) {}
@ -329,11 +329,11 @@ describe('downlevel decorator transform', () => {
it('should properly serialize constructor parameter with local qualified name type', () => {
const {output} = transform(`
import {Directive} from '@angular/core';
namespace other {
export class OtherClass {}
};
@Directive()
export class MyDir {
constructor(other: other.OtherClass) {}
@ -355,7 +355,7 @@ describe('downlevel decorator transform', () => {
it('should properly downlevel constructor parameter decorators', () => {
const {output} = transform(`
import {Inject, Directive, DOCUMENT} from '@angular/core';
@Directive()
export class MyDir {
constructor(@Inject(DOCUMENT) document: Document) {}
@ -376,7 +376,7 @@ describe('downlevel decorator transform', () => {
it('should properly downlevel constructor parameters with union type', () => {
const {output} = transform(`
import {Optional, Directive, NgZone} from '@angular/core';
@Directive()
export class MyDir {
constructor(@Optional() ngZone: NgZone|null) {}
@ -546,18 +546,20 @@ describe('downlevel decorator transform', () => {
export default interface {
hello: false;
}
export const enum KeyCodes {A, B}
`);
const {output} = transform(`
import {Directive, Inject} from '@angular/core';
import * as angular from './external';
import {IOverlay} from './external';
import {IOverlay, KeyCodes} from './external';
import TypeFromDefaultImport from './external';
@Directive()
export class MyDir {
constructor(@Inject('$state') param: angular.IState,
@Inject('$overlay') other: IOverlay,
@Inject('$default') default: TypeFromDefaultImport) {}
@Inject('$default') default: TypeFromDefaultImport,
@Inject('$keyCodes') keyCodes: KeyCodes) {}
}
`);
@ -570,7 +572,8 @@ describe('downlevel decorator transform', () => {
MyDir.ctorParameters = () => [
{ type: undefined, decorators: [{ type: core_1.Inject, args: ['$state',] }] },
{ type: undefined, decorators: [{ type: core_1.Inject, args: ['$overlay',] }] },
{ type: undefined, decorators: [{ type: core_1.Inject, args: ['$default',] }] }
{ type: undefined, decorators: [{ type: core_1.Inject, args: ['$default',] }] },
{ type: undefined, decorators: [{ type: core_1.Inject, args: ['$keyCodes',] }] }
];
`);
});
@ -593,7 +596,7 @@ describe('downlevel decorator transform', () => {
const {output} = transform(
`
import {Directive} from '@angular/core';
export class MyInjectedClass {}
@Directive()
@ -609,13 +612,36 @@ describe('downlevel decorator transform', () => {
expect(output).not.toContain('tslib');
});
it('should capture a non-const enum used as a constructor type', () => {
const {output} = transform(`
import {Component} from '@angular/core';
export enum Values {A, B};
@Component({template: 'hello'})
export class MyComp {
constructor(v: Values) {}
}
`);
expect(diagnostics.length).toBe(0);
expect(output).toContain(dedent`
MyComp.decorators = [
{ type: core_1.Component, args: [{ template: 'hello' },] }
];
MyComp.ctorParameters = () => [
{ type: Values }
];`);
expect(output).not.toContain('tslib');
});
describe('class decorators skipped', () => {
beforeEach(() => skipClassDecorators = true);
it('should not downlevel Angular class decorators', () => {
const {output} = transform(`
import {Injectable} from '@angular/core';
@Injectable()
export class MyService {}
`);
@ -632,10 +658,10 @@ describe('downlevel decorator transform', () => {
it('should downlevel constructor parameters', () => {
const {output} = transform(`
import {Injectable} from '@angular/core';
@Injectable()
export class InjectClass {}
@Injectable()
export class MyService {
constructor(dep: InjectClass) {}
@ -658,10 +684,10 @@ describe('downlevel decorator transform', () => {
it('should downlevel constructor parameter decorators', () => {
const {output} = transform(`
import {Injectable, Inject} from '@angular/core';
@Injectable()
export class InjectClass {}
@Injectable()
export class MyService {
constructor(@Inject('test') dep: InjectClass) {}
@ -684,7 +710,7 @@ describe('downlevel decorator transform', () => {
it('should downlevel class member Angular decorators', () => {
const {output} = transform(`
import {Injectable, Input} from '@angular/core';
export class MyService {
@Input() disabled: boolean;
}

View File

@ -26,7 +26,7 @@ export function isType(v: any): v is Type<any> {
* @description
*
* Represents an abstract class `T`, if applied to a concrete class it would stop being
* instantiatable.
* instantiable.
*
* @publicApi
*/

View File

@ -32,8 +32,9 @@ class DOMParserHelper implements InertBodyHelper {
getInertBodyElement(html: string): HTMLElement|null {
// We add these extra elements to ensure that the rest of the content is parsed as expected
// e.g. leading whitespace is maintained and tags like `<meta>` do not get hoisted to the
// `<head>` tag.
html = '<body><remove></remove>' + html + '</body>';
// `<head>` tag. Note that the `<body>` tag is closed implicitly to prevent unclosed tags
// in `html` from consuming the otherwise explicit `</body>` tag.
html = '<body><remove></remove>' + html;
try {
const body = new (window as any).DOMParser().parseFromString(html, 'text/html').body as
HTMLBodyElement;

View File

@ -173,6 +173,27 @@ import {isDOMParserAvailable} from '../../src/sanitization/inert_body';
expect(logMsgs.join('\n')).toMatch(/sanitizing HTML stripped some content/);
});
it('should strip unclosed iframe tag', () => {
expect(_sanitizeHtml(defaultDoc, '<iframe>')).toEqual('');
expect([
'&lt;iframe&gt;',
// Double-escaped on IE
'&amp;lt;iframe&amp;gt;'
]).toContain(_sanitizeHtml(defaultDoc, '<iframe><iframe>'));
expect([
'&lt;script&gt;evil();&lt;/script&gt;',
// Double-escaped on IE
'&amp;lt;script&amp;gt;evil();&amp;lt;/script&amp;gt;'
]).toContain(_sanitizeHtml(defaultDoc, '<iframe><script>evil();</script>'));
});
it('should ignore extraneous body tags', () => {
expect(_sanitizeHtml(defaultDoc, '</body>')).toEqual('');
expect(_sanitizeHtml(defaultDoc, 'foo</body>bar')).toEqual('foobar');
expect(_sanitizeHtml(defaultDoc, 'foo<body>bar')).toEqual('foobar');
expect(_sanitizeHtml(defaultDoc, 'fo<body>ob</body>ar')).toEqual('foobar');
});
it('should not enter an infinite loop on clobbered elements', () => {
// Some browsers are vulnerable to clobbered elements and will throw an expected exception
// IE and EDGE does not seems to be affected by those cases

View File

@ -6,6 +6,7 @@ ts_library(
name = "ivy",
srcs = glob(["*.ts"]),
deps = [
"//packages/compiler",
"//packages/compiler-cli",
"//packages/compiler-cli/src/ngtsc/core",
"//packages/compiler-cli/src/ngtsc/core:api",

View File

@ -0,0 +1,180 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ParseSourceSpan} from '@angular/compiler';
import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
/**
* Return the template AST node or expression AST node that most accurately
* represents the node at the specified cursor `position`.
* @param ast AST tree
* @param position cursor position
*/
export function findNodeAtPosition(ast: t.Node[], position: number): t.Node|e.AST|undefined {
const visitor = new R3Visitor(position);
visitor.visitAll(ast);
return visitor.path[visitor.path.length - 1];
}
class R3Visitor implements t.Visitor {
// We need to keep a path instead of the last node because we might need more
// context for the last node, for example what is the parent node?
readonly path: Array<t.Node|e.AST> = [];
// Position must be absolute in the source file.
constructor(private readonly position: number) {}
visit(node: t.Node) {
const {start, end} = getSpanIncludingEndTag(node);
// Note both start and end are inclusive because we want to match conditions
// like ¦start and end¦ where ¦ is the cursor.
if (start <= this.position && this.position <= end) {
const length = end - start;
const last: t.Node|e.AST|undefined = this.path[this.path.length - 1];
if (last) {
const {start, end} = isTemplateNode(last) ? getSpanIncludingEndTag(last) : last.sourceSpan;
const lastLength = end - start;
if (length > lastLength) {
// The current node has a span that is larger than the last node found
// so we do not descend into it. This typically means we have found
// a candidate in one of the root nodes so we do not need to visit
// other root nodes.
return;
}
}
if (node instanceof t.BoundEvent &&
this.path.find((n => n instanceof t.BoundAttribute && node.name === n.name + 'Change'))) {
// For two-way binding aka banana-in-a-box, there are two matches:
// BoundAttribute and BoundEvent. Both have the same spans. We choose to
// return BoundAttribute because it matches the identifier name verbatim.
// TODO: For operations like go to definition, ideally we want to return
// both.
return;
}
this.path.push(node);
node.visit(this);
}
}
visitElement(element: t.Element) {
this.visitAll(element.attributes);
this.visitAll(element.inputs);
this.visitAll(element.outputs);
this.visitAll(element.references);
this.visitAll(element.children);
}
visitTemplate(template: t.Template) {
this.visitAll(template.attributes);
this.visitAll(template.inputs);
this.visitAll(template.outputs);
this.visitAll(template.templateAttrs);
this.visitAll(template.references);
this.visitAll(template.variables);
this.visitAll(template.children);
}
visitContent(content: t.Content) {
t.visitAll(this, content.attributes);
}
visitVariable(variable: t.Variable) {
// Variable has no template nodes or expression nodes.
}
visitReference(reference: t.Reference) {
// Reference has no template nodes or expression nodes.
}
visitTextAttribute(attribute: t.TextAttribute) {
// Text attribute has no template nodes or expression nodes.
}
visitBoundAttribute(attribute: t.BoundAttribute) {
const visitor = new ExpressionVisitor(this.position);
visitor.visit(attribute.value, this.path);
}
visitBoundEvent(attribute: t.BoundEvent) {
const visitor = new ExpressionVisitor(this.position);
visitor.visit(attribute.handler, this.path);
}
visitText(text: t.Text) {
// Text has no template nodes or expression nodes.
}
visitBoundText(text: t.BoundText) {
const visitor = new ExpressionVisitor(this.position);
visitor.visit(text.value, this.path);
}
visitIcu(icu: t.Icu) {
for (const boundText of Object.values(icu.vars)) {
this.visit(boundText);
}
for (const boundTextOrText of Object.values(icu.placeholders)) {
this.visit(boundTextOrText);
}
}
visitAll(nodes: t.Node[]) {
for (const node of nodes) {
this.visit(node);
}
}
}
class ExpressionVisitor extends e.RecursiveAstVisitor {
// Position must be absolute in the source file.
constructor(private readonly position: number) {
super();
}
visit(node: e.AST, path: Array<t.Node|e.AST>) {
if (node instanceof e.ASTWithSource) {
// In order to reduce noise, do not include `ASTWithSource` in the path.
// For the purpose of source spans, there is no difference between
// `ASTWithSource` and and underlying node that it wraps.
node = node.ast;
}
const {start, end} = node.sourceSpan;
// The third condition is to account for the implicit receiver, which should
// not be visited.
if (start <= this.position && this.position <= end && !(node instanceof e.ImplicitReceiver)) {
path.push(node);
node.visit(this, path);
}
}
}
export function isTemplateNode(node: t.Node|e.AST): node is t.Node {
// Template node implements the Node interface so we cannot use instanceof.
return node.sourceSpan instanceof ParseSourceSpan;
}
export function isExpressionNode(node: t.Node|e.AST): node is e.AST {
return node instanceof e.AST;
}
function getSpanIncludingEndTag(ast: t.Node) {
const result = {
start: ast.sourceSpan.start.offset,
end: ast.sourceSpan.end.offset,
};
// For Element and Template node, sourceSpan.end is the end of the opening
// tag. For the purpose of language service, we need to actually recognize
// the end of the closing tag. Otherwise, for situation like
// <my-component></my-comp¦onent> where the cursor is in the closing tag
// we will not be able to return any information.
if ((ast instanceof t.Element || ast instanceof t.Template) && ast.endSourceSpan) {
result.end = ast.endSourceSpan.end.offset;
}
return result;
}

View File

@ -5,6 +5,7 @@ ts_library(
testonly = True,
srcs = glob(["*.ts"]),
deps = [
"//packages/compiler",
"//packages/language-service/ivy",
"@npm//typescript",
],

View File

@ -26,13 +26,13 @@ describe('diagnostic', () => {
});
it('should report member does not exist', () => {
const content = service.overwriteInlineTemplate(APP_COMPONENT, '{{ nope }}');
const {text} = service.overwriteInlineTemplate(APP_COMPONENT, '{{ nope }}');
const diags = ngLS.getSemanticDiagnostics(APP_COMPONENT);
expect(diags.length).toBe(1);
const {category, file, start, length, messageText} = diags[0];
expect(category).toBe(ts.DiagnosticCategory.Error);
expect(file?.fileName).toBe(APP_COMPONENT);
expect(content.substring(start!, start! + length!)).toBe('nope');
expect(text.substring(start!, start! + length!)).toBe('nope');
expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`);
});
});

View File

@ -0,0 +1,568 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ParseError, parseTemplate} from '@angular/compiler';
import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
import {findNodeAtPosition, isExpressionNode, isTemplateNode} from '../hybrid_visitor';
interface ParseResult {
nodes: t.Node[];
errors?: ParseError[];
position: number;
}
function parse(template: string): ParseResult {
const position = template.indexOf('¦');
if (position < 0) {
throw new Error(`Template "${template}" does not contain the cursor`);
}
template = template.replace('¦', '');
const templateUrl = '/foo';
return {
...parseTemplate(template, templateUrl),
position,
};
}
describe('findNodeAtPosition for template AST', () => {
it('should locate element in opening tag', () => {
const {errors, nodes, position} = parse(`<di¦v></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Element);
});
it('should locate element in closing tag', () => {
const {errors, nodes, position} = parse(`<div></di¦v>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Element);
});
it('should locate element when cursor is at the beginning', () => {
const {errors, nodes, position} = parse(`<¦div></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Element);
});
it('should locate element when cursor is at the end', () => {
const {errors, nodes, position} = parse(`<div¦></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Element);
});
it('should locate attribute key', () => {
const {errors, nodes, position} = parse(`<div cla¦ss="foo"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.TextAttribute);
});
it('should locate attribute value', () => {
const {errors, nodes, position} = parse(`<div class="fo¦o"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
// TODO: Note that we do not have the ability to detect the RHS (yet)
expect(node).toBeInstanceOf(t.TextAttribute);
});
it('should locate bound attribute key', () => {
const {errors, nodes, position} = parse(`<test-cmp [fo¦o]="bar"></test-cmp>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundAttribute);
});
it('should locate bound attribute value', () => {
const {errors, nodes, position} = parse(`<test-cmp [foo]="b¦ar"></test-cmp>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
});
it('should locate bound event key', () => {
const {errors, nodes, position} = parse(`<test-cmp (fo¦o)="bar()"></test-cmp>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundEvent);
});
it('should locate bound event value', () => {
const {errors, nodes, position} = parse(`<test-cmp (foo)="b¦ar()"></test-cmp>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.MethodCall);
});
it('should locate element children', () => {
const {errors, nodes, position} = parse(`<div><sp¦an></span></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Element);
expect((node as t.Element).name).toBe('span');
});
it('should locate element reference', () => {
const {errors, nodes, position} = parse(`<div #my¦div></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Reference);
});
it('should locate template text attribute', () => {
const {errors, nodes, position} = parse(`<ng-template ng¦If></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.TextAttribute);
});
it('should locate template bound attribute key', () => {
const {errors, nodes, position} = parse(`<ng-template [ng¦If]="foo"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundAttribute);
});
it('should locate template bound attribute value', () => {
const {errors, nodes, position} = parse(`<ng-template [ngIf]="f¦oo"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
});
it('should locate template bound attribute key in two-way binding', () => {
const {errors, nodes, position} = parse(`<ng-template [(f¦oo)]="bar"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundAttribute);
expect((node as t.BoundAttribute).name).toBe('foo');
});
it('should locate template bound attribute value in two-way binding', () => {
const {errors, nodes, position} = parse(`<ng-template [(foo)]="b¦ar"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
expect((node as e.PropertyRead).name).toBe('bar');
});
it('should locate template bound event key', () => {
const {errors, nodes, position} = parse(`<ng-template (cl¦ick)="foo()"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundEvent);
});
it('should locate template bound event value', () => {
const {errors, nodes, position} = parse(`<ng-template (click)="f¦oo()"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(node).toBeInstanceOf(e.MethodCall);
});
it('should locate template attribute key', () => {
const {errors, nodes, position} = parse(`<ng-template i¦d="foo"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.TextAttribute);
});
it('should locate template attribute value', () => {
const {errors, nodes, position} = parse(`<ng-template id="f¦oo"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
// TODO: Note that we do not have the ability to detect the RHS (yet)
expect(node).toBeInstanceOf(t.TextAttribute);
});
it('should locate template reference key via the # notation', () => {
const {errors, nodes, position} = parse(`<ng-template #f¦oo></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Reference);
expect((node as t.Reference).name).toBe('foo');
});
it('should locate template reference key via the ref- notation', () => {
const {errors, nodes, position} = parse(`<ng-template ref-fo¦o></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Reference);
expect((node as t.Reference).name).toBe('foo');
});
it('should locate template reference value via the # notation', () => {
const {errors, nodes, position} = parse(`<ng-template #foo="export¦As"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Reference);
expect((node as t.Reference).value).toBe('exportAs');
// TODO: Note that we do not have the ability to distinguish LHS and RHS
});
it('should locate template reference value via the ref- notation', () => {
const {errors, nodes, position} = parse(`<ng-template ref-foo="export¦As"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Reference);
expect((node as t.Reference).value).toBe('exportAs');
// TODO: Note that we do not have the ability to distinguish LHS and RHS
});
it('should locate template variable key', () => {
const {errors, nodes, position} = parse(`<ng-template let-f¦oo="bar"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Variable);
});
it('should locate template variable value', () => {
const {errors, nodes, position} = parse(`<ng-template let-foo="b¦ar"></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Variable);
});
it('should locate template children', () => {
const {errors, nodes, position} = parse(`<ng-template><d¦iv></div></ng-template>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Element);
});
it('should locate ng-content', () => {
const {errors, nodes, position} = parse(`<ng-co¦ntent></ng-content>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Content);
});
it('should locate ng-content attribute key', () => {
const {errors, nodes, position} = parse('<ng-content cla¦ss="red"></ng-content>');
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.TextAttribute);
});
it('should locate ng-content attribute value', () => {
const {errors, nodes, position} = parse('<ng-content class="r¦ed"></ng-content>');
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
// TODO: Note that we do not have the ability to detect the RHS (yet)
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.TextAttribute);
});
it('should not locate implicit receiver', () => {
const {errors, nodes, position} = parse(`<div [foo]="¦bar"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
});
it('should locate bound attribute key in two-way binding', () => {
const {errors, nodes, position} = parse(`<cmp [(f¦oo)]="bar"></cmp>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundAttribute);
expect((node as t.BoundAttribute).name).toBe('foo');
});
it('should locate bound attribute value in two-way binding', () => {
const {errors, nodes, position} = parse(`<cmp [(foo)]="b¦ar"></cmp>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
expect((node as e.PropertyRead).name).toBe('bar');
});
});
describe('findNodeAtPosition for expression AST', () => {
it('should not locate implicit receiver', () => {
const {errors, nodes, position} = parse(`{{ ¦title }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
expect((node as e.PropertyRead).name).toBe('title');
});
it('should locate property read', () => {
const {errors, nodes, position} = parse(`{{ ti¦tle }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
expect((node as e.PropertyRead).name).toBe('title');
});
it('should locate safe property read', () => {
const {errors, nodes, position} = parse(`{{ foo?¦.bar }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.SafePropertyRead);
expect((node as e.SafePropertyRead).name).toBe('bar');
});
it('should locate keyed read', () => {
const {errors, nodes, position} = parse(`{{ foo['bar']¦ }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.KeyedRead);
});
it('should locate property write', () => {
const {errors, nodes, position} = parse(`<div (foo)="b¦ar=$event"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyWrite);
});
it('should locate keyed write', () => {
const {errors, nodes, position} = parse(`<div (foo)="bar['baz']¦=$event"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.KeyedWrite);
});
it('should locate binary', () => {
const {errors, nodes, position} = parse(`{{ 1 +¦ 2 }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.Binary);
});
it('should locate binding pipe with an identifier', () => {
const {errors, nodes, position} = parse(`{{ title | p¦ }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.BindingPipe);
});
it('should locate binding pipe without identifier',
() => {
// TODO: We are not able to locate pipe if identifier is missing because the
// parser throws an error. This case is important for autocomplete.
// const {errors, nodes, position} = parse(`{{ title | ¦ }}`);
// expect(errors).toBeUndefined();
// const node = findNodeAtPosition(nodes, position);
// expect(isExpressionNode(node!)).toBe(true);
// expect(node).toBeInstanceOf(e.BindingPipe);
});
it('should locate method call', () => {
const {errors, nodes, position} = parse(`{{ title.toString(¦) }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.MethodCall);
});
it('should locate safe method call', () => {
const {errors, nodes, position} = parse(`{{ title?.toString(¦) }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.SafeMethodCall);
});
it('should locate literal primitive in interpolation', () => {
const {errors, nodes, position} = parse(`{{ title.indexOf('t¦') }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.LiteralPrimitive);
expect((node as e.LiteralPrimitive).value).toBe('t');
});
it('should locate literal primitive in binding', () => {
const {errors, nodes, position} = parse(`<div [id]="'t¦'"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.LiteralPrimitive);
expect((node as e.LiteralPrimitive).value).toBe('t');
});
it('should locate empty expression', () => {
const {errors, nodes, position} = parse(`<div [id]="¦"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.EmptyExpr);
});
it('should locate literal array', () => {
const {errors, nodes, position} = parse(`{{ [1, 2,¦ 3] }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.LiteralArray);
});
it('should locate literal map', () => {
const {errors, nodes, position} = parse(`{{ { hello:¦ "world" } }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.LiteralMap);
});
it('should locate conditional', () => {
const {errors, nodes, position} = parse(`{{ cond ?¦ true : false }}`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.Conditional);
});
});
describe('findNodeAtPosition for microsyntax expression', () => {
it('should locate template key', () => {
const {errors, nodes, position} = parse(`<div *ng¦If="foo"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundAttribute);
});
it('should locate template value', () => {
const {errors, nodes, position} = parse(`<div *ngIf="f¦oo"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
});
it('should locate text attribute', () => {
const {errors, nodes, position} = parse(`<div *ng¦For="let item of items"></div>`);
// ngFor is a text attribute because the desugared form is
// <ng-template ngFor let-item [ngForOf]="items">
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
// TODO: this is currently wrong because it should point to ngFor text
// attribute instead of ngForOf bound attribute
});
it('should locate not let keyword', () => {
const {errors, nodes, position} = parse(`<div *ngFor="l¦et item of items"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
// TODO: this is currently wrong because node is currently pointing to
// "item". In this case, it should return undefined.
});
it('should locate let variable', () => {
const {errors, nodes, position} = parse(`<div *ngFor="let i¦tem of items"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Variable);
expect((node as t.Variable).name).toBe('item');
});
it('should locate bound attribute key', () => {
const {errors, nodes, position} = parse(`<div *ngFor="let item o¦f items"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.BoundAttribute);
expect((node as t.BoundAttribute).name).toBe('ngForOf');
});
it('should locate bound attribute value', () => {
const {errors, nodes, position} = parse(`<div *ngFor="let item of it¦ems"></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
expect((node as e.PropertyRead).name).toBe('items');
});
it('should locate template children', () => {
const {errors, nodes, position} = parse(`<di¦v *ngIf></div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Element);
expect((node as t.Element).name).toBe('div');
});
it('should locate property read of variable declared within template', () => {
const {errors, nodes, position} = parse(`
<div *ngFor="let item of items; let i=index">
{{ i¦ }}
</div>`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isExpressionNode(node!)).toBe(true);
expect(node).toBeInstanceOf(e.PropertyRead);
});
it('should locate LHS of variable declaration', () => {
const {errors, nodes, position} = parse(`<div *ngFor="let item of items; let i¦=index">`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Variable);
// TODO: Currently there is no way to distinguish LHS from RHS
expect((node as t.Variable).name).toBe('i');
});
it('should locate RHS of variable declaration', () => {
const {errors, nodes, position} = parse(`<div *ngFor="let item of items; let i=in¦dex">`);
expect(errors).toBeUndefined();
const node = findNodeAtPosition(nodes, position);
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(t.Variable);
// TODO: Currently there is no way to distinguish LHS from RHS
expect((node as t.Variable).value).toBe('index');
});
});

View File

@ -105,6 +105,17 @@ export function setup() {
};
}
interface OverwriteResult {
/**
* Position of the cursor, -1 if there isn't one.
*/
position: number;
/**
* Overwritten content without the cursor.
*/
text: string;
}
class MockService {
private readonly overwritten = new Set<ts.server.NormalizedPath>();
@ -113,20 +124,32 @@ class MockService {
private readonly ps: ts.server.ProjectService,
) {}
overwrite(fileName: string, newText: string): string {
/**
* Overwrite the entire content of `fileName` with `newText`. If cursor is
* present in `newText`, it will be removed and the position of the cursor
* will be returned.
*/
overwrite(fileName: string, newText: string): OverwriteResult {
const scriptInfo = this.getScriptInfo(fileName);
this.overwriteScriptInfo(scriptInfo, preprocess(newText));
return newText;
return this.overwriteScriptInfo(scriptInfo, newText);
}
overwriteInlineTemplate(fileName: string, newTemplate: string): string {
/**
* Overwrite an inline template defined in `fileName` and return the entire
* content of the source file (not just the template). If a cursor is present
* in `newTemplate`, it will be removed and the position of the cursor in the
* source file will be returned.
*/
overwriteInlineTemplate(fileName: string, newTemplate: string): OverwriteResult {
const scriptInfo = this.getScriptInfo(fileName);
const snapshot = scriptInfo.getSnapshot();
const originalContent = snapshot.getText(0, snapshot.getLength());
const newContent =
originalContent.replace(/template: `([\s\S]+)`/, `template: \`${newTemplate}\``);
this.overwriteScriptInfo(scriptInfo, preprocess(newContent));
return newContent;
const originalText = snapshot.getText(0, snapshot.getLength());
const {position, text} =
replaceOnce(originalText, /template: `([\s\S]+?)`/, `template: \`${newTemplate}\``);
if (position === -1) {
throw new Error(`${fileName} does not contain a component with template`);
}
return this.overwriteScriptInfo(scriptInfo, text);
}
reset() {
@ -151,16 +174,37 @@ class MockService {
return scriptInfo;
}
private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string) {
/**
* Remove the cursor from `newText`, then replace `scriptInfo` with the new
* content and return the position of the cursor.
* @param scriptInfo
* @param newText Text that possibly contains a cursor
*/
private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string): OverwriteResult {
const result = replaceOnce(newText, /¦/, '');
const snapshot = scriptInfo.getSnapshot();
scriptInfo.editContent(0, snapshot.getLength(), newText);
scriptInfo.editContent(0, snapshot.getLength(), result.text);
this.overwritten.add(scriptInfo.fileName);
return result;
}
}
const REGEX_CURSOR = /¦/g;
function preprocess(text: string): string {
return text.replace(REGEX_CURSOR, '');
/**
* Replace at most one occurence that matches `regex` in the specified
* `searchText` with the specified `replaceText`. Throw an error if there is
* more than one occurrence.
*/
function replaceOnce(searchText: string, regex: RegExp, replaceText: string): OverwriteResult {
regex = new RegExp(regex.source, regex.flags + 'g' /* global */);
let position = -1;
const text = searchText.replace(regex, (...args) => {
if (position !== -1) {
throw new Error(`${regex} matches more than one occurrence in text: ${searchText}`);
}
position = args[args.length - 2]; // second last argument is always the index
return replaceText;
});
return {position, text};
}
const REF_MARKER = /«(((\w|\-)+)|([^ᐱ]*ᐱ(\w+)ᐱ.[^»]*))»/g;

View File

@ -8,7 +8,7 @@
import * as ts from 'typescript/lib/tsserverlibrary';
import {APP_MAIN, setup, TEST_SRCDIR} from './mock_host';
import {APP_COMPONENT, APP_MAIN, setup, TEST_SRCDIR} from './mock_host';
describe('mock host', () => {
const {project, service, tsLS} = setup();
@ -58,13 +58,93 @@ describe('mock host', () => {
expect(getText(scriptInfo)).toBe('const x: string = 0');
});
it('can find the cursor', () => {
const content = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
// content returned by overwrite() is the original content with cursor
expect(content).toBe(`const fo¦o = 'hello world';`);
const scriptInfo = service.getScriptInfo(APP_MAIN);
// script info content should not contain cursor
expect(getText(scriptInfo)).toBe(`const foo = 'hello world';`);
describe('overwrite()', () => {
it('will return the cursor position', () => {
const {position} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
expect(position).toBe(8);
});
it('will remove the cursor in overwritten text', () => {
const {text} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
expect(text).toBe(`const foo = 'hello world';`);
});
it('will update script info without cursor', () => {
const {text} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
const scriptInfo = service.getScriptInfo(APP_MAIN);
const snapshot = getText(scriptInfo);
expect(snapshot).toBe(`const foo = 'hello world';`);
expect(snapshot).toBe(text);
});
it('will throw if there is more than one cursor', () => {
expect(() => service.overwrite(APP_MAIN, `const f¦oo = 'hello wo¦rld';`))
.toThrowError(/matches more than one occurrence in text/);
});
it('will return -1 if cursor is not present', () => {
const {position} = service.overwrite(APP_MAIN, `const foo = 'hello world';`);
expect(position).toBe(-1);
});
});
describe('overwriteInlineTemplate()', () => {
it('will return the cursor position', () => {
const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`);
// The position returned should be relative to the start of the source
// file, not the start of the template.
expect(position).not.toBe(5);
expect(text.substring(position, position + 4)).toBe('o }}');
});
it('will remove the cursor in overwritten text', () => {
const {text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`);
expect(text).toContain(`{{ foo }}`);
});
it('will return the entire content of the source file', () => {
const {text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`);
expect(text).toContain(`@Component`);
});
it('will update script info without cursor', () => {
service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`);
const scriptInfo = service.getScriptInfo(APP_COMPONENT);
expect(getText(scriptInfo)).toContain(`{{ foo }}`);
});
it('will throw if there is no template in file', () => {
expect(() => service.overwriteInlineTemplate(APP_MAIN, `{{ foo }}`))
.toThrowError(/does not contain a component with template/);
});
it('will throw if there is more than one cursor', () => {
expect(() => service.overwriteInlineTemplate(APP_COMPONENT, `{{ f¦o¦o }}`))
.toThrowError(/matches more than one occurrence in text/);
});
it('will return -1 if cursor is not present', () => {
const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`);
expect(position).toBe(-1);
});
it('will throw if there is more than one component with template', () => {
service.overwrite(APP_COMPONENT, `
import {Component} from '@angular/core';
@Component({
template: \`<h1></h1>\`,
})
export class ComponentA {}
@Component({
template: \`<h2></h2>\`,
})
export class ComponentB {}
`);
expect(() => service.overwriteInlineTemplate(APP_COMPONENT, `<p></p>`))
.toThrowError(/matches more than one occurrence in text/);
});
});
});

View File

@ -75,29 +75,9 @@ export class MessageSerializer<T> extends BaseVisitor {
}
visitContainedNodes(nodes: Node[]): void {
const length = nodes.length;
let index = 0;
while (index < length) {
if (!this.isPlaceholderContainer(nodes[index])) {
const startOfContainedNodes = index;
while (index < length - 1) {
index++;
if (this.isPlaceholderContainer(nodes[index])) {
break;
}
}
if (index - startOfContainedNodes > 1) {
// Only create a container if there are two or more contained Nodes in a row
this.renderer.startContainer();
visitAll(this, nodes.slice(startOfContainedNodes, index - 1));
this.renderer.closeContainer();
}
}
if (index < length) {
nodes[index].visit(this, undefined);
}
index++;
}
this.renderer.startContainer();
visitAll(this, nodes);
this.renderer.closeContainer();
}
visitPlaceholder(name: string, body: string|undefined): void {

View File

@ -158,6 +158,46 @@ describe('Xliff1TranslationParser', () => {
['INTERPOLATION', 'START_BOLD_TEXT', 'CLOSE_BOLD_TEXT']));
});
it('should extract nested placeholder containers (i.e. nested HTML elements)', () => {
/**
* Source HTML:
*
* ```
* <div i18n>
* translatable <span>element <b>with placeholders</b></span> {{ interpolation}}
* </div>
* ```
*/
const XLIFF = [
`<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">`,
` <file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">`,
` <body>`,
` <trans-unit id="9051630253697141670" datatype="html">`,
` <source>translatable <x id="START_TAG_SPAN"/>element <x id="START_BOLD_TEXT"/>with placeholders<x id="CLOSE_BOLD_TEXT"/><x id="CLOSE_TAG_SPAN"/> <x id="INTERPOLATION"/></source>`,
` <target><x id="START_TAG_SPAN"/><x id="INTERPOLATION"/> tnemele<x id="CLOSE_TAG_SPAN"/> elbatalsnart <x id="START_BOLD_TEXT"/>sredlohecalp htiw<x id="CLOSE_BOLD_TEXT"/></target>`,
` <context-group purpose="location">`,
` <context context-type="sourcefile">file.ts</context>`,
` <context context-type="linenumber">3</context>`,
` </context-group>`,
` </trans-unit>`,
` </body>`,
` </file>`,
`</xliff>`,
].join('\n');
const result = doParse('/some/file.xlf', XLIFF);
expect(result.translations[ɵcomputeMsgId(
'translatable {$START_TAG_SPAN}element {$START_BOLD_TEXT}with placeholders' +
'{$CLOSE_BOLD_TEXT}{$CLOSE_TAG_SPAN} {$INTERPOLATION}')])
.toEqual(ɵmakeParsedTranslation(
['', '', ' tnemele', ' elbatalsnart ', 'sredlohecalp htiw', ''], [
'START_TAG_SPAN',
'INTERPOLATION',
'CLOSE_TAG_SPAN',
'START_BOLD_TEXT',
'CLOSE_BOLD_TEXT',
]));
});
it('should extract translations with placeholders containing hyphens', () => {
/**
* Source HTML:

View File

@ -114,13 +114,13 @@ describe(
* Source HTML:
*
* ```
* <div i18n>translatable element <b>>with placeholders</b> {{ interpolation}}</div>
* <div i18n>translatable element <b>with placeholders</b> {{ interpolation}}</div>
* ```
*/
const XLIFF = [
`<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">`,
` <file original="ng.template" id="ngi18n">`,
` <unit id="5057824347511785081">`,
` <unit id="6949438802869886378">`,
` <notes>`,
` <note category="location">file.ts:3</note>`,
` </notes>`,
@ -135,12 +135,58 @@ describe(
const result = doParse('/some/file.xlf', XLIFF);
expect(
result.translations[ɵcomputeMsgId(
'translatable element {$START_BOLD_TEXT}with placeholders{$LOSE_BOLD_TEXT} {$INTERPOLATION}')])
'translatable element {$START_BOLD_TEXT}with placeholders{$CLOSE_BOLD_TEXT} {$INTERPOLATION}')])
.toEqual(ɵmakeParsedTranslation(
['', ' tnemele elbatalsnart ', 'sredlohecalp htiw', ''],
['INTERPOLATION', 'START_BOLD_TEXT', 'CLOSE_BOLD_TEXT']));
});
it('should extract nested placeholder containers (i.e. nested HTML elements)', () => {
/**
* Source HTML:
*
* ```
* <div i18n>
* translatable <span>element <b>with placeholders</b></span> {{ interpolation}}
* </div>
* ```
*/
const XLIFF = [
`<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">`,
` <file original="ng.template" id="ngi18n">`,
` <unit id="9051630253697141670">`,
` <notes>`,
` <note category="location">file.ts:3</note>`,
` </notes>`,
` <segment>`,
` <source>translatable <pc id="0" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other"` +
` dispStart="&lt;span&gt;" dispEnd="&lt;/span&gt;">element <pc id="1" equivStart="START_BOLD_TEXT" equivEnd=` +
`"CLOSE_BOLD_TEXT" type="fmt" dispStart="&lt;b&gt;" dispEnd="&lt;/b&gt;">with placeholders</pc></pc>` +
` <ph id="2" equiv="INTERPOLATION" disp="{{ interpolation}}"/></source>`,
` <target><pc id="0" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="fmt" dispStart="&lt;` +
`span&gt;" dispEnd="&lt;/span&gt;"><ph id="2" equiv="INTERPOLATION" disp="{{ interpolation}}"/> tnemele</pc>` +
` elbatalsnart <pc id="1" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt" dispStart=` +
`"&lt;b&gt;" dispEnd="&lt;/b&gt;">sredlohecalp htiw</pc></target>`,
` </segment>`,
` </unit>`,
` </file>`,
`</xliff>`,
].join('\n');
const result = doParse('/some/file.xlf', XLIFF);
expect(
result.translations[ɵcomputeMsgId(
'translatable {$START_TAG_SPAN}element {$START_BOLD_TEXT}with placeholders' +
'{$CLOSE_BOLD_TEXT}{$CLOSE_TAG_SPAN} {$INTERPOLATION}')])
.toEqual(ɵmakeParsedTranslation(
['', '', ' tnemele', ' elbatalsnart ', 'sredlohecalp htiw', ''], [
'START_TAG_SPAN',
'INTERPOLATION',
'CLOSE_TAG_SPAN',
'START_BOLD_TEXT',
'CLOSE_BOLD_TEXT',
]));
});
it('should extract translations with simple ICU expressions', () => {
/**
* Source HTML:

View File

@ -96,6 +96,38 @@ describe('XtbTranslationParser', () => {
ɵmakeParsedTranslation(['', 'rab', ''], ['START_PARAGRAPH', 'CLOSE_PARAGRAPH']));
});
it('should extract nested placeholder containers (i.e. nested HTML elements)', () => {
/**
* Source HTML:
*
* ```
* <div i18n>
* translatable <span>element <b>with placeholders</b></span> {{ interpolation}}
* </div>
* ```
*/
const XLIFF = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<translationbundle>`,
` <translation id="9051630253697141670">` +
`<ph name="START_TAG_SPAN"/><ph name="INTERPOLATION"/> tnemele<ph name="CLOSE_TAG_SPAN"/> elbatalsnart <ph name="START_BOLD_TEXT"/>sredlohecalp htiw<ph name="CLOSE_BOLD_TEXT"/>` +
`</translation>`,
`</translationbundle>`,
].join('\n');
const result = doParse('/some/file.xtb', XLIFF);
expect(result.translations[ɵcomputeMsgId(
'translatable {$START_TAG_SPAN}element {$START_BOLD_TEXT}with placeholders' +
'{$CLOSE_BOLD_TEXT}{$CLOSE_TAG_SPAN} {$INTERPOLATION}')])
.toEqual(ɵmakeParsedTranslation(
['', '', ' tnemele', ' elbatalsnart ', 'sredlohecalp htiw', ''], [
'START_TAG_SPAN',
'INTERPOLATION',
'CLOSE_TAG_SPAN',
'START_BOLD_TEXT',
'CLOSE_BOLD_TEXT',
]));
});
it('should extract translations with simple ICU expressions', () => {
const XTB = [
`<?xml version="1.0" encoding="UTF-8" ?>`,

View File

@ -113,7 +113,7 @@ export function parseTranslation(messageString: TargetMessage): ParsedTranslatio
export function makeParsedTranslation(
messageParts: string[], placeholderNames: string[] = []): ParsedTranslation {
let messageString = messageParts[0];
for (let i = 0; i < placeholderNames.length - 1; i++) {
for (let i = 0; i < placeholderNames.length; i++) {
messageString += `{$${placeholderNames[i]}}${messageParts[i + 1]}`;
}
return {

View File

@ -5,7 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {computeMsgId, makeTemplateObject, ParsedTranslation, parseTranslation, TargetMessage, translate} from '..';
import {computeMsgId, makeParsedTranslation, makeTemplateObject, ParsedTranslation, parseTranslation, TargetMessage, translate} from '..';
describe('utils', () => {
describe('makeTemplateObject', () => {
@ -22,6 +22,24 @@ describe('utils', () => {
});
});
describe('makeParsedTranslation()', () => {
it('should compute a template object from the parts', () => {
expect(makeParsedTranslation(['a', 'b', 'c'], ['ph1', 'ph2']).messageParts)
.toEqual(makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']));
});
it('should include the placeholder names', () => {
expect(makeParsedTranslation(['a', 'b', 'c'], ['ph1', 'ph2']).placeholderNames).toEqual([
'ph1', 'ph2'
]);
});
it('should compute the message string from the parts and placeholder names', () => {
expect(makeParsedTranslation(['a', 'b', 'c'], ['ph1', 'ph2']).text)
.toEqual('a{$ph1}b{$ph2}c');
});
});
describe('parseTranslation', () => {
it('should extract the messageParts as a TemplateStringsArray', () => {
const translation = parseTranslation('a{$one}b{$two}c');