Files
test-repo/src/language-html/parser-html.js
T

446 lines
13 KiB
JavaScript

import parseFrontMatter from "../utils/front-matter/parse.js";
import inferParserByLanguage from "../utils/infer-parser-by-language.js";
import createError from "../common/parser-create-error.js";
import HTML_TAGS from "./utils/html-tag-names.js";
import HTML_ELEMENT_ATTRIBUTES from "./utils/html-elements-attributes.js";
import isUnknownNamespace from "./utils/is-unknown-namespace.js";
import { hasPragma } from "./pragma.js";
import { Node } from "./ast.js";
import { parseIeConditionalComment } from "./conditional-comment.js";
import { locStart, locEnd } from "./loc.js";
/**
* @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/ast.js').Node} AstNode
* @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/ast.js').Attribute} Attribute
* @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/ast.js').Element} Element
* @typedef {import('angular-html-parser/lib/compiler/src/ml_parser/parser.js').ParseTreeResult} ParserTreeResult
* @typedef {Omit<import('angular-html-parser').ParseOptions, 'canSelfClose'> & {
* name?: 'html' | 'angular' | 'vue' | 'lwc';
* recognizeSelfClosing?: boolean;
* normalizeTagName?: boolean;
* normalizeAttributeName?: boolean;
* }} ParserOptions
* @typedef {{
* parser: 'html' | 'angular' | 'vue' | 'lwc',
* filepath?: string
* }} Options
*/
/**
* @param {string} input
* @param {ParserOptions} parserOptions
* @param {Options} options
*/
async function ngHtmlParser(
input,
{
recognizeSelfClosing,
normalizeTagName,
normalizeAttributeName,
allowHtmComponentClosingTags,
isTagNameCaseSensitive,
getTagContentType,
},
options
) {
const [
parser,
{ RecursiveVisitor, visitAll },
{ ParseSourceSpan },
{ getHtmlTagDefinition },
] = await Promise.all([
import("angular-html-parser"),
import("angular-html-parser/lib/compiler/src/ml_parser/ast.js"),
import("angular-html-parser/lib/compiler/src/parse_util.js"),
import("angular-html-parser/lib/compiler/src/ml_parser/html_tags.js"),
]);
let { rootNodes, errors } = parser.parse(input, {
canSelfClose: recognizeSelfClosing,
allowHtmComponentClosingTags,
isTagNameCaseSensitive,
getTagContentType,
});
if (options.parser === "vue") {
const isVueHtml = rootNodes.some(
(node) =>
(node.type === "docType" && node.value === "html") ||
(node.type === "element" && node.name.toLowerCase() === "html")
);
if (!isVueHtml) {
const shouldParseAsHTML = (/** @type {AstNode} */ node) => {
/* istanbul ignore next */
if (!node) {
return false;
}
if (node.type !== "element" || node.name !== "template") {
return false;
}
const langAttr = node.attrs.find((attr) => attr.name === "lang");
const langValue = langAttr && langAttr.value;
return (
!langValue || inferParserByLanguage(langValue, options) === "html"
);
};
if (rootNodes.some(shouldParseAsHTML)) {
/** @type {ParserTreeResult | undefined} */
let secondParseResult;
const doSecondParse = () =>
parser.parse(input, {
canSelfClose: recognizeSelfClosing,
allowHtmComponentClosingTags,
isTagNameCaseSensitive,
});
const getSecondParse = () =>
secondParseResult || (secondParseResult = doSecondParse());
const getSameLocationNode = (node) =>
getSecondParse().rootNodes.find(
({ startSourceSpan }) =>
startSourceSpan &&
startSourceSpan.start.offset === node.startSourceSpan.start.offset
);
for (let i = 0; i < rootNodes.length; i++) {
const node = rootNodes[i];
const { endSourceSpan, startSourceSpan } = node;
const isUnclosedNode = endSourceSpan === null;
if (isUnclosedNode) {
const result = getSecondParse();
errors = result.errors;
rootNodes[i] = getSameLocationNode(node) || node;
} else if (shouldParseAsHTML(node)) {
const result = getSecondParse();
const startOffset = startSourceSpan.end.offset;
const endOffset = endSourceSpan.start.offset;
for (const error of result.errors) {
const { offset } = error.span.start;
/* istanbul ignore next */
if (startOffset < offset && offset < endOffset) {
errors = [error];
break;
}
}
rootNodes[i] = getSameLocationNode(node) || node;
}
}
}
} else {
// If not Vue SFC, treat as html
recognizeSelfClosing = true;
normalizeTagName = true;
normalizeAttributeName = true;
allowHtmComponentClosingTags = true;
isTagNameCaseSensitive = false;
const htmlParseResult = parser.parse(input, {
canSelfClose: recognizeSelfClosing,
allowHtmComponentClosingTags,
isTagNameCaseSensitive,
});
rootNodes = htmlParseResult.rootNodes;
errors = htmlParseResult.errors;
}
}
if (errors.length > 0) {
const [error] = errors;
const {
msg,
span: { start, end },
} = error;
throw createError(msg, {
loc: {
start: { line: start.line + 1, column: start.col + 1 },
end: { line: end.line + 1, column: end.col + 1 },
},
cause: error,
});
}
/**
* @param {Attribute | Element} node
*/
const restoreName = (node) => {
const namespace = node.name.startsWith(":")
? node.name.slice(1).split(":")[0]
: null;
const rawName = node.nameSpan.toString();
const hasExplicitNamespace =
namespace !== null && rawName.startsWith(`${namespace}:`);
const name = hasExplicitNamespace
? rawName.slice(namespace.length + 1)
: rawName;
node.name = name;
node.namespace = namespace;
node.hasExplicitNamespace = hasExplicitNamespace;
};
/**
* @param {AstNode} node
*/
const restoreNameAndValue = (node) => {
switch (node.type) {
case "element":
restoreName(node);
for (const attr of node.attrs) {
restoreName(attr);
if (!attr.valueSpan) {
attr.value = null;
} else {
attr.value = attr.valueSpan.toString();
if (/["']/.test(attr.value[0])) {
attr.value = attr.value.slice(1, -1);
}
}
}
break;
case "comment":
node.value = node.sourceSpan
.toString()
.slice("<!--".length, -"-->".length);
break;
case "text":
node.value = node.sourceSpan.toString();
break;
// No default
}
};
const lowerCaseIfFn = (text, fn) => {
const lowerCasedText = text.toLowerCase();
return fn(lowerCasedText) ? lowerCasedText : text;
};
const normalizeName = (node) => {
if (node.type === "element") {
if (
normalizeTagName &&
(!node.namespace ||
node.namespace === node.tagDefinition.implicitNamespacePrefix ||
isUnknownNamespace(node))
) {
node.name = lowerCaseIfFn(
node.name,
(lowerCasedName) => lowerCasedName in HTML_TAGS
);
}
if (normalizeAttributeName) {
const CURRENT_HTML_ELEMENT_ATTRIBUTES =
HTML_ELEMENT_ATTRIBUTES[node.name] || Object.create(null);
for (const attr of node.attrs) {
if (!attr.namespace) {
attr.name = lowerCaseIfFn(
attr.name,
(lowerCasedAttrName) =>
node.name in HTML_ELEMENT_ATTRIBUTES &&
(lowerCasedAttrName in HTML_ELEMENT_ATTRIBUTES["*"] ||
lowerCasedAttrName in CURRENT_HTML_ELEMENT_ATTRIBUTES)
);
}
}
}
}
};
const fixSourceSpan = (node) => {
if (node.sourceSpan && node.endSourceSpan) {
node.sourceSpan = new ParseSourceSpan(
node.sourceSpan.start,
node.endSourceSpan.end
);
}
};
/**
* @param {AstNode} node
*/
const addTagDefinition = (node) => {
if (node.type === "element") {
const tagDefinition = getHtmlTagDefinition(
isTagNameCaseSensitive ? node.name : node.name.toLowerCase()
);
if (
!node.namespace ||
node.namespace === tagDefinition.implicitNamespacePrefix ||
isUnknownNamespace(node)
) {
node.tagDefinition = tagDefinition;
} else {
node.tagDefinition = getHtmlTagDefinition(""); // the default one
}
}
};
visitAll(
new (class extends RecursiveVisitor {
visit(node) {
restoreNameAndValue(node);
addTagDefinition(node);
normalizeName(node);
fixSourceSpan(node);
}
})(),
rootNodes
);
return rootNodes;
}
/**
* @param {string} text
* @param {Options} options
* @param {ParserOptions} parserOptions
* @param {boolean} shouldParseFrontMatter
*/
async function _parse(
text,
options,
parserOptions,
shouldParseFrontMatter = true
) {
const { frontMatter, content } = shouldParseFrontMatter
? parseFrontMatter(text)
: { frontMatter: null, content: text };
const { ParseSourceSpan, ParseLocation, ParseSourceFile } = await import(
"angular-html-parser/lib/compiler/src/parse_util.js"
);
const file = new ParseSourceFile(text, options.filepath);
const start = new ParseLocation(file, 0, 0, 0);
const end = start.moveBy(text.length);
const rawAst = {
type: "root",
sourceSpan: new ParseSourceSpan(start, end),
children: await ngHtmlParser(content, parserOptions, options),
};
if (frontMatter) {
const start = new ParseLocation(file, 0, 0, 0);
const end = start.moveBy(frontMatter.raw.length);
frontMatter.sourceSpan = new ParseSourceSpan(start, end);
// @ts-expect-error
rawAst.children.unshift(frontMatter);
}
const ast = new Node(rawAst);
const parseSubHtml = async (subContent, startSpan) => {
const { offset } = startSpan;
const fakeContent = text.slice(0, offset).replace(/[^\n\r]/g, " ");
const realContent = subContent;
const subAst = await _parse(
fakeContent + realContent,
options,
parserOptions,
false
);
// @ts-expect-error
subAst.sourceSpan = new ParseSourceSpan(
startSpan,
// @ts-expect-error
subAst.children.at(-1).sourceSpan.end
);
// @ts-expect-error
const firstText = subAst.children[0];
if (firstText.length === offset) {
/* istanbul ignore next */ // @ts-expect-error
subAst.children.shift();
} else {
firstText.sourceSpan = new ParseSourceSpan(
firstText.sourceSpan.start.moveBy(offset),
firstText.sourceSpan.end
);
firstText.value = firstText.value.slice(offset);
}
return subAst;
};
await ast.walkAsync(async (node) => {
if (node.type === "comment") {
const ieConditionalComment = await parseIeConditionalComment(
node,
parseSubHtml
);
if (ieConditionalComment) {
node.parent.replaceChild(node, ieConditionalComment);
}
}
});
return ast;
}
/**
* @param {ParserOptions} parserOptions
*/
function createParser({
name,
recognizeSelfClosing = false,
normalizeTagName = false,
normalizeAttributeName = false,
allowHtmComponentClosingTags = false,
isTagNameCaseSensitive = false,
getTagContentType,
} = {}) {
return {
parse: (text, options) =>
_parse(
text,
{ parser: name, ...options },
{
recognizeSelfClosing,
normalizeTagName,
normalizeAttributeName,
allowHtmComponentClosingTags,
isTagNameCaseSensitive,
getTagContentType,
}
),
hasPragma,
astFormat: "html",
locStart,
locEnd,
};
}
const parser = {
parsers: {
html: createParser({
name: "html",
recognizeSelfClosing: true,
normalizeTagName: true,
normalizeAttributeName: true,
allowHtmComponentClosingTags: true,
}),
angular: createParser({ name: "angular" }),
vue: createParser({
name: "vue",
recognizeSelfClosing: true,
isTagNameCaseSensitive: true,
getTagContentType: (tagName, prefix, hasParent, attrs) => {
if (
tagName.toLowerCase() !== "html" &&
!hasParent &&
(tagName !== "template" ||
attrs.some(
({ name, value }) =>
name === "lang" &&
value !== "html" &&
value !== "" &&
value !== undefined
))
) {
// require("angular-html-parser").TagContentType.RAW_TEXT;
return 0;
}
},
}),
lwc: createParser({ name: "lwc" }),
},
};
export default parser;