Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/compiler/src/ml_parser/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,12 @@ class _Tokenizer {
);
this._consumeQuote(quoteChar);
} else {
const endPredicate = () => isNameEnd(this._cursor.peek());
// Per the HTML spec, an unquoted attribute value is terminated by ASCII
// whitespace, `"`, `'`, `=`, `<`, `>` or EOF. Notably, `/` is _not_ a
// terminator: e.g. `<a href=https://example.com>` and
// `<img src=path/to/foo.png>` are both valid (see
// https://html.spec.whatwg.org/#unquoted).
const endPredicate = () => isUnquotedAttrValueEnd(this._cursor.peek());
this._consumeWithInterpolation(
TokenType.ATTR_VALUE_TEXT,
TokenType.ATTR_VALUE_INTERPOLATION,
Expand Down Expand Up @@ -1454,6 +1459,24 @@ function isNameEnd(code: number): boolean {
);
}

/**
* Predicate for the end of an unquoted attribute value. Mirrors `isNameEnd` but
* does _not_ include `/`, since the HTML spec allows `/` inside unquoted
* attribute values (e.g. `href=https://example.com` or `src=path/to/foo.png`).
* See https://html.spec.whatwg.org/#unquoted.
*/
function isUnquotedAttrValueEnd(code: number): boolean {
return (
chars.isWhitespace(code) ||
code === chars.$GT ||
code === chars.$LT ||
code === chars.$SQ ||
code === chars.$DQ ||
code === chars.$EQ ||
code === chars.$EOF
);
}

function isPrefixEnd(code: number): boolean {
return (
(code < chars.$a || chars.$z < code) &&
Expand Down
16 changes: 16 additions & 0 deletions packages/compiler/test/ml_parser/html_parser_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,22 @@ describe('HtmlParser', () => {
]);
});

it('should parse unquoted attribute values containing slashes', () => {
// Regression test for https://github.com/angular/angular/issues/36932.
// Per the HTML spec, `/` is allowed inside unquoted attribute values.
expect(
humanizeDom(parser.parse('<a href=https://example.com>Link</a>', 'TestComp')),
).toEqual([
[html.Element, 'a', 0],
[html.Attribute, 'href', 'https://example.com', ['https://example.com']],
[html.Text, 'Link', 1, ['Link']],
]);
expect(humanizeDom(parser.parse('<img src=path/to/foo.png>', 'TestComp'))).toEqual([
[html.Element, 'img', 0],
[html.Attribute, 'src', 'path/to/foo.png', ['path/to/foo.png']],
]);
});

it('should parse bound inputs with expressions containing newlines', () => {
expect(
humanizeDom(
Expand Down
49 changes: 49 additions & 0 deletions packages/compiler/test/ml_parser/lexer_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1822,6 +1822,55 @@ describe('HtmlLexer', () => {
]);
});

it('should parse attributes with unquoted value containing slashes (URL)', () => {
// Regression test for https://github.com/angular/angular/issues/36932.
// Per the HTML spec, `/` is allowed inside unquoted attribute values.
expect(tokenizeAndHumanizeParts('<a href=https://example.com>')).toEqual([
[TokenType.TAG_OPEN_START, '', 'a'],
[TokenType.ATTR_NAME, '', 'href'],
[TokenType.ATTR_VALUE_TEXT, 'https://example.com'],
[TokenType.TAG_OPEN_END],
[TokenType.EOF],
]);
});

it('should parse attributes with unquoted value containing slashes (path)', () => {
// Regression test for https://github.com/angular/angular/issues/36932.
expect(tokenizeAndHumanizeParts('<img src=path/to/foo.png>')).toEqual([
[TokenType.TAG_OPEN_START, '', 'img'],
[TokenType.ATTR_NAME, '', 'src'],
[TokenType.ATTR_VALUE_TEXT, 'path/to/foo.png'],
[TokenType.TAG_OPEN_END],
[TokenType.EOF],
]);
});

it('should still treat `<br/>` as a self-closing tag', () => {
// Regression-of-regression: ensure the self-closing slash is still
// handled at the tag-end state for tags without attributes.
expect(tokenizeAndHumanizeParts('<br/>')).toEqual([
[TokenType.TAG_OPEN_START, '', 'br'],
[TokenType.TAG_OPEN_END_VOID],
[TokenType.EOF],
]);
});

it('should consume trailing `/` as part of an unquoted attribute value per the HTML spec', () => {
// Regression test for https://github.com/angular/angular/issues/36932.
// Per https://html.spec.whatwg.org/#unquoted, the only characters that
// terminate an unquoted attribute value are whitespace, `"`, `'`, `=`,
// `<`, `>` and `` ` ``. A trailing `/` is therefore part of the value,
// so `<input type=text/>` has attribute `type="text/"` and is not a
// self-closing tag.
expect(tokenizeAndHumanizeParts('<input type=text/>')).toEqual([
[TokenType.TAG_OPEN_START, '', 'input'],
[TokenType.ATTR_NAME, '', 'type'],
[TokenType.ATTR_VALUE_TEXT, 'text/'],
[TokenType.TAG_OPEN_END],
[TokenType.EOF],
]);
});

it('should parse bound inputs with expressions containing newlines', () => {
expect(
tokenizeAndHumanizeParts(`<app-component
Expand Down
Loading