Files
reverse-engineering-anything/src/domain/javascript/htmlArtifactReferences.ts
T

200 lines
6.9 KiB
TypeScript

import {
defaultTreeAdapter,
parse,
type DefaultTreeAdapterTypes,
} from "parse5";
import type {
JavaScriptSourcePoint,
JavaScriptSourceRange,
} from "./javascriptStaticAnalysisTypes.js";
interface HtmlScriptReference {
readonly scriptPath: string;
/** Null for data blocks and other non-JavaScript script types. */
readonly scriptType: HtmlInlineScript["scriptType"] | null;
/** The opening tag's range in parse5 coordinates. */
readonly location: JavaScriptSourceRange;
}
/** JavaScript written directly in a script element instead of loaded by URL. */
export interface HtmlInlineScript {
readonly scriptType: "classic" | "module";
/** The body's range, between the opening and closing tags. */
readonly location: JavaScriptSourceRange;
}
/**
* Inline bodies whose exact locations are retained per HTML file. Every body
* is still counted; locations beyond this bound are reported as omitted.
*/
export const HTML_INLINE_SCRIPT_LOCATION_LIMIT = 100;
// The JavaScript MIME type essences from the WHATWG MIME Sniffing standard.
const JAVASCRIPT_MIME_TYPES = new Set([
"application/ecmascript",
"application/javascript",
"application/x-ecmascript",
"application/x-javascript",
"text/ecmascript",
"text/javascript",
"text/javascript1.0",
"text/javascript1.1",
"text/javascript1.2",
"text/javascript1.3",
"text/javascript1.4",
"text/javascript1.5",
"text/jscript",
"text/livescript",
"text/x-ecmascript",
"text/x-javascript",
]);
/**
* Classify a script element's type as HTML "prepare the script element" does.
* Only a type attribute is whitespace-stripped, and only for the JavaScript
* MIME check; `module` must match the type string exactly, ignoring ASCII
* case. Import maps, speculation rules and data blocks are not JavaScript.
*/
const javaScriptType = (
attrs: readonly { readonly name: string; readonly value: string }[],
): HtmlInlineScript["scriptType"] | null => {
const type = attrs.find(({ name }) => name === "type");
const language = attrs.find(({ name }) => name === "language");
const typeString =
type !== undefined
? type.value
: language !== undefined && language.value !== ""
? `text/${language.value}`
: "";
if (typeString === "") return "classic";
const mimeCandidate =
type === undefined
? typeString
: typeString.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/gu, "");
if (JAVASCRIPT_MIME_TYPES.has(asciiLowercase(mimeCandidate)))
return "classic";
return asciiLowercase(typeString) === "module" ? "module" : null;
};
const asciiLowercase = (value: string): string =>
value.replace(/[A-Z]/gu, (letter) => letter.toLowerCase());
/** SVG uses href/XLink and has no HTML language fallback or src attribute. */
const scriptAttributes = (node: DefaultTreeAdapterTypes.Element) => {
const html = node.namespaceURI === "http://www.w3.org/1999/xhtml";
const source = html
? node.attrs.find(({ name }) => name === "src")
: (node.attrs.find(
({ name, namespace }) => name === "href" && namespace === undefined,
) ??
node.attrs.find(
({ name, namespace }) =>
name === "href" && namespace === "http://www.w3.org/1999/xlink",
));
return {
source,
scriptType: javaScriptType(
html
? node.attrs
: node.attrs.filter(
({ name, namespace }) => name === "type" && namespace === undefined,
),
),
};
};
/**
* Extract inert HTML script references, inline JavaScript bodies, and the
* first document base href.
*/
export const htmlArtifactReferences = (
text: string,
): {
readonly baseHref: string | null;
readonly scripts: readonly HtmlScriptReference[];
/** The first inline bodies in document order, up to the location limit. */
readonly inlineScripts: readonly HtmlInlineScript[];
readonly inlineScriptCount: number;
} => {
const pending: DefaultTreeAdapterTypes.Node[] = [
parse(text, { sourceCodeLocationInfo: true }),
];
const scripts: HtmlScriptReference[] = [];
const inlineScripts: HtmlInlineScript[] = [];
let inlineScriptCount = 0;
let baseHref: string | null = null;
while (pending.length > 0) {
const node = pending.pop();
if (node === undefined) continue;
if (
defaultTreeAdapter.isElementNode(node) &&
(node.namespaceURI === "http://www.w3.org/1999/xhtml" ||
node.namespaceURI === "http://www.w3.org/2000/svg")
) {
if (
node.namespaceURI === "http://www.w3.org/1999/xhtml" &&
node.tagName === "base" &&
baseHref === null
) {
const href = node.attrs.find(({ name }) => name === "href");
if (href !== undefined) baseHref = href.value;
}
if (node.tagName === "script") {
const { scriptType, source: src } = scriptAttributes(node);
const location = node.sourceCodeLocation?.startTag;
if (src !== undefined && src.value !== "" && location !== undefined) {
scripts.push({
scriptPath: src.value,
scriptType,
location: {
start: sourcePoint(location.startLine, location.startCol),
end: sourcePoint(location.endLine, location.endCol),
},
});
}
const inline = src === undefined ? inlineScriptBody(node) : undefined;
if (inline !== undefined && scriptType !== null) {
inlineScriptCount += 1;
if (inlineScripts.length < HTML_INLINE_SCRIPT_LOCATION_LIMIT)
inlineScripts.push({ scriptType, location: inline });
}
}
}
// Template content belongs to a separate inert document fragment. Only
// traverse the actual document children, without recursion on deep input.
if ("childNodes" in node) {
for (const child of node.childNodes.toReversed()) pending.push(child);
}
}
return { baseHref, scripts, inlineScripts, inlineScriptCount };
};
/** Locate a script element's non-blank body; browsers ignore it when src is set. */
const inlineScriptBody = (
node: DefaultTreeAdapterTypes.Element,
): JavaScriptSourceRange | undefined => {
const startTag = node.sourceCodeLocation?.startTag;
if (startTag === undefined) return undefined;
let blank = true;
let end: DefaultTreeAdapterTypes.TextNode["sourceCodeLocation"];
for (const child of node.childNodes) {
if (!defaultTreeAdapter.isTextNode(child)) continue;
if (blank && /\S/u.test(child.value)) blank = false;
end = child.sourceCodeLocation ?? end;
}
// An unclosed script's element range ends at its opening tag, so the body's
// own text location bounds it.
if (blank || end == null) return undefined;
return {
start: sourcePoint(startTag.endLine, startTag.endCol),
end: sourcePoint(end.endLine, end.endCol),
};
};
/** parse5 lines and UTF-16 columns are one-based; ranges use zero-based columns. */
const sourcePoint = (line: number, column: number): JavaScriptSourcePoint => ({
line,
column: column - 1,
});