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
@@ -54,6 +54,7 @@
},
"dependencies": {
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8"
},
+22 -13
View File
@@ -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 {
+27 -2
View File
@@ -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();
});
});
+3 -6
View File
@@ -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)) {
+26
View File
@@ -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);
});
});
+50 -45
View File
@@ -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 });
}
+14 -15
View File
@@ -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 [];
+40
View File
@@ -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(&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 () => {
const html = `
<html><body>
+68 -63
View File
@@ -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;
}