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:
Miguel Ángel
2026-07-11 00:03:07 -04:00
committed by GitHub
parent 87618eef4c
commit 05c3b5503f
10 changed files with 252 additions and 144 deletions
+1
View File
@@ -169,6 +169,7 @@
"version": "0.7.45", "version": "0.7.45",
"dependencies": { "dependencies": {
"@hyperframes/parsers": "workspace:*", "@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12", "linkedom": "^0.18.12",
"postcss": "^8.5.8", "postcss": "^8.5.8",
}, },
+1
View File
@@ -54,6 +54,7 @@
}, },
"dependencies": { "dependencies": {
"@hyperframes/parsers": "workspace:*", "@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12", "linkedom": "^0.18.12",
"postcss": "^8.5.8" "postcss": "^8.5.8"
}, },
+22 -13
View File
@@ -1,13 +1,10 @@
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types"; import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
import { import {
extractBlocks, parseHtmlStructure,
extractOpenTags,
findRootTag, findRootTag,
collectCompositionIds, collectCompositionIds,
readAttr, readAttr,
stripHtmlComments, stripHtmlComments,
STYLE_BLOCK_PATTERN,
SCRIPT_BLOCK_PATTERN,
} from "./utils"; } from "./utils";
import type { OpenTag, ExtractedBlock } 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 // hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
// stay ReDoS-free and catch markers that re-form when a comment is removed. // stay ReDoS-free and catch markers that re-form when a comment is removed.
let source = stripHtmlComments(rawSource); let source = stripHtmlComments(rawSource);
const sourceWithoutTemplates = source.replace( const initialStructure = parseHtmlStructure(source);
/<template\b[^>]*>[\s\S]*?<\/template(?:\s[^>]*)?>/gi, 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 // Some sub-composition files are HTML shells whose real root lives inside a
// <template>. Keep nested templates intact when the visible document already // <template>. Keep nested templates intact when the visible document already
// has a composition root; only unwrap when no root exists outside templates. // 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 = [ const styles = [
...extractBlocks(source, STYLE_BLOCK_PATTERN), ...structure.styles,
...(options.externalStyles ?? []).map((style) => ({ ...(options.externalStyles ?? []).map((style) => ({
attrs: `href="${style.href}"`, attrs: `href="${style.href}"`,
content: style.content, content: style.content,
@@ -54,9 +63,9 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
index: -1, index: -1,
})), })),
]; ];
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN); const scripts = structure.scripts;
const compositionIds = collectCompositionIds(tags); const compositionIds = collectCompositionIds(tags);
const rootTag = findRootTag(source); const rootTag = findRootTag(source, tags);
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id"); const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
return { return {
+27 -2
View File
@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest"; import { afterEach, describe, it, expect, vi } from "vitest";
import { lintHyperframeHtml } from "./hyperframeLinter.js"; import { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
afterEach(() => vi.unstubAllGlobals());
describe("lintHyperframeHtml — orchestrator", () => { describe("lintHyperframeHtml — orchestrator", () => {
const validComposition = ` const validComposition = `
@@ -122,3 +124,26 @@ describe("lintHyperframeHtml — orchestrator", () => {
expect(rootFindings).toHaveLength(0); 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();
});
});
+3 -6
View File
@@ -1,6 +1,6 @@
import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types"; import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types";
import { buildLintContext } from "./context"; import { buildLintContext } from "./context";
import { readAttr, truncateSnippet } from "./utils"; import { parseHtmlStructure, readAttr, truncateSnippet } from "./utils";
import { coreRules } from "./rules/core"; import { coreRules } from "./rules/core";
import { mediaRules } from "./rules/media"; import { mediaRules } from "./rules/media";
import { gsapRules } from "./rules/gsap"; import { gsapRules } from "./rules/gsap";
@@ -73,11 +73,8 @@ function extractMediaUrls(html: string): Array<{
elementId?: string; elementId?: string;
snippet: string; snippet: string;
}> = []; }> = [];
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi; for (const { name: tagName, raw } of parseHtmlStructure(html).tags) {
let match: RegExpExecArray | null; if (!/^(?:video|audio|img|source)$/.test(tagName)) continue;
while ((match = tagRe.exec(html)) !== null) {
const tagName = (match[1] ?? "").toLowerCase();
const raw = match[0];
const src = readAttr(raw, "src"); const src = readAttr(raw, "src");
if (!src) continue; if (!src) continue;
if (/^https?:\/\//i.test(src)) { if (/^https?:\/\//i.test(src)) {
+26
View File
@@ -209,3 +209,29 @@ describe("missing_or_empty_sub_composition", () => {
expect(finding?.message).toContain("compositions/does-not-exist.html"); 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);
});
});
+50 -45
View File
@@ -24,6 +24,19 @@ interface CssSource {
rootRelativePath?: string; 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 { export interface ProjectLintResult {
results: Array<{ file: string; result: HyperframeLintResult }>; results: Array<{ file: string; result: HyperframeLintResult }>;
totalErrors: number; totalErrors: number;
@@ -32,75 +45,67 @@ export interface ProjectLintResult {
} }
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]); 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 = const MASK_IMAGE_URL_RE =
/\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi; /\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 { function isLocalStylesheetHref(href: string): boolean {
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href); 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( function collectExternalStyles(
projectDir: string, projectDir: string,
html: string, html: string,
compSrcPath?: string, compSrcPath?: string,
): Array<{ href: string; content: string }> { ): Array<{ href: string; content: string }> {
const styles: Array<{ href: string; content: string }> = []; const styles: Array<{ href: string; content: string }> = [];
const linkRe = /<link\b[^>]*>/gi; const { document } = parseHTML(html);
let match: RegExpExecArray | null; for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) {
while ((match = linkRe.exec(html)) !== null) { styles.push({ href, content });
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") });
} }
return styles; return styles;
} }
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] { function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
const sources: CssSource[] = []; const sources: CssSource[] = [];
const { document } = parseHTML(html);
let styleMatch: RegExpExecArray | null; for (const style of querySelectorAllIncludingTemplates(document, "style")) {
const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags); sources.push({ content: style.textContent ?? "" });
while ((styleMatch = stylePattern.exec(html)) !== null) {
sources.push({ content: styleMatch[1] ?? "" });
} }
const linkRe = /<link\b[^>]*>/gi; for (const { content, rootRelativePath } of collectLocalStylesheets(
let linkMatch: RegExpExecArray | null; projectDir,
while ((linkMatch = linkRe.exec(html)) !== null) { document,
const tag = linkMatch[0]; compSrcPath,
const rel = readHtmlAttr(tag, "rel") ?? ""; )) {
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue; sources.push({ content, rootRelativePath });
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,
});
} }
let tagMatch: RegExpExecArray | null; for (const element of querySelectorAllIncludingTemplates(document, "[style]")) {
const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags); const style = element.getAttribute("style");
while ((tagMatch = tagPattern.exec(html)) !== null) {
const tag = tagMatch[0];
const style = readHtmlAttr(tag, "style");
if (!style) continue; if (!style) continue;
sources.push({ content: style }); sources.push({ content: style });
} }
+14 -15
View File
@@ -1,4 +1,4 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context"; import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context";
import { import {
findHtmlTag, findHtmlTag,
readAttr, readAttr,
@@ -132,12 +132,11 @@ function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | null {
* template/fragment sub-comps hold it on their composition root div. Returns * template/fragment sub-comps hold it on their composition root div. Returns
* null if any occurrence has unparseable JSON. * 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 all = new Set<string>();
const tagRe = /<[a-zA-Z][^>]*\bdata-composition-variables\b[^>]*>/gi; for (const tag of tags) {
let match: RegExpExecArray | null; if (!readAttr(tag.raw, "data-composition-variables")) continue;
while ((match = tagRe.exec(source)) !== null) { const ids = collectDeclaredVariableIds(tag.raw);
const ids = collectDeclaredVariableIds(match[0]);
if (ids === null) return null; if (ids === null) return null;
for (const id of ids) all.add(id); 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 * `<html>` and no declarations of its own (its values come from a host's
* data-variable-values, which this file can't see). * data-variable-values, which this file can't see).
*/ */
function declaredIdsForBindingCheck(source: string): Set<string> | null { function declaredIdsForBindingCheck(tags: readonly OpenTag[]): Set<string> | null {
const declared = collectAllDeclaredVariableIds(source); const declared = collectAllDeclaredVariableIds(tags);
if (declared === null) return null; if (declared === null) return null;
if (declared.size === 0 && !findHtmlTag(source)) return null; if (declared.size === 0 && !findHtmlTag(tags)) return null;
return declared; 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 // nothing, so a typo'd binding is invisible until a customer's override
// does nothing. Skipped for fragment files (no <html>): their values come // does nothing. Skipped for fragment files (no <html>): their values come
// from a host's data-variable-values, which this file can't see. // 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 // Declarations live on <html> (full-document comps) OR the composition root
// div (template/fragment sub-comps); declaredIdsForBindingCheck unions both // div (template/fragment sub-comps); declaredIdsForBindingCheck unions both
// and returns null for files this rule should skip. // and returns null for files this rule should skip.
const declared = declaredIdsForBindingCheck(source); const declared = declaredIdsForBindingCheck(tags);
if (!declared) return []; if (!declared) return [];
const findings: HyperframeLintFinding[] = []; const findings: HyperframeLintFinding[] = [];
for (const tag of tags) { 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()` // catch them at lint time rather than wondering why their `getVariables()`
// defaults aren't applied. // defaults aren't applied.
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
({ source }) => { ({ tags }) => {
const htmlTag = findHtmlTag(source); const htmlTag = findHtmlTag(tags);
if (!htmlTag) return []; if (!htmlTag) return [];
const raw = readJsonAttr(htmlTag.raw, "data-composition-variables"); const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
if (!raw) return []; 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 // fixed top-left-origin screenshot region, which RTL layout can shift the
// actual content away from), only surfaces the already-confirmed footgun // actual content away from), only surfaces the already-confirmed footgun
// before someone hits it blind. // before someone hits it blind.
({ source }) => { ({ tags }) => {
const htmlTag = findHtmlTag(source); const htmlTag = findHtmlTag(tags);
if (!htmlTag) return []; if (!htmlTag) return [];
const dir = readAttr(htmlTag.raw, "dir"); const dir = readAttr(htmlTag.raw, "dir");
if (!dir) return []; if (!dir) return [];
+40
View File
@@ -77,6 +77,46 @@ ${headContent}
} }
describe("core rules", () => { 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(&quot;#x&quot;, { 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 () => { it("warns when an id starts with a digit and is unsafe in a hash selector", async () => {
const html = ` const html = `
<html><body> <html><body>
+68 -63
View File
@@ -1,11 +1,15 @@
// Shared types, regex constants, and utility functions used across lint rule modules. // Shared types, regex constants, and utility functions used across lint rule modules.
// Nothing in this file should emit findings — it only parses and extracts. // Nothing in this file should emit findings — it only parses and extracts.
import { Parser } from "htmlparser2";
export type OpenTag = { export type OpenTag = {
raw: string; raw: string;
name: string; name: string;
attrs: string; attrs: string;
index: number; index: number;
closeIndex?: number;
endIndex?: number;
}; };
export type ExtractedBlock = { export type ExtractedBlock = {
@@ -15,9 +19,6 @@ export type ExtractedBlock = {
index: number; 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; const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
export const TIMELINE_REGISTRY_INIT_PATTERN = export const TIMELINE_REGISTRY_INIT_PATTERN =
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i; /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 = const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN =
/(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g; /(?:["']([^"']+)["']|([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[] = []; const tags: OpenTag[] = [];
let match: RegExpExecArray | null; const blocks = { script: [] as ExtractedBlock[], style: [] as ExtractedBlock[] };
const pattern = new RegExp(TAG_PATTERN.source, TAG_PATTERN.flags); const openTagsByName = new Map<string, OpenTag[]>();
while ((match = pattern.exec(source)) !== null) { const openBlocks: Array<{
const raw = match[0]; name: "script" | "style";
if (raw.startsWith("</") || raw.startsWith("<!")) continue; attrs: string;
tags.push({ contentStart: number;
raw, index: number;
name: (match[1] || "").toLowerCase(), }> = [];
attrs: match[2] || "", const parser: Parser = new Parser(
index: match.index, {
}); onopentag(name) {
} const index = parser.startIndex;
return tags; 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[] { return { tags, scripts: blocks.script, styles: blocks.style };
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;
} }
/** /**
@@ -89,41 +111,25 @@ export function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[]
* composition's visible root", whereas `<html>` is where document-level * composition's visible root", whereas `<html>` is where document-level
* metadata like `data-composition-variables` lives. * metadata like `data-composition-variables` lives.
*/ */
export function findHtmlTag(source: string): OpenTag | null { export function findHtmlTag(tags: readonly OpenTag[]): OpenTag | null {
const match = /<html\b([^<>]*)>/i.exec(source); return tags.find((tag) => tag.name === "html") ?? null;
if (!match) return null;
return {
raw: match[0],
name: "html",
attrs: match[1] ?? "",
index: match.index,
};
} }
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
export function findRootTag(source: string): OpenTag | null { export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): OpenTag | null {
const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source); const tags = parsedTags ?? parseHtmlStructure(source).tags;
const bodyCloseMatch = /<\/body>/i.exec(source); const bodyTag = tags.find((tag) => tag.name === "body");
if ( if (
bodyOpenMatch && bodyTag &&
(readAttr(bodyOpenMatch[0], "data-composition-id") || (readAttr(bodyTag.raw, "data-composition-id") ||
readAttr(bodyOpenMatch[0], "data-width") || readAttr(bodyTag.raw, "data-width") ||
readAttr(bodyOpenMatch[0], "data-height")) readAttr(bodyTag.raw, "data-height"))
) { ) {
return { return bodyTag;
raw: bodyOpenMatch[0],
name: "body",
attrs: bodyOpenMatch[1] ?? "",
index: bodyOpenMatch.index,
};
} }
const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0; const bodyStart = bodyTag ? bodyTag.index + bodyTag.raw.length : 0;
const bodyEnd = const bodyEnd = bodyTag?.closeIndex ?? source.length;
bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart const bodyTags = tags.filter((tag) => tag.index >= bodyStart && tag.index < bodyEnd);
? bodyCloseMatch.index
: source.length;
const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source;
const bodyTags = extractOpenTags(bodyContent);
// Set when a leading <svg> defs block is skipped (see below) — extractOpenTags // 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 // 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 // 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-width") &&
!readAttr(tag.raw, "data-height") !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 // No closing tag found (malformed HTML) — skip everything rather than
// risk returning one of the svg's own children as the root. // 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; continue;
} }
return { ...tag, index: tag.index + bodyStart }; return tag;
} }
return null; return null;
} }