428 lines
12 KiB
JavaScript
428 lines
12 KiB
JavaScript
import {
|
|
ParseSourceFile,
|
|
ParseLocation,
|
|
ParseSourceSpan,
|
|
parse as parseHtml,
|
|
RecursiveVisitor,
|
|
visitAll,
|
|
getHtmlTagDefinition,
|
|
TagContentType,
|
|
} from "angular-html-parser";
|
|
import parseFrontMatter from "../utils/front-matter/parse.js";
|
|
import inferParser from "../utils/infer-parser.js";
|
|
import createError from "../common/parser-create-error.js";
|
|
import isNonEmptyArray from "../utils/is-non-empty-array.js";
|
|
import HTML_TAGS from "./utils/html-tag-names.evaluate.js";
|
|
import HTML_ELEMENT_ATTRIBUTES from "./utils/html-elements-attributes.evaluate.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')} AngularHtmlParser
|
|
* @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 {import('angular-html-parser').ParseOptions & {
|
|
* name: 'html' | 'angular' | 'vue' | 'lwc';
|
|
* normalizeTagName?: boolean;
|
|
* normalizeAttributeName?: boolean;
|
|
* shouldParseAsRawText?: (tagName: string, prefix: string, hasParent: boolean, attrs: Array<{
|
|
* prefix: string;
|
|
* name: string;
|
|
* value?: string;
|
|
* }>) => boolean;
|
|
* }} ParseOptions
|
|
* @typedef {{filepath?: string}} Options
|
|
*/
|
|
|
|
// `@else if`
|
|
function normalizeAngularControlFlowBlock(node) {
|
|
if (node.type !== "block") {
|
|
return;
|
|
}
|
|
|
|
node.name = node.name.toLowerCase().replaceAll(/\s+/g, " ").trim();
|
|
node.type = "angularControlFlowBlock";
|
|
|
|
if (!isNonEmptyArray(node.parameters)) {
|
|
delete node.parameters;
|
|
return;
|
|
}
|
|
|
|
for (const parameter of node.parameters) {
|
|
parameter.type = "angularControlFlowBlockParameter";
|
|
}
|
|
|
|
node.parameters = {
|
|
type: "angularControlFlowBlockParameters",
|
|
children: node.parameters,
|
|
sourceSpan: new ParseSourceSpan(
|
|
node.parameters[0].sourceSpan.start,
|
|
node.parameters.at(-1).sourceSpan.end,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {string} input
|
|
* @param {ParseOptions} parseOptions
|
|
* @param {Options} options
|
|
*/
|
|
function ngHtmlParser(input, parseOptions, options) {
|
|
const {
|
|
name,
|
|
canSelfClose = true,
|
|
normalizeTagName = false,
|
|
normalizeAttributeName = false,
|
|
allowHtmComponentClosingTags = false,
|
|
isTagNameCaseSensitive = false,
|
|
shouldParseAsRawText,
|
|
} = parseOptions;
|
|
|
|
let { rootNodes, errors } = parseHtml(input, {
|
|
canSelfClose,
|
|
allowHtmComponentClosingTags,
|
|
isTagNameCaseSensitive,
|
|
getTagContentType: shouldParseAsRawText
|
|
? (...args) =>
|
|
shouldParseAsRawText(...args) ? TagContentType.RAW_TEXT : undefined
|
|
: undefined,
|
|
tokenizeAngularBlocks: name === "angular" ? true : undefined,
|
|
});
|
|
|
|
if (name === "vue") {
|
|
const isHtml = rootNodes.some(
|
|
(node) =>
|
|
(node.type === "docType" && node.value === "html") ||
|
|
(node.type === "element" && node.name.toLowerCase() === "html"),
|
|
);
|
|
|
|
// If not Vue SFC, treat as html
|
|
if (isHtml) {
|
|
return ngHtmlParser(input, HTML_PARSE_OPTIONS, options);
|
|
}
|
|
|
|
/** @type {ParserTreeResult | undefined} */
|
|
let secondParseResult;
|
|
const getHtmlParseResult = () =>
|
|
(secondParseResult ??= parseHtml(input, {
|
|
canSelfClose,
|
|
allowHtmComponentClosingTags,
|
|
isTagNameCaseSensitive,
|
|
}));
|
|
|
|
const getNodeWithSameLocation = (node) =>
|
|
getHtmlParseResult().rootNodes.find(
|
|
({ startSourceSpan }) =>
|
|
startSourceSpan &&
|
|
startSourceSpan.start.offset === node.startSourceSpan.start.offset,
|
|
) ?? node;
|
|
for (const [index, node] of rootNodes.entries()) {
|
|
const { endSourceSpan, startSourceSpan } = node;
|
|
const isVoidElement = endSourceSpan === null;
|
|
if (isVoidElement) {
|
|
errors = getHtmlParseResult().errors;
|
|
rootNodes[index] = getNodeWithSameLocation(node);
|
|
} else if (shouldParseVueRootNodeAsHtml(node, options)) {
|
|
const error = getHtmlParseResult().errors.find(
|
|
(error) =>
|
|
error.span.start.offset > startSourceSpan.start.offset &&
|
|
error.span.start.offset < endSourceSpan.end.offset,
|
|
);
|
|
if (error) {
|
|
throwParseError(error);
|
|
}
|
|
rootNodes[index] = getNodeWithSameLocation(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
throwParseError(errors[0]);
|
|
}
|
|
|
|
/**
|
|
* @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) =>
|
|
HTML_TAGS.has(lowerCasedName),
|
|
);
|
|
}
|
|
|
|
if (normalizeAttributeName) {
|
|
for (const attr of node.attrs) {
|
|
if (!attr.namespace) {
|
|
attr.name = lowerCaseIfFn(
|
|
attr.name,
|
|
(lowerCasedAttrName) =>
|
|
HTML_ELEMENT_ATTRIBUTES.has(node.name) &&
|
|
(HTML_ELEMENT_ATTRIBUTES.get("*").has(lowerCasedAttrName) ||
|
|
HTML_ELEMENT_ATTRIBUTES.get(node.name).has(
|
|
lowerCasedAttrName,
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
function shouldParseVueRootNodeAsHtml(node, options) {
|
|
if (node.type !== "element" || node.name !== "template") {
|
|
return false;
|
|
}
|
|
const language = node.attrs.find((attr) => attr.name === "lang")?.value;
|
|
return !language || inferParser(options, { language }) === "html";
|
|
}
|
|
|
|
function throwParseError(error) {
|
|
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 {string} text
|
|
* @param {ParseOptions} parseOptions
|
|
* @param {Options} options
|
|
* @param {boolean} shouldParseFrontMatter
|
|
*/
|
|
function parse(
|
|
text,
|
|
parseOptions,
|
|
options = {},
|
|
shouldParseFrontMatter = true,
|
|
) {
|
|
const { frontMatter, content } = shouldParseFrontMatter
|
|
? parseFrontMatter(text)
|
|
: { frontMatter: null, content: text };
|
|
|
|
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: ngHtmlParser(content, parseOptions, 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 -- not a real AstNode
|
|
rawAst.children.unshift(frontMatter);
|
|
}
|
|
|
|
const ast = new Node(rawAst);
|
|
|
|
const parseSubHtml = (subContent, startSpan) => {
|
|
const { offset } = startSpan;
|
|
const fakeContent = text.slice(0, offset).replaceAll(/[^\n\r]/g, " ");
|
|
const realContent = subContent;
|
|
const subAst = parse(
|
|
fakeContent + realContent,
|
|
parseOptions,
|
|
options,
|
|
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) {
|
|
/* c8 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;
|
|
};
|
|
|
|
ast.walk((node) => {
|
|
if (node.type === "comment") {
|
|
const ieConditionalComment = parseIeConditionalComment(
|
|
node,
|
|
parseSubHtml,
|
|
);
|
|
if (ieConditionalComment) {
|
|
node.parent.replaceChild(node, ieConditionalComment);
|
|
}
|
|
}
|
|
|
|
normalizeAngularControlFlowBlock(node);
|
|
});
|
|
|
|
return ast;
|
|
}
|
|
|
|
/**
|
|
* @param {ParseOptions} parseOptions
|
|
*/
|
|
function createParser(parseOptions) {
|
|
return {
|
|
parse: (text, options) => parse(text, parseOptions, options),
|
|
hasPragma,
|
|
astFormat: "html",
|
|
locStart,
|
|
locEnd,
|
|
};
|
|
}
|
|
|
|
/** @type {ParseOptions} */
|
|
const HTML_PARSE_OPTIONS = {
|
|
name: "html",
|
|
normalizeTagName: true,
|
|
normalizeAttributeName: true,
|
|
allowHtmComponentClosingTags: true,
|
|
};
|
|
|
|
// HTML
|
|
export const html = createParser(HTML_PARSE_OPTIONS);
|
|
// Angular
|
|
export const angular = createParser({ name: "angular" });
|
|
// Vue
|
|
export const vue = createParser({
|
|
name: "vue",
|
|
isTagNameCaseSensitive: true,
|
|
shouldParseAsRawText(tagName, prefix, hasParent, attrs) {
|
|
return (
|
|
tagName.toLowerCase() !== "html" &&
|
|
!hasParent &&
|
|
(tagName !== "template" ||
|
|
attrs.some(
|
|
({ name, value }) =>
|
|
name === "lang" &&
|
|
value !== "html" &&
|
|
value !== "" &&
|
|
value !== undefined,
|
|
))
|
|
);
|
|
},
|
|
});
|
|
// Lightning Web Components
|
|
export const lwc = createParser({ name: "lwc", canSelfClose: false });
|