@hyperframes/lint 0.7.60 → 0.7.62

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
@@ -686,6 +686,17 @@ var coreRules = [
686
686
  pattern: /crypto\.getRandomValues\s*\(/,
687
687
  label: "crypto.getRandomValues()",
688
688
  hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders."
689
+ },
690
+ {
691
+ pattern: /gsap\.utils\.random\s*\(/,
692
+ label: "gsap.utils.random()",
693
+ hint: "Each render worker initializes independently, so random values diverge across chunks. Use a seeded PRNG or fixed values."
694
+ },
695
+ {
696
+ // GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init.
697
+ pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
698
+ label: '"random(...)" tween value',
699
+ hint: "GSAP random string values re-roll at tween init and each render worker initializes independently. Use fixed values or precompute with a seeded PRNG."
689
700
  }
690
701
  ];
691
702
  for (const script of scripts) {
@@ -1311,19 +1322,24 @@ async function extractGsapWindows(script) {
1311
1322
  if (parsed.animations.length === 0) return [];
1312
1323
  const windows = [];
1313
1324
  for (const animation of parsed.animations) {
1314
- if (typeof animation.position !== "number") continue;
1325
+ const start = animation.resolvedStart ?? (typeof animation.position === "number" ? animation.position : null);
1326
+ if (start === null) continue;
1315
1327
  const repeat = extrasNumber(animation.extras?.repeat);
1316
- const cycleCount = repeat > 0 ? repeat + 1 : 1;
1328
+ const infiniteRepeat = repeat < 0;
1329
+ const cycleCount = infiniteRepeat ? 1 : repeat > 0 ? repeat + 1 : 1;
1317
1330
  const effectiveDuration = animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
1318
1331
  windows.push({
1319
1332
  targetSelector: animation.targetSelector,
1320
1333
  targetIdentity: animation.targetIdentity,
1321
- position: animation.position,
1322
- end: animation.position + effectiveDuration,
1334
+ position: start,
1335
+ end: infiniteRepeat && animation.method !== "set" ? Number.POSITIVE_INFINITY : start + effectiveDuration,
1323
1336
  properties: Object.keys(animation.properties),
1324
1337
  propertyValues: animation.properties,
1338
+ fromPropertyValues: animation.fromProperties,
1325
1339
  overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
1340
+ immediateRender: unwrapRaw(animation.extras?.immediateRender) === "true",
1326
1341
  method: animation.method,
1342
+ global: animation.global,
1327
1343
  raw: synthesizeWindowRaw(parsed.timelineVar, animation)
1328
1344
  });
1329
1345
  }
@@ -1352,6 +1368,30 @@ function isHiddenGsapState(values) {
1352
1368
  const display = stringValue(values.display)?.toLowerCase();
1353
1369
  return zeroValue(values.opacity) || zeroValue(values.autoAlpha) || visibility === "hidden" || display === "none";
1354
1370
  }
1371
+ function extractStandaloneHiddenSelectors(script) {
1372
+ const selectors = /* @__PURE__ */ new Set();
1373
+ const source = stripJsComments(script);
1374
+ const functionRanges = collectFunctionBodyRanges(source);
1375
+ const aliases = /* @__PURE__ */ new Map();
1376
+ for (const match2 of source.matchAll(
1377
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])([^"'`]+)\2\s*;/g
1378
+ )) {
1379
+ aliases.set(match2[1] ?? "", match2[3] ?? "");
1380
+ }
1381
+ const pattern = /gsap\.set\s*\(\s*([^,]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
1382
+ let match;
1383
+ while ((match = pattern.exec(source)) !== null) {
1384
+ if (indexInsideNonIifeRange(match.index, source, functionRanges)) continue;
1385
+ const target = (match[1] ?? "").trim();
1386
+ const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target);
1387
+ if (!selector) continue;
1388
+ const body = match[2] ?? "";
1389
+ if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) {
1390
+ selectors.add(selector);
1391
+ }
1392
+ }
1393
+ return selectors;
1394
+ }
1355
1395
  function oneValue(values, keys) {
1356
1396
  for (const key of keys) {
1357
1397
  const value = values[key];
@@ -1609,6 +1649,329 @@ function scanScriptsForRegexMatches(scripts, pattern, options) {
1609
1649
  }
1610
1650
  return hits;
1611
1651
  }
1652
+ var RELATIVE_TWEEN_VALUE = /^[+-]=/;
1653
+ function isRelativeTweenValue(value) {
1654
+ return typeof value === "string" && RELATIVE_TWEEN_VALUE.test(value.trim());
1655
+ }
1656
+ var TRANSFORM_SENSITIVE_READ = /\.getBoundingClientRect\s*\(|\bgetComputedStyle\s*\(|\bgsap\.getProperty\s*\(/;
1657
+ var TRANSFORM_INVARIANT_READ = /\.(?:getTotalLength|getBBox)\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/;
1658
+ var CALLBACK_MEASUREMENT_PATTERN = /\.(?:getBoundingClientRect|getTotalLength|getBBox)\s*\(|\bgetComputedStyle\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/;
1659
+ function indexTagsByToken(tags) {
1660
+ const tagsByToken = /* @__PURE__ */ new Map();
1661
+ const addToken = (token, tag) => {
1662
+ const list = tagsByToken.get(token);
1663
+ if (list) list.push(tag);
1664
+ else tagsByToken.set(token, [tag]);
1665
+ };
1666
+ for (const tag of tags) {
1667
+ const id = readAttr(tag.raw, "id");
1668
+ if (id) addToken(`#${id}`, tag);
1669
+ for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
1670
+ addToken(`.${cls}`, tag);
1671
+ }
1672
+ return tagsByToken;
1673
+ }
1674
+ function resolveSelectorTagIndexes(selector, tagsByToken) {
1675
+ const indexes = /* @__PURE__ */ new Set();
1676
+ for (const token of targetedSelectorTokens(selector)) {
1677
+ for (const tag of tagsByToken.get(token) ?? []) indexes.add(tag.index);
1678
+ }
1679
+ return indexes;
1680
+ }
1681
+ function selectorResolvesFaithfully(selector) {
1682
+ return selector.split(",").every((group) => {
1683
+ const token = group.trim();
1684
+ if (!token || token.includes("[")) return false;
1685
+ return !/[\s>+~]/.test(token);
1686
+ });
1687
+ }
1688
+ function targetsShareElement(a, b, tagsByToken) {
1689
+ if (!targetHasNoStableIdentity(a.selector, a.identity) && !targetHasNoStableIdentity(b.selector, b.identity) && (a.identity ?? a.selector) === (b.identity ?? b.selector)) {
1690
+ return true;
1691
+ }
1692
+ if (!selectorResolvesFaithfully(a.selector) || !selectorResolvesFaithfully(b.selector)) {
1693
+ return false;
1694
+ }
1695
+ const aTags = resolveSelectorTagIndexes(a.selector, tagsByToken);
1696
+ if (aTags.size === 0) return false;
1697
+ const bTags = resolveSelectorTagIndexes(b.selector, tagsByToken);
1698
+ for (const index of bTags) if (aTags.has(index)) return true;
1699
+ return false;
1700
+ }
1701
+ function matchBalanced(source, openIndex, open, close) {
1702
+ let depth = 0;
1703
+ for (let i = openIndex; i < source.length; i++) {
1704
+ const ch = source[i];
1705
+ if (ch === open) depth++;
1706
+ else if (ch === close) {
1707
+ depth--;
1708
+ if (depth === 0) return source.slice(openIndex, i + 1);
1709
+ }
1710
+ }
1711
+ return null;
1712
+ }
1713
+ function enclosingObjectLiteral(source, index) {
1714
+ let depth = 0;
1715
+ for (let i = index; i >= 0; i--) {
1716
+ const ch = source[i];
1717
+ if (ch === "}") depth++;
1718
+ else if (ch === "{") {
1719
+ if (depth === 0) return matchBalanced(source, i, "{", "}");
1720
+ depth--;
1721
+ }
1722
+ }
1723
+ return null;
1724
+ }
1725
+ function objectLiteralHasTopLevelRelativeValue(objectLiteral) {
1726
+ let depth = 0;
1727
+ let inString = null;
1728
+ for (let i = 0; i < objectLiteral.length; i++) {
1729
+ const ch = objectLiteral[i] ?? "";
1730
+ const prev = objectLiteral[i - 1] ?? "";
1731
+ if (inString) {
1732
+ if (ch === inString && prev !== "\\") inString = null;
1733
+ continue;
1734
+ }
1735
+ if (ch === '"' || ch === "'" || ch === "`") {
1736
+ inString = ch;
1737
+ if (depth === 1 && /^[+-]=/.test(objectLiteral.slice(i + 1))) return true;
1738
+ continue;
1739
+ }
1740
+ if (ch === "{" || ch === "(" || ch === "[") depth++;
1741
+ else if (ch === "}" || ch === ")" || ch === "]") depth--;
1742
+ }
1743
+ return false;
1744
+ }
1745
+ function isInsideGsapTweenVars(source, index, timelineVars) {
1746
+ let depth = 0;
1747
+ for (let i = index; i >= 0; i--) {
1748
+ const ch = source[i];
1749
+ if (ch === "}") depth++;
1750
+ else if (ch === "{") {
1751
+ if (depth === 0) {
1752
+ const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
1753
+ const receivers = ["gsap", ...timelineVars].map(escapeRegExp3).join("|");
1754
+ return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
1755
+ before
1756
+ );
1757
+ }
1758
+ depth--;
1759
+ }
1760
+ }
1761
+ return false;
1762
+ }
1763
+ function sliceExpression(source, start) {
1764
+ let depth = 0;
1765
+ for (let i = start; i < source.length; i++) {
1766
+ const ch = source[i] ?? "";
1767
+ if ("({[".includes(ch)) depth++;
1768
+ else if (")}]".includes(ch)) {
1769
+ if (depth === 0) return source.slice(start, i);
1770
+ depth--;
1771
+ } else if (ch === "," && depth === 0) return source.slice(start, i);
1772
+ }
1773
+ return source.slice(start);
1774
+ }
1775
+ function normalizeFirstParam(raw) {
1776
+ let param = raw.trim().replace(/=.*$/, "").trim();
1777
+ param = param.replace(/\s*:\s*[\w$|<>,\s[\].]+$/, "").trim();
1778
+ if (!param || /^[[{]/.test(param)) return null;
1779
+ if (!/^[A-Za-z_$][\w$]*$/.test(param)) return null;
1780
+ return param;
1781
+ }
1782
+ function parseFunctionValueSource(code) {
1783
+ const src = code.trim();
1784
+ const match = src.match(/^(?:async\s+)?function\s*[\w$]*\s*\(([^)]*)\)/) ?? src.match(/^(?:async\s*)?\(([^)]*)\)\s*=>/) ?? src.match(/^(?:async\s*)?([A-Za-z_$][\w$]*)\s*=>/);
1785
+ if (!match) return null;
1786
+ const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
1787
+ return { firstParam, body: src.slice(match[0].length) };
1788
+ }
1789
+ function escapeRegExp3(value) {
1790
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1791
+ }
1792
+ var NUMBER_METHODS = /* @__PURE__ */ new Set([
1793
+ "toFixed",
1794
+ "toString",
1795
+ "toPrecision",
1796
+ "toExponential",
1797
+ "toLocaleString",
1798
+ "valueOf"
1799
+ ]);
1800
+ function firstParamMemberAccessHazard(fn) {
1801
+ if (!fn.firstParam) return null;
1802
+ const pattern = new RegExp(
1803
+ `\\b${escapeRegExp3(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
1804
+ "g"
1805
+ );
1806
+ let match;
1807
+ while ((match = pattern.exec(fn.body)) !== null) {
1808
+ const member = match[1] ?? "";
1809
+ const after = fn.body.slice(match.index + match[0].length);
1810
+ const isCall = /^\s*\(/.test(after);
1811
+ if (isCall && NUMBER_METHODS.has(member)) continue;
1812
+ return member;
1813
+ }
1814
+ return null;
1815
+ }
1816
+ function collectTimelineVarNames(source) {
1817
+ return [...source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*gsap\.timeline\b/g)].map((m) => m[1] ?? "").filter(Boolean);
1818
+ }
1819
+ function collectNamedFunctionBodies(source) {
1820
+ const bodies = /* @__PURE__ */ new Map();
1821
+ const declPattern = /(?:^|[^.\w$])function\s+([A-Za-z_$][\w$]*)\s*\(/g;
1822
+ let match;
1823
+ while ((match = declPattern.exec(source)) !== null) {
1824
+ const braceIndex = source.indexOf("{", declPattern.lastIndex);
1825
+ if (braceIndex < 0) continue;
1826
+ const body = matchBalanced(source, braceIndex, "{", "}");
1827
+ if (body) bodies.set(match[1] ?? "", body);
1828
+ }
1829
+ const assignPattern = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b[^{]*|\([^)]*\)\s*=>\s*|[A-Za-z_$][\w$]*\s*=>\s*)/g;
1830
+ while ((match = assignPattern.exec(source)) !== null) {
1831
+ const bodyStart = assignPattern.lastIndex;
1832
+ const body = source[bodyStart] === "{" ? matchBalanced(source, bodyStart, "{", "}") : sliceExpression(source, bodyStart);
1833
+ if (body) bodies.set(match[1] ?? "", body);
1834
+ }
1835
+ return bodies;
1836
+ }
1837
+ function collectMeasuringFunctionNames(bodies) {
1838
+ const measuring = /* @__PURE__ */ new Set();
1839
+ for (const [name, body] of bodies) {
1840
+ if (CALLBACK_MEASUREMENT_PATTERN.test(body)) measuring.add(name);
1841
+ }
1842
+ for (let pass = 0; pass < 3; pass++) {
1843
+ let grew = false;
1844
+ for (const [name, body] of bodies) {
1845
+ if (measuring.has(name)) continue;
1846
+ for (const measured of measuring) {
1847
+ if (new RegExp(`\\b${escapeRegExp3(measured)}\\s*\\(`).test(body)) {
1848
+ measuring.add(name);
1849
+ grew = true;
1850
+ break;
1851
+ }
1852
+ }
1853
+ }
1854
+ if (!grew) break;
1855
+ }
1856
+ return measuring;
1857
+ }
1858
+ function expressionReachesMeasurement(expression, measuring) {
1859
+ if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
1860
+ for (const name of measuring) {
1861
+ if (new RegExp(`\\b${escapeRegExp3(name)}\\b`).test(expression)) return true;
1862
+ }
1863
+ return false;
1864
+ }
1865
+ function resolveScriptElementTokens(source, tags) {
1866
+ const documentIds = tags.map((tag) => readAttr(tag.raw, "id")).filter((id) => id !== null);
1867
+ const tokensByVar = /* @__PURE__ */ new Map();
1868
+ const add = (name, token) => {
1869
+ const tokens = tokensByVar.get(name) ?? /* @__PURE__ */ new Set();
1870
+ tokens.add(token);
1871
+ tokensByVar.set(name, tokens);
1872
+ };
1873
+ for (const match of source.matchAll(
1874
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*(["'])([^"'`]+)\2/g
1875
+ )) {
1876
+ add(match[1] ?? "", `#${match[3] ?? ""}`);
1877
+ }
1878
+ for (const match of source.matchAll(
1879
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*`([^`]*)`/g
1880
+ )) {
1881
+ const template = match[2] ?? "";
1882
+ const staticParts = template.split(/\$\{[^}]*\}/);
1883
+ if (staticParts.every((part) => part === "")) continue;
1884
+ const idPattern = new RegExp(`^${staticParts.map(escapeRegExp3).join(".*")}$`);
1885
+ for (const id of documentIds) {
1886
+ if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
1887
+ }
1888
+ }
1889
+ for (const match of source.matchAll(
1890
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.querySelector\(\s*(["'])([^"'`]+)\2/g
1891
+ )) {
1892
+ for (const token of targetedSelectorTokens(match[3] ?? "")) add(match[1] ?? "", token);
1893
+ }
1894
+ for (const match of source.matchAll(
1895
+ /\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*(["'])class\2\s*,\s*(["'])([^"'`]*)\3/g
1896
+ )) {
1897
+ for (const cls of (match[4] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`);
1898
+ }
1899
+ for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.className\s*=\s*(["'])([^"'`]*)\2/g)) {
1900
+ for (const cls of (match[3] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`);
1901
+ }
1902
+ return tokensByVar;
1903
+ }
1904
+ function elementLevelTokens(tokens, tagsByToken) {
1905
+ const expanded = new Set(tokens);
1906
+ for (const token of [...expanded]) {
1907
+ for (const tag of tagsByToken.get(token) ?? []) {
1908
+ for (const own of tagSimpleSelectors(tag)) expanded.add(own);
1909
+ }
1910
+ }
1911
+ return expanded;
1912
+ }
1913
+ function isMultiComponentDasharray(value) {
1914
+ const normalized = value.replace(/!important\s*$/i, "").trim();
1915
+ if (!normalized || /^none$/i.test(normalized)) return false;
1916
+ return normalized.split(/[\s,]+/).filter(Boolean).length >= 2;
1917
+ }
1918
+ function gsapDasharrayValueLooksMultiComponent(valueSource) {
1919
+ const literal = valueSource.trim().match(/^(["'`])([\s\S]*)\1$/)?.[2];
1920
+ if (literal === void 0) return false;
1921
+ return isMultiComponentDasharray(literal.replace(/\$\{[^}]*\}/g, "0"));
1922
+ }
1923
+ function collectFunctionBodyRanges(source) {
1924
+ const ranges = [];
1925
+ const openerPatterns = [/\bfunction\b[^{;()]*\([^)]*\)\s*\{/g, /=>\s*\{/g];
1926
+ for (const pattern of openerPatterns) {
1927
+ let match;
1928
+ while ((match = pattern.exec(source)) !== null) {
1929
+ const braceIndex = match.index + match[0].length - 1;
1930
+ const body = matchBalanced(source, braceIndex, "{", "}");
1931
+ if (body) ranges.push({ start: braceIndex, end: braceIndex + body.length });
1932
+ }
1933
+ }
1934
+ return ranges;
1935
+ }
1936
+ function indexInsideAnyRange(index, ranges) {
1937
+ return ranges.some((range) => index > range.start && index < range.end);
1938
+ }
1939
+ function isIifeBody(source, range) {
1940
+ let j = range.end;
1941
+ while (j < source.length && /\s/.test(source[j])) j++;
1942
+ if (source[j] !== ")") return false;
1943
+ j++;
1944
+ while (j < source.length && /\s/.test(source[j])) j++;
1945
+ return source[j] === "(" || source.startsWith(".call", j) || source.startsWith(".apply", j);
1946
+ }
1947
+ function indexInsideNonIifeRange(index, source, ranges) {
1948
+ return ranges.some(
1949
+ (range) => index > range.start && index < range.end && !isIifeBody(source, range)
1950
+ );
1951
+ }
1952
+ function collectCssOpacityZeroSelectors(styles, tags) {
1953
+ const selectors = /* @__PURE__ */ new Set();
1954
+ const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/;
1955
+ for (const style of styles) {
1956
+ for (const [, selector, body] of style.content.matchAll(
1957
+ /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g
1958
+ )) {
1959
+ if (body && opacityExactlyZero.test(body)) {
1960
+ selectors.add((selector ?? "").trim());
1961
+ }
1962
+ }
1963
+ }
1964
+ for (const tag of tags) {
1965
+ const inlineStyle = readAttr(tag.raw, "style");
1966
+ if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue;
1967
+ const id = readAttr(tag.raw, "id");
1968
+ if (id) selectors.add(`#${id}`);
1969
+ for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []) {
1970
+ selectors.add(`.${cls}`);
1971
+ }
1972
+ }
1973
+ return selectors;
1974
+ }
1612
1975
  var gsapRules = [
1613
1976
  // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
1614
1977
  // fallow-ignore-next-line complexity
@@ -1726,7 +2089,11 @@ ${right.raw}`)
1726
2089
  message: `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
1727
2090
  selector,
1728
2091
  elementId: readAttr(tag.raw, "id") || void 0,
1729
- fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add \`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
2092
+ // gsap_timeline_set_initial_hide warns on `tl.set(..., 0)` initial hides
2093
+ // (a zero-duration set at 0 does not render at exactly t=0), so this hint
2094
+ // must not recommend that pattern — advise authored CSS or an immediate
2095
+ // gsap.set() instead, keeping the two rules' advice consistent.
2096
+ fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add an immediate \`gsap.set("${selector}", { opacity: 0 })\` (outside the timeline) before the reveal tween.`,
1730
2097
  snippet: truncateSnippet(firstVisible.raw)
1731
2098
  });
1732
2099
  }
@@ -2015,40 +2382,39 @@ ${right.raw}`)
2015
2382
  }
2016
2383
  return findings;
2017
2384
  },
2018
- // gsap_from_opacity_noop CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
2385
+ // CSS/GSAP-hidden reveal safety. A fromTo() whose from-vars make an element
2386
+ // visible but whose destination omits opacity works during sequential seeks,
2387
+ // yet cold render workers restore the authored hidden state and encode it
2388
+ // permanently invisible.
2019
2389
  // fallow-ignore-next-line complexity
2020
2390
  async ({ styles, scripts, tags }) => {
2021
2391
  const findings = [];
2022
- const cssOpacityZeroSelectors = /* @__PURE__ */ new Set();
2023
- const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/;
2024
- for (const style of styles) {
2025
- for (const [, selector, body] of style.content.matchAll(
2026
- /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g
2027
- )) {
2028
- if (body && opacityExactlyZero.test(body)) {
2029
- cssOpacityZeroSelectors.add((selector ?? "").trim());
2030
- }
2031
- }
2032
- }
2033
- for (const tag of tags) {
2034
- const inlineStyle = readAttr(tag.raw, "style");
2035
- if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue;
2036
- const id = readAttr(tag.raw, "id");
2037
- const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
2038
- if (id) cssOpacityZeroSelectors.add(`#${id}`);
2039
- for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`);
2040
- }
2041
- if (cssOpacityZeroSelectors.size === 0) return findings;
2392
+ const cssOpacityZeroSelectors = collectCssOpacityZeroSelectors(styles, tags);
2042
2393
  for (const script of scripts) {
2043
2394
  if (!/gsap\.timeline/.test(script.content)) continue;
2044
2395
  const windows = await cachedExtractGsapWindows(script.content);
2396
+ const hiddenSelectors = /* @__PURE__ */ new Set([
2397
+ ...cssOpacityZeroSelectors,
2398
+ ...extractStandaloneHiddenSelectors(script.content)
2399
+ ]);
2045
2400
  for (const win of windows) {
2401
+ const sel = win.targetSelector;
2402
+ const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
2403
+ if (!hiddenSelectors.has(cssKey)) continue;
2404
+ if (win.method === "fromTo" && win.fromPropertyValues && isVisibleGsapState(win.fromPropertyValues) && !win.properties.some((property) => property === "opacity" || property === "autoAlpha")) {
2405
+ findings.push({
2406
+ code: "gsap_cold_seek_hidden_fromto_missing_reveal",
2407
+ severity: "error",
2408
+ message: `"${sel}" starts hidden, but its gsap.fromTo() makes it visible only in the from-vars and omits opacity/autoAlpha from the destination. Cold render workers restore the hidden authored state, so the encoded element can stay invisible even when sequential snapshots look correct.`,
2409
+ selector: sel,
2410
+ fixHint: `Add \`opacity: 1\` (or \`autoAlpha: 1\`) to the destination vars for "${sel}" so every seek path establishes the visible end state explicitly.`,
2411
+ snippet: truncateSnippet(win.raw)
2412
+ });
2413
+ continue;
2414
+ }
2046
2415
  if (win.method !== "from") continue;
2047
2416
  if (!win.properties.includes("opacity")) continue;
2048
2417
  if (win.propertyValues["opacity"] !== 0) continue;
2049
- const sel = win.targetSelector;
2050
- const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
2051
- if (!cssOpacityZeroSelectors.has(cssKey)) continue;
2052
2418
  findings.push({
2053
2419
  code: "gsap_from_opacity_noop",
2054
2420
  severity: "error",
@@ -2085,18 +2451,7 @@ ${right.raw}`)
2085
2451
  const findings = [];
2086
2452
  const layoutSubtreeRanges = tags.filter((t) => t.name.toLowerCase() === "canvas" && /\blayoutsubtree\b/i.test(t.raw)).map((t) => ({ start: t.index, end: findTagEnd(source, t) }));
2087
2453
  const isHtmlInCanvas = (tag) => layoutSubtreeRanges.some((r) => tag.index > r.start && tag.index < r.end);
2088
- const tagsByToken = /* @__PURE__ */ new Map();
2089
- const addToken = (token, tag) => {
2090
- const list = tagsByToken.get(token);
2091
- if (list) list.push(tag);
2092
- else tagsByToken.set(token, [tag]);
2093
- };
2094
- for (const tag of tags) {
2095
- const id = readAttr(tag.raw, "id");
2096
- if (id) addToken(`#${id}`, tag);
2097
- for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
2098
- addToken(`.${cls}`, tag);
2099
- }
2454
+ const tagsByToken = indexTagsByToken(tags);
2100
2455
  const allTargetsHtmlInCanvas = (selector) => {
2101
2456
  if (layoutSubtreeRanges.length === 0) return false;
2102
2457
  const matched = [...targetedSelectorTokens(selector)].flatMap(
@@ -2182,6 +2537,202 @@ ${right.raw}`)
2182
2537
  }
2183
2538
  return findings;
2184
2539
  },
2540
+ // gsap_relative_value_second_writer — a relative tween value ("+=..."/"-=...") on a
2541
+ // property that another writer is still ACTIVE on when the relative tween starts.
2542
+ // The relative tween captures its base at tween INIT, which happens on first render:
2543
+ // the sequential path inits it mid-flight of the other writer, a cold render worker
2544
+ // landing later inits it with the other writer's end state — the same frame then
2545
+ // renders at two different positions (a visible snap at chunk boundaries).
2546
+ // GSAP renders children in start-time order within a single seek pass, so a writer
2547
+ // that completes strictly BEFORE the relative tween's start yields identical bases
2548
+ // on every seek path and is never flagged. Single-writer relative values are
2549
+ // seek-stable. from()/fromTo() resolve their values at build (immediateRender), so
2550
+ // they are exempt. The position PARAMETER ("+=0.5") is not a tween value — the
2551
+ // parser keeps it out of properties — so it can never be flagged here.
2552
+ async ({ scripts, tags }) => {
2553
+ const findings = [];
2554
+ const tagsByToken = indexTagsByToken(tags);
2555
+ for (const script of scripts) {
2556
+ if (!/gsap\.timeline/.test(script.content)) continue;
2557
+ const windows = await cachedExtractGsapWindows(script.content);
2558
+ for (const win of windows) {
2559
+ if (win.method === "from" || win.method === "fromTo") continue;
2560
+ if (win.overwriteAuto) continue;
2561
+ if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
2562
+ const relativeProps = Object.entries(win.propertyValues).filter(([, value]) => isRelativeTweenValue(value)).map(([prop]) => prop);
2563
+ if (relativeProps.length === 0) continue;
2564
+ const target = { selector: win.targetSelector, identity: win.targetIdentity };
2565
+ for (const other of windows) {
2566
+ if (other === win) continue;
2567
+ if (other.position > win.position || other.end <= win.position) continue;
2568
+ const sharedProps = relativeProps.filter((prop) => other.properties.includes(prop));
2569
+ if (sharedProps.length === 0) continue;
2570
+ if (!targetsShareElement(
2571
+ target,
2572
+ { selector: other.targetSelector, identity: other.targetIdentity },
2573
+ tagsByToken
2574
+ )) {
2575
+ continue;
2576
+ }
2577
+ const values = sharedProps.map((prop) => `${prop}: "${win.propertyValues[prop]}"`).join(", ");
2578
+ const overlapEnd = Math.min(win.end, other.end);
2579
+ const formatTime = (t) => Number.isFinite(t) ? `${t.toFixed(2)}s` : "\u221E";
2580
+ findings.push({
2581
+ code: "gsap_relative_value_second_writer",
2582
+ severity: "error",
2583
+ message: `Relative value(s) ${values} on "${win.targetSelector}" start while another writer for the same propert${sharedProps.length > 1 ? "ies" : "y"} is active between ${formatTime(win.position)} and ${formatTime(overlapEnd)}. Relative tweens capture their base at tween init: the sequential path inits mid-flight of the other writer, a cold render worker landing later inits with its end state \u2014 the same frame renders at two different positions (snap at chunk boundaries).`,
2584
+ selector: win.targetSelector,
2585
+ fixHint: `Use absolute values for ${sharedProps.join(", ")}, or a fromTo() with explicit endpoints, so every seek path resolves the same state. Single-writer relative values are safe; the conflict is the second writer.`,
2586
+ snippet: truncateSnippet(`${win.raw}
2587
+ ${other.raw}`)
2588
+ });
2589
+ }
2590
+ }
2591
+ }
2592
+ return findings;
2593
+ },
2594
+ // gsap_repeat_refresh_relative_value — repeatRefresh re-resolves the tween's values
2595
+ // on every repeat iteration, so a relative value ACCUMULATES per cycle. A cold render
2596
+ // worker seeking non-linearly into iteration N skips the accumulation a sequential
2597
+ // playhead performed, so workers disagree on where the element is.
2598
+ ({ scripts }) => {
2599
+ const findings = [];
2600
+ for (const script of scripts) {
2601
+ const source = stripJsComments(script.content);
2602
+ const pattern = /repeatRefresh\s*:\s*true\b/g;
2603
+ let match;
2604
+ while ((match = pattern.exec(source)) !== null) {
2605
+ const objectLiteral = enclosingObjectLiteral(source, match.index);
2606
+ if (!objectLiteral || !objectLiteralHasTopLevelRelativeValue(objectLiteral)) continue;
2607
+ findings.push({
2608
+ code: "gsap_repeat_refresh_relative_value",
2609
+ severity: "error",
2610
+ message: '`repeatRefresh: true` combined with a relative value ("+="/"-=") accumulates per repeat iteration. A cold render worker seeking non-linearly into iteration N never performed the earlier iterations\' accumulation, so its rendered position diverges from the sequential path.',
2611
+ fixHint: "Remove `repeatRefresh: true`, or replace the relative value with absolute endpoints (e.g. a fromTo()) so each iteration resolves to the same state on every seek path.",
2612
+ snippet: truncateSnippet(objectLiteral)
2613
+ });
2614
+ }
2615
+ }
2616
+ return findings;
2617
+ },
2618
+ // gsap_function_value_hazard — function-valued tween vars re-run at tween INIT,
2619
+ // which is seek-order-dependent. A value reading transform-SENSITIVE geometry
2620
+ // (getBoundingClientRect/getComputedStyle/gsap.getProperty) captures whatever state
2621
+ // the worker's own seek order produced — error. Transform-INVARIANT layout reads
2622
+ // (offsetWidth, getTotalLength, ...) are deterministic across cold render workers
2623
+ // unless the measured layout itself animates — warning. GSAP function values receive
2624
+ // (index, target, targets) — index is a NUMBER, so a method call on the first
2625
+ // parameter (assuming it is the element) throws at init — error. Pure-index
2626
+ // arithmetic, gsap.utils.wrap/distribute, and closures over constants are statically
2627
+ // opaque or safe and are never flagged.
2628
+ //
2629
+ // Uses the raw parser output instead of the windows machinery: windows drop tweens
2630
+ // with string positions ("+=0.5", labels), and position is irrelevant to whether a
2631
+ // VALUE is hazardous.
2632
+ async ({ scripts }) => {
2633
+ const findings = [];
2634
+ const parseGsapScript = await loadParseGsapScript();
2635
+ for (const script of scripts) {
2636
+ if (!/gsap\.timeline/.test(script.content)) continue;
2637
+ const parsed = parseGsapScript(script.content);
2638
+ for (const anim of parsed.animations) {
2639
+ const raw = synthesizeWindowRaw(parsed.timelineVar, anim);
2640
+ const entries = [
2641
+ ...Object.entries(anim.properties),
2642
+ ...Object.entries(anim.fromProperties ?? {})
2643
+ ];
2644
+ for (const [prop, value] of entries) {
2645
+ if (typeof value !== "string" || !value.startsWith("__raw:")) continue;
2646
+ const fn = parseFunctionValueSource(value.slice(6));
2647
+ if (!fn) continue;
2648
+ const readsSensitive = TRANSFORM_SENSITIVE_READ.test(fn.body);
2649
+ const readsInvariant = TRANSFORM_INVARIANT_READ.test(fn.body);
2650
+ const badMember = firstParamMemberAccessHazard(fn);
2651
+ if (!readsSensitive && !readsInvariant && !badMember) continue;
2652
+ const reason = readsSensitive ? "reads transform-sensitive geometry, so its result depends on the worker's own seek order" : badMember ? `accesses .${badMember} on its first parameter \u2014 GSAP function values receive (index, target, targets), so the first parameter is a NUMBER and this throws at tween init` : "measures layout at tween init, which is deterministic across cold render workers only while the measured layout never animates";
2653
+ findings.push({
2654
+ code: "gsap_function_value_hazard",
2655
+ severity: readsSensitive || badMember ? "error" : "warning",
2656
+ message: `Function-valued tween var for ${prop} on "${anim.targetSelector}" ${reason}. Each render worker initializes tweens independently.`,
2657
+ selector: anim.targetSelector,
2658
+ fixHint: badMember ? "Use the SECOND parameter for the element: (index, target) => ... \u2014 or index arithmetic like (i) => i * 20." : "Compute the value once at build time (before the timeline is registered) and pass a constant, or derive it from fixed composition coordinates.",
2659
+ snippet: truncateSnippet(raw)
2660
+ });
2661
+ }
2662
+ }
2663
+ }
2664
+ return findings;
2665
+ },
2666
+ // gsap_callback_dom_measurement — DOM measurement reachable from timeline callbacks
2667
+ // (tl.add(fn) / tl.call(fn) / eventCallback / onStart-style vars). The capture path
2668
+ // seeks with suppressEvents=false (core/src/adapters/gsap.ts), so callbacks re-fire
2669
+ // on EVERY seek, including rewinds — and a cold render worker executes them against
2670
+ // whatever DOM state its own non-linear seek order produced. Geometry measured
2671
+ // inside a callback is therefore seek-order-dependent, and anything measured before
2672
+ // the callback ran (e.g. a build-time getTotalLength() on a path whose `d` the
2673
+ // callback assigns) is stale or zero. Warning, not error: gsap.getProperty-style
2674
+ // derived-output callbacks were excluded, but the remaining reads can still be
2675
+ // legitimate when the measured layout is static.
2676
+ ({ scripts }) => {
2677
+ const findings = [];
2678
+ for (const script of scripts) {
2679
+ const source = stripJsComments(script.content);
2680
+ if (!/gsap\.timeline/.test(source)) continue;
2681
+ const bodies = collectNamedFunctionBodies(source);
2682
+ const measuring = collectMeasuringFunctionNames(bodies);
2683
+ const callbackExpressionHazard = (expression) => {
2684
+ const trimmed = expression.trim();
2685
+ const inline = parseFunctionValueSource(trimmed);
2686
+ if (inline) return expressionReachesMeasurement(inline.body, measuring);
2687
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return measuring.has(trimmed);
2688
+ return false;
2689
+ };
2690
+ const report = (site, snippet) => {
2691
+ findings.push({
2692
+ code: "gsap_callback_dom_measurement",
2693
+ severity: "warning",
2694
+ message: "Timeline callback reaches DOM measurement (getBoundingClientRect/getTotalLength/getComputedStyle/...). The renderer seeks with suppressEvents=false, so callbacks re-fire on every seek \u2014 and a cold render worker runs them against whatever DOM state its own non-linear seek order produced. Measured geometry is seek-order-dependent, and values measured at build time (before the callback ran) are stale or zero.",
2695
+ selector: truncateSnippet(site, 120),
2696
+ fixHint: "Do all measurement and DOM setup synchronously at build time, before registering the timeline \u2014 or derive geometry from fixed composition coordinates instead of measuring.",
2697
+ snippet: truncateSnippet(snippet)
2698
+ });
2699
+ };
2700
+ const timelineVars = collectTimelineVarNames(source);
2701
+ for (const timelineVar of timelineVars) {
2702
+ const callPattern = new RegExp(
2703
+ `\\b${escapeRegExp3(timelineVar)}\\.(?:add|call)\\s*\\(`,
2704
+ "g"
2705
+ );
2706
+ let match2;
2707
+ while ((match2 = callPattern.exec(source)) !== null) {
2708
+ const parenIndex = match2.index + match2[0].length - 1;
2709
+ const argsWithParens = matchBalanced(source, parenIndex, "(", ")");
2710
+ if (!argsWithParens) continue;
2711
+ const firstArg = sliceExpression(argsWithParens.slice(1, -1), 0);
2712
+ const site = match2[0] + firstArg + ", ...)";
2713
+ if (callbackExpressionHazard(firstArg)) report(site, site);
2714
+ }
2715
+ const eventCallbackPattern = new RegExp(
2716
+ `\\b${escapeRegExp3(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
2717
+ "g"
2718
+ );
2719
+ while ((match2 = eventCallbackPattern.exec(source)) !== null) {
2720
+ const expression = sliceExpression(source, eventCallbackPattern.lastIndex);
2721
+ const site = match2[0] + expression + ")";
2722
+ if (callbackExpressionHazard(expression)) report(site, site);
2723
+ }
2724
+ }
2725
+ const varsCallbackPattern = /\bon(?:Start|Update|Complete|Repeat|ReverseComplete|Interrupt|Overwrite)\s*:\s*/g;
2726
+ let match;
2727
+ while ((match = varsCallbackPattern.exec(source)) !== null) {
2728
+ if (!isInsideGsapTweenVars(source, match.index, timelineVars)) continue;
2729
+ const expression = sliceExpression(source, varsCallbackPattern.lastIndex);
2730
+ const site = match[0] + expression;
2731
+ if (callbackExpressionHazard(expression)) report(site, site);
2732
+ }
2733
+ }
2734
+ return findings;
2735
+ },
2185
2736
  // gsap_group_selector_keyframes
2186
2737
  ({ scripts }) => {
2187
2738
  const findings = [];
@@ -2202,6 +2753,204 @@ ${right.raw}`)
2202
2753
  });
2203
2754
  }
2204
2755
  return findings;
2756
+ },
2757
+ // svg_drawon_css_dasharray_conflict — GSAP sets/tweens strokeDasharray on an element
2758
+ // whose CSS declares a MULTI-component stroke-dasharray (e.g. `10 10`). GSAP merges
2759
+ // dash lists per component, so `strokeDasharray: 641.4` over CSS `10 10` computes to
2760
+ // "641.4px, 10px" — the gap stays 10px and the hide-then-draw-on trick silently
2761
+ // fails: the line is visible the whole scene. A static two-component GSAP value is
2762
+ // the explicit fix form and is not flagged.
2763
+ // fallow-ignore-next-line complexity
2764
+ ({ scripts, styles, tags }) => {
2765
+ const findings = [];
2766
+ const tagsByToken = indexTagsByToken(tags);
2767
+ const multiDashTokens = /* @__PURE__ */ new Set();
2768
+ for (const style of styles) {
2769
+ for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
2770
+ if (!selectorList || !body) continue;
2771
+ const value = readStyleProperty(body, "stroke-dasharray");
2772
+ if (!value || !isMultiComponentDasharray(value)) continue;
2773
+ for (const group of selectorList.split(",")) {
2774
+ const trimmed = group.trim();
2775
+ if (!trimmed || /[\s>+~]/.test(trimmed)) continue;
2776
+ for (const token of targetedSelectorTokens(trimmed)) multiDashTokens.add(token);
2777
+ }
2778
+ }
2779
+ }
2780
+ for (const tag of tags) {
2781
+ const inlineValue = readStyleProperty(readAttr(tag.raw, "style") ?? "", "stroke-dasharray");
2782
+ if (!inlineValue || !isMultiComponentDasharray(inlineValue)) continue;
2783
+ for (const token of tagSimpleSelectors(tag)) multiDashTokens.add(token);
2784
+ }
2785
+ if (multiDashTokens.size === 0) return findings;
2786
+ for (const script of scripts) {
2787
+ const source = stripJsComments(script.content);
2788
+ const varTokens = resolveScriptElementTokens(source, tags);
2789
+ const reported = /* @__PURE__ */ new Set();
2790
+ const writerPattern = /\b[\w$]+\.(set|to|fromTo)\s*\(\s*(?:(["'])([^"'`]+)\2|([A-Za-z_$][\w$]*))\s*,\s*\{/g;
2791
+ let match;
2792
+ while ((match = writerPattern.exec(source)) !== null) {
2793
+ const method = match[1] ?? "";
2794
+ const braceIndex = match.index + match[0].length - 1;
2795
+ const firstVars = matchBalanced(source, braceIndex, "{", "}");
2796
+ if (!firstVars) continue;
2797
+ const varsObjects = [firstVars];
2798
+ if (method === "fromTo") {
2799
+ const afterFirst = source.slice(braceIndex + firstVars.length);
2800
+ const secondOpen = /^\s*,\s*\{/.exec(afterFirst);
2801
+ if (secondOpen) {
2802
+ const secondBrace = braceIndex + firstVars.length + secondOpen[0].length - 1;
2803
+ const secondVars = matchBalanced(source, secondBrace, "{", "}");
2804
+ if (secondVars) varsObjects.push(secondVars);
2805
+ }
2806
+ }
2807
+ const quotedSelector = match[3];
2808
+ const targetTokens = quotedSelector ? targetedSelectorTokens(quotedSelector) : varTokens.get(match[4] ?? "") ?? /* @__PURE__ */ new Set();
2809
+ if (targetTokens.size === 0) continue;
2810
+ const expanded = elementLevelTokens(targetTokens, tagsByToken);
2811
+ for (const varsObject of varsObjects) {
2812
+ const propMatch = varsObject.match(/\bstrokeDasharray\s*:\s*/) ?? varsObject.match(/["']stroke-dasharray["']\s*:\s*/);
2813
+ if (!propMatch || propMatch.index === void 0) continue;
2814
+ const valueSource = sliceExpression(varsObject, propMatch.index + propMatch[0].length);
2815
+ if (gsapDasharrayValueLooksMultiComponent(valueSource)) continue;
2816
+ const conflictToken = [...expanded].find((token) => multiDashTokens.has(token));
2817
+ if (!conflictToken) continue;
2818
+ const targetLabel = quotedSelector ?? match[4] ?? "";
2819
+ if (reported.has(targetLabel + conflictToken)) continue;
2820
+ reported.add(targetLabel + conflictToken);
2821
+ findings.push({
2822
+ code: "svg_drawon_css_dasharray_conflict",
2823
+ severity: "error",
2824
+ message: `GSAP writes strokeDasharray on "${targetLabel}", but its CSS ("${conflictToken}") declares a multi-component stroke-dasharray. GSAP merges dash lists per component, so the CSS gap survives (e.g. "641.4px, 10px") \u2014 the draw-on hide only hides one gap's worth and the line stays visible the whole scene.`,
2825
+ selector: quotedSelector ?? void 0,
2826
+ fixHint: `Remove the CSS stroke-dasharray from "${conflictToken}" (decorative dashes belong on a separate element), or set the full two-component value in GSAP: strokeDasharray: "\${len} \${len}".`,
2827
+ snippet: truncateSnippet(match[0] + firstVars.slice(1))
2828
+ });
2829
+ }
2830
+ }
2831
+ }
2832
+ return findings;
2833
+ },
2834
+ // gsap_timeline_set_initial_hide — a zero-duration tl.set(...) at position 0 inside
2835
+ // the paused timeline does NOT render while the playhead sits exactly at 0 (verified
2836
+ // against this repo's GSAP: tl.time(0) leaves the target untouched; only a seek past
2837
+ // 0 applies it). Frame 0 therefore shows the UN-hidden state, then the element pops
2838
+ // hidden on frame 1 — and only for the worker that renders frame 0. Targets already
2839
+ // hidden by authored CSS/inline styles or by a standalone gsap.set are exempt: the
2840
+ // tl.set is then a defensive re-assertion and frame 0 is hidden anyway.
2841
+ //
2842
+ // Only sets that precede every tween in source order qualify: the parser resolves a
2843
+ // mutated position variable (`var t = 0; ...; tl.set(sel, vars, t)`) to its INITIAL
2844
+ // binding, so late hard-kills can masquerade as position-0 sets. Genuine
2845
+ // initial-state hides are authored before the timeline's tweens.
2846
+ async ({ scripts, styles, tags }) => {
2847
+ const findings = [];
2848
+ const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
2849
+ const tagsByToken = indexTagsByToken(tags);
2850
+ for (const script of scripts) {
2851
+ if (!/gsap\.timeline/.test(script.content)) continue;
2852
+ const windows = await cachedExtractGsapWindows(script.content);
2853
+ const alreadyHidden = /* @__PURE__ */ new Set([
2854
+ ...cssHiddenSelectors,
2855
+ ...extractStandaloneHiddenSelectors(script.content)
2856
+ ]);
2857
+ const isInstantHold = (win) => win.method === "set" || (win.method === "to" || win.method === "fromTo") && win.end === win.position;
2858
+ const firstTweenIndex = windows.findIndex((win) => !isInstantHold(win));
2859
+ const initialHolds = firstTweenIndex < 0 ? windows : windows.slice(0, firstTweenIndex);
2860
+ for (const win of initialHolds) {
2861
+ if (!isInstantHold(win) || win.position !== 0) continue;
2862
+ if (win.global || win.immediateRender) continue;
2863
+ if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
2864
+ const targetTokens = [...targetedSelectorTokens(win.targetSelector)];
2865
+ const hiddenByToken = targetTokens.length > 0 && targetTokens.every((token) => alreadyHidden.has(token));
2866
+ const resolvedTags = targetTokens.flatMap((token) => tagsByToken.get(token) ?? []);
2867
+ const hiddenByElement = resolvedTags.length > 0 && resolvedTags.every(
2868
+ (tag) => tagSimpleSelectors(tag).some((token) => alreadyHidden.has(token))
2869
+ );
2870
+ if (hiddenByToken || hiddenByElement) continue;
2871
+ const offset = win.propertyValues["strokeDashoffset"];
2872
+ const hidesByOffset = numberValue(offset) !== null && !zeroValue(offset);
2873
+ const hides = isHiddenGsapState(win.propertyValues) || zeroValue(win.propertyValues["scale"]) || hidesByOffset;
2874
+ if (!hides) continue;
2875
+ findings.push({
2876
+ code: "gsap_timeline_set_initial_hide",
2877
+ severity: "warning",
2878
+ message: `Initial hidden state for "${win.targetSelector}" is set via tl.set(...) at position 0 inside the paused timeline. A zero-duration set at 0 does not render while the playhead sits exactly at 0, so frame 0 shows the un-hidden state.`,
2879
+ selector: win.targetSelector,
2880
+ fixHint: "Use gsap.set(...) (immediate, outside the timeline) for initial states, or author the hidden state directly in CSS/attributes.",
2881
+ snippet: truncateSnippet(win.raw)
2882
+ });
2883
+ }
2884
+ }
2885
+ return findings;
2886
+ },
2887
+ // svg_measure_before_path_d — getTotalLength() on a <path> that has no static `d`
2888
+ // attribute in the HTML. In Chrome getTotalLength() on a d-less path returns 0,
2889
+ // silently killing dash animations (offset 0 == length 0 == nothing to draw). If a
2890
+ // d assignment exists but only inside a function body, execution order is statically
2891
+ // undecidable — WARNING; if NO d assignment exists anywhere — ERROR. Element
2892
+ // identity is resolved conservatively (literal / template getElementById,
2893
+ // querySelector); createElementNS-built paths and unresolved variables are skipped.
2894
+ // fallow-ignore-next-line complexity
2895
+ ({ scripts, styles, tags }) => {
2896
+ const findings = [];
2897
+ const tagsByToken = indexTagsByToken(tags);
2898
+ const cssProvidesD = styles.some((style) => /\bd\s*:\s*path\(/.test(style.content));
2899
+ for (const script of scripts) {
2900
+ const source = stripJsComments(script.content);
2901
+ const varTokens = resolveScriptElementTokens(source, tags);
2902
+ const functionRanges = collectFunctionBodyRanges(source);
2903
+ const createdVars = new Set(
2904
+ [...source.matchAll(/([A-Za-z_$][\w$]*)\s*=\s*document\.createElementNS\(/g)].map(
2905
+ (m) => m[1] ?? ""
2906
+ )
2907
+ );
2908
+ const dAssignments = [
2909
+ ...[...source.matchAll(/\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*["']d["']\s*,/g)].map(
2910
+ (m) => ({ varName: m[1] ?? "", index: m.index ?? 0 })
2911
+ ),
2912
+ ...[
2913
+ ...source.matchAll(
2914
+ /\.(?:set|to|fromTo)\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*\{[^{}]*\battr\s*:\s*\{[^{}]*\bd\s*:/g
2915
+ )
2916
+ ].map((m) => ({ varName: m[1] ?? "", index: m.index ?? 0 }))
2917
+ ];
2918
+ const reported = /* @__PURE__ */ new Set();
2919
+ const measurePattern = /\b([A-Za-z_$][\w$]*)\.getTotalLength\s*\(/g;
2920
+ let match;
2921
+ while ((match = measurePattern.exec(source)) !== null) {
2922
+ const varName = match[1] ?? "";
2923
+ if (createdVars.has(varName)) continue;
2924
+ const tokens = varTokens.get(varName);
2925
+ if (!tokens || tokens.size === 0) continue;
2926
+ const resolvedTags = [...tokens].flatMap((token) => tagsByToken.get(token) ?? []);
2927
+ const dLessPaths = resolvedTags.filter(
2928
+ (tag) => tag.name.toLowerCase() === "path" && readAttr(tag.raw, "d") === null
2929
+ );
2930
+ if (dLessPaths.length === 0 || dLessPaths.length !== resolvedTags.length) continue;
2931
+ if (cssProvidesD) continue;
2932
+ const measureIndex = match.index;
2933
+ const assignedBeforeInScope = dAssignments.some(
2934
+ (assign) => assign.varName === varName && assign.index < measureIndex && (!indexInsideAnyRange(assign.index, functionRanges) || functionRanges.some(
2935
+ (range) => assign.index > range.start && assign.index < range.end && measureIndex > range.start && measureIndex < range.end
2936
+ ))
2937
+ );
2938
+ if (assignedBeforeInScope) continue;
2939
+ const sameVarAssignmentExists = dAssignments.some((a) => a.varName === varName);
2940
+ const tokenLabel = [...tokens].join(", ");
2941
+ if (reported.has(tokenLabel)) continue;
2942
+ reported.add(tokenLabel);
2943
+ findings.push({
2944
+ code: "svg_measure_before_path_d",
2945
+ severity: sameVarAssignmentExists ? "warning" : "error",
2946
+ message: sameVarAssignmentExists ? `getTotalLength() is called on "${tokenLabel}", whose \`d\` is only assigned inside a function body \u2014 if the measure runs before that function (e.g. the function is a timeline callback), the length is 0 and the dash animation is dead.` : `getTotalLength() is called on "${tokenLabel}", but the path has no static \`d\` attribute and no d assignment exists anywhere \u2014 getTotalLength() returns 0 in Chrome, silently killing dash animations.`,
2947
+ selector: tokenLabel,
2948
+ fixHint: "Assign the path's `d` synchronously at build time (top level, before measuring), or author a static d attribute in the HTML.",
2949
+ snippet: truncateSnippet(match[0] + ")")
2950
+ });
2951
+ }
2952
+ }
2953
+ return findings;
2205
2954
  }
2206
2955
  ];
2207
2956
 
@@ -2417,6 +3166,7 @@ var captionRules = [
2417
3166
 
2418
3167
  // src/rules/composition.ts
2419
3168
  import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
3169
+ import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
2420
3170
  var MAX_COMPOSITION_LINES = 300;
2421
3171
  var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
2422
3172
  var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
@@ -2443,6 +3193,9 @@ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
2443
3193
  var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
2444
3194
  var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
2445
3195
  var OVERLAP_EPSILON_SECONDS = 1e-6;
3196
+ function readTagTiming(rawTag) {
3197
+ return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
3198
+ }
2446
3199
  function countPhysicalLines(source) {
2447
3200
  if (source.length === 0) return 0;
2448
3201
  const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -2677,7 +3430,7 @@ var compositionRules = [
2677
3430
  if (isCaptionCue(tag)) continue;
2678
3431
  if (isCompositionRootOrMount(tag.raw)) continue;
2679
3432
  if (!readAttr(tag.raw, "data-start")) continue;
2680
- const track = readAttr(tag.raw, "data-track-index");
3433
+ const track = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
2681
3434
  if (!track) continue;
2682
3435
  trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
2683
3436
  }
@@ -2726,7 +3479,8 @@ var compositionRules = [
2726
3479
  ({ tags }) => {
2727
3480
  const findings = [];
2728
3481
  for (const tag of tags) {
2729
- if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
3482
+ const timing = readTagTiming(tag.raw);
3483
+ if (timing.diagnostics.some(({ code }) => code === "deprecated-layer")) {
2730
3484
  const elementId = readAttr(tag.raw, "id") || void 0;
2731
3485
  findings.push({
2732
3486
  code: "deprecated_data_layer",
@@ -2737,7 +3491,7 @@ var compositionRules = [
2737
3491
  snippet: truncateSnippet(tag.raw)
2738
3492
  });
2739
3493
  }
2740
- if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
3494
+ if (timing.diagnostics.some(({ code }) => code === "deprecated-end")) {
2741
3495
  const elementId = readAttr(tag.raw, "id") || void 0;
2742
3496
  findings.push({
2743
3497
  code: "deprecated_data_end",
@@ -2827,14 +3581,12 @@ var compositionRules = [
2827
3581
  const findings = [];
2828
3582
  const trackMap = /* @__PURE__ */ new Map();
2829
3583
  for (const tag of tags) {
2830
- const startStr = readAttr(tag.raw, "data-start");
2831
- const durationStr = readAttr(tag.raw, "data-duration");
2832
- const trackStr = readAttr(tag.raw, "data-track-index");
2833
- if (!startStr || !durationStr || !trackStr) continue;
2834
- const start = Number(startStr);
2835
- const duration = Number(durationStr);
3584
+ const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
3585
+ if (!trackStr) continue;
3586
+ const timing = readTagTiming(tag.raw);
3587
+ const { start, duration } = timing;
2836
3588
  const track = trackStr;
2837
- if (Number.isNaN(start) || Number.isNaN(duration)) continue;
3589
+ if (start == null || duration == null) continue;
2838
3590
  const clips = trackMap.get(track) || [];
2839
3591
  clips.push({
2840
3592
  start,
@@ -3596,7 +4348,7 @@ function extractFontFaceFamilies(styles) {
3596
4348
  return families;
3597
4349
  }
3598
4350
  function normalizeUsedFontName(part) {
3599
- const name = part.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase();
4351
+ const name = part.trim().replace(/\s*!important\s*$/i, "").replace(/^['"]|['"]$/g, "").trim().toLowerCase();
3600
4352
  if (!name || name.includes("(") || name.includes(")")) return null;
3601
4353
  return name;
3602
4354
  }
@@ -3912,11 +4664,118 @@ function shouldBlockRender(strictErrors, strictAll, totalErrors, totalWarnings)
3912
4664
 
3913
4665
  // src/project.ts
3914
4666
  import { existsSync, readFileSync, readdirSync } from "fs";
3915
- import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "path";
3916
- import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
3917
- import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
4667
+ import { dirname, extname, join, relative, resolve } from "path";
4668
+ import { rewriteAssetPath as rewriteAssetPath2 } from "@hyperframes/parsers/asset-paths";
3918
4669
  import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
3919
4670
  import { parseHTML } from "linkedom";
4671
+ import {
4672
+ cleanAssetUrl as cleanAssetUrl2,
4673
+ isRemoteOrInlineUrl as isRemoteOrInlineUrl2,
4674
+ isWithinProjectRoot,
4675
+ maskNonScannableRanges as maskNonScannableRanges2,
4676
+ resolveExistingLocalAsset as resolveExistingLocalAsset2,
4677
+ resolveLocalAssetCandidates
4678
+ } from "@hyperframes/parsers/asset-resolution";
4679
+
4680
+ // src/hevcPreviewLint.ts
4681
+ import { execFile } from "child_process";
4682
+ import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
4683
+ import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
4684
+ import {
4685
+ cleanAssetUrl,
4686
+ isRemoteOrInlineUrl,
4687
+ maskNonScannableRanges,
4688
+ resolveExistingLocalAsset
4689
+ } from "@hyperframes/parsers/asset-resolution";
4690
+ var PROBE_TIMEOUT_MS = 4e3;
4691
+ var PROBE_CONCURRENCY = 8;
4692
+ function execFileAsync(file, args) {
4693
+ return new Promise((resolvePromise, reject) => {
4694
+ execFile(file, args, { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
4695
+ if (error) reject(error);
4696
+ else resolvePromise(stdout.toString());
4697
+ });
4698
+ });
4699
+ }
4700
+ function hasHevcStream(json) {
4701
+ if (typeof json !== "object" || json === null) return false;
4702
+ const streams = Reflect.get(json, "streams");
4703
+ if (!Array.isArray(streams)) return false;
4704
+ return streams.some((stream) => {
4705
+ if (typeof stream !== "object" || stream === null) return false;
4706
+ return Reflect.get(stream, "codec_name") === "hevc";
4707
+ });
4708
+ }
4709
+ async function probeIsHevc(ffprobePath, filePath) {
4710
+ try {
4711
+ const stdout = await execFileAsync(ffprobePath, [
4712
+ "-v",
4713
+ "error",
4714
+ "-select_streams",
4715
+ "v:0",
4716
+ "-show_entries",
4717
+ "stream=codec_name",
4718
+ "-of",
4719
+ "json",
4720
+ filePath
4721
+ ]);
4722
+ return hasHevcStream(JSON.parse(stdout));
4723
+ } catch {
4724
+ return false;
4725
+ }
4726
+ }
4727
+ function collectLocalVideoCandidates(projectDir, htmlSources) {
4728
+ const candidates = /* @__PURE__ */ new Map();
4729
+ const videoSrcRe = /<video\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
4730
+ for (const { html, compSrcPath } of htmlSources) {
4731
+ const scannable = maskNonScannableRanges(html);
4732
+ const re = new RegExp(videoSrcRe.source, videoSrcRe.flags);
4733
+ let match;
4734
+ while ((match = re.exec(scannable)) !== null) {
4735
+ const src = cleanAssetUrl(match[1] ?? "");
4736
+ if (!src) continue;
4737
+ if (isRemoteOrInlineUrl(src)) continue;
4738
+ if (/^__[A-Z_]+__$/.test(src)) continue;
4739
+ const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
4740
+ const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
4741
+ if (!resolvedAsset) continue;
4742
+ if (!candidates.has(resolvedAsset.resolved)) candidates.set(resolvedAsset.resolved, src);
4743
+ }
4744
+ }
4745
+ return candidates;
4746
+ }
4747
+ async function lintHevcPreviewCodec(candidates) {
4748
+ if (candidates.size === 0) return [];
4749
+ const ffprobePath = findFfBinary("ffprobe", { configuredMustExist: true });
4750
+ if (!ffprobePath) return [];
4751
+ const entries = [...candidates.entries()];
4752
+ const isHevc = new Array(entries.length).fill(false);
4753
+ let nextIndex = 0;
4754
+ const workerCount = Math.min(PROBE_CONCURRENCY, entries.length);
4755
+ await Promise.all(
4756
+ Array.from({ length: workerCount }, async () => {
4757
+ while (nextIndex < entries.length) {
4758
+ const index = nextIndex++;
4759
+ const entry = entries[index];
4760
+ if (!entry) break;
4761
+ isHevc[index] = await probeIsHevc(ffprobePath, entry[0]);
4762
+ }
4763
+ })
4764
+ );
4765
+ const hevcSrcs = entries.filter((_, i) => isHevc[i]).map(([, src]) => src);
4766
+ if (hevcSrcs.length === 0) return [];
4767
+ const unique = [...new Set(hevcSrcs)];
4768
+ return [
4769
+ {
4770
+ code: "hevc_preview_codec",
4771
+ severity: "info",
4772
+ message: `Video file(s) use the HEVC/H.265 codec: ${unique.join(", ")}. The render pipeline pre-decodes video with FFmpeg and never uses the browser's video decoder, so these render correctly. Live preview/player playback automatically uses a cached H.264 proxy when the browser cannot decode HEVC. If playback still fails, verify ffmpeg/ffprobe are installed and auto-proxying is enabled.`,
4773
+ fixHint: unique.length === 1 ? `If "${unique[0]}" fails to play in preview, run hyperframes doctor and confirm media.autoProxy is not false.` : "If these files fail to play in preview, run hyperframes doctor and confirm media.autoProxy is not false."
4774
+ }
4775
+ ];
4776
+ }
4777
+
4778
+ // src/project.ts
3920
4779
  function parseSubCompHtml(html) {
3921
4780
  return parseHTML(html).document;
3922
4781
  }
@@ -3941,7 +4800,7 @@ function collectLocalStylesheets(projectDir, document, compSrcPath) {
3941
4800
  const href = link.getAttribute("href") ?? "";
3942
4801
  if (!isLocalStylesheetHref(href)) continue;
3943
4802
  const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
3944
- const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
4803
+ const stylesheet = resolveExistingLocalAsset2(projectDir, rootRelative);
3945
4804
  if (!stylesheet) continue;
3946
4805
  styles.push({
3947
4806
  href,
@@ -3979,52 +4838,13 @@ function collectCssSources(projectDir, html, compSrcPath) {
3979
4838
  }
3980
4839
  return sources;
3981
4840
  }
3982
- function isRemoteOrInlineUrl(url) {
3983
- return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
3984
- }
3985
- function cleanAssetUrl(url) {
3986
- return url.trim().split(/[?#]/, 1)[0] ?? "";
3987
- }
3988
- function isWithinProjectRoot(projectDir, candidate) {
3989
- const projectRoot = resolve(projectDir);
3990
- const relativePath = relative(projectRoot, candidate);
3991
- return relativePath === "" || !relativePath.startsWith("..") && !isAbsolute(relativePath);
3992
- }
3993
- function addCandidate(candidates, candidate) {
3994
- if (!candidates.includes(candidate)) candidates.push(candidate);
3995
- }
3996
- function resolveLocalAssetCandidates(projectDir, url) {
3997
- const cleanUrl = cleanAssetUrl(url);
3998
- const projectRoot = resolve(projectDir);
3999
- const candidates = [];
4000
- for (const variant of decodeUrlPathVariants(cleanUrl)) {
4001
- const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
4002
- const resolved = resolve(projectRoot, projectRelative);
4003
- if (isWithinProjectRoot(projectRoot, resolved)) {
4004
- addCandidate(candidates, resolved);
4005
- continue;
4006
- }
4007
- const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
4008
- const clamped = normalized.replace(/^(\.\.\/)+/, "");
4009
- if (clamped && !clamped.startsWith("..")) {
4010
- addCandidate(candidates, resolve(projectRoot, clamped));
4011
- }
4012
- }
4013
- return candidates;
4014
- }
4015
- function resolveExistingLocalAsset(projectDir, url) {
4016
- const projectRoot = resolve(projectDir);
4017
- const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
4018
- if (!resolved) return null;
4019
- return { resolved, rootRelativePath: relative(projectRoot, resolved) };
4020
- }
4021
4841
  function resolveCssAssetCandidates(projectDir, url, htmlCompSrcPath, cssRootRelativePath) {
4022
4842
  if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url);
4023
4843
  if (cssRootRelativePath) {
4024
4844
  return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
4025
4845
  }
4026
4846
  if (htmlCompSrcPath) {
4027
- return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
4847
+ return resolveLocalAssetCandidates(projectDir, rewriteAssetPath2(htmlCompSrcPath, url));
4028
4848
  }
4029
4849
  return resolveLocalAssetCandidates(projectDir, url);
4030
4850
  }
@@ -4089,7 +4909,8 @@ async function lintProject(projectDir, entryFile) {
4089
4909
  ...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
4090
4910
  ...!entryFile ? lintMultipleRootCompositions(projectDir) : [],
4091
4911
  ...lintDuplicateAudioTracks(allHtmlSources),
4092
- ...lintMissingOrEmptySubComposition(projectDir, rootHtml)
4912
+ ...lintMissingOrEmptySubComposition(projectDir, rootHtml),
4913
+ ...await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))
4093
4914
  ];
4094
4915
  if (projectFindings.length > 0) {
4095
4916
  for (const finding of projectFindings) {
@@ -4141,7 +4962,7 @@ function lintAudioSrcNotFound(projectDir, htmlSources) {
4141
4962
  const src = match[1];
4142
4963
  if (/^(https?:|data:|blob:)/i.test(src)) continue;
4143
4964
  if (/^__[A-Z_]+__$/.test(src)) continue;
4144
- const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
4965
+ const rootRelative = compSrcPath ? rewriteAssetPath2(compSrcPath, src) : src;
4145
4966
  if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
4146
4967
  missingSrcs.push(src);
4147
4968
  }
@@ -4158,32 +4979,23 @@ function lintAudioSrcNotFound(projectDir, htmlSources) {
4158
4979
  }
4159
4980
  return findings;
4160
4981
  }
4161
- function maskRange(src, pattern) {
4162
- return src.replace(pattern, (m) => " ".repeat(m.length));
4163
- }
4164
- function maskNonScannableRanges(html) {
4165
- let out = maskRange(html, /<!--[\s\S]*?-->/g);
4166
- out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
4167
- out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
4168
- return out;
4169
- }
4170
4982
  function lintMissingLocalAsset(projectDir, htmlSources) {
4171
4983
  const findings = [];
4172
4984
  const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
4173
4985
  const missingByTag = /* @__PURE__ */ new Map();
4174
4986
  for (const { html, compSrcPath } of htmlSources) {
4175
- const scannable = maskNonScannableRanges(html);
4987
+ const scannable = maskNonScannableRanges2(html);
4176
4988
  const re = new RegExp(localAssetSrcRe.source, localAssetSrcRe.flags);
4177
4989
  let match;
4178
4990
  while ((match = re.exec(scannable)) !== null) {
4179
4991
  const tagName = (match[1] ?? "").toLowerCase();
4180
4992
  const rawSrc = match[2] ?? "";
4181
- const src = cleanAssetUrl(rawSrc);
4993
+ const src = cleanAssetUrl2(rawSrc);
4182
4994
  if (!src) continue;
4183
- if (isRemoteOrInlineUrl(src)) continue;
4995
+ if (isRemoteOrInlineUrl2(src)) continue;
4184
4996
  if (/^__[A-Z_]+__$/.test(src)) continue;
4185
- const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
4186
- const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
4997
+ const rootRelative = compSrcPath ? rewriteAssetPath2(compSrcPath, src) : src;
4998
+ const resolvedAsset = resolveExistingLocalAsset2(projectDir, rootRelative);
4187
4999
  if (resolvedAsset) continue;
4188
5000
  const resolvedKey = resolve(projectDir, rootRelative);
4189
5001
  let bucket = missingByTag.get(tagName);
@@ -4213,8 +5025,8 @@ function lintTextureMaskAssetNotFound(projectDir, htmlSources) {
4213
5025
  const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
4214
5026
  while ((match = pattern.exec(cssSource.content)) !== null) {
4215
5027
  const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
4216
- const url = cleanAssetUrl(rawUrl);
4217
- if (!url || isRemoteOrInlineUrl(url)) continue;
5028
+ const url = cleanAssetUrl2(rawUrl);
5029
+ if (!url || isRemoteOrInlineUrl2(url)) continue;
4218
5030
  if (/^__[A-Z_]+__$/.test(url)) continue;
4219
5031
  const candidates = resolveCssAssetCandidates(
4220
5032
  projectDir,
@@ -4314,7 +5126,7 @@ function lintMissingOrEmptySubComposition(projectDir, rootHtml) {
4314
5126
  const visited = /* @__PURE__ */ new Set();
4315
5127
  const walk = (html) => {
4316
5128
  const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
4317
- const scannable = maskNonScannableRanges(html);
5129
+ const scannable = maskNonScannableRanges2(html);
4318
5130
  let match;
4319
5131
  while ((match = compositionSrcRe.exec(scannable)) !== null) {
4320
5132
  const srcPath = (match[1] ?? "").trim();