@hyperframes/lint 0.7.24 → 0.7.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,10 +4,13 @@ var STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
4
4
  var SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
5
5
  var COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
6
6
  var TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
7
+ var TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN = /window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i;
7
8
  var TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
8
9
  var WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
9
10
  var INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
10
11
  var TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
12
+ var TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i;
13
+ var TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN = /(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
11
14
  function extractOpenTags(source) {
12
15
  const tags = [];
13
16
  let match;
@@ -115,6 +118,17 @@ function extractTimelineRegistryKeys(source) {
115
118
  const key = match[1] ?? match[2];
116
119
  if (key) keys.add(key);
117
120
  }
121
+ const objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1];
122
+ if (objectBody) {
123
+ const entryPattern = new RegExp(
124
+ TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
125
+ TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags
126
+ );
127
+ while ((match = entryPattern.exec(objectBody)) !== null) {
128
+ const key = match[1] ?? match[2];
129
+ if (key) keys.add(key);
130
+ }
131
+ }
118
132
  return [...keys];
119
133
  }
120
134
  function getInlineScriptSyntaxError(source) {
@@ -211,6 +225,9 @@ function extractScriptTextsAndSrcs(scripts) {
211
225
  function isMediaTag(tagName) {
212
226
  return tagName === "video" || tagName === "audio" || tagName === "img";
213
227
  }
228
+ function hasCaptionStyles(styles) {
229
+ return styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
230
+ }
214
231
  function truncateSnippet(value, maxLength = 220) {
215
232
  const normalized = value.replace(/\s+/g, " ").trim();
216
233
  if (!normalized) return void 0;
@@ -438,7 +455,7 @@ var coreRules = [
438
455
  return [];
439
456
  }
440
457
  const findings = [];
441
- if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) && !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)) {
458
+ if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) && !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) && !TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source)) {
442
459
  findings.push({
443
460
  code: "missing_timeline_registry",
444
461
  severity: "error",
@@ -1353,13 +1370,16 @@ function findContainingCompositionId(tag, ranges) {
1353
1370
  }
1354
1371
  return match?.id || null;
1355
1372
  }
1373
+ function getClipTagClasses(tag) {
1374
+ const classAttr = readAttr(tag.raw, "class") || "";
1375
+ const classes = classAttr.split(/\s+/).filter(Boolean);
1376
+ return classes.includes("clip") ? { classAttr, classes } : null;
1377
+ }
1356
1378
  function collectClipStartBoundariesByComposition(source, tags) {
1357
1379
  const ranges = collectCompositionRanges(source, tags);
1358
1380
  const boundaries = /* @__PURE__ */ new Map();
1359
1381
  for (const tag of tags) {
1360
- const classAttr = readAttr(tag.raw, "class") || "";
1361
- const classes = classAttr.split(/\s+/).filter(Boolean);
1362
- if (!classes.includes("clip")) continue;
1382
+ if (!getClipTagClasses(tag)) continue;
1363
1383
  const compositionId = findContainingCompositionId(tag, ranges);
1364
1384
  if (!compositionId) continue;
1365
1385
  const start = numberValue(readAttr(tag.raw, "data-start") ?? void 0);
@@ -1516,6 +1536,23 @@ function extractStandaloneGsapTransformCalls(script) {
1516
1536
  }
1517
1537
  return calls;
1518
1538
  }
1539
+ function scanScriptsForRegexMatches(scripts, pattern, options) {
1540
+ const hits = [];
1541
+ for (const script of scripts) {
1542
+ const content = options.stripComments ? stripJsComments(script.content) : script.content;
1543
+ const regex = new RegExp(pattern.source, pattern.flags);
1544
+ let match;
1545
+ while ((match = regex.exec(content)) !== null) {
1546
+ const contextStart = Math.max(0, match.index - options.contextBefore);
1547
+ const contextEnd = Math.min(
1548
+ content.length,
1549
+ match.index + match[0].length + options.contextAfter
1550
+ );
1551
+ hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
1552
+ }
1553
+ }
1554
+ return hits;
1555
+ }
1519
1556
  var gsapRules = [
1520
1557
  // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
1521
1558
  // fallow-ignore-next-line complexity
@@ -1524,17 +1561,16 @@ var gsapRules = [
1524
1561
  const clipIds = /* @__PURE__ */ new Map();
1525
1562
  const clipClasses = /* @__PURE__ */ new Map();
1526
1563
  for (const tag of tags) {
1527
- const classAttr = readAttr(tag.raw, "class") || "";
1528
- const classes = classAttr.split(/\s+/).filter(Boolean);
1529
- if (!classes.includes("clip")) continue;
1564
+ const clipTag = getClipTagClasses(tag);
1565
+ if (!clipTag) continue;
1530
1566
  const id = readAttr(tag.raw, "id");
1531
1567
  const info = {
1532
1568
  tag: tag.name,
1533
1569
  id: id || "",
1534
- classes: classAttr
1570
+ classes: clipTag.classAttr
1535
1571
  };
1536
1572
  if (id) clipIds.set(`#${id}`, info);
1537
- for (const cls of classes) {
1573
+ for (const cls of clipTag.classes) {
1538
1574
  if (cls !== "clip") clipClasses.set(`.${cls}`, info);
1539
1575
  }
1540
1576
  }
@@ -1782,8 +1818,7 @@ ${right.raw}`)
1782
1818
  // fallow-ignore-next-line complexity
1783
1819
  ({ scripts, styles }) => {
1784
1820
  const findings = [];
1785
- const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
1786
- if (!isCaptionFile) return findings;
1821
+ if (!hasCaptionStyles(styles)) return findings;
1787
1822
  for (const script of scripts) {
1788
1823
  const content = script.content;
1789
1824
  const hasAudioData = /AUDIO|audio[-_]?data|bands\[/.test(content);
@@ -1808,44 +1843,38 @@ ${right.raw}`)
1808
1843
  // gsap_infinite_repeat
1809
1844
  ({ scripts }) => {
1810
1845
  const findings = [];
1811
- for (const script of scripts) {
1812
- const content = stripJsComments(script.content);
1813
- const pattern = /repeat\s*:\s*-1(?!\d)/g;
1814
- let match;
1815
- while ((match = pattern.exec(content)) !== null) {
1816
- const contextStart = Math.max(0, match.index - 60);
1817
- const contextEnd = Math.min(content.length, match.index + match[0].length + 60);
1818
- const snippet = content.slice(contextStart, contextEnd).trim();
1819
- findings.push({
1820
- code: "gsap_infinite_repeat",
1821
- severity: "error",
1822
- message: "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic capture engine which seeks to exact frame times. Use a finite repeat count calculated from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
1823
- fixHint: "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
1824
- snippet: truncateSnippet(snippet)
1825
- });
1826
- }
1846
+ const pattern = /repeat\s*:\s*-1(?!\d)/g;
1847
+ for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
1848
+ stripComments: true,
1849
+ contextBefore: 60,
1850
+ contextAfter: 60
1851
+ })) {
1852
+ findings.push({
1853
+ code: "gsap_infinite_repeat",
1854
+ severity: "error",
1855
+ message: "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic capture engine which seeks to exact frame times. Use a finite repeat count calculated from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
1856
+ fixHint: "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
1857
+ snippet: truncateSnippet(snippet)
1858
+ });
1827
1859
  }
1828
1860
  return findings;
1829
1861
  },
1830
1862
  // gsap_repeat_ceil_overshoot
1831
1863
  ({ scripts }) => {
1832
1864
  const findings = [];
1833
- for (const script of scripts) {
1834
- const content = script.content;
1835
- const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
1836
- let match;
1837
- while ((match = pattern.exec(content)) !== null) {
1838
- const contextStart = Math.max(0, match.index - 40);
1839
- const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
1840
- const snippet = content.slice(contextStart, contextEnd).trim();
1841
- findings.push({
1842
- code: "gsap_repeat_ceil_overshoot",
1843
- severity: "warning",
1844
- message: "GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. For example, Math.ceil(10.5 / 2) - 1 = 5 repeats \u2192 6 cycles \xD7 2s = 12s, exceeding 10.5s.",
1845
- fixHint: "Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: `repeat: Math.floor(totalDuration / cycleDuration) - 1`. Math.floor(10.5 / 2) - 1 = 4 repeats \u2192 5 cycles \xD7 2s = 10s \u2713",
1846
- snippet: truncateSnippet(snippet)
1847
- });
1848
- }
1865
+ const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
1866
+ for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
1867
+ stripComments: false,
1868
+ contextBefore: 40,
1869
+ contextAfter: 40
1870
+ })) {
1871
+ findings.push({
1872
+ code: "gsap_repeat_ceil_overshoot",
1873
+ severity: "warning",
1874
+ message: "GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. For example, Math.ceil(10.5 / 2) - 1 = 5 repeats \u2192 6 cycles \xD7 2s = 12s, exceeding 10.5s.",
1875
+ fixHint: "Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: `repeat: Math.floor(totalDuration / cycleDuration) - 1`. Math.floor(10.5 / 2) - 1 = 4 repeats \u2192 5 cycles \xD7 2s = 10s \u2713",
1876
+ snippet: truncateSnippet(snippet)
1877
+ });
1849
1878
  }
1850
1879
  return findings;
1851
1880
  },
@@ -1886,7 +1915,7 @@ ${right.raw}`)
1886
1915
  for (const script of scripts) {
1887
1916
  const content = script.content;
1888
1917
  if (!/gsap\.timeline/.test(content)) continue;
1889
- const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content);
1918
+ const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) || TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
1890
1919
  if (hasRegistration || canInheritFromHost) continue;
1891
1920
  findings.push({
1892
1921
  code: "gsap_timeline_not_registered",
@@ -1971,23 +2000,21 @@ ${right.raw}`)
1971
2000
  // gsap_group_selector_keyframes
1972
2001
  ({ scripts }) => {
1973
2002
  const findings = [];
1974
- for (const script of scripts) {
1975
- const content = stripJsComments(script.content);
1976
- const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
1977
- let match;
1978
- while ((match = pattern.exec(content)) !== null) {
1979
- const selector = match[1];
1980
- const count = selector.split(",").length;
1981
- const contextStart = Math.max(0, match.index - 20);
1982
- const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
1983
- findings.push({
1984
- code: "gsap_group_selector_keyframes",
1985
- severity: "warning",
1986
- message: `GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). Editing one element's keyframes in Studio will affect all ${count} elements. Split into individual tweens for per-element keyframe control.`,
1987
- fixHint: `Replace the group selector with individual tl.to() calls per element, each with their own keyframes object.`,
1988
- snippet: truncateSnippet(content.slice(contextStart, contextEnd))
1989
- });
1990
- }
2003
+ const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
2004
+ for (const { match, snippet } of scanScriptsForRegexMatches(scripts, pattern, {
2005
+ stripComments: true,
2006
+ contextBefore: 20,
2007
+ contextAfter: 40
2008
+ })) {
2009
+ const selector = match[1];
2010
+ const count = selector.split(",").length;
2011
+ findings.push({
2012
+ code: "gsap_group_selector_keyframes",
2013
+ severity: "warning",
2014
+ message: `GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). Editing one element's keyframes in Studio will affect all ${count} elements. Split into individual tweens for per-element keyframe control.`,
2015
+ fixHint: `Replace the group selector with individual tl.to() calls per element, each with their own keyframes object.`,
2016
+ snippet: truncateSnippet(snippet)
2017
+ });
1991
2018
  }
1992
2019
  return findings;
1993
2020
  }
@@ -2208,6 +2235,7 @@ import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
2208
2235
  var MAX_COMPOSITION_LINES = 300;
2209
2236
  var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
2210
2237
  var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
2238
+ var OVERLAP_EPSILON_SECONDS = 1e-6;
2211
2239
  function countPhysicalLines(source) {
2212
2240
  if (source.length === 0) return 0;
2213
2241
  const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -2520,7 +2548,7 @@ var compositionRules = [
2520
2548
  const current = clips[i];
2521
2549
  const next = clips[i + 1];
2522
2550
  if (!current || !next) continue;
2523
- if (current.end > next.start) {
2551
+ if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
2524
2552
  findings.push({
2525
2553
  code: "overlapping_clips_same_track",
2526
2554
  severity: "error",