@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/browser.js +804 -52
- package/dist/browser.js.map +1 -1
- package/dist/index.js +927 -115
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/browser.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
|
-
|
|
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
|
|
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:
|
|
1322
|
-
end: animation.
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
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
|
|
2831
|
-
|
|
2832
|
-
const
|
|
2833
|
-
|
|
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 (
|
|
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
|
}
|