mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(lint): parse HTML structure without regex (#2223)
* fix(lint): ignore scripts inside quoted attributes * Address PR review feedback (#2223) - replace repeated attribute scanner with quoted tag ranges - remove the Fallow complexity finding * refactor(lint): parse HTML structure with htmlparser2 * fix(lint): traverse template style sources * test(lint): cover nested template style sources
This commit is contained in:
@@ -54,6 +54,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.5.8"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
|
||||
import {
|
||||
extractBlocks,
|
||||
extractOpenTags,
|
||||
parseHtmlStructure,
|
||||
findRootTag,
|
||||
collectCompositionIds,
|
||||
readAttr,
|
||||
stripHtmlComments,
|
||||
STYLE_BLOCK_PATTERN,
|
||||
SCRIPT_BLOCK_PATTERN,
|
||||
} from "./utils";
|
||||
import type { OpenTag, ExtractedBlock } from "./utils";
|
||||
|
||||
@@ -34,19 +31,31 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
|
||||
// hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
|
||||
// stay ReDoS-free and catch markers that re-form when a comment is removed.
|
||||
let source = stripHtmlComments(rawSource);
|
||||
const sourceWithoutTemplates = source.replace(
|
||||
/<template\b[^>]*>[\s\S]*?<\/template(?:\s[^>]*)?>/gi,
|
||||
" ",
|
||||
const initialStructure = parseHtmlStructure(source);
|
||||
const templateTags = initialStructure.tags.filter(
|
||||
(tag) => tag.name === "template" && tag.closeIndex != null,
|
||||
);
|
||||
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
||||
let sourceWithoutTemplates = source;
|
||||
for (const template of [...templateTags].reverse()) {
|
||||
const end = template.endIndex ?? template.index;
|
||||
sourceWithoutTemplates =
|
||||
sourceWithoutTemplates.slice(0, template.index) +
|
||||
" ".repeat(end - template.index) +
|
||||
sourceWithoutTemplates.slice(end);
|
||||
}
|
||||
// Some sub-composition files are HTML shells whose real root lives inside a
|
||||
// <template>. Keep nested templates intact when the visible document already
|
||||
// has a composition root; only unwrap when no root exists outside templates.
|
||||
if (templateMatch?.[1] && !findRootTag(sourceWithoutTemplates)) source = templateMatch[1];
|
||||
const template = templateTags[0];
|
||||
let structure = initialStructure;
|
||||
if (template && !findRootTag(sourceWithoutTemplates)) {
|
||||
source = source.slice(template.index + template.raw.length, template.closeIndex);
|
||||
structure = parseHtmlStructure(source);
|
||||
}
|
||||
|
||||
const tags = extractOpenTags(source);
|
||||
const tags = structure.tags;
|
||||
const styles = [
|
||||
...extractBlocks(source, STYLE_BLOCK_PATTERN),
|
||||
...structure.styles,
|
||||
...(options.externalStyles ?? []).map((style) => ({
|
||||
attrs: `href="${style.href}"`,
|
||||
content: style.content,
|
||||
@@ -54,9 +63,9 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
|
||||
index: -1,
|
||||
})),
|
||||
];
|
||||
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
|
||||
const scripts = structure.scripts;
|
||||
const compositionIds = collectCompositionIds(tags);
|
||||
const rootTag = findRootTag(source);
|
||||
const rootTag = findRootTag(source, tags);
|
||||
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("lintHyperframeHtml — orchestrator", () => {
|
||||
const validComposition = `
|
||||
@@ -122,3 +124,26 @@ describe("lintHyperframeHtml — orchestrator", () => {
|
||||
expect(rootFindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lintMediaUrls", () => {
|
||||
it("checks top-level remote media elements", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await lintMediaUrls('<img id="hero" src="https://example.com/hero.png">');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/hero.png",
|
||||
expect.objectContaining({ method: "HEAD" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores remote media embedded inside an iframe srcdoc attribute", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await lintMediaUrls(`<iframe srcdoc='<img src="https://example.com/embedded.png">'></iframe>`);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types";
|
||||
import { buildLintContext } from "./context";
|
||||
import { readAttr, truncateSnippet } from "./utils";
|
||||
import { parseHtmlStructure, readAttr, truncateSnippet } from "./utils";
|
||||
import { coreRules } from "./rules/core";
|
||||
import { mediaRules } from "./rules/media";
|
||||
import { gsapRules } from "./rules/gsap";
|
||||
@@ -73,11 +73,8 @@ function extractMediaUrls(html: string): Array<{
|
||||
elementId?: string;
|
||||
snippet: string;
|
||||
}> = [];
|
||||
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRe.exec(html)) !== null) {
|
||||
const tagName = (match[1] ?? "").toLowerCase();
|
||||
const raw = match[0];
|
||||
for (const { name: tagName, raw } of parseHtmlStructure(html).tags) {
|
||||
if (!/^(?:video|audio|img|source)$/.test(tagName)) continue;
|
||||
const src = readAttr(raw, "src");
|
||||
if (!src) continue;
|
||||
if (/^https?:\/\//i.test(src)) {
|
||||
|
||||
@@ -209,3 +209,29 @@ describe("missing_or_empty_sub_composition", () => {
|
||||
expect(finding?.message).toContain("compositions/does-not-exist.html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("template shell style sources", () => {
|
||||
it("collects links, style blocks, and inline styles from template content", async () => {
|
||||
const project = makeProject(`<html><body>
|
||||
<div id="scene" data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
|
||||
<template data-composition-id="shell">
|
||||
<link rel="stylesheet" href="shell.css">
|
||||
<style>[data-composition-id="main"] .title { opacity: 0; }</style>
|
||||
<div style="mask-image: url(missing-inline-mask.png)"></div>
|
||||
<template><style>[data-composition-id="main"] .nested { opacity: 0; }</style></template>
|
||||
</template>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`);
|
||||
writeFileSync(
|
||||
join(project, "shell.css"),
|
||||
'[data-composition-id="main"] .from-link { opacity: 0; }',
|
||||
);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const findings = results.flatMap((entry) => entry.result.findings);
|
||||
expect(
|
||||
findings.filter((finding) => finding.code === "composition_self_attribute_selector"),
|
||||
).toHaveLength(3);
|
||||
expect(findings.some((finding) => finding.code === "texture_mask_asset_not_found")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,19 @@ interface CssSource {
|
||||
rootRelativePath?: string;
|
||||
}
|
||||
|
||||
/** Linkedom keeps template contents in a DocumentFragment that is not part of
|
||||
* the document query tree. Lint rules must still see shell styles and links
|
||||
* inside templates, so walk each template's content recursively without
|
||||
* falling back to regex parsing. */
|
||||
function querySelectorAllIncludingTemplates(root: ParentNode, selector: string): Element[] {
|
||||
const matches: Element[] = [...root.querySelectorAll(selector)];
|
||||
for (const template of root.querySelectorAll("template")) {
|
||||
const content = (template as HTMLTemplateElement).content;
|
||||
if (content) matches.push(...querySelectorAllIncludingTemplates(content, selector));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export interface ProjectLintResult {
|
||||
results: Array<{ file: string; result: HyperframeLintResult }>;
|
||||
totalErrors: number;
|
||||
@@ -32,75 +45,67 @@ export interface ProjectLintResult {
|
||||
}
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
|
||||
const STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
const OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
|
||||
const MASK_IMAGE_URL_RE =
|
||||
/\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
|
||||
|
||||
function readHtmlAttr(tag: string, name: string): string | null {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = tag.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
|
||||
return match?.[1] ?? match?.[2] ?? null;
|
||||
}
|
||||
|
||||
function isLocalStylesheetHref(href: string): boolean {
|
||||
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
|
||||
}
|
||||
|
||||
function collectLocalStylesheets(
|
||||
projectDir: string,
|
||||
document: ParentNode,
|
||||
compSrcPath?: string,
|
||||
): Array<{ href: string; content: string; rootRelativePath: string }> {
|
||||
const styles: Array<{ href: string; content: string; rootRelativePath: string }> = [];
|
||||
for (const link of querySelectorAllIncludingTemplates(document, "link")) {
|
||||
const rel = link.getAttribute("rel") ?? "";
|
||||
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
if (!isLocalStylesheetHref(href)) continue;
|
||||
const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
||||
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
|
||||
if (!stylesheet) continue;
|
||||
styles.push({
|
||||
href,
|
||||
content: readFileSync(stylesheet.resolved, "utf-8"),
|
||||
rootRelativePath: stylesheet.rootRelativePath,
|
||||
});
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
function collectExternalStyles(
|
||||
projectDir: string,
|
||||
html: string,
|
||||
compSrcPath?: string,
|
||||
): Array<{ href: string; content: string }> {
|
||||
const styles: Array<{ href: string; content: string }> = [];
|
||||
const linkRe = /<link\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = linkRe.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const rel = tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
|
||||
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
||||
const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
|
||||
if (!isLocalStylesheetHref(href)) continue;
|
||||
const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
||||
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
|
||||
if (!stylesheet) continue;
|
||||
styles.push({ href, content: readFileSync(stylesheet.resolved, "utf-8") });
|
||||
const { document } = parseHTML(html);
|
||||
for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) {
|
||||
styles.push({ href, content });
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
|
||||
const sources: CssSource[] = [];
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
let styleMatch: RegExpExecArray | null;
|
||||
const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags);
|
||||
while ((styleMatch = stylePattern.exec(html)) !== null) {
|
||||
sources.push({ content: styleMatch[1] ?? "" });
|
||||
for (const style of querySelectorAllIncludingTemplates(document, "style")) {
|
||||
sources.push({ content: style.textContent ?? "" });
|
||||
}
|
||||
|
||||
const linkRe = /<link\b[^>]*>/gi;
|
||||
let linkMatch: RegExpExecArray | null;
|
||||
while ((linkMatch = linkRe.exec(html)) !== null) {
|
||||
const tag = linkMatch[0];
|
||||
const rel = readHtmlAttr(tag, "rel") ?? "";
|
||||
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
||||
const href = readHtmlAttr(tag, "href") ?? "";
|
||||
if (!isLocalStylesheetHref(href)) continue;
|
||||
|
||||
const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
||||
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelativePath);
|
||||
if (!stylesheet) continue;
|
||||
sources.push({
|
||||
content: readFileSync(stylesheet.resolved, "utf-8"),
|
||||
rootRelativePath: stylesheet.rootRelativePath,
|
||||
});
|
||||
for (const { content, rootRelativePath } of collectLocalStylesheets(
|
||||
projectDir,
|
||||
document,
|
||||
compSrcPath,
|
||||
)) {
|
||||
sources.push({ content, rootRelativePath });
|
||||
}
|
||||
|
||||
let tagMatch: RegExpExecArray | null;
|
||||
const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags);
|
||||
while ((tagMatch = tagPattern.exec(html)) !== null) {
|
||||
const tag = tagMatch[0];
|
||||
const style = readHtmlAttr(tag, "style");
|
||||
for (const element of querySelectorAllIncludingTemplates(document, "[style]")) {
|
||||
const style = element.getAttribute("style");
|
||||
if (!style) continue;
|
||||
sources.push({ content: style });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
|
||||
import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context";
|
||||
import {
|
||||
findHtmlTag,
|
||||
readAttr,
|
||||
@@ -132,12 +132,11 @@ function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | null {
|
||||
* template/fragment sub-comps hold it on their composition root div. Returns
|
||||
* null if any occurrence has unparseable JSON.
|
||||
*/
|
||||
function collectAllDeclaredVariableIds(source: string): Set<string> | null {
|
||||
function collectAllDeclaredVariableIds(tags: readonly OpenTag[]): Set<string> | null {
|
||||
const all = new Set<string>();
|
||||
const tagRe = /<[a-zA-Z][^>]*\bdata-composition-variables\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRe.exec(source)) !== null) {
|
||||
const ids = collectDeclaredVariableIds(match[0]);
|
||||
for (const tag of tags) {
|
||||
if (!readAttr(tag.raw, "data-composition-variables")) continue;
|
||||
const ids = collectDeclaredVariableIds(tag.raw);
|
||||
if (ids === null) return null;
|
||||
for (const id of ids) all.add(id);
|
||||
}
|
||||
@@ -150,10 +149,10 @@ function collectAllDeclaredVariableIds(source: string): Set<string> | null {
|
||||
* `<html>` and no declarations of its own (its values come from a host's
|
||||
* data-variable-values, which this file can't see).
|
||||
*/
|
||||
function declaredIdsForBindingCheck(source: string): Set<string> | null {
|
||||
const declared = collectAllDeclaredVariableIds(source);
|
||||
function declaredIdsForBindingCheck(tags: readonly OpenTag[]): Set<string> | null {
|
||||
const declared = collectAllDeclaredVariableIds(tags);
|
||||
if (declared === null) return null;
|
||||
if (declared.size === 0 && !findHtmlTag(source)) return null;
|
||||
if (declared.size === 0 && !findHtmlTag(tags)) return null;
|
||||
return declared;
|
||||
}
|
||||
|
||||
@@ -653,11 +652,11 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// nothing, so a typo'd binding is invisible until a customer's override
|
||||
// does nothing. Skipped for fragment files (no <html>): their values come
|
||||
// from a host's data-variable-values, which this file can't see.
|
||||
({ source, tags }) => {
|
||||
({ tags }) => {
|
||||
// Declarations live on <html> (full-document comps) OR the composition root
|
||||
// div (template/fragment sub-comps); declaredIdsForBindingCheck unions both
|
||||
// and returns null for files this rule should skip.
|
||||
const declared = declaredIdsForBindingCheck(source);
|
||||
const declared = declaredIdsForBindingCheck(tags);
|
||||
if (!declared) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
@@ -683,8 +682,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// catch them at lint time rather than wondering why their `getVariables()`
|
||||
// defaults aren't applied.
|
||||
// fallow-ignore-next-line complexity
|
||||
({ source }) => {
|
||||
const htmlTag = findHtmlTag(source);
|
||||
({ tags }) => {
|
||||
const htmlTag = findHtmlTag(tags);
|
||||
if (!htmlTag) return [];
|
||||
const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
|
||||
if (!raw) return [];
|
||||
@@ -763,8 +762,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// fixed top-left-origin screenshot region, which RTL layout can shift the
|
||||
// actual content away from), only surfaces the already-confirmed footgun
|
||||
// before someone hits it blind.
|
||||
({ source }) => {
|
||||
const htmlTag = findHtmlTag(source);
|
||||
({ tags }) => {
|
||||
const htmlTag = findHtmlTag(tags);
|
||||
if (!htmlTag) return [];
|
||||
const dir = readAttr(htmlTag.raw, "dir");
|
||||
if (!dir) return [];
|
||||
|
||||
@@ -77,6 +77,46 @@ ${headContent}
|
||||
}
|
||||
|
||||
describe("core rules", () => {
|
||||
it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="1280" data-height="720"></div>
|
||||
<iframe srcdoc="<script>const child = gsap.timeline({ paused: true }); child.to("#x", { opacity: 1 });</script>"></iframe>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const rootTl = gsap.timeline({ paused: true });
|
||||
window.__timelines["root"] = rootTl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
|
||||
expect(
|
||||
result.findings.find((finding) => finding.code === "invalid_inline_script_syntax"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.findings.find((finding) => finding.code === "gsap_timeline_not_registered"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not lint elements embedded inside an iframe srcdoc attribute", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="1280" data-height="720"></div>
|
||||
<iframe srcdoc='<video src="child.mp4" data-start="0"></video>'></iframe>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
|
||||
expect(
|
||||
result.findings.find(
|
||||
(finding) => finding.elementId === undefined && finding.message.includes("<video"),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when an id starts with a digit and is unsafe in a hash selector", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
+68
-63
@@ -1,11 +1,15 @@
|
||||
// Shared types, regex constants, and utility functions used across lint rule modules.
|
||||
// Nothing in this file should emit findings — it only parses and extracts.
|
||||
|
||||
import { Parser } from "htmlparser2";
|
||||
|
||||
export type OpenTag = {
|
||||
raw: string;
|
||||
name: string;
|
||||
attrs: string;
|
||||
index: number;
|
||||
closeIndex?: number;
|
||||
endIndex?: number;
|
||||
};
|
||||
|
||||
export type ExtractedBlock = {
|
||||
@@ -15,9 +19,6 @@ export type ExtractedBlock = {
|
||||
index: number;
|
||||
};
|
||||
|
||||
const TAG_PATTERN = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
|
||||
export const STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
|
||||
export const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||
const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
|
||||
export const TIMELINE_REGISTRY_INIT_PATTERN =
|
||||
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
|
||||
@@ -51,36 +52,57 @@ const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\
|
||||
const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN =
|
||||
/(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
|
||||
|
||||
export function extractOpenTags(source: string): OpenTag[] {
|
||||
export function parseHtmlStructure(source: string): {
|
||||
tags: OpenTag[];
|
||||
scripts: ExtractedBlock[];
|
||||
styles: ExtractedBlock[];
|
||||
} {
|
||||
const tags: OpenTag[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(TAG_PATTERN.source, TAG_PATTERN.flags);
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const raw = match[0];
|
||||
if (raw.startsWith("</") || raw.startsWith("<!")) continue;
|
||||
tags.push({
|
||||
raw,
|
||||
name: (match[1] || "").toLowerCase(),
|
||||
attrs: match[2] || "",
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
const blocks = { script: [] as ExtractedBlock[], style: [] as ExtractedBlock[] };
|
||||
const openTagsByName = new Map<string, OpenTag[]>();
|
||||
const openBlocks: Array<{
|
||||
name: "script" | "style";
|
||||
attrs: string;
|
||||
contentStart: number;
|
||||
index: number;
|
||||
}> = [];
|
||||
const parser: Parser = new Parser(
|
||||
{
|
||||
onopentag(name) {
|
||||
const index = parser.startIndex;
|
||||
const raw = source.slice(index, parser.endIndex + 1);
|
||||
const attrs = raw.slice(name.length + 1, -1).replace(/\s*\/$/, "");
|
||||
const tag = { raw, name, attrs, index };
|
||||
tags.push(tag);
|
||||
const sameNameStack = openTagsByName.get(name) ?? [];
|
||||
sameNameStack.push(tag);
|
||||
openTagsByName.set(name, sameNameStack);
|
||||
if (name === "script" || name === "style") {
|
||||
openBlocks.push({ name, attrs, contentStart: parser.endIndex + 1, index });
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
const tag = openTagsByName.get(name)?.pop();
|
||||
if (tag) {
|
||||
tag.closeIndex = parser.startIndex;
|
||||
tag.endIndex = parser.endIndex + 1;
|
||||
}
|
||||
if (name !== "script" && name !== "style") return;
|
||||
const block = openBlocks.pop();
|
||||
if (!block || block.name !== name) return;
|
||||
blocks[name].push({
|
||||
attrs: block.attrs,
|
||||
content: source.slice(block.contentStart, parser.startIndex),
|
||||
raw: source.slice(block.index, parser.endIndex + 1),
|
||||
index: block.index,
|
||||
});
|
||||
},
|
||||
},
|
||||
{ decodeEntities: false, lowerCaseAttributeNames: false, lowerCaseTags: true },
|
||||
);
|
||||
parser.end(source);
|
||||
|
||||
export function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[] {
|
||||
const blocks: ExtractedBlock[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const p = new RegExp(pattern.source, pattern.flags);
|
||||
while ((match = p.exec(source)) !== null) {
|
||||
blocks.push({
|
||||
attrs: match[1] || "",
|
||||
content: match[2] || "",
|
||||
raw: match[0],
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
return { tags, scripts: blocks.script, styles: blocks.style };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,41 +111,25 @@ export function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[]
|
||||
* composition's visible root", whereas `<html>` is where document-level
|
||||
* metadata like `data-composition-variables` lives.
|
||||
*/
|
||||
export function findHtmlTag(source: string): OpenTag | null {
|
||||
const match = /<html\b([^<>]*)>/i.exec(source);
|
||||
if (!match) return null;
|
||||
return {
|
||||
raw: match[0],
|
||||
name: "html",
|
||||
attrs: match[1] ?? "",
|
||||
index: match.index,
|
||||
};
|
||||
export function findHtmlTag(tags: readonly OpenTag[]): OpenTag | null {
|
||||
return tags.find((tag) => tag.name === "html") ?? null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function findRootTag(source: string): OpenTag | null {
|
||||
const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source);
|
||||
const bodyCloseMatch = /<\/body>/i.exec(source);
|
||||
export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): OpenTag | null {
|
||||
const tags = parsedTags ?? parseHtmlStructure(source).tags;
|
||||
const bodyTag = tags.find((tag) => tag.name === "body");
|
||||
if (
|
||||
bodyOpenMatch &&
|
||||
(readAttr(bodyOpenMatch[0], "data-composition-id") ||
|
||||
readAttr(bodyOpenMatch[0], "data-width") ||
|
||||
readAttr(bodyOpenMatch[0], "data-height"))
|
||||
bodyTag &&
|
||||
(readAttr(bodyTag.raw, "data-composition-id") ||
|
||||
readAttr(bodyTag.raw, "data-width") ||
|
||||
readAttr(bodyTag.raw, "data-height"))
|
||||
) {
|
||||
return {
|
||||
raw: bodyOpenMatch[0],
|
||||
name: "body",
|
||||
attrs: bodyOpenMatch[1] ?? "",
|
||||
index: bodyOpenMatch.index,
|
||||
};
|
||||
return bodyTag;
|
||||
}
|
||||
const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
||||
const bodyEnd =
|
||||
bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart
|
||||
? bodyCloseMatch.index
|
||||
: source.length;
|
||||
const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source;
|
||||
const bodyTags = extractOpenTags(bodyContent);
|
||||
const bodyStart = bodyTag ? bodyTag.index + bodyTag.raw.length : 0;
|
||||
const bodyEnd = bodyTag?.closeIndex ?? source.length;
|
||||
const bodyTags = tags.filter((tag) => tag.index >= bodyStart && tag.index < bodyEnd);
|
||||
// Set when a leading <svg> defs block is skipped (see below) — extractOpenTags
|
||||
// is a flat, nesting-unaware scan, so without this the very next tag it
|
||||
// returns is the svg's own nested child (<defs>, <filter>, ...), not the
|
||||
@@ -146,13 +152,12 @@ export function findRootTag(source: string): OpenTag | null {
|
||||
!readAttr(tag.raw, "data-width") &&
|
||||
!readAttr(tag.raw, "data-height")
|
||||
) {
|
||||
const closeMatch = /<\/svg\s*>/i.exec(bodyContent.slice(tag.index));
|
||||
// No closing tag found (malformed HTML) — skip everything rather than
|
||||
// risk returning one of the svg's own children as the root.
|
||||
skipBefore = closeMatch ? tag.index + closeMatch.index + closeMatch[0].length : Infinity;
|
||||
skipBefore = tag.endIndex ?? Infinity;
|
||||
continue;
|
||||
}
|
||||
return { ...tag, index: tag.index + bodyStart };
|
||||
return tag;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user