fix(lint): a timeline registered in a local script file counts as registered (#6829)

* fix(lint): a timeline registered in a local script file counts as registered

* fix(lint): a module script file is read as a module

* style(lint): format the module script check
This commit is contained in:
Miguel Ángel
2026-10-30 18:29:58 +00:00
committed by GitHub
parent ef9155fee1
commit 0708f6ca7d
5 changed files with 106 additions and 7 deletions
+11 -1
View File
@@ -64,7 +64,17 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
rootRelativePath: style.rootRelativePath,
})),
];
const scripts = structure.scripts;
const scripts = [
...structure.scripts,
...(options.externalScripts ?? []).map((script) => ({
attrs: script.attrs,
content: script.content,
raw: script.content,
index: -1,
file: script.file,
rootRelativePath: script.rootRelativePath,
})),
];
const compositionIds = collectCompositionIds(tags);
const rootTag = findRootTag(source, tags);
const rootCompositionId = readDecodedAttr(rootTag?.raw || "", "data-composition-id");
+40
View File
@@ -566,6 +566,46 @@ describe("template shell style sources", () => {
});
});
describe("local script files", () => {
const registryFindings = async (project: string) =>
(await lintProject(project)).results
.flatMap((entry) => entry.result.findings)
.filter((finding) => finding.code === "missing_timeline_registry");
function projectWithScript(script: string, attrs = ""): string {
const project = makeProject(
`<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
<script ${attrs} src="animation.js"></script>
</body></html>`,
);
writeFileSync(join(project, "animation.js"), script);
return project;
}
it("counts a timeline registered in a local script file", async () => {
const registered = projectWithScript(
'window.__timelines = window.__timelines || {};\nwindow.__timelines["main"] = gsap.timeline({ paused: true });',
);
expect(await registryFindings(registered)).toEqual([]);
expect(await registryFindings(projectWithScript("console.log('no timeline')"))).toHaveLength(1);
});
it("reads a module script file as a module", async () => {
const project = projectWithScript(
'import { gsap } from "gsap";\nwindow.__timelines = window.__timelines || {};\nwindow.__timelines["main"] = gsap.timeline({ paused: true });',
'type="module"',
);
const codes = (await lintProject(project)).results
.flatMap((entry) => entry.result.findings)
.map((finding) => finding.code);
expect(codes).not.toContain("invalid_inline_script_syntax");
expect(codes).not.toContain("missing_timeline_registry");
});
});
describe("sub-composition stylesheet links", () => {
function projectWithSharedFonts(compDir: string, href: string): string {
const project = makeProject(validHtml());
+38
View File
@@ -155,6 +155,42 @@ export function collectExternalStyles(
return styles;
}
const MAX_EXTERNAL_SCRIPT_BYTES = 1_000_000;
function collectExternalScripts(
projectDir: string,
html: string,
compSrcPath?: string,
): NonNullable<HyperframeLinterOptions["externalScripts"]> {
const scripts: NonNullable<HyperframeLinterOptions["externalScripts"]> = [];
const { document } = parseHTML(html);
for (const element of querySelectorAllIncludingTemplates(document, "script[src]")) {
const src = element.getAttribute("src") ?? "";
if (!isLocalStylesheetHref(src) || /\.min\.m?js(?:[?#]|$)/i.test(src)) continue;
const path = src.split(/[?#]/)[0] ?? src;
const besideFile = compSrcPath ? join(dirname(compSrcPath), path) : path;
const script =
resolveExistingLocalAsset(projectDir, besideFile) ??
(compSrcPath && staysInsideProjectRoot(path)
? resolveExistingLocalAsset(projectDir, path)
: null);
if (!script) continue;
const read = readProjectFile(script.resolved);
if (read.kind !== "file" || read.text.length > MAX_EXTERNAL_SCRIPT_BYTES) continue;
const attrs = Array.from(element.attributes)
.map(({ name, value }) => `${name}="${value.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"`)
.join(" ");
scripts.push({
src,
attrs,
content: read.text,
file: join(projectDir, script.rootRelativePath),
rootRelativePath: script.rootRelativePath,
});
}
return scripts;
}
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
const sources: CssSource[] = [];
const { document } = parseHTML(html);
@@ -268,6 +304,7 @@ export async function lintProject(
compSrcPath: rootFile,
externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath),
projectFontFamilies,
externalScripts: collectExternalScripts(projectDir, rootHtml, rootCompSrcPath),
});
results.push({ file: rootFile, result: rootResult, contentHash: contentDigest(rootHtml) });
totalErrors += rootResult.errorCount;
@@ -311,6 +348,7 @@ export async function lintProject(
compSrcPath,
externalStyles: collectExternalStyles(projectDir, html, compSrcPath),
projectFontFamilies,
externalScripts: collectExternalScripts(projectDir, html, compSrcPath),
});
results.push({
file: `compositions/${file}`,
+10 -6
View File
@@ -626,10 +626,14 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
if (/(?:^|\s)data-no-timeline(?=[\s=/]|$)/i.test(rootTag?.attrs || "")) return [];
const findings: HyperframeLintFinding[] = [];
const registered = [source, ...(options.externalScripts ?? []).map((script) => script.content)];
if (
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
!TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source)
!registered.some(
(text) =>
TIMELINE_REGISTRY_INIT_PATTERN.test(text) ||
TIMELINE_REGISTRY_ASSIGN_PATTERN.test(text) ||
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(text),
)
) {
findings.push({
code: "missing_timeline_registry",
@@ -839,9 +843,9 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
({ scripts, locate }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const type = readDecodedAttr(script.raw, "type")?.trim().toLowerCase() ?? "";
if (readDecodedAttr(script.raw, "src") !== null || SCRIPT_SYNTAX_EXEMPT_TYPES.has(type))
continue;
const tag = `<script ${script.attrs}>`;
const type = readDecodedAttr(tag, "type")?.trim().toLowerCase() ?? "";
if (readDecodedAttr(tag, "src") !== null || SCRIPT_SYNTAX_EXEMPT_TYPES.has(type)) continue;
const syntaxError = getInlineScriptSyntaxError(script.content);
if (!syntaxError) continue;
findings.push({
+7
View File
@@ -47,6 +47,13 @@ export type HyperframeLinterOptions = {
file?: string;
rootRelativePath?: string;
}>;
externalScripts?: Array<{
src: string;
attrs: string;
content: string;
file: string;
rootRelativePath: string;
}>;
/**
* Set to `true` when linting compositions destined for distributed / Lambda
* rendering, where system-font capture (`allowSystemFontCapture`) is