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 = `