diff --git a/packages/lint/src/context.ts b/packages/lint/src/context.ts
index c01fbadd59..fd6e48c0b6 100644
--- a/packages/lint/src/context.ts
+++ b/packages/lint/src/context.ts
@@ -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");
diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts
index 9e39147c82..ac56524e08 100644
--- a/packages/lint/src/project.test.ts
+++ b/packages/lint/src/project.test.ts
@@ -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(
+ `
+
+
+`,
+ );
+ 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());
diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts
index 0c26e5d5e1..e16877d5d2 100644
--- a/packages/lint/src/project.ts
+++ b/packages/lint/src/project.ts
@@ -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 {
+ const scripts: NonNullable = [];
+ 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, "&").replace(/"/g, """)}"`)
+ .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}`,
diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts
index 9ab93b8fb1..69f9e581b5 100644
--- a/packages/lint/src/rules/core.ts
+++ b/packages/lint/src/rules/core.ts
@@ -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 = `