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

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 });