Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/lint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
},
"dependencies": {
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8"
},
Expand Down
35 changes: 22 additions & 13 deletions packages/lint/src/context.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -34,29 +31,41 @@ 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,
raw: style.content,
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 {
Expand Down
29 changes: 27 additions & 2 deletions packages/lint/src/hyperframeLinter.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
Expand Down Expand Up @@ -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();
});
});
9 changes: 3 additions & 6 deletions packages/lint/src/hyperframeLinter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down
26 changes: 26 additions & 0 deletions packages/lint/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
93 changes: 49 additions & 44 deletions packages/lint/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 collectExternalStyles(
function collectLocalStylesheets(
projectDir: string,
html: string,
document: ParentNode,
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] ?? "";
): 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 = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
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") });
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 { 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 });
}
Expand Down
29 changes: 14 additions & 15 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context";
import {
findHtmlTag,
readAttr,
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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 [];
Expand Down Expand Up @@ -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 [];
Expand Down
Loading
Loading