import type { LintContext, HyperframeLintFinding } from "../context"; import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils"; import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract"; /** * Does the GSAP call that names `#id` also set `volume` in the same call? * * Depth-counted rather than regex-bounded: the selector opens somewhere inside a * call, and the interesting region ends when THAT call closes — a nested * `fadeTime(2)` opens and closes on the way and must not end the scan. A regex * cannot count parens, and both fixed bounds were wrong in opposite directions: * unbounded blamed a later element, first-paren missed a whole ordinary shape. */ function tweensVolumeInSameCall(script: string, id: string): boolean { const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g"); for (let hit = selector.exec(script); hit; hit = selector.exec(script)) { let depth = 0; // Cap the scan so a malformed script cannot walk the whole file. const limit = Math.min(script.length, hit.index + 2000); for (let i = hit.index; i < limit; i += 1) { const ch = script[i]; if (ch === "(") depth += 1; else if (ch === ")") { // Past the end of the call the selector sits in. if (depth === 0) break; depth -= 1; } else if (ch === ";" && depth === 0) break; else if (ch === "v" && /^volume\s*:/.test(script.slice(i))) return true; } } return false; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function hasAttrName(tagSource: string, attr: string): boolean { const escaped = escapeRegExp(attr); const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, ""); return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs); } function classNamesFromAttr(classAttr: string | null): string[] { if (!classAttr) return []; return classAttr.split(/\s+/).filter(Boolean); } type MediaSelectorIndex = { ids: Set; classes: Set; hasVideo: boolean; hasAudio: boolean; }; function selectorTargetsManagedMedia(selector: string, mediaIndex: MediaSelectorIndex): boolean { const normalized = selector.trim(); if (!normalized) return false; if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true; if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true; for (const mediaId of mediaIndex.ids) { const escapedId = escapeRegExp(mediaId); if ( new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`) ) { return true; } } for (const className of mediaIndex.classes) { if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) { return true; } } return false; } function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] { const findings: HyperframeLintFinding[] = []; const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio"); const mediaIndex: MediaSelectorIndex = { ids: new Set( mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id): id is string => Boolean(id)), ), classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))), hasVideo: mediaTags.some((tag) => tag.name === "video"), hasAudio: mediaTags.some((tag) => tag.name === "audio"), }; if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings; for (const script of ctx.scripts) { const mediaVars = new Map(); const assignmentPatterns = [ { pattern: /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g, variableIndex: 1, targetIndex: 2, }, { pattern: /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g, variableIndex: 1, targetIndex: 3, }, ]; for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) { let match: RegExpExecArray | null; while ((match = pattern.exec(script.content)) !== null) { const variableName = match[variableIndex]; const target = match[targetIndex]; if (!variableName || !target) continue; if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) { mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : undefined); } } } const directIdPatterns = [ { pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g, kind: "play()", targetIndex: 1, }, { pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g, kind: "pause()", targetIndex: 1, }, { pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g, kind: "currentTime", targetIndex: 1, }, { pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g, kind: "muted assignment", targetIndex: 1, }, { pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g, kind: "play()", targetIndex: 2, }, { pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g, kind: "pause()", targetIndex: 2, }, { pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g, kind: "currentTime", targetIndex: 2, }, { pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g, kind: "muted assignment", targetIndex: 2, }, ]; for (const { pattern, kind, targetIndex } of directIdPatterns) { let match: RegExpExecArray | null; while ((match = pattern.exec(script.content)) !== null) { const target = match[targetIndex]; if (!target) continue; const elementId = mediaIndex.ids.has(target) ? target : selectorTargetsManagedMedia(target, mediaIndex) ? undefined : null; if (elementId === null) continue; findings.push({ code: "imperative_media_control", severity: "error", message: `Inline