angular-html-parser
Advanced tools
Comparing version
@@ -14,555 +14,873 @@ /** | ||
export class TreeError extends ParseError { | ||
static create(elementName, span, msg) { | ||
return new TreeError(elementName, span, msg); | ||
} | ||
constructor(elementName, span, msg) { | ||
super(span, msg); | ||
this.elementName = elementName; | ||
} | ||
static create(elementName, span, msg) { | ||
return new TreeError(elementName, span, msg); | ||
} | ||
constructor(elementName, span, msg) { | ||
super(span, msg); | ||
this.elementName = elementName; | ||
} | ||
} | ||
export class ParseTreeResult { | ||
constructor(rootNodes, errors) { | ||
this.rootNodes = rootNodes; | ||
this.errors = errors; | ||
} | ||
constructor(rootNodes, errors) { | ||
this.rootNodes = rootNodes; | ||
this.errors = errors; | ||
} | ||
} | ||
export class Parser { | ||
constructor(getTagDefinition) { | ||
this.getTagDefinition = getTagDefinition; | ||
} | ||
parse(source, url, options, isTagNameCaseSensitive = false, getTagContentType) { | ||
const lowercasify = (fn) => (x, ...args) => fn(x.toLowerCase(), ...args); | ||
const getTagDefinition = isTagNameCaseSensitive ? this.getTagDefinition : lowercasify(this.getTagDefinition); | ||
const getDefaultTagContentType = (tagName) => getTagDefinition(tagName).getContentType(); | ||
const getTagContentTypeWithProcessedTagName = isTagNameCaseSensitive ? getTagContentType : lowercasify(getTagContentType); | ||
const _getTagContentType = getTagContentType ? | ||
(tagName, prefix, hasParent, attrs) => { | ||
const contentType = getTagContentTypeWithProcessedTagName(tagName, prefix, hasParent, attrs); | ||
return contentType !== undefined ? contentType : getDefaultTagContentType(tagName); | ||
} : | ||
getDefaultTagContentType; | ||
const tokenizeResult = tokenize(source, url, _getTagContentType, options); | ||
const canSelfClose = (options && options.canSelfClose) || false; | ||
const allowHtmComponentClosingTags = (options && options.allowHtmComponentClosingTags) || false; | ||
const parser = new _TreeBuilder(tokenizeResult.tokens, getTagDefinition, canSelfClose, allowHtmComponentClosingTags, isTagNameCaseSensitive); | ||
parser.build(); | ||
return new ParseTreeResult(parser.rootNodes, tokenizeResult.errors.concat(parser.errors)); | ||
} | ||
constructor(getTagDefinition) { | ||
this.getTagDefinition = getTagDefinition; | ||
} | ||
parse( | ||
source, | ||
url, | ||
options, | ||
isTagNameCaseSensitive = false, | ||
getTagContentType, | ||
) { | ||
const lowercasify = | ||
(fn) => | ||
(x, ...args) => | ||
fn(x.toLowerCase(), ...args); | ||
const getTagDefinition = isTagNameCaseSensitive | ||
? this.getTagDefinition | ||
: lowercasify(this.getTagDefinition); | ||
const getDefaultTagContentType = (tagName) => | ||
getTagDefinition(tagName).getContentType(); | ||
const getTagContentTypeWithProcessedTagName = isTagNameCaseSensitive | ||
? getTagContentType | ||
: lowercasify(getTagContentType); | ||
const _getTagContentType = getTagContentType | ||
? (tagName, prefix, hasParent, attrs) => { | ||
const contentType = getTagContentTypeWithProcessedTagName( | ||
tagName, | ||
prefix, | ||
hasParent, | ||
attrs, | ||
); | ||
return contentType !== undefined | ||
? contentType | ||
: getDefaultTagContentType(tagName); | ||
} | ||
: getDefaultTagContentType; | ||
const tokenizeResult = tokenize(source, url, _getTagContentType, options); | ||
const canSelfClose = (options && options.canSelfClose) || false; | ||
const allowHtmComponentClosingTags = | ||
(options && options.allowHtmComponentClosingTags) || false; | ||
const parser = new _TreeBuilder( | ||
tokenizeResult.tokens, | ||
getTagDefinition, | ||
canSelfClose, | ||
allowHtmComponentClosingTags, | ||
isTagNameCaseSensitive, | ||
); | ||
parser.build(); | ||
return new ParseTreeResult( | ||
parser.rootNodes, | ||
tokenizeResult.errors.concat(parser.errors), | ||
); | ||
} | ||
} | ||
class _TreeBuilder { | ||
constructor(tokens, getTagDefinition, canSelfClose, allowHtmComponentClosingTags, isTagNameCaseSensitive) { | ||
this.tokens = tokens; | ||
this.getTagDefinition = getTagDefinition; | ||
this.canSelfClose = canSelfClose; | ||
this.allowHtmComponentClosingTags = allowHtmComponentClosingTags; | ||
this.isTagNameCaseSensitive = isTagNameCaseSensitive; | ||
this._index = -1; | ||
this._containerStack = []; | ||
this.rootNodes = []; | ||
this.errors = []; | ||
constructor( | ||
tokens, | ||
getTagDefinition, | ||
canSelfClose, | ||
allowHtmComponentClosingTags, | ||
isTagNameCaseSensitive, | ||
) { | ||
this.tokens = tokens; | ||
this.getTagDefinition = getTagDefinition; | ||
this.canSelfClose = canSelfClose; | ||
this.allowHtmComponentClosingTags = allowHtmComponentClosingTags; | ||
this.isTagNameCaseSensitive = isTagNameCaseSensitive; | ||
this._index = -1; | ||
this._containerStack = []; | ||
this.rootNodes = []; | ||
this.errors = []; | ||
this._advance(); | ||
} | ||
build() { | ||
while (this._peek.type !== 34 /* TokenType.EOF */) { | ||
if ( | ||
this._peek.type === 0 /* TokenType.TAG_OPEN_START */ || | ||
this._peek.type === 4 /* TokenType.INCOMPLETE_TAG_OPEN */ | ||
) { | ||
this._consumeStartTag(this._advance()); | ||
} else if (this._peek.type === 3 /* TokenType.TAG_CLOSE */) { | ||
this._closeVoidElement(); | ||
this._consumeEndTag(this._advance()); | ||
} else if (this._peek.type === 12 /* TokenType.CDATA_START */) { | ||
this._closeVoidElement(); | ||
this._consumeCdata(this._advance()); | ||
} else if (this._peek.type === 10 /* TokenType.COMMENT_START */) { | ||
this._closeVoidElement(); | ||
this._consumeComment(this._advance()); | ||
} else if ( | ||
this._peek.type === 5 /* TokenType.TEXT */ || | ||
this._peek.type === 7 /* TokenType.RAW_TEXT */ || | ||
this._peek.type === 6 /* TokenType.ESCAPABLE_RAW_TEXT */ | ||
) { | ||
this._closeVoidElement(); | ||
this._consumeText(this._advance()); | ||
} else if (this._peek.type === 20 /* TokenType.EXPANSION_FORM_START */) { | ||
this._consumeExpansion(this._advance()); | ||
} else if (this._peek.type === 25 /* TokenType.BLOCK_OPEN_START */) { | ||
this._closeVoidElement(); | ||
this._consumeBlockOpen(this._advance()); | ||
} else if (this._peek.type === 27 /* TokenType.BLOCK_CLOSE */) { | ||
this._closeVoidElement(); | ||
this._consumeBlockClose(this._advance()); | ||
} else if (this._peek.type === 29 /* TokenType.INCOMPLETE_BLOCK_OPEN */) { | ||
this._closeVoidElement(); | ||
this._consumeIncompleteBlock(this._advance()); | ||
} else if (this._peek.type === 30 /* TokenType.LET_START */) { | ||
this._closeVoidElement(); | ||
this._consumeLet(this._advance()); | ||
} else if (this._peek.type === 18 /* TokenType.DOC_TYPE_START */) { | ||
this._consumeDocType(this._advance()); | ||
} else if (this._peek.type === 33 /* TokenType.INCOMPLETE_LET */) { | ||
this._closeVoidElement(); | ||
this._consumeIncompleteLet(this._advance()); | ||
} else { | ||
// Skip all other tokens... | ||
this._advance(); | ||
} | ||
} | ||
build() { | ||
while (this._peek.type !== 34 /* TokenType.EOF */) { | ||
if (this._peek.type === 0 /* TokenType.TAG_OPEN_START */ || | ||
this._peek.type === 4 /* TokenType.INCOMPLETE_TAG_OPEN */) { | ||
this._consumeStartTag(this._advance()); | ||
} | ||
else if (this._peek.type === 3 /* TokenType.TAG_CLOSE */) { | ||
this._closeVoidElement(); | ||
this._consumeEndTag(this._advance()); | ||
} | ||
else if (this._peek.type === 12 /* TokenType.CDATA_START */) { | ||
this._closeVoidElement(); | ||
this._consumeCdata(this._advance()); | ||
} | ||
else if (this._peek.type === 10 /* TokenType.COMMENT_START */) { | ||
this._closeVoidElement(); | ||
this._consumeComment(this._advance()); | ||
} | ||
else if (this._peek.type === 5 /* TokenType.TEXT */ || | ||
this._peek.type === 7 /* TokenType.RAW_TEXT */ || | ||
this._peek.type === 6 /* TokenType.ESCAPABLE_RAW_TEXT */) { | ||
this._closeVoidElement(); | ||
this._consumeText(this._advance()); | ||
} | ||
else if (this._peek.type === 20 /* TokenType.EXPANSION_FORM_START */) { | ||
this._consumeExpansion(this._advance()); | ||
} | ||
else if (this._peek.type === 25 /* TokenType.BLOCK_OPEN_START */) { | ||
this._closeVoidElement(); | ||
this._consumeBlockOpen(this._advance()); | ||
} | ||
else if (this._peek.type === 27 /* TokenType.BLOCK_CLOSE */) { | ||
this._closeVoidElement(); | ||
this._consumeBlockClose(this._advance()); | ||
} | ||
else if (this._peek.type === 29 /* TokenType.INCOMPLETE_BLOCK_OPEN */) { | ||
this._closeVoidElement(); | ||
this._consumeIncompleteBlock(this._advance()); | ||
} | ||
else if (this._peek.type === 30 /* TokenType.LET_START */) { | ||
this._closeVoidElement(); | ||
this._consumeLet(this._advance()); | ||
} | ||
else if (this._peek.type === 18 /* TokenType.DOC_TYPE_START */) { | ||
this._consumeDocType(this._advance()); | ||
} | ||
else if (this._peek.type === 33 /* TokenType.INCOMPLETE_LET */) { | ||
this._closeVoidElement(); | ||
this._consumeIncompleteLet(this._advance()); | ||
} | ||
else { | ||
// Skip all other tokens... | ||
this._advance(); | ||
} | ||
} | ||
for (const leftoverContainer of this._containerStack) { | ||
// Unlike HTML elements, blocks aren't closed implicitly by the end of the file. | ||
if (leftoverContainer instanceof html.Block) { | ||
this.errors.push(TreeError.create(leftoverContainer.name, leftoverContainer.sourceSpan, `Unclosed block "${leftoverContainer.name}"`)); | ||
} | ||
} | ||
for (const leftoverContainer of this._containerStack) { | ||
// Unlike HTML elements, blocks aren't closed implicitly by the end of the file. | ||
if (leftoverContainer instanceof html.Block) { | ||
this.errors.push( | ||
TreeError.create( | ||
leftoverContainer.name, | ||
leftoverContainer.sourceSpan, | ||
`Unclosed block "${leftoverContainer.name}"`, | ||
), | ||
); | ||
} | ||
} | ||
_advance() { | ||
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; | ||
} | ||
_advance() { | ||
const prev = this._peek; | ||
if (this._index < this.tokens.length - 1) { | ||
// Note: there is always an EOF token at the end | ||
this._index++; | ||
} | ||
_advanceIf(type) { | ||
if (this._peek.type === type) { | ||
return this._advance(); | ||
} | ||
return null; | ||
this._peek = this.tokens[this._index]; | ||
return prev; | ||
} | ||
_advanceIf(type) { | ||
if (this._peek.type === type) { | ||
return this._advance(); | ||
} | ||
_consumeCdata(startToken) { | ||
const text = this._advance(); | ||
const value = this._getText(text); | ||
const endToken = this._advanceIf(13 /* TokenType.CDATA_END */); | ||
this._addToParent(new html.CDATA(value, new ParseSourceSpan(startToken.sourceSpan.start, (endToken || text).sourceSpan.end), [text])); | ||
return null; | ||
} | ||
_consumeCdata(startToken) { | ||
const text = this._advance(); | ||
const value = this._getText(text); | ||
const endToken = this._advanceIf(13 /* TokenType.CDATA_END */); | ||
this._addToParent( | ||
new html.CDATA( | ||
value, | ||
new ParseSourceSpan( | ||
startToken.sourceSpan.start, | ||
(endToken || text).sourceSpan.end, | ||
), | ||
[text], | ||
), | ||
); | ||
} | ||
_consumeComment(token) { | ||
const text = this._advanceIf(7 /* TokenType.RAW_TEXT */); | ||
const endToken = this._advanceIf(11 /* TokenType.COMMENT_END */); | ||
const value = text != null ? text.parts[0].trim() : null; | ||
const sourceSpan = | ||
endToken == null | ||
? token.sourceSpan | ||
: new ParseSourceSpan( | ||
token.sourceSpan.start, | ||
endToken.sourceSpan.end, | ||
token.sourceSpan.fullStart, | ||
); | ||
this._addToParent(new html.Comment(value, sourceSpan)); | ||
} | ||
_consumeDocType(startToken) { | ||
const text = this._advanceIf(7 /* TokenType.RAW_TEXT */); | ||
const endToken = this._advanceIf(19 /* TokenType.DOC_TYPE_END */); | ||
const value = text != null ? text.parts[0].trim() : null; | ||
const sourceSpan = new ParseSourceSpan( | ||
startToken.sourceSpan.start, | ||
(endToken || text || startToken).sourceSpan.end, | ||
); | ||
this._addToParent(new html.DocType(value, sourceSpan)); | ||
} | ||
_consumeExpansion(token) { | ||
const switchValue = this._advance(); | ||
const type = this._advance(); | ||
const cases = []; | ||
// read = | ||
while (this._peek.type === 21 /* TokenType.EXPANSION_CASE_VALUE */) { | ||
const expCase = this._parseExpansionCase(); | ||
if (!expCase) return; // error | ||
cases.push(expCase); | ||
} | ||
_consumeComment(token) { | ||
const text = this._advanceIf(7 /* TokenType.RAW_TEXT */); | ||
const endToken = this._advanceIf(11 /* TokenType.COMMENT_END */); | ||
const value = text != null ? text.parts[0].trim() : null; | ||
const sourceSpan = endToken == null | ||
? token.sourceSpan | ||
: new ParseSourceSpan(token.sourceSpan.start, endToken.sourceSpan.end, token.sourceSpan.fullStart); | ||
this._addToParent(new html.Comment(value, sourceSpan)); | ||
// read the final } | ||
if (this._peek.type !== 24 /* TokenType.EXPANSION_FORM_END */) { | ||
this.errors.push( | ||
TreeError.create( | ||
null, | ||
this._peek.sourceSpan, | ||
`Invalid ICU message. Missing '}'.`, | ||
), | ||
); | ||
return; | ||
} | ||
_consumeDocType(startToken) { | ||
const text = this._advanceIf(7 /* TokenType.RAW_TEXT */); | ||
const endToken = this._advanceIf(19 /* TokenType.DOC_TYPE_END */); | ||
const value = text != null ? text.parts[0].trim() : null; | ||
const sourceSpan = new ParseSourceSpan(startToken.sourceSpan.start, (endToken || text || startToken).sourceSpan.end); | ||
this._addToParent(new html.DocType(value, sourceSpan)); | ||
const sourceSpan = new ParseSourceSpan( | ||
token.sourceSpan.start, | ||
this._peek.sourceSpan.end, | ||
token.sourceSpan.fullStart, | ||
); | ||
this._addToParent( | ||
new html.Expansion( | ||
switchValue.parts[0], | ||
type.parts[0], | ||
cases, | ||
sourceSpan, | ||
switchValue.sourceSpan, | ||
), | ||
); | ||
this._advance(); | ||
} | ||
_parseExpansionCase() { | ||
const value = this._advance(); | ||
// read { | ||
if (this._peek.type !== 22 /* TokenType.EXPANSION_CASE_EXP_START */) { | ||
this.errors.push( | ||
TreeError.create( | ||
null, | ||
this._peek.sourceSpan, | ||
`Invalid ICU message. Missing '{'.`, | ||
), | ||
); | ||
return null; | ||
} | ||
_consumeExpansion(token) { | ||
const switchValue = this._advance(); | ||
const type = this._advance(); | ||
const cases = []; | ||
// read = | ||
while (this._peek.type === 21 /* TokenType.EXPANSION_CASE_VALUE */) { | ||
const expCase = this._parseExpansionCase(); | ||
if (!expCase) | ||
return; // error | ||
cases.push(expCase); | ||
} | ||
// read the final } | ||
if (this._peek.type !== 24 /* 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, token.sourceSpan.fullStart); | ||
this._addToParent(new html.Expansion(switchValue.parts[0], type.parts[0], cases, sourceSpan, switchValue.sourceSpan)); | ||
this._advance(); | ||
// read until } | ||
const start = this._advance(); | ||
const exp = this._collectExpansionExpTokens(start); | ||
if (!exp) return null; | ||
const end = this._advance(); | ||
exp.push({ | ||
type: 34 /* TokenType.EOF */, | ||
parts: [], | ||
sourceSpan: end.sourceSpan, | ||
}); | ||
// parse everything in between { and } | ||
const expansionCaseParser = new _TreeBuilder( | ||
exp, | ||
this.getTagDefinition, | ||
this.canSelfClose, | ||
this.allowHtmComponentClosingTags, | ||
this.isTagNameCaseSensitive, | ||
); | ||
expansionCaseParser.build(); | ||
if (expansionCaseParser.errors.length > 0) { | ||
this.errors = this.errors.concat(expansionCaseParser.errors); | ||
return null; | ||
} | ||
_parseExpansionCase() { | ||
const value = this._advance(); | ||
// read { | ||
if (this._peek.type !== 22 /* TokenType.EXPANSION_CASE_EXP_START */) { | ||
this.errors.push(TreeError.create(null, this._peek.sourceSpan, `Invalid ICU message. Missing '{'.`)); | ||
return null; | ||
const sourceSpan = new ParseSourceSpan( | ||
value.sourceSpan.start, | ||
end.sourceSpan.end, | ||
value.sourceSpan.fullStart, | ||
); | ||
const expSourceSpan = new ParseSourceSpan( | ||
start.sourceSpan.start, | ||
end.sourceSpan.end, | ||
start.sourceSpan.fullStart, | ||
); | ||
return new html.ExpansionCase( | ||
value.parts[0], | ||
expansionCaseParser.rootNodes, | ||
sourceSpan, | ||
value.sourceSpan, | ||
expSourceSpan, | ||
); | ||
} | ||
_collectExpansionExpTokens(start) { | ||
const exp = []; | ||
const expansionFormStack = [22 /* TokenType.EXPANSION_CASE_EXP_START */]; | ||
while (true) { | ||
if ( | ||
this._peek.type === 20 /* TokenType.EXPANSION_FORM_START */ || | ||
this._peek.type === 22 /* TokenType.EXPANSION_CASE_EXP_START */ | ||
) { | ||
expansionFormStack.push(this._peek.type); | ||
} | ||
if (this._peek.type === 23 /* TokenType.EXPANSION_CASE_EXP_END */) { | ||
if ( | ||
lastOnStack( | ||
expansionFormStack, | ||
22 /* 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; | ||
} | ||
// read until } | ||
const start = this._advance(); | ||
const exp = this._collectExpansionExpTokens(start); | ||
if (!exp) | ||
return null; | ||
const end = this._advance(); | ||
exp.push({ type: 34 /* TokenType.EOF */, parts: [], sourceSpan: end.sourceSpan }); | ||
// parse everything in between { and } | ||
const expansionCaseParser = new _TreeBuilder(exp, this.getTagDefinition, this.canSelfClose, this.allowHtmComponentClosingTags, this.isTagNameCaseSensitive); | ||
expansionCaseParser.build(); | ||
if (expansionCaseParser.errors.length > 0) { | ||
this.errors = this.errors.concat(expansionCaseParser.errors); | ||
return null; | ||
} | ||
if (this._peek.type === 24 /* TokenType.EXPANSION_FORM_END */) { | ||
if ( | ||
lastOnStack( | ||
expansionFormStack, | ||
20 /* TokenType.EXPANSION_FORM_START */, | ||
) | ||
) { | ||
expansionFormStack.pop(); | ||
} else { | ||
this.errors.push( | ||
TreeError.create( | ||
null, | ||
start.sourceSpan, | ||
`Invalid ICU message. Missing '}'.`, | ||
), | ||
); | ||
return null; | ||
} | ||
const sourceSpan = new ParseSourceSpan(value.sourceSpan.start, end.sourceSpan.end, value.sourceSpan.fullStart); | ||
const expSourceSpan = new ParseSourceSpan(start.sourceSpan.start, end.sourceSpan.end, start.sourceSpan.fullStart); | ||
return new html.ExpansionCase(value.parts[0], expansionCaseParser.rootNodes, sourceSpan, value.sourceSpan, expSourceSpan); | ||
} | ||
if (this._peek.type === 34 /* TokenType.EOF */) { | ||
this.errors.push( | ||
TreeError.create( | ||
null, | ||
start.sourceSpan, | ||
`Invalid ICU message. Missing '}'.`, | ||
), | ||
); | ||
return null; | ||
} | ||
exp.push(this._advance()); | ||
} | ||
_collectExpansionExpTokens(start) { | ||
const exp = []; | ||
const expansionFormStack = [22 /* TokenType.EXPANSION_CASE_EXP_START */]; | ||
while (true) { | ||
if (this._peek.type === 20 /* TokenType.EXPANSION_FORM_START */ || | ||
this._peek.type === 22 /* TokenType.EXPANSION_CASE_EXP_START */) { | ||
expansionFormStack.push(this._peek.type); | ||
} | ||
if (this._peek.type === 23 /* TokenType.EXPANSION_CASE_EXP_END */) { | ||
if (lastOnStack(expansionFormStack, 22 /* 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 === 24 /* TokenType.EXPANSION_FORM_END */) { | ||
if (lastOnStack(expansionFormStack, 20 /* 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 === 34 /* TokenType.EOF */) { | ||
this.errors.push(TreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`)); | ||
return null; | ||
} | ||
exp.push(this._advance()); | ||
} | ||
} | ||
_getText(token) { | ||
let text = token.parts[0]; | ||
if (text.length > 0 && text[0] == "\n") { | ||
const parent = this._getClosestParentElement(); | ||
if ( | ||
parent != null && | ||
parent.children.length == 0 && | ||
this.getTagDefinition(parent.name).ignoreFirstLf | ||
) { | ||
text = text.substring(1); | ||
} | ||
} | ||
_getText(token) { | ||
let text = token.parts[0]; | ||
if (text.length > 0 && text[0] == '\n') { | ||
const parent = this._getClosestParentElement(); | ||
if (parent != null && parent.children.length == 0 && | ||
this.getTagDefinition(parent.name).ignoreFirstLf) { | ||
text = text.substring(1); | ||
} | ||
} | ||
return text; | ||
return text; | ||
} | ||
_consumeText(token) { | ||
const tokens = [token]; | ||
const startSpan = token.sourceSpan; | ||
let text = token.parts[0]; | ||
if (text.length > 0 && text[0] === "\n") { | ||
const parent = this._getContainer(); | ||
if ( | ||
parent != null && | ||
parent.children.length === 0 && | ||
this.getTagDefinition(parent.name).ignoreFirstLf | ||
) { | ||
text = text.substring(1); | ||
tokens[0] = { | ||
type: token.type, | ||
sourceSpan: token.sourceSpan, | ||
parts: [text], | ||
}; | ||
} | ||
} | ||
_consumeText(token) { | ||
const tokens = [token]; | ||
const startSpan = token.sourceSpan; | ||
let text = token.parts[0]; | ||
if (text.length > 0 && text[0] === '\n') { | ||
const parent = this._getContainer(); | ||
if (parent != null && | ||
parent.children.length === 0 && | ||
this.getTagDefinition(parent.name).ignoreFirstLf) { | ||
text = text.substring(1); | ||
tokens[0] = { type: token.type, sourceSpan: token.sourceSpan, parts: [text] }; | ||
} | ||
} | ||
while (this._peek.type === 8 /* TokenType.INTERPOLATION */ || | ||
this._peek.type === 5 /* TokenType.TEXT */ || | ||
this._peek.type === 9 /* TokenType.ENCODED_ENTITY */) { | ||
token = this._advance(); | ||
tokens.push(token); | ||
if (token.type === 8 /* TokenType.INTERPOLATION */) { | ||
// For backward compatibility we decode HTML entities that appear in interpolation | ||
// expressions. This is arguably a bug, but it could be a considerable breaking change to | ||
// fix it. It should be addressed in a larger project to refactor the entire parser/lexer | ||
// chain after View Engine has been removed. | ||
text += token.parts.join('').replace(/&([^;]+);/g, decodeEntity); | ||
} | ||
else if (token.type === 9 /* TokenType.ENCODED_ENTITY */) { | ||
text += token.parts[0]; | ||
} | ||
else { | ||
text += token.parts.join(''); | ||
} | ||
} | ||
if (text.length > 0) { | ||
const endSpan = token.sourceSpan; | ||
this._addToParent(new html.Text(text, new ParseSourceSpan(startSpan.start, endSpan.end, startSpan.fullStart, startSpan.details), tokens)); | ||
} | ||
while ( | ||
this._peek.type === 8 /* TokenType.INTERPOLATION */ || | ||
this._peek.type === 5 /* TokenType.TEXT */ || | ||
this._peek.type === 9 /* TokenType.ENCODED_ENTITY */ | ||
) { | ||
token = this._advance(); | ||
tokens.push(token); | ||
if (token.type === 8 /* TokenType.INTERPOLATION */) { | ||
// For backward compatibility we decode HTML entities that appear in interpolation | ||
// expressions. This is arguably a bug, but it could be a considerable breaking change to | ||
// fix it. It should be addressed in a larger project to refactor the entire parser/lexer | ||
// chain after View Engine has been removed. | ||
text += token.parts.join("").replace(/&([^;]+);/g, decodeEntity); | ||
} else if (token.type === 9 /* TokenType.ENCODED_ENTITY */) { | ||
text += token.parts[0]; | ||
} else { | ||
text += token.parts.join(""); | ||
} | ||
} | ||
_closeVoidElement() { | ||
const el = this._getContainer(); | ||
if (el instanceof html.Element && this.getTagDefinition(el.name).isVoid) { | ||
this._containerStack.pop(); | ||
} | ||
if (text.length > 0) { | ||
const endSpan = token.sourceSpan; | ||
this._addToParent( | ||
new html.Text( | ||
text, | ||
new ParseSourceSpan( | ||
startSpan.start, | ||
endSpan.end, | ||
startSpan.fullStart, | ||
startSpan.details, | ||
), | ||
tokens, | ||
), | ||
); | ||
} | ||
_consumeStartTag(startTagToken) { | ||
const [prefix, name] = startTagToken.parts; | ||
const attrs = []; | ||
while (this._peek.type === 14 /* TokenType.ATTR_NAME */) { | ||
attrs.push(this._consumeAttr(this._advance())); | ||
} | ||
const fullName = this._getElementFullName(prefix, name, this._getClosestParentElement()); | ||
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 === 2 /* TokenType.TAG_OPEN_END_VOID */) { | ||
this._advance(); | ||
selfClosing = true; | ||
const tagDef = this.getTagDefinition(fullName); | ||
if (!(this.canSelfClose || tagDef.canSelfClose || getNsPrefix(fullName) !== null || tagDef.isVoid)) { | ||
this.errors.push(TreeError.create(fullName, startTagToken.sourceSpan, `Only void, custom and foreign elements can be self closed "${startTagToken.parts[1]}"`)); | ||
} | ||
} | ||
else if (this._peek.type === 1 /* TokenType.TAG_OPEN_END */) { | ||
this._advance(); | ||
selfClosing = false; | ||
} | ||
const end = this._peek.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan(startTagToken.sourceSpan.start, end, startTagToken.sourceSpan.fullStart); | ||
// Create a separate `startSpan` because `span` will be modified when there is an `end` span. | ||
const startSpan = new ParseSourceSpan(startTagToken.sourceSpan.start, end, startTagToken.sourceSpan.fullStart); | ||
const nameSpan = new ParseSourceSpan(startTagToken.sourceSpan.start.moveBy(1), startTagToken.sourceSpan.end); | ||
const el = new html.Element(fullName, attrs, [], span, startSpan, undefined, nameSpan); | ||
const parentEl = this._getContainer(); | ||
this._pushContainer(el, parentEl instanceof html.Element && | ||
this.getTagDefinition(parentEl.name).isClosedByChild(el.name)); | ||
if (selfClosing) { | ||
// Elements that are self-closed have their `endSourceSpan` set to the full span, as the | ||
// element start tag also represents the end tag. | ||
this._popContainer(fullName, html.Element, span); | ||
} | ||
else if (startTagToken.type === 4 /* TokenType.INCOMPLETE_TAG_OPEN */) { | ||
// We already know the opening tag is not complete, so it is unlikely it has a corresponding | ||
// close tag. Let's optimistically parse it as a full element and emit an error. | ||
this._popContainer(fullName, html.Element, null); | ||
this.errors.push(TreeError.create(fullName, span, `Opening tag "${fullName}" not terminated.`)); | ||
} | ||
} | ||
_closeVoidElement() { | ||
const el = this._getContainer(); | ||
if (el instanceof html.Element && this.getTagDefinition(el.name).isVoid) { | ||
this._containerStack.pop(); | ||
} | ||
_pushContainer(node, isClosedByChild) { | ||
if (isClosedByChild) { | ||
this._containerStack.pop(); | ||
} | ||
this._addToParent(node); | ||
this._containerStack.push(node); | ||
} | ||
_consumeStartTag(startTagToken) { | ||
const [prefix, name] = startTagToken.parts; | ||
const attrs = []; | ||
while (this._peek.type === 14 /* TokenType.ATTR_NAME */) { | ||
attrs.push(this._consumeAttr(this._advance())); | ||
} | ||
_consumeEndTag(endTagToken) { | ||
// @ts-expect-error -- in angular-html-parser endTagToken.parts.length can be 0 (HTM component | ||
// end-tags) | ||
const fullName = this.allowHtmComponentClosingTags && endTagToken.parts.length === 0 ? | ||
null : | ||
this._getElementFullName(endTagToken.parts[0], endTagToken.parts[1], this._getClosestParentElement()); | ||
if (fullName && 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._popContainer(fullName, html.Element, endTagToken.sourceSpan)) { | ||
const errMsg = `Unexpected closing tag "${fullName}". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags`; | ||
this.errors.push(TreeError.create(fullName, endTagToken.sourceSpan, errMsg)); | ||
} | ||
const fullName = this._getElementFullName( | ||
prefix, | ||
name, | ||
this._getClosestParentElement(), | ||
); | ||
console.log({ prefix, name, fullName }); | ||
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 === 2 /* TokenType.TAG_OPEN_END_VOID */) { | ||
this._advance(); | ||
selfClosing = true; | ||
const tagDef = this.getTagDefinition(fullName); | ||
if ( | ||
!( | ||
this.canSelfClose || | ||
tagDef.canSelfClose || | ||
getNsPrefix(fullName) !== null || | ||
tagDef.isVoid | ||
) | ||
) { | ||
this.errors.push( | ||
TreeError.create( | ||
fullName, | ||
startTagToken.sourceSpan, | ||
`Only void, custom and foreign elements can be self closed "${startTagToken.parts[1]}"`, | ||
), | ||
); | ||
} | ||
} else if (this._peek.type === 1 /* TokenType.TAG_OPEN_END */) { | ||
this._advance(); | ||
selfClosing = false; | ||
} | ||
/** | ||
* Closes the nearest element with the tag name `fullName` in the parse tree. | ||
* `endSourceSpan` is the span of the closing tag, or null if the element does | ||
* not have a closing tag (for example, this happens when an incomplete | ||
* opening tag is recovered). | ||
*/ | ||
_popContainer(expectedName, expectedType, endSourceSpan) { | ||
let unexpectedCloseTagDetected = false; | ||
for (let stackIndex = this._containerStack.length - 1; stackIndex >= 0; stackIndex--) { | ||
const node = this._containerStack[stackIndex]; | ||
if (( /* isForeignElement */(getNsPrefix(node.name) ? node.name === expectedName : (expectedName == null || node.name.toLowerCase() === expectedName.toLowerCase()) && | ||
node instanceof expectedType))) { | ||
// Record the parse span with the element that is being closed. Any elements that are | ||
// removed from the element stack at this point are closed implicitly, so they won't get | ||
// an end source span (as there is no explicit closing element). | ||
node.endSourceSpan = endSourceSpan; | ||
node.sourceSpan.end = endSourceSpan !== null ? endSourceSpan.end : node.sourceSpan.end; | ||
this._containerStack.splice(stackIndex, this._containerStack.length - stackIndex); | ||
return !unexpectedCloseTagDetected; | ||
} | ||
// Blocks and most elements are not self closing. | ||
if (node instanceof html.Block || | ||
(node instanceof html.Element && !this.getTagDefinition(node.name).closedByParent)) { | ||
// Note that we encountered an unexpected close tag but continue processing the element | ||
// stack so we can assign an `endSourceSpan` if there is a corresponding start tag for this | ||
// end tag in the stack. | ||
unexpectedCloseTagDetected = true; | ||
} | ||
} | ||
return false; | ||
const end = this._peek.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan( | ||
startTagToken.sourceSpan.start, | ||
end, | ||
startTagToken.sourceSpan.fullStart, | ||
); | ||
// Create a separate `startSpan` because `span` will be modified when there is an `end` span. | ||
const startSpan = new ParseSourceSpan( | ||
startTagToken.sourceSpan.start, | ||
end, | ||
startTagToken.sourceSpan.fullStart, | ||
); | ||
const nameSpan = new ParseSourceSpan( | ||
startTagToken.sourceSpan.start.moveBy(1), | ||
startTagToken.sourceSpan.end, | ||
); | ||
const el = new html.Element( | ||
fullName, | ||
attrs, | ||
[], | ||
span, | ||
startSpan, | ||
undefined, | ||
nameSpan, | ||
); | ||
const parentEl = this._getContainer(); | ||
this._pushContainer( | ||
el, | ||
parentEl instanceof html.Element && | ||
this.getTagDefinition(parentEl.name).isClosedByChild(el.name), | ||
); | ||
if (selfClosing) { | ||
// Elements that are self-closed have their `endSourceSpan` set to the full span, as the | ||
// element start tag also represents the end tag. | ||
this._popContainer(fullName, html.Element, span); | ||
} else if (startTagToken.type === 4 /* TokenType.INCOMPLETE_TAG_OPEN */) { | ||
// We already know the opening tag is not complete, so it is unlikely it has a corresponding | ||
// close tag. Let's optimistically parse it as a full element and emit an error. | ||
this._popContainer(fullName, html.Element, null); | ||
this.errors.push( | ||
TreeError.create( | ||
fullName, | ||
span, | ||
`Opening tag "${fullName}" not terminated.`, | ||
), | ||
); | ||
} | ||
_consumeAttr(attrName) { | ||
const fullName = mergeNsAndName(attrName.parts[0], attrName.parts[1]); | ||
let attrEnd = attrName.sourceSpan.end; | ||
let startQuoteToken; | ||
// Consume any quote | ||
if (this._peek.type === 15 /* TokenType.ATTR_QUOTE */) { | ||
startQuoteToken = this._advance(); | ||
} | ||
// Consume the attribute value | ||
let value = ''; | ||
const valueTokens = []; | ||
let valueStartSpan = undefined; | ||
let valueEnd = undefined; | ||
// NOTE: We need to use a new variable `nextTokenType` here to hide the actual type of | ||
// `_peek.type` from TS. Otherwise TS will narrow the type of `_peek.type` preventing it from | ||
// being able to consider `ATTR_VALUE_INTERPOLATION` as an option. This is because TS is not | ||
// able to see that `_advance()` will actually mutate `_peek`. | ||
const nextTokenType = this._peek.type; | ||
if (nextTokenType === 16 /* TokenType.ATTR_VALUE_TEXT */) { | ||
valueStartSpan = this._peek.sourceSpan; | ||
valueEnd = this._peek.sourceSpan.end; | ||
while (this._peek.type === 16 /* TokenType.ATTR_VALUE_TEXT */ || | ||
this._peek.type === 17 /* TokenType.ATTR_VALUE_INTERPOLATION */ || | ||
this._peek.type === 9 /* TokenType.ENCODED_ENTITY */) { | ||
const valueToken = this._advance(); | ||
valueTokens.push(valueToken); | ||
if (valueToken.type === 17 /* TokenType.ATTR_VALUE_INTERPOLATION */) { | ||
// For backward compatibility we decode HTML entities that appear in interpolation | ||
// expressions. This is arguably a bug, but it could be a considerable breaking change to | ||
// fix it. It should be addressed in a larger project to refactor the entire parser/lexer | ||
// chain after View Engine has been removed. | ||
value += valueToken.parts.join('').replace(/&([^;]+);/g, decodeEntity); | ||
} | ||
else if (valueToken.type === 9 /* TokenType.ENCODED_ENTITY */) { | ||
value += valueToken.parts[0]; | ||
} | ||
else { | ||
value += valueToken.parts.join(''); | ||
} | ||
valueEnd = attrEnd = valueToken.sourceSpan.end; | ||
} | ||
} | ||
// Consume any quote | ||
if (this._peek.type === 15 /* TokenType.ATTR_QUOTE */) { | ||
const quoteToken = this._advance(); | ||
valueEnd = attrEnd = quoteToken.sourceSpan.end; | ||
} | ||
const valueSpan = valueStartSpan && | ||
valueEnd && | ||
new ParseSourceSpan(startQuoteToken?.sourceSpan.start ?? valueStartSpan.start, valueEnd, startQuoteToken?.sourceSpan.fullStart ?? valueStartSpan.fullStart); | ||
return new html.Attribute(fullName, value, new ParseSourceSpan(attrName.sourceSpan.start, attrEnd, attrName.sourceSpan.fullStart), attrName.sourceSpan, valueSpan, valueTokens.length > 0 ? valueTokens : undefined, undefined); | ||
} | ||
_pushContainer(node, isClosedByChild) { | ||
if (isClosedByChild) { | ||
this._containerStack.pop(); | ||
} | ||
_consumeBlockOpen(token) { | ||
const parameters = []; | ||
while (this._peek.type === 28 /* TokenType.BLOCK_PARAMETER */) { | ||
const paramToken = this._advance(); | ||
parameters.push(new html.BlockParameter(paramToken.parts[0], paramToken.sourceSpan)); | ||
} | ||
if (this._peek.type === 26 /* TokenType.BLOCK_OPEN_END */) { | ||
this._advance(); | ||
} | ||
const end = this._peek.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart); | ||
// Create a separate `startSpan` because `span` will be modified when there is an `end` span. | ||
const startSpan = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart); | ||
const block = new html.Block(token.parts[0], parameters, [], span, token.sourceSpan, startSpan); | ||
this._pushContainer(block, false); | ||
this._addToParent(node); | ||
this._containerStack.push(node); | ||
} | ||
_consumeEndTag(endTagToken) { | ||
// @ts-expect-error -- in angular-html-parser endTagToken.parts.length can be 0 (HTM component | ||
// end-tags) | ||
const fullName = | ||
this.allowHtmComponentClosingTags && endTagToken.parts.length === 0 | ||
? null | ||
: this._getElementFullName( | ||
endTagToken.parts[0], | ||
endTagToken.parts[1], | ||
this._getClosestParentElement(), | ||
); | ||
if (fullName && 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._popContainer(fullName, html.Element, endTagToken.sourceSpan) | ||
) { | ||
const errMsg = `Unexpected closing tag "${fullName}". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags`; | ||
this.errors.push( | ||
TreeError.create(fullName, endTagToken.sourceSpan, errMsg), | ||
); | ||
} | ||
_consumeBlockClose(token) { | ||
if (!this._popContainer(null, html.Block, token.sourceSpan)) { | ||
this.errors.push(TreeError.create(null, token.sourceSpan, `Unexpected closing block. The block may have been closed earlier. ` + | ||
`If you meant to write the } character, you should use the "}" ` + | ||
`HTML entity instead.`)); | ||
} | ||
} | ||
/** | ||
* Closes the nearest element with the tag name `fullName` in the parse tree. | ||
* `endSourceSpan` is the span of the closing tag, or null if the element does | ||
* not have a closing tag (for example, this happens when an incomplete | ||
* opening tag is recovered). | ||
*/ | ||
_popContainer(expectedName, expectedType, endSourceSpan) { | ||
let unexpectedCloseTagDetected = false; | ||
for ( | ||
let stackIndex = this._containerStack.length - 1; | ||
stackIndex >= 0; | ||
stackIndex-- | ||
) { | ||
const node = this._containerStack[stackIndex]; | ||
if ( | ||
/* isForeignElement */ getNsPrefix(node.name) | ||
? node.name === expectedName | ||
: (expectedName == null || | ||
node.name.toLowerCase() === expectedName.toLowerCase()) && | ||
node instanceof expectedType | ||
) { | ||
// Record the parse span with the element that is being closed. Any elements that are | ||
// removed from the element stack at this point are closed implicitly, so they won't get | ||
// an end source span (as there is no explicit closing element). | ||
node.endSourceSpan = endSourceSpan; | ||
node.sourceSpan.end = | ||
endSourceSpan !== null ? endSourceSpan.end : node.sourceSpan.end; | ||
this._containerStack.splice( | ||
stackIndex, | ||
this._containerStack.length - stackIndex, | ||
); | ||
return !unexpectedCloseTagDetected; | ||
} | ||
// Blocks and most elements are not self closing. | ||
if ( | ||
node instanceof html.Block || | ||
(node instanceof html.Element && | ||
!this.getTagDefinition(node.name).closedByParent) | ||
) { | ||
// Note that we encountered an unexpected close tag but continue processing the element | ||
// stack so we can assign an `endSourceSpan` if there is a corresponding start tag for this | ||
// end tag in the stack. | ||
unexpectedCloseTagDetected = true; | ||
} | ||
} | ||
_consumeIncompleteBlock(token) { | ||
const parameters = []; | ||
while (this._peek.type === 28 /* TokenType.BLOCK_PARAMETER */) { | ||
const paramToken = this._advance(); | ||
parameters.push(new html.BlockParameter(paramToken.parts[0], paramToken.sourceSpan)); | ||
} | ||
const end = this._peek.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart); | ||
// Create a separate `startSpan` because `span` will be modified when there is an `end` span. | ||
const startSpan = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart); | ||
const block = new html.Block(token.parts[0], parameters, [], span, token.sourceSpan, startSpan); | ||
this._pushContainer(block, false); | ||
// Incomplete blocks don't have children so we close them immediately and report an error. | ||
this._popContainer(null, html.Block, null); | ||
this.errors.push(TreeError.create(token.parts[0], span, `Incomplete block "${token.parts[0]}". If you meant to write the @ character, ` + | ||
`you should use the "@" HTML entity instead.`)); | ||
return false; | ||
} | ||
_consumeAttr(attrName) { | ||
const fullName = mergeNsAndName(attrName.parts[0], attrName.parts[1]); | ||
let attrEnd = attrName.sourceSpan.end; | ||
let startQuoteToken; | ||
// Consume any quote | ||
if (this._peek.type === 15 /* TokenType.ATTR_QUOTE */) { | ||
startQuoteToken = this._advance(); | ||
} | ||
_consumeLet(startToken) { | ||
const name = startToken.parts[0]; | ||
let valueToken; | ||
let endToken; | ||
if (this._peek.type !== 31 /* TokenType.LET_VALUE */) { | ||
this.errors.push(TreeError.create(startToken.parts[0], startToken.sourceSpan, `Invalid @let declaration "${name}". Declaration must have a value.`)); | ||
return; | ||
// Consume the attribute value | ||
let value = ""; | ||
const valueTokens = []; | ||
let valueStartSpan = undefined; | ||
let valueEnd = undefined; | ||
// NOTE: We need to use a new variable `nextTokenType` here to hide the actual type of | ||
// `_peek.type` from TS. Otherwise TS will narrow the type of `_peek.type` preventing it from | ||
// being able to consider `ATTR_VALUE_INTERPOLATION` as an option. This is because TS is not | ||
// able to see that `_advance()` will actually mutate `_peek`. | ||
const nextTokenType = this._peek.type; | ||
if (nextTokenType === 16 /* TokenType.ATTR_VALUE_TEXT */) { | ||
valueStartSpan = this._peek.sourceSpan; | ||
valueEnd = this._peek.sourceSpan.end; | ||
while ( | ||
this._peek.type === 16 /* TokenType.ATTR_VALUE_TEXT */ || | ||
this._peek.type === 17 /* TokenType.ATTR_VALUE_INTERPOLATION */ || | ||
this._peek.type === 9 /* TokenType.ENCODED_ENTITY */ | ||
) { | ||
const valueToken = this._advance(); | ||
valueTokens.push(valueToken); | ||
if (valueToken.type === 17 /* TokenType.ATTR_VALUE_INTERPOLATION */) { | ||
// For backward compatibility we decode HTML entities that appear in interpolation | ||
// expressions. This is arguably a bug, but it could be a considerable breaking change to | ||
// fix it. It should be addressed in a larger project to refactor the entire parser/lexer | ||
// chain after View Engine has been removed. | ||
value += valueToken.parts | ||
.join("") | ||
.replace(/&([^;]+);/g, decodeEntity); | ||
} else if (valueToken.type === 9 /* TokenType.ENCODED_ENTITY */) { | ||
value += valueToken.parts[0]; | ||
} else { | ||
value += valueToken.parts.join(""); | ||
} | ||
else { | ||
valueToken = this._advance(); | ||
} | ||
// Type cast is necessary here since TS narrowed the type of `peek` above. | ||
if (this._peek.type !== 32 /* TokenType.LET_END */) { | ||
this.errors.push(TreeError.create(startToken.parts[0], startToken.sourceSpan, `Unterminated @let declaration "${name}". Declaration must be terminated with a semicolon.`)); | ||
return; | ||
} | ||
else { | ||
endToken = this._advance(); | ||
} | ||
const end = endToken.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan(startToken.sourceSpan.start, end, startToken.sourceSpan.fullStart); | ||
// The start token usually captures the `@let`. Construct a name span by | ||
// offsetting the start by the length of any text before the name. | ||
const startOffset = startToken.sourceSpan.toString().lastIndexOf(name); | ||
const nameStart = startToken.sourceSpan.start.moveBy(startOffset); | ||
const nameSpan = new ParseSourceSpan(nameStart, startToken.sourceSpan.end); | ||
const node = new html.LetDeclaration(name, valueToken.parts[0], span, nameSpan, valueToken.sourceSpan); | ||
this._addToParent(node); | ||
valueEnd = attrEnd = valueToken.sourceSpan.end; | ||
} | ||
} | ||
_consumeIncompleteLet(token) { | ||
// Incomplete `@let` declaration may end up with an empty name. | ||
const name = token.parts[0] ?? ''; | ||
const nameString = name ? ` "${name}"` : ''; | ||
// If there's at least a name, we can salvage an AST node that can be used for completions. | ||
if (name.length > 0) { | ||
const startOffset = token.sourceSpan.toString().lastIndexOf(name); | ||
const nameStart = token.sourceSpan.start.moveBy(startOffset); | ||
const nameSpan = new ParseSourceSpan(nameStart, token.sourceSpan.end); | ||
const valueSpan = new ParseSourceSpan(token.sourceSpan.start, token.sourceSpan.start.moveBy(0)); | ||
const node = new html.LetDeclaration(name, '', token.sourceSpan, nameSpan, valueSpan); | ||
this._addToParent(node); | ||
} | ||
this.errors.push(TreeError.create(token.parts[0], token.sourceSpan, `Incomplete @let declaration${nameString}. ` + | ||
`@let declarations must be written as \`@let <name> = <value>;\``)); | ||
// Consume any quote | ||
if (this._peek.type === 15 /* TokenType.ATTR_QUOTE */) { | ||
const quoteToken = this._advance(); | ||
valueEnd = attrEnd = quoteToken.sourceSpan.end; | ||
} | ||
_getContainer() { | ||
return this._containerStack.length > 0 | ||
? this._containerStack[this._containerStack.length - 1] | ||
: null; | ||
const valueSpan = | ||
valueStartSpan && | ||
valueEnd && | ||
new ParseSourceSpan( | ||
startQuoteToken?.sourceSpan.start ?? valueStartSpan.start, | ||
valueEnd, | ||
startQuoteToken?.sourceSpan.fullStart ?? valueStartSpan.fullStart, | ||
); | ||
return new html.Attribute( | ||
fullName, | ||
value, | ||
new ParseSourceSpan( | ||
attrName.sourceSpan.start, | ||
attrEnd, | ||
attrName.sourceSpan.fullStart, | ||
), | ||
attrName.sourceSpan, | ||
valueSpan, | ||
valueTokens.length > 0 ? valueTokens : undefined, | ||
undefined, | ||
); | ||
} | ||
_consumeBlockOpen(token) { | ||
const parameters = []; | ||
while (this._peek.type === 28 /* TokenType.BLOCK_PARAMETER */) { | ||
const paramToken = this._advance(); | ||
parameters.push( | ||
new html.BlockParameter(paramToken.parts[0], paramToken.sourceSpan), | ||
); | ||
} | ||
_getClosestParentElement() { | ||
for (let i = this._containerStack.length - 1; i > -1; i--) { | ||
if (this._containerStack[i] instanceof html.Element) { | ||
return this._containerStack[i]; | ||
} | ||
} | ||
return null; | ||
if (this._peek.type === 26 /* TokenType.BLOCK_OPEN_END */) { | ||
this._advance(); | ||
} | ||
_addToParent(node) { | ||
const parent = this._getContainer(); | ||
if (parent === null) { | ||
this.rootNodes.push(node); | ||
} | ||
else { | ||
parent.children.push(node); | ||
} | ||
const end = this._peek.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan( | ||
token.sourceSpan.start, | ||
end, | ||
token.sourceSpan.fullStart, | ||
); | ||
// Create a separate `startSpan` because `span` will be modified when there is an `end` span. | ||
const startSpan = new ParseSourceSpan( | ||
token.sourceSpan.start, | ||
end, | ||
token.sourceSpan.fullStart, | ||
); | ||
const block = new html.Block( | ||
token.parts[0], | ||
parameters, | ||
[], | ||
span, | ||
token.sourceSpan, | ||
startSpan, | ||
); | ||
this._pushContainer(block, false); | ||
} | ||
_consumeBlockClose(token) { | ||
if (!this._popContainer(null, html.Block, token.sourceSpan)) { | ||
this.errors.push( | ||
TreeError.create( | ||
null, | ||
token.sourceSpan, | ||
`Unexpected closing block. The block may have been closed earlier. ` + | ||
`If you meant to write the } character, you should use the "}" ` + | ||
`HTML entity instead.`, | ||
), | ||
); | ||
} | ||
_getElementFullName(prefix, localName, parentElement) { | ||
if (prefix === '') { | ||
prefix = this.getTagDefinition(localName).implicitNamespacePrefix || ''; | ||
if (prefix === '' && parentElement != null) { | ||
const parentTagName = splitNsName(parentElement.name)[1]; | ||
const parentTagDefinition = this.getTagDefinition(parentTagName); | ||
if (!parentTagDefinition.preventNamespaceInheritance) { | ||
prefix = getNsPrefix(parentElement.name); | ||
} | ||
} | ||
} | ||
_consumeIncompleteBlock(token) { | ||
const parameters = []; | ||
while (this._peek.type === 28 /* TokenType.BLOCK_PARAMETER */) { | ||
const paramToken = this._advance(); | ||
parameters.push( | ||
new html.BlockParameter(paramToken.parts[0], paramToken.sourceSpan), | ||
); | ||
} | ||
const end = this._peek.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan( | ||
token.sourceSpan.start, | ||
end, | ||
token.sourceSpan.fullStart, | ||
); | ||
// Create a separate `startSpan` because `span` will be modified when there is an `end` span. | ||
const startSpan = new ParseSourceSpan( | ||
token.sourceSpan.start, | ||
end, | ||
token.sourceSpan.fullStart, | ||
); | ||
const block = new html.Block( | ||
token.parts[0], | ||
parameters, | ||
[], | ||
span, | ||
token.sourceSpan, | ||
startSpan, | ||
); | ||
this._pushContainer(block, false); | ||
// Incomplete blocks don't have children so we close them immediately and report an error. | ||
this._popContainer(null, html.Block, null); | ||
this.errors.push( | ||
TreeError.create( | ||
token.parts[0], | ||
span, | ||
`Incomplete block "${token.parts[0]}". If you meant to write the @ character, ` + | ||
`you should use the "@" HTML entity instead.`, | ||
), | ||
); | ||
} | ||
_consumeLet(startToken) { | ||
const name = startToken.parts[0]; | ||
let valueToken; | ||
let endToken; | ||
if (this._peek.type !== 31 /* TokenType.LET_VALUE */) { | ||
this.errors.push( | ||
TreeError.create( | ||
startToken.parts[0], | ||
startToken.sourceSpan, | ||
`Invalid @let declaration "${name}". Declaration must have a value.`, | ||
), | ||
); | ||
return; | ||
} else { | ||
valueToken = this._advance(); | ||
} | ||
// Type cast is necessary here since TS narrowed the type of `peek` above. | ||
if (this._peek.type !== 32 /* TokenType.LET_END */) { | ||
this.errors.push( | ||
TreeError.create( | ||
startToken.parts[0], | ||
startToken.sourceSpan, | ||
`Unterminated @let declaration "${name}". Declaration must be terminated with a semicolon.`, | ||
), | ||
); | ||
return; | ||
} else { | ||
endToken = this._advance(); | ||
} | ||
const end = endToken.sourceSpan.fullStart; | ||
const span = new ParseSourceSpan( | ||
startToken.sourceSpan.start, | ||
end, | ||
startToken.sourceSpan.fullStart, | ||
); | ||
// The start token usually captures the `@let`. Construct a name span by | ||
// offsetting the start by the length of any text before the name. | ||
const startOffset = startToken.sourceSpan.toString().lastIndexOf(name); | ||
const nameStart = startToken.sourceSpan.start.moveBy(startOffset); | ||
const nameSpan = new ParseSourceSpan(nameStart, startToken.sourceSpan.end); | ||
const node = new html.LetDeclaration( | ||
name, | ||
valueToken.parts[0], | ||
span, | ||
nameSpan, | ||
valueToken.sourceSpan, | ||
); | ||
this._addToParent(node); | ||
} | ||
_consumeIncompleteLet(token) { | ||
// Incomplete `@let` declaration may end up with an empty name. | ||
const name = token.parts[0] ?? ""; | ||
const nameString = name ? ` "${name}"` : ""; | ||
// If there's at least a name, we can salvage an AST node that can be used for completions. | ||
if (name.length > 0) { | ||
const startOffset = token.sourceSpan.toString().lastIndexOf(name); | ||
const nameStart = token.sourceSpan.start.moveBy(startOffset); | ||
const nameSpan = new ParseSourceSpan(nameStart, token.sourceSpan.end); | ||
const valueSpan = new ParseSourceSpan( | ||
token.sourceSpan.start, | ||
token.sourceSpan.start.moveBy(0), | ||
); | ||
const node = new html.LetDeclaration( | ||
name, | ||
"", | ||
token.sourceSpan, | ||
nameSpan, | ||
valueSpan, | ||
); | ||
this._addToParent(node); | ||
} | ||
this.errors.push( | ||
TreeError.create( | ||
token.parts[0], | ||
token.sourceSpan, | ||
`Incomplete @let declaration${nameString}. ` + | ||
`@let declarations must be written as \`@let <name> = <value>;\``, | ||
), | ||
); | ||
} | ||
_getContainer() { | ||
return this._containerStack.length > 0 | ||
? this._containerStack[this._containerStack.length - 1] | ||
: null; | ||
} | ||
_getClosestParentElement() { | ||
for (let i = this._containerStack.length - 1; i > -1; i--) { | ||
if (this._containerStack[i] instanceof html.Element) { | ||
return this._containerStack[i]; | ||
} | ||
} | ||
return null; | ||
} | ||
_addToParent(node) { | ||
const parent = this._getContainer(); | ||
if (parent === null) { | ||
this.rootNodes.push(node); | ||
} else { | ||
parent.children.push(node); | ||
} | ||
} | ||
_getElementFullName(prefix, localName, parentElement) { | ||
if (prefix === "") { | ||
prefix = this.getTagDefinition(localName).implicitNamespacePrefix || ""; | ||
if (prefix === "" && parentElement != null) { | ||
const parentTagName = splitNsName(parentElement.name)[1]; | ||
const parentTagDefinition = this.getTagDefinition(parentTagName); | ||
if (!parentTagDefinition.preventNamespaceInheritance) { | ||
prefix = getNsPrefix(parentElement.name); | ||
} | ||
return mergeNsAndName(prefix, localName); | ||
} | ||
} | ||
return mergeNsAndName(prefix, localName); | ||
} | ||
} | ||
function lastOnStack(stack, element) { | ||
return stack.length > 0 && stack[stack.length - 1] === element; | ||
return stack.length > 0 && stack[stack.length - 1] === element; | ||
} | ||
@@ -575,12 +893,12 @@ /** | ||
function decodeEntity(match, entity) { | ||
if (NAMED_ENTITIES[entity] !== undefined) { | ||
return NAMED_ENTITIES[entity] || match; | ||
} | ||
if (/^#x[a-f0-9]+$/i.test(entity)) { | ||
return String.fromCodePoint(parseInt(entity.slice(2), 16)); | ||
} | ||
if (/^#\d+$/.test(entity)) { | ||
return String.fromCodePoint(parseInt(entity.slice(1), 10)); | ||
} | ||
return match; | ||
if (NAMED_ENTITIES[entity] !== undefined) { | ||
return NAMED_ENTITIES[entity] || match; | ||
} | ||
if (/^#x[a-f0-9]+$/i.test(entity)) { | ||
return String.fromCodePoint(parseInt(entity.slice(2), 16)); | ||
} | ||
if (/^#\d+$/.test(entity)) { | ||
return String.fromCodePoint(parseInt(entity.slice(1), 10)); | ||
} | ||
return match; | ||
} |
{ | ||
"name": "angular-html-parser", | ||
"version": "8.1.0", | ||
"version": "9.0.0", | ||
"description": "A HTML parser extracted from Angular with some modifications", | ||
@@ -27,3 +27,3 @@ "repository": "https://github.com/prettier/angular-html-parser/blob/HEAD/packages/angular-html-parser", | ||
"test": "vitest", | ||
"release": "standard-version", | ||
"release": "release-it", | ||
"fix": "prettier . --write", | ||
@@ -33,13 +33,14 @@ "lint": "prettier . --check" | ||
"devDependencies": { | ||
"@types/node": "22.13.10", | ||
"@vitest/coverage-v8": "3.0.8", | ||
"@types/node": "22.14.1", | ||
"@vitest/coverage-v8": "3.1.1", | ||
"del-cli": "6.0.0", | ||
"jasmine": "5.6.0", | ||
"jscodeshift": "17.1.2", | ||
"jscodeshift": "17.3.0", | ||
"prettier": "3.5.3", | ||
"release-it": "18.1.2", | ||
"standard-version": "9.5.0", | ||
"ts-node": "10.9.2", | ||
"tsconfig-paths": "4.2.0", | ||
"typescript": "5.8.2", | ||
"vitest": "3.0.8" | ||
"typescript": "5.8.3", | ||
"vitest": "3.1.1" | ||
}, | ||
@@ -46,0 +47,0 @@ "engines": { |
@@ -0,0 +0,0 @@ # angular-html-parser |
8050
4.11%297703
-0.26%12
9.09%