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:
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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("#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 () => {
|
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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user