From 4e5efcc54e2daa6f7c176cf17ba7fd0a435e3f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 11 Jul 2026 15:32:55 +0000 Subject: [PATCH] fix(lint): detect timelines in local external scripts --- packages/cli/src/utils/lintProject.test.ts | 20 ++++++++++++++++++++ packages/lint/src/context.ts | 10 +++++++++- packages/lint/src/project.ts | 20 ++++++++++++++++++++ packages/lint/src/rules/core.ts | 18 ++++++++++-------- packages/lint/src/types.ts | 1 + 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 0ce2eb0c67..bb00a864b5 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -71,6 +71,26 @@ describe("lintProject", () => { expect(first?.file).toBe("index.html"); }); + it("detects timeline registration in a local external script", async () => { + const projectDir = makeProject(` +
+ + `); + const workDir = join(projectDir, "work"); + mkdirSync(workDir, { recursive: true }); + writeFileSync( + join(workDir, "dance.js"), + `window.__timelines = window.__timelines || {}; + window.__timelines["dance"] = gsap.timeline({ paused: true });`, + ); + + const { results } = await lintProject(projectDir); + + expect( + results[0]?.result.findings.find((finding) => finding.code === "missing_timeline_registry"), + ).toBeUndefined(); + }); + it("detects errors in index.html", async () => { const project = makeProject(htmlWithMissingMediaId()); const { totalErrors, results } = await lintProject(project); diff --git a/packages/lint/src/context.ts b/packages/lint/src/context.ts index 9638f3ac52..74b533fadb 100644 --- a/packages/lint/src/context.ts +++ b/packages/lint/src/context.ts @@ -63,7 +63,15 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions index: -1, })), ]; - const scripts = structure.scripts; + const scripts = [ + ...structure.scripts, + ...(options.externalScripts ?? []).map((script) => ({ + attrs: `src="${script.src}"`, + content: script.content, + raw: script.content, + index: -1, + })), + ]; const compositionIds = collectCompositionIds(tags); const rootTag = findRootTag(source, tags); const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id"); diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 52d828c1db..2578d40347 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -88,6 +88,24 @@ function collectExternalStyles( return styles; } +function collectExternalScripts( + projectDir: string, + html: string, + compSrcPath?: string, +): Array<{ src: string; content: string }> { + const scripts: Array<{ src: string; content: string }> = []; + const { document } = parseHTML(html); + for (const script of querySelectorAllIncludingTemplates(document, "script[src]")) { + const src = script.getAttribute("src") ?? ""; + if (!src || isRemoteOrInlineUrl(src)) continue; + const rootRelative = compSrcPath ? join(dirname(compSrcPath), src) : src; + const asset = resolveExistingLocalAsset(projectDir, rootRelative); + if (!asset) continue; + scripts.push({ src, content: readFileSync(asset.resolved, "utf-8") }); + } + return scripts; +} + function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] { const sources: CssSource[] = []; const { document } = parseHTML(html); @@ -191,6 +209,7 @@ export async function lintProject(projectDir: string): Promise HyperframeLintFinding[]> = [ }, // missing_timeline_registry + timeline_registry_missing_init - ({ source, rawSource, options }) => { + ({ source, rawSource, scripts, options }) => { // Sub-compositions inherit window.__timelines from the host composition if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith(" script.content)].join("\n"); if ( - !TIMELINE_REGISTRY_INIT_PATTERN.test(source) && - !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) && - !TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source) + !TIMELINE_REGISTRY_INIT_PATTERN.test(timelineSource) && + !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(timelineSource) && + !TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(timelineSource) ) { findings.push({ code: "missing_timeline_registry", @@ -283,8 +284,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ }); } if ( - TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) && - !TIMELINE_REGISTRY_INIT_PATTERN.test(source) + TIMELINE_REGISTRY_ASSIGN_PATTERN.test(timelineSource) && + !TIMELINE_REGISTRY_INIT_PATTERN.test(timelineSource) ) { findings.push({ code: "timeline_registry_missing_init", @@ -299,7 +300,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ }, // timeline_id_mismatch - ({ source }) => { + ({ source, scripts }) => { const findings: HyperframeLintFinding[] = []; const htmlCompIds = new Set(); const timelineRegKeys = new Set(); @@ -308,7 +309,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ while ((m = compIdRe.exec(source)) !== null) { if (m[1]) htmlCompIds.add(m[1]); } - for (const key of extractTimelineRegistryKeys(source)) { + const timelineSource = [source, ...scripts.map((script) => script.content)].join("\n"); + for (const key of extractTimelineRegistryKeys(timelineSource)) { timelineRegKeys.add(key); } for (const key of timelineRegKeys) { diff --git a/packages/lint/src/types.ts b/packages/lint/src/types.ts index 8911566f93..dc9242a07c 100644 --- a/packages/lint/src/types.ts +++ b/packages/lint/src/types.ts @@ -23,6 +23,7 @@ export type HyperframeLinterOptions = { filePath?: string; isSubComposition?: boolean; externalStyles?: Array<{ href: string; content: string }>; + externalScripts?: Array<{ src: string; content: string }>; /** * Set to `true` when linting compositions destined for distributed / Lambda * rendering, where system-font capture (`allowSystemFontCapture`) is